feat: Add initial implementation of Vulnerability Resolver Jobs
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Created project for StellaOps.Scanner.Analyzers.Native.Tests with necessary dependencies.
- Documented roles and guidelines in AGENTS.md for Scheduler module.
- Implemented IResolverJobService interface and InMemoryResolverJobService for handling resolver jobs.
- Added ResolverBacklogNotifier and ResolverBacklogService for monitoring job metrics.
- Developed API endpoints for managing resolver jobs and retrieving metrics.
- Defined models for resolver job requests and responses.
- Integrated dependency injection for resolver job services.
- Implemented ImpactIndexSnapshot for persisting impact index data.
- Introduced SignalsScoringOptions for configurable scoring weights in reachability scoring.
- Added unit tests for ReachabilityScoringService and RuntimeFactsIngestionService.
- Created dotnet-filter.sh script to handle command-line arguments for dotnet.
- Established nuget-prime project for managing package downloads.
This commit is contained in:
master
2025-11-18 07:52:15 +02:00
parent e69b57d467
commit 8355e2ff75
299 changed files with 13293 additions and 2444 deletions

View File

@@ -0,0 +1,16 @@
# stella-forensic-verify (preview)
Minimal dotnet tool for offline HMAC verification of provenance payloads.
## Usage
/mnt/e/dev/git.stella-ops.org /mnt/e/dev/git.stella-ops.org
/mnt/e/dev/git.stella-ops.org
Outputs deterministic JSON:
## Exit codes
- 0: signature valid
- 2: signature invalid
- 1: bad arguments/hex parse failure

View File

@@ -1,7 +1,7 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Linq;
using System.Security.Cryptography;
namespace StellaOps.Provenance.Attestation;
@@ -111,3 +111,35 @@ public static class BuildStatementFactory
{
public static BuildStatement Create(BuildDefinition definition, BuildMetadata metadata) => new(definition, metadata);
}
public static class BuildStatementDigest
{
public static byte[] ComputeSha256(BuildStatement statement)
{
ArgumentNullException.ThrowIfNull(statement);
var canonicalBytes = CanonicalJson.SerializeToUtf8Bytes(statement);
return SHA256.HashData(canonicalBytes);
}
public static string ComputeSha256Hex(BuildStatement statement)
{
return Convert.ToHexString(ComputeSha256(statement)).ToLowerInvariant();
}
public static byte[] ComputeMerkleRoot(IEnumerable<BuildStatement> statements)
{
ArgumentNullException.ThrowIfNull(statements);
var leaves = statements.Select(ComputeSha256).ToArray();
if (leaves.Length == 0)
{
throw new ArgumentException("At least one build statement required", nameof(statements));
}
return MerkleTree.ComputeRoot(leaves);
}
public static string ComputeMerkleRootHex(IEnumerable<BuildStatement> statements)
{
return Convert.ToHexString(ComputeMerkleRoot(statements)).ToLowerInvariant();
}
}

View File

