fix: close Ticket 11 hardening review gaps
This commit is contained in:
+135
-28
@@ -263,27 +263,140 @@ func normalizeBypassEntry(raw string) (string, error) {
|
||||
return host, nil
|
||||
}
|
||||
|
||||
var (
|
||||
schemeCredentialPattern = regexp.MustCompile(`(?i)((?:https?|socks5h?)://)([^/@:\r\n]+):([^@\r\n]*)@`)
|
||||
bareCredentialPattern = regexp.MustCompile(`(?i)([^/@\s:]+):([^@/\s]*)@`)
|
||||
)
|
||||
|
||||
// RedactText removes URL passwords from diagnostic text without requiring the
|
||||
// surrounding text to be a valid URL. The scheme-aware first pass deliberately
|
||||
// accepts spaces and other malformed password bytes up to @; diagnostics and
|
||||
// form validation must not expose a credential merely because parsing failed.
|
||||
func RedactText(value string) string {
|
||||
value = schemeCredentialPattern.ReplaceAllString(value, `${1}${2}:REDACTED@`)
|
||||
return bareCredentialPattern.ReplaceAllString(value, `${1}:REDACTED@`)
|
||||
type credentialSpan struct {
|
||||
userinfoStart int
|
||||
passwordStart int
|
||||
at int
|
||||
}
|
||||
|
||||
// credentialSpans is deliberately a scanner rather than a URL regular
|
||||
// expression. Diagnostics routinely contain values rejected by net/url, and a
|
||||
// malformed password may contain whitespace, CR/LF, slashes, or additional @
|
||||
// signs. The scanner requires the comparatively stable user:...@host shape,
|
||||
// chooses an @ only when what follows can be an authority, and stops each
|
||||
// replacement at that @ so surrounding diagnostics remain untouched.
|
||||
func credentialSpans(value string) []credentialSpan {
|
||||
var spans []credentialSpan
|
||||
for cursor := 0; cursor < len(value); {
|
||||
colon := strings.IndexByte(value[cursor:], ':')
|
||||
if colon < 0 {
|
||||
break
|
||||
}
|
||||
colon += cursor
|
||||
if colon+2 < len(value) && value[colon+1:colon+3] == "//" {
|
||||
cursor = colon + 3
|
||||
continue
|
||||
}
|
||||
start := credentialUsernameStart(value, colon)
|
||||
if start == colon || (colon+1 < len(value) && isAuthorityBoundary(value[colon+1]) && !hasSchemeAuthorityPrefix(value, start)) {
|
||||
cursor = colon + 1
|
||||
continue
|
||||
}
|
||||
at := credentialDelimiter(value, colon+1)
|
||||
if at < 0 || hasNestedCredentialStart(value, colon+1, at) {
|
||||
cursor = colon + 1
|
||||
continue
|
||||
}
|
||||
spans = append(spans, credentialSpan{userinfoStart: start, passwordStart: colon + 1, at: at})
|
||||
cursor = at + 1
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
func credentialUsernameStart(value string, colon int) int {
|
||||
start := colon
|
||||
for start > 0 && isCredentialUsernameByte(value[start-1]) {
|
||||
start--
|
||||
}
|
||||
if start > 0 && (isCredentialUsernameByte(value[start-1]) || value[start-1] == '@') {
|
||||
return colon
|
||||
}
|
||||
if start > 0 && value[start-1] == '/' && (start < 3 || value[start-3:start] != "://") {
|
||||
return colon
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
func hasSchemeAuthorityPrefix(value string, usernameStart int) bool {
|
||||
return usernameStart >= 3 && value[usernameStart-3:usernameStart] == "://"
|
||||
}
|
||||
|
||||
func isCredentialUsernameByte(value byte) bool {
|
||||
return value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' ||
|
||||
value >= '0' && value <= '9' || strings.ContainsRune("._~%+-", rune(value))
|
||||
}
|
||||
|
||||
func credentialDelimiter(value string, passwordStart int) int {
|
||||
for at := passwordStart; at < len(value); at++ {
|
||||
if value[at] != '@' {
|
||||
continue
|
||||
}
|
||||
authorityStart := at + 1
|
||||
end := authorityStart
|
||||
for end < len(value) && !isAuthorityBoundary(value[end]) {
|
||||
end++
|
||||
}
|
||||
if end == authorityStart || strings.ContainsRune(value[authorityStart:end], '@') {
|
||||
continue
|
||||
}
|
||||
return at
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func isAuthorityBoundary(value byte) bool {
|
||||
switch value {
|
||||
case ' ', '\t', '\r', '\n', '/', '?', '#', '\'', '"', '<', '>', '[', ']', '(', ')', '{', '}', ',', ';':
|
||||
return true
|
||||
default:
|
||||
return value < 0x20 || value == 0x7f
|
||||
}
|
||||
}
|
||||
|
||||
func hasNestedCredentialStart(value string, passwordStart, at int) bool {
|
||||
for nested := passwordStart; nested < at; nested++ {
|
||||
if value[nested] != ':' {
|
||||
continue
|
||||
}
|
||||
// A later scheme authority proves the earlier colon was prose (for
|
||||
// example "Proxy URL:") rather than the credential separator.
|
||||
if nested+2 < len(value) && value[nested+1:nested+3] == "//" {
|
||||
return true
|
||||
}
|
||||
if credentialUsernameStart(value, nested) < nested && credentialDelimiter(value, nested+1) == at {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func redactCredentialSpans(value string, removeUsername bool) string {
|
||||
spans := credentialSpans(value)
|
||||
if len(spans) == 0 {
|
||||
return value
|
||||
}
|
||||
var redacted strings.Builder
|
||||
last := 0
|
||||
for _, span := range spans {
|
||||
redacted.WriteString(value[last:span.userinfoStart])
|
||||
if !removeUsername {
|
||||
redacted.WriteString(value[span.userinfoStart:span.passwordStart])
|
||||
}
|
||||
redacted.WriteString("REDACTED@")
|
||||
last = span.at + 1
|
||||
}
|
||||
redacted.WriteString(value[last:])
|
||||
return redacted.String()
|
||||
}
|
||||
|
||||
// RedactText removes passwords from valid and malformed credential-bearing
|
||||
// text while retaining usernames for diagnostics where that behavior is useful.
|
||||
func RedactText(value string) string { return redactCredentialSpans(value, false) }
|
||||
|
||||
// RedactUserinfoText removes usernames as well as passwords. Form frames use
|
||||
// this stricter boundary because even the identity of the probe source is
|
||||
// private while a Profile is being edited.
|
||||
func RedactUserinfoText(value string) string {
|
||||
value = schemeCredentialPattern.ReplaceAllString(value, `${1}REDACTED@`)
|
||||
return bareCredentialPattern.ReplaceAllString(value, `REDACTED@`)
|
||||
}
|
||||
func RedactUserinfoText(value string) string { return redactCredentialSpans(value, true) }
|
||||
|
||||
func ObscureURL(value string) string {
|
||||
u, err := url.Parse(value)
|
||||
@@ -301,22 +414,16 @@ func ObscureURL(value string) string {
|
||||
// rune count. A text input clone can therefore retain its real cursor and
|
||||
// horizontal viewport positions without rendering either username or password.
|
||||
func ObscureURLForEditing(value string) string {
|
||||
masked := maskCredentialMatches(value, schemeCredentialPattern, 4, 7)
|
||||
return maskCredentialMatches(masked, bareCredentialPattern, 2, 5)
|
||||
}
|
||||
|
||||
func maskCredentialMatches(value string, pattern *regexp.Regexp, userinfoStartGroup, userinfoEndGroup int) string {
|
||||
matches := pattern.FindAllStringSubmatchIndex(value, -1)
|
||||
if len(matches) == 0 {
|
||||
spans := credentialSpans(value)
|
||||
if len(spans) == 0 {
|
||||
return value
|
||||
}
|
||||
var masked strings.Builder
|
||||
last := 0
|
||||
for _, match := range matches {
|
||||
userinfoStart, userinfoEnd := match[userinfoStartGroup], match[userinfoEndGroup]
|
||||
masked.WriteString(value[last:userinfoStart])
|
||||
masked.WriteString(strings.Repeat("•", len([]rune(value[userinfoStart:userinfoEnd]))))
|
||||
last = userinfoEnd
|
||||
for _, span := range spans {
|
||||
masked.WriteString(value[last:span.userinfoStart])
|
||||
masked.WriteString(strings.Repeat("•", len([]rune(value[span.userinfoStart:span.at]))))
|
||||
last = span.at
|
||||
}
|
||||
masked.WriteString(value[last:])
|
||||
return masked.String()
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRedactTextConservativelyScansMalformedCredentials(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "carriage return in password",
|
||||
input: "before http://alice:first\rsecond@proxy.example:8080 after",
|
||||
expected: "before http://alice:REDACTED@proxy.example:8080 after",
|
||||
},
|
||||
{
|
||||
name: "line feed in password",
|
||||
input: "before http://alice:first\nsecond@proxy.example:8080 after",
|
||||
expected: "before http://alice:REDACTED@proxy.example:8080 after",
|
||||
},
|
||||
{
|
||||
name: "spaces in password",
|
||||
input: "before http://alice:two words here@proxy.example:8080 after",
|
||||
expected: "before http://alice:REDACTED@proxy.example:8080 after",
|
||||
},
|
||||
{
|
||||
name: "slash in password",
|
||||
input: "before http://alice:part/other@proxy.example:8080 after",
|
||||
expected: "before http://alice:REDACTED@proxy.example:8080 after",
|
||||
},
|
||||
{
|
||||
name: "unsupported scheme and CRLF space slash password",
|
||||
input: "probe failed [ftp+ssh://alice:line one/line\r\ntwo@proxy.example:8080] retrying",
|
||||
expected: "probe failed [ftp+ssh://alice:REDACTED@proxy.example:8080] retrying",
|
||||
},
|
||||
{
|
||||
name: "bare malformed userinfo",
|
||||
input: "before alice:bare secret/part@proxy.example:8080 after",
|
||||
expected: "before alice:REDACTED@proxy.example:8080 after",
|
||||
},
|
||||
{
|
||||
name: "multiple at signs",
|
||||
input: "before http://alice:sec@ret@proxy.example:8080 after",
|
||||
expected: "before http://alice:REDACTED@proxy.example:8080 after",
|
||||
},
|
||||
{
|
||||
name: "multiple URLs on one line",
|
||||
input: "left http://alice:first@one.example:1 middle invalid-scheme://bob:second value@two.example:2 right",
|
||||
expected: "left http://alice:REDACTED@one.example:1 middle invalid-scheme://bob:REDACTED@two.example:2 right",
|
||||
},
|
||||
{
|
||||
name: "prose labels before URLs",
|
||||
input: "Proxy URL: odd+scheme://alice:first@one.example:1; fallback URL: invalid://bob:second@two.example:2",
|
||||
expected: "Proxy URL: odd+scheme://alice:REDACTED@one.example:1; fallback URL: invalid://bob:REDACTED@two.example:2",
|
||||
},
|
||||
{
|
||||
name: "surrounding diagnostics with colons remain",
|
||||
input: "error: connect alice:secret@proxy.example:80: refused; code: EHOSTUNREACH",
|
||||
expected: "error: connect alice:REDACTED@proxy.example:80: refused; code: EHOSTUNREACH",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := RedactText(test.input); got != test.expected {
|
||||
t.Fatalf("RedactText() = %q, want %q", got, test.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactTextDoesNotSwallowUnrelatedTextAcrossLines(t *testing.T) {
|
||||
input := "diagnostic:\ncontact admin@example.com\nnext diagnostic remains"
|
||||
if got := RedactText(input); got != input {
|
||||
t.Fatalf("RedactText crossed a diagnostic line boundary: got %q, want %q", got, input)
|
||||
}
|
||||
|
||||
input = "first http://alice:first@one.example:1\nsecond http://bob:second@two.example:2\ntrailing diagnostic"
|
||||
want := "first http://alice:REDACTED@one.example:1\nsecond http://bob:REDACTED@two.example:2\ntrailing diagnostic"
|
||||
if got := RedactText(input); got != want {
|
||||
t.Fatalf("RedactText swallowed across independent URL lines: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactUserinfoTextRemovesCompleteMalformedUserinfoAndPreservesContext(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
userinfo string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
input: "BEGIN unsupported://alice:secret words/part@proxy.example:80 END",
|
||||
userinfo: "alice:secret words/part",
|
||||
expected: "BEGIN unsupported://REDACTED@proxy.example:80 END",
|
||||
},
|
||||
{
|
||||
input: "BEGIN alice:secret\r\nwords@proxy.example:80 END",
|
||||
userinfo: "alice:secret\r\nwords",
|
||||
expected: "BEGIN REDACTED@proxy.example:80 END",
|
||||
},
|
||||
{
|
||||
input: "BEGIN http://alice:sec@ret@proxy.example:80 END",
|
||||
userinfo: "alice:sec@ret",
|
||||
expected: "BEGIN http://REDACTED@proxy.example:80 END",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range cases {
|
||||
got := RedactUserinfoText(test.input)
|
||||
if strings.Contains(got, test.userinfo) {
|
||||
t.Fatalf("RedactUserinfoText retained userinfo %q in %q", test.userinfo, got)
|
||||
}
|
||||
if got != test.expected {
|
||||
t.Fatalf("RedactUserinfoText(%q) = %q, want %q", test.input, got, test.expected)
|
||||
}
|
||||
if !strings.HasPrefix(got, "BEGIN ") || !strings.HasSuffix(got, " END") {
|
||||
t.Fatalf("RedactUserinfoText swallowed surrounding diagnostics: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactionPreservesArbitrarySurroundingDiagnostics(t *testing.T) {
|
||||
prefixes := []string{"", "error: ", "[target apt] ", "first URL failed; "}
|
||||
suffixes := []string{"", " (retrying)", "; next check continues", "\nstack trace follows"}
|
||||
for _, prefix := range prefixes {
|
||||
for _, suffix := range suffixes {
|
||||
input := prefix + "odd+scheme://user:malformed secret/path@host.example:9" + suffix
|
||||
got := RedactText(input)
|
||||
if strings.Contains(got, "malformed secret/path") {
|
||||
t.Fatalf("secret survived for prefix=%q suffix=%q: %q", prefix, suffix, got)
|
||||
}
|
||||
if !strings.HasPrefix(got, prefix) || !strings.HasSuffix(got, suffix) {
|
||||
t.Fatalf("surrounding diagnostics changed for prefix=%q suffix=%q: %q", prefix, suffix, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
type confirmationDialog struct {
|
||||
request app.Confirmation
|
||||
confirmFocused bool
|
||||
offset int
|
||||
}
|
||||
|
||||
func newConfirmationDialog(request app.Confirmation) confirmationDialog {
|
||||
@@ -29,6 +30,14 @@ func newConfirmationDialog(request app.Confirmation) confirmationDialog {
|
||||
|
||||
func (d *confirmationDialog) update(key string) confirmationResult {
|
||||
switch key {
|
||||
case "pgup":
|
||||
d.offset = max(0, d.offset-5)
|
||||
case "pgdown":
|
||||
d.offset += 5
|
||||
case "home":
|
||||
d.offset = 0
|
||||
case "end":
|
||||
d.offset = 1 << 20
|
||||
case "left", "right", "tab", "shift+tab":
|
||||
d.confirmFocused = !d.confirmFocused
|
||||
case "y":
|
||||
|
||||
+15
-7
@@ -63,9 +63,11 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
|
||||
}
|
||||
page.dashboard = nil
|
||||
page.primary = dialog.view()
|
||||
page.primaryOffset = dialog.offset
|
||||
page.focusLine = -1
|
||||
page.wrapPrimary = true
|
||||
page.context = "Cancellation is selected by default."
|
||||
page.footer = "←/→/Tab: choose y: confirm Enter: choose\nn/q/Esc: cancel ?: help"
|
||||
page.footer = "←/→/Tab: choose PgUp/PgDn: scroll y: confirm Enter: choose\nn/q/Esc: cancel ?: help"
|
||||
}
|
||||
return page
|
||||
}
|
||||
@@ -177,11 +179,12 @@ func (p terminalPresentation) profilesView(model presentationState) terminalPage
|
||||
}
|
||||
if model.profileDelete != nil {
|
||||
return terminalPage{
|
||||
header: p.theme.title.Render("Proxy Profile management"),
|
||||
primary: model.profileDelete.dialog.view(),
|
||||
wrapPrimary: true,
|
||||
footer: "←/→/Tab: choose Enter: choose y: delete\nn/q/Esc: cancel ?: help",
|
||||
focusLine: -1,
|
||||
header: p.theme.title.Render("Proxy Profile management"),
|
||||
primary: model.profileDelete.dialog.view(),
|
||||
primaryOffset: model.profileDelete.dialog.offset,
|
||||
wrapPrimary: true,
|
||||
footer: "←/→/Tab: choose PgUp/PgDn: scroll Enter: choose y: delete\nn/q/Esc: cancel ?: help",
|
||||
focusLine: -1,
|
||||
}
|
||||
}
|
||||
return terminalPage{
|
||||
@@ -196,6 +199,10 @@ func (p terminalPresentation) profilesView(model presentationState) terminalPage
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeFormText(value string) string {
|
||||
return sanitizeUntrustedControls(profile.RedactUserinfoText(value))
|
||||
}
|
||||
|
||||
func selectedProfileDetails(p terminalPresentation, model presentationState) string {
|
||||
selected, ok := profileAt(model.snapshot, model.selected)
|
||||
if !ok {
|
||||
@@ -259,6 +266,7 @@ func (p terminalPresentation) formView(model presentationState) terminalPage {
|
||||
validation = conflict
|
||||
}
|
||||
}
|
||||
validation = sanitizeFormText(validation)
|
||||
if validation != "" {
|
||||
fmt.Fprintf(&fields, "\n Error: %s", validation)
|
||||
currentLine++
|
||||
@@ -270,7 +278,7 @@ func (p terminalPresentation) formView(model presentationState) terminalPage {
|
||||
bypassPreview.WriteString(" (none)")
|
||||
} else {
|
||||
for _, line := range analysis {
|
||||
bypassPreview.WriteString("\n " + line)
|
||||
bypassPreview.WriteString("\n " + sanitizeFormText(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+221
-39
@@ -1,11 +1,13 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@@ -109,6 +111,11 @@ type terminalPage struct {
|
||||
}
|
||||
|
||||
func (p terminalPresentation) Render(state presentationState) string {
|
||||
// Application snapshots, adapter diagnostics, operation updates, and form
|
||||
// input are all untrusted presentation data. Strip their controls before any
|
||||
// semanticTheme style is applied; otherwise an attacker could inject one of
|
||||
// the same allowlisted SGR sequences that the theme legitimately emits.
|
||||
state = sanitizePresentationState(state)
|
||||
width, height := state.width, state.height
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
@@ -147,15 +154,10 @@ func (p terminalPresentation) Render(state presentationState) string {
|
||||
page.statusExpanded = state.errorExpanded && state.currentError != nil
|
||||
page.statusOffset = state.errorOffset
|
||||
page = redactTerminalPage(page)
|
||||
// Dashboard composition retains structured Target metadata until pane sizes
|
||||
// are known. Redact the fully composed frame as the final presentation
|
||||
// boundary as well, so no render path can bypass credential scrubbing. Form
|
||||
// frames use the stricter boundary that removes the entire userinfo.
|
||||
frame := p.renderShell(page, width, height)
|
||||
if state.page == formPage {
|
||||
return sanitizeStyledText(profile.RedactUserinfoText(frame))
|
||||
}
|
||||
return sanitizeStyledText(profile.RedactText(frame))
|
||||
// Untrusted values were redacted and stripped of controls before semantic
|
||||
// styling. The final pass therefore removes unexpected terminal controls
|
||||
// without reparsing clipped, composed prose as if it were a URL.
|
||||
return sanitizeStyledText(p.renderShell(page, width, height))
|
||||
}
|
||||
|
||||
func renderStatus(state presentationState) string {
|
||||
@@ -187,16 +189,159 @@ func redactTerminalPage(page terminalPage) terminalPage {
|
||||
}
|
||||
|
||||
func protectRenderedText(value string) string {
|
||||
return sanitizeStyledText(profile.RedactText(value))
|
||||
return sanitizeStyledText(value)
|
||||
}
|
||||
|
||||
func sanitizeUntrustedControls(value string) string {
|
||||
return sanitizeTerminalText(value, false)
|
||||
}
|
||||
|
||||
func sanitizeUntrustedText(value string) string {
|
||||
return sanitizeUntrustedControls(profile.RedactText(value))
|
||||
}
|
||||
|
||||
func sanitizeUntrustedUserinfoText(value string) string {
|
||||
return sanitizeUntrustedControls(profile.RedactUserinfoText(value))
|
||||
}
|
||||
|
||||
func sanitizePresentationState(state presentationState) presentationState {
|
||||
state.snapshot.ActiveProfile = sanitizeUntrustedText(state.snapshot.ActiveProfile)
|
||||
state.snapshot.Profiles = append([]profile.Profile(nil), state.snapshot.Profiles...)
|
||||
for i := range state.snapshot.Profiles {
|
||||
state.snapshot.Profiles[i] = sanitizePresentationProfile(state.snapshot.Profiles[i])
|
||||
}
|
||||
state.snapshot.Targets = append([]app.TargetSnapshot(nil), state.snapshot.Targets...)
|
||||
for i := range state.snapshot.Targets {
|
||||
item := &state.snapshot.Targets[i]
|
||||
item.Description.Name = sanitizeUntrustedText(item.Description.Name)
|
||||
item.Description.Summary = sanitizeUntrustedText(item.Description.Summary)
|
||||
item.Description.Scope = sanitizeUntrustedText(item.Description.Scope)
|
||||
item.Description.Permissions = sanitizeUntrustedText(item.Description.Permissions)
|
||||
item.Description.Ownership = sanitizeUntrustedText(item.Description.Ownership)
|
||||
item.Description.RestartRisk = sanitizeUntrustedText(item.Description.RestartRisk)
|
||||
item.Description.Details = sanitizeUntrustedText(item.Description.Details)
|
||||
item.AvailabilityEvidence = sanitizeUntrustedText(item.AvailabilityEvidence)
|
||||
item.Evidence = sanitizeUntrustedText(item.Evidence)
|
||||
item.DetectionErr = sanitizePresentationError(item.DetectionErr)
|
||||
item.PrimaryAction.DisabledReason = sanitizeUntrustedText(item.PrimaryAction.DisabledReason)
|
||||
item.DriftResolutionActions = append([]app.TargetAction(nil), item.DriftResolutionActions...)
|
||||
for action := range item.DriftResolutionActions {
|
||||
item.DriftResolutionActions[action].DisabledReason = sanitizeUntrustedText(item.DriftResolutionActions[action].DisabledReason)
|
||||
}
|
||||
}
|
||||
|
||||
sanitizeStatus := sanitizeUntrustedText
|
||||
if state.page == formPage {
|
||||
// Profile editing treats the identity as private as well as the password.
|
||||
// Apply that stronger policy to every status/error ingress before styling.
|
||||
sanitizeStatus = sanitizeUntrustedUserinfoText
|
||||
}
|
||||
state.status = sanitizeStatus(state.status)
|
||||
state.operationStatus.Summary = sanitizeStatus(state.operationStatus.Summary)
|
||||
if state.currentError != nil {
|
||||
current := *state.currentError
|
||||
current.status.Summary = sanitizeStatus(current.status.Summary)
|
||||
current.detail = sanitizeStatus(current.detail)
|
||||
state.currentError = ¤t
|
||||
}
|
||||
if state.confirmation != nil {
|
||||
confirmation := sanitizePresentationConfirmation(*state.confirmation)
|
||||
state.confirmation = &confirmation
|
||||
}
|
||||
if state.confirmationDialog != nil {
|
||||
dialog := *state.confirmationDialog
|
||||
dialog.request = sanitizePresentationConfirmation(dialog.request)
|
||||
state.confirmationDialog = &dialog
|
||||
}
|
||||
if state.profileDelete != nil {
|
||||
confirmation := *state.profileDelete
|
||||
confirmation.name = sanitizeUntrustedText(confirmation.name)
|
||||
confirmation.dialog.request = sanitizePresentationConfirmation(confirmation.dialog.request)
|
||||
state.profileDelete = &confirmation
|
||||
}
|
||||
|
||||
state.picker.selectedName = sanitizeUntrustedText(state.picker.selectedName)
|
||||
searchPosition := state.picker.search.Position()
|
||||
state.picker.search.SetValue(sanitizeUntrustedText(state.picker.search.Value()))
|
||||
state.picker.search.SetCursor(searchPosition)
|
||||
state.form = sanitizePresentationForm(state.form)
|
||||
return state
|
||||
}
|
||||
|
||||
func sanitizePresentationProfile(value profile.Profile) profile.Profile {
|
||||
value.Name = sanitizeUntrustedText(value.Name)
|
||||
value.SingleURL = sanitizeUntrustedText(value.SingleURL)
|
||||
value.HTTPURL = sanitizeUntrustedText(value.HTTPURL)
|
||||
value.HTTPSURL = sanitizeUntrustedText(value.HTTPSURL)
|
||||
value.AllURL = sanitizeUntrustedText(value.AllURL)
|
||||
value.Bypass = append([]string(nil), value.Bypass...)
|
||||
for i := range value.Bypass {
|
||||
value.Bypass[i] = sanitizeUntrustedText(value.Bypass[i])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sanitizePresentationForm(form formState) formState {
|
||||
// Preserve credential byte/rune positions in the copied editor state so
|
||||
// textinput cursor and horizontal viewport calculations stay truthful.
|
||||
// renderedInput masks userinfo before clipping, and the final form boundary
|
||||
// redacts it again; this pass only removes terminal controls before styling.
|
||||
form.originalName = sanitizeUntrustedControls(form.originalName)
|
||||
form.profile = sanitizePresentationFormProfile(form.profile)
|
||||
form.inputs = append([]formInput(nil), form.inputs...)
|
||||
for i := range form.inputs {
|
||||
form.inputs[i].label = sanitizeUntrustedText(form.inputs[i].label)
|
||||
position := form.inputs[i].input.Position()
|
||||
form.inputs[i].input.SetValue(sanitizeUntrustedControls(form.inputs[i].input.Value()))
|
||||
form.inputs[i].input.SetCursor(position)
|
||||
}
|
||||
form.probe.address = sanitizeUntrustedText(form.probe.address)
|
||||
form.probe.feedback = sanitizeUntrustedText(form.probe.feedback)
|
||||
form.probe.sourceValue = sanitizeUntrustedText(form.probe.sourceValue)
|
||||
form.refreshValidation()
|
||||
return form
|
||||
}
|
||||
|
||||
func sanitizePresentationFormProfile(value profile.Profile) profile.Profile {
|
||||
value.Name = sanitizeUntrustedControls(value.Name)
|
||||
value.SingleURL = sanitizeUntrustedControls(value.SingleURL)
|
||||
value.HTTPURL = sanitizeUntrustedControls(value.HTTPURL)
|
||||
value.HTTPSURL = sanitizeUntrustedControls(value.HTTPSURL)
|
||||
value.AllURL = sanitizeUntrustedControls(value.AllURL)
|
||||
value.Bypass = append([]string(nil), value.Bypass...)
|
||||
for i := range value.Bypass {
|
||||
value.Bypass[i] = sanitizeUntrustedControls(value.Bypass[i])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sanitizePresentationConfirmation(value app.Confirmation) app.Confirmation {
|
||||
value.Title = sanitizeUntrustedText(value.Title)
|
||||
value.Summary = sanitizeUntrustedText(value.Summary)
|
||||
value.ConfirmLabel = sanitizeUntrustedText(value.ConfirmLabel)
|
||||
value.CancelLabel = sanitizeUntrustedText(value.CancelLabel)
|
||||
value.Details = append([]string(nil), value.Details...)
|
||||
for i := range value.Details {
|
||||
value.Details[i] = sanitizeUntrustedText(value.Details[i])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sanitizePresentationError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return errors.New(sanitizeUntrustedText(err.Error()))
|
||||
}
|
||||
|
||||
var safeSGRPattern = regexp.MustCompile(`^(?:0|(?:(?:1;)?(?:4;)?38;5;(?:25|34|75|160|172|235|242|245|252)))$`)
|
||||
|
||||
// sanitizeStyledText removes terminal-control injection while retaining only
|
||||
// the small SGR palette emitted by semanticTheme. OSC title/hyperlink commands,
|
||||
// cursor movement, concealment, carriage returns, and private C1 controls are
|
||||
// never meaningful application text and must not reach a rendered frame.
|
||||
func sanitizeStyledText(value string) string {
|
||||
// sanitizeStyledText is the final defense after semantic styling. Untrusted
|
||||
// values have already passed sanitizeUntrustedText, so the only SGR retained
|
||||
// here is output generated by semanticTheme.
|
||||
func sanitizeStyledText(value string) string { return sanitizeTerminalText(value, true) }
|
||||
|
||||
func sanitizeTerminalText(value string, allowThemeSGR bool) string {
|
||||
var safe strings.Builder
|
||||
for i := 0; i < len(value); {
|
||||
if value[i] == '\x1b' {
|
||||
@@ -206,7 +351,7 @@ func sanitizeStyledText(value string) string {
|
||||
end++
|
||||
}
|
||||
if end < len(value) {
|
||||
if value[end] == 'm' && safeSGRPattern.MatchString(value[i+2:end]) {
|
||||
if allowThemeSGR && value[end] == 'm' && safeSGRPattern.MatchString(value[i+2:end]) {
|
||||
safe.WriteString(value[i : end+1])
|
||||
}
|
||||
i = end + 1
|
||||
@@ -216,30 +361,44 @@ func sanitizeStyledText(value string) string {
|
||||
continue
|
||||
}
|
||||
if i+1 < len(value) && value[i+1] == ']' {
|
||||
i += 2
|
||||
for i < len(value) {
|
||||
if value[i] == '\a' {
|
||||
i++
|
||||
break
|
||||
}
|
||||
if value[i] == '\x1b' && i+1 < len(value) && value[i+1] == '\\' {
|
||||
i += 2
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
i = skipTerminalString(value, i+2)
|
||||
continue
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if value[i] == 0x9b {
|
||||
end := i + 1
|
||||
for end < len(value) && (value[end] < 0x40 || value[end] > 0x7e) {
|
||||
end++
|
||||
}
|
||||
if end < len(value) {
|
||||
i = end + 1
|
||||
} else {
|
||||
i = len(value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if value[i] == 0x9d {
|
||||
i = skipTerminalString(value, i+1)
|
||||
continue
|
||||
}
|
||||
switch value[i] {
|
||||
case '\n':
|
||||
safe.WriteByte('\n')
|
||||
case '\t':
|
||||
safe.WriteString(" ")
|
||||
default:
|
||||
if value[i] >= 0x20 && value[i] != 0x7f {
|
||||
if value[i] >= utf8.RuneSelf {
|
||||
r, size := utf8.DecodeRuneInString(value[i:])
|
||||
if r != utf8.RuneError || size > 1 {
|
||||
if r != '\ufeff' && !(r >= 0x80 && r <= 0x9f) && !(r >= 0x202a && r <= 0x202e) && !(r >= 0x2066 && r <= 0x2069) {
|
||||
safe.WriteString(value[i : i+size])
|
||||
}
|
||||
i += size
|
||||
continue
|
||||
}
|
||||
} else if value[i] >= 0x20 && value[i] != 0x7f {
|
||||
safe.WriteByte(value[i])
|
||||
}
|
||||
}
|
||||
@@ -248,7 +407,7 @@ func sanitizeStyledText(value string) string {
|
||||
|
||||
var visible strings.Builder
|
||||
for _, r := range safe.String() {
|
||||
if (r >= 0x80 && r <= 0x9f) || (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) {
|
||||
if r == '\ufeff' || (r >= 0x80 && r <= 0x9f) || (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) {
|
||||
continue
|
||||
}
|
||||
visible.WriteRune(r)
|
||||
@@ -256,10 +415,33 @@ func sanitizeStyledText(value string) string {
|
||||
return visible.String()
|
||||
}
|
||||
|
||||
func skipTerminalString(value string, i int) int {
|
||||
for i < len(value) {
|
||||
if value[i] == '\a' || value[i] == 0x9c {
|
||||
return i + 1
|
||||
}
|
||||
if value[i] == '\x1b' && i+1 < len(value) && value[i+1] == '\\' {
|
||||
return i + 2
|
||||
}
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func (p terminalPresentation) renderShell(page terminalPage, width, height int) string {
|
||||
header := viewportContent(page.header, width, lineCount(page.header), 0)
|
||||
footer := viewportContent(page.footer, width, lineCount(page.footer), 0)
|
||||
|
||||
headerHeight := lineCount(header)
|
||||
footerHeight := lineCount(footer)
|
||||
baseSeparators := 0
|
||||
if header != "" {
|
||||
baseSeparators++
|
||||
}
|
||||
if footer != "" || page.status != "" {
|
||||
baseSeparators++
|
||||
}
|
||||
|
||||
status := ""
|
||||
if page.status != "" {
|
||||
wrapped := lipgloss.NewStyle().Width(width).Render(p.theme.status.Render("Status: " + page.status))
|
||||
@@ -267,21 +449,21 @@ func (p terminalPresentation) renderShell(page terminalPage, width, height int)
|
||||
statusOffset := 0
|
||||
if page.statusExpanded {
|
||||
statusHeight = min(lineCount(wrapped), max(3, height/2))
|
||||
// The compact dashboard needs ten rows to retain both panes. Bound
|
||||
// expanded errors to the remaining terminal budget instead of
|
||||
// allowing their viewport to push the frame beyond the terminal.
|
||||
minimumBodyHeight := 1
|
||||
if page.dashboard != nil {
|
||||
minimumBodyHeight = 10
|
||||
}
|
||||
statusHeight = min(statusHeight, max(1, height-headerHeight-footerHeight-baseSeparators-minimumBodyHeight))
|
||||
statusOffset = page.statusOffset
|
||||
}
|
||||
status = viewportContent(wrapped, width, statusHeight, statusOffset)
|
||||
}
|
||||
|
||||
headerHeight := lineCount(header)
|
||||
footerHeight := lineCount(footer)
|
||||
statusHeight := lineCount(status)
|
||||
separators := 0
|
||||
if header != "" {
|
||||
separators++
|
||||
}
|
||||
if footer != "" || status != "" {
|
||||
separators++
|
||||
}
|
||||
separators := baseSeparators
|
||||
bodyHeight := height - headerHeight - footerHeight - statusHeight - separators
|
||||
if bodyHeight < 1 {
|
||||
bodyHeight = 1
|
||||
|
||||
@@ -87,7 +87,7 @@ func (f *formState) rebuildInputs(focus int) tea.Cmd {
|
||||
input.Placeholder = fieldPlaceholder(definition.field)
|
||||
input.CharLimit = 4096
|
||||
input.Width = 48
|
||||
input.SetValue(definition.value)
|
||||
input.SetValue(sanitizeUntrustedControls(definition.value))
|
||||
input.CursorEnd()
|
||||
f.inputs[i] = formInput{label: definition.label, field: definition.field, input: input}
|
||||
}
|
||||
@@ -122,6 +122,9 @@ func (f *formState) updateInput(key tea.KeyMsg) tea.Cmd {
|
||||
if len(f.inputs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if key.Type == tea.KeyRunes {
|
||||
key.Runes = []rune(sanitizeUntrustedControls(string(key.Runes)))
|
||||
}
|
||||
// Bubbles textinput intentionally collapses pasted newlines. Bypass uses
|
||||
// canonical comma/newline delimiters, so preserve that boundary as commas.
|
||||
if f.inputs[f.field].field == profile.BypassField && key.Type == tea.KeyRunes {
|
||||
|
||||
@@ -45,6 +45,9 @@ func (p *profilePickerState) close() {
|
||||
}
|
||||
|
||||
func (p *profilePickerState) update(key tea.KeyMsg, snapshot app.Snapshot) tea.Cmd {
|
||||
if key.Type == tea.KeyRunes {
|
||||
key.Runes = []rune(sanitizeUntrustedControls(string(key.Runes)))
|
||||
}
|
||||
switch key.String() {
|
||||
case "up", "ctrl+p":
|
||||
p.move(snapshot, -1)
|
||||
|
||||
@@ -3,6 +3,7 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -215,6 +216,108 @@ func TestCompleteModelBoundsUnicodeAndKeepsLongContentReachable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelLongFieldsAndDialogsRemainReachableAcrossResizeAtNonterminalOffsets(t *testing.T) {
|
||||
t.Run("long URL field and Bypass preview", func(t *testing.T) {
|
||||
snapshot := hardeningSnapshot()
|
||||
snapshot.Profiles[0].SingleURL = "http://start.example:8080/" + strings.Repeat("a", 90) + " URL-MIDDLE " + strings.Repeat("z", 90) + "URL-END"
|
||||
bypass := make([]string, 40)
|
||||
for i := range bypass {
|
||||
bypass[i] = fmt.Sprintf("host-%02d.example", i+1)
|
||||
}
|
||||
snapshot.Profiles[0].Bypass = bypass
|
||||
model := resizedModel(t, &recordingModule{snapshot: snapshot}, themeModeNoColor, 80, 24)
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "e")
|
||||
model = updateKey(model, "tab")
|
||||
for range 115 {
|
||||
model = updateKey(model, "left")
|
||||
}
|
||||
assertCellBoundedFrame(t, model.View(), 80, 24, "URL-MIDDLE")
|
||||
model = resize(model, 120, 36)
|
||||
assertCellBoundedFrame(t, model.View(), 120, 36, "URL-MIDDLE")
|
||||
model = resize(model, 80, 24)
|
||||
|
||||
model = updateKey(model, "tab")
|
||||
model = updateKey(model, "home")
|
||||
model = updateKey(model, "pgdown")
|
||||
model = updateKey(model, "pgdown")
|
||||
view := model.View()
|
||||
assertCellBoundedFrame(t, view, 80, 24, "host-10.example")
|
||||
if strings.Contains(view, "host-40.example") {
|
||||
t.Fatalf("Bypass preview unexpectedly jumped to its terminal offset:\n%s", view)
|
||||
}
|
||||
model = resize(model, 120, 36)
|
||||
assertCellBoundedFrame(t, model.View(), 120, 36, "host-10.example")
|
||||
})
|
||||
|
||||
t.Run("expanded error", func(t *testing.T) {
|
||||
snapshot := hardeningSnapshot()
|
||||
lines := make([]string, 60)
|
||||
for i := range lines {
|
||||
lines[i] = fmt.Sprintf("ERROR-LINE-%02d", i+1)
|
||||
}
|
||||
module := &targetModule{snapshot: snapshot, result: app.Result{Snapshot: snapshot, Err: errors.New(strings.Join(lines, "\n"))}}
|
||||
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
model = updated.(Model)
|
||||
updated, _ = model.Update(command())
|
||||
model = updated.(Model)
|
||||
model = updateKey(model, "e")
|
||||
model = updateKey(model, "pgdown")
|
||||
model = updateKey(model, "pgdown")
|
||||
view := model.View()
|
||||
assertCellBoundedFrame(t, view, 80, 24, "ERROR-LINE-05")
|
||||
if strings.Contains(view, "ERROR-LINE-60") {
|
||||
t.Fatalf("expanded error unexpectedly jumped to its terminal offset:\n%s", view)
|
||||
}
|
||||
model = resize(model, 120, 36)
|
||||
assertCellBoundedFrame(t, model.View(), 120, 36, "ERROR-LINE-10")
|
||||
})
|
||||
|
||||
t.Run("arbitrary confirmation details and labels", func(t *testing.T) {
|
||||
snapshot := hardeningSnapshot()
|
||||
updates := make(chan app.OperationUpdate, 1)
|
||||
module := &interactiveScriptModule{snapshot: snapshot, streams: []chan app.OperationUpdate{updates}}
|
||||
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||
updated, command := model.Update(keyMessage("r"))
|
||||
model = updated.(Model)
|
||||
details := make([]string, 45)
|
||||
for i := range details {
|
||||
details[i] = fmt.Sprintf("CONFIRM-DETAIL-%02d", i+1)
|
||||
}
|
||||
updates <- app.ConfirmationOperationUpdate(snapshot, app.Confirmation{
|
||||
Title: "Long confirmation", Summary: "Review every detail", Details: details,
|
||||
CancelLabel: "CANCEL-START " + strings.Repeat("cancel ", 50) + "CANCEL-MIDDLE " + strings.Repeat("later ", 50) + "CANCEL-END",
|
||||
ConfirmLabel: "CONFIRM-START " + strings.Repeat("confirm ", 50) + "CONFIRM-MIDDLE " + strings.Repeat("later ", 50) + "CONFIRM-END",
|
||||
})
|
||||
model, _ = applyOperationCommand(t, model, command)
|
||||
model = updateKey(model, "pgdown")
|
||||
model = updateKey(model, "pgdown")
|
||||
view := model.View()
|
||||
assertCellBoundedFrame(t, view, 80, 24, "CONFIRM-DETAIL-10")
|
||||
if strings.Contains(view, "CONFIRM-DETAIL-45") {
|
||||
t.Fatalf("confirmation unexpectedly jumped to its terminal offset:\n%s", view)
|
||||
}
|
||||
model = resize(model, 120, 36)
|
||||
assertCellBoundedFrame(t, model.View(), 120, 36, "CONFIRM-DETAIL-10")
|
||||
model = resize(model, 80, 24)
|
||||
cancelMiddleAtNonterminal, confirmMiddleAtNonterminal := false, false
|
||||
for range 20 {
|
||||
model = updateKey(model, "pgdown")
|
||||
view = model.View()
|
||||
if strings.Contains(view, "CANCEL-MIDDLE") && !strings.Contains(view, "CANCEL-END") {
|
||||
cancelMiddleAtNonterminal = true
|
||||
}
|
||||
if strings.Contains(view, "CONFIRM-MIDDLE") && !strings.Contains(view, "CONFIRM-END") {
|
||||
confirmMiddleAtNonterminal = true
|
||||
}
|
||||
}
|
||||
if !cancelMiddleAtNonterminal || !confirmMiddleAtNonterminal {
|
||||
t.Fatalf("long confirmation labels were not reachable before their terminal offsets: cancel=%t confirm=%t\n%s", cancelMiddleAtNonterminal, confirmMiddleAtNonterminal, model.View())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompleteModelResizeStressPreservesDialogFocusSelectionViewportInFlightAndDeferredExit(t *testing.T) {
|
||||
snapshot := hardeningSnapshot()
|
||||
snapshot.Targets[1].Description.Details = strings.Repeat("APT scrollable detail. ", 90) + " APT-VIEWPORT-END"
|
||||
@@ -269,14 +372,116 @@ func TestCompleteModelResizeStressPreservesDialogFocusSelectionViewportInFlightA
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelRedactsAdversarialSecretsFromEveryRenderedIngress(t *testing.T) {
|
||||
const secret = "ticket11 secret"
|
||||
credential := "https://alice:" + secret + "@proxy.example:8080"
|
||||
hostile := "\x1b]2;hostile-title\x07\x1b[8m" + credential + "\r"
|
||||
func TestCompleteModelStripsThemeAllowlistedANSIAndTerminalControlsBeforeSemanticStyling(t *testing.T) {
|
||||
const plain = "INJECTED"
|
||||
attack := "\x1b[38;5;75m" + plain + "\x1b[0m\x1b[1;38;5;160m\x1b]8;;https://attacker.invalid\x07\x9b\x00\r"
|
||||
for _, mode := range []themeMode{themeModeDark, themeModeLight, themeModeNoColor} {
|
||||
t.Run(fmt.Sprintf("theme-%d", mode), func(t *testing.T) {
|
||||
got := completeModelInjectionFrames(t, mode, attack)
|
||||
want := completeModelInjectionFrames(t, mode, plain)
|
||||
if got != want {
|
||||
t.Fatalf("untrusted presentation text affected rendered styling\n--- hostile ---\n%q\n--- clean ---\n%q", got, want)
|
||||
}
|
||||
if strings.ContainsAny(got, "\x00\x07\r\x9b") || strings.Contains(got, "attacker.invalid") {
|
||||
t.Fatalf("terminal control payload survived complete-Model rendering: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func completeModelInjectionFrames(t *testing.T, mode themeMode, value string) string {
|
||||
t.Helper()
|
||||
snapshot := hardeningSnapshot()
|
||||
snapshot.ActiveProfile = value
|
||||
snapshot.Profiles[0] = profile.Profile{Name: value, Mode: profile.SingleMode, SingleURL: value, Bypass: []string{value}}
|
||||
snapshot.Targets[0].Description = target.Description{
|
||||
Name: value, Summary: value, Details: value, Scope: value,
|
||||
Permissions: value, Ownership: value, RestartRisk: value,
|
||||
}
|
||||
snapshot.Targets[0].Evidence = value
|
||||
snapshot.Targets[0].AvailabilityEvidence = value
|
||||
snapshot.Targets[0].PrimaryAction.DisabledReason = value
|
||||
|
||||
updates := make(chan app.OperationUpdate, 3)
|
||||
confirmations := make(chan app.OperationUpdate, 1)
|
||||
module := &interactiveScriptModule{snapshot: snapshot, streams: []chan app.OperationUpdate{updates, confirmations}}
|
||||
model := resizedModel(t, module, mode, 120, 36)
|
||||
frames := []string{model.View()}
|
||||
|
||||
model = updateKey(model, "?")
|
||||
frames = append(frames, model.View())
|
||||
model = updateKey(model, "q")
|
||||
model = updateKey(model, "p")
|
||||
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)})
|
||||
model = updated.(Model)
|
||||
frames = append(frames, model.View())
|
||||
model = updateKey(model, "esc")
|
||||
model = updateKey(model, "m")
|
||||
frames = append(frames, model.View())
|
||||
model = updateKey(model, "e")
|
||||
frames = append(frames, model.View())
|
||||
model = updateKey(model, "q")
|
||||
model = updateKey(model, "q")
|
||||
|
||||
updated, command := model.Update(keyMessage("r"))
|
||||
model = updated.(Model)
|
||||
updates <- app.RunningOperationUpdate(snapshot,
|
||||
app.OperationStatus{Summary: value, TargetID: "shell"},
|
||||
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressRunning, Evidence: value, Err: errors.New(value)}})
|
||||
model, command = applyOperationCommand(t, model, command)
|
||||
frames = append(frames, model.View())
|
||||
updates <- app.FailedOperationUpdate(snapshot,
|
||||
app.OperationStatus{Summary: value, TargetID: "shell"},
|
||||
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressFailed, Evidence: value, Err: errors.New(value)}},
|
||||
"shell", errors.New(value))
|
||||
close(updates)
|
||||
model, _ = applyOperationCommand(t, model, command)
|
||||
model = updateKey(model, "e")
|
||||
frames = append(frames, model.View())
|
||||
|
||||
updated, command = model.Update(keyMessage("r"))
|
||||
model = updated.(Model)
|
||||
confirmations <- app.ConfirmationOperationUpdate(snapshot, app.Confirmation{
|
||||
Title: value, Summary: value, Details: []string{value}, ConfirmLabel: value, CancelLabel: value,
|
||||
})
|
||||
model, _ = applyOperationCommand(t, model, command)
|
||||
frames = append(frames, model.View())
|
||||
return strings.Join(frames, "\n--- frame ---\n")
|
||||
}
|
||||
|
||||
func TestCompleteModelRedactsMalformedSecretMatrixFromEveryRenderedIngress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
secrets []string
|
||||
}{
|
||||
{"valid URL", "https://alice:valid-secret@proxy.example:8080", []string{"valid-secret"}},
|
||||
{"unsupported scheme CRLF spaces and slash", "odd+scheme://alice:line one/part\r\ntwo@proxy.example:8080", []string{"line one/part\r\ntwo"}},
|
||||
{"bare malformed userinfo", "alice:bare secret/part@proxy.example:8080", []string{"bare secret/part"}},
|
||||
{"multiple at signs", "http://alice:sec@ret@proxy.example:8080", []string{"sec@ret"}},
|
||||
{"multiple URLs", "first http://alice:first-secret@one.example:1 then invalid://bob:second secret@two.example:2", []string{"first-secret", "second secret"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
frames := completeModelSecretFrames(t, test.value)
|
||||
for _, secret := range test.secrets {
|
||||
assertSecretAbsent(t, frames, secret)
|
||||
}
|
||||
for _, surrounding := range []string{"before diagnostic", "after diagnostic"} {
|
||||
if !strings.Contains(frames, surrounding) {
|
||||
t.Fatalf("redaction swallowed surrounding diagnostics %q:\n%s", surrounding, frames)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func completeModelSecretFrames(t *testing.T, credential string) string {
|
||||
t.Helper()
|
||||
hostile := "before diagnostic \x1b]2;hostile-title\x07\x1b[8m" + credential + "\r after diagnostic"
|
||||
snapshot := hardeningSnapshot()
|
||||
snapshot.ActiveProfile = hostile
|
||||
snapshot.Profiles[0] = profile.Profile{Name: credential, Mode: profile.SingleMode, SingleURL: credential, Bypass: []string{credential}}
|
||||
snapshot.Targets[0].ID = credential
|
||||
snapshot.Targets[0].Description = target.Description{
|
||||
Name: credential, Summary: credential, Details: credential, Scope: credential,
|
||||
Permissions: credential, Ownership: credential, RestartRisk: credential,
|
||||
@@ -288,42 +493,45 @@ func TestCompleteModelRedactsAdversarialSecretsFromEveryRenderedIngress(t *testi
|
||||
confirmation := make(chan app.OperationUpdate, 1)
|
||||
module := &interactiveScriptModule{snapshot: snapshot, streams: []chan app.OperationUpdate{updates, confirmation}}
|
||||
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
||||
assertSecretAbsent(t, model.View(), secret)
|
||||
frames := []string{model.View()}
|
||||
|
||||
model = updateKey(model, "?")
|
||||
assertSecretAbsent(t, model.View(), secret)
|
||||
frames = append(frames, model.View())
|
||||
model = updateKey(model, "q")
|
||||
model = updateKey(model, "p")
|
||||
frames = append(frames, model.View())
|
||||
model = updateKey(model, "esc")
|
||||
model = updateKey(model, "m")
|
||||
assertSecretAbsent(t, model.View(), secret)
|
||||
frames = append(frames, model.View())
|
||||
model = updateKey(model, "e")
|
||||
assertSecretAbsent(t, model.View(), secret)
|
||||
frames = append(frames, model.View())
|
||||
model = updateKey(model, "q")
|
||||
model = updateKey(model, "q")
|
||||
|
||||
updated, command := model.Update(keyMessage("r"))
|
||||
model = updated.(Model)
|
||||
updates <- app.RunningOperationUpdate(snapshot,
|
||||
app.OperationStatus{Summary: "notice " + credential, TargetID: credential},
|
||||
[]app.TargetProgress{{TargetID: credential, Phase: app.TargetProgressRunning, Evidence: credential, Err: errors.New("runner output " + credential)}})
|
||||
app.OperationStatus{Summary: hostile, TargetID: "shell"},
|
||||
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressRunning, Evidence: hostile, Err: errors.New(hostile)}})
|
||||
model, command = applyOperationCommand(t, model, command)
|
||||
assertSecretAbsent(t, model.View(), secret)
|
||||
frames = append(frames, model.View())
|
||||
updates <- app.FailedOperationUpdate(snapshot,
|
||||
app.OperationStatus{Summary: "error " + credential, TargetID: credential},
|
||||
[]app.TargetProgress{{TargetID: credential, Phase: app.TargetProgressFailed, Evidence: credential, Err: errors.New("progress output " + credential)}},
|
||||
credential, errors.New("runner output "+credential))
|
||||
app.OperationStatus{Summary: hostile, TargetID: "shell"},
|
||||
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressFailed, Evidence: hostile, Err: errors.New(hostile)}},
|
||||
"shell", errors.New(hostile))
|
||||
close(updates)
|
||||
model, _ = applyOperationCommand(t, model, command)
|
||||
model = updateKey(model, "e")
|
||||
assertSecretAbsent(t, model.View(), secret)
|
||||
frames = append(frames, model.View())
|
||||
|
||||
updated, command = model.Update(keyMessage("r"))
|
||||
model = updated.(Model)
|
||||
confirmation <- app.ConfirmationOperationUpdate(snapshot, app.Confirmation{
|
||||
Title: "confirm " + credential, Summary: credential, Details: []string{credential},
|
||||
ConfirmLabel: "continue " + credential, CancelLabel: "cancel " + credential,
|
||||
Title: hostile, Summary: hostile, Details: []string{hostile}, ConfirmLabel: hostile, CancelLabel: hostile,
|
||||
})
|
||||
model, _ = applyOperationCommand(t, model, command)
|
||||
assertSecretAbsent(t, model.View(), secret)
|
||||
frames = append(frames, model.View())
|
||||
return strings.Join(frames, "\n--- frame ---\n")
|
||||
}
|
||||
|
||||
func hardeningSnapshot() app.Snapshot {
|
||||
|
||||
Reference in New Issue
Block a user