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:
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user