up the blokcing tasks
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Risk Bundle CI / risk-bundle-build (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Risk Bundle CI / risk-bundle-offline-kit (push) Has been cancelled
Risk Bundle CI / publish-checksums (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-11 02:32:18 +02:00
parent 92bc4d3a07
commit 49922dff5a
474 changed files with 76071 additions and 12411 deletions

View File

@@ -57,6 +57,7 @@ internal static class CommandFactory
root.Add(BuildVulnCommand(services, verboseOption, cancellationToken));
root.Add(BuildVexCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildCryptoCommand(services, verboseOption, cancellationToken));
root.Add(BuildExportCommand(services, verboseOption, cancellationToken));
root.Add(BuildAttestCommand(services, verboseOption, cancellationToken));
root.Add(BuildRiskProfileCommand(verboseOption, cancellationToken));
root.Add(BuildAdvisoryCommand(services, verboseOption, cancellationToken));
@@ -8713,6 +8714,261 @@ internal static class CommandFactory
return sbom;
}
private static Command BuildExportCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var export = new Command("export", "Manage export profiles and runs.");
var jsonOption = new Option<bool>("--json")
{
Description = "Emit output in JSON."
};
var profiles = new Command("profiles", "Manage export profiles.");
var profilesList = new Command("list", "List export profiles.");
var profileLimitOption = new Option<int?>("--limit")
{
Description = "Maximum number of profiles to return."
};
var profileCursorOption = new Option<string?>("--cursor")
{
Description = "Pagination cursor."
};
profilesList.Add(profileLimitOption);
profilesList.Add(profileCursorOption);
profilesList.Add(jsonOption);
profilesList.Add(verboseOption);
profilesList.SetAction((parseResult, _) =>
{
var limit = parseResult.GetValue(profileLimitOption);
var cursor = parseResult.GetValue(profileCursorOption);
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExportProfilesListAsync(
services,
limit,
cursor,
json,
verbose,
cancellationToken);
});
var profilesShow = new Command("show", "Show export profile details.");
var profileIdArg = new Argument<string>("profile-id")
{
Description = "Export profile identifier."
};
profilesShow.Add(profileIdArg);
profilesShow.Add(jsonOption);
profilesShow.Add(verboseOption);
profilesShow.SetAction((parseResult, _) =>
{
var profileId = parseResult.GetValue(profileIdArg) ?? string.Empty;
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExportProfileShowAsync(
services,
profileId,
json,
verbose,
cancellationToken);
});
profiles.Add(profilesList);
profiles.Add(profilesShow);
export.Add(profiles);
var runs = new Command("runs", "Manage export runs.");
var runsList = new Command("list", "List export runs.");
var runProfileOption = new Option<string?>("--profile-id")
{
Description = "Filter runs by profile ID."
};
var runLimitOption = new Option<int?>("--limit")
{
Description = "Maximum number of runs to return."
};
var runCursorOption = new Option<string?>("--cursor")
{
Description = "Pagination cursor."
};
runsList.Add(runProfileOption);
runsList.Add(runLimitOption);
runsList.Add(runCursorOption);
runsList.Add(jsonOption);
runsList.Add(verboseOption);
runsList.SetAction((parseResult, _) =>
{
var profileId = parseResult.GetValue(runProfileOption);
var limit = parseResult.GetValue(runLimitOption);
var cursor = parseResult.GetValue(runCursorOption);
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExportRunsListAsync(
services,
profileId,
limit,
cursor,
json,
verbose,
cancellationToken);
});
var runIdArg = new Argument<string>("run-id")
{
Description = "Export run identifier."
};
var runsShow = new Command("show", "Show export run details.");
runsShow.Add(runIdArg);
runsShow.Add(jsonOption);
runsShow.Add(verboseOption);
runsShow.SetAction((parseResult, _) =>
{
var runId = parseResult.GetValue(runIdArg) ?? string.Empty;
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExportRunShowAsync(
services,
runId,
json,
verbose,
cancellationToken);
});
var runsDownload = new Command("download", "Download an export bundle for a run.");
runsDownload.Add(runIdArg);
var runOutputOption = new Option<string>("--output", new[] { "-o" })
{
Description = "Path to write the export bundle.",
IsRequired = true
};
var runOverwriteOption = new Option<bool>("--overwrite")
{
Description = "Overwrite output file if it exists."
};
var runVerifyHashOption = new Option<string?>("--verify-hash")
{
Description = "Optional SHA256 hash to verify after download."
};
var runTypeOption = new Option<string>("--type")
{
Description = "Run type: evidence (default) or attestation."
};
runTypeOption.SetDefaultValue("evidence");
runsDownload.Add(runOutputOption);
runsDownload.Add(runOverwriteOption);
runsDownload.Add(runVerifyHashOption);
runsDownload.Add(runTypeOption);
runsDownload.Add(verboseOption);
runsDownload.SetAction((parseResult, _) =>
{
var runId = parseResult.GetValue(runIdArg) ?? string.Empty;
var output = parseResult.GetValue(runOutputOption) ?? string.Empty;
var overwrite = parseResult.GetValue(runOverwriteOption);
var verifyHash = parseResult.GetValue(runVerifyHashOption);
var runType = parseResult.GetValue(runTypeOption) ?? "evidence";
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExportRunDownloadAsync(
services,
runId,
output,
overwrite,
verifyHash,
runType,
verbose,
cancellationToken);
});
runs.Add(runsList);
runs.Add(runsShow);
runs.Add(runsDownload);
export.Add(runs);
var start = new Command("start", "Start export jobs.");
var startProfileOption = new Option<string>("--profile-id")
{
Description = "Export profile identifier.",
IsRequired = true
};
var startSelectorOption = new Option<string[]?>("--selector", new[] { "-s" })
{
Description = "Selector key=value filters (repeatable).",
AllowMultipleArgumentsPerToken = true
};
var startCallbackOption = new Option<string?>("--callback-url")
{
Description = "Optional callback URL for completion notifications."
};
var startEvidence = new Command("evidence", "Start an evidence export run.");
startEvidence.Add(startProfileOption);
startEvidence.Add(startSelectorOption);
startEvidence.Add(startCallbackOption);
startEvidence.Add(jsonOption);
startEvidence.Add(verboseOption);
startEvidence.SetAction((parseResult, _) =>
{
var profileId = parseResult.GetValue(startProfileOption) ?? string.Empty;
var selectors = parseResult.GetValue(startSelectorOption);
var callback = parseResult.GetValue(startCallbackOption);
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExportStartEvidenceAsync(
services,
profileId,
selectors,
callback,
json,
verbose,
cancellationToken);
});
var startAttestation = new Command("attestation", "Start an attestation export run.");
startAttestation.Add(startProfileOption);
startAttestation.Add(startSelectorOption);
var startTransparencyOption = new Option<bool>("--include-transparency")
{
Description = "Include transparency log entries."
};
startAttestation.Add(startTransparencyOption);
startAttestation.Add(startCallbackOption);
startAttestation.Add(jsonOption);
startAttestation.Add(verboseOption);
startAttestation.SetAction((parseResult, _) =>
{
var profileId = parseResult.GetValue(startProfileOption) ?? string.Empty;
var selectors = parseResult.GetValue(startSelectorOption);
var includeTransparency = parseResult.GetValue(startTransparencyOption);
var callback = parseResult.GetValue(startCallbackOption);
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExportStartAttestationAsync(
services,
profileId,
selectors,
includeTransparency,
callback,
json,
verbose,
cancellationToken);
});
start.Add(startEvidence);
start.Add(startAttestation);
export.Add(start);
return export;
}
// CLI-PARITY-41-002: Notify command group
private static Command BuildNotifyCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
@@ -9038,6 +9294,79 @@ internal static class CommandFactory
notify.Add(deliveries);
// notify simulate
var simulate = new Command("simulate", "Simulate notification rules against events.");
var simulateEventsFileOption = new Option<string?>("--events-file")
{
Description = "Path to JSON file containing events array for simulation."
};
var simulateRulesFileOption = new Option<string?>("--rules-file")
{
Description = "Optional JSON file containing rules array to evaluate (overrides server rules)."
};
var simulateEnabledOnlyOption = new Option<bool>("--enabled-only")
{
Description = "Only evaluate enabled rules."
};
var simulateLookbackOption = new Option<int?>("--lookback-minutes")
{
Description = "Historical lookback window for events."
};
var simulateMaxEventsOption = new Option<int?>("--max-events")
{
Description = "Maximum events to evaluate."
};
var simulateEventKindOption = new Option<string?>("--event-kind")
{
Description = "Filter simulation to a specific event kind."
};
var simulateIncludeNonMatchesOption = new Option<bool>("--include-non-matches")
{
Description = "Include non-match explanations."
};
simulate.Add(tenantOption);
simulate.Add(simulateEventsFileOption);
simulate.Add(simulateRulesFileOption);
simulate.Add(simulateEnabledOnlyOption);
simulate.Add(simulateLookbackOption);
simulate.Add(simulateMaxEventsOption);
simulate.Add(simulateEventKindOption);
simulate.Add(simulateIncludeNonMatchesOption);
simulate.Add(jsonOption);
simulate.Add(verboseOption);
simulate.SetAction((parseResult, _) =>
{
var tenant = parseResult.GetValue(tenantOption);
var eventsFile = parseResult.GetValue(simulateEventsFileOption);
var rulesFile = parseResult.GetValue(simulateRulesFileOption);
var enabledOnly = parseResult.GetValue(simulateEnabledOnlyOption);
var lookback = parseResult.GetValue(simulateLookbackOption);
var maxEvents = parseResult.GetValue(simulateMaxEventsOption);
var eventKind = parseResult.GetValue(simulateEventKindOption);
var includeNonMatches = parseResult.GetValue(simulateIncludeNonMatchesOption);
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleNotifySimulateAsync(
services,
tenant,
eventsFile,
rulesFile,
enabledOnly,
lookback,
maxEvents,
eventKind,
includeNonMatches,
json,
verbose,
cancellationToken);
});
notify.Add(simulate);
// notify send
var send = new Command("send", "Send a notification.");
@@ -9112,6 +9441,61 @@ internal static class CommandFactory
notify.Add(send);
// notify ack
var ack = new Command("ack", "Acknowledge a notification or incident.");
var ackTenantOption = new Option<string?>("--tenant")
{
Description = "Tenant identifier (header)."
};
var ackIncidentOption = new Option<string?>("--incident-id")
{
Description = "Incident identifier to acknowledge."
};
var ackTokenOption = new Option<string?>("--token")
{
Description = "Signed acknowledgment token."
};
var ackByOption = new Option<string?>("--by")
{
Description = "Actor performing the acknowledgment."
};
var ackCommentOption = new Option<string?>("--comment")
{
Description = "Optional acknowledgment comment."
};
ack.Add(ackTenantOption);
ack.Add(ackIncidentOption);
ack.Add(ackTokenOption);
ack.Add(ackByOption);
ack.Add(ackCommentOption);
ack.Add(jsonOption);
ack.Add(verboseOption);
ack.SetAction((parseResult, _) =>
{
var tenant = parseResult.GetValue(ackTenantOption);
var incidentId = parseResult.GetValue(ackIncidentOption);
var token = parseResult.GetValue(ackTokenOption);
var by = parseResult.GetValue(ackByOption);
var comment = parseResult.GetValue(ackCommentOption);
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleNotifyAckAsync(
services,
tenant,
incidentId,
token,
by,
comment,
json,
verbose,
cancellationToken);
});
notify.Add(ack);
return notify;
}
@@ -10682,4 +11066,3 @@ internal static class CommandFactory
return devportal;
}
}

