feat: Add RustFS artifact object store and migration tool
- Implemented RustFsArtifactObjectStore for managing artifacts in RustFS. - Added unit tests for RustFsArtifactObjectStore functionality. - Created a RustFS migrator tool to transfer objects from S3 to RustFS. - Introduced policy preview and report models for API integration. - Added fixtures and tests for policy preview and report functionality. - Included necessary metadata and scripts for cache_pkg package.
This commit is contained in:
@@ -50,6 +50,7 @@ public class ClientCredentialsHandlersTests
|
||||
allowedScopes: "jobs:read");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
@@ -59,6 +60,7 @@ public class ClientCredentialsHandlersTests
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:write");
|
||||
@@ -80,6 +82,7 @@ public class ClientCredentialsHandlersTests
|
||||
allowedScopes: "jobs:read jobs:trigger");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
@@ -89,6 +92,7 @@ public class ClientCredentialsHandlersTests
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
|
||||
@@ -114,6 +118,7 @@ public class ClientCredentialsHandlersTests
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var sink = new TestAuthEventSink();
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
@@ -123,6 +128,7 @@ public class ClientCredentialsHandlersTests
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
|
||||
@@ -139,12 +145,11 @@ public class ClientCredentialsHandlersTests
|
||||
[Fact]
|
||||
public async Task ValidateDpopProof_AllowsSenderConstrainedClient()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
var options = TestHelpers.CreateAuthorityOptions(opts =>
|
||||
{
|
||||
Issuer = new Uri("https://authority.test")
|
||||
};
|
||||
options.Security.SenderConstraints.Dpop.Enabled = true;
|
||||
options.Security.SenderConstraints.Dpop.Nonce.Enabled = false;
|
||||
opts.Security.SenderConstraints.Dpop.Enabled = true;
|
||||
opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false;
|
||||
});
|
||||
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
@@ -214,6 +219,7 @@ public class ClientCredentialsHandlersTests
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
await validateHandler.HandleAsync(validateContext);
|
||||
@@ -389,6 +395,7 @@ public class ClientCredentialsHandlersTests
|
||||
TimeProvider.System,
|
||||
validator,
|
||||
httpContextAccessor,
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
|
||||
@@ -435,6 +442,7 @@ public class ClientCredentialsHandlersTests
|
||||
TimeProvider.System,
|
||||
validator,
|
||||
httpContextAccessor,
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
|
||||
@@ -446,6 +454,94 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_Rejects_WhenAudienceRequiresMtlsButClientConfiguredForDpop()
|
||||
{
|
||||
var options = TestHelpers.CreateAuthorityOptions(opts =>
|
||||
{
|
||||
opts.Security.SenderConstraints.Mtls.Enabled = true;
|
||||
opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Clear();
|
||||
opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Add("signer");
|
||||
});
|
||||
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "jobs:read",
|
||||
allowedAudiences: "signer");
|
||||
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop;
|
||||
clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop;
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
|
||||
Assert.Equal("Requested audiences require mutual TLS sender constraint.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RequiresMtlsWhenAudienceMatchesEnforcement()
|
||||
{
|
||||
var options = TestHelpers.CreateAuthorityOptions(opts =>
|
||||
{
|
||||
opts.Security.SenderConstraints.Mtls.Enabled = true;
|
||||
opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Clear();
|
||||
opts.Security.SenderConstraints.Mtls.EnforceForAudiences.Add("signer");
|
||||
});
|
||||
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "jobs:read",
|
||||
allowedAudiences: "signer");
|
||||
clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding
|
||||
{
|
||||
Thumbprint = "DEADBEEF"
|
||||
});
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var certificateValidator = new RecordingCertificateValidator();
|
||||
var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() };
|
||||
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
certificateValidator,
|
||||
httpContextAccessor,
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
|
||||
Assert.Equal("client_certificate_required", context.ErrorDescription);
|
||||
Assert.True(certificateValidator.Invoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleClientCredentials_PersistsTokenAndEnrichesClaims()
|
||||
{
|
||||
@@ -462,6 +558,7 @@ public class ClientCredentialsHandlersTests
|
||||
var sessionAccessor = new NullMongoSessionAccessor();
|
||||
var authSink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var validateHandler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
@@ -471,6 +568,7 @@ public class ClientCredentialsHandlersTests
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, secret: null, scope: "jobs:trigger");
|
||||
@@ -828,6 +926,82 @@ public class AuthorityClientCertificateValidatorTests
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(thumbprint, result.HexThumbprint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_Rejects_WhenBindingSubjectMismatch()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.test")
|
||||
};
|
||||
options.Security.SenderConstraints.Mtls.Enabled = true;
|
||||
options.Security.SenderConstraints.Mtls.RequireChainValidation = false;
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
options.Storage.ConnectionString = "mongodb://localhost/test";
|
||||
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var sanBuilder = new SubjectAlternativeNameBuilder();
|
||||
sanBuilder.AddDnsName("client.mtls.test");
|
||||
request.CertificateExtensions.Add(sanBuilder.Build());
|
||||
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5));
|
||||
|
||||
var clientDocument = CreateClient();
|
||||
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
|
||||
clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding
|
||||
{
|
||||
Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)),
|
||||
Subject = "CN=different-client"
|
||||
});
|
||||
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Connection.ClientCertificate = certificate;
|
||||
|
||||
var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger<AuthorityClientCertificateValidator>.Instance);
|
||||
var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal("certificate_binding_subject_mismatch", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_Rejects_WhenBindingSansMissing()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.test")
|
||||
};
|
||||
options.Security.SenderConstraints.Mtls.Enabled = true;
|
||||
options.Security.SenderConstraints.Mtls.RequireChainValidation = false;
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
options.Storage.ConnectionString = "mongodb://localhost/test";
|
||||
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var sanBuilder = new SubjectAlternativeNameBuilder();
|
||||
sanBuilder.AddDnsName("client.mtls.test");
|
||||
request.CertificateExtensions.Add(sanBuilder.Build());
|
||||
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5));
|
||||
|
||||
var clientDocument = CreateClient();
|
||||
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
|
||||
clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding
|
||||
{
|
||||
Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256)),
|
||||
SubjectAlternativeNames = new List<string> { "spiffe://client" }
|
||||
});
|
||||
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Connection.ClientCertificate = certificate;
|
||||
|
||||
var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger<AuthorityClientCertificateValidator>.Instance);
|
||||
var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal("certificate_binding_san_mismatch", result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestClientStore : IAuthorityClientStore
|
||||
@@ -1011,6 +1185,33 @@ internal sealed class NoopCertificateValidator : IAuthorityClientCertificateVali
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RecordingCertificateValidator : IAuthorityClientCertificateValidator
|
||||
{
|
||||
public bool Invoked { get; private set; }
|
||||
|
||||
public ValueTask<AuthorityClientCertificateValidationResult> ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken)
|
||||
{
|
||||
Invoked = true;
|
||||
|
||||
if (httpContext.Connection.ClientCertificate is null)
|
||||
{
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("client_certificate_required"));
|
||||
}
|
||||
|
||||
AuthorityClientCertificateBinding binding;
|
||||
if (client.CertificateBindings.Count > 0)
|
||||
{
|
||||
binding = client.CertificateBindings[0];
|
||||
}
|
||||
else
|
||||
{
|
||||
binding = new AuthorityClientCertificateBinding { Thumbprint = "stub" };
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Success("stub", binding.Thumbprint, binding));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor
|
||||
{
|
||||
public ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default)
|
||||
@@ -1021,6 +1222,21 @@ internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor
|
||||
|
||||
internal static class TestHelpers
|
||||
{
|
||||
public static StellaOpsAuthorityOptions CreateAuthorityOptions(Action<StellaOpsAuthorityOptions>? configure = null)
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.test")
|
||||
};
|
||||
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
options.Storage.ConnectionString = "mongodb://localhost/test";
|
||||
|
||||
configure?.Invoke(options);
|
||||
return options;
|
||||
}
|
||||
|
||||
public static AuthorityClientDocument CreateClient(
|
||||
string? secret = "s3cr3t!",
|
||||
string clientType = "confidential",
|
||||
|
||||
@@ -64,7 +64,8 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
|
||||
var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, authSink, metadataAccessor, clock, new NoopCertificateValidator(), new HttpContextAccessor(), NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, authSink, metadataAccessor, clock, new NoopCertificateValidator(), new HttpContextAccessor(), options, NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
|
||||
var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
|
||||
|
||||
|
||||
@@ -10,18 +10,19 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Extensions;
|
||||
using OpenIddict.Server;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Authority.Security;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using OpenIddict.Server;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Authority.Security;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
@@ -31,33 +32,36 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
private readonly IAuthorityIdentityProviderRegistry registry;
|
||||
private readonly ActivitySource activitySource;
|
||||
private readonly IAuthEventSink auditSink;
|
||||
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly IAuthorityClientCertificateValidator certificateValidator;
|
||||
private readonly IHttpContextAccessor httpContextAccessor;
|
||||
private readonly ILogger<ValidateClientCredentialsHandler> logger;
|
||||
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly IAuthorityClientCertificateValidator certificateValidator;
|
||||
private readonly IHttpContextAccessor httpContextAccessor;
|
||||
private readonly StellaOpsAuthorityOptions authorityOptions;
|
||||
private readonly ILogger<ValidateClientCredentialsHandler> logger;
|
||||
|
||||
public ValidateClientCredentialsHandler(
|
||||
IAuthorityClientStore clientStore,
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
ActivitySource activitySource,
|
||||
IAuthEventSink auditSink,
|
||||
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
|
||||
TimeProvider timeProvider,
|
||||
IAuthorityClientCertificateValidator certificateValidator,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<ValidateClientCredentialsHandler> logger)
|
||||
{
|
||||
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
|
||||
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
|
||||
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.certificateValidator = certificateValidator ?? throw new ArgumentNullException(nameof(certificateValidator));
|
||||
this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
|
||||
TimeProvider timeProvider,
|
||||
IAuthorityClientCertificateValidator certificateValidator,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
StellaOpsAuthorityOptions authorityOptions,
|
||||
ILogger<ValidateClientCredentialsHandler> logger)
|
||||
{
|
||||
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
|
||||
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
|
||||
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.certificateValidator = certificateValidator ?? throw new ArgumentNullException(nameof(certificateValidator));
|
||||
this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
this.authorityOptions = authorityOptions ?? throw new ArgumentNullException(nameof(authorityOptions));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
|
||||
{
|
||||
@@ -124,14 +128,30 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
? existingConstraint
|
||||
: null;
|
||||
|
||||
var normalizedSenderConstraint = !string.IsNullOrWhiteSpace(existingSenderConstraint)
|
||||
? existingSenderConstraint
|
||||
: ClientCredentialHandlerHelpers.NormalizeSenderConstraint(document);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedSenderConstraint))
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientSenderConstraintProperty] = normalizedSenderConstraint;
|
||||
}
|
||||
var normalizedSenderConstraint = !string.IsNullOrWhiteSpace(existingSenderConstraint)
|
||||
? existingSenderConstraint
|
||||
: ClientCredentialHandlerHelpers.NormalizeSenderConstraint(document);
|
||||
|
||||
var (mtlsRequired, matchedAudiences) = EvaluateMtlsRequirement(context.Request, document);
|
||||
if (mtlsRequired)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(normalizedSenderConstraint))
|
||||
{
|
||||
normalizedSenderConstraint = AuthoritySenderConstraintKinds.Mtls;
|
||||
logger.LogDebug("Enforcing mTLS sender constraint for {ClientId} due to audiences {Audiences}.", document.ClientId, string.Join(",", matchedAudiences));
|
||||
}
|
||||
else if (!string.Equals(normalizedSenderConstraint, AuthoritySenderConstraintKinds.Mtls, StringComparison.Ordinal))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Requested audiences require mutual TLS sender constraint.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: mTLS required for audiences {Audiences} but client sender constraint was {Constraint}.", context.ClientId, string.Join(",", matchedAudiences), normalizedSenderConstraint);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedSenderConstraint))
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientSenderConstraintProperty] = normalizedSenderConstraint;
|
||||
}
|
||||
|
||||
if (string.Equals(normalizedSenderConstraint, AuthoritySenderConstraintKinds.Mtls, StringComparison.Ordinal))
|
||||
{
|
||||
@@ -281,8 +301,95 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
|
||||
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private (bool Required, string[] Audiences) EvaluateMtlsRequirement(OpenIddictRequest? request, AuthorityClientDocument document)
|
||||
{
|
||||
var mtlsOptions = authorityOptions.Security.SenderConstraints.Mtls;
|
||||
if (!mtlsOptions.Enabled)
|
||||
{
|
||||
return (false, Array.Empty<string>());
|
||||
}
|
||||
|
||||
var enforcedAudiences = ResolveEnforcedAudiences(mtlsOptions);
|
||||
if (enforcedAudiences.Count == 0)
|
||||
{
|
||||
return (false, Array.Empty<string>());
|
||||
}
|
||||
|
||||
static void CollectMatches(IEnumerable<string?> values, ISet<string> enforced, HashSet<string> matches)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidate = value.Trim();
|
||||
if (candidate.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (enforced.Contains(candidate))
|
||||
{
|
||||
matches.Add(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var matched = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (request?.Resources is { } resources)
|
||||
{
|
||||
CollectMatches(resources, enforcedAudiences, matched);
|
||||
}
|
||||
|
||||
if (request?.Audiences is { } audiences)
|
||||
{
|
||||
CollectMatches(audiences, enforcedAudiences, matched);
|
||||
}
|
||||
|
||||
var configuredAudiences = ClientCredentialHandlerHelpers.Split(document.Properties, AuthorityClientMetadataKeys.Audiences);
|
||||
if (configuredAudiences.Count > 0)
|
||||
{
|
||||
CollectMatches(configuredAudiences, enforcedAudiences, matched);
|
||||
}
|
||||
|
||||
return matched.Count == 0
|
||||
? (false, Array.Empty<string>())
|
||||
: (true, matched.OrderBy(value => value, StringComparer.OrdinalIgnoreCase).ToArray());
|
||||
}
|
||||
|
||||
private static HashSet<string> ResolveEnforcedAudiences(AuthorityMtlsOptions mtlsOptions)
|
||||
{
|
||||
if (mtlsOptions.NormalizedAudiences.Count > 0)
|
||||
{
|
||||
return new HashSet<string>(mtlsOptions.NormalizedAudiences, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var audience in mtlsOptions.EnforceForAudiences)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(audience))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = audience.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
set.Add(trimmed);
|
||||
}
|
||||
|
||||
return set;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext>
|
||||
{
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Formats.Asn1;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Formats.Asn1;
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -145,12 +146,47 @@ internal sealed class AuthorityClientCertificateValidator : IAuthorityClientCert
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_unbound"));
|
||||
}
|
||||
|
||||
if (binding.NotBefore is { } bindingNotBefore)
|
||||
{
|
||||
var effectiveNotBefore = bindingNotBefore - mtlsOptions.RotationGrace;
|
||||
if (now < effectiveNotBefore)
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: certificate binding not active until {NotBefore:o} (grace applied).", client.ClientId, bindingNotBefore);
|
||||
if (!string.IsNullOrWhiteSpace(binding.Subject) &&
|
||||
!string.Equals(binding.Subject, certificate.Subject, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: certificate subject {Subject} did not match binding subject {BindingSubject}.", client.ClientId, certificate.Subject, binding.Subject);
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_binding_subject_mismatch"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(binding.SerialNumber))
|
||||
{
|
||||
var normalizedCertificateSerial = NormalizeSerialNumber(certificate.SerialNumber);
|
||||
var normalizedBindingSerial = NormalizeSerialNumber(binding.SerialNumber);
|
||||
if (!string.Equals(normalizedCertificateSerial, normalizedBindingSerial, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: certificate serial {Serial} did not match binding serial {BindingSerial}.", client.ClientId, normalizedCertificateSerial, normalizedBindingSerial);
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_binding_serial_mismatch"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(binding.Issuer) &&
|
||||
!string.Equals(binding.Issuer, certificate.Issuer, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: certificate issuer {Issuer} did not match binding issuer {BindingIssuer}.", client.ClientId, certificate.Issuer, binding.Issuer);
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_binding_issuer_mismatch"));
|
||||
}
|
||||
|
||||
if (binding.SubjectAlternativeNames.Count > 0)
|
||||
{
|
||||
var certificateSans = new HashSet<string>(subjectAlternativeNames.Select(san => san.Value), StringComparer.OrdinalIgnoreCase);
|
||||
if (!binding.SubjectAlternativeNames.All(san => certificateSans.Contains(san)))
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: certificate SANs did not include all binding values.", client.ClientId);
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_binding_san_mismatch"));
|
||||
}
|
||||
}
|
||||
|
||||
if (binding.NotBefore is { } bindingNotBefore)
|
||||
{
|
||||
var effectiveNotBefore = bindingNotBefore - mtlsOptions.RotationGrace;
|
||||
if (now < effectiveNotBefore)
|
||||
{
|
||||
logger.LogWarning("mTLS validation failed for {ClientId}: certificate binding not active until {NotBefore:o} (grace applied).", client.ClientId, bindingNotBefore);
|
||||
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_binding_inactive"));
|
||||
}
|
||||
}
|
||||
@@ -197,11 +233,11 @@ internal sealed class AuthorityClientCertificateValidator : IAuthorityClientCert
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<(string Type, string Value)> GetSubjectAlternativeNames(X509Certificate2 certificate)
|
||||
{
|
||||
foreach (var extension in certificate.Extensions)
|
||||
{
|
||||
if (!string.Equals(extension.Oid?.Value, "2.5.29.17", StringComparison.Ordinal))
|
||||
private static IReadOnlyList<(string Type, string Value)> GetSubjectAlternativeNames(X509Certificate2 certificate)
|
||||
{
|
||||
foreach (var extension in certificate.Extensions)
|
||||
{
|
||||
if (!string.Equals(extension.Oid?.Value, "2.5.29.17", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -256,28 +292,28 @@ internal sealed class AuthorityClientCertificateValidator : IAuthorityClientCert
|
||||
}
|
||||
}
|
||||
|
||||
return Array.Empty<(string, string)>();
|
||||
}
|
||||
private bool ValidateCertificateChain(X509Certificate2 certificate)
|
||||
{
|
||||
using var chain = new X509Chain
|
||||
{
|
||||
ChainPolicy =
|
||||
{
|
||||
RevocationMode = X509RevocationMode.NoCheck,
|
||||
RevocationFlag = X509RevocationFlag.ExcludeRoot,
|
||||
VerificationFlags = X509VerificationFlags.IgnoreWrongUsage
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
return chain.Build(certificate);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "mTLS chain validation threw an exception.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.Empty<(string, string)>();
|
||||
}
|
||||
|
||||
private static string NormalizeSerialNumber(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var buffer = new char[value.Length];
|
||||
var length = 0;
|
||||
foreach (var character in value)
|
||||
{
|
||||
if (character is ':' or ' ')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer[length++] = char.ToUpperInvariant(character);
|
||||
}
|
||||
|
||||
return new string(buffer, 0, length);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,9 @@
|
||||
| AUTH-PLUGIN-COORD-08-002 | DONE (2025-10-20) | Authority Core, Plugin Platform Guild | PLUGIN-DI-08-001 | Coordinate scoped-service adoption for Authority plug-in registrars and background jobs ahead of PLUGIN-DI-08-002 implementation. | ✅ Workshop completed 2025-10-20 15:00–16:05 UTC with notes/action log in `docs/dev/authority-plugin-di-coordination.md`; ✅ Follow-up backlog updates assigned via documented action items ahead of PLUGIN-DI-08-002 delivery. |
|
||||
| AUTH-DPOP-11-001 | DONE (2025-10-20) | Authority Core & Security Guild | — | Implement DPoP proof validation + nonce handling for high-value audiences per architecture. | ✅ Redis-configurable nonce store surfaced via `security.senderConstraints.dpop.nonce` with sample YAML and architecture docs refreshed<br>✅ High-value audience enforcement uses normalised required audiences to avoid whitespace/case drift<br>✅ Operator guide updated with Redis-backed nonce snippet and env-var override guidance; integration test already covers nonce challenge |
|
||||
> Remark (2025-10-20): `etc/authority.yaml.sample` gains senderConstraint sections (rate limits, DPoP, mTLS), docs (`docs/ARCHITECTURE_AUTHORITY.md`, `docs/11_AUTHORITY.md`, plan) refreshed. `ResolveNonceAudience` now relies on `NormalizedAudiences` and options trim persisted values. `dotnet test StellaOps.Authority.sln` attempted (2025-10-20 15:12 UTC) but failed on `NU1900` because the mirrored NuGet service index `https://mirrors.ablera.dev/nuget/nuget-mirror/v3/index.json` was unreachable; no project build executed.
|
||||
| AUTH-MTLS-11-002 | DOING (2025-10-19) | Authority Core & Security Guild | — | Add OAuth mTLS client credential support with certificate-bound tokens and introspection updates. | • Certificate validator scaffold plus cnf stamping present; tokens persist sender thumbprints<br>• Remaining: provisioning/storage for certificate bindings, SAN/CA validation, introspection propagation, integration tests/docs before marking DONE |
|
||||
> Remark (2025-10-19): Client provisioning accepts certificate bindings; validator enforces SAN types/CA allow-list with rotation grace; mtls integration tests updated (full suite still blocked by upstream build).
|
||||
| AUTH-MTLS-11-002 | DONE (2025-10-23) | Authority Core & Security Guild | — | Add OAuth mTLS client credential support with certificate-bound tokens and introspection updates. | ✅ Deterministic provisioning/storage for certificate bindings (thumbprint/subject/issuer/serial/SAN)<br>✅ Audience enforcement auto-switches to mTLS via `security.senderConstraints.mtls.enforceForAudiences`<br>✅ Validator matches binding metadata with rotation grace and emits confirmation thumbprints<br>✅ Introspection returns `cnf.x5t#S256`; docs & sample config refreshed; Authority test suite green |
|
||||
> Remark (2025-10-23): Audience enforcement now rejects non-mTLS clients targeting high-value audiences; certificate validator checks binding subject/issuer/serial/SAN values and returns deterministic error codes. Docs (`docs/11_AUTHORITY.md`, `docs/ARCHITECTURE_AUTHORITY.md`, `docs/dev/authority-dpop-mtls-plan.md`) and `etc/authority.yaml.sample` updated. `dotnet test src/StellaOps.Authority/StellaOps.Authority.sln` (2025-10-23 18:07 UTC) succeeded.
|
||||
> Remark (2025-10-19, AUTHSTORAGE-MONGO-08-001): Prerequisites re-checked (none outstanding). Session accessor wired through Authority pipeline; stores accept optional sessions; added replica-set election regression test for read-your-write.
|
||||
> Remark (2025-10-19, AUTH-DPOP-11-001): Handler, nonce store, and persistence hooks merged; Redis-backed configuration + end-to-end nonce enforcement still open. (Superseded by 2025-10-20 update above.)
|
||||
> Remark (2025-10-19, AUTH-MTLS-11-002): Certificate validator + cnf stamping delivered; binding storage, CA/SAN validation, integration suites outstanding before status can move to DONE.
|
||||
|
||||
> Update status columns (TODO / DOING / DONE / BLOCKED) together with code changes. Always run `dotnet test src/StellaOps.Authority.sln` when touching host logic.
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
[
|
||||
{
|
||||
"analyzerId": "python",
|
||||
"componentKey": "purl::pkg:pypi/layered@2.0",
|
||||
"purl": "pkg:pypi/layered@2.0",
|
||||
"name": "layered",
|
||||
"version": "2.0",
|
||||
"type": "pypi",
|
||||
"usedByEntrypoint": true,
|
||||
"metadata": {
|
||||
"author": "Layered Maintainer",
|
||||
"authorEmail": "maintainer@example.com",
|
||||
"classifier[0]": "Programming Language :: Python :: 3",
|
||||
"classifiers": "Programming Language :: Python :: 3",
|
||||
"distInfoPath": "layer1/usr/lib/python3.11/site-packages/layered-2.0.dist-info",
|
||||
"editable": "true",
|
||||
"entryPoints.console_scripts": "layered-cli=layered.cli:main",
|
||||
"entryPoints.layered.hooks": "register=layered.plugins:register",
|
||||
"installer": "pip",
|
||||
"license": "Apache-2.0",
|
||||
"license.classifier[0]": "License :: OSI Approved :: Apache Software License",
|
||||
"license.file[0]": "layer2/usr/lib/python3.11/site-packages/LICENSE",
|
||||
"licenseExpression": "Apache-2.0",
|
||||
"name": "layered",
|
||||
"normalizedName": "layered",
|
||||
"projectUrl": "Documentation, https://example.com/layered/docs",
|
||||
"provenance": "dist-info",
|
||||
"record.hashMismatches": "0",
|
||||
"record.hashedEntries": "8",
|
||||
"record.ioErrors": "0",
|
||||
"record.missingFiles": "0",
|
||||
"record.totalEntries": "9",
|
||||
"requiresDist": "requests",
|
||||
"requiresPython": "\u003E=3.9",
|
||||
"sourceCommit": "abc123",
|
||||
"sourceSubdirectory": "src/layered",
|
||||
"sourceUrl": "https://git.example.com/layered",
|
||||
"sourceVcs": "git",
|
||||
"summary": "Base layer metadata",
|
||||
"version": "2.0",
|
||||
"wheel.generator": "pip 24.0",
|
||||
"wheel.rootIsPurelib": "true",
|
||||
"wheel.tags": "py3-none-any",
|
||||
"wheel.version": "1.0"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "INSTALLER",
|
||||
"locator": "layer1/usr/lib/python3.11/site-packages/layered-2.0.dist-info/INSTALLER"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "INSTALLER",
|
||||
"locator": "layer2/usr/lib/python3.11/site-packages/layered-2.0.dist-info/INSTALLER"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "METADATA",
|
||||
"locator": "layer1/usr/lib/python3.11/site-packages/layered-2.0.dist-info/METADATA"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "METADATA",
|
||||
"locator": "layer2/usr/lib/python3.11/site-packages/layered-2.0.dist-info/METADATA"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "RECORD",
|
||||
"locator": "layer1/usr/lib/python3.11/site-packages/layered-2.0.dist-info/RECORD"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "RECORD",
|
||||
"locator": "layer2/usr/lib/python3.11/site-packages/layered-2.0.dist-info/RECORD"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "WHEEL",
|
||||
"locator": "layer1/usr/lib/python3.11/site-packages/layered-2.0.dist-info/WHEEL"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "WHEEL",
|
||||
"locator": "layer2/usr/lib/python3.11/site-packages/layered-2.0.dist-info/WHEEL"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "entry_points.txt",
|
||||
"locator": "layer1/usr/lib/python3.11/site-packages/layered-2.0.dist-info/entry_points.txt"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "entry_points.txt",
|
||||
"locator": "layer2/usr/lib/python3.11/site-packages/layered-2.0.dist-info/entry_points.txt"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "license",
|
||||
"locator": "layer2/usr/lib/python3.11/site-packages/LICENSE"
|
||||
},
|
||||
{
|
||||
"kind": "metadata",
|
||||
"source": "direct_url.json",
|
||||
"locator": "layer2/usr/lib/python3.11/site-packages/layered-2.0.dist-info/direct_url.json",
|
||||
"value": "https://git.example.com/layered"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
pip
|
||||
@@ -0,0 +1,8 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: layered
|
||||
Version: 2.0
|
||||
Summary: Base layer metadata
|
||||
License: Apache-2.0
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Requires-Python: >=3.9
|
||||
Requires-Dist: requests
|
||||
@@ -0,0 +1,9 @@
|
||||
layered/__init__.py,sha256=3Q3bv/BWCZLW2p2dVYw8QfAfBYV2YBtuYtT9TIJCmFM=,139
|
||||
layered/core.py,sha256=izBXI4cRE/cgf7hgc/gHfTp1OyIIJ23NgUJXaHWVFpU=,80
|
||||
layered/cli.py,sha256=xQrTznF7ch6C9qyQALJpsqRTIh9DVCRG4IXoQ1eLLnY=,126
|
||||
../../../bin/layered-cli,sha256=6IGTGCEapolFoAUnXGNrWIfPSXU8W7bsZ07DYF/wmNc=,91
|
||||
layered-2.0.dist-info/METADATA,sha256=jNEi7xsj4V+SSzOJJToxMoZmZ7gxyto7zuKxCjxUFjk=,193
|
||||
layered-2.0.dist-info/WHEEL,sha256=m8MHT7vQnqC5W8H/y4uJdEpx9ijH0jJGpuoaxRbcsQg=,79
|
||||
layered-2.0.dist-info/entry_points.txt,sha256=hm8bgJUYe2zoYNATyAsQzQKQTdQtFe4ctbf5kSlxFj0=,47
|
||||
layered-2.0.dist-info/INSTALLER,sha256=zuuue4knoyJ+UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg=,4
|
||||
layered-2.0.dist-info/RECORD,,
|
||||
@@ -0,0 +1,4 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: pip 24.0
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
@@ -0,0 +1,2 @@
|
||||
[console_scripts]
|
||||
layered-cli=layered.cli:main
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Layered package demonstrating merged metadata across layers."""
|
||||
|
||||
from .core import get_version # noqa: F401
|
||||
|
||||
__all__ = ["get_version"]
|
||||
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .core import get_version
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"layered {get_version()}")
|
||||
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
return "2.0"
|
||||
@@ -0,0 +1 @@
|
||||
Apache License Version 2.0
|
||||
@@ -0,0 +1 @@
|
||||
pip
|
||||
@@ -0,0 +1,10 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: layered
|
||||
Version: 2.0
|
||||
Summary: Overlay metadata adding direct URL information
|
||||
License-Expression: Apache-2.0
|
||||
License-File: LICENSE
|
||||
Author: Layered Maintainer
|
||||
Author-email: maintainer@example.com
|
||||
Project-URL: Documentation, https://example.com/layered/docs
|
||||
Classifier: License :: OSI Approved :: Apache Software License
|
||||
@@ -0,0 +1,9 @@
|
||||
layered/plugins/__init__.py,sha256=hMd8TidtznWDaiA4biHZ04ZoVXcAc7z/p77bIdAsPyE=,53
|
||||
layered/plugins/plugin.py,sha256=PBVqd9coVIzSBTQ2qdL5qxoK0fnsRZZ1DkhqnaVySPA=,87
|
||||
LICENSE,sha256=cXKP+wQk9Jyqh8mUi7nURl9jOOjojDqrabZ119S2EzM=,27
|
||||
layered-2.0.dist-info/METADATA,sha256=V09W93ILksWKLP8My6UatmScZ+5MCLiQ/5ieWzb585M=,346
|
||||
layered-2.0.dist-info/WHEEL,sha256=m8MHT7vQnqC5W8H/y4uJdEpx9ijH0jJGpuoaxRbcsQg=,79
|
||||
layered-2.0.dist-info/entry_points.txt,sha256=JYpkYczwozo6Ek7diDPgPj8ReYv5wTpaW0pFjL82bGU=,50
|
||||
layered-2.0.dist-info/INSTALLER,sha256=zuuue4knoyJ+UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg=,4
|
||||
layered-2.0.dist-info/direct_url.json,sha256=8NtnZQZq2S5tcEn+P5fH6/EpABJ9+Ha5aIq8Sn2szig=,189
|
||||
layered-2.0.dist-info/RECORD,,
|
||||
@@ -0,0 +1,4 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: pip 24.0
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"url": "https://git.example.com/layered",
|
||||
"dir_info": {
|
||||
"editable": true,
|
||||
"subdirectory": "src/layered"
|
||||
},
|
||||
"vcs_info": {
|
||||
"vcs": "git",
|
||||
"commit_id": "abc123"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[layered.hooks]
|
||||
register=layered.plugins:register
|
||||
@@ -0,0 +1,3 @@
|
||||
from .plugin import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def register() -> str:
|
||||
return "layer2-plugin"
|
||||
@@ -0,0 +1,87 @@
|
||||
[
|
||||
{
|
||||
"analyzerId": "python",
|
||||
"componentKey": "purl::pkg:pypi/cache-pkg@1.2.3",
|
||||
"purl": "pkg:pypi/cache-pkg@1.2.3",
|
||||
"name": "Cache-Pkg",
|
||||
"version": "1.2.3",
|
||||
"type": "pypi",
|
||||
"usedByEntrypoint": true,
|
||||
"metadata": {
|
||||
"classifier[0]": "Intended Audience :: Developers",
|
||||
"classifier[1]": "License :: OSI Approved :: BSD License",
|
||||
"classifier[2]": "Programming Language :: Python :: 3",
|
||||
"classifiers": "Intended Audience :: Developers;License :: OSI Approved :: BSD License;Programming Language :: Python :: 3",
|
||||
"distInfoPath": "lib/python3.11/site-packages/cache_pkg-1.2.3.dist-info",
|
||||
"entryPoints.console_scripts": "cache-tool=cache_pkg:main",
|
||||
"installer": "pip",
|
||||
"license": "BSD-3-Clause",
|
||||
"license.classifier[0]": "License :: OSI Approved :: BSD License",
|
||||
"license.file[0]": "LICENSE",
|
||||
"name": "Cache-Pkg",
|
||||
"normalizedName": "cache-pkg",
|
||||
"projectUrl": "Source, https://example.com/cache-pkg",
|
||||
"provenance": "dist-info",
|
||||
"record.hashMismatches": "1",
|
||||
"record.hashedEntries": "9",
|
||||
"record.ioErrors": "0",
|
||||
"record.missingFiles": "2",
|
||||
"record.totalEntries": "12",
|
||||
"record.unsupportedAlgorithms": "md5",
|
||||
"requiresDist": "click",
|
||||
"requiresPython": "\u003E=3.8",
|
||||
"summary": "Cache test package for hashed RECORD coverage",
|
||||
"version": "1.2.3",
|
||||
"wheel.generator": "pip 24.0",
|
||||
"wheel.rootIsPurelib": "true",
|
||||
"wheel.tags": "py3-none-any",
|
||||
"wheel.version": "1.0"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "derived",
|
||||
"source": "RECORD",
|
||||
"locator": "../etc/passwd",
|
||||
"value": "outside-root"
|
||||
},
|
||||
{
|
||||
"kind": "derived",
|
||||
"source": "RECORD",
|
||||
"locator": "lib/python3.11/site-packages/cache_pkg/LICENSE",
|
||||
"value": "sha256 mismatch expected=pdUY6NGoyWCpcK8ThpBPUIVXbLvU9PzP8lsXEVEnFd0= actual=pdUY6NGoyWCpcK8ThpBPUIVXbLvU9PzP8lsXEVEnFdk=",
|
||||
"sha256": "pdUY6NGoyWCpcK8ThpBPUIVXbLvU9PzP8lsXEVEnFdk="
|
||||
},
|
||||
{
|
||||
"kind": "derived",
|
||||
"source": "RECORD",
|
||||
"locator": "lib/python3.11/site-packages/cache_pkg/missing/data.json",
|
||||
"value": "missing"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "INSTALLER",
|
||||
"locator": "lib/python3.11/site-packages/cache_pkg-1.2.3.dist-info/INSTALLER"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "METADATA",
|
||||
"locator": "lib/python3.11/site-packages/cache_pkg-1.2.3.dist-info/METADATA"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "RECORD",
|
||||
"locator": "lib/python3.11/site-packages/cache_pkg-1.2.3.dist-info/RECORD"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "WHEEL",
|
||||
"locator": "lib/python3.11/site-packages/cache_pkg-1.2.3.dist-info/WHEEL"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "entry_points.txt",
|
||||
"locator": "lib/python3.11/site-packages/cache_pkg-1.2.3.dist-info/entry_points.txt"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
from cache_pkg import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
pip
|
||||
@@ -0,0 +1,12 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: Cache-Pkg
|
||||
Version: 1.2.3
|
||||
Summary: Cache test package for hashed RECORD coverage
|
||||
License: BSD-3-Clause
|
||||
License-File: LICENSE
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: License :: OSI Approved :: BSD License
|
||||
Requires-Python: >=3.8
|
||||
Requires-Dist: click
|
||||
Project-URL: Source, https://example.com/cache-pkg
|
||||
@@ -0,0 +1,12 @@
|
||||
cache_pkg/__init__.py,sha256=iw2XGXcGU2Si1KAQ7o82tPSKxwEFc6UNZfITpGPh7mM=,189
|
||||
cache_pkg/data/config.json,sha256=Oa/wlgi1qJHC93RF5vwoOFffyviGtm5ccL7lrI0gkeY=,49
|
||||
cache_pkg/LICENSE,sha256=pdUY6NGoyWCpcK8ThpBPUIVXbLvU9PzP8lsXEVEnFd0=,73
|
||||
cache_pkg/md5only.txt,md5=Zm9v,4
|
||||
cache_pkg-1.2.3.data/scripts/cache-tool,sha256=2rsv/gnYOtlJZCy75Wz0rCADxYPnQAkyKvNbuoquZQ4=,89
|
||||
cache_pkg-1.2.3.dist-info/METADATA,sha256=DXPSItxOR1kPkVzjyq8F50jf8FOR9brSs/TGcZmcEHo=,390
|
||||
cache_pkg-1.2.3.dist-info/WHEEL,sha256=m8MHT7vQnqC5W8H/y4uJdEpx9ijH0jJGpuoaxRbcsQg=,79
|
||||
cache_pkg-1.2.3.dist-info/entry_points.txt,sha256=S1tGoBGlzWL6jeECCTw1pZP09HM8voEm/qQ7DtOHPyc=,44
|
||||
cache_pkg-1.2.3.dist-info/INSTALLER,sha256=zuuue4knoyJ+UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg=,4
|
||||
cache_pkg-1.2.3.dist-info/RECORD,,
|
||||
cache_pkg/missing/data.json,sha256=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=,12
|
||||
../../../../etc/passwd,,
|
||||
@@ -0,0 +1,4 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: pip 24.0
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
@@ -0,0 +1,2 @@
|
||||
[console_scripts]
|
||||
cache-tool=cache_pkg:main
|
||||
@@ -0,0 +1,4 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2025, StellaOps
|
||||
All rights reserved.
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Cache fixture package for determinism tests."""
|
||||
|
||||
from .data import config # noqa: F401
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point used by console script."""
|
||||
print("cache-pkg running")
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"cacheEnabled": true,
|
||||
"ttlSeconds": 3600
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
foo
|
||||
@@ -10,19 +10,24 @@
|
||||
"metadata": {
|
||||
"author": "Example Dev",
|
||||
"authorEmail": "dev@example.com",
|
||||
"classifiers": "Programming Language :: Python :: 3;License :: OSI Approved :: Apache Software License",
|
||||
"classifier[0]": "License :: OSI Approved :: Apache Software License",
|
||||
"classifier[1]": "Programming Language :: Python :: 3",
|
||||
"classifiers": "License :: OSI Approved :: Apache Software License;Programming Language :: Python :: 3",
|
||||
"distInfoPath": "lib/python3.11/site-packages/simple-1.0.0.dist-info",
|
||||
"editable": "true",
|
||||
"entryPoints.console_scripts": "simple-tool=simple.core:main",
|
||||
"homePage": "https://example.com/simple",
|
||||
"installer": "pip",
|
||||
"license": "Apache-2.0",
|
||||
"license.classifier[0]": "License :: OSI Approved :: Apache Software License",
|
||||
"name": "simple",
|
||||
"normalizedName": "simple",
|
||||
"projectUrl": "Source, https://example.com/simple/src",
|
||||
"provenance": "dist-info",
|
||||
"record.hashMismatches": "0",
|
||||
"record.hashedEntries": "9",
|
||||
"record.hashedEntries": "8",
|
||||
"record.ioErrors": "0",
|
||||
"record.missingFiles": "0",
|
||||
"record.missingFiles": "1",
|
||||
"record.totalEntries": "10",
|
||||
"requiresDist": "requests (\u003E=2.0)",
|
||||
"requiresPython": "\u003E=3.9",
|
||||
@@ -38,11 +43,27 @@
|
||||
"wheel.version": "1.0"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "derived",
|
||||
"source": "RECORD",
|
||||
"locator": "bin/simple-tool",
|
||||
"value": "missing"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "INSTALLER",
|
||||
"locator": "lib/python3.11/site-packages/simple-1.0.0.dist-info/INSTALLER"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "METADATA",
|
||||
"locator": "lib/python3.11/site-packages/simple-1.0.0.dist-info/METADATA"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "RECORD",
|
||||
"locator": "lib/python3.11/site-packages/simple-1.0.0.dist-info/RECORD"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "WHEEL",
|
||||
@@ -61,4 +82,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -30,4 +30,54 @@ public sealed class PythonLanguageAnalyzerTests
|
||||
cancellationToken,
|
||||
usageHints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PipCacheFixtureProducesDeterministicOutputAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = TestPaths.ResolveFixture("lang", "python", "pip-cache");
|
||||
var goldenPath = Path.Combine(fixturePath, "expected.json");
|
||||
|
||||
var usageHints = new LanguageUsageHints(new[]
|
||||
{
|
||||
Path.Combine(fixturePath, "lib", "python3.11", "site-packages", "cache_pkg-1.2.3.data", "scripts", "cache-tool")
|
||||
});
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new PythonLanguageAnalyzer()
|
||||
};
|
||||
|
||||
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
|
||||
fixturePath,
|
||||
goldenPath,
|
||||
analyzers,
|
||||
cancellationToken,
|
||||
usageHints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LayeredEditableFixtureMergesAcrossLayersAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = TestPaths.ResolveFixture("lang", "python", "layered-editable");
|
||||
var goldenPath = Path.Combine(fixturePath, "expected.json");
|
||||
|
||||
var usageHints = new LanguageUsageHints(new[]
|
||||
{
|
||||
Path.Combine(fixturePath, "layer1", "usr", "bin", "layered-cli")
|
||||
});
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new PythonLanguageAnalyzer()
|
||||
};
|
||||
|
||||
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
|
||||
fixturePath,
|
||||
goldenPath,
|
||||
analyzers,
|
||||
cancellationToken,
|
||||
usageHints);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,22 +45,97 @@ internal static class PythonDistributionLoader
|
||||
AddFileEvidence(context, metadataPath, "METADATA", evidenceEntries);
|
||||
AddFileEvidence(context, wheelPath, "WHEEL", evidenceEntries);
|
||||
AddFileEvidence(context, entryPointsPath, "entry_points.txt", evidenceEntries);
|
||||
AddFileEvidence(context, installerPath, "INSTALLER", evidenceEntries);
|
||||
AddFileEvidence(context, recordPath, "RECORD", evidenceEntries);
|
||||
|
||||
AppendMetadata(metadataEntries, "distInfoPath", PythonPathHelper.NormalizeRelative(context, distInfoPath));
|
||||
AppendMetadata(metadataEntries, "name", trimmedName);
|
||||
AppendMetadata(metadataEntries, "version", trimmedVersion);
|
||||
AppendMetadata(metadataEntries, "normalizedName", normalizedName);
|
||||
AppendMetadata(metadataEntries, "summary", metadataDocument.GetFirst("Summary"));
|
||||
AppendMetadata(metadataEntries, "license", metadataDocument.GetFirst("License"));
|
||||
AppendMetadata(metadataEntries, "licenseExpression", metadataDocument.GetFirst("License-Expression"));
|
||||
AppendMetadata(metadataEntries, "homePage", metadataDocument.GetFirst("Home-page"));
|
||||
AppendMetadata(metadataEntries, "author", metadataDocument.GetFirst("Author"));
|
||||
AppendMetadata(metadataEntries, "authorEmail", metadataDocument.GetFirst("Author-email"));
|
||||
AppendMetadata(metadataEntries, "projectUrl", metadataDocument.GetFirst("Project-URL"));
|
||||
AppendMetadata(metadataEntries, "requiresPython", metadataDocument.GetFirst("Requires-Python"));
|
||||
|
||||
var licenseFiles = metadataDocument.GetAll("License-File");
|
||||
if (licenseFiles.Count > 0)
|
||||
{
|
||||
var packageRoot = ResolvePackageRoot(distInfoPath);
|
||||
var licenseIndex = 0;
|
||||
var seenLicensePaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var licenseFile in licenseFiles)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(licenseFile))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = licenseFile.Trim();
|
||||
var resolved = TryResolvePackagePath(packageRoot, trimmed);
|
||||
string metadataValue;
|
||||
string? evidenceLocator = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(resolved) && File.Exists(resolved))
|
||||
{
|
||||
metadataValue = PythonPathHelper.NormalizeRelative(context, resolved);
|
||||
evidenceLocator = metadataValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
metadataValue = trimmed;
|
||||
}
|
||||
|
||||
if (metadataValue.Length == 0 || !seenLicensePaths.Add(metadataValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AppendMetadata(metadataEntries, $"license.file[{licenseIndex}]", metadataValue);
|
||||
licenseIndex++;
|
||||
|
||||
if (!string.IsNullOrEmpty(evidenceLocator))
|
||||
{
|
||||
evidenceEntries.Add(new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.File,
|
||||
"license",
|
||||
evidenceLocator,
|
||||
Value: null,
|
||||
Sha256: null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var classifiers = metadataDocument.GetAll("Classifier");
|
||||
if (classifiers.Count > 0)
|
||||
{
|
||||
AppendMetadata(metadataEntries, "classifiers", string.Join(';', classifiers));
|
||||
var orderedClassifiers = classifiers
|
||||
.Where(static classifier => !string.IsNullOrWhiteSpace(classifier))
|
||||
.Select(static classifier => classifier.Trim())
|
||||
.OrderBy(static classifier => classifier, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (orderedClassifiers.Length > 0)
|
||||
{
|
||||
AppendMetadata(metadataEntries, "classifiers", string.Join(';', orderedClassifiers));
|
||||
|
||||
var licenseClassifierIndex = 0;
|
||||
for (var index = 0; index < orderedClassifiers.Length; index++)
|
||||
{
|
||||
var classifier = orderedClassifiers[index];
|
||||
AppendMetadata(metadataEntries, $"classifier[{index}]", classifier);
|
||||
|
||||
if (classifier.StartsWith("License ::", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AppendMetadata(metadataEntries, $"license.classifier[{licenseClassifierIndex}]", classifier);
|
||||
licenseClassifierIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var requiresDist = metadataDocument.GetAll("Requires-Dist");
|
||||
@@ -125,6 +200,7 @@ internal static class PythonDistributionLoader
|
||||
|
||||
evidenceEntries.AddRange(verification.Evidence);
|
||||
var usedByEntrypoint = verification.UsedByEntrypoint || EvaluateEntryPointUsage(context, distInfoPath, entryPoints);
|
||||
AppendMetadata(metadataEntries, "provenance", "dist-info");
|
||||
|
||||
return new PythonDistribution(
|
||||
trimmedName,
|
||||
@@ -267,6 +343,24 @@ internal static class PythonDistributionLoader
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string ResolvePackageRoot(string distInfoPath)
|
||||
{
|
||||
var parent = Directory.GetParent(distInfoPath);
|
||||
return parent?.FullName ?? distInfoPath;
|
||||
}
|
||||
|
||||
private static string? TryResolvePackagePath(string basePath, string relativePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Path.GetFullPath(Path.Combine(basePath, relativePath));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string?> ReadSingleLineAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
@@ -769,6 +863,11 @@ internal static class PythonRecordVerifier
|
||||
var entryPath = entry.Path.Replace('/', Path.DirectorySeparatorChar);
|
||||
var fullPath = Path.GetFullPath(Path.Combine(parent, entryPath));
|
||||
|
||||
if (context.UsageHints.IsPathUsed(fullPath))
|
||||
{
|
||||
usedByEntrypoint = true;
|
||||
}
|
||||
|
||||
if (!fullPath.StartsWith(root, StringComparison.Ordinal))
|
||||
{
|
||||
missing++;
|
||||
@@ -793,11 +892,6 @@ internal static class PythonRecordVerifier
|
||||
continue;
|
||||
}
|
||||
|
||||
if (context.UsageHints.IsPathUsed(fullPath))
|
||||
{
|
||||
usedByEntrypoint = true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entry.HashAlgorithm) || string.IsNullOrWhiteSpace(entry.HashValue))
|
||||
{
|
||||
continue;
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
| 1 | SCANNER-ANALYZERS-LANG-10-303A | DONE (2025-10-21) | SCANNER-ANALYZERS-LANG-10-307 | STREAM-based parser for `*.dist-info` (`METADATA`, `WHEEL`, `entry_points.txt`) with normalization + evidence capture. | Parser handles CPython 3.8–3.12 metadata variations; fixtures confirm canonical ordering and UTF-8 handling. |
|
||||
| 2 | SCANNER-ANALYZERS-LANG-10-303B | DONE (2025-10-21) | SCANNER-ANALYZERS-LANG-10-303A | RECORD hash verifier with chunked hashing, Zip64 support, and mismatch diagnostics. | Verifier processes 5 GB RECORD fixture without allocations >2 MB; mismatches produce deterministic evidence records. |
|
||||
| 3 | SCANNER-ANALYZERS-LANG-10-303C | DONE (2025-10-21) | SCANNER-ANALYZERS-LANG-10-303B | Editable install + pip cache detection; integrate EntryTrace hints for runtime usage flags. | Editable installs resolved to source path; usage flags propagated; regression tests cover mixed editable + wheel installs. |
|
||||
| 4 | SCANNER-ANALYZERS-LANG-10-307P | DOING (2025-10-23) | SCANNER-ANALYZERS-LANG-10-303C | Shared helper integration (license metadata, quiet provenance, component merging). | Shared helpers reused; analyzer-specific metadata minimal; deterministic merge tests pass. |
|
||||
| 5 | SCANNER-ANALYZERS-LANG-10-308P | TODO | SCANNER-ANALYZERS-LANG-10-307P | Golden fixtures + determinism harness for Python analyzer; add benchmark and hash throughput reporting. | Fixtures under `Fixtures/lang/python/`; determinism CI guard; benchmark CSV added with threshold alerts. |
|
||||
| 6 | SCANNER-ANALYZERS-LANG-10-309P | TODO | SCANNER-ANALYZERS-LANG-10-308P | Package plug-in (manifest, DI registration) and document Offline Kit bundling of Python stdlib metadata if needed. | Manifest copied to `plugins/scanner/analyzers/lang/`; Worker loads analyzer; Offline Kit doc updated. |
|
||||
| 4 | SCANNER-ANALYZERS-LANG-10-307P | DONE (2025-10-23) | SCANNER-ANALYZERS-LANG-10-303C | Shared helper integration (license metadata, quiet provenance, component merging). | Shared helpers reused; analyzer-specific metadata minimal; deterministic merge tests pass. |
|
||||
| 5 | SCANNER-ANALYZERS-LANG-10-308P | DONE (2025-10-23) | SCANNER-ANALYZERS-LANG-10-307P | Golden fixtures + determinism harness for Python analyzer; add benchmark and hash throughput reporting. | Fixtures under `Fixtures/lang/python/`; determinism CI guard; benchmark CSV added with threshold alerts. |
|
||||
| 6 | SCANNER-ANALYZERS-LANG-10-309P | DONE (2025-10-23) | SCANNER-ANALYZERS-LANG-10-308P | Package plug-in (manifest, DI registration) and document Offline Kit bundling of Python stdlib metadata if needed. | Manifest copied to `plugins/scanner/analyzers/lang/`; Worker loads analyzer; Offline Kit doc updated. |
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
| 1 | SCANNER-ANALYZERS-LANG-10-306A | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-307 | Parse Cargo metadata (`Cargo.lock`, `.fingerprint`, `.metadata`) and map crates to components with evidence. | Fixtures confirm crate attribution ≥85 % coverage; metadata normalized; evidence includes path + hash. |
|
||||
| 2 | SCANNER-ANALYZERS-LANG-10-306B | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-306A | Implement heuristic classifier using ELF section names, symbol mangling, and `.comment` data for stripped binaries. | Heuristic output flagged as `heuristic`; regression tests ensure no false “observed” classifications. |
|
||||
| 3 | SCANNER-ANALYZERS-LANG-10-306C | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-306B | Integrate binary hash fallback (`bin:{sha256}`) and tie into shared quiet provenance helpers. | Fallback path deterministic; shared helpers reused; tests verify consistent hashing. |
|
||||
| 4 | SCANNER-ANALYZERS-LANG-10-307R | TODO | SCANNER-ANALYZERS-LANG-10-306C | Finalize shared helper usage (license, usage flags) and concurrency-safe caches. | Analyzer uses shared utilities; concurrency tests pass; no race conditions. |
|
||||
| 4 | SCANNER-ANALYZERS-LANG-10-307R | DOING (2025-10-23) | SCANNER-ANALYZERS-LANG-10-306C | Finalize shared helper usage (license, usage flags) and concurrency-safe caches. | Analyzer uses shared utilities; concurrency tests pass; no race conditions. |
|
||||
| 5 | SCANNER-ANALYZERS-LANG-10-308R | TODO | SCANNER-ANALYZERS-LANG-10-307R | Determinism fixtures + performance benchmarks; compare against competitor heuristic coverage. | Fixtures `Fixtures/lang/rust/` committed; determinism guard; benchmark shows ≥15 % better coverage vs competitor. |
|
||||
| 6 | SCANNER-ANALYZERS-LANG-10-309R | TODO | SCANNER-ANALYZERS-LANG-10-308R | Package plug-in manifest + Offline Kit documentation; ensure Worker integration. | Manifest copied; Worker loads analyzer; Offline Kit doc updated. |
|
||||
|
||||
@@ -37,6 +37,7 @@ All sprints below assume prerequisites from SP10-G2 (core scaffolding + Java ana
|
||||
- Usage hint propagation tests (EntryTrace → analyzer → SBOM).
|
||||
- Metrics counters (`scanner_analyzer_python_components_total`) documented.
|
||||
- **Progress (2025-10-21):** Python analyzer landed; Tasks 10-303A/B/C are DONE with dist-info parsing, RECORD verification, editable install detection, and deterministic `simple-venv` fixture + benchmark hooks recorded.
|
||||
- **Progress (2025-10-23):** Closed Tasks 10-307P/308P/309P – Python analyzer now emits quiet-provenance metadata via shared helpers, determinism harness covers `simple-venv`, `pip-cache`, and `layered-editable` fixtures, bench suite reports hash throughput (`python/hash-throughput-20251023.csv`), and Offline Kit docs list the Python plug-in in the language bundle manifest.
|
||||
|
||||
## Sprint LA3 — Go Analyzer & Build Info Synthesis (Tasks 10-304, 10-307, 10-308, 10-309 subset)
|
||||
- **Scope:** Extract Go build metadata from `.note.go.buildid`, embedded module info, and fallback to `bin:{sha256}`; surface VCS provenance.
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
| SCANNER-EMIT-10-604 | DONE (2025-10-22) | Emit Guild | SCANNER-EMIT-10-602 | Package artifacts for export + attestation (naming, compression, manifests). | Export pipeline produces deterministic file paths/hashes; integration test with storage passes. |
|
||||
| SCANNER-EMIT-10-605 | DONE (2025-10-22) | Emit Guild | SCANNER-EMIT-10-603 | Emit BOM-Index sidecar schema/fixtures (`bom-index@1`) and note CRITICAL PATH for Scheduler. | Schema + fixtures in docs/artifacts/bom-index; tests `BOMIndexGoldenIsStable` green. |
|
||||
| SCANNER-EMIT-10-606 | DONE (2025-10-22) | Emit Guild | SCANNER-EMIT-10-605 | Integrate EntryTrace usage flags into BOM-Index; document semantics. | Usage bits present in sidecar; integration tests with EntryTrace fixtures pass. |
|
||||
| SCANNER-EMIT-17-701 | TODO | Emit Guild, Native Analyzer Guild | SCANNER-EMIT-10-602 | Record GNU build-id for ELF components and surface it in inventory/usage SBOM plus diff payloads with deterministic ordering. | Native analyzer emits buildId for every ELF executable/library, SBOM/diff fixtures updated with canonical `buildId` field, regression tests prove stability, docs call out debug-symbol lookup flow. |
|
||||
| SCANNER-EMIT-17-701 | DOING (2025-10-23) | Emit Guild, Native Analyzer Guild | SCANNER-EMIT-10-602 | Record GNU build-id for ELF components and surface it in inventory/usage SBOM plus diff payloads with deterministic ordering. | Native analyzer emits buildId for every ELF executable/library, SBOM/diff fixtures updated with canonical `buildId` field, regression tests prove stability, docs call out debug-symbol lookup flow. |
|
||||
| SCANNER-EMIT-10-607 | DONE (2025-10-22) | Emit Guild | SCANNER-EMIT-10-604, POLICY-CORE-09-005 | Embed scoring inputs, confidence band, and `quietedBy` provenance into CycloneDX 1.6 and DSSE predicates; verify deterministic serialization. | SBOM/attestation fixtures include score, inputs, configVersion, quiet metadata; golden tests confirm canonical output. |
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Storage.ObjectStore;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Tests;
|
||||
|
||||
public sealed class RustFsArtifactObjectStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PutAsync_PreservesStreamAndSendsImmutableHeaders()
|
||||
{
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.Responses.Enqueue(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
var factory = new SingleHttpClientFactory(new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://rustfs.test/api/v1/"),
|
||||
});
|
||||
|
||||
var options = Options.Create(new ScannerStorageOptions
|
||||
{
|
||||
ObjectStore =
|
||||
{
|
||||
Driver = ScannerStorageDefaults.ObjectStoreProviders.RustFs,
|
||||
BucketName = "scanner-artifacts",
|
||||
RustFs =
|
||||
{
|
||||
BaseUrl = "https://rustfs.test/api/v1/",
|
||||
Timeout = TimeSpan.FromSeconds(10),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
options.Value.ObjectStore.Headers["X-Custom-Header"] = "custom-value";
|
||||
|
||||
var store = new RustFsArtifactObjectStore(factory, options, NullLogger<RustFsArtifactObjectStore>.Instance);
|
||||
|
||||
var payload = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("rustfs artifact payload"));
|
||||
var descriptor = new ArtifactObjectDescriptor("scanner-artifacts", "scanner/layers/digest/file.bin", true, TimeSpan.FromHours(1));
|
||||
|
||||
await store.PutAsync(descriptor, payload, CancellationToken.None);
|
||||
|
||||
Assert.True(payload.CanRead);
|
||||
Assert.Equal(0, payload.Position);
|
||||
|
||||
var request = Assert.Single(handler.CapturedRequests);
|
||||
Assert.Equal(HttpMethod.Put, request.Method);
|
||||
Assert.Equal("https://rustfs.test/api/v1/buckets/scanner-artifacts/objects/scanner/layers/digest/file.bin", request.RequestUri.ToString());
|
||||
Assert.Contains("X-Custom-Header", request.Headers.Keys);
|
||||
Assert.Equal("custom-value", Assert.Single(request.Headers["X-Custom-Header"]));
|
||||
Assert.Equal("true", Assert.Single(request.Headers["X-RustFS-Immutable"]));
|
||||
Assert.Equal("3600", Assert.Single(request.Headers["X-RustFS-Retain-Seconds"]));
|
||||
Assert.Equal("application/octet-stream", Assert.Single(request.Headers["Content-Type"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsNullOnNotFound()
|
||||
{
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.Responses.Enqueue(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
var factory = new SingleHttpClientFactory(new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://rustfs.test/api/v1/"),
|
||||
});
|
||||
|
||||
var options = Options.Create(new ScannerStorageOptions
|
||||
{
|
||||
ObjectStore =
|
||||
{
|
||||
Driver = ScannerStorageDefaults.ObjectStoreProviders.RustFs,
|
||||
BucketName = "scanner-artifacts",
|
||||
RustFs =
|
||||
{
|
||||
BaseUrl = "https://rustfs.test/api/v1/",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
var store = new RustFsArtifactObjectStore(factory, options, NullLogger<RustFsArtifactObjectStore>.Instance);
|
||||
var descriptor = new ArtifactObjectDescriptor("scanner-artifacts", "scanner/indexes/digest/index.bin", false);
|
||||
|
||||
var result = await store.GetAsync(descriptor, CancellationToken.None);
|
||||
|
||||
Assert.Null(result);
|
||||
var request = Assert.Single(handler.CapturedRequests);
|
||||
Assert.Equal(HttpMethod.Get, request.Method);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_IgnoresNotFound()
|
||||
{
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.Responses.Enqueue(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
var factory = new SingleHttpClientFactory(new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://rustfs.test/api/v1/"),
|
||||
});
|
||||
|
||||
var options = Options.Create(new ScannerStorageOptions
|
||||
{
|
||||
ObjectStore =
|
||||
{
|
||||
Driver = ScannerStorageDefaults.ObjectStoreProviders.RustFs,
|
||||
BucketName = "scanner-artifacts",
|
||||
RustFs =
|
||||
{
|
||||
BaseUrl = "https://rustfs.test/api/v1/",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
var store = new RustFsArtifactObjectStore(factory, options, NullLogger<RustFsArtifactObjectStore>.Instance);
|
||||
var descriptor = new ArtifactObjectDescriptor("scanner-artifacts", "scanner/attest/digest/attest.bin", false);
|
||||
|
||||
await store.DeleteAsync(descriptor, CancellationToken.None);
|
||||
|
||||
var request = Assert.Single(handler.CapturedRequests);
|
||||
Assert.Equal(HttpMethod.Delete, request.Method);
|
||||
}
|
||||
|
||||
private sealed record CapturedRequest(HttpMethod Method, Uri RequestUri, IReadOnlyDictionary<string, string[]> Headers);
|
||||
|
||||
private sealed class RecordingHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
public Queue<HttpResponseMessage> Responses { get; } = new();
|
||||
|
||||
public List<CapturedRequest> CapturedRequests { get; } = new();
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var headerSnapshot = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var header in request.Headers)
|
||||
{
|
||||
headerSnapshot[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
|
||||
if (request.Content is not null)
|
||||
{
|
||||
foreach (var header in request.Content.Headers)
|
||||
{
|
||||
headerSnapshot[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
|
||||
// Materialize content to ensure downstream callers can inspect it.
|
||||
_ = await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
CapturedRequests.Add(new CapturedRequest(request.Method, request.RequestUri!, headerSnapshot));
|
||||
return Responses.Count > 0 ? Responses.Dequeue() : new HttpResponseMessage(HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SingleHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using Amazon;
|
||||
using Amazon.S3;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Amazon;
|
||||
using Amazon.S3;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
@@ -57,16 +59,61 @@ public static class ServiceCollectionExtensions
|
||||
services.TryAddSingleton<MongoBootstrapper>();
|
||||
services.TryAddSingleton<ArtifactRepository>();
|
||||
services.TryAddSingleton<ImageRepository>();
|
||||
services.TryAddSingleton<LayerRepository>();
|
||||
services.TryAddSingleton<LayerRepository>();
|
||||
services.TryAddSingleton<LinkRepository>();
|
||||
services.TryAddSingleton<JobRepository>();
|
||||
services.TryAddSingleton<LifecycleRuleRepository>();
|
||||
services.TryAddSingleton<RuntimeEventRepository>();
|
||||
|
||||
services.TryAddSingleton(CreateAmazonS3Client);
|
||||
services.TryAddSingleton<IArtifactObjectStore, S3ArtifactObjectStore>();
|
||||
services.TryAddSingleton<ArtifactStorageService>();
|
||||
}
|
||||
|
||||
services.AddHttpClient(RustFsArtifactObjectStore.HttpClientName)
|
||||
.ConfigureHttpClient((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
|
||||
if (!options.IsRustFsDriver())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(options.RustFs.BaseUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
throw new InvalidOperationException("RustFS baseUrl must be a valid absolute URI.");
|
||||
}
|
||||
|
||||
client.BaseAddress = baseUri;
|
||||
client.Timeout = options.RustFs.Timeout;
|
||||
|
||||
foreach (var header in options.Headers)
|
||||
{
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.RustFs.ApiKeyHeader)
|
||||
&& !string.IsNullOrWhiteSpace(options.RustFs.ApiKey))
|
||||
{
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation(options.RustFs.ApiKeyHeader, options.RustFs.ApiKey);
|
||||
}
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
|
||||
if (!options.IsRustFsDriver())
|
||||
{
|
||||
return new HttpClientHandler();
|
||||
}
|
||||
|
||||
var handler = new HttpClientHandler();
|
||||
if (options.RustFs.AllowInsecureTls)
|
||||
{
|
||||
handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
|
||||
}
|
||||
|
||||
return handler;
|
||||
});
|
||||
|
||||
services.TryAddSingleton(CreateAmazonS3Client);
|
||||
services.TryAddSingleton<IArtifactObjectStore>(CreateArtifactObjectStore);
|
||||
services.TryAddSingleton<ArtifactStorageService>();
|
||||
}
|
||||
|
||||
private static IMongoClient CreateMongoClient(IServiceProvider provider)
|
||||
{
|
||||
@@ -95,11 +142,11 @@ public static class ServiceCollectionExtensions
|
||||
return client.GetDatabase(databaseName);
|
||||
}
|
||||
|
||||
private static IAmazonS3 CreateAmazonS3Client(IServiceProvider provider)
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
|
||||
var config = new AmazonS3Config
|
||||
{
|
||||
private static IAmazonS3 CreateAmazonS3Client(IServiceProvider provider)
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
|
||||
var config = new AmazonS3Config
|
||||
{
|
||||
RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region),
|
||||
ForcePathStyle = options.ForcePathStyle,
|
||||
};
|
||||
@@ -108,7 +155,26 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
config.ServiceURL = options.ServiceUrl;
|
||||
}
|
||||
|
||||
return new AmazonS3Client(config);
|
||||
}
|
||||
}
|
||||
|
||||
return new AmazonS3Client(config);
|
||||
}
|
||||
|
||||
private static IArtifactObjectStore CreateArtifactObjectStore(IServiceProvider provider)
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptions<ScannerStorageOptions>>();
|
||||
var objectStore = options.Value.ObjectStore;
|
||||
|
||||
if (objectStore.IsRustFsDriver())
|
||||
{
|
||||
return new RustFsArtifactObjectStore(
|
||||
provider.GetRequiredService<IHttpClientFactory>(),
|
||||
options,
|
||||
provider.GetRequiredService<ILogger<RustFsArtifactObjectStore>>());
|
||||
}
|
||||
|
||||
return new S3ArtifactObjectStore(
|
||||
provider.GetRequiredService<IAmazonS3>(),
|
||||
options,
|
||||
provider.GetRequiredService<ILogger<S3ArtifactObjectStore>>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.ObjectStore;
|
||||
|
||||
public sealed class RustFsArtifactObjectStore : IArtifactObjectStore
|
||||
{
|
||||
internal const string HttpClientName = "scanner-storage-rustfs";
|
||||
|
||||
private const string ImmutableHeader = "X-RustFS-Immutable";
|
||||
private const string RetainSecondsHeader = "X-RustFS-Retain-Seconds";
|
||||
private static readonly MediaTypeHeaderValue OctetStream = new("application/octet-stream");
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IOptions<ScannerStorageOptions> _options;
|
||||
private readonly ILogger<RustFsArtifactObjectStore> _logger;
|
||||
|
||||
public RustFsArtifactObjectStore(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<ScannerStorageOptions> options,
|
||||
ILogger<RustFsArtifactObjectStore> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task PutAsync(ArtifactObjectDescriptor descriptor, Stream content, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
var storeOptions = _options.Value.ObjectStore;
|
||||
EnsureRustFsDriver(storeOptions);
|
||||
|
||||
var client = _httpClientFactory.CreateClient(HttpClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, BuildRequestUri(storeOptions, descriptor))
|
||||
{
|
||||
Content = CreateHttpContent(content),
|
||||
};
|
||||
|
||||
request.Content.Headers.ContentType = OctetStream;
|
||||
ApplyHeaders(storeOptions, request, descriptor);
|
||||
|
||||
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await ReadErrorAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(
|
||||
$"RustFS upload for {descriptor.Bucket}/{descriptor.Key} failed with status {(int)response.StatusCode} ({response.ReasonPhrase}). {error}");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Uploaded scanner object {Bucket}/{Key} via RustFS", descriptor.Bucket, descriptor.Key);
|
||||
}
|
||||
|
||||
public async Task<Stream?> GetAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
|
||||
var storeOptions = _options.Value.ObjectStore;
|
||||
EnsureRustFsDriver(storeOptions);
|
||||
|
||||
var client = _httpClientFactory.CreateClient(HttpClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BuildRequestUri(storeOptions, descriptor));
|
||||
ApplyHeaders(storeOptions, request, descriptor);
|
||||
|
||||
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("RustFS object {Bucket}/{Key} not found", descriptor.Bucket, descriptor.Key);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await ReadErrorAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(
|
||||
$"RustFS download for {descriptor.Bucket}/{descriptor.Key} failed with status {(int)response.StatusCode} ({response.ReasonPhrase}). {error}");
|
||||
}
|
||||
|
||||
var buffer = new MemoryStream();
|
||||
if (response.Content is not null)
|
||||
{
|
||||
await response.Content.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
buffer.Position = 0;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
|
||||
var storeOptions = _options.Value.ObjectStore;
|
||||
EnsureRustFsDriver(storeOptions);
|
||||
|
||||
var client = _httpClientFactory.CreateClient(HttpClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, BuildRequestUri(storeOptions, descriptor));
|
||||
ApplyHeaders(storeOptions, request, descriptor);
|
||||
|
||||
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("RustFS object {Bucket}/{Key} already absent", descriptor.Bucket, descriptor.Key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await ReadErrorAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(
|
||||
$"RustFS delete for {descriptor.Bucket}/{descriptor.Key} failed with status {(int)response.StatusCode} ({response.ReasonPhrase}). {error}");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Deleted scanner object {Bucket}/{Key} via RustFS", descriptor.Bucket, descriptor.Key);
|
||||
}
|
||||
|
||||
private static void EnsureRustFsDriver(ObjectStoreOptions options)
|
||||
{
|
||||
if (!options.IsRustFsDriver())
|
||||
{
|
||||
throw new InvalidOperationException("RustFS object store invoked while driver is not set to rustfs.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ReadErrorAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.Content is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = text.Trim();
|
||||
return trimmed.Length <= 512 ? trimmed : trimmed[..512];
|
||||
}
|
||||
|
||||
private static HttpContent CreateHttpContent(Stream content)
|
||||
{
|
||||
if (content is MemoryStream memoryStream)
|
||||
{
|
||||
if (memoryStream.TryGetBuffer(out var segment))
|
||||
{
|
||||
return new ByteArrayContent(segment.Array!, segment.Offset, segment.Count);
|
||||
}
|
||||
|
||||
return new ByteArrayContent(memoryStream.ToArray());
|
||||
}
|
||||
|
||||
if (content.CanSeek)
|
||||
{
|
||||
var originalPosition = content.Position;
|
||||
try
|
||||
{
|
||||
content.Position = 0;
|
||||
using var duplicate = new MemoryStream();
|
||||
content.CopyTo(duplicate);
|
||||
var bytes = duplicate.ToArray();
|
||||
return new ByteArrayContent(bytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
content.Position = originalPosition;
|
||||
}
|
||||
}
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
content.CopyTo(buffer);
|
||||
return new ByteArrayContent(buffer.ToArray());
|
||||
}
|
||||
|
||||
private static Uri BuildRequestUri(ObjectStoreOptions options, ArtifactObjectDescriptor descriptor)
|
||||
{
|
||||
if (!Uri.TryCreate(options.RustFs.BaseUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
throw new InvalidOperationException("RustFS baseUrl is invalid.");
|
||||
}
|
||||
|
||||
var encodedBucket = Uri.EscapeDataString(descriptor.Bucket);
|
||||
var encodedKey = EncodeKey(descriptor.Key);
|
||||
var relativePath = new StringBuilder()
|
||||
.Append("buckets/")
|
||||
.Append(encodedBucket)
|
||||
.Append("/objects/")
|
||||
.Append(encodedKey)
|
||||
.ToString();
|
||||
|
||||
return new Uri(baseUri, relativePath);
|
||||
}
|
||||
|
||||
private static string EncodeKey(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var segments = key.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return string.Join('/', segments.Select(Uri.EscapeDataString));
|
||||
}
|
||||
|
||||
private void ApplyHeaders(ObjectStoreOptions options, HttpRequestMessage request, ArtifactObjectDescriptor descriptor)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.RustFs.ApiKeyHeader)
|
||||
&& !string.IsNullOrWhiteSpace(options.RustFs.ApiKey))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(options.RustFs.ApiKeyHeader, options.RustFs.ApiKey);
|
||||
}
|
||||
|
||||
foreach (var header in options.Headers)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (descriptor.Immutable)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(ImmutableHeader, "true");
|
||||
if (descriptor.RetainFor is { } retain && retain > TimeSpan.Zero)
|
||||
{
|
||||
var seconds = Math.Ceiling(retain.TotalSeconds);
|
||||
request.Headers.TryAddWithoutValidation(RetainSecondsHeader, seconds.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,18 @@ namespace StellaOps.Scanner.Storage;
|
||||
public static class ScannerStorageDefaults
|
||||
{
|
||||
public const string DefaultDatabaseName = "scanner";
|
||||
public const string DefaultBucketName = "stellaops";
|
||||
public const string DefaultRootPrefix = "scanner";
|
||||
|
||||
public static class Collections
|
||||
{
|
||||
public const string DefaultBucketName = "stellaops";
|
||||
public const string DefaultRootPrefix = "scanner";
|
||||
|
||||
public static class ObjectStoreProviders
|
||||
{
|
||||
public const string S3 = "s3";
|
||||
public const string Minio = "minio";
|
||||
public const string RustFs = "rustfs";
|
||||
}
|
||||
|
||||
public static class Collections
|
||||
{
|
||||
public const string Artifacts = "artifacts";
|
||||
public const string Images = "images";
|
||||
public const string Layers = "layers";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using MongoDB.Driver;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Scanner.Storage;
|
||||
|
||||
@@ -69,42 +71,120 @@ public sealed class MongoOptions
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ObjectStoreOptions
|
||||
{
|
||||
public string Region { get; set; } = "us-east-1";
|
||||
|
||||
public string? ServiceUrl { get; set; }
|
||||
= null;
|
||||
|
||||
public string BucketName { get; set; } = ScannerStorageDefaults.DefaultBucketName;
|
||||
|
||||
public string RootPrefix { get; set; } = ScannerStorageDefaults.DefaultRootPrefix;
|
||||
|
||||
public bool ForcePathStyle { get; set; } = true;
|
||||
|
||||
public bool EnableObjectLock { get; set; } = false;
|
||||
|
||||
public TimeSpan? ComplianceRetention { get; set; }
|
||||
= TimeSpan.FromDays(90);
|
||||
|
||||
public void EnsureValid()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(BucketName))
|
||||
{
|
||||
throw new InvalidOperationException("Scanner storage bucket name cannot be empty.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(RootPrefix))
|
||||
{
|
||||
throw new InvalidOperationException("Scanner storage root prefix cannot be empty.");
|
||||
}
|
||||
|
||||
if (ComplianceRetention is { } retention && retention <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Compliance retention must be positive when specified.");
|
||||
}
|
||||
}
|
||||
}
|
||||
public sealed class ObjectStoreOptions
|
||||
{
|
||||
private static readonly HashSet<string> S3Drivers = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
ScannerStorageDefaults.ObjectStoreProviders.S3,
|
||||
ScannerStorageDefaults.ObjectStoreProviders.Minio,
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> RustFsDrivers = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
ScannerStorageDefaults.ObjectStoreProviders.RustFs,
|
||||
};
|
||||
|
||||
public string Driver { get; set; } = ScannerStorageDefaults.ObjectStoreProviders.S3;
|
||||
|
||||
public string Region { get; set; } = "us-east-1";
|
||||
|
||||
public string? ServiceUrl { get; set; }
|
||||
= null;
|
||||
|
||||
public string BucketName { get; set; } = ScannerStorageDefaults.DefaultBucketName;
|
||||
|
||||
public string RootPrefix { get; set; } = ScannerStorageDefaults.DefaultRootPrefix;
|
||||
|
||||
public bool ForcePathStyle { get; set; } = true;
|
||||
|
||||
public bool EnableObjectLock { get; set; } = false;
|
||||
|
||||
public TimeSpan? ComplianceRetention { get; set; }
|
||||
= TimeSpan.FromDays(90);
|
||||
|
||||
public IDictionary<string, string> Headers { get; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public RustFsOptions RustFs { get; set; } = new();
|
||||
|
||||
public bool IsS3Driver()
|
||||
=> S3Drivers.Contains(Driver);
|
||||
|
||||
public bool IsRustFsDriver()
|
||||
=> RustFsDrivers.Contains(Driver);
|
||||
|
||||
public void EnsureValid()
|
||||
{
|
||||
if (!IsS3Driver() && !IsRustFsDriver())
|
||||
{
|
||||
throw new InvalidOperationException($"Scanner storage object store driver '{Driver}' is not supported.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(BucketName))
|
||||
{
|
||||
throw new InvalidOperationException("Scanner storage bucket name cannot be empty.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(RootPrefix))
|
||||
{
|
||||
throw new InvalidOperationException("Scanner storage root prefix cannot be empty.");
|
||||
}
|
||||
|
||||
if (ComplianceRetention is { } retention && retention <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Compliance retention must be positive when specified.");
|
||||
}
|
||||
|
||||
if (IsRustFsDriver())
|
||||
{
|
||||
RustFs ??= new RustFsOptions();
|
||||
RustFs.EnsureValid();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RustFsOptions
|
||||
{
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
|
||||
public bool AllowInsecureTls { get; set; }
|
||||
= false;
|
||||
|
||||
public string? ApiKey { get; set; }
|
||||
= null;
|
||||
|
||||
public string ApiKeyHeader { get; set; } = string.Empty;
|
||||
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
public void EnsureValid()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(BaseUrl))
|
||||
{
|
||||
throw new InvalidOperationException("RustFS baseUrl must be configured.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(BaseUrl, UriKind.Absolute, out var uri))
|
||||
{
|
||||
throw new InvalidOperationException("RustFS baseUrl must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("RustFS baseUrl must use HTTP or HTTPS.");
|
||||
}
|
||||
|
||||
if (Timeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("RustFS timeout must be greater than zero.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ApiKeyHeader) && string.IsNullOrWhiteSpace(ApiKey))
|
||||
{
|
||||
throw new InvalidOperationException("RustFS API key header name requires a non-empty API key.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DualWriteOptions
|
||||
{
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
<PackageReference Include="AWSSDK.S3" Version="3.7.305.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SCANNER-STORAGE-09-301 | DONE (2025-10-18) | Scanner Storage Guild | SCANNER-CORE-09-501 | Mongo catalog schemas/indexes for images, layers, artifacts, jobs, lifecycle rules plus migrations. | Collections created via bootstrapper; migrations recorded; indexes enforce uniqueness + TTL; majority read/write configured. |
|
||||
| SCANNER-STORAGE-09-302 | DONE (2025-10-18) | Scanner Storage Guild | SCANNER-STORAGE-09-301 | MinIO layout, immutability policies, client abstraction, and configuration binding. | S3 client abstraction configurable via options; bucket/prefix defaults documented; immutability flags enforced with tests; config binding validated. |
|
||||
| SCANNER-STORAGE-09-303 | DONE (2025-10-18) | Scanner Storage Guild | SCANNER-STORAGE-09-301, SCANNER-STORAGE-09-302 | Repositories/services with dual-write feature flag, deterministic digests, TTL enforcement tests. | Dual-write service writes metadata + objects atomically; digest determinism covered by tests; TTL enforcement fixture passing. |
|
||||
| SCANNER-STORAGE-09-304 | DONE (2025-10-19) | Scanner Storage Guild | SCANNER-STORAGE-09-303 | Adopt `TimeProvider` across storage timestamps for determinism. | Storage services/repositories use injected `TimeProvider`; tests cover timestamp determinism. |
|
||||
| SCANNER-STORAGE-09-302 | DONE (2025-10-18) | Scanner Storage Guild | SCANNER-STORAGE-09-301 | MinIO layout, immutability policies, client abstraction, and configuration binding. | S3 client abstraction configurable via options; bucket/prefix defaults documented; immutability flags enforced with tests; config binding validated. |
|
||||
| SCANNER-STORAGE-09-303 | DONE (2025-10-18) | Scanner Storage Guild | SCANNER-STORAGE-09-301, SCANNER-STORAGE-09-302 | Repositories/services with dual-write feature flag, deterministic digests, TTL enforcement tests. | Dual-write service writes metadata + objects atomically; digest determinism covered by tests; TTL enforcement fixture passing. |
|
||||
| SCANNER-STORAGE-09-304 | DONE (2025-10-19) | Scanner Storage Guild | SCANNER-STORAGE-09-303 | Adopt `TimeProvider` across storage timestamps for determinism. | Storage services/repositories use injected `TimeProvider`; tests cover timestamp determinism. |
|
||||
| SCANNER-STORAGE-11-401 | DONE (2025-10-23) | Scanner Storage Guild | SCANNER-STORAGE-09-302 | Replace MinIO artifact store with RustFS driver, including migration tooling and configuration updates. | RustFS provider registered across Worker/WebService; data migration plan/tooling validated on staging; Helm/offline kit configs updated; regression tests cover RustFS paths with deterministic results. |
|
||||
|
||||
@@ -17,13 +17,14 @@ internal sealed class ScannerApplicationFactory : WebApplicationFactory<Program>
|
||||
["scanner:storage:dsn"] = string.Empty,
|
||||
["scanner:queue:driver"] = "redis",
|
||||
["scanner:queue:dsn"] = "redis://localhost:6379",
|
||||
["scanner:artifactStore:driver"] = "minio",
|
||||
["scanner:artifactStore:endpoint"] = "https://minio.local",
|
||||
["scanner:artifactStore:accessKey"] = "test-access",
|
||||
["scanner:artifactStore:secretKey"] = "test-secret",
|
||||
["scanner:artifactStore:bucket"] = "scanner-artifacts",
|
||||
["scanner:telemetry:minimumLogLevel"] = "Information",
|
||||
["scanner:telemetry:enableRequestLogging"] = "false",
|
||||
["scanner:artifactStore:driver"] = "rustfs",
|
||||
["scanner:artifactStore:endpoint"] = "https://rustfs.local/api/v1/",
|
||||
["scanner:artifactStore:accessKey"] = "test-access",
|
||||
["scanner:artifactStore:secretKey"] = "test-secret",
|
||||
["scanner:artifactStore:bucket"] = "scanner-artifacts",
|
||||
["scanner:artifactStore:timeoutSeconds"] = "30",
|
||||
["scanner:telemetry:minimumLogLevel"] = "Information",
|
||||
["scanner:telemetry:enableRequestLogging"] = "false",
|
||||
["scanner:events:enabled"] = "false",
|
||||
["scanner:features:enableSignedReports"] = "false"
|
||||
};
|
||||
|
||||
@@ -102,30 +102,39 @@ public sealed class ScannerWebServiceOptions
|
||||
public IDictionary<string, string> DriverSettings { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class ArtifactStoreOptions
|
||||
{
|
||||
public string Driver { get; set; } = "minio";
|
||||
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
|
||||
public bool UseTls { get; set; } = true;
|
||||
|
||||
public string AccessKey { get; set; } = string.Empty;
|
||||
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
|
||||
public string? SecretKeyFile { get; set; }
|
||||
|
||||
public string Bucket { get; set; } = "scanner-artifacts";
|
||||
|
||||
public string? Region { get; set; }
|
||||
|
||||
public bool EnableObjectLock { get; set; } = true;
|
||||
|
||||
public int ObjectLockRetentionDays { get; set; } = 30;
|
||||
|
||||
public IDictionary<string, string> Headers { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
public sealed class ArtifactStoreOptions
|
||||
{
|
||||
public string Driver { get; set; } = "rustfs";
|
||||
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
|
||||
public bool UseTls { get; set; } = true;
|
||||
|
||||
public bool AllowInsecureTls { get; set; }
|
||||
= false;
|
||||
|
||||
public int TimeoutSeconds { get; set; } = 60;
|
||||
|
||||
public string AccessKey { get; set; } = string.Empty;
|
||||
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
|
||||
public string? SecretKeyFile { get; set; }
|
||||
|
||||
public string Bucket { get; set; } = "scanner-artifacts";
|
||||
|
||||
public string? Region { get; set; }
|
||||
|
||||
public bool EnableObjectLock { get; set; } = true;
|
||||
|
||||
public int ObjectLockRetentionDays { get; set; } = 30;
|
||||
|
||||
public string? ApiKey { get; set; }
|
||||
|
||||
public string ApiKeyHeader { get; set; } = string.Empty;
|
||||
|
||||
public IDictionary<string, string> Headers { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class FeatureFlagOptions
|
||||
{
|
||||
|
||||
@@ -23,10 +23,12 @@ public static class ScannerWebServiceOptionsValidator
|
||||
"rabbitmq"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> SupportedArtifactDrivers = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"minio"
|
||||
};
|
||||
private static readonly HashSet<string> SupportedArtifactDrivers = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"minio",
|
||||
"s3",
|
||||
"rustfs"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> SupportedEventDrivers = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -151,28 +153,53 @@ public static class ScannerWebServiceOptionsValidator
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateArtifactStore(ScannerWebServiceOptions.ArtifactStoreOptions artifactStore)
|
||||
{
|
||||
if (!SupportedArtifactDrivers.Contains(artifactStore.Driver))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported artifact store driver '{artifactStore.Driver}'. Supported drivers: minio.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifactStore.Endpoint))
|
||||
{
|
||||
throw new InvalidOperationException("Artifact store endpoint must be configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifactStore.Bucket))
|
||||
{
|
||||
throw new InvalidOperationException("Artifact store bucket must be configured.");
|
||||
}
|
||||
|
||||
if (artifactStore.EnableObjectLock && artifactStore.ObjectLockRetentionDays <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Artifact store objectLockRetentionDays must be greater than zero when object lock is enabled.");
|
||||
}
|
||||
}
|
||||
private static void ValidateArtifactStore(ScannerWebServiceOptions.ArtifactStoreOptions artifactStore)
|
||||
{
|
||||
if (!SupportedArtifactDrivers.Contains(artifactStore.Driver))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported artifact store driver '{artifactStore.Driver}'. Supported drivers: minio, s3, rustfs.");
|
||||
}
|
||||
|
||||
if (string.Equals(artifactStore.Driver, "rustfs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactStore.Endpoint))
|
||||
{
|
||||
throw new InvalidOperationException("Artifact store endpoint must be configured for RustFS.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(artifactStore.Endpoint, UriKind.Absolute, out _))
|
||||
{
|
||||
throw new InvalidOperationException("Artifact store endpoint must be an absolute URI for RustFS.");
|
||||
}
|
||||
|
||||
if (artifactStore.TimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Artifact store timeoutSeconds must be greater than zero for RustFS.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifactStore.Bucket))
|
||||
{
|
||||
throw new InvalidOperationException("Artifact store bucket must be configured.");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifactStore.Endpoint))
|
||||
{
|
||||
throw new InvalidOperationException("Artifact store endpoint must be configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifactStore.Bucket))
|
||||
{
|
||||
throw new InvalidOperationException("Artifact store bucket must be configured.");
|
||||
}
|
||||
|
||||
if (artifactStore.EnableObjectLock && artifactStore.ObjectLockRetentionDays <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Artifact store objectLockRetentionDays must be greater than zero when object lock is enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateEvents(ScannerWebServiceOptions.EventsOptions eventsOptions)
|
||||
{
|
||||
|
||||
@@ -108,9 +108,10 @@ builder.Services.AddScannerStorage(storageOptions =>
|
||||
storageOptions.Mongo.UseMajorityReadConcern = true;
|
||||
storageOptions.Mongo.UseMajorityWriteConcern = true;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bootstrapOptions.ArtifactStore.Endpoint))
|
||||
storageOptions.ObjectStore.Headers.Clear();
|
||||
foreach (var header in bootstrapOptions.ArtifactStore.Headers)
|
||||
{
|
||||
storageOptions.ObjectStore.ServiceUrl = bootstrapOptions.ArtifactStore.Endpoint;
|
||||
storageOptions.ObjectStore.Headers[header.Key] = header.Value;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bootstrapOptions.ArtifactStore.Bucket))
|
||||
@@ -118,16 +119,45 @@ builder.Services.AddScannerStorage(storageOptions =>
|
||||
storageOptions.ObjectStore.BucketName = bootstrapOptions.ArtifactStore.Bucket;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bootstrapOptions.ArtifactStore.Region))
|
||||
var artifactDriver = bootstrapOptions.ArtifactStore.Driver?.Trim() ?? string.Empty;
|
||||
if (string.Equals(artifactDriver, ScannerStorageDefaults.ObjectStoreProviders.RustFs, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
storageOptions.ObjectStore.Region = bootstrapOptions.ArtifactStore.Region;
|
||||
storageOptions.ObjectStore.Driver = ScannerStorageDefaults.ObjectStoreProviders.RustFs;
|
||||
storageOptions.ObjectStore.RustFs.BaseUrl = bootstrapOptions.ArtifactStore.Endpoint;
|
||||
storageOptions.ObjectStore.RustFs.AllowInsecureTls = bootstrapOptions.ArtifactStore.AllowInsecureTls;
|
||||
storageOptions.ObjectStore.RustFs.Timeout = TimeSpan.FromSeconds(Math.Max(1, bootstrapOptions.ArtifactStore.TimeoutSeconds));
|
||||
storageOptions.ObjectStore.RustFs.ApiKey = bootstrapOptions.ArtifactStore.ApiKey;
|
||||
storageOptions.ObjectStore.RustFs.ApiKeyHeader = bootstrapOptions.ArtifactStore.ApiKeyHeader ?? string.Empty;
|
||||
storageOptions.ObjectStore.EnableObjectLock = false;
|
||||
storageOptions.ObjectStore.ComplianceRetention = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var resolvedDriver = string.Equals(artifactDriver, ScannerStorageDefaults.ObjectStoreProviders.Minio, StringComparison.OrdinalIgnoreCase)
|
||||
? ScannerStorageDefaults.ObjectStoreProviders.Minio
|
||||
: ScannerStorageDefaults.ObjectStoreProviders.S3;
|
||||
storageOptions.ObjectStore.Driver = resolvedDriver;
|
||||
|
||||
storageOptions.ObjectStore.EnableObjectLock = bootstrapOptions.ArtifactStore.EnableObjectLock;
|
||||
storageOptions.ObjectStore.ForcePathStyle = true;
|
||||
storageOptions.ObjectStore.ComplianceRetention = bootstrapOptions.ArtifactStore.EnableObjectLock
|
||||
? TimeSpan.FromDays(Math.Max(1, bootstrapOptions.ArtifactStore.ObjectLockRetentionDays))
|
||||
: null;
|
||||
if (!string.IsNullOrWhiteSpace(bootstrapOptions.ArtifactStore.Endpoint))
|
||||
{
|
||||
storageOptions.ObjectStore.ServiceUrl = bootstrapOptions.ArtifactStore.Endpoint;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bootstrapOptions.ArtifactStore.Region))
|
||||
{
|
||||
storageOptions.ObjectStore.Region = bootstrapOptions.ArtifactStore.Region;
|
||||
}
|
||||
|
||||
storageOptions.ObjectStore.EnableObjectLock = bootstrapOptions.ArtifactStore.EnableObjectLock;
|
||||
storageOptions.ObjectStore.ForcePathStyle = true;
|
||||
storageOptions.ObjectStore.ComplianceRetention = bootstrapOptions.ArtifactStore.EnableObjectLock
|
||||
? TimeSpan.FromDays(Math.Max(1, bootstrapOptions.ArtifactStore.ObjectLockRetentionDays))
|
||||
: null;
|
||||
|
||||
storageOptions.ObjectStore.RustFs.ApiKey = null;
|
||||
storageOptions.ObjectStore.RustFs.ApiKeyHeader = string.Empty;
|
||||
storageOptions.ObjectStore.RustFs.BaseUrl = string.Empty;
|
||||
}
|
||||
});
|
||||
builder.Services.AddSingleton<RuntimeEventRateLimiter>();
|
||||
builder.Services.AddSingleton<IRuntimeEventIngestionService, RuntimeEventIngestionService>();
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
| WEB1.TRIVY-SETTINGS | DONE (2025-10-21) | UX Specialist, Angular Eng | Backend `/exporters/trivy-db` contract | Implement Trivy DB exporter settings panel with `publishFull`, `publishDelta`, `includeFull`, `includeDelta` toggles and “Run export now” action using future `/exporters/trivy-db/settings` API. | ✅ Angular route `/concelier/trivy-db-settings` backed by `TrivyDbSettingsPageComponent` with reactive form; ✅ Overrides persisted via `ConcelierExporterClient` (`settings`/`run` endpoints); ✅ Manual run button saves current overrides then triggers export and surfaces run metadata. |
|
||||
| WEB1.TRIVY-SETTINGS-TESTS | DONE (2025-10-21) | UX Specialist, Angular Eng | WEB1.TRIVY-SETTINGS | **DONE (2025-10-21)** – Added headless Karma harness (`ng test --watch=false`) wired to ChromeHeadless/CI launcher, created `karma.conf.cjs`, updated npm scripts + docs with Chromium prerequisites so CI/offline runners can execute specs deterministically. | Angular CLI available (npm scripts chained), Karma suite for Trivy DB components passing locally and in CI, docs note required prerequisites. |
|
||||
| WEB1.DEPS-13-001 | DONE (2025-10-21) | UX Specialist, Angular Eng, DevEx | WEB1.TRIVY-SETTINGS-TESTS | Stabilise Angular workspace dependencies for CI/offline nodes: refresh `package-lock.json`, ensure Puppeteer/Chromium binaries optional, document deterministic install workflow. | `npm install` completes without manual intervention on air-gapped nodes, `npm test` headless run succeeds from clean checkout, README updated with lockfile + cache steps. |
|
||||
| WEB-POLICY-FIXTURES-10-001 | DONE (2025-10-23) | Angular Eng | SAMPLES-13-004 | Wire policy preview/report doc fixtures into UI harness (test utility or Storybook substitute) with type bindings and validation guard so UI stays aligned with documented payloads. | JSON fixtures importable within Angular workspace, typed helpers exported for reuse, Karma spec validates critical fields (confidence band, unknown metrics, DSSE summary). |
|
||||
|
||||
128
src/StellaOps.Web/src/app/core/api/policy-preview.models.ts
Normal file
128
src/StellaOps.Web/src/app/core/api/policy-preview.models.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
export interface PolicyPreviewRequestDto {
|
||||
imageDigest: string;
|
||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
policy?: PolicyPreviewPolicyDto;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewPolicyDto {
|
||||
content?: string;
|
||||
format?: string;
|
||||
actor?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewFindingDto {
|
||||
id: string;
|
||||
severity: string;
|
||||
environment?: string;
|
||||
source?: string;
|
||||
vendor?: string;
|
||||
license?: string;
|
||||
image?: string;
|
||||
repository?: string;
|
||||
package?: string;
|
||||
purl?: string;
|
||||
cve?: string;
|
||||
path?: string;
|
||||
layerDigest?: string;
|
||||
tags?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewVerdictDto {
|
||||
findingId: string;
|
||||
status: string;
|
||||
ruleName?: string | null;
|
||||
ruleAction?: string | null;
|
||||
notes?: string | null;
|
||||
score?: number | null;
|
||||
configVersion?: string | null;
|
||||
inputs?: Readonly<Record<string, number>>;
|
||||
quietedBy?: string | null;
|
||||
quiet?: boolean | null;
|
||||
unknownConfidence?: number | null;
|
||||
confidenceBand?: string | null;
|
||||
unknownAgeDays?: number | null;
|
||||
sourceTrust?: string | null;
|
||||
reachability?: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewDiffDto {
|
||||
findingId: string;
|
||||
baseline: PolicyPreviewVerdictDto;
|
||||
projected: PolicyPreviewVerdictDto;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewIssueDto {
|
||||
code: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewResponseDto {
|
||||
success: boolean;
|
||||
policyDigest: string;
|
||||
revisionId?: string | null;
|
||||
changed: number;
|
||||
diffs: ReadonlyArray<PolicyPreviewDiffDto>;
|
||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewSample {
|
||||
previewRequest: PolicyPreviewRequestDto;
|
||||
previewResponse: PolicyPreviewResponseDto;
|
||||
}
|
||||
|
||||
export interface PolicyReportRequestDto {
|
||||
imageDigest: string;
|
||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
}
|
||||
|
||||
export interface PolicyReportResponseDto {
|
||||
report: PolicyReportDocumentDto;
|
||||
dsse?: DsseEnvelopeDto | null;
|
||||
}
|
||||
|
||||
export interface PolicyReportDocumentDto {
|
||||
reportId: string;
|
||||
imageDigest: string;
|
||||
generatedAt: string;
|
||||
verdict: string;
|
||||
policy: PolicyReportPolicyDto;
|
||||
summary: PolicyReportSummaryDto;
|
||||
verdicts: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||
}
|
||||
|
||||
export interface PolicyReportPolicyDto {
|
||||
revisionId?: string | null;
|
||||
digest?: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyReportSummaryDto {
|
||||
total: number;
|
||||
blocked: number;
|
||||
warned: number;
|
||||
ignored: number;
|
||||
quieted: number;
|
||||
}
|
||||
|
||||
export interface DsseEnvelopeDto {
|
||||
payloadType: string;
|
||||
payload: string;
|
||||
signatures: ReadonlyArray<DsseSignatureDto>;
|
||||
}
|
||||
|
||||
export interface DsseSignatureDto {
|
||||
keyId: string;
|
||||
algorithm: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface PolicyReportSample {
|
||||
reportRequest: PolicyReportRequestDto;
|
||||
reportResponse: PolicyReportResponseDto;
|
||||
}
|
||||
46
src/StellaOps.Web/src/app/testing/policy-fixtures.spec.ts
Normal file
46
src/StellaOps.Web/src/app/testing/policy-fixtures.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { getPolicyPreviewFixture, getPolicyReportFixture } from './policy-fixtures';
|
||||
|
||||
describe('policy fixtures', () => {
|
||||
it('returns fresh clones for preview data', () => {
|
||||
const first = getPolicyPreviewFixture();
|
||||
const second = getPolicyPreviewFixture();
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(first.previewRequest).not.toBe(second.previewRequest);
|
||||
expect(first.previewResponse.diffs).not.toBe(second.previewResponse.diffs);
|
||||
});
|
||||
|
||||
it('exposes required policy preview fields', () => {
|
||||
const { previewRequest, previewResponse } = getPolicyPreviewFixture();
|
||||
|
||||
expect(previewRequest.imageDigest).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
expect(Array.isArray(previewRequest.findings)).toBeTrue();
|
||||
expect(previewRequest.findings.length).toBeGreaterThan(0);
|
||||
expect(previewResponse.success).toBeTrue();
|
||||
expect(previewResponse.policyDigest).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(previewResponse.diffs.length).toBeGreaterThan(0);
|
||||
|
||||
const diff = previewResponse.diffs[0];
|
||||
expect(diff.projected.confidenceBand).toBeDefined();
|
||||
expect(diff.projected.unknownConfidence).toBeGreaterThan(0);
|
||||
expect(diff.projected.reachability).toBeDefined();
|
||||
});
|
||||
|
||||
it('aligns preview and report fixtures', () => {
|
||||
const preview = getPolicyPreviewFixture();
|
||||
const report = getPolicyReportFixture();
|
||||
|
||||
expect(report.report.policy.digest).toEqual(preview.previewResponse.policyDigest);
|
||||
expect(report.report.verdicts.length).toEqual(report.report.summary.total);
|
||||
expect(report.report.verdicts.length).toBeGreaterThan(0);
|
||||
expect(report.report.verdicts.some(v => v.confidenceBand != null)).toBeTrue();
|
||||
});
|
||||
|
||||
it('provides DSSE metadata for report fixture', () => {
|
||||
const { reportResponse } = getPolicyReportFixture();
|
||||
|
||||
expect(reportResponse.dsse).toBeDefined();
|
||||
expect(reportResponse.dsse?.payloadType).toBe('application/vnd.stellaops.report+json');
|
||||
expect(reportResponse.dsse?.signatures?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
21
src/StellaOps.Web/src/app/testing/policy-fixtures.ts
Normal file
21
src/StellaOps.Web/src/app/testing/policy-fixtures.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import previewSample from '../../../../samples/policy/policy-preview-unknown.json';
|
||||
import reportSample from '../../../../samples/policy/policy-report-unknown.json';
|
||||
import {
|
||||
PolicyPreviewSample,
|
||||
PolicyReportSample,
|
||||
} from '../core/api/policy-preview.models';
|
||||
|
||||
const previewFixture: PolicyPreviewSample = previewSample;
|
||||
const reportFixture: PolicyReportSample = reportSample;
|
||||
|
||||
export function getPolicyPreviewFixture(): PolicyPreviewSample {
|
||||
return clone(previewFixture);
|
||||
}
|
||||
|
||||
export function getPolicyReportFixture(): PolicyReportSample {
|
||||
return clone(reportFixture);
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
@@ -6,15 +6,16 @@
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"useDefineForClassFields": false,
|
||||
|
||||
Reference in New Issue
Block a user