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:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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