fix: close Ticket 11 hardening review gaps
This commit is contained in:
@@ -21,4 +21,4 @@
|
||||
|
||||
## Answer
|
||||
|
||||
Final hardening now exercises every completed workflow through the production Model at all supported size classes, checks semantic equivalence across light, dark, and `NO_COLOR`, uses cell-width bounds for Unicode content, rejects private-use/Nerd Font glyphs, and preserves complete interaction state through resize stress. The final rendering boundary redacts malformed as well as valid credential-bearing text, long Profile names and confirmation copy wrap into reachable viewports, and process tests prove alternate-screen restoration after normal and safely deferred exit. README documents the final keyboard, responsive, contextual-action, accessibility, lifecycle, and privacy contracts.
|
||||
Final hardening now exercises every completed workflow through the production Model at all supported size classes, checks semantic equivalence across light, dark, and `NO_COLOR`, uses cell-width bounds for Unicode content, rejects private-use/Nerd Font glyphs, and preserves complete interaction state through resize stress. Every untrusted ingress is credential-redacted and stripped of terminal controls before semantic styling; the final frame pass only validates controls and does not reparse clipped prose. Long fields, Bypass previews, errors, and arbitrary confirmation copy remain scrollable through resize at nonterminal offsets. Real subprocess tests under a PTY prove alternate-screen restoration after normal exit and after exit is deferred for an already-blocked Target mutation. Unprivileged hermetic verification hashes all readable host paths and compares stable metadata plus explicit markers for protected paths without sudo. README documents the final keyboard, responsive, contextual-action, accessibility, lifecycle, and privacy contracts.
|
||||
|
||||
@@ -2,12 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"proxyctl/internal/profile"
|
||||
"proxyctl/internal/target"
|
||||
@@ -88,76 +86,6 @@ func TestDefaultInvocationUsesAlternateScreenAndRestoresItOnExit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type blockingDetectionTarget struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (t *blockingDetectionTarget) ID() string { return "blocking-test" }
|
||||
func (t *blockingDetectionTarget) Description() target.Description {
|
||||
return target.Description{Name: "Blocking test Target"}
|
||||
}
|
||||
func (t *blockingDetectionTarget) Availability(context.Context) target.Availability {
|
||||
return target.Availability{Available: true}
|
||||
}
|
||||
func (t *blockingDetectionTarget) Inspect(context.Context, *profile.Profile) target.Inspection {
|
||||
return target.Inspection{State: target.Disabled}
|
||||
}
|
||||
func (t *blockingDetectionTarget) Confirmation(bool) string { return "" }
|
||||
func (t *blockingDetectionTarget) SetDesired(context.Context, bool, *profile.Profile) target.OperationResult {
|
||||
return target.OperationResult{}
|
||||
}
|
||||
func (t *blockingDetectionTarget) Detect(context.Context, *profile.Profile) target.Detection {
|
||||
select {
|
||||
case t.started <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-t.release
|
||||
return target.Detection{
|
||||
Availability: target.Availability{Available: true, Evidence: "hermetic test Target"},
|
||||
Inspection: target.Inspection{State: target.Disabled, Evidence: "no configuration"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeferredExitCompletesSafelyAndRestoresAlternateScreen(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
adapter := &blockingDetectionTarget{started: make(chan struct{}, 1), release: make(chan struct{})}
|
||||
var output bytes.Buffer
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- runWithSystemTargets(nil, strings.NewReader("q"), &output, []target.Target{adapter})
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-adapter.started:
|
||||
case err := <-done:
|
||||
t.Fatalf("TUI exited before progressive startup began: %v", err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("progressive startup did not reach the hermetic Target")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("deferred exit returned before safe operation completion: %v", err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
close(adapter.release)
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("run proxyctl: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("TUI did not exit after safe operation completion")
|
||||
}
|
||||
|
||||
frame := output.String()
|
||||
enter := strings.Index(frame, "\x1b[?1049h")
|
||||
leave := strings.LastIndex(frame, "\x1b[?1049l")
|
||||
if enter < 0 || leave <= enter {
|
||||
t.Fatalf("deferred exit did not enter and restore the alternate screen in order: %q", frame)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultInvocationLoadsPersistedActiveProfile(t *testing.T) {
|
||||
xdg := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdg)
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
func TestRealPTYProcessRestoresAlternateScreenOnNormalAndDeferredMutationExit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
binary := filepath.Join(root, "bin", "proxyctl")
|
||||
if err := os.MkdirAll(filepath.Dir(binary), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
build := exec.Command("go", "build", "-tags", "proxyctl_process_test", "-o", binary, ".")
|
||||
if output, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build process fixture: %v\n%s", err, output)
|
||||
}
|
||||
|
||||
t.Run("normal exit", func(t *testing.T) {
|
||||
process := startPTYProcess(t, binary, isolatedPTYEnvironment(t, filepath.Join(root, "normal")))
|
||||
process.waitForOutput(t, "Target availability and state checks completed")
|
||||
process.write(t, "q")
|
||||
process.waitForExit(t)
|
||||
assertAlternateScreenRestored(t, process.output())
|
||||
})
|
||||
|
||||
t.Run("exit deferred after Target mutation starts", func(t *testing.T) {
|
||||
processRoot := filepath.Join(root, "deferred")
|
||||
environment := isolatedPTYEnvironment(t, processRoot)
|
||||
fifo := filepath.Join(processRoot, "mutation-release.fifo")
|
||||
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
started := filepath.Join(processRoot, "mutation-started")
|
||||
environment = append(environment,
|
||||
"PROXYCTL_PROCESS_TEST_MUTATION_FIFO="+fifo,
|
||||
"PROXYCTL_PROCESS_TEST_MUTATION_STARTED="+started,
|
||||
)
|
||||
writeConfig(t, filepath.Join(processRoot, "config", "proxyctl", "profiles.json"), `{
|
||||
"profiles": [{"name":"office","mode":"single","singleUrl":"http://proxy.example:8080"}],
|
||||
"activeProfile": "office"
|
||||
}`)
|
||||
|
||||
process := startPTYProcess(t, binary, environment)
|
||||
process.waitForOutput(t, "Disabled Blocking mutation Target")
|
||||
process.write(t, "\x1b[B ")
|
||||
waitForPath(t, started)
|
||||
process.write(t, "\x03")
|
||||
process.waitForOutput(t, "Exit requested — waiting for the current mutation to finish safely")
|
||||
process.assertRunning(t)
|
||||
|
||||
release, err := os.OpenFile(fifo, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open mutation release FIFO: %v", err)
|
||||
}
|
||||
if _, err := release.Write([]byte("release\n")); err != nil {
|
||||
t.Fatalf("release mutation: %v", err)
|
||||
}
|
||||
if err := release.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process.waitForExit(t)
|
||||
assertAlternateScreenRestored(t, process.output())
|
||||
})
|
||||
}
|
||||
|
||||
type ptyProcess struct {
|
||||
command *exec.Cmd
|
||||
terminal *os.File
|
||||
done chan error
|
||||
readDone chan struct{}
|
||||
changed chan struct{}
|
||||
mu sync.Mutex
|
||||
buffer bytes.Buffer
|
||||
}
|
||||
|
||||
func startPTYProcess(t *testing.T, binary string, environment []string) *ptyProcess {
|
||||
t.Helper()
|
||||
command := exec.Command(binary)
|
||||
command.Env = environment
|
||||
terminal, err := pty.StartWithSize(command, &pty.Winsize{Rows: 36, Cols: 120})
|
||||
if err != nil {
|
||||
t.Fatalf("start process under PTY: %v", err)
|
||||
}
|
||||
process := &ptyProcess{
|
||||
command: command, terminal: terminal, done: make(chan error, 1),
|
||||
readDone: make(chan struct{}), changed: make(chan struct{}, 1),
|
||||
}
|
||||
go func() {
|
||||
_, _ = io.Copy(process, terminal)
|
||||
close(process.readDone)
|
||||
}()
|
||||
go func() { process.done <- command.Wait() }()
|
||||
t.Cleanup(func() {
|
||||
_ = terminal.Close()
|
||||
if command.Process != nil {
|
||||
_ = command.Process.Kill()
|
||||
}
|
||||
})
|
||||
return process
|
||||
}
|
||||
|
||||
func (p *ptyProcess) Write(data []byte) (int, error) {
|
||||
p.mu.Lock()
|
||||
_, _ = p.buffer.Write(data)
|
||||
p.mu.Unlock()
|
||||
select {
|
||||
case p.changed <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
func (p *ptyProcess) write(t *testing.T, value string) {
|
||||
t.Helper()
|
||||
if _, err := p.terminal.WriteString(value); err != nil {
|
||||
t.Fatalf("write PTY input: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ptyProcess) output() string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.buffer.String()
|
||||
}
|
||||
|
||||
func (p *ptyProcess) waitForOutput(t *testing.T, expected string) {
|
||||
t.Helper()
|
||||
timer := time.NewTimer(10 * time.Second)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
if strings.Contains(p.output(), expected) {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-p.changed:
|
||||
case err := <-p.done:
|
||||
t.Fatalf("process exited before output %q: %v\n%s", expected, err, p.output())
|
||||
case <-timer.C:
|
||||
t.Fatalf("timed out waiting for output %q\n%s", expected, p.output())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ptyProcess) assertRunning(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-p.done:
|
||||
t.Fatalf("process exited while Target mutation was blocked: %v\n%s", err, p.output())
|
||||
default:
|
||||
}
|
||||
if err := p.command.Process.Signal(syscall.Signal(0)); err != nil {
|
||||
t.Fatalf("blocked process is not alive: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ptyProcess) waitForExit(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-p.done:
|
||||
if err != nil {
|
||||
t.Fatalf("PTY process exited with error: %v\n%s", err, p.output())
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("timed out waiting for PTY process exit\n%s", p.output())
|
||||
}
|
||||
_ = p.terminal.Close()
|
||||
select {
|
||||
case <-p.readDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("PTY output reader did not finish")
|
||||
}
|
||||
}
|
||||
|
||||
func isolatedPTYEnvironment(t *testing.T, root string) []string {
|
||||
t.Helper()
|
||||
for _, path := range []string{filepath.Join(root, "home"), filepath.Join(root, "config"), filepath.Join(root, "state")} {
|
||||
if err := os.MkdirAll(path, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return append(isolatedProcessEnvironment(root), "TERM=xterm-256color", "NO_COLOR=1")
|
||||
}
|
||||
|
||||
func waitForPath(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
timer := time.NewTimer(10 * time.Second)
|
||||
defer timer.Stop()
|
||||
ticker := time.NewTicker(5 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("inspect synchronization path: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-timer.C:
|
||||
t.Fatalf("timed out waiting for synchronization path %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertAlternateScreenRestored(t *testing.T, output string) {
|
||||
t.Helper()
|
||||
enter := strings.Index(output, "\x1b[?1049h")
|
||||
leave := strings.LastIndex(output, "\x1b[?1049l")
|
||||
if enter < 0 || leave <= enter {
|
||||
t.Fatalf("alternate screen was not entered and restored in order: %q", output)
|
||||
}
|
||||
}
|
||||
@@ -18,21 +18,47 @@ type processResetTarget struct{ marker string }
|
||||
|
||||
func (t processResetTarget) ID() string { return "docker-daemon-test" }
|
||||
func (t processResetTarget) Description() target.Description {
|
||||
if os.Getenv("PROXYCTL_PROCESS_TEST_MUTATION_FIFO") != "" {
|
||||
return target.Description{Name: "Blocking mutation Target"}
|
||||
}
|
||||
return target.Description{Name: "Docker daemon"}
|
||||
}
|
||||
func (t processResetTarget) Availability(context.Context) target.Availability {
|
||||
return target.Availability{Available: true, Evidence: "isolated process fixture"}
|
||||
}
|
||||
func (t processResetTarget) Inspect(context.Context, *profile.Profile) target.Inspection {
|
||||
if os.Getenv("PROXYCTL_PROCESS_TEST_MUTATION_FIFO") != "" {
|
||||
return target.Inspection{State: target.Disabled, Evidence: "ready for isolated blocked mutation"}
|
||||
}
|
||||
if _, err := os.Stat(t.marker); err == nil {
|
||||
return target.Inspection{State: target.Disabled, Evidence: "isolated managed configuration removed"}
|
||||
}
|
||||
return target.Inspection{State: target.Drifted, Evidence: "isolated recovery metadata exists"}
|
||||
}
|
||||
func (t processResetTarget) Confirmation(bool) string {
|
||||
if os.Getenv("PROXYCTL_PROCESS_TEST_MUTATION_FIFO") != "" {
|
||||
return ""
|
||||
}
|
||||
return "Docker will restart immediately. This may disrupt running containers and other work."
|
||||
}
|
||||
func (t processResetTarget) SetDesired(context.Context, bool, *profile.Profile) target.OperationResult {
|
||||
func (t processResetTarget) SetDesired(ctx context.Context, enabled bool, active *profile.Profile) target.OperationResult {
|
||||
if fifo := os.Getenv("PROXYCTL_PROCESS_TEST_MUTATION_FIFO"); fifo != "" {
|
||||
started := os.Getenv("PROXYCTL_PROCESS_TEST_MUTATION_STARTED")
|
||||
if err := os.WriteFile(started, []byte("started\n"), 0o600); err != nil {
|
||||
return target.OperationResult{Inspection: t.Inspect(ctx, active), Err: err}
|
||||
}
|
||||
release, err := os.Open(fifo)
|
||||
if err == nil {
|
||||
var signal [1]byte
|
||||
_, err = release.Read(signal[:])
|
||||
_ = release.Close()
|
||||
}
|
||||
state := target.Disabled
|
||||
if enabled {
|
||||
state = target.Enabled
|
||||
}
|
||||
return target.OperationResult{Inspection: target.Inspection{State: state, Evidence: "isolated mutation completed"}, Err: err}
|
||||
}
|
||||
err := os.WriteFile(t.marker, []byte("reset\n"), 0o600)
|
||||
return target.OperationResult{Inspection: t.Inspect(context.Background(), nil), Err: err}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ require (
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/muesli/termenv v0.16.0
|
||||
golang.org/x/sys v0.36.0
|
||||
)
|
||||
|
||||
@@ -16,6 +16,8 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
|
||||
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/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
|
||||
+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":
|
||||
|
||||
+11
-3
@@ -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
|
||||
}
|
||||
@@ -179,8 +181,9 @@ func (p terminalPresentation) profilesView(model presentationState) terminalPage
|
||||
return terminalPage{
|
||||
header: p.theme.title.Render("Proxy Profile management"),
|
||||
primary: model.profileDelete.dialog.view(),
|
||||
primaryOffset: model.profileDelete.dialog.offset,
|
||||
wrapPrimary: true,
|
||||
footer: "←/→/Tab: choose Enter: choose y: delete\nn/q/Esc: cancel ?: help",
|
||||
footer: "←/→/Tab: choose PgUp/PgDn: scroll Enter: choose y: delete\nn/q/Esc: cancel ?: help",
|
||||
focusLine: -1,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,15 +12,45 @@ snapshot_path() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Keep traversal, metadata collection, hashing, and sorting as separate
|
||||
# commands so set -e observes every failure without relying on pipefail.
|
||||
find -H "$path" -xdev \
|
||||
# An unprivileged CI user cannot traverse every protected system root. Keep
|
||||
# a stable top-level observation for those roots instead of using sudo or
|
||||
# failing before the tests run. Readable roots are still traversed fully and
|
||||
# every readable regular file is content-hashed.
|
||||
stat --printf='top-metadata\t%F\t%a\t%u\t%g\t%N\n' -- "$path"
|
||||
if { [ -d "$path" ] && { [ ! -r "$path" ] || [ ! -x "$path" ]; }; } || \
|
||||
{ [ ! -d "$path" ] && [ ! -r "$path" ]; };
|
||||
then
|
||||
printf 'unreadable\t%s\n' "$path"
|
||||
return
|
||||
fi
|
||||
|
||||
traversal_complete=true
|
||||
if ! find -H "$path" -xdev \
|
||||
-exec stat --printf='metadata\t%F\t%a\t%u\t%g\t%N\n' -- {} + \
|
||||
>"$work/snapshot-metadata"
|
||||
>"$work/snapshot-metadata" 2>/dev/null
|
||||
then
|
||||
traversal_complete=false
|
||||
fi
|
||||
LC_ALL=C sort "$work/snapshot-metadata"
|
||||
find -H "$path" -xdev -type f -exec sha256sum -- {} + \
|
||||
>"$work/snapshot-content"
|
||||
|
||||
if ! find -H "$path" -xdev \
|
||||
\( \( -type d \( ! -readable -o ! -executable \) \) -o \( -type f ! -readable \) \) \
|
||||
-printf='unreadable\t%p\n' >"$work/snapshot-unreadable" 2>/dev/null
|
||||
then
|
||||
traversal_complete=false
|
||||
fi
|
||||
LC_ALL=C sort "$work/snapshot-unreadable"
|
||||
|
||||
if ! find -H "$path" -xdev -type f -readable -exec sha256sum -- {} + \
|
||||
>"$work/snapshot-content" 2>/dev/null
|
||||
then
|
||||
traversal_complete=false
|
||||
fi
|
||||
LC_ALL=C sort "$work/snapshot-content"
|
||||
|
||||
if [ "$traversal_complete" = false ]; then
|
||||
printf 'unreadable-descendant\t%s\n' "$path"
|
||||
fi
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
@@ -50,4 +80,4 @@ snapshot >"$work/before"
|
||||
)
|
||||
snapshot >"$work/after"
|
||||
cmp "$work/before" "$work/after"
|
||||
printf 'full Go suite passed without changing host Shell, APT, Docker, GNOME, or system-service state\n'
|
||||
printf 'full Go suite passed unprivileged: readable host configuration content and metadata were unchanged; protected roots retained the same top metadata and unreadable markers; no sudo was used\n'
|
||||
|
||||
Reference in New Issue
Block a user