save work
This commit is contained in:
@@ -34,6 +34,13 @@ public sealed class PeBuilder
|
||||
private readonly List<PeImportSpec> _delayImports = [];
|
||||
private string? _manifestXml;
|
||||
private bool _embedManifestAsResource;
|
||||
private readonly Dictionary<string, string> _versionInfo = new(StringComparer.Ordinal);
|
||||
private readonly List<string> _exports = [];
|
||||
private Guid? _codeViewGuid;
|
||||
private int _codeViewAge;
|
||||
private string? _codeViewPdbPath;
|
||||
private uint? _richXorKey;
|
||||
private readonly List<PeCompilerHint> _richHeaderHints = [];
|
||||
|
||||
#region Configuration
|
||||
|
||||
@@ -72,6 +79,89 @@ public sealed class PeBuilder
|
||||
|
||||
#endregion
|
||||
|
||||
#region Golden Fixture Extensions
|
||||
|
||||
/// <summary>
|
||||
/// Adds a CodeView (RSDS/PDB70) debug record to the fixture.
|
||||
/// </summary>
|
||||
public PeBuilder WithCodeViewDebugInfo(Guid guid, int age, string pdbPath)
|
||||
{
|
||||
_codeViewGuid = guid;
|
||||
_codeViewAge = age;
|
||||
_codeViewPdbPath = pdbPath ?? throw new ArgumentNullException(nameof(pdbPath));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a simplified Rich header block to the DOS stub.
|
||||
/// </summary>
|
||||
public PeBuilder WithRichHeader(uint xorKey, params PeCompilerHint[] hints)
|
||||
{
|
||||
_richXorKey = xorKey;
|
||||
_richHeaderHints.Clear();
|
||||
|
||||
if (hints is not null)
|
||||
{
|
||||
_richHeaderHints.AddRange(hints);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds simplified version information strings into the resource section.
|
||||
/// </summary>
|
||||
public PeBuilder WithVersionInfo(
|
||||
string? productVersion = null,
|
||||
string? fileVersion = null,
|
||||
string? companyName = null,
|
||||
string? productName = null,
|
||||
string? originalFilename = null)
|
||||
{
|
||||
_versionInfo.Clear();
|
||||
|
||||
AddVersionString("ProductVersion", productVersion);
|
||||
AddVersionString("FileVersion", fileVersion);
|
||||
AddVersionString("CompanyName", companyName);
|
||||
AddVersionString("ProductName", productName);
|
||||
AddVersionString("OriginalFilename", originalFilename);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds PE export names to the fixture.
|
||||
/// </summary>
|
||||
public PeBuilder WithExports(params string[] exports)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exports);
|
||||
|
||||
_exports.Clear();
|
||||
foreach (var export in exports)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(export))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_exports.Add(export.Trim());
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private void AddVersionString(string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_versionInfo[key] = value.Trim();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Imports
|
||||
|
||||
/// <summary>
|
||||
@@ -168,10 +258,17 @@ public sealed class PeBuilder
|
||||
const int optionalHeaderSize = 0xF0; // PE32+ optional header
|
||||
const int dataDirectoryCount = 16;
|
||||
|
||||
var includeResourceSection = (_manifestXml != null && _embedManifestAsResource) || _versionInfo.Count > 0;
|
||||
var includeExportSection = _exports.Count > 0;
|
||||
var includeDebugSection = _codeViewGuid.HasValue;
|
||||
var includeRichHeader = _richXorKey.HasValue && _richHeaderHints.Count > 0;
|
||||
|
||||
var numberOfSections = 1; // .text
|
||||
if (_imports.Count > 0) numberOfSections++;
|
||||
if (_delayImports.Count > 0) numberOfSections++;
|
||||
if (_manifestXml != null && _embedManifestAsResource) numberOfSections++;
|
||||
if (includeResourceSection) numberOfSections++;
|
||||
if (includeExportSection) numberOfSections++;
|
||||
if (includeDebugSection) numberOfSections++;
|
||||
|
||||
var sectionHeadersOffset = peOffset + coffHeaderSize + optionalHeaderSize;
|
||||
var sectionHeaderSize = 40;
|
||||
@@ -227,22 +324,54 @@ public sealed class PeBuilder
|
||||
currentFileOffset += delayImportSize;
|
||||
}
|
||||
|
||||
// Resource section (for manifest)
|
||||
// Resource section (.rsrc) - used for manifest and/or version strings
|
||||
var resourceRva = 0;
|
||||
var resourceFileOffset = 0;
|
||||
var resourceSize = 0;
|
||||
byte[]? resourceData = null;
|
||||
|
||||
if (_manifestXml != null && _embedManifestAsResource)
|
||||
if (includeResourceSection)
|
||||
{
|
||||
resourceRva = currentRva;
|
||||
resourceFileOffset = currentFileOffset;
|
||||
resourceData = BuildResourceSection(_manifestXml, resourceRva);
|
||||
resourceData = BuildResourceSectionData(resourceRva);
|
||||
resourceSize = BinaryBufferWriter.AlignTo(resourceData.Length, 0x200);
|
||||
currentRva += 0x1000;
|
||||
currentFileOffset += resourceSize;
|
||||
}
|
||||
|
||||
// Export section (.edata)
|
||||
var exportRva = 0;
|
||||
var exportFileOffset = 0;
|
||||
var exportSize = 0;
|
||||
byte[]? exportData = null;
|
||||
|
||||
if (includeExportSection)
|
||||
{
|
||||
exportRva = currentRva;
|
||||
exportFileOffset = currentFileOffset;
|
||||
exportData = BuildExportSection(_exports, exportRva);
|
||||
exportSize = BinaryBufferWriter.AlignTo(exportData.Length, 0x200);
|
||||
currentRva += 0x1000;
|
||||
currentFileOffset += exportSize;
|
||||
}
|
||||
|
||||
// Debug section (.debug)
|
||||
var debugDirRva = 0;
|
||||
var debugFileOffset = 0;
|
||||
var debugSize = 0;
|
||||
byte[]? debugData = null;
|
||||
|
||||
if (includeDebugSection)
|
||||
{
|
||||
debugDirRva = currentRva;
|
||||
debugFileOffset = currentFileOffset;
|
||||
debugData = BuildDebugSection(debugFileOffset);
|
||||
debugSize = BinaryBufferWriter.AlignTo(debugData.Length, 0x200);
|
||||
currentRva += 0x1000;
|
||||
currentFileOffset += debugSize;
|
||||
}
|
||||
|
||||
var totalSize = currentFileOffset;
|
||||
var buffer = new byte[totalSize];
|
||||
|
||||
@@ -251,6 +380,11 @@ public sealed class PeBuilder
|
||||
buffer[1] = (byte)'Z';
|
||||
BinaryBufferWriter.WriteU32LE(buffer, 0x3C, (uint)peOffset);
|
||||
|
||||
if (includeRichHeader)
|
||||
{
|
||||
WriteRichHeader(buffer, peOffset);
|
||||
}
|
||||
|
||||
// PE signature
|
||||
buffer[peOffset] = (byte)'P';
|
||||
buffer[peOffset + 1] = (byte)'E';
|
||||
@@ -284,6 +418,13 @@ public sealed class PeBuilder
|
||||
// Data directories (at offset 112 for PE32+)
|
||||
var dataDirOffset = optOffset + 112;
|
||||
|
||||
// Export directory (entry 0)
|
||||
if (exportData != null)
|
||||
{
|
||||
BinaryBufferWriter.WriteU32LE(buffer, dataDirOffset + 0, (uint)exportRva);
|
||||
BinaryBufferWriter.WriteU32LE(buffer, dataDirOffset + 4, (uint)exportData.Length);
|
||||
}
|
||||
|
||||
// Import directory (entry 1)
|
||||
if (_imports.Count > 0)
|
||||
{
|
||||
@@ -298,6 +439,13 @@ public sealed class PeBuilder
|
||||
BinaryBufferWriter.WriteU32LE(buffer, dataDirOffset + 20, (uint)resourceData.Length);
|
||||
}
|
||||
|
||||
// Debug directory (entry 6)
|
||||
if (debugData != null)
|
||||
{
|
||||
BinaryBufferWriter.WriteU32LE(buffer, dataDirOffset + 48, (uint)debugDirRva);
|
||||
BinaryBufferWriter.WriteU32LE(buffer, dataDirOffset + 52, 28); // 1 entry * 28 bytes
|
||||
}
|
||||
|
||||
// Delay import directory (entry 13)
|
||||
if (_delayImports.Count > 0)
|
||||
{
|
||||
@@ -338,6 +486,22 @@ public sealed class PeBuilder
|
||||
sectionIndex++;
|
||||
}
|
||||
|
||||
// .edata section
|
||||
if (exportData != null)
|
||||
{
|
||||
WriteSectionHeader(buffer, shOffset, ".edata", exportRva, exportSize, exportFileOffset);
|
||||
shOffset += sectionHeaderSize;
|
||||
sectionIndex++;
|
||||
}
|
||||
|
||||
// .debug section
|
||||
if (debugData != null)
|
||||
{
|
||||
WriteSectionHeader(buffer, shOffset, ".debug", debugDirRva, debugSize, debugFileOffset);
|
||||
shOffset += sectionHeaderSize;
|
||||
sectionIndex++;
|
||||
}
|
||||
|
||||
// Write .text section (with manifest if not as resource)
|
||||
if (textManifest != null)
|
||||
{
|
||||
@@ -362,6 +526,18 @@ public sealed class PeBuilder
|
||||
resourceData.CopyTo(buffer, resourceFileOffset);
|
||||
}
|
||||
|
||||
// Write export section
|
||||
if (exportData != null)
|
||||
{
|
||||
exportData.CopyTo(buffer, exportFileOffset);
|
||||
}
|
||||
|
||||
// Write debug section
|
||||
if (debugData != null)
|
||||
{
|
||||
debugData.CopyTo(buffer, debugFileOffset);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
@@ -477,6 +653,202 @@ public sealed class PeBuilder
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private byte[] BuildDebugSection(int sectionFileOffset)
|
||||
{
|
||||
if (!_codeViewGuid.HasValue || string.IsNullOrWhiteSpace(_codeViewPdbPath))
|
||||
{
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
// Layout: [IMAGE_DEBUG_DIRECTORY (28 bytes)] [padding] [RSDS record]
|
||||
const int recordOffset = 0x40;
|
||||
|
||||
var pdbBytes = Encoding.UTF8.GetBytes(_codeViewPdbPath!);
|
||||
var recordSize = 4 + 16 + 4 + pdbBytes.Length + 1;
|
||||
|
||||
var buffer = new byte[recordOffset + recordSize];
|
||||
|
||||
// IMAGE_DEBUG_DIRECTORY fields used by parser:
|
||||
// offset +12: Type (CODEVIEW=2)
|
||||
// offset +16: SizeOfData
|
||||
// offset +24: PointerToRawData (file offset)
|
||||
BinaryBufferWriter.WriteU32LE(buffer, 12, 2);
|
||||
BinaryBufferWriter.WriteU32LE(buffer, 16, (uint)recordSize);
|
||||
BinaryBufferWriter.WriteU32LE(buffer, 24, (uint)(sectionFileOffset + recordOffset));
|
||||
|
||||
// RSDS (PDB70) record
|
||||
BinaryBufferWriter.WriteU32LE(buffer, recordOffset + 0, 0x53445352); // "RSDS"
|
||||
_codeViewGuid.Value.ToByteArray().CopyTo(buffer, recordOffset + 4);
|
||||
BinaryBufferWriter.WriteU32LE(buffer, recordOffset + 20, (uint)_codeViewAge);
|
||||
pdbBytes.CopyTo(buffer, recordOffset + 24);
|
||||
buffer[recordOffset + 24 + pdbBytes.Length] = 0;
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static byte[] BuildExportSection(IReadOnlyList<string> exports, int sectionRva)
|
||||
{
|
||||
if (exports.Count == 0)
|
||||
{
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
// Layout: [IMAGE_EXPORT_DIRECTORY (40 bytes)] [names RVA array] [name strings...]
|
||||
const int exportDirectorySize = 40;
|
||||
var namesArrayOffset = exportDirectorySize;
|
||||
var namesArraySize = exports.Count * 4;
|
||||
var stringsOffset = namesArrayOffset + namesArraySize;
|
||||
|
||||
var strings = exports
|
||||
.Select(name => Encoding.ASCII.GetBytes(name + "\0"))
|
||||
.ToArray();
|
||||
|
||||
var totalSize = stringsOffset + strings.Sum(s => s.Length);
|
||||
var buffer = new byte[totalSize];
|
||||
|
||||
// IMAGE_EXPORT_DIRECTORY fields used by parser:
|
||||
// offset 24: NumberOfNames
|
||||
// offset 32: AddressOfNames (RVA)
|
||||
BinaryBufferWriter.WriteU32LE(buffer, 24, (uint)exports.Count);
|
||||
BinaryBufferWriter.WriteU32LE(buffer, 32, (uint)(sectionRva + namesArrayOffset));
|
||||
|
||||
// Write name RVAs + strings
|
||||
var currentStringOffset = stringsOffset;
|
||||
for (var i = 0; i < exports.Count; i++)
|
||||
{
|
||||
var nameRva = sectionRva + currentStringOffset;
|
||||
BinaryBufferWriter.WriteU32LE(buffer, namesArrayOffset + i * 4, (uint)nameRva);
|
||||
|
||||
var bytes = strings[i];
|
||||
bytes.CopyTo(buffer, currentStringOffset);
|
||||
currentStringOffset += bytes.Length;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private byte[] BuildResourceSectionData(int sectionRva)
|
||||
{
|
||||
byte[]? baseResource = null;
|
||||
|
||||
if (_manifestXml != null && _embedManifestAsResource)
|
||||
{
|
||||
baseResource = BuildResourceSection(_manifestXml, sectionRva);
|
||||
}
|
||||
|
||||
byte[]? versionBlob = null;
|
||||
if (_versionInfo.Count > 0)
|
||||
{
|
||||
versionBlob = BuildVersionInfoBlob(_versionInfo);
|
||||
}
|
||||
|
||||
if (baseResource is null || baseResource.Length == 0)
|
||||
{
|
||||
return versionBlob ?? Array.Empty<byte>();
|
||||
}
|
||||
|
||||
if (versionBlob is null || versionBlob.Length == 0)
|
||||
{
|
||||
return baseResource;
|
||||
}
|
||||
|
||||
var combined = new byte[baseResource.Length + versionBlob.Length];
|
||||
baseResource.CopyTo(combined, 0);
|
||||
versionBlob.CopyTo(combined, baseResource.Length);
|
||||
return combined;
|
||||
}
|
||||
|
||||
private static byte[] BuildVersionInfoBlob(IReadOnlyDictionary<string, string> strings)
|
||||
{
|
||||
// The production parser scans for these wide strings and reads the following null-terminated wide-string value.
|
||||
// Keep layout simple but aligned to the 4-byte boundary rules in PeReader.ParseVersionStrings().
|
||||
var buffer = new List<byte>(512);
|
||||
|
||||
buffer.AddRange(new byte[32]); // padding
|
||||
buffer.AddRange(Encoding.Unicode.GetBytes("VS_VERSION_INFO"));
|
||||
|
||||
// Null terminator (wide)
|
||||
buffer.Add(0);
|
||||
buffer.Add(0);
|
||||
|
||||
var orderedKeys = new[] { "ProductVersion", "FileVersion", "CompanyName", "ProductName", "OriginalFilename" };
|
||||
foreach (var key in orderedKeys)
|
||||
{
|
||||
if (!strings.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer.AddRange(Encoding.Unicode.GetBytes(key));
|
||||
buffer.Add(0);
|
||||
buffer.Add(0);
|
||||
|
||||
while (buffer.Count % 4 != 0)
|
||||
{
|
||||
buffer.Add(0);
|
||||
}
|
||||
|
||||
buffer.AddRange(Encoding.Unicode.GetBytes(value));
|
||||
buffer.Add(0);
|
||||
buffer.Add(0);
|
||||
|
||||
while (buffer.Count % 4 != 0)
|
||||
{
|
||||
buffer.Add(0);
|
||||
}
|
||||
}
|
||||
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private void WriteRichHeader(byte[] buffer, int peHeaderOffset)
|
||||
{
|
||||
if (!_richXorKey.HasValue || _richHeaderHints.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var xorKey = _richXorKey.Value;
|
||||
|
||||
// Fixed layout inside DOS stub:
|
||||
// 0x40: DanS^key
|
||||
// 0x44..0x4F: padding
|
||||
// 0x50..0x6F: 4 entries (8 bytes each)
|
||||
// 0x70: Rich marker
|
||||
// 0x74: key
|
||||
const int dansOffset = 0x40;
|
||||
const int entriesOffset = 0x50;
|
||||
const int richOffset = 0x70;
|
||||
|
||||
if (peHeaderOffset < richOffset + 8)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BinaryBufferWriter.WriteU32LE(buffer, dansOffset, 0x536E6144 ^ xorKey); // "DanS" ^ key
|
||||
|
||||
// Write entries (up to 4); empty entries use raw==key so decoded value becomes 0.
|
||||
var entryIndex = 0;
|
||||
for (; entryIndex < Math.Min(4, _richHeaderHints.Count); entryIndex++)
|
||||
{
|
||||
var hint = _richHeaderHints[entryIndex];
|
||||
var compId = (uint)((hint.ToolVersion << 16) | hint.ToolId);
|
||||
var useCount = (uint)hint.UseCount;
|
||||
|
||||
BinaryBufferWriter.WriteU32LE(buffer, entriesOffset + entryIndex * 8, compId ^ xorKey);
|
||||
BinaryBufferWriter.WriteU32LE(buffer, entriesOffset + entryIndex * 8 + 4, useCount ^ xorKey);
|
||||
}
|
||||
|
||||
for (; entryIndex < 4; entryIndex++)
|
||||
{
|
||||
BinaryBufferWriter.WriteU32LE(buffer, entriesOffset + entryIndex * 8, xorKey);
|
||||
BinaryBufferWriter.WriteU32LE(buffer, entriesOffset + entryIndex * 8 + 4, xorKey);
|
||||
}
|
||||
|
||||
BinaryBufferWriter.WriteU32LE(buffer, richOffset, 0x68636952); // "Rich"
|
||||
BinaryBufferWriter.WriteU32LE(buffer, richOffset + 4, xorKey);
|
||||
}
|
||||
|
||||
private static void WriteSectionHeader(byte[] buffer, int offset, string name, int rva, int size, int fileOffset)
|
||||
{
|
||||
var nameBytes = Encoding.ASCII.GetBytes(name.PadRight(8, '\0'));
|
||||
@@ -653,5 +1025,47 @@ public sealed class PeBuilder
|
||||
.WithSubsystem(PeSubsystem.WindowsGui)
|
||||
.WithMachine(PeMachine.I386);
|
||||
|
||||
/// <summary>
|
||||
/// Toolchain-like fixture: MSVC-style (Rich header + CodeView debug + version strings).
|
||||
/// </summary>
|
||||
public static PeBuilder MsvcConsole64() => Console64()
|
||||
.WithRichHeader(
|
||||
xorKey: 0xA5A5A5A5,
|
||||
new PeCompilerHint(ToolId: 0x0102, ToolVersion: 0x000E, UseCount: 3),
|
||||
new PeCompilerHint(ToolId: 0x0101, ToolVersion: 0x000E, UseCount: 1))
|
||||
.WithCodeViewDebugInfo(
|
||||
guid: new Guid("00112233-4455-6677-8899-aabbccddeeff"),
|
||||
age: 42,
|
||||
pdbPath: "msvc-demo.pdb")
|
||||
.WithVersionInfo(
|
||||
productVersion: "1.2.3",
|
||||
fileVersion: "1.2.3.4",
|
||||
companyName: "StellaOps",
|
||||
productName: "StellaOps Demo",
|
||||
originalFilename: "msvc-demo.exe")
|
||||
.WithExports("ExportOne", "ExportTwo");
|
||||
|
||||
/// <summary>
|
||||
/// Toolchain-like fixture: MinGW-style (no Rich header, no CodeView in this simplified fixture).
|
||||
/// </summary>
|
||||
public static PeBuilder MingwConsole64() => Console64()
|
||||
.WithExports("mingw_export");
|
||||
|
||||
/// <summary>
|
||||
/// Toolchain-like fixture: Clang/LLVM-style (CodeView debug, no Rich header in this simplified fixture).
|
||||
/// </summary>
|
||||
public static PeBuilder ClangConsole64() => Console64()
|
||||
.WithCodeViewDebugInfo(
|
||||
guid: new Guid("11223344-5566-7788-9900-aabbccddeeff"),
|
||||
age: 7,
|
||||
pdbPath: "clang-demo.pdb")
|
||||
.WithVersionInfo(
|
||||
productVersion: "9.9.9",
|
||||
fileVersion: "9.9.9.9",
|
||||
companyName: "LLVM",
|
||||
productName: "Clang Demo",
|
||||
originalFilename: "clang-demo.exe")
|
||||
.WithExports("clang_export");
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.Analyzers.Native.Index;
|
||||
using StellaOps.Scanner.ProofSpine;
|
||||
using StellaOps.Scanner.ProofSpine.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native.Index.Tests;
|
||||
|
||||
public sealed class OfflineBuildIdIndexSignatureTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public OfflineBuildIdIndexSignatureTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"buildid-sig-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_RequiresTrustedDsseSignature_WhenEnabled()
|
||||
{
|
||||
var indexPath = Path.Combine(_tempDir, "index.ndjson");
|
||||
await File.WriteAllTextAsync(indexPath, """
|
||||
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31","distro":"debian","confidence":"exact","indexed_at":"2025-01-15T10:00:00Z"}
|
||||
""");
|
||||
|
||||
var signaturePath = Path.Combine(_tempDir, "index.ndjson.dsse.json");
|
||||
await File.WriteAllTextAsync(signaturePath, CreateDsseSignature(indexPath, expectedSha256: ComputeSha256Hex(indexPath)));
|
||||
|
||||
var dsseService = CreateTrustedDsseService(keyId: "buildid-index-test-key", secretBase64: Convert.ToBase64String("supersecret-supersecret-supersecret"u8.ToArray()));
|
||||
|
||||
var options = Options.Create(new BuildIdIndexOptions
|
||||
{
|
||||
IndexPath = indexPath,
|
||||
SignaturePath = signaturePath,
|
||||
RequireSignature = true,
|
||||
});
|
||||
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, dsseService);
|
||||
await index.LoadAsync();
|
||||
|
||||
Assert.True(index.IsLoaded);
|
||||
Assert.Equal(1, index.Count);
|
||||
|
||||
var result = await index.LookupAsync("gnu-build-id:abc123");
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("pkg:deb/debian/libc6@2.31", result.Purl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_RefusesToLoadIndex_WhenDigestDoesNotMatchSignaturePayload()
|
||||
{
|
||||
var indexPath = Path.Combine(_tempDir, "index.ndjson");
|
||||
await File.WriteAllTextAsync(indexPath, """
|
||||
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
|
||||
""");
|
||||
|
||||
var signaturePath = Path.Combine(_tempDir, "index.ndjson.dsse.json");
|
||||
await File.WriteAllTextAsync(signaturePath, CreateDsseSignature(indexPath, expectedSha256: "deadbeef"));
|
||||
|
||||
var dsseService = CreateTrustedDsseService(keyId: "buildid-index-test-key", secretBase64: Convert.ToBase64String("supersecret-supersecret-supersecret"u8.ToArray()));
|
||||
|
||||
var options = Options.Create(new BuildIdIndexOptions
|
||||
{
|
||||
IndexPath = indexPath,
|
||||
SignaturePath = signaturePath,
|
||||
RequireSignature = true,
|
||||
});
|
||||
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, dsseService);
|
||||
await index.LoadAsync();
|
||||
|
||||
Assert.True(index.IsLoaded);
|
||||
Assert.Equal(0, index.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_RefusesToLoadIndex_WhenSignatureFileMissing()
|
||||
{
|
||||
var indexPath = Path.Combine(_tempDir, "index.ndjson");
|
||||
await File.WriteAllTextAsync(indexPath, """
|
||||
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
|
||||
""");
|
||||
|
||||
var signaturePath = Path.Combine(_tempDir, "missing.dsse.json");
|
||||
var dsseService = CreateTrustedDsseService(keyId: "buildid-index-test-key", secretBase64: Convert.ToBase64String("supersecret-supersecret-supersecret"u8.ToArray()));
|
||||
|
||||
var options = Options.Create(new BuildIdIndexOptions
|
||||
{
|
||||
IndexPath = indexPath,
|
||||
SignaturePath = signaturePath,
|
||||
RequireSignature = true,
|
||||
});
|
||||
|
||||
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance, dsseService);
|
||||
await index.LoadAsync();
|
||||
|
||||
Assert.True(index.IsLoaded);
|
||||
Assert.Equal(0, index.Count);
|
||||
}
|
||||
|
||||
private static string CreateDsseSignature(string indexPath, string expectedSha256)
|
||||
{
|
||||
var dsseService = CreateTrustedDsseService(keyId: "buildid-index-test-key", secretBase64: Convert.ToBase64String("supersecret-supersecret-supersecret"u8.ToArray()));
|
||||
|
||||
var payload = new
|
||||
{
|
||||
Schema = "stellaops.buildid.index.signature@v1",
|
||||
IndexSha256 = $"sha256:{expectedSha256}",
|
||||
IndexPath = Path.GetFileName(indexPath),
|
||||
};
|
||||
|
||||
var envelope = dsseService.SignAsync(
|
||||
payload,
|
||||
payloadType: "stellaops.buildid.index.signature@v1",
|
||||
cryptoProfile: new TestCryptoProfile("buildid-index-test-key", "hs256"))
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
|
||||
return JsonSerializer.Serialize(envelope);
|
||||
}
|
||||
|
||||
private static IDsseSigningService CreateTrustedDsseService(string keyId, string secretBase64)
|
||||
{
|
||||
var options = Options.Create(new ProofSpineDsseSigningOptions
|
||||
{
|
||||
Mode = "hmac",
|
||||
KeyId = keyId,
|
||||
Algorithm = "hs256",
|
||||
SecretBase64 = secretBase64,
|
||||
AllowDeterministicFallback = false,
|
||||
});
|
||||
|
||||
return new HmacDsseSigningService(
|
||||
options,
|
||||
DefaultCryptoHmac.CreateForTests(),
|
||||
DefaultCryptoHash.CreateForTests());
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string path)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
using var stream = File.OpenRead(path);
|
||||
var hash = sha256.ComputeHash(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed record TestCryptoProfile(string KeyId, string Algorithm) : ICryptoProfile;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Xunit;
|
||||
|
||||
@@ -14,13 +15,80 @@ public sealed class MachOReaderTests
|
||||
/// <summary>
|
||||
/// Builds a minimal 64-bit Mach-O binary for testing.
|
||||
/// </summary>
|
||||
private static byte[] BuildExportsTrie(IReadOnlyList<string> exports)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exports);
|
||||
|
||||
if (exports.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// Minimal exports trie:
|
||||
// - Root node: terminalSize=0, childCount=N, each edge is a full symbol name.
|
||||
// - Child node: terminalSize=1 (dummy terminal info byte), childCount=0.
|
||||
// Offsets are relative to the start of the trie and are kept < 128 so ULEB128 is 1 byte.
|
||||
var ordered = exports
|
||||
.Where(static e => !string.IsNullOrWhiteSpace(e))
|
||||
.Select(static e => e.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static e => e, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var rootSize = 2; // terminalSize(0) + childCount
|
||||
foreach (var edge in ordered)
|
||||
{
|
||||
rootSize += Encoding.UTF8.GetByteCount(edge) + 1; // edge + null
|
||||
rootSize += 1; // child offset ULEB128 (1 byte)
|
||||
}
|
||||
|
||||
const int childNodeSize = 3; // terminalSize(1) + terminalByte + childCount(0)
|
||||
var totalSize = rootSize + (ordered.Length * childNodeSize);
|
||||
|
||||
if (totalSize >= 128)
|
||||
{
|
||||
throw new InvalidOperationException("Exports trie fixture is too large for 1-byte ULEB128 offsets.");
|
||||
}
|
||||
|
||||
var trie = new byte[totalSize];
|
||||
|
||||
var cursor = 0;
|
||||
trie[cursor++] = 0x00; // terminalSize=0
|
||||
trie[cursor++] = (byte)ordered.Length;
|
||||
|
||||
var childOffset = rootSize;
|
||||
foreach (var edge in ordered)
|
||||
{
|
||||
var edgeBytes = Encoding.UTF8.GetBytes(edge);
|
||||
Array.Copy(edgeBytes, 0, trie, cursor, edgeBytes.Length);
|
||||
cursor += edgeBytes.Length;
|
||||
trie[cursor++] = 0x00; // null terminator
|
||||
trie[cursor++] = (byte)childOffset; // child node offset (ULEB128, 1 byte)
|
||||
childOffset += childNodeSize;
|
||||
}
|
||||
|
||||
// Child nodes (one per export)
|
||||
var nodeCursor = rootSize;
|
||||
for (var i = 0; i < ordered.Length; i++)
|
||||
{
|
||||
trie[nodeCursor++] = 0x01; // terminalSize=1
|
||||
trie[nodeCursor++] = 0x00; // dummy terminal data
|
||||
trie[nodeCursor++] = 0x00; // childCount=0
|
||||
}
|
||||
|
||||
return trie;
|
||||
}
|
||||
|
||||
private static byte[] BuildMachO64(
|
||||
int cpuType = 0x0100000C, // arm64
|
||||
int cpuSubtype = 0,
|
||||
byte[]? uuid = null,
|
||||
MachOPlatform platform = MachOPlatform.MacOS,
|
||||
uint minOs = 0x000E0000, // 14.0
|
||||
uint sdk = 0x000E0000)
|
||||
uint sdk = 0x000E0000,
|
||||
IReadOnlyList<string>? exports = null,
|
||||
bool exportsViaDyldInfoOnly = true,
|
||||
byte[]? codeSignatureBlob = null)
|
||||
{
|
||||
var loadCommands = new List<byte[]>();
|
||||
|
||||
@@ -44,6 +112,44 @@ public sealed class MachOReaderTests
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buildVersionCmd.AsSpan(20), 0); // ntools
|
||||
loadCommands.Add(buildVersionCmd);
|
||||
|
||||
byte[]? exportsTrieBytes = null;
|
||||
byte[]? exportsCommand = null;
|
||||
|
||||
if (exports is { Count: > 0 })
|
||||
{
|
||||
exportsTrieBytes = BuildExportsTrie(exports);
|
||||
|
||||
if (exportsViaDyldInfoOnly)
|
||||
{
|
||||
// dyld_info_command (LC_DYLD_INFO_ONLY) is 48 bytes total.
|
||||
exportsCommand = new byte[48];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(exportsCommand, 0x80000022); // LC_DYLD_INFO_ONLY
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(exportsCommand.AsSpan(4), 48); // cmdsize
|
||||
// export_off/export_size patched after sizeOfCmds is known (offsets 40/44)
|
||||
}
|
||||
else
|
||||
{
|
||||
// linkedit_data_command (LC_DYLD_EXPORTS_TRIE) is 16 bytes.
|
||||
exportsCommand = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(exportsCommand, 0x80000033); // LC_DYLD_EXPORTS_TRIE
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(exportsCommand.AsSpan(4), 16); // cmdsize
|
||||
// dataoff/datasize patched after sizeOfCmds is known (offsets 8/12)
|
||||
}
|
||||
|
||||
loadCommands.Add(exportsCommand);
|
||||
}
|
||||
|
||||
byte[]? codeSignatureCommand = null;
|
||||
if (codeSignatureBlob is { Length: > 0 })
|
||||
{
|
||||
// linkedit_data_command (LC_CODE_SIGNATURE) is 16 bytes
|
||||
codeSignatureCommand = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(codeSignatureCommand, 0x1D); // LC_CODE_SIGNATURE
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(codeSignatureCommand.AsSpan(4), 16); // cmdsize
|
||||
// dataoff/datasize patched after sizeOfCmds is known (offsets 8/12)
|
||||
loadCommands.Add(codeSignatureCommand);
|
||||
}
|
||||
|
||||
var sizeOfCmds = loadCommands.Sum(c => c.Length);
|
||||
|
||||
// Build header (32 bytes for 64-bit)
|
||||
@@ -57,8 +163,39 @@ public sealed class MachOReaderTests
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(24), 0); // flags
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(28), 0); // reserved
|
||||
|
||||
// Patch linkedit offsets and append trailing data
|
||||
var dataOffset = 32 + sizeOfCmds;
|
||||
if (exportsTrieBytes is not null && exportsCommand is not null)
|
||||
{
|
||||
var exportOff = (uint)dataOffset;
|
||||
var exportSize = (uint)exportsTrieBytes.Length;
|
||||
dataOffset += exportsTrieBytes.Length;
|
||||
|
||||
if (exportsViaDyldInfoOnly)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(exportsCommand.AsSpan(40), exportOff);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(exportsCommand.AsSpan(44), exportSize);
|
||||
}
|
||||
else
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(exportsCommand.AsSpan(8), exportOff);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(exportsCommand.AsSpan(12), exportSize);
|
||||
}
|
||||
}
|
||||
|
||||
if (codeSignatureBlob is not null && codeSignatureCommand is not null)
|
||||
{
|
||||
var sigOff = (uint)dataOffset;
|
||||
var sigSize = (uint)codeSignatureBlob.Length;
|
||||
dataOffset += codeSignatureBlob.Length;
|
||||
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(codeSignatureCommand.AsSpan(8), sigOff);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(codeSignatureCommand.AsSpan(12), sigSize);
|
||||
}
|
||||
|
||||
// Combine
|
||||
var result = new byte[32 + sizeOfCmds];
|
||||
var trailingSize = (exportsTrieBytes?.Length ?? 0) + (codeSignatureBlob?.Length ?? 0);
|
||||
var result = new byte[32 + sizeOfCmds + trailingSize];
|
||||
Array.Copy(header, result, 32);
|
||||
var offset = 32;
|
||||
foreach (var cmd in loadCommands)
|
||||
@@ -67,6 +204,18 @@ public sealed class MachOReaderTests
|
||||
offset += cmd.Length;
|
||||
}
|
||||
|
||||
if (exportsTrieBytes is not null)
|
||||
{
|
||||
Array.Copy(exportsTrieBytes, 0, result, offset, exportsTrieBytes.Length);
|
||||
offset += exportsTrieBytes.Length;
|
||||
}
|
||||
|
||||
if (codeSignatureBlob is not null)
|
||||
{
|
||||
Array.Copy(codeSignatureBlob, 0, result, offset, codeSignatureBlob.Length);
|
||||
offset += codeSignatureBlob.Length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -156,6 +305,88 @@ public sealed class MachOReaderTests
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] BuildEmbeddedSignature(string signingId, string teamId, bool hardenedRuntime, params string[] entitlementKeys)
|
||||
{
|
||||
var flags = hardenedRuntime ? 0x00010000u : 0u;
|
||||
|
||||
var codeDirectory = BuildCodeDirectory(signingId, teamId, flags);
|
||||
var entitlements = BuildEntitlements(entitlementKeys);
|
||||
return BuildSuperBlob(codeDirectory, entitlements);
|
||||
}
|
||||
|
||||
private static byte[] BuildCodeDirectory(string signingId, string teamId, uint flags)
|
||||
{
|
||||
var signingIdBytes = Encoding.UTF8.GetBytes(signingId + "\0");
|
||||
var teamIdBytes = Encoding.UTF8.GetBytes(teamId + "\0");
|
||||
|
||||
const uint version = 0x00020200;
|
||||
const int headerSize = 56; // up to and including teamOffset field
|
||||
|
||||
var identOffset = (uint)headerSize;
|
||||
var teamOffset = identOffset + (uint)signingIdBytes.Length;
|
||||
var length = (uint)(teamOffset + (uint)teamIdBytes.Length);
|
||||
|
||||
var blob = new byte[length];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(blob, 0xFADE0C02); // CSMAGIC_CODEDIRECTORY
|
||||
BinaryPrimitives.WriteUInt32BigEndian(blob.AsSpan(4), length);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(blob.AsSpan(8), version);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(blob.AsSpan(12), flags);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(blob.AsSpan(20), identOffset);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(blob.AsSpan(52), teamOffset);
|
||||
|
||||
signingIdBytes.CopyTo(blob, (int)identOffset);
|
||||
teamIdBytes.CopyTo(blob, (int)teamOffset);
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
private static byte[] BuildEntitlements(params string[] entitlementKeys)
|
||||
{
|
||||
var keys = entitlementKeys ?? Array.Empty<string>();
|
||||
|
||||
var keyXml = string.Concat(keys.Select(static key => $"<key>{key}</key><true/>"));
|
||||
var plistXml = $"<plist><dict>{keyXml}</dict></plist>";
|
||||
var plistBytes = Encoding.UTF8.GetBytes(plistXml);
|
||||
|
||||
var length = 8 + plistBytes.Length;
|
||||
var blob = new byte[length];
|
||||
|
||||
BinaryPrimitives.WriteUInt32BigEndian(blob, 0xFADE7171); // CSMAGIC_EMBEDDED_ENTITLEMENTS
|
||||
BinaryPrimitives.WriteUInt32BigEndian(blob.AsSpan(4), (uint)length);
|
||||
plistBytes.CopyTo(blob, 8);
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
private static byte[] BuildSuperBlob(byte[] codeDirectory, byte[] entitlements)
|
||||
{
|
||||
const int count = 2;
|
||||
var indexStart = 12;
|
||||
var indexSize = count * 8;
|
||||
|
||||
var cdOffset = indexStart + indexSize;
|
||||
var entOffset = cdOffset + codeDirectory.Length;
|
||||
var totalLength = entOffset + entitlements.Length;
|
||||
|
||||
var blob = new byte[totalLength];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(blob, 0xFADE0CC0); // CSMAGIC_EMBEDDED_SIGNATURE
|
||||
BinaryPrimitives.WriteUInt32BigEndian(blob.AsSpan(4), (uint)totalLength);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(blob.AsSpan(8), (uint)count);
|
||||
|
||||
// Index entry 0: CodeDirectory
|
||||
BinaryPrimitives.WriteUInt32BigEndian(blob.AsSpan(indexStart + 0), 0xFADE0C02);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(blob.AsSpan(indexStart + 4), (uint)cdOffset);
|
||||
|
||||
// Index entry 1: Entitlements
|
||||
BinaryPrimitives.WriteUInt32BigEndian(blob.AsSpan(indexStart + 8), 0xFADE7171);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(blob.AsSpan(indexStart + 12), (uint)entOffset);
|
||||
|
||||
codeDirectory.CopyTo(blob, cdOffset);
|
||||
entitlements.CopyTo(blob, entOffset);
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Magic Detection Tests
|
||||
@@ -249,6 +480,34 @@ public sealed class MachOReaderTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Export Trie Tests
|
||||
|
||||
[Fact]
|
||||
public void Parse_Extracts_Exports_From_LC_DYLD_INFO_ONLY()
|
||||
{
|
||||
var data = BuildMachO64(exports: new[] { "_main", "_printf" }, exportsViaDyldInfoOnly: true);
|
||||
using var stream = new MemoryStream(data);
|
||||
var result = MachOReader.Parse(stream, "/test/exports-dyld-info");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Identities);
|
||||
Assert.Equal(new[] { "_main", "_printf" }, result.Identities[0].Exports);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Extracts_Exports_From_LC_DYLD_EXPORTS_TRIE()
|
||||
{
|
||||
var data = BuildMachO64(exports: new[] { "_zeta", "_alpha" }, exportsViaDyldInfoOnly: false);
|
||||
using var stream = new MemoryStream(data);
|
||||
var result = MachOReader.Parse(stream, "/test/exports-trie");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Identities);
|
||||
Assert.Equal(new[] { "_alpha", "_zeta" }, result.Identities[0].Exports);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Platform Detection Tests
|
||||
|
||||
[Theory]
|
||||
@@ -304,6 +563,56 @@ public sealed class MachOReaderTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Code Signature Tests
|
||||
|
||||
[Fact]
|
||||
public void Parse_UnsignedBinary_HasNull_CodeSignature()
|
||||
{
|
||||
var data = BuildMachO64();
|
||||
using var stream = new MemoryStream(data);
|
||||
var result = MachOReader.Parse(stream, "/test/unsigned");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Identities);
|
||||
Assert.Null(result.Identities[0].CodeSignature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_SignedBinary_Extracts_SigningId_TeamId_CdHash_Entitlements_And_HardenedRuntime()
|
||||
{
|
||||
var signingId = "com.stellaops.demo";
|
||||
var teamId = "ABCDE12345";
|
||||
var hardenedRuntime = true;
|
||||
|
||||
var signature = BuildEmbeddedSignature(
|
||||
signingId,
|
||||
teamId,
|
||||
hardenedRuntime,
|
||||
"com.apple.security.cs.disable-library-validation",
|
||||
"com.apple.security.cs.allow-jit");
|
||||
|
||||
var codeDirectory = BuildCodeDirectory(signingId, teamId, flags: 0x00010000u);
|
||||
var expectedCdHash = Convert.ToHexStringLower(SHA256.HashData(codeDirectory));
|
||||
|
||||
var data = BuildMachO64(codeSignatureBlob: signature);
|
||||
using var stream = new MemoryStream(data);
|
||||
var result = MachOReader.Parse(stream, "/test/signed");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Identities);
|
||||
Assert.NotNull(result.Identities[0].CodeSignature);
|
||||
|
||||
var cs = result.Identities[0].CodeSignature!;
|
||||
Assert.Equal(teamId, cs.TeamId);
|
||||
Assert.Equal(signingId, cs.SigningId);
|
||||
Assert.Equal(expectedCdHash, cs.CdHash);
|
||||
Assert.True(cs.HasHardenedRuntime);
|
||||
Assert.Contains("com.apple.security.cs.disable-library-validation", cs.Entitlements);
|
||||
Assert.Contains("com.apple.security.cs.allow-jit", cs.Entitlements);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CPU Type Tests
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -214,6 +214,24 @@ public class PeReaderTests : NativeTestBase
|
||||
identity.RichHeaderHash.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryExtractIdentity_RichHeader_ExtractsCompilerHints()
|
||||
{
|
||||
// Arrange
|
||||
var pe = PeBuilder.MsvcConsole64().Build();
|
||||
|
||||
// Act
|
||||
var result = PeReader.TryExtractIdentity(pe, out var identity);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
identity.Should().NotBeNull();
|
||||
identity!.RichHeaderHash.Should().Be(0xA5A5A5A5);
|
||||
identity.CompilerHints.Should().ContainInOrder(
|
||||
new PeCompilerHint(ToolId: 0x0102, ToolVersion: 0x000E, UseCount: 3),
|
||||
new PeCompilerHint(ToolId: 0x0101, ToolVersion: 0x000E, UseCount: 1));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CodeView Debug Info
|
||||
@@ -235,6 +253,23 @@ public class PeReaderTests : NativeTestBase
|
||||
identity.PdbPath.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryExtractIdentity_CodeViewDebugInfo_ExtractsGuidAgeAndPdbPath()
|
||||
{
|
||||
// Arrange
|
||||
var pe = PeBuilder.MsvcConsole64().Build();
|
||||
|
||||
// Act
|
||||
var result = PeReader.TryExtractIdentity(pe, out var identity);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
identity.Should().NotBeNull();
|
||||
identity!.CodeViewGuid.Should().Be("00112233445566778899aabbccddeeff");
|
||||
identity.CodeViewAge.Should().Be(42);
|
||||
identity.PdbPath.Should().Be("msvc-demo.pdb");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Version Resources
|
||||
@@ -258,6 +293,63 @@ public class PeReaderTests : NativeTestBase
|
||||
identity.OriginalFilename.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryExtractIdentity_VersionResource_ExtractsStrings()
|
||||
{
|
||||
// Arrange
|
||||
var pe = PeBuilder.ClangConsole64().Build();
|
||||
|
||||
// Act
|
||||
var result = PeReader.TryExtractIdentity(pe, out var identity);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
identity.Should().NotBeNull();
|
||||
identity!.ProductVersion.Should().Be("9.9.9");
|
||||
identity.FileVersion.Should().Be("9.9.9.9");
|
||||
identity.CompanyName.Should().Be("LLVM");
|
||||
identity.ProductName.Should().Be("Clang Demo");
|
||||
identity.OriginalFilename.Should().Be("clang-demo.exe");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Golden Fixtures
|
||||
|
||||
[Fact]
|
||||
public void TryExtractIdentity_Exports_ExtractsExportNames()
|
||||
{
|
||||
// Arrange
|
||||
var pe = PeBuilder.MingwConsole64().Build();
|
||||
|
||||
// Act
|
||||
var result = PeReader.TryExtractIdentity(pe, out var identity);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
identity.Should().NotBeNull();
|
||||
identity!.Exports.Should().ContainSingle().Which.Should().Be("mingw_export");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryExtractIdentity_MingwFixture_HasNoRichOrCodeView()
|
||||
{
|
||||
// Arrange
|
||||
var pe = PeBuilder.MingwConsole64().Build();
|
||||
|
||||
// Act
|
||||
var result = PeReader.TryExtractIdentity(pe, out var identity);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
identity.Should().NotBeNull();
|
||||
identity!.RichHeaderHash.Should().BeNull();
|
||||
identity.CompilerHints.Should().BeEmpty();
|
||||
identity.CodeViewGuid.Should().BeNull();
|
||||
identity.CodeViewAge.Should().BeNull();
|
||||
identity.PdbPath.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.Native.Index;
|
||||
using StellaOps.Scanner.Emit.Composition;
|
||||
using StellaOps.Scanner.Emit.Native;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Emit.Tests.Native;
|
||||
|
||||
public sealed class NativeBinarySbomIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Compose_EmitsNativeBinariesAsFileComponents_WithBuildIdPurlAndLayerTracking()
|
||||
{
|
||||
var index = new FakeBuildIdIndex();
|
||||
index.AddEntry("gnu-build-id:abc123", new BuildIdLookupResult(
|
||||
BuildId: "gnu-build-id:abc123",
|
||||
Purl: "pkg:deb/debian/libc6@2.31",
|
||||
Version: "2.31",
|
||||
SourceDistro: "debian",
|
||||
Confidence: BuildIdConfidence.Exact,
|
||||
IndexedAt: new DateTimeOffset(2025, 12, 19, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
var emitter = new NativeComponentEmitter(index, NullLogger<NativeComponentEmitter>.Instance);
|
||||
var mapper = new NativeComponentMapper(emitter);
|
||||
|
||||
const string layer1 = "sha256:layer1";
|
||||
const string layer2 = "sha256:layer2";
|
||||
|
||||
var resolvedBinary = new NativeBinaryMetadata
|
||||
{
|
||||
Format = "elf",
|
||||
FilePath = "/usr/lib/libc.so.6",
|
||||
BuildId = "GNU-BUILD-ID:ABC123",
|
||||
Architecture = "x86_64",
|
||||
Platform = "linux",
|
||||
};
|
||||
|
||||
var unresolvedBinary = new NativeBinaryMetadata
|
||||
{
|
||||
Format = "elf",
|
||||
FilePath = "/usr/lib/libssl.so.3",
|
||||
BuildId = "gnu-build-id:def456",
|
||||
Architecture = "x86_64",
|
||||
Platform = "linux",
|
||||
};
|
||||
|
||||
var mappingLayer1 = await mapper.MapLayerAsync(layer1, new[] { resolvedBinary, unresolvedBinary });
|
||||
var mappingLayer2 = await mapper.MapLayerAsync(layer2, new[] { resolvedBinary });
|
||||
|
||||
var request = SbomCompositionRequest.Create(
|
||||
new ImageArtifactDescriptor
|
||||
{
|
||||
ImageDigest = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
ImageReference = "registry.example.com/app/service:1.2.3",
|
||||
Repository = "registry.example.com/app/service",
|
||||
Tag = "1.2.3",
|
||||
Architecture = "amd64",
|
||||
},
|
||||
new[] { mappingLayer1.ToFragment(), mappingLayer2.ToFragment() },
|
||||
new DateTimeOffset(2025, 12, 19, 12, 0, 0, TimeSpan.Zero),
|
||||
generatorName: "StellaOps.Scanner",
|
||||
generatorVersion: "0.10.0");
|
||||
|
||||
var composer = new CycloneDxComposer();
|
||||
var result = composer.Compose(request);
|
||||
|
||||
using var document = JsonDocument.Parse(result.Inventory.JsonBytes);
|
||||
var components = document.RootElement.GetProperty("components").EnumerateArray().ToArray();
|
||||
Assert.Equal(2, components.Length);
|
||||
|
||||
var resolvedPurl = mappingLayer1.Components.Single(component => component.IndexMatch).Purl;
|
||||
var unresolvedPurl = mappingLayer1.Components.Single(component => !component.IndexMatch).Purl;
|
||||
|
||||
var resolvedComponent = components.Single(component => string.Equals(component.GetProperty("purl").GetString(), resolvedPurl, StringComparison.Ordinal));
|
||||
Assert.Equal("file", resolvedComponent.GetProperty("type").GetString());
|
||||
Assert.Equal(resolvedPurl, resolvedComponent.GetProperty("bom-ref").GetString());
|
||||
|
||||
var resolvedProperties = resolvedComponent
|
||||
.GetProperty("properties")
|
||||
.EnumerateArray()
|
||||
.ToDictionary(
|
||||
property => property.GetProperty("name").GetString()!,
|
||||
property => property.GetProperty("value").GetString()!,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
Assert.Equal("gnu-build-id:abc123", resolvedProperties["stellaops:buildId"]);
|
||||
Assert.Equal("elf", resolvedProperties["stellaops:binary.format"]);
|
||||
Assert.Equal(layer1, resolvedProperties["stellaops:firstLayerDigest"]);
|
||||
Assert.Equal(layer2, resolvedProperties["stellaops:lastLayerDigest"]);
|
||||
Assert.Equal($"{layer1},{layer2}", resolvedProperties["stellaops:layerDigests"]);
|
||||
|
||||
var unresolvedComponent = components.Single(component => string.Equals(component.GetProperty("purl").GetString(), unresolvedPurl, StringComparison.Ordinal));
|
||||
Assert.Equal("file", unresolvedComponent.GetProperty("type").GetString());
|
||||
Assert.StartsWith("pkg:generic/libssl.so.3@unknown", unresolvedPurl, StringComparison.Ordinal);
|
||||
Assert.Contains("build-id=gnu-build-id%3Adef456", unresolvedPurl, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class FakeBuildIdIndex : IBuildIdIndex
|
||||
{
|
||||
private readonly Dictionary<string, BuildIdLookupResult> _entries = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public int Count => _entries.Count;
|
||||
public bool IsLoaded => true;
|
||||
|
||||
public void AddEntry(string buildId, BuildIdLookupResult result)
|
||||
{
|
||||
_entries[buildId] = result;
|
||||
}
|
||||
|
||||
public Task<BuildIdLookupResult?> LookupAsync(string buildId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_entries.TryGetValue(buildId, out var result);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<BuildIdLookupResult>> BatchLookupAsync(IEnumerable<string> buildIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = buildIds
|
||||
.Select(id => _entries.TryGetValue(id, out var result) ? result : null)
|
||||
.Where(result => result is not null)
|
||||
.Select(result => result!)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<BuildIdLookupResult>>(results);
|
||||
}
|
||||
|
||||
public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.ProofSpine;
|
||||
using StellaOps.Scanner.ProofSpine.Options;
|
||||
using StellaOps.Scanner.Reachability.Attestation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
public sealed class ReachabilityWitnessPublisherIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PublishAsync_WhenStoreInCasEnabled_StoresGraphAndEnvelopeInCas()
|
||||
{
|
||||
var options = Options.Create(new ReachabilityWitnessOptions
|
||||
{
|
||||
Enabled = true,
|
||||
StoreInCas = true,
|
||||
PublishToRekor = false,
|
||||
});
|
||||
|
||||
var cas = new FakeFileContentAddressableStore();
|
||||
var cryptoHash = CryptoHashFactory.CreateDefault();
|
||||
var publisher = new ReachabilityWitnessPublisher(
|
||||
options,
|
||||
cryptoHash,
|
||||
NullLogger<ReachabilityWitnessPublisher>.Instance,
|
||||
cas: cas);
|
||||
|
||||
var graph = CreateTestGraph();
|
||||
var graphBytes = System.Text.Encoding.UTF8.GetBytes("{\"schema\":\"richgraph-v1\",\"nodes\":[],\"edges\":[]}");
|
||||
|
||||
var result = await publisher.PublishAsync(
|
||||
graph,
|
||||
graphBytes,
|
||||
graphHash: "blake3:abc123",
|
||||
subjectDigest: "sha256:def456");
|
||||
|
||||
Assert.Equal("cas://reachability/graphs/abc123", result.CasUri);
|
||||
Assert.Equal(graphBytes, cas.GetBytes("abc123"));
|
||||
Assert.NotNull(cas.GetBytes("abc123.dsse"));
|
||||
Assert.NotEmpty(result.DsseEnvelopeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_WhenRekorEnabled_SubmitsDsseEnvelope()
|
||||
{
|
||||
var rekor = new CapturingRekorClient();
|
||||
var signer = CreateDeterministicSigner(keyId: "reachability-test-key");
|
||||
var cryptoProfile = new TestCryptoProfile("reachability-test-key", "hs256");
|
||||
|
||||
var options = Options.Create(new ReachabilityWitnessOptions
|
||||
{
|
||||
Enabled = true,
|
||||
StoreInCas = false,
|
||||
PublishToRekor = true,
|
||||
RekorUrl = new Uri("https://rekor.test"),
|
||||
RekorBackendName = "primary",
|
||||
SigningKeyId = "reachability-test-key",
|
||||
Tier = AttestationTier.Standard
|
||||
});
|
||||
|
||||
var cryptoHash = CryptoHashFactory.CreateDefault();
|
||||
var publisher = new ReachabilityWitnessPublisher(
|
||||
options,
|
||||
cryptoHash,
|
||||
NullLogger<ReachabilityWitnessPublisher>.Instance,
|
||||
dsseSigningService: signer,
|
||||
cryptoProfile: cryptoProfile,
|
||||
rekorClient: rekor);
|
||||
|
||||
var graph = CreateTestGraph();
|
||||
var result = await publisher.PublishAsync(
|
||||
graph,
|
||||
graphBytes: Array.Empty<byte>(),
|
||||
graphHash: "blake3:abc123",
|
||||
subjectDigest: "sha256:def456");
|
||||
|
||||
Assert.NotNull(rekor.LastRequest);
|
||||
Assert.NotNull(rekor.LastBackend);
|
||||
Assert.Equal("primary", rekor.LastBackend!.Name);
|
||||
Assert.Equal(new Uri("https://rekor.test"), rekor.LastBackend.Url);
|
||||
|
||||
var request = rekor.LastRequest!;
|
||||
Assert.Equal("application/vnd.in-toto+json", request.Bundle.Dsse.PayloadType);
|
||||
Assert.False(string.IsNullOrWhiteSpace(request.Bundle.Dsse.PayloadBase64));
|
||||
Assert.NotEmpty(request.Bundle.Dsse.Signatures);
|
||||
Assert.Equal("reachability-test-key", request.Bundle.Dsse.Signatures[0].KeyId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(request.Meta.BundleSha256));
|
||||
|
||||
Assert.Equal(1234, result.RekorLogIndex);
|
||||
Assert.Equal("rekor-uuid-1234", result.RekorLogId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_WhenAirGapped_SkipsRekorSubmission()
|
||||
{
|
||||
var rekor = new CapturingRekorClient();
|
||||
|
||||
var options = Options.Create(new ReachabilityWitnessOptions
|
||||
{
|
||||
Enabled = true,
|
||||
StoreInCas = false,
|
||||
PublishToRekor = true,
|
||||
RekorUrl = new Uri("https://rekor.test"),
|
||||
Tier = AttestationTier.AirGapped,
|
||||
});
|
||||
|
||||
var cryptoHash = CryptoHashFactory.CreateDefault();
|
||||
var publisher = new ReachabilityWitnessPublisher(
|
||||
options,
|
||||
cryptoHash,
|
||||
NullLogger<ReachabilityWitnessPublisher>.Instance,
|
||||
rekorClient: rekor);
|
||||
|
||||
var graph = CreateTestGraph();
|
||||
var result = await publisher.PublishAsync(
|
||||
graph,
|
||||
graphBytes: Array.Empty<byte>(),
|
||||
graphHash: "blake3:abc123",
|
||||
subjectDigest: "sha256:def456");
|
||||
|
||||
Assert.Null(rekor.LastRequest);
|
||||
Assert.Null(result.RekorLogIndex);
|
||||
Assert.Null(result.RekorLogId);
|
||||
}
|
||||
|
||||
private static RichGraph CreateTestGraph()
|
||||
{
|
||||
return new RichGraph(
|
||||
Schema: "richgraph-v1",
|
||||
Analyzer: new RichGraphAnalyzer("test-analyzer", "1.0.0", null),
|
||||
Nodes: new[]
|
||||
{
|
||||
new RichGraphNode("n1", "sym:dotnet:A", null, null, "dotnet", "method", "A", null, null, null, null),
|
||||
new RichGraphNode("n2", "sym:dotnet:B", null, null, "dotnet", "sink", "B", null, null, null, null)
|
||||
},
|
||||
Edges: new[]
|
||||
{
|
||||
new RichGraphEdge("n1", "n2", "call", null, null, null, 1.0, null)
|
||||
},
|
||||
Roots: null);
|
||||
}
|
||||
|
||||
private static IDsseSigningService CreateDeterministicSigner(string keyId)
|
||||
{
|
||||
var options = Options.Create(new ProofSpineDsseSigningOptions
|
||||
{
|
||||
Mode = "hash",
|
||||
KeyId = keyId,
|
||||
Algorithm = "hs256",
|
||||
AllowDeterministicFallback = true,
|
||||
});
|
||||
|
||||
return new HmacDsseSigningService(
|
||||
options,
|
||||
DefaultCryptoHmac.CreateForTests(),
|
||||
DefaultCryptoHash.CreateForTests());
|
||||
}
|
||||
|
||||
private sealed record TestCryptoProfile(string KeyId, string Algorithm) : ICryptoProfile;
|
||||
|
||||
private sealed class CapturingRekorClient : IRekorClient
|
||||
{
|
||||
public AttestorSubmissionRequest? LastRequest { get; private set; }
|
||||
|
||||
public RekorBackend? LastBackend { get; private set; }
|
||||
|
||||
public Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastRequest = request;
|
||||
LastBackend = backend;
|
||||
|
||||
return Task.FromResult(new RekorSubmissionResponse
|
||||
{
|
||||
Uuid = "rekor-uuid-1234",
|
||||
Index = 1234,
|
||||
LogUrl = backend.Url.ToString(),
|
||||
Status = "included",
|
||||
Proof = null
|
||||
});
|
||||
}
|
||||
|
||||
public Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<RekorProofResponse?>(null);
|
||||
|
||||
public Task<RekorInclusionVerificationResult> VerifyInclusionAsync(string rekorUuid, byte[] payloadDigest, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(RekorInclusionVerificationResult.Failure("not_implemented"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Analyzers.Lang;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Plugin;
|
||||
using StellaOps.Scanner.Analyzers.Native.Index;
|
||||
using StellaOps.Scanner.Analyzers.OS;
|
||||
using StellaOps.Scanner.Analyzers.OS.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.OS.Plugin;
|
||||
@@ -23,6 +24,7 @@ using StellaOps.Scanner.Surface.Secrets;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
using StellaOps.Scanner.Worker.Diagnostics;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using StellaOps.Scanner.Emit.Native;
|
||||
using StellaOps.Scanner.Worker.Tests.TestInfrastructure;
|
||||
using Xunit;
|
||||
using WorkerOptions = StellaOps.Scanner.Worker.Options.ScannerWorkerOptions;
|
||||
@@ -104,7 +106,9 @@ public sealed class CompositeScanAnalyzerDispatcherTests
|
||||
|
||||
var scopeFactory = services.GetRequiredService<IServiceScopeFactory>();
|
||||
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new WorkerOptions());
|
||||
var workerOptions = new WorkerOptions();
|
||||
workerOptions.NativeAnalyzers.Enabled = false;
|
||||
var options = Microsoft.Extensions.Options.Options.Create(workerOptions);
|
||||
var dispatcher = new CompositeScanAnalyzerDispatcher(
|
||||
scopeFactory,
|
||||
osCatalog,
|
||||
@@ -225,7 +229,9 @@ public sealed class CompositeScanAnalyzerDispatcherTests
|
||||
|
||||
var scopeFactory = services.GetRequiredService<IServiceScopeFactory>();
|
||||
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new WorkerOptions());
|
||||
var workerOptions = new WorkerOptions();
|
||||
workerOptions.NativeAnalyzers.Enabled = false;
|
||||
var options = Microsoft.Extensions.Options.Options.Create(workerOptions);
|
||||
var dispatcher = new CompositeScanAnalyzerDispatcher(
|
||||
scopeFactory,
|
||||
osCatalog,
|
||||
@@ -266,6 +272,74 @@ public sealed class CompositeScanAnalyzerDispatcherTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_RunsNativeAnalyzer_AppendsFileComponents()
|
||||
{
|
||||
using var rootfs = new TempDirectory();
|
||||
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
{ ScanMetadataKeys.RootFilesystemPath, rootfs.Path },
|
||||
{ ScanMetadataKeys.WorkspacePath, rootfs.Path },
|
||||
};
|
||||
|
||||
var binaryPath = Path.Combine(rootfs.Path, "usr", "lib", "libdemo.so");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(binaryPath)!);
|
||||
|
||||
var elfBytes = new byte[2048];
|
||||
elfBytes[0] = 0x7F;
|
||||
elfBytes[1] = (byte)'E';
|
||||
elfBytes[2] = (byte)'L';
|
||||
elfBytes[3] = (byte)'F';
|
||||
await File.WriteAllBytesAsync(binaryPath, elfBytes, CancellationToken.None);
|
||||
|
||||
var serviceCollection = new ServiceCollection();
|
||||
serviceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
serviceCollection.AddSingleton(TimeProvider.System);
|
||||
serviceCollection.AddSingleton<ScannerWorkerMetrics>();
|
||||
serviceCollection.AddSingleton<IOptions<StellaOps.Scanner.Worker.Options.NativeAnalyzerOptions>>(
|
||||
Microsoft.Extensions.Options.Options.Create(new StellaOps.Scanner.Worker.Options.NativeAnalyzerOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MinFileSizeBytes = 0,
|
||||
MaxBinariesPerScan = 50,
|
||||
MaxBinariesPerLayer = 50,
|
||||
}));
|
||||
serviceCollection.AddSingleton<IBuildIdIndex, EmptyBuildIdIndex>();
|
||||
serviceCollection.AddSingleton<INativeComponentEmitter, NativeComponentEmitter>();
|
||||
serviceCollection.AddSingleton<NativeBinaryDiscovery>();
|
||||
serviceCollection.AddSingleton<NativeAnalyzerExecutor>();
|
||||
|
||||
await using var services = serviceCollection.BuildServiceProvider();
|
||||
|
||||
var scopeFactory = services.GetRequiredService<IServiceScopeFactory>();
|
||||
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
||||
var metrics = services.GetRequiredService<ScannerWorkerMetrics>();
|
||||
|
||||
var workerOptions = new WorkerOptions();
|
||||
workerOptions.NativeAnalyzers.Enabled = true;
|
||||
var options = Microsoft.Extensions.Options.Options.Create(workerOptions);
|
||||
|
||||
var dispatcher = new CompositeScanAnalyzerDispatcher(
|
||||
scopeFactory,
|
||||
new FakeOsCatalog(),
|
||||
new FakeLanguageCatalog(),
|
||||
options,
|
||||
loggerFactory.CreateLogger<CompositeScanAnalyzerDispatcher>(),
|
||||
metrics,
|
||||
new TestCryptoHash());
|
||||
|
||||
var lease = new TestJobLease(metadata);
|
||||
var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), CancellationToken.None);
|
||||
|
||||
await dispatcher.ExecuteAsync(context, CancellationToken.None);
|
||||
|
||||
var fragments = context.Analysis.GetLayerFragments();
|
||||
Assert.True(fragments.Length > 0);
|
||||
Assert.Contains(fragments, fragment => fragment.Components.Any(component => string.Equals(component.Identity.ComponentType, "file", StringComparison.Ordinal)));
|
||||
Assert.Contains(fragments, fragment => fragment.Components.Any(component => string.Equals(component.Identity.Name, "libdemo.so", StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
private sealed class FakeOsCatalog : IOSAnalyzerPluginCatalog
|
||||
{
|
||||
private readonly IReadOnlyList<IOSPackageAnalyzer> _analyzers;
|
||||
@@ -302,6 +376,21 @@ public sealed class CompositeScanAnalyzerDispatcherTests
|
||||
public IReadOnlyList<ILanguageAnalyzer> CreateAnalyzers(IServiceProvider services) => _analyzers;
|
||||
}
|
||||
|
||||
private sealed class EmptyBuildIdIndex : IBuildIdIndex
|
||||
{
|
||||
public int Count => 0;
|
||||
|
||||
public bool IsLoaded => true;
|
||||
|
||||
public Task<BuildIdLookupResult?> LookupAsync(string buildId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<BuildIdLookupResult?>(null);
|
||||
|
||||
public Task<IReadOnlyList<BuildIdLookupResult>> BatchLookupAsync(IEnumerable<string> buildIds, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<BuildIdLookupResult>>(Array.Empty<BuildIdLookupResult>());
|
||||
|
||||
public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class NoopSurfaceValidatorRunner : ISurfaceValidatorRunner
|
||||
{
|
||||
public ValueTask<SurfaceValidationResult> RunAllAsync(SurfaceValidationContext context, CancellationToken cancellationToken = default)
|
||||
|
||||
Reference in New Issue
Block a user