Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

@@ -0,0 +1,145 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Xunit;
namespace StellaOps.Scanner.Advisory.Tests;
public sealed class AdvisoryClientTests
{
[Fact(DisplayName = "GetCveSymbolsAsync uses Concelier response and caches results")]
public async Task GetCveSymbolsAsync_UsesConcelierAndCaches()
{
var handler = new StubHandler(request =>
{
Assert.Equal("/v1/lnm/linksets/CVE-2024-1234", request.RequestUri!.AbsolutePath);
var response = new
{
advisoryId = "CVE-2024-1234",
source = "nvd",
purl = Array.Empty<string>(),
normalized = new { purl = new[] { "pkg:npm/lodash@4.17.21" } }
};
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(response)
};
});
var httpClient = new HttpClient(handler);
var cache = new MemoryCache(new MemoryCacheOptions());
var options = Options.Create(new AdvisoryClientOptions
{
Enabled = true,
BaseUrl = "https://concelier.test",
CacheTtlMinutes = 60
});
var client = new AdvisoryClient(
httpClient,
cache,
options,
new NullAdvisoryBundleStore(),
NullLogger<AdvisoryClient>.Instance);
var mapping1 = await client.GetCveSymbolsAsync("CVE-2024-1234");
var mapping2 = await client.GetCveSymbolsAsync("CVE-2024-1234");
Assert.NotNull(mapping1);
Assert.Equal("CVE-2024-1234", mapping1!.CveId);
Assert.Single(mapping1.Packages);
Assert.Equal("pkg:npm/lodash@4.17.21", mapping1.Packages[0].Purl);
Assert.Equal("concelier", mapping1.Source);
Assert.Equal(1, handler.CallCount);
Assert.NotNull(mapping2);
}
[Fact(DisplayName = "GetCveSymbolsAsync falls back to bundle store on HTTP failure")]
public async Task GetCveSymbolsAsync_FallsBackToBundle()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError));
var httpClient = new HttpClient(handler);
var cache = new MemoryCache(new MemoryCacheOptions());
var options = Options.Create(new AdvisoryClientOptions
{
Enabled = true,
BaseUrl = "https://concelier.test",
CacheTtlMinutes = 5
});
using var temp = new TempFile();
var bundle = new
{
items = new[]
{
new
{
cveId = "CVE-2024-9999",
source = "bundle",
packages = new[]
{
new { purl = "pkg:npm/test@1.0.0", symbols = new[] { "test.func" } }
}
}
}
};
await File.WriteAllTextAsync(temp.Path, JsonSerializer.Serialize(bundle, new JsonSerializerOptions(JsonSerializerDefaults.Web)));
var client = new AdvisoryClient(
httpClient,
cache,
options,
new FileAdvisoryBundleStore(temp.Path),
NullLogger<AdvisoryClient>.Instance);
var mapping = await client.GetCveSymbolsAsync("CVE-2024-9999");
Assert.NotNull(mapping);
Assert.Equal("bundle", mapping!.Source);
Assert.Single(mapping.Packages);
Assert.Single(mapping.Packages[0].Symbols);
}
private sealed class StubHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
{
_handler = handler;
}
public int CallCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
CallCount++;
return Task.FromResult(_handler(request));
}
}
private sealed class TempFile : IDisposable
{
public TempFile()
{
Path = System.IO.Path.GetTempFileName();
}
public string Path { get; }
public void Dispose()
{
try
{
File.Delete(Path);
}
catch
{
// best-effort cleanup only
}
}
}
}

View File

@@ -0,0 +1,55 @@
using System.Text.Json;
using Xunit;
namespace StellaOps.Scanner.Advisory.Tests;
public sealed class FileAdvisoryBundleStoreTests
{
[Fact(DisplayName = "FileAdvisoryBundleStore resolves CVE IDs case-insensitively")]
public async Task TryGetAsync_ResolvesCaseInsensitive()
{
using var temp = new TempFile();
var bundle = new
{
items = new[]
{
new
{
cveId = "cve-2024-0001",
source = "bundle",
packages = Array.Empty<object>()
}
}
};
await File.WriteAllTextAsync(temp.Path, JsonSerializer.Serialize(bundle, new JsonSerializerOptions(JsonSerializerDefaults.Web)));
var store = new FileAdvisoryBundleStore(temp.Path);
var mapping = await store.TryGetAsync("CVE-2024-0001");
Assert.NotNull(mapping);
Assert.Equal("bundle", mapping!.Source);
}
private sealed class TempFile : IDisposable
{
public TempFile()
{
Path = System.IO.Path.GetTempFileName();
}
public string Path { get; }
public void Dispose()
{
try
{
File.Delete(Path);
}
catch
{
// best-effort cleanup only
}
}
}
}

View File

@@ -0,0 +1,20 @@
<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.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Advisory\StellaOps.Scanner.Advisory.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,265 @@
using FluentAssertions;
using StellaOps.Scanner.Analyzers.Native.RuntimeCapture.Timeline;
using Xunit;
namespace StellaOps.Scanner.Analyzers.Native.Tests.RuntimeCapture.Timeline;
public class TimelineBuilderTests
{
private readonly TimelineBuilder _builder = new();
[Fact]
public void Build_WithNoObservations_ReturnsUnknownPosture()
{
var evidence = CreateEmptyEvidence();
var result = _builder.Build(evidence, "pkg:npm/test@1.0.0", new TimelineOptions());
result.Posture.Should().Be(RuntimePosture.Unknown);
result.PostureExplanation.Should().Contain("No runtime observations");
}
[Fact]
public void Build_ComponentNotLoaded_ReturnsSupportsPosture()
{
var evidence = CreateEvidenceWithoutComponent();
var result = _builder.Build(evidence, "pkg:npm/vulnerable@1.0.0", new TimelineOptions());
result.Posture.Should().Be(RuntimePosture.Supports);
result.PostureExplanation.Should().Contain("not loaded");
}
[Fact]
public void Build_WithNetworkExposure_ReturnsContradictsPosture()
{
var evidence = CreateEvidenceWithNetworkExposure();
var result = _builder.Build(evidence, "pkg:npm/vulnerable@1.0.0", new TimelineOptions());
result.Posture.Should().Be(RuntimePosture.Contradicts);
result.PostureExplanation.Should().Contain("actively used");
}
[Fact]
public void Build_CreatesCorrectBuckets()
{
var evidence = CreateEvidenceOver24Hours();
var options = new TimelineOptions { BucketSize = TimeSpan.FromHours(6) };
var result = _builder.Build(evidence, "pkg:npm/test@1.0.0", options);
result.Buckets.Should().HaveCount(4);
result.Buckets.All(b => b.End - b.Start == TimeSpan.FromHours(6)).Should().BeTrue();
}
[Fact]
public void Build_ExtractsSignificantEvents()
{
var evidence = CreateEvidenceWithComponentLoad();
var result = _builder.Build(evidence, "pkg:npm/test@1.0.0", new TimelineOptions());
result.Events.Should().Contain(e => e.Type == TimelineEventType.ComponentLoaded);
result.Events.Should().Contain(e => e.Type == TimelineEventType.CaptureStarted);
}
[Fact]
public void Build_CountsTotalObservationsCorrectly()
{
var evidence = CreateEvidenceWith10Observations();
var result = _builder.Build(evidence, "pkg:npm/test@1.0.0", new TimelineOptions());
result.TotalObservations.Should().Be(10);
}
private static RuntimeEvidence CreateEmptyEvidence()
{
return new RuntimeEvidence
{
FirstObservation = DateTimeOffset.UtcNow.AddHours(-1),
LastObservation = DateTimeOffset.UtcNow,
Observations = Array.Empty<RuntimeObservation>(),
Sessions = new[]
{
new RuntimeSession
{
StartTime = DateTimeOffset.UtcNow.AddHours(-1),
EndTime = DateTimeOffset.UtcNow,
Platform = "linux-x64"
}
},
SessionDigests = new[] { "sha256:abc123" }
};
}
private static RuntimeEvidence CreateEvidenceWithoutComponent()
{
var now = DateTimeOffset.UtcNow;
return new RuntimeEvidence
{
FirstObservation = now.AddHours(-1),
LastObservation = now,
Observations = new[]
{
new RuntimeObservation
{
Timestamp = now.AddMinutes(-30),
Type = "library_load",
Path = "/usr/lib/libc.so.6",
ProcessId = 1234,
Digest = "sha256:def456"
}
},
Sessions = new[]
{
new RuntimeSession
{
StartTime = now.AddHours(-1),
EndTime = now,
Platform = "linux-x64"
}
},
SessionDigests = new[] { "sha256:abc123" }
};
}
private static RuntimeEvidence CreateEvidenceWithNetworkExposure()
{
var now = DateTimeOffset.UtcNow;
return new RuntimeEvidence
{
FirstObservation = now.AddHours(-1),
LastObservation = now,
Observations = new[]
{
new RuntimeObservation
{
Timestamp = now.AddMinutes(-45),
Type = "library_load",
Path = "/app/node_modules/vulnerable/index.js",
ProcessId = 1234,
Digest = "sha256:aaa111"
},
new RuntimeObservation
{
Timestamp = now.AddMinutes(-30),
Type = "network",
Port = 80,
ProcessId = 1234,
Digest = "sha256:bbb222"
}
},
Sessions = new[]
{
new RuntimeSession
{
StartTime = now.AddHours(-1),
EndTime = now,
Platform = "linux-x64"
}
},
SessionDigests = new[] { "sha256:abc123" }
};
}
private static RuntimeEvidence CreateEvidenceOver24Hours()
{
var start = DateTimeOffset.UtcNow.AddHours(-24);
var observations = new List<RuntimeObservation>();
for (int i = 0; i < 24; i++)
{
observations.Add(new RuntimeObservation
{
Timestamp = start.AddHours(i),
Type = "syscall",
ProcessId = 1234,
Digest = $"sha256:obs{i}"
});
}
return new RuntimeEvidence
{
FirstObservation = start,
LastObservation = start.AddHours(24),
Observations = observations,
Sessions = new[]
{
new RuntimeSession
{
StartTime = start,
EndTime = start.AddHours(24),
Platform = "linux-x64"
}
},
SessionDigests = new[] { "sha256:abc123" }
};
}
private static RuntimeEvidence CreateEvidenceWithComponentLoad()
{
var now = DateTimeOffset.UtcNow;
return new RuntimeEvidence
{
FirstObservation = now.AddHours(-1),
LastObservation = now,
Observations = new[]
{
new RuntimeObservation
{
Timestamp = now.AddMinutes(-30),
Type = "library_load",
Path = "/app/node_modules/test/index.js",
ProcessId = 1234,
Digest = "sha256:load123"
}
},
Sessions = new[]
{
new RuntimeSession
{
StartTime = now.AddHours(-1),
EndTime = now,
Platform = "linux-x64"
}
},
SessionDigests = new[] { "sha256:abc123" }
};
}
private static RuntimeEvidence CreateEvidenceWith10Observations()
{
var now = DateTimeOffset.UtcNow;
var observations = new List<RuntimeObservation>();
for (int i = 0; i < 10; i++)
{
observations.Add(new RuntimeObservation
{
Timestamp = now.AddMinutes(-60 + i * 6),
Type = "syscall",
ProcessId = 1234,
Digest = $"sha256:obs{i}"
});
}
return new RuntimeEvidence
{
FirstObservation = now.AddHours(-1),
LastObservation = now,
Observations = observations,
Sessions = new[]
{
new RuntimeSession
{
StartTime = now.AddHours(-1),
EndTime = now,
Platform = "linux-x64"
}
},
SessionDigests = new[] { "sha256:abc123" }
};
}
}

View File

@@ -0,0 +1,53 @@
using StellaOps.Scanner.CallGraph.Binary;
using StellaOps.Scanner.CallGraph.Binary.Disassembly;
using Xunit;
namespace StellaOps.Scanner.CallGraph.Tests;
public class BinaryDisassemblyTests
{
[Fact]
public void X86Disassembler_Extracts_Call_And_Jmp()
{
var disassembler = new X86Disassembler();
var code = new byte[]
{
0xE8, 0x05, 0x00, 0x00, 0x00, // call +5
0xE9, 0x02, 0x00, 0x00, 0x00, // jmp +2
0x90, 0x90
};
var calls = disassembler.ExtractDirectCalls(code, 0x1000, 64);
Assert.Equal(2, calls.Length);
Assert.Equal(0x1000UL, calls[0].InstructionAddress);
Assert.Equal(0x100AUL, calls[0].TargetAddress);
Assert.Equal(0x1005UL, calls[1].InstructionAddress);
Assert.Equal(0x100CUL, calls[1].TargetAddress);
}
[Fact]
public void DirectCallExtractor_Maps_Targets_To_Symbols()
{
var extractor = new DirectCallExtractor();
var textSection = new BinaryTextSection(
Bytes: new byte[] { 0xE8, 0x00, 0x00, 0x00, 0x00 },
VirtualAddress: 0x1000,
Bitness: 64,
Architecture: BinaryArchitecture.X64,
SectionName: ".text");
var symbols = new List<BinarySymbol>
{
new() { Name = "main", Address = 0x1000, Size = 10, IsGlobal = true, IsExported = true },
new() { Name = "helper", Address = 0x1005, Size = 10, IsGlobal = true, IsExported = true }
};
var edges = extractor.Extract(textSection, symbols, "app.bin");
Assert.Single(edges);
Assert.Equal("native:app.bin/main", edges[0].SourceId);
Assert.Equal("native:app.bin/helper", edges[0].TargetId);
Assert.Equal("0x1000", edges[0].CallSite);
}
}

View File