@@ -18,12 +18,14 @@ public interface IKeyProvider
{
string KeyId { get; }
byte[] KeyMaterial { get; }
DateTimeOffset? NotAfter { get; }
}
public interface IAuditSink
{
void LogSigned(string keyId, string contentType, IReadOnlyDictionary<string, string>? claims, DateTimeOffset signedAt);
void LogMissingClaim(string keyId, string claimName);
void LogKeyRotation(string previousKeyId, string nextKeyId, DateTimeOffset rotatedAt);
}
public sealed class NullAuditSink : IAuditSink
@@ -32,6 +34,7 @@ public sealed class NullAuditSink : IAuditSink
private NullAuditSink() { }
public void LogSigned(string keyId, string contentType, IReadOnlyDictionary<string, string>? claims, DateTimeOffset signedAt) { }
public void LogMissingClaim(string keyId, string claimName) { }
public void LogKeyRotation(string previousKeyId, string nextKeyId, DateTimeOffset rotatedAt) { }
}
public sealed class HmacSigner : ISigner
@@ -86,11 +89,13 @@ public sealed class InMemoryKeyProvider : IKeyProvider
{
public string KeyId { get; }
public byte[] KeyMaterial { get; }
public DateTimeOffset? NotAfter { get; }
public InMemoryKeyProvider(string keyId, byte[] keyMaterial)
public InMemoryKeyProvider(string keyId, byte[] keyMaterial, DateTimeOffset? notAfter = null)
{
KeyId = keyId ?? throw new ArgumentNullException(nameof(keyId));
KeyMaterial = keyMaterial ?? throw new ArgumentNullException(nameof(keyMaterial));
NotAfter = notAfter;
}
}
@@ -98,10 +103,145 @@ public sealed class InMemoryAuditSink : IAuditSink
{
public List<(string keyId, string contentType, IReadOnlyDictionary<string, string>? claims, DateTimeOffset signedAt)> Signed { get; } = new();
public List<(string keyId, string claim)> Missing { get; } = new();
public List<(string previousKeyId, string nextKeyId, DateTimeOffset rotatedAt)> Rotations { get; } = new();
public void LogSigned(string keyId, string contentType, IReadOnlyDictionary<string, string>? claims, DateTimeOffset signedAt)
=> Signed.Add((keyId, contentType, claims, signedAt));
public void LogMissingClaim(string keyId, string claimName)
=> Missing.Add((keyId, claimName));
public void LogKeyRotation(string previousKeyId, string nextKeyId, DateTimeOffset rotatedAt)
=> Rotations.Add((previousKeyId, nextKeyId, rotatedAt));
}
public sealed class RotatingKeyProvider : IKeyProvider
{
private readonly IReadOnlyList<IKeyProvider> _keys;
private readonly TimeProvider _timeProvider;
private readonly IAuditSink _audit;
private string _activeKeyId;
public RotatingKeyProvider(IEnumerable<IKeyProvider> keys, TimeProvider? timeProvider = null, IAuditSink? audit = null)
{
_keys = keys?.ToList() ?? throw new ArgumentNullException(nameof(keys));
if (_keys.Count == 0) throw new ArgumentException("At least one key is required", nameof(keys));
_timeProvider = timeProvider ?? TimeProvider.System;
_audit = audit ?? NullAuditSink.Instance;
_activeKeyId = _keys[0].KeyId;
}
private IKeyProvider ResolveActive()
{
var now = _timeProvider.GetUtcNow();
var next = _keys
.OrderByDescending(k => k.NotAfter ?? DateTimeOffset.MaxValue)
.First(k => !k.NotAfter.HasValue || k.NotAfter.Value >= now);
if (!string.Equals(next.KeyId, _activeKeyId, StringComparison.Ordinal))
{
_audit.LogKeyRotation(_activeKeyId, next.KeyId, now);
_activeKeyId = next.KeyId;
}
return next;
}
public string KeyId => ResolveActive().KeyId;
public byte[] KeyMaterial => ResolveActive().KeyMaterial;
public DateTimeOffset? NotAfter => ResolveActive().NotAfter;
}
public interface ICosignClient
{
Task<byte[]> SignAsync(byte[] payload, string contentType, string keyRef, CancellationToken cancellationToken);
}
public interface IKmsClient
{
Task<byte[]> SignAsync(byte[] payload, string contentType, string keyId, CancellationToken cancellationToken);
}
public sealed class CosignSigner : ISigner
{
private readonly string _keyRef;
private readonly ICosignClient _client;
private readonly IAuditSink _audit;
private readonly TimeProvider _timeProvider;
public CosignSigner(string keyRef, ICosignClient client, IAuditSink? audit = null, TimeProvider? timeProvider = null)
{
_keyRef = string.IsNullOrWhiteSpace(keyRef) ? throw new ArgumentException("Key reference required", nameof(keyRef)) : keyRef;
_client = client ?? throw new ArgumentNullException(nameof(client));
_audit = audit ?? NullAuditSink.Instance;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<SignResult> SignAsync(SignRequest request, CancellationToken cancellationToken = default)
{
if (request is null) throw new ArgumentNullException(nameof(request));
EnforceClaims(request);
var signature = await _client.SignAsync(request.Payload, request.ContentType, _keyRef, cancellationToken).ConfigureAwait(false);
var signedAt = _timeProvider.GetUtcNow();
_audit.LogSigned(_keyRef, request.ContentType, request.Claims, signedAt);
return new SignResult(signature, _keyRef, signedAt, request.Claims);
}
private void EnforceClaims(SignRequest request)
{
if (request.RequiredClaims is null)
{
return;
}
foreach (var required in request.RequiredClaims)
{
if (request.Claims is null || !request.Claims.ContainsKey(required))
{
_audit.LogMissingClaim(_keyRef, required);
throw new InvalidOperationException($"Missing required claim {required}.");
}
}
}
}
public sealed class KmsSigner : ISigner
{
private readonly IKmsClient _client;
private readonly IKeyProvider _keyProvider;
private readonly IAuditSink _audit;
private readonly TimeProvider _timeProvider;
public KmsSigner(IKmsClient client, IKeyProvider keyProvider, IAuditSink? audit = null, TimeProvider? timeProvider = null)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_keyProvider = keyProvider ?? throw new ArgumentNullException(nameof(keyProvider));
_audit = audit ?? NullAuditSink.Instance;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<SignResult> SignAsync(SignRequest request, CancellationToken cancellationToken = default)
{
if (request is null) throw new ArgumentNullException(nameof(request));
if (request.RequiredClaims is not null)
{
foreach (var required in request.RequiredClaims)
{
if (request.Claims is null || !request.Claims.ContainsKey(required))
{
_audit.LogMissingClaim(_keyProvider.KeyId, required);
throw new InvalidOperationException($"Missing required claim {required}.");
}
}
}
var signature = await _client.SignAsync(request.Payload, request.ContentType, _keyProvider.KeyId, cancellationToken).ConfigureAwait(false);
var signedAt = _timeProvider.GetUtcNow();
_audit.LogSigned(_keyProvider.KeyId, request.ContentType, request.Claims, signedAt);
return new SignResult(signature, _keyProvider.KeyId, signedAt, request.Claims);
}
}

View File

@@ -32,4 +32,9 @@ public sealed class HmacVerifier : IVerifier
var result = new VerificationResult(
IsValid: ok,
Reason: ok ? ok : signature
Reason: ok ? "verified" : "signature mismatch",
VerifiedAt: _timeProvider.GetUtcNow());
return Task.FromResult(result);
}
}

