Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

169
src/Cli/StellaOps.Cli.sln Normal file
View File

@@ -0,0 +1,169 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{41F15E67-7190-CF23-3BC4-77E87134CADD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cli", "StellaOps.Cli\StellaOps.Cli.csproj", "{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{2846557F-1917-4A55-9EDB-EB28398D22EB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{39C8D95B-08FB-486A-9A0B-1559D70E8689}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "..\Authority\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{376B4717-AD51-4775-9B25-2C573F1E6215}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cli.Plugins.NonCore", "__Libraries\StellaOps.Cli.Plugins.NonCore\StellaOps.Cli.Plugins.NonCore.csproj", "{30E528B3-0EB1-4A89-8130-F69D3C0F1962}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{56BCE1BF-7CBA-7CE8-203D-A88051F1D642}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cli.Tests", "__Tests\StellaOps.Cli.Tests\StellaOps.Cli.Tests.csproj", "{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Debug|x64.ActiveCfg = Debug|Any CPU
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Debug|x64.Build.0 = Debug|Any CPU
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Debug|x86.ActiveCfg = Debug|Any CPU
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Debug|x86.Build.0 = Debug|Any CPU
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Release|Any CPU.Build.0 = Release|Any CPU
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Release|x64.ActiveCfg = Release|Any CPU
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Release|x64.Build.0 = Release|Any CPU
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Release|x86.ActiveCfg = Release|Any CPU
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8}.Release|x86.Build.0 = Release|Any CPU
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Debug|x64.ActiveCfg = Debug|Any CPU
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Debug|x64.Build.0 = Debug|Any CPU
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Debug|x86.ActiveCfg = Debug|Any CPU
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Debug|x86.Build.0 = Debug|Any CPU
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Release|Any CPU.Build.0 = Release|Any CPU
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Release|x64.ActiveCfg = Release|Any CPU
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Release|x64.Build.0 = Release|Any CPU
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Release|x86.ActiveCfg = Release|Any CPU
{2846557F-1917-4A55-9EDB-EB28398D22EB}.Release|x86.Build.0 = Release|Any CPU
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Debug|x64.ActiveCfg = Debug|Any CPU
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Debug|x64.Build.0 = Debug|Any CPU
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Debug|x86.ActiveCfg = Debug|Any CPU
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Debug|x86.Build.0 = Debug|Any CPU
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Release|Any CPU.Build.0 = Release|Any CPU
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Release|x64.ActiveCfg = Release|Any CPU
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Release|x64.Build.0 = Release|Any CPU
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Release|x86.ActiveCfg = Release|Any CPU
{16D7BF0B-AEFE-4D3D-AE3F-88F96CD483AB}.Release|x86.Build.0 = Release|Any CPU
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Debug|Any CPU.Build.0 = Debug|Any CPU
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Debug|x64.ActiveCfg = Debug|Any CPU
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Debug|x64.Build.0 = Debug|Any CPU
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Debug|x86.ActiveCfg = Debug|Any CPU
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Debug|x86.Build.0 = Debug|Any CPU
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Release|Any CPU.ActiveCfg = Release|Any CPU
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Release|Any CPU.Build.0 = Release|Any CPU
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Release|x64.ActiveCfg = Release|Any CPU
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Release|x64.Build.0 = Release|Any CPU
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Release|x86.ActiveCfg = Release|Any CPU
{39C8D95B-08FB-486A-9A0B-1559D70E8689}.Release|x86.Build.0 = Release|Any CPU
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Debug|x64.ActiveCfg = Debug|Any CPU
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Debug|x64.Build.0 = Debug|Any CPU
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Debug|x86.ActiveCfg = Debug|Any CPU
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Debug|x86.Build.0 = Debug|Any CPU
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Release|Any CPU.Build.0 = Release|Any CPU
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Release|x64.ActiveCfg = Release|Any CPU
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Release|x64.Build.0 = Release|Any CPU
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Release|x86.ActiveCfg = Release|Any CPU
{D42AC6A1-BB0E-48AD-A609-5672B6B888A2}.Release|x86.Build.0 = Release|Any CPU
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Debug|x64.ActiveCfg = Debug|Any CPU
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Debug|x64.Build.0 = Debug|Any CPU
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Debug|x86.ActiveCfg = Debug|Any CPU
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Debug|x86.Build.0 = Debug|Any CPU
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Release|Any CPU.Build.0 = Release|Any CPU
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Release|x64.ActiveCfg = Release|Any CPU
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Release|x64.Build.0 = Release|Any CPU
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Release|x86.ActiveCfg = Release|Any CPU
{77853EC3-FED1-490B-B680-E9A1BDDC0D7C}.Release|x86.Build.0 = Release|Any CPU
{376B4717-AD51-4775-9B25-2C573F1E6215}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{376B4717-AD51-4775-9B25-2C573F1E6215}.Debug|Any CPU.Build.0 = Debug|Any CPU
{376B4717-AD51-4775-9B25-2C573F1E6215}.Debug|x64.ActiveCfg = Debug|Any CPU
{376B4717-AD51-4775-9B25-2C573F1E6215}.Debug|x64.Build.0 = Debug|Any CPU
{376B4717-AD51-4775-9B25-2C573F1E6215}.Debug|x86.ActiveCfg = Debug|Any CPU
{376B4717-AD51-4775-9B25-2C573F1E6215}.Debug|x86.Build.0 = Debug|Any CPU
{376B4717-AD51-4775-9B25-2C573F1E6215}.Release|Any CPU.ActiveCfg = Release|Any CPU
{376B4717-AD51-4775-9B25-2C573F1E6215}.Release|Any CPU.Build.0 = Release|Any CPU
{376B4717-AD51-4775-9B25-2C573F1E6215}.Release|x64.ActiveCfg = Release|Any CPU
{376B4717-AD51-4775-9B25-2C573F1E6215}.Release|x64.Build.0 = Release|Any CPU
{376B4717-AD51-4775-9B25-2C573F1E6215}.Release|x86.ActiveCfg = Release|Any CPU
{376B4717-AD51-4775-9B25-2C573F1E6215}.Release|x86.Build.0 = Release|Any CPU
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Debug|x64.ActiveCfg = Debug|Any CPU
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Debug|x64.Build.0 = Debug|Any CPU
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Debug|x86.ActiveCfg = Debug|Any CPU
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Debug|x86.Build.0 = Debug|Any CPU
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Release|Any CPU.Build.0 = Release|Any CPU
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Release|x64.ActiveCfg = Release|Any CPU
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Release|x64.Build.0 = Release|Any CPU
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Release|x86.ActiveCfg = Release|Any CPU
{429E5D21-7ABE-4A19-B3C3-BBEF97337ADA}.Release|x86.Build.0 = Release|Any CPU
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Debug|Any CPU.Build.0 = Debug|Any CPU
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Debug|x64.ActiveCfg = Debug|Any CPU
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Debug|x64.Build.0 = Debug|Any CPU
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Debug|x86.ActiveCfg = Debug|Any CPU
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Debug|x86.Build.0 = Debug|Any CPU
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Release|Any CPU.ActiveCfg = Release|Any CPU
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Release|Any CPU.Build.0 = Release|Any CPU
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Release|x64.ActiveCfg = Release|Any CPU
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Release|x64.Build.0 = Release|Any CPU
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Release|x86.ActiveCfg = Release|Any CPU
{30E528B3-0EB1-4A89-8130-F69D3C0F1962}.Release|x86.Build.0 = Release|Any CPU
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Debug|x64.ActiveCfg = Debug|Any CPU
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Debug|x64.Build.0 = Debug|Any CPU
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Debug|x86.ActiveCfg = Debug|Any CPU
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Debug|x86.Build.0 = Debug|Any CPU
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Release|Any CPU.Build.0 = Release|Any CPU
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Release|x64.ActiveCfg = Release|Any CPU
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Release|x64.Build.0 = Release|Any CPU
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Release|x86.ActiveCfg = Release|Any CPU
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{9258A5D3-2567-4BBA-8F0B-D018E431B7F8} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{30E528B3-0EB1-4A89-8130-F69D3C0F1962} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{B434D60B-8A05-44EC-ADA6-07C9E2CB1D92} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,32 @@
# StellaOps.Cli — Agent Brief
## Mission
- Deliver an offline-capable command-line interface that drives StellaOps back-end operations: scanner distribution, scan execution, result uploads, and Concelier database lifecycle calls (init/resume/export).
- Honour StellaOps principles of determinism, observability, and offline-first behaviour while providing a polished operator experience.
## Role Charter
| Role | Mandate | Collaboration |
| --- | --- | --- |
| **DevEx/CLI** | Own CLI UX, command routing, and configuration model. Ensure commands work with empty/default config and document overrides. | Coordinate with Backend/WebService for API contracts and with Docs for operator workflows. |
| **Ops Integrator** | Maintain integration paths for shell/dotnet/docker tooling. Validate that air-gapped runners can bootstrap required binaries. | Work with Concelier/Agent teams to mirror packaging and signing requirements. |
| **QA** | Provide command-level fixtures, golden outputs, and regression coverage (unit & smoke). Ensure commands respect cancellation and deterministic logging. | Partner with QA guild for shared harnesses and test data. |
## Working Agreements
- Configuration is centralised in `StellaOps.Configuration`; always consume the bootstrapper instead of hand rolling builders. Env vars (`API_KEY`, `STELLAOPS_BACKEND_URL`, `StellaOps:*`) override JSON/YAML and default to empty values.
- Command verbs (`scanner`, `scan`, `db`, `config`) are wired through System.CommandLine 2.0; keep handlers composable, cancellation-aware, and unit-testable.
- `scanner download` must verify digests/signatures, install containers locally (docker load), and log artefact metadata.
- `scan run` must execute the container against a directory, materialise artefacts in `ResultsDirectory`, and auto-upload them on success; `scan upload` is the manual retry path.
- Emit structured console logs (single line, UTC timestamps) and honour offline-first expectations—no hidden network calls.
- Mirror repository guidance: stay within `src/Cli/StellaOps.Cli` unless collaborating via documented handshakes.
- Update `TASKS.md` as states change (TODO → DOING → DONE/BLOCKED) and record added tests/fixtures alongside implementation notes.
## Reference Materials
- `docs/ARCHITECTURE_CONCELIER.md` for database operations surface area.
- Backend OpenAPI/contract docs (once available) for job triggers and scanner endpoints.
- Existing module AGENTS/TASKS files for style and coordination cues.
- `docs/09_API_CLI_REFERENCE.md` (section 3) for the user-facing synopsis of the CLI verbs and flags.
### Attestor Command Guild
- Owns the `stella attest` verb family (sign, verify, list, fetch) plus key lifecycle helpers (create, import, rotate, revoke).
- Ensures all attestation flows use the official SDK transport, support offline bundles, and surface JSON/table outputs for automation.
- Guards parity with attestor service policies (verification policies, explainability) and keeps fixtures/tests covering file-based and KMS-backed keys.

View File

@@ -0,0 +1,994 @@
using System;
using System.CommandLine;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Plugins;
namespace StellaOps.Cli.Commands;
internal static class CommandFactory
{
public static RootCommand Create(
IServiceProvider services,
StellaOpsCliOptions options,
CancellationToken cancellationToken,
ILoggerFactory loggerFactory)
{
ArgumentNullException.ThrowIfNull(loggerFactory);
var verboseOption = new Option<bool>("--verbose", new[] { "-v" })
{
Description = "Enable verbose logging output."
};
var root = new RootCommand("StellaOps command-line interface")
{
TreatUnmatchedTokensAsErrors = true
};
root.Add(verboseOption);
root.Add(BuildScannerCommand(services, verboseOption, cancellationToken));
root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken));
root.Add(BuildSourcesCommand(services, verboseOption, cancellationToken));
root.Add(BuildAocCommand(services, verboseOption, cancellationToken));
root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildPolicyCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildFindingsCommand(services, verboseOption, cancellationToken));
root.Add(BuildConfigCommand(options));
root.Add(BuildVulnCommand(services, verboseOption, cancellationToken));
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger);
pluginLoader.RegisterModules(root, verboseOption, cancellationToken);
return root;
}
private static Command BuildScannerCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var scanner = new Command("scanner", "Manage scanner artifacts and lifecycle.");
var download = new Command("download", "Download the latest scanner bundle.");
var channelOption = new Option<string>("--channel", new[] { "-c" })
{
Description = "Scanner channel (stable, beta, nightly)."
};
var outputOption = new Option<string?>("--output")
{
Description = "Optional output path for the downloaded bundle."
};
var overwriteOption = new Option<bool>("--overwrite")
{
Description = "Overwrite existing bundle if present."
};
var noInstallOption = new Option<bool>("--no-install")
{
Description = "Skip installing the scanner container after download."
};
download.Add(channelOption);
download.Add(outputOption);
download.Add(overwriteOption);
download.Add(noInstallOption);
download.SetAction((parseResult, _) =>
{
var channel = parseResult.GetValue(channelOption) ?? "stable";
var output = parseResult.GetValue(outputOption);
var overwrite = parseResult.GetValue(overwriteOption);
var install = !parseResult.GetValue(noInstallOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleScannerDownloadAsync(services, channel, output, overwrite, install, verbose, cancellationToken);
});
scanner.Add(download);
return scanner;
}
private static Command BuildScanCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var scan = new Command("scan", "Execute scanners and manage scan outputs.");
var run = new Command("run", "Execute a scanner bundle with the configured runner.");
var runnerOption = new Option<string>("--runner")
{
Description = "Execution runtime (dotnet, self, docker)."
};
var entryOption = new Option<string>("--entry")
{
Description = "Path to the scanner entrypoint or Docker image.",
Required = true
};
var targetOption = new Option<string>("--target")
{
Description = "Directory to scan.",
Required = true
};
var argsArgument = new Argument<string[]>("scanner-args")
{
Arity = ArgumentArity.ZeroOrMore
};
run.Add(runnerOption);
run.Add(entryOption);
run.Add(targetOption);
run.Add(argsArgument);
run.SetAction((parseResult, _) =>
{
var runner = parseResult.GetValue(runnerOption) ?? options.DefaultRunner;
var entry = parseResult.GetValue(entryOption) ?? string.Empty;
var target = parseResult.GetValue(targetOption) ?? string.Empty;
var forwardedArgs = parseResult.GetValue(argsArgument) ?? Array.Empty<string>();
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleScannerRunAsync(services, runner, entry, target, forwardedArgs, verbose, cancellationToken);
});
var upload = new Command("upload", "Upload completed scan results to the backend.");
var fileOption = new Option<string>("--file")
{
Description = "Path to the scan result artifact.",
Required = true
};
upload.Add(fileOption);
upload.SetAction((parseResult, _) =>
{
var file = parseResult.GetValue(fileOption) ?? string.Empty;
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleScanUploadAsync(services, file, verbose, cancellationToken);
});
scan.Add(run);
scan.Add(upload);
return scan;
}
private static Command BuildDatabaseCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var db = new Command("db", "Trigger Concelier database operations via backend jobs.");
var fetch = new Command("fetch", "Trigger connector fetch/parse/map stages.");
var sourceOption = new Option<string>("--source")
{
Description = "Connector source identifier (e.g. redhat, osv, vmware).",
Required = true
};
var stageOption = new Option<string>("--stage")
{
Description = "Stage to trigger: fetch, parse, or map."
};
var modeOption = new Option<string?>("--mode")
{
Description = "Optional connector-specific mode (init, resume, cursor)."
};
fetch.Add(sourceOption);
fetch.Add(stageOption);
fetch.Add(modeOption);
fetch.SetAction((parseResult, _) =>
{
var source = parseResult.GetValue(sourceOption) ?? string.Empty;
var stage = parseResult.GetValue(stageOption) ?? "fetch";
var mode = parseResult.GetValue(modeOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleConnectorJobAsync(services, source, stage, mode, verbose, cancellationToken);
});
var merge = new Command("merge", "Run canonical merge reconciliation.");
merge.SetAction((parseResult, _) =>
{
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleMergeJobAsync(services, verbose, cancellationToken);
});
var export = new Command("export", "Run Concelier export jobs.");
var formatOption = new Option<string>("--format")
{
Description = "Export format: json or trivy-db."
};
var deltaOption = new Option<bool>("--delta")
{
Description = "Request a delta export when supported."
};
var publishFullOption = new Option<bool?>("--publish-full")
{
Description = "Override whether full exports push to ORAS (true/false)."
};
var publishDeltaOption = new Option<bool?>("--publish-delta")
{
Description = "Override whether delta exports push to ORAS (true/false)."
};
var includeFullOption = new Option<bool?>("--bundle-full")
{
Description = "Override whether offline bundles include full exports (true/false)."
};
var includeDeltaOption = new Option<bool?>("--bundle-delta")
{
Description = "Override whether offline bundles include delta exports (true/false)."
};
export.Add(formatOption);
export.Add(deltaOption);
export.Add(publishFullOption);
export.Add(publishDeltaOption);
export.Add(includeFullOption);
export.Add(includeDeltaOption);
export.SetAction((parseResult, _) =>
{
var format = parseResult.GetValue(formatOption) ?? "json";
var delta = parseResult.GetValue(deltaOption);
var publishFull = parseResult.GetValue(publishFullOption);
var publishDelta = parseResult.GetValue(publishDeltaOption);
var includeFull = parseResult.GetValue(includeFullOption);
var includeDelta = parseResult.GetValue(includeDeltaOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExportJobAsync(services, format, delta, publishFull, publishDelta, includeFull, includeDelta, verbose, cancellationToken);
});
db.Add(fetch);
db.Add(merge);
db.Add(export);
return db;
}
private static Command BuildSourcesCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var sources = new Command("sources", "Interact with source ingestion workflows.");
var ingest = new Command("ingest", "Validate source documents before ingestion.");
var dryRunOption = new Option<bool>("--dry-run")
{
Description = "Evaluate guard rules without writing to persistent storage."
};
var sourceOption = new Option<string>("--source")
{
Description = "Logical source identifier (e.g. redhat, ubuntu, osv).",
Required = true
};
var inputOption = new Option<string>("--input")
{
Description = "Path to a local document or HTTPS URI.",
Required = true
};
var tenantOption = new Option<string?>("--tenant")
{
Description = "Tenant identifier override."
};
var formatOption = new Option<string>("--format")
{
Description = "Output format: table or json."
};
var noColorOption = new Option<bool>("--no-color")
{
Description = "Disable ANSI colouring in console output."
};
var outputOption = new Option<string?>("--output")
{
Description = "Write the JSON report to the specified file path."
};
ingest.Add(dryRunOption);
ingest.Add(sourceOption);
ingest.Add(inputOption);
ingest.Add(tenantOption);
ingest.Add(formatOption);
ingest.Add(noColorOption);
ingest.Add(outputOption);
ingest.SetAction((parseResult, _) =>
{
var dryRun = parseResult.GetValue(dryRunOption);
var source = parseResult.GetValue(sourceOption) ?? string.Empty;
var input = parseResult.GetValue(inputOption) ?? string.Empty;
var tenant = parseResult.GetValue(tenantOption);
var format = parseResult.GetValue(formatOption) ?? "table";
var noColor = parseResult.GetValue(noColorOption);
var output = parseResult.GetValue(outputOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleSourcesIngestAsync(
services,
dryRun,
source,
input,
tenant,
format,
noColor,
output,
verbose,
cancellationToken);
});
sources.Add(ingest);
return sources;
}
private static Command BuildAocCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var aoc = new Command("aoc", "Aggregation-Only Contract verification commands.");
var verify = new Command("verify", "Verify stored raw documents against AOC guardrails.");
var sinceOption = new Option<string?>("--since")
{
Description = "Verification window start (ISO-8601 timestamp) or relative duration (e.g. 24h, 7d)."
};
var limitOption = new Option<int?>("--limit")
{
Description = "Maximum number of violations to include per code (0 = no limit)."
};
var sourcesOption = new Option<string?>("--sources")
{
Description = "Comma-separated list of sources (e.g. redhat,ubuntu,osv)."
};
var codesOption = new Option<string?>("--codes")
{
Description = "Comma-separated list of violation codes (ERR_AOC_00x)."
};
var formatOption = new Option<string>("--format")
{
Description = "Output format: table or json."
};
var exportOption = new Option<string?>("--export")
{
Description = "Write the JSON report to the specified file path."
};
var tenantOption = new Option<string?>("--tenant")
{
Description = "Tenant identifier override."
};
var noColorOption = new Option<bool>("--no-color")
{
Description = "Disable ANSI colouring in console output."
};
verify.Add(sinceOption);
verify.Add(limitOption);
verify.Add(sourcesOption);
verify.Add(codesOption);
verify.Add(formatOption);
verify.Add(exportOption);
verify.Add(tenantOption);
verify.Add(noColorOption);
verify.SetAction((parseResult, _) =>
{
var since = parseResult.GetValue(sinceOption);
var limit = parseResult.GetValue(limitOption);
var sources = parseResult.GetValue(sourcesOption);
var codes = parseResult.GetValue(codesOption);
var format = parseResult.GetValue(formatOption) ?? "table";
var export = parseResult.GetValue(exportOption);
var tenant = parseResult.GetValue(tenantOption);
var noColor = parseResult.GetValue(noColorOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAocVerifyAsync(
services,
since,
limit,
sources,
codes,
format,
export,
tenant,
noColor,
verbose,
cancellationToken);
});
aoc.Add(verify);
return aoc;
}
private static Command BuildAuthCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var auth = new Command("auth", "Manage authentication with StellaOps Authority.");
var login = new Command("login", "Acquire and cache access tokens using the configured credentials.");
var forceOption = new Option<bool>("--force")
{
Description = "Ignore existing cached tokens and force re-authentication."
};
login.Add(forceOption);
login.SetAction((parseResult, _) =>
{
var verbose = parseResult.GetValue(verboseOption);
var force = parseResult.GetValue(forceOption);
return CommandHandlers.HandleAuthLoginAsync(services, options, verbose, force, cancellationToken);
});
var logout = new Command("logout", "Remove cached tokens for the current credentials.");
logout.SetAction((parseResult, _) =>
{
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAuthLogoutAsync(services, options, verbose, cancellationToken);
});
var status = new Command("status", "Display cached token status.");
status.SetAction((parseResult, _) =>
{
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAuthStatusAsync(services, options, verbose, cancellationToken);
});
var whoami = new Command("whoami", "Display cached token claims (subject, scopes, expiry).");
whoami.SetAction((parseResult, _) =>
{
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAuthWhoAmIAsync(services, options, verbose, cancellationToken);
});
var revoke = new Command("revoke", "Manage revocation exports.");
var export = new Command("export", "Export the revocation bundle and signature to disk.");
var outputOption = new Option<string?>("--output")
{
Description = "Directory to write exported revocation files (defaults to current directory)."
};
export.Add(outputOption);
export.SetAction((parseResult, _) =>
{
var output = parseResult.GetValue(outputOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAuthRevokeExportAsync(services, options, output, verbose, cancellationToken);
});
revoke.Add(export);
var verify = new Command("verify", "Verify a revocation bundle against a detached JWS signature.");
var bundleOption = new Option<string>("--bundle")
{
Description = "Path to the revocation-bundle.json file."
};
var signatureOption = new Option<string>("--signature")
{
Description = "Path to the revocation-bundle.json.jws file."
};
var keyOption = new Option<string>("--key")
{
Description = "Path to the PEM-encoded public/private key used for verification."
};
verify.Add(bundleOption);
verify.Add(signatureOption);
verify.Add(keyOption);
verify.SetAction((parseResult, _) =>
{
var bundlePath = parseResult.GetValue(bundleOption) ?? string.Empty;
var signaturePath = parseResult.GetValue(signatureOption) ?? string.Empty;
var keyPath = parseResult.GetValue(keyOption) ?? string.Empty;
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAuthRevokeVerifyAsync(bundlePath, signaturePath, keyPath, verbose, cancellationToken);
});
revoke.Add(verify);
auth.Add(login);
auth.Add(logout);
auth.Add(status);
auth.Add(whoami);
auth.Add(revoke);
return auth;
}
private static Command BuildPolicyCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
{
_ = options;
var policy = new Command("policy", "Interact with Policy Engine operations.");
var simulate = new Command("simulate", "Simulate a policy revision against selected SBOMs and environment.");
var policyIdArgument = new Argument<string>("policy-id")
{
Description = "Policy identifier (e.g. P-7)."
};
simulate.Add(policyIdArgument);
var baseOption = new Option<int?>("--base")
{
Description = "Base policy version for diff calculations."
};
var candidateOption = new Option<int?>("--candidate")
{
Description = "Candidate policy version. Defaults to latest approved."
};
var sbomOption = new Option<string[]>("--sbom")
{
Description = "SBOM identifier to include (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
sbomOption.AllowMultipleArgumentsPerToken = true;
var envOption = new Option<string[]>("--env")
{
Description = "Environment override (key=value, repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
envOption.AllowMultipleArgumentsPerToken = true;
var formatOption = new Option<string?>("--format")
{
Description = "Output format: table or json."
};
var outputOption = new Option<string?>("--output")
{
Description = "Write JSON output to the specified file."
};
var explainOption = new Option<bool>("--explain")
{
Description = "Request explain traces for diffed findings."
};
var failOnDiffOption = new Option<bool>("--fail-on-diff")
{
Description = "Exit with code 20 when findings are added or removed."
};
simulate.Add(baseOption);
simulate.Add(candidateOption);
simulate.Add(sbomOption);
simulate.Add(envOption);
simulate.Add(formatOption);
simulate.Add(outputOption);
simulate.Add(explainOption);
simulate.Add(failOnDiffOption);
simulate.SetAction((parseResult, _) =>
{
var policyId = parseResult.GetValue(policyIdArgument) ?? string.Empty;
var baseVersion = parseResult.GetValue(baseOption);
var candidateVersion = parseResult.GetValue(candidateOption);
var sbomSet = parseResult.GetValue(sbomOption) ?? Array.Empty<string>();
var environment = parseResult.GetValue(envOption) ?? Array.Empty<string>();
var format = parseResult.GetValue(formatOption);
var output = parseResult.GetValue(outputOption);
var explain = parseResult.GetValue(explainOption);
var failOnDiff = parseResult.GetValue(failOnDiffOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandlePolicySimulateAsync(
services,
policyId,
baseVersion,
candidateVersion,
sbomSet,
environment,
format,
output,
explain,
failOnDiff,
verbose,
cancellationToken);
});
policy.Add(simulate);
var activate = new Command("activate", "Activate an approved policy revision.");
var activatePolicyIdArgument = new Argument<string>("policy-id")
{
Description = "Policy identifier (e.g. P-7)."
};
activate.Add(activatePolicyIdArgument);
var activateVersionOption = new Option<int>("--version")
{
Description = "Revision version to activate."
};
var activationNoteOption = new Option<string?>("--note")
{
Description = "Optional activation note recorded with the approval."
};
var runNowOption = new Option<bool>("--run-now")
{
Description = "Trigger an immediate full policy run after activation."
};
var scheduledAtOption = new Option<string?>("--scheduled-at")
{
Description = "Schedule activation at the provided ISO-8601 timestamp."
};
var priorityOption = new Option<string?>("--priority")
{
Description = "Optional activation priority label (e.g. low, standard, high)."
};
var rollbackOption = new Option<bool>("--rollback")
{
Description = "Indicate that this activation is a rollback to a previous version."
};
var incidentOption = new Option<string?>("--incident")
{
Description = "Associate the activation with an incident identifier."
};
activate.Add(activateVersionOption);
activate.Add(activationNoteOption);
activate.Add(runNowOption);
activate.Add(scheduledAtOption);
activate.Add(priorityOption);
activate.Add(rollbackOption);
activate.Add(incidentOption);
activate.SetAction((parseResult, _) =>
{
var policyId = parseResult.GetValue(activatePolicyIdArgument) ?? string.Empty;
var version = parseResult.GetValue(activateVersionOption);
var note = parseResult.GetValue(activationNoteOption);
var runNow = parseResult.GetValue(runNowOption);
var scheduledAt = parseResult.GetValue(scheduledAtOption);
var priority = parseResult.GetValue(priorityOption);
var rollback = parseResult.GetValue(rollbackOption);
var incident = parseResult.GetValue(incidentOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandlePolicyActivateAsync(
services,
policyId,
version,
note,
runNow,
scheduledAt,
priority,
rollback,
incident,
verbose,
cancellationToken);
});
policy.Add(activate);
return policy;
}
private static Command BuildFindingsCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var findings = new Command("findings", "Inspect policy findings.");
var list = new Command("ls", "List effective findings that match the provided filters.");
var policyOption = new Option<string>("--policy")
{
Description = "Policy identifier (e.g. P-7).",
Required = true
};
var sbomOption = new Option<string[]>("--sbom")
{
Description = "Filter by SBOM identifier (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
sbomOption.AllowMultipleArgumentsPerToken = true;
var statusOption = new Option<string[]>("--status")
{
Description = "Filter by finding status (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
statusOption.AllowMultipleArgumentsPerToken = true;
var severityOption = new Option<string[]>("--severity")
{
Description = "Filter by severity label (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
severityOption.AllowMultipleArgumentsPerToken = true;
var sinceOption = new Option<string?>("--since")
{
Description = "Filter by last-updated timestamp (ISO-8601)."
};
var cursorOption = new Option<string?>("--cursor")
{
Description = "Resume listing from the provided cursor."
};
var pageOption = new Option<int?>("--page")
{
Description = "Page number (starts at 1)."
};
var pageSizeOption = new Option<int?>("--page-size")
{
Description = "Results per page (default backend limit applies)."
};
var formatOption = new Option<string?>("--format")
{
Description = "Output format: table or json."
};
var outputOption = new Option<string?>("--output")
{
Description = "Write JSON payload to the specified file."
};
list.Add(policyOption);
list.Add(sbomOption);
list.Add(statusOption);
list.Add(severityOption);
list.Add(sinceOption);
list.Add(cursorOption);
list.Add(pageOption);
list.Add(pageSizeOption);
list.Add(formatOption);
list.Add(outputOption);
list.SetAction((parseResult, _) =>
{
var policy = parseResult.GetValue(policyOption) ?? string.Empty;
var sboms = parseResult.GetValue(sbomOption) ?? Array.Empty<string>();
var statuses = parseResult.GetValue(statusOption) ?? Array.Empty<string>();
var severities = parseResult.GetValue(severityOption) ?? Array.Empty<string>();
var since = parseResult.GetValue(sinceOption);
var cursor = parseResult.GetValue(cursorOption);
var page = parseResult.GetValue(pageOption);
var pageSize = parseResult.GetValue(pageSizeOption);
var selectedFormat = parseResult.GetValue(formatOption);
var output = parseResult.GetValue(outputOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandlePolicyFindingsListAsync(
services,
policy,
sboms,
statuses,
severities,
since,
cursor,
page,
pageSize,
selectedFormat,
output,
verbose,
cancellationToken);
});
var get = new Command("get", "Retrieve a specific finding.");
var findingArgument = new Argument<string>("finding-id")
{
Description = "Finding identifier (e.g. P-7:S-42:pkg:...)."
};
var getPolicyOption = new Option<string>("--policy")
{
Description = "Policy identifier for the finding.",
Required = true
};
var getFormatOption = new Option<string?>("--format")
{
Description = "Output format: table or json."
};
var getOutputOption = new Option<string?>("--output")
{
Description = "Write JSON payload to the specified file."
};
get.Add(findingArgument);
get.Add(getPolicyOption);
get.Add(getFormatOption);
get.Add(getOutputOption);
get.SetAction((parseResult, _) =>
{
var policy = parseResult.GetValue(getPolicyOption) ?? string.Empty;
var finding = parseResult.GetValue(findingArgument) ?? string.Empty;
var selectedFormat = parseResult.GetValue(getFormatOption);
var output = parseResult.GetValue(getOutputOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandlePolicyFindingsGetAsync(
services,
policy,
finding,
selectedFormat,
output,
verbose,
cancellationToken);
});
var explain = new Command("explain", "Fetch explain trace for a finding.");
var explainFindingArgument = new Argument<string>("finding-id")
{
Description = "Finding identifier."
};
var explainPolicyOption = new Option<string>("--policy")
{
Description = "Policy identifier.",
Required = true
};
var modeOption = new Option<string?>("--mode")
{
Description = "Explain mode (for example: verbose)."
};
var explainFormatOption = new Option<string?>("--format")
{
Description = "Output format: table or json."
};
var explainOutputOption = new Option<string?>("--output")
{
Description = "Write JSON payload to the specified file."
};
explain.Add(explainFindingArgument);
explain.Add(explainPolicyOption);
explain.Add(modeOption);
explain.Add(explainFormatOption);
explain.Add(explainOutputOption);
explain.SetAction((parseResult, _) =>
{
var policy = parseResult.GetValue(explainPolicyOption) ?? string.Empty;
var finding = parseResult.GetValue(explainFindingArgument) ?? string.Empty;
var mode = parseResult.GetValue(modeOption);
var selectedFormat = parseResult.GetValue(explainFormatOption);
var output = parseResult.GetValue(explainOutputOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandlePolicyFindingsExplainAsync(
services,
policy,
finding,
mode,
selectedFormat,
output,
verbose,
cancellationToken);
});
findings.Add(list);
findings.Add(get);
findings.Add(explain);
return findings;
}
private static Command BuildVulnCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var vuln = new Command("vuln", "Explore vulnerability observations and overlays.");
var observations = new Command("observations", "List raw advisory observations for overlay consumers.");
var tenantOption = new Option<string>("--tenant")
{
Description = "Tenant identifier.",
Required = true
};
var observationIdOption = new Option<string[]>("--observation-id")
{
Description = "Filter by observation identifier (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var aliasOption = new Option<string[]>("--alias")
{
Description = "Filter by vulnerability alias (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var purlOption = new Option<string[]>("--purl")
{
Description = "Filter by Package URL (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var cpeOption = new Option<string[]>("--cpe")
{
Description = "Filter by CPE value (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var jsonOption = new Option<bool>("--json")
{
Description = "Emit raw JSON payload instead of a table."
};
var limitOption = new Option<int?>("--limit")
{
Description = "Maximum number of observations to return (default 200, max 500)."
};
var cursorOption = new Option<string?>("--cursor")
{
Description = "Opaque cursor token returned by a previous page."
};
observations.Add(tenantOption);
observations.Add(observationIdOption);
observations.Add(aliasOption);
observations.Add(purlOption);
observations.Add(cpeOption);
observations.Add(limitOption);
observations.Add(cursorOption);
observations.Add(jsonOption);
observations.SetAction((parseResult, _) =>
{
var tenant = parseResult.GetValue(tenantOption) ?? string.Empty;
var observationIds = parseResult.GetValue(observationIdOption) ?? Array.Empty<string>();
var aliases = parseResult.GetValue(aliasOption) ?? Array.Empty<string>();
var purls = parseResult.GetValue(purlOption) ?? Array.Empty<string>();
var cpes = parseResult.GetValue(cpeOption) ?? Array.Empty<string>();
var limit = parseResult.GetValue(limitOption);
var cursor = parseResult.GetValue(cursorOption);
var emitJson = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleVulnObservationsAsync(
services,
tenant,
observationIds,
aliases,
purls,
cpes,
limit,
cursor,
emitJson,
verbose,
cancellationToken);
});
vuln.Add(observations);
return vuln;
}
private static Command BuildConfigCommand(StellaOpsCliOptions options)
{
var config = new Command("config", "Inspect CLI configuration state.");
var show = new Command("show", "Display resolved configuration values.");
show.SetAction((_, _) =>
{
var authority = options.Authority ?? new StellaOpsCliAuthorityOptions();
var lines = new[]
{
$"Backend URL: {MaskIfEmpty(options.BackendUrl)}",
$"Concelier URL: {MaskIfEmpty(options.ConcelierUrl)}",
$"API Key: {DescribeSecret(options.ApiKey)}",
$"Scanner Cache: {options.ScannerCacheDirectory}",
$"Results Directory: {options.ResultsDirectory}",
$"Default Runner: {options.DefaultRunner}",
$"Authority URL: {MaskIfEmpty(authority.Url)}",
$"Authority Client ID: {MaskIfEmpty(authority.ClientId)}",
$"Authority Client Secret: {DescribeSecret(authority.ClientSecret ?? string.Empty)}",
$"Authority Username: {MaskIfEmpty(authority.Username)}",
$"Authority Password: {DescribeSecret(authority.Password ?? string.Empty)}",
$"Authority Scope: {MaskIfEmpty(authority.Scope)}",
$"Authority Token Cache: {MaskIfEmpty(authority.TokenCacheDirectory ?? string.Empty)}"
};
foreach (var line in lines)
{
Console.WriteLine(line);
}
return Task.CompletedTask;
});
config.Add(show);
return config;
}
private static string MaskIfEmpty(string value)
=> string.IsNullOrWhiteSpace(value) ? "<not configured>" : value;
private static string DescribeSecret(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "<not configured>";
}
return value.Length switch
{
<= 4 => "****",
_ => $"{value[..2]}***{value[^2..]}"
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
using System;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Cli.Configuration;
internal static class AuthorityTokenUtilities
{
public static string ResolveScope(StellaOpsCliOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var scope = options.Authority?.Scope;
return string.IsNullOrWhiteSpace(scope)
? StellaOpsScopes.ConcelierJobsTrigger
: scope.Trim();
}
public static string BuildCacheKey(StellaOpsCliOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (options.Authority is null)
{
return string.Empty;
}
var scope = ResolveScope(options);
var credential = !string.IsNullOrWhiteSpace(options.Authority.Username)
? $"user:{options.Authority.Username}"
: $"client:{options.Authority.ClientId}";
var cacheKey = $"{options.Authority.Url}|{credential}|{scope}";
if (!string.IsNullOrWhiteSpace(scope) && scope.Contains("orch:operate", StringComparison.OrdinalIgnoreCase))
{
var reasonHash = HashOperatorMetadata(options.Authority.OperatorReason);
var ticketHash = HashOperatorMetadata(options.Authority.OperatorTicket);
cacheKey = $"{cacheKey}|op_reason:{reasonHash}|op_ticket:{ticketHash}";
}
return cacheKey;
}
private static string HashOperatorMetadata(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "none";
}
var trimmed = value.Trim();
var bytes = Encoding.UTF8.GetBytes(trimmed);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,418 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Microsoft.Extensions.Configuration;
using StellaOps.Configuration;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Cli.Configuration;
public static class CliBootstrapper
{
public static (StellaOpsCliOptions Options, IConfigurationRoot Configuration) Build(string[] args)
{
var bootstrap = StellaOpsConfigurationBootstrapper.Build<StellaOpsCliOptions>(options =>
{
options.BindingSection = "StellaOps";
options.ConfigureBuilder = builder =>
{
if (args.Length > 0)
{
builder.AddCommandLine(args);
}
};
options.PostBind = (cliOptions, configuration) =>
{
cliOptions.ApiKey = ResolveWithFallback(cliOptions.ApiKey, configuration, "API_KEY", "StellaOps:ApiKey", "ApiKey");
cliOptions.BackendUrl = ResolveWithFallback(cliOptions.BackendUrl, configuration, "STELLAOPS_BACKEND_URL", "StellaOps:BackendUrl", "BackendUrl");
cliOptions.ConcelierUrl = ResolveWithFallback(cliOptions.ConcelierUrl, configuration, "STELLAOPS_CONCELIER_URL", "StellaOps:ConcelierUrl", "ConcelierUrl");
cliOptions.ScannerSignaturePublicKeyPath = ResolveWithFallback(cliOptions.ScannerSignaturePublicKeyPath, configuration, "SCANNER_PUBLIC_KEY", "STELLAOPS_SCANNER_PUBLIC_KEY", "StellaOps:ScannerSignaturePublicKeyPath", "ScannerSignaturePublicKeyPath");
cliOptions.ApiKey = cliOptions.ApiKey?.Trim() ?? string.Empty;
cliOptions.BackendUrl = cliOptions.BackendUrl?.Trim() ?? string.Empty;
cliOptions.ConcelierUrl = cliOptions.ConcelierUrl?.Trim() ?? string.Empty;
cliOptions.ScannerSignaturePublicKeyPath = cliOptions.ScannerSignaturePublicKeyPath?.Trim() ?? string.Empty;
var attemptsRaw = ResolveWithFallback(
string.Empty,
configuration,
"SCANNER_DOWNLOAD_ATTEMPTS",
"STELLAOPS_SCANNER_DOWNLOAD_ATTEMPTS",
"StellaOps:ScannerDownloadAttempts",
"ScannerDownloadAttempts");
if (string.IsNullOrWhiteSpace(attemptsRaw))
{
attemptsRaw = cliOptions.ScannerDownloadAttempts.ToString(CultureInfo.InvariantCulture);
}
if (int.TryParse(attemptsRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedAttempts) && parsedAttempts > 0)
{
cliOptions.ScannerDownloadAttempts = parsedAttempts;
}
if (cliOptions.ScannerDownloadAttempts <= 0)
{
cliOptions.ScannerDownloadAttempts = 3;
}
cliOptions.Authority ??= new StellaOpsCliAuthorityOptions();
var authority = cliOptions.Authority;
authority.Url = ResolveWithFallback(
authority.Url,
configuration,
"STELLAOPS_AUTHORITY_URL",
"StellaOps:Authority:Url",
"Authority:Url",
"Authority:Issuer");
authority.ClientId = ResolveWithFallback(
authority.ClientId,
configuration,
"STELLAOPS_AUTHORITY_CLIENT_ID",
"StellaOps:Authority:ClientId",
"Authority:ClientId");
authority.ClientSecret = ResolveWithFallback(
authority.ClientSecret ?? string.Empty,
configuration,
"STELLAOPS_AUTHORITY_CLIENT_SECRET",
"StellaOps:Authority:ClientSecret",
"Authority:ClientSecret");
authority.Username = ResolveWithFallback(
authority.Username,
configuration,
"STELLAOPS_AUTHORITY_USERNAME",
"StellaOps:Authority:Username",
"Authority:Username");
authority.Password = ResolveWithFallback(
authority.Password ?? string.Empty,
configuration,
"STELLAOPS_AUTHORITY_PASSWORD",
"StellaOps:Authority:Password",
"Authority:Password");
authority.Scope = ResolveWithFallback(
authority.Scope,
configuration,
"STELLAOPS_AUTHORITY_SCOPE",
"StellaOps:Authority:Scope",
"Authority:Scope");
authority.OperatorReason = ResolveWithFallback(
authority.OperatorReason,
configuration,
"STELLAOPS_ORCH_REASON",
"StellaOps:Authority:OperatorReason",
"Authority:OperatorReason");
authority.OperatorTicket = ResolveWithFallback(
authority.OperatorTicket,
configuration,
"STELLAOPS_ORCH_TICKET",
"StellaOps:Authority:OperatorTicket",
"Authority:OperatorTicket");
authority.TokenCacheDirectory = ResolveWithFallback(
authority.TokenCacheDirectory,
configuration,
"STELLAOPS_AUTHORITY_TOKEN_CACHE_DIR",
"StellaOps:Authority:TokenCacheDirectory",
"Authority:TokenCacheDirectory");
authority.Url = authority.Url?.Trim() ?? string.Empty;
authority.ClientId = authority.ClientId?.Trim() ?? string.Empty;
authority.ClientSecret = string.IsNullOrWhiteSpace(authority.ClientSecret) ? null : authority.ClientSecret.Trim();
authority.Username = authority.Username?.Trim() ?? string.Empty;
authority.Password = string.IsNullOrWhiteSpace(authority.Password) ? null : authority.Password.Trim();
authority.Scope = string.IsNullOrWhiteSpace(authority.Scope) ? StellaOpsScopes.ConcelierJobsTrigger : authority.Scope.Trim();
authority.OperatorReason = authority.OperatorReason?.Trim() ?? string.Empty;
authority.OperatorTicket = authority.OperatorTicket?.Trim() ?? string.Empty;
authority.Resilience ??= new StellaOpsCliAuthorityResilienceOptions();
authority.Resilience.RetryDelays ??= new List<TimeSpan>();
var resilience = authority.Resilience;
if (!resilience.EnableRetries.HasValue)
{
var raw = ResolveWithFallback(
string.Empty,
configuration,
"STELLAOPS_AUTHORITY_ENABLE_RETRIES",
"StellaOps:Authority:Resilience:EnableRetries",
"StellaOps:Authority:EnableRetries",
"Authority:Resilience:EnableRetries",
"Authority:EnableRetries");
if (TryParseBoolean(raw, out var parsed))
{
resilience.EnableRetries = parsed;
}
}
var retryDelaysRaw = ResolveWithFallback(
string.Empty,
configuration,
"STELLAOPS_AUTHORITY_RETRY_DELAYS",
"StellaOps:Authority:Resilience:RetryDelays",
"StellaOps:Authority:RetryDelays",
"Authority:Resilience:RetryDelays",
"Authority:RetryDelays");
if (!string.IsNullOrWhiteSpace(retryDelaysRaw))
{
resilience.RetryDelays.Clear();
foreach (var delay in ParseRetryDelays(retryDelaysRaw))
{
if (delay > TimeSpan.Zero)
{
resilience.RetryDelays.Add(delay);
}
}
}
if (!resilience.AllowOfflineCacheFallback.HasValue)
{
var raw = ResolveWithFallback(
string.Empty,
configuration,
"STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK",
"StellaOps:Authority:Resilience:AllowOfflineCacheFallback",
"StellaOps:Authority:AllowOfflineCacheFallback",
"Authority:Resilience:AllowOfflineCacheFallback",
"Authority:AllowOfflineCacheFallback");
if (TryParseBoolean(raw, out var parsed))
{
resilience.AllowOfflineCacheFallback = parsed;
}
}
if (!resilience.OfflineCacheTolerance.HasValue)
{
var raw = ResolveWithFallback(
string.Empty,
configuration,
"STELLAOPS_AUTHORITY_OFFLINE_CACHE_TOLERANCE",
"StellaOps:Authority:Resilience:OfflineCacheTolerance",
"StellaOps:Authority:OfflineCacheTolerance",
"Authority:Resilience:OfflineCacheTolerance",
"Authority:OfflineCacheTolerance");
if (TimeSpan.TryParse(raw, CultureInfo.InvariantCulture, out var tolerance) && tolerance >= TimeSpan.Zero)
{
resilience.OfflineCacheTolerance = tolerance;
}
}
var defaultTokenCache = GetDefaultTokenCacheDirectory();
if (string.IsNullOrWhiteSpace(authority.TokenCacheDirectory))
{
authority.TokenCacheDirectory = defaultTokenCache;
}
else
{
authority.TokenCacheDirectory = Path.GetFullPath(authority.TokenCacheDirectory);
}
cliOptions.Offline ??= new StellaOpsCliOfflineOptions();
var offline = cliOptions.Offline;
var kitsDirectory = ResolveWithFallback(
string.Empty,
configuration,
"STELLAOPS_OFFLINE_KITS_DIRECTORY",
"STELLAOPS_OFFLINE_KITS_DIR",
"StellaOps:Offline:KitsDirectory",
"StellaOps:Offline:KitDirectory",
"Offline:KitsDirectory",
"Offline:KitDirectory");
if (string.IsNullOrWhiteSpace(kitsDirectory))
{
kitsDirectory = offline.KitsDirectory ?? "offline-kits";
}
offline.KitsDirectory = Path.GetFullPath(kitsDirectory);
if (!Directory.Exists(offline.KitsDirectory))
{
Directory.CreateDirectory(offline.KitsDirectory);
}
var mirror = ResolveWithFallback(
string.Empty,
configuration,
"STELLAOPS_OFFLINE_MIRROR_URL",
"StellaOps:Offline:KitMirror",
"Offline:KitMirror",
"Offline:MirrorUrl");
offline.MirrorUrl = string.IsNullOrWhiteSpace(mirror) ? null : mirror.Trim();
cliOptions.Plugins ??= new StellaOpsCliPluginOptions();
var pluginOptions = cliOptions.Plugins;
pluginOptions.BaseDirectory = ResolveWithFallback(
pluginOptions.BaseDirectory,
configuration,
"STELLAOPS_CLI_PLUGIN_BASE_DIRECTORY",
"StellaOps:Plugins:BaseDirectory",
"Plugins:BaseDirectory");
pluginOptions.BaseDirectory = (pluginOptions.BaseDirectory ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(pluginOptions.BaseDirectory))
{
pluginOptions.BaseDirectory = AppContext.BaseDirectory;
}
pluginOptions.BaseDirectory = Path.GetFullPath(pluginOptions.BaseDirectory);
pluginOptions.Directory = ResolveWithFallback(
pluginOptions.Directory,
configuration,
"STELLAOPS_CLI_PLUGIN_DIRECTORY",
"StellaOps:Plugins:Directory",
"Plugins:Directory");
pluginOptions.Directory = (pluginOptions.Directory ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(pluginOptions.Directory))
{
pluginOptions.Directory = Path.Combine("plugins", "cli");
}
if (!Path.IsPathRooted(pluginOptions.Directory))
{
pluginOptions.Directory = Path.GetFullPath(Path.Combine(pluginOptions.BaseDirectory, pluginOptions.Directory));
}
else
{
pluginOptions.Directory = Path.GetFullPath(pluginOptions.Directory);
}
pluginOptions.ManifestSearchPattern = ResolveWithFallback(
pluginOptions.ManifestSearchPattern,
configuration,
"STELLAOPS_CLI_PLUGIN_MANIFEST_PATTERN",
"StellaOps:Plugins:ManifestSearchPattern",
"Plugins:ManifestSearchPattern");
pluginOptions.ManifestSearchPattern = (pluginOptions.ManifestSearchPattern ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(pluginOptions.ManifestSearchPattern))
{
pluginOptions.ManifestSearchPattern = "*.manifest.json";
}
if (pluginOptions.SearchPatterns is null || pluginOptions.SearchPatterns.Count == 0)
{
pluginOptions.SearchPatterns = new List<string> { "StellaOps.Cli.Plugin.*.dll" };
}
else
{
pluginOptions.SearchPatterns = pluginOptions.SearchPatterns
.Where(pattern => !string.IsNullOrWhiteSpace(pattern))
.Select(pattern => pattern.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (pluginOptions.SearchPatterns.Count == 0)
{
pluginOptions.SearchPatterns.Add("StellaOps.Cli.Plugin.*.dll");
}
}
if (pluginOptions.PluginOrder is null)
{
pluginOptions.PluginOrder = new List<string>();
}
else
{
pluginOptions.PluginOrder = pluginOptions.PluginOrder
.Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => name.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
};
});
return (bootstrap.Options, bootstrap.Configuration);
}
private static string ResolveWithFallback(string currentValue, IConfiguration configuration, params string[] keys)
{
if (!string.IsNullOrWhiteSpace(currentValue))
{
return currentValue;
}
foreach (var key in keys)
{
var value = configuration[key];
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return string.Empty;
}
private static bool TryParseBoolean(string value, out bool parsed)
{
if (string.IsNullOrWhiteSpace(value))
{
parsed = default;
return false;
}
if (bool.TryParse(value, out parsed))
{
return true;
}
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric))
{
parsed = numeric != 0;
return true;
}
parsed = default;
return false;
}
private static IEnumerable<TimeSpan> ParseRetryDelays(string raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
yield break;
}
var separators = new[] { ',', ';', ' ' };
foreach (var token in raw.Split(separators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (TimeSpan.TryParse(token, CultureInfo.InvariantCulture, out var delay) && delay > TimeSpan.Zero)
{
yield return delay;
}
}
}
private static string GetDefaultTokenCacheDirectory()
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrWhiteSpace(home))
{
home = AppContext.BaseDirectory;
}
return Path.GetFullPath(Path.Combine(home, ".stellaops", "tokens"));
}
}

View File

@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using StellaOps.Auth.Abstractions;
using System.IO;
namespace StellaOps.Cli.Configuration;
public sealed class StellaOpsCliOptions
{
public string ApiKey { get; set; } = string.Empty;
public string BackendUrl { get; set; } = string.Empty;
public string ConcelierUrl { get; set; } = string.Empty;
public string ScannerCacheDirectory { get; set; } = "scanners";
public string ResultsDirectory { get; set; } = "results";
public string DefaultRunner { get; set; } = "docker";
public string ScannerSignaturePublicKeyPath { get; set; } = string.Empty;
public int ScannerDownloadAttempts { get; set; } = 3;
public int ScanUploadAttempts { get; set; } = 3;
public StellaOpsCliAuthorityOptions Authority { get; set; } = new();
public StellaOpsCliOfflineOptions Offline { get; set; } = new();
public StellaOpsCliPluginOptions Plugins { get; set; } = new();
}
public sealed class StellaOpsCliAuthorityOptions
{
public string Url { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public string? ClientSecret { get; set; }
public string Username { get; set; } = string.Empty;
public string? Password { get; set; }
public string Scope { get; set; } = StellaOpsScopes.ConcelierJobsTrigger;
public string OperatorReason { get; set; } = string.Empty;
public string OperatorTicket { get; set; } = string.Empty;
public string TokenCacheDirectory { get; set; } = string.Empty;
public StellaOpsCliAuthorityResilienceOptions Resilience { get; set; } = new();
}
public sealed class StellaOpsCliAuthorityResilienceOptions
{
public bool? EnableRetries { get; set; }
public IList<TimeSpan> RetryDelays { get; set; } = new List<TimeSpan>();
public bool? AllowOfflineCacheFallback { get; set; }
public TimeSpan? OfflineCacheTolerance { get; set; }
}
public sealed class StellaOpsCliOfflineOptions
{
public string KitsDirectory { get; set; } = "offline-kits";
public string? MirrorUrl { get; set; }
}
public sealed class StellaOpsCliPluginOptions
{
public string BaseDirectory { get; set; } = string.Empty;
public string Directory { get; set; } = "plugins/cli";
public IList<string> SearchPatterns { get; set; } = new List<string>();
public IList<string> PluginOrder { get; set; } = new List<string>();
public string ManifestSearchPattern { get; set; } = "*.manifest.json";
}

View File

@@ -0,0 +1,278 @@
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Configuration;
using StellaOps.Plugin.Hosting;
namespace StellaOps.Cli.Plugins;
internal sealed class CliCommandModuleLoader
{
private readonly IServiceProvider _services;
private readonly StellaOpsCliOptions _options;
private readonly ILogger<CliCommandModuleLoader> _logger;
private readonly RestartOnlyCliPluginGuard _guard = new();
private IReadOnlyList<ICliCommandModule> _modules = Array.Empty<ICliCommandModule>();
private bool _loaded;
public CliCommandModuleLoader(
IServiceProvider services,
StellaOpsCliOptions options,
ILogger<CliCommandModuleLoader> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public IReadOnlyList<ICliCommandModule> LoadModules()
{
if (_loaded)
{
return _modules;
}
var pluginOptions = _options.Plugins ?? new StellaOpsCliPluginOptions();
var baseDirectory = ResolveBaseDirectory(pluginOptions);
var pluginsDirectory = ResolvePluginsDirectory(pluginOptions, baseDirectory);
var searchPatterns = ResolveSearchPatterns(pluginOptions);
var manifestPattern = string.IsNullOrWhiteSpace(pluginOptions.ManifestSearchPattern)
? "*.manifest.json"
: pluginOptions.ManifestSearchPattern;
_logger.LogDebug("Loading CLI plug-ins from '{Directory}' (base: '{Base}').", pluginsDirectory, baseDirectory);
var manifestLoader = new CliPluginManifestLoader(pluginsDirectory, manifestPattern);
IReadOnlyList<CliPluginManifest> manifests;
try
{
manifests = manifestLoader.LoadAsync(CancellationToken.None).GetAwaiter().GetResult();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to enumerate CLI plug-in manifests from '{Directory}'.", pluginsDirectory);
manifests = Array.Empty<CliPluginManifest>();
}
if (manifests.Count == 0)
{
_logger.LogInformation("No CLI plug-in manifests discovered under '{Directory}'.", pluginsDirectory);
_loaded = true;
_guard.Seal();
_modules = Array.Empty<ICliCommandModule>();
return _modules;
}
var hostOptions = new PluginHostOptions
{
BaseDirectory = baseDirectory,
PluginsDirectory = pluginsDirectory,
EnsureDirectoryExists = false,
RecursiveSearch = true,
PrimaryPrefix = "StellaOps.Cli"
};
foreach (var pattern in searchPatterns)
{
hostOptions.SearchPatterns.Add(pattern);
}
foreach (var ordered in pluginOptions.PluginOrder ?? Array.Empty<string>())
{
if (!string.IsNullOrWhiteSpace(ordered))
{
hostOptions.PluginOrder.Add(ordered);
}
}
var loadResult = PluginHost.LoadPlugins(hostOptions, _logger);
var assemblies = loadResult.Plugins.ToDictionary(
descriptor => Normalize(descriptor.AssemblyPath),
descriptor => descriptor.Assembly,
StringComparer.OrdinalIgnoreCase);
var modules = new List<ICliCommandModule>(manifests.Count);
foreach (var manifest in manifests)
{
try
{
var assemblyPath = ResolveAssemblyPath(manifest);
_guard.EnsureRegistrationAllowed(assemblyPath);
if (!assemblies.TryGetValue(assemblyPath, out var assembly))
{
if (!File.Exists(assemblyPath))
{
throw new FileNotFoundException($"Plug-in assembly '{assemblyPath}' referenced by manifest '{manifest.Id}' was not found.");
}
assembly = Assembly.LoadFrom(assemblyPath);
assemblies[assemblyPath] = assembly;
}
var module = CreateModule(assembly, manifest);
if (module is null)
{
continue;
}
modules.Add(module);
_logger.LogInformation("Registered CLI plug-in '{PluginId}' ({PluginName}) from '{AssemblyPath}'.", manifest.Id, module.Name, assemblyPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to register CLI plug-in '{PluginId}'.", manifest.Id);
}
}
_modules = modules;
_loaded = true;
_guard.Seal();
return _modules;
}
public void RegisterModules(RootCommand root, Option<bool> verboseOption, CancellationToken cancellationToken)
{
if (root is null)
{
throw new ArgumentNullException(nameof(root));
}
if (verboseOption is null)
{
throw new ArgumentNullException(nameof(verboseOption));
}
var modules = LoadModules();
if (modules.Count == 0)
{
return;
}
foreach (var module in modules)
{
if (!module.IsAvailable(_services))
{
_logger.LogDebug("CLI plug-in '{Name}' reported unavailable; skipping registration.", module.Name);
continue;
}
try
{
module.RegisterCommands(root, _services, _options, verboseOption, cancellationToken);
_logger.LogInformation("CLI plug-in '{Name}' commands registered.", module.Name);
}
catch (Exception ex)
{
_logger.LogError(ex, "CLI plug-in '{Name}' failed to register commands.", module.Name);
}
}
}
private static string ResolveAssemblyPath(CliPluginManifest manifest)
{
if (manifest.EntryPoint is null)
{
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' does not define an entry point.");
}
var assemblyPath = manifest.EntryPoint.Assembly;
if (string.IsNullOrWhiteSpace(assemblyPath))
{
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' specifies an empty assembly path.");
}
if (!Path.IsPathRooted(assemblyPath))
{
if (string.IsNullOrWhiteSpace(manifest.SourceDirectory))
{
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' cannot resolve relative assembly path without source directory metadata.");
}
assemblyPath = Path.Combine(manifest.SourceDirectory, assemblyPath);
}
return Normalize(assemblyPath);
}
private ICliCommandModule? CreateModule(Assembly assembly, CliPluginManifest manifest)
{
if (manifest.EntryPoint is null)
{
return null;
}
var type = assembly.GetType(manifest.EntryPoint.TypeName, throwOnError: true);
if (type is null)
{
throw new InvalidOperationException($"Plug-in type '{manifest.EntryPoint.TypeName}' could not be loaded from assembly '{assembly.FullName}'.");
}
var module = ActivatorUtilities.CreateInstance(_services, type) as ICliCommandModule;
if (module is null)
{
throw new InvalidOperationException($"Plug-in type '{manifest.EntryPoint.TypeName}' does not implement {nameof(ICliCommandModule)}.");
}
return module;
}
private static string ResolveBaseDirectory(StellaOpsCliPluginOptions options)
{
var baseDirectory = options.BaseDirectory;
if (string.IsNullOrWhiteSpace(baseDirectory))
{
baseDirectory = AppContext.BaseDirectory;
}
return Path.GetFullPath(baseDirectory);
}
private static string ResolvePluginsDirectory(StellaOpsCliPluginOptions options, string baseDirectory)
{
var directory = options.Directory;
if (string.IsNullOrWhiteSpace(directory))
{
directory = Path.Combine("plugins", "cli");
}
directory = directory.Trim();
if (!Path.IsPathRooted(directory))
{
directory = Path.Combine(baseDirectory, directory);
}
return Path.GetFullPath(directory);
}
private static IReadOnlyList<string> ResolveSearchPatterns(StellaOpsCliPluginOptions options)
{
if (options.SearchPatterns is null || options.SearchPatterns.Count == 0)
{
return new[] { "StellaOps.Cli.Plugin.*.dll" };
}
return options.SearchPatterns
.Where(pattern => !string.IsNullOrWhiteSpace(pattern))
.Select(pattern => pattern.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string Normalize(string path)
{
var full = Path.GetFullPath(path);
return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
}

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cli.Plugins;
public sealed record CliPluginManifest
{
public const string CurrentSchemaVersion = "1.0";
public string SchemaVersion { get; init; } = CurrentSchemaVersion;
public string Id { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string Version { get; init; } = "0.0.0";
public bool RequiresRestart { get; init; } = true;
public CliPluginEntryPoint? EntryPoint { get; init; }
public IReadOnlyList<string> Capabilities { get; init; } = Array.Empty<string>();
public IReadOnlyDictionary<string, string> Metadata { get; init; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public string? SourcePath { get; init; }
public string? SourceDirectory { get; init; }
}
public sealed record CliPluginEntryPoint
{
public string Type { get; init; } = "dotnet";
public string Assembly { get; init; } = string.Empty;
public string TypeName { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Plugins;
internal sealed class CliPluginManifestLoader
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
PropertyNameCaseInsensitive = true
};
private readonly string _directory;
private readonly string _searchPattern;
public CliPluginManifestLoader(string directory, string searchPattern)
{
if (string.IsNullOrWhiteSpace(directory))
{
throw new ArgumentException("Plug-in manifest directory is required.", nameof(directory));
}
if (string.IsNullOrWhiteSpace(searchPattern))
{
throw new ArgumentException("Manifest search pattern is required.", nameof(searchPattern));
}
_directory = Path.GetFullPath(directory);
_searchPattern = searchPattern;
}
public async Task<IReadOnlyList<CliPluginManifest>> LoadAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(_directory))
{
return Array.Empty<CliPluginManifest>();
}
var manifests = new List<CliPluginManifest>();
foreach (var file in Directory.EnumerateFiles(_directory, _searchPattern, SearchOption.AllDirectories))
{
if (IsHidden(file))
{
continue;
}
var manifest = await DeserializeAsync(file, cancellationToken).ConfigureAwait(false);
manifests.Add(manifest);
}
return manifests
.OrderBy(static m => m.Id, StringComparer.OrdinalIgnoreCase)
.ThenBy(static m => m.Version, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static bool IsHidden(string path)
{
var directory = Path.GetDirectoryName(path);
while (!string.IsNullOrEmpty(directory))
{
var name = Path.GetFileName(directory);
if (name.StartsWith(".", StringComparison.Ordinal))
{
return true;
}
directory = Path.GetDirectoryName(directory);
}
return false;
}
private static async Task<CliPluginManifest> DeserializeAsync(string file, CancellationToken cancellationToken)
{
await using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous);
CliPluginManifest? manifest;
try
{
manifest = await JsonSerializer.DeserializeAsync<CliPluginManifest>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (JsonException ex)
{
throw new InvalidOperationException($"Failed to parse CLI plug-in manifest '{file}'.", ex);
}
if (manifest is null)
{
throw new InvalidOperationException($"CLI plug-in manifest '{file}' is empty or invalid.");
}
ValidateManifest(manifest, file);
var directory = Path.GetDirectoryName(file);
return manifest with
{
SourcePath = file,
SourceDirectory = directory
};
}
private static void ValidateManifest(CliPluginManifest manifest, string file)
{
if (!string.Equals(manifest.SchemaVersion, CliPluginManifest.CurrentSchemaVersion, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Manifest '{file}' uses unsupported schema version '{manifest.SchemaVersion}'. Expected '{CliPluginManifest.CurrentSchemaVersion}'.");
}
if (string.IsNullOrWhiteSpace(manifest.Id))
{
throw new InvalidOperationException($"Manifest '{file}' must specify a non-empty 'id'.");
}
if (manifest.EntryPoint is null)
{
throw new InvalidOperationException($"Manifest '{file}' must specify an 'entryPoint'.");
}
if (!string.Equals(manifest.EntryPoint.Type, "dotnet", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Manifest '{file}' entry point type '{manifest.EntryPoint.Type}' is not supported. Expected 'dotnet'.");
}
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.Assembly))
{
throw new InvalidOperationException($"Manifest '{file}' must specify an entry point assembly.");
}
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.TypeName))
{
throw new InvalidOperationException($"Manifest '{file}' must specify an entry point type.");
}
if (!manifest.RequiresRestart)
{
throw new InvalidOperationException($"Manifest '{file}' must set 'requiresRestart' to true.");
}
}
}

View File

@@ -0,0 +1,20 @@
using System;
using System.CommandLine;
using System.Threading;
using StellaOps.Cli.Configuration;
namespace StellaOps.Cli.Plugins;
public interface ICliCommandModule
{
string Name { get; }
bool IsAvailable(IServiceProvider services);
void RegisterCommands(
RootCommand root,
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,41 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
namespace StellaOps.Cli.Plugins;
internal sealed class RestartOnlyCliPluginGuard
{
private readonly ConcurrentDictionary<string, byte> _plugins = new(StringComparer.OrdinalIgnoreCase);
private bool _sealed;
public IReadOnlyCollection<string> KnownPlugins => _plugins.Keys.ToArray();
public bool IsSealed => Volatile.Read(ref _sealed);
public void EnsureRegistrationAllowed(string pluginPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginPath);
var normalized = Normalize(pluginPath);
if (IsSealed && !_plugins.ContainsKey(normalized))
{
throw new InvalidOperationException($"Plug-in '{pluginPath}' cannot be registered after startup. Restart required.");
}
_plugins.TryAdd(normalized, 0);
}
public void Seal()
{
Volatile.Write(ref _sealed, true);
}
private static string Normalize(string path)
{
var full = Path.GetFullPath(path);
return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
}

View File

@@ -0,0 +1,141 @@
using System;
using System.CommandLine;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Client;
using StellaOps.Cli.Commands;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services;
using StellaOps.Cli.Telemetry;
namespace StellaOps.Cli;
internal static class Program
{
internal static async Task<int> Main(string[] args)
{
var (options, configuration) = CliBootstrapper.Build(args);
var services = new ServiceCollection();
services.AddSingleton(configuration);
services.AddSingleton(options);
var verbosityState = new VerbosityState();
services.AddSingleton(verbosityState);
services.AddLogging(builder =>
{
builder.ClearProviders();
builder.AddSimpleConsole(logOptions =>
{
logOptions.TimestampFormat = "HH:mm:ss ";
logOptions.SingleLine = true;
});
builder.AddFilter((category, level) => level >= verbosityState.MinimumLevel);
});
if (!string.IsNullOrWhiteSpace(options.Authority.Url))
{
services.AddStellaOpsAuthClient(clientOptions =>
{
clientOptions.Authority = options.Authority.Url;
clientOptions.ClientId = options.Authority.ClientId ?? string.Empty;
clientOptions.ClientSecret = options.Authority.ClientSecret;
clientOptions.DefaultScopes.Clear();
clientOptions.DefaultScopes.Add(string.IsNullOrWhiteSpace(options.Authority.Scope)
? StellaOps.Auth.Abstractions.StellaOpsScopes.ConcelierJobsTrigger
: options.Authority.Scope);
var resilience = options.Authority.Resilience ?? new StellaOpsCliAuthorityResilienceOptions();
clientOptions.EnableRetries = resilience.EnableRetries ?? true;
if (resilience.RetryDelays is { Count: > 0 })
{
clientOptions.RetryDelays.Clear();
foreach (var delay in resilience.RetryDelays)
{
if (delay > TimeSpan.Zero)
{
clientOptions.RetryDelays.Add(delay);
}
}
}
if (resilience.AllowOfflineCacheFallback.HasValue)
{
clientOptions.AllowOfflineCacheFallback = resilience.AllowOfflineCacheFallback.Value;
}
if (resilience.OfflineCacheTolerance.HasValue && resilience.OfflineCacheTolerance.Value >= TimeSpan.Zero)
{
clientOptions.OfflineCacheTolerance = resilience.OfflineCacheTolerance.Value;
}
});
var cacheDirectory = options.Authority.TokenCacheDirectory;
if (!string.IsNullOrWhiteSpace(cacheDirectory))
{
Directory.CreateDirectory(cacheDirectory);
services.AddStellaOpsFileTokenCache(cacheDirectory);
}
services.AddHttpClient<IAuthorityRevocationClient, AuthorityRevocationClient>(client =>
{
client.Timeout = TimeSpan.FromMinutes(2);
if (Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var authorityUri))
{
client.BaseAddress = authorityUri;
}
});
}
services.AddHttpClient<IBackendOperationsClient, BackendOperationsClient>(client =>
{
client.Timeout = TimeSpan.FromMinutes(5);
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
{
client.BaseAddress = backendUri;
}
});
services.AddHttpClient<IConcelierObservationsClient, ConcelierObservationsClient>(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl) &&
Uri.TryCreate(options.ConcelierUrl, UriKind.Absolute, out var concelierUri))
{
client.BaseAddress = concelierUri;
}
});
services.AddSingleton<IScannerExecutor, ScannerExecutor>();
services.AddSingleton<IScannerInstaller, ScannerInstaller>();
await using var serviceProvider = services.BuildServiceProvider();
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var startupLogger = loggerFactory.CreateLogger("StellaOps.Cli.Startup");
AuthorityDiagnosticsReporter.Emit(configuration, startupLogger);
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
cts.Cancel();
};
var rootCommand = CommandFactory.Create(serviceProvider, options, cts.Token, loggerFactory);
var commandConfiguration = new CommandLineConfiguration(rootCommand);
var commandExit = await commandConfiguration.InvokeAsync(args, cts.Token).ConfigureAwait(false);
var finalExit = Environment.ExitCode != 0 ? Environment.ExitCode : commandExit;
if (cts.IsCancellationRequested && finalExit == 0)
{
finalExit = 130; // Typical POSIX cancellation exit code
}
return finalExit;
}
}

View File

@@ -0,0 +1,52 @@
using Spectre.Console;
namespace StellaOps.Cli.Prompts;
internal static class TrivyDbExportPrompt
{
public static (bool? publishFull, bool? publishDelta, bool? includeFull, bool? includeDelta) PromptOverrides()
{
if (!AnsiConsole.Profile.Capabilities.Interactive)
{
return (null, null, null, null);
}
AnsiConsole.Write(
new Panel("[bold]Trivy DB Export Overrides[/]")
.Border(BoxBorder.Rounded)
.Header("Trivy DB")
.Collapse());
var shouldOverride = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Adjust publishing or offline bundle behaviour?")
.AddChoices("Leave defaults", "Override"));
if (shouldOverride == "Leave defaults")
{
return (null, null, null, null);
}
var publishFull = PromptBoolean("Push full exports to ORAS?");
var publishDelta = PromptBoolean("Push delta exports to ORAS?");
var includeFull = PromptBoolean("Include full exports in offline bundle?");
var includeDelta = PromptBoolean("Include delta exports in offline bundle?");
return (publishFull, publishDelta, includeFull, includeDelta);
}
private static bool? PromptBoolean(string question)
{
var choice = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title($"{question} [grey](select override or keep default)[/]")
.AddChoices("Keep default", "Yes", "No"));
return choice switch
{
"Yes" => true,
"No" => false,
_ => (bool?)null,
};
}
}

View File

@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Cli.Tests")]
[assembly: InternalsVisibleTo("StellaOps.Cli.Plugins.NonCore")]

View File

@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Configuration;
namespace StellaOps.Cli.Services;
/// <summary>
/// Emits Authority configuration diagnostics discovered during CLI startup.
/// </summary>
internal static class AuthorityDiagnosticsReporter
{
public static void Emit(IConfiguration configuration, ILogger logger)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(logger);
var basePath = Directory.GetCurrentDirectory();
EmitInternal(configuration, logger, basePath);
}
internal static void Emit(IConfiguration configuration, ILogger logger, string basePath)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(basePath);
EmitInternal(configuration, logger, basePath);
}
private static void EmitInternal(IConfiguration configuration, ILogger logger, string basePath)
{
if (string.IsNullOrWhiteSpace(basePath))
{
basePath = Directory.GetCurrentDirectory();
}
var authoritySection = configuration.GetSection("Authority");
if (!authoritySection.Exists())
{
return;
}
var authorityOptions = new StellaOpsAuthorityOptions();
authoritySection.Bind(authorityOptions);
if (authorityOptions.Plugins.Descriptors.Count == 0)
{
return;
}
var resolvedBasePath = Path.GetFullPath(basePath);
IReadOnlyList<AuthorityPluginContext> contexts;
try
{
contexts = AuthorityPluginConfigurationLoader.Load(authorityOptions, resolvedBasePath);
}
catch (Exception ex)
{
logger.LogDebug(ex, "Failed to load Authority plug-in configuration for diagnostics.");
return;
}
if (contexts.Count == 0)
{
return;
}
IReadOnlyList<AuthorityConfigurationDiagnostic> diagnostics;
try
{
diagnostics = AuthorityPluginConfigurationAnalyzer.Analyze(contexts);
}
catch (Exception ex)
{
logger.LogDebug(ex, "Failed to analyze Authority plug-in configuration for diagnostics.");
return;
}
if (diagnostics.Count == 0)
{
return;
}
var contextLookup = new Dictionary<string, AuthorityPluginContext>(StringComparer.OrdinalIgnoreCase);
foreach (var context in contexts)
{
if (context?.Manifest?.Name is { Length: > 0 } name && !contextLookup.ContainsKey(name))
{
contextLookup[name] = context;
}
}
foreach (var diagnostic in diagnostics)
{
var level = diagnostic.Severity switch
{
AuthorityConfigurationDiagnosticSeverity.Error => LogLevel.Error,
AuthorityConfigurationDiagnosticSeverity.Warning => LogLevel.Warning,
_ => LogLevel.Information
};
if (!logger.IsEnabled(level))
{
continue;
}
if (contextLookup.TryGetValue(diagnostic.PluginName, out var context) &&
context?.Manifest?.ConfigPath is { Length: > 0 } configPath)
{
logger.Log(level, "{DiagnosticMessage} (config: {ConfigPath})", diagnostic.Message, configPath);
}
else
{
logger.Log(level, "{DiagnosticMessage}", diagnostic.Message);
}
}
}
}

View File

@@ -0,0 +1,223 @@
using System;
using System.Buffers.Text;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
internal sealed class AuthorityRevocationClient : IAuthorityRevocationClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
private readonly HttpClient httpClient;
private readonly StellaOpsCliOptions options;
private readonly ILogger<AuthorityRevocationClient> logger;
private readonly IStellaOpsTokenClient? tokenClient;
private readonly object tokenSync = new();
private string? cachedAccessToken;
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
public AuthorityRevocationClient(
HttpClient httpClient,
StellaOpsCliOptions options,
ILogger<AuthorityRevocationClient> logger,
IStellaOpsTokenClient? tokenClient = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.tokenClient = tokenClient;
if (!string.IsNullOrWhiteSpace(options.Authority?.Url) && httpClient.BaseAddress is null && Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var authorityUri))
{
httpClient.BaseAddress = authorityUri;
}
}
public async Task<AuthorityRevocationExportResult> ExportAsync(bool verbose, CancellationToken cancellationToken)
{
EnsureAuthorityConfigured();
using var request = new HttpRequestMessage(HttpMethod.Get, "internal/revocations/export");
var accessToken = await AcquireAccessTokenAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(accessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
}
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var message = $"Authority export request failed with {(int)response.StatusCode} {response.ReasonPhrase}: {body}";
throw new InvalidOperationException(message);
}
var payload = await JsonSerializer.DeserializeAsync<ExportResponseDto>(
await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false),
SerializerOptions,
cancellationToken).ConfigureAwait(false);
if (payload is null)
{
throw new InvalidOperationException("Authority export response payload was empty.");
}
var bundleBytes = Convert.FromBase64String(payload.Bundle.Data);
var digest = payload.Digest?.Value ?? string.Empty;
if (verbose)
{
logger.LogInformation(
"Received revocation export sequence {Sequence} (sha256:{Digest}, signing key {KeyId}, provider {Provider}).",
payload.Sequence,
digest,
payload.SigningKeyId ?? "<unspecified>",
string.IsNullOrWhiteSpace(payload.Signature?.Provider) ? "default" : payload.Signature!.Provider);
}
return new AuthorityRevocationExportResult
{
BundleBytes = bundleBytes,
Signature = payload.Signature?.Value ?? string.Empty,
Digest = digest,
Sequence = payload.Sequence,
IssuedAt = payload.IssuedAt,
SigningKeyId = payload.SigningKeyId,
SigningProvider = payload.Signature?.Provider
};
}
private async Task<string?> AcquireAccessTokenAsync(CancellationToken cancellationToken)
{
if (tokenClient is null)
{
return null;
}
lock (tokenSync)
{
if (!string.IsNullOrEmpty(cachedAccessToken) && cachedAccessTokenExpiresAt - TokenRefreshSkew > DateTimeOffset.UtcNow)
{
return cachedAccessToken;
}
}
var scope = AuthorityTokenUtilities.ResolveScope(options);
var token = await RequestAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
lock (tokenSync)
{
cachedAccessToken = token.AccessToken;
cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
return cachedAccessToken;
}
}
private async Task<StellaOpsTokenResult> RequestAccessTokenAsync(string scope, CancellationToken cancellationToken)
{
if (options.Authority is null)
{
throw new InvalidOperationException("Authority credentials are not configured.");
}
if (!string.IsNullOrWhiteSpace(options.Authority.Username))
{
if (string.IsNullOrWhiteSpace(options.Authority.Password))
{
throw new InvalidOperationException("Authority password must be configured or run 'auth login'.");
}
return await tokenClient!.RequestPasswordTokenAsync(
options.Authority.Username,
options.Authority.Password!,
scope,
null,
cancellationToken).ConfigureAwait(false);
}
return await tokenClient!.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
}
private void EnsureAuthorityConfigured()
{
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
{
throw new InvalidOperationException("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update stellaops.yaml.");
}
if (httpClient.BaseAddress is null)
{
if (!Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var baseUri))
{
throw new InvalidOperationException("Authority URL is invalid.");
}
httpClient.BaseAddress = baseUri;
}
}
private sealed class ExportResponseDto
{
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; set; } = string.Empty;
[JsonPropertyName("bundleId")]
public string BundleId { get; set; } = string.Empty;
[JsonPropertyName("sequence")]
public long Sequence { get; set; }
[JsonPropertyName("issuedAt")]
public DateTimeOffset IssuedAt { get; set; }
[JsonPropertyName("signingKeyId")]
public string? SigningKeyId { get; set; }
[JsonPropertyName("bundle")]
public ExportPayloadDto Bundle { get; set; } = new();
[JsonPropertyName("signature")]
public ExportSignatureDto? Signature { get; set; }
[JsonPropertyName("digest")]
public ExportDigestDto? Digest { get; set; }
}
private sealed class ExportPayloadDto
{
[JsonPropertyName("data")]
public string Data { get; set; } = string.Empty;
}
private sealed class ExportSignatureDto
{
[JsonPropertyName("algorithm")]
public string Algorithm { get; set; } = string.Empty;
[JsonPropertyName("keyId")]
public string KeyId { get; set; } = string.Empty;
[JsonPropertyName("provider")]
public string Provider { get; set; } = string.Empty;
[JsonPropertyName("value")]
public string Value { get; set; } = string.Empty;
}
private sealed class ExportDigestDto
{
[JsonPropertyName("value")]
public string Value { get; set; } = string.Empty;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,250 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
private readonly HttpClient httpClient;
private readonly StellaOpsCliOptions options;
private readonly ILogger<ConcelierObservationsClient> logger;
private readonly IStellaOpsTokenClient? tokenClient;
private readonly object tokenSync = new();
private string? cachedAccessToken;
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
public ConcelierObservationsClient(
HttpClient httpClient,
StellaOpsCliOptions options,
ILogger<ConcelierObservationsClient> logger,
IStellaOpsTokenClient? tokenClient = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.tokenClient = tokenClient;
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl) && httpClient.BaseAddress is null)
{
if (Uri.TryCreate(options.ConcelierUrl, UriKind.Absolute, out var baseUri))
{
httpClient.BaseAddress = baseUri;
}
}
}
public async Task<AdvisoryObservationsResponse> GetObservationsAsync(
AdvisoryObservationsQuery query,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
EnsureConfigured();
var requestUri = BuildRequestUri(query);
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to query observations (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
response.EnsureSuccessStatusCode();
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<AdvisoryObservationsResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new AdvisoryObservationsResponse();
}
private static string BuildRequestUri(AdvisoryObservationsQuery query)
{
var builder = new StringBuilder("/concelier/observations?tenant=");
builder.Append(Uri.EscapeDataString(query.Tenant));
AppendValues(builder, "observationId", query.ObservationIds);
AppendValues(builder, "alias", query.Aliases);
AppendValues(builder, "purl", query.Purls);
AppendValues(builder, "cpe", query.Cpes);
if (query.Limit.HasValue && query.Limit.Value > 0)
{
builder.Append('&');
builder.Append("limit=");
builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(query.Cursor))
{
builder.Append('&');
builder.Append("cursor=");
builder.Append(Uri.EscapeDataString(query.Cursor));
}
return builder.ToString();
static void AppendValues(StringBuilder builder, string name, IReadOnlyList<string> values)
{
if (values is null || values.Count == 0)
{
return;
}
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
builder.Append('&');
builder.Append(name);
builder.Append('=');
builder.Append(Uri.EscapeDataString(value));
}
}
}
private void EnsureConfigured()
{
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl))
{
return;
}
throw new InvalidOperationException(
"ConcelierUrl is not configured. Set StellaOps:ConcelierUrl or STELLAOPS_CONCELIER_URL.");
}
private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var token = await ResolveAccessTokenAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
}
private async Task<string?> ResolveAccessTokenAsync(CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(options.ApiKey))
{
return options.ApiKey;
}
if (tokenClient is null || string.IsNullOrWhiteSpace(options.Authority.Url))
{
return null;
}
var now = DateTimeOffset.UtcNow;
lock (tokenSync)
{
if (!string.IsNullOrEmpty(cachedAccessToken) && now < cachedAccessTokenExpiresAt - TokenRefreshSkew)
{
return cachedAccessToken;
}
}
var (scope, cacheKey) = BuildScopeAndCacheKey(options);
var cachedEntry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cachedEntry is not null && now < cachedEntry.ExpiresAtUtc - TokenRefreshSkew)
{
lock (tokenSync)
{
cachedAccessToken = cachedEntry.AccessToken;
cachedAccessTokenExpiresAt = cachedEntry.ExpiresAtUtc;
return cachedAccessToken;
}
}
StellaOpsTokenResult token;
if (!string.IsNullOrWhiteSpace(options.Authority.Username))
{
if (string.IsNullOrWhiteSpace(options.Authority.Password))
{
throw new InvalidOperationException("Authority password must be configured when username is provided.");
}
token = await tokenClient.RequestPasswordTokenAsync(
options.Authority.Username,
options.Authority.Password!,
scope,
null,
cancellationToken).ConfigureAwait(false);
}
else
{
token = await tokenClient.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
}
await tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false);
lock (tokenSync)
{
cachedAccessToken = token.AccessToken;
cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
return cachedAccessToken;
}
}
private static (string Scope, string CacheKey) BuildScopeAndCacheKey(StellaOpsCliOptions options)
{
var baseScope = AuthorityTokenUtilities.ResolveScope(options);
var finalScope = EnsureScope(baseScope, StellaOpsScopes.VulnRead);
var credential = !string.IsNullOrWhiteSpace(options.Authority.Username)
? $"user:{options.Authority.Username}"
: $"client:{options.Authority.ClientId}";
var cacheKey = $"{options.Authority.Url}|{credential}|{finalScope}";
return (finalScope, cacheKey);
}
private static string EnsureScope(string scopes, string required)
{
if (string.IsNullOrWhiteSpace(scopes))
{
return required;
}
var parts = scopes
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(static scope => scope.ToLowerInvariant())
.Distinct(StringComparer.Ordinal)
.ToList();
if (!parts.Contains(required, StringComparer.Ordinal))
{
parts.Add(required);
}
return string.Join(' ', parts);
}
}

