feat: Initialize Zastava Webhook service with TLS and Authority authentication

- Added Program.cs to set up the web application with Serilog for logging, health check endpoints, and a placeholder admission endpoint.
- Configured Kestrel server to use TLS 1.3 and handle client certificates appropriately.
- Created StellaOps.Zastava.Webhook.csproj with necessary dependencies including Serilog and Polly.
- Documented tasks in TASKS.md for the Zastava Webhook project, outlining current work and exit criteria for each task.
This commit is contained in:
master
2025-10-19 18:36:22 +03:00
parent 2062da7a8b
commit d099a90f9b
966 changed files with 91038 additions and 1850 deletions

View File

@@ -0,0 +1,120 @@
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Infrastructure.Rekor;
using StellaOps.Attestor.Infrastructure.Storage;
using StellaOps.Attestor.Infrastructure.Submission;
using Xunit;
namespace StellaOps.Attestor.Tests;
public sealed class AttestorSubmissionServiceTests
{
[Fact]
public async Task SubmitAsync_ReturnsDeterministicUuid_OnDuplicateBundle()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions
{
Url = string.Empty
},
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.stellaops.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
}
});
var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
var auditSink = new InMemoryAttestorAuditSink();
var logger = new NullLogger<AttestorSubmissionService>();
using var metrics = new AttestorMetrics();
var service = new AttestorSubmissionService(
validator,
repository,
dedupeStore,
rekorClient,
archiveStore,
auditSink,
options,
logger,
TimeProvider.System,
metrics);
var request = CreateValidRequest(canonicalizer);
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default",
ClientCertificate = null,
MtlsThumbprint = "00"
};
var first = await service.SubmitAsync(request, context);
var second = await service.SubmitAsync(request, context);
Assert.NotNull(first.Uuid);
Assert.Equal(first.Uuid, second.Uuid);
var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256);
Assert.NotNull(stored);
Assert.Equal(first.Uuid, stored!.RekorUuid);
}
private static AttestorSubmissionRequest CreateValidRequest(DefaultDsseCanonicalizer canonicalizer)
{
var request = new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Mode = "keyless",
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/vnd.in-toto+json",
PayloadBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
Signatures =
{
new AttestorSubmissionRequest.DsseSignature
{
KeyId = "test",
Signature = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
}
}
}
},
Meta = new AttestorSubmissionRequest.SubmissionMeta
{
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Sha256 = new string('a', 64),
Kind = "sbom"
},
LogPreference = "primary",
Archive = false
}
};
var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
return request;
}
}

View File

@@ -0,0 +1,194 @@
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Attestor.Infrastructure.Storage;
using StellaOps.Attestor.Infrastructure.Submission;
using StellaOps.Attestor.Infrastructure.Verification;
using StellaOps.Attestor.Infrastructure.Rekor;
using StellaOps.Attestor.Core.Observability;
using Xunit;
namespace StellaOps.Attestor.Tests;
public sealed class AttestorVerificationServiceTests
{
[Fact]
public async Task VerifyAsync_ReturnsOk_ForExistingUuid()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions
{
Url = string.Empty
},
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.stellaops.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
}
});
using var metrics = new AttestorMetrics();
var canonicalizer = new DefaultDsseCanonicalizer();
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
var auditSink = new InMemoryAttestorAuditSink();
var submissionService = new AttestorSubmissionService(
new AttestorSubmissionValidator(canonicalizer),
repository,
dedupeStore,
rekorClient,
archiveStore,
auditSink,
options,
new NullLogger<AttestorSubmissionService>(),
TimeProvider.System,
metrics);
var submission = CreateSubmissionRequest(canonicalizer);
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default"
};
var response = await submissionService.SubmitAsync(submission, context);
var verificationService = new AttestorVerificationService(
repository,
canonicalizer,
rekorClient,
options,
new NullLogger<AttestorVerificationService>());
var verifyResult = await verificationService.VerifyAsync(new AttestorVerificationRequest
{
Uuid = response.Uuid
});
Assert.True(verifyResult.Ok);
Assert.Equal(response.Uuid, verifyResult.Uuid);
Assert.Empty(verifyResult.Issues);
}
[Fact]
public async Task VerifyAsync_FlagsTamperedBundle()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.example/",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
}
});
using var metrics = new AttestorMetrics();
var canonicalizer = new DefaultDsseCanonicalizer();
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
var auditSink = new InMemoryAttestorAuditSink();
var submissionService = new AttestorSubmissionService(
new AttestorSubmissionValidator(canonicalizer),
repository,
dedupeStore,
rekorClient,
archiveStore,
auditSink,
options,
new NullLogger<AttestorSubmissionService>(),
TimeProvider.System,
metrics);
var submission = CreateSubmissionRequest(canonicalizer);
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default"
};
var response = await submissionService.SubmitAsync(submission, context);
var verificationService = new AttestorVerificationService(
repository,
canonicalizer,
rekorClient,
options,
new NullLogger<AttestorVerificationService>());
var tamperedBundle = submission.Bundle;
tamperedBundle.Dsse.PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"tampered\":true}"));
var result = await verificationService.VerifyAsync(new AttestorVerificationRequest
{
Uuid = response.Uuid,
Bundle = tamperedBundle
});
Assert.False(result.Ok);
Assert.Contains(result.Issues, issue => issue.Contains("Bundle hash", StringComparison.OrdinalIgnoreCase));
}
private static AttestorSubmissionRequest CreateSubmissionRequest(DefaultDsseCanonicalizer canonicalizer)
{
var payload = Encoding.UTF8.GetBytes("{}");
var request = new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Mode = "keyless",
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/vnd.in-toto+json",
PayloadBase64 = Convert.ToBase64String(payload),
Signatures =
{
new AttestorSubmissionRequest.DsseSignature
{
KeyId = "test",
Signature = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
}
}
}
},
Meta = new AttestorSubmissionRequest.SubmissionMeta
{
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Sha256 = new string('a', 64),
Kind = "sbom"
},
LogPreference = "primary",
Archive = false
}
};
var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
return request;
}
}

