This commit is contained in:
master
2025-10-19 10:38:55 +03:00
parent 8dc7273e27
commit aef7ffb535
250 changed files with 17967 additions and 66 deletions

View File

@@ -0,0 +1,81 @@
using System.Text.Json;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Serialization;
using StellaOps.Scanner.Core.Utility;
using Xunit;
namespace StellaOps.Scanner.Core.Tests.Contracts;
public sealed class ScanJobTests
{
[Fact]
public void SerializeAndDeserialize_RoundTripsDeterministically()
{
var createdAt = new DateTimeOffset(2025, 10, 18, 14, 30, 15, TimeSpan.Zero);
var jobId = ScannerIdentifiers.CreateJobId("registry.example.com/stellaops/scanner:1.2.3", "sha256:ABCDEF", "tenant-a", "request-1");
var correlationId = ScannerIdentifiers.CreateCorrelationId(jobId, "enqueue");
var error = new ScannerError(
ScannerErrorCode.AnalyzerFailure,
ScannerErrorSeverity.Error,
"Analyzer crashed for layer sha256:abc",
createdAt,
retryable: false,
details: new Dictionary<string, string>
{
["stage"] = "analyze-os",
["layer"] = "sha256:abc"
});
var job = new ScanJob(
jobId,
ScanJobStatus.Running,
"registry.example.com/stellaops/scanner:1.2.3",
"SHA256:ABCDEF",
createdAt,
createdAt,
correlationId,
"tenant-a",
new Dictionary<string, string>
{
["requestId"] = "request-1"
},
error);
var json = JsonSerializer.Serialize(job, ScannerJsonOptions.CreateDefault());
var deserialized = JsonSerializer.Deserialize<ScanJob>(json, ScannerJsonOptions.CreateDefault());
Assert.NotNull(deserialized);
Assert.Equal(job.Id, deserialized!.Id);
Assert.Equal(job.ImageDigest, deserialized.ImageDigest);
Assert.Equal(job.CorrelationId, deserialized.CorrelationId);
Assert.Equal(job.Metadata["requestId"], deserialized.Metadata["requestId"]);
var secondJson = JsonSerializer.Serialize(deserialized, ScannerJsonOptions.CreateDefault());
Assert.Equal(json, secondJson);
}
[Fact]
public void WithStatus_UpdatesTimestampDeterministically()
{
var createdAt = new DateTimeOffset(2025, 10, 18, 14, 30, 15, 123, TimeSpan.Zero);
var jobId = ScannerIdentifiers.CreateJobId("example/scanner:latest", "sha256:def", null, null);
var correlationId = ScannerIdentifiers.CreateCorrelationId(jobId, "enqueue");
var job = new ScanJob(
jobId,
ScanJobStatus.Pending,
"example/scanner:latest",
"sha256:def",
createdAt,
null,
correlationId,
null,
null,
null);
var updated = job.WithStatus(ScanJobStatus.Running, createdAt.AddSeconds(5));
Assert.Equal(ScanJobStatus.Running, updated.Status);
Assert.Equal(ScannerTimestamps.Normalize(createdAt.AddSeconds(5)), updated.UpdatedAt);
}
}

View File

@@ -0,0 +1,39 @@
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Observability;
using StellaOps.Scanner.Core.Utility;
using Xunit;
namespace StellaOps.Scanner.Core.Tests.Observability;
public sealed class ScannerLogExtensionsTests
{
[Fact]
public void BeginScanScope_PopulatesCorrelationContext()
{
using var factory = LoggerFactory.Create(builder => builder.AddFilter(_ => true));
var logger = factory.CreateLogger("test");
var jobId = ScannerIdentifiers.CreateJobId("example/scanner:1.0", "sha256:abc", null, null);
var correlationId = ScannerIdentifiers.CreateCorrelationId(jobId, "enqueue");
var job = new ScanJob(
jobId,
ScanJobStatus.Pending,
"example/scanner:1.0",
"sha256:abc",
DateTimeOffset.UtcNow,
null,
correlationId,
null,
null,
null);
using (logger.BeginScanScope(job, "enqueue"))
{
Assert.True(ScannerCorrelationContextAccessor.TryGetCorrelationId(out var current));
Assert.Equal(correlationId, current);
}
Assert.False(ScannerCorrelationContextAccessor.TryGetCorrelationId(out _));
}
}

View File

