feat: integrate enterprise security system into core libraries
Adds KMS, secrets management, config encryption, and auth plugins to enable zero-trust security architecture across the provisioning platform.
This commit is contained in:
parent
228dbb889b
commit
1fe83246d6
1
.gitignore
vendored
1
.gitignore
vendored
@ -12,6 +12,7 @@ CLAUDE.md
|
|||||||
wrks
|
wrks
|
||||||
ROOT
|
ROOT
|
||||||
OLD
|
OLD
|
||||||
|
plugins/nushell-plugins
|
||||||
# Generated by Cargo
|
# Generated by Cargo
|
||||||
# will have compiled files and executables
|
# will have compiled files and executables
|
||||||
debug/
|
debug/
|
||||||
|
|||||||
418
nulib/audit/commands.nu
Normal file
418
nulib/audit/commands.nu
Normal file
@ -0,0 +1,418 @@
|
|||||||
|
# Audit logging CLI commands for Provisioning platform
|
||||||
|
#
|
||||||
|
# Provides query, export, and management of audit logs
|
||||||
|
|
||||||
|
use std assert
|
||||||
|
|
||||||
|
# Query audit logs with filters
|
||||||
|
#
|
||||||
|
# @param --user: Filter by user ID
|
||||||
|
# @param --action: Filter by action type
|
||||||
|
# @param --resource: Filter by resource name
|
||||||
|
# @param --workspace: Filter by workspace name
|
||||||
|
# @param --status: Filter by status (success, failure, error)
|
||||||
|
# @param --from: Filter by start date (RFC3339 or relative like "1d", "1w", "1M")
|
||||||
|
# @param --to: Filter by end date (RFC3339 or relative)
|
||||||
|
# @param --ip: Filter by IP address
|
||||||
|
# @param --limit: Maximum number of results (default: 100)
|
||||||
|
# @param --offset: Offset for pagination (default: 0)
|
||||||
|
# @param --sort-by: Sort field (timestamp, user, action)
|
||||||
|
# @param --sort-desc: Sort in descending order
|
||||||
|
# @param --out: Output format (json, table, csv)
|
||||||
|
#
|
||||||
|
# @example Query all server creates by a user
|
||||||
|
# audit query --user "user123" --action "server_create"
|
||||||
|
#
|
||||||
|
# @example Query failed operations in last 24 hours
|
||||||
|
# audit query --status "failure" --from "1d" --out table
|
||||||
|
export def "audit query" [
|
||||||
|
--user: string # Filter by user ID
|
||||||
|
--action: string # Filter by action type
|
||||||
|
--resource: string # Filter by resource
|
||||||
|
--workspace: string # Filter by workspace
|
||||||
|
--status: string # Filter by status
|
||||||
|
--from: string # Start date (RFC3339 or relative)
|
||||||
|
--to: string # End date (RFC3339 or relative)
|
||||||
|
--ip: string # Filter by IP address
|
||||||
|
--limit: int = 100 # Maximum results
|
||||||
|
--offset: int = 0 # Pagination offset
|
||||||
|
--sort-by: string = "timestamp" # Sort field
|
||||||
|
--sort-desc # Sort descending
|
||||||
|
--out: string = "table" # Output format
|
||||||
|
] {
|
||||||
|
# Convert relative dates to absolute
|
||||||
|
let from_date = if ($from | is-not-empty) {
|
||||||
|
parse_relative_date $from
|
||||||
|
} else { null }
|
||||||
|
|
||||||
|
let to_date = if ($to | is-not-empty) {
|
||||||
|
parse_relative_date $to
|
||||||
|
} else { null }
|
||||||
|
|
||||||
|
# Build query parameters
|
||||||
|
let query = {
|
||||||
|
filter: {
|
||||||
|
user_id: $user
|
||||||
|
action_type: $action
|
||||||
|
resource: $resource
|
||||||
|
workspace: $workspace
|
||||||
|
status: $status
|
||||||
|
from: $from_date
|
||||||
|
to: $to_date
|
||||||
|
ip: $ip
|
||||||
|
}
|
||||||
|
limit: $limit
|
||||||
|
offset: $offset
|
||||||
|
sort_by: $sort_by
|
||||||
|
sort_desc: $sort_desc
|
||||||
|
}
|
||||||
|
|
||||||
|
# Call orchestrator API
|
||||||
|
let orchestrator_url = get_orchestrator_url
|
||||||
|
let response = http post $"($orchestrator_url)/api/v1/audit/query" $query
|
||||||
|
|
||||||
|
if ($response | get success) {
|
||||||
|
let events = ($response | get data)
|
||||||
|
|
||||||
|
# Format output
|
||||||
|
match $out {
|
||||||
|
"json" => { $events | to json }
|
||||||
|
"csv" => { $events | to csv }
|
||||||
|
"table" => {
|
||||||
|
$events | select event_id timestamp user.id action.action_type action.resource result.status result.duration_ms
|
||||||
|
| rename event_id timestamp user action resource status duration_ms
|
||||||
|
| table
|
||||||
|
}
|
||||||
|
_ => { $events }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error make {
|
||||||
|
msg: "Failed to query audit logs"
|
||||||
|
label: {
|
||||||
|
text: ($response | get error)
|
||||||
|
span: (metadata $response).span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Export audit logs to file
|
||||||
|
#
|
||||||
|
# @param format: Export format (json, jsonlines, csv, splunk, elastic)
|
||||||
|
# @param --output: Output file path
|
||||||
|
# @param --user: Filter by user ID
|
||||||
|
# @param --action: Filter by action type
|
||||||
|
# @param --from: Start date filter
|
||||||
|
# @param --to: End date filter
|
||||||
|
#
|
||||||
|
# @example Export last 7 days to JSON
|
||||||
|
# audit export json --output audit-export.json --from "7d"
|
||||||
|
#
|
||||||
|
# @example Export to Splunk format
|
||||||
|
# audit export splunk --output splunk-events.json
|
||||||
|
export def "audit export" [
|
||||||
|
format: string # Export format (json, jsonlines, csv, splunk, elastic)
|
||||||
|
--output: string # Output file path
|
||||||
|
--user: string # Filter by user ID
|
||||||
|
--action: string # Filter by action type
|
||||||
|
--resource: string # Filter by resource
|
||||||
|
--workspace: string # Filter by workspace
|
||||||
|
--status: string # Filter by status
|
||||||
|
--from: string # Start date filter
|
||||||
|
--to: string # End date filter
|
||||||
|
] {
|
||||||
|
# Validate format
|
||||||
|
let valid_formats = ["json" "jsonlines" "csv" "splunk" "elastic"]
|
||||||
|
if ($format not-in $valid_formats) {
|
||||||
|
error make {
|
||||||
|
msg: $"Invalid format: ($format)"
|
||||||
|
label: {
|
||||||
|
text: $"Valid formats: ($valid_formats | str join ', ')"
|
||||||
|
span: (metadata $format).span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Convert relative dates
|
||||||
|
let from_date = if ($from | is-not-empty) {
|
||||||
|
parse_relative_date $from
|
||||||
|
} else { null }
|
||||||
|
|
||||||
|
let to_date = if ($to | is-not-empty) {
|
||||||
|
parse_relative_date $to
|
||||||
|
} else { null }
|
||||||
|
|
||||||
|
# Build export request
|
||||||
|
let request = {
|
||||||
|
format: $format
|
||||||
|
filter: {
|
||||||
|
user_id: $user
|
||||||
|
action_type: $action
|
||||||
|
resource: $resource
|
||||||
|
workspace: $workspace
|
||||||
|
status: $status
|
||||||
|
from: $from_date
|
||||||
|
to: $to_date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Call orchestrator API
|
||||||
|
let orchestrator_url = get_orchestrator_url
|
||||||
|
let response = http post $"($orchestrator_url)/api/v1/audit/export" $request
|
||||||
|
|
||||||
|
if ($response | get success) {
|
||||||
|
let data = ($response | get data)
|
||||||
|
|
||||||
|
# Write to file if output specified
|
||||||
|
if ($output | is-not-empty) {
|
||||||
|
$data | save --force $output
|
||||||
|
print $"✓ Audit logs exported to: ($output)"
|
||||||
|
} else {
|
||||||
|
# Print to stdout
|
||||||
|
$data
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error make {
|
||||||
|
msg: "Failed to export audit logs"
|
||||||
|
label: {
|
||||||
|
text: ($response | get error)
|
||||||
|
span: (metadata $response).span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show audit log statistics
|
||||||
|
#
|
||||||
|
# @example Display audit statistics
|
||||||
|
# audit stats
|
||||||
|
export def "audit stats" [] {
|
||||||
|
let orchestrator_url = get_orchestrator_url
|
||||||
|
let response = http get $"($orchestrator_url)/api/v1/audit/stats"
|
||||||
|
|
||||||
|
if ($response | get success) {
|
||||||
|
let stats = ($response | get data)
|
||||||
|
|
||||||
|
print "\nAudit Log Statistics:\n"
|
||||||
|
|
||||||
|
[
|
||||||
|
["Metric" "Value"];
|
||||||
|
["Total Events" ($stats.total_events | into string)]
|
||||||
|
["Success Events" ($stats.success_events | into string)]
|
||||||
|
["Failure Events" ($stats.failure_events | into string)]
|
||||||
|
["Error Events" ($stats.error_events | into string)]
|
||||||
|
["Buffered Events" ($stats.buffered_events | into string)]
|
||||||
|
["Flushed Events" ($stats.flushed_events | into string)]
|
||||||
|
] | table
|
||||||
|
} else {
|
||||||
|
error make {
|
||||||
|
msg: "Failed to get audit statistics"
|
||||||
|
label: {
|
||||||
|
text: ($response | get error)
|
||||||
|
span: (metadata $response).span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show storage statistics
|
||||||
|
#
|
||||||
|
# @example Display storage statistics
|
||||||
|
# audit storage-stats
|
||||||
|
export def "audit storage-stats" [] {
|
||||||
|
let orchestrator_url = get_orchestrator_url
|
||||||
|
let response = http get $"($orchestrator_url)/api/v1/audit/storage-stats"
|
||||||
|
|
||||||
|
if ($response | get success) {
|
||||||
|
let stats = ($response | get data)
|
||||||
|
|
||||||
|
print "\nAudit Storage Statistics:\n"
|
||||||
|
|
||||||
|
[
|
||||||
|
["Metric" "Value"];
|
||||||
|
["Total Events" ($stats.total_events | into string)]
|
||||||
|
["Total Size" (format_bytes $stats.total_size_bytes)]
|
||||||
|
["Oldest Event" ($stats.oldest_event | default "N/A")]
|
||||||
|
["Newest Event" ($stats.newest_event | default "N/A")]
|
||||||
|
] | table
|
||||||
|
} else {
|
||||||
|
error make {
|
||||||
|
msg: "Failed to get storage statistics"
|
||||||
|
label: {
|
||||||
|
text: ($response | get error)
|
||||||
|
span: (metadata $response).span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Apply retention policy to audit logs
|
||||||
|
#
|
||||||
|
# @param --max-age-days: Maximum age in days (default: 365)
|
||||||
|
# @param --archive: Archive before deletion
|
||||||
|
# @param --archive-path: Archive destination path
|
||||||
|
# @param --yes: Skip confirmation
|
||||||
|
#
|
||||||
|
# @example Apply 90-day retention policy
|
||||||
|
# audit apply-retention --max-age-days 90 --archive --yes
|
||||||
|
export def "audit apply-retention" [
|
||||||
|
--max-age-days: int = 365 # Maximum age in days
|
||||||
|
--archive # Archive before deletion
|
||||||
|
--archive-path: string # Archive destination
|
||||||
|
--yes # Skip confirmation
|
||||||
|
] {
|
||||||
|
if not $yes {
|
||||||
|
print $"⚠️ This will delete audit logs older than ($max-age_days) days"
|
||||||
|
let confirm = (input "Continue? (yes/no): ")
|
||||||
|
if ($confirm != "yes") {
|
||||||
|
print "Cancelled"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let policy = {
|
||||||
|
max_age_days: $max_age_days
|
||||||
|
auto_apply: false
|
||||||
|
archive_before_delete: $archive
|
||||||
|
archive_path: $archive_path
|
||||||
|
}
|
||||||
|
|
||||||
|
let orchestrator_url = get_orchestrator_url
|
||||||
|
let response = http post $"($orchestrator_url)/api/v1/audit/apply-retention" $policy
|
||||||
|
|
||||||
|
if ($response | get success) {
|
||||||
|
let deleted = ($response | get data)
|
||||||
|
print $"✓ Retention policy applied: ($deleted) events processed"
|
||||||
|
} else {
|
||||||
|
error make {
|
||||||
|
msg: "Failed to apply retention policy"
|
||||||
|
label: {
|
||||||
|
text: ($response | get error)
|
||||||
|
span: (metadata $response).span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# View recent audit events (tail)
|
||||||
|
#
|
||||||
|
# @param --count: Number of recent events to show (default: 20)
|
||||||
|
# @param --follow: Follow new events in real-time
|
||||||
|
#
|
||||||
|
# @example View last 50 events
|
||||||
|
# audit tail --count 50
|
||||||
|
#
|
||||||
|
# @example Follow audit log in real-time
|
||||||
|
# audit tail --follow
|
||||||
|
export def "audit tail" [
|
||||||
|
--count: int = 20 # Number of events to show
|
||||||
|
--follow # Follow new events
|
||||||
|
] {
|
||||||
|
if $follow {
|
||||||
|
# Real-time following via WebSocket
|
||||||
|
let orchestrator_url = get_orchestrator_url
|
||||||
|
let ws_url = ($orchestrator_url | str replace "http" "ws")
|
||||||
|
|
||||||
|
print "Following audit log (Ctrl+C to stop)..."
|
||||||
|
# TODO: Implement WebSocket streaming
|
||||||
|
print "WebSocket streaming not yet implemented"
|
||||||
|
} else {
|
||||||
|
# Show recent events
|
||||||
|
audit query --limit $count --sort-by timestamp --sort-desc --out table
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Search audit logs with text query
|
||||||
|
#
|
||||||
|
# @param query: Search text
|
||||||
|
# @param --field: Field to search (user, resource, action)
|
||||||
|
#
|
||||||
|
# @example Search for specific server
|
||||||
|
# audit search "server-123"
|
||||||
|
#
|
||||||
|
# @example Search in specific field
|
||||||
|
# audit search "john" --field user
|
||||||
|
export def "audit search" [
|
||||||
|
query: string # Search query
|
||||||
|
--field: string = "all" # Field to search
|
||||||
|
] {
|
||||||
|
# Use orchestrator search endpoint
|
||||||
|
let orchestrator_url = get_orchestrator_url
|
||||||
|
let response = http post $"($orchestrator_url)/api/v1/audit/search" {
|
||||||
|
query: $query
|
||||||
|
field: $field
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response | get success) {
|
||||||
|
let results = ($response | get data)
|
||||||
|
$results | select event_id timestamp user.id action.action_type action.resource
|
||||||
|
| rename event_id timestamp user action resource
|
||||||
|
| table
|
||||||
|
} else {
|
||||||
|
error make {
|
||||||
|
msg: "Failed to search audit logs"
|
||||||
|
label: {
|
||||||
|
text: ($response | get error)
|
||||||
|
span: (metadata $response).span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper: Get orchestrator URL from config
|
||||||
|
def get_orchestrator_url [] {
|
||||||
|
# Try environment variable first
|
||||||
|
let env_url = ($env.PROVISIONING_ORCHESTRATOR_URL? | default "")
|
||||||
|
|
||||||
|
if ($env_url | is-not-empty) {
|
||||||
|
$env_url
|
||||||
|
} else {
|
||||||
|
# Default to localhost
|
||||||
|
"http://localhost:8080"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper: Parse relative date (1d, 7d, 1w, 1M, 1y)
|
||||||
|
def parse_relative_date [relative: string] {
|
||||||
|
let now = (date now)
|
||||||
|
|
||||||
|
if ($relative | str ends-with "d") {
|
||||||
|
let days = ($relative | str replace "d" "" | into int)
|
||||||
|
$now | date subtract ($days) day | format date "%+"
|
||||||
|
} else if ($relative | str ends-with "w") {
|
||||||
|
let weeks = ($relative | str replace "w" "" | into int)
|
||||||
|
$now | date subtract ($weeks * 7) day | format date "%+"
|
||||||
|
} else if ($relative | str ends-with "M") {
|
||||||
|
let months = ($relative | str replace "M" "" | into int)
|
||||||
|
$now | date subtract ($months) month | format date "%+"
|
||||||
|
} else if ($relative | str ends-with "y") {
|
||||||
|
let years = ($relative | str replace "y" "" | into int)
|
||||||
|
$now | date subtract ($years) year | format date "%+"
|
||||||
|
} else {
|
||||||
|
# Assume it's already RFC3339
|
||||||
|
$relative
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper: Format bytes to human-readable
|
||||||
|
def format_bytes [bytes: int] {
|
||||||
|
if $bytes < 1024 {
|
||||||
|
$"($bytes) B"
|
||||||
|
} else if $bytes < (1024 * 1024) {
|
||||||
|
$"(($bytes / 1024) | math round -p 2) KB"
|
||||||
|
} else if $bytes < (1024 * 1024 * 1024) {
|
||||||
|
$"(($bytes / 1024 / 1024) | math round -p 2) MB"
|
||||||
|
} else {
|
||||||
|
$"(($bytes / 1024 / 1024 / 1024) | math round -p 2) GB"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper: Test if value is empty
|
||||||
|
def "is-empty" [] {
|
||||||
|
$in == null or $in == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper: Test if value is not empty
|
||||||
|
def "is-not-empty" [] {
|
||||||
|
not ($in | is-empty)
|
||||||
|
}
|
||||||
329
nulib/break_glass/commands.nu
Normal file
329
nulib/break_glass/commands.nu
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
# Break-Glass Emergency Access Commands
|
||||||
|
#
|
||||||
|
# Provides CLI interface for break-glass emergency access system
|
||||||
|
|
||||||
|
# Request emergency access
|
||||||
|
export def "break-glass request" [
|
||||||
|
reason: string # Emergency reason (brief)
|
||||||
|
--justification: string # Detailed justification (required)
|
||||||
|
--resources: list<string> = [] # Target resources
|
||||||
|
--permissions: list<string> = [] # Requested permissions
|
||||||
|
--duration: duration = 4hr # Maximum session duration
|
||||||
|
--orchestrator: string = "http://localhost:8080" # Orchestrator URL
|
||||||
|
]: nothing -> record {
|
||||||
|
if ($justification | is-empty) {
|
||||||
|
error make {msg: "Justification is required for break-glass requests"}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get current user info
|
||||||
|
let requester = {
|
||||||
|
id: (whoami)
|
||||||
|
email: $"(whoami)@example.com"
|
||||||
|
name: (whoami)
|
||||||
|
teams: ["operations"]
|
||||||
|
roles: ["Operator"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Convert permissions list to structured format
|
||||||
|
let structured_permissions = $permissions | each {|p|
|
||||||
|
let parts = ($p | split row ":")
|
||||||
|
{
|
||||||
|
resource: ($parts | get 0)
|
||||||
|
action: ($parts | get 1? | default "admin")
|
||||||
|
scope: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Convert duration to hours
|
||||||
|
let duration_hours = ($duration | into int) / (1hr | into int)
|
||||||
|
|
||||||
|
let payload = {
|
||||||
|
requester: $requester
|
||||||
|
reason: $reason
|
||||||
|
justification: $justification
|
||||||
|
target_resources: $resources
|
||||||
|
requested_permissions: $structured_permissions
|
||||||
|
duration_hours: $duration_hours
|
||||||
|
}
|
||||||
|
|
||||||
|
print $"🚨 Requesting emergency access..."
|
||||||
|
print $" Reason: ($reason)"
|
||||||
|
print $" Duration: ($duration)"
|
||||||
|
print $" Resources: ($resources | str join ', ')"
|
||||||
|
|
||||||
|
let response = (http post $"($orchestrator)/api/v1/break-glass/request" $payload)
|
||||||
|
|
||||||
|
print $"✅ Request created: ($response.request_id)"
|
||||||
|
print $" Status: ($response.status)"
|
||||||
|
print $" Expires: ($response.expires_at)"
|
||||||
|
print ""
|
||||||
|
print $"⏳ Waiting for approval from 2+ approvers..."
|
||||||
|
|
||||||
|
$response
|
||||||
|
}
|
||||||
|
|
||||||
|
# Approve emergency request
|
||||||
|
export def "break-glass approve" [
|
||||||
|
request_id: string # Request ID to approve
|
||||||
|
--reason: string = "Approved" # Approval reason
|
||||||
|
--orchestrator: string = "http://localhost:8080" # Orchestrator URL
|
||||||
|
]: nothing -> record {
|
||||||
|
# Get current user info
|
||||||
|
let approver = {
|
||||||
|
id: (whoami)
|
||||||
|
email: $"(whoami)@example.com"
|
||||||
|
name: (whoami)
|
||||||
|
teams: ["security"]
|
||||||
|
roles: ["SecurityOfficer"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get IP address
|
||||||
|
let ip_address = "127.0.0.1"
|
||||||
|
|
||||||
|
let payload = {
|
||||||
|
approver: $approver
|
||||||
|
reason: $reason
|
||||||
|
ip_address: $ip_address
|
||||||
|
}
|
||||||
|
|
||||||
|
print $"✅ Approving request ($request_id)..."
|
||||||
|
|
||||||
|
let response = (http post $"($orchestrator)/api/v1/break-glass/requests/($request_id)/approve" $payload)
|
||||||
|
|
||||||
|
if $response.approved {
|
||||||
|
print $"✅ Request fully approved!"
|
||||||
|
print $" All required approvals received"
|
||||||
|
print $" Ready for activation"
|
||||||
|
} else {
|
||||||
|
print $"⏳ Approval recorded"
|
||||||
|
print $" ($response.message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$response
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deny emergency request
|
||||||
|
export def "break-glass deny" [
|
||||||
|
request_id: string # Request ID to deny
|
||||||
|
--reason: string = "Denied" # Denial reason
|
||||||
|
--orchestrator: string = "http://localhost:8080" # Orchestrator URL
|
||||||
|
]: nothing -> nothing {
|
||||||
|
# Get current user info
|
||||||
|
let denier = {
|
||||||
|
id: (whoami)
|
||||||
|
email: $"(whoami)@example.com"
|
||||||
|
name: (whoami)
|
||||||
|
teams: ["security"]
|
||||||
|
roles: ["SecurityOfficer"]
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = {
|
||||||
|
denier: $denier
|
||||||
|
reason: $reason
|
||||||
|
}
|
||||||
|
|
||||||
|
print $"❌ Denying request ($request_id)..."
|
||||||
|
|
||||||
|
http post $"($orchestrator)/api/v1/break-glass/requests/($request_id)/deny" $payload | ignore
|
||||||
|
|
||||||
|
print $"✅ Request denied"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Activate approved session
|
||||||
|
export def "break-glass activate" [
|
||||||
|
request_id: string # Request ID to activate
|
||||||
|
--orchestrator: string = "http://localhost:8080" # Orchestrator URL
|
||||||
|
]: nothing -> record {
|
||||||
|
print $"🔓 Activating emergency session for request ($request_id)..."
|
||||||
|
|
||||||
|
let token = (http post $"($orchestrator)/api/v1/break-glass/requests/($request_id)/activate" {})
|
||||||
|
|
||||||
|
print $"✅ Emergency session activated!"
|
||||||
|
print $" Session ID: ($token.session_id)"
|
||||||
|
print $" Expires: ($token.expires_at)"
|
||||||
|
print $" Token: ($token.access_token | str substring 0..50)..."
|
||||||
|
print ""
|
||||||
|
print $"⚠️ This session is logged and monitored"
|
||||||
|
print $"⚠️ All actions will be audited"
|
||||||
|
print ""
|
||||||
|
print $"Export token:"
|
||||||
|
print $" export EMERGENCY_TOKEN=($token.access_token)"
|
||||||
|
|
||||||
|
$token
|
||||||
|
}
|
||||||
|
|
||||||
|
# Revoke active session
|
||||||
|
export def "break-glass revoke" [
|
||||||
|
session_id: string # Session ID to revoke
|
||||||
|
--reason: string = "Manual revocation" # Revocation reason
|
||||||
|
--orchestrator: string = "http://localhost:8080" # Orchestrator URL
|
||||||
|
]: nothing -> nothing {
|
||||||
|
let payload = {
|
||||||
|
reason: $reason
|
||||||
|
}
|
||||||
|
|
||||||
|
print $"🔒 Revoking session ($session_id)..."
|
||||||
|
|
||||||
|
http post $"($orchestrator)/api/v1/break-glass/sessions/($session_id)/revoke" $payload | ignore
|
||||||
|
|
||||||
|
print $"✅ Session revoked"
|
||||||
|
}
|
||||||
|
|
||||||
|
# List pending requests
|
||||||
|
export def "break-glass list-requests" [
|
||||||
|
--status: string = "pending" # Filter by status (pending, all)
|
||||||
|
--orchestrator: string = "http://localhost:8080" # Orchestrator URL
|
||||||
|
]: nothing -> table {
|
||||||
|
let pending_only = ($status == "pending")
|
||||||
|
|
||||||
|
print $"📋 Listing break-glass requests..."
|
||||||
|
|
||||||
|
let requests = (http get $"($orchestrator)/api/v1/break-glass/requests?pending_only=($pending_only)")
|
||||||
|
|
||||||
|
if ($requests | is-empty) {
|
||||||
|
print "No requests found"
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
$requests | select id status requester.email reason created_at expires_at
|
||||||
|
}
|
||||||
|
|
||||||
|
# List active sessions
|
||||||
|
export def "break-glass list-sessions" [
|
||||||
|
--active-only: bool = false # Show only active sessions
|
||||||
|
--orchestrator: string = "http://localhost:8080" # Orchestrator URL
|
||||||
|
]: nothing -> table {
|
||||||
|
print $"📋 Listing break-glass sessions..."
|
||||||
|
|
||||||
|
let sessions = (http get $"($orchestrator)/api/v1/break-glass/sessions?active_only=($active_only)")
|
||||||
|
|
||||||
|
if ($sessions | is-empty) {
|
||||||
|
print "No sessions found"
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessions | select id status request.requester.email activated_at expires_at
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show session details
|
||||||
|
export def "break-glass show" [
|
||||||
|
session_id: string # Session ID to show
|
||||||
|
--orchestrator: string = "http://localhost:8080" # Orchestrator URL
|
||||||
|
]: nothing -> record {
|
||||||
|
print $"🔍 Fetching session details for ($session_id)..."
|
||||||
|
|
||||||
|
let session = (http get $"($orchestrator)/api/v1/break-glass/sessions/($session_id)")
|
||||||
|
|
||||||
|
print ""
|
||||||
|
print $"Session ID: ($session.id)"
|
||||||
|
print $"Status: ($session.status)"
|
||||||
|
print $"Requester: ($session.request.requester.email)"
|
||||||
|
print $"Reason: ($session.request.reason)"
|
||||||
|
print ""
|
||||||
|
print "Approvals:"
|
||||||
|
$session.approvals | each {|a|
|
||||||
|
print $" - ($a.approver.email) at ($a.approved_at)"
|
||||||
|
}
|
||||||
|
print ""
|
||||||
|
print $"Activated: ($session.activated_at)"
|
||||||
|
print $"Expires: ($session.expires_at)"
|
||||||
|
print ""
|
||||||
|
print $"Actions performed: ($session.actions | length)"
|
||||||
|
|
||||||
|
$session
|
||||||
|
}
|
||||||
|
|
||||||
|
# Query break-glass audit logs
|
||||||
|
export def "break-glass audit" [
|
||||||
|
--from: datetime # Start time
|
||||||
|
--to: datetime # End time
|
||||||
|
--session-id: string # Filter by session ID
|
||||||
|
--orchestrator: string = "http://localhost:8080" # Orchestrator URL
|
||||||
|
]: nothing -> table {
|
||||||
|
print $"📜 Querying break-glass audit logs..."
|
||||||
|
|
||||||
|
mut params = []
|
||||||
|
|
||||||
|
if not ($from | is-empty) {
|
||||||
|
$params = ($params | append $"from=($from | date to-record | get year)-($from | date to-record | get month)-($from | date to-record | get day)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if not ($to | is-empty) {
|
||||||
|
$params = ($params | append $"to=($to | date to-record | get year)-($to | date to-record | get month)-($to | date to-record | get day)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if not ($session_id | is-empty) {
|
||||||
|
$params = ($params | append $"session_id=($session_id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let query_string = if ($params | is-empty) { "" } else { $"?($params | str join '&')" }
|
||||||
|
|
||||||
|
let logs = (http get $"($orchestrator)/api/v1/break-glass/audit($query_string)")
|
||||||
|
|
||||||
|
if ($logs | is-empty) {
|
||||||
|
print "No audit logs found"
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
$logs | select event_id event_type session_id user.email timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show break-glass statistics
|
||||||
|
export def "break-glass stats" [
|
||||||
|
--orchestrator: string = "http://localhost:8080" # Orchestrator URL
|
||||||
|
]: nothing -> record {
|
||||||
|
print $"📊 Fetching break-glass statistics..."
|
||||||
|
|
||||||
|
let stats = (http get $"($orchestrator)/api/v1/break-glass/statistics")
|
||||||
|
|
||||||
|
print ""
|
||||||
|
print "Approval Statistics:"
|
||||||
|
print $" Total requests: ($stats.approval.total_requests)"
|
||||||
|
print $" Pending: ($stats.approval.pending_requests)"
|
||||||
|
print $" Approved: ($stats.approval.approved_requests)"
|
||||||
|
print $" Denied: ($stats.approval.denied_requests)"
|
||||||
|
print $" Expired: ($stats.approval.expired_requests)"
|
||||||
|
print ""
|
||||||
|
print "Session Statistics:"
|
||||||
|
print $" Total sessions: ($stats.session.total_sessions)"
|
||||||
|
print $" Active: ($stats.session.active_sessions)"
|
||||||
|
print $" Revoked: ($stats.session.revoked_sessions)"
|
||||||
|
print $" Expired: ($stats.session.expired_sessions)"
|
||||||
|
print $" Total actions: ($stats.session.total_actions)"
|
||||||
|
print ""
|
||||||
|
print "Revocation Monitoring:"
|
||||||
|
print $" Enabled: ($stats.revocation.monitoring_enabled)"
|
||||||
|
print $" Check interval: ($stats.revocation.check_interval_seconds)s"
|
||||||
|
|
||||||
|
$stats
|
||||||
|
}
|
||||||
|
|
||||||
|
# Break-glass help
|
||||||
|
export def "break-glass help" []: nothing -> nothing {
|
||||||
|
print "Break-Glass Emergency Access System"
|
||||||
|
print ""
|
||||||
|
print "Commands:"
|
||||||
|
print " request - Request emergency access"
|
||||||
|
print " approve - Approve emergency request"
|
||||||
|
print " deny - Deny emergency request"
|
||||||
|
print " activate - Activate approved session"
|
||||||
|
print " revoke - Revoke active session"
|
||||||
|
print " list-requests - List pending requests"
|
||||||
|
print " list-sessions - List active sessions"
|
||||||
|
print " show - Show session details"
|
||||||
|
print " audit - Query audit logs"
|
||||||
|
print " stats - Show statistics"
|
||||||
|
print ""
|
||||||
|
print "Examples:"
|
||||||
|
print " # Request emergency access"
|
||||||
|
print " break-glass request 'Production outage' --justification 'Database cluster down' --resources ['db/*'] --duration 2hr"
|
||||||
|
print ""
|
||||||
|
print " # Approve request"
|
||||||
|
print " break-glass approve <request_id> --reason 'Emergency confirmed'"
|
||||||
|
print ""
|
||||||
|
print " # Activate session"
|
||||||
|
print " break-glass activate <request_id>"
|
||||||
|
print ""
|
||||||
|
print " # Revoke session"
|
||||||
|
print " break-glass revoke <session_id> --reason 'Emergency resolved'"
|
||||||
|
}
|
||||||
508
nulib/compliance/commands.nu
Normal file
508
nulib/compliance/commands.nu
Normal file
@ -0,0 +1,508 @@
|
|||||||
|
# Compliance CLI Commands
|
||||||
|
# Provides comprehensive compliance features for GDPR, SOC2, and ISO 27001
|
||||||
|
|
||||||
|
const ORCHESTRATOR_URL = "http://localhost:8080"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# GDPR Commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Export personal data for a user (GDPR Article 15 - Right to Access)
|
||||||
|
export def "compliance gdpr export" [
|
||||||
|
user_id: string # User ID to export data for
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/gdpr/export/($user_id)"
|
||||||
|
|
||||||
|
print $"Exporting personal data for user: ($user_id)"
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response = http post $url {}
|
||||||
|
$response | to json
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to export data: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Delete personal data for a user (GDPR Article 17 - Right to Erasure)
|
||||||
|
export def "compliance gdpr delete" [
|
||||||
|
user_id: string # User ID to delete data for
|
||||||
|
--reason: string = "user_request" # Deletion reason
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/gdpr/delete/($user_id)"
|
||||||
|
|
||||||
|
print $"Deleting personal data for user: ($user_id)"
|
||||||
|
print $"Reason: ($reason)"
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response = http post $url {reason: $reason}
|
||||||
|
print "✓ Data deletion completed"
|
||||||
|
$response | to json
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to delete data: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rectify personal data for a user (GDPR Article 16 - Right to Rectification)
|
||||||
|
export def "compliance gdpr rectify" [
|
||||||
|
user_id: string # User ID
|
||||||
|
--field: string # Field to rectify
|
||||||
|
--value: string # New value
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
if ($field | is-empty) or ($value | is-empty) {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: "Both --field and --value must be provided"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/gdpr/rectify/($user_id)"
|
||||||
|
let corrections = {($field): $value}
|
||||||
|
|
||||||
|
print $"Rectifying data for user: ($user_id)"
|
||||||
|
print $"Field: ($field) -> ($value)"
|
||||||
|
|
||||||
|
try {
|
||||||
|
http post $url {corrections: $corrections}
|
||||||
|
print "✓ Data rectification completed"
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to rectify data: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Export data for portability (GDPR Article 20 - Right to Data Portability)
|
||||||
|
export def "compliance gdpr portability" [
|
||||||
|
user_id: string # User ID
|
||||||
|
--format: string = "json" # Export format (json, csv, xml)
|
||||||
|
--output: string # Output file path
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/gdpr/portability/($user_id)"
|
||||||
|
|
||||||
|
print $"Exporting data for portability: ($user_id)"
|
||||||
|
print $"Format: ($format)"
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response = http post $url {format: $format}
|
||||||
|
|
||||||
|
if ($output | is-empty) {
|
||||||
|
$response
|
||||||
|
} else {
|
||||||
|
$response | save $output
|
||||||
|
print $"✓ Data exported to: ($output)"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to export data: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Record objection to processing (GDPR Article 21 - Right to Object)
|
||||||
|
export def "compliance gdpr object" [
|
||||||
|
user_id: string # User ID
|
||||||
|
processing_type: string # Type of processing to object (direct_marketing, profiling, etc.)
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/gdpr/object/($user_id)"
|
||||||
|
|
||||||
|
print $"Recording objection for user: ($user_id)"
|
||||||
|
print $"Processing type: ($processing_type)"
|
||||||
|
|
||||||
|
try {
|
||||||
|
http post $url {processing_type: $processing_type}
|
||||||
|
print "✓ Objection recorded"
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to record objection: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SOC2 Commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Generate SOC2 compliance report
|
||||||
|
export def "compliance soc2 report" [
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
--output: string # Output file path
|
||||||
|
] {
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/soc2/report"
|
||||||
|
|
||||||
|
print "Generating SOC2 compliance report..."
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response = http get $url
|
||||||
|
|
||||||
|
if ($output | is-empty) {
|
||||||
|
$response | to json
|
||||||
|
} else {
|
||||||
|
$response | to json | save $output
|
||||||
|
print $"✓ SOC2 report saved to: ($output)"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to generate SOC2 report: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# List SOC2 Trust Service Criteria
|
||||||
|
export def "compliance soc2 controls" [
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/soc2/controls"
|
||||||
|
|
||||||
|
try {
|
||||||
|
http get $url | get controls
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to list controls: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ISO 27001 Commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Generate ISO 27001 compliance report
|
||||||
|
export def "compliance iso27001 report" [
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
--output: string # Output file path
|
||||||
|
] {
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/iso27001/report"
|
||||||
|
|
||||||
|
print "Generating ISO 27001 compliance report..."
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response = http get $url
|
||||||
|
|
||||||
|
if ($output | is-empty) {
|
||||||
|
$response | to json
|
||||||
|
} else {
|
||||||
|
$response | to json | save $output
|
||||||
|
print $"✓ ISO 27001 report saved to: ($output)"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to generate ISO 27001 report: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# List ISO 27001 Annex A controls
|
||||||
|
export def "compliance iso27001 controls" [
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/iso27001/controls"
|
||||||
|
|
||||||
|
try {
|
||||||
|
http get $url | get controls
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to list controls: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# List identified risks
|
||||||
|
export def "compliance iso27001 risks" [
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/iso27001/risks"
|
||||||
|
|
||||||
|
try {
|
||||||
|
http get $url | get risks
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to list risks: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Data Protection Commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Verify data protection controls
|
||||||
|
export def "compliance protection verify" [
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/protection/verify"
|
||||||
|
|
||||||
|
print "Verifying data protection controls..."
|
||||||
|
|
||||||
|
try {
|
||||||
|
http get $url | to json
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to verify protection: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Classify data
|
||||||
|
export def "compliance protection classify" [
|
||||||
|
data: string # Data to classify
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/protection/classify"
|
||||||
|
|
||||||
|
try {
|
||||||
|
http post $url {data: $data} | get classification
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to classify data: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Access Control Commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# List available roles
|
||||||
|
export def "compliance access roles" [
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/access/roles"
|
||||||
|
|
||||||
|
try {
|
||||||
|
http get $url | get roles
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to list roles: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get permissions for a role
|
||||||
|
export def "compliance access permissions" [
|
||||||
|
role: string # Role name
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/access/permissions/($role)"
|
||||||
|
|
||||||
|
try {
|
||||||
|
http get $url | get permissions
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to get permissions: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if role has permission
|
||||||
|
export def "compliance access check" [
|
||||||
|
role: string # Role name
|
||||||
|
permission: string # Permission to check
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/access/check"
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result = http post $url {role: $role, permission: $permission}
|
||||||
|
$result | get allowed
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to check permission: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Incident Response Commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Report a security incident
|
||||||
|
export def "compliance incident report" [
|
||||||
|
--severity: string # Incident severity (critical, high, medium, low)
|
||||||
|
--type: string # Incident type (data_breach, unauthorized_access, etc.)
|
||||||
|
--description: string # Incident description
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
if ($severity | is-empty) or ($type | is-empty) or ($description | is-empty) {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: "All parameters (--severity, --type, --description) are required"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/incidents"
|
||||||
|
|
||||||
|
print $"Reporting ($severity) incident of type ($type)"
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response = http post $url {
|
||||||
|
severity: $severity,
|
||||||
|
incident_type: $type,
|
||||||
|
description: $description,
|
||||||
|
affected_systems: [],
|
||||||
|
affected_users: [],
|
||||||
|
reported_by: "cli-user"
|
||||||
|
}
|
||||||
|
print $"✓ Incident reported: ($response.incident_id)"
|
||||||
|
$response.incident_id
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to report incident: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# List security incidents
|
||||||
|
export def "compliance incident list" [
|
||||||
|
--severity: string # Filter by severity
|
||||||
|
--status: string # Filter by status
|
||||||
|
--type: string # Filter by type
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
mut query_params = []
|
||||||
|
|
||||||
|
if not ($severity | is-empty) {
|
||||||
|
$query_params = ($query_params | append $"severity=($severity)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if not ($status | is-empty) {
|
||||||
|
$query_params = ($query_params | append $"status=($status)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if not ($type | is-empty) {
|
||||||
|
$query_params = ($query_params | append $"incident_type=($type)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let query_string = if ($query_params | length) > 0 {
|
||||||
|
$"?($query_params | str join '&')"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/incidents($query_string)"
|
||||||
|
|
||||||
|
try {
|
||||||
|
http get $url
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to list incidents: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get incident details
|
||||||
|
export def "compliance incident show" [
|
||||||
|
incident_id: string # Incident ID
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/incidents/($incident_id)"
|
||||||
|
|
||||||
|
try {
|
||||||
|
http get $url | to json
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to get incident: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Combined Reporting
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Generate combined compliance report
|
||||||
|
export def "compliance report" [
|
||||||
|
--format: string = "json" # Output format (json, yaml)
|
||||||
|
--output: string # Output file path
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/reports/combined"
|
||||||
|
|
||||||
|
print "Generating combined compliance report..."
|
||||||
|
print "This includes GDPR, SOC2, and ISO 27001 compliance status"
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response = http get $url
|
||||||
|
|
||||||
|
let formatted = if $format == "yaml" {
|
||||||
|
$response | to yaml
|
||||||
|
} else {
|
||||||
|
$response | to json
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($output | is-empty) {
|
||||||
|
$formatted
|
||||||
|
} else {
|
||||||
|
$formatted | save $output
|
||||||
|
print $"✓ Compliance report saved to: ($output)"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to generate report: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check compliance health status
|
||||||
|
export def "compliance health" [
|
||||||
|
--orchestrator-url: string = $ORCHESTRATOR_URL # Orchestrator URL
|
||||||
|
] {
|
||||||
|
let url = $"($orchestrator_url)/api/v1/compliance/health"
|
||||||
|
|
||||||
|
try {
|
||||||
|
http get $url
|
||||||
|
} catch {
|
||||||
|
error make --unspanned {
|
||||||
|
msg: $"Failed to check health: ($in)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Helper Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Show compliance command help
|
||||||
|
export def "compliance help" [] {
|
||||||
|
print "
|
||||||
|
Compliance CLI - GDPR, SOC2, and ISO 27001 Features
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
compliance <category> <command> [options]
|
||||||
|
|
||||||
|
Categories:
|
||||||
|
gdpr - GDPR compliance (data subject rights)
|
||||||
|
soc2 - SOC2 Trust Service Criteria
|
||||||
|
iso27001 - ISO 27001 Annex A controls
|
||||||
|
protection - Data protection controls
|
||||||
|
access - Access control matrix
|
||||||
|
incident - Incident response
|
||||||
|
report - Combined compliance reporting
|
||||||
|
health - Health check
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Export user data (GDPR)
|
||||||
|
compliance gdpr export user123
|
||||||
|
|
||||||
|
# Generate SOC2 report
|
||||||
|
compliance soc2 report --output soc2-report.json
|
||||||
|
|
||||||
|
# Generate ISO 27001 report
|
||||||
|
compliance iso27001 report --output iso27001-report.json
|
||||||
|
|
||||||
|
# Report security incident
|
||||||
|
compliance incident report --severity critical --type data_breach --description \"Unauthorized access detected\"
|
||||||
|
|
||||||
|
# Generate combined report
|
||||||
|
compliance report --output compliance-report.json
|
||||||
|
|
||||||
|
For detailed help on a specific command, use:
|
||||||
|
help compliance <category> <command>
|
||||||
|
"
|
||||||
|
}
|
||||||
6
nulib/kms/mod.nu
Normal file
6
nulib/kms/mod.nu
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env nu
|
||||||
|
|
||||||
|
# KMS Service Module
|
||||||
|
# Unified interface for Key Management Service operations
|
||||||
|
|
||||||
|
export use service.nu *
|
||||||
362
nulib/kms/service.nu
Normal file
362
nulib/kms/service.nu
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
#!/usr/bin/env nu
|
||||||
|
|
||||||
|
# KMS Service CLI Integration
|
||||||
|
# Provides commands to interact with the KMS service API
|
||||||
|
|
||||||
|
# Get KMS service base URL from environment or use default
|
||||||
|
def kms-url [] -> string {
|
||||||
|
$env.KMS_SERVICE_URL? | default "http://localhost:8081"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build KMS API endpoint URL
|
||||||
|
def kms-endpoint [path: string] -> string {
|
||||||
|
$"(kms-url)/api/v1/kms/(path)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Encrypt data using KMS
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# "secret-data" | kms encrypt
|
||||||
|
# "api-key" | kms encrypt --context "env=prod,service=api"
|
||||||
|
export def "kms encrypt" [
|
||||||
|
--context: string # Encryption context (key1=val1,key2=val2)
|
||||||
|
--backend: string = "vault" # KMS backend (vault or aws-kms)
|
||||||
|
] -> string {
|
||||||
|
let input = $in
|
||||||
|
|
||||||
|
# Encode plaintext to base64
|
||||||
|
let plaintext_b64 = $input | encode base64
|
||||||
|
|
||||||
|
# Prepare request body
|
||||||
|
let body = {
|
||||||
|
plaintext: $plaintext_b64
|
||||||
|
context: $context
|
||||||
|
}
|
||||||
|
|
||||||
|
# Make API request
|
||||||
|
let response = http post (kms-endpoint "encrypt") $body | complete
|
||||||
|
|
||||||
|
if $response.exit_code != 0 {
|
||||||
|
error make {
|
||||||
|
msg: "KMS encrypt request failed"
|
||||||
|
label: {
|
||||||
|
text: $response.stderr
|
||||||
|
span: (metadata $input).span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = $response.stdout | from json
|
||||||
|
|
||||||
|
if ($result | get -i error | is-not-empty) {
|
||||||
|
error make {
|
||||||
|
msg: "KMS encryption failed"
|
||||||
|
label: {
|
||||||
|
text: $result.error
|
||||||
|
span: (metadata $input).span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result.ciphertext
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decrypt data using KMS
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# "vault:v1:..." | kms decrypt
|
||||||
|
# $ciphertext | kms decrypt --context "env=prod,service=api"
|
||||||
|
export def "kms decrypt" [
|
||||||
|
--context: string # Encryption context (must match encryption context)
|
||||||
|
] -> string {
|
||||||
|
let ciphertext = $in
|
||||||
|
|
||||||
|
# Prepare request body
|
||||||
|
let body = {
|
||||||
|
ciphertext: $ciphertext
|
||||||
|
context: $context
|
||||||
|
}
|
||||||
|
|
||||||
|
# Make API request
|
||||||
|
let response = http post (kms-endpoint "decrypt") $body | complete
|
||||||
|
|
||||||
|
if $response.exit_code != 0 {
|
||||||
|
error make {
|
||||||
|
msg: "KMS decrypt request failed"
|
||||||
|
label: {
|
||||||
|
text: $response.stderr
|
||||||
|
span: (metadata $ciphertext).span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = $response.stdout | from json
|
||||||
|
|
||||||
|
if ($result | get -i error | is-not-empty) {
|
||||||
|
error make {
|
||||||
|
msg: "KMS decryption failed"
|
||||||
|
label: {
|
||||||
|
text: $result.error
|
||||||
|
span: (metadata $ciphertext).span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decode base64 plaintext
|
||||||
|
$result.plaintext | decode base64
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate a data key for envelope encryption
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# kms generate-key
|
||||||
|
# kms generate-key --key-spec AES_128
|
||||||
|
export def "kms generate-key" [
|
||||||
|
--key-spec: string = "AES_256" # Key specification (AES_128, AES_256, RSA_2048, RSA_4096)
|
||||||
|
] -> record {
|
||||||
|
let body = {
|
||||||
|
key_spec: $key_spec
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = http post (kms-endpoint "generate-key") $body | complete
|
||||||
|
|
||||||
|
if $response.exit_code != 0 {
|
||||||
|
error make {
|
||||||
|
msg: "KMS generate-key request failed"
|
||||||
|
label: {
|
||||||
|
text: $response.stderr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = $response.stdout | from json
|
||||||
|
|
||||||
|
if ($result | get -i error | is-not-empty) {
|
||||||
|
error make {
|
||||||
|
msg: "KMS key generation failed"
|
||||||
|
label: {
|
||||||
|
text: $result.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
plaintext: $result.plaintext
|
||||||
|
ciphertext: $result.ciphertext
|
||||||
|
key_id: $result.key_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rotate KMS key
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# kms rotate-key "arn:aws:kms:..."
|
||||||
|
export def "kms rotate-key" [
|
||||||
|
key_id: string # Key ID to rotate
|
||||||
|
] -> record {
|
||||||
|
let body = {
|
||||||
|
key_id: $key_id
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = http post (kms-endpoint "rotate-key") $body | complete
|
||||||
|
|
||||||
|
if $response.exit_code != 0 {
|
||||||
|
error make {
|
||||||
|
msg: "KMS rotate-key request failed"
|
||||||
|
label: {
|
||||||
|
text: $response.stderr
|
||||||
|
span: (metadata $key_id).span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = $response.stdout | from json
|
||||||
|
|
||||||
|
if ($result | get -i error | is-not-empty) {
|
||||||
|
error make {
|
||||||
|
msg: "KMS key rotation failed"
|
||||||
|
label: {
|
||||||
|
text: $result.error
|
||||||
|
span: (metadata $key_id).span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
new_key_id: $result.new_key_id
|
||||||
|
success: $result.success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check KMS service health
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# kms health
|
||||||
|
export def "kms health" [] -> record {
|
||||||
|
let response = http get (kms-endpoint "health") | complete
|
||||||
|
|
||||||
|
if $response.exit_code != 0 {
|
||||||
|
return {
|
||||||
|
status: "unhealthy"
|
||||||
|
error: $response.stderr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$response.stdout | from json
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get KMS service status
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# kms status
|
||||||
|
export def "kms status" [] -> record {
|
||||||
|
let response = http get (kms-endpoint "status") | complete
|
||||||
|
|
||||||
|
if $response.exit_code != 0 {
|
||||||
|
error make {
|
||||||
|
msg: "KMS status request failed"
|
||||||
|
label: {
|
||||||
|
text: $response.stderr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$response.stdout | from json
|
||||||
|
}
|
||||||
|
|
||||||
|
# Encrypt file using KMS
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# kms encrypt-file config.yaml
|
||||||
|
# kms encrypt-file secrets.json --output secrets.enc --context "env=prod"
|
||||||
|
export def "kms encrypt-file" [
|
||||||
|
input_file: path # File to encrypt
|
||||||
|
--output: path # Output file (default: <input>.enc)
|
||||||
|
--context: string # Encryption context
|
||||||
|
] {
|
||||||
|
if not ($input_file | path exists) {
|
||||||
|
error make {
|
||||||
|
msg: "Input file not found"
|
||||||
|
label: {
|
||||||
|
text: $input_file
|
||||||
|
span: (metadata $input_file).span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_file = if ($output | is-empty) {
|
||||||
|
$"($input_file).enc"
|
||||||
|
} else {
|
||||||
|
$output
|
||||||
|
}
|
||||||
|
|
||||||
|
print $"Encrypting ($input_file)..."
|
||||||
|
|
||||||
|
# Read file and encrypt
|
||||||
|
let plaintext = open --raw $input_file
|
||||||
|
let ciphertext = $plaintext | kms encrypt --context $context
|
||||||
|
|
||||||
|
# Save encrypted data
|
||||||
|
$ciphertext | save --raw $output_file
|
||||||
|
|
||||||
|
print $"✓ Encrypted file saved to ($output_file)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decrypt file using KMS
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# kms decrypt-file config.yaml.enc
|
||||||
|
# kms decrypt-file secrets.enc --output secrets.json --context "env=prod"
|
||||||
|
export def "kms decrypt-file" [
|
||||||
|
input_file: path # File to decrypt
|
||||||
|
--output: path # Output file (default: remove .enc extension)
|
||||||
|
--context: string # Encryption context (must match)
|
||||||
|
] {
|
||||||
|
if not ($input_file | path exists) {
|
||||||
|
error make {
|
||||||
|
msg: "Input file not found"
|
||||||
|
label: {
|
||||||
|
text: $input_file
|
||||||
|
span: (metadata $input_file).span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_file = if ($output | is-empty) {
|
||||||
|
if ($input_file | str ends-with ".enc") {
|
||||||
|
$input_file | str replace ".enc" ""
|
||||||
|
} else {
|
||||||
|
$"($input_file).dec"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$output
|
||||||
|
}
|
||||||
|
|
||||||
|
print $"Decrypting ($input_file)..."
|
||||||
|
|
||||||
|
# Read encrypted data and decrypt
|
||||||
|
let ciphertext = open --raw $input_file
|
||||||
|
let plaintext = $ciphertext | kms decrypt --context $context
|
||||||
|
|
||||||
|
# Save decrypted data
|
||||||
|
$plaintext | save --raw $output_file
|
||||||
|
|
||||||
|
print $"✓ Decrypted file saved to ($output_file)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Envelope encrypt data (AWS KMS only)
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# "large-data" | kms envelope encrypt
|
||||||
|
export def "kms envelope encrypt" [] -> string {
|
||||||
|
let input = $in
|
||||||
|
let plaintext_b64 = $input | encode base64
|
||||||
|
|
||||||
|
let body = { plaintext: $plaintext_b64 }
|
||||||
|
let response = http post (kms-endpoint "envelope/encrypt") $body | complete
|
||||||
|
|
||||||
|
if $response.exit_code != 0 {
|
||||||
|
error make {
|
||||||
|
msg: "KMS envelope encrypt request failed"
|
||||||
|
label: { text: $response.stderr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = $response.stdout | from json
|
||||||
|
if ($result | get -i error | is-not-empty) {
|
||||||
|
error make {
|
||||||
|
msg: "KMS envelope encryption failed"
|
||||||
|
label: { text: $result.error }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result.ciphertext
|
||||||
|
}
|
||||||
|
|
||||||
|
# Envelope decrypt data (AWS KMS only)
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# $envelope_ciphertext | kms envelope decrypt
|
||||||
|
export def "kms envelope decrypt" [] -> string {
|
||||||
|
let ciphertext = $in
|
||||||
|
|
||||||
|
let body = { ciphertext: $ciphertext }
|
||||||
|
let response = http post (kms-endpoint "envelope/decrypt") $body | complete
|
||||||
|
|
||||||
|
if $response.exit_code != 0 {
|
||||||
|
error make {
|
||||||
|
msg: "KMS envelope decrypt request failed"
|
||||||
|
label: { text: $response.stderr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = $response.stdout | from json
|
||||||
|
if ($result | get -i error | is-not-empty) {
|
||||||
|
error make {
|
||||||
|
msg: "KMS envelope decryption failed"
|
||||||
|
label: { text: $result.error }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result.plaintext | decode base64
|
||||||
|
}
|
||||||
394
nulib/lib_provisioning/config/commands.nu
Normal file
394
nulib/lib_provisioning/config/commands.nu
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
# Configuration Encryption CLI Commands
|
||||||
|
# Provides user-friendly commands for config encryption operations
|
||||||
|
|
||||||
|
use encryption.nu *
|
||||||
|
use accessor.nu *
|
||||||
|
|
||||||
|
# Encrypt a configuration file
|
||||||
|
export def "config encrypt" [
|
||||||
|
file: path # Configuration file to encrypt
|
||||||
|
--output (-o): path # Output path (default: <file>.enc)
|
||||||
|
--kms (-k): string = "age" # KMS backend: age, aws-kms, vault, cosmian
|
||||||
|
--in-place (-i) # Encrypt in-place (overwrites original)
|
||||||
|
--debug (-d) # Enable debug output
|
||||||
|
] {
|
||||||
|
if not ($file | path exists) {
|
||||||
|
print $"❌ File not found: ($file)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print $"🔒 Encrypting configuration file: ($file)"
|
||||||
|
print $" Backend: ($kms)"
|
||||||
|
|
||||||
|
if $in_place {
|
||||||
|
print $" Mode: In-place (will overwrite original)"
|
||||||
|
} else if ($output | is-not-empty) {
|
||||||
|
print $" Output: ($output)"
|
||||||
|
} else {
|
||||||
|
print $" Output: ($file).enc"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if $in_place {
|
||||||
|
encrypt-config $file --kms=$kms --in-place --debug=$debug
|
||||||
|
} else {
|
||||||
|
encrypt-config $file $output --kms=$kms --debug=$debug
|
||||||
|
}
|
||||||
|
} catch { |err|
|
||||||
|
print $"❌ Encryption failed: ($err.msg)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decrypt a configuration file
|
||||||
|
export def "config decrypt" [
|
||||||
|
file: path # Encrypted configuration file
|
||||||
|
--output (-o): path # Output path (default: removes .enc extension)
|
||||||
|
--in-place (-i) # Decrypt in-place (overwrites original)
|
||||||
|
--debug (-d) # Enable debug output
|
||||||
|
] {
|
||||||
|
if not ($file | path exists) {
|
||||||
|
print $"❌ File not found: ($file)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print $"🔓 Decrypting configuration file: ($file)"
|
||||||
|
|
||||||
|
try {
|
||||||
|
if $in_place {
|
||||||
|
decrypt-config $file --in-place --debug=$debug
|
||||||
|
} else {
|
||||||
|
decrypt-config $file $output --debug=$debug
|
||||||
|
}
|
||||||
|
} catch { |err|
|
||||||
|
print $"❌ Decryption failed: ($err.msg)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Edit encrypted configuration file securely
|
||||||
|
export def "config edit-secure" [
|
||||||
|
file: path # Encrypted configuration file
|
||||||
|
--editor (-e): string # Editor to use (default: $EDITOR or vim)
|
||||||
|
--debug (-d) # Enable debug output
|
||||||
|
] {
|
||||||
|
if not ($file | path exists) {
|
||||||
|
print $"❌ File not found: ($file)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($editor | is-not-empty) {
|
||||||
|
edit-encrypted-config $file --editor=$editor --debug=$debug
|
||||||
|
} else {
|
||||||
|
edit-encrypted-config $file --debug=$debug
|
||||||
|
}
|
||||||
|
} catch { |err|
|
||||||
|
print $"❌ Edit failed: ($err.msg)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rotate encryption keys for a configuration file
|
||||||
|
export def "config rotate-keys" [
|
||||||
|
file: path # Encrypted configuration file
|
||||||
|
new_key: string # New key ID or recipient
|
||||||
|
--debug (-d) # Enable debug output
|
||||||
|
] {
|
||||||
|
if not ($file | path exists) {
|
||||||
|
print $"❌ File not found: ($file)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print $"🔄 Rotating encryption keys"
|
||||||
|
print $" File: ($file)"
|
||||||
|
print $" New key: ($new_key)"
|
||||||
|
|
||||||
|
try {
|
||||||
|
rotate-encryption-keys $file $new_key --debug=$debug
|
||||||
|
} catch { |err|
|
||||||
|
print $"❌ Key rotation failed: ($err.msg)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if a configuration file is encrypted
|
||||||
|
export def "config is-encrypted" [
|
||||||
|
file: path # Configuration file to check
|
||||||
|
] {
|
||||||
|
if not ($file | path exists) {
|
||||||
|
print $"❌ File not found: ($file)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let encrypted = (is-encrypted-config $file)
|
||||||
|
|
||||||
|
if $encrypted {
|
||||||
|
print $"🔒 File is encrypted: ($file)"
|
||||||
|
} else {
|
||||||
|
print $"🔓 File is not encrypted: ($file)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate encryption configuration and setup
|
||||||
|
export def "config validate-encryption" [] {
|
||||||
|
print $"🔍 Validating encryption configuration..."
|
||||||
|
print ""
|
||||||
|
|
||||||
|
let validation = (validate-encryption-config)
|
||||||
|
|
||||||
|
# Show summary
|
||||||
|
print $"📊 Encryption Configuration Summary"
|
||||||
|
print $"=================================="
|
||||||
|
print $" SOPS installed: ($validation.summary.sops_installed)"
|
||||||
|
print $" Age backend: ($validation.summary.age_backend)"
|
||||||
|
print $" KMS enabled: ($validation.summary.kms_enabled)"
|
||||||
|
print $" Errors: ($validation.summary.total_errors)"
|
||||||
|
print $" Warnings: ($validation.summary.total_warnings)"
|
||||||
|
print ""
|
||||||
|
|
||||||
|
# Show errors
|
||||||
|
if ($validation.errors | length) > 0 {
|
||||||
|
print $"❌ Errors:"
|
||||||
|
for error in $validation.errors {
|
||||||
|
print $" • ($error.message)"
|
||||||
|
}
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show warnings
|
||||||
|
if ($validation.warnings | length) > 0 {
|
||||||
|
print $"⚠️ Warnings:"
|
||||||
|
for warning in $validation.warnings {
|
||||||
|
print $" • ($warning.message)"
|
||||||
|
}
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if $validation.valid {
|
||||||
|
print $"✅ Encryption configuration is valid"
|
||||||
|
} else {
|
||||||
|
print $"❌ Encryption configuration has errors"
|
||||||
|
}
|
||||||
|
|
||||||
|
$validation
|
||||||
|
}
|
||||||
|
|
||||||
|
# Scan directory for unencrypted sensitive configurations
|
||||||
|
export def "config scan-sensitive" [
|
||||||
|
directory: path = "." # Directory to scan
|
||||||
|
--recursive (-r) # Scan recursively
|
||||||
|
] {
|
||||||
|
if not ($directory | path exists) {
|
||||||
|
print $"❌ Directory not found: ($directory)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print $"🔍 Scanning for unencrypted sensitive configs in: ($directory)"
|
||||||
|
|
||||||
|
let results = (scan-unencrypted-configs $directory --recursive=$recursive)
|
||||||
|
|
||||||
|
if ($results | is-empty) {
|
||||||
|
print $"✅ No unencrypted sensitive configs found"
|
||||||
|
} else {
|
||||||
|
print $"\n⚠️ Found ($results | length) unencrypted sensitive configs:"
|
||||||
|
print ""
|
||||||
|
print $results
|
||||||
|
print ""
|
||||||
|
print $"💡 Run 'config encrypt-all ($directory)' to encrypt them"
|
||||||
|
}
|
||||||
|
|
||||||
|
$results
|
||||||
|
}
|
||||||
|
|
||||||
|
# Encrypt all sensitive configurations in directory
|
||||||
|
export def "config encrypt-all" [
|
||||||
|
directory: path = "." # Directory to encrypt
|
||||||
|
--kms (-k): string = "age" # KMS backend: age, aws-kms, vault, cosmian
|
||||||
|
--recursive (-r) # Scan recursively
|
||||||
|
--dry-run (-n) # Dry run (no actual encryption)
|
||||||
|
] {
|
||||||
|
if not ($directory | path exists) {
|
||||||
|
print $"❌ Directory not found: ($directory)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
encrypt-sensitive-configs $directory --kms=$kms --recursive=$recursive --dry-run=$dry_run
|
||||||
|
} catch { |err|
|
||||||
|
print $"❌ Bulk encryption failed: ($err.msg)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show encryption information for configuration
|
||||||
|
export def "config encryption-info" [
|
||||||
|
file: path # Configuration file
|
||||||
|
] {
|
||||||
|
if not ($file | path exists) {
|
||||||
|
print $"❌ File not found: ($file)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print $"📋 Encryption Information"
|
||||||
|
print $"========================"
|
||||||
|
print $" File: ($file)"
|
||||||
|
|
||||||
|
let encrypted = (is-encrypted-config $file)
|
||||||
|
print $" Encrypted: ($encrypted)"
|
||||||
|
|
||||||
|
if $encrypted {
|
||||||
|
# Try to extract SOPS metadata
|
||||||
|
try {
|
||||||
|
let content = (open $file --raw)
|
||||||
|
if ($content | str contains "sops:") {
|
||||||
|
print $" Type: SOPS encrypted"
|
||||||
|
|
||||||
|
# Extract some metadata (without decrypting)
|
||||||
|
if ($content | str contains "age:") {
|
||||||
|
print $" Backend: Age"
|
||||||
|
}
|
||||||
|
if ($content | str contains "kms:") {
|
||||||
|
print $" Backend: AWS KMS"
|
||||||
|
}
|
||||||
|
if ($content | str contains "vault:") {
|
||||||
|
print $" Backend: Vault"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print $" Type: Encrypted (unknown format)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let sensitive = (contains-sensitive-data $file)
|
||||||
|
print $" Contains sensitive data: ($sensitive)"
|
||||||
|
|
||||||
|
if $sensitive {
|
||||||
|
print ""
|
||||||
|
print $"⚠️ This file contains sensitive data but is not encrypted!"
|
||||||
|
print $"💡 Run 'config encrypt ($file)' to encrypt it"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize encryption setup (generate keys, create SOPS config)
|
||||||
|
export def "config init-encryption" [
|
||||||
|
--kms (-k): string = "age" # KMS backend to initialize: age, aws-kms, vault
|
||||||
|
--force (-f) # Force re-initialization
|
||||||
|
] {
|
||||||
|
print $"🔧 Initializing encryption setup with ($kms)"
|
||||||
|
print ""
|
||||||
|
|
||||||
|
match $kms {
|
||||||
|
"age" => {
|
||||||
|
# Check if age is installed
|
||||||
|
let age_check = (^which age | complete)
|
||||||
|
if $age_check.exit_code != 0 {
|
||||||
|
print $"❌ Age is not installed"
|
||||||
|
print $"💡 Install with: brew install age"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate age key if not exists
|
||||||
|
let age_key_file = ($env.HOME | path join ".config" | path join "sops" | path join "age" | path join "keys.txt")
|
||||||
|
let age_key_dir = ($age_key_file | path dirname)
|
||||||
|
|
||||||
|
if ($age_key_file | path exists) and not $force {
|
||||||
|
print $"✅ Age key already exists: ($age_key_file)"
|
||||||
|
print $" Use --force to regenerate"
|
||||||
|
} else {
|
||||||
|
# Create directory
|
||||||
|
mkdir $age_key_dir
|
||||||
|
|
||||||
|
# Generate new age key
|
||||||
|
print $"🔑 Generating new Age key..."
|
||||||
|
let key_output = (^age-keygen -o $age_key_file | complete)
|
||||||
|
|
||||||
|
if $key_output.exit_code == 0 {
|
||||||
|
print $"✅ Age key generated: ($age_key_file)"
|
||||||
|
|
||||||
|
# Extract recipient
|
||||||
|
let key_content = (open $age_key_file --raw)
|
||||||
|
let recipient = ($key_content | lines | where ($it | str starts-with "# public key:") | first | split row ": " | get 1)
|
||||||
|
|
||||||
|
print $" Public key (recipient): ($recipient)"
|
||||||
|
print ""
|
||||||
|
print $"💡 Set this environment variable:"
|
||||||
|
print $" export SOPS_AGE_RECIPIENTS=($recipient)"
|
||||||
|
} else {
|
||||||
|
print $"❌ Failed to generate Age key"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create .sops.yaml if not exists
|
||||||
|
let sops_config = ($env.PWD | path join ".sops.yaml")
|
||||||
|
if not ($sops_config | path exists) or $force {
|
||||||
|
print $"📝 Creating SOPS configuration: ($sops_config)"
|
||||||
|
|
||||||
|
let key_content = (open $age_key_file --raw)
|
||||||
|
let recipient = ($key_content | lines | where ($it | str starts-with "# public key:") | first | split row ": " | get 1)
|
||||||
|
|
||||||
|
let sops_yaml = $"creation_rules:
|
||||||
|
- path_regex: .*\\.enc\\.yaml$
|
||||||
|
age: ($recipient)
|
||||||
|
- path_regex: .*\\.enc\\.yml$
|
||||||
|
age: ($recipient)
|
||||||
|
- path_regex: .*\\.enc\\.toml$
|
||||||
|
age: ($recipient)
|
||||||
|
- path_regex: .*\\.enc\\.json$
|
||||||
|
age: ($recipient)
|
||||||
|
- path_regex: workspace/.*/config/secure\\.yaml$
|
||||||
|
age: ($recipient)
|
||||||
|
"
|
||||||
|
|
||||||
|
$sops_yaml | save --force $sops_config
|
||||||
|
print $"✅ SOPS configuration created"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"aws-kms" => {
|
||||||
|
print $"⚠️ AWS KMS requires manual configuration"
|
||||||
|
print $"💡 Follow these steps:"
|
||||||
|
print $" 1. Create KMS key in AWS Console"
|
||||||
|
print $" 2. Update .sops.yaml with KMS ARN"
|
||||||
|
print $" 3. Configure AWS credentials"
|
||||||
|
}
|
||||||
|
"vault" | "cosmian" => {
|
||||||
|
print $"⚠️ ($kms) KMS requires manual configuration"
|
||||||
|
print $"💡 Configure KMS settings in config file"
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
print $"❌ Unknown KMS backend: ($kms)"
|
||||||
|
print $" Supported: age, aws-kms, vault, cosmian"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print ""
|
||||||
|
print $"✅ Encryption initialization completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main help command
|
||||||
|
export def main [] {
|
||||||
|
print "Configuration Encryption Commands"
|
||||||
|
print "================================="
|
||||||
|
print ""
|
||||||
|
print "Encryption/Decryption:"
|
||||||
|
print " config encrypt <file> Encrypt configuration file"
|
||||||
|
print " config decrypt <file> Decrypt configuration file"
|
||||||
|
print " config edit-secure <file> Edit encrypted file securely"
|
||||||
|
print " config rotate-keys <file> Rotate encryption keys"
|
||||||
|
print ""
|
||||||
|
print "Information:"
|
||||||
|
print " config is-encrypted <file> Check if file is encrypted"
|
||||||
|
print " config encryption-info <file> Show encryption details"
|
||||||
|
print " config validate-encryption Validate encryption setup"
|
||||||
|
print ""
|
||||||
|
print "Bulk Operations:"
|
||||||
|
print " config scan-sensitive <dir> Find unencrypted sensitive configs"
|
||||||
|
print " config encrypt-all <dir> Encrypt all sensitive configs"
|
||||||
|
print ""
|
||||||
|
print "Setup:"
|
||||||
|
print " config init-encryption Initialize encryption (generate keys)"
|
||||||
|
print ""
|
||||||
|
print "Examples:"
|
||||||
|
print " config encrypt workspace/config/secure.yaml"
|
||||||
|
print " config edit-secure workspace/config/secure.enc.yaml"
|
||||||
|
print " config scan-sensitive workspace/config --recursive"
|
||||||
|
print " config init-encryption --kms age"
|
||||||
|
}
|
||||||
505
nulib/lib_provisioning/config/encryption.nu
Normal file
505
nulib/lib_provisioning/config/encryption.nu
Normal file
@ -0,0 +1,505 @@
|
|||||||
|
# Configuration Encryption Module for Provisioning System
|
||||||
|
# Provides transparent encryption/decryption for configuration files using SOPS
|
||||||
|
|
||||||
|
use std log
|
||||||
|
use ../sops/lib.nu *
|
||||||
|
use ../kms/lib.nu *
|
||||||
|
use accessor.nu *
|
||||||
|
|
||||||
|
# Detect if a config file is encrypted
|
||||||
|
export def is-encrypted-config [
|
||||||
|
file_path: string
|
||||||
|
]: nothing -> bool {
|
||||||
|
if not ($file_path | path exists) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if file has SOPS metadata
|
||||||
|
is_sops_file $file_path
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load configuration file with automatic decryption
|
||||||
|
export def load-encrypted-config [
|
||||||
|
file_path: string
|
||||||
|
--debug = false
|
||||||
|
]: nothing -> record {
|
||||||
|
if not ($file_path | path exists) {
|
||||||
|
error make {
|
||||||
|
msg: $"Configuration file not found: ($file_path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if $debug {
|
||||||
|
print $"Loading configuration file: ($file_path)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if file is encrypted
|
||||||
|
if (is-encrypted-config $file_path) {
|
||||||
|
if $debug {
|
||||||
|
print $" Detected encrypted file, decrypting..."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decrypt in memory (never write to disk)
|
||||||
|
let decrypted_content = (decrypt-config-memory $file_path --debug=$debug)
|
||||||
|
|
||||||
|
# Parse based on file extension
|
||||||
|
let ext = ($file_path | path parse | get extension)
|
||||||
|
match $ext {
|
||||||
|
"yaml" | "yml" => ($decrypted_content | from yaml)
|
||||||
|
"toml" => ($decrypted_content | from toml)
|
||||||
|
"json" => ($decrypted_content | from json)
|
||||||
|
_ => {
|
||||||
|
error make {
|
||||||
|
msg: $"Unsupported encrypted config format: ($ext)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if $debug {
|
||||||
|
print $" Loading unencrypted file..."
|
||||||
|
}
|
||||||
|
# Load unencrypted file normally
|
||||||
|
open $file_path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decrypt configuration file in memory only (never writes to disk)
|
||||||
|
export def decrypt-config-memory [
|
||||||
|
file_path: string
|
||||||
|
--debug = false
|
||||||
|
]: nothing -> string {
|
||||||
|
if not (is-encrypted-config $file_path) {
|
||||||
|
error make {
|
||||||
|
msg: $"File is not encrypted: ($file_path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use SOPS to decrypt (output goes to stdout, captured in memory)
|
||||||
|
let decrypted = (on_sops "decrypt" $file_path --quiet)
|
||||||
|
|
||||||
|
if ($decrypted | is-empty) {
|
||||||
|
error make {
|
||||||
|
msg: $"Failed to decrypt configuration file: ($file_path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$decrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
# Encrypt a configuration file
|
||||||
|
export def encrypt-config [
|
||||||
|
source_path: string
|
||||||
|
output_path?: string
|
||||||
|
--kms: string = "age" # age, aws-kms, vault
|
||||||
|
--in-place = false
|
||||||
|
--debug = false
|
||||||
|
]: nothing -> nothing {
|
||||||
|
if not ($source_path | path exists) {
|
||||||
|
error make {
|
||||||
|
msg: $"Source file not found: ($source_path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if already encrypted
|
||||||
|
if (is-encrypted-config $source_path) {
|
||||||
|
print $"⚠️ File is already encrypted: ($source_path)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine output path
|
||||||
|
let target = if $in_place {
|
||||||
|
$source_path
|
||||||
|
} else if ($output_path | is-not-empty) {
|
||||||
|
$output_path
|
||||||
|
} else {
|
||||||
|
$"($source_path).enc"
|
||||||
|
}
|
||||||
|
|
||||||
|
if $debug {
|
||||||
|
print $"Encrypting ($source_path) → ($target) using ($kms)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Encrypt based on KMS backend
|
||||||
|
match $kms {
|
||||||
|
"age" => {
|
||||||
|
let encrypted = (on_sops "encrypt" $source_path)
|
||||||
|
if ($encrypted | is-empty) {
|
||||||
|
error make {
|
||||||
|
msg: $"Failed to encrypt file with age: ($source_path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$encrypted | save --force $target
|
||||||
|
print $"✅ Encrypted successfully: ($target)"
|
||||||
|
}
|
||||||
|
"aws-kms" => {
|
||||||
|
# For AWS KMS, SOPS will use AWS KMS backend if configured
|
||||||
|
let encrypted = (on_sops "encrypt" $source_path)
|
||||||
|
if ($encrypted | is-empty) {
|
||||||
|
error make {
|
||||||
|
msg: $"Failed to encrypt file with AWS KMS: ($source_path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$encrypted | save --force $target
|
||||||
|
print $"✅ Encrypted successfully with AWS KMS: ($target)"
|
||||||
|
}
|
||||||
|
"vault" | "cosmian" => {
|
||||||
|
# Use KMS client for Vault or Cosmian KMS
|
||||||
|
let encrypted = (on_kms "encrypt" $source_path)
|
||||||
|
if ($encrypted | is-empty) {
|
||||||
|
error make {
|
||||||
|
msg: $"Failed to encrypt file with ($kms): ($source_path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$encrypted | save --force $target
|
||||||
|
print $"✅ Encrypted successfully with ($kms): ($target)"
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
error make {
|
||||||
|
msg: $"Unsupported KMS backend: ($kms). Supported: age, aws-kms, vault, cosmian"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decrypt a configuration file
|
||||||
|
export def decrypt-config [
|
||||||
|
source_path: string
|
||||||
|
output_path?: string
|
||||||
|
--in-place = false
|
||||||
|
--debug = false
|
||||||
|
]: nothing -> nothing {
|
||||||
|
if not ($source_path | path exists) {
|
||||||
|
error make {
|
||||||
|
msg: $"Source file not found: ($source_path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if encrypted
|
||||||
|
if not (is-encrypted-config $source_path) {
|
||||||
|
print $"⚠️ File is not encrypted: ($source_path)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine output path
|
||||||
|
let target = if $in_place {
|
||||||
|
$source_path
|
||||||
|
} else if ($output_path | is-not-empty) {
|
||||||
|
$output_path
|
||||||
|
} else {
|
||||||
|
# Remove .enc extension if present
|
||||||
|
if ($source_path | str ends-with ".enc") {
|
||||||
|
$source_path | str substring 0..-5
|
||||||
|
} else {
|
||||||
|
$"($source_path).dec"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if $debug {
|
||||||
|
print $"Decrypting ($source_path) → ($target)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decrypt using SOPS (works with any backend)
|
||||||
|
let decrypted = (on_sops "decrypt" $source_path)
|
||||||
|
if ($decrypted | is-empty) {
|
||||||
|
error make {
|
||||||
|
msg: $"Failed to decrypt file: ($source_path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$decrypted | save --force $target
|
||||||
|
print $"✅ Decrypted successfully: ($target)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Edit encrypted config file (decrypt, edit, re-encrypt)
|
||||||
|
export def edit-encrypted-config [
|
||||||
|
file_path: string
|
||||||
|
--editor: string = ""
|
||||||
|
--debug = false
|
||||||
|
]: nothing -> nothing {
|
||||||
|
if not ($file_path | path exists) {
|
||||||
|
error make {
|
||||||
|
msg: $"File not found: ($file_path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if encrypted
|
||||||
|
if not (is-encrypted-config $file_path) {
|
||||||
|
print $"⚠️ File is not encrypted, opening normally..."
|
||||||
|
let editor_cmd = if ($editor | is-not-empty) {
|
||||||
|
$editor
|
||||||
|
} else {
|
||||||
|
$env.EDITOR? | default "vim"
|
||||||
|
}
|
||||||
|
^$editor_cmd $file_path
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use SOPS editor functionality (handles decrypt -> edit -> encrypt)
|
||||||
|
print $"🔒 Opening encrypted file with SOPS editor..."
|
||||||
|
|
||||||
|
# SOPS has built-in editor support
|
||||||
|
let sops_config = (find-sops-config-path)
|
||||||
|
if ($sops_config | is-not-empty) {
|
||||||
|
^sops --config $sops_config $file_path
|
||||||
|
} else {
|
||||||
|
^sops $file_path
|
||||||
|
}
|
||||||
|
|
||||||
|
print $"✅ File saved and re-encrypted"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rotate encryption keys for a file
|
||||||
|
export def rotate-encryption-keys [
|
||||||
|
file_path: string
|
||||||
|
new_key_id: string
|
||||||
|
--debug = false
|
||||||
|
]: nothing -> nothing {
|
||||||
|
if not ($file_path | path exists) {
|
||||||
|
error make {
|
||||||
|
msg: $"File not found: ($file_path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if not (is-encrypted-config $file_path) {
|
||||||
|
error make {
|
||||||
|
msg: $"File is not encrypted: ($file_path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print $"🔄 Rotating encryption keys for ($file_path)"
|
||||||
|
print $" New key: ($new_key_id)"
|
||||||
|
|
||||||
|
# Create temporary decrypted file
|
||||||
|
let temp_file = ($file_path | str replace ".yaml" ".tmp.yaml")
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Decrypt to temp
|
||||||
|
let decrypted = (on_sops "decrypt" $file_path)
|
||||||
|
$decrypted | save --force $temp_file
|
||||||
|
|
||||||
|
# Update SOPS config to use new key
|
||||||
|
# This requires updating .sops.yaml to reference new key
|
||||||
|
print $"⚠️ Manual step required: Update .sops.yaml with new key ($new_key_id)"
|
||||||
|
print $" Then run: sops updatekeys ($file_path)"
|
||||||
|
|
||||||
|
# Re-encrypt with new key
|
||||||
|
^sops updatekeys $file_path
|
||||||
|
|
||||||
|
# Remove temp file
|
||||||
|
rm --force $temp_file
|
||||||
|
|
||||||
|
print $"✅ Key rotation completed"
|
||||||
|
} catch {
|
||||||
|
# Clean up temp file on error
|
||||||
|
if ($temp_file | path exists) {
|
||||||
|
rm --force $temp_file
|
||||||
|
}
|
||||||
|
error make {
|
||||||
|
msg: $"Key rotation failed for ($file_path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate encryption configuration
|
||||||
|
export def validate-encryption-config []: nothing -> record {
|
||||||
|
mut errors = []
|
||||||
|
mut warnings = []
|
||||||
|
|
||||||
|
# Check if SOPS is installed
|
||||||
|
let sops_check = (^which sops | complete)
|
||||||
|
if $sops_check.exit_code != 0 {
|
||||||
|
$errors = ($errors | append {
|
||||||
|
type: "missing_binary"
|
||||||
|
severity: "error"
|
||||||
|
message: "SOPS binary not found. Install with: brew install sops"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if Age is installed (if using Age)
|
||||||
|
let use_sops = (get-provisioning-use-sops)
|
||||||
|
if ($use_sops | str contains "age") {
|
||||||
|
let age_check = (^which age | complete)
|
||||||
|
if $age_check.exit_code != 0 {
|
||||||
|
$warnings = ($warnings | append {
|
||||||
|
type: "missing_binary"
|
||||||
|
severity: "warning"
|
||||||
|
message: "Age binary not found. Install with: brew install age"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for Age keys
|
||||||
|
let age_key_file = (get-sops-age-key-file)
|
||||||
|
if ($age_key_file | is-empty) {
|
||||||
|
$warnings = ($warnings | append {
|
||||||
|
type: "missing_key"
|
||||||
|
severity: "warning"
|
||||||
|
message: "Age key file not configured (PROVISIONING_KAGE)"
|
||||||
|
})
|
||||||
|
} else if not ($age_key_file | path exists) {
|
||||||
|
$errors = ($errors | append {
|
||||||
|
type: "missing_key_file"
|
||||||
|
severity: "error"
|
||||||
|
message: $"Age key file not found: ($age_key_file)"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check SOPS configuration file
|
||||||
|
let sops_config = (find-sops-config-path)
|
||||||
|
if ($sops_config | is-empty) {
|
||||||
|
$warnings = ($warnings | append {
|
||||||
|
type: "missing_config"
|
||||||
|
severity: "warning"
|
||||||
|
message: "SOPS config file (.sops.yaml) not found in project"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check KMS configuration if enabled
|
||||||
|
let kms_enabled = (get-kms-enabled)
|
||||||
|
if $kms_enabled {
|
||||||
|
let kms_server = (get-kms-server)
|
||||||
|
if ($kms_server | is-empty) {
|
||||||
|
$errors = ($errors | append {
|
||||||
|
type: "missing_kms_config"
|
||||||
|
severity: "error"
|
||||||
|
message: "KMS enabled but server URL not configured"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
valid: (($errors | length) == 0)
|
||||||
|
errors: $errors
|
||||||
|
warnings: $warnings
|
||||||
|
summary: {
|
||||||
|
sops_installed: ($sops_check.exit_code == 0)
|
||||||
|
age_backend: ($use_sops | str contains "age")
|
||||||
|
kms_enabled: $kms_enabled
|
||||||
|
total_errors: ($errors | length)
|
||||||
|
total_warnings: ($warnings | length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find SOPS configuration file
|
||||||
|
def find-sops-config-path []: nothing -> string {
|
||||||
|
# Check common locations
|
||||||
|
let locations = [
|
||||||
|
".sops.yaml"
|
||||||
|
".sops.yml"
|
||||||
|
($env.PWD | path join ".sops.yaml")
|
||||||
|
($env.HOME | path join ".config" | path join "provisioning" | path join "sops.yaml")
|
||||||
|
(get-sops-config-path)
|
||||||
|
]
|
||||||
|
|
||||||
|
for loc in $locations {
|
||||||
|
if ($loc | path exists) {
|
||||||
|
return $loc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if config file contains sensitive data (heuristic)
|
||||||
|
export def contains-sensitive-data [
|
||||||
|
file_path: string
|
||||||
|
]: nothing -> bool {
|
||||||
|
if not ($file_path | path exists) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = (open $file_path --raw)
|
||||||
|
|
||||||
|
# Patterns that indicate sensitive data
|
||||||
|
let sensitive_patterns = [
|
||||||
|
"password" "secret" "api_key" "token" "private_key"
|
||||||
|
"credential" "auth" "access_key" "secret_key"
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in $sensitive_patterns {
|
||||||
|
if ($content | str contains $pattern) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
# Scan directory for unencrypted sensitive configs
|
||||||
|
export def scan-unencrypted-configs [
|
||||||
|
directory: string
|
||||||
|
--recursive = true
|
||||||
|
]: nothing -> table {
|
||||||
|
mut results = []
|
||||||
|
|
||||||
|
let files = if $recursive {
|
||||||
|
glob $"($directory)/**/*.{yaml,yml,toml,json}"
|
||||||
|
} else {
|
||||||
|
glob $"($directory)/*.{yaml,yml,toml,json}"
|
||||||
|
}
|
||||||
|
|
||||||
|
for file in $files {
|
||||||
|
if (contains-sensitive-data $file) and not (is-encrypted-config $file) {
|
||||||
|
$results = ($results | append {
|
||||||
|
file: $file
|
||||||
|
encrypted: false
|
||||||
|
has_sensitive_data: true
|
||||||
|
recommendation: "Should be encrypted"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$results
|
||||||
|
}
|
||||||
|
|
||||||
|
# Encrypt all sensitive configs in directory
|
||||||
|
export def encrypt-sensitive-configs [
|
||||||
|
directory: string
|
||||||
|
--kms: string = "age"
|
||||||
|
--dry-run = false
|
||||||
|
--recursive = true
|
||||||
|
]: nothing -> nothing {
|
||||||
|
print $"🔍 Scanning for unencrypted sensitive configs in ($directory)"
|
||||||
|
|
||||||
|
let unencrypted = (scan-unencrypted-configs $directory --recursive=$recursive)
|
||||||
|
|
||||||
|
if ($unencrypted | is-empty) {
|
||||||
|
print $"✅ No unencrypted sensitive configs found"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print $"\n📋 Found ($unencrypted | length) unencrypted sensitive configs:"
|
||||||
|
print $unencrypted
|
||||||
|
|
||||||
|
if $dry_run {
|
||||||
|
print $"\n⚠️ Dry run mode - no files will be encrypted"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print $"\n🔒 Encrypting files with ($kms)..."
|
||||||
|
|
||||||
|
for file in ($unencrypted | get file) {
|
||||||
|
print $" Encrypting: ($file)"
|
||||||
|
encrypt-config $file --kms $kms --in-place
|
||||||
|
}
|
||||||
|
|
||||||
|
print $"\n✅ Encryption completed for all sensitive configs"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Export key functions for CLI integration
|
||||||
|
export def main [] {
|
||||||
|
print "Configuration Encryption Module"
|
||||||
|
print "================================"
|
||||||
|
print ""
|
||||||
|
print "Available commands:"
|
||||||
|
print " - is-encrypted-config <file> Check if config is encrypted"
|
||||||
|
print " - load-encrypted-config <file> Load and decrypt config"
|
||||||
|
print " - encrypt-config <file> Encrypt config file"
|
||||||
|
print " - decrypt-config <file> Decrypt config file"
|
||||||
|
print " - edit-encrypted-config <file> Edit encrypted config"
|
||||||
|
print " - rotate-encryption-keys <file> Rotate encryption keys"
|
||||||
|
print " - validate-encryption-config Validate encryption setup"
|
||||||
|
print " - scan-unencrypted-configs <dir> Find unencrypted sensitive configs"
|
||||||
|
print " - encrypt-sensitive-configs <dir> Encrypt all sensitive configs"
|
||||||
|
}
|
||||||
589
nulib/lib_provisioning/config/encryption_tests.nu
Normal file
589
nulib/lib_provisioning/config/encryption_tests.nu
Normal file
@ -0,0 +1,589 @@
|
|||||||
|
# Configuration Encryption System Tests
|
||||||
|
# Comprehensive test suite for encryption functionality
|
||||||
|
|
||||||
|
use encryption.nu *
|
||||||
|
use ../kms/client.nu *
|
||||||
|
|
||||||
|
# Test suite runner
|
||||||
|
export def run-encryption-tests [
|
||||||
|
--verbose (-v) # Verbose output
|
||||||
|
--test: string = "" # Run specific test (empty = all)
|
||||||
|
] {
|
||||||
|
print "🧪 Configuration Encryption Test Suite"
|
||||||
|
print "======================================"
|
||||||
|
print ""
|
||||||
|
|
||||||
|
mut results = []
|
||||||
|
|
||||||
|
# Test 1: Encryption detection
|
||||||
|
if ($test == "" or $test == "detection") {
|
||||||
|
print "🔍 Test 1: Encryption Detection"
|
||||||
|
let result = (test-encryption-detection)
|
||||||
|
$results = ($results | append $result)
|
||||||
|
show-test-result $result
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 2: Encrypt/Decrypt round-trip
|
||||||
|
if ($test == "" or $test == "roundtrip") {
|
||||||
|
print "🔍 Test 2: Encrypt/Decrypt Round-trip"
|
||||||
|
let result = (test-encrypt-decrypt-roundtrip)
|
||||||
|
$results = ($results | append $result)
|
||||||
|
show-test-result $result
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 3: Memory-only decryption
|
||||||
|
if ($test == "" or $test == "memory") {
|
||||||
|
print "🔍 Test 3: Memory-Only Decryption"
|
||||||
|
let result = (test-memory-only-decryption)
|
||||||
|
$results = ($results | append $result)
|
||||||
|
show-test-result $result
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 4: Sensitive data detection
|
||||||
|
if ($test == "" or $test == "sensitive") {
|
||||||
|
print "🔍 Test 4: Sensitive Data Detection"
|
||||||
|
let result = (test-sensitive-data-detection)
|
||||||
|
$results = ($results | append $result)
|
||||||
|
show-test-result $result
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 5: KMS backend integration
|
||||||
|
if ($test == "" or $test == "kms") {
|
||||||
|
print "🔍 Test 5: KMS Backend Integration"
|
||||||
|
let result = (test-kms-backend-integration)
|
||||||
|
$results = ($results | append $result)
|
||||||
|
show-test-result $result
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 6: Config loader integration
|
||||||
|
if ($test == "" or $test == "loader") {
|
||||||
|
print "🔍 Test 6: Config Loader Integration"
|
||||||
|
let result = (test-config-loader-integration)
|
||||||
|
$results = ($results | append $result)
|
||||||
|
show-test-result $result
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 7: Validation
|
||||||
|
if ($test == "" or $test == "validation") {
|
||||||
|
print "🔍 Test 7: Encryption Validation"
|
||||||
|
let result = (test-encryption-validation)
|
||||||
|
$results = ($results | append $result)
|
||||||
|
show-test-result $result
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print "📊 Test Summary"
|
||||||
|
print "=============="
|
||||||
|
let total = ($results | length)
|
||||||
|
let passed = ($results | where passed == true | length)
|
||||||
|
let failed = ($total - $passed)
|
||||||
|
|
||||||
|
print $" Total tests: ($total)"
|
||||||
|
print $" Passed: ($passed)"
|
||||||
|
print $" Failed: ($failed)"
|
||||||
|
print $" Success rate: (($passed * 100) / $total)%"
|
||||||
|
|
||||||
|
if $failed == 0 {
|
||||||
|
print ""
|
||||||
|
print "✅ All tests passed!"
|
||||||
|
} else {
|
||||||
|
print ""
|
||||||
|
print "❌ Some tests failed:"
|
||||||
|
for result in ($results | where passed == false) {
|
||||||
|
print $" • ($result.test_name): ($result.error)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
total: $total
|
||||||
|
passed: $passed
|
||||||
|
failed: $failed
|
||||||
|
results: $results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 1: Encryption detection
|
||||||
|
def test-encryption-detection []: nothing -> record {
|
||||||
|
let test_name = "Encryption Detection"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Create test file
|
||||||
|
let test_file = "/tmp/test_plain.yaml"
|
||||||
|
"test: value" | save --force $test_file
|
||||||
|
|
||||||
|
# Should detect as not encrypted
|
||||||
|
let is_enc = (is-encrypted-config $test_file)
|
||||||
|
|
||||||
|
rm --force $test_file
|
||||||
|
|
||||||
|
if $is_enc {
|
||||||
|
return {
|
||||||
|
test_name: $test_name
|
||||||
|
passed: false
|
||||||
|
error: "Plain file incorrectly detected as encrypted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
test_name: $test_name
|
||||||
|
passed: true
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
} catch { |err|
|
||||||
|
{
|
||||||
|
test_name: $test_name
|
||||||
|
passed: false
|
||||||
|
error: $err.msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 2: Encrypt/Decrypt round-trip
|
||||||
|
def test-encrypt-decrypt-roundtrip []: nothing -> record {
|
||||||
|
let test_name = "Encrypt/Decrypt Round-trip"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Create test file
|
||||||
|
let test_file = "/tmp/test_roundtrip.yaml"
|
||||||
|
let original_content = "test_key: secret_value"
|
||||||
|
$original_content | save --force $test_file
|
||||||
|
|
||||||
|
# Check if age is available
|
||||||
|
let age_check = (^which age | complete)
|
||||||
|
if $age_check.exit_code != 0 {
|
||||||
|
rm --force $test_file
|
||||||
|
return {
|
||||||
|
test_name: $test_name
|
||||||
|
passed: true
|
||||||
|
error: "Skipped: Age not installed"
|
||||||
|
skipped: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if SOPS recipients configured
|
||||||
|
let recipients = ($env.SOPS_AGE_RECIPIENTS? | default "")
|
||||||
|
if ($recipients | is-empty) {
|
||||||
|
rm --force $test_file
|
||||||
|
return {
|
||||||
|
test_name: $test_name
|
||||||
|
passed: true
|
||||||
|
error: "Skipped: SOPS_AGE_RECIPIENTS not configured"
|
||||||
|
skipped: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Encrypt the file
|
||||||
|
let encrypted_file = $"($test_file).enc"
|
||||||
|
encrypt-config $test_file $encrypted_file --kms="age"
|
||||||
|
|
||||||
|
# Verify it's encrypted
|
||||||
|
if not (is-encrypted-config $encrypted_file) {
|
||||||
|
rm --force $test_file $encrypted_file
|
||||||
|
return {
|
||||||
|
test_name: $test_name
|
||||||
|
passed: false
|
||||||
|
error: "File not encrypted after encrypt-config"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decrypt the file
|
||||||
|
let decrypted_file = $"($test_file).dec"
|
||||||
|
decrypt-config $encrypted_file $decrypted_file
|
||||||
|
|
||||||
|
# Verify content matches
|
||||||
|
let decrypted_content = (open $decrypted_file --raw)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm --force $test_file $encrypted_file $decrypted_file
|
||||||
|
|
||||||
|
if $decrypted_content != $original_content {
|
||||||
|
return {
|
||||||
|
test_name: $test_name
|
||||||
|
passed: false
|
||||||
|
error: "Decrypted content doesn't match original"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
test_name: $test_name
|
||||||
|
passed: true
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
} catch { |err|
|
||||||
|
{
|
||||||
|
test_name: $test_name
|
||||||
|
passed: false
|
||||||
|
error: $err.msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 3: Memory-only decryption
|
||||||
|
def test-memory-only-decryption []: nothing -> record {
|
||||||
|
let test_name = "Memory-Only Decryption"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Check if age is available
|
||||||
|
let age_check = (^which age | complete)
|
||||||
|
if $age_check.exit_code != 0 {
|
||||||
|
return {
|
||||||
|
test_name: $test_name
|
||||||
|
passed: true
|
||||||
|
error: "Skipped: Age not installed"
|
||||||
|
skipped: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if SOPS recipients configured
|
||||||
|
let recipients = ($env.SOPS_AGE_RECIPIENTS? | default "")
|
||||||
|
if ($recipients | is-empty) {
|
||||||
|
return {
|
||||||
|
test_name: $test_name
|
||||||
|
passed: true
|
||||||
|
error: "Skipped: SOPS_AGE_RECIPIENTS not configured"
|
||||||
|
skipped: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create and encrypt test file
|
||||||
|
let test_file = "/tmp/test_memory.yaml"
|
||||||
|
let original_content = "secret: memory_test"
|
||||||
|
$original_content | save --force $test_file
|
||||||
|
|
||||||
|
let encrypted_file = $"($test_file).enc"
|
||||||
|
encrypt-config $test_file $encrypted_file --kms="age"
|
||||||
|
|
||||||
|
# Decrypt in memory
|
||||||
|
let decrypted_memory = (decrypt-config-memory $encrypted_file)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm --force $test_file $encrypted_file
|
||||||
|
|
||||||
|
# Verify no decrypted file was created
|
||||||
|
if ($"($encrypted_file).dec" | path exists) {
|
||||||
|
return {
|
||||||
|
test_name: $test_name
|
||||||
|
passed: false
|
||||||
|
error: "Decrypted file was created (should be memory-only)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify decrypted content
|
||||||
|
if $decrypted_memory != $original_content {
|
||||||
|
return {
|
||||||
|
test_name: $test_name
|
||||||
|
passed: false
|
||||||
|
error: "Memory-decrypted content doesn't match original"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
test_name: $test_name
|
||||||
|
passed: true
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
} catch { |err|
|
||||||
|
{
|
||||||
|
test_name: $test_name
|
||||||
|
passed: false
|
||||||
|
error: $err.msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 4: Sensitive data detection
|
||||||
|
def test-sensitive-data-detection []: nothing -> record {
|
||||||
|
let test_name = "Sensitive Data Detection"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Create test file with sensitive data
|
||||||
|
let test_file = "/tmp/test_sensitive.yaml"
|
||||||
|
let sensitive_content = "api_key: secret123\npassword: mypassword"
|
||||||
|
$sensitive_content | save --force $test_file
|
||||||
|
|
||||||
|
# Should detect sensitive data
|
||||||
|
let has_sensitive = (contains-sensitive-data $test_file)
|
||||||
|
|
||||||
|
# Create test file without sensitive data
|
||||||
|
let test_file_safe = "/tmp/test_safe.yaml"
|
||||||
|
let safe_content = "name: test\nvalue: 123"
|
||||||
|
$safe_content | save --force $test_file_safe
|
||||||
|
|
||||||
|
# Should not detect sensitive data
|
||||||
|
let has_no_sensitive = not (contains-sensitive-data $test_file_safe)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm --force $test_file $test_file_safe
|
||||||
|
|
||||||
|
if not ($has_sensitive and $has_no_sensitive) {
|
||||||
|
return {
|
||||||
|
test_name: $test_name
|
||||||
|
passed: false
|
||||||
|
error: "Sensitive data detection not working correctly"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
test_name: $test_name
|
||||||
|
passed: true
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
} catch { |err|
|
||||||
|
{
|
||||||
|
test_name: $test_name
|
||||||
|
passed: false
|
||||||
|
error: $err.msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 5: KMS backend integration
|
||||||
|
def test-kms-backend-integration []: nothing -> record {
|
||||||
|
let test_name = "KMS Backend Integration"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Test detection
|
||||||
|
let backend = (detect-kms-backend)
|
||||||
|
|
||||||
|
if ($backend | is-empty) {
|
||||||
|
return {
|
||||||
|
test_name: $test_name
|
||||||
|
passed: false
|
||||||
|
error: "Failed to detect KMS backend"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test status
|
||||||
|
let status = (kms-status)
|
||||||
|
|
||||||
|
if ($status.backend | is-empty) {
|
||||||
|
return {
|
||||||
|
test_name: $test_name
|
||||||
|
passed: false
|
||||||
|
error: "KMS status check failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
test_name: $test_name
|
||||||
|
passed: true
|
||||||
|
error: null
|
||||||
|
details: {
|
||||||
|
detected_backend: $backend
|
||||||
|
status: $status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { |err|
|
||||||
|
{
|
||||||
|
test_name: $test_name
|
||||||
|
passed: false
|
||||||
|
error: $err.msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 6: Config loader integration
|
||||||
|
def test-config-loader-integration []: nothing -> record {
|
||||||
|
let test_name = "Config Loader Integration"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Create test directory
|
||||||
|
mkdir /tmp/test_config_loader
|
||||||
|
|
||||||
|
# Create plain config
|
||||||
|
let plain_config = "/tmp/test_config_loader/plain.yaml"
|
||||||
|
{test: "plain", value: 123} | to yaml | save --force $plain_config
|
||||||
|
|
||||||
|
# Load plain config (should work)
|
||||||
|
use loader.nu
|
||||||
|
let loaded_plain = (loader load-config-file $plain_config false false "yaml")
|
||||||
|
|
||||||
|
if ($loaded_plain.test != "plain") {
|
||||||
|
rm --force --recursive /tmp/test_config_loader
|
||||||
|
return {
|
||||||
|
test_name: $test_name
|
||||||
|
passed: false
|
||||||
|
error: "Failed to load plain config through loader"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm --force --recursive /tmp/test_config_loader
|
||||||
|
|
||||||
|
{
|
||||||
|
test_name: $test_name
|
||||||
|
passed: true
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
} catch { |err|
|
||||||
|
{
|
||||||
|
test_name: $test_name
|
||||||
|
passed: false
|
||||||
|
error: $err.msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 7: Encryption validation
|
||||||
|
def test-encryption-validation []: nothing -> record {
|
||||||
|
let test_name = "Encryption Validation"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Run validation
|
||||||
|
let validation = (validate-encryption-config)
|
||||||
|
|
||||||
|
# Check that validation returns expected structure
|
||||||
|
if not (($validation | columns) | all { |col| $col in ["valid", "errors", "warnings", "summary"] }) {
|
||||||
|
return {
|
||||||
|
test_name: $test_name
|
||||||
|
passed: false
|
||||||
|
error: "Validation result structure incorrect"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
test_name: $test_name
|
||||||
|
passed: true
|
||||||
|
error: null
|
||||||
|
details: $validation.summary
|
||||||
|
}
|
||||||
|
} catch { |err|
|
||||||
|
{
|
||||||
|
test_name: $test_name
|
||||||
|
passed: false
|
||||||
|
error: $err.msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show test result
|
||||||
|
def show-test-result [result: record] {
|
||||||
|
if $result.passed {
|
||||||
|
print $" ✅ ($result.test_name)"
|
||||||
|
if ($result | get -o skipped) == true {
|
||||||
|
print $" ⚠️ ($result.error)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print $" ❌ ($result.test_name)"
|
||||||
|
print $" Error: ($result.error)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Integration test with full workflow
|
||||||
|
export def test-full-encryption-workflow [] {
|
||||||
|
print "🧪 Full Encryption Workflow Test"
|
||||||
|
print "================================="
|
||||||
|
print ""
|
||||||
|
|
||||||
|
let test_dir = "/tmp/test_encryption_workflow"
|
||||||
|
mkdir $test_dir
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Step 1: Create test config with sensitive data
|
||||||
|
print "📝 Step 1: Creating test configuration"
|
||||||
|
let config_file = $"($test_dir)/secure.yaml"
|
||||||
|
{
|
||||||
|
database: {
|
||||||
|
host: "localhost"
|
||||||
|
password: "supersecret123"
|
||||||
|
api_key: "key_abc123"
|
||||||
|
}
|
||||||
|
aws: {
|
||||||
|
access_key: "AKIAIOSFODNN7EXAMPLE"
|
||||||
|
secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||||
|
}
|
||||||
|
} | to yaml | save --force $config_file
|
||||||
|
print " ✅ Config created"
|
||||||
|
|
||||||
|
# Step 2: Detect sensitive data
|
||||||
|
print "🔍 Step 2: Detecting sensitive data"
|
||||||
|
let has_sensitive = (contains-sensitive-data $config_file)
|
||||||
|
if $has_sensitive {
|
||||||
|
print " ✅ Sensitive data detected"
|
||||||
|
} else {
|
||||||
|
print " ❌ Failed to detect sensitive data"
|
||||||
|
rm --force --recursive $test_dir
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 3: Check encryption prerequisites
|
||||||
|
print "🔧 Step 3: Checking encryption prerequisites"
|
||||||
|
let validation = (validate-encryption-config)
|
||||||
|
if $validation.valid {
|
||||||
|
print " ✅ Encryption system configured"
|
||||||
|
} else {
|
||||||
|
print " ⚠️ Encryption system has issues:"
|
||||||
|
for error in $validation.errors {
|
||||||
|
print $" • ($error.message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 4: Encrypt config (if possible)
|
||||||
|
print "🔒 Step 4: Encrypting configuration"
|
||||||
|
let recipients = ($env.SOPS_AGE_RECIPIENTS? | default "")
|
||||||
|
if ($recipients | is-not-empty) {
|
||||||
|
try {
|
||||||
|
encrypt-config $config_file --in-place --kms="age"
|
||||||
|
print " ✅ Configuration encrypted"
|
||||||
|
|
||||||
|
# Step 5: Verify encryption
|
||||||
|
print "🔍 Step 5: Verifying encryption"
|
||||||
|
let is_enc = (is-encrypted-config $config_file)
|
||||||
|
if $is_enc {
|
||||||
|
print " ✅ Encryption verified"
|
||||||
|
} else {
|
||||||
|
print " ❌ File not encrypted"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 6: Load encrypted config
|
||||||
|
print "📖 Step 6: Loading encrypted config"
|
||||||
|
let loaded = (load-encrypted-config $config_file)
|
||||||
|
if ($loaded.database.password == "supersecret123") {
|
||||||
|
print " ✅ Decrypted and loaded correctly"
|
||||||
|
} else {
|
||||||
|
print " ❌ Failed to load encrypted config"
|
||||||
|
}
|
||||||
|
} catch { |err|
|
||||||
|
print $" ❌ Encryption failed: ($err.msg)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print " ⚠️ Skipped: SOPS_AGE_RECIPIENTS not configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
print ""
|
||||||
|
print "✅ Workflow test completed"
|
||||||
|
|
||||||
|
} catch { |err|
|
||||||
|
print $"❌ Workflow test failed: ($err.msg)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm --force --recursive $test_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main help
|
||||||
|
export def main [] {
|
||||||
|
print "Configuration Encryption Tests"
|
||||||
|
print "=============================="
|
||||||
|
print ""
|
||||||
|
print "Commands:"
|
||||||
|
print " run-encryption-tests Run all tests"
|
||||||
|
print " run-encryption-tests --test <name> Run specific test"
|
||||||
|
print " test-full-encryption-workflow Run complete workflow test"
|
||||||
|
print ""
|
||||||
|
print "Available tests:"
|
||||||
|
print " detection - Encryption detection"
|
||||||
|
print " roundtrip - Encrypt/decrypt round-trip"
|
||||||
|
print " memory - Memory-only decryption"
|
||||||
|
print " sensitive - Sensitive data detection"
|
||||||
|
print " kms - KMS backend integration"
|
||||||
|
print " loader - Config loader integration"
|
||||||
|
print " validation - Encryption validation"
|
||||||
|
}
|
||||||
@ -164,7 +164,7 @@ export def load-provisioning-config [
|
|||||||
$final_config
|
$final_config
|
||||||
}
|
}
|
||||||
|
|
||||||
# Load a single configuration file (supports YAML and TOML)
|
# Load a single configuration file (supports YAML and TOML with automatic decryption)
|
||||||
export def load-config-file [
|
export def load-config-file [
|
||||||
file_path: string
|
file_path: string
|
||||||
required = false
|
required = false
|
||||||
@ -188,32 +188,67 @@ export def load-config-file [
|
|||||||
# log debug $"Loading config file: ($file_path)"
|
# log debug $"Loading config file: ($file_path)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Determine format from file extension if auto
|
# Check if file is encrypted and auto-decrypt
|
||||||
let file_format = if $format == "auto" {
|
# Inline SOPS detection to avoid circular import
|
||||||
let ext = ($file_path | path parse | get extension)
|
if (check-if-sops-encrypted $file_path) {
|
||||||
match $ext {
|
if $debug {
|
||||||
"yaml" | "yml" => "yaml"
|
# log debug $"Detected encrypted config, decrypting in memory: ($file_path)"
|
||||||
"toml" => "toml"
|
|
||||||
_ => "toml" # default to toml for backward compatibility
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
$format
|
|
||||||
}
|
|
||||||
|
|
||||||
# Load the file with appropriate parser
|
try {
|
||||||
if ($file_path | path exists) {
|
# Decrypt in memory using SOPS
|
||||||
match $file_format {
|
let decrypted_content = (decrypt-sops-file $file_path)
|
||||||
"yaml" => (open $file_path)
|
|
||||||
"toml" => (open $file_path)
|
if ($decrypted_content | is-empty) {
|
||||||
_ => (open $file_path)
|
if $debug {
|
||||||
|
print $"⚠️ Failed to decrypt ($file_path), attempting to load as plain file"
|
||||||
|
}
|
||||||
|
open $file_path
|
||||||
|
} else {
|
||||||
|
# Parse based on file extension
|
||||||
|
let ext = ($file_path | path parse | get extension)
|
||||||
|
match $ext {
|
||||||
|
"yaml" | "yml" => ($decrypted_content | from yaml)
|
||||||
|
"toml" => ($decrypted_content | from toml)
|
||||||
|
"json" => ($decrypted_content | from json)
|
||||||
|
_ => ($decrypted_content | from yaml) # default to yaml
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if $debug {
|
||||||
|
print $"⚠️ Failed to decrypt ($file_path), attempting to load as plain file"
|
||||||
|
}
|
||||||
|
# Fallback to regular loading if decryption fails
|
||||||
|
open $file_path
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if $required {
|
# Determine format from file extension if auto
|
||||||
error make {
|
let file_format = if $format == "auto" {
|
||||||
msg: $"Configuration file not found: ($file_path)"
|
let ext = ($file_path | path parse | get extension)
|
||||||
|
match $ext {
|
||||||
|
"yaml" | "yml" => "yaml"
|
||||||
|
"toml" => "toml"
|
||||||
|
_ => "toml" # default to toml for backward compatibility
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
{}
|
$format
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load unencrypted file with appropriate parser
|
||||||
|
if ($file_path | path exists) {
|
||||||
|
match $file_format {
|
||||||
|
"yaml" => (open $file_path)
|
||||||
|
"toml" => (open $file_path)
|
||||||
|
_ => (open $file_path)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if $required {
|
||||||
|
error make {
|
||||||
|
msg: $"Configuration file not found: ($file_path)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1747,6 +1782,60 @@ def update-workspace-last-used-internal [workspace_name: string] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check if file is SOPS encrypted (inline to avoid circular import)
|
||||||
|
def check-if-sops-encrypted [file_path: string]: nothing -> bool {
|
||||||
|
if not ($file_path | path exists) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_content = (open $file_path --raw)
|
||||||
|
|
||||||
|
# Check for SOPS markers
|
||||||
|
if ($file_content | str contains "sops:") and ($file_content | str contains "ENC[") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decrypt SOPS file (inline to avoid circular import)
|
||||||
|
def decrypt-sops-file [file_path: string]: nothing -> string {
|
||||||
|
# Find SOPS config
|
||||||
|
let sops_config = find-sops-config-path
|
||||||
|
|
||||||
|
# Decrypt using SOPS binary
|
||||||
|
let result = if ($sops_config | is-not-empty) {
|
||||||
|
^sops --decrypt --config $sops_config $file_path | complete
|
||||||
|
} else {
|
||||||
|
^sops --decrypt $file_path | complete
|
||||||
|
}
|
||||||
|
|
||||||
|
if $result.exit_code != 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
$result.stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find SOPS configuration file
|
||||||
|
def find-sops-config-path []: nothing -> string {
|
||||||
|
# Check common locations
|
||||||
|
let locations = [
|
||||||
|
".sops.yaml"
|
||||||
|
".sops.yml"
|
||||||
|
($env.PWD | path join ".sops.yaml")
|
||||||
|
($env.HOME | path join ".config" | path join "provisioning" | path join "sops.yaml")
|
||||||
|
]
|
||||||
|
|
||||||
|
for loc in $locations {
|
||||||
|
if ($loc | path exists) {
|
||||||
|
return $loc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
# Get active workspace from user config
|
# Get active workspace from user config
|
||||||
# CRITICAL: This replaces get-defaults-config-path
|
# CRITICAL: This replaces get-defaults-config-path
|
||||||
def get-active-workspace [] {
|
def get-active-workspace [] {
|
||||||
|
|||||||
@ -6,6 +6,10 @@ export use loader.nu *
|
|||||||
export use accessor.nu *
|
export use accessor.nu *
|
||||||
export use migration.nu *
|
export use migration.nu *
|
||||||
|
|
||||||
|
# Encryption functionality
|
||||||
|
export use encryption.nu *
|
||||||
|
export use commands.nu *
|
||||||
|
|
||||||
# Convenience function to get the complete configuration
|
# Convenience function to get the complete configuration
|
||||||
export def config [] {
|
export def config [] {
|
||||||
get-config
|
get-config
|
||||||
|
|||||||
547
nulib/lib_provisioning/kms/client.nu
Normal file
547
nulib/lib_provisioning/kms/client.nu
Normal file
@ -0,0 +1,547 @@
|
|||||||
|
# KMS Client Interface for Multiple Backends
|
||||||
|
# Provides unified interface for Age, AWS KMS, HashiCorp Vault, and Cosmian KMS
|
||||||
|
|
||||||
|
use std log
|
||||||
|
use ../config/accessor.nu *
|
||||||
|
use ../utils/error.nu throw-error
|
||||||
|
use ../utils/interface.nu _print
|
||||||
|
|
||||||
|
# KMS Client for encryption/decryption operations
|
||||||
|
export def kms-encrypt [
|
||||||
|
data: string # Data to encrypt
|
||||||
|
key_id?: string # Key ID (backend-specific)
|
||||||
|
--backend: string = "" # age, aws-kms, vault, cosmian (auto-detect if empty)
|
||||||
|
--output-format: string = "base64" # base64, hex, binary
|
||||||
|
]: nothing -> string {
|
||||||
|
let kms_backend = if ($backend | is-empty) {
|
||||||
|
detect-kms-backend
|
||||||
|
} else {
|
||||||
|
$backend
|
||||||
|
}
|
||||||
|
|
||||||
|
match $kms_backend {
|
||||||
|
"age" => {
|
||||||
|
kms-encrypt-age $data $key_id --output-format=$output_format
|
||||||
|
}
|
||||||
|
"aws-kms" => {
|
||||||
|
kms-encrypt-aws $data $key_id --output-format=$output_format
|
||||||
|
}
|
||||||
|
"vault" => {
|
||||||
|
kms-encrypt-vault $data $key_id --output-format=$output_format
|
||||||
|
}
|
||||||
|
"cosmian" => {
|
||||||
|
kms-encrypt-cosmian $data $key_id --output-format=$output_format
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
error make {
|
||||||
|
msg: $"Unsupported KMS backend: ($kms_backend)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decrypt data using KMS
|
||||||
|
export def kms-decrypt [
|
||||||
|
encrypted_data: string # Encrypted data
|
||||||
|
key_id?: string # Key ID (backend-specific)
|
||||||
|
--backend: string = "" # age, aws-kms, vault, cosmian (auto-detect if empty)
|
||||||
|
--input-format: string = "base64" # base64, hex, binary
|
||||||
|
]: nothing -> string {
|
||||||
|
let kms_backend = if ($backend | is-empty) {
|
||||||
|
detect-kms-backend
|
||||||
|
} else {
|
||||||
|
$backend
|
||||||
|
}
|
||||||
|
|
||||||
|
match $kms_backend {
|
||||||
|
"age" => {
|
||||||
|
kms-decrypt-age $encrypted_data $key_id --input-format=$input_format
|
||||||
|
}
|
||||||
|
"aws-kms" => {
|
||||||
|
kms-decrypt-aws $encrypted_data $key_id --input-format=$input_format
|
||||||
|
}
|
||||||
|
"vault" => {
|
||||||
|
kms-decrypt-vault $encrypted_data $key_id --input-format=$input_format
|
||||||
|
}
|
||||||
|
"cosmian" => {
|
||||||
|
kms-decrypt-cosmian $encrypted_data $key_id --input-format=$input_format
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
error make {
|
||||||
|
msg: $"Unsupported KMS backend: ($kms_backend)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Age backend encryption
|
||||||
|
def kms-encrypt-age [
|
||||||
|
data: string
|
||||||
|
key_id?: string
|
||||||
|
--output-format: string = "base64"
|
||||||
|
]: nothing -> string {
|
||||||
|
# Get Age recipients
|
||||||
|
let recipients = if ($key_id | is-not-empty) {
|
||||||
|
$key_id
|
||||||
|
} else {
|
||||||
|
$env.SOPS_AGE_RECIPIENTS? | default (get-sops-age-recipients)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($recipients | is-empty) {
|
||||||
|
error make {
|
||||||
|
msg: "No Age recipients configured"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Encrypt with age
|
||||||
|
let encrypted = ($data | ^age -r $recipients -a | complete)
|
||||||
|
|
||||||
|
if $encrypted.exit_code != 0 {
|
||||||
|
error make {
|
||||||
|
msg: $"Age encryption failed: ($encrypted.stderr)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$encrypted.stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
# Age backend decryption
|
||||||
|
def kms-decrypt-age [
|
||||||
|
encrypted_data: string
|
||||||
|
key_id?: string
|
||||||
|
--input-format: string = "base64"
|
||||||
|
]: nothing -> string {
|
||||||
|
# Get Age key file
|
||||||
|
let key_file = if ($key_id | is-not-empty) {
|
||||||
|
$key_id
|
||||||
|
} else {
|
||||||
|
get-sops-age-key-file
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($key_file | is-empty) {
|
||||||
|
error make {
|
||||||
|
msg: "No Age key file configured"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if not ($key_file | path exists) {
|
||||||
|
error make {
|
||||||
|
msg: $"Age key file not found: ($key_file)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decrypt with age
|
||||||
|
let decrypted = ($encrypted_data | ^age -d -i $key_file | complete)
|
||||||
|
|
||||||
|
if $decrypted.exit_code != 0 {
|
||||||
|
error make {
|
||||||
|
msg: $"Age decryption failed: ($decrypted.stderr)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$decrypted.stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
# AWS KMS backend encryption
|
||||||
|
def kms-encrypt-aws [
|
||||||
|
data: string
|
||||||
|
key_id?: string
|
||||||
|
--output-format: string = "base64"
|
||||||
|
]: nothing -> string {
|
||||||
|
# Get KMS key ID from config or parameter
|
||||||
|
let kms_key = if ($key_id | is-not-empty) {
|
||||||
|
$key_id
|
||||||
|
} else {
|
||||||
|
get-config-value "kms.aws.key_id" ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($kms_key | is-empty) {
|
||||||
|
error make {
|
||||||
|
msg: "No AWS KMS key ID configured"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if AWS CLI is available
|
||||||
|
let aws_check = (^which aws | complete)
|
||||||
|
if $aws_check.exit_code != 0 {
|
||||||
|
error make {
|
||||||
|
msg: "AWS CLI not installed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Encrypt with AWS KMS
|
||||||
|
let encrypted = ($data | ^aws kms encrypt --key-id $kms_key --plaintext fileb:///dev/stdin --output text --query CiphertextBlob | complete)
|
||||||
|
|
||||||
|
if $encrypted.exit_code != 0 {
|
||||||
|
error make {
|
||||||
|
msg: $"AWS KMS encryption failed: ($encrypted.stderr)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$encrypted.stdout | str trim
|
||||||
|
}
|
||||||
|
|
||||||
|
# AWS KMS backend decryption
|
||||||
|
def kms-decrypt-aws [
|
||||||
|
encrypted_data: string
|
||||||
|
key_id?: string
|
||||||
|
--input-format: string = "base64"
|
||||||
|
]: nothing -> binary {
|
||||||
|
# Check if AWS CLI is available
|
||||||
|
let aws_check = (^which aws | complete)
|
||||||
|
if $aws_check.exit_code != 0 {
|
||||||
|
error make {
|
||||||
|
msg: "AWS CLI not installed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decrypt with AWS KMS
|
||||||
|
let decrypted = ($encrypted_data | ^aws kms decrypt --ciphertext-blob fileb:///dev/stdin --output text --query Plaintext | complete)
|
||||||
|
|
||||||
|
if $decrypted.exit_code != 0 {
|
||||||
|
error make {
|
||||||
|
msg: $"AWS KMS decryption failed: ($decrypted.stderr)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$decrypted.stdout | str trim | decode base64
|
||||||
|
}
|
||||||
|
|
||||||
|
# HashiCorp Vault backend encryption
|
||||||
|
def kms-encrypt-vault [
|
||||||
|
data: string
|
||||||
|
key_id?: string
|
||||||
|
--output-format: string = "base64"
|
||||||
|
]: nothing -> string {
|
||||||
|
# Get Vault configuration
|
||||||
|
let vault_addr = $env.VAULT_ADDR? | default (get-config-value "kms.vault.address" "")
|
||||||
|
let vault_token = $env.VAULT_TOKEN? | default (get-config-value "kms.vault.token" "")
|
||||||
|
let transit_key = if ($key_id | is-not-empty) { $key_id } else { get-config-value "kms.vault.transit_key" "provisioning" }
|
||||||
|
|
||||||
|
if ($vault_addr | is-empty) {
|
||||||
|
error make {
|
||||||
|
msg: "Vault address not configured"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Encode data to base64 for Vault transit
|
||||||
|
let plaintext_b64 = ($data | encode base64)
|
||||||
|
|
||||||
|
# Encrypt with Vault transit
|
||||||
|
let vault_payload = {plaintext: $plaintext_b64} | to json
|
||||||
|
|
||||||
|
let encrypted = (^curl -s -X POST -H $"X-Vault-Token: ($vault_token)" -H "Content-Type: application/json" -d $vault_payload $"($vault_addr)/v1/transit/encrypt/($transit_key)" | complete)
|
||||||
|
|
||||||
|
if $encrypted.exit_code != 0 {
|
||||||
|
error make {
|
||||||
|
msg: $"Vault encryption failed: ($encrypted.stderr)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = ($encrypted.stdout | from json)
|
||||||
|
|
||||||
|
if ($response | get -o errors) != null {
|
||||||
|
error make {
|
||||||
|
msg: $"Vault encryption error: ($response.errors | to json)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$response.data.ciphertext
|
||||||
|
}
|
||||||
|
|
||||||
|
# HashiCorp Vault backend decryption
|
||||||
|
def kms-decrypt-vault [
|
||||||
|
encrypted_data: string
|
||||||
|
key_id?: string
|
||||||
|
--input-format: string = "base64"
|
||||||
|
]: nothing -> binary {
|
||||||
|
# Get Vault configuration
|
||||||
|
let vault_addr = $env.VAULT_ADDR? | default (get-config-value "kms.vault.address" "")
|
||||||
|
let vault_token = $env.VAULT_TOKEN? | default (get-config-value "kms.vault.token" "")
|
||||||
|
let transit_key = if ($key_id | is-not-empty) { $key_id } else { get-config-value "kms.vault.transit_key" "provisioning" }
|
||||||
|
|
||||||
|
if ($vault_addr | is-empty) {
|
||||||
|
error make {
|
||||||
|
msg: "Vault address not configured"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decrypt with Vault transit
|
||||||
|
let vault_payload = {ciphertext: $encrypted_data} | to json
|
||||||
|
|
||||||
|
let decrypted = (^curl -s -X POST -H $"X-Vault-Token: ($vault_token)" -H "Content-Type: application/json" -d $vault_payload $"($vault_addr)/v1/transit/decrypt/($transit_key)" | complete)
|
||||||
|
|
||||||
|
if $decrypted.exit_code != 0 {
|
||||||
|
error make {
|
||||||
|
msg: $"Vault decryption failed: ($decrypted.stderr)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = ($decrypted.stdout | from json)
|
||||||
|
|
||||||
|
if ($response | get -o errors) != null {
|
||||||
|
error make {
|
||||||
|
msg: $"Vault decryption error: ($response.errors | to json)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$response.data.plaintext | decode base64
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cosmian KMS backend encryption
|
||||||
|
def kms-encrypt-cosmian [
|
||||||
|
data: string
|
||||||
|
key_id?: string
|
||||||
|
--output-format: string = "base64"
|
||||||
|
]: nothing -> string {
|
||||||
|
# Get Cosmian KMS configuration
|
||||||
|
let kms_server = get-kms-server
|
||||||
|
|
||||||
|
if ($kms_server | is-empty) {
|
||||||
|
error make {
|
||||||
|
msg: "Cosmian KMS server not configured"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use curl to call Cosmian KMS REST API
|
||||||
|
let encrypted = ($data | ^curl -s -X POST -H "Content-Type: application/octet-stream" --data-binary @- $"($kms_server)/encrypt" | complete)
|
||||||
|
|
||||||
|
if $encrypted.exit_code != 0 {
|
||||||
|
error make {
|
||||||
|
msg: $"Cosmian KMS encryption failed: ($encrypted.stderr)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$encrypted.stdout | encode base64
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cosmian KMS backend decryption
|
||||||
|
def kms-decrypt-cosmian [
|
||||||
|
encrypted_data: string
|
||||||
|
key_id?: string
|
||||||
|
--input-format: string = "base64"
|
||||||
|
]: nothing -> string {
|
||||||
|
# Get Cosmian KMS configuration
|
||||||
|
let kms_server = get-kms-server
|
||||||
|
|
||||||
|
if ($kms_server | is-empty) {
|
||||||
|
error make {
|
||||||
|
msg: "Cosmian KMS server not configured"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decode from base64 first
|
||||||
|
let binary_data = ($encrypted_data | decode base64)
|
||||||
|
|
||||||
|
# Use curl to call Cosmian KMS REST API
|
||||||
|
let decrypted = ($binary_data | ^curl -s -X POST -H "Content-Type: application/octet-stream" --data-binary @- $"($kms_server)/decrypt" | complete)
|
||||||
|
|
||||||
|
if $decrypted.exit_code != 0 {
|
||||||
|
error make {
|
||||||
|
msg: $"Cosmian KMS decryption failed: ($decrypted.stderr)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$decrypted.stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect KMS backend from configuration
|
||||||
|
def detect-kms-backend []: nothing -> string {
|
||||||
|
let kms_enabled = (get-kms-enabled)
|
||||||
|
|
||||||
|
if not $kms_enabled {
|
||||||
|
# Default to Age if KMS not enabled
|
||||||
|
return "age"
|
||||||
|
}
|
||||||
|
|
||||||
|
let kms_mode = (get-kms-mode)
|
||||||
|
|
||||||
|
match $kms_mode {
|
||||||
|
"local" => {
|
||||||
|
let local_provider = (get-kms-local-provider)
|
||||||
|
$local_provider
|
||||||
|
}
|
||||||
|
"remote" => {
|
||||||
|
# Remote KMS (Cosmian or Vault)
|
||||||
|
let kms_server = (get-kms-server)
|
||||||
|
if ($kms_server | str contains "vault") {
|
||||||
|
"vault"
|
||||||
|
} else {
|
||||||
|
"cosmian"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => "age"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test KMS connectivity and functionality
|
||||||
|
export def kms-test [
|
||||||
|
--backend: string = "" # age, aws-kms, vault, cosmian (auto-detect if empty)
|
||||||
|
]: nothing -> record {
|
||||||
|
print $"🧪 Testing KMS backend..."
|
||||||
|
|
||||||
|
let kms_backend = if ($backend | is-empty) {
|
||||||
|
detect-kms-backend
|
||||||
|
} else {
|
||||||
|
$backend
|
||||||
|
}
|
||||||
|
|
||||||
|
print $" Backend: ($kms_backend)"
|
||||||
|
|
||||||
|
let test_data = "Hello, KMS encryption test!"
|
||||||
|
let initial_result = {
|
||||||
|
backend: $kms_backend
|
||||||
|
encryption_success: false
|
||||||
|
decryption_success: false
|
||||||
|
round_trip_success: false
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
|
||||||
|
let test_result = (do {
|
||||||
|
# Test encryption
|
||||||
|
print $" Testing encryption..."
|
||||||
|
let encrypted = (kms-encrypt $test_data --backend=$kms_backend)
|
||||||
|
let encryption_result = ($initial_result | upsert encryption_success true)
|
||||||
|
|
||||||
|
# Test decryption
|
||||||
|
print $" Testing decryption..."
|
||||||
|
let decrypted = (kms-decrypt $encrypted --backend=$kms_backend)
|
||||||
|
let decryption_result = ($encryption_result | upsert decryption_success true)
|
||||||
|
|
||||||
|
# Verify round-trip
|
||||||
|
if $decrypted == $test_data {
|
||||||
|
print $" ✅ Round-trip successful"
|
||||||
|
$decryption_result | upsert round_trip_success true
|
||||||
|
} else {
|
||||||
|
print $" ❌ Round-trip failed: data mismatch"
|
||||||
|
$decryption_result | upsert error "Round-trip data mismatch"
|
||||||
|
}
|
||||||
|
} | complete | if $in.exit_code == 0 {
|
||||||
|
$in.stdout
|
||||||
|
} else {
|
||||||
|
print $" ❌ Test failed"
|
||||||
|
$initial_result | upsert error "Test execution failed"
|
||||||
|
})
|
||||||
|
|
||||||
|
print ""
|
||||||
|
if $test_result.round_trip_success {
|
||||||
|
print $"✅ KMS backend ($kms_backend) is working correctly"
|
||||||
|
} else {
|
||||||
|
print $"❌ KMS backend ($kms_backend) test failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
$test_result
|
||||||
|
}
|
||||||
|
|
||||||
|
# List available KMS backends
|
||||||
|
export def kms-list-backends [] {
|
||||||
|
print "Available KMS Backends:"
|
||||||
|
print "======================="
|
||||||
|
print ""
|
||||||
|
print "Local Backends:"
|
||||||
|
print " • age - Age encryption (file-based keys)"
|
||||||
|
print ""
|
||||||
|
print "Cloud Backends:"
|
||||||
|
print " • aws-kms - AWS Key Management Service"
|
||||||
|
print ""
|
||||||
|
print "Enterprise Backends:"
|
||||||
|
print " • vault - HashiCorp Vault Transit Engine"
|
||||||
|
print " • cosmian - Cosmian KMS (confidential computing)"
|
||||||
|
print ""
|
||||||
|
print "Current Configuration:"
|
||||||
|
|
||||||
|
let kms_enabled = (get-kms-enabled)
|
||||||
|
print $" KMS Enabled: ($kms_enabled)"
|
||||||
|
|
||||||
|
if $kms_enabled {
|
||||||
|
let kms_mode = (get-kms-mode)
|
||||||
|
print $" Mode: ($kms_mode)"
|
||||||
|
|
||||||
|
let detected_backend = (detect-kms-backend)
|
||||||
|
print $" Detected Backend: ($detected_backend)"
|
||||||
|
} else {
|
||||||
|
print " Detected Backend: age (default)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get KMS backend status
|
||||||
|
export def kms-status []: nothing -> record {
|
||||||
|
let kms_enabled = (get-kms-enabled)
|
||||||
|
let backend = (detect-kms-backend)
|
||||||
|
|
||||||
|
mut status = {
|
||||||
|
enabled: $kms_enabled
|
||||||
|
backend: $backend
|
||||||
|
configured: false
|
||||||
|
available: false
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if backend is configured
|
||||||
|
match $backend {
|
||||||
|
"age" => {
|
||||||
|
let key_file = (get-sops-age-key-file)
|
||||||
|
let recipients = ($env.SOPS_AGE_RECIPIENTS? | default "")
|
||||||
|
|
||||||
|
$status = ($status | upsert configured (($key_file | is-not-empty) and ($recipients | is-not-empty)))
|
||||||
|
let available = if ($key_file | is-not-empty) { $key_file | path exists } else { false }
|
||||||
|
$status = ($status | upsert available $available)
|
||||||
|
}
|
||||||
|
"aws-kms" => {
|
||||||
|
let aws_check = (^which aws | complete)
|
||||||
|
$status = ($status | upsert available ($aws_check.exit_code == 0))
|
||||||
|
|
||||||
|
let key_id = (get-config-value "kms.aws.key_id" "")
|
||||||
|
$status = ($status | upsert configured ($key_id | is-not-empty))
|
||||||
|
}
|
||||||
|
"vault" => {
|
||||||
|
let vault_addr = ($env.VAULT_ADDR? | default "")
|
||||||
|
let vault_token = ($env.VAULT_TOKEN? | default "")
|
||||||
|
|
||||||
|
$status = ($status | upsert configured (($vault_addr | is-not-empty) and ($vault_token | is-not-empty)))
|
||||||
|
|
||||||
|
# Test connectivity
|
||||||
|
if $status.configured {
|
||||||
|
let health_check = (^curl -s $"($vault_addr)/v1/sys/health" | complete)
|
||||||
|
$status = ($status | upsert available ($health_check.exit_code == 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"cosmian" => {
|
||||||
|
let kms_server = (get-kms-server)
|
||||||
|
$status = ($status | upsert configured ($kms_server | is-not-empty))
|
||||||
|
|
||||||
|
# Test connectivity
|
||||||
|
if $status.configured {
|
||||||
|
let health_check = (^curl -s $"($kms_server)/health" | complete)
|
||||||
|
$status = ($status | upsert available ($health_check.exit_code == 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$status
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get configuration value with fallback
|
||||||
|
def get-config-value [
|
||||||
|
path: string
|
||||||
|
default_value: any
|
||||||
|
]: nothing -> any {
|
||||||
|
# This would integrate with the config accessor
|
||||||
|
# For now, return default
|
||||||
|
$default_value
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main help
|
||||||
|
export def main [] {
|
||||||
|
print "KMS Client Interface"
|
||||||
|
print "===================="
|
||||||
|
print ""
|
||||||
|
print "Commands:"
|
||||||
|
print " kms-encrypt <data> Encrypt data using KMS"
|
||||||
|
print " kms-decrypt <data> Decrypt data using KMS"
|
||||||
|
print " kms-test Test KMS backend functionality"
|
||||||
|
print " kms-list-backends List available KMS backends"
|
||||||
|
print " kms-status Show KMS backend status"
|
||||||
|
print ""
|
||||||
|
print "Supported Backends:"
|
||||||
|
print " age, aws-kms, vault, cosmian"
|
||||||
|
}
|
||||||
@ -1 +1,2 @@
|
|||||||
export use lib.nu *
|
export use lib.nu *
|
||||||
|
export use client.nu *
|
||||||
378
nulib/mfa/commands.nu
Normal file
378
nulib/mfa/commands.nu
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
# Multi-Factor Authentication (MFA) CLI commands
|
||||||
|
#
|
||||||
|
# Provides comprehensive MFA management through the control-center API
|
||||||
|
|
||||||
|
use ../lib_provisioning/config/loader.nu get-config
|
||||||
|
|
||||||
|
# Get API base URL from config
|
||||||
|
def get-api-url [] {
|
||||||
|
let config = get-config
|
||||||
|
$config.api.base_url? | default "http://localhost:8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get auth token from environment or config
|
||||||
|
def get-auth-token [] {
|
||||||
|
$env.PROVISIONING_AUTH_TOKEN? | default ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Make authenticated API request
|
||||||
|
def api-request [
|
||||||
|
method: string # HTTP method (GET, POST, DELETE)
|
||||||
|
endpoint: string # API endpoint path
|
||||||
|
body?: any # Request body (optional)
|
||||||
|
] {
|
||||||
|
let base_url = get-api-url
|
||||||
|
let token = get-auth-token
|
||||||
|
let url = $"($base_url)/api/v1($endpoint)"
|
||||||
|
|
||||||
|
let headers = {
|
||||||
|
"Authorization": $"Bearer ($token)"
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($body | is-empty) {
|
||||||
|
http $method $url --headers $headers
|
||||||
|
} else {
|
||||||
|
http $method $url --headers $headers ($body | to json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TOTP Commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Enroll TOTP (Time-based One-Time Password)
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# mfa totp enroll
|
||||||
|
export def "mfa totp enroll" [] {
|
||||||
|
print "📱 Enrolling TOTP device..."
|
||||||
|
|
||||||
|
let response = api-request "POST" "/mfa/totp/enroll"
|
||||||
|
|
||||||
|
print ""
|
||||||
|
print "✅ TOTP device enrolled successfully!"
|
||||||
|
print ""
|
||||||
|
print "📋 Device ID:" $response.device_id
|
||||||
|
print ""
|
||||||
|
print "🔑 Manual entry secret (if QR code doesn't work):"
|
||||||
|
print $" ($response.secret)"
|
||||||
|
print ""
|
||||||
|
print "📱 Scan this QR code with your authenticator app:"
|
||||||
|
print " (Google Authenticator, Authy, Microsoft Authenticator, etc.)"
|
||||||
|
print ""
|
||||||
|
|
||||||
|
# Save QR code to file
|
||||||
|
let qr_file = $"/tmp/mfa-qr-($response.device_id).html"
|
||||||
|
$"<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>MFA Setup - QR Code</title></head>
|
||||||
|
<body style='text-align: center; padding: 50px;'>
|
||||||
|
<h1>Scan QR Code</h1>
|
||||||
|
<img src='($response.qr_code)' style='max-width: 400px;' />
|
||||||
|
<p><code>($response.secret)</code></p>
|
||||||
|
</body>
|
||||||
|
</html>" | save -f $qr_file
|
||||||
|
|
||||||
|
print $" QR code saved to: ($qr_file)"
|
||||||
|
print $" Open in browser: open ($qr_file)"
|
||||||
|
print ""
|
||||||
|
print "💾 Backup codes (save these securely):"
|
||||||
|
for code in $response.backup_codes {
|
||||||
|
print $" ($code)"
|
||||||
|
}
|
||||||
|
print ""
|
||||||
|
print "⚠️ IMPORTANT: Test your TOTP setup with 'mfa totp verify <code>'"
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify TOTP code
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# mfa totp verify 123456
|
||||||
|
export def "mfa totp verify" [
|
||||||
|
code: string # 6-digit TOTP code
|
||||||
|
--device-id: string # Specific device ID (optional)
|
||||||
|
] {
|
||||||
|
print $"🔐 Verifying TOTP code: ($code)..."
|
||||||
|
|
||||||
|
let body = {
|
||||||
|
code: $code
|
||||||
|
device_id: $device_id
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = api-request "POST" "/mfa/totp/verify" $body
|
||||||
|
|
||||||
|
if $response.verified {
|
||||||
|
print ""
|
||||||
|
print "✅ TOTP verification successful!"
|
||||||
|
if $response.backup_code_used {
|
||||||
|
print "⚠️ Note: A backup code was used"
|
||||||
|
}
|
||||||
|
print ""
|
||||||
|
} else {
|
||||||
|
print ""
|
||||||
|
print "❌ TOTP verification failed"
|
||||||
|
print " Please check your code and try again"
|
||||||
|
print ""
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Disable TOTP
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# mfa totp disable
|
||||||
|
export def "mfa totp disable" [] {
|
||||||
|
print "⚠️ Disabling TOTP..."
|
||||||
|
print ""
|
||||||
|
print "This will remove all TOTP devices from your account."
|
||||||
|
let confirm = input "Are you sure? (yes/no): "
|
||||||
|
|
||||||
|
if $confirm != "yes" {
|
||||||
|
print "Cancelled."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api-request "POST" "/mfa/totp/disable"
|
||||||
|
|
||||||
|
print ""
|
||||||
|
print "✅ TOTP disabled successfully"
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show backup codes status
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# mfa totp backup-codes
|
||||||
|
export def "mfa totp backup-codes" [] {
|
||||||
|
print "🔑 Fetching backup codes status..."
|
||||||
|
|
||||||
|
let response = api-request "GET" "/mfa/totp/backup-codes"
|
||||||
|
|
||||||
|
print ""
|
||||||
|
print "📋 Backup Codes:"
|
||||||
|
for code in $response.backup_codes {
|
||||||
|
print $" ($code)"
|
||||||
|
}
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Regenerate backup codes
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# mfa totp regenerate
|
||||||
|
export def "mfa totp regenerate" [] {
|
||||||
|
print "🔄 Regenerating backup codes..."
|
||||||
|
print ""
|
||||||
|
print "⚠️ This will invalidate all existing backup codes."
|
||||||
|
let confirm = input "Continue? (yes/no): "
|
||||||
|
|
||||||
|
if $confirm != "yes" {
|
||||||
|
print "Cancelled."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = api-request "POST" "/mfa/totp/regenerate"
|
||||||
|
|
||||||
|
print ""
|
||||||
|
print "✅ New backup codes generated:"
|
||||||
|
print ""
|
||||||
|
for code in $response.backup_codes {
|
||||||
|
print $" ($code)"
|
||||||
|
}
|
||||||
|
print ""
|
||||||
|
print "💾 Save these codes securely!"
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# WebAuthn Commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Enroll WebAuthn device (security key)
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# mfa webauthn enroll --device-name "YubiKey 5"
|
||||||
|
export def "mfa webauthn enroll" [
|
||||||
|
--device-name: string = "Security Key" # Device name
|
||||||
|
] {
|
||||||
|
print $"🔐 Enrolling WebAuthn device: ($device_name)"
|
||||||
|
print ""
|
||||||
|
print "⚠️ WebAuthn enrollment requires browser interaction."
|
||||||
|
print " Use the Web UI at: (get-api-url)/mfa/setup"
|
||||||
|
print ""
|
||||||
|
print " Or use the API directly with a browser-based client."
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# List WebAuthn devices
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# mfa webauthn list
|
||||||
|
export def "mfa webauthn list" [] {
|
||||||
|
print "🔑 Fetching WebAuthn devices..."
|
||||||
|
|
||||||
|
let devices = api-request "GET" "/mfa/webauthn/devices"
|
||||||
|
|
||||||
|
if ($devices | is-empty) {
|
||||||
|
print ""
|
||||||
|
print "No WebAuthn devices registered"
|
||||||
|
print ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print ""
|
||||||
|
print "📱 WebAuthn Devices:"
|
||||||
|
print ""
|
||||||
|
|
||||||
|
for device in $devices {
|
||||||
|
print $"Device: ($device.device_name)"
|
||||||
|
print $" ID: ($device.id)"
|
||||||
|
print $" Created: ($device.created_at)"
|
||||||
|
print $" Last used: ($device.last_used | default 'Never')"
|
||||||
|
print $" Status: (if $device.enabled { '✅ Enabled' } else { '❌ Disabled' })"
|
||||||
|
print $" Transports: ($device.transports | str join ', ')"
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove WebAuthn device
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# mfa webauthn remove <device-id>
|
||||||
|
export def "mfa webauthn remove" [
|
||||||
|
device_id: string # Device ID to remove
|
||||||
|
] {
|
||||||
|
print $"🗑️ Removing WebAuthn device: ($device_id)"
|
||||||
|
print ""
|
||||||
|
|
||||||
|
let confirm = input "Are you sure? (yes/no): "
|
||||||
|
if $confirm != "yes" {
|
||||||
|
print "Cancelled."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api-request "DELETE" $"/mfa/webauthn/devices/($device_id)"
|
||||||
|
|
||||||
|
print ""
|
||||||
|
print "✅ Device removed successfully"
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# General MFA Commands
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Show MFA status
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# mfa status
|
||||||
|
export def "mfa status" [] {
|
||||||
|
print "🔐 Fetching MFA status..."
|
||||||
|
|
||||||
|
let status = api-request "GET" "/mfa/status"
|
||||||
|
|
||||||
|
print ""
|
||||||
|
print "📊 MFA Status:"
|
||||||
|
print $" Enabled: (if $status.enabled { '✅ Yes' } else { '❌ No' })"
|
||||||
|
print ""
|
||||||
|
|
||||||
|
if not ($status.totp_devices | is-empty) {
|
||||||
|
print "📱 TOTP Devices:"
|
||||||
|
for device in $status.totp_devices {
|
||||||
|
print $" • ID: ($device.id)"
|
||||||
|
print $" Created: ($device.created_at)"
|
||||||
|
print $" Last used: ($device.last_used | default 'Never')"
|
||||||
|
print $" Status: (if $device.enabled { 'Enabled' } else { 'Not verified' })"
|
||||||
|
}
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if not ($status.webauthn_devices | is-empty) {
|
||||||
|
print "🔑 WebAuthn Devices:"
|
||||||
|
for device in $status.webauthn_devices {
|
||||||
|
print $" • ($device.device_name)"
|
||||||
|
print $" ID: ($device.id)"
|
||||||
|
print $" Created: ($device.created_at)"
|
||||||
|
print $" Last used: ($device.last_used | default 'Never')"
|
||||||
|
}
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if $status.has_backup_codes {
|
||||||
|
print "💾 Backup codes: Available"
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (not $status.enabled) {
|
||||||
|
print "ℹ️ MFA is not enabled. Set it up with:"
|
||||||
|
print " • mfa totp enroll - For TOTP (recommended)"
|
||||||
|
print " • mfa webauthn enroll - For hardware keys"
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Disable all MFA methods
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# mfa disable
|
||||||
|
export def "mfa disable" [] {
|
||||||
|
print "⚠️ Disabling ALL MFA methods..."
|
||||||
|
print ""
|
||||||
|
print "This will remove:"
|
||||||
|
print " • All TOTP devices"
|
||||||
|
print " • All WebAuthn devices"
|
||||||
|
print " • All backup codes"
|
||||||
|
print ""
|
||||||
|
|
||||||
|
let confirm = input "Are you ABSOLUTELY sure? Type 'disable mfa': "
|
||||||
|
|
||||||
|
if $confirm != "disable mfa" {
|
||||||
|
print "Cancelled."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api-request "POST" "/mfa/disable"
|
||||||
|
|
||||||
|
print ""
|
||||||
|
print "✅ All MFA methods have been disabled"
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# List all MFA devices
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# mfa list-devices
|
||||||
|
export def "mfa list-devices" [] {
|
||||||
|
mfa status
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Help Command
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Show MFA help
|
||||||
|
export def "mfa help" [] {
|
||||||
|
print ""
|
||||||
|
print "🔐 Multi-Factor Authentication (MFA) Commands"
|
||||||
|
print ""
|
||||||
|
print "TOTP (Time-based One-Time Password):"
|
||||||
|
print " mfa totp enroll - Enroll TOTP device"
|
||||||
|
print " mfa totp verify <code> - Verify TOTP code"
|
||||||
|
print " mfa totp disable - Disable TOTP"
|
||||||
|
print " mfa totp backup-codes - Show backup codes status"
|
||||||
|
print " mfa totp regenerate - Regenerate backup codes"
|
||||||
|
print ""
|
||||||
|
print "WebAuthn (Hardware Security Keys):"
|
||||||
|
print " mfa webauthn enroll - Enroll security key"
|
||||||
|
print " mfa webauthn list - List registered devices"
|
||||||
|
print " mfa webauthn remove <id> - Remove device"
|
||||||
|
print ""
|
||||||
|
print "General:"
|
||||||
|
print " mfa status - Show MFA status"
|
||||||
|
print " mfa list-devices - List all devices"
|
||||||
|
print " mfa disable - Disable all MFA"
|
||||||
|
print " mfa help - Show this help"
|
||||||
|
print ""
|
||||||
|
}
|
||||||
5
nulib/ssh/mod.nu
Normal file
5
nulib/ssh/mod.nu
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# SSH Module
|
||||||
|
#
|
||||||
|
# Temporal SSH key management system
|
||||||
|
|
||||||
|
export use temporal.nu *
|
||||||
248
nulib/ssh/temporal.nu
Normal file
248
nulib/ssh/temporal.nu
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
# SSH Temporal Key Management CLI
|
||||||
|
#
|
||||||
|
# Provides Nushell commands for managing temporal SSH keys with automatic cleanup.
|
||||||
|
|
||||||
|
# Generate temporal SSH key
|
||||||
|
export def "ssh generate-key" [
|
||||||
|
server: string # Target server (hostname or IP)
|
||||||
|
--user: string = "root" # SSH user
|
||||||
|
--ttl: duration = 1hr # Key lifetime
|
||||||
|
--type: string = "dynamic" # Key type: ca, otp, or dynamic
|
||||||
|
--ip: string # Allowed IP (for OTP mode)
|
||||||
|
--principal: string # Principal (for CA mode)
|
||||||
|
] {
|
||||||
|
let ttl_seconds = ($ttl | into int) / 1_000_000_000
|
||||||
|
|
||||||
|
let request = {
|
||||||
|
key_type: (match $type {
|
||||||
|
"ca" => "certificate"
|
||||||
|
"otp" => "otp"
|
||||||
|
"dynamic" => "dynamickeypair"
|
||||||
|
_ => (error make {msg: $"Invalid key type: ($type). Use: ca, otp, or dynamic"})
|
||||||
|
})
|
||||||
|
user: $user
|
||||||
|
target_server: $server
|
||||||
|
ttl_seconds: $ttl_seconds
|
||||||
|
allowed_ip: $ip
|
||||||
|
principal: $principal
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = (http post http://localhost:8080/api/v1/ssh/generate $request)
|
||||||
|
|
||||||
|
if $response.success {
|
||||||
|
print $"✓ SSH key generated successfully"
|
||||||
|
print $" Key ID: ($response.data.id)"
|
||||||
|
print $" Type: ($response.data.key_type)"
|
||||||
|
print $" User: ($response.data.user)"
|
||||||
|
print $" Server: ($response.data.target_server)"
|
||||||
|
print $" Expires: ($response.data.expires_at)"
|
||||||
|
print $" Fingerprint: ($response.data.fingerprint)"
|
||||||
|
|
||||||
|
if $response.data.private_key != null {
|
||||||
|
print ""
|
||||||
|
print "Private Key (save this securely, will not be shown again):"
|
||||||
|
print $"($response.data.private_key)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$response.data
|
||||||
|
} else {
|
||||||
|
error make {msg: $"Failed to generate SSH key: ($response.error)"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deploy SSH key to server
|
||||||
|
export def "ssh deploy-key" [
|
||||||
|
key_id: string # SSH key ID
|
||||||
|
] {
|
||||||
|
let response = (http post $"http://localhost:8080/api/v1/ssh/($key_id)/deploy" {})
|
||||||
|
|
||||||
|
if $response.success {
|
||||||
|
if $response.data.success {
|
||||||
|
print $"✓ SSH key deployed successfully to ($response.data.server)"
|
||||||
|
} else {
|
||||||
|
error make {msg: $"Failed to deploy key: ($response.data.error)"}
|
||||||
|
}
|
||||||
|
$response.data
|
||||||
|
} else {
|
||||||
|
error make {msg: $"Failed to deploy SSH key: ($response.error)"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# List active SSH keys
|
||||||
|
export def "ssh list-keys" [
|
||||||
|
--expired: bool = false # Include expired keys
|
||||||
|
] {
|
||||||
|
let response = (http get http://localhost:8080/api/v1/ssh/keys)
|
||||||
|
|
||||||
|
if $response.success {
|
||||||
|
let keys = $response.data
|
||||||
|
|
||||||
|
if ($keys | is-empty) {
|
||||||
|
print "No active SSH keys found"
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
print $"Found ($keys | length) active SSH keys:"
|
||||||
|
$keys | select id key_type user target_server expires_at deployed
|
||||||
|
} else {
|
||||||
|
error make {msg: $"Failed to list SSH keys: ($response.error)"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Revoke SSH key
|
||||||
|
export def "ssh revoke-key" [
|
||||||
|
key_id: string # SSH key ID to revoke
|
||||||
|
] {
|
||||||
|
let response = (http post $"http://localhost:8080/api/v1/ssh/($key_id)/revoke" {})
|
||||||
|
|
||||||
|
if $response.success {
|
||||||
|
print $"✓ SSH key revoked: ($key_id)"
|
||||||
|
$response.data
|
||||||
|
} else {
|
||||||
|
error make {msg: $"Failed to revoke SSH key: ($response.error)"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get SSH key details
|
||||||
|
export def "ssh get-key" [
|
||||||
|
key_id: string # SSH key ID
|
||||||
|
] {
|
||||||
|
let response = (http get $"http://localhost:8080/api/v1/ssh/($key_id)")
|
||||||
|
|
||||||
|
if $response.success {
|
||||||
|
$response.data
|
||||||
|
} else {
|
||||||
|
error make {msg: $"Failed to get SSH key: ($response.error)"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# SSH with temporal key (auto-generate, deploy, connect, revoke)
|
||||||
|
export def "ssh connect" [
|
||||||
|
server: string # Target server
|
||||||
|
--user: string = "root" # SSH user
|
||||||
|
--ttl: duration = 1hr # Key lifetime
|
||||||
|
--type: string = "dynamic" # Key type: ca, otp, or dynamic
|
||||||
|
--keep: bool = false # Keep key after disconnect
|
||||||
|
] {
|
||||||
|
print $"Generating temporal SSH key for ($user)@($server)..."
|
||||||
|
let key = (ssh generate-key $server --user $user --ttl $ttl --type $type)
|
||||||
|
|
||||||
|
print "Deploying key to server..."
|
||||||
|
ssh deploy-key $key.id
|
||||||
|
|
||||||
|
# Save private key to temp file
|
||||||
|
let key_file = $"/tmp/ssh_temp_($key.id)"
|
||||||
|
$key.private_key | save -f $key_file
|
||||||
|
chmod 600 $key_file
|
||||||
|
|
||||||
|
print $"Connecting to ($user)@($server)..."
|
||||||
|
^ssh -i $key_file -o "StrictHostKeyChecking=no" $"($user)@($server)"
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm $key_file
|
||||||
|
|
||||||
|
if not $keep {
|
||||||
|
print "Revoking temporary key..."
|
||||||
|
ssh revoke-key $key.id
|
||||||
|
} else {
|
||||||
|
print $"Key ($key.id) kept active until expiration"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show SSH key statistics
|
||||||
|
export def "ssh stats" [] {
|
||||||
|
let response = (http get http://localhost:8080/api/v1/ssh/stats)
|
||||||
|
|
||||||
|
if $response.success {
|
||||||
|
let stats = $response.data
|
||||||
|
print "SSH Key Statistics:"
|
||||||
|
print $" Total generated: ($stats.total_generated)"
|
||||||
|
print $" Active keys: ($stats.active_keys)"
|
||||||
|
print $" Expired keys: ($stats.expired_keys)"
|
||||||
|
print ""
|
||||||
|
print "Keys by type:"
|
||||||
|
$stats.keys_by_type | transpose key value | each {|row| print $" ($row.key): ($row.value)"}
|
||||||
|
|
||||||
|
if $stats.last_cleanup_at != null {
|
||||||
|
print ""
|
||||||
|
print $"Last cleanup: ($stats.last_cleanup_at)"
|
||||||
|
print $" Cleaned keys: ($stats.last_cleanup_count)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats
|
||||||
|
} else {
|
||||||
|
error make {msg: $"Failed to get SSH statistics: ($response.error)"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Trigger manual cleanup of expired keys
|
||||||
|
export def "ssh cleanup" [] {
|
||||||
|
print "Triggering cleanup of expired SSH keys..."
|
||||||
|
let response = (http post http://localhost:8080/api/v1/ssh/cleanup {})
|
||||||
|
|
||||||
|
if $response.success {
|
||||||
|
print $"✓ Cleaned up ($response.data.cleaned_count) expired keys"
|
||||||
|
if ($response.data.cleaned_key_ids | length) > 0 {
|
||||||
|
print "Cleaned key IDs:"
|
||||||
|
$response.data.cleaned_key_ids | each {|id| print $" - ($id)"}
|
||||||
|
}
|
||||||
|
$response.data
|
||||||
|
} else {
|
||||||
|
error make {msg: $"Failed to cleanup expired keys: ($response.error)"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Quick test - generate, deploy, and revoke a test key
|
||||||
|
export def "ssh test" [
|
||||||
|
server: string # Target server
|
||||||
|
--user: string = "root" # SSH user
|
||||||
|
] {
|
||||||
|
print "=== SSH Temporal Key Test ==="
|
||||||
|
print ""
|
||||||
|
|
||||||
|
print "1. Generating test key..."
|
||||||
|
let key = (ssh generate-key $server --user $user --ttl 5min)
|
||||||
|
print ""
|
||||||
|
|
||||||
|
print "2. Deploying key to server..."
|
||||||
|
ssh deploy-key $key.id
|
||||||
|
print ""
|
||||||
|
|
||||||
|
print "3. Testing SSH connection..."
|
||||||
|
print "(Manual step: Try connecting with the private key shown above)"
|
||||||
|
print ""
|
||||||
|
|
||||||
|
print "4. Revoking key..."
|
||||||
|
ssh revoke-key $key.id
|
||||||
|
print ""
|
||||||
|
|
||||||
|
print "✓ Test complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show help for SSH commands
|
||||||
|
export def "ssh help" [] {
|
||||||
|
print "SSH Temporal Key Management Commands:"
|
||||||
|
print ""
|
||||||
|
print " ssh generate-key <server> Generate new SSH key"
|
||||||
|
print " ssh deploy-key <key_id> Deploy key to server"
|
||||||
|
print " ssh list-keys List active keys"
|
||||||
|
print " ssh get-key <key_id> Get key details"
|
||||||
|
print " ssh revoke-key <key_id> Revoke key immediately"
|
||||||
|
print " ssh connect <server> Connect with auto-generated key"
|
||||||
|
print " ssh stats Show statistics"
|
||||||
|
print " ssh cleanup Cleanup expired keys"
|
||||||
|
print " ssh test <server> Run quick test"
|
||||||
|
print ""
|
||||||
|
print "Options:"
|
||||||
|
print " --user <name> SSH user (default: root)"
|
||||||
|
print " --ttl <duration> Key lifetime (default: 1hr)"
|
||||||
|
print " --type <ca|otp|dynamic> Key type (default: dynamic)"
|
||||||
|
print " --ip <address> Allowed IP (OTP mode)"
|
||||||
|
print " --principal <name> Principal (CA mode)"
|
||||||
|
print ""
|
||||||
|
print "Examples:"
|
||||||
|
print " ssh generate-key server.example.com --ttl 30min"
|
||||||
|
print " ssh connect server.example.com --user deploy"
|
||||||
|
print " ssh list-keys | where deployed == true"
|
||||||
|
print " ssh stats"
|
||||||
|
}
|
||||||
0
plugins/.gitkeep
Normal file
0
plugins/.gitkeep
Normal file
Loading…
x
Reference in New Issue
Block a user