feat(telemetry): add telemetry client and services for tracking events

- Implemented TelemetryClient to handle event queuing and flushing to the telemetry endpoint.
- Created TtfsTelemetryService for emitting specific telemetry events related to TTFS.
- Added tests for TelemetryClient to ensure event queuing and flushing functionality.
- Introduced models for reachability drift detection, including DriftResult and DriftedSink.
- Developed DriftApiService for interacting with the drift detection API.
- Updated FirstSignalCardComponent to emit telemetry events on signal appearance.
- Enhanced localization support for first signal component with i18n strings.
This commit is contained in:
master
2025-12-18 16:19:16 +02:00
parent 00d2c99af9
commit 811f35cba7
114 changed files with 13702 additions and 268 deletions

View File

@@ -23,6 +23,17 @@ public sealed class CommandFactoryTests
Assert.Contains(offline.Subcommands, command => string.Equals(command.Name, "status", StringComparison.Ordinal));
}
[Fact]
public void Create_ExposesVerifyOfflineCommands()
{
using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None));
var services = new ServiceCollection().BuildServiceProvider();
var root = CommandFactory.Create(services, new StellaOpsCliOptions(), CancellationToken.None, loggerFactory);
var verify = Assert.Single(root.Subcommands, command => string.Equals(command.Name, "verify", StringComparison.Ordinal));
Assert.Contains(verify.Subcommands, command => string.Equals(command.Name, "offline", StringComparison.Ordinal));
}
[Fact]
public void Create_ExposesExportCacheCommands()
{

View File

@@ -4760,6 +4760,9 @@ spec:
public Task<Stream> DownloadVulnExportAsync(string exportId, string? tenant, CancellationToken cancellationToken)
=> Task.FromResult<Stream>(new MemoryStream(Encoding.UTF8.GetBytes("{}")));
public Task<string?> GetScanSarifAsync(string scanId, bool includeHardening, bool includeReachability, string? minSeverity, CancellationToken cancellationToken)
=> Task.FromResult<string?>(null);
}
private sealed class StubExecutor : IScannerExecutor

View File

