Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions internal/output/nocolor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package output

import (
"strings"
"testing"

"github.com/charmbracelet/lipgloss"
)

// regression guard: Highlight.Render must strip embedded control/OSC bytes
// from its argument before styling it, even under NO_COLOR, so an
// attacker-controlled string (a response title, header, ...) can't rewrite
// the terminal title or move the cursor when it's highlighted for display.
func TestHighlightRenderStripsEmbeddedESC(t *testing.T) {
t.Setenv("NO_COLOR", "1")
lipgloss.SetColorProfile(4)
evil := "\x1b]0;PWNED\x07x"
out := Highlight.Render(evil)
if strings.Contains(out, "\x1b]0;PWNED\x07") {
t.Fatalf("NO_COLOR path let attacker OSC sequence through: %q", out)
}
if !strings.Contains(out, "x") {
t.Fatalf("legitimate content was lost, got %q", out)
}
t.Logf("CONFIRMED: Render strips attacker control bytes from content: %q", out)
}
119 changes: 104 additions & 15 deletions internal/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"os"
"strings"
"sync"
"unicode/utf8"

"github.com/charmbracelet/lipgloss"
)
Expand All @@ -42,9 +43,9 @@ var (

// Text styles
var (
Highlight = lipgloss.NewStyle().Bold(true).Foreground(ColorWhite)
Muted = lipgloss.NewStyle().Foreground(ColorGray)
Status = lipgloss.NewStyle().Bold(true).Foreground(ColorGreen)
Highlight = Style{lipgloss.NewStyle().Bold(true).Foreground(ColorWhite)}
Muted = Style{lipgloss.NewStyle().Foreground(ColorGray)}
Status = Style{lipgloss.NewStyle().Bold(true).Foreground(ColorGreen)}
)

// Box style for banners
Expand All @@ -68,10 +69,10 @@ var Subheading = lipgloss.NewStyle().

// Severity styles
var (
SeverityLow = lipgloss.NewStyle().Foreground(ColorGreen)
SeverityMedium = lipgloss.NewStyle().Foreground(ColorYellow)
SeverityHigh = lipgloss.NewStyle().Foreground(lipgloss.Color("#f97316")) // orange
SeverityCritical = lipgloss.NewStyle().Foreground(ColorRed).Bold(true)
SeverityLow = Style{lipgloss.NewStyle().Foreground(ColorGreen)}
SeverityMedium = Style{lipgloss.NewStyle().Foreground(ColorYellow)}
SeverityHigh = Style{lipgloss.NewStyle().Foreground(lipgloss.Color("#f97316"))}
SeverityCritical = Style{lipgloss.NewStyle().Foreground(ColorRed).Bold(true)}
)

// Module color palette - visually distinct, nice colors
Expand Down Expand Up @@ -196,6 +197,94 @@ func Writer() io.Writer {
return sink
}

// Style wraps a lipgloss.Style so Render sanitizes its arguments first. Styles
// used to highlight response-derived content are declared with this type so
// every call site gets the control-byte stripping in Sanitize for free,
// instead of every caller having to remember to sanitize before styling.
type Style struct {
lipgloss.Style
}

// Render sanitizes strs before delegating to the wrapped lipgloss.Style.
func (s *Style) Render(strs ...string) string {
clean := make([]string, len(strs))
for i, v := range strs {
clean[i] = Sanitize(v)
}
return s.Style.Render(clean...)
}

// Sanitize strips terminal control bytes from s so attacker-controlled
// content (page titles, response bodies, whois records, ...) can't rewrite
// the terminal title, move the cursor, or clear the screen. \t, \n, and SGR
// color sequences (ESC [ ... m, what lipgloss emits) survive; every other
// C0/C1 control byte, DEL, OSC sequence, and non-SGR CSI sequence is dropped.
func Sanitize(s string) string {
var b strings.Builder
b.Grow(len(s))

for i := 0; i < len(s); {
c := s[i]

if c == 0x1b { // ESC
if i+1 < len(s) && s[i+1] == '[' { // CSI: ESC [ params... final
j := i + 2
for j < len(s) && s[j] >= 0x20 && s[j] <= 0x3f {
j++
}
if j < len(s) && s[j] >= 0x40 && s[j] <= 0x7e {
final := s[j]
j++
if final == 'm' {
b.WriteString(s[i:j]) // SGR: keep (color/bold/reset)
}
i = j
continue
}
// malformed/unterminated CSI: drop what we've consumed
i = j
continue
}
if i+1 < len(s) && s[i+1] == ']' { // OSC: ESC ] ... BEL | ESC \
j := i + 2
for j < len(s) {
if s[j] == 0x07 {
j++
break
}
if s[j] == 0x1b && j+1 < len(s) && s[j+1] == '\\' {
j += 2
break
}
j++
}
i = j
continue
}
// bare/unrecognized ESC: drop just the byte
i++
continue
}

r, size := utf8.DecodeRuneInString(s[i:])
switch {
case r == '\t' || r == '\n':
b.WriteRune(r)
case r < 0x20:
// other C0 controls (CR, BEL, backspace, ...): drop
case r == 0x7f:
// DEL: drop
case r >= 0x80 && r <= 0x9f:
// C1 controls: drop
default:
b.WriteRune(r)
}
i += size
}

return b.String()
}

// Sink is a routable output destination: the writer chrome lands on, plus
// whether interactive widgets (spinners, live progress) may animate on it. A
// scan can be handed its own Sink so its chrome routes to a chosen writer.
Expand Down Expand Up @@ -228,31 +317,31 @@ func (s *Sink) Info(format string, args ...interface{}) {
if apiMode {
return
}
fmt.Fprintf(s.w, "%s %s\n", prefixInfo.Render("[*]"), fmt.Sprintf(format, args...))
fmt.Fprintf(s.w, "%s %s\n", prefixInfo.Render("[*]"), Sanitize(fmt.Sprintf(format, args...)))
}

// Success logs a [+]-prefixed message; a no-op in API mode.
func (s *Sink) Success(format string, args ...interface{}) {
if apiMode {
return
}
fmt.Fprintf(s.w, "%s %s\n", prefixSuccess.Render("[+]"), fmt.Sprintf(format, args...))
fmt.Fprintf(s.w, "%s %s\n", prefixSuccess.Render("[+]"), Sanitize(fmt.Sprintf(format, args...)))
}

// Warn logs a [!]-prefixed message; a no-op in API mode.
func (s *Sink) Warn(format string, args ...interface{}) {
if apiMode {
return
}
fmt.Fprintf(s.w, "%s %s\n", prefixWarning.Render("[!]"), fmt.Sprintf(format, args...))
fmt.Fprintf(s.w, "%s %s\n", prefixWarning.Render("[!]"), Sanitize(fmt.Sprintf(format, args...)))
}

// Error logs a [-]-prefixed message; a no-op in API mode.
func (s *Sink) Error(format string, args ...interface{}) {
if apiMode {
return
}
fmt.Fprintf(s.w, "%s %s\n", prefixError.Render("[-]"), fmt.Sprintf(format, args...))
fmt.Fprintf(s.w, "%s %s\n", prefixError.Render("[-]"), Sanitize(fmt.Sprintf(format, args...)))
}

func Info(format string, args ...interface{}) { DefaultSink().Info(format, args...) }
Expand Down Expand Up @@ -308,7 +397,7 @@ func (m *ModuleLogger) Info(format string, args ...interface{}) {
if apiMode {
return
}
msg := fmt.Sprintf(format, args...)
msg := Sanitize(fmt.Sprintf(format, args...))
fmt.Fprintf(m.sink.w, "%s %s\n", m.prefix(), msg)
}

Expand All @@ -317,7 +406,7 @@ func (m *ModuleLogger) Success(format string, args ...interface{}) {
if apiMode {
return
}
msg := fmt.Sprintf(format, args...)
msg := Sanitize(fmt.Sprintf(format, args...))
fmt.Fprintf(m.sink.w, "%s %s %s\n", m.prefix(), prefixSuccess.Render("✓"), msg)
}

Expand All @@ -326,7 +415,7 @@ func (m *ModuleLogger) Warn(format string, args ...interface{}) {
if apiMode {
return
}
msg := fmt.Sprintf(format, args...)
msg := Sanitize(fmt.Sprintf(format, args...))
fmt.Fprintf(m.sink.w, "%s %s %s\n", m.prefix(), prefixWarning.Render("!"), msg)
}

Expand All @@ -335,7 +424,7 @@ func (m *ModuleLogger) Error(format string, args ...interface{}) {
if apiMode {
return
}
msg := fmt.Sprintf(format, args...)
msg := Sanitize(fmt.Sprintf(format, args...))
fmt.Fprintf(m.sink.w, "%s %s %s\n", m.prefix(), prefixError.Render("✗"), msg)
}

Expand Down
9 changes: 6 additions & 3 deletions internal/output/progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"fmt"
"sync"
"sync/atomic"
"unicode/utf8"
)

