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
+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")
}
}