feat: Add initial implementation of Vulnerability Resolver Jobs
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user