@@ -0,0 +1,267 @@
using System.Buffers.Binary;
using System.Text;
using StellaOps.Scanner.CallGraph.Binary;
using StellaOps.Scanner.CallGraph.Binary.Disassembly;
using StellaOps.Scanner.CallGraph.Binary.Analysis;
using Xunit;
namespace StellaOps.Scanner.CallGraph.Tests;
public class BinaryTextSectionReaderTests
{
[Fact]
public async Task ReadsElfTextSection()
{
var textBytes = new byte[] { 0x90, 0x90, 0xC3, 0x90 };
var rodataBytes = Encoding.ASCII.GetBytes("libfoo.so\0");
var data = BuildElf64Fixture(textBytes, rodataBytes);
var path = WriteTempFile(data);
try
{
var section = await BinaryTextSectionReader.TryReadAsync(path, BinaryFormat.Elf, CancellationToken.None);
Assert.NotNull(section);
Assert.Equal(".text", section!.SectionName);
Assert.Equal(BinaryArchitecture.X64, section.Architecture);
Assert.Equal(0x1000UL, section.VirtualAddress);
Assert.Equal(textBytes, section.Bytes);
}
finally
{
File.Delete(path);
}
}
[Fact]
public async Task ReadsPeTextSection()
{
var textBytes = new byte[] { 0x90, 0x90, 0xC3, 0x90 };
var data = BuildPe64Fixture(textBytes);
var path = WriteTempFile(data);
try
{
var section = await BinaryTextSectionReader.TryReadAsync(path, BinaryFormat.Pe, CancellationToken.None);
Assert.NotNull(section);
Assert.Equal(".text", section!.SectionName);
Assert.Equal(BinaryArchitecture.X64, section.Architecture);
Assert.Equal(0x1000UL, section.VirtualAddress);
Assert.Equal(textBytes, section.Bytes);
}
finally
{
File.Delete(path);
}
}
[Fact]
public async Task ReadsMachOTextSection()
{
var textBytes = new byte[] { 0x1F, 0x20, 0x03, 0xD5 };
var data = BuildMachO64Fixture(textBytes);
var path = WriteTempFile(data);
try
{
var section = await BinaryTextSectionReader.TryReadAsync(path, BinaryFormat.MachO, CancellationToken.None);
Assert.NotNull(section);
Assert.Equal("__text", section!.SectionName);
Assert.Equal(BinaryArchitecture.Arm64, section.Architecture);
Assert.Equal(0x1000UL, section.VirtualAddress);
Assert.Equal(textBytes, section.Bytes);
}
finally
{
File.Delete(path);
}
}
[Fact]
public async Task StringScannerExtractsLibraryCandidates()
{
var textBytes = new byte[] { 0x90, 0x90, 0xC3, 0x90 };
var rodataBytes = Encoding.ASCII.GetBytes("libfoo.so\0libbar.so.1\0");
var data = BuildElf64Fixture(textBytes, rodataBytes);
var path = WriteTempFile(data);
try
{
var scanner = new BinaryStringLiteralScanner();
var candidates = await scanner.ExtractLibraryCandidatesAsync(path, BinaryFormat.Elf, CancellationToken.None);
Assert.Contains("libfoo.so", candidates);
Assert.Contains("libbar.so.1", candidates);
}
finally
{
File.Delete(path);
}
}
private static string WriteTempFile(byte[] data)
{
var path = Path.GetTempFileName();
File.WriteAllBytes(path, data);
return path;
}
private static byte[] BuildElf64Fixture(byte[] textBytes, byte[] rodataBytes)
{
const int textOffset = 0x100;
var rodataOffset = textOffset + textBytes.Length;
var shstrOffset = rodataOffset + rodataBytes.Length;
const int sectionHeaderOffset = 0x200;
const ushort sectionHeaderSize = 64;
const ushort sectionCount = 4;
var shstrtab = Encoding.ASCII.GetBytes("\0.text\0.rodata\0.shstrtab\0");
var fileSize = sectionHeaderOffset + sectionHeaderSize * sectionCount;
if (fileSize < shstrOffset + shstrtab.Length)
{
fileSize = shstrOffset + shstrtab.Length;
}
var data = new byte[fileSize];
data[0] = 0x7F;
data[1] = (byte)'E';
data[2] = (byte)'L';
data[3] = (byte)'F';
data[4] = 2; // 64-bit
data[5] = 1; // little endian
WriteUInt16(data, 16, 2); // e_type
WriteUInt16(data, 18, 62); // e_machine x86_64
WriteInt64(data, 40, sectionHeaderOffset); // e_shoff
WriteUInt16(data, 58, sectionHeaderSize); // e_shentsize
WriteUInt16(data, 60, sectionCount); // e_shnum
WriteUInt16(data, 62, 3); // e_shstrndx
Array.Copy(textBytes, 0, data, textOffset, textBytes.Length);
Array.Copy(rodataBytes, 0, data, rodataOffset, rodataBytes.Length);
Array.Copy(shstrtab, 0, data, shstrOffset, shstrtab.Length);
WriteElfSectionHeader(data, sectionHeaderOffset + sectionHeaderSize * 1, 1, textOffset, textBytes.Length, 0x1000);
WriteElfSectionHeader(data, sectionHeaderOffset + sectionHeaderSize * 2, 7, rodataOffset, rodataBytes.Length, 0x2000);
WriteElfSectionHeader(data, sectionHeaderOffset + sectionHeaderSize * 3, 15, shstrOffset, shstrtab.Length, 0);
return data;
}
private static void WriteElfSectionHeader(
byte[] data,
int offset,
uint nameOffset,
int sectionOffset,
int sectionSize,
ulong address)
{
WriteUInt32(data, offset + 0, nameOffset);
WriteUInt32(data, offset + 4, 1); // SHT_PROGBITS
WriteUInt64(data, offset + 8, 0); // flags
WriteUInt64(data, offset + 16, address);
WriteUInt64(data, offset + 24, (ulong)sectionOffset);
WriteUInt64(data, offset + 32, (ulong)sectionSize);
}
private static byte[] BuildPe64Fixture(byte[] textBytes)
{
const int peOffset = 0x80;
const int optionalHeaderSize = 0xF0;
const int sectionHeaderStart = peOffset + 4 + 20 + optionalHeaderSize;
const int textOffset = 0x200;
var fileSize = textOffset + textBytes.Length;
if (fileSize < sectionHeaderStart + 40)
{
fileSize = sectionHeaderStart + 40;
}
var data = new byte[fileSize];
WriteInt32(data, 0x3C, peOffset);
WriteUInt32(data, peOffset, 0x00004550); // PE\0\0
WriteUInt16(data, peOffset + 4, 0x8664); // machine
WriteUInt16(data, peOffset + 6, 1); // sections
WriteUInt16(data, peOffset + 20, optionalHeaderSize);
WriteUInt16(data, peOffset + 24, 0x20b); // optional header magic
WriteAscii(data, sectionHeaderStart + 0, ".text", 8);
WriteUInt32(data, sectionHeaderStart + 8, (uint)textBytes.Length);
WriteUInt32(data, sectionHeaderStart + 12, 0x1000);
WriteUInt32(data, sectionHeaderStart + 16, (uint)textBytes.Length);
WriteUInt32(data, sectionHeaderStart + 20, textOffset);
Array.Copy(textBytes, 0, data, textOffset, textBytes.Length);
return data;
}
private static byte[] BuildMachO64Fixture(byte[] textBytes)
{
const uint magic = 0xFEEDFACF;
const int headerSize = 32;
const int cmdSize = 152;
const int commandStart = headerSize;
const int textOffset = 0x200;
var fileSize = textOffset + textBytes.Length;
if (fileSize < commandStart + cmdSize)
{
fileSize = commandStart + cmdSize;
}
var data = new byte[fileSize];
WriteUInt32(data, 0, magic);
WriteInt32(data, 4, unchecked((int)0x0100000C)); // CPU_TYPE_ARM64
WriteInt32(data, 8, 0);
WriteUInt32(data, 12, 2); // filetype
WriteUInt32(data, 16, 1); // ncmds
WriteUInt32(data, 20, cmdSize);
WriteUInt32(data, 24, 0); // flags
WriteUInt32(data, 28, 0); // reserved
WriteUInt32(data, commandStart + 0, 0x19); // LC_SEGMENT_64
WriteUInt32(data, commandStart + 4, cmdSize);
WriteAscii(data, commandStart + 8, "__TEXT", 16);
WriteUInt64(data, commandStart + 24, 0x1000);
WriteUInt64(data, commandStart + 32, 0x1000);
WriteUInt64(data, commandStart + 40, textOffset);
WriteUInt64(data, commandStart + 48, textBytes.Length);
WriteInt32(data, commandStart + 56, 7);
WriteInt32(data, commandStart + 60, 5);
WriteUInt32(data, commandStart + 64, 1); // nsects
WriteUInt32(data, commandStart + 68, 0); // flags
var sectionStart = commandStart + 72;
WriteAscii(data, sectionStart + 0, "__text", 16);
WriteAscii(data, sectionStart + 16, "__TEXT", 16);
WriteUInt64(data, sectionStart + 32, 0x1000);
WriteUInt64(data, sectionStart + 40, textBytes.Length);
WriteUInt32(data, sectionStart + 48, textOffset);
Array.Copy(textBytes, 0, data, textOffset, textBytes.Length);
return data;
}
private static void WriteAscii(byte[] buffer, int offset, string value, int length)
{
var bytes = Encoding.ASCII.GetBytes(value);
Array.Copy(bytes, 0, buffer, offset, Math.Min(length, bytes.Length));
}
private static void WriteUInt16(byte[] buffer, int offset, ushort value)
=> BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(offset, 2), value);
private static void WriteUInt32(byte[] buffer, int offset, uint value)
=> BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset, 4), value);
private static void WriteInt32(byte[] buffer, int offset, int value)
=> BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(offset, 4), value);
private static void WriteUInt64(byte[] buffer, int offset, ulong value)
=> BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(offset, 8), value);
private static void WriteInt64(byte[] buffer, int offset, long value)
=> BinaryPrimitives.WriteInt64LittleEndian(buffer.AsSpan(offset, 8), value);
}

View File