View File

@@ -0,0 +1,10 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
internal interface IAuthorityRevocationClient
{
Task<AuthorityRevocationExportResult> ExportAsync(bool verbose, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,45 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
internal interface IBackendOperationsClient
{
Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken);
Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken);
Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken);
Task<ExcititorOperationResult> ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken);
Task<ExcititorExportDownloadResult> DownloadExcititorExportAsync(string exportId, string destinationPath, string? expectedDigestAlgorithm, string? expectedDigest, CancellationToken cancellationToken);
Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken);
Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken);
Task<PolicySimulationResult> SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken);
Task<PolicyActivationResult> ActivatePolicyRevisionAsync(string policyId, int version, PolicyActivationRequest request, CancellationToken cancellationToken);
Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken);
Task<OfflineKitImportResult> ImportOfflineKitAsync(OfflineKitImportRequest request, CancellationToken cancellationToken);
Task<OfflineKitStatus> GetOfflineKitStatusAsync(CancellationToken cancellationToken);
Task<AocIngestDryRunResponse> ExecuteAocIngestDryRunAsync(AocIngestDryRunRequest request, CancellationToken cancellationToken);
Task<AocVerifyResponse> ExecuteAocVerifyAsync(AocVerifyRequest request, CancellationToken cancellationToken);
Task<PolicyFindingsPage> GetPolicyFindingsAsync(PolicyFindingsQuery query, CancellationToken cancellationToken);
Task<PolicyFindingDocument> GetPolicyFindingAsync(string policyId, string findingId, CancellationToken cancellationToken);
Task<PolicyFindingExplainResult> GetPolicyFindingExplainAsync(string policyId, string findingId, string? mode, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
internal interface IConcelierObservationsClient
{
Task<AdvisoryObservationsResponse> GetObservationsAsync(
AdvisoryObservationsQuery query,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Services;
internal interface IScannerExecutor
{
Task<ScannerExecutionResult> RunAsync(
string runner,
string entry,
string targetDirectory,
string resultsDirectory,
IReadOnlyList<string> arguments,
bool verbose,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Services;
internal interface IScannerInstaller
{
Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
internal sealed record AdvisoryObservationsQuery(
string Tenant,
IReadOnlyList<string> ObservationIds,
IReadOnlyList<string> Aliases,
IReadOnlyList<string> Purls,
IReadOnlyList<string> Cpes,
int? Limit,
string? Cursor);
internal sealed class AdvisoryObservationsResponse
{
[JsonPropertyName("observations")]
public IReadOnlyList<AdvisoryObservationDocument> Observations { get; init; } =
Array.Empty<AdvisoryObservationDocument>();
[JsonPropertyName("linkset")]
public AdvisoryObservationLinksetAggregate Linkset { get; init; } =
new();
[JsonPropertyName("nextCursor")]
public string? NextCursor { get; init; }
[JsonPropertyName("hasMore")]
public bool HasMore { get; init; }
}
internal sealed class AdvisoryObservationDocument
{
[JsonPropertyName("observationId")]
public string ObservationId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("source")]
public AdvisoryObservationSource Source { get; init; } = new();
[JsonPropertyName("upstream")]
public AdvisoryObservationUpstream Upstream { get; init; } = new();
[JsonPropertyName("linkset")]
public AdvisoryObservationLinkset Linkset { get; init; } = new();
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
}
internal sealed class AdvisoryObservationSource
{
[JsonPropertyName("vendor")]
public string Vendor { get; init; } = string.Empty;
[JsonPropertyName("stream")]
public string Stream { get; init; } = string.Empty;
[JsonPropertyName("api")]
public string Api { get; init; } = string.Empty;
[JsonPropertyName("collectorVersion")]
public string? CollectorVersion { get; init; }
}
internal sealed class AdvisoryObservationUpstream
{
[JsonPropertyName("upstreamId")]
public string UpstreamId { get; init; } = string.Empty;
[JsonPropertyName("documentVersion")]
public string? DocumentVersion { get; init; }
}
internal sealed class AdvisoryObservationLinkset
{
[JsonPropertyName("aliases")]
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
[JsonPropertyName("purls")]
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
[JsonPropertyName("cpes")]
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
[JsonPropertyName("references")]
public IReadOnlyList<AdvisoryObservationReference> References { get; init; } =
Array.Empty<AdvisoryObservationReference>();
}
internal sealed class AdvisoryObservationReference
{
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
[JsonPropertyName("url")]
public string Url { get; init; } = string.Empty;
}
internal sealed class AdvisoryObservationLinksetAggregate
{
[JsonPropertyName("aliases")]
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
[JsonPropertyName("purls")]
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
[JsonPropertyName("cpes")]
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
[JsonPropertyName("references")]
public IReadOnlyList<AdvisoryObservationReference> References { get; init; } =
Array.Empty<AdvisoryObservationReference>();
}

View File

@@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
internal sealed class AocIngestDryRunRequest
{
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("source")]
public string Source { get; init; } = string.Empty;
[JsonPropertyName("document")]
public AocIngestDryRunDocument Document { get; init; } = new();
}
internal sealed class AocIngestDryRunDocument
{
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("content")]
public string Content { get; init; } = string.Empty;
[JsonPropertyName("contentType")]
public string ContentType { get; init; } = "application/json";
[JsonPropertyName("contentEncoding")]
public string? ContentEncoding { get; init; }
}
internal sealed class AocIngestDryRunResponse
{
[JsonPropertyName("source")]
public string? Source { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("guardVersion")]
public string? GuardVersion { get; init; }
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("document")]
public AocIngestDryRunDocumentResult Document { get; init; } = new();
[JsonPropertyName("violations")]
public IReadOnlyList<AocIngestDryRunViolation> Violations { get; init; } =
Array.Empty<AocIngestDryRunViolation>();
}
internal sealed class AocIngestDryRunDocumentResult
{
[JsonPropertyName("contentHash")]
public string? ContentHash { get; init; }
[JsonPropertyName("supersedes")]
public string? Supersedes { get; init; }
[JsonPropertyName("provenance")]
public AocIngestDryRunProvenance Provenance { get; init; } = new();
}
internal sealed class AocIngestDryRunProvenance
{
[JsonPropertyName("signature")]
public AocIngestDryRunSignature Signature { get; init; } = new();
}
internal sealed class AocIngestDryRunSignature
{
[JsonPropertyName("format")]
public string? Format { get; init; }
[JsonPropertyName("present")]
public bool Present { get; init; }
}
internal sealed class AocIngestDryRunViolation
{
[JsonPropertyName("code")]
public string Code { get; init; } = string.Empty;
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("path")]
public string? Path { get; init; }
}

View File

@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
internal sealed class AocVerifyRequest
{
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("since")]
public string? Since { get; init; }
[JsonPropertyName("limit")]
public int? Limit { get; init; }
[JsonPropertyName("sources")]
public IReadOnlyList<string>? Sources { get; init; }
[JsonPropertyName("codes")]
public IReadOnlyList<string>? Codes { get; init; }
}
internal sealed class AocVerifyResponse
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("window")]
public AocVerifyWindow Window { get; init; } = new();
[JsonPropertyName("checked")]
public AocVerifyChecked Checked { get; init; } = new();
[JsonPropertyName("violations")]
public IReadOnlyList<AocVerifyViolation> Violations { get; init; } =
Array.Empty<AocVerifyViolation>();
[JsonPropertyName("metrics")]
public AocVerifyMetrics Metrics { get; init; } = new();
[JsonPropertyName("truncated")]
public bool? Truncated { get; init; }
}
internal sealed class AocVerifyWindow
{
[JsonPropertyName("from")]
public DateTimeOffset? From { get; init; }
[JsonPropertyName("to")]
public DateTimeOffset? To { get; init; }
}
internal sealed class AocVerifyChecked
{
[JsonPropertyName("advisories")]
public int Advisories { get; init; }
[JsonPropertyName("vex")]
public int Vex { get; init; }
}
internal sealed class AocVerifyViolation
{
[JsonPropertyName("code")]
public string Code { get; init; } = string.Empty;
[JsonPropertyName("count")]
public int Count { get; init; }
[JsonPropertyName("examples")]
public IReadOnlyList<AocVerifyViolationExample> Examples { get; init; } =
Array.Empty<AocVerifyViolationExample>();
}
internal sealed class AocVerifyViolationExample
{
[JsonPropertyName("source")]
public string? Source { get; init; }
[JsonPropertyName("documentId")]
public string? DocumentId { get; init; }
[JsonPropertyName("contentHash")]
public string? ContentHash { get; init; }
[JsonPropertyName("path")]
public string? Path { get; init; }
}
internal sealed class AocVerifyMetrics
{
[JsonPropertyName("ingestion_write_total")]
public int? IngestionWriteTotal { get; init; }
[JsonPropertyName("aoc_violation_total")]
public int? AocViolationTotal { get; init; }
}

