diff --git a/.gitignore b/.gitignore index 366db47..fc3117b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ CLAUDE.md wrks ROOT OLD +plugins/nushell-plugins # Generated by Cargo # will have compiled files and executables debug/ diff --git a/nulib/audit/commands.nu b/nulib/audit/commands.nu new file mode 100644 index 0000000..5e4ebb3 --- /dev/null +++ b/nulib/audit/commands.nu @@ -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) +} diff --git a/nulib/break_glass/commands.nu b/nulib/break_glass/commands.nu new file mode 100644 index 0000000..24023b6 --- /dev/null +++ b/nulib/break_glass/commands.nu @@ -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 = [] # Target resources + --permissions: list = [] # 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 --reason 'Emergency confirmed'" + print "" + print " # Activate session" + print " break-glass activate " + print "" + print " # Revoke session" + print " break-glass revoke --reason 'Emergency resolved'" +} diff --git a/nulib/compliance/commands.nu b/nulib/compliance/commands.nu new file mode 100644 index 0000000..2082809 --- /dev/null +++ b/nulib/compliance/commands.nu @@ -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 [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 +" +} diff --git a/nulib/kms/mod.nu b/nulib/kms/mod.nu new file mode 100644 index 0000000..7603f74 --- /dev/null +++ b/nulib/kms/mod.nu @@ -0,0 +1,6 @@ +#!/usr/bin/env nu + +# KMS Service Module +# Unified interface for Key Management Service operations + +export use service.nu * diff --git a/nulib/kms/service.nu b/nulib/kms/service.nu new file mode 100644 index 0000000..b09a510 --- /dev/null +++ b/nulib/kms/service.nu @@ -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: .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 +} diff --git a/nulib/lib_provisioning/config/commands.nu b/nulib/lib_provisioning/config/commands.nu new file mode 100644 index 0000000..4373774 --- /dev/null +++ b/nulib/lib_provisioning/config/commands.nu @@ -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: .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 Encrypt configuration file" + print " config decrypt Decrypt configuration file" + print " config edit-secure Edit encrypted file securely" + print " config rotate-keys Rotate encryption keys" + print "" + print "Information:" + print " config is-encrypted Check if file is encrypted" + print " config encryption-info Show encryption details" + print " config validate-encryption Validate encryption setup" + print "" + print "Bulk Operations:" + print " config scan-sensitive Find unencrypted sensitive configs" + print " config encrypt-all 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" +} diff --git a/nulib/lib_provisioning/config/encryption.nu b/nulib/lib_provisioning/config/encryption.nu new file mode 100644 index 0000000..bd258a9 --- /dev/null +++ b/nulib/lib_provisioning/config/encryption.nu @@ -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 Check if config is encrypted" + print " - load-encrypted-config Load and decrypt config" + print " - encrypt-config Encrypt config file" + print " - decrypt-config Decrypt config file" + print " - edit-encrypted-config Edit encrypted config" + print " - rotate-encryption-keys Rotate encryption keys" + print " - validate-encryption-config Validate encryption setup" + print " - scan-unencrypted-configs Find unencrypted sensitive configs" + print " - encrypt-sensitive-configs Encrypt all sensitive configs" +} diff --git a/nulib/lib_provisioning/config/encryption_tests.nu b/nulib/lib_provisioning/config/encryption_tests.nu new file mode 100644 index 0000000..e5f120e --- /dev/null +++ b/nulib/lib_provisioning/config/encryption_tests.nu @@ -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 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" +} diff --git a/nulib/lib_provisioning/config/loader.nu b/nulib/lib_provisioning/config/loader.nu index 6ff1814..b062988 100644 --- a/nulib/lib_provisioning/config/loader.nu +++ b/nulib/lib_provisioning/config/loader.nu @@ -164,7 +164,7 @@ export def load-provisioning-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 [ file_path: string required = false @@ -188,32 +188,67 @@ export def load-config-file [ # log debug $"Loading config file: ($file_path)" } - # Determine format from file extension if auto - let file_format = if $format == "auto" { - let ext = ($file_path | path parse | get extension) - match $ext { - "yaml" | "yml" => "yaml" - "toml" => "toml" - _ => "toml" # default to toml for backward compatibility + # Check if file is encrypted and auto-decrypt + # Inline SOPS detection to avoid circular import + if (check-if-sops-encrypted $file_path) { + if $debug { + # log debug $"Detected encrypted config, decrypting in memory: ($file_path)" } - } else { - $format - } - # Load the file with appropriate parser - if ($file_path | path exists) { - match $file_format { - "yaml" => (open $file_path) - "toml" => (open $file_path) - _ => (open $file_path) + try { + # Decrypt in memory using SOPS + let decrypted_content = (decrypt-sops-file $file_path) + + if ($decrypted_content | is-empty) { + 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 { - if $required { - error make { - msg: $"Configuration file not found: ($file_path)" + # Determine format from file extension if auto + let file_format = if $format == "auto" { + let ext = ($file_path | path parse | get extension) + match $ext { + "yaml" | "yml" => "yaml" + "toml" => "toml" + _ => "toml" # default to toml for backward compatibility } } 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 # CRITICAL: This replaces get-defaults-config-path def get-active-workspace [] { diff --git a/nulib/lib_provisioning/config/mod.nu b/nulib/lib_provisioning/config/mod.nu index 7895fbf..2ac1275 100644 --- a/nulib/lib_provisioning/config/mod.nu +++ b/nulib/lib_provisioning/config/mod.nu @@ -6,6 +6,10 @@ export use loader.nu * export use accessor.nu * export use migration.nu * +# Encryption functionality +export use encryption.nu * +export use commands.nu * + # Convenience function to get the complete configuration export def config [] { get-config diff --git a/nulib/lib_provisioning/kms/client.nu b/nulib/lib_provisioning/kms/client.nu new file mode 100644 index 0000000..a4c566f --- /dev/null +++ b/nulib/lib_provisioning/kms/client.nu @@ -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 Encrypt data using KMS" + print " kms-decrypt 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" +} diff --git a/nulib/lib_provisioning/kms/mod.nu b/nulib/lib_provisioning/kms/mod.nu index f43e870..66cb1fe 100644 --- a/nulib/lib_provisioning/kms/mod.nu +++ b/nulib/lib_provisioning/kms/mod.nu @@ -1 +1,2 @@ -export use lib.nu * \ No newline at end of file +export use lib.nu * +export use client.nu * \ No newline at end of file diff --git a/nulib/mfa/commands.nu b/nulib/mfa/commands.nu new file mode 100644 index 0000000..fa476ac --- /dev/null +++ b/nulib/mfa/commands.nu @@ -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" + $" + +MFA Setup - QR Code + +