View File

@@ -23,6 +23,8 @@ using Microsoft.Extensions.Options;
using Spectre.Console;
using Spectre.Console.Rendering;
using StellaOps.Auth.Client;
using StellaOps.ExportCenter.Client;
using StellaOps.ExportCenter.Client.Models;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Output;
using StellaOps.Cli.Prompts;
@@ -24774,8 +24776,485 @@ stella policy test {policyName}.stella
#endregion
#region Export Handlers (CLI-EXPORT-35-037)
internal static async Task<int> HandleExportProfilesListAsync(
IServiceProvider services,
int? limit,
string? cursor,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<IExportCenterClient>();
var response = await client.ListProfilesAsync(cursor, limit, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
return 0;
}
if (response.Profiles.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No export profiles found.[/]");
return 0;
}
var table = new Table();
table.AddColumn("Profile ID");
table.AddColumn("Name");
table.AddColumn("Adapter");
table.AddColumn("Format");
table.AddColumn("Signing");
table.AddColumn("Created");
table.AddColumn("Updated");
foreach (var profile in response.Profiles)
{
table.AddRow(
Markup.Escape(profile.ProfileId),
Markup.Escape(profile.Name),
Markup.Escape(profile.Adapter),
Markup.Escape(profile.OutputFormat),
profile.SigningEnabled ? "[green]Yes[/]" : "[grey]No[/]",
profile.CreatedAt.ToString("u", CultureInfo.InvariantCulture),
profile.UpdatedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]");
}
AnsiConsole.Write(table);
return 0;
}
internal static async Task<int> HandleExportProfileShowAsync(
IServiceProvider services,
string profileId,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<IExportCenterClient>();
var profile = await client.GetProfileAsync(profileId, cancellationToken).ConfigureAwait(false);
if (profile is null)
{
AnsiConsole.MarkupLine($"[red]Profile not found:[/] {Markup.Escape(profileId)}");
return 1;
}
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(profile, JsonOptions));
return 0;
}
var profileTable = new Table { Border = TableBorder.Rounded };
profileTable.AddColumn("Field");
profileTable.AddColumn("Value");
profileTable.AddRow("Profile ID", Markup.Escape(profile.ProfileId));
profileTable.AddRow("Name", Markup.Escape(profile.Name));
profileTable.AddRow("Description", string.IsNullOrWhiteSpace(profile.Description) ? "[grey]-[/]" : Markup.Escape(profile.Description));
profileTable.AddRow("Adapter", Markup.Escape(profile.Adapter));
profileTable.AddRow("Format", Markup.Escape(profile.OutputFormat));
profileTable.AddRow("Signing", profile.SigningEnabled ? "[green]Enabled[/]" : "[grey]Disabled[/]");
profileTable.AddRow("Created", profile.CreatedAt.ToString("u", CultureInfo.InvariantCulture));
profileTable.AddRow("Updated", profile.UpdatedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]");
if (profile.Selectors is { Count: > 0 })
{
var selectorTable = new Table { Title = new TableTitle("Selectors") };
selectorTable.AddColumn("Key");
selectorTable.AddColumn("Value");
foreach (var selector in profile.Selectors)
{
selectorTable.AddRow(Markup.Escape(selector.Key), Markup.Escape(selector.Value));
}
AnsiConsole.Write(profileTable);
AnsiConsole.WriteLine();
AnsiConsole.Write(selectorTable);
}
else
{
AnsiConsole.Write(profileTable);
}
return 0;
}
internal static async Task<int> HandleExportRunsListAsync(
IServiceProvider services,
string? profileId,
int? limit,
string? cursor,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<IExportCenterClient>();
var response = await client.ListRunsAsync(profileId, cursor, limit, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
return 0;
}
if (response.Runs.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No export runs found.[/]");
return 0;
}
var table = new Table();
table.AddColumn("Run ID");
table.AddColumn("Profile");
table.AddColumn("Status");
table.AddColumn("Progress");
table.AddColumn("Started");
table.AddColumn("Completed");
table.AddColumn("Bundle");
foreach (var run in response.Runs)
{
table.AddRow(
Markup.Escape(run.RunId),
Markup.Escape(run.ProfileId),
Markup.Escape(run.Status),
run.Progress.HasValue ? $"{run.Progress.Value}%" : "[grey]-[/]",
run.StartedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]",
run.CompletedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]",
string.IsNullOrWhiteSpace(run.BundleHash) ? "[grey]-[/]" : Markup.Escape(run.BundleHash));
}
AnsiConsole.Write(table);
if (response.HasMore && !string.IsNullOrWhiteSpace(response.ContinuationToken))
{
AnsiConsole.MarkupLine($"[yellow]More available. Use --cursor {Markup.Escape(response.ContinuationToken)}[/]");
}
return 0;
}
internal static async Task<int> HandleExportRunShowAsync(
IServiceProvider services,
string runId,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<IExportCenterClient>();
var run = await client.GetRunAsync(runId, cancellationToken).ConfigureAwait(false);
if (run is null)
{
AnsiConsole.MarkupLine($"[red]Run not found:[/] {Markup.Escape(runId)}");
return 1;
}
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(run, JsonOptions));
return 0;
}
var table = new Table { Border = TableBorder.Rounded };
table.AddColumn("Field");
table.AddColumn("Value");
table.AddRow("Run ID", Markup.Escape(run.RunId));
table.AddRow("Profile ID", Markup.Escape(run.ProfileId));
table.AddRow("Status", Markup.Escape(run.Status));
table.AddRow("Progress", run.Progress.HasValue ? $"{run.Progress.Value}%" : "[grey]-[/]");
table.AddRow("Started", run.StartedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]");
table.AddRow("Completed", run.CompletedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]");
table.AddRow("Bundle Hash", string.IsNullOrWhiteSpace(run.BundleHash) ? "[grey]-[/]" : Markup.Escape(run.BundleHash));
table.AddRow("Bundle URL", string.IsNullOrWhiteSpace(run.BundleUrl) ? "[grey]-[/]" : Markup.Escape(run.BundleUrl));
table.AddRow("Error Code", string.IsNullOrWhiteSpace(run.ErrorCode) ? "[grey]-[/]" : Markup.Escape(run.ErrorCode));
table.AddRow("Error Message", string.IsNullOrWhiteSpace(run.ErrorMessage) ? "[grey]-[/]" : Markup.Escape(run.ErrorMessage));
AnsiConsole.Write(table);
return 0;
}
internal static async Task<int> HandleExportRunDownloadAsync(
IServiceProvider services,
string runId,
string outputPath,
bool overwrite,
string? verifyHash,
string runType,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<IExportCenterClient>();
if (File.Exists(outputPath) && !overwrite)
{
AnsiConsole.MarkupLine($"[red]Output file already exists:[/] {Markup.Escape(outputPath)} (use --overwrite to replace)");
return 1;
}
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPath)) ?? ".");
Stream? stream = null;
if (string.Equals(runType, "attestation", StringComparison.OrdinalIgnoreCase))
{
stream = await client.DownloadAttestationExportAsync(runId, cancellationToken).ConfigureAwait(false);
}
else
{
stream = await client.DownloadEvidenceExportAsync(runId, cancellationToken).ConfigureAwait(false);
}
if (stream is null)
{
AnsiConsole.MarkupLine($"[red]Export bundle not available for run:[/] {Markup.Escape(runId)}");
return 1;
}
await using (stream)
await using (var fileStream = File.Create(outputPath))
{
await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
}
if (!string.IsNullOrWhiteSpace(verifyHash))
{
await using var file = File.OpenRead(outputPath);
var hash = await SHA256.HashDataAsync(file, cancellationToken).ConfigureAwait(false);
var hashString = Convert.ToHexString(hash).ToLowerInvariant();
if (!string.Equals(hashString, verifyHash.Trim(), StringComparison.OrdinalIgnoreCase))
{
AnsiConsole.MarkupLine($"[red]Hash verification failed.[/] expected={Markup.Escape(verifyHash)}, actual={hashString}");
return 1;
}
}
AnsiConsole.MarkupLine($"[green]Bundle written to[/] {Markup.Escape(outputPath)}");
return 0;
}
internal static async Task<int> HandleExportStartEvidenceAsync(
IServiceProvider services,
string profileId,
string[]? selectors,
string? callbackUrl,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<IExportCenterClient>();
var selectorMap = ParseSelectorMap(selectors);
var request = new CreateEvidenceExportRequest(profileId, selectorMap, callbackUrl);
var response = await client.CreateEvidenceExportAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
return 0;
}
AnsiConsole.MarkupLine($"[green]Export started.[/] runId={Markup.Escape(response.RunId)} status={Markup.Escape(response.Status)}");
return 0;
}
internal static async Task<int> HandleExportStartAttestationAsync(
IServiceProvider services,
string profileId,
string[]? selectors,
bool includeTransparencyLog,
string? callbackUrl,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<IExportCenterClient>();
var selectorMap = ParseSelectorMap(selectors);
var request = new CreateAttestationExportRequest(profileId, selectorMap, includeTransparencyLog, callbackUrl);
var response = await client.CreateAttestationExportAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
return 0;
}
AnsiConsole.MarkupLine($"[green]Attestation export started.[/] runId={Markup.Escape(response.RunId)} status={Markup.Escape(response.Status)}");
return 0;
}
private static IReadOnlyDictionary<string, string>? ParseSelectorMap(string[]? selectors)
{
if (selectors is null || selectors.Length == 0)
{
return null;
}
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var selector in selectors)
{
if (string.IsNullOrWhiteSpace(selector))
{
continue;
}
var parts = selector.Split('=', 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1]))
{
AnsiConsole.MarkupLine($"[yellow]Ignoring selector with invalid format (expected key=value):[/] {Markup.Escape(selector)}");
continue;
}
result[parts[0]] = parts[1];
}
return result.Count == 0 ? null : result;
}
#endregion
#region Notify Handlers (CLI-PARITY-41-002)
internal static async Task<int> HandleNotifySimulateAsync(
IServiceProvider services,
string? tenant,
string? eventsFile,
string? rulesFile,
bool enabledOnly,
int? lookbackMinutes,
int? maxEvents,
string? eventKind,
bool includeNonMatches,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<INotifyClient>();
var eventsPayload = LoadJsonElement(eventsFile);
var rulesPayload = LoadJsonElement(rulesFile);
var request = new NotifySimulationRequest
{
TenantId = tenant,
Events = eventsPayload,
Rules = rulesPayload,
EnabledRulesOnly = enabledOnly,
HistoricalLookbackMinutes = lookbackMinutes,
MaxEvents = maxEvents,
EventKindFilter = eventKind,
IncludeNonMatches = includeNonMatches
};
var result = await client.SimulateAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return 0;
}
AnsiConsole.MarkupLine(result.SimulationId is null
? "[yellow]Simulation completed.[/]"
: $"[green]Simulation {Markup.Escape(result.SimulationId)} completed.[/]");
var table = new Table();
table.AddColumn("Total Events");
table.AddColumn("Total Rules");
table.AddColumn("Matched Events");
table.AddColumn("Actions");
table.AddColumn("Duration (ms)");
table.AddRow(
(result.TotalEvents ?? 0).ToString(CultureInfo.InvariantCulture),
(result.TotalRules ?? 0).ToString(CultureInfo.InvariantCulture),
(result.MatchedEvents ?? 0).ToString(CultureInfo.InvariantCulture),
(result.TotalActionsTriggered ?? 0).ToString(CultureInfo.InvariantCulture),
result.DurationMs?.ToString("0.00", CultureInfo.InvariantCulture) ?? "-");
AnsiConsole.Write(table);
return 0;
}
internal static async Task<int> HandleNotifyAckAsync(
IServiceProvider services,
string? tenant,
string? incidentId,
string? token,
string? acknowledgedBy,
string? comment,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<INotifyClient>();
if (string.IsNullOrWhiteSpace(token) && string.IsNullOrWhiteSpace(incidentId))
{
AnsiConsole.MarkupLine("[red]Either --token or --incident-id is required.[/]");
return 1;
}
var request = new NotifyAckRequest
{
TenantId = tenant,
IncidentId = incidentId,
Token = token,
AcknowledgedBy = acknowledgedBy,
Comment = comment
};
var result = await client.AckAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return 0;
}
if (!result.Success)
{
AnsiConsole.MarkupLine($"[red]Acknowledge failed:[/] {Markup.Escape(result.Error ?? "unknown error")}");
return 1;
}
AnsiConsole.MarkupLine($"[green]Acknowledged.[/] incidentId={Markup.Escape(result.IncidentId ?? incidentId ?? "n/a")}");
return 0;
}
private static JsonElement? LoadJsonElement(string? filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
{
return null;
}
try
{
var content = File.ReadAllText(filePath);
using var doc = JsonDocument.Parse(content);
return doc.RootElement.Clone();
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[yellow]Failed to load JSON from {Markup.Escape(filePath)}:[/] {Markup.Escape(ex.Message)}");
return null;
}
}
internal static async Task<int> HandleNotifyChannelsListAsync(
IServiceProvider services,
string? tenant,

View File

@@ -15,6 +15,7 @@ using StellaOps.Cli.Telemetry;
using StellaOps.AirGap.Policy;
using StellaOps.Configuration;
using StellaOps.Policy.Scoring.Engine;
using StellaOps.ExportCenter.Client;
namespace StellaOps.Cli;
@@ -124,6 +125,16 @@ internal static class Program
}
}).AddEgressPolicyGuard("stellaops-cli", "backend-api");
services.AddHttpClient<IExportCenterClient, ExportCenterClient>(client =>
{
client.Timeout = TimeSpan.FromMinutes(10);
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var exportCenterUri))
{
client.BaseAddress = exportCenterUri;
}
}).AddEgressPolicyGuard("stellaops-cli", "export-center-api");
services.AddHttpClient<IConcelierObservationsClient, ConcelierObservationsClient>(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);