@@ -0,0 +1,89 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Client;
using StellaOps.Scanner.Core.Security;
using Xunit;
namespace StellaOps.Scanner.Core.Tests.Security;
public sealed class AuthorityTokenSourceTests
{
[Fact]
public async Task GetAsync_ReusesCachedTokenUntilRefreshSkew()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
var client = new FakeTokenClient(timeProvider);
var source = new AuthorityTokenSource(client, TimeSpan.FromSeconds(30), timeProvider, NullLogger<AuthorityTokenSource>.Instance);
var token1 = await source.GetAsync("scanner", new[] { "scanner.read" });
Assert.Equal(1, client.RequestCount);
var token2 = await source.GetAsync("scanner", new[] { "scanner.read" });
Assert.Equal(1, client.RequestCount);
Assert.Equal(token1.AccessToken, token2.AccessToken);
timeProvider.Advance(TimeSpan.FromMinutes(3));
var token3 = await source.GetAsync("scanner", new[] { "scanner.read" });
Assert.Equal(2, client.RequestCount);
Assert.NotEqual(token1.AccessToken, token3.AccessToken);
}
[Fact]
public async Task InvalidateAsync_RemovesCachedToken()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
var client = new FakeTokenClient(timeProvider);
var source = new AuthorityTokenSource(client, TimeSpan.FromSeconds(30), timeProvider, NullLogger<AuthorityTokenSource>.Instance);
_ = await source.GetAsync("scanner", new[] { "scanner.read" });
Assert.Equal(1, client.RequestCount);
await source.InvalidateAsync("scanner", new[] { "scanner.read" });
_ = await source.GetAsync("scanner", new[] { "scanner.read" });
Assert.Equal(2, client.RequestCount);
}
private sealed class FakeTokenClient : IStellaOpsTokenClient
{
private readonly FakeTimeProvider timeProvider;
private int counter;
public FakeTokenClient(FakeTimeProvider timeProvider)
{
this.timeProvider = timeProvider;
}
public int RequestCount => counter;
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, CancellationToken cancellationToken = default)
{
var access = $"token-{Interlocked.Increment(ref counter)}";
var expires = timeProvider.GetUtcNow().AddMinutes(2);
var scopes = scope is null
? Array.Empty<string>()
: scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return Task.FromResult(new StellaOpsTokenResult(access, "Bearer", expires, scopes));
}
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,117 @@
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using Microsoft.Extensions.Time.Testing;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Scanner.Core.Security;
using Xunit;
namespace StellaOps.Scanner.Core.Tests.Security;
public sealed class DpopProofValidatorTests
{
[Fact]
public async Task ValidateAsync_ReturnsSuccess_ForValidProof()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
var validator = new DpopProofValidator(Options.Create(new DpopValidationOptions()), new InMemoryDpopReplayCache(timeProvider), timeProvider);
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var securityKey = new ECDsaSecurityKey(key) { KeyId = Guid.NewGuid().ToString("N") };
var proof = CreateProof(timeProvider, securityKey, "GET", new Uri("https://scanner.example.com/api/v1/scans"));
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://scanner.example.com/api/v1/scans"));
Assert.True(result.IsValid);
Assert.NotNull(result.PublicKey);
Assert.NotNull(result.JwtId);
}
[Fact]
public async Task ValidateAsync_Fails_OnNonceMismatch()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
var validator = new DpopProofValidator(Options.Create(new DpopValidationOptions()), new InMemoryDpopReplayCache(timeProvider), timeProvider);
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var securityKey = new ECDsaSecurityKey(key) { KeyId = Guid.NewGuid().ToString("N") };
var proof = CreateProof(timeProvider, securityKey, "POST", new Uri("https://scanner.example.com/api/v1/scans"), nonce: "expected");
var result = await validator.ValidateAsync(proof, "POST", new Uri("https://scanner.example.com/api/v1/scans"), nonce: "different");
Assert.False(result.IsValid);
Assert.Equal("invalid_token", result.ErrorCode);
}
[Fact]
public async Task ValidateAsync_Fails_OnReplay()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
var cache = new InMemoryDpopReplayCache(timeProvider);
var validator = new DpopProofValidator(Options.Create(new DpopValidationOptions()), cache, timeProvider);
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var securityKey = new ECDsaSecurityKey(key) { KeyId = Guid.NewGuid().ToString("N") };
var jti = Guid.NewGuid().ToString();
var proof = CreateProof(timeProvider, securityKey, "GET", new Uri("https://scanner.example.com/api/v1/scans"), jti: jti);
var first = await validator.ValidateAsync(proof, "GET", new Uri("https://scanner.example.com/api/v1/scans"));
Assert.True(first.IsValid);
var second = await validator.ValidateAsync(proof, "GET", new Uri("https://scanner.example.com/api/v1/scans"));
Assert.False(second.IsValid);
Assert.Equal("replay", second.ErrorCode);
}
private static string CreateProof(FakeTimeProvider timeProvider, ECDsaSecurityKey key, string method, Uri uri, string? nonce = null, string? jti = null)
{
var handler = new JwtSecurityTokenHandler();
var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(key);
var header = new JwtHeader(signingCredentials)
{
["typ"] = "dpop+jwt",
["jwk"] = new Dictionary<string, object?>
{
["kty"] = jwk.Kty,
["crv"] = jwk.Crv,
["x"] = jwk.X,
["y"] = jwk.Y
}
};
var payload = new JwtPayload
{
["htm"] = method.ToUpperInvariant(),
["htu"] = Normalize(uri),
["iat"] = timeProvider.GetUtcNow().ToUnixTimeSeconds(),
["jti"] = jti ?? Guid.NewGuid().ToString()
};
if (nonce is not null)
{
payload["nonce"] = nonce;
}
var token = new JwtSecurityToken(header, payload);
return handler.WriteToken(token);
}
private static string Normalize(Uri uri)
{
var builder = new UriBuilder(uri)
{
Fragment = string.Empty
};
builder.Host = builder.Host.ToLowerInvariant();
builder.Scheme = builder.Scheme.ToLowerInvariant();
if ((builder.Scheme == "http" && builder.Port == 80) || (builder.Scheme == "https" && builder.Port == 443))
{
builder.Port = -1;
}
return builder.Uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped);
}
}