View File

@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using StellaOps.Provenance.Attestation;
using Xunit;
namespace StellaOps.Provenance.Attestation.Tests;
public class CosignAndKmsSignerTests
{
private sealed class FakeCosignClient : ICosignClient
{
public List<(byte[] payload, string contentType, string keyRef)> Calls { get; } = new();
public Task<byte[]> SignAsync(byte[] payload, string contentType, string keyRef, CancellationToken cancellationToken)
{
Calls.Add((payload, contentType, keyRef));
return Task.FromResult(Encoding.UTF8.GetBytes("cosign-" + keyRef));
}
}
private sealed class FakeKmsClient : IKmsClient
{
public List<(byte[] payload, string contentType, string keyId)> Calls { get; } = new();
public Task<byte[]> SignAsync(byte[] payload, string contentType, string keyId, CancellationToken cancellationToken)
{
Calls.Add((payload, contentType, keyId));
return Task.FromResult(Encoding.UTF8.GetBytes("kms-" + keyId));
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
}
[Fact]
public async Task CosignSigner_enforces_required_claims_and_logs()
{
var client = new FakeCosignClient();
var audit = new InMemoryAuditSink();
var signer = new CosignSigner("cosign-key", client, audit, new FixedTimeProvider(DateTimeOffset.UnixEpoch));
var request = new SignRequest(
Payload: Encoding.UTF8.GetBytes("payload"),
ContentType: "application/vnd.dsse",
Claims: new Dictionary<string, string> { ["sub"] = "artifact" },
RequiredClaims: new[] { "sub" });
var result = await signer.SignAsync(request);
result.KeyId.Should().Be("cosign-key");
result.Signature.Should().BeEquivalentTo(Encoding.UTF8.GetBytes("cosign-cosign-key"));
audit.Signed.Should().ContainSingle();
client.Calls.Should().ContainSingle(call => call.keyRef == "cosign-key" && call.contentType == "application/vnd.dsse");
}
[Fact]
public async Task CosignSigner_throws_on_missing_required_claim()
{
var client = new FakeCosignClient();
var audit = new InMemoryAuditSink();
var signer = new CosignSigner("cosign-key", client, audit, new FixedTimeProvider(DateTimeOffset.UnixEpoch));
var request = new SignRequest(
Payload: Encoding.UTF8.GetBytes("payload"),
ContentType: "application/vnd.dsse",
Claims: new Dictionary<string, string>(),
RequiredClaims: new[] { "sub" });
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => signer.SignAsync(request));
ex.Message.Should().Contain("sub");
audit.Missing.Should().ContainSingle(m => m.claim == "sub");
}
[Fact]
public async Task KmsSigner_signs_with_current_key_and_logs()
{
var kms = new FakeKmsClient();
var key = new InMemoryKeyProvider("kms-key-1", Encoding.UTF8.GetBytes("secret-kms"));
var audit = new InMemoryAuditSink();
var signer = new KmsSigner(kms, key, audit, new FixedTimeProvider(DateTimeOffset.UnixEpoch));
var request = new SignRequest(Encoding.UTF8.GetBytes("payload"), "application/vnd.dsse");
var result = await signer.SignAsync(request);
result.KeyId.Should().Be("kms-key-1");
result.Signature.Should().BeEquivalentTo(Encoding.UTF8.GetBytes("kms-kms-key-1"));
audit.Signed.Should().ContainSingle();
kms.Calls.Should().ContainSingle(call => call.keyId == "kms-key-1");
}
}

