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,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