guide: read-only guided manual-rotation layer (links + worklist + TUI)

Advances the guided-rotation goal without rotating anything.

- internal/links: offline change-password URL generation — curated per-service
  table + RFC 8615 /.well-known/change-password + parent-domain fallback; derives
  a web host from a credential (git/netrc "user @ host", docker registry, aws)
- internal/worklist: secrets-free rotation worklist (source, identity, auto/manual,
  link) + Markdown renderer
- internal/tui: Bubble Tea walk-through (one credential per screen, open link,
  mark done/skip, progress) — READ-ONLY, enters/stores no secret
- cmd: `incredigo worklist` (offline, stdout/--out) and `incredigo guide` (TUI)
- tests: link derivation, worklist build/markdown + no-secret-column, TUI
  navigation/done/quit/view

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-15 14:58:23 -07:00
parent 6c2bcebc12
commit 71a74e7166
10 changed files with 665 additions and 5 deletions
+8 -4
View File
@@ -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)
+59 -1
View File
@@ -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) {
+18
View File
@@ -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
)
+41
View File
@@ -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=
+116
View File
@@ -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
}
+56
View File
@@ -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)
}
}
+150
View File
@@ -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
}
}
+97
View File
@@ -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)
}
}
+65
View File
@@ -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()
}
+55
View File
@@ -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")
}
}