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:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user