Add Authority Advisory AI and API Lifecycle Configuration
- Introduced AuthorityAdvisoryAiOptions and related classes for managing advisory AI configurations, including remote inference options and tenant-specific settings. - Added AuthorityApiLifecycleOptions to control API lifecycle settings, including legacy OAuth endpoint configurations. - Implemented validation and normalization methods for both advisory AI and API lifecycle options to ensure proper configuration. - Created AuthorityNotificationsOptions and its related classes for managing notification settings, including ack tokens, webhooks, and escalation options. - Developed IssuerDirectoryClient and related models for interacting with the issuer directory service, including caching mechanisms and HTTP client configurations. - Added support for dependency injection through ServiceCollectionExtensions for the Issuer Directory Client. - Updated project file to include necessary package references for the new Issuer Directory Client library.
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
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;
|
||||
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static class CommandFactory
|
||||
{
|
||||
public static RootCommand Create(
|
||||
@@ -22,11 +22,11 @@ internal static class CommandFactory
|
||||
{
|
||||
Description = "Enable verbose logging output."
|
||||
};
|
||||
|
||||
var root = new RootCommand("StellaOps command-line interface")
|
||||
{
|
||||
TreatUnmatchedTokensAsErrors = true
|
||||
};
|
||||
|
||||
var root = new RootCommand("StellaOps command-line interface")
|
||||
{
|
||||
TreatUnmatchedTokensAsErrors = true
|
||||
};
|
||||
root.Add(verboseOption);
|
||||
|
||||
root.Add(BuildScannerCommand(services, verboseOption, cancellationToken));
|
||||
@@ -47,108 +47,132 @@ internal static class CommandFactory
|
||||
|
||||
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 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);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
var entryTrace = new Command("entrytrace", "Show entry trace summary for a scan.");
|
||||
var scanIdOption = new Option<string>("--scan-id")
|
||||
{
|
||||
Description = "Scan identifier.",
|
||||
Required = true
|
||||
};
|
||||
var includeNdjsonOption = new Option<bool>("--include-ndjson")
|
||||
{
|
||||
Description = "Include raw NDJSON output."
|
||||
};
|
||||
|
||||
entryTrace.Add(scanIdOption);
|
||||
entryTrace.Add(includeNdjsonOption);
|
||||
|
||||
entryTrace.SetAction((parseResult, _) =>
|
||||
{
|
||||
var id = parseResult.GetValue(scanIdOption) ?? string.Empty;
|
||||
var includeNdjson = parseResult.GetValue(includeNdjsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleScanEntryTraceAsync(services, id, includeNdjson, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
scan.Add(entryTrace);
|
||||
|
||||
scan.Add(run);
|
||||
scan.Add(upload);
|
||||
return scan;
|
||||
}
|
||||
@@ -272,43 +296,43 @@ internal static class CommandFactory
|
||||
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 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."
|
||||
@@ -351,7 +375,7 @@ internal static class CommandFactory
|
||||
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);
|
||||
@@ -1087,25 +1111,25 @@ internal static class CommandFactory
|
||||
|
||||
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..]}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -42,4 +42,6 @@ internal interface IBackendOperationsClient
|
||||
Task<PolicyFindingDocument> GetPolicyFindingAsync(string policyId, string findingId, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyFindingExplainResult> GetPolicyFindingExplainAsync(string policyId, string findingId, string? mode, CancellationToken cancellationToken);
|
||||
|
||||
Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record EntryTraceResponseModel(
|
||||
string ScanId,
|
||||
string ImageDigest,
|
||||
DateTimeOffset GeneratedAt,
|
||||
EntryTraceGraph Graph,
|
||||
IReadOnlyList<string> Ndjson);
|
||||
@@ -44,6 +44,7 @@
|
||||
<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" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.EntryTrace/StellaOps.Scanner.EntryTrace.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
> 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.
|
||||
> Docs note (2025-10-26): `docs/modules/cli/guides/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`.
|
||||
> 2025-11-01: Update CLI auth defaults to request `attestor.verify` (and `attestor.read` for list/detail) after Attestor scope split; tokens without new scopes will fail verification calls.
|
||||
|
||||
## Policy Engine v2
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
@@ -25,6 +26,7 @@ using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Cli.Tests.Testing;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Kms;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Testing;
|
||||
|
||||
@@ -82,11 +84,11 @@ public sealed class CommandHandlersTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleScannerRunAsync_AutomaticallyUploadsResults()
|
||||
{
|
||||
using var tempDir = new TempDirectory();
|
||||
var resultsFile = Path.Combine(tempDir.Path, "results", "scan.json");
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", null, null));
|
||||
public async Task HandleScannerRunAsync_AutomaticallyUploadsResults()
|
||||
{
|
||||
using var tempDir = new TempDirectory();
|
||||
var resultsFile = Path.Combine(tempDir.Path, "results", "scan.json");
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", null, null));
|
||||
var metadataFile = Path.Combine(tempDir.Path, "results", "scan-run.json");
|
||||
var executor = new StubExecutor(new ScannerExecutionResult(0, resultsFile, metadataFile));
|
||||
var options = new StellaOpsCliOptions
|
||||
@@ -117,13 +119,114 @@ public sealed class CommandHandlersTests
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthLoginAsync_UsesClientCredentialsFlow()
|
||||
{
|
||||
var original = Environment.ExitCode;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleScanEntryTraceAsync_RendersPlansAndNdjson()
|
||||
{
|
||||
var originalExit = Environment.ExitCode;
|
||||
var console = new TestConsole();
|
||||
var originalConsole = AnsiConsole.Console;
|
||||
|
||||
var graph = new EntryTraceGraph(
|
||||
EntryTraceOutcome.Resolved,
|
||||
ImmutableArray<EntryTraceNode>.Empty,
|
||||
ImmutableArray<EntryTraceEdge>.Empty,
|
||||
ImmutableArray<EntryTraceDiagnostic>.Empty,
|
||||
ImmutableArray.Create(new EntryTracePlan(
|
||||
ImmutableArray.Create("/usr/bin/python", "app.py"),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
"/workspace",
|
||||
"appuser",
|
||||
"/usr/bin/python",
|
||||
EntryTraceTerminalType.Managed,
|
||||
"python",
|
||||
0.95,
|
||||
ImmutableDictionary<string, string>.Empty)),
|
||||
ImmutableArray.Create(new EntryTraceTerminal(
|
||||
"/usr/bin/python",
|
||||
EntryTraceTerminalType.Managed,
|
||||
"python",
|
||||
0.95,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
"appuser",
|
||||
"/workspace",
|
||||
ImmutableArray<string>.Empty)));
|
||||
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", null, null))
|
||||
{
|
||||
EntryTraceResponse = new EntryTraceResponseModel(
|
||||
"scan-123",
|
||||
"sha256:deadbeef",
|
||||
DateTimeOffset.Parse("2025-11-02T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal),
|
||||
graph,
|
||||
new[] { "{\"type\":\"terminal\"}" })
|
||||
};
|
||||
|
||||
var provider = BuildServiceProvider(backend);
|
||||
AnsiConsole.Console = console;
|
||||
|
||||
try
|
||||
{
|
||||
await CommandHandlers.HandleScanEntryTraceAsync(
|
||||
provider,
|
||||
"scan-123",
|
||||
includeNdjson: true,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
Assert.Equal("scan-123", backend.LastEntryTraceScanId);
|
||||
|
||||
var output = console.Output;
|
||||
Assert.Contains("scan-123", output, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("NDJSON Output", output, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("{\"type\":\"terminal\"}", output, StringComparison.Ordinal);
|
||||
Assert.Contains("/usr/bin/python", output, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = originalExit;
|
||||
AnsiConsole.Console = originalConsole;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleScanEntryTraceAsync_WarnsWhenResultMissing()
|
||||
{
|
||||
var originalExit = Environment.ExitCode;
|
||||
var console = new TestConsole();
|
||||
var originalConsole = AnsiConsole.Console;
|
||||
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", null, null));
|
||||
var provider = BuildServiceProvider(backend);
|
||||
AnsiConsole.Console = console;
|
||||
|
||||
try
|
||||
{
|
||||
await CommandHandlers.HandleScanEntryTraceAsync(
|
||||
provider,
|
||||
"scan-missing",
|
||||
includeNdjson: false,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, Environment.ExitCode);
|
||||
Assert.Equal("scan-missing", backend.LastEntryTraceScanId);
|
||||
Assert.Contains("No EntryTrace data", console.Output, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = originalExit;
|
||||
AnsiConsole.Console = originalConsole;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthLoginAsync_UsesClientCredentialsFlow()
|
||||
{
|
||||
var original = Environment.ExitCode;
|
||||
using var tempDir = new TempDirectory();
|
||||
|
||||
try
|
||||
@@ -2327,13 +2430,16 @@ public sealed class CommandHandlersTests
|
||||
null);
|
||||
public (string PolicyId, string FindingId)? LastFindingGet { get; private set; }
|
||||
public PolicyApiException? FindingGetException { get; set; }
|
||||
public PolicyFindingExplainResult ExplainResult { get; set; } = new PolicyFindingExplainResult(
|
||||
"finding-default",
|
||||
1,
|
||||
new ReadOnlyCollection<PolicyFindingExplainStep>(Array.Empty<PolicyFindingExplainStep>()),
|
||||
new ReadOnlyCollection<PolicyFindingExplainHint>(Array.Empty<PolicyFindingExplainHint>()));
|
||||
public (string PolicyId, string FindingId, string? Mode)? LastFindingExplain { get; private set; }
|
||||
public PolicyApiException? FindingExplainException { get; set; }
|
||||
public PolicyFindingExplainResult ExplainResult { get; set; } = new PolicyFindingExplainResult(
|
||||
"finding-default",
|
||||
1,
|
||||
new ReadOnlyCollection<PolicyFindingExplainStep>(Array.Empty<PolicyFindingExplainStep>()),
|
||||
new ReadOnlyCollection<PolicyFindingExplainHint>(Array.Empty<PolicyFindingExplainHint>()));
|
||||
public (string PolicyId, string FindingId, string? Mode)? LastFindingExplain { get; private set; }
|
||||
public PolicyApiException? FindingExplainException { get; set; }
|
||||
public EntryTraceResponseModel? EntryTraceResponse { get; set; }
|
||||
public Exception? EntryTraceException { get; set; }
|
||||
public string? LastEntryTraceScanId { get; private set; }
|
||||
|
||||
public Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
@@ -2445,27 +2551,37 @@ public sealed class CommandHandlersTests
|
||||
return Task.FromResult(FindingDocument);
|
||||
}
|
||||
|
||||
public Task<PolicyFindingExplainResult> GetPolicyFindingExplainAsync(string policyId, string findingId, string? mode, CancellationToken cancellationToken)
|
||||
{
|
||||
LastFindingExplain = (policyId, findingId, mode);
|
||||
if (FindingExplainException is not null)
|
||||
{
|
||||
throw FindingExplainException;
|
||||
}
|
||||
|
||||
return Task.FromResult(ExplainResult);
|
||||
}
|
||||
|
||||
|
||||
public Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken)
|
||||
public Task<PolicyFindingExplainResult> GetPolicyFindingExplainAsync(string policyId, string findingId, string? mode, CancellationToken cancellationToken)
|
||||
{
|
||||
LastFindingExplain = (policyId, findingId, mode);
|
||||
if (FindingExplainException is not null)
|
||||
{
|
||||
throw FindingExplainException;
|
||||
}
|
||||
|
||||
return Task.FromResult(ExplainResult);
|
||||
}
|
||||
|
||||
public Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<OfflineKitImportResult> ImportOfflineKitAsync(OfflineKitImportRequest request, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<OfflineKitImportResult> ImportOfflineKitAsync(OfflineKitImportRequest request, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<OfflineKitStatus> GetOfflineKitStatusAsync(CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
public Task<OfflineKitStatus> GetOfflineKitStatusAsync(CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
LastEntryTraceScanId = scanId;
|
||||
if (EntryTraceException is not null)
|
||||
{
|
||||
throw EntryTraceException;
|
||||
}
|
||||
|
||||
return Task.FromResult(EntryTraceResponse);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubExecutor : IScannerExecutor
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
@@ -17,9 +18,10 @@ using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
using StellaOps.Cli.Tests.Testing;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
using StellaOps.Cli.Tests.Testing;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Services;
|
||||
@@ -170,11 +172,11 @@ public sealed class BackendOperationsClientTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadScanResultsAsync_RetriesOnRetryAfter()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
var filePath = Path.Combine(temp.Path, "scan.json");
|
||||
await File.WriteAllTextAsync(filePath, "{}");
|
||||
public async Task UploadScanResultsAsync_RetriesOnRetryAfter()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
var filePath = Path.Combine(temp.Path, "scan.json");
|
||||
await File.WriteAllTextAsync(filePath, "{}");
|
||||
|
||||
var attempts = 0;
|
||||
var handler = new StubHttpMessageHandler(
|
||||
@@ -250,9 +252,103 @@ public sealed class BackendOperationsClientTests
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => client.UploadScanResultsAsync(filePath, CancellationToken.None));
|
||||
Assert.Equal(2, attempts);
|
||||
}
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => client.UploadScanResultsAsync(filePath, CancellationToken.None));
|
||||
Assert.Equal(2, attempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntryTraceAsync_ReturnsResponse()
|
||||
{
|
||||
var scanId = $"scan-{Guid.NewGuid():n}";
|
||||
var generatedAt = new DateTimeOffset(2025, 11, 1, 8, 30, 0, TimeSpan.Zero);
|
||||
var plan = new EntryTracePlan(
|
||||
ImmutableArray.Create("/usr/bin/app"),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
"/work",
|
||||
"root",
|
||||
"/usr/bin/app",
|
||||
EntryTraceTerminalType.Native,
|
||||
"go",
|
||||
80d,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
var terminal = new EntryTraceTerminal(
|
||||
"/usr/bin/app",
|
||||
EntryTraceTerminalType.Native,
|
||||
"go",
|
||||
80d,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
"root",
|
||||
"/work",
|
||||
ImmutableArray<string>.Empty);
|
||||
var graph = new EntryTraceGraph(
|
||||
EntryTraceOutcome.Resolved,
|
||||
ImmutableArray<EntryTraceNode>.Empty,
|
||||
ImmutableArray<EntryTraceEdge>.Empty,
|
||||
ImmutableArray<EntryTraceDiagnostic>.Empty,
|
||||
ImmutableArray.Create(plan),
|
||||
ImmutableArray.Create(terminal));
|
||||
var responseModel = new EntryTraceResponseModel(
|
||||
scanId,
|
||||
"sha256:test",
|
||||
generatedAt,
|
||||
graph,
|
||||
EntryTraceNdjsonWriter.Serialize(graph, new EntryTraceNdjsonMetadata(scanId, "sha256:test", generatedAt)));
|
||||
var json = JsonSerializer.Serialize(responseModel, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
var handler = new StubHttpMessageHandler((request, _) =>
|
||||
{
|
||||
var message = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
return message;
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://scanner.example")
|
||||
};
|
||||
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
BackendUrl = "https://scanner.example"
|
||||
};
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
|
||||
|
||||
var result = await client.GetEntryTraceAsync(scanId, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(responseModel.ScanId, result!.ScanId);
|
||||
Assert.Equal(responseModel.ImageDigest, result.ImageDigest);
|
||||
Assert.Equal(responseModel.Graph.Plans.Length, result.Graph.Plans.Length);
|
||||
Assert.Equal(responseModel.Ndjson.Count, result.Ndjson.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntryTraceAsync_ReturnsNullWhenNotFound()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler((request, _) => new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
RequestMessage = request
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://scanner.example")
|
||||
};
|
||||
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
BackendUrl = "https://scanner.example"
|
||||
};
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
|
||||
|
||||
var result = await client.GetEntryTraceAsync("scan-missing", CancellationToken.None);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerJobAsync_ReturnsAcceptedResult()
|
||||
@@ -809,13 +905,13 @@ public sealed class BackendOperationsClientTests
|
||||
switch (name)
|
||||
{
|
||||
case "metadata":
|
||||
MetadataJson = await part.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
MetadataJson = await part.ReadAsStringAsync(cancellationToken);
|
||||
break;
|
||||
case "bundle":
|
||||
BundlePayload = await part.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
BundlePayload = await part.ReadAsByteArrayAsync(cancellationToken);
|
||||
break;
|
||||
case "manifest":
|
||||
ManifestPayload = await part.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
ManifestPayload = await part.ReadAsByteArrayAsync(cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user