View File

@@ -67,4 +67,18 @@ internal interface INotifyClient
Task<NotifySendResult> SendAsync(
NotifySendRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Simulate rule evaluation.
/// </summary>
Task<NotifySimulationResult> SimulateAsync(
NotifySimulationRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Acknowledge an incident or signed token.
/// </summary>
Task<NotifyAckResult> AckAsync(
NotifyAckRequest request,
CancellationToken cancellationToken);
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
@@ -610,3 +611,83 @@ internal sealed class NotifySendResult
[JsonPropertyName("idempotencyKey")]
public string? IdempotencyKey { get; init; }
}
internal sealed class NotifySimulationRequest
{
[JsonPropertyName("tenantId")]
public string? TenantId { get; init; }
[JsonPropertyName("events")]
public JsonElement? Events { get; init; }
[JsonPropertyName("rules")]
public JsonElement? Rules { get; init; }
[JsonPropertyName("enabledRulesOnly")]
public bool? EnabledRulesOnly { get; init; }
[JsonPropertyName("historicalLookbackMinutes")]
public int? HistoricalLookbackMinutes { get; init; }
[JsonPropertyName("maxEvents")]
public int? MaxEvents { get; init; }
[JsonPropertyName("eventKindFilter")]
public string? EventKindFilter { get; init; }
[JsonPropertyName("includeNonMatches")]
public bool? IncludeNonMatches { get; init; }
}
internal sealed class NotifySimulationResult
{
[JsonPropertyName("simulationId")]
public string? SimulationId { get; init; }
[JsonPropertyName("totalEvents")]
public int? TotalEvents { get; init; }
[JsonPropertyName("totalRules")]
public int? TotalRules { get; init; }
[JsonPropertyName("matchedEvents")]
public int? MatchedEvents { get; init; }
[JsonPropertyName("totalActionsTriggered")]
public int? TotalActionsTriggered { get; init; }
[JsonPropertyName("durationMs")]
public double? DurationMs { get; init; }
}
internal sealed class NotifyAckRequest
{
[JsonPropertyName("tenantId")]
public string? TenantId { get; init; }
[JsonPropertyName("incidentId")]
public string? IncidentId { get; init; }
[JsonPropertyName("acknowledgedBy")]
public string? AcknowledgedBy { get; init; }
[JsonPropertyName("comment")]
public string? Comment { get; init; }
public string? Token { get; init; }
}
internal sealed class NotifyAckResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("incidentId")]
public string? IncidentId { get; init; }
[JsonPropertyName("error")]
public string? Error { get; init; }
[JsonPropertyName("message")]
public string? Message { get; init; }
}