View File

@@ -0,0 +1,20 @@
using System;
namespace StellaOps.Cli.Services.Models;
internal sealed class AuthorityRevocationExportResult
{
public required byte[] BundleBytes { get; init; }
public required string Signature { get; init; }
public required string Digest { get; init; }
public required long Sequence { get; init; }
public required DateTimeOffset IssuedAt { get; init; }
public string? SigningKeyId { get; init; }
public string? SigningProvider { get; init; }
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Cli.Services.Models;
internal sealed record ExcititorExportDownloadResult(
string Path,
long SizeBytes,
bool FromCache);

View File

@@ -0,0 +1,9 @@
using System.Text.Json;
namespace StellaOps.Cli.Services.Models;
internal sealed record ExcititorOperationResult(
bool Success,
string Message,
string? Location,
JsonElement? Payload);

View File

@@ -0,0 +1,11 @@
using System;
namespace StellaOps.Cli.Services.Models;
internal sealed record ExcititorProviderSummary(
string Id,
string Kind,
string DisplayName,
string TrustTier,
bool Enabled,
DateTimeOffset? LastIngestedAt);

View File

@@ -0,0 +1,9 @@
using StellaOps.Cli.Services.Models.Transport;
namespace StellaOps.Cli.Services.Models;
internal sealed record JobTriggerResult(
bool Success,
string Message,
string? Location,
JobRunResponse? Run);

View File

@@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models;
internal sealed record OfflineKitBundleDescriptor(
string BundleId,
string BundleName,
string BundleSha256,
long BundleSize,
Uri BundleDownloadUri,
string ManifestName,
string ManifestSha256,
Uri ManifestDownloadUri,
DateTimeOffset CapturedAt,
string? Channel,
string? Kind,
bool IsDelta,
string? BaseBundleId,
string? BundleSignatureName,
Uri? BundleSignatureDownloadUri,
string? ManifestSignatureName,
Uri? ManifestSignatureDownloadUri,
long? ManifestSize);
internal sealed record OfflineKitDownloadResult(
OfflineKitBundleDescriptor Descriptor,
string BundlePath,
string ManifestPath,
string? BundleSignaturePath,
string? ManifestSignaturePath,
string MetadataPath,
bool FromCache);
internal sealed record OfflineKitImportRequest(
string BundlePath,
string? ManifestPath,
string? BundleSignaturePath,
string? ManifestSignaturePath,
string? BundleId,
string? BundleSha256,
long? BundleSize,
DateTimeOffset? CapturedAt,
string? Channel,
string? Kind,
bool? IsDelta,
string? BaseBundleId,
string? ManifestSha256,
long? ManifestSize);
internal sealed record OfflineKitImportResult(
string? ImportId,
string? Status,
DateTimeOffset SubmittedAt,
string? Message);
internal sealed record OfflineKitStatus(
string? BundleId,
string? Channel,
string? Kind,
bool IsDelta,
string? BaseBundleId,
DateTimeOffset? CapturedAt,
DateTimeOffset? ImportedAt,
string? BundleSha256,
long? BundleSize,
IReadOnlyList<OfflineKitComponentStatus> Components);
internal sealed record OfflineKitComponentStatus(
string Name,
string? Version,
string? Digest,
DateTimeOffset? CapturedAt,
long? SizeBytes);
internal sealed record OfflineKitMetadataDocument
{
public string? BundleId { get; init; }
public string BundleName { get; init; } = string.Empty;
public string BundleSha256 { get; init; } = string.Empty;
public long BundleSize { get; init; }
public string BundlePath { get; init; } = string.Empty;
public DateTimeOffset CapturedAt { get; init; }
public DateTimeOffset DownloadedAt { get; init; }
public string? Channel { get; init; }
public string? Kind { get; init; }
public bool IsDelta { get; init; }
public string? BaseBundleId { get; init; }
public string ManifestName { get; init; } = string.Empty;
public string ManifestSha256 { get; init; } = string.Empty;
public long? ManifestSize { get; init; }
public string ManifestPath { get; init; } = string.Empty;
public string? BundleSignaturePath { get; init; }
public string? ManifestSignaturePath { get; init; }
}

View File

@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models;
internal sealed record PolicyActivationRequest(
bool RunNow,
DateTimeOffset? ScheduledAt,
string? Priority,
bool Rollback,
string? IncidentId,
string? Comment);
internal sealed record PolicyActivationResult(
string Status,
PolicyActivationRevision Revision);
internal sealed record PolicyActivationRevision(
string PolicyId,
int Version,
string Status,
bool RequiresTwoPersonApproval,
DateTimeOffset CreatedAt,
DateTimeOffset? ActivatedAt,
IReadOnlyList<PolicyActivationApproval> Approvals);
internal sealed record PolicyActivationApproval(
string ActorId,
DateTimeOffset ApprovedAt,
string? Comment);

View File

@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models;
internal sealed record PolicyFindingsQuery(
string PolicyId,
IReadOnlyList<string> SbomIds,
IReadOnlyList<string> Statuses,
IReadOnlyList<string> Severities,
string? Cursor,
int? Page,
int? PageSize,
DateTimeOffset? Since);
internal sealed record PolicyFindingsPage(
IReadOnlyList<PolicyFindingDocument> Items,
string? NextCursor,
int? TotalCount);
internal sealed record PolicyFindingDocument(
string FindingId,
string Status,
PolicyFindingSeverity Severity,
string SbomId,
IReadOnlyList<string> AdvisoryIds,
PolicyFindingVexMetadata? Vex,
int PolicyVersion,
DateTimeOffset UpdatedAt,
string? RunId);
internal sealed record PolicyFindingSeverity(string Normalized, double? Score);
internal sealed record PolicyFindingVexMetadata(string? WinningStatementId, string? Source, string? Status);
internal sealed record PolicyFindingExplainResult(
string FindingId,
int PolicyVersion,
IReadOnlyList<PolicyFindingExplainStep> Steps,
IReadOnlyList<PolicyFindingExplainHint> SealedHints);
internal sealed record PolicyFindingExplainStep(
string Rule,
string? Status,
string? Action,
double? Score,
IReadOnlyDictionary<string, string> Inputs,
IReadOnlyDictionary<string, string>? Evidence);
internal sealed record PolicyFindingExplainHint(string Message);

View File

@@ -0,0 +1,26 @@
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models;
internal sealed record PolicySimulationInput(
int? BaseVersion,
int? CandidateVersion,
IReadOnlyList<string> SbomSet,
IReadOnlyDictionary<string, object?> Environment,
bool Explain);
internal sealed record PolicySimulationResult(
PolicySimulationDiff Diff,
string? ExplainUri);
internal sealed record PolicySimulationDiff(
string? SchemaVersion,
int Added,
int Removed,
int Unchanged,
IReadOnlyDictionary<string, PolicySimulationSeverityDelta> BySeverity,
IReadOnlyList<PolicySimulationRuleDelta> RuleHits);
internal sealed record PolicySimulationSeverityDelta(int? Up, int? Down);
internal sealed record PolicySimulationRuleDelta(string RuleId, string RuleName, int? Up, int? Down);

View File

@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models;
internal sealed record RuntimePolicyEvaluationRequest(
string? Namespace,
IReadOnlyDictionary<string, string> Labels,
IReadOnlyList<string> Images);
internal sealed record RuntimePolicyEvaluationResult(
int TtlSeconds,
DateTimeOffset? ExpiresAtUtc,
string? PolicyRevision,
IReadOnlyDictionary<string, RuntimePolicyImageDecision> Decisions);
internal sealed record RuntimePolicyImageDecision(
string PolicyVerdict,
bool? Signed,
bool? HasSbomReferrers,
IReadOnlyList<string> Reasons,
RuntimePolicyRekorReference? Rekor,
IReadOnlyDictionary<string, object?> AdditionalProperties);
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);

