Redact credentials throughout publication recovery displays
This commit is contained in:
@@ -108,7 +108,7 @@ func reportPublicationRecord(result releasekit.PublicationResult, publicationErr
|
|||||||
if result.RecordPath == "" {
|
if result.RecordPath == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stderr, "publication record saved: %s\n", result.RecordPath)
|
fmt.Fprintf(stderr, "publication record saved: %s\n", git.DisplayDiagnostic(result.RecordPath))
|
||||||
if publicationErr == nil || !publicationOffersRecovery(result.Phase) {
|
if publicationErr == nil || !publicationOffersRecovery(result.Phase) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ func reportPublicationRecord(result releasekit.PublicationResult, publicationErr
|
|||||||
fmt.Fprintln(stderr, "Use your selected supported ReleaseKit CLI with this record if the original executable is unavailable.")
|
fmt.Fprintln(stderr, "Use your selected supported ReleaseKit CLI with this record if the original executable is unavailable.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stderr, "recovery command: %s\n", command)
|
fmt.Fprintf(stderr, "recovery command: %s\n", git.DisplayDiagnostic(command))
|
||||||
fmt.Fprintln(stderr, "If this executable is unavailable, use your selected supported ReleaseKit CLI with the same record.")
|
fmt.Fprintln(stderr, "If this executable is unavailable, use your selected supported ReleaseKit CLI with the same record.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +140,8 @@ func publicationRecoveryCommand(recordPath string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("resolve current executable path: %w", err)
|
return "", fmt.Errorf("resolve current executable path: %w", err)
|
||||||
}
|
}
|
||||||
recordPath, err = filepath.Abs(recordPath)
|
// Redact before path cleaning can change a pasted URL's recognizable syntax.
|
||||||
|
recordPath, err = filepath.Abs(git.DisplayDiagnostic(recordPath))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("resolve publication record path: %w", err)
|
return "", fmt.Errorf("resolve publication record path: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,16 +60,16 @@ type attemptsDocument struct {
|
|||||||
|
|
||||||
func safe(e releasekit.PublicationEvidence) safeEvidence {
|
func safe(e releasekit.PublicationEvidence) safeEvidence {
|
||||||
return safeEvidence{
|
return safeEvidence{
|
||||||
RepositoryDir: e.RepositoryDir,
|
RepositoryDir: git.DisplayDiagnostic(e.RepositoryDir),
|
||||||
ModuleDir: e.ModuleDir,
|
ModuleDir: git.DisplayDiagnostic(e.ModuleDir),
|
||||||
ModulePath: e.ModulePath,
|
ModulePath: git.DisplayDiagnostic(e.ModulePath),
|
||||||
Version: e.Version,
|
Version: git.DisplayDiagnostic(e.Version),
|
||||||
Tag: e.Tag,
|
Tag: git.DisplayDiagnostic(e.Tag),
|
||||||
CandidateCommit: e.CandidateCommit,
|
CandidateCommit: git.DisplayDiagnostic(e.CandidateCommit),
|
||||||
TagObject: e.TagObject,
|
TagObject: git.DisplayDiagnostic(e.TagObject),
|
||||||
Endpoint: git.DisplayURL(e.Endpoint),
|
Endpoint: git.DisplayDiagnostic(e.Endpoint),
|
||||||
Remote: e.Remote,
|
Remote: git.DisplayDiagnostic(e.Remote),
|
||||||
RecordedAt: e.RecordedAt,
|
RecordedAt: git.DisplayDiagnostic(e.RecordedAt),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,9 +79,13 @@ func diagnostic(err error) *recoveryDiagnostic {
|
|||||||
}
|
}
|
||||||
var typed *releasekit.PublicationRecoveryError
|
var typed *releasekit.PublicationRecoveryError
|
||||||
if errors.As(err, &typed) {
|
if errors.As(err, &typed) {
|
||||||
return &recoveryDiagnostic{Kind: typed.Kind, Message: err.Error(), RecordPath: typed.RecordPath, MissingFields: typed.MissingFields}
|
missing := make([]string, len(typed.MissingFields))
|
||||||
|
for i, field := range typed.MissingFields {
|
||||||
|
missing[i] = git.DisplayDiagnostic(field)
|
||||||
|
}
|
||||||
|
return &recoveryDiagnostic{Kind: git.DisplayDiagnostic(typed.Kind), Message: git.DisplayError(err).Error(), RecordPath: git.DisplayDiagnostic(typed.RecordPath), MissingFields: missing}
|
||||||
}
|
}
|
||||||
return &recoveryDiagnostic{Kind: "invocation", Message: err.Error()}
|
return &recoveryDiagnostic{Kind: "invocation", Message: git.DisplayError(err).Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runRecovery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
|
func runRecovery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
|
||||||
@@ -92,7 +96,7 @@ func runRecovery(ctx context.Context, invocation cli.Invocation, stdout, stderr
|
|||||||
if invocation.JSON {
|
if invocation.JSON {
|
||||||
attempts := make([]attemptDocument, 0, len(listed.Attempts))
|
attempts := make([]attemptDocument, 0, len(listed.Attempts))
|
||||||
for _, item := range listed.Attempts {
|
for _, item := range listed.Attempts {
|
||||||
attempts = append(attempts, attemptDocument{RecordPath: item.RecordPath, Evidence: safe(item.Evidence)})
|
attempts = append(attempts, attemptDocument{RecordPath: git.DisplayDiagnostic(item.RecordPath), Evidence: safe(item.Evidence)})
|
||||||
}
|
}
|
||||||
diagnostics := make([]*recoveryDiagnostic, 0, len(listed.Diagnostics))
|
diagnostics := make([]*recoveryDiagnostic, 0, len(listed.Diagnostics))
|
||||||
for _, item := range listed.Diagnostics {
|
for _, item := range listed.Diagnostics {
|
||||||
@@ -101,24 +105,24 @@ func runRecovery(ctx context.Context, invocation cli.Invocation, stdout, stderr
|
|||||||
writeJSON(stdout, attemptsDocument{
|
writeJSON(stdout, attemptsDocument{
|
||||||
SchemaVersion: 1,
|
SchemaVersion: 1,
|
||||||
Command: "publication-attempts",
|
Command: "publication-attempts",
|
||||||
RecordDir: listed.RecordDir,
|
RecordDir: git.DisplayDiagnostic(listed.RecordDir),
|
||||||
Attempts: attempts,
|
Attempts: attempts,
|
||||||
Diagnostics: diagnostics,
|
Diagnostics: diagnostics,
|
||||||
Diagnostic: diagnostic(err),
|
Diagnostic: diagnostic(err),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(stdout, "publication records in %s\n", listed.RecordDir)
|
fmt.Fprintf(stdout, "publication records in %s\n", git.DisplayDiagnostic(listed.RecordDir))
|
||||||
for _, item := range listed.Attempts {
|
for _, item := range listed.Attempts {
|
||||||
fmt.Fprintf(stdout, "%s module=%s tag=%s commit=%s recorded-at=%s\n", item.RecordPath, item.Evidence.ModulePath, item.Evidence.Tag, item.Evidence.CandidateCommit, item.Evidence.RecordedAt)
|
fmt.Fprintf(stdout, "%s module=%s tag=%s commit=%s recorded-at=%s\n", git.DisplayDiagnostic(item.RecordPath), git.DisplayDiagnostic(item.Evidence.ModulePath), git.DisplayDiagnostic(item.Evidence.Tag), git.DisplayDiagnostic(item.Evidence.CandidateCommit), git.DisplayDiagnostic(item.Evidence.RecordedAt))
|
||||||
}
|
}
|
||||||
if err != nil || len(listed.Diagnostics) > 0 {
|
if err != nil || len(listed.Diagnostics) > 0 {
|
||||||
fmt.Fprintln(stderr, "discovery is incomplete")
|
fmt.Fprintln(stderr, "discovery is incomplete")
|
||||||
}
|
}
|
||||||
for _, item := range listed.Diagnostics {
|
for _, item := range listed.Diagnostics {
|
||||||
fmt.Fprintf(stderr, "record %s: %s\n", item.RecordPath, humanDiagnostic(item))
|
fmt.Fprintf(stderr, "record %s: %s\n", git.DisplayDiagnostic(item.RecordPath), humanDiagnostic(item))
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "discovery failed: %v\n", err)
|
fmt.Fprintf(stderr, "discovery failed: %v\n", git.DisplayError(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil || len(listed.Diagnostics) > 0 {
|
if err != nil || len(listed.Diagnostics) > 0 {
|
||||||
@@ -136,7 +140,7 @@ func runReconcile(ctx context.Context, invocation cli.Invocation, stdout, stderr
|
|||||||
}
|
}
|
||||||
status := reconciliationStatus(result.State, err)
|
status := reconciliationStatus(result.State, err)
|
||||||
if invocation.JSON {
|
if invocation.JSON {
|
||||||
dto := reconciliationDocument{SchemaVersion: 1, Command: "reconcile", RecordPath: invocation.RecordPath}
|
dto := reconciliationDocument{SchemaVersion: 1, Command: "reconcile", RecordPath: git.DisplayDiagnostic(invocation.RecordPath)}
|
||||||
if loadErr == nil {
|
if loadErr == nil {
|
||||||
safeEvidence := safe(evidence)
|
safeEvidence := safe(evidence)
|
||||||
dto.Evidence = &safeEvidence
|
dto.Evidence = &safeEvidence
|
||||||
@@ -147,9 +151,9 @@ func runReconcile(ctx context.Context, invocation cli.Invocation, stdout, stderr
|
|||||||
dto.Diagnostic = diagnostic(err)
|
dto.Diagnostic = diagnostic(err)
|
||||||
writeJSON(stdout, dto)
|
writeJSON(stdout, dto)
|
||||||
} else if err == nil {
|
} else if err == nil {
|
||||||
fmt.Fprintf(stdout, "reconciled %s module=%s tag=%s state=%s\n", invocation.RecordPath, evidence.ModulePath, evidence.Tag, result.State)
|
fmt.Fprintf(stdout, "reconciled %s module=%s tag=%s state=%s\n", git.DisplayDiagnostic(invocation.RecordPath), git.DisplayDiagnostic(evidence.ModulePath), git.DisplayDiagnostic(evidence.Tag), result.State)
|
||||||
if result.State == releasekit.PublicationConflict {
|
if result.State == releasekit.PublicationConflict {
|
||||||
fmt.Fprintf(stdout, "expected tag-object=%s commit=%s; observed tag-object=%s commit=%s\n", evidence.TagObject, evidence.CandidateCommit, result.ObservedTagObject, result.ObservedCommit)
|
fmt.Fprintf(stdout, "expected tag-object=%s commit=%s; observed tag-object=%s commit=%s\n", git.DisplayDiagnostic(evidence.TagObject), git.DisplayDiagnostic(evidence.CandidateCommit), result.ObservedTagObject, result.ObservedCommit)
|
||||||
} else if result.ObservedTagObject != "" || result.ObservedCommit != "" {
|
} else if result.ObservedTagObject != "" || result.ObservedCommit != "" {
|
||||||
fmt.Fprintf(stdout, "observed tag-object=%s commit=%s\n", result.ObservedTagObject, result.ObservedCommit)
|
fmt.Fprintf(stdout, "observed tag-object=%s commit=%s\n", result.ObservedTagObject, result.ObservedCommit)
|
||||||
}
|
}
|
||||||
@@ -159,12 +163,12 @@ func runReconcile(ctx context.Context, invocation cli.Invocation, stdout, stderr
|
|||||||
}
|
}
|
||||||
if err != nil && !invocation.JSON {
|
if err != nil && !invocation.JSON {
|
||||||
if loadErr == nil {
|
if loadErr == nil {
|
||||||
fmt.Fprintf(stdout, "reconciled %s module=%s tag=%s state=%s\n", invocation.RecordPath, evidence.ModulePath, evidence.Tag, result.State)
|
fmt.Fprintf(stdout, "reconciled %s module=%s tag=%s state=%s\n", git.DisplayDiagnostic(invocation.RecordPath), git.DisplayDiagnostic(evidence.ModulePath), git.DisplayDiagnostic(evidence.Tag), result.State)
|
||||||
if result.ObservedTagObject != "" || result.ObservedCommit != "" {
|
if result.ObservedTagObject != "" || result.ObservedCommit != "" {
|
||||||
fmt.Fprintf(stdout, "observed tag-object=%s commit=%s\n", result.ObservedTagObject, result.ObservedCommit)
|
fmt.Fprintf(stdout, "observed tag-object=%s commit=%s\n", result.ObservedTagObject, result.ObservedCommit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stderr, "reconcile %s: %s\n", invocation.RecordPath, humanDiagnostic(err))
|
fmt.Fprintf(stderr, "reconcile %s: %s\n", git.DisplayDiagnostic(invocation.RecordPath), humanDiagnostic(err))
|
||||||
if guidance := recoveryGuidance(err); guidance != "" {
|
if guidance := recoveryGuidance(err); guidance != "" {
|
||||||
fmt.Fprintf(stderr, "%s\n", guidance)
|
fmt.Fprintf(stderr, "%s\n", guidance)
|
||||||
}
|
}
|
||||||
@@ -234,9 +238,9 @@ func reconciliationGuidance(state releasekit.PublicationState) string {
|
|||||||
func humanDiagnostic(err error) string {
|
func humanDiagnostic(err error) string {
|
||||||
var typed *releasekit.PublicationRecoveryError
|
var typed *releasekit.PublicationRecoveryError
|
||||||
if errors.As(err, &typed) && len(typed.MissingFields) > 0 {
|
if errors.As(err, &typed) && len(typed.MissingFields) > 0 {
|
||||||
return fmt.Sprintf("%s (missing fields: %s): %s", typed.Kind, strings.Join(typed.MissingFields, ", "), err.Error())
|
return git.DisplayDiagnostic(fmt.Sprintf("%s (missing fields: %s): %s", typed.Kind, strings.Join(typed.MissingFields, ", "), err.Error()))
|
||||||
}
|
}
|
||||||
return err.Error()
|
return git.DisplayError(err).Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(writer io.Writer, value any) {
|
func writeJSON(writer io.Writer, value any) {
|
||||||
|
|||||||
@@ -10,9 +10,57 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
releasekit "go.maximumdirect.net/releasekit"
|
||||||
"go.maximumdirect.net/releasekit/internal/publish"
|
"go.maximumdirect.net/releasekit/internal/publish"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestRecoveryDisplaysRedactCredentialPathsAndMetadata(t *testing.T) {
|
||||||
|
secret := "audit-secret"
|
||||||
|
raw := "https://audit-user:" + secret + "@example.invalid/attempt.json"
|
||||||
|
for _, jsonOutput := range []bool{false, true} {
|
||||||
|
args := []string{"reconcile", "--record", raw}
|
||||||
|
if jsonOutput {
|
||||||
|
args = append(args, "--json")
|
||||||
|
}
|
||||||
|
var out, diagnostics bytes.Buffer
|
||||||
|
if status := run(t.Context(), args, &out, &diagnostics); status != 2 {
|
||||||
|
t.Fatalf("load status=%d", status)
|
||||||
|
}
|
||||||
|
if strings.Contains(out.String()+diagnostics.String(), secret) {
|
||||||
|
t.Fatalf("credential leaked: %s%s", &out, &diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repo := newCLIRepository(t)
|
||||||
|
store := t.TempDir()
|
||||||
|
evidence := publish.Evidence{RepositoryDir: raw, ModuleDir: raw, ModulePath: raw, Version: raw, Tag: raw, CandidateCommit: raw, TagObject: raw, Remote: raw, RecordedAt: raw}
|
||||||
|
path, err := publish.SaveEvidence(t.Context(), repo, store, evidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, command := range [][]string{{"reconcile", "--record", path}, {"publication-attempts", "--record-dir", store}} {
|
||||||
|
for _, jsonOutput := range []bool{false, true} {
|
||||||
|
args := append([]string{}, command...)
|
||||||
|
if jsonOutput {
|
||||||
|
args = append(args, "--json")
|
||||||
|
}
|
||||||
|
var out, diagnostics bytes.Buffer
|
||||||
|
run(t.Context(), args, &out, &diagnostics)
|
||||||
|
if strings.Contains(out.String()+diagnostics.String(), secret) {
|
||||||
|
t.Fatalf("metadata leaked: %s%s", &out, &diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loaded, err := publish.LoadEvidence(path)
|
||||||
|
if err != nil || loaded != evidence {
|
||||||
|
t.Fatalf("private metadata changed: %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
reportPublicationRecord(releasekit.PublicationResult{RecordPath: raw, Phase: releasekit.PublicationPush}, fmt.Errorf("push failed"), &out)
|
||||||
|
if strings.Contains(out.String(), secret) {
|
||||||
|
t.Fatalf("handoff leaked credentials: %s", &out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReconcileCommandClassifiesRecordsFromAnotherWorkingDirectory(t *testing.T) {
|
func TestReconcileCommandClassifiesRecordsFromAnotherWorkingDirectory(t *testing.T) {
|
||||||
repository := newCLIRepository(t)
|
repository := newCLIRepository(t)
|
||||||
remote := filepath.Join(t.TempDir(), "bare remote.git")
|
remote := filepath.Join(t.TempDir(), "bare remote.git")
|
||||||
|
|||||||
@@ -174,6 +174,11 @@ the existing immediate-recovery timeout semantics.
|
|||||||
|
|
||||||
## R05: Redact recovery paths and diagnostic fields at display boundaries — P2
|
## R05: Redact recovery paths and diagnostic fields at display boundaries — P2
|
||||||
|
|
||||||
|
**Completed:** Facade errors and CLI recovery displays now redact recognized
|
||||||
|
HTTP credentials in paths, metadata, diagnostics, and handoff commands. Public
|
||||||
|
error and CLI human/JSON regressions passed; raw typed causes and private record
|
||||||
|
contents remain intact.
|
||||||
|
|
||||||
**Relevant code:** `recovery.go:53`; `cmd/releasekit/recovery.go:76`, its JSON DTO
|
**Relevant code:** `recovery.go:53`; `cmd/releasekit/recovery.go:76`, its JSON DTO
|
||||||
construction, and human path displays; `cmd/releasekit/main.go`'s record displays.
|
construction, and human path displays; `cmd/releasekit/main.go`'s record displays.
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ func displayDiagnostic(message string) string {
|
|||||||
return httpURLSpan.ReplaceAllStringFunc(message, DisplayURL)
|
return httpURLSpan.ReplaceAllStringFunc(message, DisplayURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisplayDiagnostic redacts recognized HTTP credentials in a display string.
|
||||||
|
func DisplayDiagnostic(message string) string { return displayDiagnostic(message) }
|
||||||
|
|
||||||
type displayError struct{ cause error }
|
type displayError struct{ cause error }
|
||||||
|
|
||||||
func (err displayError) Error() string { return displayDiagnostic(err.cause.Error()) }
|
func (err displayError) Error() string { return displayDiagnostic(err.cause.Error()) }
|
||||||
|
|||||||
@@ -56,12 +56,12 @@ func (e *PublicationRecoveryError) Error() string {
|
|||||||
}
|
}
|
||||||
message := fmt.Sprintf("publication recovery %s", e.Kind)
|
message := fmt.Sprintf("publication recovery %s", e.Kind)
|
||||||
if e.RecordPath != "" {
|
if e.RecordPath != "" {
|
||||||
message += fmt.Sprintf(" for %q", e.RecordPath)
|
message += fmt.Sprintf(" for %q", git.DisplayDiagnostic(e.RecordPath))
|
||||||
}
|
}
|
||||||
if e.cause != nil {
|
if e.cause != nil {
|
||||||
message += ": " + git.DisplayError(e.cause).Error()
|
message += ": " + git.DisplayError(e.cause).Error()
|
||||||
}
|
}
|
||||||
return message
|
return git.DisplayDiagnostic(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unwrap returns the underlying recovery or storage cause.
|
// Unwrap returns the underlying recovery or storage cause.
|
||||||
|
|||||||
@@ -5,11 +5,25 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
releasekit "go.maximumdirect.net/releasekit"
|
releasekit "go.maximumdirect.net/releasekit"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestRecoveryFacadeRedactsSelectedPathAndRetainsRawCause(t *testing.T) {
|
||||||
|
path := "https://audit-user:audit-secret@example.invalid/attempt.json"
|
||||||
|
_, err := releasekit.LoadPublicationEvidence(path)
|
||||||
|
var recovery *releasekit.PublicationRecoveryError
|
||||||
|
var pathErr *os.PathError
|
||||||
|
if !errors.As(err, &recovery) || recovery.RecordPath != path || !errors.As(err, &pathErr) || pathErr.Path != path {
|
||||||
|
t.Fatalf("raw diagnostic context lost: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "audit-secret") || strings.Contains(err.Error(), "audit-user") {
|
||||||
|
t.Fatalf("credentials in displayed error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRecoveryFacadeLoadsPartialEvidenceAndPreservesCause(t *testing.T) {
|
func TestRecoveryFacadeLoadsPartialEvidenceAndPreservesCause(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "attempt.json")
|
path := filepath.Join(t.TempDir(), "attempt.json")
|
||||||
if err := os.WriteFile(path, []byte(`{"format_version":1,"evidence":{}}`), 0600); err != nil {
|
if err := os.WriteFile(path, []byte(`{"format_version":1,"evidence":{}}`), 0600); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user