From 545e189b43cd1d736e1a3344aa374f567c3d6bce Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 29 Apr 2026 01:12:46 -0400 Subject: [PATCH] feat(cli): subcommand dispatch with 9 commands + output formatters CLI grammar: cre run daemon (sqlite or postgres) cre watch daemon + live TUI in same process cre check one-shot policy eval (CI-friendly exit codes) cre rotate manual rotation cre policy list / show compile-time-baked policy registry inspection cre export --framework=X compliance bundle (Phase 14 stub) cre audit verify hash chain + HMAC + Merkle integrity verification cre demo Tier 1 demo (Phase 15 stub) cre version version cre help usage Output module: human / json / ndjson formatter; CI-friendly exit codes (0 ok, 1 violations/error, 64 usage, 2 audit-chain-broken). run/watch wire engine + persistence + scheduler + evaluator + log notifier (and TUI for watch); SIGINT triggers graceful shutdown. 7 unit specs cover usage / version / policy-list-empty / policy-show-404 / unknown-subcommand / check-no-violations. All commands callable end-to-end against a working binary. --- .../spec/unit/cli/cli_spec.cr | 58 ++++++++++++ .../credential-rotation-enforcer/src/cre.cr | 3 +- .../src/cre/cli/cli.cr | 66 ++++++++++++++ .../src/cre/cli/commands.cr | 14 +++ .../src/cre/cli/commands/audit.cr | 49 ++++++++++ .../src/cre/cli/commands/check.cr | 77 ++++++++++++++++ .../src/cre/cli/commands/demo.cr | 21 +++++ .../src/cre/cli/commands/export.cr | 40 +++++++++ .../src/cre/cli/commands/policy.cr | 75 ++++++++++++++++ .../src/cre/cli/commands/rotate.cr | 90 +++++++++++++++++++ .../src/cre/cli/commands/run.cr | 72 +++++++++++++++ .../src/cre/cli/commands/version.cr | 14 +++ .../src/cre/cli/commands/watch.cr | 59 ++++++++++++ .../src/cre/cli/output.cr | 41 +++++++++ .../src/cre/compliance/bundle.cr | 19 ++++ .../src/cre/demo/tier_1.cr | 16 ++++ .../src/cre/notifiers/log_notifier.cr | 4 +- .../src/cre/notifiers/telegram.cr | 2 +- .../src/cre/notifiers/telegram_bot.cr | 12 +-- .../src/cre/rotators/vault_dynamic.cr | 8 +- .../src/cre/tui/ansi.cr | 38 ++++++-- 21 files changed, 755 insertions(+), 23 deletions(-) create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/cli/cli_spec.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/cli.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/audit.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/check.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/demo.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/export.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/policy.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/rotate.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/run.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/version.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/watch.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/output.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/compliance/bundle.cr create mode 100644 PROJECTS/intermediate/credential-rotation-enforcer/src/cre/demo/tier_1.cr diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/cli/cli_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/cli/cli_spec.cr new file mode 100644 index 00000000..1f5c49f5 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/cli/cli_spec.cr @@ -0,0 +1,58 @@ +# =================== +# ©AngelaMos | 2026 +# cli_spec.cr +# =================== + +require "../../spec_helper" +require "../../../src/cre/cli/cli" + +describe CRE::Cli do + it "prints usage when no args given and exits 64" do + io = IO::Memory.new + code = CRE::Cli.dispatch([] of String, io) + code.should eq 64 + io.to_s.should contain "Subcommands" + end + + it "prints usage on help" do + io = IO::Memory.new + code = CRE::Cli.dispatch(["help"], io) + code.should eq 0 + io.to_s.should contain "Subcommands" + end + + it "prints version on version subcommand" do + io = IO::Memory.new + code = CRE::Cli.dispatch(["version"], io) + code.should eq 0 + io.to_s.strip.should eq CRE::VERSION + end + + it "policy list works against an empty registry" do + CRE::Policy.clear_registry! + io = IO::Memory.new + code = CRE::Cli.dispatch(["policy", "list"], io) + code.should eq 0 + io.to_s.should contain "no policies" + end + + it "policy show returns 1 on missing policy" do + CRE::Policy.clear_registry! + io = IO::Memory.new + code = CRE::Cli.dispatch(["policy", "show", "nonexistent"], io) + code.should eq 1 + end + + it "rejects unknown subcommands" do + io = IO::Memory.new + code = CRE::Cli.dispatch(["thisisbad"], io) + code.should eq 64 + io.to_s.should contain "unknown subcommand" + end + + it "check on empty db returns 0 (no violations)" do + io = IO::Memory.new + code = CRE::Cli.dispatch(["check", "--db=:memory:"], io) + code.should eq 0 + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre.cr index 6251157b..a5c75450 100644 --- a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre.cr +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre.cr @@ -4,10 +4,11 @@ # =================== require "./cre/version" +require "./cre/cli/cli" module CRE def self.main(argv : Array(String)) : Int32 - 0 + Cli.dispatch(argv) end end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/cli.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/cli.cr new file mode 100644 index 00000000..362eb0aa --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/cli.cr @@ -0,0 +1,66 @@ +# =================== +# ©AngelaMos | 2026 +# cli.cr +# =================== + +require "option_parser" +require "../version" +require "./output" +require "./commands" + +module CRE::Cli + USAGE = <<-USAGE + cre - Credential Rotation Enforcer + + Usage: cre [options] + + Subcommands: + run headless daemon (production / systemd) + watch engine + live TUI in same process + check evaluate policies once, exit non-zero on violations + rotate manually rotate a single credential + policy list list compiled-in policies + policy show inspect one policy + export --framework= generate signed compliance evidence bundle + audit verify verify hash chain + HMAC ratchet + Merkle batches + demo tier-1 zero-deps demo (SQLite + .env rotator) + version print version + help this message + + Common options: + --output=human|json|ndjson output format (default: human) + --config=PATH config file (default: $CRE_CONFIG or ./config.cr) + USAGE + + def self.dispatch(argv : Array(String), io : IO = STDOUT) : Int32 + if argv.empty? || %w[--help -h help].includes?(argv.first) + io.puts USAGE + return argv.empty? ? 64 : 0 # 64 = EX_USAGE + end + + subcommand = argv.shift + case subcommand + when "version" + io.puts CRE::VERSION + 0 + when "run" then Commands::Run.new.execute(argv, io) + when "watch" then Commands::Watch.new.execute(argv, io) + when "check" then Commands::Check.new.execute(argv, io) + when "rotate" then Commands::Rotate.new.execute(argv, io) + when "policy" then Commands::Policy.new.execute(argv, io) + when "export" then Commands::Export.new.execute(argv, io) + when "audit" then Commands::Audit.new.execute(argv, io) + when "demo" then Commands::Demo.new.execute(argv, io) + else + io.puts "unknown subcommand: #{subcommand}" + io.puts USAGE + 64 + end + rescue ex : OptionParser::InvalidOption | OptionParser::MissingOption + io.puts "error: #{ex.message}" + 64 + rescue ex + io.puts "error: #{ex.message}" + 1 + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands.cr new file mode 100644 index 00000000..3727574a --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands.cr @@ -0,0 +1,14 @@ +# =================== +# ©AngelaMos | 2026 +# commands.cr +# =================== + +require "./commands/run" +require "./commands/watch" +require "./commands/check" +require "./commands/rotate" +require "./commands/policy" +require "./commands/export" +require "./commands/audit" +require "./commands/demo" +require "./commands/version" diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/audit.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/audit.cr new file mode 100644 index 00000000..e4371243 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/audit.cr @@ -0,0 +1,49 @@ +# =================== +# ©AngelaMos | 2026 +# audit.cr +# =================== + +require "../../audit/audit_log" +require "../../persistence/sqlite/sqlite_persistence" + +module CRE::Cli::Commands + class Audit + def execute(argv : Array(String), io : IO) : Int32 + sub = argv.shift? + case sub + when "verify" then verify(argv, io) + when nil, "--help", "-h" + io.puts "Usage: cre audit verify [--db=PATH]" + 0 + else + io.puts "unknown audit subcommand: #{sub}" + 64 + end + end + + private def verify(argv : Array(String), io : IO) : Int32 + db_path = ENV["CRE_DB_PATH"]? || "cre.db" + hmac_hex = ENV["CRE_HMAC_KEY_HEX"]? || "0" * 64 + + OptionParser.parse(argv) do |parser| + parser.on("--db=PATH", "") { |p| db_path = p } + end + + persist = CRE::Persistence::Sqlite::SqlitePersistence.new(db_path) + persist.migrate! + + log = CRE::Audit::AuditLog.new(persist, hmac_hex.hexbytes, 1, 1024) + ok = log.verify_chain + latest_seq = persist.audit.latest_seq + persist.close + + if ok + io.puts "✓ audit chain valid: #{latest_seq} entries" + 0 + else + io.puts "✗ audit chain BROKEN — verification failed" + 2 + end + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/check.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/check.cr new file mode 100644 index 00000000..2dc78c15 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/check.cr @@ -0,0 +1,77 @@ +# =================== +# ©AngelaMos | 2026 +# check.cr +# =================== + +require "../../engine/event_bus" +require "../../persistence/sqlite/sqlite_persistence" +require "../../policy/evaluator" + +module CRE::Cli::Commands + class Check + def execute(argv : Array(String), io : IO) : Int32 + _help_requested = false + output_format = OutputFormat::Human + db_path = ":memory:" + + OptionParser.parse(argv) do |parser| + parser.banner = "Usage: cre check [options]" + parser.on("--output=FORMAT", "human|json|ndjson") { |f| output_format = Output.parse_format(f) } + parser.on("--db=PATH", "SQLite path (default :memory:)") { |p| db_path = p } + parser.on("-h", "--help") { _help_requested = true; io.puts parser } + end + return 0 if _help_requested + + persist = CRE::Persistence::Sqlite::SqlitePersistence.new(db_path) + persist.migrate! + + bus = CRE::Engine::EventBus.new + ch = bus.subscribe(buffer: 256) + bus.run + + CRE::Policy::Evaluator.new(bus, persist).evaluate_all + sleep 0.1.seconds + + violations = drain(ch).select(&.is_a?(CRE::Events::PolicyViolation)).map(&.as(CRE::Events::PolicyViolation)) + bus.stop + persist.close + + case output_format + in OutputFormat::Human + if violations.empty? + io.puts "OK: no policy violations" + else + io.puts "VIOLATIONS (#{violations.size}):" + violations.each do |v| + io.puts " - credential=#{v.credential_id} policy=#{v.policy_name} reason=#{v.reason}" + end + end + in OutputFormat::Json, OutputFormat::Ndjson + rows = violations.map do |v| + { + "credential_id" => v.credential_id.to_s, + "policy" => v.policy_name, + "reason" => v.reason, + "occurred_at" => v.occurred_at.to_rfc3339, + } + end + Output.print(io, output_format, rows) + end + + violations.empty? ? 0 : 1 + end + + private def drain(ch : ::Channel(CRE::Events::Event)) : Array(CRE::Events::Event) + out = [] of CRE::Events::Event + loop do + select + when ev = ch.receive + out << ev + else + break + end + end + out + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/demo.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/demo.cr new file mode 100644 index 00000000..0dd8697f --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/demo.cr @@ -0,0 +1,21 @@ +# =================== +# ©AngelaMos | 2026 +# demo.cr +# =================== + +require "../../demo/tier_1" + +module CRE::Cli::Commands + class Demo + def execute(argv : Array(String), io : IO) : Int32 + _help_requested = false + OptionParser.parse(argv) do |parser| + parser.banner = "Usage: cre demo (tier-1, no external deps)" + parser.on("-h", "--help") { _help_requested = true; io.puts parser } + end + return 0 if _help_requested + + CRE::Demo::Tier1.run(io) + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/export.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/export.cr new file mode 100644 index 00000000..95083f21 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/export.cr @@ -0,0 +1,40 @@ +# =================== +# ©AngelaMos | 2026 +# export.cr +# =================== + +require "../../compliance/bundle" +require "../../persistence/sqlite/sqlite_persistence" + +module CRE::Cli::Commands + class Export + def execute(argv : Array(String), io : IO) : Int32 + _help_requested = false + framework = "soc2" + out_path = "evidence.zip" + db_path = ENV["CRE_DB_PATH"]? || "cre.db" + + OptionParser.parse(argv) do |parser| + parser.banner = "Usage: cre export --framework= --out=" + parser.on("--framework=NAME", "soc2|pci_dss|iso27001|hipaa") { |f| framework = f } + parser.on("--out=PATH", "output zip path") { |p| out_path = p } + parser.on("--db=PATH", "") { |p| db_path = p } + parser.on("-h", "--help") { _help_requested = true; io.puts parser } + end + return 0 if _help_requested + + persist = CRE::Persistence::Sqlite::SqlitePersistence.new(db_path) + persist.migrate! + + bundle = CRE::Compliance::Bundle.new(persist, framework) + bundle.write(out_path) + persist.close + + io.puts "evidence bundle written to #{out_path}" + 0 + rescue ex + io.puts "export failed: #{ex.message}" + 1 + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/policy.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/policy.cr new file mode 100644 index 00000000..ec9a8228 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/policy.cr @@ -0,0 +1,75 @@ +# =================== +# ©AngelaMos | 2026 +# policy.cr +# =================== + +require "../../policy/policy" + +module CRE::Cli::Commands + class Policy + def execute(argv : Array(String), io : IO) : Int32 + sub = argv.shift? + case sub + when "list" then list(argv, io) + when "show" then show(argv, io) + when nil, "--help", "-h" + io.puts "Usage: cre policy >" + 0 + else + io.puts "unknown policy subcommand: #{sub}" + 64 + end + end + + private def list(argv : Array(String), io : IO) : Int32 + output_format = OutputFormat::Human + OptionParser.parse(argv) do |parser| + parser.on("--output=FORMAT", "human|json") { |f| output_format = Output.parse_format(f) } + end + + policies = CRE::Policy.registry + case output_format + in OutputFormat::Human + if policies.empty? + io.puts "(no policies compiled in)" + else + io.puts "Compiled policies:" + policies.each { |p| io.puts " - #{p.name} (max_age=#{p.max_age}, enforce=#{p.enforce_action.to_s.downcase})" } + end + in OutputFormat::Json, OutputFormat::Ndjson + rows = policies.map do |p| + { + "name" => p.name, + "max_age" => p.max_age.to_s, + "enforce" => p.enforce_action.to_s.downcase, + "warn_at" => p.warn_at.try(&.to_s), + } + end + Output.print(io, output_format, rows) + end + 0 + end + + private def show(argv : Array(String), io : IO) : Int32 + name = argv.shift? + if name.nil? + io.puts "usage: cre policy show " + return 64 + end + policy = CRE::Policy.registry.find { |p| p.name == name } + if policy.nil? + io.puts "policy not found: #{name}" + return 1 + end + io.puts "name: #{policy.name}" + io.puts "desc: #{policy.description || "(none)"}" + io.puts "max_age: #{policy.max_age}" + io.puts "warn_at: #{policy.warn_at || "(none)"}" + io.puts "enforce: #{policy.enforce_action.to_s.downcase}" + io.puts "channels: #{policy.notify_channels.map(&.to_s.downcase).join(", ")}" + io.puts "triggers:" + policy.triggers.each { |k, v| io.puts " #{k.to_s.downcase}: #{v.to_s.downcase}" } + 0 + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/rotate.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/rotate.cr new file mode 100644 index 00000000..a52e7fcd --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/rotate.cr @@ -0,0 +1,90 @@ +# =================== +# ©AngelaMos | 2026 +# rotate.cr +# =================== + +require "../../engine/event_bus" +require "../../engine/rotation_orchestrator" +require "../../persistence/sqlite/sqlite_persistence" +require "../../rotators/env_file" + +module CRE::Cli::Commands + class Rotate + def execute(argv : Array(String), io : IO) : Int32 + _help_requested = false + db_url = ENV["DATABASE_URL"]? || "sqlite:cre.db" + cred_id_str = nil + + OptionParser.parse(argv) do |parser| + parser.banner = "Usage: cre rotate [options]" + parser.on("--db=URL", "") { |u| db_url = u } + parser.on("-h", "--help") { _help_requested = true; io.puts parser } + parser.unknown_args { |args| cred_id_str = args.first? } + end + return 0 if _help_requested + + if cred_id_str.nil? + io.puts "usage: cre rotate " + return 64 + end + + cred_id = UUID.new(cred_id_str.not_nil!) rescue nil + if cred_id.nil? + io.puts "invalid credential id" + return 64 + end + + persist = if db_url.starts_with?("sqlite:") + CRE::Persistence::Sqlite::SqlitePersistence.new(db_url.lchop("sqlite:")) + else + raise "rotate currently supports SQLite only via CLI shortcut" + end + persist.migrate! + + cred = persist.credentials.find(cred_id) + if cred.nil? + io.puts "credential not found: #{cred_id}" + return 1 + end + + rotator_class = CRE::Rotators::Rotator.for(rotator_kind_for(cred.kind)) + if rotator_class.nil? + io.puts "no rotator registered for kind=#{cred.kind}" + return 1 + end + + bus = CRE::Engine::EventBus.new + bus.run + orchestrator = CRE::Engine::RotationOrchestrator.new(bus, persist) + + rotator = case cred.kind + when CRE::Domain::CredentialKind::EnvFile then CRE::Rotators::EnvFileRotator.new + else + raise "this CLI shortcut only supports env_file via direct rotation; cloud rotators need full daemon config" + end + + io.puts "Rotating #{cred.name} (#{cred.id}) via #{rotator.kind}..." + state = orchestrator.run(cred, rotator) + sleep 0.1.seconds + bus.stop + persist.close + + case state + when CRE::Persistence::RotationState::Completed then io.puts "✓ rotation completed"; 0 + when CRE::Persistence::RotationState::Failed then io.puts "✗ rotation failed"; 1 + else io.puts "rotation ended in unexpected state #{state}"; 2 + end + end + + private def rotator_kind_for(kind : CRE::Domain::CredentialKind) : Symbol + case kind + in .aws_secretsmgr? then :aws_secretsmgr + in .vault_dynamic? then :vault_dynamic + in .github_pat? then :github_pat + in .env_file? then :env_file + in .aws_iam_key? then :aws_iam_key + in .database? then :database + end + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/run.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/run.cr new file mode 100644 index 00000000..1088a507 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/run.cr @@ -0,0 +1,72 @@ +# =================== +# ©AngelaMos | 2026 +# run.cr +# =================== + +require "../../engine/engine" +require "../../engine/scheduler" +require "../../persistence/sqlite/sqlite_persistence" +require "../../persistence/postgres/postgres_persistence" +require "../../policy/evaluator" +require "../../notifiers/log_notifier" + +module CRE::Cli::Commands + class Run + def execute(argv : Array(String), io : IO) : Int32 + _help_requested = false + db_url = ENV["DATABASE_URL"]? || "sqlite:cre.db" + hmac_hex = ENV["CRE_HMAC_KEY_HEX"]? || "0" * 64 + interval = (ENV["CRE_TICK_SECONDS"]? || "60").to_i + + OptionParser.parse(argv) do |parser| + parser.banner = "Usage: cre run [options]" + parser.on("--db=URL", "database URL (sqlite:path or postgres://...)") { |u| db_url = u } + parser.on("--interval=SECONDS", "scheduler tick interval") { |i| interval = i.to_i } + parser.on("-h", "--help") { _help_requested = true; io.puts parser } + end + return 0 if _help_requested + + persist = build_persistence(db_url) + persist.migrate! + + engine = CRE::Engine::Engine.new(persist, hmac_hex.hexbytes) + log_notifier = CRE::Notifiers::LogNotifier.new(engine.bus) + evaluator = CRE::Policy::Evaluator.new(engine.bus, persist) + scheduler = CRE::Engine::Scheduler.new(engine.bus, interval.seconds) + + engine.start + log_notifier.start + evaluator.start + scheduler.start + + io.puts "cre running. PID #{Process.pid}, tick #{interval}s, db #{redact(db_url)}" + + Signal::INT.trap do + io.puts "\nshutting down..." + scheduler.stop + evaluator.stop + log_notifier.stop + engine.stop + persist.close + exit 0 + end + + sleep + 0 + end + + private def build_persistence(url : String) : CRE::Persistence::Persistence + if url.starts_with?("sqlite:") + CRE::Persistence::Sqlite::SqlitePersistence.new(url.lchop("sqlite:")) + elsif url.starts_with?("postgres://") || url.starts_with?("postgresql://") + CRE::Persistence::Postgres::PostgresPersistence.new(url) + else + raise "unknown database URL: #{url}" + end + end + + private def redact(url : String) : String + url.gsub(/:(\/{2,})([^:@]+):([^@]+)@/) { |_| ":#{$1}#{$2}:****@" } + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/version.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/version.cr new file mode 100644 index 00000000..142315ca --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/version.cr @@ -0,0 +1,14 @@ +# =================== +# ©AngelaMos | 2026 +# version.cr +# =================== + +require "../../version" + +module CRE::Cli::Commands + module Version + def self.print(io : IO) : Nil + io.puts "cre v#{CRE::VERSION}" + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/watch.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/watch.cr new file mode 100644 index 00000000..1c1118c4 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/commands/watch.cr @@ -0,0 +1,59 @@ +# =================== +# ©AngelaMos | 2026 +# watch.cr +# =================== + +require "../../engine/engine" +require "../../engine/scheduler" +require "../../persistence/sqlite/sqlite_persistence" +require "../../persistence/postgres/postgres_persistence" +require "../../policy/evaluator" +require "../../tui/tui" + +module CRE::Cli::Commands + class Watch + def execute(argv : Array(String), io : IO) : Int32 + _help_requested = false + db_url = ENV["DATABASE_URL"]? || "sqlite:cre.db" + hmac_hex = ENV["CRE_HMAC_KEY_HEX"]? || "0" * 64 + + OptionParser.parse(argv) do |parser| + parser.banner = "Usage: cre watch [options]" + parser.on("--db=URL", "") { |u| db_url = u } + parser.on("-h", "--help") { _help_requested = true; io.puts parser } + end + return 0 if _help_requested + + persist = if db_url.starts_with?("sqlite:") + CRE::Persistence::Sqlite::SqlitePersistence.new(db_url.lchop("sqlite:")) + elsif db_url.starts_with?("postgres://") || db_url.starts_with?("postgresql://") + CRE::Persistence::Postgres::PostgresPersistence.new(db_url) + else + raise "unknown database URL" + end + persist.migrate! + + engine = CRE::Engine::Engine.new(persist, hmac_hex.hexbytes) + evaluator = CRE::Policy::Evaluator.new(engine.bus, persist) + scheduler = CRE::Engine::Scheduler.new(engine.bus, 60.seconds) + tui = CRE::Tui::Tui.new(engine.bus) + + engine.start + evaluator.start + scheduler.start + tui.start + + Signal::INT.trap do + tui.stop + scheduler.stop + evaluator.stop + engine.stop + persist.close + exit 0 + end + + sleep + 0 + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/output.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/output.cr new file mode 100644 index 00000000..aee755e4 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/cli/output.cr @@ -0,0 +1,41 @@ +# =================== +# ©AngelaMos | 2026 +# output.cr +# =================== + +require "json" + +module CRE::Cli + enum OutputFormat + Human + Json + Ndjson + end + + module Output + def self.parse_format(s : String) : OutputFormat + case s.downcase + when "human" then OutputFormat::Human + when "json" then OutputFormat::Json + when "ndjson" then OutputFormat::Ndjson + else + raise "unknown output format: #{s} (valid: human, json, ndjson)" + end + end + + def self.print(io : IO, format : OutputFormat, data) : Nil + case format + in OutputFormat::Human + io.puts data.to_s + in OutputFormat::Json + io.puts data.to_json + in OutputFormat::Ndjson + if data.is_a?(Array) + data.each { |row| io.puts row.to_json } + else + io.puts data.to_json + end + end + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/compliance/bundle.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/compliance/bundle.cr new file mode 100644 index 00000000..0b94e2d7 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/compliance/bundle.cr @@ -0,0 +1,19 @@ +# =================== +# ©AngelaMos | 2026 +# bundle.cr +# =================== + +require "../persistence/persistence" + +module CRE::Compliance + # Bundle is implemented in Phase 14. This stub keeps the CLI export + # subcommand wired and compilable until then. + class Bundle + def initialize(@persistence : Persistence::Persistence, @framework : String) + end + + def write(path : String) : Nil + raise NotImplementedError.new("Compliance::Bundle.write will be wired up in Phase 14") + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/demo/tier_1.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/demo/tier_1.cr new file mode 100644 index 00000000..35e86ae5 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/demo/tier_1.cr @@ -0,0 +1,16 @@ +# =================== +# ©AngelaMos | 2026 +# tier_1.cr +# =================== + +module CRE::Demo + # Tier 1 demo is implemented in Phase 15. This stub keeps `cre demo` + # wired and compilable until then. + module Tier1 + def self.run(io : IO) : Int32 + io.puts "Tier 1 demo not yet implemented (Phase 15 of build)." + io.puts "Try: cre run --db=sqlite:cre.db" + 0 + end + end +end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/notifiers/log_notifier.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/notifiers/log_notifier.cr index 619276b3..e41a3c1d 100644 --- a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/notifiers/log_notifier.cr +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/notifiers/log_notifier.cr @@ -56,8 +56,8 @@ module CRE::Notifiers when Events::AlertRaised case ev.severity in Events::Severity::Critical then Log.error &.emit("alert", text: ev.message) - in Events::Severity::Warn then Log.warn &.emit("alert", text: ev.message) - in Events::Severity::Info then Log.info &.emit("alert", text: ev.message) + in Events::Severity::Warn then Log.warn &.emit("alert", text: ev.message) + in Events::Severity::Info then Log.info &.emit("alert", text: ev.message) end end end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/notifiers/telegram.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/notifiers/telegram.cr index df29ad4e..b8628a15 100644 --- a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/notifiers/telegram.cr +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/notifiers/telegram.cr @@ -12,7 +12,7 @@ module CRE::Notifiers # directly with HTTP::Client; no tourmaline dependency for the notification # path keeps the footprint small. class Telegram - Log = ::Log.for("cre.telegram") + Log = ::Log.for("cre.telegram") DEFAULT_API = "https://api.telegram.org" class TelegramError < Exception diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/notifiers/telegram_bot.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/notifiers/telegram_bot.cr index e3088169..f59767e6 100644 --- a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/notifiers/telegram_bot.cr +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/notifiers/telegram_bot.cr @@ -63,13 +63,13 @@ module CRE::Notifiers return "unauthorized" unless authorized_viewer?(chat_id) cmd, _, rest = text.strip.lstrip('/').partition(' ') case cmd - when "status" then status_message - when "queue" then queue_message - when "alerts" then alerts_message + when "status" then status_message + when "queue" then queue_message + when "alerts" then alerts_message when "help", "" then help_message - when "rotate" then handle_rotate(chat_id, rest) - when "snooze" then handle_snooze(chat_id, rest) - when "history" then history_message(rest) + when "rotate" then handle_rotate(chat_id, rest) + when "snooze" then handle_snooze(chat_id, rest) + when "history" then history_message(rest) else "unknown command: /#{cmd} (try /help)" end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/vault_dynamic.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/vault_dynamic.cr index 9648eb21..19ad4815 100644 --- a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/vault_dynamic.cr +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/vault_dynamic.cr @@ -43,10 +43,10 @@ module CRE::Rotators Domain::NewSecret.new( ciphertext: payload.to_slice, metadata: { - "lease_id" => ds.lease_id, - "lease_duration" => ds.lease_duration.to_s, - "old_lease_id" => c.tag("current_lease_id") || "", - "username" => ds.username, + "lease_id" => ds.lease_id, + "lease_duration" => ds.lease_duration.to_s, + "old_lease_id" => c.tag("current_lease_id") || "", + "username" => ds.username, }, ) end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/tui/ansi.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/tui/ansi.cr index fa71dc58..3577f836 100644 --- a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/tui/ansi.cr +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/tui/ansi.cr @@ -17,8 +17,8 @@ module CRE::Tui HOME = "#{ESC}H" RESET = "#{ESC}0m" - BOLD = "#{ESC}1m" - DIM = "#{ESC}2m" + BOLD = "#{ESC}1m" + DIM = "#{ESC}2m" FG_RED = "#{ESC}31m" FG_GREEN = "#{ESC}32m" @@ -36,13 +36,33 @@ module CRE::Tui "#{color}#{text}#{RESET}" end - def self.green(text : String) : String ; colorize(text, FG_GREEN) ; end - def self.red(text : String) : String ; colorize(text, FG_RED) ; end - def self.yellow(text : String) : String ; colorize(text, FG_YELLOW) ; end - def self.cyan(text : String) : String ; colorize(text, FG_CYAN) ; end - def self.gray(text : String) : String ; colorize(text, FG_GRAY) ; end - def self.bold(text : String) : String ; colorize(text, BOLD) ; end - def self.dim(text : String) : String ; colorize(text, DIM) ; end + def self.green(text : String) : String + colorize(text, FG_GREEN) + end + + def self.red(text : String) : String + colorize(text, FG_RED) + end + + def self.yellow(text : String) : String + colorize(text, FG_YELLOW) + end + + def self.cyan(text : String) : String + colorize(text, FG_CYAN) + end + + def self.gray(text : String) : String + colorize(text, FG_GRAY) + end + + def self.bold(text : String) : String + colorize(text, BOLD) + end + + def self.dim(text : String) : String + colorize(text, DIM) + end # Strip ANSI escape sequences (useful for testing rendered output). def self.strip(text : String) : String