View File

@@ -0,0 +1,3 @@
namespace StellaOps.Cli.Services.Models;
internal sealed record ScannerArtifactResult(string Path, long SizeBytes, bool FromCache);

View File

@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models.Transport;
internal sealed class JobRunResponse
{
public Guid RunId { get; set; }
public string Kind { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string Trigger { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? StartedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public string? Error { get; set; }
public TimeSpan? Duration { get; set; }
public IReadOnlyDictionary<string, object?> Parameters { get; set; } = new Dictionary<string, object?>(StringComparer.Ordinal);
}

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models.Transport;
internal sealed class JobTriggerRequest
{
public string Trigger { get; set; } = "cli";
public Dictionary<string, object?> Parameters { get; set; } = new(StringComparer.Ordinal);
}

View File

@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models.Transport;
internal sealed class OfflineKitBundleDescriptorTransport
{
public string? BundleId { get; set; }
public string? BundleName { get; set; }
public string? BundleSha256 { get; set; }
public long BundleSize { get; set; }
public string? BundleUrl { get; set; }
public string? BundlePath { get; set; }
public string? BundleSignatureName { get; set; }
public string? BundleSignatureUrl { get; set; }
public string? BundleSignaturePath { get; set; }
public string? ManifestName { get; set; }
public string? ManifestSha256 { get; set; }
public long? ManifestSize { get; set; }
public string? ManifestUrl { get; set; }
public string? ManifestPath { get; set; }
public string? ManifestSignatureName { get; set; }
public string? ManifestSignatureUrl { get; set; }
public string? ManifestSignaturePath { get; set; }
public DateTimeOffset? CapturedAt { get; set; }
public string? Channel { get; set; }
public string? Kind { get; set; }
public bool? IsDelta { get; set; }
public string? BaseBundleId { get; set; }
}
internal sealed class OfflineKitStatusBundleTransport
{
public string? BundleId { get; set; }
public string? Channel { get; set; }
public string? Kind { get; set; }
public bool? IsDelta { get; set; }
public string? BaseBundleId { get; set; }
public string? BundleSha256 { get; set; }
public long? BundleSize { get; set; }
public DateTimeOffset? CapturedAt { get; set; }
public DateTimeOffset? ImportedAt { get; set; }
}
internal sealed class OfflineKitStatusTransport
{
public OfflineKitStatusBundleTransport? Current { get; set; }
public List<OfflineKitComponentStatusTransport>? Components { get; set; }
}
internal sealed class OfflineKitComponentStatusTransport
{
public string? Name { get; set; }
public string? Version { get; set; }
public string? Digest { get; set; }
public DateTimeOffset? CapturedAt { get; set; }
public long? SizeBytes { get; set; }
}
internal sealed class OfflineKitImportResponseTransport
{
public string? ImportId { get; set; }
public string? Status { get; set; }
public DateTimeOffset? SubmittedAt { get; set; }
public string? Message { get; set; }
}

View File

@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models.Transport;
internal sealed class PolicyActivationRequestDocument
{
public string? Comment { get; set; }
public bool? RunNow { get; set; }
public DateTimeOffset? ScheduledAt { get; set; }
public string? Priority { get; set; }
public bool? Rollback { get; set; }
public string? IncidentId { get; set; }
}
internal sealed class PolicyActivationResponseDocument
{
public string? Status { get; set; }
public PolicyActivationRevisionDocument? Revision { get; set; }
}
internal sealed class PolicyActivationRevisionDocument
{
public string? PackId { get; set; }
public int? Version { get; set; }
public string? Status { get; set; }
public bool? RequiresTwoPersonApproval { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
public DateTimeOffset? ActivatedAt { get; set; }
public List<PolicyActivationApprovalDocument>? Approvals { get; set; }
}
internal sealed class PolicyActivationApprovalDocument
{
public string? ActorId { get; set; }
public DateTimeOffset? ApprovedAt { get; set; }
public string? Comment { get; set; }
}

View File

@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
namespace StellaOps.Cli.Services.Models.Transport;
internal sealed class PolicyFindingsResponseDocument
{
public List<PolicyFindingDocumentDocument>? Items { get; set; }
public string? NextCursor { get; set; }
public int? TotalCount { get; set; }
}
internal sealed class PolicyFindingDocumentDocument
{
public string? FindingId { get; set; }
public string? Status { get; set; }
public PolicyFindingSeverityDocument? Severity { get; set; }
public string? SbomId { get; set; }
public List<string>? AdvisoryIds { get; set; }
public PolicyFindingVexDocument? Vex { get; set; }
public int? PolicyVersion { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
public string? RunId { get; set; }
}
internal sealed class PolicyFindingSeverityDocument
{
public string? Normalized { get; set; }
public double? Score { get; set; }
}
internal sealed class PolicyFindingVexDocument
{
public string? WinningStatementId { get; set; }
public string? Source { get; set; }
public string? Status { get; set; }
}
internal sealed class PolicyFindingExplainResponseDocument
{
public string? FindingId { get; set; }
public int? PolicyVersion { get; set; }
public List<PolicyFindingExplainStepDocument>? Steps { get; set; }
public List<PolicyFindingExplainHintDocument>? SealedHints { get; set; }
}
internal sealed class PolicyFindingExplainStepDocument
{
public string? Rule { get; set; }
public string? Status { get; set; }
public string? Action { get; set; }
public double? Score { get; set; }
public Dictionary<string, JsonElement>? Inputs { get; set; }
public Dictionary<string, JsonElement>? Evidence { get; set; }
}
internal sealed class PolicyFindingExplainHintDocument
{
public string? Message { get; set; }
}

View File

@@ -0,0 +1,57 @@
using System.Collections.Generic;
using System.Text.Json;
namespace StellaOps.Cli.Services.Models.Transport;
internal sealed class PolicySimulationRequestDocument
{
public int? BaseVersion { get; set; }
public int? CandidateVersion { get; set; }
public IReadOnlyList<string>? SbomSet { get; set; }
public Dictionary<string, JsonElement>? Env { get; set; }
public bool? Explain { get; set; }
}
internal sealed class PolicySimulationResponseDocument
{
public PolicySimulationDiffDocument? Diff { get; set; }
public string? ExplainUri { get; set; }
}
internal sealed class PolicySimulationDiffDocument
{
public string? SchemaVersion { get; set; }
public int? Added { get; set; }
public int? Removed { get; set; }
public int? Unchanged { get; set; }
public Dictionary<string, PolicySimulationSeverityDeltaDocument>? BySeverity { get; set; }
public List<PolicySimulationRuleDeltaDocument>? RuleHits { get; set; }
}
internal sealed class PolicySimulationSeverityDeltaDocument
{
public int? Up { get; set; }
public int? Down { get; set; }
}
internal sealed class PolicySimulationRuleDeltaDocument
{
public string? RuleId { get; set; }
public string? RuleName { get; set; }
public int? Up { get; set; }
public int? Down { get; set; }
}

View File

@@ -0,0 +1,18 @@
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models.Transport;
internal sealed class ProblemDocument
{
public string? Type { get; set; }
public string? Title { get; set; }
public string? Detail { get; set; }
public int? Status { get; set; }
public string? Instance { get; set; }
public Dictionary<string, object?>? Extensions { get; set; }
}

View File

@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models.Transport;
internal sealed class RuntimePolicyEvaluationRequestDocument
{
[JsonPropertyName("namespace")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Namespace { get; set; }
[JsonPropertyName("labels")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, string>? Labels { get; set; }
[JsonPropertyName("images")]
public List<string> Images { get; set; } = new();
}
internal sealed class RuntimePolicyEvaluationResponseDocument
{
[JsonPropertyName("ttlSeconds")]
public int? TtlSeconds { get; set; }
[JsonPropertyName("expiresAtUtc")]
public DateTimeOffset? ExpiresAtUtc { get; set; }
[JsonPropertyName("policyRevision")]
public string? PolicyRevision { get; set; }
[JsonPropertyName("results")]
public Dictionary<string, RuntimePolicyEvaluationImageDocument>? Results { get; set; }
}
internal sealed class RuntimePolicyEvaluationImageDocument
{
[JsonPropertyName("policyVerdict")]
public string? PolicyVerdict { get; set; }
[JsonPropertyName("signed")]
public bool? Signed { get; set; }
[JsonPropertyName("hasSbomReferrers")]
public bool? HasSbomReferrers { get; set; }
// Legacy field kept for pre-contract-sync services.
[JsonPropertyName("hasSbom")]
public bool? HasSbomLegacy { get; set; }
[JsonPropertyName("reasons")]
public List<string>? Reasons { get; set; }
[JsonPropertyName("rekor")]
public RuntimePolicyRekorDocument? Rekor { get; set; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? ExtensionData { get; set; }
}
internal sealed class RuntimePolicyRekorDocument
{
[JsonPropertyName("uuid")]
public string? Uuid { get; set; }
[JsonPropertyName("url")]
public string? Url { get; set; }
[JsonPropertyName("verified")]
public bool? Verified { get; set; }
}

View File

@@ -0,0 +1,18 @@
using System;
using System.Net;
namespace StellaOps.Cli.Services;
internal sealed class PolicyApiException : Exception
{
public PolicyApiException(string message, HttpStatusCode statusCode, string? errorCode, Exception? innerException = null)
: base(message, innerException)
{
StatusCode = statusCode;
ErrorCode = errorCode;
}
public HttpStatusCode StatusCode { get; }
public string? ErrorCode { get; }
}

View File

@@ -0,0 +1,3 @@
namespace StellaOps.Cli.Services;
internal sealed record ScannerExecutionResult(int ExitCode, string ResultsPath, string RunMetadataPath);

View File

@@ -0,0 +1,329 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using System.Text.Json;
namespace StellaOps.Cli.Services;
internal sealed class ScannerExecutor : IScannerExecutor
{
private readonly ILogger<ScannerExecutor> _logger;
public ScannerExecutor(ILogger<ScannerExecutor> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ScannerExecutionResult> RunAsync(
string runner,
string entry,
string targetDirectory,
string resultsDirectory,
IReadOnlyList<string> arguments,
bool verbose,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(targetDirectory))
{
throw new ArgumentException("Target directory must be provided.", nameof(targetDirectory));
}
runner = string.IsNullOrWhiteSpace(runner) ? "docker" : runner.Trim().ToLowerInvariant();
entry = entry?.Trim() ?? string.Empty;
var normalizedTarget = Path.GetFullPath(targetDirectory);
if (!Directory.Exists(normalizedTarget))
{
throw new DirectoryNotFoundException($"Scan target directory '{normalizedTarget}' does not exist.");
}
resultsDirectory = string.IsNullOrWhiteSpace(resultsDirectory)
? Path.Combine(Directory.GetCurrentDirectory(), "scan-results")
: Path.GetFullPath(resultsDirectory);
Directory.CreateDirectory(resultsDirectory);
var executionTimestamp = DateTimeOffset.UtcNow;
var baselineFiles = Directory.GetFiles(resultsDirectory, "*", SearchOption.AllDirectories);
var baseline = new HashSet<string>(baselineFiles, StringComparer.OrdinalIgnoreCase);
var startInfo = BuildProcessStartInfo(runner, entry, normalizedTarget, resultsDirectory, arguments);
using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
var stdout = new List<string>();
var stderr = new List<string>();
process.OutputDataReceived += (_, args) =>
{
if (args.Data is null)
{
return;
}
stdout.Add(args.Data);
if (verbose)
{
_logger.LogInformation("[scan] {Line}", args.Data);
}
};
process.ErrorDataReceived += (_, args) =>
{
if (args.Data is null)
{
return;
}
stderr.Add(args.Data);
_logger.LogError("[scan] {Line}", args.Data);
};
_logger.LogInformation("Launching scanner via {Runner} (entry: {Entry})...", runner, entry);
if (!process.Start())
{
throw new InvalidOperationException("Failed to start scanner process.");
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
var completionTimestamp = DateTimeOffset.UtcNow;
if (process.ExitCode == 0)
{
_logger.LogInformation("Scanner completed successfully.");
}
else
{
_logger.LogWarning("Scanner exited with code {Code}.", process.ExitCode);
}
var resultsPath = ResolveResultsPath(resultsDirectory, executionTimestamp, baseline);
if (string.IsNullOrWhiteSpace(resultsPath))
{
resultsPath = CreatePlaceholderResult(resultsDirectory);
}
var metadataPath = WriteRunMetadata(
resultsDirectory,
executionTimestamp,
completionTimestamp,
runner,
entry,
normalizedTarget,
resultsPath,
arguments,
process.ExitCode,
stdout,
stderr);
return new ScannerExecutionResult(process.ExitCode, resultsPath, metadataPath);
}
private ProcessStartInfo BuildProcessStartInfo(
string runner,
string entry,
string targetDirectory,
string resultsDirectory,
IReadOnlyList<string> args)
{
return runner switch
{
"self" or "native" => BuildNativeStartInfo(entry, args),
"dotnet" => BuildDotNetStartInfo(entry, args),
"docker" => BuildDockerStartInfo(entry, targetDirectory, resultsDirectory, args),
_ => BuildCustomRunnerStartInfo(runner, entry, args)
};
}
private static ProcessStartInfo BuildNativeStartInfo(string binaryPath, IReadOnlyList<string> args)
{
if (string.IsNullOrWhiteSpace(binaryPath) || !File.Exists(binaryPath))
{
throw new FileNotFoundException("Scanner entrypoint not found.", binaryPath);
}
var startInfo = new ProcessStartInfo
{
FileName = binaryPath,
WorkingDirectory = Directory.GetCurrentDirectory()
};
foreach (var argument in args)
{
startInfo.ArgumentList.Add(argument);
}
startInfo.RedirectStandardError = true;
startInfo.RedirectStandardOutput = true;
startInfo.UseShellExecute = false;
return startInfo;
}
private static ProcessStartInfo BuildDotNetStartInfo(string binaryPath, IReadOnlyList<string> args)
{
var startInfo = new ProcessStartInfo
{
FileName = "dotnet",
WorkingDirectory = Directory.GetCurrentDirectory()
};
startInfo.ArgumentList.Add(binaryPath);
foreach (var argument in args)
{
startInfo.ArgumentList.Add(argument);
}
startInfo.RedirectStandardError = true;
startInfo.RedirectStandardOutput = true;
startInfo.UseShellExecute = false;
return startInfo;
}
private static ProcessStartInfo BuildDockerStartInfo(string image, string targetDirectory, string resultsDirectory, IReadOnlyList<string> args)
{
if (string.IsNullOrWhiteSpace(image))
{
throw new ArgumentException("Docker image must be provided when runner is 'docker'.", nameof(image));
}
var cwd = Directory.GetCurrentDirectory();
var startInfo = new ProcessStartInfo
{
FileName = "docker",
WorkingDirectory = cwd
};
startInfo.ArgumentList.Add("run");
startInfo.ArgumentList.Add("--rm");
startInfo.ArgumentList.Add("-v");
startInfo.ArgumentList.Add($"{cwd}:{cwd}");
startInfo.ArgumentList.Add("-v");
startInfo.ArgumentList.Add($"{targetDirectory}:/scan-target:ro");
startInfo.ArgumentList.Add("-v");
startInfo.ArgumentList.Add($"{resultsDirectory}:/scan-results");
startInfo.ArgumentList.Add("-w");
startInfo.ArgumentList.Add(cwd);
startInfo.ArgumentList.Add(image);
startInfo.ArgumentList.Add("--target");
startInfo.ArgumentList.Add("/scan-target");
startInfo.ArgumentList.Add("--output");
startInfo.ArgumentList.Add("/scan-results/scan.json");
foreach (var argument in args)
{
startInfo.ArgumentList.Add(argument);
}
startInfo.RedirectStandardError = true;
startInfo.RedirectStandardOutput = true;
startInfo.UseShellExecute = false;
return startInfo;
}
private static ProcessStartInfo BuildCustomRunnerStartInfo(string runner, string entry, IReadOnlyList<string> args)
{
var startInfo = new ProcessStartInfo
{
FileName = runner,
WorkingDirectory = Directory.GetCurrentDirectory()
};
if (!string.IsNullOrWhiteSpace(entry))
{
startInfo.ArgumentList.Add(entry);
}
foreach (var argument in args)
{
startInfo.ArgumentList.Add(argument);
}
startInfo.RedirectStandardError = true;
startInfo.RedirectStandardOutput = true;
startInfo.UseShellExecute = false;
return startInfo;
}
private static string ResolveResultsPath(string resultsDirectory, DateTimeOffset startTimestamp, HashSet<string> baseline)
{
var candidates = Directory.GetFiles(resultsDirectory, "*", SearchOption.AllDirectories);
string? newest = null;
DateTimeOffset newestTimestamp = startTimestamp;
foreach (var candidate in candidates)
{
if (baseline.Contains(candidate))
{
continue;
}
var info = new FileInfo(candidate);
if (info.LastWriteTimeUtc >= newestTimestamp)
{
newestTimestamp = info.LastWriteTimeUtc;
newest = candidate;
}
}
return newest ?? string.Empty;
}
private static string CreatePlaceholderResult(string resultsDirectory)
{
var fileName = $"scan-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json";
var path = Path.Combine(resultsDirectory, fileName);
File.WriteAllText(path, "{\"status\":\"placeholder\"}");
return path;
}
private static string WriteRunMetadata(
string resultsDirectory,
DateTimeOffset startedAt,
DateTimeOffset completedAt,
string runner,
string entry,
string targetDirectory,
string resultsPath,
IReadOnlyList<string> arguments,
int exitCode,
IReadOnlyList<string> stdout,
IReadOnlyList<string> stderr)
{
var duration = completedAt - startedAt;
var payload = new
{
runner,
entry,
targetDirectory,
resultsPath,
arguments,
exitCode,
startedAt = startedAt,
completedAt = completedAt,
durationSeconds = Math.Round(duration.TotalSeconds, 3, MidpointRounding.AwayFromZero),
stdout,
stderr
};
var fileName = $"scan-run-{startedAt:yyyyMMddHHmmssfff}.json";
var path = Path.Combine(resultsDirectory, fileName);
var options = new JsonSerializerOptions
{
WriteIndented = true
};
var json = JsonSerializer.Serialize(payload, options);
File.WriteAllText(path, json);
return path;
}
}

View File

@@ -0,0 +1,79 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Cli.Services;
internal sealed class ScannerInstaller : IScannerInstaller
{
private readonly ILogger<ScannerInstaller> _logger;
public ScannerInstaller(ILogger<ScannerInstaller> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(artifactPath) || !File.Exists(artifactPath))
{
throw new FileNotFoundException("Scanner artifact not found for installation.", artifactPath);
}
// Current implementation assumes docker-based scanner bundle.
var processInfo = new ProcessStartInfo
{
FileName = "docker",
ArgumentList = { "load", "-i", artifactPath },
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
using var process = new Process { StartInfo = processInfo, EnableRaisingEvents = true };
process.OutputDataReceived += (_, args) =>
{
if (args.Data is null)
{
return;
}
if (verbose)
{
_logger.LogInformation("[install] {Line}", args.Data);
}
};
process.ErrorDataReceived += (_, args) =>
{
if (args.Data is null)
{
return;
}
_logger.LogError("[install] {Line}", args.Data);
};
_logger.LogInformation("Installing scanner container from {Path}...", artifactPath);
if (!process.Start())
{
throw new InvalidOperationException("Failed to start container installation process.");
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"Container installation failed with exit code {process.ExitCode}.");
}
_logger.LogInformation("Scanner container installed successfully.");
}
}

View File

@@ -0,0 +1,47 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="NetEscapades.Configuration.Yaml" Version="2.1.0" />
<PackageReference Include="Spectre.Console" Version="0.48.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25306.1" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.local.json" Condition="Exists('appsettings.local.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.yaml" Condition="Exists('appsettings.yaml')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.local.yaml" Condition="Exists('appsettings.local.yaml')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,204 @@
# CLI Task Board — Epic 1: Aggregation-Only Contract
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-AOC-19-001 | DONE (2025-10-27) | DevEx/CLI Guild | CONCELIER-WEB-AOC-19-001, EXCITITOR-WEB-AOC-19-001 | Implement `stella sources ingest --dry-run` printing would-write payloads with forbidden field scan results and guard status. | Command displays diff-safe JSON, highlights forbidden fields, exits non-zero on guard violation, and has unit tests. |
> Docs ready (2025-10-26): Reference behaviour/spec in `docs/cli/cli-reference.md` §2 and AOC reference §5.
> 2025-10-27: CLI command scaffolded with backend client call, JSON/table output, gzip/base64 normalisation, and exit-code mapping. Awaiting Concelier dry-run endpoint + integration tests once backend lands.
> 2025-10-27: Progress paused before adding CLI unit tests; blocked on extending `StubBackendClient` + fixtures for `ExecuteAocIngestDryRunAsync` coverage.
> 2025-10-27: Added stubbed ingest responses + unit tests covering success/violation paths, output writing, and exit-code mapping.
| CLI-AOC-19-002 | DONE (2025-10-27) | DevEx/CLI Guild | CLI-AOC-19-001 | Add `stella aoc verify` command supporting `--since`/`--limit`, mapping `ERR_AOC_00x` to exit codes, with JSON/table output. | Command integrates with both services, exit codes documented, regression tests green. |
> Docs ready (2025-10-26): CLI guide §3 covers options/exit codes; deployment doc `docs/deploy/containers.md` describes required verifier user.
> 2025-10-27: CLI wiring in progress; backend client/command surface being added with table/JSON output.
> 2025-10-27: Added JSON/table Spectre output, integration tests for exit-code handling, CLI metrics, and updated quickstart/architecture docs to cover guard workflows.
| CLI-AOC-19-003 | DONE (2025-10-27) | Docs/CLI Guild | CLI-AOC-19-001, CLI-AOC-19-002 | Update CLI reference and quickstart docs to cover new commands, exit codes, and offline verification workflows. | Docs updated; examples recorded; release notes mention new commands. |
> Docs note (2025-10-26): `docs/cli/cli-reference.md` now describes both commands, exit codes, and offline usage—sync help text once implementation lands.
> 2025-10-27: CLI reference now reflects final summary fields/JSON schema, quickstart includes verification/dry-run workflows, and API reference tables list both `sources ingest --dry-run` and `aoc verify`.
## Policy Engine v2
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-POLICY-20-001 | TODO | DevEx/CLI Guild | WEB-POLICY-20-001 | Add `stella policy new|edit|submit|approve` commands with local editor integration, version pinning, and approval workflow wiring. | Commands round-trip policy drafts with temp files; approval requires correct scopes; unit tests cover happy/error paths. |
| CLI-POLICY-20-002 | DONE (2025-10-27) | DevEx/CLI Guild | CLI-POLICY-20-001, WEB-POLICY-20-001, WEB-POLICY-20-002 | Implement `stella policy simulate` with SBOM/env arguments and diff output (table/JSON), handling exit codes for `ERR_POL_*`. | Simulation outputs deterministic diffs; JSON schema documented; tests validate exit codes + piping of env variables. |
> 2025-10-26: Scheduler Models expose canonical run/diff schemas (`src/Scheduler/__Libraries/StellaOps.Scheduler.Models/docs/SCHED-MODELS-20-001-POLICY-RUNS.md`). Schema exporter lives at `scripts/export-policy-schemas.sh`; wire schema validation once DevOps publishes artifacts (see DEVOPS-POLICY-20-004).
> 2025-10-27: DevOps pipeline now publishes `policy-schema-exports` artefacts per commit (see `.gitea/workflows/build-test-deploy.yml`); Slack `#policy-engine` alerts trigger on schema diffs. Pull the JSON from the CI artifact instead of committing local copies.
> 2025-10-27: CLI command supports table/JSON output, environment parsing, `--fail-on-diff`, and maps `ERR_POL_*` to exit codes; tested in `StellaOps.Cli.Tests` against stubbed backend.
| CLI-POLICY-20-003 | DONE (2025-10-30) | DevEx/CLI Guild, Docs Guild | CLI-POLICY-20-002, WEB-POLICY-20-003, DOCS-POLICY-20-006 | Extend `stella findings ls|get` commands for policy-filtered retrieval with pagination, severity filters, and explain output. | Commands stream paginated results; explain view renders rationale entries; docs/help updated; end-to-end tests cover filters. |
> 2025-10-27: Work paused after stubbing backend parsing helpers; command wiring/tests still pending. Resume by finishing backend query serialization + CLI output paths.
> 2025-10-30: Resuming implementation; wiring backend query DTOs, CLI handlers, and tests for paginated policy-filtered findings.
> 2025-10-30: Implemented backend client + CLI command surface for policy findings list/get/explain, added telemetry, interactive/json output, file writes, and unit tests covering filters + explain traces.
> 2025-10-30: Pending POLICY-ENGINE-20-006 change-stream orchestration to validate live pagination/cursor behaviour once engine emits incremental updates.
## Graph Explorer v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
## Link-Not-Merge v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-LNM-22-001 | TODO | DevEx/CLI Guild | WEB-LNM-21-001 | Implement `stella advisory obs get/linkset show/export` commands with JSON/OSV output, pagination, and conflict display; ensure `ERR_AGG_*` mapping. | Commands fetch observation/linkset data; exports validated against fixtures; unit tests cover error handling. |
| CLI-LNM-22-002 | TODO | DevEx/CLI Guild | WEB-LNM-21-002 | Implement `stella vex obs get/linkset show` commands with product filters, status filters, and JSON output for CI usage. | Commands support filters + streaming; integration tests use sample linksets; docs updated. |
## Policy Engine + Editor v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-POLICY-23-004 | TODO | DevEx/CLI Guild | WEB-POLICY-23-001 | Add `stella policy lint` command validating SPL files with compiler diagnostics; support JSON output. | Command returns lint diagnostics; exit codes documented; tests cover error scenarios. |
| CLI-POLICY-23-005 | DOING (2025-10-28) | DevEx/CLI Guild | POLICY-GATEWAY-18-002..003, WEB-POLICY-23-002 | Implement `stella policy activate` with scheduling window, approval enforcement, and summary output. | Activation command integrates with API, handles 2-person rule failures; tests cover success/error. |
> 2025-10-28: CLI command implemented with gateway integration (`policy activate`), interactive summary output, retry-aware metrics, and exit codes (0 success, 75 pending second approval). Tests cover success/pending/error paths.
| CLI-POLICY-23-006 | TODO | DevEx/CLI Guild | WEB-POLICY-23-004 | Provide `stella policy history` and `stella policy explain` commands to pull run history and explanation trees. | Commands output JSON/table; integration tests with fixtures; docs updated. |
## Graph & Vuln Explorer v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
## Exceptions v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-EXC-25-001 | TODO | DevEx/CLI Guild | WEB-EXC-25-001 | Implement `stella exceptions list|draft|propose|approve|revoke` commands with JSON/table output, validation, and workflow exit codes. | Commands exercise end-to-end workflow; unit/integration tests cover errors; docs updated. |
| CLI-EXC-25-002 | TODO | DevEx/CLI Guild | WEB-EXC-25-002 | Extend `stella policy simulate` with `--with-exception`/`--without-exception` flags to preview exception impact. | Simulation handles overrides; regression tests cover presence/absence; help text updated. |
## Reachability v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-SIG-26-001 | TODO | DevEx/CLI Guild | WEB-SIG-26-001 | Implement `stella reachability upload-callgraph` and `stella reachability list/explain` commands with streaming upload, pagination, and exit codes. | Commands operate end-to-end; integration tests with fixtures; docs updated. |
| CLI-SIG-26-002 | TODO | DevEx/CLI Guild | WEB-SIG-26-003 | Extend `stella policy simulate` with reachability override flags (`--reachability-state`, `--reachability-score`). | Simulation command accepts overrides; regression tests cover adjustments; help text updated. |
## Policy Studio (Sprint 27)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-POLICY-27-001 | TODO | DevEx/CLI Guild | REGISTRY-API-27-001, WEB-POLICY-27-001 | Implement policy workspace commands (`stella policy init`, `edit`, `lint`, `compile`, `test`) with template selection, local cache, JSON output, and deterministic temp directories. | Commands operate offline with cached templates; diagnostics mirror API responses; unit tests cover happy/error paths; help text updated. |
> Docs dependency: `DOCS-POLICY-27-007` blocked until CLI commands + help output land.
| CLI-POLICY-27-002 | TODO | DevEx/CLI Guild | REGISTRY-API-27-006, WEB-POLICY-27-002 | Add submission/review workflow commands (`stella policy version bump`, `submit`, `review comment`, `approve`, `reject`) supporting reviewer assignment, changelog capture, and exit codes. | Workflow commands enforce required approvers; comments upload correctly; integration tests cover approval failure; docs updated. |
> Docs dependency: `DOCS-POLICY-27-007` and `DOCS-POLICY-27-006` require review/promotion CLI flows.
| CLI-POLICY-27-003 | TODO | DevEx/CLI Guild | REGISTRY-API-27-005, SCHED-CONSOLE-27-001 | Implement `stella policy simulate` enhancements (quick vs batch, SBOM selectors, heatmap summary, manifest download) with `--json` and Markdown report output for CI. | CLI can trigger batch sim, poll progress, download artifacts; outputs deterministic schemas; CI sample workflow documented; tests cover cancellation/timeouts. |
> Docs dependency: `DOCS-POLICY-27-004` needs simulate CLI examples.
| CLI-POLICY-27-004 | TODO | DevEx/CLI Guild | REGISTRY-API-27-007, REGISTRY-API-27-008, AUTH-POLICY-27-002 | Add lifecycle commands for publish/promote/rollback/sign (`stella policy publish --sign`, `promote --env`, `rollback`) with attestation verification and canary arguments. | Commands enforce signing requirement, support dry-run, produce audit logs; integration tests cover promotion + rollback; documentation updated. |
> Docs dependency: `DOCS-POLICY-27-006` requires publish/promote/rollback CLI examples.
| CLI-POLICY-27-005 | TODO | DevEx/CLI Guild, Docs Guild | DOCS-CONSOLE-27-007, DOCS-POLICY-27-007 | Update CLI reference and samples for Policy Studio including JSON schemas, exit codes, and CI snippets. | CLI docs merged with screenshots/transcripts; parity matrix updated; acceptance tests ensure `--help` examples compile. |
| CLI-POLICY-27-006 | TODO | DevEx/CLI Guild | AUTH-POLICY-27-001, CLI-POLICY-27-001 | Update CLI policy profiles/help text to request the new Policy Studio scope family, surface ProblemDetails guidance for `invalid_scope`, and adjust regression tests for scope failures. | Default CLI profiles reference new scopes, `stella policy` commands emit updated guidance, automated tests cover missing-scope responses, and docs regenerated via `scripts/update-cli-docs.sh`. |
> Heads-up: Gateway/Authority now reject `policy:write`/`policy:submit` tokens; automation will fail until profiles switch to the new scope bundle.
## Vulnerability Explorer (Sprint 29)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-VULN-29-001 | TODO | DevEx/CLI Guild | VULN-API-29-002, AUTH-VULN-29-001 | Implement `stella vuln list` with grouping, paging, filters, `--json/--csv`, and policy selection. | Command returns deterministic output; paging works; regression tests cover filters/grouping. |
| CLI-VULN-29-002 | TODO | DevEx/CLI Guild | VULN-API-29-003 | Implement `stella vuln show` displaying evidence, policy rationale, paths, ledger summary; support `--json` for automation. | Output matches schema; evidence rendered with provenance; tests cover missing data. |
| CLI-VULN-29-003 | TODO | DevEx/CLI Guild | VULN-API-29-004, LEDGER-29-005 | Add workflow commands (`assign`, `comment`, `accept-risk`, `verify-fix`, `target-fix`, `reopen`) with filter selection (`--filter`) and idempotent retries. | Commands create ledger events; exit codes documented; integration tests cover role enforcement. |
| CLI-VULN-29-004 | TODO | DevEx/CLI Guild | VULN-API-29-005 | Implement `stella vuln simulate` producing delta summaries and optional Markdown report for CI. | CLI simulation returns diff tables + JSON; tests verify diff correctness; docs updated. |
| CLI-VULN-29-005 | TODO | DevEx/CLI Guild | VULN-API-29-008 | Add `stella vuln export` and `stella vuln bundle verify` commands to trigger/download evidence bundles and verify signatures. | Export command streams to file; verify command checks signatures; tests cover success/failure. |
| CLI-VULN-29-006 | TODO | DevEx/CLI Guild, Docs Guild | DOCS-VULN-29-004, DOCS-VULN-29-005 | Update CLI docs/examples for Vulnerability Explorer with compliance checklist and CI snippets. | Docs merged; automated examples validated; compliance checklist appended. |
## VEX Lens (Sprint 30)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-VEX-30-001 | TODO | DevEx/CLI Guild | VEXLENS-30-007 | Implement `stella vex consensus list` with filters, paging, policy selection, `--json/--csv`. | Command returns deterministic output; regression tests cover filters/paging; docs updated. |
| CLI-VEX-30-002 | TODO | DevEx/CLI Guild | VEXLENS-30-007 | Implement `stella vex consensus show` displaying quorum, evidence, rationale, signature status. | Output matches schema; tests cover conflicting evidence; docs updated. |
| CLI-VEX-30-003 | TODO | DevEx/CLI Guild | VEXLENS-30-007 | Implement `stella vex simulate` for trust/threshold overrides with JSON diff output. | Simulation command returns diff summary; tests cover policy scenarios; docs updated. |
| CLI-VEX-30-004 | TODO | DevEx/CLI Guild | VEXLENS-30-007 | Implement `stella vex export` for consensus NDJSON bundles with signature verification helper. | Export & verify commands operational; tests cover file output; docs updated. |
## Advisory AI (Sprint 31)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-AIAI-31-001 | TODO | DevEx/CLI Guild | AIAI-31-006 | Implement `stella advise summarize` command with JSON/Markdown outputs and citation display. | Command returns summary + JSON; citations preserved; tests cover filters. |
| CLI-AIAI-31-002 | TODO | DevEx/CLI Guild | AIAI-31-006 | Implement `stella advise explain` showing conflict narrative and structured rationale. | Output matches schemas; tests cover disputed cases. |
| CLI-AIAI-31-003 | TODO | DevEx/CLI Guild | AIAI-31-006 | Implement `stella advise remediate` generating remediation plans with `--strategy` filters and file output. | Plans saved to file; exit codes documented; tests cover version mapping. |
| CLI-AIAI-31-004 | TODO | DevEx/CLI Guild | AIAI-31-006 | Implement `stella advise batch` for summaries/conflicts/remediation with progress + multi-status responses. | Batch command handles 207 responses; tests cover partial failures. |
## Export Center (Epic 10)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-EXPORT-35-001 | BLOCKED (2025-10-29) | DevEx/CLI Guild | WEB-EXPORT-35-001, AUTH-EXPORT-35-001 | Implement `stella export profiles|runs` list/show, `run create`, `run status`, and resumable download commands with manifest/provenance retrieval. | Commands respect viewer/operator scopes; downloads resume via range requests; integration tests cover filters and offline mode. |
> Blocked: Gateway routing (`WEB-EXPORT-35-001`) and Authority scopes pending; CLI cannot hit Export APIs until those services land.
| CLI-EXPORT-36-001 | TODO | DevEx/CLI Guild | CLI-EXPORT-35-001, WEB-EXPORT-36-001 | Add distribution commands (`stella export distribute`, `run download --resume` enhancements) and improved status polling with progress bars. | Distribution commands push OCI/object storage; status polling handles SSE fallback; tests cover failure cases. |
| CLI-EXPORT-37-001 | TODO | DevEx/CLI Guild | CLI-EXPORT-36-001, WEB-EXPORT-37-001 | Provide scheduling (`stella export schedule`), retention, and `export verify` commands performing signature/hash validation. | Scheduling/retention commands enforce admin scopes; verify command checks signatures/hashes; examples documented; tests cover success/failure. |
## Orchestrator Dashboard (Epic 9)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-ORCH-32-001 | TODO | DevEx/CLI Guild | WEB-ORCH-32-001, AUTH-ORCH-32-001 | Implement `stella orch sources|runs|jobs` list/show commands with filters, pagination, table/JSON output, and deterministic exit codes. | Commands respect viewer scope; JSON schema documented; integration tests cover filters/paging/offline mode. |
| CLI-ORCH-33-001 | TODO | DevEx/CLI Guild | CLI-ORCH-32-001, WEB-ORCH-33-001, AUTH-ORCH-33-001 | Add action verbs (`sources test|pause|resume|sync-now`, `jobs retry|cancel|tail`) with streaming output, reason prompts, and retry/backoff handling. | Actions succeed with operator scope; streaming tail resilient to reconnect; tests cover permission failures and retries. |
| CLI-ORCH-34-001 | TODO | DevEx/CLI Guild | CLI-ORCH-33-001, WEB-ORCH-34-001, AUTH-ORCH-34-001 | Provide backfill wizard (`--from/--to --dry-run`), quota management (`quotas get|set`), and safety guardrails for orchestrator GA. | Backfill preview output matches API; quota updates require reason; CLI docs/help updated; regression tests cover dry-run + failure paths. |
## Notifications Studio (Epic 11)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-NOTIFY-38-001 | BLOCKED (2025-10-29) | DevEx/CLI Guild | WEB-NOTIFY-38-001, AUTH-NOTIFY-38-001 | Implement `stella notify rules|templates|incidents` commands (list/create/update/test/ack) with file inputs, JSON output, and RBAC-aware flow. | Commands invoke notifier APIs successfully; rule test uses local events file; integration tests cover create/test/ack; help docs updated. |
> Blocked: Gateway routing (`WEB-NOTIFY-38-001`) and Authority scopes (`AUTH-NOTIFY-38-001`) pending; CLI cannot exercise APIs until endpoints and token scopes are published.
| CLI-NOTIFY-39-001 | BLOCKED (2025-10-29) | DevEx/CLI Guild | CLI-NOTIFY-38-001, WEB-NOTIFY-39-001 | Add simulation (`stella notify simulate`) and digest commands with diff output and schedule triggering, including dry-run mode. | Simulation command returns deterministic diff; digest command triggers run and polls status; tests cover filters and failures. |
> Blocked: Foundation commands (`CLI-NOTIFY-38-001`) and gateway digest/simulation APIs (`WEB-NOTIFY-39-001`) not available yet.
| CLI-NOTIFY-40-001 | TODO | DevEx/CLI Guild | CLI-NOTIFY-39-001, WEB-NOTIFY-40-001 | Provide ack token redemption workflow, escalation management, localization previews, and channel health checks. | Ack redemption validates signed tokens; escalation commands manage schedules; localization preview shows variants; integration tests cover negative cases. |
## CLI Parity & Task Packs (Epic 12)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-CORE-41-001 | TODO | DevEx/CLI Guild | AUTH-PACKS-41-001 | Implement CLI core features: config precedence, profiles/contexts, auth flows, output renderer (json/yaml/table), error mapping, global flags, telemetry opt-in. | CLI loads config deterministically; auth works (device/PAT); outputs render correctly; tests cover precedence and exit codes. |
| CLI-PARITY-41-001 | TODO | DevEx/CLI Guild | CLI-CORE-41-001 | Deliver parity command groups (`policy`, `sbom`, `vuln`, `vex`, `advisory`, `export`, `orchestrator`) with `--explain`, deterministic outputs, and parity matrix entries. | Commands match Console behavior; parity matrix green for covered actions; integration tests cover major flows. |
| CLI-PARITY-41-002 | TODO | DevEx/CLI Guild | CLI-PARITY-41-001, WEB-NOTIFY-38-001 | Implement `notify`, `aoc`, `auth` command groups, idempotency keys, shell completions, config docs, and parity matrix export tooling. | Commands functional; completions generated; docs updated; parity matrix auto-exported; CI checks gating. |
| CLI-PACKS-42-001 | TODO | DevEx/CLI Guild | CLI-CORE-41-001, PACKS-REG-41-001, TASKRUN-41-001 | Implement Task Pack commands (`pack plan/run/push/pull/verify`) with schema validation, expression sandbox, plan/simulate engine, remote execution. | Pack commands operational; plan/sim produce accurate graph; remote run streams logs; schema validation enforced. |
| CLI-PACKS-43-001 | TODO | DevEx/CLI Guild | CLI-PACKS-42-001, TASKRUN-42-001 | Deliver advanced pack features (approvals pause/resume, secret injection, localization, man pages, offline cache). | Approvals handled; secrets redacted; localization supported; man pages built; offline cache documented; integration tests cover scenarios. |
## Authority-Backed Scopes & Tenancy (Epic 14)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-TEN-47-001 | TODO | DevEx/CLI Guild | AUTH-TEN-47-001 | Implement `stella login`, `whoami`, `tenants list`, persistent profiles, secure token storage, and `--tenant` override with validation. | Commands functional across platforms; tokens stored securely; tenancy header set on requests; integration tests cover login/tenant switch. |
| CLI-TEN-49-001 | TODO | DevEx/CLI Guild | CLI-TEN-47-001, AUTH-TEN-49-001 | Add service account token minting, delegation (`stella token delegate`), impersonation banner, and audit-friendly logging. | Service tokens minted with scopes/TTL; delegation recorded; CLI displays impersonation banner; docs updated. |
## Observability & Forensics (Epic 15)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-OBS-50-001 | TODO | DevEx/CLI Guild | TELEMETRY-OBS-50-002, WEB-OBS-50-001 | Ensure CLI HTTP client propagates `traceparent` headers for all commands, prints correlation IDs on failure, and records trace IDs in verbose logs (scrubbed). | Trace headers observed in integration tests; verbose logs include trace IDs; redaction guard verified. |
| CLI-OBS-51-001 | TODO | DevEx/CLI Guild | CLI-OBS-50-001, WEB-OBS-51-001 | Implement `stella obs top` command streaming service health metrics, SLO status, and burn-rate alerts with TUI view and JSON output. | Command streams metrics; JSON output documented; integration tests cover streaming and exit codes. |
| CLI-OBS-52-001 | TODO | DevEx/CLI Guild | CLI-OBS-51-001, TIMELINE-OBS-52-003 | Add `stella obs trace <trace_id>` and `stella obs logs --from/--to` commands that correlate timeline events, logs, and evidence links with pagination + guardrails. | Commands fetch timeline/log data; paging tokens handled; fixtures stored under `samples/obs/`; tests cover errors. |
| CLI-FORENSICS-53-001 | TODO | DevEx/CLI Guild, Evidence Locker Guild | CLI-OBS-52-001, EVID-OBS-53-003 | Implement `stella forensic snapshot create --case` and `snapshot list/show` commands invoking evidence locker APIs, surfacing manifest digests, and storing local cache metadata. | Snapshot commands functional; manifests displayed; cache metadata deterministic; docs/help updated. |
| CLI-FORENSICS-54-001 | TODO | DevEx/CLI Guild, Provenance Guild | CLI-FORENSICS-53-001, PROV-OBS-54-001 | Provide `stella forensic verify <bundle>` command validating checksums, DSSE signatures, and timeline chain-of-custody. Support JSON/pretty output and exit codes for CI. | Verification works with sample bundles; tests cover success/failure; docs updated. |
| CLI-FORENSICS-54-002 | TODO | DevEx/CLI Guild, Provenance Guild | CLI-FORENSICS-54-001 | Implement `stella forensic attest show <artifact>` listing attestation details (signer, timestamp, subjects) and verifying signatures. | Command prints attestation summary; verification errors flagged; tests cover offline mode. |
| CLI-OBS-55-001 | TODO | DevEx/CLI Guild, DevOps Guild | CLI-OBS-52-001, WEB-OBS-55-001, DEVOPS-OBS-55-001 | Add `stella obs incident-mode enable|disable|status` commands with confirmation guards, cooldown timers, and audit logging. | Commands manage incident mode; audit logs verified; tests cover permissions and cooldown. |
## Air-Gapped Mode (Epic 16)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-AIRGAP-56-001 | TODO | DevEx/CLI Guild | MIRROR-CRT-56-001, AIRGAP-IMP-56-001 | Implement `stella mirror create|verify` and `stella airgap verify` commands with DSSE/TUF results, dry-run mode, and deterministic manifests. | Commands produce deterministic bundles; verify outputs structured DSSE/TUF results; integration tests cover tampering scenarios. |
| CLI-AIRGAP-56-002 | TODO | DevEx/CLI Guild | CLI-OBS-50-001, AIRGAP-IMP-56-001 | Ensure telemetry propagation under sealed mode (no remote exporters) while preserving correlation IDs; add label `AirGapped-Phase-1`. | CLI traces flow via local exporters in sealed mode; correlation IDs still printed; tests cover sealed toggle + fallback. |
| CLI-AIRGAP-57-001 | TODO | DevEx/CLI Guild | CLI-AIRGAP-56-001, AIRGAP-IMP-58-001 | Add `stella airgap import` with diff preview, bundle scope selection (`--tenant`, `--global`), audit logging, and progress reporting. | Import updates catalog; diff preview rendered; audit entries include bundle ID + scope; tests cover idempotent re-import. |
| CLI-AIRGAP-57-002 | TODO | DevEx/CLI Guild | CLI-AIRGAP-56-001, AIRGAP-CTL-56-002 | Provide `stella airgap seal|status` commands surfacing sealing state, drift, staleness metrics, and remediation guidance with safe confirmation prompts. | Status command prints drift/staleness; seal requires confirmation + scope; integration tests cover RBAC denials. |
| CLI-AIRGAP-58-001 | TODO | DevEx/CLI Guild, Evidence Locker Guild | CLI-AIRGAP-57-001, CLI-FORENSICS-54-001 | Implement `stella airgap export evidence` helper for portable evidence packages, including checksum manifest and verification. | Command generates portable bundle; verification step validates signatures; docs/help updated with examples. |
## SDKs & OpenAPI (Epic 17)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-SDK-62-001 | TODO | DevEx/CLI Guild, SDK Generator Guild | SDKGEN-63-001 | Replace bespoke HTTP clients with official SDK (TS/Go) for all CLI commands; ensure modular transport for air-gapped mode. | CLI builds using SDK; regression suite passes; telemetry shows SDK version. |
| CLI-SDK-62-002 | TODO | DevEx/CLI Guild | CLI-SDK-62-001, APIGOV-61-001 | Update CLI error handling to surface standardized API error envelope with `error.code` and `trace_id`. | CLI displays envelope data; integration tests cover new output. |
| CLI-SDK-63-001 | TODO | DevEx/CLI Guild, API Governance Guild | OAS-61-002 | Expose `stella api spec download` command retrieving aggregate OAS and verifying checksum/ETag. | Command downloads + verifies spec; docs updated; tests cover failure cases. |
| CLI-SDK-64-001 | TODO | DevEx/CLI Guild, SDK Release Guild | SDKREL-63-001 | Add CLI subcommand `stella sdk update` to fetch latest SDK manifests/changelogs; integrate with Notifications for deprecations. | Command lists versions/changelogs; notifications triggered on updates. |
## Risk Profiles (Epic 18)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-RISK-66-001 | TODO | DevEx/CLI Guild, Policy Guild | POLICY-RISK-67-002 | Implement `stella risk profile list|get|create|publish` commands with schema validation and scope selectors. | Commands operate against API; validation errors surfaced; tests cover CRUD. |
| CLI-RISK-66-002 | TODO | DevEx/CLI Guild, Risk Engine Guild | RISK-ENGINE-69-001 | Ship `stella risk simulate` supporting SBOM/asset inputs, diff mode, and export to JSON/CSV. | Simulation runs via CLI; output tested; docs updated. |
| CLI-RISK-67-001 | TODO | DevEx/CLI Guild, Findings Ledger Guild | LEDGER-RISK-67-001 | Provide `stella risk results` with filtering, severity thresholds, explainability fetch. | Results command returns paginated data; explaination fetch command outputs artifact; tests pass. |
| CLI-RISK-68-001 | TODO | DevEx/CLI Guild, Export Guild | RISK-BUNDLE-70-001 | Add `stella risk bundle verify` and integrate with offline risk bundles. | Verification command validates signatures; integration tests cover tampered bundle. |
## Attestor Console (Epic 19)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-ATTEST-73-001 | TODO | CLI Attestor Guild | ATTESTOR-73-001, SDKGEN-63-001 | Implement `stella attest sign` (payload selection, subject digest, key reference, output format) using official SDK transport. | Command signs envelopes; tests cover file/KMS keys; docs updated. |
| CLI-ATTEST-73-002 | TODO | CLI Attestor Guild | ATTESTOR-73-002 | Implement `stella attest verify` with policy selection, explainability output, and JSON/table formatting. | Verification command returns structured report; exit codes match pass/fail; integration tests pass. |
| CLI-ATTEST-74-001 | TODO | CLI Attestor Guild | ATTESTOR-73-003 | Implement `stella attest list` with filters (subject, type, issuer, scope) and pagination. | Command outputs table/JSON; tests cover filters. |
| CLI-ATTEST-74-002 | TODO | CLI Attestor Guild | ATTESTOR-73-003 | Implement `stella attest fetch` to download envelopes and payloads to disk. | Fetch command saves files; checks digests; tests cover air-gap use. |
| CLI-ATTEST-75-001 | TODO | CLI Attestor Guild, KMS Guild | KMS-72-001 | Implement `stella attest key create|import|rotate|revoke` commands. | Key commands work with file/KMS drivers; tests cover rotation/revocation. |
| CLI-ATTEST-75-002 | TODO | CLI Attestor Guild, Export Guild | ATTESTOR-75-001 | Add support for building/verifying attestation bundles in CLI. | Bundle commands functional; verification catches tampering; docs updated. |

View File

@@ -0,0 +1,8 @@
using System.Diagnostics;
namespace StellaOps.Cli.Telemetry;
internal static class CliActivitySource
{
public static readonly ActivitySource Instance = new("StellaOps.Cli");
}

View File

@@ -0,0 +1,126 @@
using System;
using System.Diagnostics.Metrics;
namespace StellaOps.Cli.Telemetry;
internal static class CliMetrics
{
private static readonly Meter Meter = new("StellaOps.Cli", "1.0.0");
private static readonly Counter<long> ScannerDownloadCounter = Meter.CreateCounter<long>("stellaops.cli.scanner.download.count");
private static readonly Counter<long> ScannerInstallCounter = Meter.CreateCounter<long>("stellaops.cli.scanner.install.count");
private static readonly Counter<long> ScanRunCounter = Meter.CreateCounter<long>("stellaops.cli.scan.run.count");
private static readonly Counter<long> OfflineKitDownloadCounter = Meter.CreateCounter<long>("stellaops.cli.offline.kit.download.count");
private static readonly Counter<long> OfflineKitImportCounter = Meter.CreateCounter<long>("stellaops.cli.offline.kit.import.count");
private static readonly Counter<long> PolicySimulationCounter = Meter.CreateCounter<long>("stellaops.cli.policy.simulate.count");
private static readonly Counter<long> PolicyActivationCounter = Meter.CreateCounter<long>("stellaops.cli.policy.activate.count");
private static readonly Counter<long> SourcesDryRunCounter = Meter.CreateCounter<long>("stellaops.cli.sources.dryrun.count");
private static readonly Counter<long> AocVerifyCounter = Meter.CreateCounter<long>("stellaops.cli.aoc.verify.count");
private static readonly Counter<long> PolicyFindingsListCounter = Meter.CreateCounter<long>("stellaops.cli.policy.findings.list.count");
private static readonly Counter<long> PolicyFindingsGetCounter = Meter.CreateCounter<long>("stellaops.cli.policy.findings.get.count");
private static readonly Counter<long> PolicyFindingsExplainCounter = Meter.CreateCounter<long>("stellaops.cli.policy.findings.explain.count");
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
public static void RecordScannerDownload(string channel, bool fromCache)
=> ScannerDownloadCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("channel", channel),
new("cache", fromCache ? "hit" : "miss")
});
public static void RecordScannerInstall(string channel)
=> ScannerInstallCounter.Add(1, new KeyValuePair<string, object?>[] { new("channel", channel) });
public static void RecordScanRun(string runner, int exitCode)
=> ScanRunCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("runner", runner),
new("exit_code", exitCode)
});
public static void RecordOfflineKitDownload(string kind, bool fromCache)
=> OfflineKitDownloadCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("kind", string.IsNullOrWhiteSpace(kind) ? "unknown" : kind),
new("cache", fromCache ? "hit" : "miss")
});
public static void RecordOfflineKitImport(string? status)
=> OfflineKitImportCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("status", string.IsNullOrWhiteSpace(status) ? "queued" : status)
});
public static void RecordPolicySimulation(string outcome)
=> PolicySimulationCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static void RecordPolicyActivation(string outcome)
=> PolicyActivationCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static void RecordSourcesDryRun(string status)
=> SourcesDryRunCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("status", string.IsNullOrWhiteSpace(status) ? "unknown" : status)
});
public static void RecordAocVerify(string outcome)
=> AocVerifyCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static void RecordPolicyFindingsList(string outcome)
=> PolicyFindingsListCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static void RecordPolicyFindingsGet(string outcome)
=> PolicyFindingsGetCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static void RecordPolicyFindingsExplain(string outcome)
=> PolicyFindingsExplainCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static IDisposable MeasureCommandDuration(string command)
{
var start = DateTime.UtcNow;
return new DurationScope(command, start);
}
private sealed class DurationScope : IDisposable
{
private readonly string _command;
private readonly DateTime _start;
private bool _disposed;
public DurationScope(string command, DateTime start)
{
_command = command;
_start = start;
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
var elapsed = (DateTime.UtcNow - _start).TotalMilliseconds;
CommandDurationHistogram.Record(elapsed, new KeyValuePair<string, object?>[] { new("command", _command) });
}
}
}