View File

@@ -0,0 +1,42 @@
using System;
using System.Text;
using System.Threading.Tasks;
using FluentAssertions;
using StellaOps.Provenance.Attestation;
using Xunit;
namespace StellaOps.Provenance.Attestation.Tests;
public sealed class RotatingSignerTests
{
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset now) => _now = now;
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
public override DateTimeOffset GetUtcNow() => _now;
}
[Fact]
public async Task Rotates_to_newest_unexpired_key_and_logs_rotation()
{
var t = new TestTimeProvider(DateTimeOffset.Parse("2025-11-17T00:00:00Z"));
var keyOld = new InMemoryKeyProvider("k1", Encoding.UTF8.GetBytes("old"), t.GetUtcNow().AddMinutes(-1));
var keyNew = new InMemoryKeyProvider("k2", Encoding.UTF8.GetBytes("new"), t.GetUtcNow().AddHours(1));
var audit = new InMemoryAuditSink();
var rotating = new RotatingKeyProvider(new[] { keyOld, keyNew }, t, audit);
var signer = new HmacSigner(rotating, audit, t);
var req = new SignRequest(Encoding.UTF8.GetBytes("payload"), "text/plain");
var r1 = await signer.SignAsync(req);
r1.KeyId.Should().Be("k2");
audit.Rotations.Should().ContainSingle(r => r.previousKeyId == "k1" && r.nextKeyId == "k2");
t.Advance(TimeSpan.FromHours(2));
var r2 = await signer.SignAsync(req);
r2.KeyId.Should().Be("k2"); // stays on latest known key
audit.Rotations.Should().HaveCount(1);
}
}

View File

@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Provenance.Attestation;
using Xunit;
namespace StellaOps.Provenance.Attestation.Tests;
public class SampleStatementDigestTests
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = null,
WriteIndented = false
};
private static string RepoRoot
{
get
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir))
{
var candidate = Path.Combine(dir, "samples", "provenance");
if (Directory.Exists(candidate))
{
return dir;
}
var parent = Directory.GetParent(dir);
dir = parent?.FullName ?? string.Empty;
}
throw new DirectoryNotFoundException("Could not locate repository root containing samples/provenance.");
}
}
private static IEnumerable<(string Name, BuildStatement Statement)> LoadSamples()
{
var samplesDir = Path.Combine(RepoRoot, "samples", "provenance");
foreach (var path in Directory.EnumerateFiles(samplesDir, "*.json").OrderBy(p => p, StringComparer.Ordinal))
{
var json = File.ReadAllText(path);
var statement = JsonSerializer.Deserialize<BuildStatement>(json, SerializerOptions);
if (statement is null)
{
continue;
}
yield return (Path.GetFileName(path), statement);
}
}
[Fact]
public void Sha256_hashes_match_expected_samples()
{
var expectations = new Dictionary<string, string>(StringComparer.Ordinal)
{
["build-statement-sample.json"] = "7e458d1e5ba14f72432b3f76808e95d6ed82128c775870dd8608175e6c76a374",
["export-service-statement.json"] = "3124e44f042ad6071d965b7f03bb736417640680feff65f2f0d1c5bfb2e56ec6",
["job-runner-statement.json"] = "8b8b58d12685b52ab73d5b0abf4b3866126901ede7200128f0b22456a1ceb6fc",
["orchestrator-statement.json"] = "975501f7ee7f319adb6fa88d913b227f0fa09ac062620f03bb0f2b0834c4be8a"
};
foreach (var (name, statement) in LoadSamples())
{
BuildStatementDigest.ComputeSha256Hex(statement)
.Should()
.Be(expectations[name], because: $"{name} hash must be deterministic");
}
}
[Fact]
public void Merkle_root_is_stable_across_sample_set()
{
var statements = LoadSamples().Select(pair => pair.Statement).ToArray();
BuildStatementDigest.ComputeMerkleRootHex(statements)
.Should()
.Be("e3a89fe0d08e2b16a6c7f1feb1d82d9e7ef9e8b74363bf60da64f36078d80eea");
}
}

View File

@@ -8,7 +8,7 @@
<ItemGroup>
<ProjectReference Include="../../StellaOps.Provenance.Attestation/StellaOps.Provenance.Attestation.csproj" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.8" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
</Project>