@@ -0,0 +1,288 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using Spectre.Console.Testing;
using StellaOps.Cli.Commands;
using StellaOps.Cli.Telemetry;
using StellaOps.Cli.Tests.Testing;
namespace StellaOps.Cli.Tests.Commands;
public sealed class VerifyOfflineCommandHandlersTests
{
[Fact]
public async Task HandleVerifyOfflineAsync_WhenEvidenceAndPolicyValid_PassesAndWritesGraph()
{
using var temp = new TempDirectory();
var evidenceDir = Path.Combine(temp.Path, "evidence");
Directory.CreateDirectory(evidenceDir);
var policyDir = Path.Combine(evidenceDir, "policy");
var keysDir = Path.Combine(evidenceDir, "keys", "identities");
var tlogKeysDir = Path.Combine(evidenceDir, "keys", "tlog-root");
var attestationsDir = Path.Combine(evidenceDir, "attestations");
var tlogDir = Path.Combine(evidenceDir, "tlog");
Directory.CreateDirectory(policyDir);
Directory.CreateDirectory(keysDir);
Directory.CreateDirectory(tlogKeysDir);
Directory.CreateDirectory(attestationsDir);
Directory.CreateDirectory(tlogDir);
// Artifact under test.
var artifactBytes = Encoding.UTF8.GetBytes("artifact-content");
var artifactDigest = ComputeSha256Hex(artifactBytes);
var artifact = $"sha256:{artifactDigest}";
// DSSE trust-root key (RSA-PSS) used by DsseVerifier.
using var rsa = RSA.Create(2048);
var rsaPublicKeyDer = rsa.ExportSubjectPublicKeyInfo();
var fingerprint = ComputeSha256Hex(rsaPublicKeyDer);
var vendorKeyPath = Path.Combine(keysDir, "vendor_A.pub");
await File.WriteAllTextAsync(vendorKeyPath, WrapPem("PUBLIC KEY", rsaPublicKeyDer), CancellationToken.None);
var attestationPath = Path.Combine(attestationsDir, "provenance.intoto.json");
await WriteDsseProvenanceAttestationAsync(attestationPath, rsa, fingerprint, artifactDigest, CancellationToken.None);
// Rekor offline proof material.
using var rekorEcdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var dsseFileBytes = await File.ReadAllBytesAsync(attestationPath, CancellationToken.None);
var dsseSha256 = SHA256.HashData(dsseFileBytes);
var otherLeaf = SHA256.HashData(Encoding.UTF8.GetBytes("other-envelope"));
var leaf0 = HashLeaf(dsseSha256);
var leaf1 = HashLeaf(otherLeaf);
var root = HashInterior(leaf0, leaf1);
var checkpointPath = Path.Combine(tlogDir, "checkpoint.sig");
await WriteCheckpointAsync(checkpointPath, rekorEcdsa, root, CancellationToken.None);
var rekorPubKeyPath = Path.Combine(tlogKeysDir, "rekor-pub.pem");
await File.WriteAllTextAsync(rekorPubKeyPath, WrapPem("PUBLIC KEY", rekorEcdsa.ExportSubjectPublicKeyInfo()), CancellationToken.None);
var receiptPath = Path.Combine(attestationsDir, "provenance.intoto.rekor.json");
var receiptJson = JsonSerializer.Serialize(new
{
uuid = "uuid-1",
logIndex = 0,
rootHash = Convert.ToHexString(root).ToLowerInvariant(),
hashes = new[] { Convert.ToHexString(leaf1).ToLowerInvariant() },
checkpoint = "../tlog/checkpoint.sig"
}, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
await File.WriteAllTextAsync(receiptPath, receiptJson, new UTF8Encoding(false), CancellationToken.None);
// Policy (YAML), resolved under evidence-dir/policy by the handler.
var policyPath = Path.Combine(policyDir, "verify-policy.yaml");
var policyYaml = """
keys:
- ./evidence/keys/identities/vendor_A.pub
tlog:
mode: "offline"
checkpoint: "./evidence/tlog/checkpoint.sig"
entry_pack: "./evidence/tlog/entries"
attestations:
required:
- type: slsa-provenance
optional: []
constraints:
subjects:
alg: "sha256"
certs:
allowed_issuers:
- "https://fulcio.offline"
allow_expired_if_timepinned: true
""";
await File.WriteAllTextAsync(policyPath, policyYaml, new UTF8Encoding(false), CancellationToken.None);
using var services = BuildServices();
var outputRoot = Path.Combine(temp.Path, "out");
var originalExitCode = Environment.ExitCode;
try
{
var output = await CaptureTestConsoleAsync(console => CommandHandlers.HandleVerifyOfflineAsync(
services,
evidenceDirectory: evidenceDir,
artifactDigest: artifact,
policyPath: "verify-policy.yaml",
outputDirectory: outputRoot,
outputFormat: "json",
verbose: false,
cancellationToken: CancellationToken.None));
Assert.Equal(OfflineExitCodes.Success, Environment.ExitCode);
using var document = JsonDocument.Parse(output.Console.Trim());
Assert.Equal("passed", document.RootElement.GetProperty("status").GetString());
Assert.Equal(OfflineExitCodes.Success, document.RootElement.GetProperty("exitCode").GetInt32());
Assert.Equal(artifact, document.RootElement.GetProperty("artifact").GetString());
var outputDir = document.RootElement.GetProperty("outputDir").GetString();
Assert.False(string.IsNullOrWhiteSpace(outputDir));
Assert.True(File.Exists(Path.Combine(outputDir!, "evidence-graph.json")));
Assert.True(File.Exists(Path.Combine(outputDir!, "evidence-graph.sha256")));
}
finally
{
Environment.ExitCode = originalExitCode;
}
}
private static ServiceProvider BuildServices()
{
var services = new ServiceCollection();
services.AddSingleton(new VerbosityState());
services.AddSingleton<ILoggerFactory>(_ => LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None)));
return services.BuildServiceProvider();
}
private static async Task<CapturedConsoleOutput> CaptureTestConsoleAsync(Func<TestConsole, Task> action)
{
var testConsole = new TestConsole();
testConsole.Width(4000);
var originalConsole = AnsiConsole.Console;
var originalOut = Console.Out;
using var writer = new StringWriter();
try
{
AnsiConsole.Console = testConsole;
Console.SetOut(writer);
await action(testConsole).ConfigureAwait(false);
return new CapturedConsoleOutput(testConsole.Output.ToString(), writer.ToString());
}
finally
{
Console.SetOut(originalOut);
AnsiConsole.Console = originalConsole;
}
}
private static async Task WriteDsseProvenanceAttestationAsync(
string path,
RSA signingKey,
string keyId,
string artifactSha256Hex,
CancellationToken ct)
{
var statementJson = JsonSerializer.Serialize(new
{
_type = "https://in-toto.io/Statement/v1",
predicateType = "https://slsa.dev/provenance/v1",
subject = new[]
{
new
{
name = "artifact",
digest = new
{
sha256 = artifactSha256Hex
}
}
},
predicate = new { }
}, new JsonSerializerOptions(JsonSerializerDefaults.Web));
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson));
var pae = BuildDssePae("application/vnd.in-toto+json", payloadBase64);
var signature = Convert.ToBase64String(signingKey.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss));
var envelopeJson = JsonSerializer.Serialize(new
{
payloadType = "application/vnd.in-toto+json",
payload = payloadBase64,
signatures = new[]
{
new { keyid = keyId, sig = signature }
}
}, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
await File.WriteAllTextAsync(path, envelopeJson, new UTF8Encoding(false), ct);
}
private static byte[] BuildDssePae(string payloadType, string payloadBase64)
{
var payloadBytes = Convert.FromBase64String(payloadBase64);
var payloadText = Encoding.UTF8.GetString(payloadBytes);
var parts = new[]
{
"DSSEv1",
payloadType,
payloadText
};
var builder = new StringBuilder();
builder.Append("PAE:");
builder.Append(parts.Length);
foreach (var part in parts)
{
builder.Append(' ');
builder.Append(part.Length);
builder.Append(' ');
builder.Append(part);
}
return Encoding.UTF8.GetBytes(builder.ToString());
}
private static async Task WriteCheckpointAsync(string path, ECDsa signingKey, byte[] rootHash, CancellationToken ct)
{
var origin = "rekor.sigstore.dev - 2605736670972794746";
var treeSize = 2L;
var rootBase64 = Convert.ToBase64String(rootHash);
var timestamp = "1700000000";
var canonicalBody = $"{origin}\n{treeSize}\n{rootBase64}\n{timestamp}\n";
var signature = signingKey.SignData(Encoding.UTF8.GetBytes(canonicalBody), HashAlgorithmName.SHA256);
var signatureBase64 = Convert.ToBase64String(signature);
await File.WriteAllTextAsync(path, canonicalBody + $"sig {signatureBase64}\n", new UTF8Encoding(false), ct);
}
private static byte[] HashLeaf(byte[] leafData)
{
var buffer = new byte[1 + leafData.Length];
buffer[0] = 0x00;
leafData.CopyTo(buffer, 1);
return SHA256.HashData(buffer);
}
private static byte[] HashInterior(byte[] left, byte[] right)
{
var buffer = new byte[1 + left.Length + right.Length];
buffer[0] = 0x01;
left.CopyTo(buffer, 1);
right.CopyTo(buffer, 1 + left.Length);
return SHA256.HashData(buffer);
}
private static string ComputeSha256Hex(byte[] bytes)
{
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string WrapPem(string label, byte[] derBytes)
{
var base64 = Convert.ToBase64String(derBytes);
var builder = new StringBuilder();
builder.Append("-----BEGIN ").Append(label).AppendLine("-----");
for (var offset = 0; offset < base64.Length; offset += 64)
{
builder.AppendLine(base64.Substring(offset, Math.Min(64, base64.Length - offset)));
}
builder.Append("-----END ").Append(label).AppendLine("-----");
return builder.ToString();
}
private sealed record CapturedConsoleOutput(string Console, string Plain);
}