save work

This commit is contained in:
StellaOps Bot
2025-12-19 09:40:41 +02:00
parent 2eafe98d44
commit 43882078a4
44 changed files with 3044 additions and 492 deletions

View File

@@ -232,6 +232,161 @@ public sealed class CommandHandlersTests
}
}
[Fact]
public async Task HandleGraphExplainAsync_SetsExitCode4WhenNoFilters()
{
var originalExit = Environment.ExitCode;
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
var provider = BuildServiceProvider(backend);
try
{
var output = await CaptureTestConsoleAsync(console => CommandHandlers.HandleGraphExplainAsync(
provider,
tenant: null,
graphId: "graph-123",
vulnerabilityId: null,
packagePurl: null,
includeCallPaths: false,
includeRuntimeHits: false,
includePredicates: false,
includeDsse: false,
includeCounterfactuals: false,
emitJson: false,
verbose: false,
cancellationToken: CancellationToken.None));
Assert.Equal(4, Environment.ExitCode);
Assert.Contains("--vuln-id", output.Combined, StringComparison.OrdinalIgnoreCase);
Assert.Null(backend.LastGraphExplainRequest);
}
finally
{
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandleGraphExplainAsync_CallsBackendAndRendersJson()
{
var originalExit = Environment.ExitCode;
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
{
GraphExplainResponse = new GraphExplainResult
{
GraphId = "graph-123",
GraphHash = "blake3:abc123",
VulnerabilityId = "CVE-2025-0001",
ReachabilityState = "reachable",
Confidence = "high"
}
};
var provider = BuildServiceProvider(backend);
try
{
var output = await CaptureTestConsoleAsync(console => CommandHandlers.HandleGraphExplainAsync(
provider,
tenant: "t-1",
graphId: "graph-123",
vulnerabilityId: "CVE-2025-0001",
packagePurl: null,
includeCallPaths: true,
includeRuntimeHits: false,
includePredicates: false,
includeDsse: true,
includeCounterfactuals: false,
emitJson: true,
verbose: false,
cancellationToken: CancellationToken.None));
Assert.Equal(0, Environment.ExitCode);
Assert.NotNull(backend.LastGraphExplainRequest);
Assert.Equal("graph-123", backend.LastGraphExplainRequest!.GraphId);
Assert.Equal("CVE-2025-0001", backend.LastGraphExplainRequest!.VulnerabilityId);
Assert.True(backend.LastGraphExplainRequest!.IncludeCallPaths);
Assert.True(backend.LastGraphExplainRequest!.IncludeDsseEnvelopes);
Assert.Equal("t-1", backend.LastGraphExplainRequest!.Tenant);
using var document = JsonDocument.Parse(output.SpectreBuffer.Trim());
var root = document.RootElement;
Assert.Equal("graph-123", root.GetProperty("graphId").GetString());
Assert.Equal("blake3:abc123", root.GetProperty("graphHash").GetString());
Assert.Equal("reachable", root.GetProperty("reachabilityState").GetString());
}
finally
{
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandleGraphVerifyAsync_EmitsJsonWithExpectedFields()
{
var originalExit = Environment.ExitCode;
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)));
try
{
var output = await CaptureTestConsoleAsync(console => CommandHandlers.HandleGraphVerifyAsync(
provider,
tenant: null,
hash: "blake3:deadbeef",
includeBundles: true,
specificBundle: null,
verifyRekor: true,
casRoot: "C:\\offline-cas",
format: "json",
verbose: false,
cancellationToken: CancellationToken.None));
Assert.Equal(0, Environment.ExitCode);
using var document = JsonDocument.Parse(output.SpectreBuffer.Trim());
var root = document.RootElement;
Assert.Equal("blake3:deadbeef", root.GetProperty("hash").GetString());
Assert.Equal("VERIFIED", root.GetProperty("status").GetString());
Assert.True(root.GetProperty("offlineMode").GetBoolean());
Assert.True(root.GetProperty("rekorIncluded").GetBoolean());
Assert.Equal(2, root.GetProperty("bundlesVerified").GetInt32());
}
finally
{
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandleGraphBundlesAsync_EmitsJsonWithBundles()
{
var originalExit = Environment.ExitCode;
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)));
try
{
var output = await CaptureTestConsoleAsync(console => CommandHandlers.HandleGraphBundlesAsync(
provider,
tenant: null,
graphHash: "blake3:deadbeef",
emitJson: true,
verbose: false,
cancellationToken: CancellationToken.None));
Assert.Equal(0, Environment.ExitCode);
using var document = JsonDocument.Parse(output.SpectreBuffer.Trim());
var root = document.RootElement;
Assert.Equal("blake3:deadbeef", root.GetProperty("graphHash").GetString());
Assert.Equal(2, root.GetProperty("bundles").GetArrayLength());
Assert.Contains(root.GetProperty("bundles").EnumerateArray(), bundle =>
string.Equals(bundle.GetProperty("bundleId").GetString(), "bundle:001", StringComparison.OrdinalIgnoreCase));
}
finally
{
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandleNodeLockValidateAsync_RendersDeclaredOnlyAndMissingLock()
{
@@ -4669,8 +4824,15 @@ spec:
public Task<ReachabilityExplainResult> ExplainReachabilityAsync(ReachabilityExplainRequest request, CancellationToken cancellationToken)
=> Task.FromResult(new ReachabilityExplainResult());
public GraphExplainRequest? LastGraphExplainRequest { get; private set; }
public GraphExplainResult GraphExplainResponse { get; set; } = new GraphExplainResult();
public Task<GraphExplainResult> ExplainGraphAsync(GraphExplainRequest request, CancellationToken cancellationToken)
=> Task.FromResult(new GraphExplainResult());
{
LastGraphExplainRequest = request;
return Task.FromResult(GraphExplainResponse);
}
public Task<ApiSpecListResponse> ListApiSpecsAsync(string? tenant, CancellationToken cancellationToken)
=> Task.FromResult(new ApiSpecListResponse());

View File

@@ -85,8 +85,21 @@ public sealed class PolicyDecisionAttestationService : IPolicyDecisionAttestatio
var payloadBase64 = Convert.ToBase64String(statementJson);
// Sign the payload
string? tenantId = request.TenantId;
if (string.IsNullOrWhiteSpace(tenantId) && _signerClient is not null && options.UseSignerService)
{
return new PolicyDecisionAttestationResult
{
Success = false,
Error = "TenantId is required when using the signer service"
};
}
tenantId ??= "unknown";
string? attestationDigest;
string? keyId;
VexDsseSignature signature;
if (_signerClient is not null && options.UseSignerService)
{
@@ -96,7 +109,7 @@ public sealed class PolicyDecisionAttestationService : IPolicyDecisionAttestatio
PayloadType = PredicateTypes.StellaOpsPolicyDecision,
PayloadBase64 = payloadBase64,
KeyId = request.KeyId ?? options.DefaultKeyId,
TenantId = request.TenantId
TenantId = tenantId
},
cancellationToken).ConfigureAwait(false);
@@ -110,25 +123,39 @@ public sealed class PolicyDecisionAttestationService : IPolicyDecisionAttestatio
};
}
// Compute attestation digest from signed payload
attestationDigest = ComputeDigest(statementJson);
keyId = signResult.KeyId;
signature = new VexDsseSignature
{
KeyId = signResult.KeyId,
Sig = signResult.Signature!
};
}
else
{
// Create unsigned attestation (dev/test mode)
attestationDigest = ComputeDigest(statementJson);
// Create locally-signed envelope (dev/test mode; placeholder signature).
keyId = null;
_logger.LogDebug("Created unsigned attestation (signer service not available)");
signature = SignLocally(PredicateTypes.StellaOpsPolicyDecision, statementJson);
_logger.LogDebug("Created locally-signed attestation (signer service not available)");
}
var envelope = new VexDsseEnvelope
{
PayloadType = PredicateTypes.StellaOpsPolicyDecision,
Payload = payloadBase64,
Signatures = [signature]
};
var envelopeJson = SerializeCanonical(envelope);
var envelopeDigestHex = Convert.ToHexString(SHA256.HashData(envelopeJson)).ToLowerInvariant();
attestationDigest = $"sha256:{envelopeDigestHex}";
// Submit to Rekor if requested
RekorSubmissionResult? rekorResult = null;
var shouldSubmitToRekor = request.SubmitToRekor || options.SubmitToRekorByDefault;
if (shouldSubmitToRekor && attestationDigest is not null)
{
rekorResult = await SubmitToRekorAsync(attestationDigest, cancellationToken)
rekorResult = await SubmitEnvelopeToRekorAsync(envelope, envelopeDigestHex, request, cancellationToken)
.ConfigureAwait(false);
if (!rekorResult.Success)
@@ -266,6 +293,99 @@ public sealed class PolicyDecisionAttestationService : IPolicyDecisionAttestatio
return JsonSerializer.SerializeToUtf8Bytes(value, CanonicalJsonOptions);
}
private static VexDsseSignature SignLocally(string payloadType, byte[] payload)
{
// DSSE PAE: "DSSEv1" + len(payloadType) + payloadType + len(payload) + payload
using var ms = new MemoryStream();
using var writer = new BinaryWriter(ms);
var prefix = "DSSEv1 "u8;
writer.Write(prefix);
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
writer.Write(typeBytes.Length.ToString());
writer.Write(' ');
writer.Write(typeBytes);
writer.Write(' ');
writer.Write(payload.Length.ToString());
writer.Write(' ');
writer.Write(payload);
var pae = ms.ToArray();
var signatureBytes = SHA256.HashData(pae);
return new VexDsseSignature
{
Sig = Convert.ToBase64String(signatureBytes)
};
}
private async Task<RekorSubmissionResult> SubmitEnvelopeToRekorAsync(
VexDsseEnvelope envelope,
string envelopeDigestHex,
PolicyDecisionAttestationRequest request,
CancellationToken cancellationToken)
{
if (_rekorClient is null)
{
return new RekorSubmissionResult
{
Success = false,
Error = "Rekor client not available"
};
}
var subjectUris = request.Subjects
.OrderBy(static x => x.Name, StringComparer.Ordinal)
.Select(static subject =>
{
var digest = subject.Digest
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
.Select(static kvp => $"{kvp.Key}:{kvp.Value}")
.FirstOrDefault();
return digest is null ? subject.Name : $"{subject.Name}@{digest}";
})
.ToList();
var submitResult = await _rekorClient.SubmitAsync(
new VexRekorSubmitRequest
{
Envelope = envelope,
EnvelopeDigest = envelopeDigestHex,
ArtifactKind = "policy-decision",
SubjectUris = subjectUris
},
cancellationToken).ConfigureAwait(false);
if (!submitResult.Success)
{
return new RekorSubmissionResult
{
Success = false,
Error = submitResult.Error ?? "Rekor submission failed"
};
}
if (submitResult.Metadata is null)
{
return new RekorSubmissionResult
{
Success = false,
Error = "Rekor submission succeeded but no metadata was returned"
};
}
return new RekorSubmissionResult
{
Success = true,
LogIndex = submitResult.Metadata.Index,
Uuid = submitResult.Metadata.Uuid,
IntegratedTime = submitResult.Metadata.IntegratedAt
};
}
private static string ComputeDigest(byte[] data)
{
var hash = SHA256.HashData(data);

View File

@@ -184,7 +184,8 @@ public sealed class ProofAwareScoringEngine : IScoringEngine
using var sha256 = System.Security.Cryptography.SHA256.Create();
var inputString = $"{input.FindingId}:{input.TenantId}:{input.ProfileId}:{input.AsOf:O}";
foreach (var kvp in input.InputDigests?.OrderBy(x => x.Key) ?? [])
foreach (var kvp in input.InputDigests?.OrderBy(x => x.Key)
?? Enumerable.Empty<System.Collections.Generic.KeyValuePair<string, string>>())
{
inputString += $":{kvp.Key}={kvp.Value}";
}

View File

@@ -57,7 +57,7 @@ public sealed class ScoringEngineFactory : IScoringEngineFactory
/// </summary>
public IScoringEngine GetEngine(ScoringProfile profile)
{
var engine = profile switch
IScoringEngine engine = profile switch
{
ScoringProfile.Simple => _services.GetRequiredService<SimpleScoringEngine>(),
ScoringProfile.Advanced => _services.GetRequiredService<AdvancedScoringEngine>(),

View File

@@ -6,3 +6,4 @@ This file mirrors sprint work for the Policy Engine module.
| --- | --- | --- | --- |
| `POLICY-GATE-401-033` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Implemented PolicyGateEvaluator (lattice/uncertainty/evidence completeness) and aligned tests/docs; see `src/Policy/StellaOps.Policy.Engine/Gates/PolicyGateEvaluator.cs` and `src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Gates/PolicyGateEvaluatorTests.cs`. |
| `DET-3401-011` | `docs/implplan/SPRINT_3401_0001_0001_determinism_scoring_foundations.md` | DONE (2025-12-14) | Added `Explain` to `RiskScoringResult` and covered JSON serialization + null-coercion in `src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Scoring/RiskScoringResultTests.cs`. |
| `PDA-3801-0001` | `docs/implplan/SPRINT_3801_0001_0001_policy_decision_attestation.md` | DONE (2025-12-19) | Implemented `PolicyDecisionAttestationService` + predicate model + DI wiring; covered signer/Rekor flows in `src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Attestation/PolicyDecisionAttestationServiceTests.cs`. |

View File

@@ -67,10 +67,10 @@ public class PolicyDecisionAttestationServiceTests
_signerClientMock.Setup(x => x.SignAsync(
It.IsAny<VexSignerRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResponse
.ReturnsAsync(new VexSignerResult
{
Success = true,
AttestationDigest = "sha256:abc123",
Signature = "AQID",
KeyId = "key-1"
});
@@ -81,7 +81,8 @@ public class PolicyDecisionAttestationServiceTests
// Assert
Assert.True(result.Success);
Assert.Equal("sha256:abc123", result.AttestationDigest);
Assert.NotNull(result.AttestationDigest);
Assert.Matches("^sha256:[a-f0-9]{64}$", result.AttestationDigest);
Assert.Equal("key-1", result.KeyId);
_signerClientMock.Verify(x => x.SignAsync(
@@ -97,7 +98,7 @@ public class PolicyDecisionAttestationServiceTests
_signerClientMock.Setup(x => x.SignAsync(
It.IsAny<VexSignerRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResponse
.ReturnsAsync(new VexSignerResult
{
Success = false,
Error = "Key not found"
@@ -120,21 +121,26 @@ public class PolicyDecisionAttestationServiceTests
_signerClientMock.Setup(x => x.SignAsync(
It.IsAny<VexSignerRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResponse
.ReturnsAsync(new VexSignerResult
{
Success = true,
AttestationDigest = "sha256:abc123",
Signature = "AQID",
KeyId = "key-1"
});
_rekorClientMock.Setup(x => x.SubmitAsync(
It.IsAny<string>(),
It.IsAny<VexRekorSubmitRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexRekorResponse
.ReturnsAsync(new VexRekorSubmitResult
{
Success = true,
LogIndex = 12345,
Uuid = "rekor-uuid-123"
Metadata = new VexRekorMetadata
{
Uuid = "rekor-uuid-123",
Index = 12345,
LogUrl = "https://rekor.local/api/v1/log/entries/rekor-uuid-123",
IntegratedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
}
});
var request = CreateTestRequest() with { SubmitToRekor = true };
@@ -147,9 +153,16 @@ public class PolicyDecisionAttestationServiceTests
Assert.NotNull(result.RekorResult);
Assert.True(result.RekorResult.Success);
Assert.Equal(12345, result.RekorResult.LogIndex);
Assert.Equal("rekor-uuid-123", result.RekorResult.Uuid);
var envelopeDigestHex = result.AttestationDigest!.Substring("sha256:".Length);
_rekorClientMock.Verify(x => x.SubmitAsync(
"sha256:abc123",
It.Is<VexRekorSubmitRequest>(r =>
r.ArtifactKind == "policy-decision" &&
r.Envelope.PayloadType == PredicateTypes.StellaOpsPolicyDecision &&
r.EnvelopeDigest == envelopeDigestHex &&
r.SubjectUris!.Contains("example.com/image:v1@sha256:abc123")),
It.IsAny<CancellationToken>()),
Times.Once);
}
@@ -183,10 +196,10 @@ public class PolicyDecisionAttestationServiceTests
_signerClientMock.Setup(x => x.SignAsync(
It.IsAny<VexSignerRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResponse
.ReturnsAsync(new VexSignerResult
{
Success = true,
AttestationDigest = "sha256:abc123"
Signature = "AQID"
});
var request = CreateTestRequest() with
@@ -306,7 +319,8 @@ public class PolicyDecisionAttestationServiceTests
Name = "example.com/image:v1",
Digest = new Dictionary<string, string> { ["sha256"] = "abc123" }
}
}
},
TenantId = "tenant-1"
};
}
}

View File

@@ -55,8 +55,8 @@ public sealed class ScorePolicyServiceCachingTests
var result2 = _service.GetPolicy("tenant-2");
result1.Should().NotBeSameAs(result2);
result1.PolicyId.Should().Be("tenant-1");
result2.PolicyId.Should().Be("tenant-2");
result1.Should().BeSameAs(policy1);
result2.Should().BeSameAs(policy2);
_providerMock.Verify(p => p.GetPolicy("tenant-1"), Times.Once());
_providerMock.Verify(p => p.GetPolicy("tenant-2"), Times.Once());
}
@@ -193,7 +193,7 @@ public sealed class ScorePolicyServiceCachingTests
var policy1 = new ScorePolicy
{
PolicyVersion = "score.v1",
PolicyId = "stable-test",
ScoringProfile = "advanced",
WeightsBps = new WeightsBps
{
BaseSeverity = 2500,
@@ -206,7 +206,7 @@ public sealed class ScorePolicyServiceCachingTests
var policy2 = new ScorePolicy
{
PolicyVersion = "score.v1",
PolicyId = "stable-test",
ScoringProfile = "advanced",
WeightsBps = new WeightsBps
{
BaseSeverity = 2500,
@@ -225,12 +225,11 @@ public sealed class ScorePolicyServiceCachingTests
private static ScorePolicy CreateTestPolicy(string id) => new()
{
PolicyVersion = "score.v1",
PolicyId = id,
PolicyName = $"Test Policy {id}",
ScoringProfile = "advanced",
WeightsBps = new WeightsBps
{
BaseSeverity = 2500,
Reachability = 2500,
BaseSeverity = id.EndsWith("2", StringComparison.Ordinal) ? 2400 : 2500,
Reachability = id.EndsWith("2", StringComparison.Ordinal) ? 2600 : 2500,
Evidence = 2500,
Provenance = 2500
}

View File

@@ -199,7 +199,13 @@ public sealed class SimpleScoringEngineTests
{
Evidence = new EvidenceInput
{
Types = new HashSet<EvidenceType> { EvidenceType.Runtime },
Types = new HashSet<EvidenceType>
{
EvidenceType.Runtime,
EvidenceType.Dast,
EvidenceType.Sast,
EvidenceType.Sca
},
NewestEvidenceAt = asOf
},
Provenance = new ProvenanceInput { Level = ProvenanceLevel.Reproducible }
@@ -220,7 +226,13 @@ public sealed class SimpleScoringEngineTests
{
Evidence = new EvidenceInput
{
Types = new HashSet<EvidenceType> { EvidenceType.Runtime },
Types = new HashSet<EvidenceType>
{
EvidenceType.Runtime,
EvidenceType.Dast,
EvidenceType.Sast,
EvidenceType.Sca
},
NewestEvidenceAt = DateTimeOffset.UtcNow
},
Provenance = new ProvenanceInput { Level = ProvenanceLevel.Reproducible }
@@ -311,7 +323,16 @@ public sealed class SimpleScoringEngineTests
]
};
var input = CreateInput(cvss: 10.0m, hopCount: null);
var asOf = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
var input = CreateInput(cvss: 10.0m, hopCount: null, asOf: asOf) with
{
Evidence = new EvidenceInput
{
Types = new HashSet<EvidenceType> { EvidenceType.Runtime },
NewestEvidenceAt = asOf
},
Provenance = new ProvenanceInput { Level = ProvenanceLevel.Reproducible }
};
var result = await _engine.ScoreAsync(input, policy);

View File

@@ -1,7 +1,10 @@
using System.Collections.Frozen;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Replay.Core;
using StellaOps.Scanner.ProofSpine;
namespace StellaOps.Scanner.Analyzers.Native.Index;
@@ -13,6 +16,7 @@ public sealed class OfflineBuildIdIndex : IBuildIdIndex
{
private readonly BuildIdIndexOptions _options;
private readonly ILogger<OfflineBuildIdIndex> _logger;
private readonly IDsseSigningService? _dsseSigningService;
private FrozenDictionary<string, BuildIdLookupResult> _index = FrozenDictionary<string, BuildIdLookupResult>.Empty;
private bool _isLoaded;
@@ -24,13 +28,17 @@ public sealed class OfflineBuildIdIndex : IBuildIdIndex
/// <summary>
/// Creates a new offline Build-ID index.
/// </summary>
public OfflineBuildIdIndex(IOptions<BuildIdIndexOptions> options, ILogger<OfflineBuildIdIndex> logger)
public OfflineBuildIdIndex(
IOptions<BuildIdIndexOptions> options,
ILogger<OfflineBuildIdIndex> logger,
IDsseSigningService? dsseSigningService = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
_options = options.Value;
_logger = logger;
_dsseSigningService = dsseSigningService;
}
/// <inheritdoc />
@@ -99,7 +107,17 @@ public sealed class OfflineBuildIdIndex : IBuildIdIndex
return;
}
// TODO: BID-006 - Verify DSSE signature if RequireSignature is true
if (_options.RequireSignature)
{
var verified = await VerifySignatureAsync(_options.IndexPath, cancellationToken).ConfigureAwait(false);
if (!verified)
{
_logger.LogError("Build-ID index signature verification failed; refusing to load index.");
_index = FrozenDictionary<string, BuildIdLookupResult>.Empty;
_isLoaded = true;
return;
}
}
var entries = new Dictionary<string, BuildIdLookupResult>(StringComparer.OrdinalIgnoreCase);
var lineNumber = 0;
@@ -204,4 +222,195 @@ public sealed class OfflineBuildIdIndex : IBuildIdIndex
}
private static bool IsHex(string s) => s.All(c => char.IsAsciiHexDigit(c));
private async Task<bool> VerifySignatureAsync(string indexPath, CancellationToken cancellationToken)
{
if (_dsseSigningService is null)
{
_logger.LogError("RequireSignature is enabled but no DSSE signing service is configured.");
return false;
}
var signaturePath = ResolveSignaturePath(indexPath);
if (string.IsNullOrWhiteSpace(signaturePath) || !File.Exists(signaturePath))
{
_logger.LogError("Build-ID index signature file not found at {SignaturePath}.", signaturePath);
return false;
}
var indexSha256 = ComputeSha256Hex(indexPath);
if (string.IsNullOrWhiteSpace(indexSha256))
{
_logger.LogError("Failed to compute SHA-256 for Build-ID index at {IndexPath}.", indexPath);
return false;
}
DsseEnvelope? envelope;
try
{
var json = await File.ReadAllTextAsync(signaturePath, cancellationToken).ConfigureAwait(false);
envelope = JsonSerializer.Deserialize<DsseEnvelope>(json, JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse Build-ID index signature file at {SignaturePath}.", signaturePath);
return false;
}
if (envelope is null)
{
_logger.LogError("Build-ID index signature file at {SignaturePath} did not contain a DSSE envelope.", signaturePath);
return false;
}
DsseVerificationOutcome outcome;
try
{
outcome = await _dsseSigningService.VerifyAsync(envelope, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "DSSE verification failed for Build-ID index signature file at {SignaturePath}.", signaturePath);
return false;
}
if (!outcome.IsValid)
{
_logger.LogError("DSSE signature invalid for Build-ID index: {FailureReason}", outcome.FailureReason ?? "unknown");
return false;
}
if (!outcome.IsTrusted)
{
_logger.LogError("DSSE signature was not trusted for Build-ID index: {FailureReason}", outcome.FailureReason ?? "dsse_untrusted");
return false;
}
if (!TryDecodeBase64(envelope.Payload, out var payloadBytes))
{
_logger.LogError("DSSE envelope payload is not valid base64 for Build-ID index signature file at {SignaturePath}.", signaturePath);
return false;
}
try
{
using var doc = JsonDocument.Parse(payloadBytes);
if (!TryExtractSha256(doc.RootElement, out var expectedSha256))
{
_logger.LogError("DSSE payload did not contain an index SHA-256 digest.");
return false;
}
var expectedHex = NormalizeSha256(expectedSha256);
if (string.IsNullOrWhiteSpace(expectedHex))
{
_logger.LogError("DSSE payload index SHA-256 digest was empty/invalid.");
return false;
}
if (!string.Equals(expectedHex, indexSha256, StringComparison.Ordinal))
{
_logger.LogError(
"Build-ID index SHA-256 mismatch (expected {Expected}, computed {Computed}).",
expectedHex,
indexSha256);
return false;
}
}
catch (JsonException ex)
{
_logger.LogError(ex, "DSSE payload is not valid JSON for Build-ID index signature.");
return false;
}
return true;
}
private string ResolveSignaturePath(string indexPath)
{
if (!string.IsNullOrWhiteSpace(_options.SignaturePath))
{
return _options.SignaturePath!;
}
return indexPath + ".dsse.json";
}
private static bool TryExtractSha256(JsonElement root, out string sha256)
{
sha256 = string.Empty;
if (TryGetString(root, out sha256, "IndexSha256", "indexSha256", "index_sha256"))
{
return true;
}
if (TryGetString(root, out sha256, "Digest", "digest", "sha256"))
{
return true;
}
return false;
}
private static bool TryGetString(JsonElement root, out string value, params string[] propertyNames)
{
foreach (var name in propertyNames)
{
if (root.TryGetProperty(name, out var element) && element.ValueKind == JsonValueKind.String)
{
value = element.GetString() ?? string.Empty;
return !string.IsNullOrWhiteSpace(value);
}
}
value = string.Empty;
return false;
}
private static string NormalizeSha256(string value)
{
var trimmed = value.Trim();
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed[7..];
}
return trimmed.ToLowerInvariant();
}
private static string ComputeSha256Hex(string filePath)
{
try
{
using var sha256 = SHA256.Create();
using var stream = File.OpenRead(filePath);
var hash = sha256.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
catch
{
return string.Empty;
}
}
private static bool TryDecodeBase64(string? value, out byte[] bytes)
{
if (string.IsNullOrWhiteSpace(value))
{
bytes = Array.Empty<byte>();
return false;
}
try
{
bytes = Convert.FromBase64String(value);
return true;
}
catch (FormatException)
{
bytes = Array.Empty<byte>();
return false;
}
}
}

View File

@@ -333,6 +333,29 @@ public static class MachOReader
stream.Position = currentPos;
}
continue;
case LC_DYLD_INFO:
case LC_DYLD_INFO_ONLY:
if (exports.Count == 0 && TryReadBytes(stream, cmdDataSize, out var dyldInfoBytes) && dyldInfoBytes.Length >= 40)
{
// dyld_info_command: export_off/export_size are the last two uint32 fields
var exportOff = ReadUInt32(dyldInfoBytes, 32, swapBytes);
var exportSize = ReadUInt32(dyldInfoBytes, 36, swapBytes);
TryParseExportsTrie(stream, startOffset, exportOff, exportSize, exports);
}
continue;
case LC_DYLD_EXPORTS_TRIE:
if (exports.Count == 0 && TryReadBytes(stream, cmdDataSize, out var exportsTrieBytes) && exportsTrieBytes.Length >= 8)
{
// linkedit_data_command: dataoff/datasize
var dataOff = ReadUInt32(exportsTrieBytes, 0, swapBytes);
var dataSize = ReadUInt32(exportsTrieBytes, 4, swapBytes);
TryParseExportsTrie(stream, startOffset, dataOff, dataSize, exports);
}
continue;
}
@@ -344,6 +367,16 @@ public static class MachOReader
}
}
IReadOnlyList<string> exportList = exports;
if (exports.Count > 0)
{
exportList = exports
.Where(static name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.Ordinal)
.OrderBy(static name => name, StringComparer.Ordinal)
.ToList();
}
return new MachOIdentity(
cpuTypeName,
cpuSubtype,
@@ -353,7 +386,7 @@ public static class MachOReader
minOsVersion,
sdkVersion,
codeSignature,
exports);
exportList);
}
/// <summary>
@@ -452,7 +485,7 @@ public static class MachOReader
// CodeDirectory has a complex structure, we'll extract key fields
stream.Position = blobStart;
if (!TryReadBytes(stream, Math.Min(length, 52), out var cdBytes))
if (!TryReadBytes(stream, Math.Min(length, 56), out var cdBytes))
{
return (null, null, null, false);
}
@@ -550,6 +583,164 @@ public static class MachOReader
return keys;
}
private static void TryParseExportsTrie(Stream stream, long startOffset, uint dataOff, uint dataSize, List<string> exports)
{
const int MaxTrieSizeBytes = 16 * 1024 * 1024;
if (dataOff == 0 || dataSize == 0 || dataSize > MaxTrieSizeBytes)
{
return;
}
if (!stream.CanSeek)
{
return;
}
long endOffset;
try
{
endOffset = checked(startOffset + dataOff + dataSize);
}
catch (OverflowException)
{
return;
}
if (endOffset > stream.Length)
{
return;
}
var currentPos = stream.Position;
try
{
stream.Position = startOffset + dataOff;
if (!TryReadBytes(stream, (int)dataSize, out var trieBytes))
{
return;
}
exports.AddRange(ParseExportsTrie(trieBytes));
}
finally
{
stream.Position = currentPos;
}
}
private static IReadOnlyList<string> ParseExportsTrie(ReadOnlySpan<byte> trie)
{
const int MaxExports = 10_000;
var exports = new List<string>();
if (trie.IsEmpty)
{
return exports;
}
var visited = new HashSet<int>();
var stack = new Stack<(int Offset, string Prefix)>();
stack.Push((0, string.Empty));
while (stack.Count > 0 && exports.Count < MaxExports)
{
var (nodeOffset, prefix) = stack.Pop();
if (nodeOffset < 0 || nodeOffset >= trie.Length)
{
continue;
}
if (!visited.Add(nodeOffset))
{
continue;
}
var cursor = nodeOffset;
if (!TryReadUleb128(trie, ref cursor, out var terminalSize))
{
continue;
}
if (terminalSize > (ulong)(trie.Length - cursor))
{
continue;
}
if (terminalSize > 0 && !string.IsNullOrEmpty(prefix))
{
exports.Add(prefix);
}
cursor += (int)terminalSize;
if (cursor >= trie.Length)
{
continue;
}
var childCount = trie[cursor++];
for (var i = 0; i < childCount; i++)
{
if (cursor >= trie.Length)
{
break;
}
// Edge string is null-terminated
var remaining = trie[cursor..];
var terminator = remaining.IndexOf((byte)0);
if (terminator < 0)
{
break;
}
var edge = Encoding.UTF8.GetString(remaining[..terminator]);
cursor += terminator + 1;
if (!TryReadUleb128(trie, ref cursor, out var childOffsetUleb))
{
break;
}
if (childOffsetUleb > int.MaxValue)
{
continue;
}
var childOffset = (int)childOffsetUleb;
var nextPrefix = string.IsNullOrEmpty(prefix) ? edge : prefix + edge;
stack.Push((childOffset, nextPrefix));
}
}
exports.Sort(StringComparer.Ordinal);
return exports;
}
private static bool TryReadUleb128(ReadOnlySpan<byte> data, ref int offset, out ulong value)
{
value = 0;
var shift = 0;
while (offset < data.Length && shift <= 63)
{
var b = data[offset++];
value |= (ulong)(b & 0x7Fu) << shift;
if ((b & 0x80) == 0)
{
return true;
}
shift += 7;
}
value = 0;
return false;
}
/// <summary>
/// Get CPU type name from CPU type value.
/// </summary>

View File

@@ -13,6 +13,10 @@
<InternalsVisibleTo Include="StellaOps.Scanner.Analyzers.Native.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\__Libraries\\StellaOps.Scanner.ProofSpine\\StellaOps.Scanner.ProofSpine.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-*" />

View File

@@ -0,0 +1,7 @@
# Scanner Native Analyzer Tasks
| Task ID | Sprint | Status | Notes | Updated (UTC) |
| --- | --- | --- | --- | --- |
| BID-3500-0011 | `docs/implplan/SPRINT_3500_0011_0001_buildid_mapping_index.md` | DONE | Offline Build-ID→PURL index (NDJSON) with DSSE verification + SHA-256 binding; test evidence under `src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/Index/`. | 2025-12-19 |
| PE-3500-0010-0001 | `docs/implplan/SPRINT_3500_0010_0001_pe_full_parser.md` | DONE | Completed golden fixtures (MSVC/MinGW/Clang) via `src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/Fixtures/PeBuilder.cs` and added positive parsing tests in `src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/PeReaderTests.cs`. | 2025-12-19 |
| MACH-3500-0010-0002 | `docs/implplan/SPRINT_3500_0010_0002_macho_full_parser.md` | DONE | Implemented export trie parsing (LC_DYLD_INFO(_ONLY)/LC_DYLD_EXPORTS_TRIE) + added signed/unsigned fixtures and tests in `src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Native.Tests/MachOReaderTests.cs`. | 2025-12-19 |

View File

@@ -5,6 +5,7 @@ using System.Collections.ObjectModel;
using System.Linq;
using System.IO;
using System.Text;
using System.Security.Cryptography;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -67,8 +68,9 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
var osAnalyzers = _osCatalog.CreateAnalyzers(services);
var languageAnalyzers = _languageCatalog.CreateAnalyzers(services);
var nativeAnalyzersEnabled = _options.NativeAnalyzers.Enabled;
if (osAnalyzers.Count == 0 && languageAnalyzers.Count == 0)
if (osAnalyzers.Count == 0 && languageAnalyzers.Count == 0 && !nativeAnalyzersEnabled)
{
_logger.LogWarning("No analyzer plug-ins available; skipping analyzer stage for job {JobId}.", context.JobId);
return;
@@ -89,6 +91,11 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
await ExecuteLanguageAnalyzersAsync(context, languageAnalyzers, services, workspacePath, cancellationToken)
.ConfigureAwait(false);
}
if (nativeAnalyzersEnabled)
{
await ExecuteNativeAnalyzerAsync(context, services, rootfsPath, cancellationToken).ConfigureAwait(false);
}
}
private async Task ExecuteOsAnalyzersAsync(
@@ -329,6 +336,59 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
}
}
private async Task ExecuteNativeAnalyzerAsync(
ScanJobContext context,
IServiceProvider services,
string? rootfsPath,
CancellationToken cancellationToken)
{
if (rootfsPath is null)
{
_logger.LogWarning(
"Metadata key '{MetadataKey}' missing for job {JobId}; unable to locate root filesystem. Native analyzer skipped.",
_options.Analyzers.RootFilesystemMetadataKey,
context.JobId);
return;
}
NativeAnalysisResult result;
try
{
var executor = services.GetRequiredService<NativeAnalyzerExecutor>();
result = await executor.ExecuteAsync(rootfsPath, context, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Native analyzer execution failed for job {JobId}.", context.JobId);
return;
}
if (result.Components is null || result.Components.Count == 0)
{
return;
}
var layerDigest = ComputeLayerDigest("native");
var records = result.Components
.Select(component => component.ToComponentRecord(layerDigest))
.ToList();
if (records.Count == 0)
{
return;
}
var fragment = LayerComponentFragment.Create(layerDigest, ImmutableArray.CreateRange(records));
context.Analysis.AppendLayerFragments(ImmutableArray.Create(fragment));
}
private static string ComputeLayerDigest(string kind)
{
var normalized = $"stellaops:{kind.Trim().ToLowerInvariant()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private void LoadPlugins()
{
_osPluginDirectories = NormalizeDirectories(_options.Analyzers.PluginDirectories, Path.Combine("plugins", "scanner", "analyzers", "os"));

View File

@@ -12,9 +12,11 @@ using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Reachability.Gates;
using StellaOps.Scanner.Analyzers.OS.Plugin;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
using StellaOps.Scanner.Analyzers.Native.Index;
using StellaOps.Scanner.EntryTrace;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Security;
using StellaOps.Scanner.Emit.Native;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.FS;
using StellaOps.Scanner.Surface.Secrets;
@@ -45,6 +47,10 @@ builder.Services.AddOptions<ScannerWorkerOptions>()
}
});
builder.Services.AddOptions<NativeAnalyzerOptions>()
.BindConfiguration(NativeAnalyzerOptions.SectionName)
.ValidateOnStart();
builder.Services.AddSingleton<IValidateOptions<ScannerWorkerOptions>, ScannerWorkerOptionsValidator>();
var workerOptions = builder.Configuration.GetSection(ScannerWorkerOptions.SectionName).Get<ScannerWorkerOptions>() ?? new ScannerWorkerOptions();
@@ -143,6 +149,10 @@ builder.Services.TryAddSingleton<IScanJobSource, NullScanJobSource>();
builder.Services.TryAddSingleton<IPluginCatalogGuard, RestartOnlyPluginGuard>();
builder.Services.AddSingleton<IOSAnalyzerPluginCatalog, OsAnalyzerPluginCatalog>();
builder.Services.AddSingleton<ILanguageAnalyzerPluginCatalog, LanguageAnalyzerPluginCatalog>();
builder.Services.AddSingleton<IBuildIdIndex, OfflineBuildIdIndex>();
builder.Services.AddSingleton<INativeComponentEmitter, NativeComponentEmitter>();
builder.Services.AddSingleton<NativeBinaryDiscovery>();
builder.Services.AddSingleton<NativeAnalyzerExecutor>();
builder.Services.AddSingleton<IScanAnalyzerDispatcher, CompositeScanAnalyzerDispatcher>();
builder.Services.AddSingleton<IScanStageExecutor, RegistrySecretStageExecutor>();
builder.Services.AddSingleton<IScanStageExecutor, AnalyzerStageExecutor>();

View File

@@ -30,6 +30,6 @@
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Analyzers.Native/StellaOps.Scanner.Analyzers.Native.csproj" />
<ProjectReference Include="../StellaOps.Scanner.Analyzers.Native/StellaOps.Scanner.Analyzers.Native.csproj" />
</ItemGroup>
</Project>

View File

@@ -3,4 +3,6 @@
| Task ID | Status | Notes | Updated (UTC) |
| --- | --- | --- | --- |
| SCAN-NL-0409-002 | DONE | OS analyzer surface-cache wiring + hit/miss metrics + worker tests updated to current APIs. | 2025-12-12 |
| SCAN-NATIVE-3500-0014 | DONE | Native analyzer stage integrated into dispatcher (discovery → emit → layer fragments) + unit tests for native stage execution. | 2025-12-19 |
| NAI-003 | DONE | Native analyzer stage wired into `CompositeScanAnalyzerDispatcher` (and Worker project references canonical `StellaOps.Scanner.Analyzers.Native` so `*.Index` types resolve). | 2025-12-19 |
| NAI-005 | DONE | Integration tests for native analyzer stage + fragment append behavior (`StellaOps.Scanner.Worker.Tests`). | 2025-12-19 |

View File

@@ -1,4 +1,7 @@
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.Scanner.Analyzers.Native.Index;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Native;
@@ -17,7 +20,143 @@ public sealed record NativeComponentEmitResult(
string? Version,
NativeBinaryMetadata Metadata,
bool IndexMatch,
BuildIdLookupResult? LookupResult);
BuildIdLookupResult? LookupResult)
{
public ComponentRecord ToComponentRecord(string layerDigest)
{
ArgumentException.ThrowIfNullOrWhiteSpace(layerDigest);
ArgumentNullException.ThrowIfNull(Metadata);
var fileName = string.IsNullOrWhiteSpace(Name)
? Path.GetFileName(Metadata.FilePath)
: Name.Trim();
if (string.IsNullOrWhiteSpace(fileName))
{
fileName = Purl;
}
var properties = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["stellaops:binary.format"] = Metadata.Format,
["stellaops:binary.indexMatch"] = IndexMatch ? "true" : "false",
};
AddIfNotEmpty(properties, "stellaops:binary.architecture", Metadata.Architecture);
AddIfNotEmpty(properties, "stellaops:binary.platform", Metadata.Platform);
AddIfNotEmpty(properties, "stellaops:binary.filePath", Metadata.FilePath);
AddIfNotEmpty(properties, "stellaops:binary.fileDigest", Metadata.FileDigest);
if (Metadata.FileSize > 0)
{
properties["stellaops:binary.fileSizeBytes"] = Metadata.FileSize.ToString(CultureInfo.InvariantCulture);
}
if (Metadata.LayerIndex >= 0)
{
properties["stellaops:binary.layerIndex"] = Metadata.LayerIndex.ToString(CultureInfo.InvariantCulture);
}
if (Metadata.Is64Bit)
{
properties["stellaops:binary.is64Bit"] = "true";
}
if (Metadata.IsSigned)
{
properties["stellaops:binary.isSigned"] = "true";
}
AddIfNotEmpty(properties, "stellaops:binary.signatureDetails", Metadata.SignatureDetails);
AddIfNotEmpty(properties, "stellaops:binary.productVersion", Metadata.ProductVersion);
AddIfNotEmpty(properties, "stellaops:binary.fileVersion", Metadata.FileVersion);
AddIfNotEmpty(properties, "stellaops:binary.companyName", Metadata.CompanyName);
AddDictionary(properties, "stellaops:binary.hardeningFlags", Metadata.HardeningFlags);
AddList(properties, "stellaops:binary.imports", Metadata.Imports);
AddList(properties, "stellaops:binary.exports", Metadata.Exports);
if (LookupResult is not null)
{
AddIfNotEmpty(properties, "stellaops:binary.index.sourceDistro", LookupResult.SourceDistro);
properties["stellaops:binary.index.confidence"] = LookupResult.Confidence.ToString();
}
var componentMetadata = new ComponentMetadata
{
BuildId = Metadata.BuildId,
Properties = properties.Count == 0 ? null : properties,
};
return new ComponentRecord
{
Identity = ComponentIdentity.Create(
key: Purl,
name: fileName,
version: Version,
purl: Purl,
componentType: "file"),
LayerDigest = layerDigest,
Evidence = ImmutableArray.Create(ComponentEvidence.FromPath(Metadata.FilePath)),
Dependencies = ImmutableArray<string>.Empty,
Metadata = componentMetadata,
Usage = ComponentUsage.Unused,
};
}
private static void AddIfNotEmpty(IDictionary<string, string> properties, string key, string? value)
{
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
{
return;
}
properties[key] = value.Trim();
}
private static void AddDictionary(IDictionary<string, string> properties, string key, IReadOnlyDictionary<string, string>? dictionary)
{
if (dictionary is null || dictionary.Count == 0)
{
return;
}
var entries = dictionary
.Where(pair => !string.IsNullOrWhiteSpace(pair.Key) && !string.IsNullOrWhiteSpace(pair.Value))
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}")
.ToArray();
if (entries.Length == 0)
{
return;
}
properties[key] = string.Join(",", entries);
}
private static void AddList(IDictionary<string, string> properties, string key, IReadOnlyList<string>? items)
{
if (items is null || items.Count == 0)
{
return;
}
var normalized = items
.Where(static item => !string.IsNullOrWhiteSpace(item))
.Select(static item => item.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(static item => item, StringComparer.Ordinal)
.ToArray();
if (normalized.Length == 0)
{
return;
}
properties[key] = string.Join(",", normalized);
}
}
/// <summary>
/// Interface for emitting native binary components for SBOM generation.

View File

@@ -6,6 +6,7 @@
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Analyzers.Native.Index;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Native;
@@ -183,7 +184,15 @@ public sealed record LayerComponentMapping(
IReadOnlyList<NativeComponentEmitResult> Components,
int TotalCount,
int ResolvedCount,
int UnresolvedCount);
int UnresolvedCount)
{
public LayerComponentFragment ToFragment()
{
return LayerComponentFragment.Create(
LayerDigest,
Components.Select(component => component.ToComponentRecord(LayerDigest)));
}
}
/// <summary>
/// Result of mapping an entire container image to SBOM components.

View File

@@ -0,0 +1,5 @@
# Scanner Emit Local Tasks
| Task ID | Sprint | Status | Notes |
| --- | --- | --- | --- |
| `BSE-009` | `docs/implplan/SPRINT_3500_0012_0001_binary_sbom_emission.md` | DONE | Added end-to-end integration test coverage for native binary SBOM emission (emit → fragments → CycloneDX). |

View File

@@ -6,6 +6,12 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Cryptography;
using StellaOps.Scanner.Cache.Abstractions;
using StellaOps.Scanner.ProofSpine;
namespace StellaOps.Scanner.Reachability.Attestation;
@@ -25,7 +31,16 @@ public static class ReachabilityAttestationServiceCollectionExtensions
services.TryAddSingleton<ReachabilityWitnessDsseBuilder>();
// Register publisher
services.TryAddSingleton<IReachabilityWitnessPublisher, ReachabilityWitnessPublisher>();
services.TryAddSingleton<IReachabilityWitnessPublisher>(sp =>
new ReachabilityWitnessPublisher(
sp.GetRequiredService<IOptions<ReachabilityWitnessOptions>>(),
sp.GetRequiredService<ICryptoHash>(),
sp.GetRequiredService<ILogger<ReachabilityWitnessPublisher>>(),
timeProvider: sp.GetService<TimeProvider>(),
cas: sp.GetService<IFileContentAddressableStore>(),
dsseSigningService: sp.GetService<IDsseSigningService>(),
cryptoProfile: sp.GetService<ICryptoProfile>(),
rekorClient: sp.GetService<IRekorClient>()));
// Register attesting writer (wraps RichGraphWriter)
services.TryAddSingleton<AttestingRichGraphWriter>();

View File

@@ -1,6 +1,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Cryptography;
using StellaOps.Replay.Core;
namespace StellaOps.Scanner.Reachability.Attestation;
@@ -13,14 +13,6 @@ public sealed class ReachabilityWitnessDsseBuilder
private readonly ICryptoHash _cryptoHash;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions CanonicalJsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
/// <summary>
/// Creates a new DSSE builder.
/// </summary>
@@ -98,7 +90,7 @@ public sealed class ReachabilityWitnessDsseBuilder
public byte[] SerializeStatement(InTotoStatement statement)
{
ArgumentNullException.ThrowIfNull(statement);
return JsonSerializer.SerializeToUtf8Bytes(statement, CanonicalJsonOptions);
return CanonicalJson.SerializeToUtf8Bytes(statement);
}
/// <summary>

View File

@@ -16,6 +16,16 @@ public sealed class ReachabilityWitnessOptions
/// <summary>Whether to publish to Rekor transparency log</summary>
public bool PublishToRekor { get; set; } = true;
/// <summary>
/// Rekor backend base URL (required when <see cref="PublishToRekor"/> is enabled and tier is not air-gapped).
/// </summary>
public Uri? RekorUrl { get; set; }
/// <summary>
/// Rekor backend name used for labeling/logging.
/// </summary>
public string RekorBackendName { get; set; } = "primary";
/// <summary>Whether to store graph in CAS</summary>
public bool StoreInCas { get; set; } = true;

View File

@@ -1,6 +1,14 @@
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Replay.Core;
using StellaOps.Scanner.Cache.Abstractions;
using StellaOps.Scanner.ProofSpine;
namespace StellaOps.Scanner.Reachability.Attestation;
@@ -13,6 +21,14 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
private readonly ReachabilityWitnessDsseBuilder _dsseBuilder;
private readonly ICryptoHash _cryptoHash;
private readonly ILogger<ReachabilityWitnessPublisher> _logger;
private readonly IFileContentAddressableStore? _cas;
private readonly IDsseSigningService? _dsseSigningService;
private readonly ICryptoProfile? _cryptoProfile;
private readonly IRekorClient? _rekorClient;
private static readonly JsonSerializerOptions DsseJsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
/// <summary>
/// Creates a new reachability witness publisher.
@@ -21,7 +37,11 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
IOptions<ReachabilityWitnessOptions> options,
ICryptoHash cryptoHash,
ILogger<ReachabilityWitnessPublisher> logger,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
IFileContentAddressableStore? cas = null,
IDsseSigningService? dsseSigningService = null,
ICryptoProfile? cryptoProfile = null,
IRekorClient? rekorClient = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(cryptoHash);
@@ -31,6 +51,10 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
_cryptoHash = cryptoHash;
_logger = logger;
_dsseBuilder = new ReachabilityWitnessDsseBuilder(cryptoHash, timeProvider);
_cas = cas;
_dsseSigningService = dsseSigningService;
_cryptoProfile = cryptoProfile;
_rekorClient = rekorClient;
}
/// <inheritdoc />
@@ -61,11 +85,13 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
}
string? casUri = null;
string? casKey = null;
// Step 1: Store graph in CAS (if enabled)
if (_options.StoreInCas)
{
casUri = await StoreInCasAsync(graphBytes, graphHash, cancellationToken).ConfigureAwait(false);
casKey = ExtractHashDigest(graphHash);
casUri = await StoreInCasAsync(graphBytes, casKey, cancellationToken).ConfigureAwait(false);
}
// Step 2: Build in-toto statement
@@ -86,8 +112,14 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
graph.Nodes.Count,
graph.Edges.Count);
// Step 3: Create DSSE envelope (placeholder - actual signing via Attestor service)
var dsseEnvelope = CreateDsseEnvelope(statementBytes);
// Step 3: Create DSSE envelope (signed where configured; deterministic fallback otherwise).
var (envelope, dsseEnvelopeBytes) = await CreateDsseEnvelopeAsync(statement, statementBytes, cancellationToken)
.ConfigureAwait(false);
if (_options.StoreInCas && casKey is not null)
{
await StoreDsseInCasAsync(dsseEnvelopeBytes, casKey, cancellationToken).ConfigureAwait(false);
}
// Step 4: Submit to Rekor (if enabled and not air-gapped)
long? rekorLogIndex = null;
@@ -95,7 +127,7 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
if (_options.PublishToRekor && _options.Tier != AttestationTier.AirGapped)
{
(rekorLogIndex, rekorLogId) = await SubmitToRekorAsync(dsseEnvelope, cancellationToken).ConfigureAwait(false);
(rekorLogIndex, rekorLogId) = await SubmitToRekorAsync(envelope, dsseEnvelopeBytes, cancellationToken).ConfigureAwait(false);
}
else if (_options.Tier == AttestationTier.AirGapped)
{
@@ -108,40 +140,157 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
CasUri: casUri,
RekorLogIndex: rekorLogIndex,
RekorLogId: rekorLogId,
DsseEnvelopeBytes: dsseEnvelope);
DsseEnvelopeBytes: dsseEnvelopeBytes);
}
private Task<string?> StoreInCasAsync(byte[] graphBytes, string graphHash, CancellationToken cancellationToken)
private async Task<string?> StoreInCasAsync(byte[] graphBytes, string casKey, CancellationToken cancellationToken)
{
// TODO: Integrate with actual CAS storage (BID-007)
// For now, return a placeholder CAS URI based on hash
var casUri = $"cas://local/{graphHash}";
_logger.LogDebug("Stored graph in CAS: {CasUri}", casUri);
return Task.FromResult<string?>(casUri);
}
private byte[] CreateDsseEnvelope(byte[] statementBytes)
{
// TODO: Integrate with Attestor DSSE signing service (RWD-008)
// For now, return unsigned envelope structure
// In production, this would call the Attestor service to sign the statement
// Minimal DSSE envelope structure (unsigned)
var envelope = new
if (_cas is null)
{
payloadType = "application/vnd.in-toto+json",
payload = Convert.ToBase64String(statementBytes),
signatures = Array.Empty<object>() // Will be populated by Attestor
_logger.LogWarning("CAS storage requested but no CAS store is configured; skipping graph CAS publication.");
return null;
}
var existing = await _cas.TryGetAsync(casKey, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
await using var stream = new MemoryStream(graphBytes, writable: false);
await _cas.PutAsync(new FileCasPutRequest(casKey, stream, leaveOpen: false), cancellationToken).ConfigureAwait(false);
}
var casUri = $"cas://reachability/graphs/{casKey}";
_logger.LogDebug("Stored graph in CAS: {CasUri}", casUri);
return casUri;
}
private async Task StoreDsseInCasAsync(byte[] dsseBytes, string casKey, CancellationToken cancellationToken)
{
if (_cas is null)
{
return;
}
var key = $"{casKey}.dsse";
var existing = await _cas.TryGetAsync(key, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
return;
}
await using var stream = new MemoryStream(dsseBytes, writable: false);
await _cas.PutAsync(new FileCasPutRequest(key, stream, leaveOpen: false), cancellationToken).ConfigureAwait(false);
}
private async Task<(DsseEnvelope Envelope, byte[] EnvelopeBytes)> CreateDsseEnvelopeAsync(
InTotoStatement statement,
byte[] statementBytes,
CancellationToken cancellationToken)
{
const string payloadType = "application/vnd.in-toto+json";
if (_dsseSigningService is not null)
{
var profile = _cryptoProfile ?? new InlineCryptoProfile(_options.SigningKeyId ?? "scanner-deterministic", "hs256");
var signed = await _dsseSigningService.SignAsync(statement, payloadType, profile, cancellationToken).ConfigureAwait(false);
return (signed, SerializeDsseEnvelope(signed));
}
// Deterministic fallback signature: SHA-256 over the canonical statement bytes (no external key material).
var signature = SHA256.HashData(statementBytes);
var envelope = new DsseEnvelope(
payloadType,
Convert.ToBase64String(statementBytes),
new[] { new DsseSignature(_options.SigningKeyId ?? "scanner-deterministic", Convert.ToBase64String(signature)) });
return (envelope, SerializeDsseEnvelope(envelope));
}
private async Task<(long? logIndex, string? logId)> SubmitToRekorAsync(
DsseEnvelope envelope,
byte[] envelopeBytes,
CancellationToken cancellationToken)
{
if (_rekorClient is null)
{
_logger.LogWarning("Rekor submission requested but no Rekor client is configured; skipping.");
return (null, null);
}
if (_options.RekorUrl is null)
{
_logger.LogWarning("Rekor submission requested but no RekorUrl is configured; skipping.");
return (null, null);
}
var request = new AttestorSubmissionRequest();
request.Bundle.Dsse.PayloadType = envelope.PayloadType;
request.Bundle.Dsse.PayloadBase64 = envelope.Payload;
request.Bundle.Dsse.Signatures.Clear();
foreach (var signature in envelope.Signatures)
{
request.Bundle.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
{
KeyId = signature.KeyId,
Signature = signature.Sig
});
}
request.Meta.BundleSha256 = ComputeSha256Hex(envelopeBytes);
request.Meta.LogPreference = _options.RekorBackendName;
var backend = new RekorBackend
{
Name = _options.RekorBackendName,
Url = _options.RekorUrl
};
return System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(envelope);
try
{
var response = await _rekorClient.SubmitAsync(request, backend, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(response.Uuid))
{
_logger.LogInformation("Submitted reachability witness envelope to Rekor backend {Backend} as {Uuid}", backend.Name, response.Uuid);
}
return (response.Index, response.Uuid);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to submit reachability witness envelope to Rekor backend {Backend}", backend.Name);
return (null, null);
}
}
private Task<(long? logIndex, string? logId)> SubmitToRekorAsync(byte[] dsseEnvelope, CancellationToken cancellationToken)
private static string ExtractHashDigest(string prefixedHash)
{
// TODO: Integrate with Rekor backend (RWD-008)
// For now, return placeholder values
_logger.LogDebug("Rekor submission placeholder - actual integration pending");
return Task.FromResult<(long?, string?)>((null, null));
var colonIndex = prefixedHash.IndexOf(':');
return colonIndex >= 0 ? prefixedHash[(colonIndex + 1)..] : prefixedHash;
}
private static string ComputeSha256Hex(ReadOnlySpan<byte> data)
{
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(data, hash);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static byte[] SerializeDsseEnvelope(DsseEnvelope envelope)
{
var signatures = envelope.Signatures
.OrderBy(static s => s.KeyId, StringComparer.Ordinal)
.ThenBy(static s => s.Sig, StringComparer.Ordinal)
.Select(static s => new { keyid = s.KeyId, sig = s.Sig })
.ToArray();
var dto = new
{
payloadType = envelope.PayloadType,
payload = envelope.Payload,
signatures
};
return JsonSerializer.SerializeToUtf8Bytes(dto, DsseJsonOptions);
}
private sealed record InlineCryptoProfile(string KeyId, string Algorithm) : ICryptoProfile;
}

