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:
2025-10-19 18:36:22 +03:00
parent 7e2fa0a42a
commit 5ce40d2eeb
966 changed files with 91038 additions and 1850 deletions

View File

@@ -0,0 +1,86 @@
namespace StellaOps.Zastava.Core.Contracts;
/// <summary>
/// Envelope returned by the admission webhook to the Kubernetes API server.
/// </summary>
public sealed record class AdmissionDecisionEnvelope
{
public required string SchemaVersion { get; init; }
public required AdmissionDecision Decision { get; init; }
public static AdmissionDecisionEnvelope Create(AdmissionDecision decision, ZastavaContractVersions.ContractVersion contract)
{
ArgumentNullException.ThrowIfNull(decision);
return new AdmissionDecisionEnvelope
{
SchemaVersion = contract.ToString(),
Decision = decision
};
}
public bool IsSupported()
=> ZastavaContractVersions.IsAdmissionDecisionSupported(SchemaVersion);
}
/// <summary>
/// Canonical admission decision payload.
/// </summary>
public sealed record class AdmissionDecision
{
public required string AdmissionId { get; init; }
[JsonPropertyName("namespace")]
public required string Namespace { get; init; }
public required string PodSpecDigest { get; init; }
public IReadOnlyList<AdmissionImageVerdict> Images { get; init; } = Array.Empty<AdmissionImageVerdict>();
public required AdmissionDecisionOutcome Decision { get; init; }
public int TtlSeconds { get; init; }
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
}
public enum AdmissionDecisionOutcome
{
Allow,
Deny
}
public sealed record class AdmissionImageVerdict
{
public required string Name { get; init; }
public required string Resolved { get; init; }
public bool Signed { get; init; }
[JsonPropertyName("hasSbomReferrers")]
public bool HasSbomReferrers { get; init; }
public PolicyVerdict PolicyVerdict { get; init; }
public IReadOnlyList<string> Reasons { get; init; } = Array.Empty<string>();
public AdmissionRekorEvidence? Rekor { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
public enum PolicyVerdict
{
Pass,
Warn,
Fail,
Error
}
public sealed record class AdmissionRekorEvidence
{
public string? Uuid { get; init; }
public bool? Verified { get; init; }
}

View File

@@ -0,0 +1,179 @@
namespace StellaOps.Zastava.Core.Contracts;
/// <summary>
/// Envelope published by the observer towards Scanner runtime ingestion.
/// </summary>
public sealed record class RuntimeEventEnvelope
{
/// <summary>
/// Contract identifier consumed by negotiation logic (<c>zastava.runtime.event@v1</c>).
/// </summary>
public required string SchemaVersion { get; init; }
/// <summary>
/// Runtime event payload.
/// </summary>
public required RuntimeEvent Event { get; init; }
/// <summary>
/// Creates an envelope using the provided runtime contract version.
/// </summary>
public static RuntimeEventEnvelope Create(RuntimeEvent runtimeEvent, ZastavaContractVersions.ContractVersion contract)
{
ArgumentNullException.ThrowIfNull(runtimeEvent);
return new RuntimeEventEnvelope
{
SchemaVersion = contract.ToString(),
Event = runtimeEvent
};
}
/// <summary>
/// Checks whether the envelope schema is supported by the current runtime.
/// </summary>
public bool IsSupported()
=> ZastavaContractVersions.IsRuntimeEventSupported(SchemaVersion);
}
/// <summary>
/// Canonical runtime event emitted by the observer.
/// </summary>
public sealed record class RuntimeEvent
{
public required string EventId { get; init; }
public required DateTimeOffset When { get; init; }
public required RuntimeEventKind Kind { get; init; }
public required string Tenant { get; init; }
public required string Node { get; init; }
public required RuntimeEngine Runtime { get; init; }
public required RuntimeWorkload Workload { get; init; }
public RuntimeProcess? Process { get; init; }
[JsonPropertyName("loadedLibs")]
public IReadOnlyList<RuntimeLoadedLibrary> LoadedLibraries { get; init; } = Array.Empty<RuntimeLoadedLibrary>();
public RuntimePosture? Posture { get; init; }
public RuntimeDelta? Delta { get; init; }
public IReadOnlyList<RuntimeEvidence> Evidence { get; init; } = Array.Empty<RuntimeEvidence>();
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
}
public enum RuntimeEventKind
{
ContainerStart,
ContainerStop,
Drift,
PolicyViolation,
AttestationStatus
}
public sealed record class RuntimeEngine
{
public required string Engine { get; init; }
public string? Version { get; init; }
}
public sealed record class RuntimeWorkload
{
public required string Platform { get; init; }
[JsonPropertyName("namespace")]
public string? Namespace { get; init; }
public string? Pod { get; init; }
public string? Container { get; init; }
public string? ContainerId { get; init; }
public string? ImageRef { get; init; }
public RuntimeWorkloadOwner? Owner { get; init; }
}
public sealed record class RuntimeWorkloadOwner
{
public string? Kind { get; init; }
public string? Name { get; init; }
}
public sealed record class RuntimeProcess
{
public int Pid { get; init; }
public IReadOnlyList<string> Entrypoint { get; init; } = Array.Empty<string>();
[JsonPropertyName("entryTrace")]
public IReadOnlyList<RuntimeEntryTrace> EntryTrace { get; init; } = Array.Empty<RuntimeEntryTrace>();
}
public sealed record class RuntimeEntryTrace
{
public string? File { get; init; }
public int? Line { get; init; }
public string? Op { get; init; }
public string? Target { get; init; }
}
public sealed record class RuntimeLoadedLibrary
{
public required string Path { get; init; }
public long? Inode { get; init; }
public string? Sha256 { get; init; }
}
public sealed record class RuntimePosture
{
public bool? ImageSigned { get; init; }
public string? SbomReferrer { get; init; }
public RuntimeAttestation? Attestation { get; init; }
}
public sealed record class RuntimeAttestation
{
public string? Uuid { get; init; }
public bool? Verified { get; init; }
}
public sealed record class RuntimeDelta
{
public string? BaselineImageDigest { get; init; }
public IReadOnlyList<string> ChangedFiles { get; init; } = Array.Empty<string>();
public IReadOnlyList<RuntimeNewBinary> NewBinaries { get; init; } = Array.Empty<RuntimeNewBinary>();
}
public sealed record class RuntimeNewBinary
{
public required string Path { get; init; }
public string? Sha256 { get; init; }
}
public sealed record class RuntimeEvidence
{
public required string Signal { get; init; }
public string? Value { get; init; }
}

View File

@@ -0,0 +1,173 @@
namespace StellaOps.Zastava.Core.Contracts;
/// <summary>
/// Centralises schema identifiers and version negotiation rules for Zastava contracts.
/// </summary>
public static class ZastavaContractVersions
{
/// <summary>
/// Current local runtime event contract (major version 1).
/// </summary>
public static ContractVersion RuntimeEvent { get; } = new("zastava.runtime.event", new Version(1, 0));
/// <summary>
/// Current local admission decision contract (major version 1).
/// </summary>
public static ContractVersion AdmissionDecision { get; } = new("zastava.admission.decision", new Version(1, 0));
/// <summary>
/// Determines whether the provided schema string is supported for runtime events.
/// </summary>
public static bool IsRuntimeEventSupported(string schemaVersion)
=> ContractVersion.TryParse(schemaVersion, out var candidate) && candidate.IsCompatibleWith(RuntimeEvent);
/// <summary>
/// Determines whether the provided schema string is supported for admission decisions.
/// </summary>
public static bool IsAdmissionDecisionSupported(string schemaVersion)
=> ContractVersion.TryParse(schemaVersion, out var candidate) && candidate.IsCompatibleWith(AdmissionDecision);
/// <summary>
/// Selects the newest runtime event contract shared between the local implementation and a remote peer.
/// </summary>
public static ContractVersion NegotiateRuntimeEvent(IEnumerable<string> offeredSchemaVersions)
=> Negotiate(RuntimeEvent, offeredSchemaVersions);
/// <summary>
/// Selects the newest admission decision contract shared between the local implementation and a remote peer.
/// </summary>
public static ContractVersion NegotiateAdmissionDecision(IEnumerable<string> offeredSchemaVersions)
=> Negotiate(AdmissionDecision, offeredSchemaVersions);
private static ContractVersion Negotiate(ContractVersion local, IEnumerable<string> offered)
{
ArgumentNullException.ThrowIfNull(offered);
ContractVersion? best = null;
foreach (var entry in offered)
{
if (!ContractVersion.TryParse(entry, out var candidate))
{
continue;
}
if (!candidate.Schema.Equals(local.Schema, StringComparison.Ordinal))
{
continue;
}
if (candidate.Version.Major != local.Version.Major)
{
continue;
}
if (candidate.Version > local.Version)
{
continue;
}
if (best is null || candidate.Version > best.Value.Version)
{
best = candidate;
}
}
return best ?? local;
}
/// <summary>
/// Represents a schema + semantic version pairing in canonical form.
/// </summary>
public readonly record struct ContractVersion
{
public ContractVersion(string schema, Version version)
{
if (string.IsNullOrWhiteSpace(schema))
{
throw new ArgumentException("Schema cannot be null or whitespace.", nameof(schema));
}
Schema = schema.Trim();
Version = new Version(Math.Max(version.Major, 0), Math.Max(version.Minor, 0));
}
/// <summary>
/// Schema identifier (e.g. <c>zastava.runtime.event</c>).
/// </summary>
public string Schema { get; }
/// <summary>
/// Major/minor version recognised by the implementation.
/// </summary>
public Version Version { get; }
/// <summary>
/// Canonical string representation (schema@vMajor.Minor).
/// </summary>
public override string ToString()
=> $"{Schema}@v{Version.ToString(2, CultureInfo.InvariantCulture)}";
/// <summary>
/// Determines whether a remote contract is compatible with the local definition.
/// </summary>
public bool IsCompatibleWith(ContractVersion local)
{
if (!Schema.Equals(local.Schema, StringComparison.Ordinal))
{
return false;
}
if (Version.Major != local.Version.Major)
{
return false;
}
return Version <= local.Version;
}
/// <summary>
/// Attempts to parse a schema string in canonical format.
/// </summary>
public static bool TryParse(string? value, out ContractVersion contract)
{
contract = default;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var trimmed = value.Trim();
var separator = trimmed.IndexOf('@');
if (separator < 0)
{
return false;
}
var schema = trimmed[..separator];
if (!schema.Contains('.', StringComparison.Ordinal))
{
return false;
}
var versionToken = trimmed[(separator + 1)..];
if (versionToken.Length == 0)
{
return false;
}
if (versionToken[0] is 'v' or 'V')
{
versionToken = versionToken[1..];
}
if (!Version.TryParse(versionToken, out var parsed))
{
return false;
}
var canonical = new Version(Math.Max(parsed.Major, 0), Math.Max(parsed.Minor, 0));
contract = new ContractVersion(schema, canonical);
return true;
}
}
}

View File

@@ -0,0 +1,10 @@
global using System.Collections.Generic;
global using System.Collections.Immutable;
global using System.Diagnostics;
global using System.Diagnostics.Metrics;
global using System.Security.Cryptography;
global using System.Text;
global using System.Text.Json;
global using System.Text.Json.Serialization;
global using System.Text.Json.Serialization.Metadata;
global using System.Globalization;

View File

@@ -0,0 +1,59 @@
using StellaOps.Zastava.Core.Serialization;
namespace StellaOps.Zastava.Core.Hashing;
/// <summary>
/// Produces deterministic multihashes for runtime and admission payloads.
/// </summary>
public static class ZastavaHashing
{
public const string DefaultAlgorithm = "sha256";
/// <summary>
/// Serialises the payload using canonical options and computes a multihash string.
/// </summary>
public static string ComputeMultihash<T>(T value, string? algorithm = null)
{
ArgumentNullException.ThrowIfNull(value);
var bytes = ZastavaCanonicalJsonSerializer.SerializeToUtf8Bytes(value);
return ComputeMultihash(bytes, algorithm);
}
/// <summary>
/// Computes a multihash string from the provided payload.
/// </summary>
public static string ComputeMultihash(ReadOnlySpan<byte> payload, string? algorithm = null)
{
var normalized = NormalizeAlgorithm(algorithm);
var digest = normalized switch
{
"sha256" => SHA256.HashData(payload),
"sha512" => SHA512.HashData(payload),
_ => throw new NotSupportedException($"Hash algorithm '{normalized}' is not supported.")
};
return $"{normalized}-{ToBase64Url(digest)}";
}
private static string NormalizeAlgorithm(string? algorithm)
{
if (string.IsNullOrWhiteSpace(algorithm))
{
return DefaultAlgorithm;
}
var normalized = algorithm.Trim().ToLowerInvariant();
return normalized switch
{
"sha-256" or "sha256" => "sha256",
"sha-512" or "sha512" => "sha512",
_ => normalized
};
}
private static string ToBase64Url(ReadOnlySpan<byte> bytes)
{
var base64 = Convert.ToBase64String(bytes);
return base64.TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
}

View File

@@ -0,0 +1,110 @@
namespace StellaOps.Zastava.Core.Serialization;
/// <summary>
/// Deterministic serializer used for runtime/admission contracts.
/// </summary>
public static class ZastavaCanonicalJsonSerializer
{
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false);
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
private static readonly IReadOnlyDictionary<Type, string[]> PropertyOrderOverrides = new Dictionary<Type, string[]>
{
{ typeof(RuntimeEventEnvelope), new[] { "schemaVersion", "event" } },
{ typeof(RuntimeEvent), new[] { "eventId", "when", "kind", "tenant", "node", "runtime", "workload", "process", "loadedLibs", "posture", "delta", "evidence", "annotations" } },
{ typeof(RuntimeEngine), new[] { "engine", "version" } },
{ typeof(RuntimeWorkload), new[] { "platform", "namespace", "pod", "container", "containerId", "imageRef", "owner" } },
{ typeof(RuntimeWorkloadOwner), new[] { "kind", "name" } },
{ typeof(RuntimeProcess), new[] { "pid", "entrypoint", "entryTrace" } },
{ typeof(RuntimeEntryTrace), new[] { "file", "line", "op", "target" } },
{ typeof(RuntimeLoadedLibrary), new[] { "path", "inode", "sha256" } },
{ typeof(RuntimePosture), new[] { "imageSigned", "sbomReferrer", "attestation" } },
{ typeof(RuntimeAttestation), new[] { "uuid", "verified" } },
{ typeof(RuntimeDelta), new[] { "baselineImageDigest", "changedFiles", "newBinaries" } },
{ typeof(RuntimeNewBinary), new[] { "path", "sha256" } },
{ typeof(RuntimeEvidence), new[] { "signal", "value" } },
{ typeof(AdmissionDecisionEnvelope), new[] { "schemaVersion", "decision" } },
{ typeof(AdmissionDecision), new[] { "admissionId", "namespace", "podSpecDigest", "images", "decision", "ttlSeconds", "annotations" } },
{ typeof(AdmissionImageVerdict), new[] { "name", "resolved", "signed", "hasSbomReferrers", "policyVerdict", "reasons", "rekor", "metadata" } },
{ typeof(AdmissionRekorEvidence), new[] { "uuid", "verified" } },
{ typeof(ZastavaContractVersions.ContractVersion), new[] { "schema", "version" } }
};
public static string Serialize<T>(T value)
=> JsonSerializer.Serialize(value, CompactOptions);
public static string SerializeIndented<T>(T value)
=> JsonSerializer.Serialize(value, PrettyOptions);
public static byte[] SerializeToUtf8Bytes<T>(T value)
=> JsonSerializer.SerializeToUtf8Bytes(value, CompactOptions);
public static T Deserialize<T>(string json)
=> JsonSerializer.Deserialize<T>(json, CompactOptions)!;
private static JsonSerializerOptions CreateOptions(bool writeIndented)
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = writeIndented,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
var baselineResolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
options.TypeInfoResolver = new DeterministicTypeInfoResolver(baselineResolver);
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false));
return options;
}
private sealed class DeterministicTypeInfoResolver : IJsonTypeInfoResolver
{
private readonly IJsonTypeInfoResolver inner;
public DeterministicTypeInfoResolver(IJsonTypeInfoResolver inner)
{
this.inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
{
var info = inner.GetTypeInfo(type, options);
if (info is null)
{
throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
}
if (info.Kind is JsonTypeInfoKind.Object && info.Properties is { Count: > 1 })
{
var ordered = info.Properties
.OrderBy(property => GetPropertyOrder(type, property.Name))
.ThenBy(property => property.Name, StringComparer.Ordinal)
.ToArray();
info.Properties.Clear();
foreach (var property in ordered)
{
info.Properties.Add(property);
}
}
return info;
}
private static int GetPropertyOrder(Type type, string propertyName)
{
if (PropertyOrderOverrides.TryGetValue(type, out var order))
{
var index = Array.IndexOf(order, propertyName);
if (index >= 0)
{
return index;
}
}
return int.MaxValue;
}
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.Abstractions" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
# Zastava Core Task Board
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| ZASTAVA-CORE-12-201 | DOING (2025-10-19) | Zastava Core Guild | — | Define runtime event/admission DTOs, hashing helpers, and versioning strategy. | DTOs cover runtime events and admission verdict envelopes with canonical JSON schema; hashing helpers accept payloads and yield deterministic multihash outputs; version negotiation rules documented and exercised by serialization tests. |
| ZASTAVA-CORE-12-202 | DOING (2025-10-19) | Zastava Core Guild | — | Provide configuration/logging/metrics utilities shared by Observer/Webhook. | Shared options bind from configuration with validation; logging scopes/metrics exporters registered via reusable DI extension; integration test host demonstrates Observer/Webhook consumption with deterministic instrumentation. |
| ZASTAVA-CORE-12-203 | DOING (2025-10-19) | Zastava Core Guild | — | Authority client helpers, OpTok caching, and security guardrails for runtime services. | Typed Authority client surfaces OpTok retrieval + renewal with configurable cache; guardrails enforce DPoP/mTLS expectations and emit structured audit logs; negative-path tests cover expired/invalid tokens and configuration toggles. |
| ZASTAVA-OPS-12-204 | DOING (2025-10-19) | Zastava Core Guild | — | Operational runbooks, alert rules, and dashboard exports for runtime plane. | Runbooks capture install/upgrade/rollback + incident handling; alert rules and dashboard JSON exported for Prometheus/Grafana bundle; docs reference Offline Kit packaging and verification checklist. |
> Remark (2025-10-19): Prerequisites reviewed—none outstanding. ZASTAVA-CORE-12-201, ZASTAVA-CORE-12-202, ZASTAVA-CORE-12-203, and ZASTAVA-OPS-12-204 moved to DOING for Wave 0 kickoff.