@@ -0,0 +1,286 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Scanner.Orchestration.Fidelity;
using Xunit;
namespace StellaOps.Scanner.Core.Tests.Fidelity;
public class FidelityAwareAnalyzerTests
{
private readonly Mock<ICallGraphExtractor> _callGraphExtractor = new();
private readonly Mock<IRuntimeCorrelator> _runtimeCorrelator = new();
private readonly Mock<IBinaryMapper> _binaryMapper = new();
private readonly Mock<IPackageMatcher> _packageMatcher = new();
private readonly Mock<IAnalysisRepository> _repository = new();
private readonly FidelityAwareAnalyzer _analyzer;
public FidelityAwareAnalyzerTests()
{
_analyzer = new FidelityAwareAnalyzer(
_callGraphExtractor.Object,
_runtimeCorrelator.Object,
_binaryMapper.Object,
_packageMatcher.Object,
_repository.Object,
NullLogger<FidelityAwareAnalyzer>.Instance);
// Default setup for package matcher
_packageMatcher.Setup(m => m.MatchAsync(It.IsAny<AnalysisRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new PackageMatchResult
{
HasExactMatch = true,
Matches = new[]
{
new PackageMatch { PackageName = "test-package", Version = "1.0.0" }
}
});
}
[Fact]
public async Task AnalyzeAsync_QuickLevel_SkipsCallGraph()
{
var request = CreateAnalysisRequest();
var result = await _analyzer.AnalyzeAsync(request, FidelityLevel.Quick, CancellationToken.None);
result.FidelityLevel.Should().Be(FidelityLevel.Quick);
result.CallGraph.Should().BeNull();
result.Confidence.Should().BeLessThan(0.7m);
result.CanUpgrade.Should().BeTrue();
result.UpgradeRecommendation.Should().Contain("Standard");
// Verify call graph was not invoked
_callGraphExtractor.Verify(
e => e.ExtractAsync(It.IsAny<AnalysisRequest>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task AnalyzeAsync_StandardLevel_IncludesCallGraph()
{
var request = CreateAnalysisRequest();
_callGraphExtractor.Setup(e => e.ExtractAsync(
It.IsAny<AnalysisRequest>(),
It.IsAny<IReadOnlyList<string>>(),
It.IsAny<int>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new CallGraphResult
{
IsComplete = true,
HasPathToVulnerable = true
});
var result = await _analyzer.AnalyzeAsync(request, FidelityLevel.Standard, CancellationToken.None);
result.FidelityLevel.Should().Be(FidelityLevel.Standard);
result.CallGraph.Should().NotBeNull();
result.CallGraph!.IsComplete.Should().BeTrue();
result.IsReachable.Should().BeTrue();
result.CanUpgrade.Should().BeTrue();
// Verify call graph was invoked
_callGraphExtractor.Verify(
e => e.ExtractAsync(request, It.IsAny<IReadOnlyList<string>>(), 10, It.IsAny<CancellationToken>()),
Times.Once);
// Verify runtime was not invoked
_runtimeCorrelator.Verify(
r => r.CorrelateAsync(It.IsAny<AnalysisRequest>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task AnalyzeAsync_DeepLevel_IncludesRuntime()
{
var request = CreateAnalysisRequest();
_callGraphExtractor.Setup(e => e.ExtractAsync(
It.IsAny<AnalysisRequest>(),
It.IsAny<IReadOnlyList<string>>(),
It.IsAny<int>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new CallGraphResult { IsComplete = true });
_runtimeCorrelator.Setup(r => r.CorrelateAsync(It.IsAny<AnalysisRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new RuntimeCorrelationResult
{
WasExecuted = true,
ObservationCount = 150,
HasCorroboration = true
});
_binaryMapper.Setup(b => b.MapAsync(It.IsAny<AnalysisRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new BinaryMappingResult { HasMapping = true });
var result = await _analyzer.AnalyzeAsync(request, FidelityLevel.Deep, CancellationToken.None);
result.FidelityLevel.Should().Be(FidelityLevel.Deep);
result.RuntimeCorrelation.Should().NotBeNull();
result.BinaryMapping.Should().NotBeNull();
result.Confidence.Should().BeGreaterThanOrEqualTo(0.9m);
result.CanUpgrade.Should().BeFalse();
result.UpgradeRecommendation.Should().BeNull();
// Verify all components were invoked
_callGraphExtractor.Verify(
e => e.ExtractAsync(It.IsAny<AnalysisRequest>(), It.IsAny<IReadOnlyList<string>>(), 50, It.IsAny<CancellationToken>()),
Times.Once);
_runtimeCorrelator.Verify(
r => r.CorrelateAsync(request, It.IsAny<CancellationToken>()),
Times.Once);
_binaryMapper.Verify(
b => b.MapAsync(request, It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task AnalyzeAsync_QuickLevel_SetsBaseConfidence()
{
var request = CreateAnalysisRequest();
var result = await _analyzer.AnalyzeAsync(request, FidelityLevel.Quick, CancellationToken.None);
// Quick base confidence is 0.5, plus 0.1 for exact match
result.Confidence.Should().Be(0.6m);
}
[Fact]
public async Task AnalyzeAsync_StandardLevel_AdjustsConfidenceBasedOnCallGraph()
{
var request = CreateAnalysisRequest();
_callGraphExtractor.Setup(e => e.ExtractAsync(
It.IsAny<AnalysisRequest>(),
It.IsAny<IReadOnlyList<string>>(),
It.IsAny<int>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new CallGraphResult
{
IsComplete = true,
HasPathToVulnerable = false
});
var result = await _analyzer.AnalyzeAsync(request, FidelityLevel.Standard, CancellationToken.None);
// Standard base confidence is 0.75, plus 0.15 for complete call graph
result.Confidence.Should().Be(0.9m);
result.IsReachable.Should().BeFalse();
}
[Fact]
public async Task UpgradeFidelityAsync_FromQuickToStandard_ImprovesConfidence()
{
var findingId = Guid.NewGuid();
var existingResult = new FidelityAnalysisResult
{
FidelityLevel = FidelityLevel.Quick,
Confidence = 0.6m,
IsReachable = null,
PackageMatches = Array.Empty<PackageMatch>(),
AnalysisTime = TimeSpan.FromSeconds(5),
TimedOut = false,
CanUpgrade = true
};
_repository.Setup(r => r.GetAnalysisAsync(findingId, It.IsAny<CancellationToken>()))
.ReturnsAsync(existingResult);
_callGraphExtractor.Setup(e => e.ExtractAsync(
It.IsAny<AnalysisRequest>(),
It.IsAny<IReadOnlyList<string>>(),
It.IsAny<int>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new CallGraphResult
{
IsComplete = true,
HasPathToVulnerable = true
});
var result = await _analyzer.UpgradeFidelityAsync(findingId, FidelityLevel.Standard, CancellationToken.None);
result.Success.Should().BeTrue();
result.PreviousLevel.Should().Be(FidelityLevel.Quick);
result.NewLevel.Should().Be(FidelityLevel.Standard);
result.ConfidenceImprovement.Should().BePositive();
result.NewResult!.Confidence.Should().BeGreaterThan(existingResult.Confidence);
// Verify result was saved
_repository.Verify(
r => r.SaveAnalysisAsync(It.IsAny<FidelityAnalysisResult>(), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task UpgradeFidelityAsync_FindingNotFound_ReturnsNotFound()
{
var findingId = Guid.NewGuid();
_repository.Setup(r => r.GetAnalysisAsync(findingId, It.IsAny<CancellationToken>()))
.ReturnsAsync((FidelityAnalysisResult?)null);
var result = await _analyzer.UpgradeFidelityAsync(findingId, FidelityLevel.Standard, CancellationToken.None);
result.Success.Should().BeFalse();
result.Error.Should().Be("Finding not found");
}
[Fact]
public async Task UpgradeFidelityAsync_AlreadyAtLevel_ReturnsExisting()
{
var findingId = Guid.NewGuid();
var existingResult = new FidelityAnalysisResult
{
FidelityLevel = FidelityLevel.Standard,
Confidence = 0.85m,
IsReachable = true,
PackageMatches = Array.Empty<PackageMatch>(),
AnalysisTime = TimeSpan.FromMinutes(2),
TimedOut = false,
CanUpgrade = true
};
_repository.Setup(r => r.GetAnalysisAsync(findingId, It.IsAny<CancellationToken>()))
.ReturnsAsync(existingResult);
var result = await _analyzer.UpgradeFidelityAsync(findingId, FidelityLevel.Standard, CancellationToken.None);
result.Success.Should().BeTrue();
result.PreviousLevel.Should().Be(FidelityLevel.Standard);
result.NewLevel.Should().Be(FidelityLevel.Standard);
result.ConfidenceImprovement.Should().Be(0);
// Verify no analysis was performed
_callGraphExtractor.Verify(
e => e.ExtractAsync(It.IsAny<AnalysisRequest>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task AnalyzeAsync_RuntimeCorroborationTrue_SetsHighConfidence()
{
var request = CreateAnalysisRequest();
_callGraphExtractor.Setup(e => e.ExtractAsync(
It.IsAny<AnalysisRequest>(),
It.IsAny<IReadOnlyList<string>>(),
It.IsAny<int>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new CallGraphResult { IsComplete = true });
_runtimeCorrelator.Setup(r => r.CorrelateAsync(It.IsAny<AnalysisRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new RuntimeCorrelationResult
{
WasExecuted = true,
ObservationCount = 200,
HasCorroboration = true
});
_binaryMapper.Setup(b => b.MapAsync(It.IsAny<AnalysisRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new BinaryMappingResult { HasMapping = false });
var result = await _analyzer.AnalyzeAsync(request, FidelityLevel.Deep, CancellationToken.None);
result.Confidence.Should().Be(0.95m);
result.IsReachable.Should().BeTrue();
}
private static AnalysisRequest CreateAnalysisRequest()
{
return new AnalysisRequest
{
DetectedLanguages = new[] { "java", "python" }
};
}
}

View File

@@ -21,12 +21,12 @@ public sealed class CycloneDxComposerTests
Assert.NotNull(result.Inventory);
Assert.StartsWith("urn:uuid:", result.Inventory.SerialNumber, StringComparison.Ordinal);
Assert.Equal("application/vnd.cyclonedx+json; version=1.6", result.Inventory.JsonMediaType);
Assert.Equal("application/vnd.cyclonedx+protobuf; version=1.6", result.Inventory.ProtobufMediaType);
Assert.Equal("application/vnd.cyclonedx+json; version=1.7", result.Inventory.JsonMediaType);
Assert.Equal("application/vnd.cyclonedx+protobuf; version=1.7", result.Inventory.ProtobufMediaType);
Assert.Equal(2, result.Inventory.Components.Length);
Assert.NotNull(result.Usage);
Assert.Equal("application/vnd.cyclonedx+json; version=1.6; view=usage", result.Usage!.JsonMediaType);
Assert.Equal("application/vnd.cyclonedx+json; version=1.7; view=usage", result.Usage!.JsonMediaType);
Assert.Single(result.Usage.Components);
Assert.Equal("pkg:npm/a", result.Usage.Components[0].Identity.Key);
@@ -213,6 +213,7 @@ public sealed class CycloneDxComposerTests
using var document = JsonDocument.Parse(data);
var root = document.RootElement;
Assert.Equal("1.7", root.GetProperty("specVersion").GetString());
Assert.True(root.TryGetProperty("metadata", out var metadata), "metadata property missing");
var properties = metadata.GetProperty("properties");
var viewProperty = properties.EnumerateArray()

View File

@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Text.Json;
using FluentAssertions;
using Json.Schema;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Composition;
using Xunit;
namespace StellaOps.Scanner.Emit.Tests.Composition;
public sealed class CycloneDxSchemaValidationTests
{
[Fact]
public void Compose_InventoryPassesCycloneDx17Schema()
{
var request = BuildRequest();
var composer = new CycloneDxComposer();
var result = composer.Compose(request);
using var document = JsonDocument.Parse(result.Inventory.JsonBytes);
var schema = LoadSchema();
var validation = schema.Evaluate(document.RootElement);
validation.IsValid.Should().BeTrue(validation.ToString());
}
private static JsonSchema LoadSchema()
{
var schemaPath = Path.Combine(
AppContext.BaseDirectory,
"Fixtures",
"schemas",
"cyclonedx-bom-1.7.schema.json");
var schemaJson = File.ReadAllText(schemaPath);
return JsonSchema.FromText(schemaJson);
}
private static SbomCompositionRequest BuildRequest()
{
var fragments = new[]
{
LayerComponentFragment.Create("sha256:layer1", new[]
{
new ComponentRecord
{
Identity = ComponentIdentity.Create(
"pkg:npm/demo",
"demo",
"1.0.0",
"pkg:npm/demo@1.0.0",
"library"),
LayerDigest = "sha256:layer1",
Evidence = ImmutableArray.Create(ComponentEvidence.FromPath("/app/node_modules/demo/package.json")),
Usage = ComponentUsage.Create(false),
Metadata = new ComponentMetadata
{
Properties = new Dictionary<string, string>
{
["stellaops:source"] = "package-lock.json",
},
},
}
})
};
var image = new ImageArtifactDescriptor
{
ImageDigest = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
ImageReference = "registry.example.com/demo/app:1.0.0",
Repository = "registry.example.com/demo/app",
Tag = "1.0.0",
Architecture = "amd64",
};
return SbomCompositionRequest.Create(
image,
fragments,
new DateTimeOffset(2025, 10, 20, 0, 0, 0, TimeSpan.Zero),
generatorName: "StellaOps.Scanner",
generatorVersion: "0.10.0");
}
}

View File

@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Composition;
using Xunit;
namespace StellaOps.Scanner.Emit.Tests.Composition;
public sealed class SpdxComposerTests
{
[Fact]
public void Compose_ProducesJsonLdArtifact()
{
var request = BuildRequest();
var composer = new SpdxComposer();
var result = composer.Compose(request, new SpdxCompositionOptions());
Assert.Equal("application/spdx+json; version=3.0.1", result.JsonMediaType);
Assert.Equal(result.JsonSha256, result.ContentHash);
Assert.Equal(64, result.JsonSha256.Length);
Assert.Null(result.TagValueBytes);
using var document = JsonDocument.Parse(result.JsonBytes);
var root = document.RootElement;
Assert.Equal("https://spdx.org/rdf/3.0.1/spdx-context.jsonld", root.GetProperty("@context").GetString());
var graph = root.GetProperty("@graph").EnumerateArray().ToArray();
Assert.NotEmpty(graph);
var docNode = graph.Single(node => node.GetProperty("type").GetString() == "SpdxDocument");
var rootElement = docNode.GetProperty("rootElement").EnumerateArray().Select(element => element.GetString()).ToArray();
Assert.Single(rootElement);
var packages = graph
.Where(node => node.GetProperty("type").GetString() == "software_Package")
.ToArray();
Assert.Equal(3, packages.Length);
var lodash = packages.Single(node => node.GetProperty("name").GetString() == "component-b");
Assert.Equal("pkg:npm/b@2.0.0", lodash.GetProperty("software_packageUrl").GetString());
}
[Fact]
public void Compose_WithTagValue_IncludesLegacyOutput()
{
var request = BuildRequest();
var composer = new SpdxComposer();
var result = composer.Compose(request, new SpdxCompositionOptions { IncludeTagValue = true });
Assert.NotNull(result.TagValueBytes);
var tagValue = System.Text.Encoding.UTF8.GetString(result.TagValueBytes!);
Assert.Contains("SPDXVersion: SPDX-2.3", tagValue, StringComparison.Ordinal);
Assert.Contains("DocumentNamespace:", tagValue, StringComparison.Ordinal);
}
[Fact]
public void Compose_IsDeterministic()
{
var request = BuildRequest();
var composer = new SpdxComposer();
var first = composer.Compose(request, new SpdxCompositionOptions());
var second = composer.Compose(request, new SpdxCompositionOptions());
Assert.Equal(first.JsonSha256, second.JsonSha256);
}
private static SbomCompositionRequest BuildRequest()
{
var fragments = new[]
{
LayerComponentFragment.Create("sha256:layer1", new[]
{
new ComponentRecord
{
Identity = ComponentIdentity.Create("pkg:npm/a", "component-a", "1.0.0", "pkg:npm/a@1.0.0", "library"),
LayerDigest = "sha256:layer1",
Evidence = ImmutableArray.Create(ComponentEvidence.FromPath("/app/node_modules/a/package.json")),
Dependencies = ImmutableArray.Create("pkg:npm/b"),
Usage = ComponentUsage.Create(true, new[] { "/app/start.sh" }),
Metadata = new ComponentMetadata
{
Scope = "runtime",
Licenses = new[] { "MIT" },
Properties = new Dictionary<string, string>
{
["stellaops:source"] = "package-lock.json",
},
BuildId = "ABCDEF1234567890ABCDEF1234567890ABCDEF12",
},
}
}),
LayerComponentFragment.Create("sha256:layer2", new[]
{
new ComponentRecord
{
Identity = ComponentIdentity.Create("pkg:npm/b", "component-b", "2.0.0", "pkg:npm/b@2.0.0", "library"),
LayerDigest = "sha256:layer2",
Evidence = ImmutableArray.Create(ComponentEvidence.FromPath("/app/node_modules/b/package.json")),
Usage = ComponentUsage.Create(false),
Metadata = new ComponentMetadata
{
Scope = "development",
Licenses = new[] { "Apache-2.0" },
Properties = new Dictionary<string, string>
{
["stellaops.os.analyzer"] = "language-node",
},
},
}
})
};
var image = new ImageArtifactDescriptor
{
ImageDigest = "sha256:1234567890abcdef",
ImageReference = "registry.example.com/app/service:1.2.3",
Repository = "registry.example.com/app/service",
Tag = "1.2.3",
Architecture = "amd64",
};
return SbomCompositionRequest.Create(
image,
fragments,
new DateTimeOffset(2025, 10, 19, 12, 0, 0, TimeSpan.Zero),
generatorName: "StellaOps.Scanner",
generatorVersion: "0.10.0",
properties: new Dictionary<string, string>
{
["stellaops:scanId"] = "scan-1234",
});
}
}

View File

@@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CycloneDX.Models;
using StellaOps.Scanner.Emit.Spdx.Conversion;
using Xunit;
namespace StellaOps.Scanner.Emit.Tests.Composition;
public sealed class SpdxCycloneDxConversionTests
{
[Fact]
public void Converts_CycloneDx_To_Spdx()
{
var bom = BuildBom();
var spdx = SpdxCycloneDxConverter.FromCycloneDx(bom);
var packages = spdx.Elements.OfType<StellaOps.Scanner.Emit.Spdx.Models.SpdxPackage>().ToArray();
Assert.Equal(2, packages.Length);
Assert.Contains(packages, pkg => pkg.Name == "demo-app");
Assert.Contains(spdx.Relationships, rel => rel.Type == StellaOps.Scanner.Emit.Spdx.Models.SpdxRelationshipType.DependsOn);
}
[Fact]
public void Converts_Spdx_To_CycloneDx()
{
var bom = BuildBom();
var spdx = SpdxCycloneDxConverter.FromCycloneDx(bom);
var converted = SpdxCycloneDxConverter.ToCycloneDx(spdx);
Assert.NotNull(converted.Metadata);
Assert.NotNull(converted.Components);
Assert.Contains(converted.Components!, component => component.Name == "dependency");
}
private static Bom BuildBom()
{
var root = new Component
{
BomRef = "root",
Name = "demo-app",
Version = "1.0.0",
Type = Component.Classification.Application
};
var dependency = new Component
{
BomRef = "dep",
Name = "dependency",
Version = "2.0.0",
Type = Component.Classification.Library
};
return new Bom
{
SpecVersion = SpecificationVersion.v1_7,
Version = 1,
Metadata = new Metadata
{
Timestamp = new DateTime(2025, 10, 20, 0, 0, 0, DateTimeKind.Utc),
Component = root
},
Components = new List<Component> { dependency },
Dependencies = new List<Dependency>
{
new()
{
Ref = root.BomRef,
Dependencies = new List<Dependency> { new() { Ref = dependency.BomRef } }
}
}
};
}
}

View File

@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Text.Json;
using FluentAssertions;
using Json.Schema;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Composition;
using Xunit;
namespace StellaOps.Scanner.Emit.Tests.Composition;
public sealed class SpdxJsonLdSchemaValidationTests
{
[Fact]
public void Compose_InventoryPassesSpdxJsonLdSchema()
{
var request = BuildRequest();
var composer = new SpdxComposer();
var result = composer.Compose(request, new SpdxCompositionOptions());
using var document = JsonDocument.Parse(result.JsonBytes);
var schema = LoadSchema();
var validation = schema.Evaluate(document.RootElement);
validation.IsValid.Should().BeTrue(validation.ToString());
}
private static JsonSchema LoadSchema()
{
var schemaPath = Path.Combine(
AppContext.BaseDirectory,
"Fixtures",
"schemas",
"spdx-jsonld-3.0.1.schema.json");
var schemaJson = File.ReadAllText(schemaPath);
return JsonSchema.FromText(schemaJson);
}
private static SbomCompositionRequest BuildRequest()
{
var fragments = new[]
{
LayerComponentFragment.Create("sha256:layer1", new[]
{
new ComponentRecord
{
Identity = ComponentIdentity.Create(
"pkg:npm/demo",
"demo",
"1.0.0",
"pkg:npm/demo@1.0.0",
"library"),
LayerDigest = "sha256:layer1",
Evidence = ImmutableArray.Create(ComponentEvidence.FromPath("/app/node_modules/demo/package.json")),
Usage = ComponentUsage.Create(false),
Metadata = new ComponentMetadata
{
Licenses = new[] { "MIT" },
Properties = new Dictionary<string, string>
{
["stellaops:source"] = "package-lock.json",
},
},
}
})
};
var image = new ImageArtifactDescriptor
{
ImageDigest = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
ImageReference = "registry.example.com/demo/app:1.0.0",
Repository = "registry.example.com/demo/app",
Tag = "1.0.0",
Architecture = "amd64",
};
return SbomCompositionRequest.Create(
image,
fragments,
new DateTimeOffset(2025, 10, 20, 0, 0, 0, TimeSpan.Zero),
generatorName: "StellaOps.Scanner",
generatorVersion: "0.10.0");
}
}

View File

@@ -0,0 +1,37 @@
using StellaOps.Scanner.Emit.Spdx.Models;
using Xunit;
namespace StellaOps.Scanner.Emit.Tests.Composition;
public sealed class SpdxLicenseExpressionTests
{
[Fact]
public void Parse_RecognizesException()
{
var list = SpdxLicenseListProvider.Get(SpdxLicenseListVersion.V3_21);
var expression = SpdxLicenseExpressionParser.Parse("GPL-2.0-only WITH Classpath-exception-2.0", list);
var rendered = SpdxLicenseExpressionRenderer.Render(expression);
Assert.Equal("GPL-2.0-only WITH Classpath-exception-2.0", rendered);
}
[Fact]
public void Render_PreservesGrouping()
{
var list = SpdxLicenseListProvider.Get(SpdxLicenseListVersion.V3_21);
var expression = SpdxLicenseExpressionParser.Parse("MIT AND (Apache-2.0 OR BSD-3-Clause)", list);
var rendered = SpdxLicenseExpressionRenderer.Render(expression);
Assert.Equal("MIT AND (Apache-2.0 OR BSD-3-Clause)", rendered);
}
[Fact]
public void Parse_AcceptsLicenseRef()
{
var list = SpdxLicenseListProvider.Get(SpdxLicenseListVersion.V3_21);
var expression = SpdxLicenseExpressionParser.Parse("LicenseRef-Custom", list);
var rendered = SpdxLicenseExpressionRenderer.Render(expression);
Assert.Equal("LicenseRef-Custom", rendered);
}
}

View File

@@ -2,6 +2,8 @@ using System;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Composition;
using StellaOps.Scanner.Emit.Index;
@@ -76,7 +78,64 @@ public sealed class ScannerArtifactPackageBuilderTests
Assert.Equal(6, root.GetProperty("artifacts").GetArrayLength());
var usageEntry = root.GetProperty("artifacts").EnumerateArray().First(element => element.GetProperty("kind").GetString() == "sbom-usage");
Assert.Equal("application/vnd.cyclonedx+json; version=1.6; view=usage", usageEntry.GetProperty("mediaType").GetString());
Assert.Equal("application/vnd.cyclonedx+json; version=1.7; view=usage", usageEntry.GetProperty("mediaType").GetString());
}
[Fact]
public void BuildPackage_IncludesSpdxWhenProvided()
{
var fragments = new[]
{
LayerComponentFragment.Create("sha256:layer1", new[]
{
CreateComponent("pkg:npm/a", "1.0.0", "sha256:layer1"),
})
};
var request = SbomCompositionRequest.Create(
new ImageArtifactDescriptor
{
ImageDigest = "sha256:image",
ImageReference = "registry.example/app:latest",
Repository = "registry.example/app",
Tag = "latest",
},
fragments,
new DateTimeOffset(2025, 10, 19, 12, 30, 0, TimeSpan.Zero),
generatorName: "StellaOps.Scanner",
generatorVersion: "0.10.0");
var composer = new CycloneDxComposer();
var composition = composer.Compose(request);
var spdxBytes = Encoding.UTF8.GetBytes("{\"@context\":\"https://spdx.org/rdf/3.0.1/spdx-context.jsonld\",\"@graph\":[]}");
var spdxSha = Convert.ToHexString(SHA256.HashData(spdxBytes)).ToLowerInvariant();
composition = composition with
{
SpdxInventory = new SpdxArtifact
{
View = SbomView.Inventory,
GeneratedAt = request.GeneratedAt,
JsonBytes = spdxBytes,
JsonSha256 = spdxSha,
ContentHash = spdxSha,
JsonMediaType = "application/spdx+json; version=3.0.1"
}
};
var indexBuilder = new BomIndexBuilder();
var bomIndex = indexBuilder.Build(new BomIndexBuildRequest
{
ImageDigest = request.Image.ImageDigest,
Graph = composition.Graph,
GeneratedAt = request.GeneratedAt,
});
var packageBuilder = new ScannerArtifactPackageBuilder();
var package = packageBuilder.Build(request.Image.ImageDigest, request.GeneratedAt, composition, bomIndex);
Assert.Contains(package.Artifacts, artifact => artifact.Format == StellaOps.Scanner.Storage.Catalog.ArtifactDocumentFormat.SpdxJson);
}
private static ComponentRecord CreateComponent(string key, string version, string layerDigest, ComponentUsage? usage = null, IReadOnlyDictionary<string, string>? metadata = null)

View File

@@ -12,5 +12,11 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="JsonSchema.Net" Version="7.3.2" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\..\..\docs\schemas\cyclonedx-bom-1.7.schema.json" Link="Fixtures\schemas\cyclonedx-bom-1.7.schema.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="..\..\..\..\docs\schemas\spdx-jsonld-3.0.1.schema.json" Link="Fixtures\schemas\spdx-jsonld-3.0.1.schema.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,367 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_4300_0002_0001
// Task: T4 - Unit Tests for Evidence Redaction Service
using System.Security.Claims;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Evidence.Models;
using StellaOps.Scanner.Evidence.Privacy;
using Xunit;
namespace StellaOps.Scanner.Evidence.Tests.Privacy;
public sealed class EvidenceRedactionServiceTests
{
private readonly EvidenceRedactionService _service;
public EvidenceRedactionServiceTests()
{
_service = new EvidenceRedactionService(NullLogger<EvidenceRedactionService>.Instance);
}
[Fact]
public void Redact_Standard_RemovesSourceCode()
{
var bundle = CreateBundleWithSource();
var result = _service.Redact(bundle, EvidenceRedactionLevel.Standard);
var steps = result.Reachability!.Paths.SelectMany(p => p.Steps).ToList();
steps.Should().AllSatisfy(s => s.SourceCode.Should().BeNull());
}
[Fact]
public void Redact_Standard_KeepsFileHashes()
{
var bundle = CreateBundleWithSource();
var result = _service.Redact(bundle, EvidenceRedactionLevel.Standard);
var steps = result.Reachability!.Paths.SelectMany(p => p.Steps).ToList();
steps.Should().AllSatisfy(s => s.FileHash.Should().NotBeNull());
}
[Fact]
public void Redact_Standard_KeepsLineRanges()
{
var bundle = CreateBundleWithSource();
var result = _service.Redact(bundle, EvidenceRedactionLevel.Standard);
var steps = result.Reachability!.Paths.SelectMany(p => p.Steps).ToList();
steps.Should().AllSatisfy(s =>
{
s.Lines.Should().NotBeNull();
s.Lines.Should().HaveCount(2);
});
}
[Fact]
public void Redact_Standard_RedactsSymbolArguments()
{
var bundle = CreateBundleWithSource();
var result = _service.Redact(bundle, EvidenceRedactionLevel.Standard);
var firstStep = result.Reachability!.Paths.First().Steps.First();
firstStep.Node.Should().Be("MyClass.MyMethod(...)");
}
[Fact]
public void Redact_Standard_RemovesCallStackArguments()
{
var bundle = CreateBundleWithCallStack();
var result = _service.Redact(bundle, EvidenceRedactionLevel.Standard);
var frames = result.CallStack!.Frames.ToList();
frames.Should().AllSatisfy(f =>
{
f.Arguments.Should().BeNull();
f.Locals.Should().BeNull();
});
}
[Fact]
public void Redact_Minimal_RemovesPaths()
{
var bundle = CreateBundleWithPaths(5);
var result = _service.Redact(bundle, EvidenceRedactionLevel.Minimal);
result.Reachability!.Paths.Should().BeEmpty();
result.Reachability.PathCount.Should().Be(0);
}
[Fact]
public void Redact_Minimal_KeepsResultAndConfidence()
{
var bundle = CreateBundleWithPaths(5);
var result = _service.Redact(bundle, EvidenceRedactionLevel.Minimal);
result.Reachability!.Result.Should().Be("reachable");
result.Reachability.Confidence.Should().Be(0.95);
}
[Fact]
public void Redact_Minimal_RemovesCallStack()
{
var bundle = CreateBundleWithCallStack();
var result = _service.Redact(bundle, EvidenceRedactionLevel.Minimal);
result.CallStack.Should().BeNull();
}
[Fact]
public void Redact_Minimal_KeepsVexAndEpss()
{
var bundle = CreateBundleWithVexAndEpss();
var result = _service.Redact(bundle, EvidenceRedactionLevel.Minimal);
result.Vex.Should().NotBeNull();
result.Epss.Should().NotBeNull();
}
[Fact]
public void Redact_Full_NoChanges()
{
var bundle = CreateBundleWithSource();
var result = _service.Redact(bundle, EvidenceRedactionLevel.Full);
result.Should().Be(bundle);
}
[Fact]
public void DetermineLevel_SecurityAdmin_ReturnsFull()
{
var user = CreateUserWithRole("security_admin");
var level = _service.DetermineLevel(user);
level.Should().Be(EvidenceRedactionLevel.Full);
}
[Fact]
public void DetermineLevel_EvidenceFullScope_ReturnsFull()
{
var user = CreateUserWithScope("evidence:full");
var level = _service.DetermineLevel(user);
level.Should().Be(EvidenceRedactionLevel.Full);
}
[Fact]
public void DetermineLevel_SecurityAnalyst_ReturnsStandard()
{
var user = CreateUserWithRole("security_analyst");
var level = _service.DetermineLevel(user);
level.Should().Be(EvidenceRedactionLevel.Standard);
}
[Fact]
public void DetermineLevel_EvidenceStandardScope_ReturnsStandard()
{
var user = CreateUserWithScope("evidence:standard");
var level = _service.DetermineLevel(user);
level.Should().Be(EvidenceRedactionLevel.Standard);
}
[Fact]
public void DetermineLevel_NoScopes_ReturnsMinimal()
{
var user = CreateUserWithNoScopes();
var level = _service.DetermineLevel(user);
level.Should().Be(EvidenceRedactionLevel.Minimal);
}
[Fact]
public void RedactFields_SourceCode_RemovesOnlySourceCode()
{
var bundle = CreateBundleWithSource();
var result = _service.RedactFields(bundle, RedactableFields.SourceCode);
var steps = result.Reachability!.Paths.SelectMany(p => p.Steps).ToList();
steps.Should().AllSatisfy(s => s.SourceCode.Should().BeNull());
// Should keep other fields
steps.Should().AllSatisfy(s =>
{
s.FileHash.Should().NotBeNull();
s.Lines.Should().NotBeNull();
});
}
[Fact]
public void RedactFields_CallArguments_RemovesOnlyArguments()
{
var bundle = CreateBundleWithCallStack();
var result = _service.RedactFields(bundle, RedactableFields.CallArguments);
var frames = result.CallStack!.Frames.ToList();
frames.Should().AllSatisfy(f =>
{
f.Arguments.Should().BeNull();
f.Locals.Should().BeNull();
});
// Should keep function names
frames.Should().AllSatisfy(f => f.Function.Should().NotBeNull());
}
[Fact]
public void RedactFields_None_NoChanges()
{
var bundle = CreateBundleWithSource();
var result = _service.RedactFields(bundle, RedactableFields.None);
result.Should().Be(bundle);
}
// Helper methods for creating test data
private EvidenceBundle CreateBundleWithSource()
{
return new EvidenceBundle
{
Reachability = new ReachabilityEvidence
{
Result = "reachable",
Confidence = 0.95,
Paths = new[]
{
new ReachabilityPath
{
PathId = "path-1",
Steps = new[]
{
new ReachabilityStep
{
Node = "MyClass.MyMethod(string arg1, int arg2)",
FileHash = "sha256:abc123",
Lines = new[] { 10, 15 },
SourceCode = "var result = DoSomething(arg1, arg2);"
}
}
}
},
GraphDigest = "sha256:graph123"
}
};
}
private EvidenceBundle CreateBundleWithPaths(int pathCount)
{
var paths = Enumerable.Range(1, pathCount)
.Select(i => new ReachabilityPath
{
PathId = $"path-{i}",
Steps = new[]
{
new ReachabilityStep
{
Node = $"Function{i}",
FileHash = $"sha256:hash{i}",
Lines = new[] { i * 10, i * 10 + 5 }
}
}
})
.ToList();
return new EvidenceBundle
{
Reachability = new ReachabilityEvidence
{
Result = "reachable",
Confidence = 0.95,
Paths = paths,
GraphDigest = "sha256:graph123"
}
};
}
private EvidenceBundle CreateBundleWithCallStack()
{
return new EvidenceBundle
{
CallStack = new CallStackEvidence
{
Frames = new[]
{
new CallFrame
{
Function = "MyClass.MyMethod(...)",
FileHash = "sha256:abc123",
Line = 42,
Arguments = new Dictionary<string, string>
{
["arg1"] = "sensitive_value",
["arg2"] = "123"
},
Locals = new Dictionary<string, string>
{
["local1"] = "local_value"
}
}
}
}
};
}
private EvidenceBundle CreateBundleWithVexAndEpss()
{
return new EvidenceBundle
{
Reachability = new ReachabilityEvidence
{
Result = "reachable",
Confidence = 0.8,
Paths = new[]
{
new ReachabilityPath
{
PathId = "path-1",
Steps = new[]
{
new ReachabilityStep
{
Node = "SomeMethod",
FileHash = "sha256:xyz",
Lines = new[] { 1, 5 }
}
}
}
},
GraphDigest = "sha256:graph"
},
Vex = new VexEvidence
{
Status = "not_affected",
Justification = "vulnerable_code_not_in_execute_path"
},
Epss = new EpssEvidence
{
Score = 0.05,
Percentile = 0.75,
ModelDate = new DateOnly(2025, 12, 22),
CapturedAt = DateTimeOffset.UtcNow
}
};
}
private ClaimsPrincipal CreateUserWithRole(string role)
{
var claims = new[] { new Claim("role", role) };
var identity = new ClaimsIdentity(claims, "TestAuth");
return new ClaimsPrincipal(identity);
}
private ClaimsPrincipal CreateUserWithScope(string scope)
{
var claims = new[] { new Claim("scope", scope) };
var identity = new ClaimsIdentity(claims, "TestAuth");
return new ClaimsPrincipal(identity);
}
private ClaimsPrincipal CreateUserWithNoScopes()
{
var identity = new ClaimsIdentity(Array.Empty<Claim>(), "TestAuth");
return new ClaimsPrincipal(identity);
}
}

View File

@@ -0,0 +1,28 @@
<?xml version='1.0' encoding='utf-8'?>
<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="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Evidence/StellaOps.Scanner.Evidence.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,395 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
using StellaOps.Scanner.Reachability.MiniMap;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.MiniMap;
public class MiniMapExtractorTests
{
private readonly MiniMapExtractor _extractor = new();
[Fact]
public void Extract_ReachableComponent_ReturnsPaths()
{
var graph = CreateGraphWithPaths();
var result = _extractor.Extract(graph, "pkg:npm/vulnerable@1.0.0");
result.State.Should().Be(ReachabilityState.StaticReachable);
result.Paths.Should().NotBeEmpty();
result.Entrypoints.Should().NotBeEmpty();
result.Confidence.Should().BeGreaterThan(0.5m);
}
[Fact]
public void Extract_UnreachableComponent_ReturnsEmptyPaths()
{
var graph = CreateGraphWithoutPaths();
var result = _extractor.Extract(graph, "pkg:npm/isolated@1.0.0");
result.State.Should().Be(ReachabilityState.StaticUnreachable);
result.Paths.Should().BeEmpty();
result.Confidence.Should().Be(0.9m); // High confidence in unreachability
}
[Fact]
public void Extract_WithRuntimeEvidence_ReturnsConfirmedReachable()
{
var graph = CreateGraphWithRuntimeEvidence();
var result = _extractor.Extract(graph, "pkg:npm/vulnerable@1.0.0");
result.State.Should().Be(ReachabilityState.ConfirmedReachable);
result.Paths.Should().Contain(p => p.HasRuntimeEvidence);
result.Confidence.Should().BeGreaterThan(0.8m);
}
[Fact]
public void Extract_NonExistentComponent_ReturnsNotFoundMap()
{
var graph = CreateGraphWithPaths();
var result = _extractor.Extract(graph, "pkg:npm/nonexistent@1.0.0");
result.State.Should().Be(ReachabilityState.Unknown);
result.Confidence.Should().Be(0m);
result.VulnerableComponent.Id.Should().Be("pkg:npm/nonexistent@1.0.0");
}
[Fact]
public void Extract_RespectMaxPaths_LimitsResults()
{
var graph = CreateGraphWithManyPaths();
var result = _extractor.Extract(graph, "pkg:npm/vulnerable@1.0.0", maxPaths: 5);
result.Paths.Count.Should().BeLessOrEqualTo(5);
}
[Fact]
public void Extract_ClassifiesEntrypointKinds_Correctly()
{
var graph = CreateGraphWithDifferentEntrypoints();
var result = _extractor.Extract(graph, "pkg:npm/vulnerable@1.0.0");
result.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.HttpEndpoint);
result.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.MainFunction);
}
private static RichGraph CreateGraphWithPaths()
{
var nodes = new List<RichGraphNode>
{
new(
Id: "entrypoint:main",
SymbolId: "main",
CodeId: null,
Purl: null,
Lang: "javascript",
Kind: "main",
Display: "main()",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null),
new(
Id: "function:process",
SymbolId: "process",
CodeId: null,
Purl: null,
Lang: "javascript",
Kind: "function",
Display: "process()",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null),
new(
Id: "vuln:component",
SymbolId: "vulnerable",
CodeId: null,
Purl: "pkg:npm/vulnerable@1.0.0",
Lang: "javascript",
Kind: "function",
Display: "vulnerable()",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null)
};
var edges = new List<RichGraphEdge>
{
new(
From: "entrypoint:main",
To: "function:process",
Kind: "call",
Purl: null,
SymbolDigest: null,
Evidence: null,
Confidence: 0.9,
Candidates: null),
new(
From: "function:process",
To: "vuln:component",
Kind: "call",
Purl: "pkg:npm/vulnerable@1.0.0",
SymbolDigest: null,
Evidence: null,
Confidence: 0.9,
Candidates: null)
};
return new RichGraph(
Nodes: nodes,
Edges: edges,
Roots: Array.Empty<RichGraphRoot>(),
Analyzer: new RichGraphAnalyzer("test", "1.0", null));
}
private static RichGraph CreateGraphWithoutPaths()
{
var nodes = new List<RichGraphNode>
{
new(
Id: "entrypoint:main",
SymbolId: "main",
CodeId: null,
Purl: null,
Lang: "javascript",
Kind: "main",
Display: "main()",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null),
new(
Id: "isolated:component",
SymbolId: "isolated",
CodeId: null,
Purl: "pkg:npm/isolated@1.0.0",
Lang: "javascript",
Kind: "function",
Display: "isolated()",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null)
};
// No edges - isolated component
var edges = new List<RichGraphEdge>();
return new RichGraph(
Nodes: nodes,
Edges: edges,
Roots: Array.Empty<RichGraphRoot>(),
Analyzer: new RichGraphAnalyzer("test", "1.0", null));
}
private static RichGraph CreateGraphWithRuntimeEvidence()
{
var nodes = new List<RichGraphNode>
{
new(
Id: "entrypoint:main",
SymbolId: "main",
CodeId: null,
Purl: null,
Lang: "javascript",
Kind: "main",
Display: "main()",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null),
new(
Id: "vuln:component",
SymbolId: "vulnerable",
CodeId: null,
Purl: "pkg:npm/vulnerable@1.0.0",
Lang: "javascript",
Kind: "function",
Display: "vulnerable()",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null)
};
var edges = new List<RichGraphEdge>
{
new(
From: "entrypoint:main",
To: "vuln:component",
Kind: "call",
Purl: "pkg:npm/vulnerable@1.0.0",
SymbolDigest: null,
Evidence: new[] { "runtime", "static" },
Confidence: 0.95,
Candidates: null)
};
return new RichGraph(
Nodes: nodes,
Edges: edges,
Roots: Array.Empty<RichGraphRoot>(),
Analyzer: new RichGraphAnalyzer("test", "1.0", null));
}
private static RichGraph CreateGraphWithManyPaths()
{
var nodes = new List<RichGraphNode>
{
new(
Id: "entrypoint:main",
SymbolId: "main",
CodeId: null,
Purl: null,
Lang: "javascript",
Kind: "main",
Display: "main()",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null),
new(
Id: "vuln:component",
SymbolId: "vulnerable",
CodeId: null,
Purl: "pkg:npm/vulnerable@1.0.0",
Lang: "javascript",
Kind: "function",
Display: "vulnerable()",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null)
};
// Add intermediate nodes to create multiple paths
for (int i = 1; i <= 10; i++)
{
nodes.Add(new RichGraphNode(
Id: $"function:intermediate{i}",
SymbolId: $"intermediate{i}",
CodeId: null,
Purl: null,
Lang: "javascript",
Kind: "function",
Display: $"intermediate{i}()",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null));
}
var edges = new List<RichGraphEdge>();
// Create multiple paths from main to vuln through different intermediates
for (int i = 1; i <= 10; i++)
{
edges.Add(new RichGraphEdge(
From: "entrypoint:main",
To: $"function:intermediate{i}",
Kind: "call",
Purl: null,
SymbolDigest: null,
Evidence: null,
Confidence: 0.9,
Candidates: null));
edges.Add(new RichGraphEdge(
From: $"function:intermediate{i}",
To: "vuln:component",
Kind: "call",
Purl: "pkg:npm/vulnerable@1.0.0",
SymbolDigest: null,
Evidence: null,
Confidence: 0.9,
Candidates: null));
}
return new RichGraph(
Nodes: nodes,
Edges: edges,
Roots: Array.Empty<RichGraphRoot>(),
Analyzer: new RichGraphAnalyzer("test", "1.0", null));
}
private static RichGraph CreateGraphWithDifferentEntrypoints()
{
var nodes = new List<RichGraphNode>
{
new(
Id: "entrypoint:http",
SymbolId: "handleRequest",
CodeId: null,
Purl: null,
Lang: "javascript",
Kind: "entrypoint",
Display: "handleRequest()",
BuildId: null,
Evidence: null,
Attributes: new Dictionary<string, string> { ["http_method"] = "POST" },
SymbolDigest: null),
new(
Id: "entrypoint:main",
SymbolId: "main",
CodeId: null,
Purl: null,
Lang: "javascript",
Kind: "main",
Display: "main()",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null),
new(
Id: "vuln:component",
SymbolId: "vulnerable",
CodeId: null,
Purl: "pkg:npm/vulnerable@1.0.0",
Lang: "javascript",
Kind: "function",
Display: "vulnerable()",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null)
};
var edges = new List<RichGraphEdge>
{
new(
From: "entrypoint:http",
To: "vuln:component",
Kind: "call",
Purl: "pkg:npm/vulnerable@1.0.0",
SymbolDigest: null,
Evidence: null,
Confidence: 0.9,
Candidates: null),
new(
From: "entrypoint:main",
To: "vuln:component",
Kind: "call",
Purl: "pkg:npm/vulnerable@1.0.0",
SymbolDigest: null,
Evidence: null,
Confidence: 0.9,
Candidates: null)
};
return new RichGraph(
Nodes: nodes,
Edges: edges,
Roots: Array.Empty<RichGraphRoot>(),
Analyzer: new RichGraphAnalyzer("test", "1.0", null));
}
}

View File

@@ -0,0 +1,97 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Reachability.Gates;
using StellaOps.Scanner.Reachability.Subgraph;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
public sealed class ReachabilitySubgraphExtractorTests
{
[Fact]
public void Extract_BuildsSubgraphWithEntrypointAndVulnerable()
{
var graph = CreateGraph();
var request = new ReachabilitySubgraphRequest(
Graph: graph,
FindingKeys: ["CVE-2025-1234@pkg:npm/demo@1.0.0"],
TargetSymbols: ["sink"],
Entrypoints: []);
var extractor = new ReachabilitySubgraphExtractor();
var subgraph = extractor.Extract(request);
Assert.Equal(3, subgraph.Nodes.Length);
Assert.Equal(2, subgraph.Edges.Length);
Assert.Contains(subgraph.Nodes, n => n.Id == "root" && n.Type == ReachabilitySubgraphNodeType.Entrypoint);
Assert.Contains(subgraph.Nodes, n => n.Id == "sink" && n.Type == ReachabilitySubgraphNodeType.Vulnerable);
Assert.Contains(subgraph.Nodes, n => n.Id == "call" && n.Type == ReachabilitySubgraphNodeType.Call);
}
[Fact]
public void Extract_MapsGateMetadata()
{
var graph = CreateGraph(withGate: true);
var request = new ReachabilitySubgraphRequest(
Graph: graph,
FindingKeys: ["CVE-2025-1234@pkg:npm/demo@1.0.0"],
TargetSymbols: ["sink"],
Entrypoints: []);
var extractor = new ReachabilitySubgraphExtractor();
var subgraph = extractor.Extract(request);
var gatedEdge = subgraph.Edges.First(e => e.To == "sink");
Assert.NotNull(gatedEdge.Gate);
Assert.Equal("auth", gatedEdge.Gate!.GateType);
Assert.Equal("auth.check", gatedEdge.Gate.GuardSymbol);
}
[Fact]
public void Extract_WithNoTargets_ReturnsEmptySubgraph()
{
var graph = CreateGraph();
var request = new ReachabilitySubgraphRequest(
Graph: graph,
FindingKeys: [],
TargetSymbols: [],
Entrypoints: []);
var extractor = new ReachabilitySubgraphExtractor();
var subgraph = extractor.Extract(request);
Assert.Empty(subgraph.Nodes);
Assert.Empty(subgraph.Edges);
Assert.NotNull(subgraph.AnalysisMetadata);
}
private static RichGraph CreateGraph(bool withGate = false)
{
var nodes = new List<RichGraphNode>
{
new("root", "root", null, null, "csharp", "entrypoint", "root", null, null, null, null),
new("call", "call", null, null, "csharp", "call", "call", null, null, null, null),
new("sink", "sink", null, "pkg:npm/demo@1.0.0", "csharp", "sink", "sink", null, null, null, null)
};
var edges = new List<RichGraphEdge>
{
new("root", "call", "call", null, null, null, 0.9, null),
new("call", "sink", "call", null, null, null, 0.8, null,
withGate ? new[]
{
new DetectedGate
{
Type = GateType.AuthRequired,
Detail = "auth",
GuardSymbol = "auth.check",
Confidence = 0.9,
DetectionMethod = "static"
}
} : null)
};
var roots = new List<RichGraphRoot> { new("root", "runtime", null) };
var analyzer = new RichGraphAnalyzer("reachability", "1.0.0", null);
return new RichGraph(nodes, edges, roots, analyzer).Trimmed();
}
}

View File

@@ -0,0 +1,54 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Scanner.Reachability.Attestation;
using StellaOps.Scanner.Reachability.Subgraph;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
public sealed class ReachabilitySubgraphPublisherTests
{
[Fact]
public async Task PublishAsync_BuildsDigestAndStoresInCas()
{
var subgraph = new ReachabilitySubgraph
{
FindingKeys = ["CVE-2025-1234@pkg:npm/demo@1.0.0"],
Nodes =
[
new ReachabilitySubgraphNode { Id = "root", Type = ReachabilitySubgraphNodeType.Entrypoint, Symbol = "root" },
new ReachabilitySubgraphNode { Id = "sink", Type = ReachabilitySubgraphNodeType.Vulnerable, Symbol = "sink" }
],
Edges =
[
new ReachabilitySubgraphEdge { From = "root", To = "sink", Type = "call", Confidence = 0.9 }
],
AnalysisMetadata = new ReachabilitySubgraphMetadata
{
Analyzer = "reachability",
AnalyzerVersion = "1.0.0",
Confidence = 0.9,
Completeness = "partial",
GeneratedAt = new DateTimeOffset(2025, 12, 22, 0, 0, 0, TimeSpan.Zero)
}
};
var options = Options.Create(new ReachabilitySubgraphOptions { Enabled = true, StoreInCas = true });
var cas = new FakeFileContentAddressableStore();
var publisher = new ReachabilitySubgraphPublisher(
options,
CryptoHashFactory.CreateDefault(),
NullLogger<ReachabilitySubgraphPublisher>.Instance,
cas: cas);
var result = await publisher.PublishAsync(subgraph, "sha256:subject");
Assert.False(string.IsNullOrWhiteSpace(result.SubgraphDigest));
Assert.False(string.IsNullOrWhiteSpace(result.AttestationDigest));
Assert.NotNull(result.CasUri);
Assert.NotEmpty(result.DsseEnvelopeBytes);
Assert.NotNull(cas.GetBytes(result.SubgraphDigest.Split(':')[1]));
}
}

View File

@@ -0,0 +1,69 @@
using StellaOps.Scanner.Reachability.Tests;
using StellaOps.Scanner.Reachability.Slices;
using System;
using StellaOps.Cryptography;
using StellaOps.Replay.Core;
using StellaOps.Scanner.ProofSpine;
using StellaOps.Scanner.ProofSpine.Options;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Slices;
[Trait("Category", "Slice")]
[Trait("Sprint", "3810")]
public sealed class SliceCasStorageTests
{
[Fact(DisplayName = "SliceCasStorage stores slice and DSSE envelope in CAS")]
public async Task StoreAsync_WritesSliceAndDsseToCas()
{
var cryptoHash = DefaultCryptoHash.CreateForTests();
var signer = CreateDeterministicSigner("slice-test-key");
var cryptoProfile = new TestCryptoProfile("slice-test-key", "hs256");
var hasher = new SliceHasher(cryptoHash);
var dsseSigner = new SliceDsseSigner(signer, cryptoProfile, hasher, new FixedTimeProvider());
var storage = new SliceCasStorage(hasher, dsseSigner, cryptoHash);
var cas = new FakeFileContentAddressableStore();
var slice = SliceTestData.CreateSlice();
var result = await storage.StoreAsync(slice, cas);
var key = ExtractDigestHex(result.SliceDigest);
Assert.NotNull(cas.GetBytes(key));
Assert.NotNull(cas.GetBytes(key + ".dsse"));
Assert.StartsWith("cas://slices/", result.SliceCasUri, StringComparison.Ordinal);
Assert.EndsWith(".dsse", result.DsseCasUri, StringComparison.Ordinal);
Assert.Equal(SliceSchema.DssePayloadType, result.SignedSlice.Envelope.PayloadType);
}
private static IDsseSigningService CreateDeterministicSigner(string keyId)
{
var options = Microsoft.Extensions.Options.Options.Create(new ProofSpineDsseSigningOptions
{
Mode = "hash",
KeyId = keyId,
Algorithm = "hs256",
AllowDeterministicFallback = true,
});
return new HmacDsseSigningService(
options,
DefaultCryptoHmac.CreateForTests(),
DefaultCryptoHash.CreateForTests());
}
private static string ExtractDigestHex(string prefixed)
{
var index = prefixed.IndexOf(':');
return index >= 0 ? prefixed[(index + 1)..] : prefixed;
}
private sealed record TestCryptoProfile(string KeyId, string Algorithm) : ICryptoProfile;
private sealed class FixedTimeProvider : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(2025, 12, 22, 10, 0, 0, TimeSpan.Zero);
}
}

View File

@@ -0,0 +1,54 @@
using StellaOps.Scanner.Reachability.Slices;
using System.Collections.Immutable;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Slices;
[Trait("Category", "Slice")]
[Trait("Sprint", "3810")]
public sealed class SliceExtractorTests
{
[Fact(DisplayName = "SliceExtractor returns reachable slice for entrypoint -> target path")]
public void Extract_WithPath_ReturnsReachableSlice()
{
var graph = SliceTestData.CreateGraph();
var request = new SliceExtractionRequest(
Graph: graph,
Inputs: SliceTestData.CreateInputs(),
Query: SliceTestData.CreateQuery(
targets: ImmutableArray.Create("target"),
entrypoints: ImmutableArray.Create("entry")),
Manifest: SliceTestData.CreateManifest());
var extractor = new SliceExtractor(new VerdictComputer());
var slice = extractor.Extract(request);
Assert.Equal(SliceVerdictStatus.Reachable, slice.Verdict.Status);
Assert.Contains(slice.Subgraph.Nodes, n => n.Id == "entry" && n.Kind == SliceNodeKind.Entrypoint);
Assert.Contains(slice.Subgraph.Nodes, n => n.Id == "target" && n.Kind == SliceNodeKind.Target);
Assert.DoesNotContain(slice.Subgraph.Nodes, n => n.Id == "other");
Assert.Equal(2, slice.Subgraph.Edges.Length);
Assert.Contains(slice.Verdict.PathWitnesses, witness => witness.Contains("entry", StringComparison.Ordinal));
}
[Fact(DisplayName = "SliceExtractor returns unknown verdict when entrypoints are missing")]
public void Extract_MissingEntrypoints_ReturnsUnknown()
{
var graph = SliceTestData.CreateGraph();
var request = new SliceExtractionRequest(
Graph: graph,
Inputs: SliceTestData.CreateInputs(),
Query: SliceTestData.CreateQuery(
targets: ImmutableArray.Create("target"),
entrypoints: ImmutableArray.Create("missing")),
Manifest: SliceTestData.CreateManifest());
var extractor = new SliceExtractor(new VerdictComputer());
var slice = extractor.Extract(request);
Assert.Equal(SliceVerdictStatus.Unknown, slice.Verdict.Status);
Assert.Contains("missing_entrypoints", slice.Verdict.Reasons);
}
}

View File

@@ -0,0 +1,45 @@
using StellaOps.Scanner.Reachability.Slices;
using System.Collections.Immutable;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Slices;
[Trait("Category", "Determinism")]
[Trait("Sprint", "3810")]
public sealed class SliceHasherTests
{
[Fact(DisplayName = "SliceHasher produces deterministic bytes across ordering differences")]
public void ComputeDigest_IsDeterministicAcrossOrdering()
{
var nodesA = ImmutableArray.Create(
new SliceNode { Id = "node:2", Symbol = "b", Kind = SliceNodeKind.Intermediate },
new SliceNode { Id = "node:1", Symbol = "a", Kind = SliceNodeKind.Entrypoint },
new SliceNode { Id = "node:3", Symbol = "c", Kind = SliceNodeKind.Target });
var nodesB = ImmutableArray.Create(
new SliceNode { Id = "node:3", Symbol = "c", Kind = SliceNodeKind.Target },
new SliceNode { Id = "node:1", Symbol = "a", Kind = SliceNodeKind.Entrypoint },
new SliceNode { Id = "node:2", Symbol = "b", Kind = SliceNodeKind.Intermediate });
var edgesA = ImmutableArray.Create(
new SliceEdge { From = "node:2", To = "node:3", Kind = SliceEdgeKind.Direct, Confidence = 0.9 },
new SliceEdge { From = "node:1", To = "node:2", Kind = SliceEdgeKind.Direct, Confidence = 1.0 });
var edgesB = ImmutableArray.Create(
new SliceEdge { From = "node:1", To = "node:2", Kind = SliceEdgeKind.Direct, Confidence = 1.0 },
new SliceEdge { From = "node:2", To = "node:3", Kind = SliceEdgeKind.Direct, Confidence = 0.9 });
var sliceA = SliceTestData.CreateSlice(nodesA, edgesA);
var sliceB = SliceTestData.CreateSlice(nodesB, edgesB);
var hasher = new SliceHasher(DefaultCryptoHash.CreateForTests());
var digestA = hasher.ComputeDigest(sliceA);
var digestB = hasher.ComputeDigest(sliceB);
Assert.Equal(digestA.Digest, digestB.Digest);
Assert.Equal(digestA.CanonicalBytes, digestB.CanonicalBytes);
}
}

View File

@@ -0,0 +1,105 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using FluentAssertions;
using Json.Schema;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Slices;
[Trait("Category", "Schema")]
[Trait("Sprint", "3810")]
public sealed class SliceSchemaValidationTests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
[Fact(DisplayName = "Valid ReachabilitySlice passes schema validation")]
public void ValidSlice_PassesValidation()
{
var schema = LoadSchema();
var slice = SliceTestData.CreateSlice();
var json = JsonSerializer.Serialize(slice, JsonOptions);
var node = JsonDocument.Parse(json).RootElement;
var result = schema.Evaluate(node);
result.IsValid.Should().BeTrue("valid slices should pass schema validation");
}
[Fact(DisplayName = "Slice missing required fields fails validation")]
public void MissingRequiredField_FailsValidation()
{
var schema = LoadSchema();
var json = """
{
"_type": "stellaops.dev/predicates/reachability-slice@v1",
"inputs": { "graphDigest": "blake3:a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd" }
}
""";
var node = JsonDocument.Parse(json).RootElement;
var result = schema.Evaluate(node);
result.IsValid.Should().BeFalse("missing required subgraph/verdict/manifest should fail validation");
}
[Fact(DisplayName = "Slice with invalid verdict status fails validation")]
public void InvalidVerdictStatus_FailsValidation()
{
var schema = LoadSchema();
var json = """
{
"_type": "stellaops.dev/predicates/reachability-slice@v1",
"inputs": { "graphDigest": "blake3:a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd" },
"query": { "cveId": "CVE-2024-1234" },
"subgraph": { "nodes": [], "edges": [] },
"verdict": { "status": "invalid", "confidence": 0.5 },
"manifest": {
"scanId": "scan-1",
"createdAtUtc": "2025-12-22T10:00:00Z",
"artifactDigest": "sha256:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
"scannerVersion": "scanner.native:1.2.0",
"workerVersion": "scanner.worker:1.2.0",
"concelierSnapshotHash": "sha256:1111222233334444555566667777888899990000aaaabbbbccccddddeeeeffff",
"excititorSnapshotHash": "sha256:2222333344445555666677778888999900001111aaaabbbbccccddddeeeeffff",
"latticePolicyHash": "sha256:3333444455556666777788889999000011112222aaaabbbbccccddddeeeeffff",
"deterministic": true,
"seed": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"knobs": { "maxDepth": "20" }
}
}
""";
var node = JsonDocument.Parse(json).RootElement;
var result = schema.Evaluate(node);
result.IsValid.Should().BeFalse("invalid verdict status should fail validation");
}
private static JsonSchema LoadSchema()
{
var schemaPath = FindSchemaPath();
var json = File.ReadAllText(schemaPath);
return JsonSchema.FromText(json);
}
private static string FindSchemaPath()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
var candidate = Path.Combine(dir.FullName, "docs", "schemas", "stellaops-slice.v1.schema.json");
if (File.Exists(candidate))
{
return candidate;
}
dir = dir.Parent;
}
throw new FileNotFoundException("Could not locate stellaops-slice.v1.schema.json from test directory.");
}
}

View File

@@ -0,0 +1,109 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Reachability.Slices;
using StellaOps.Scanner.Core;
namespace StellaOps.Scanner.Reachability.Tests.Slices;
internal static class SliceTestData
{
private static readonly DateTimeOffset FixedTimestamp = new(2025, 12, 22, 10, 0, 0, TimeSpan.Zero);
public static ScanManifest CreateManifest(
string scanId = "scan-1",
string artifactDigest = "sha256:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
{
var seed = new byte[32];
var builder = ScanManifest.CreateBuilder(scanId, artifactDigest)
.WithCreatedAt(FixedTimestamp)
.WithArtifactPurl("pkg:generic/app@1.0.0")
.WithScannerVersion("scanner.native:1.2.0")
.WithWorkerVersion("scanner.worker:1.2.0")
.WithConcelierSnapshot("sha256:1111222233334444555566667777888899990000aaaabbbbccccddddeeeeffff")
.WithExcititorSnapshot("sha256:2222333344445555666677778888999900001111aaaabbbbccccddddeeeeffff")
.WithLatticePolicyHash("sha256:3333444455556666777788889999000011112222aaaabbbbccccddddeeeeffff")
.WithDeterministic(true)
.WithSeed(seed)
.WithKnob("maxDepth", "20");
return builder.Build();
}
public static SliceInputs CreateInputs()
{
return new SliceInputs
{
GraphDigest = "blake3:a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd",
BinaryDigests = ImmutableArray.Create(
"sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
SbomDigest = "sha256:cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe"
};
}
public static SliceQuery CreateQuery(
ImmutableArray<string>? targets = null,
ImmutableArray<string>? entrypoints = null)
{
return new SliceQuery
{
CveId = "CVE-2024-1234",
TargetSymbols = targets ?? ImmutableArray.Create("openssl:EVP_PKEY_decrypt"),
Entrypoints = entrypoints ?? ImmutableArray.Create("main")
};
}
public static ReachabilitySlice CreateSlice(
ImmutableArray<SliceNode>? nodes = null,
ImmutableArray<SliceEdge>? edges = null)
{
return new ReachabilitySlice
{
Inputs = CreateInputs(),
Query = CreateQuery(),
Subgraph = new SliceSubgraph
{
Nodes = nodes ?? ImmutableArray.Create(
new SliceNode { Id = "node:1", Symbol = "main", Kind = SliceNodeKind.Entrypoint },
new SliceNode { Id = "node:2", Symbol = "decrypt_data", Kind = SliceNodeKind.Intermediate },
new SliceNode { Id = "node:3", Symbol = "EVP_PKEY_decrypt", Kind = SliceNodeKind.Target }
),
Edges = edges ?? ImmutableArray.Create(
new SliceEdge { From = "node:1", To = "node:2", Kind = SliceEdgeKind.Direct, Confidence = 1.0 },
new SliceEdge { From = "node:2", To = "node:3", Kind = SliceEdgeKind.Plt, Confidence = 0.9 }
)
},
Verdict = new SliceVerdict
{
Status = SliceVerdictStatus.Reachable,
Confidence = 0.9,
Reasons = ImmutableArray.Create("path_exists_high_confidence"),
PathWitnesses = ImmutableArray.Create("main -> decrypt_data -> EVP_PKEY_decrypt"),
UnknownCount = 0
},
Manifest = CreateManifest()
};
}
public static RichGraph CreateGraph()
{
var nodes = new[]
{
new RichGraphNode("entry", "entry", null, null, "native", "method", "entry", null, null, null, null),
new RichGraphNode("mid", "mid", null, null, "native", "method", "mid", null, null, null, null),
new RichGraphNode("target", "target", null, null, "native", "method", "target", null, null, null, null),
new RichGraphNode("other", "other", null, null, "native", "method", "other", null, null, null, null)
};
var edges = new[]
{
new RichGraphEdge("entry", "mid", "call", null, null, null, 0.95, null),
new RichGraphEdge("mid", "target", "call", null, null, null, 0.9, null),
new RichGraphEdge("other", "mid", "call", null, null, null, 0.5, null)
};
var roots = new[] { new RichGraphRoot("entry", "runtime", null) };
return new RichGraph(nodes, edges, roots, new RichGraphAnalyzer("slice-test", "1.0.0", null));
}
}

View File

@@ -0,0 +1,40 @@
using StellaOps.Scanner.Reachability.Slices;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Slices;
[Trait("Category", "Slice")]
[Trait("Sprint", "3810")]
public sealed class SliceVerdictComputerTests
{
[Fact(DisplayName = "VerdictComputer returns reachable when path is strong and no unknowns")]
public void Compute_ReturnsReachable()
{
var paths = new[] { new SlicePathSummary("path-1", 0.85, "entry -> target") };
var verdict = new VerdictComputer().Compute(paths, unknownEdgeCount: 0);
Assert.Equal(SliceVerdictStatus.Reachable, verdict.Status);
Assert.Contains("path_exists_high_confidence", verdict.Reasons);
}
[Fact(DisplayName = "VerdictComputer returns unreachable when no paths and no unknowns")]
public void Compute_ReturnsUnreachable()
{
var verdict = new VerdictComputer().Compute(Array.Empty<SlicePathSummary>(), unknownEdgeCount: 0);
Assert.Equal(SliceVerdictStatus.Unreachable, verdict.Status);
Assert.Contains("no_paths_found", verdict.Reasons);
}
[Fact(DisplayName = "VerdictComputer returns unknown when unknown edges exist")]
public void Compute_ReturnsUnknownWhenUnknownEdgesPresent()
{
var paths = new[] { new SlicePathSummary("path-1", 0.9, "entry -> target") };
var verdict = new VerdictComputer().Compute(paths, unknownEdgeCount: 2);
Assert.Equal(SliceVerdictStatus.Unknown, verdict.Status);
Assert.Contains("unknown_edges:2", verdict.Reasons);
}
}

View File

@@ -9,6 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="JsonSchema.Net" Version="7.3.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />

View File

@@ -55,7 +55,7 @@ public sealed class AttestorClientTests
private static DescriptorDocument BuildDescriptorDocument()
{
var subject = new DescriptorSubject("application/vnd.oci.image.manifest.v1+json", "sha256:img");
var artifact = new DescriptorArtifact("application/vnd.cyclonedx+json", "sha256:sbom", 42, new System.Collections.Generic.Dictionary<string, string>());
var artifact = new DescriptorArtifact("application/vnd.cyclonedx+json; version=1.7", "sha256:sbom", 42, new System.Collections.Generic.Dictionary<string, string>());
var provenance = new DescriptorProvenance("pending", "sha256:dsse", "nonce", "https://attestor.example.com/api/v1/provenance", "https://slsa.dev/provenance/v1");
var generatorMetadata = new DescriptorGeneratorMetadata("generator", "1.0.0");
var metadata = new System.Collections.Generic.Dictionary<string, string>();

View File

@@ -30,7 +30,7 @@ public sealed class DescriptorGeneratorTests
{
ImageDigest = "sha256:0123456789abcdef",
SbomPath = sbomPath,
SbomMediaType = "application/vnd.cyclonedx+json",
SbomMediaType = "application/vnd.cyclonedx+json; version=1.7",
SbomFormat = "cyclonedx-json",
SbomKind = "inventory",
SbomArtifactType = "application/vnd.stellaops.sbom.layer+json",
@@ -79,7 +79,7 @@ public sealed class DescriptorGeneratorTests
{
ImageDigest = "sha256:0123456789abcdef",
SbomPath = sbomPath,
SbomMediaType = "application/vnd.cyclonedx+json",
SbomMediaType = "application/vnd.cyclonedx+json; version=1.7",
SbomFormat = "cyclonedx-json",
SbomKind = "inventory",
SbomArtifactType = "application/vnd.stellaops.sbom.layer+json",

View File

@@ -34,7 +34,7 @@ public sealed class DescriptorGoldenTests
{
ImageDigest = "sha256:0123456789abcdef",
SbomPath = sbomPath,
SbomMediaType = "application/vnd.cyclonedx+json",
SbomMediaType = "application/vnd.cyclonedx+json; version=1.7",
SbomFormat = "cyclonedx-json",
SbomKind = "inventory",
SbomArtifactType = "application/vnd.stellaops.sbom.layer+json",

View File

@@ -10,7 +10,7 @@
"digest": "sha256:0123456789abcdef"
},
"artifact": {
"mediaType": "application/vnd.cyclonedx\u002Bjson",
"mediaType": "application/vnd.cyclonedx\u002Bjson; version=1.7",
"digest": "sha256:d07d06ae82e1789a5b505731f3ec3add106e23a55395213c9a881c7e816c695c",
"size": 45,
"annotations": {
@@ -36,10 +36,10 @@
"metadata": {
"sbomDigest": "sha256:d07d06ae82e1789a5b505731f3ec3add106e23a55395213c9a881c7e816c695c",
"sbomPath": "sample.cdx.json",
"sbomMediaType": "application/vnd.cyclonedx\u002Bjson",
"sbomMediaType": "application/vnd.cyclonedx\u002Bjson; version=1.7",
"subjectMediaType": "application/vnd.oci.image.manifest.v1\u002Bjson",
"repository": "git.stella-ops.org/stellaops",
"buildRef": "refs/heads/main",
"attestorUri": "https://attestor.local/api/v1/provenance"
}
}
}

View File

@@ -0,0 +1,74 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps Contributors
using System.Collections.Immutable;
using StellaOps.Attestor.ProofChain.Predicates;
using StellaOps.Scanner.SmartDiff.Attestation;
using StellaOps.Scanner.SmartDiff.Detection;
using Xunit;
namespace StellaOps.Scanner.SmartDiffTests;
public sealed class DeltaVerdictBuilderTests
{
[Fact]
public void BuildStatement_BuildsPredicateAndSubjects()
{
var changes = new[]
{
new MaterialRiskChangeResult(
FindingKey: new FindingKey("CVE-2025-0001", "pkg:npm/a@1.0.0"),
HasMaterialChange: true,
Changes: ImmutableArray.Create(new DetectedChange(
Rule: DetectionRule.R1_ReachabilityFlip,
ChangeType: MaterialChangeType.ReachabilityFlip,
Direction: RiskDirection.Increased,
Reason: "reachability_flip",
PreviousValue: "false",
CurrentValue: "true",
Weight: 1.0)),
PriorityScore: 100,
PreviousStateHash: "sha256:prev",
CurrentStateHash: "sha256:curr"),
new MaterialRiskChangeResult(
FindingKey: new FindingKey("CVE-2025-0002", "pkg:npm/b@2.0.0"),
HasMaterialChange: true,
Changes: ImmutableArray.Create(new DetectedChange(
Rule: DetectionRule.R2_VexFlip,
ChangeType: MaterialChangeType.VexFlip,
Direction: RiskDirection.Decreased,
Reason: "vex_flip",
PreviousValue: "affected",
CurrentValue: "not_affected",
Weight: 0.7)),
PriorityScore: 50,
PreviousStateHash: "sha256:prev2",
CurrentStateHash: "sha256:curr2")
};
var request = new DeltaVerdictBuildRequest
{
BeforeRevisionId = "rev-before",
AfterRevisionId = "rev-after",
BeforeImageDigest = "sha256:before",
AfterImageDigest = "sha256:after",
Changes = changes,
ComparedAt = new DateTimeOffset(2025, 12, 22, 0, 0, 0, TimeSpan.Zero),
BeforeProofSpine = new AttestationReference { Digest = "sha256:spine-before" },
AfterProofSpine = new AttestationReference { Digest = "sha256:spine-after" }
};
var builder = new DeltaVerdictBuilder();
var statement = builder.BuildStatement(request);
Assert.Equal(2, statement.Subject.Count);
Assert.Equal("delta-verdict.stella/v1", statement.PredicateType);
Assert.True(statement.Predicate.HasMaterialChange);
Assert.Equal(150, statement.Predicate.PriorityScore);
Assert.Equal("rev-before", statement.Predicate.BeforeRevisionId);
Assert.Equal("rev-after", statement.Predicate.AfterRevisionId);
Assert.Equal(2, statement.Predicate.Changes.Length);
Assert.Equal("R1", statement.Predicate.Changes[0].Rule);
Assert.Equal("increased", statement.Predicate.Changes[0].Direction);
}
}

View File

@@ -105,6 +105,22 @@ public sealed class SarifOutputGeneratorTests
r.Level == SarifLevel.Warning);
}
[Fact(DisplayName = "Delta verdict reference included in material change properties")]
public void DeltaVerdictReference_IncludedInMaterialChangeProperties()
{
// Arrange
var input = CreateBasicInput() with { DeltaVerdictReference = "sha256:delta" };
// Act
var sarifLog = _generator.Generate(input);
// Assert
var result = sarifLog.Runs[0].Results.First(r => r.RuleId == "SDIFF001");
result.Properties.Should().NotBeNull();
result.Properties!.Value.Should().ContainKey("deltaVerdictRef");
result.Properties["deltaVerdictRef"].Should().Be("sha256:delta");
}
[Fact(DisplayName = "Hardening regressions generate error-level results")]
public void HardeningRegressions_GenerateErrorResults()
{

View File

@@ -0,0 +1,97 @@
using System.Net;
using System.Net.Http;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Cryptography;
using StellaOps.Scanner.Storage.Oci;
using Xunit;
namespace StellaOps.Scanner.Storage.Oci.Tests;
public sealed class OciArtifactPusherTests
{
[Fact]
public async Task PushAsync_PushesManifestAndLayers()
{
var handler = new TestRegistryHandler();
var httpClient = new HttpClient(handler);
var pusher = new OciArtifactPusher(
httpClient,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciArtifactPusher>.Instance,
timeProvider: new FixedTimeProvider(new DateTimeOffset(2025, 12, 22, 0, 0, 0, TimeSpan.Zero)));
var request = new OciArtifactPushRequest
{
Reference = "registry.example/stellaops/delta:demo",
ArtifactType = OciMediaTypes.DeltaVerdictPredicate,
SubjectDigest = "sha256:subject",
Layers =
[
new OciLayerContent { Content = new byte[] { 0x01, 0x02 }, MediaType = OciMediaTypes.DsseEnvelope },
new OciLayerContent { Content = new byte[] { 0x03, 0x04 }, MediaType = OciMediaTypes.DeltaVerdictPredicate }
],
Annotations = new Dictionary<string, string>
{
["org.opencontainers.image.description"] = "delta"
}
};
var result = await pusher.PushAsync(request);
Assert.True(result.Success);
Assert.NotNull(result.ManifestDigest);
Assert.NotNull(result.ManifestReference);
Assert.NotNull(handler.ManifestBytes);
using var doc = JsonDocument.Parse(handler.ManifestBytes!);
Assert.True(doc.RootElement.TryGetProperty("annotations", out var annotations));
Assert.True(annotations.TryGetProperty("org.opencontainers.image.created", out _));
}
private sealed class TestRegistryHandler : HttpMessageHandler
{
public byte[]? ManifestBytes { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Head && path.Contains("/blobs/", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
}
if (request.Method == HttpMethod.Post && path.EndsWith("/blobs/uploads/", StringComparison.Ordinal))
{
var response = new HttpResponseMessage(HttpStatusCode.Accepted);
response.Headers.Location = new Uri("/v2/stellaops/delta/blobs/uploads/upload-id", UriKind.Relative);
return response;
}
if (request.Method == HttpMethod.Put && path.Contains("/blobs/uploads/", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.Created);
}
if (request.Method == HttpMethod.Put && path.Contains("/manifests/", StringComparison.Ordinal))
{
ManifestBytes = request.Content is null ? null : await request.Content.ReadAsByteArrayAsync(cancellationToken);
return new HttpResponseMessage(HttpStatusCode.Created);
}
return new HttpResponseMessage(HttpStatusCode.OK);
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _time;
public FixedTimeProvider(DateTimeOffset time) => _time = time;
public override DateTimeOffset GetUtcNow() => _time;
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<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.Storage.Oci\StellaOps.Scanner.Storage.Oci.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,182 @@
using Dapper;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Storage.Entities;
using StellaOps.Scanner.Storage.Postgres;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.Storage.Services;
using Xunit;
namespace StellaOps.Scanner.Storage.Tests;
[Collection("scanner-postgres")]
public sealed class BinaryEvidenceServiceTests : IAsyncLifetime
{
private readonly ScannerPostgresFixture _fixture;
private ScannerDataSource _dataSource = null!;
private IBinaryEvidenceRepository _repository = null!;
private IBinaryEvidenceService _service = null!;
private string _schemaName = string.Empty;
public BinaryEvidenceServiceTests(ScannerPostgresFixture fixture)
{
_fixture = fixture;
}
public async Task InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
var options = new ScannerStorageOptions
{
Postgres = new StellaOps.Infrastructure.Postgres.Options.PostgresOptions
{
ConnectionString = _fixture.ConnectionString,
SchemaName = _fixture.SchemaName
}
};
_schemaName = options.Postgres.SchemaName ?? ScannerDataSource.DefaultSchema;
_dataSource = new ScannerDataSource(Options.Create(options), NullLoggerFactory.Instance.CreateLogger<ScannerDataSource>());
_repository = new PostgresBinaryEvidenceRepository(_dataSource);
_service = new BinaryEvidenceService(_repository, NullLogger<BinaryEvidenceService>.Instance);
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task RecordBinary_NewBinary_CreatesRecord()
{
var scanId = await InsertScanAsync();
var info = CreateBinaryInfo();
var identity = await _service.RecordBinaryAsync(scanId, info);
Assert.NotEqual(Guid.Empty, identity.Id);
Assert.Equal("aabbccdd", identity.BuildId);
Assert.Equal("gnu-build-id", identity.BuildIdType);
Assert.Equal("x86_64", identity.Architecture);
Assert.Equal("elf", identity.BinaryFormat);
}
[Fact]
public async Task RecordBinary_DuplicateHash_ReturnsExisting()
{
var scanId = await InsertScanAsync();
var info = CreateBinaryInfo();
var first = await _service.RecordBinaryAsync(scanId, info);
var otherScan = await InsertScanAsync();
var second = await _service.RecordBinaryAsync(otherScan, info);
Assert.Equal(first.Id, second.Id);
var matches = await _repository.GetByFileSha256Async("a1b2c3d4", CancellationToken.None);
Assert.NotNull(matches);
}
[Fact]
public async Task MatchToPackage_Duplicate_ReturnsNull()
{
var identity = await CreateBinaryAsync();
var evidence = new PackageMatchEvidence(
MatchType: "build-id",
Confidence: 0.95m,
Source: "build-id-index",
Details: new { path = "/usr/lib/debug/libc.so.6" });
var first = await _service.MatchToPackageAsync(identity.Id, "pkg:rpm/glibc@2.38", evidence);
var second = await _service.MatchToPackageAsync(identity.Id, "pkg:rpm/glibc@2.38", evidence);
Assert.NotNull(first);
Assert.Null(second);
}
[Fact]
public async Task RecordAssertion_Valid_CreatesAssertion()
{
var identity = await CreateBinaryAsync();
var assertion = new AssertionInfo(
Status: "not_affected",
Source: "static-analysis",
Type: "symbol_absence",
Confidence: 0.8m,
Evidence: new { symbol = "vulnerable_func" },
ValidFrom: DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
ValidUntil: null,
SignatureRef: null);
var result = await _service.RecordAssertionAsync(identity.Id, "CVE-2024-1234", assertion);
Assert.NotEqual(Guid.Empty, result.Id);
Assert.Equal("not_affected", result.Status);
Assert.Equal("symbol_absence", result.AssertionType);
}
[Fact]
public async Task GetEvidence_ByBuildId_ReturnsComplete()
{
var identity = await CreateBinaryAsync();
var evidence = new PackageMatchEvidence(
MatchType: "build-id",
Confidence: 0.90m,
Source: "build-id-index",
Details: new { hint = "debuglink" });
await _service.MatchToPackageAsync(identity.Id, "pkg:rpm/glibc@2.38", evidence);
var assertion = new AssertionInfo(
Status: "fixed",
Source: "static-analysis",
Type: "symbol_presence",
Confidence: 0.92m,
Evidence: new { symbol = "patched_func" },
ValidFrom: DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
ValidUntil: null,
SignatureRef: "sig:local");
await _service.RecordAssertionAsync(identity.Id, "CVE-2024-1234", assertion);
var result = await _service.GetEvidenceForBinaryAsync("gnu-build-id:aabbccdd");
Assert.NotNull(result);
Assert.Equal(identity.Id, result!.Identity.Id);
Assert.Single(result.PackageMaps);
Assert.Single(result.VulnAssertions);
}
private BinaryInfo CreateBinaryInfo() => new(
FilePath: "/usr/lib/libc.so.6",
FileSha256: "sha256:A1B2C3D4",
TextSha256: "b2c3d4e5",
BuildId: "gnu-build-id:aabbccdd",
BuildIdType: null,
Architecture: "x86_64",
Format: "elf",
FileSize: 1024,
IsStripped: false,
HasDebugInfo: true);
private async Task<BinaryIdentityRow> CreateBinaryAsync()
{
var scanId = await InsertScanAsync();
return await _service.RecordBinaryAsync(scanId, CreateBinaryInfo());
}
private async Task<Guid> InsertScanAsync()
{
var scanId = Guid.NewGuid();
var table = $"{_schemaName}.scans";
await using var connection = await _dataSource.OpenSystemConnectionAsync().ConfigureAwait(false);
await connection.ExecuteAsync(
$"INSERT INTO {table} (scan_id) VALUES (@ScanId)",
new { ScanId = scanId }).ConfigureAwait(false);
return scanId;
}
}

View File

@@ -51,7 +51,7 @@ public sealed class StorageDualWriteFixture
var document = await service.StoreArtifactAsync(
ArtifactDocumentType.LayerBom,
ArtifactDocumentFormat.CycloneDxJson,
mediaType: "application/vnd.cyclonedx+json",
mediaType: "application/vnd.cyclonedx+json; version=1.7",
content: stream,
immutable: true,
ttlClass: "compliance",

View File

@@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
using StellaOps.Scanner.Triage.Models;
using StellaOps.Scanner.Triage.Services;
using Xunit;
namespace StellaOps.Scanner.Triage.Tests;
public sealed class ExploitPathGroupingServiceTests
{
private readonly Mock<IReachabilityQueryService> _reachabilityMock;
private readonly Mock<IVexDecisionService> _vexServiceMock;
private readonly Mock<IExceptionEvaluator> _exceptionEvaluatorMock;
private readonly Mock<ILogger<ExploitPathGroupingService>> _loggerMock;
private readonly ExploitPathGroupingService _service;
public ExploitPathGroupingServiceTests()
{
_reachabilityMock = new Mock<IReachabilityQueryService>();
_vexServiceMock = new Mock<IVexDecisionService>();
_exceptionEvaluatorMock = new Mock<IExceptionEvaluator>();
_loggerMock = new Mock<ILogger<ExploitPathGroupingService>>();
_service = new ExploitPathGroupingService(
_reachabilityMock.Object,
_vexServiceMock.Object,
_exceptionEvaluatorMock.Object,
_loggerMock.Object);
}
[Fact]
public async Task GroupFindingsAsync_WhenNoReachGraph_UsesFallback()
{
// Arrange
var artifactDigest = "sha256:test";
var findings = CreateTestFindings();
_reachabilityMock.Setup(x => x.GetReachGraphAsync(artifactDigest, It.IsAny<CancellationToken>()))
.ReturnsAsync((ReachabilityGraph?)null);
// Act
var result = await _service.GroupFindingsAsync(artifactDigest, findings);
// Assert
result.Should().NotBeEmpty();
result.Should().AllSatisfy(p =>
{
p.Reachability.Should().Be(ReachabilityStatus.Unknown);
p.Symbol.FullyQualifiedName.Should().Be("unknown");
});
}
[Fact]
public async Task GroupFindingsAsync_GroupsByPackageSymbolEntry()
{
// Arrange
var artifactDigest = "sha256:test";
var findings = CreateTestFindings();
var graphMock = new Mock<ReachabilityGraph>();
_reachabilityMock.Setup(x => x.GetReachGraphAsync(artifactDigest, It.IsAny<CancellationToken>()))
.ReturnsAsync(graphMock.Object);
graphMock.Setup(x => x.GetSymbolsForPackage(It.IsAny<string>()))
.Returns(new List<VulnerableSymbol>
{
new VulnerableSymbol("com.example.Foo.bar", "Foo.java", 42, "java")
});
graphMock.Setup(x => x.GetEntryPointsTo(It.IsAny<string>()))
.Returns(new List<EntryPoint>
{
new EntryPoint("POST /api/users", "http", "/api/users")
});
graphMock.Setup(x => x.GetPathsTo(It.IsAny<string>()))
.Returns(new List<ReachPath>
{
new ReachPath("POST /api/users", "com.example.Foo.bar", false, 0.8m)
});
_vexServiceMock.Setup(x => x.GetStatusForPathAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ImmutableArray<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexStatusResult(false, VexStatus.Unknown, null, 0m));
_exceptionEvaluatorMock.Setup(x => x.GetActiveExceptionsForPathAsync(
It.IsAny<string>(), It.IsAny<ImmutableArray<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ActiveException>());
// Act
var result = await _service.GroupFindingsAsync(artifactDigest, findings);
// Assert
result.Should().NotBeEmpty();
result.Should().AllSatisfy(p =>
{
p.PathId.Should().StartWith("path:");
p.Package.Purl.Should().NotBeNullOrEmpty();
p.Symbol.FullyQualifiedName.Should().NotBeNullOrEmpty();
p.Evidence.Items.Should().NotBeEmpty();
});
}
[Fact]
public void GeneratePathId_IsDeterministic()
{
// Arrange
var digest = "sha256:test";
var purl = "pkg:maven/com.example/lib@1.0.0";
var symbol = "com.example.Lib.method";
var entry = "POST /api";
// Act
var id1 = ExploitPathGroupingService.GeneratePathId(digest, purl, symbol, entry);
var id2 = ExploitPathGroupingService.GeneratePathId(digest, purl, symbol, entry);
// Assert
id1.Should().Be(id2);
id1.Should().StartWith("path:");
id1.Length.Should().Be(21); // "path:" + 16 hex chars
}
private static IReadOnlyList<Finding> CreateTestFindings()
{
return new List<Finding>
{
new Finding(
"finding-001",
"pkg:maven/com.example/lib@1.0.0",
"lib",
"1.0.0",
new List<string> { "CVE-2024-1234" },
7.5m,
0.3m,
Severity.High,
"sha256:test",
DateTimeOffset.UtcNow.AddDays(-7))
};
}
}

View File

@@ -1,22 +1,18 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="Moq" Version="4.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.*" PrivateAssets="all" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.10.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Triage/StellaOps.Scanner.Triage.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Infrastructure.Postgres.Testing/StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Triage\StellaOps.Scanner.Triage.csproj" />
</ItemGroup>
</Project>

View File

@@ -28,7 +28,7 @@ public sealed class TriageQueryPerformanceTests : IAsyncLifetime
return Task.CompletedTask;
}
public async Task DisposeAsync()
public async ValueTask DisposeAsync()
{
if (_context != null)
{

View File

@@ -27,7 +27,7 @@ public sealed class TriageSchemaIntegrationTests : IAsyncLifetime
return Task.CompletedTask;
}
public async Task DisposeAsync()
public async ValueTask DisposeAsync()
{
if (_context != null)
{

View File

@@ -1,6 +1,6 @@
// -----------------------------------------------------------------------------
// FindingEvidenceContractsTests.cs
// Sprint: SPRINT_3800_0001_0001_evidence_api_models
// Sprint: SPRINT_4300_0001_0002_findings_evidence_api
// Description: Unit tests for JSON serialization of evidence API contracts.
// -----------------------------------------------------------------------------
@@ -27,23 +27,26 @@ public class FindingEvidenceContractsTests
{
FindingId = "finding-123",
Cve = "CVE-2021-44228",
Component = new ComponentRef
Component = new ComponentInfo
{
Purl = "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
Name = "log4j-core",
Version = "2.14.1",
Type = "maven"
Purl = "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
Ecosystem = "maven"
},
ReachablePath = new[] { "com.example.App.main", "org.apache.log4j.Logger.log" },
LastSeen = new DateTimeOffset(2025, 12, 18, 12, 0, 0, TimeSpan.Zero)
LastSeen = new DateTimeOffset(2025, 12, 18, 12, 0, 0, TimeSpan.Zero),
AttestationRefs = new[] { "dsse:sha256:abc123" },
Freshness = new FreshnessInfo { IsStale = false }
};
var json = JsonSerializer.Serialize(response, SerializerOptions);
Assert.Contains("\"finding_id\":\"finding-123\"", json);
Assert.Contains("\"cve\":\"CVE-2021-44228\"", json);
Assert.Contains("\"component\":", json);
Assert.Contains("\"reachable_path\":", json);
Assert.Contains("\"last_seen\":", json);
Assert.Contains("\"freshness\":", json);
}
[Fact]
@@ -53,39 +56,35 @@ public class FindingEvidenceContractsTests
{
FindingId = "finding-456",
Cve = "CVE-2023-12345",
Component = new ComponentRef
Component = new ComponentInfo
{
Purl = "pkg:npm/lodash@4.17.20",
Name = "lodash",
Version = "4.17.20",
Type = "npm"
Purl = "pkg:npm/lodash@4.17.20",
Ecosystem = "npm"
},
Entrypoint = new EntrypointProof
Entrypoint = new EntrypointInfo
{
Type = "http_handler",
Type = "http",
Route = "/api/v1/users",
Method = "POST",
Auth = "required",
Fqn = "com.example.UserController.createUser"
Auth = "jwt:write"
},
ScoreExplain = new ScoreExplanationDto
Score = new ScoreInfo
{
Kind = "stellaops_risk_v1",
RiskScore = 7.5,
RiskScore = 75,
Contributions = new[]
{
new ScoreContributionDto
new ScoreContribution
{
Factor = "cvss_base",
Weight = 0.4,
RawValue = 9.8,
Contribution = 3.92,
Explanation = "CVSS v4 base score"
Factor = "reachability",
Value = 25,
Reason = "Reachable from entrypoint"
}
},
LastSeen = DateTimeOffset.UtcNow
}
},
LastSeen = DateTimeOffset.UtcNow
LastSeen = DateTimeOffset.UtcNow,
Freshness = new FreshnessInfo { IsStale = false }
};
var json = JsonSerializer.Serialize(original, SerializerOptions);
@@ -94,178 +93,129 @@ public class FindingEvidenceContractsTests
Assert.NotNull(deserialized);
Assert.Equal(original.FindingId, deserialized.FindingId);
Assert.Equal(original.Cve, deserialized.Cve);
Assert.Equal(original.Component?.Purl, deserialized.Component?.Purl);
Assert.Equal(original.Component.Purl, deserialized.Component.Purl);
Assert.Equal(original.Entrypoint?.Type, deserialized.Entrypoint?.Type);
Assert.Equal(original.ScoreExplain?.RiskScore, deserialized.ScoreExplain?.RiskScore);
Assert.Equal(original.Score?.RiskScore, deserialized.Score?.RiskScore);
}
[Fact]
public void ComponentRef_SerializesAllFields()
public void ComponentInfo_SerializesAllFields()
{
var component = new ComponentRef
var component = new ComponentInfo
{
Purl = "pkg:nuget/Newtonsoft.Json@13.0.1",
Name = "Newtonsoft.Json",
Version = "13.0.1",
Type = "nuget"
Purl = "pkg:nuget/Newtonsoft.Json@13.0.1",
Ecosystem = "nuget"
};
var json = JsonSerializer.Serialize(component, SerializerOptions);
Assert.Contains("\"purl\":\"pkg:nuget/Newtonsoft.Json@13.0.1\"", json);
Assert.Contains("\"name\":\"Newtonsoft.Json\"", json);
Assert.Contains("\"version\":\"13.0.1\"", json);
Assert.Contains("\"type\":\"nuget\"", json);
Assert.Contains("\"purl\":\"pkg:nuget/Newtonsoft.Json@13.0.1\"", json);
Assert.Contains("\"ecosystem\":\"nuget\"", json);
}
[Fact]
public void EntrypointProof_SerializesWithLocation()
public void EntrypointInfo_SerializesAllFields()
{
var entrypoint = new EntrypointProof
var entrypoint = new EntrypointInfo
{
Type = "grpc_method",
Type = "grpc",
Route = "grpc.UserService.GetUser",
Auth = "required",
Phase = "runtime",
Fqn = "com.example.UserServiceImpl.getUser",
Location = new SourceLocation
{
File = "src/main/java/com/example/UserServiceImpl.java",
Line = 42,
Column = 5
}
Method = "CALL",
Auth = "mtls"
};
var json = JsonSerializer.Serialize(entrypoint, SerializerOptions);
Assert.Contains("\"type\":\"grpc_method\"", json);
Assert.Contains("\"type\":\"grpc\"", json);
Assert.Contains("\"route\":\"grpc.UserService.GetUser\"", json);
Assert.Contains("\"location\":", json);
Assert.Contains("\"file\":\"src/main/java/com/example/UserServiceImpl.java\"", json);
Assert.Contains("\"line\":42", json);
Assert.Contains("\"method\":\"CALL\"", json);
Assert.Contains("\"auth\":\"mtls\"", json);
}
[Fact]
public void BoundaryProofDto_SerializesWithControls()
public void BoundaryInfo_SerializesWithControls()
{
var boundary = new BoundaryProofDto
var boundary = new BoundaryInfo
{
Kind = "network",
Surface = new SurfaceDescriptor
{
Type = "api",
Protocol = "https",
Port = 443
},
Exposure = new ExposureDescriptor
{
Level = "public",
InternetFacing = true,
Zone = "dmz"
},
Auth = new AuthDescriptor
{
Required = true,
Type = "jwt",
Roles = new[] { "admin", "user" }
},
Controls = new[]
{
new ControlDescriptor
{
Type = "waf",
Active = true,
Config = "OWASP-ModSecurity"
}
},
LastSeen = DateTimeOffset.UtcNow,
Confidence = 0.95
Surface = "api",
Exposure = "internet",
Controls = new[] { "waf", "rate_limit" }
};
var json = JsonSerializer.Serialize(boundary, SerializerOptions);
Assert.Contains("\"kind\":\"network\"", json);
Assert.Contains("\"internet_facing\":true", json);
Assert.Contains("\"controls\":[", json);
Assert.Contains("\"confidence\":0.95", json);
Assert.Contains("\"surface\":\"api\"", json);
Assert.Contains("\"exposure\":\"internet\"", json);
Assert.Contains("\"controls\":[\"waf\",\"rate_limit\"]", json);
}
[Fact]
public void VexEvidenceDto_SerializesCorrectly()
public void VexStatusInfo_SerializesCorrectly()
{
var vex = new VexEvidenceDto
var vex = new VexStatusInfo
{
Status = "not_affected",
Justification = "vulnerable_code_not_in_execute_path",
Impact = "The vulnerable code path is never executed in our usage",
AttestationRef = "dsse:sha256:abc123",
IssuedAt = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero),
ExpiresAt = new DateTimeOffset(2026, 12, 1, 0, 0, 0, TimeSpan.Zero),
Source = "vendor"
Timestamp = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero),
Issuer = "vendor"
};
var json = JsonSerializer.Serialize(vex, SerializerOptions);
Assert.Contains("\"status\":\"not_affected\"", json);
Assert.Contains("\"justification\":\"vulnerable_code_not_in_execute_path\"", json);
Assert.Contains("\"attestation_ref\":\"dsse:sha256:abc123\"", json);
Assert.Contains("\"source\":\"vendor\"", json);
Assert.Contains("\"issuer\":\"vendor\"", json);
}
[Fact]
public void ScoreExplanationDto_SerializesContributions()
public void ScoreInfo_SerializesContributions()
{
var explanation = new ScoreExplanationDto
var score = new ScoreInfo
{
Kind = "stellaops_risk_v1",
RiskScore = 6.2,
RiskScore = 62,
Contributions = new[]
{
new ScoreContributionDto
new ScoreContribution
{
Factor = "cvss_base",
Weight = 0.4,
RawValue = 9.8,
Contribution = 3.92,
Explanation = "Critical CVSS base score"
Value = 40,
Reason = "Critical CVSS base score"
},
new ScoreContributionDto
{
Factor = "epss",
Weight = 0.2,
RawValue = 0.45,
Contribution = 0.09,
Explanation = "45% probability of exploitation"
},
new ScoreContributionDto
new ScoreContribution
{
Factor = "reachability",
Weight = 0.3,
RawValue = 1.0,
Contribution = 0.3,
Explanation = "Reachable from HTTP entrypoint"
},
new ScoreContributionDto
{
Factor = "gate_multiplier",
Weight = 1.0,
RawValue = 0.5,
Contribution = -2.11,
Explanation = "Auth gate reduces exposure by 50%"
Value = 22,
Reason = "Reachable from HTTP entrypoint"
}
},
LastSeen = DateTimeOffset.UtcNow
}
};
var json = JsonSerializer.Serialize(explanation, SerializerOptions);
var json = JsonSerializer.Serialize(score, SerializerOptions);
Assert.Contains("\"kind\":\"stellaops_risk_v1\"", json);
Assert.Contains("\"risk_score\":6.2", json);
Assert.Contains("\"contributions\":[", json);
Assert.Contains("\"risk_score\":62", json);
Assert.Contains("\"factor\":\"cvss_base\"", json);
Assert.Contains("\"factor\":\"epss\"", json);
Assert.Contains("\"factor\":\"reachability\"", json);
Assert.Contains("\"factor\":\"gate_multiplier\"", json);
}
[Fact]
public void FreshnessInfo_SerializesCorrectly()
{
var freshness = new FreshnessInfo
{
IsStale = true,
ExpiresAt = new DateTimeOffset(2025, 12, 31, 0, 0, 0, TimeSpan.Zero),
TtlRemainingHours = 0
};
var json = JsonSerializer.Serialize(freshness, SerializerOptions);
Assert.Contains("\"is_stale\":true", json);
Assert.Contains("\"expires_at\":", json);
Assert.Contains("\"ttl_remaining_hours\":0", json);
}
[Fact]
@@ -275,19 +225,22 @@ public class FindingEvidenceContractsTests
{
FindingId = "finding-minimal",
Cve = "CVE-2025-0001",
LastSeen = DateTimeOffset.UtcNow
// All optional fields are null
Component = new ComponentInfo
{
Name = "unknown",
Version = "unknown"
},
LastSeen = DateTimeOffset.UtcNow,
Freshness = new FreshnessInfo { IsStale = false }
};
var json = JsonSerializer.Serialize(response, SerializerOptions);
var deserialized = JsonSerializer.Deserialize<FindingEvidenceResponse>(json, SerializerOptions);
Assert.NotNull(deserialized);
Assert.Null(deserialized.Component);
Assert.Null(deserialized.ReachablePath);
Assert.Null(deserialized.Entrypoint);
Assert.Null(deserialized.Boundary);
Assert.Null(deserialized.Vex);
Assert.Null(deserialized.ScoreExplain);
Assert.Null(deserialized.Score);
Assert.Null(deserialized.Boundary);
}
}

View File

@@ -0,0 +1,159 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Scanner.Triage;
using StellaOps.Scanner.Triage.Entities;
using StellaOps.Scanner.WebService.Contracts;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class FindingsEvidenceControllerTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Fact]
public async Task GetEvidence_ReturnsNotFound_WhenFindingMissing()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"/api/v1/findings/{Guid.NewGuid()}/evidence");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetEvidence_ReturnsForbidden_WhenRawScopeMissing()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"/api/v1/findings/{Guid.NewGuid()}/evidence?includeRaw=true");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task GetEvidence_ReturnsEvidence_WhenFindingExists()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
using var client = factory.CreateClient();
var findingId = await SeedFindingAsync(factory);
var response = await client.GetAsync($"/api/v1/findings/{findingId}/evidence");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<FindingEvidenceResponse>(SerializerOptions);
Assert.NotNull(result);
Assert.Equal(findingId.ToString(), result!.FindingId);
Assert.Equal("CVE-2024-12345", result.Cve);
}
[Fact]
public async Task BatchEvidence_ReturnsBadRequest_WhenTooMany()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
using var client = factory.CreateClient();
var request = new BatchEvidenceRequest
{
FindingIds = Enumerable.Range(0, 101).Select(_ => Guid.NewGuid().ToString()).ToList()
};
var response = await client.PostAsJsonAsync("/api/v1/findings/evidence/batch", request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task BatchEvidence_ReturnsResults_ForExistingFindings()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
using var client = factory.CreateClient();
var findingId = await SeedFindingAsync(factory);
var request = new BatchEvidenceRequest
{
FindingIds = new[] { findingId.ToString(), Guid.NewGuid().ToString() }
};
var response = await client.PostAsJsonAsync("/api/v1/findings/evidence/batch", request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<BatchEvidenceResponse>(SerializerOptions);
Assert.NotNull(result);
Assert.Single(result!.Findings);
Assert.Equal(findingId.ToString(), result.Findings[0].FindingId);
}
private static async Task<Guid> SeedFindingAsync(ScannerApplicationFactory factory)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TriageDbContext>();
await db.Database.MigrateAsync();
var findingId = Guid.NewGuid();
var finding = new TriageFinding
{
Id = findingId,
AssetId = Guid.NewGuid(),
AssetLabel = "prod/api-gateway:1.2.3",
Purl = "pkg:npm/lodash@4.17.20",
CveId = "CVE-2024-12345",
LastSeenAt = DateTimeOffset.UtcNow
};
db.Findings.Add(finding);
db.RiskResults.Add(new TriageRiskResult
{
FindingId = findingId,
PolicyId = "policy-1",
PolicyVersion = "1.0.0",
InputsHash = "sha256:inputs",
Score = 72,
Verdict = TriageVerdict.Block,
Lane = TriageLane.High,
Why = "High risk score",
ComputedAt = DateTimeOffset.UtcNow
});
db.EvidenceArtifacts.Add(new TriageEvidenceArtifact
{
FindingId = findingId,
Type = TriageEvidenceType.Attestation,
Title = "SBOM attestation",
ContentHash = "sha256:attestation",
Uri = "s3://evidence/attestation.json"
});
await db.SaveChangesAsync();
return findingId;
}
}

View File

@@ -310,7 +310,7 @@ public sealed class NotifierIngestionTests
},
GeneratedAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"),
Format = "cyclonedx",
SpecVersion = "1.6",
SpecVersion = "1.7",
ComponentCount = 127,
SbomRef = "s3://sboms/sbom-001.json",
Digest = "sha256:sbom-digest-789"
@@ -333,7 +333,7 @@ public sealed class NotifierIngestionTests
Assert.NotNull(payload);
Assert.Equal("sbom-001", payload["sbomId"]?.GetValue<string>());
Assert.Equal("cyclonedx", payload["format"]?.GetValue<string>());
Assert.Equal("1.6", payload["specVersion"]?.GetValue<string>());
Assert.Equal("1.7", payload["specVersion"]?.GetValue<string>());
Assert.Equal(127, payload["componentCount"]?.GetValue<int>());
}

View File

@@ -31,7 +31,7 @@ public sealed class SbomEndpointsTests
var sbomJson = """
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"specVersion": "1.7",
"version": 1,
"components": []
}
@@ -39,7 +39,7 @@ public sealed class SbomEndpointsTests
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/sbom")
{
Content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json")
Content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json; version=1.7")
};
var response = await client.SendAsync(request);

View File

@@ -0,0 +1,168 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scanner.Storage.ObjectStore;
using StellaOps.Scanner.WebService.Contracts;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class SbomUploadEndpointsTests
{
[Fact]
public async Task Upload_accepts_cyclonedx_fixture_and_returns_record()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = CreateFactory();
using var client = factory.CreateClient();
var request = new SbomUploadRequestDto
{
ArtifactRef = "example.com/app:1.0",
SbomBase64 = LoadFixtureBase64("sample.cdx.json"),
Source = new SbomUploadSourceDto
{
Tool = "syft",
Version = "1.0.0",
CiContext = new SbomUploadCiContextDto
{
BuildId = "build-123",
Repository = "github.com/example/app"
}
}
};
var response = await client.PostAsJsonAsync("/api/v1/sbom/upload", request);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<SbomUploadResponseDto>();
Assert.NotNull(payload);
Assert.Equal("example.com/app:1.0", payload!.ArtifactRef);
Assert.Equal("cyclonedx", payload.Format);
Assert.Equal("1.6", payload.FormatVersion);
Assert.True(payload.ValidationResult.Valid);
Assert.False(string.IsNullOrWhiteSpace(payload.AnalysisJobId));
var recordResponse = await client.GetAsync($"/api/v1/sbom/uploads/{payload.SbomId}");
Assert.Equal(HttpStatusCode.OK, recordResponse.StatusCode);
var record = await recordResponse.Content.ReadFromJsonAsync<SbomUploadRecordDto>();
Assert.NotNull(record);
Assert.Equal(payload.SbomId, record!.SbomId);
Assert.Equal("example.com/app:1.0", record.ArtifactRef);
Assert.Equal("syft", record.Source?.Tool);
Assert.Equal("build-123", record.Source?.CiContext?.BuildId);
}
[Fact]
public async Task Upload_accepts_spdx_fixture_and_reports_quality_score()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = CreateFactory();
using var client = factory.CreateClient();
var request = new SbomUploadRequestDto
{
ArtifactRef = "example.com/service:2.0",
SbomBase64 = LoadFixtureBase64("sample.spdx.json")
};
var response = await client.PostAsJsonAsync("/api/v1/sbom/upload", request);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<SbomUploadResponseDto>();
Assert.NotNull(payload);
Assert.Equal("spdx", payload!.Format);
Assert.Equal("2.3", payload.FormatVersion);
Assert.True(payload.ValidationResult.Valid);
Assert.True(payload.ValidationResult.QualityScore > 0);
Assert.True(payload.ValidationResult.ComponentCount > 0);
}
[Fact]
public async Task Upload_rejects_unknown_format()
{
using var secrets = new TestSurfaceSecretsScope();
using var factory = CreateFactory();
using var client = factory.CreateClient();
var invalid = new SbomUploadRequestDto
{
ArtifactRef = "example.com/invalid:1.0",
SbomBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{\"name\":\"oops\"}"))
};
var response = await client.PostAsJsonAsync("/api/v1/sbom/upload", invalid);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private static ScannerApplicationFactory CreateFactory()
{
return new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
}, configureServices: services =>
{
services.RemoveAll<IArtifactObjectStore>();
services.AddSingleton<IArtifactObjectStore>(new InMemoryArtifactObjectStore());
});
}
private static string LoadFixtureBase64(string fileName)
{
var baseDirectory = AppContext.BaseDirectory;
var repoRoot = Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", ".."));
var path = Path.Combine(
repoRoot,
"tests",
"AirGap",
"StellaOps.AirGap.Importer.Tests",
"Reconciliation",
"Fixtures",
fileName);
Assert.True(File.Exists(path), $"Fixture not found at {path}.");
var bytes = File.ReadAllBytes(path);
return Convert.ToBase64String(bytes);
}
private sealed class InMemoryArtifactObjectStore : IArtifactObjectStore
{
private readonly ConcurrentDictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
public async Task PutAsync(ArtifactObjectDescriptor descriptor, Stream content, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(content);
using var buffer = new MemoryStream();
await content.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
var key = $"{descriptor.Bucket}:{descriptor.Key}";
_objects[key] = buffer.ToArray();
}
public Task<Stream?> GetAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(descriptor);
var key = $"{descriptor.Bucket}:{descriptor.Key}";
if (!_objects.TryGetValue(key, out var bytes))
{
return Task.FromResult<Stream?>(null);
}
return Task.FromResult<Stream?>(new MemoryStream(bytes, writable: false));
}
public Task DeleteAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(descriptor);
var key = $"{descriptor.Bucket}:{descriptor.Key}";
_objects.TryRemove(key, out _);
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,476 @@
using System.Collections.Immutable;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Scanner.Reachability.Slices;
using StellaOps.Scanner.WebService.Endpoints;
using StellaOps.Scanner.WebService.Services;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
/// <summary>
/// Integration tests for slice query and replay endpoints.
/// </summary>
public sealed class SliceEndpointsTests : IClassFixture<ScannerApplicationFixture>
{
private readonly ScannerApplicationFixture _fixture;
private readonly HttpClient _client;
public SliceEndpointsTests(ScannerApplicationFixture fixture)
{
_fixture = fixture;
_client = fixture.CreateClient();
}
[Fact]
public async Task QuerySlice_WithValidCve_ReturnsSlice()
{
// Arrange
var request = new SliceQueryRequestDto
{
ScanId = "test-scan-001",
CveId = "CVE-2024-1234",
Symbols = new List<string> { "vulnerable_function" }
};
// Act
var response = await _client.PostAsJsonAsync("/api/slices/query", request);
// Assert
// Note: May return 404 if no test data, but validates endpoint registration
Assert.True(
response.StatusCode == HttpStatusCode.OK ||
response.StatusCode == HttpStatusCode.NotFound ||
response.StatusCode == HttpStatusCode.Unauthorized,
$"Unexpected status: {response.StatusCode}");
}
[Fact]
public async Task QuerySlice_WithoutScanId_ReturnsBadRequest()
{
// Arrange
var request = new SliceQueryRequestDto
{
CveId = "CVE-2024-1234"
};
// Act
var response = await _client.PostAsJsonAsync("/api/slices/query", request);
// Assert
Assert.True(
response.StatusCode == HttpStatusCode.BadRequest ||
response.StatusCode == HttpStatusCode.Unauthorized,
$"Expected BadRequest or Unauthorized, got {response.StatusCode}");
}
[Fact]
public async Task QuerySlice_WithoutCveOrSymbols_ReturnsBadRequest()
{
// Arrange
var request = new SliceQueryRequestDto
{
ScanId = "test-scan-001"
};
// Act
var response = await _client.PostAsJsonAsync("/api/slices/query", request);
// Assert
Assert.True(
response.StatusCode == HttpStatusCode.BadRequest ||
response.StatusCode == HttpStatusCode.Unauthorized,
$"Expected BadRequest or Unauthorized, got {response.StatusCode}");
}
[Fact]
public async Task GetSlice_WithValidDigest_ReturnsSlice()
{
// Arrange
var digest = "sha256:abc123";
// Act
var response = await _client.GetAsync($"/api/slices/{digest}");
// Assert
Assert.True(
response.StatusCode == HttpStatusCode.OK ||
response.StatusCode == HttpStatusCode.NotFound ||
response.StatusCode == HttpStatusCode.Unauthorized,
$"Unexpected status: {response.StatusCode}");
}
[Fact]
public async Task GetSlice_WithDsseAccept_ReturnsDsseEnvelope()
{
// Arrange
var digest = "sha256:abc123";
var request = new HttpRequestMessage(HttpMethod.Get, $"/api/slices/{digest}");
request.Headers.Add("Accept", "application/dsse+json");
// Act
var response = await _client.SendAsync(request);
// Assert
Assert.True(
response.StatusCode == HttpStatusCode.OK ||
response.StatusCode == HttpStatusCode.NotFound ||
response.StatusCode == HttpStatusCode.Unauthorized,
$"Unexpected status: {response.StatusCode}");
}
[Fact]
public async Task ReplaySlice_WithValidDigest_ReturnsReplayResult()
{
// Arrange
var request = new SliceReplayRequestDto
{
SliceDigest = "sha256:abc123"
};
// Act
var response = await _client.PostAsJsonAsync("/api/slices/replay", request);
// Assert
Assert.True(
response.StatusCode == HttpStatusCode.OK ||
response.StatusCode == HttpStatusCode.NotFound ||
response.StatusCode == HttpStatusCode.Unauthorized,
$"Unexpected status: {response.StatusCode}");
}
[Fact]
public async Task ReplaySlice_WithoutDigest_ReturnsBadRequest()
{
// Arrange
var request = new SliceReplayRequestDto();
// Act
var response = await _client.PostAsJsonAsync("/api/slices/replay", request);
// Assert
Assert.True(
response.StatusCode == HttpStatusCode.BadRequest ||
response.StatusCode == HttpStatusCode.Unauthorized,
$"Expected BadRequest or Unauthorized, got {response.StatusCode}");
}
}
/// <summary>
/// Unit tests for SliceDiffComputer.
/// </summary>
public sealed class SliceDiffComputerTests
{
private readonly SliceDiffComputer _computer = new();
[Fact]
public void Compare_IdenticalSlices_ReturnsMatch()
{
// Arrange
var slice = CreateTestSlice();
// Act
var result = _computer.Compare(slice, slice);
// Assert
Assert.True(result.Match);
Assert.Empty(result.MissingNodes);
Assert.Empty(result.ExtraNodes);
Assert.Empty(result.MissingEdges);
Assert.Empty(result.ExtraEdges);
Assert.Null(result.VerdictDiff);
}
[Fact]
public void Compare_DifferentNodes_ReturnsDiff()
{
// Arrange
var original = CreateTestSlice();
var modified = original with
{
Subgraph = original.Subgraph with
{
Nodes = original.Subgraph.Nodes.Add(new SliceNode
{
Id = "extra-node",
Symbol = "extra_func",
Kind = SliceNodeKind.Intermediate
})
}
};
// Act
var result = _computer.Compare(original, modified);
// Assert
Assert.False(result.Match);
Assert.Empty(result.MissingNodes);
Assert.Single(result.ExtraNodes);
Assert.Contains("extra-node", result.ExtraNodes);
}
[Fact]
public void Compare_DifferentEdges_ReturnsDiff()
{
// Arrange
var original = CreateTestSlice();
var modified = original with
{
Subgraph = original.Subgraph with
{
Edges = original.Subgraph.Edges.RemoveAt(0)
}
};
// Act
var result = _computer.Compare(original, modified);
// Assert
Assert.False(result.Match);
Assert.Single(result.MissingEdges);
}
[Fact]
public void Compare_DifferentVerdict_ReturnsDiff()
{
// Arrange
var original = CreateTestSlice();
var modified = original with
{
Verdict = original.Verdict with
{
Status = SliceVerdictStatus.Unreachable
}
};
// Act
var result = _computer.Compare(original, modified);
// Assert
Assert.False(result.Match);
Assert.NotNull(result.VerdictDiff);
Assert.Contains("Status:", result.VerdictDiff);
}
[Fact]
public void ComputeCacheKey_SameInputs_ReturnsSameKey()
{
// Arrange
var symbols = new[] { "func_a", "func_b" };
var entrypoints = new[] { "main" };
// Act
var key1 = SliceDiffComputer.ComputeCacheKey("scan1", "CVE-2024-1234", symbols, entrypoints, null);
var key2 = SliceDiffComputer.ComputeCacheKey("scan1", "CVE-2024-1234", symbols, entrypoints, null);
// Assert
Assert.Equal(key1, key2);
}
[Fact]
public void ComputeCacheKey_DifferentInputs_ReturnsDifferentKey()
{
// Arrange
var symbols = new[] { "func_a", "func_b" };
// Act
var key1 = SliceDiffComputer.ComputeCacheKey("scan1", "CVE-2024-1234", symbols, null, null);
var key2 = SliceDiffComputer.ComputeCacheKey("scan2", "CVE-2024-1234", symbols, null, null);
// Assert
Assert.NotEqual(key1, key2);
}
[Fact]
public void ToSummary_MatchingSlices_ReturnsMatchMessage()
{
// Arrange
var result = new SliceDiffResult { Match = true };
// Act
var summary = result.ToSummary();
// Assert
Assert.Contains("match exactly", summary);
}
[Fact]
public void ToSummary_DifferingSlices_ReturnsDetailedDiff()
{
// Arrange
var result = new SliceDiffResult
{
Match = false,
MissingNodes = ImmutableArray.Create("node1", "node2"),
ExtraEdges = ImmutableArray.Create("edge1"),
VerdictDiff = "Status: reachable -> unreachable"
};
// Act
var summary = result.ToSummary();
// Assert
Assert.Contains("Missing nodes", summary);
Assert.Contains("Extra edges", summary);
Assert.Contains("Verdict changed", summary);
}
private static ReachabilitySlice CreateTestSlice()
{
return new ReachabilitySlice
{
Inputs = new SliceInputs
{
GraphDigest = "sha256:graph123"
},
Query = new SliceQuery
{
CveId = "CVE-2024-1234",
TargetSymbols = ImmutableArray.Create("vulnerable_func"),
Entrypoints = ImmutableArray.Create("main")
},
Subgraph = new SliceSubgraph
{
Nodes = ImmutableArray.Create(
new SliceNode { Id = "main", Symbol = "main", Kind = SliceNodeKind.Entrypoint },
new SliceNode { Id = "vuln", Symbol = "vulnerable_func", Kind = SliceNodeKind.Target }
),
Edges = ImmutableArray.Create(
new SliceEdge { From = "main", To = "vuln", Kind = SliceEdgeKind.Direct, Confidence = 1.0 }
)
},
Verdict = new SliceVerdict
{
Status = SliceVerdictStatus.Reachable,
Confidence = 0.95
},
Manifest = new Scanner.Core.ScanManifest()
};
}
}
/// <summary>
/// Unit tests for SliceCache.
/// </summary>
public sealed class SliceCacheTests
{
[Fact]
public void TryGet_EmptyCache_ReturnsFalse()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions());
using var cache = new SliceCache(options);
// Act
var found = cache.TryGet("nonexistent", out var entry);
// Assert
Assert.False(found);
Assert.Null(entry);
}
[Fact]
public void Set_ThenGet_ReturnsEntry()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions());
using var cache = new SliceCache(options);
var slice = CreateTestSlice();
// Act
cache.Set("key1", slice, "sha256:abc123");
var found = cache.TryGet("key1", out var entry);
// Assert
Assert.True(found);
Assert.NotNull(entry);
Assert.Equal("sha256:abc123", entry.Digest);
}
[Fact]
public void TryGet_IncrementsCacheStats()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions());
using var cache = new SliceCache(options);
var slice = CreateTestSlice();
cache.Set("key1", slice, "sha256:abc123");
// Act
cache.TryGet("key1", out _); // hit
cache.TryGet("missing", out _); // miss
var stats = cache.GetStats();
// Assert
Assert.Equal(1, stats.HitCount);
Assert.Equal(1, stats.MissCount);
Assert.Equal(0.5, stats.HitRate);
}
[Fact]
public void Clear_RemovesAllEntries()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions());
using var cache = new SliceCache(options);
var slice = CreateTestSlice();
cache.Set("key1", slice, "sha256:abc123");
cache.Set("key2", slice, "sha256:def456");
// Act
cache.Clear();
var stats = cache.GetStats();
// Assert
Assert.Equal(0, stats.ItemCount);
}
[Fact]
public void Invalidate_RemovesSpecificEntry()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions());
using var cache = new SliceCache(options);
var slice = CreateTestSlice();
cache.Set("key1", slice, "sha256:abc123");
cache.Set("key2", slice, "sha256:def456");
// Act
cache.Invalidate("key1");
// Assert
Assert.False(cache.TryGet("key1", out _));
Assert.True(cache.TryGet("key2", out _));
}
[Fact]
public void Disabled_NeverCaches()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions { Enabled = false });
using var cache = new SliceCache(options);
var slice = CreateTestSlice();
// Act
cache.Set("key1", slice, "sha256:abc123");
var found = cache.TryGet("key1", out _);
// Assert
Assert.False(found);
}
private static ReachabilitySlice CreateTestSlice()
{
return new ReachabilitySlice
{
Inputs = new SliceInputs { GraphDigest = "sha256:graph123" },
Query = new SliceQuery(),
Subgraph = new SliceSubgraph(),
Verdict = new SliceVerdict { Status = SliceVerdictStatus.Unknown, Confidence = 0.0 },
Manifest = new Scanner.Core.ScanManifest()
};
}
}