View File

@@ -0,0 +1,26 @@
using System;
using StellaOps.Scanner.Core.Security;
using Xunit;
namespace StellaOps.Scanner.Core.Tests.Security;
public sealed class RestartOnlyPluginGuardTests
{
[Fact]
public void EnsureRegistrationAllowed_AllowsNewPluginsBeforeSeal()
{
var guard = new RestartOnlyPluginGuard();
guard.EnsureRegistrationAllowed("./plugins/analyzer.dll");
Assert.Contains(guard.KnownPlugins, path => path.EndsWith("analyzer.dll", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void EnsureRegistrationAllowed_ThrowsAfterSeal()
{
var guard = new RestartOnlyPluginGuard(new[] { "./plugins/a.dll" });
guard.Seal();
Assert.Throws<InvalidOperationException>(() => guard.EnsureRegistrationAllowed("./plugins/new.dll"));
}
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj" />
<ProjectReference Include="../StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,33 @@
using StellaOps.Scanner.Core.Utility;
using Xunit;
namespace StellaOps.Scanner.Core.Tests.Utility;
public sealed class ScannerIdentifiersTests
{
[Fact]
public void CreateJobId_IsDeterministicAndCaseInsensitive()
{
var first = ScannerIdentifiers.CreateJobId("registry.example.com/repo:latest", "SHA256:ABC", "Tenant-A", "salt");
var second = ScannerIdentifiers.CreateJobId("REGISTRY.EXAMPLE.COM/REPO:latest", "sha256:abc", "tenant-a", "salt");
Assert.Equal(first, second);
}
[Fact]
public void CreateDeterministicHash_ProducesLowercaseHex()
{
var hash = ScannerIdentifiers.CreateDeterministicHash("scan", "abc", "123");
Assert.Matches("^[0-9a-f]{64}$", hash);
Assert.Equal(hash, hash.ToLowerInvariant());
}
[Fact]
public void NormalizeImageReference_LowercasesRegistryAndRepository()
{
var normalized = ScannerIdentifiers.NormalizeImageReference("Registry.Example.com/StellaOps/Scanner:1.0");
Assert.Equal("registry.example.com/stellaops/scanner:1.0", normalized);
}
}

View File

@@ -0,0 +1,26 @@
using StellaOps.Scanner.Core.Utility;
using Xunit;
namespace StellaOps.Scanner.Core.Tests.Utility;
public sealed class ScannerTimestampsTests
{
[Fact]
public void Normalize_TrimsToMicroseconds()
{
var value = new DateTimeOffset(2025, 10, 18, 14, 30, 15, TimeSpan.Zero).AddTicks(7);
var normalized = ScannerTimestamps.Normalize(value);
var expectedTicks = value.UtcTicks - (value.UtcTicks % 10);
Assert.Equal(expectedTicks, normalized.UtcTicks);
}
[Fact]
public void ToIso8601_ProducesUtcString()
{
var value = new DateTimeOffset(2025, 10, 18, 14, 30, 15, TimeSpan.FromHours(-4));
var iso = ScannerTimestamps.ToIso8601(value);
Assert.Equal("2025-10-18T18:30:15.000000Z", iso);
}
}