Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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" }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class TriageQueryPerformanceTests : IAsyncLifetime
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_context != null)
|
||||
{
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class TriageSchemaIntegrationTests : IAsyncLifetime
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_context != null)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user