View File

@@ -0,0 +1,149 @@
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Infrastructure.Rekor;
using Xunit;
namespace StellaOps.Attestor.Tests;
public sealed class HttpRekorClientTests
{
[Fact]
public async Task SubmitAsync_ParsesResponse()
{
var payload = new
{
uuid = "123",
index = 42,
logURL = "https://rekor.example/api/v2/log/entries/123",
status = "included",
proof = new
{
checkpoint = new { origin = "rekor", size = 10, rootHash = "abc", timestamp = "2025-10-19T00:00:00Z" },
inclusion = new { leafHash = "leaf", path = new[] { "p1", "p2" } }
}
};
var client = CreateClient(HttpStatusCode.Created, payload);
var rekorClient = new HttpRekorClient(client, NullLogger<HttpRekorClient>.Instance);
var request = new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/json",
PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
Signatures = { new AttestorSubmissionRequest.DsseSignature { Signature = "sig" } }
}
}
};
var backend = new RekorBackend
{
Name = "primary",
Url = new Uri("https://rekor.example/"),
ProofTimeout = TimeSpan.FromSeconds(1),
PollInterval = TimeSpan.FromMilliseconds(100),
MaxAttempts = 1
};
var response = await rekorClient.SubmitAsync(request, backend);
Assert.Equal("123", response.Uuid);
Assert.Equal(42, response.Index);
Assert.Equal("included", response.Status);
Assert.NotNull(response.Proof);
Assert.Equal("leaf", response.Proof!.Inclusion!.LeafHash);
}
[Fact]
public async Task SubmitAsync_ThrowsOnConflict()
{
var client = CreateClient(HttpStatusCode.Conflict, new { error = "duplicate" });
var rekorClient = new HttpRekorClient(client, NullLogger<HttpRekorClient>.Instance);
var request = new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/json",
PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
Signatures = { new AttestorSubmissionRequest.DsseSignature { Signature = "sig" } }
}
}
};
var backend = new RekorBackend
{
Name = "primary",
Url = new Uri("https://rekor.example/"),
ProofTimeout = TimeSpan.FromSeconds(1),
PollInterval = TimeSpan.FromMilliseconds(100),
MaxAttempts = 1
};
await Assert.ThrowsAsync<InvalidOperationException>(() => rekorClient.SubmitAsync(request, backend));
}
[Fact]
public async Task GetProofAsync_ReturnsNullOnNotFound()
{
var client = CreateClient(HttpStatusCode.NotFound, new { });
var rekorClient = new HttpRekorClient(client, NullLogger<HttpRekorClient>.Instance);
var backend = new RekorBackend
{
Name = "primary",
Url = new Uri("https://rekor.example/"),
ProofTimeout = TimeSpan.FromSeconds(1),
PollInterval = TimeSpan.FromMilliseconds(100),
MaxAttempts = 1
};
var proof = await rekorClient.GetProofAsync("abc", backend);
Assert.Null(proof);
}
private static HttpClient CreateClient(HttpStatusCode statusCode, object payload)
{
var handler = new StubHandler(statusCode, payload);
return new HttpClient(handler)
{
BaseAddress = new Uri("https://rekor.example/")
};
}
private sealed class StubHandler : HttpMessageHandler
{
private readonly HttpStatusCode _statusCode;
private readonly object _payload;
public StubHandler(HttpStatusCode statusCode, object payload)
{
_statusCode = statusCode;
_payload = payload;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(_payload);
var response = new HttpResponseMessage(_statusCode)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
return Task.FromResult(response);
}
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Mongo2Go" Version="3.1.3" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Attestor.WebService\StellaOps.Attestor.WebService.csproj" />
<ProjectReference Include="..\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" />
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,54 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Audit;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Tests;
internal sealed class InMemoryAttestorEntryRepository : IAttestorEntryRepository
{
private readonly ConcurrentDictionary<string, AttestorEntry> _entries = new();
public Task<AttestorEntry?> GetByBundleShaAsync(string bundleSha256, CancellationToken cancellationToken = default)
{
var entry = _entries.Values.FirstOrDefault(e => string.Equals(e.BundleSha256, bundleSha256, StringComparison.OrdinalIgnoreCase));
return Task.FromResult(entry);
}
public Task<AttestorEntry?> GetByUuidAsync(string rekorUuid, CancellationToken cancellationToken = default)
{
_entries.TryGetValue(rekorUuid, out var entry);
return Task.FromResult(entry);
}
public Task<IReadOnlyList<AttestorEntry>> GetByArtifactShaAsync(string artifactSha256, CancellationToken cancellationToken = default)
{
var entries = _entries.Values
.Where(e => string.Equals(e.Artifact.Sha256, artifactSha256, StringComparison.OrdinalIgnoreCase))
.OrderBy(e => e.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<AttestorEntry>>(entries);
}
public Task SaveAsync(AttestorEntry entry, CancellationToken cancellationToken = default)
{
_entries[entry.RekorUuid] = entry;
return Task.CompletedTask;
}
}
internal sealed class InMemoryAttestorAuditSink : IAttestorAuditSink
{
public List<AttestorAuditRecord> Records { get; } = new();
public Task WriteAsync(AttestorAuditRecord record, CancellationToken cancellationToken = default)
{
Records.Add(record);
return Task.CompletedTask;
}
}