Scan QR Code

+ +

($response.secret)

+ +" | 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 '" + 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 +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 - 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 - 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 "" +} diff --git a/nulib/ssh/mod.nu b/nulib/ssh/mod.nu new file mode 100644 index 0000000..28d9282 --- /dev/null +++ b/nulib/ssh/mod.nu @@ -0,0 +1,5 @@ +# SSH Module +# +# Temporal SSH key management system + +export use temporal.nu * diff --git a/nulib/ssh/temporal.nu b/nulib/ssh/temporal.nu new file mode 100644 index 0000000..6fd26a8 --- /dev/null +++ b/nulib/ssh/temporal.nu @@ -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 Generate new SSH key" + print " ssh deploy-key Deploy key to server" + print " ssh list-keys List active keys" + print " ssh get-key Get key details" + print " ssh revoke-key Revoke key immediately" + print " ssh connect Connect with auto-generated key" + print " ssh stats Show statistics" + print " ssh cleanup Cleanup expired keys" + print " ssh test Run quick test" + print "" + print "Options:" + print " --user SSH user (default: root)" + print " --ttl Key lifetime (default: 1hr)" + print " --type Key type (default: dynamic)" + print " --ip
Allowed IP (OTP mode)" + print " --principal 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" +} diff --git a/plugins/.gitkeep b/plugins/.gitkeep new file mode 100644 index 0000000..e69de29