View File

@@ -569,6 +569,131 @@ internal sealed class NotifyClient : INotifyClient
}
}
public async Task<NotifySimulationResult> SimulateAsync(
NotifySimulationRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var json = JsonSerializer.Serialize(request, SerializerOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v2/simulate")
{
Content = content
};
if (!string.IsNullOrWhiteSpace(request.TenantId))
{
httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.TenantId);
}
await AuthorizeRequestAsync(httpRequest, "notify.simulate", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to simulate notify rules (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new NotifySimulationResult { SimulationId = null, TotalEvents = 0, TotalRules = 0 };
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<NotifySimulationResult>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new NotifySimulationResult { SimulationId = null, TotalEvents = 0, TotalRules = 0 };
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while simulating notify rules");
return new NotifySimulationResult { SimulationId = null, TotalEvents = 0, TotalRules = 0 };
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while simulating notify rules");
return new NotifySimulationResult { SimulationId = null, TotalEvents = 0, TotalRules = 0 };
}
}
public async Task<NotifyAckResult> AckAsync(
NotifyAckRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var hasToken = !string.IsNullOrWhiteSpace(request.Token);
using var httpRequest = hasToken
? new HttpRequestMessage(HttpMethod.Get, $"/api/v2/ack?token={Uri.EscapeDataString(request.Token!)}")
: new HttpRequestMessage(HttpMethod.Post, "/api/v2/ack")
{
Content = new StringContent(JsonSerializer.Serialize(new AckApiRequestBody
{
TenantId = request.TenantId,
IncidentId = request.IncidentId,
AcknowledgedBy = request.AcknowledgedBy,
Comment = request.Comment
}, SerializerOptions), Encoding.UTF8, "application/json")
};
if (!string.IsNullOrWhiteSpace(request.TenantId))
{
httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.TenantId);
}
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to acknowledge notification (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new NotifyAckResult { Success = false, IncidentId = request.IncidentId, Error = payload };
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<NotifyAckResult>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new NotifyAckResult { Success = true, IncidentId = request.IncidentId };
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while acknowledging notification");
return new NotifyAckResult { Success = false, IncidentId = request.IncidentId, Error = ex.Message };
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while acknowledging notification");
return new NotifyAckResult { Success = false, IncidentId = request.IncidentId, Error = "Request timed out" };
}
}
private sealed record AckApiRequestBody
{
public string? TenantId { get; init; }
public string? IncidentId { get; init; }
public string? AcknowledgedBy { get; init; }
public string? Comment { get; init; }
}
private static string BuildChannelListUri(NotifyChannelListRequest request)
{
var queryParams = new List<string>();

View File

@@ -70,6 +70,7 @@
<ProjectReference Include="../../Notify/__Libraries/StellaOps.Notify.Storage.Postgres/StellaOps.Notify.Storage.Postgres.csproj" />
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Storage.Postgres/StellaOps.Excititor.Storage.Postgres.csproj" />
<ProjectReference Include="../../Policy/StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
<ProjectReference Include="../../ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Client/StellaOps.ExportCenter.Client.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(StellaOpsEnableCryptoPro)' == 'true'">