diff --git a/CLAUDE.md b/CLAUDE.md index ee183e4..b0497e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,10 +23,14 @@ credentials that legitimately require a human. See `docs/ROTATION.md` Part II. - **v1 shipped:** discover → migrate → expiry → sealed export/import. 8 scanners (aws, env, netrc, ssh, docker, kube, git, file/`--path`); sealers age (default), hmac, openssl. Tests green, `-race` clean. -- **Rotation: design + safety-spine only.** `internal/rotate` has the `Rotator` - interface, the **mandatory backup gate** (`Snapshot`), and a dry-run `incredigo - rotate`. **Zero rotation drivers exist; `--execute` is refused.** Nothing changes a - credential at any service yet — keep it that way until explicitly authorized. +- **Rotation: design + safety-spine + guided manual layer.** `internal/rotate` has + the `Rotator` interface and the **mandatory backup gate** (`Snapshot`); dry-run + `incredigo rotate` refuses `--execute`. The guided layer is built and **read-only**: + `internal/links` (offline change-password URL generation: curated table + RFC 8615 + well-known + host fallback), `internal/worklist` (secrets-free worklist), and + `internal/tui` (Bubble Tea walk-through) — surfaced as `incredigo worklist` and + `incredigo guide`. **Zero rotation drivers exist; nothing changes a credential at + any service yet** — keep it that way until explicitly authorized. ## Hard rules (do not violate — this tool holds real credentials) diff --git a/cmd/incredigo/main.go b/cmd/incredigo/main.go index b6437f4..cf8e727 100644 --- a/cmd/incredigo/main.go +++ b/cmd/incredigo/main.go @@ -23,7 +23,9 @@ import ( "incredigo/internal/policy" "incredigo/internal/rotate" "incredigo/internal/sink" + "incredigo/internal/tui" "incredigo/internal/vault" + "incredigo/internal/worklist" ) var ( @@ -51,7 +53,7 @@ func main() { root.PersistentFlags().StringVar(&flagConfig, "config", "", "policy.yaml path") root.PersistentFlags().StringVar(&flagSealer, "sealer", "age", "backup sealer: age (default, authenticated), hmac (authenticated, openssl-only), or openssl (unauthenticated)") - root.AddCommand(scanCmd(), migrateCmd(), statusCmd(), exportCmd(), importCmd(), rotateCmd()) + root.AddCommand(scanCmd(), migrateCmd(), statusCmd(), exportCmd(), importCmd(), rotateCmd(), worklistCmd(), guideCmd()) if err := root.Execute(); err != nil { fmt.Fprintln(os.Stderr, "incredigo:", err) @@ -409,6 +411,62 @@ func rotateCmd() *cobra.Command { return c } +// worklistCmd emits a rotation worklist (links per credential, no secrets). It is +// read-only, offline, and rotates nothing. +func worklistCmd() *cobra.Command { + var out string + c := &cobra.Command{ + Use: "worklist", + Short: "Generate a rotation worklist with per-credential change-password links (no secrets)", + RunE: func(cmd *cobra.Command, args []string) error { + applyPaths() + return withVault(func(ctx context.Context, v *vault.Vault) error { + creds, err := discover.ScanAll(ctx, v, flagSources...) + if err != nil { + return err + } + md := worklist.Markdown(worklist.Build(creds)) + if out == "" { + fmt.Print(md) + return nil + } + if err := os.WriteFile(out, []byte(md), 0o644); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "wrote worklist (%d credential(s)) -> %s\n", len(creds), out) + return nil + }) + }, + } + c.Flags().StringVar(&out, "out", "", "write the worklist to this file (default: stdout)") + c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to include") + c.Flags().StringSliceVar(&flagSources, "source", nil, "limit discovery to these scanners") + return c +} + +// guideCmd launches the interactive guided-rotation walk-through (TUI). It is +// read-only guidance: it shows each credential and its rotation link and can open +// it, but enters/stores/rotates no secret. +func guideCmd() *cobra.Command { + c := &cobra.Command{ + Use: "guide", + Short: "Interactive walk-through of credentials to rotate manually (rotates nothing)", + RunE: func(cmd *cobra.Command, args []string) error { + applyPaths() + return withVault(func(ctx context.Context, v *vault.Vault) error { + creds, err := discover.ScanAll(ctx, v, flagSources...) + if err != nil { + return err + } + return tui.Run(worklist.Build(creds)) + }) + }, + } + c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to include") + c.Flags().StringSliceVar(&flagSources, "source", nil, "limit discovery to these scanners") + return c +} + // sealerFor selects the backup sealer. age is the authenticated default; openssl // is an unauthenticated fallback (see internal/sink/openssl.go). func sealerFor(name string) (sink.Sealer, error) { diff --git a/go.mod b/go.mod index d6c9cd3..cfe5963 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.25.0 require ( filippo.io/age v1.3.1 github.com/awnumar/memguard v0.22.5 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.8.1 golang.org/x/term v0.37.0 gopkg.in/yaml.v3 v3.0.1 @@ -13,8 +15,24 @@ require ( require ( filippo.io/hpke v0.4.0 // indirect github.com/awnumar/memcall v0.2.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index f8e7cd4..c570e86 100644 --- a/go.sum +++ b/go.sum @@ -8,21 +8,62 @@ github.com/awnumar/memcall v0.2.0 h1:sRaogqExTOOkkNwO9pzJsL8jrOV29UuUW7teRMfbqtI github.com/awnumar/memcall v0.2.0/go.mod h1:S911igBPR9CThzd/hYQQmTc9SWNu3ZHIlCGaWsWsoJo= github.com/awnumar/memguard v0.22.5 h1:PH7sbUVERS5DdXh3+mLo8FDcl1eIeVjJVYMnyuYpvuI= github.com/awnumar/memguard v0.22.5/go.mod h1:+APmZGThMBWjnMlKiSM1X7MVpbIVewen2MTkqWkA/zE= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/links/links.go b/internal/links/links.go new file mode 100644 index 0000000..091bb55 --- /dev/null +++ b/internal/links/links.go @@ -0,0 +1,116 @@ +// Package links derives the web page a user must visit to rotate a credential by +// hand. It is OFFLINE and pure — it never makes a network call. A URL comes from +// one of three sources, in order of quality: a curated per-service table, the +// RFC 8615 `/.well-known/change-password` convention (which sites redirect to +// their real change-password page), or nothing when no web host can be derived. +package links + +import ( + "strings" + + "incredigo/internal/discover" +) + +type Source string + +const ( + SourceKnown Source = "known" // curated credential/change-password page + SourceWellKnown Source = "well-known" // RFC 8615 /.well-known/change-password + SourceNone Source = "none" // no web host could be derived +) + +// Link is a generated rotation URL plus how it was derived. +type Link struct { + URL string + Source Source +} + +// curated maps a host to the exact page where its credential is rotated. +var curated = map[string]string{ + "github.com": "https://github.com/settings/tokens", + "ghcr.io": "https://github.com/settings/tokens", + "gitlab.com": "https://gitlab.com/-/user_settings/personal_access_tokens", + "gitea.com": "https://gitea.com/user/settings/applications", + "pypi.org": "https://pypi.org/manage/account/token/", + "npmjs.com": "https://www.npmjs.com/settings/~/tokens", + "registry.npmjs.org": "https://www.npmjs.com/settings/~/tokens", + "hub.docker.com": "https://hub.docker.com/settings/security", + "docker.io": "https://hub.docker.com/settings/security", + "index.docker.io": "https://hub.docker.com/settings/security", + "digitalocean.com": "https://cloud.digitalocean.com/account/api/tokens", + "cloudflare.com": "https://dash.cloudflare.com/profile/api-tokens", + "stripe.com": "https://dashboard.stripe.com/apikeys", + "openai.com": "https://platform.openai.com/api-keys", + "anthropic.com": "https://console.anthropic.com/settings/keys", + "sendgrid.com": "https://app.sendgrid.com/settings/api_keys", + "amazonaws.com": "https://console.aws.amazon.com/iam/home#/security_credentials", + "console.aws.amazon.com": "https://console.aws.amazon.com/iam/home#/security_credentials", + "heroku.com": "https://dashboard.heroku.com/account/applications", +} + +// serviceByScanner supplies a host/service when the credential carries no usable +// host of its own (e.g. an AWS key from ~/.aws/credentials). +var serviceByScanner = map[string]string{ + "aws": "console.aws.amazon.com", +} + +// For returns the best rotation URL for a host. +func For(host string) Link { + h := normalize(host) + if h == "" { + return Link{Source: SourceNone} + } + if u, ok := curated[h]; ok { + return Link{URL: u, Source: SourceKnown} + } + if u, ok := curated[parentDomain(h)]; ok { + return Link{URL: u, Source: SourceKnown} + } + return Link{URL: "https://" + h + "/.well-known/change-password", Source: SourceWellKnown} +} + +// HostFor derives a web host from a discovered credential, or "" if none applies +// (e.g. ssh keys and opaque file/env secrets have no rotation page). +func HostFor(c discover.Credential) string { + // "user @ host" identities (git, netrc). + if i := strings.LastIndex(c.Identity, " @ "); i >= 0 { + if host := strings.TrimSpace(c.Identity[i+3:]); host != "" && host != "default" { + return host + } + } + // docker identity is the registry host. + if c.Source == "docker" && c.Identity != "" { + return c.Identity + } + if s, ok := serviceByScanner[c.Source]; ok { + return s + } + return "" +} + +// LinkFor is HostFor composed with For. +func LinkFor(c discover.Credential) Link { return For(HostFor(c)) } + +func normalize(host string) string { + h := strings.ToLower(strings.TrimSpace(host)) + h = strings.TrimPrefix(h, "https://") + h = strings.TrimPrefix(h, "http://") + if i := strings.IndexByte(h, '/'); i >= 0 { + h = h[:i] + } + if i := strings.IndexByte(h, ':'); i >= 0 { + h = h[:i] // strip port + } + return h +} + +// parentDomain returns the registrable-ish parent ("api.github.com" -> "github.com") +// for a one-level curated fallback. Good enough for the common multi-part TLD-free +// cases we curate; the well-known fallback covers everything else. +func parentDomain(h string) string { + parts := strings.Split(h, ".") + if len(parts) >= 2 { + return strings.Join(parts[len(parts)-2:], ".") + } + return h +} diff --git a/internal/links/links_test.go b/internal/links/links_test.go new file mode 100644 index 0000000..e89a9e7 --- /dev/null +++ b/internal/links/links_test.go @@ -0,0 +1,56 @@ +package links + +import ( + "testing" + + "incredigo/internal/discover" +) + +func TestFor(t *testing.T) { + cases := []struct { + host string + wantURL string + wantSrc Source + }{ + {"github.com", "https://github.com/settings/tokens", SourceKnown}, + {"GitHub.com:443", "https://github.com/settings/tokens", SourceKnown}, + {"api.github.com", "https://github.com/settings/tokens", SourceKnown}, // parent-domain fallback + {"s3.amazonaws.com", "https://console.aws.amazon.com/iam/home#/security_credentials", SourceKnown}, + {"example.com", "https://example.com/.well-known/change-password", SourceWellKnown}, + {"registry.internal.corp", "https://registry.internal.corp/.well-known/change-password", SourceWellKnown}, + {"", "", SourceNone}, + } + for _, c := range cases { + got := For(c.host) + if got.URL != c.wantURL || got.Source != c.wantSrc { + t.Errorf("For(%q) = {%q,%s}, want {%q,%s}", c.host, got.URL, got.Source, c.wantURL, c.wantSrc) + } + } +} + +func TestHostFor(t *testing.T) { + cases := []struct { + cred discover.Credential + want string + }{ + {discover.Credential{Source: "git", Identity: "alice @ github.com"}, "github.com"}, + {discover.Credential{Source: "netrc", Identity: "bob @ api.example.com"}, "api.example.com"}, + {discover.Credential{Source: "netrc", Identity: "bob @ default"}, ""}, // netrc "default" is not a host + {discover.Credential{Source: "docker", Identity: "ghcr.io"}, "ghcr.io"}, + {discover.Credential{Source: "aws", Identity: "default / AKIA***"}, "console.aws.amazon.com"}, + {discover.Credential{Source: "ssh", Identity: "id_ed25519"}, ""}, + {discover.Credential{Source: "file", Identity: "keys/deploy"}, ""}, + } + for _, c := range cases { + if got := HostFor(c.cred); got != c.want { + t.Errorf("HostFor(%s/%q) = %q, want %q", c.cred.Source, c.cred.Identity, got, c.want) + } + } +} + +func TestLinkForGit(t *testing.T) { + l := LinkFor(discover.Credential{Source: "git", Identity: "alice @ github.com"}) + if l.Source != SourceKnown || l.URL != "https://github.com/settings/tokens" { + t.Errorf("LinkFor git github = {%q,%s}", l.URL, l.Source) + } +} diff --git a/internal/tui/guide.go b/internal/tui/guide.go new file mode 100644 index 0000000..3a64b89 --- /dev/null +++ b/internal/tui/guide.go @@ -0,0 +1,150 @@ +// Package tui is the interactive guided-rotation walk-through (Charm Bubble Tea). +// +// It is READ-ONLY guidance: it steps through the rotation worklist one credential +// at a time, generates the change-password link, and can open it — it does NOT +// enter, store, or rotate any secret. Capturing a rotated value (and the backup +// gate that must precede it) is a later, separately-gated step. +package tui + +import ( + "fmt" + "os/exec" + "runtime" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "incredigo/internal/worklist" +) + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + autoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + manualStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) + doneStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) + linkStyle = lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("39")) + keyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("white")).Background(lipgloss.Color("238")).Padding(0, 1) +) + +// Model is the guided-rotation walk-through state. Exported for testing. +type Model struct { + items []worklist.Item + cur int + done map[int]bool + quit bool +} + +// New builds a guide model over a worklist. +func New(items []worklist.Item) Model { + return Model{items: items, done: map[int]bool{}} +} + +func (m Model) Init() tea.Cmd { return nil } + +// Update is the Bubble Tea event loop. Exported indirectly via tea; the key +// handling here is what the tests exercise. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch key.String() { + case "q", "ctrl+c", "esc": + m.quit = true + return m, tea.Quit + case "j", "down", "n", "tab": + if m.cur < len(m.items)-1 { + m.cur++ + } + case "k", "up", "p", "shift+tab": + if m.cur > 0 { + m.cur-- + } + case "o": + if u := m.items[m.cur].Link.URL; u != "" { + return m, openURL(u) + } + case "enter", " ": + m.done[m.cur] = true + if m.cur < len(m.items)-1 { + m.cur++ + } + case "s": + if m.cur < len(m.items)-1 { + m.cur++ + } + } + return m, nil +} + +func (m Model) View() string { + if len(m.items) == 0 { + return "No credentials to rotate.\n" + } + it := m.items[m.cur] + + rot := manualStyle.Render("manual — no API; rotate on the site") + if !it.Manual() { + rot = autoStyle.Render("auto: " + it.Driver) + } + status := "" + if m.done[m.cur] { + status = " " + doneStyle.Render("✓ done") + } + + link := dimStyle.Render("— no web page for this credential") + if it.Link.URL != "" { + link = linkStyle.Render(it.Link.URL) + dimStyle.Render(" ("+string(it.Link.Source)+")") + } + + header := titleStyle.Render(fmt.Sprintf("incredigo · guided rotation — %d of %d", m.cur+1, len(m.items))) + + dimStyle.Render(fmt.Sprintf(" (%d marked done)", len(m.done))) + + body := fmt.Sprintf( + "\n%s\n\n %s %s%s\n %s\n\n 1. Open the rotation page:\n %s\n 2. Change the credential on the site, then come back.\n\n", + header, + titleStyle.Render(it.Credential.Source+" · "+it.Credential.Identity), + rot, status, + dimStyle.Render("kind: "+string(it.Credential.Kind)), + link, + ) + + hints := dimStyle.Render(" ") + + keyStyle.Render("o") + dimStyle.Render(" open ") + + keyStyle.Render("enter") + dimStyle.Render(" mark done & next ") + + keyStyle.Render("n/p") + dimStyle.Render(" next/prev ") + + keyStyle.Render("s") + dimStyle.Render(" skip ") + + keyStyle.Render("q") + dimStyle.Render(" quit") + + "\n\n" + dimStyle.Render(" Read-only: incredigo enters/stores no secret here.") + + return body + hints + "\n" +} + +// Run launches the interactive guide. Requires a TTY; for headless use, generate a +// worklist file instead (incredigo worklist). +func Run(items []worklist.Item) error { + if len(items) == 0 { + fmt.Println("No credentials discovered — nothing to rotate.") + return nil + } + _, err := tea.NewProgram(New(items)).Run() + return err +} + +// openURL opens a link in the user's browser, best-effort. +func openURL(url string) tea.Cmd { + return func() tea.Msg { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + cmd = exec.Command("xdg-open", url) + } + _ = cmd.Start() // best-effort; ignore on headless/no-DE + return nil + } +} diff --git a/internal/tui/guide_test.go b/internal/tui/guide_test.go new file mode 100644 index 0000000..d4478db --- /dev/null +++ b/internal/tui/guide_test.go @@ -0,0 +1,97 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "incredigo/internal/discover" + "incredigo/internal/worklist" +) + +func key(s string) tea.KeyMsg { + switch s { + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "q": + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")} + default: + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + } +} + +func sampleModel() Model { + items := worklist.Build([]discover.Credential{ + {Source: "git", Identity: "alice @ github.com", Kind: discover.KindToken}, + {Source: "ssh", Identity: "id_ed25519", Kind: discover.KindPrivateKey}, + }) + return New(items) +} + +func TestNavigationAndDone(t *testing.T) { + m := sampleModel() + if m.cur != 0 { + t.Fatalf("start cur=%d", m.cur) + } + + // next + nm, _ := m.Update(key("n")) + m = nm.(Model) + if m.cur != 1 { + t.Errorf("after n, cur=%d want 1", m.cur) + } + // can't go past end + nm, _ = m.Update(key("n")) + m = nm.(Model) + if m.cur != 1 { + t.Errorf("n past end moved to %d", m.cur) + } + // prev + nm, _ = m.Update(key("p")) + m = nm.(Model) + if m.cur != 0 { + t.Errorf("after p, cur=%d want 0", m.cur) + } + // enter marks done and advances + nm, _ = m.Update(key("enter")) + m = nm.(Model) + if !m.done[0] { + t.Error("enter did not mark item 0 done") + } + if m.cur != 1 { + t.Errorf("enter did not advance, cur=%d", m.cur) + } +} + +func TestQuit(t *testing.T) { + m := sampleModel() + nm, cmd := m.Update(key("q")) + m = nm.(Model) + if !m.quit { + t.Error("q did not set quit") + } + if cmd == nil { + t.Error("q should return a quit command") + } +} + +func TestViewContent(t *testing.T) { + m := sampleModel() + v := m.View() + if !strings.Contains(v, "github.com") { + t.Errorf("view missing identity/link:\n%s", v) + } + if !strings.Contains(v, "1 of 2") { + t.Errorf("view missing progress:\n%s", v) + } + if !strings.Contains(v, "no secret") { + t.Errorf("view missing read-only reassurance:\n%s", v) + } +} + +func TestEmptyModelView(t *testing.T) { + if got := New(nil).View(); !strings.Contains(got, "No credentials") { + t.Errorf("empty view = %q", got) + } +} diff --git a/internal/worklist/worklist.go b/internal/worklist/worklist.go new file mode 100644 index 0000000..6569cfc --- /dev/null +++ b/internal/worklist/worklist.go @@ -0,0 +1,65 @@ +// Package worklist turns discovered credentials into a rotation worklist: per +// credential, whether it can be auto-rotated (a driver exists) or is manual, and +// the page to rotate it on. It contains NO secrets — only non-secret metadata and +// links — so a worklist is safe to write to disk or print. +package worklist + +import ( + "fmt" + "strings" + + "incredigo/internal/discover" + "incredigo/internal/links" + "incredigo/internal/rotate" +) + +// Item is one credential's rotation entry. +type Item struct { + Index int + Credential discover.Credential + Driver string // "" when no rotation driver handles it (manual) + Link links.Link +} + +// Manual reports whether this credential has no automated rotation path today. +func (it Item) Manual() bool { return it.Driver == "" } + +// Build classifies credentials (via the rotate registry) and attaches links. +// With no drivers registered every Item is Manual. +func Build(creds []discover.Credential) []Item { + plans := rotate.PlanAll(creds) + items := make([]Item, len(plans)) + for i, p := range plans { + items[i] = Item{ + Index: i + 1, + Credential: p.Credential, + Driver: p.Driver, + Link: links.LinkFor(p.Credential), + } + } + return items +} + +// Markdown renders a worklist as a Markdown document (no secrets). +func Markdown(items []Item) string { + var b strings.Builder + b.WriteString("# incredigo rotation worklist\n\n") + fmt.Fprintf(&b, "%d credential(s). No secrets are included in this file.\n\n", len(items)) + b.WriteString("| # | source | identity | rotation | change-password link |\n") + b.WriteString("|---|--------|----------|----------|----------------------|\n") + for _, it := range items { + rot := "manual" + if !it.Manual() { + rot = "auto: " + it.Driver + } + url := it.Link.URL + if url == "" { + url = "— (no web page)" + } + fmt.Fprintf(&b, "| %d | %s | %s | %s | %s |\n", + it.Index, it.Credential.Source, it.Credential.Identity, rot, url) + } + b.WriteString("\nGenerated by incredigo. Links are derived offline ") + b.WriteString("(curated table or RFC 8615 /.well-known/change-password).\n") + return b.String() +} diff --git a/internal/worklist/worklist_test.go b/internal/worklist/worklist_test.go new file mode 100644 index 0000000..921500f --- /dev/null +++ b/internal/worklist/worklist_test.go @@ -0,0 +1,55 @@ +package worklist + +import ( + "strings" + "testing" + + "incredigo/internal/discover" +) + +func TestBuildAndMarkdown(t *testing.T) { + creds := []discover.Credential{ + {Source: "git", Identity: "alice @ github.com", Kind: discover.KindToken}, + {Source: "ssh", Identity: "id_ed25519", Kind: discover.KindPrivateKey}, + {Source: "docker", Identity: "ghcr.io", Kind: discover.KindPassword}, + } + items := Build(creds) + if len(items) != 3 { + t.Fatalf("got %d items, want 3", len(items)) + } + // No drivers are registered yet, so everything is manual. + for _, it := range items { + if !it.Manual() { + t.Errorf("item %q unexpectedly has driver %q", it.Credential.Identity, it.Driver) + } + } + // github link is curated; ssh has no web page. + if items[0].Link.URL != "https://github.com/settings/tokens" { + t.Errorf("git link = %q", items[0].Link.URL) + } + if items[1].Link.URL != "" { + t.Errorf("ssh should have no link, got %q", items[1].Link.URL) + } + + md := Markdown(items) + for _, want := range []string{ + "# incredigo rotation worklist", + "3 credential(s)", + "https://github.com/settings/tokens", + "alice @ github.com", + "— (no web page)", // ssh row + } { + if !strings.Contains(md, want) { + t.Errorf("markdown missing %q\n---\n%s", want, md) + } + } +} + +func TestMarkdownHasNoSecrets(t *testing.T) { + // Identities are pre-redacted; assert the renderer never invents a secret column. + items := Build([]discover.Credential{{Source: "aws", Identity: "default / AKIA***"}}) + md := Markdown(items) + if strings.Contains(strings.ToLower(md), "password |") && strings.Contains(md, "secret value") { + t.Error("worklist must not contain a secret-value column") + } +}