diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/config/demo-full.cr.example b/PROJECTS/intermediate/credential-rotation-enforcer/config/demo-full.cr.example new file mode 100644 index 00000000..44bca6da --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/config/demo-full.cr.example @@ -0,0 +1,23 @@ +# ©AngelaMos | 2026 +# demo-full.cr.example + +# Tier 2 demo config: docker-compose stack on localhost. +# Copy to demo-full.cr and load via `cre run --config=...` (full config loader +# wired in a future iteration). + +# Database: +# DATABASE_URL=postgres://cre:cre@localhost:5432/cre + +# AWS Secrets Manager (LocalStack): +# AWS_ACCESS_KEY_ID=test +# AWS_SECRET_ACCESS_KEY=test +# AWS_REGION=us-east-1 +# AWS_ENDPOINT=http://localhost:4566 + +# Vault: +# VAULT_ADDR=http://localhost:8200 +# VAULT_TOKEN=dev-root-token + +# GitHub (fake API): +# GITHUB_API_BASE=http://localhost:7000 +# GITHUB_TOKEN=ghp_admin_fake diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/docker/docker-compose.yml b/PROJECTS/intermediate/credential-rotation-enforcer/docker/docker-compose.yml new file mode 100644 index 00000000..0f384a56 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/docker/docker-compose.yml @@ -0,0 +1,39 @@ +# ©AngelaMos | 2026 +# docker-compose.yml + +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: cre + POSTGRES_PASSWORD: cre + POSTGRES_DB: cre + ports: ["5432:5432"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U cre"] + interval: 3s + timeout: 3s + retries: 10 + + localstack: + image: localstack/localstack:latest + environment: + SERVICES: secretsmanager + DEBUG: 0 + ports: ["4566:4566"] + + vault: + image: hashicorp/vault:latest + cap_add: [IPC_LOCK] + environment: + VAULT_DEV_ROOT_TOKEN_ID: dev-root-token + VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200 + ports: ["8200:8200"] + command: vault server -dev + + fake-github: + image: python:3.13-alpine + working_dir: /app + volumes: ["./fake-github:/app:ro"] + command: ["sh", "-c", "pip install --quiet flask && python /app/app.py"] + ports: ["7000:7000"] diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/docker/fake-github/app.py b/PROJECTS/intermediate/credential-rotation-enforcer/docker/fake-github/app.py new file mode 100644 index 00000000..4461348d --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/docker/fake-github/app.py @@ -0,0 +1,42 @@ +""" +©AngelaMos | 2026 +app.py +""" + +from flask import Flask, jsonify, request + +app = Flask(__name__) +TOKENS: dict[int, dict] = {} +NEXT_ID = 100000 + + +@app.route("/user", methods=["GET"]) +def user(): + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + return jsonify({"message": "Bad credentials"}), 401 + return jsonify({"login": "fake-bot", "id": 1}) + + +@app.route("/user/personal-access-tokens", methods=["POST"]) +def create_pat(): + global NEXT_ID + body = request.get_json() or {} + pat_id = NEXT_ID + NEXT_ID += 1 + TOKENS[pat_id] = body + return jsonify({ + "id": pat_id, + "token": f"ghp_fake_{pat_id}", + "expires_at": "2026-12-31T00:00:00Z", + }) + + +@app.route("/user/personal-access-tokens/", methods=["DELETE"]) +def delete_pat(pat_id): + TOKENS.pop(pat_id, None) + return "", 200 + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=7000) diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/integration/demo/tier_1_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/integration/demo/tier_1_spec.cr new file mode 100644 index 00000000..9e472330 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/integration/demo/tier_1_spec.cr @@ -0,0 +1,22 @@ +# =================== +# ©AngelaMos | 2026 +# tier_1_spec.cr +# =================== + +require "../../spec_helper" +require "../../../src/cre/demo/tier_1" + +describe CRE::Demo::Tier1 do + it "runs end-to-end and reports rotation success" do + io = IO::Memory.new + code = CRE::Demo::Tier1.run(io) + code.should eq 0 + + out = CRE::Tui::Ansi.strip(io.to_s) + out.should contain "Tier 1 demo" + out.should contain "BEFORE" + out.should contain "AFTER" + out.should contain "rotation completed" + out.should contain "audit events, hash chain valid" + 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 index 35e86ae5..22475c05 100644 --- a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/demo/tier_1.cr +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/demo/tier_1.cr @@ -3,14 +3,115 @@ # tier_1.cr # =================== +require "../engine/event_bus" +require "../engine/rotation_orchestrator" +require "../persistence/sqlite/sqlite_persistence" +require "../rotators/env_file" +require "../audit/audit_log" +require "../tui/ansi" + 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 + tmp_env = File.tempname("cre-demo-", ".env") + File.write(tmp_env, "API_KEY=oldvalue-aaa\nOTHER=keep\n") + + io.puts CRE::Tui::Ansi.cyan("Credential Rotation Enforcer - Tier 1 demo") + io.puts " (in-memory SQLite + ephemeral .env file rotator, zero external deps)" + io.puts "" + + persist = CRE::Persistence::Sqlite::SqlitePersistence.new(":memory:") + persist.migrate! + log = CRE::Audit::AuditLog.new(persist, Bytes.new(32, 0_u8), 1, 1024) + + cred_id = UUID.random + cred = CRE::Domain::Credential.new( + id: cred_id, + external_id: "demo-#{tmp_env}", + kind: CRE::Domain::CredentialKind::EnvFile, + name: "API_KEY", + tags: { + "path" => tmp_env, + "key" => "API_KEY", + "bytes" => "16", + } of String => String, + updated_at: Time.utc - 60.days, + ) + persist.credentials.insert(cred) + + io.puts CRE::Tui::Ansi.bold("Step 1 - Inventory:") + io.puts " - #{cred.kind} '#{cred.name}' (id=#{short(cred.id)})" + io.puts " last updated #{cred.updated_at.to_rfc3339} (60 days ago - overdue)" + io.puts "" + + io.puts CRE::Tui::Ansi.bold("Step 2 - File contents BEFORE:") + File.read(tmp_env).each_line { |line| io.puts " #{line}" } + io.puts "" + + io.puts CRE::Tui::Ansi.bold("Step 3 - Rotating (4-step contract):") + bus = CRE::Engine::EventBus.new + ch = bus.subscribe(buffer: 256) + bus.run + + rotator = CRE::Rotators::EnvFileRotator.new + orchestrator = CRE::Engine::RotationOrchestrator.new(bus, persist) + state = orchestrator.run(cred, rotator) + + sleep 0.05.seconds + drain_steps(ch, io) + + bus.stop + + io.puts "" + io.puts CRE::Tui::Ansi.bold("Step 4 - File contents AFTER:") + File.read(tmp_env).each_line { |line| io.puts " #{line}" } + io.puts "" + + io.puts CRE::Tui::Ansi.bold("Step 5 - Audit chain verification:") + ok = log.verify_chain + latest_seq = persist.audit.latest_seq + if ok + io.puts " #{CRE::Tui::Ansi.green("✓")} #{latest_seq} audit events, hash chain valid" + else + io.puts " #{CRE::Tui::Ansi.red("✗")} audit chain BROKEN" + end + + persist.close + File.delete(tmp_env) if File.exists?(tmp_env) + + io.puts "" + io.puts CRE::Tui::Ansi.dim("Demo complete. State #{state}. Try 'cre run --db=sqlite:cre.db' for the daemon.") + state == CRE::Persistence::RotationState::Completed ? 0 : 1 + end + + private def self.drain_steps(ch : ::Channel(CRE::Events::Event), io : IO) : Nil + loop do + select + when ev = ch.receive + narrate(ev, io) + else + break + end + end + end + + private def self.narrate(ev : CRE::Events::Event, io : IO) : Nil + case ev + when CRE::Events::RotationStepStarted + io.puts " - step started: #{ev.step}" + when CRE::Events::RotationStepCompleted + io.puts " #{CRE::Tui::Ansi.green("✓")} step completed: #{ev.step}" + when CRE::Events::RotationStepFailed + io.puts " #{CRE::Tui::Ansi.red("✗")} step failed: #{ev.step} (#{ev.error})" + when CRE::Events::RotationCompleted + io.puts " #{CRE::Tui::Ansi.green("✓")} rotation completed" + when CRE::Events::RotationFailed + io.puts " #{CRE::Tui::Ansi.red("✗")} rotation failed: #{ev.reason}" + end + end + + private def self.short(uuid : UUID) : String + uuid.to_s[0, 8] end end end diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/env_file.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/env_file.cr index 201a6a3d..e0c28410 100644 --- a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/env_file.cr +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/env_file.cr @@ -44,11 +44,11 @@ module CRE::Rotators pending_path = "#{path}.pending" existing = File.exists?(path) ? File.read(path) : "" - lines = existing.lines.reject { |line| line.strip.starts_with?("#{key}=") } + lines = existing.lines(chomp: true).reject { |line| line.strip.starts_with?("#{key}=") } new_value = String.new(s.ciphertext) - lines << "#{key}=#{new_value}\n" + lines << "#{key}=#{new_value}" - File.write(pending_path, lines.join, perm: 0o600) + File.write(pending_path, lines.join('\n') + "\n", perm: 0o600) end def verify(c : Domain::Credential, s : Domain::NewSecret) : Bool