// Progress bar configuration
Expand Down Expand Up @@ -177,10 +178,12 @@ func (p *Progress) render() {
}
}

// Truncate item if too long
// rune-aware so a multibyte character straddling the cut point isn't
// split into invalid UTF-8.
maxItemLen := 30
if len(lastItem) > maxItemLen {
lastItem = lastItem[:maxItemLen-3] + "..."
if runeCount := utf8.RuneCountInString(lastItem); runeCount > maxItemLen {
runes := []rune(lastItem)
lastItem = string(runes[:maxItemLen-3]) + "..."
}

// Format: [========> ] 45% (4500/10000) /admin
Expand Down
88 changes: 88 additions & 0 deletions internal/output/sanitize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package output

import (
"bufio"
"bytes"
"net"
"net/http"
"strings"
"testing"
"time"
)

// regression guard: Info (and by extension Success/Warn/Error/ModuleLogger.*)
// must strip raw ANSI/OSC control bytes from the formatted message before it
// reaches the sink, even when a caller forgets to route dynamic content
// through a Style first.
func TestInfoStripsRawANSI(t *testing.T) {
var buf bytes.Buffer
old := sink
sink = &buf
defer func() { sink = old }()

// ESC]0;PWNED BEL -> rewrites the terminal title; ESC[2J clears screen.
evil := "\x1b]0;PWNED\x07\x1b[2Jheader-value"
Info("%s", evil)

got := buf.String()
if strings.Contains(got, "\x1b]0;PWNED\x07") {
t.Fatalf("expected OSC title sequence to be stripped, got %q", got)
}
if strings.Contains(got, "\x1b[2J") {
t.Fatalf("expected clear-screen sequence to be stripped, got %q", got)
}
if !strings.Contains(got, "header-value") {
t.Fatalf("expected legitimate content to survive, got %q", got)
}
t.Logf("CONFIRMED: Info sanitizes control bytes before printing: %q", got)
}

// SGR color sequences (what lipgloss emits) must survive sanitization:
// Sanitize is meant to strip attacker control bytes, not legitimate styling.
func TestSanitizeKeepsSGRColor(t *testing.T) {
styled := "\x1b[1;38;5;231mhello\x1b[0m"
got := Sanitize(styled)
if got != styled {
t.Fatalf("expected SGR sequence to survive sanitize, got %q want %q", got, styled)
}
}

// documents an existing protection: Go's net/http rejects control bytes in
// response header values at the transport layer, so headers.go's print path
// can't carry a raw ESC through a header value.
func TestHTTPHeaderRejectsESC(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()

go func() {
c, err := ln.Accept()
if err != nil {
return
}
defer c.Close()
br := bufio.NewReader(c)
for {
line, err := br.ReadString('\n')
if err != nil || line == "\r\n" {
break
}
}
resp := "HTTP/1.1 200 OK\r\n" +
"X-Evil: \x1b]0;PWNED\x07pwn\r\n" +
"Content-Length: 0\r\n\r\n"
_, _ = c.Write([]byte(resp))
}()

client := &http.Client{Timeout: 2 * time.Second}
r, err := client.Get("http://" + ln.Addr().String())
if err == nil {
defer r.Body.Close()
t.Fatalf("expected Go http to reject control chars in header value, got %q", r.Header.Get("X-Evil"))
}
if !strings.Contains(err.Error(), "malformed MIME header") {
t.Fatalf("unexpected error: %v", err)
}
}
3 changes: 1 addition & 2 deletions internal/scan/cloudstorage.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"

Expand Down Expand Up @@ -69,7 +68,7 @@ func CloudStorage(url string, timeout time.Duration, logdir string) ([]CloudStor
}
}