View File

@@ -0,0 +1,8 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Cli.Telemetry;
internal sealed class VerbosityState
{
public LogLevel MinimumLevel { get; set; } = LogLevel.Information;
}

View File

@@ -0,0 +1,12 @@
{
"StellaOps": {
"ApiKey": "",
"BackendUrl": "",
"ConcelierUrl": "",
"ScannerCacheDirectory": "scanners",
"ResultsDirectory": "results",
"DefaultRunner": "dotnet",
"ScannerSignaturePublicKeyPath": "",
"ScannerDownloadAttempts": 3
}
}

View File

@@ -0,0 +1,416 @@
using System;
using System.CommandLine;
using System.Threading;
using StellaOps.Cli.Commands;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Plugins;
namespace StellaOps.Cli.Plugins.NonCore;
public sealed class NonCoreCliCommandModule : ICliCommandModule
{
public string Name => "stellaops.cli.plugins.noncore";
public bool IsAvailable(IServiceProvider services) => true;
public void RegisterCommands(
RootCommand root,
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(root);
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(verboseOption);
root.Add(BuildExcititorCommand(services, verboseOption, cancellationToken));
root.Add(BuildRuntimeCommand(services, verboseOption, cancellationToken));
root.Add(BuildOfflineCommand(services, verboseOption, cancellationToken));
}
private static Command BuildExcititorCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var excititor = new Command("excititor", "Manage Excititor ingest, exports, and reconciliation workflows.");
var init = new Command("init", "Initialize Excititor ingest state.");
var initProviders = new Option<string[]>("--provider", new[] { "-p" })
{
Description = "Optional provider identifier(s) to initialize.",
Arity = ArgumentArity.ZeroOrMore
};
var resumeOption = new Option<bool>("--resume")
{
Description = "Resume ingest from the last persisted checkpoint instead of starting fresh."
};
init.Add(initProviders);
init.Add(resumeOption);
init.SetAction((parseResult, _) =>
{
var providers = parseResult.GetValue(initProviders) ?? Array.Empty<string>();
var resume = parseResult.GetValue(resumeOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorInitAsync(services, providers, resume, verbose, cancellationToken);
});
var pull = new Command("pull", "Trigger Excititor ingest for configured providers.");
var pullProviders = new Option<string[]>("--provider", new[] { "-p" })
{
Description = "Optional provider identifier(s) to ingest.",
Arity = ArgumentArity.ZeroOrMore
};
var sinceOption = new Option<DateTimeOffset?>("--since")
{
Description = "Optional ISO-8601 timestamp to begin the ingest window."
};
var windowOption = new Option<TimeSpan?>("--window")
{
Description = "Optional window duration (e.g. 24:00:00)."
};
var forceOption = new Option<bool>("--force")
{
Description = "Force ingestion even if the backend reports no pending work."
};
pull.Add(pullProviders);
pull.Add(sinceOption);
pull.Add(windowOption);
pull.Add(forceOption);
pull.SetAction((parseResult, _) =>
{
var providers = parseResult.GetValue(pullProviders) ?? Array.Empty<string>();
var since = parseResult.GetValue(sinceOption);
var window = parseResult.GetValue(windowOption);
var force = parseResult.GetValue(forceOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorPullAsync(services, providers, since, window, force, verbose, cancellationToken);
});
var resume = new Command("resume", "Resume Excititor ingest using a checkpoint token.");
var resumeProviders = new Option<string[]>("--provider", new[] { "-p" })
{
Description = "Optional provider identifier(s) to resume.",
Arity = ArgumentArity.ZeroOrMore
};
var checkpointOption = new Option<string?>("--checkpoint")
{
Description = "Optional checkpoint identifier to resume from."
};
resume.Add(resumeProviders);
resume.Add(checkpointOption);
resume.SetAction((parseResult, _) =>
{
var providers = parseResult.GetValue(resumeProviders) ?? Array.Empty<string>();
var checkpoint = parseResult.GetValue(checkpointOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorResumeAsync(services, providers, checkpoint, verbose, cancellationToken);
});
var list = new Command("list-providers", "List Excititor providers and their ingest status.");
var includeDisabledOption = new Option<bool>("--include-disabled")
{
Description = "Include disabled providers in the listing."
};
list.Add(includeDisabledOption);
list.SetAction((parseResult, _) =>
{
var includeDisabled = parseResult.GetValue(includeDisabledOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorListProvidersAsync(services, includeDisabled, verbose, cancellationToken);
});
var export = new Command("export", "Trigger Excititor export generation.");
var formatOption = new Option<string>("--format")
{
Description = "Export format (e.g. openvex, json)."
};
var exportDeltaOption = new Option<bool>("--delta")
{
Description = "Request a delta export when supported."
};
var exportScopeOption = new Option<string?>("--scope")
{
Description = "Optional policy scope or tenant identifier."
};
var exportSinceOption = new Option<DateTimeOffset?>("--since")
{
Description = "Optional ISO-8601 timestamp to restrict export contents."
};
var exportProviderOption = new Option<string?>("--provider")
{
Description = "Optional provider identifier when requesting targeted exports."
};
var exportOutputOption = new Option<string?>("--output")
{
Description = "Optional path to download the export artifact."
};
export.Add(formatOption);
export.Add(exportDeltaOption);
export.Add(exportScopeOption);
export.Add(exportSinceOption);
export.Add(exportProviderOption);
export.Add(exportOutputOption);
export.SetAction((parseResult, _) =>
{
var format = parseResult.GetValue(formatOption) ?? "openvex";
var delta = parseResult.GetValue(exportDeltaOption);
var scope = parseResult.GetValue(exportScopeOption);
var since = parseResult.GetValue(exportSinceOption);
var provider = parseResult.GetValue(exportProviderOption);
var output = parseResult.GetValue(exportOutputOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorExportAsync(services, format, delta, scope, since, provider, output, verbose, cancellationToken);
});
var backfill = new Command("backfill-statements", "Replay historical raw documents into Excititor statements.");
var backfillRetrievedSinceOption = new Option<DateTimeOffset?>("--retrieved-since")
{
Description = "Only process raw documents retrieved on or after the provided ISO-8601 timestamp."
};
var backfillForceOption = new Option<bool>("--force")
{
Description = "Reprocess documents even if statements already exist."
};
var backfillBatchSizeOption = new Option<int>("--batch-size")
{
Description = "Number of raw documents to fetch per batch (default 100)."
};
var backfillMaxDocumentsOption = new Option<int?>("--max-documents")
{
Description = "Optional maximum number of raw documents to process."
};
backfill.Add(backfillRetrievedSinceOption);
backfill.Add(backfillForceOption);
backfill.Add(backfillBatchSizeOption);
backfill.Add(backfillMaxDocumentsOption);
backfill.SetAction((parseResult, _) =>
{
var retrievedSince = parseResult.GetValue(backfillRetrievedSinceOption);
var force = parseResult.GetValue(backfillForceOption);
var batchSize = parseResult.GetValue(backfillBatchSizeOption);
if (batchSize <= 0)
{
batchSize = 100;
}
var maxDocuments = parseResult.GetValue(backfillMaxDocumentsOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorBackfillStatementsAsync(
services,
retrievedSince,
force,
batchSize,
maxDocuments,
verbose,
cancellationToken);
});
var verify = new Command("verify", "Verify Excititor exports or attestations.");
var exportIdOption = new Option<string?>("--export-id")
{
Description = "Export identifier to verify."
};
var digestOption = new Option<string?>("--digest")
{
Description = "Expected digest for the export or attestation."
};
var attestationOption = new Option<string?>("--attestation")
{
Description = "Path to a local attestation file to verify (base64 content will be uploaded)."
};
verify.Add(exportIdOption);
verify.Add(digestOption);
verify.Add(attestationOption);
verify.SetAction((parseResult, _) =>
{
var exportId = parseResult.GetValue(exportIdOption);
var digest = parseResult.GetValue(digestOption);
var attestation = parseResult.GetValue(attestationOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorVerifyAsync(services, exportId, digest, attestation, verbose, cancellationToken);
});
var reconcile = new Command("reconcile", "Trigger Excititor reconciliation against canonical advisories.");
var reconcileProviders = new Option<string[]>("--provider", new[] { "-p" })
{
Description = "Optional provider identifier(s) to reconcile.",
Arity = ArgumentArity.ZeroOrMore
};
var maxAgeOption = new Option<TimeSpan?>("--max-age")
{
Description = "Optional maximum age window (e.g. 7.00:00:00)."
};
reconcile.Add(reconcileProviders);
reconcile.Add(maxAgeOption);
reconcile.SetAction((parseResult, _) =>
{
var providers = parseResult.GetValue(reconcileProviders) ?? Array.Empty<string>();
var maxAge = parseResult.GetValue(maxAgeOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorReconcileAsync(services, providers, maxAge, verbose, cancellationToken);
});
excititor.Add(init);
excititor.Add(pull);
excititor.Add(resume);
excititor.Add(list);
excititor.Add(export);
excititor.Add(backfill);
excititor.Add(verify);
excititor.Add(reconcile);
return excititor;
}
private static Command BuildRuntimeCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var runtime = new Command("runtime", "Interact with runtime admission policy APIs.");
var policy = new Command("policy", "Runtime policy operations.");
var test = new Command("test", "Evaluate runtime policy decisions for image digests.");
var namespaceOption = new Option<string?>("--namespace", new[] { "--ns" })
{
Description = "Namespace or logical scope for the evaluation."
};
var imageOption = new Option<string[]>("--image", new[] { "-i", "--images" })
{
Description = "Image digests to evaluate (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var fileOption = new Option<string?>("--file", new[] { "-f" })
{
Description = "Path to a file containing image digests (one per line)."
};
var labelOption = new Option<string[]>("--label", new[] { "-l", "--labels" })
{
Description = "Pod labels in key=value format (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var jsonOption = new Option<bool>("--json")
{
Description = "Emit the raw JSON response."
};
test.Add(namespaceOption);
test.Add(imageOption);
test.Add(fileOption);
test.Add(labelOption);
test.Add(jsonOption);
test.SetAction((parseResult, _) =>
{
var nsValue = parseResult.GetValue(namespaceOption);
var images = parseResult.GetValue(imageOption) ?? Array.Empty<string>();
var file = parseResult.GetValue(fileOption);
var labels = parseResult.GetValue(labelOption) ?? Array.Empty<string>();
var outputJson = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleRuntimePolicyTestAsync(
services,
nsValue,
images,
file,
labels,
outputJson,
verbose,
cancellationToken);
});
policy.Add(test);
runtime.Add(policy);
return runtime;
}
private static Command BuildOfflineCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var offline = new Command("offline", "Offline kit workflows and utilities.");
var kit = new Command("kit", "Manage offline kit bundles.");
var pull = new Command("pull", "Download the latest offline kit bundle.");
var bundleIdOption = new Option<string?>("--bundle-id")
{
Description = "Optional bundle identifier. Defaults to the latest available."
};
var destinationOption = new Option<string?>("--destination")
{
Description = "Directory to store downloaded bundles (defaults to the configured offline kits directory)."
};
var overwriteOption = new Option<bool>("--overwrite")
{
Description = "Overwrite existing files even if checksums match."
};
var noResumeOption = new Option<bool>("--no-resume")
{
Description = "Disable resuming partial downloads."
};
pull.Add(bundleIdOption);
pull.Add(destinationOption);
pull.Add(overwriteOption);
pull.Add(noResumeOption);
pull.SetAction((parseResult, _) =>
{
var bundleId = parseResult.GetValue(bundleIdOption);
var destination = parseResult.GetValue(destinationOption);
var overwrite = parseResult.GetValue(overwriteOption);
var resume = !parseResult.GetValue(noResumeOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleOfflineKitPullAsync(services, bundleId, destination, overwrite, resume, verbose, cancellationToken);
});
var import = new Command("import", "Upload an offline kit bundle to the backend.");
var bundleArgument = new Argument<string>("bundle")
{
Description = "Path to the offline kit tarball (.tgz)."
};
var manifestOption = new Option<string?>("--manifest")
{
Description = "Offline manifest JSON path (defaults to metadata or sibling file)."
};
var bundleSignatureOption = new Option<string?>("--bundle-signature")
{
Description = "Detached signature for the offline bundle (e.g. .sig)."
};
var manifestSignatureOption = new Option<string?>("--manifest-signature")
{
Description = "Detached signature for the offline manifest (e.g. .jws)."
};
import.Add(bundleArgument);
import.Add(manifestOption);
import.Add(bundleSignatureOption);
import.Add(manifestSignatureOption);
import.SetAction((parseResult, _) =>
{
var bundlePath = parseResult.GetValue(bundleArgument) ?? string.Empty;
var manifest = parseResult.GetValue(manifestOption);
var bundleSignature = parseResult.GetValue(bundleSignatureOption);
var manifestSignature = parseResult.GetValue(manifestSignatureOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleOfflineKitImportAsync(services, bundlePath, manifest, bundleSignature, manifestSignature, verbose, cancellationToken);
});
var status = new Command("status", "Display offline kit installation status.");
var jsonOption = new Option<bool>("--json")
{
Description = "Emit status as JSON."
};
status.Add(jsonOption);
status.SetAction((parseResult, _) =>
{
var asJson = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleOfflineKitStatusAsync(services, asJson, verbose, cancellationToken);
});
kit.Add(pull);
kit.Add(import);
kit.Add(status);
offline.Add(kit);
return offline;
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<PluginOutputDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\\..\\plugins\\cli\\StellaOps.Cli.Plugins.NonCore\\'))</PluginOutputDirectory>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Cli\StellaOps.Cli.csproj" />
</ItemGroup>
<Target Name="CopyPluginBinaries" AfterTargets="Build">
<MakeDir Directories="$(PluginOutputDirectory)" />
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(PluginOutputDirectory)" />
<Copy SourceFiles="$(TargetDir)$(TargetName).pdb"
DestinationFolder="$(PluginOutputDirectory)"
Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
</Target>
</Project>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,110 @@
using System;
using System.IO;
using System.Text.Json;
using StellaOps.Cli.Configuration;
using Xunit;
namespace StellaOps.Cli.Tests.Configuration;
public sealed class CliBootstrapperTests : IDisposable
{
private readonly string _originalDirectory = Directory.GetCurrentDirectory();
private readonly string _tempDirectory = Path.Combine(Path.GetTempPath(), $"stellaops-cli-tests-{Guid.NewGuid():N}");
public CliBootstrapperTests()
{
Directory.CreateDirectory(_tempDirectory);
Directory.SetCurrentDirectory(_tempDirectory);
}
[Fact]
public void Build_UsesEnvironmentVariablesWhenPresent()
{
Environment.SetEnvironmentVariable("API_KEY", "env-key");
Environment.SetEnvironmentVariable("STELLAOPS_BACKEND_URL", "https://env-backend.example");
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_URL", "https://authority.env");
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_CLIENT_ID", "cli-env");
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SCOPE", "concelier.jobs.trigger");
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ENABLE_RETRIES", "false");
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_RETRY_DELAYS", "00:00:02,00:00:05");
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK", "false");
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_OFFLINE_CACHE_TOLERANCE", "00:20:00");
try
{
var (options, _) = CliBootstrapper.Build(Array.Empty<string>());
Assert.Equal("env-key", options.ApiKey);
Assert.Equal("https://env-backend.example", options.BackendUrl);
Assert.Equal("https://authority.env", options.Authority.Url);
Assert.Equal("cli-env", options.Authority.ClientId);
Assert.Equal("concelier.jobs.trigger", options.Authority.Scope);
Assert.NotNull(options.Authority.Resilience);
Assert.False(options.Authority.Resilience.EnableRetries);
Assert.Equal(new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5) }, options.Authority.Resilience.RetryDelays);
Assert.False(options.Authority.Resilience.AllowOfflineCacheFallback);
Assert.Equal(TimeSpan.FromMinutes(20), options.Authority.Resilience.OfflineCacheTolerance);
}
finally
{
Environment.SetEnvironmentVariable("API_KEY", null);
Environment.SetEnvironmentVariable("STELLAOPS_BACKEND_URL", null);
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_URL", null);
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_CLIENT_ID", null);
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SCOPE", null);
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ENABLE_RETRIES", null);
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_RETRY_DELAYS", null);
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK", null);
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_OFFLINE_CACHE_TOLERANCE", null);
}
}
[Fact]
public void Build_FallsBackToAppSettings()
{
WriteAppSettings(new
{
StellaOps = new
{
ApiKey = "file-key",
BackendUrl = "https://file-backend.example",
Authority = new
{
Url = "https://authority.file",
ClientId = "cli-file",
Scope = "concelier.jobs.trigger"
}
}
});
var (options, _) = CliBootstrapper.Build(Array.Empty<string>());
Assert.Equal("file-key", options.ApiKey);
Assert.Equal("https://file-backend.example", options.BackendUrl);
Assert.Equal("https://authority.file", options.Authority.Url);
Assert.Equal("cli-file", options.Authority.ClientId);
}
public void Dispose()
{
Directory.SetCurrentDirectory(_originalDirectory);
if (Directory.Exists(_tempDirectory))
{
try
{
Directory.Delete(_tempDirectory, recursive: true);
}
catch
{
// Ignored.
}
}
}
private static void WriteAppSettings<T>(T payload)
{
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText("appsettings.json", json);
}
}

View File

@@ -0,0 +1,43 @@
using System;
using System.CommandLine;
using System.IO;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Plugins;
using Xunit;
namespace StellaOps.Cli.Tests.Plugins;
public sealed class CliCommandModuleLoaderTests
{
[Fact]
public void RegisterModules_LoadsNonCoreCommandsFromPlugin()
{
var options = new StellaOpsCliOptions();
var repoRoot = Path.GetFullPath(
Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
options.Plugins.BaseDirectory = repoRoot;
options.Plugins.Directory = "plugins/cli";
options.Plugins.ManifestSearchPattern = "manifest.json";
var services = new ServiceCollection()
.AddSingleton(options)
.BuildServiceProvider();
var logger = NullLoggerFactory.Instance.CreateLogger<CliCommandModuleLoader>();
var loader = new CliCommandModuleLoader(services, options, logger);
var root = new RootCommand();
var verbose = new Option<bool>("--verbose");
loader.RegisterModules(root, verbose, CancellationToken.None);
Assert.Contains(root.Children, command => string.Equals(command.Name, "excititor", StringComparison.Ordinal));
Assert.Contains(root.Children, command => string.Equals(command.Name, "runtime", StringComparison.Ordinal));
Assert.Contains(root.Children, command => string.Equals(command.Name, "offline", StringComparison.Ordinal));
}
}

View File

@@ -0,0 +1,29 @@
using StellaOps.Cli.Plugins;
using Xunit;
namespace StellaOps.Cli.Tests.Plugins;
public sealed class RestartOnlyCliPluginGuardTests
{
[Fact]
public void EnsureRegistrationAllowed_AllowsDuringStartup()
{
var guard = new RestartOnlyCliPluginGuard();
guard.EnsureRegistrationAllowed("./plugins/sample.dll");
guard.Seal();
// Re-registering known plug-ins after sealing should succeed.
guard.EnsureRegistrationAllowed("./plugins/sample.dll");
Assert.True(guard.IsSealed);
Assert.Single(guard.KnownPlugins);
}
[Fact]
public void EnsureRegistrationAllowed_ThrowsForUnknownAfterSeal()
{
var guard = new RestartOnlyCliPluginGuard();
guard.Seal();
Assert.Throws<InvalidOperationException>(() => guard.EnsureRegistrationAllowed("./plugins/new.dll"));
}
}

View File

@@ -0,0 +1,137 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services;
using Xunit;
namespace StellaOps.Cli.Tests.Services;
public sealed class AuthorityDiagnosticsReporterTests : IDisposable
{
private readonly string _originalDirectory = Directory.GetCurrentDirectory();
private readonly string _tempDirectory = Path.Combine(Path.GetTempPath(), $"stellaops-cli-tests-{Guid.NewGuid():N}");
public AuthorityDiagnosticsReporterTests()
{
Directory.CreateDirectory(_tempDirectory);
Directory.SetCurrentDirectory(_tempDirectory);
}
[Fact]
public void Emit_LogsWarning_WhenPasswordPolicyWeakened()
{
WriteAuthorityConfiguration(minimumLength: 8);
var (_, configuration) = CliBootstrapper.Build(Array.Empty<string>());
var logger = new ListLogger();
AuthorityDiagnosticsReporter.Emit(configuration, logger);
var warning = Assert.Single(logger.Entries, entry => entry.Level == LogLevel.Warning);
Assert.Contains("minimum length 8 < 12", warning.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("standard.yaml", warning.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Emit_EmitsNoWarnings_WhenPasswordPolicyMeetsBaseline()
{
WriteAuthorityConfiguration(minimumLength: 12);
var (_, configuration) = CliBootstrapper.Build(Array.Empty<string>());
var logger = new ListLogger();
AuthorityDiagnosticsReporter.Emit(configuration, logger);
Assert.DoesNotContain(logger.Entries, entry => entry.Level >= LogLevel.Warning);
}
public void Dispose()
{
Directory.SetCurrentDirectory(_originalDirectory);
try
{
if (Directory.Exists(_tempDirectory))
{
Directory.Delete(_tempDirectory, recursive: true);
}
}
catch
{
// Ignored.
}
}
private static void WriteAuthorityConfiguration(int minimumLength)
{
var payload = new
{
Authority = new
{
Plugins = new
{
ConfigurationDirectory = "plugins",
Descriptors = new
{
standard = new
{
AssemblyName = "StellaOps.Authority.Plugin.Standard",
Enabled = true,
ConfigFile = "standard.yaml"
}
}
}
}
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText("appsettings.json", json);
var pluginDirectory = Path.Combine(Directory.GetCurrentDirectory(), "plugins");
Directory.CreateDirectory(pluginDirectory);
var pluginConfig = $"""
bootstrapUser:
username: "admin"
password: "changeme"
passwordPolicy:
minimumLength: {minimumLength}
requireUppercase: true
requireLowercase: true
requireDigit: true
requireSymbol: true
""";
File.WriteAllText(Path.Combine(pluginDirectory, "standard.yaml"), pluginConfig);
}
private sealed class ListLogger : ILogger
{
public readonly record struct LogEntry(LogLevel Level, string Message);
public List<LogEntry> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
var message = formatter(state, exception);
Entries.Add(new LogEntry(logLevel, message));
}
}
private sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new();
public void Dispose()
{
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Spectre.Console.Testing" Version="0.48.0" />
<ProjectReference Include="../../StellaOps.Cli/StellaOps.Cli.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Text;
namespace StellaOps.Cli.Tests.Testing;
internal sealed class TempDirectory : IDisposable
{
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-cli-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
try
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
catch
{
// ignored
}
}
}
internal sealed class TempFile : IDisposable
{
public TempFile(string fileName, byte[] contents)
{
var directory = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-cli-file-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
Path = System.IO.Path.Combine(directory, fileName);
File.WriteAllBytes(Path, contents);
}
public string Path { get; }
public void Dispose()
{
try
{
if (File.Exists(Path))
{
File.Delete(Path);
}
var directory = System.IO.Path.GetDirectoryName(Path);
if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
catch
{
// ignored intentionally
}
}
}
internal sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Queue<Func<HttpRequestMessage, CancellationToken, HttpResponseMessage>> _responses;
public StubHttpMessageHandler(params Func<HttpRequestMessage, CancellationToken, HttpResponseMessage>[] handlers)
{
if (handlers is null || handlers.Length == 0)
{
throw new ArgumentException("At least one handler must be provided.", nameof(handlers));
}
_responses = new Queue<Func<HttpRequestMessage, CancellationToken, HttpResponseMessage>>(handlers);
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var factory = _responses.Count > 1 ? _responses.Dequeue() : _responses.Peek();
return Task.FromResult(factory(request, cancellationToken));
}
}

View File

@@ -0,0 +1,10 @@
namespace StellaOps.Cli.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -0,0 +1,3 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
}