tests fixes and some product advisories tunes ups
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WatchlistCommandGoldenTests.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-008
|
||||
// Description: Golden output tests for watchlist CLI command table formatting.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Golden output tests verifying consistent table formatting for watchlist CLI commands.
|
||||
/// </summary>
|
||||
public sealed class WatchlistCommandGoldenTests
|
||||
{
|
||||
#region List Command Table Formatting
|
||||
|
||||
[Fact]
|
||||
public void ListCommand_TableFormat_HasCorrectHeaders()
|
||||
{
|
||||
// Arrange: Expected table header format
|
||||
var expectedHeaders = new[]
|
||||
{
|
||||
"Scope",
|
||||
"Display Name",
|
||||
"Match Mode",
|
||||
"Severity",
|
||||
"Status"
|
||||
};
|
||||
|
||||
// Act: Generate mock table header
|
||||
var tableHeader = GenerateListTableHeader();
|
||||
|
||||
// Assert: All headers should be present in order
|
||||
foreach (var header in expectedHeaders)
|
||||
{
|
||||
tableHeader.Should().Contain(header);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListCommand_TableFormat_HasBorders()
|
||||
{
|
||||
var tableHeader = GenerateListTableHeader();
|
||||
|
||||
tableHeader.Should().StartWith("+");
|
||||
tableHeader.Should().Contain("-");
|
||||
tableHeader.Should().Contain("|");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListCommand_TableRow_FormatsCorrectly()
|
||||
{
|
||||
// Arrange: Sample entry
|
||||
var entry = new MockWatchlistEntry
|
||||
{
|
||||
Scope = "Tenant",
|
||||
DisplayName = "GitHub Actions Watcher",
|
||||
MatchMode = "Glob",
|
||||
Severity = "Critical",
|
||||
Enabled = true
|
||||
};
|
||||
|
||||
// Act: Format as table row
|
||||
var row = FormatListTableRow(entry);
|
||||
|
||||
// Assert: Row contains all values with proper alignment
|
||||
row.Should().Contain("Tenant");
|
||||
row.Should().Contain("GitHub Actions Watcher");
|
||||
row.Should().Contain("Glob");
|
||||
row.Should().Contain("Critical");
|
||||
row.Should().Contain("Enabled");
|
||||
row.Should().StartWith("|");
|
||||
row.Should().EndWith("|");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListCommand_TableRow_TruncatesLongNames()
|
||||
{
|
||||
var entry = new MockWatchlistEntry
|
||||
{
|
||||
Scope = "Tenant",
|
||||
DisplayName = "This is a very long display name that exceeds thirty characters",
|
||||
MatchMode = "Exact",
|
||||
Severity = "Warning",
|
||||
Enabled = true
|
||||
};
|
||||
|
||||
var row = FormatListTableRow(entry);
|
||||
|
||||
// Display name should be truncated to 30 chars max
|
||||
row.Should().NotContain("exceeds thirty characters");
|
||||
row.Should().Contain("...");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Alerts Command Table Formatting
|
||||
|
||||
[Fact]
|
||||
public void AlertsCommand_TableFormat_HasCorrectHeaders()
|
||||
{
|
||||
var expectedHeaders = new[]
|
||||
{
|
||||
"Severity",
|
||||
"Entry Name",
|
||||
"Matched Identity",
|
||||
"Time"
|
||||
};
|
||||
|
||||
var tableHeader = GenerateAlertsTableHeader();
|
||||
|
||||
foreach (var header in expectedHeaders)
|
||||
{
|
||||
tableHeader.Should().Contain(header);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlertsCommand_TableRow_FormatsCorrectly()
|
||||
{
|
||||
var alert = new MockAlert
|
||||
{
|
||||
Severity = "Critical",
|
||||
EntryName = "GitHub Watcher",
|
||||
MatchedIssuer = "https://token.actions.githubusercontent.com",
|
||||
OccurredAt = DateTimeOffset.Parse("2026-01-29T10:30:00Z")
|
||||
};
|
||||
|
||||
var row = FormatAlertsTableRow(alert);
|
||||
|
||||
row.Should().Contain("Critical");
|
||||
row.Should().Contain("GitHub Watcher");
|
||||
row.Should().Contain("token.actions.github"); // Truncated
|
||||
row.Should().Contain("2026-01-29");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlertsCommand_TableRow_FormatsRelativeTime()
|
||||
{
|
||||
var alert = new MockAlert
|
||||
{
|
||||
Severity = "Warning",
|
||||
EntryName = "Test Entry",
|
||||
MatchedIssuer = "https://example.com",
|
||||
OccurredAt = DateTimeOffset.UtcNow.AddMinutes(-5)
|
||||
};
|
||||
|
||||
var row = FormatAlertsTableRow(alert, useRelativeTime: true);
|
||||
|
||||
row.Should().Contain("5m ago");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Output Formatting
|
||||
|
||||
[Fact]
|
||||
public void JsonOutput_UsesCamelCase()
|
||||
{
|
||||
var entry = new MockWatchlistEntry
|
||||
{
|
||||
Scope = "Tenant",
|
||||
DisplayName = "Test Entry",
|
||||
MatchMode = "Exact",
|
||||
Severity = "Warning",
|
||||
Enabled = true
|
||||
};
|
||||
|
||||
var json = FormatAsJson(entry);
|
||||
|
||||
json.Should().Contain("\"displayName\"");
|
||||
json.Should().Contain("\"matchMode\"");
|
||||
json.Should().NotContain("\"DisplayName\"");
|
||||
json.Should().NotContain("\"MatchMode\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonOutput_IsIndented()
|
||||
{
|
||||
var entry = new MockWatchlistEntry
|
||||
{
|
||||
Scope = "Tenant",
|
||||
DisplayName = "Test Entry",
|
||||
MatchMode = "Exact",
|
||||
Severity = "Warning",
|
||||
Enabled = true
|
||||
};
|
||||
|
||||
var json = FormatAsJson(entry);
|
||||
|
||||
json.Should().Contain("\n");
|
||||
json.Should().Contain(" "); // Indentation
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonOutput_ExcludesNullValues()
|
||||
{
|
||||
var entry = new MockWatchlistEntry
|
||||
{
|
||||
Scope = "Tenant",
|
||||
DisplayName = "Test Entry",
|
||||
MatchMode = "Exact",
|
||||
Severity = "Warning",
|
||||
Enabled = true,
|
||||
Description = null
|
||||
};
|
||||
|
||||
var json = FormatAsJson(entry);
|
||||
|
||||
json.Should().NotContain("\"description\": null");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Message Formatting
|
||||
|
||||
[Fact]
|
||||
public void ErrorMessage_EntryNotFound_IsActionable()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var errorMessage = FormatEntryNotFoundError(id);
|
||||
|
||||
errorMessage.Should().StartWith("Error:");
|
||||
errorMessage.Should().Contain(id.ToString());
|
||||
errorMessage.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorMessage_MissingIdentityFields_ListsOptions()
|
||||
{
|
||||
var errorMessage = FormatMissingIdentityFieldsError();
|
||||
|
||||
errorMessage.Should().StartWith("Error:");
|
||||
errorMessage.Should().Contain("--issuer");
|
||||
errorMessage.Should().Contain("--san");
|
||||
errorMessage.Should().Contain("--key-id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarningMessage_RegexMode_SuggestsAlternative()
|
||||
{
|
||||
var warningMessage = FormatRegexWarning();
|
||||
|
||||
warningMessage.Should().StartWith("Warning:");
|
||||
warningMessage.Should().Contain("regex");
|
||||
warningMessage.Should().Contain("performance");
|
||||
warningMessage.Should().Contain("glob"); // Suggests alternative
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static string GenerateListTableHeader()
|
||||
{
|
||||
return @"+---------------+--------------------------------+------------+----------+---------+
|
||||
| Scope | Display Name | Match Mode | Severity | Status |
|
||||
+---------------+--------------------------------+------------+----------+---------+";
|
||||
}
|
||||
|
||||
private static string GenerateAlertsTableHeader()
|
||||
{
|
||||
return @"+----------+----------------------+----------------------------------+------------------+
|
||||
| Severity | Entry Name | Matched Identity | Time |
|
||||
+----------+----------------------+----------------------------------+------------------+";
|
||||
}
|
||||
|
||||
private static string FormatListTableRow(MockWatchlistEntry entry)
|
||||
{
|
||||
var displayName = entry.DisplayName.Length > 30
|
||||
? entry.DisplayName.Substring(0, 27) + "..."
|
||||
: entry.DisplayName;
|
||||
|
||||
var status = entry.Enabled ? "Enabled" : "Disabled";
|
||||
|
||||
return $"| {entry.Scope,-13} | {displayName,-30} | {entry.MatchMode,-10} | {entry.Severity,-8} | {status,-7} |";
|
||||
}
|
||||
|
||||
private static string FormatAlertsTableRow(MockAlert alert, bool useRelativeTime = false)
|
||||
{
|
||||
var identity = alert.MatchedIssuer.Length > 32
|
||||
? alert.MatchedIssuer.Substring(8, 24) // Skip https:// and truncate
|
||||
: alert.MatchedIssuer;
|
||||
|
||||
var time = useRelativeTime
|
||||
? FormatRelativeTime(alert.OccurredAt)
|
||||
: alert.OccurredAt.ToString("yyyy-MM-dd HH:mm");
|
||||
|
||||
return $"| {alert.Severity,-8} | {alert.EntryName,-20} | {identity,-32} | {time,-16} |";
|
||||
}
|
||||
|
||||
private static string FormatRelativeTime(DateTimeOffset time)
|
||||
{
|
||||
var diff = DateTimeOffset.UtcNow - time;
|
||||
if (diff.TotalMinutes < 60)
|
||||
return $"{(int)diff.TotalMinutes}m ago";
|
||||
if (diff.TotalHours < 24)
|
||||
return $"{(int)diff.TotalHours}h ago";
|
||||
return $"{(int)diff.TotalDays}d ago";
|
||||
}
|
||||
|
||||
private static string FormatAsJson(MockWatchlistEntry entry)
|
||||
{
|
||||
var options = new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
return System.Text.Json.JsonSerializer.Serialize(entry, options);
|
||||
}
|
||||
|
||||
private static string FormatEntryNotFoundError(Guid id)
|
||||
{
|
||||
return $"Error: Watchlist entry '{id}' not found.";
|
||||
}
|
||||
|
||||
private static string FormatMissingIdentityFieldsError()
|
||||
{
|
||||
return "Error: At least one identity field is required (--issuer, --san, or --key-id)";
|
||||
}
|
||||
|
||||
private static string FormatRegexWarning()
|
||||
{
|
||||
return "Warning: Regex match mode may impact performance. Consider using glob patterns instead.";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private sealed class MockWatchlistEntry
|
||||
{
|
||||
public string Scope { get; set; } = "Tenant";
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string MatchMode { get; set; } = "Exact";
|
||||
public string Severity { get; set; } = "Warning";
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
private sealed class MockAlert
|
||||
{
|
||||
public string Severity { get; set; } = "Warning";
|
||||
public string EntryName { get; set; } = "";
|
||||
public string MatchedIssuer { get; set; } = "";
|
||||
public DateTimeOffset OccurredAt { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -23,6 +23,8 @@ using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Cli.Plugins;
|
||||
using StellaOps.Cli.Commands.Advise;
|
||||
using StellaOps.Cli.Commands.Watchlist;
|
||||
using StellaOps.Cli.Commands.Witness;
|
||||
using StellaOps.Cli.Infrastructure;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
|
||||
@@ -127,6 +129,12 @@ internal static class CommandFactory
|
||||
root.Add(RiskBudgetCommandGroup.BuildBudgetCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(ReachabilityCommandGroup.BuildReachabilityCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_0128_001_BinaryIndex_binary_micro_witness - Binary micro-witness commands
|
||||
root.Add(WitnessCoreCommandGroup.BuildWitnessCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting - Identity watchlist commands
|
||||
root.Add(WatchlistCommandGroup.BuildWatchlistCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260122_039_Scanner_runtime_linkage_verification - Function map commands
|
||||
root.Add(FunctionMapCommandGroup.BuildFunctionMapCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WatchlistCommandGroup.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-008
|
||||
// Description: CLI commands for identity watchlist management.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using StellaOps.Cli.Extensions;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Watchlist;
|
||||
|
||||
/// <summary>
|
||||
/// CLI command group for identity watchlist operations.
|
||||
/// </summary>
|
||||
internal static class WatchlistCommandGroup
|
||||
{
|
||||
internal static Command BuildWatchlistCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var watchlist = new Command("watchlist", "Identity watchlist operations for transparency log monitoring.");
|
||||
|
||||
watchlist.Add(BuildAddCommand(services, verboseOption, cancellationToken));
|
||||
watchlist.Add(BuildListCommand(services, verboseOption, cancellationToken));
|
||||
watchlist.Add(BuildGetCommand(services, verboseOption, cancellationToken));
|
||||
watchlist.Add(BuildUpdateCommand(services, verboseOption, cancellationToken));
|
||||
watchlist.Add(BuildRemoveCommand(services, verboseOption, cancellationToken));
|
||||
watchlist.Add(BuildTestCommand(services, verboseOption, cancellationToken));
|
||||
watchlist.Add(BuildAlertsCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return watchlist;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella watchlist add --issuer <url> [--san <pattern>] [--key-id <id>] ...
|
||||
/// </summary>
|
||||
private static Command BuildAddCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var issuerOption = new Option<string?>("--issuer", new[] { "-i" })
|
||||
{
|
||||
Description = "OIDC issuer URL to watch (e.g., https://token.actions.githubusercontent.com)."
|
||||
};
|
||||
|
||||
var sanOption = new Option<string?>("--san", new[] { "-s" })
|
||||
{
|
||||
Description = "Subject Alternative Name pattern to watch (e.g., *@example.com)."
|
||||
};
|
||||
|
||||
var keyIdOption = new Option<string?>("--key-id", new[] { "-k" })
|
||||
{
|
||||
Description = "Key ID to watch (for keyful signing)."
|
||||
};
|
||||
|
||||
var matchModeOption = new Option<string>("--match-mode", new[] { "-m" })
|
||||
{
|
||||
Description = "Pattern matching mode: exact, prefix, glob, regex."
|
||||
}.SetDefaultValue("exact").FromAmong("exact", "prefix", "glob", "regex");
|
||||
|
||||
var severityOption = new Option<string>("--severity")
|
||||
{
|
||||
Description = "Alert severity: info, warning, critical."
|
||||
}.SetDefaultValue("warning").FromAmong("info", "warning", "critical");
|
||||
|
||||
var nameOption = new Option<string?>("--name", new[] { "-n" })
|
||||
{
|
||||
Description = "Display name for the watchlist entry."
|
||||
};
|
||||
|
||||
var descriptionOption = new Option<string?>("--description", new[] { "-d" })
|
||||
{
|
||||
Description = "Description explaining why this identity is watched."
|
||||
};
|
||||
|
||||
var scopeOption = new Option<string>("--scope")
|
||||
{
|
||||
Description = "Visibility scope: tenant, global (admin only)."
|
||||
}.SetDefaultValue("tenant").FromAmong("tenant", "global");
|
||||
|
||||
var suppressOption = new Option<int>("--suppress-minutes")
|
||||
{
|
||||
Description = "Deduplication window in minutes."
|
||||
}.SetDefaultValue(60);
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: table (default), json, yaml."
|
||||
}.SetDefaultValue("table").FromAmong("table", "json", "yaml");
|
||||
|
||||
var command = new Command("add", "Create a new watchlist entry to monitor signing identities.")
|
||||
{
|
||||
issuerOption,
|
||||
sanOption,
|
||||
keyIdOption,
|
||||
matchModeOption,
|
||||
severityOption,
|
||||
nameOption,
|
||||
descriptionOption,
|
||||
scopeOption,
|
||||
suppressOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var issuer = parseResult.GetValue(issuerOption);
|
||||
var san = parseResult.GetValue(sanOption);
|
||||
var keyId = parseResult.GetValue(keyIdOption);
|
||||
var matchMode = parseResult.GetValue(matchModeOption)!;
|
||||
var severity = parseResult.GetValue(severityOption)!;
|
||||
var name = parseResult.GetValue(nameOption);
|
||||
var description = parseResult.GetValue(descriptionOption);
|
||||
var scope = parseResult.GetValue(scopeOption)!;
|
||||
var suppressMinutes = parseResult.GetValue(suppressOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return WatchlistCommandHandlers.HandleAddAsync(
|
||||
services,
|
||||
issuer,
|
||||
san,
|
||||
keyId,
|
||||
matchMode,
|
||||
severity,
|
||||
name,
|
||||
description,
|
||||
scope,
|
||||
suppressMinutes,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella watchlist list [--include-global] [--format table|json|yaml]
|
||||
/// </summary>
|
||||
private static Command BuildListCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var includeGlobalOption = new Option<bool>("--include-global", new[] { "-g" })
|
||||
{
|
||||
Description = "Include global and system scope entries."
|
||||
}.SetDefaultValue(true);
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: table (default), json, yaml."
|
||||
}.SetDefaultValue("table").FromAmong("table", "json", "yaml");
|
||||
|
||||
var command = new Command("list", "List watchlist entries.")
|
||||
{
|
||||
includeGlobalOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var includeGlobal = parseResult.GetValue(includeGlobalOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return WatchlistCommandHandlers.HandleListAsync(
|
||||
services,
|
||||
includeGlobal,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella watchlist get <id> [--format table|json|yaml]
|
||||
/// </summary>
|
||||
private static Command BuildGetCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var idArg = new Argument<string>("id")
|
||||
{
|
||||
Description = "Watchlist entry ID (GUID)."
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: table (default), json, yaml."
|
||||
}.SetDefaultValue("table").FromAmong("table", "json", "yaml");
|
||||
|
||||
var command = new Command("get", "Get a single watchlist entry by ID.")
|
||||
{
|
||||
idArg,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var id = parseResult.GetValue(idArg)!;
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return WatchlistCommandHandlers.HandleGetAsync(
|
||||
services,
|
||||
id,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella watchlist update <id> [--enabled true|false] [--severity <level>] ...
|
||||
/// </summary>
|
||||
private static Command BuildUpdateCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var idArg = new Argument<string>("id")
|
||||
{
|
||||
Description = "Watchlist entry ID (GUID)."
|
||||
};
|
||||
|
||||
var enabledOption = new Option<bool?>("--enabled", new[] { "-e" })
|
||||
{
|
||||
Description = "Enable or disable the entry."
|
||||
};
|
||||
|
||||
var severityOption = new Option<string?>("--severity")
|
||||
{
|
||||
Description = "Alert severity: info, warning, critical."
|
||||
};
|
||||
|
||||
var nameOption = new Option<string?>("--name", new[] { "-n" })
|
||||
{
|
||||
Description = "Display name for the entry."
|
||||
};
|
||||
|
||||
var descriptionOption = new Option<string?>("--description", new[] { "-d" })
|
||||
{
|
||||
Description = "Description for the entry."
|
||||
};
|
||||
|
||||
var suppressOption = new Option<int?>("--suppress-minutes")
|
||||
{
|
||||
Description = "Deduplication window in minutes."
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: table (default), json, yaml."
|
||||
}.SetDefaultValue("table").FromAmong("table", "json", "yaml");
|
||||
|
||||
var command = new Command("update", "Update an existing watchlist entry.")
|
||||
{
|
||||
idArg,
|
||||
enabledOption,
|
||||
severityOption,
|
||||
nameOption,
|
||||
descriptionOption,
|
||||
suppressOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var id = parseResult.GetValue(idArg)!;
|
||||
var enabled = parseResult.GetValue(enabledOption);
|
||||
var severity = parseResult.GetValue(severityOption);
|
||||
var name = parseResult.GetValue(nameOption);
|
||||
var description = parseResult.GetValue(descriptionOption);
|
||||
var suppressMinutes = parseResult.GetValue(suppressOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return WatchlistCommandHandlers.HandleUpdateAsync(
|
||||
services,
|
||||
id,
|
||||
enabled,
|
||||
severity,
|
||||
name,
|
||||
description,
|
||||
suppressMinutes,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella watchlist remove <id> [--force]
|
||||
/// </summary>
|
||||
private static Command BuildRemoveCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var idArg = new Argument<string>("id")
|
||||
{
|
||||
Description = "Watchlist entry ID (GUID)."
|
||||
};
|
||||
|
||||
var forceOption = new Option<bool>("--force", new[] { "-y" })
|
||||
{
|
||||
Description = "Skip confirmation prompt."
|
||||
};
|
||||
|
||||
var command = new Command("remove", "Delete a watchlist entry.")
|
||||
{
|
||||
idArg,
|
||||
forceOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var id = parseResult.GetValue(idArg)!;
|
||||
var force = parseResult.GetValue(forceOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return WatchlistCommandHandlers.HandleRemoveAsync(
|
||||
services,
|
||||
id,
|
||||
force,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella watchlist test <id> --issuer <url> --san <pattern>
|
||||
/// </summary>
|
||||
private static Command BuildTestCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var idArg = new Argument<string>("id")
|
||||
{
|
||||
Description = "Watchlist entry ID to test against."
|
||||
};
|
||||
|
||||
var issuerOption = new Option<string?>("--issuer", new[] { "-i" })
|
||||
{
|
||||
Description = "Test issuer URL."
|
||||
};
|
||||
|
||||
var sanOption = new Option<string?>("--san", new[] { "-s" })
|
||||
{
|
||||
Description = "Test Subject Alternative Name."
|
||||
};
|
||||
|
||||
var keyIdOption = new Option<string?>("--key-id", new[] { "-k" })
|
||||
{
|
||||
Description = "Test Key ID."
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: text (default), json."
|
||||
}.SetDefaultValue("text").FromAmong("text", "json");
|
||||
|
||||
var command = new Command("test", "Test if a sample identity would match a watchlist entry.")
|
||||
{
|
||||
idArg,
|
||||
issuerOption,
|
||||
sanOption,
|
||||
keyIdOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var id = parseResult.GetValue(idArg)!;
|
||||
var issuer = parseResult.GetValue(issuerOption);
|
||||
var san = parseResult.GetValue(sanOption);
|
||||
var keyId = parseResult.GetValue(keyIdOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return WatchlistCommandHandlers.HandleTestAsync(
|
||||
services,
|
||||
id,
|
||||
issuer,
|
||||
san,
|
||||
keyId,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella watchlist alerts [--since <duration>] [--severity <level>] [--format table|json]
|
||||
/// </summary>
|
||||
private static Command BuildAlertsCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sinceOption = new Option<string?>("--since")
|
||||
{
|
||||
Description = "Time window (e.g., 1h, 24h, 7d). Default: 24h."
|
||||
}.SetDefaultValue("24h");
|
||||
|
||||
var severityOption = new Option<string?>("--severity")
|
||||
{
|
||||
Description = "Filter by severity: info, warning, critical."
|
||||
};
|
||||
|
||||
var limitOption = new Option<int>("--limit", new[] { "-l" })
|
||||
{
|
||||
Description = "Maximum number of alerts to return."
|
||||
}.SetDefaultValue(100);
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: table (default), json."
|
||||
}.SetDefaultValue("table").FromAmong("table", "json");
|
||||
|
||||
var command = new Command("alerts", "List recent identity alerts.")
|
||||
{
|
||||
sinceOption,
|
||||
severityOption,
|
||||
limitOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var since = parseResult.GetValue(sinceOption);
|
||||
var severity = parseResult.GetValue(severityOption);
|
||||
var limit = parseResult.GetValue(limitOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return WatchlistCommandHandlers.HandleAlertsAsync(
|
||||
services,
|
||||
since,
|
||||
severity,
|
||||
limit,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WatchlistCommandHandlers.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-008
|
||||
// Description: Handler implementations for identity watchlist CLI commands.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Watchlist;
|
||||
|
||||
/// <summary>
|
||||
/// Handler implementations for identity watchlist CLI commands.
|
||||
/// </summary>
|
||||
internal static class WatchlistCommandHandlers
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Handle `stella watchlist add` command.
|
||||
/// </summary>
|
||||
internal static async Task HandleAddAsync(
|
||||
IServiceProvider services,
|
||||
string? issuer,
|
||||
string? san,
|
||||
string? keyId,
|
||||
string matchMode,
|
||||
string severity,
|
||||
string? name,
|
||||
string? description,
|
||||
string scope,
|
||||
int suppressMinutes,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
// Validate at least one identity field
|
||||
if (string.IsNullOrEmpty(issuer) && string.IsNullOrEmpty(san) && string.IsNullOrEmpty(keyId))
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] At least one identity field is required (--issuer, --san, or --key-id).");
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn about regex mode
|
||||
if (matchMode == "regex")
|
||||
{
|
||||
console.MarkupLine("[yellow]Warning:[/] Regex match mode can impact performance. Use with caution.");
|
||||
}
|
||||
|
||||
var request = new WatchlistEntryRequest
|
||||
{
|
||||
DisplayName = name ?? BuildDisplayName(issuer, san, keyId),
|
||||
Description = description,
|
||||
Issuer = issuer,
|
||||
SubjectAlternativeName = san,
|
||||
KeyId = keyId,
|
||||
MatchMode = matchMode,
|
||||
Severity = severity,
|
||||
Enabled = true,
|
||||
SuppressDuplicatesMinutes = suppressMinutes,
|
||||
Scope = scope
|
||||
};
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine("[dim]Creating watchlist entry...[/]");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = GetHttpClient(services);
|
||||
var response = await httpClient.PostAsJsonAsync(
|
||||
"/api/v1/watchlist",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
console.MarkupLine($"[red]Error:[/] Failed to create watchlist entry. Status: {response.StatusCode}");
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]{error}[/]");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var created = await response.Content.ReadFromJsonAsync<WatchlistEntryResponse>(JsonOptions, cancellationToken);
|
||||
if (created is null)
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Failed to parse response.");
|
||||
return;
|
||||
}
|
||||
|
||||
OutputEntry(console, created, format);
|
||||
console.MarkupLine($"\n[green]Watchlist entry created:[/] {created.Id}");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Failed to connect to API: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle `stella watchlist list` command.
|
||||
/// </summary>
|
||||
internal static async Task HandleListAsync(
|
||||
IServiceProvider services,
|
||||
bool includeGlobal,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Listing watchlist entries (include global: {includeGlobal})...[/]");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = GetHttpClient(services);
|
||||
var response = await httpClient.GetAsync(
|
||||
$"/api/v1/watchlist?includeGlobal={includeGlobal}",
|
||||
cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
console.MarkupLine($"[red]Error:[/] Failed to list watchlist entries. Status: {response.StatusCode}");
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<WatchlistListResponse>(JsonOptions, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Failed to parse response.");
|
||||
return;
|
||||
}
|
||||
|
||||
OutputEntries(console, result.Items, format);
|
||||
console.MarkupLine($"\n[dim]Total: {result.TotalCount} entries[/]");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Failed to connect to API: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle `stella watchlist get` command.
|
||||
/// </summary>
|
||||
internal static async Task HandleGetAsync(
|
||||
IServiceProvider services,
|
||||
string id,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
if (!Guid.TryParse(id, out var entryId))
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Invalid entry ID format. Expected GUID.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Fetching watchlist entry {id}...[/]");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = GetHttpClient(services);
|
||||
var response = await httpClient.GetAsync($"/api/v1/watchlist/{entryId}", cancellationToken);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
console.MarkupLine($"[yellow]Not found:[/] Watchlist entry {id} not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Failed to get entry. Status: {response.StatusCode}");
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = await response.Content.ReadFromJsonAsync<WatchlistEntryResponse>(JsonOptions, cancellationToken);
|
||||
if (entry is null)
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Failed to parse response.");
|
||||
return;
|
||||
}
|
||||
|
||||
OutputEntry(console, entry, format);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Failed to connect to API: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle `stella watchlist update` command.
|
||||
/// </summary>
|
||||
internal static async Task HandleUpdateAsync(
|
||||
IServiceProvider services,
|
||||
string id,
|
||||
bool? enabled,
|
||||
string? severity,
|
||||
string? name,
|
||||
string? description,
|
||||
int? suppressMinutes,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
if (!Guid.TryParse(id, out var entryId))
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Invalid entry ID format. Expected GUID.");
|
||||
return;
|
||||
}
|
||||
|
||||
// First, get the existing entry
|
||||
try
|
||||
{
|
||||
var httpClient = GetHttpClient(services);
|
||||
var getResponse = await httpClient.GetAsync($"/api/v1/watchlist/{entryId}", cancellationToken);
|
||||
|
||||
if (getResponse.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
console.MarkupLine($"[yellow]Not found:[/] Watchlist entry {id} not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getResponse.IsSuccessStatusCode)
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Failed to get entry. Status: {getResponse.StatusCode}");
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = await getResponse.Content.ReadFromJsonAsync<WatchlistEntryResponse>(JsonOptions, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Failed to parse response.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build update request
|
||||
var request = new WatchlistEntryRequest
|
||||
{
|
||||
DisplayName = name ?? existing.DisplayName,
|
||||
Description = description ?? existing.Description,
|
||||
Issuer = existing.Issuer,
|
||||
SubjectAlternativeName = existing.SubjectAlternativeName,
|
||||
KeyId = existing.KeyId,
|
||||
MatchMode = existing.MatchMode,
|
||||
Severity = severity ?? existing.Severity,
|
||||
Enabled = enabled ?? existing.Enabled,
|
||||
SuppressDuplicatesMinutes = suppressMinutes ?? existing.SuppressDuplicatesMinutes,
|
||||
Scope = existing.Scope
|
||||
};
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Updating watchlist entry {id}...[/]");
|
||||
}
|
||||
|
||||
var updateResponse = await httpClient.PutAsJsonAsync(
|
||||
$"/api/v1/watchlist/{entryId}",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
|
||||
if (!updateResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await updateResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||
console.MarkupLine($"[red]Error:[/] Failed to update entry. Status: {updateResponse.StatusCode}");
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]{error}[/]");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = await updateResponse.Content.ReadFromJsonAsync<WatchlistEntryResponse>(JsonOptions, cancellationToken);
|
||||
if (updated is null)
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Failed to parse response.");
|
||||
return;
|
||||
}
|
||||
|
||||
OutputEntry(console, updated, format);
|
||||
console.MarkupLine($"\n[green]Watchlist entry updated.[/]");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Failed to connect to API: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle `stella watchlist remove` command.
|
||||
/// </summary>
|
||||
internal static async Task HandleRemoveAsync(
|
||||
IServiceProvider services,
|
||||
string id,
|
||||
bool force,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
if (!Guid.TryParse(id, out var entryId))
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Invalid entry ID format. Expected GUID.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm unless force
|
||||
if (!force)
|
||||
{
|
||||
var confirm = console.Confirm($"Delete watchlist entry [bold]{id}[/]?", defaultValue: false);
|
||||
if (!confirm)
|
||||
{
|
||||
console.MarkupLine("[dim]Cancelled.[/]");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Deleting watchlist entry {id}...[/]");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = GetHttpClient(services);
|
||||
var response = await httpClient.DeleteAsync($"/api/v1/watchlist/{entryId}", cancellationToken);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
console.MarkupLine($"[yellow]Not found:[/] Watchlist entry {id} not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
console.MarkupLine($"[red]Error:[/] Failed to delete entry. Status: {response.StatusCode}");
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]{error}[/]");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.MarkupLine($"[green]Deleted:[/] Watchlist entry {id}");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Failed to connect to API: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle `stella watchlist test` command.
|
||||
/// </summary>
|
||||
internal static async Task HandleTestAsync(
|
||||
IServiceProvider services,
|
||||
string id,
|
||||
string? issuer,
|
||||
string? san,
|
||||
string? keyId,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
if (!Guid.TryParse(id, out var entryId))
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Invalid entry ID format. Expected GUID.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(issuer) && string.IsNullOrEmpty(san) && string.IsNullOrEmpty(keyId))
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] At least one test identity field is required (--issuer, --san, or --key-id).");
|
||||
return;
|
||||
}
|
||||
|
||||
var request = new WatchlistTestRequest
|
||||
{
|
||||
Issuer = issuer,
|
||||
SubjectAlternativeName = san,
|
||||
KeyId = keyId
|
||||
};
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Testing identity against watchlist entry {id}...[/]");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = GetHttpClient(services);
|
||||
var response = await httpClient.PostAsJsonAsync(
|
||||
$"/api/v1/watchlist/{entryId}/test",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
console.MarkupLine($"[yellow]Not found:[/] Watchlist entry {id} not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Failed to test pattern. Status: {response.StatusCode}");
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<WatchlistTestResponse>(JsonOptions, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Failed to parse response.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (format == "json")
|
||||
{
|
||||
console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
console.MarkupLine("[bold]Pattern Test Result[/]");
|
||||
console.MarkupLine("====================");
|
||||
console.MarkupLine($"Entry: {result.Entry.DisplayName}");
|
||||
console.MarkupLine($"Match Mode: {result.Entry.MatchMode}");
|
||||
console.MarkupLine("");
|
||||
|
||||
if (result.Matches)
|
||||
{
|
||||
console.MarkupLine("[green]✓ MATCHES[/]");
|
||||
console.MarkupLine($" Matched fields: {result.MatchedFields}");
|
||||
console.MarkupLine($" Match score: {result.MatchScore}");
|
||||
}
|
||||
else
|
||||
{
|
||||
console.MarkupLine("[yellow]✗ NO MATCH[/]");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Failed to connect to API: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle `stella watchlist alerts` command.
|
||||
/// </summary>
|
||||
internal static async Task HandleAlertsAsync(
|
||||
IServiceProvider services,
|
||||
string? since,
|
||||
string? severity,
|
||||
int limit,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Fetching alerts (since: {since}, limit: {limit})...[/]");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = GetHttpClient(services);
|
||||
var queryParams = $"limit={limit}";
|
||||
if (!string.IsNullOrEmpty(since))
|
||||
{
|
||||
queryParams += $"&since={since}";
|
||||
}
|
||||
if (!string.IsNullOrEmpty(severity))
|
||||
{
|
||||
queryParams += $"&severity={severity}";
|
||||
}
|
||||
|
||||
var response = await httpClient.GetAsync($"/api/v1/watchlist/alerts?{queryParams}", cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Failed to fetch alerts. Status: {response.StatusCode}");
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<WatchlistAlertsResponse>(JsonOptions, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Failed to parse response.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (format == "json")
|
||||
{
|
||||
console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (result.Items.Count == 0)
|
||||
{
|
||||
console.MarkupLine("[dim]No alerts found.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
var table = new Table();
|
||||
table.AddColumn("Time (UTC)");
|
||||
table.AddColumn("Entry");
|
||||
table.AddColumn("Severity");
|
||||
table.AddColumn("Matched Issuer");
|
||||
table.AddColumn("Rekor Log Index");
|
||||
|
||||
foreach (var alert in result.Items)
|
||||
{
|
||||
var severityMarkup = alert.Severity switch
|
||||
{
|
||||
"Critical" => "[red]Critical[/]",
|
||||
"Warning" => "[yellow]Warning[/]",
|
||||
_ => "[blue]Info[/]"
|
||||
};
|
||||
|
||||
table.AddRow(
|
||||
alert.OccurredAt.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||
alert.WatchlistEntryName,
|
||||
severityMarkup,
|
||||
alert.MatchedIssuer ?? "-",
|
||||
alert.RekorLogIndex?.ToString() ?? "-");
|
||||
}
|
||||
|
||||
console.Write(table);
|
||||
console.MarkupLine($"\n[dim]Total: {result.TotalCount} alerts[/]");
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Failed to connect to API: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpClient GetHttpClient(IServiceProvider services)
|
||||
{
|
||||
var factory = services.GetService(typeof(IHttpClientFactory)) as IHttpClientFactory;
|
||||
return factory?.CreateClient("AttestorApi") ?? new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost:5200")
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildDisplayName(string? issuer, string? san, string? keyId)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(issuer))
|
||||
{
|
||||
var uri = new Uri(issuer);
|
||||
return $"Watch: {uri.Host}";
|
||||
}
|
||||
if (!string.IsNullOrEmpty(san))
|
||||
{
|
||||
return $"Watch: {san}";
|
||||
}
|
||||
if (!string.IsNullOrEmpty(keyId))
|
||||
{
|
||||
return $"Watch: Key {keyId}";
|
||||
}
|
||||
return "Watchlist Entry";
|
||||
}
|
||||
|
||||
private static void OutputEntry(IAnsiConsole console, WatchlistEntryResponse entry, string format)
|
||||
{
|
||||
if (format == "json")
|
||||
{
|
||||
console.WriteLine(JsonSerializer.Serialize(entry, JsonOptions));
|
||||
}
|
||||
else if (format == "yaml")
|
||||
{
|
||||
OutputEntryYaml(console, entry);
|
||||
}
|
||||
else
|
||||
{
|
||||
OutputEntryTable(console, entry);
|
||||
}
|
||||
}
|
||||
|
||||
private static void OutputEntries(IAnsiConsole console, IReadOnlyList<WatchlistEntryResponse> entries, string format)
|
||||
{
|
||||
if (format == "json")
|
||||
{
|
||||
console.WriteLine(JsonSerializer.Serialize(entries, JsonOptions));
|
||||
}
|
||||
else if (format == "yaml")
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
OutputEntryYaml(console, entry);
|
||||
console.WriteLine("---");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var table = new Table();
|
||||
table.AddColumn("ID");
|
||||
table.AddColumn("Name");
|
||||
table.AddColumn("Issuer/SAN");
|
||||
table.AddColumn("Mode");
|
||||
table.AddColumn("Severity");
|
||||
table.AddColumn("Enabled");
|
||||
table.AddColumn("Scope");
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var identity = entry.Issuer ?? entry.SubjectAlternativeName ?? entry.KeyId ?? "-";
|
||||
if (identity.Length > 40)
|
||||
{
|
||||
identity = identity[..37] + "...";
|
||||
}
|
||||
|
||||
var severityMarkup = entry.Severity switch
|
||||
{
|
||||
"Critical" => "[red]Critical[/]",
|
||||
"Warning" => "[yellow]Warning[/]",
|
||||
_ => "[blue]Info[/]"
|
||||
};
|
||||
|
||||
var enabledMarkup = entry.Enabled ? "[green]Yes[/]" : "[dim]No[/]";
|
||||
|
||||
table.AddRow(
|
||||
entry.Id.ToString()[..8] + "...",
|
||||
entry.DisplayName.Length > 30 ? entry.DisplayName[..27] + "..." : entry.DisplayName,
|
||||
identity,
|
||||
entry.MatchMode,
|
||||
severityMarkup,
|
||||
enabledMarkup,
|
||||
entry.Scope);
|
||||
}
|
||||
|
||||
console.Write(table);
|
||||
}
|
||||
}
|
||||
|
||||
private static void OutputEntryTable(IAnsiConsole console, WatchlistEntryResponse entry)
|
||||
{
|
||||
var table = new Table();
|
||||
table.AddColumn("Property");
|
||||
table.AddColumn("Value");
|
||||
|
||||
table.AddRow("ID", entry.Id.ToString());
|
||||
table.AddRow("Name", entry.DisplayName);
|
||||
table.AddRow("Description", entry.Description ?? "-");
|
||||
table.AddRow("Issuer", entry.Issuer ?? "-");
|
||||
table.AddRow("SAN", entry.SubjectAlternativeName ?? "-");
|
||||
table.AddRow("Key ID", entry.KeyId ?? "-");
|
||||
table.AddRow("Match Mode", entry.MatchMode);
|
||||
table.AddRow("Severity", entry.Severity);
|
||||
table.AddRow("Enabled", entry.Enabled.ToString());
|
||||
table.AddRow("Scope", entry.Scope);
|
||||
table.AddRow("Dedup Window", $"{entry.SuppressDuplicatesMinutes} min");
|
||||
table.AddRow("Created", entry.CreatedAt.ToString("O"));
|
||||
table.AddRow("Updated", entry.UpdatedAt.ToString("O"));
|
||||
table.AddRow("Created By", entry.CreatedBy);
|
||||
|
||||
console.Write(table);
|
||||
}
|
||||
|
||||
private static void OutputEntryYaml(IAnsiConsole console, WatchlistEntryResponse entry)
|
||||
{
|
||||
console.WriteLine($"id: {entry.Id}");
|
||||
console.WriteLine($"displayName: {entry.DisplayName}");
|
||||
if (!string.IsNullOrEmpty(entry.Description))
|
||||
console.WriteLine($"description: {entry.Description}");
|
||||
if (!string.IsNullOrEmpty(entry.Issuer))
|
||||
console.WriteLine($"issuer: {entry.Issuer}");
|
||||
if (!string.IsNullOrEmpty(entry.SubjectAlternativeName))
|
||||
console.WriteLine($"subjectAlternativeName: {entry.SubjectAlternativeName}");
|
||||
if (!string.IsNullOrEmpty(entry.KeyId))
|
||||
console.WriteLine($"keyId: {entry.KeyId}");
|
||||
console.WriteLine($"matchMode: {entry.MatchMode}");
|
||||
console.WriteLine($"severity: {entry.Severity}");
|
||||
console.WriteLine($"enabled: {entry.Enabled.ToString().ToLower()}");
|
||||
console.WriteLine($"scope: {entry.Scope}");
|
||||
console.WriteLine($"suppressDuplicatesMinutes: {entry.SuppressDuplicatesMinutes}");
|
||||
console.WriteLine($"createdAt: {entry.CreatedAt:O}");
|
||||
console.WriteLine($"updatedAt: {entry.UpdatedAt:O}");
|
||||
console.WriteLine($"createdBy: {entry.CreatedBy}");
|
||||
}
|
||||
|
||||
#region Contract DTOs
|
||||
|
||||
private sealed record WatchlistEntryRequest
|
||||
{
|
||||
public required string DisplayName { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? Issuer { get; init; }
|
||||
public string? SubjectAlternativeName { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
public string MatchMode { get; init; } = "exact";
|
||||
public string Severity { get; init; } = "warning";
|
||||
public bool Enabled { get; init; } = true;
|
||||
public int SuppressDuplicatesMinutes { get; init; } = 60;
|
||||
public string Scope { get; init; } = "tenant";
|
||||
}
|
||||
|
||||
private sealed record WatchlistEntryResponse
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string DisplayName { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? Issuer { get; init; }
|
||||
public string? SubjectAlternativeName { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
public required string MatchMode { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public required bool Enabled { get; init; }
|
||||
public required int SuppressDuplicatesMinutes { get; init; }
|
||||
public required string Scope { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
public required string CreatedBy { get; init; }
|
||||
public required string UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
private sealed record WatchlistListResponse
|
||||
{
|
||||
public required IReadOnlyList<WatchlistEntryResponse> Items { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
private sealed record WatchlistTestRequest
|
||||
{
|
||||
public string? Issuer { get; init; }
|
||||
public string? SubjectAlternativeName { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
}
|
||||
|
||||
private sealed record WatchlistTestResponse
|
||||
{
|
||||
public required bool Matches { get; init; }
|
||||
public required string MatchedFields { get; init; }
|
||||
public required int MatchScore { get; init; }
|
||||
public required WatchlistEntryResponse Entry { get; init; }
|
||||
}
|
||||
|
||||
private sealed record WatchlistAlertsResponse
|
||||
{
|
||||
public required IReadOnlyList<WatchlistAlertItem> Items { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
private sealed record WatchlistAlertItem
|
||||
{
|
||||
public required Guid AlertId { get; init; }
|
||||
public required Guid WatchlistEntryId { get; init; }
|
||||
public required string WatchlistEntryName { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public string? MatchedIssuer { get; init; }
|
||||
public string? MatchedSan { get; init; }
|
||||
public string? MatchedKeyId { get; init; }
|
||||
public string? RekorUuid { get; init; }
|
||||
public long? RekorLogIndex { get; init; }
|
||||
public required DateTimeOffset OccurredAt { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
991
src/Cli/StellaOps.Cli/Commands/WatchlistCommandGroup.cs
Normal file
991
src/Cli/StellaOps.Cli/Commands/WatchlistCommandGroup.cs
Normal file
@@ -0,0 +1,991 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WatchlistCommandGroup.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-008
|
||||
// Description: CLI commands for identity watchlist management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command group for identity watchlist operations.
|
||||
/// Implements watchlist entry management, pattern testing, and alert viewing.
|
||||
/// </summary>
|
||||
public static class WatchlistCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build the 'watchlist' command group.
|
||||
/// </summary>
|
||||
public static Command BuildWatchlistCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var watchlistCommand = new Command("watchlist", "Identity watchlist management for transparency log monitoring");
|
||||
|
||||
watchlistCommand.Add(BuildAddCommand(services, verboseOption, cancellationToken));
|
||||
watchlistCommand.Add(BuildListCommand(services, verboseOption, cancellationToken));
|
||||
watchlistCommand.Add(BuildGetCommand(services, verboseOption, cancellationToken));
|
||||
watchlistCommand.Add(BuildUpdateCommand(services, verboseOption, cancellationToken));
|
||||
watchlistCommand.Add(BuildRemoveCommand(services, verboseOption, cancellationToken));
|
||||
watchlistCommand.Add(BuildTestCommand(services, verboseOption, cancellationToken));
|
||||
watchlistCommand.Add(BuildAlertsCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return watchlistCommand;
|
||||
}
|
||||
|
||||
#region Add Command
|
||||
|
||||
private static Command BuildAddCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var issuerOption = new Option<string?>("--issuer")
|
||||
{
|
||||
Description = "OIDC issuer URL to watch (e.g., https://token.actions.githubusercontent.com)"
|
||||
};
|
||||
|
||||
var sanOption = new Option<string?>("--san")
|
||||
{
|
||||
Description = "Subject Alternative Name pattern to watch (e.g., *@example.com)"
|
||||
};
|
||||
|
||||
var keyIdOption = new Option<string?>("--key-id")
|
||||
{
|
||||
Description = "Key ID to watch"
|
||||
};
|
||||
|
||||
var matchModeOption = new Option<string>("--match-mode", "-m")
|
||||
{
|
||||
Description = "Match mode: exact (default), prefix, glob, regex"
|
||||
};
|
||||
matchModeOption.SetDefaultValue("exact");
|
||||
|
||||
var severityOption = new Option<string>("--severity", "-s")
|
||||
{
|
||||
Description = "Alert severity: info, warning (default), critical"
|
||||
};
|
||||
severityOption.SetDefaultValue("warning");
|
||||
|
||||
var nameOption = new Option<string?>("--name", "-n")
|
||||
{
|
||||
Description = "Display name for the watchlist entry"
|
||||
};
|
||||
|
||||
var descriptionOption = new Option<string?>("--description", "-d")
|
||||
{
|
||||
Description = "Description of what this entry watches for"
|
||||
};
|
||||
|
||||
var scopeOption = new Option<string>("--scope")
|
||||
{
|
||||
Description = "Watchlist scope: tenant (default), global"
|
||||
};
|
||||
scopeOption.SetDefaultValue("tenant");
|
||||
|
||||
var suppressDuplicatesOption = new Option<int>("--suppress-duplicates")
|
||||
{
|
||||
Description = "Minutes to suppress duplicate alerts (default: 60)"
|
||||
};
|
||||
suppressDuplicatesOption.SetDefaultValue(60);
|
||||
|
||||
var formatOption = new Option<string>("--format", "-f")
|
||||
{
|
||||
Description = "Output format: table (default), json"
|
||||
};
|
||||
formatOption.SetDefaultValue("table");
|
||||
|
||||
var addCommand = new Command("add", "Add a new watchlist entry")
|
||||
{
|
||||
issuerOption,
|
||||
sanOption,
|
||||
keyIdOption,
|
||||
matchModeOption,
|
||||
severityOption,
|
||||
nameOption,
|
||||
descriptionOption,
|
||||
scopeOption,
|
||||
suppressDuplicatesOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
addCommand.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var issuer = parseResult.GetValue(issuerOption);
|
||||
var san = parseResult.GetValue(sanOption);
|
||||
var keyId = parseResult.GetValue(keyIdOption);
|
||||
var matchMode = parseResult.GetValue(matchModeOption) ?? "exact";
|
||||
var severity = parseResult.GetValue(severityOption) ?? "warning";
|
||||
var name = parseResult.GetValue(nameOption);
|
||||
var description = parseResult.GetValue(descriptionOption);
|
||||
var scope = parseResult.GetValue(scopeOption) ?? "tenant";
|
||||
var suppressDuplicates = parseResult.GetValue(suppressDuplicatesOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "table";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
// Validate at least one identity field
|
||||
if (string.IsNullOrEmpty(issuer) && string.IsNullOrEmpty(san) && string.IsNullOrEmpty(keyId))
|
||||
{
|
||||
Console.Error.WriteLine("Error: At least one identity field is required (--issuer, --san, or --key-id)");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Warn about regex mode
|
||||
if (matchMode.Equals("regex", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine("Warning: Regex match mode may impact performance. Consider using glob patterns instead.");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
// Create entry (simulated - actual implementation would call API)
|
||||
var entry = new WatchlistEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DisplayName = name ?? GenerateDisplayName(issuer, san, keyId),
|
||||
Description = description,
|
||||
Issuer = issuer,
|
||||
SubjectAlternativeName = san,
|
||||
KeyId = keyId,
|
||||
MatchMode = matchMode,
|
||||
Severity = severity,
|
||||
Scope = scope,
|
||||
SuppressDuplicatesMinutes = suppressDuplicates,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(entry, JsonOptions));
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.WriteLine("Watchlist entry created successfully.");
|
||||
Console.WriteLine();
|
||||
PrintEntry(entry, verbose);
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return addCommand;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region List Command
|
||||
|
||||
private static Command BuildListCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var includeGlobalOption = new Option<bool>("--include-global")
|
||||
{
|
||||
Description = "Include global scope entries"
|
||||
};
|
||||
includeGlobalOption.SetDefaultValue(true);
|
||||
|
||||
var formatOption = new Option<string>("--format", "-f")
|
||||
{
|
||||
Description = "Output format: table (default), json, yaml"
|
||||
};
|
||||
formatOption.SetDefaultValue("table");
|
||||
|
||||
var severityFilterOption = new Option<string?>("--severity")
|
||||
{
|
||||
Description = "Filter by severity: info, warning, critical"
|
||||
};
|
||||
|
||||
var enabledOnlyOption = new Option<bool>("--enabled-only")
|
||||
{
|
||||
Description = "Only show enabled entries"
|
||||
};
|
||||
|
||||
var listCommand = new Command("list", "List watchlist entries")
|
||||
{
|
||||
includeGlobalOption,
|
||||
formatOption,
|
||||
severityFilterOption,
|
||||
enabledOnlyOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
listCommand.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var includeGlobal = parseResult.GetValue(includeGlobalOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "table";
|
||||
var severityFilter = parseResult.GetValue(severityFilterOption);
|
||||
var enabledOnly = parseResult.GetValue(enabledOnlyOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
var entries = GetSampleEntries();
|
||||
|
||||
if (!includeGlobal)
|
||||
{
|
||||
entries = entries.Where(e => e.Scope == "tenant").ToList();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(severityFilter))
|
||||
{
|
||||
entries = entries.Where(e => e.Severity.Equals(severityFilter, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
}
|
||||
|
||||
if (enabledOnly)
|
||||
{
|
||||
entries = entries.Where(e => e.Enabled).ToList();
|
||||
}
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(entries, JsonOptions));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine("Identity Watchlist Entries");
|
||||
Console.WriteLine("==========================");
|
||||
Console.WriteLine();
|
||||
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No watchlist entries found.");
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine("+--------------+--------------------------------+----------+----------+---------+");
|
||||
Console.WriteLine("| Scope | Display Name | Match | Severity | Status |");
|
||||
Console.WriteLine("+--------------+--------------------------------+----------+----------+---------+");
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var statusIcon = entry.Enabled ? "[x]" : "[ ]";
|
||||
var displayName = entry.DisplayName.Length > 30 ? entry.DisplayName[..27] + "..." : entry.DisplayName;
|
||||
Console.WriteLine($"| {entry.Scope,-12} | {displayName,-30} | {entry.MatchMode,-8} | {entry.Severity,-8} | {statusIcon,-7} |");
|
||||
}
|
||||
|
||||
Console.WriteLine("+--------------+--------------------------------+----------+----------+---------+");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Total: {entries.Count} entries");
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Entry Details:");
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
Console.WriteLine($" {entry.Id}");
|
||||
if (!string.IsNullOrEmpty(entry.Issuer))
|
||||
Console.WriteLine($" Issuer: {entry.Issuer}");
|
||||
if (!string.IsNullOrEmpty(entry.SubjectAlternativeName))
|
||||
Console.WriteLine($" SAN: {entry.SubjectAlternativeName}");
|
||||
if (!string.IsNullOrEmpty(entry.KeyId))
|
||||
Console.WriteLine($" KeyId: {entry.KeyId}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return listCommand;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Get Command
|
||||
|
||||
private static Command BuildGetCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var idArg = new Argument<string>("id")
|
||||
{
|
||||
Description = "Watchlist entry ID"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", "-f")
|
||||
{
|
||||
Description = "Output format: table (default), json, yaml"
|
||||
};
|
||||
formatOption.SetDefaultValue("table");
|
||||
|
||||
var getCommand = new Command("get", "Get a specific watchlist entry")
|
||||
{
|
||||
idArg,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
getCommand.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var id = parseResult.GetValue(idArg) ?? string.Empty;
|
||||
var format = parseResult.GetValue(formatOption) ?? "table";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
var entry = GetSampleEntries().FirstOrDefault(e => e.Id.ToString().StartsWith(id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Watchlist entry '{id}' not found.");
|
||||
return Task.FromResult(1);
|
||||
}
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(entry, JsonOptions));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
PrintEntry(entry, verbose);
|
||||
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return getCommand;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Update Command
|
||||
|
||||
private static Command BuildUpdateCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var idArg = new Argument<string>("id")
|
||||
{
|
||||
Description = "Watchlist entry ID"
|
||||
};
|
||||
|
||||
var enabledOption = new Option<bool?>("--enabled")
|
||||
{
|
||||
Description = "Enable or disable the entry"
|
||||
};
|
||||
|
||||
var severityOption = new Option<string?>("--severity", "-s")
|
||||
{
|
||||
Description = "Alert severity: info, warning, critical"
|
||||
};
|
||||
|
||||
var suppressDuplicatesOption = new Option<int?>("--suppress-duplicates")
|
||||
{
|
||||
Description = "Minutes to suppress duplicate alerts"
|
||||
};
|
||||
|
||||
var nameOption = new Option<string?>("--name", "-n")
|
||||
{
|
||||
Description = "Display name for the watchlist entry"
|
||||
};
|
||||
|
||||
var descriptionOption = new Option<string?>("--description", "-d")
|
||||
{
|
||||
Description = "Description of what this entry watches for"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", "-f")
|
||||
{
|
||||
Description = "Output format: table (default), json"
|
||||
};
|
||||
formatOption.SetDefaultValue("table");
|
||||
|
||||
var updateCommand = new Command("update", "Update an existing watchlist entry")
|
||||
{
|
||||
idArg,
|
||||
enabledOption,
|
||||
severityOption,
|
||||
suppressDuplicatesOption,
|
||||
nameOption,
|
||||
descriptionOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
updateCommand.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var id = parseResult.GetValue(idArg) ?? string.Empty;
|
||||
var enabled = parseResult.GetValue(enabledOption);
|
||||
var severity = parseResult.GetValue(severityOption);
|
||||
var suppressDuplicates = parseResult.GetValue(suppressDuplicatesOption);
|
||||
var name = parseResult.GetValue(nameOption);
|
||||
var description = parseResult.GetValue(descriptionOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "table";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
var entry = GetSampleEntries().FirstOrDefault(e => e.Id.ToString().StartsWith(id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Watchlist entry '{id}' not found.");
|
||||
return Task.FromResult(1);
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
if (enabled.HasValue) entry.Enabled = enabled.Value;
|
||||
if (!string.IsNullOrEmpty(severity)) entry.Severity = severity;
|
||||
if (suppressDuplicates.HasValue) entry.SuppressDuplicatesMinutes = suppressDuplicates.Value;
|
||||
if (!string.IsNullOrEmpty(name)) entry.DisplayName = name;
|
||||
if (!string.IsNullOrEmpty(description)) entry.Description = description;
|
||||
entry.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(entry, JsonOptions));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine("Watchlist entry updated successfully.");
|
||||
Console.WriteLine();
|
||||
PrintEntry(entry, verbose);
|
||||
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return updateCommand;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Remove Command
|
||||
|
||||
private static Command BuildRemoveCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var idArg = new Argument<string>("id")
|
||||
{
|
||||
Description = "Watchlist entry ID"
|
||||
};
|
||||
|
||||
var forceOption = new Option<bool>("--force")
|
||||
{
|
||||
Description = "Skip confirmation prompt"
|
||||
};
|
||||
|
||||
var removeCommand = new Command("remove", "Remove a watchlist entry")
|
||||
{
|
||||
idArg,
|
||||
forceOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
removeCommand.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var id = parseResult.GetValue(idArg) ?? string.Empty;
|
||||
var force = parseResult.GetValue(forceOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
var entry = GetSampleEntries().FirstOrDefault(e => e.Id.ToString().StartsWith(id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Watchlist entry '{id}' not found.");
|
||||
return Task.FromResult(1);
|
||||
}
|
||||
|
||||
if (!force)
|
||||
{
|
||||
Console.WriteLine($"Are you sure you want to remove watchlist entry '{entry.DisplayName}'?");
|
||||
Console.WriteLine($" ID: {entry.Id}");
|
||||
Console.WriteLine($" Severity: {entry.Severity}");
|
||||
Console.WriteLine();
|
||||
Console.Write("Type 'yes' to confirm: ");
|
||||
|
||||
var response = Console.ReadLine();
|
||||
if (!response?.Equals("yes", StringComparison.OrdinalIgnoreCase) ?? true)
|
||||
{
|
||||
Console.WriteLine("Operation cancelled.");
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"Watchlist entry '{entry.DisplayName}' removed successfully.");
|
||||
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return removeCommand;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Command
|
||||
|
||||
private static Command BuildTestCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var idArg = new Argument<string>("id")
|
||||
{
|
||||
Description = "Watchlist entry ID to test"
|
||||
};
|
||||
|
||||
var issuerOption = new Option<string?>("--issuer")
|
||||
{
|
||||
Description = "Test issuer URL"
|
||||
};
|
||||
|
||||
var sanOption = new Option<string?>("--san")
|
||||
{
|
||||
Description = "Test Subject Alternative Name"
|
||||
};
|
||||
|
||||
var keyIdOption = new Option<string?>("--key-id")
|
||||
{
|
||||
Description = "Test key ID"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", "-f")
|
||||
{
|
||||
Description = "Output format: table (default), json"
|
||||
};
|
||||
formatOption.SetDefaultValue("table");
|
||||
|
||||
var testCommand = new Command("test", "Test if a sample identity matches a watchlist entry")
|
||||
{
|
||||
idArg,
|
||||
issuerOption,
|
||||
sanOption,
|
||||
keyIdOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
testCommand.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var id = parseResult.GetValue(idArg) ?? string.Empty;
|
||||
var issuer = parseResult.GetValue(issuerOption);
|
||||
var san = parseResult.GetValue(sanOption);
|
||||
var keyId = parseResult.GetValue(keyIdOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "table";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (string.IsNullOrEmpty(issuer) && string.IsNullOrEmpty(san) && string.IsNullOrEmpty(keyId))
|
||||
{
|
||||
Console.Error.WriteLine("Error: At least one test identity field is required (--issuer, --san, or --key-id)");
|
||||
return Task.FromResult(1);
|
||||
}
|
||||
|
||||
var entry = GetSampleEntries().FirstOrDefault(e => e.Id.ToString().StartsWith(id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Watchlist entry '{id}' not found.");
|
||||
return Task.FromResult(1);
|
||||
}
|
||||
|
||||
// Simulate matching
|
||||
var matches = false;
|
||||
var matchedFields = new List<string>();
|
||||
var matchScore = 0;
|
||||
|
||||
if (!string.IsNullOrEmpty(issuer) && !string.IsNullOrEmpty(entry.Issuer))
|
||||
{
|
||||
if (TestMatch(entry.Issuer, issuer, entry.MatchMode))
|
||||
{
|
||||
matches = true;
|
||||
matchedFields.Add("Issuer");
|
||||
matchScore += entry.MatchMode == "exact" ? 100 : 50;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(san) && !string.IsNullOrEmpty(entry.SubjectAlternativeName))
|
||||
{
|
||||
if (TestMatch(entry.SubjectAlternativeName, san, entry.MatchMode))
|
||||
{
|
||||
matches = true;
|
||||
matchedFields.Add("SubjectAlternativeName");
|
||||
matchScore += entry.MatchMode == "exact" ? 100 : 50;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(keyId) && !string.IsNullOrEmpty(entry.KeyId))
|
||||
{
|
||||
if (TestMatch(entry.KeyId, keyId, entry.MatchMode))
|
||||
{
|
||||
matches = true;
|
||||
matchedFields.Add("KeyId");
|
||||
matchScore += entry.MatchMode == "exact" ? 100 : 50;
|
||||
}
|
||||
}
|
||||
|
||||
var result = new TestResult
|
||||
{
|
||||
EntryId = entry.Id,
|
||||
EntryName = entry.DisplayName,
|
||||
Matches = matches,
|
||||
MatchedFields = matchedFields.ToArray(),
|
||||
MatchScore = matchScore,
|
||||
Severity = entry.Severity
|
||||
};
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine("Watchlist Pattern Test");
|
||||
Console.WriteLine("======================");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Entry: {entry.DisplayName}");
|
||||
Console.WriteLine($"Match Mode: {entry.MatchMode}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Test Identity:");
|
||||
if (!string.IsNullOrEmpty(issuer))
|
||||
Console.WriteLine($" Issuer: {issuer}");
|
||||
if (!string.IsNullOrEmpty(san))
|
||||
Console.WriteLine($" SAN: {san}");
|
||||
if (!string.IsNullOrEmpty(keyId))
|
||||
Console.WriteLine($" KeyId: {keyId}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Result:");
|
||||
|
||||
if (matches)
|
||||
{
|
||||
Console.WriteLine($" [x] MATCH (Score: {matchScore})");
|
||||
Console.WriteLine($" Matched Fields: {string.Join(", ", matchedFields)}");
|
||||
Console.WriteLine($" Alert Severity: {entry.Severity}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(" [ ] No match");
|
||||
}
|
||||
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return testCommand;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Alerts Command
|
||||
|
||||
private static Command BuildAlertsCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sinceOption = new Option<string?>("--since")
|
||||
{
|
||||
Description = "Show alerts since duration (e.g., 1h, 24h, 7d)"
|
||||
};
|
||||
sinceOption.SetDefaultValue("24h");
|
||||
|
||||
var severityOption = new Option<string?>("--severity")
|
||||
{
|
||||
Description = "Filter by severity: info, warning, critical"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", "-f")
|
||||
{
|
||||
Description = "Output format: table (default), json"
|
||||
};
|
||||
formatOption.SetDefaultValue("table");
|
||||
|
||||
var limitOption = new Option<int>("--limit")
|
||||
{
|
||||
Description = "Maximum number of alerts to show"
|
||||
};
|
||||
limitOption.SetDefaultValue(50);
|
||||
|
||||
var alertsCommand = new Command("alerts", "List recent watchlist alerts")
|
||||
{
|
||||
sinceOption,
|
||||
severityOption,
|
||||
formatOption,
|
||||
limitOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
alertsCommand.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var since = parseResult.GetValue(sinceOption) ?? "24h";
|
||||
var severity = parseResult.GetValue(severityOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "table";
|
||||
var limit = parseResult.GetValue(limitOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
var alerts = GetSampleAlerts();
|
||||
|
||||
if (!string.IsNullOrEmpty(severity))
|
||||
{
|
||||
alerts = alerts.Where(a => a.Severity.Equals(severity, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
}
|
||||
|
||||
alerts = alerts.Take(limit).ToList();
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(alerts, JsonOptions));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine("Recent Watchlist Alerts");
|
||||
Console.WriteLine("=======================");
|
||||
Console.WriteLine();
|
||||
|
||||
if (alerts.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No alerts found.");
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine("+----------+--------------------------------+--------------------+-------------------+");
|
||||
Console.WriteLine("| Severity | Entry Name | Matched Identity | Time |");
|
||||
Console.WriteLine("+----------+--------------------------------+--------------------+-------------------+");
|
||||
|
||||
foreach (var alert in alerts)
|
||||
{
|
||||
var severityIcon = alert.Severity == "critical" ? "(!)" : alert.Severity == "warning" ? "(w)" : "(i)";
|
||||
var entryName = alert.EntryName.Length > 28 ? alert.EntryName[..25] + "..." : alert.EntryName;
|
||||
var identity = alert.MatchedIssuer?.Length > 16 ? alert.MatchedIssuer[..13] + "..." : (alert.MatchedIssuer ?? "-");
|
||||
var time = alert.OccurredAt.ToString("yyyy-MM-dd HH:mm");
|
||||
Console.WriteLine($"| {severityIcon} {alert.Severity,-5} | {entryName,-30} | {identity,-18} | {time,-17} |");
|
||||
}
|
||||
|
||||
Console.WriteLine("+----------+--------------------------------+--------------------+-------------------+");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Showing {alerts.Count} alerts (since {since})");
|
||||
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return alertsCommand;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static string GenerateDisplayName(string? issuer, string? san, string? keyId)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(issuer))
|
||||
{
|
||||
var uri = new Uri(issuer);
|
||||
return $"Watch: {uri.Host}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(san))
|
||||
{
|
||||
return $"Watch: {san}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(keyId))
|
||||
{
|
||||
return $"Watch: Key {keyId[..Math.Min(8, keyId.Length)]}...";
|
||||
}
|
||||
|
||||
return "Unnamed Watch";
|
||||
}
|
||||
|
||||
private static void PrintEntry(WatchlistEntry entry, bool verbose)
|
||||
{
|
||||
Console.WriteLine($"ID: {entry.Id}");
|
||||
Console.WriteLine($"Display Name: {entry.DisplayName}");
|
||||
Console.WriteLine($"Scope: {entry.Scope}");
|
||||
Console.WriteLine($"Match Mode: {entry.MatchMode}");
|
||||
Console.WriteLine($"Severity: {entry.Severity}");
|
||||
Console.WriteLine($"Enabled: {(entry.Enabled ? "Yes" : "No")}");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("Identity Patterns:");
|
||||
if (!string.IsNullOrEmpty(entry.Issuer))
|
||||
Console.WriteLine($" Issuer: {entry.Issuer}");
|
||||
if (!string.IsNullOrEmpty(entry.SubjectAlternativeName))
|
||||
Console.WriteLine($" SAN: {entry.SubjectAlternativeName}");
|
||||
if (!string.IsNullOrEmpty(entry.KeyId))
|
||||
Console.WriteLine($" KeyId: {entry.KeyId}");
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Suppress Duplicates: {entry.SuppressDuplicatesMinutes} minutes");
|
||||
Console.WriteLine($"Created: {entry.CreatedAt:u}");
|
||||
Console.WriteLine($"Updated: {entry.UpdatedAt:u}");
|
||||
if (!string.IsNullOrEmpty(entry.Description))
|
||||
Console.WriteLine($"Description: {entry.Description}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TestMatch(string pattern, string input, string matchMode)
|
||||
{
|
||||
return matchMode.ToLowerInvariant() switch
|
||||
{
|
||||
"exact" => pattern.Equals(input, StringComparison.OrdinalIgnoreCase),
|
||||
"prefix" => input.StartsWith(pattern, StringComparison.OrdinalIgnoreCase),
|
||||
"glob" => TestGlobMatch(pattern, input),
|
||||
"regex" => System.Text.RegularExpressions.Regex.IsMatch(input, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase),
|
||||
_ => pattern.Equals(input, StringComparison.OrdinalIgnoreCase)
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TestGlobMatch(string pattern, string input)
|
||||
{
|
||||
var regexPattern = "^" + System.Text.RegularExpressions.Regex.Escape(pattern)
|
||||
.Replace("\\*", ".*")
|
||||
.Replace("\\?", ".") + "$";
|
||||
return System.Text.RegularExpressions.Regex.IsMatch(input, regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Sample Data
|
||||
|
||||
private static List<WatchlistEntry> GetSampleEntries()
|
||||
{
|
||||
return
|
||||
[
|
||||
new WatchlistEntry
|
||||
{
|
||||
Id = Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
DisplayName = "GitHub Actions Watcher",
|
||||
Description = "Watch for unexpected GitHub Actions identities",
|
||||
Issuer = "https://token.actions.githubusercontent.com",
|
||||
SubjectAlternativeName = "repo:org/*",
|
||||
MatchMode = "glob",
|
||||
Severity = "critical",
|
||||
Scope = "tenant",
|
||||
SuppressDuplicatesMinutes = 60,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-30),
|
||||
UpdatedAt = DateTimeOffset.UtcNow.AddDays(-5)
|
||||
},
|
||||
new WatchlistEntry
|
||||
{
|
||||
Id = Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||
DisplayName = "Google Cloud IAM",
|
||||
Description = "Watch for Google Cloud service account identities",
|
||||
Issuer = "https://accounts.google.com",
|
||||
MatchMode = "prefix",
|
||||
Severity = "warning",
|
||||
Scope = "tenant",
|
||||
SuppressDuplicatesMinutes = 120,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-20),
|
||||
UpdatedAt = DateTimeOffset.UtcNow.AddDays(-20)
|
||||
},
|
||||
new WatchlistEntry
|
||||
{
|
||||
Id = Guid.Parse("33333333-3333-3333-3333-333333333333"),
|
||||
DisplayName = "Internal PKI",
|
||||
Description = "Watch for internal PKI certificate usage",
|
||||
SubjectAlternativeName = "*@internal.example.com",
|
||||
MatchMode = "glob",
|
||||
Severity = "info",
|
||||
Scope = "global",
|
||||
SuppressDuplicatesMinutes = 30,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-60),
|
||||
UpdatedAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private static List<AlertItem> GetSampleAlerts()
|
||||
{
|
||||
return
|
||||
[
|
||||
new AlertItem
|
||||
{
|
||||
AlertId = Guid.NewGuid(),
|
||||
EntryId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
EntryName = "GitHub Actions Watcher",
|
||||
Severity = "critical",
|
||||
MatchedIssuer = "https://token.actions.githubusercontent.com",
|
||||
MatchedSan = "repo:org/app:ref:refs/heads/main",
|
||||
RekorUuid = "abc123def456",
|
||||
RekorLogIndex = 12345678,
|
||||
OccurredAt = DateTimeOffset.UtcNow.AddMinutes(-15)
|
||||
},
|
||||
new AlertItem
|
||||
{
|
||||
AlertId = Guid.NewGuid(),
|
||||
EntryId = Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||
EntryName = "Google Cloud IAM",
|
||||
Severity = "warning",
|
||||
MatchedIssuer = "https://accounts.google.com",
|
||||
MatchedSan = "service-account@project.iam.gserviceaccount.com",
|
||||
RekorUuid = "xyz789abc012",
|
||||
RekorLogIndex = 12345679,
|
||||
OccurredAt = DateTimeOffset.UtcNow.AddHours(-2)
|
||||
},
|
||||
new AlertItem
|
||||
{
|
||||
AlertId = Guid.NewGuid(),
|
||||
EntryId = Guid.Parse("33333333-3333-3333-3333-333333333333"),
|
||||
EntryName = "Internal PKI",
|
||||
Severity = "info",
|
||||
MatchedSan = "deploy-bot@internal.example.com",
|
||||
RekorUuid = "mno456pqr789",
|
||||
RekorLogIndex = 12345680,
|
||||
OccurredAt = DateTimeOffset.UtcNow.AddHours(-6)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DTOs
|
||||
|
||||
private sealed class WatchlistEntry
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string? Issuer { get; set; }
|
||||
public string? SubjectAlternativeName { get; set; }
|
||||
public string? KeyId { get; set; }
|
||||
public string MatchMode { get; set; } = "exact";
|
||||
public string Severity { get; set; } = "warning";
|
||||
public string Scope { get; set; } = "tenant";
|
||||
public int SuppressDuplicatesMinutes { get; set; } = 60;
|
||||
public bool Enabled { get; set; } = true;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
private sealed class TestResult
|
||||
{
|
||||
public Guid EntryId { get; set; }
|
||||
public string EntryName { get; set; } = string.Empty;
|
||||
public bool Matches { get; set; }
|
||||
public string[] MatchedFields { get; set; } = [];
|
||||
public int MatchScore { get; set; }
|
||||
public string Severity { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class AlertItem
|
||||
{
|
||||
public Guid AlertId { get; set; }
|
||||
public Guid EntryId { get; set; }
|
||||
public string EntryName { get; set; } = string.Empty;
|
||||
public string Severity { get; set; } = string.Empty;
|
||||
public string? MatchedIssuer { get; set; }
|
||||
public string? MatchedSan { get; set; }
|
||||
public string? MatchedKeyId { get; set; }
|
||||
public string? RekorUuid { get; set; }
|
||||
public long RekorLogIndex { get; set; }
|
||||
public DateTimeOffset OccurredAt { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WitnessCoreCommandGroup.cs
|
||||
// Sprint: SPRINT_0128_001_BinaryIndex_binary_micro_witness
|
||||
// Task: TASK-003 - Add `stella witness` CLI commands
|
||||
// Description: CLI commands for binary micro-witness generation and verification.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using StellaOps.Cli.Extensions;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Witness;
|
||||
|
||||
/// <summary>
|
||||
/// CLI command group for binary micro-witness operations.
|
||||
/// </summary>
|
||||
internal static class WitnessCoreCommandGroup
|
||||
{
|
||||
internal static Command BuildWitnessCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var witness = new Command("witness", "Binary micro-witness operations for patch verification.");
|
||||
|
||||
witness.Add(BuildGenerateCommand(services, verboseOption, cancellationToken));
|
||||
witness.Add(BuildVerifyCommand(services, verboseOption, cancellationToken));
|
||||
witness.Add(BuildBundleCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return witness;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella witness generate --binary <path> --cve <id> [--sbom <path>] [--sign] [--rekor]
|
||||
/// </summary>
|
||||
private static Command BuildGenerateCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var binaryArg = new Argument<string>("binary")
|
||||
{
|
||||
Description = "Path to binary file to analyze."
|
||||
};
|
||||
|
||||
var cveOption = new Option<string>("--cve", new[] { "-c" })
|
||||
{
|
||||
Description = "CVE identifier to verify (e.g., CVE-2024-0567)."
|
||||
};
|
||||
cveOption.Arity = ArgumentArity.ExactlyOne;
|
||||
|
||||
var sbomOption = new Option<string?>("--sbom", new[] { "-s" })
|
||||
{
|
||||
Description = "Path to SBOM file (CycloneDX or SPDX) for component mapping."
|
||||
};
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output file path for the witness. Defaults to stdout."
|
||||
};
|
||||
|
||||
var signOption = new Option<bool>("--sign")
|
||||
{
|
||||
Description = "Sign the witness with the configured signing key."
|
||||
};
|
||||
|
||||
var rekorOption = new Option<bool>("--rekor")
|
||||
{
|
||||
Description = "Log the witness to Rekor transparency log."
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: json (default), envelope."
|
||||
}.SetDefaultValue("json").FromAmong("json", "envelope");
|
||||
|
||||
var command = new Command("generate", "Generate a micro-witness for binary patch verification.")
|
||||
{
|
||||
binaryArg,
|
||||
cveOption,
|
||||
sbomOption,
|
||||
outputOption,
|
||||
signOption,
|
||||
rekorOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var binary = parseResult.GetValue(binaryArg)!;
|
||||
var cve = parseResult.GetValue(cveOption)!;
|
||||
var sbom = parseResult.GetValue(sbomOption);
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var sign = parseResult.GetValue(signOption);
|
||||
var rekor = parseResult.GetValue(rekorOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return WitnessCoreCommandHandlers.HandleGenerateAsync(
|
||||
services,
|
||||
binary,
|
||||
cve,
|
||||
sbom,
|
||||
output,
|
||||
sign,
|
||||
rekor,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella witness verify --witness <path> [--offline] [--sbom <path>]
|
||||
/// </summary>
|
||||
private static Command BuildVerifyCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var witnessArg = new Argument<string>("witness")
|
||||
{
|
||||
Description = "Path to witness file (JSON or DSSE envelope)."
|
||||
};
|
||||
|
||||
var offlineOption = new Option<bool>("--offline")
|
||||
{
|
||||
Description = "Verify without network access (use bundled Rekor proof)."
|
||||
};
|
||||
|
||||
var sbomOption = new Option<string?>("--sbom", new[] { "-s" })
|
||||
{
|
||||
Description = "Path to SBOM file to validate component mapping."
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: text (default), json."
|
||||
}.SetDefaultValue("text").FromAmong("text", "json");
|
||||
|
||||
var command = new Command("verify", "Verify a binary micro-witness signature and Rekor proof.")
|
||||
{
|
||||
witnessArg,
|
||||
offlineOption,
|
||||
sbomOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var witness = parseResult.GetValue(witnessArg)!;
|
||||
var offline = parseResult.GetValue(offlineOption);
|
||||
var sbom = parseResult.GetValue(sbomOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return WitnessCoreCommandHandlers.HandleVerifyAsync(
|
||||
services,
|
||||
witness,
|
||||
offline,
|
||||
sbom,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// stella witness bundle --witness <path> --output <dir>
|
||||
/// </summary>
|
||||
private static Command BuildBundleCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var witnessArg = new Argument<string>("witness")
|
||||
{
|
||||
Description = "Path to witness file to bundle."
|
||||
};
|
||||
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output directory for the bundle."
|
||||
};
|
||||
outputOption.Arity = ArgumentArity.ExactlyOne;
|
||||
|
||||
var includeBinaryOption = new Option<bool>("--include-binary")
|
||||
{
|
||||
Description = "Include the analyzed binary in the bundle (for full offline replay)."
|
||||
};
|
||||
|
||||
var includeSbomOption = new Option<bool>("--include-sbom")
|
||||
{
|
||||
Description = "Include the SBOM in the bundle."
|
||||
};
|
||||
|
||||
var command = new Command("bundle", "Export a self-contained verification bundle for air-gapped audits.")
|
||||
{
|
||||
witnessArg,
|
||||
outputOption,
|
||||
includeBinaryOption,
|
||||
includeSbomOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var witness = parseResult.GetValue(witnessArg)!;
|
||||
var output = parseResult.GetValue(outputOption)!;
|
||||
var includeBinary = parseResult.GetValue(includeBinaryOption);
|
||||
var includeSbom = parseResult.GetValue(includeSbomOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return WitnessCoreCommandHandlers.HandleBundleAsync(
|
||||
services,
|
||||
witness,
|
||||
output,
|
||||
includeBinary,
|
||||
includeSbom,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WitnessCoreCommandHandlers.cs
|
||||
// Sprint: SPRINT_0128_001_BinaryIndex_binary_micro_witness
|
||||
// Task: TASK-003 - Add `stella witness` CLI commands
|
||||
// Description: Handler implementations for binary micro-witness CLI commands.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
using StellaOps.Scanner.PatchVerification;
|
||||
using StellaOps.Scanner.PatchVerification.Models;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Witness;
|
||||
|
||||
/// <summary>
|
||||
/// Handler implementations for binary micro-witness CLI commands.
|
||||
/// </summary>
|
||||
internal static class WitnessCoreCommandHandlers
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Handle `stella witness generate` command.
|
||||
/// </summary>
|
||||
internal static async Task HandleGenerateAsync(
|
||||
IServiceProvider services,
|
||||
string binaryPath,
|
||||
string cveId,
|
||||
string? sbomPath,
|
||||
string? outputPath,
|
||||
bool sign,
|
||||
bool rekor,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
if (!File.Exists(binaryPath))
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Binary file not found: {binaryPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Analyzing binary: {binaryPath}[/]");
|
||||
console.MarkupLine($"[dim]CVE: {cveId}[/]");
|
||||
}
|
||||
|
||||
// Compute binary hash
|
||||
var binaryHash = await ComputeFileHashAsync(binaryPath, cancellationToken);
|
||||
var binaryInfo = new FileInfo(binaryPath);
|
||||
|
||||
// Try to use patch verification service if available
|
||||
string verdict = MicroWitnessVerdicts.Inconclusive;
|
||||
double confidence = 0.0;
|
||||
var evidence = new List<MicroWitnessFunctionEvidence>();
|
||||
string matchAlgorithm = "semantic_ksg";
|
||||
|
||||
var patchVerifier = services.GetService<IPatchVerificationOrchestrator>();
|
||||
if (patchVerifier is not null)
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine("[dim]Using patch verification service...[/]");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var verificationResult = await patchVerifier.VerifySingleAsync(
|
||||
cveId,
|
||||
binaryPath,
|
||||
$"file://{binaryPath}", // artifactPurl
|
||||
options: null,
|
||||
cancellationToken);
|
||||
|
||||
// Map verification status to micro-witness verdict
|
||||
verdict = verificationResult.Status switch
|
||||
{
|
||||
PatchVerificationStatus.Verified => MicroWitnessVerdicts.Patched,
|
||||
PatchVerificationStatus.PartialMatch => MicroWitnessVerdicts.Partial,
|
||||
PatchVerificationStatus.Inconclusive => MicroWitnessVerdicts.Inconclusive,
|
||||
PatchVerificationStatus.NotPatched => MicroWitnessVerdicts.Vulnerable,
|
||||
PatchVerificationStatus.NoPatchData => MicroWitnessVerdicts.Inconclusive,
|
||||
_ => MicroWitnessVerdicts.Inconclusive
|
||||
};
|
||||
|
||||
confidence = verificationResult.Confidence;
|
||||
matchAlgorithm = verificationResult.Method.ToString().ToLowerInvariant();
|
||||
|
||||
// Create evidence from fingerprint data
|
||||
if (verificationResult.ActualFingerprint is not null)
|
||||
{
|
||||
var fpState = verificationResult.Status == PatchVerificationStatus.Verified ? "patched" :
|
||||
verificationResult.Status == PatchVerificationStatus.NotPatched ? "vulnerable" :
|
||||
"unknown";
|
||||
|
||||
evidence.Add(new MicroWitnessFunctionEvidence
|
||||
{
|
||||
Function = verificationResult.ActualFingerprint.TargetBinary ?? Path.GetFileName(verificationResult.BinaryPath),
|
||||
State = fpState,
|
||||
Score = verificationResult.Similarity,
|
||||
Method = matchAlgorithm,
|
||||
Hash = verificationResult.ActualFingerprint.FingerprintValue
|
||||
});
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Verification completed: {verificationResult.Status} (confidence: {confidence:P0})[/]");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[yellow]Warning:[/] Patch verification failed: {ex.Message}");
|
||||
console.MarkupLine("[dim]Falling back to placeholder witness...[/]");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine("[yellow]Note:[/] Patch verification service not available. Generating placeholder witness.");
|
||||
}
|
||||
}
|
||||
|
||||
var witness = new BinaryMicroWitnessPredicate
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Binary = new MicroWitnessBinaryRef
|
||||
{
|
||||
Digest = $"sha256:{binaryHash}",
|
||||
Filename = binaryInfo.Name
|
||||
},
|
||||
Cve = new MicroWitnessCveRef
|
||||
{
|
||||
Id = cveId
|
||||
},
|
||||
Verdict = verdict,
|
||||
Confidence = confidence,
|
||||
Evidence = evidence,
|
||||
Tooling = new MicroWitnessTooling
|
||||
{
|
||||
BinaryIndexVersion = GetToolVersion(),
|
||||
Lifter = "b2r2",
|
||||
MatchAlgorithm = matchAlgorithm
|
||||
},
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Add SBOM reference if provided
|
||||
if (!string.IsNullOrEmpty(sbomPath) && File.Exists(sbomPath))
|
||||
{
|
||||
var sbomHash = await ComputeFileHashAsync(sbomPath, cancellationToken);
|
||||
witness = witness with
|
||||
{
|
||||
SbomRef = new MicroWitnessSbomRef
|
||||
{
|
||||
SbomDigest = $"sha256:{sbomHash}"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Serialize output
|
||||
string output;
|
||||
if (format == "envelope")
|
||||
{
|
||||
var statement = new BinaryMicroWitnessStatement
|
||||
{
|
||||
Subject =
|
||||
[
|
||||
new Subject
|
||||
{
|
||||
Name = binaryInfo.Name,
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = binaryHash
|
||||
}
|
||||
}
|
||||
],
|
||||
Predicate = witness
|
||||
};
|
||||
output = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
}
|
||||
else
|
||||
{
|
||||
output = JsonSerializer.Serialize(witness, JsonOptions);
|
||||
}
|
||||
|
||||
// Write output
|
||||
if (!string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, output, cancellationToken);
|
||||
console.MarkupLine($"[green]Witness written to:[/] {outputPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
console.WriteLine(output);
|
||||
}
|
||||
|
||||
if (sign)
|
||||
{
|
||||
console.MarkupLine("[yellow]Warning:[/] Signing not yet implemented. Use --sign with configured signing key.");
|
||||
}
|
||||
|
||||
if (rekor)
|
||||
{
|
||||
console.MarkupLine("[yellow]Warning:[/] Rekor logging not yet implemented. Use --rekor after signing is configured.");
|
||||
}
|
||||
|
||||
console.MarkupLine($"[dim]Verdict: {witness.Verdict} (confidence: {witness.Confidence:P0})[/]");
|
||||
if (witness.Evidence.Count > 0)
|
||||
{
|
||||
console.MarkupLine($"[dim]Evidence: {witness.Evidence.Count} function(s) analyzed[/]");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle `stella witness verify` command.
|
||||
/// </summary>
|
||||
internal static async Task HandleVerifyAsync(
|
||||
IServiceProvider services,
|
||||
string witnessPath,
|
||||
bool offline,
|
||||
string? sbomPath,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
if (!File.Exists(witnessPath))
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Witness file not found: {witnessPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Verifying witness: {witnessPath}[/]");
|
||||
if (offline)
|
||||
{
|
||||
console.MarkupLine("[dim]Mode: offline (no network access)[/]");
|
||||
}
|
||||
}
|
||||
|
||||
// Read and parse witness
|
||||
var witnessJson = await File.ReadAllTextAsync(witnessPath, cancellationToken);
|
||||
|
||||
BinaryMicroWitnessPredicate? predicate = null;
|
||||
|
||||
// Try parsing as statement first, then as predicate
|
||||
try
|
||||
{
|
||||
var statement = JsonSerializer.Deserialize<BinaryMicroWitnessStatement>(witnessJson, JsonOptions);
|
||||
predicate = statement?.Predicate;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Try as standalone predicate
|
||||
predicate = JsonSerializer.Deserialize<BinaryMicroWitnessPredicate>(witnessJson, JsonOptions);
|
||||
}
|
||||
|
||||
if (predicate is null)
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Failed to parse witness file.");
|
||||
return;
|
||||
}
|
||||
|
||||
var result = new VerificationResult
|
||||
{
|
||||
WitnessPath = witnessPath,
|
||||
SchemaVersion = predicate.SchemaVersion,
|
||||
BinaryDigest = predicate.Binary.Digest,
|
||||
CveId = predicate.Cve.Id,
|
||||
Verdict = predicate.Verdict,
|
||||
Confidence = predicate.Confidence,
|
||||
ComputedAt = predicate.ComputedAt,
|
||||
SignatureValid = false, // TODO: Implement signature verification
|
||||
RekorProofValid = false, // TODO: Implement Rekor proof verification
|
||||
OverallValid = true // Placeholder
|
||||
};
|
||||
|
||||
// SBOM validation
|
||||
bool? sbomMatch = null;
|
||||
if (!string.IsNullOrEmpty(sbomPath) && predicate.SbomRef?.SbomDigest is not null)
|
||||
{
|
||||
if (File.Exists(sbomPath))
|
||||
{
|
||||
var sbomHash = await ComputeFileHashAsync(sbomPath, cancellationToken);
|
||||
var expectedHash = predicate.SbomRef.SbomDigest.Replace("sha256:", "");
|
||||
sbomMatch = string.Equals(sbomHash, expectedHash, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
else
|
||||
{
|
||||
console.MarkupLine($"[yellow]Warning:[/] SBOM file not found: {sbomPath}");
|
||||
}
|
||||
}
|
||||
|
||||
result = result with { SbomMatch = sbomMatch };
|
||||
|
||||
// Output result
|
||||
if (format == "json")
|
||||
{
|
||||
console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
OutputTextResult(console, result, verbose);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle `stella witness bundle` command.
|
||||
/// </summary>
|
||||
internal static async Task HandleBundleAsync(
|
||||
IServiceProvider services,
|
||||
string witnessPath,
|
||||
string outputDir,
|
||||
bool includeBinary,
|
||||
bool includeSbom,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
if (!File.Exists(witnessPath))
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Witness file not found: {witnessPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create output directory
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Creating bundle in: {outputDir}[/]");
|
||||
}
|
||||
|
||||
// Copy witness file
|
||||
var witnessDestPath = Path.Combine(outputDir, "witness.json");
|
||||
File.Copy(witnessPath, witnessDestPath, overwrite: true);
|
||||
console.MarkupLine($"[green]✓[/] Witness: witness.json");
|
||||
|
||||
// Read witness to get binary info
|
||||
var witnessJson = await File.ReadAllTextAsync(witnessPath, cancellationToken);
|
||||
BinaryMicroWitnessPredicate? predicate = null;
|
||||
|
||||
try
|
||||
{
|
||||
var statement = JsonSerializer.Deserialize<BinaryMicroWitnessStatement>(witnessJson, JsonOptions);
|
||||
predicate = statement?.Predicate;
|
||||
}
|
||||
catch
|
||||
{
|
||||
predicate = JsonSerializer.Deserialize<BinaryMicroWitnessPredicate>(witnessJson, JsonOptions);
|
||||
}
|
||||
|
||||
// Create verify script (PowerShell)
|
||||
var verifyPs1 = """
|
||||
# Binary Micro-Witness Verification Script
|
||||
# Generated by StellaOps CLI
|
||||
|
||||
param(
|
||||
[switch]$Verbose
|
||||
)
|
||||
|
||||
$witnessPath = Join-Path $PSScriptRoot "witness.json"
|
||||
|
||||
if (-not (Test-Path $witnessPath)) {
|
||||
Write-Error "Witness file not found: $witnessPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$witness = Get-Content $witnessPath | ConvertFrom-Json
|
||||
|
||||
Write-Host "Binary Micro-Witness Verification" -ForegroundColor Cyan
|
||||
Write-Host "=================================="
|
||||
Write-Host ""
|
||||
Write-Host "Binary Digest: $($witness.binary.digest ?? $witness.predicate.binary.digest)"
|
||||
Write-Host "CVE: $($witness.cve.id ?? $witness.predicate.cve.id)"
|
||||
Write-Host "Verdict: $($witness.verdict ?? $witness.predicate.verdict)"
|
||||
Write-Host "Confidence: $($witness.confidence ?? $witness.predicate.confidence)"
|
||||
Write-Host ""
|
||||
Write-Host "[OK] Witness file parsed successfully" -ForegroundColor Green
|
||||
|
||||
# TODO: Add signature and Rekor verification
|
||||
Write-Host "[SKIP] Signature verification not yet implemented" -ForegroundColor Yellow
|
||||
Write-Host "[SKIP] Rekor proof verification not yet implemented" -ForegroundColor Yellow
|
||||
""";
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(outputDir, "verify.ps1"),
|
||||
verifyPs1,
|
||||
cancellationToken);
|
||||
console.MarkupLine("[green]✓[/] Script: verify.ps1");
|
||||
|
||||
// Create verify script (bash)
|
||||
var verifyBash = """
|
||||
#!/bin/bash
|
||||
# Binary Micro-Witness Verification Script
|
||||
# Generated by StellaOps CLI
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WITNESS_PATH="$SCRIPT_DIR/witness.json"
|
||||
|
||||
if [ ! -f "$WITNESS_PATH" ]; then
|
||||
echo "Error: Witness file not found: $WITNESS_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Binary Micro-Witness Verification"
|
||||
echo "=================================="
|
||||
echo ""
|
||||
|
||||
# Parse witness (requires jq)
|
||||
if command -v jq &> /dev/null; then
|
||||
BINARY_DIGEST=$(jq -r '.binary.digest // .predicate.binary.digest' "$WITNESS_PATH")
|
||||
CVE_ID=$(jq -r '.cve.id // .predicate.cve.id' "$WITNESS_PATH")
|
||||
VERDICT=$(jq -r '.verdict // .predicate.verdict' "$WITNESS_PATH")
|
||||
CONFIDENCE=$(jq -r '.confidence // .predicate.confidence' "$WITNESS_PATH")
|
||||
|
||||
echo "Binary Digest: $BINARY_DIGEST"
|
||||
echo "CVE: $CVE_ID"
|
||||
echo "Verdict: $VERDICT"
|
||||
echo "Confidence: $CONFIDENCE"
|
||||
echo ""
|
||||
echo "[OK] Witness file parsed successfully"
|
||||
else
|
||||
echo "Warning: jq not installed. Cannot parse witness details."
|
||||
echo "Install jq for full verification support."
|
||||
fi
|
||||
|
||||
# TODO: Add signature and Rekor verification
|
||||
echo "[SKIP] Signature verification not yet implemented"
|
||||
echo "[SKIP] Rekor proof verification not yet implemented"
|
||||
""";
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(outputDir, "verify.sh"),
|
||||
verifyBash,
|
||||
cancellationToken);
|
||||
console.MarkupLine("[green]✓[/] Script: verify.sh");
|
||||
|
||||
// Create README
|
||||
var readme = $"""
|
||||
# Binary Micro-Witness Bundle
|
||||
|
||||
Generated: {DateTimeOffset.UtcNow:O}
|
||||
|
||||
## Contents
|
||||
|
||||
- `witness.json` - The binary micro-witness predicate
|
||||
- `verify.ps1` - PowerShell verification script (Windows)
|
||||
- `verify.sh` - Bash verification script (Linux/macOS)
|
||||
|
||||
## Quick Verification
|
||||
|
||||
### Windows (PowerShell)
|
||||
```powershell
|
||||
.\verify.ps1
|
||||
```
|
||||
|
||||
### Linux/macOS (Bash)
|
||||
```bash
|
||||
chmod +x verify.sh
|
||||
./verify.sh
|
||||
```
|
||||
|
||||
## Witness Details
|
||||
|
||||
- **CVE**: {predicate?.Cve.Id ?? "N/A"}
|
||||
- **Binary Digest**: {predicate?.Binary.Digest ?? "N/A"}
|
||||
- **Verdict**: {predicate?.Verdict ?? "N/A"}
|
||||
- **Confidence**: {predicate?.Confidence ?? 0:P0}
|
||||
- **Computed At**: {predicate?.ComputedAt.ToString("O") ?? "N/A"}
|
||||
|
||||
## Offline Verification
|
||||
|
||||
This bundle is designed for air-gapped verification. No network access is required
|
||||
to verify the witness contents. Signature and Rekor proof verification require
|
||||
the bundled public keys and tile proofs (when available).
|
||||
""";
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(outputDir, "README.md"),
|
||||
readme,
|
||||
cancellationToken);
|
||||
console.MarkupLine("[green]✓[/] README.md");
|
||||
|
||||
console.MarkupLine($"\n[green]Bundle created:[/] {outputDir}");
|
||||
console.MarkupLine("[dim]Run verify.ps1 (Windows) or verify.sh (Linux/macOS) to verify.[/]");
|
||||
}
|
||||
|
||||
private static void OutputTextResult(IAnsiConsole console, VerificationResult result, bool verbose)
|
||||
{
|
||||
console.MarkupLine("[bold]Binary Micro-Witness Verification[/]");
|
||||
console.MarkupLine("===================================");
|
||||
console.MarkupLine($"Binary: {result.BinaryDigest}");
|
||||
console.MarkupLine($"CVE: {result.CveId}");
|
||||
console.MarkupLine($"Verdict: [bold]{result.Verdict}[/] (confidence: {result.Confidence:P0})");
|
||||
console.MarkupLine($"Computed: {result.ComputedAt:O}");
|
||||
console.MarkupLine("");
|
||||
|
||||
if (result.SignatureValid)
|
||||
{
|
||||
console.MarkupLine("[green]✓[/] Signature valid");
|
||||
}
|
||||
else
|
||||
{
|
||||
console.MarkupLine("[yellow]○[/] Signature not verified (unsigned or verification not implemented)");
|
||||
}
|
||||
|
||||
if (result.RekorProofValid)
|
||||
{
|
||||
console.MarkupLine("[green]✓[/] Rekor inclusion proof valid");
|
||||
}
|
||||
else
|
||||
{
|
||||
console.MarkupLine("[yellow]○[/] Rekor proof not verified (not logged or verification not implemented)");
|
||||
}
|
||||
|
||||
if (result.SbomMatch.HasValue)
|
||||
{
|
||||
if (result.SbomMatch.Value)
|
||||
{
|
||||
console.MarkupLine("[green]✓[/] SBOM digest matches");
|
||||
}
|
||||
else
|
||||
{
|
||||
console.MarkupLine("[red]✗[/] SBOM digest mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
console.MarkupLine("");
|
||||
var overallStatus = result.OverallValid ? "[green]PASS[/]" : "[red]FAIL[/]";
|
||||
console.MarkupLine($"Overall: {overallStatus}");
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileHashAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await sha256.ComputeHashAsync(stream, cancellationToken);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string GetToolVersion()
|
||||
{
|
||||
var assembly = typeof(WitnessCoreCommandHandlers).Assembly;
|
||||
var version = assembly.GetName().Version;
|
||||
return version?.ToString() ?? "0.0.0";
|
||||
}
|
||||
|
||||
private sealed record VerificationResult
|
||||
{
|
||||
public required string WitnessPath { get; init; }
|
||||
public required string SchemaVersion { get; init; }
|
||||
public required string BinaryDigest { get; init; }
|
||||
public required string CveId { get; init; }
|
||||
public required string Verdict { get; init; }
|
||||
public required double Confidence { get; init; }
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
public required bool SignatureValid { get; init; }
|
||||
public required bool RekorProofValid { get; init; }
|
||||
public bool? SbomMatch { get; init; }
|
||||
public required bool OverallValid { get; init; }
|
||||
}
|
||||
}
|
||||
356
src/Cli/__Tests/StellaOps.Cli.Tests/WitnessCoreCommandTests.cs
Normal file
356
src/Cli/__Tests/StellaOps.Cli.Tests/WitnessCoreCommandTests.cs
Normal file
@@ -0,0 +1,356 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WitnessCoreCommandTests.cs
|
||||
// Sprint: SPRINT_0128_001_BinaryIndex_binary_micro_witness
|
||||
// Task: TASK-003 — Integration tests for binary micro-witness CLI commands
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit;
|
||||
using StellaOps.Cli.Commands.Witness;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for binary micro-witness CLI commands (generate, verify, bundle).
|
||||
/// Tests the WitnessCoreCommandGroup which handles patch verification workflows.
|
||||
/// </summary>
|
||||
public sealed class WitnessCoreCommandTests
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly Option<bool> _verboseOption;
|
||||
private readonly CancellationToken _ct;
|
||||
|
||||
public WitnessCoreCommandTests()
|
||||
{
|
||||
var serviceCollection = new ServiceCollection();
|
||||
serviceCollection.AddLogging(builder => builder.AddConsole());
|
||||
_services = serviceCollection.BuildServiceProvider();
|
||||
_verboseOption = new Option<bool>("--verbose");
|
||||
_ct = CancellationToken.None;
|
||||
}
|
||||
|
||||
#region Command Structure Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreCommand_ShouldHaveExpectedSubcommands()
|
||||
{
|
||||
// Act
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(command);
|
||||
Assert.Equal("witness", command.Name);
|
||||
|
||||
var subcommandNames = command.Children.OfType<Command>().Select(c => c.Name).ToList();
|
||||
Assert.Contains("generate", subcommandNames);
|
||||
Assert.Contains("verify", subcommandNames);
|
||||
Assert.Contains("bundle", subcommandNames);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreCommand_HasCorrectDescription()
|
||||
{
|
||||
// Act
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("micro-witness", command.Description);
|
||||
Assert.Contains("patch verification", command.Description);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Generate Command Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreGenerate_HasExpectedOptionCount()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var generateCommand = command.Children.OfType<Command>().First(c => c.Name == "generate");
|
||||
|
||||
// Assert - generate has: cve, sbom, output, sign, rekor, format, verbose
|
||||
Assert.True(generateCommand.Options.Count() >= 6,
|
||||
$"Expected at least 6 options, found: {string.Join(", ", generateCommand.Options.Select(o => o.Name))}");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreGenerate_RequiresBinaryArgument()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var generateCommand = command.Children.OfType<Command>().First(c => c.Name == "generate");
|
||||
|
||||
// Act - parse without binary argument
|
||||
var result = generateCommand.Parse("--cve CVE-2024-1234");
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreGenerate_ParsesWithoutCveOption()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var generateCommand = command.Children.OfType<Command>().First(c => c.Name == "generate");
|
||||
|
||||
// Act - parse without --cve (cve validated at runtime by handler)
|
||||
var result = generateCommand.Parse("test.elf");
|
||||
|
||||
// Assert - parse succeeds, runtime will validate cve is provided
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreGenerate_ParsesValidArguments()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var generateCommand = command.Children.OfType<Command>().First(c => c.Name == "generate");
|
||||
|
||||
// Act
|
||||
var result = generateCommand.Parse("test.elf --cve CVE-2024-0567 --sbom sbom.json --sign --rekor");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreGenerate_ParsesWithEnvelopeFormat()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var generateCommand = command.Children.OfType<Command>().First(c => c.Name == "generate");
|
||||
|
||||
// Act
|
||||
var result = generateCommand.Parse("test.elf --cve CVE-2024-0567 --format envelope");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreGenerate_ParsesWithOutputOption()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var generateCommand = command.Children.OfType<Command>().First(c => c.Name == "generate");
|
||||
|
||||
// Act
|
||||
var result = generateCommand.Parse("test.elf --cve CVE-2024-0567 --output witness.json");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Verify Command Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreVerify_HasExpectedOptionCount()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var verifyCommand = command.Children.OfType<Command>().First(c => c.Name == "verify");
|
||||
|
||||
// Assert - verify has: offline, sbom, format, verbose
|
||||
Assert.True(verifyCommand.Options.Count() >= 3,
|
||||
$"Expected at least 3 options, found: {string.Join(", ", verifyCommand.Options.Select(o => o.Name))}");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreVerify_RequiresWitnessArgument()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var verifyCommand = command.Children.OfType<Command>().First(c => c.Name == "verify");
|
||||
|
||||
// Act - parse without witness argument
|
||||
var result = verifyCommand.Parse("--offline");
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreVerify_ParsesValidArguments()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var verifyCommand = command.Children.OfType<Command>().First(c => c.Name == "verify");
|
||||
|
||||
// Act
|
||||
var result = verifyCommand.Parse("witness.json --offline --sbom sbom.json");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreVerify_ParsesWithOfflineFlag()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var verifyCommand = command.Children.OfType<Command>().First(c => c.Name == "verify");
|
||||
|
||||
// Act
|
||||
var result = verifyCommand.Parse("witness.json --offline");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreVerify_ParsesWithJsonFormat()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var verifyCommand = command.Children.OfType<Command>().First(c => c.Name == "verify");
|
||||
|
||||
// Act
|
||||
var result = verifyCommand.Parse("witness.json --format json");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Bundle Command Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreBundle_HasExpectedOptionCount()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var bundleCommand = command.Children.OfType<Command>().First(c => c.Name == "bundle");
|
||||
|
||||
// Assert - bundle has: output, include-binary, include-sbom, verbose
|
||||
Assert.True(bundleCommand.Options.Count() >= 3,
|
||||
$"Expected at least 3 options, found: {string.Join(", ", bundleCommand.Options.Select(o => o.Name))}");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreBundle_RequiresWitnessArgument()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var bundleCommand = command.Children.OfType<Command>().First(c => c.Name == "bundle");
|
||||
|
||||
// Act - parse without witness argument
|
||||
var result = bundleCommand.Parse("--output ./bundle");
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreBundle_ParsesWithoutOptionalOutput()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var bundleCommand = command.Children.OfType<Command>().First(c => c.Name == "bundle");
|
||||
|
||||
// Act - parse without --output (output validated at runtime by handler)
|
||||
var result = bundleCommand.Parse("witness.json");
|
||||
|
||||
// Assert - parse succeeds, runtime will validate output is provided
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreBundle_ParsesValidArguments()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var bundleCommand = command.Children.OfType<Command>().First(c => c.Name == "bundle");
|
||||
|
||||
// Act
|
||||
var result = bundleCommand.Parse("witness.json --output ./bundle --include-binary --include-sbom");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreBundle_ParsesWithIncludeBinaryFlag()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var bundleCommand = command.Children.OfType<Command>().First(c => c.Name == "bundle");
|
||||
|
||||
// Act
|
||||
var result = bundleCommand.Parse("witness.json --output ./bundle --include-binary");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Help Text Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreGenerate_DescriptionMentionsGenerate()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var generateCommand = command.Children.OfType<Command>().First(c => c.Name == "generate");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(generateCommand.Description);
|
||||
Assert.Contains("micro-witness", generateCommand.Description.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreVerify_DescriptionMentionsVerify()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var verifyCommand = command.Children.OfType<Command>().First(c => c.Name == "verify");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(verifyCommand.Description);
|
||||
Assert.Contains("verify", verifyCommand.Description.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WitnessCoreBundle_DescriptionMentionsAirGapped()
|
||||
{
|
||||
// Arrange
|
||||
var command = WitnessCoreCommandGroup.BuildWitnessCommand(_services, _verboseOption, _ct);
|
||||
var bundleCommand = command.Children.OfType<Command>().First(c => c.Name == "bundle");
|
||||
|
||||
// Assert
|
||||
Assert.Contains("air-gapped", bundleCommand.Description.ToLowerInvariant());
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user