cloudlog := log.NewWithOptions(os.Stderr, log.Options{
cloudlog := log.NewWithOptions(output.Writer(), log.Options{
Prefix: "C3",
}).With("url", url)

Expand Down
4 changes: 2 additions & 2 deletions internal/scan/js/supabase.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import (
"fmt"
"io"
"net/http"
"os"
"regexp"
"slices"
"strconv"
Expand All @@ -32,6 +31,7 @@ import (

"github.com/charmbracelet/log"
"github.com/vmfunc/sif/internal/httpx"
"github.com/vmfunc/sif/internal/output"
)

// jwtRegex matches JWT tokens in JavaScript content.
Expand Down Expand Up @@ -175,7 +175,7 @@ func doSupabaseRequest(projectId, path, apikey string, auth *string, timeout tim
}

func ScanSupabase(jsContent string, jsUrl string, timeout time.Duration) ([]supabaseScanResult, error) {
supabaselog := log.NewWithOptions(os.Stderr, log.Options{
supabaselog := log.NewWithOptions(output.Writer(), log.Options{
Prefix: "JavaScript > Supabase",
}).With("url", jsUrl)

Expand Down
2 changes: 1 addition & 1 deletion internal/scan/nuclei.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func Nuclei(url string, timeout time.Duration, threads int, logdir string) ([]ou
spin := sifoutput.NewSpinner("Running nuclei templates")
spin.Start()

nucleilog := log.NewWithOptions(os.Stderr, log.Options{
nucleilog := log.NewWithOptions(sifoutput.Writer(), log.Options{
Prefix: "nuclei",
}).With("url", url)

Expand Down
Loading
Loading