View File

@@ -6,9 +6,11 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.ProofSpine\StellaOps.Scanner.ProofSpine.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Surface.Env\StellaOps.Scanner.Surface.Env.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.SmartDiff\StellaOps.Scanner.SmartDiff.csproj" />
<ProjectReference Include="..\..\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
<ProjectReference Include="..\..\..\Attestor\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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]

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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"));
}
}

View File

@@ -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)

View File

@@ -156,15 +156,7 @@ public sealed record UnknownItem(
double score,
string? proofRef = null)
{
// Extract reasons from context/kind
var reasons = unknown.Kind switch
{
UnknownKind.MissingVex => ["missing_vex"],
UnknownKind.AmbiguousIndirect => ["ambiguous_indirect_call"],
UnknownKind.NoGraph => ["no_dependency_graph"],
UnknownKind.StaleEvidence => ["stale_evidence"],
_ => [unknown.Kind.ToString().ToLowerInvariant()]
};
var reasons = new[] { unknown.Kind.ToString().ToLowerInvariant() };
return new UnknownItem(
Id: unknown.Id.ToString(),

View File

@@ -18,11 +18,14 @@ namespace StellaOps.Unknowns.Core.Services;
public sealed class NativeUnknownClassifier
{
private readonly TimeProvider _timeProvider;
private readonly string _createdBy;
public NativeUnknownClassifier(TimeProvider timeProvider)
public NativeUnknownClassifier(TimeProvider timeProvider, string createdBy = "unknowns")
{
ArgumentNullException.ThrowIfNull(timeProvider);
ArgumentException.ThrowIfNullOrWhiteSpace(createdBy);
_timeProvider = timeProvider;
_createdBy = createdBy;
}
/// <summary>
@@ -49,7 +52,9 @@ public sealed class NativeUnknownClassifier
Severity = UnknownSeverity.Medium,
Context = SerializeContext(context with { ClassifiedAt = now }),
ValidFrom = now,
SysFrom = now
SysFrom = now,
CreatedAt = now,
CreatedBy = _createdBy
};
}
@@ -82,7 +87,9 @@ public sealed class NativeUnknownClassifier
Severity = UnknownSeverity.Low,
Context = SerializeContext(context with { ClassifiedAt = now }),
ValidFrom = now,
SysFrom = now
SysFrom = now,
CreatedAt = now,
CreatedBy = _createdBy
};
}
@@ -115,7 +122,9 @@ public sealed class NativeUnknownClassifier
Severity = UnknownSeverity.Low,
Context = SerializeContext(context with { ClassifiedAt = now }),
ValidFrom = now,
SysFrom = now
SysFrom = now,
CreatedAt = now,
CreatedBy = _createdBy
};
}
@@ -156,7 +165,9 @@ public sealed class NativeUnknownClassifier
Severity = severity,
Context = SerializeContext(context with { ClassifiedAt = now }),
ValidFrom = now,
SysFrom = now
SysFrom = now,
CreatedAt = now,
CreatedBy = _createdBy
};
}
@@ -184,7 +195,9 @@ public sealed class NativeUnknownClassifier
Severity = UnknownSeverity.Info,
Context = SerializeContext(context with { ClassifiedAt = now }),
ValidFrom = now,
SysFrom = now
SysFrom = now,
CreatedAt = now,
CreatedBy = _createdBy
};
}

View File

@@ -11,5 +11,12 @@
<Description>Core domain models and abstractions for the StellaOps Unknowns module (bitemporal ambiguity tracking)</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../Policy/__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
</ItemGroup>
</Project>