Files
blackbox/pkg/makesafe/makesafe.go
Tom Limoncelli 1c77c87555 Implement blackbox in Golang (#250)
* Initial release
2020-07-24 14:21:33 -04:00

286 lines
7.8 KiB
Go

package makesafe
// untaint -- A string with a Stringer that is shell safe.
// This goes to great lengths to make sure the String() is pastable.
// Whitespace and shell "special chars" are handled as expected.
// However to be extra paranoid, unicode is turned into backtick
// printf statements. I don't know anyone that puts unicode in their
// filenames, but I hope they appreciate this.
// Most people would just use strconv.QuoteToGraphic() but I'm a
// control freak.
import (
"fmt"
"strings"
"unicode"
)
type protection int
const (
// Unknown indicates we don't know if it is safe.
Unknown protection = iota
// None requires no special escaping.
None // Nothing special
// SingleQuote is unsafe in bash and requires a single quote.
SingleQuote // Requires at least a single quote
// DoubleQuote is unsafe in bash and requires escaping or other double-quote features.
DoubleQuote // Can only be in a double-quoted string
)
const (
// IsAQuote is either a `'` or `"`
IsAQuote = None
// IsSpace is ascii 32
IsSpace = SingleQuote
// ShellUnsafe is ()!$ or other bash special char
ShellUnsafe = SingleQuote
// GlobUnsafe means could be a glob char (* or ?)
GlobUnsafe = SingleQuote
// InterpolationUnsafe used in bash string interpolation ($)
InterpolationUnsafe = SingleQuote
// HasBackslash things like \n \t \r \000 \xFF
HasBackslash = DoubleQuote
)
func max(i, j protection) protection {
if i > j {
return i
}
return j
}
type tabEntry struct {
level protection
fn func(s rune) string
}
var tab [128]tabEntry
func init() {
for i := 0; i <= 31; i++ { // Control chars
tab[i] = tabEntry{HasBackslash, oct()}
}
tab['\t'] = tabEntry{HasBackslash, literal(`\t`)} // Override
tab['\n'] = tabEntry{HasBackslash, literal(`\n`)} // Override
tab['\r'] = tabEntry{HasBackslash, literal(`\r`)} // Override
tab[' '] = tabEntry{IsSpace, same()}
tab['!'] = tabEntry{ShellUnsafe, same()}
tab['"'] = tabEntry{IsAQuote, same()}
tab['#'] = tabEntry{ShellUnsafe, same()}
tab['@'] = tabEntry{InterpolationUnsafe, same()}
tab['$'] = tabEntry{InterpolationUnsafe, same()}
tab['%'] = tabEntry{InterpolationUnsafe, same()}
tab['&'] = tabEntry{ShellUnsafe, same()}
tab['\''] = tabEntry{IsAQuote, same()}
tab['('] = tabEntry{ShellUnsafe, same()}
tab[')'] = tabEntry{ShellUnsafe, same()}
tab['*'] = tabEntry{GlobUnsafe, same()}
tab['+'] = tabEntry{GlobUnsafe, same()}
tab[','] = tabEntry{None, same()}
tab['-'] = tabEntry{None, same()}
tab['.'] = tabEntry{None, same()}
tab['/'] = tabEntry{None, same()}
for i := '0'; i <= '9'; i++ {
tab[i] = tabEntry{None, same()}
}
tab[':'] = tabEntry{InterpolationUnsafe, same()} // ${foo:=default}
tab[';'] = tabEntry{ShellUnsafe, same()}
tab['<'] = tabEntry{ShellUnsafe, same()}
tab['='] = tabEntry{InterpolationUnsafe, same()} // ${foo:=default}
tab['>'] = tabEntry{ShellUnsafe, same()}
tab['?'] = tabEntry{GlobUnsafe, same()}
tab['@'] = tabEntry{InterpolationUnsafe, same()} // ${myarray[@]};
for i := 'A'; i <= 'Z'; i++ {
tab[i] = tabEntry{None, same()}
}
tab['['] = tabEntry{ShellUnsafe, same()}
tab['\\'] = tabEntry{ShellUnsafe, same()}
tab[']'] = tabEntry{GlobUnsafe, same()}
tab['^'] = tabEntry{GlobUnsafe, same()}
tab['_'] = tabEntry{None, same()}
tab['`'] = tabEntry{ShellUnsafe, same()}
for i := 'a'; i <= 'z'; i++ {
tab[i] = tabEntry{None, same()}
}
tab['{'] = tabEntry{ShellUnsafe, same()}
tab['|'] = tabEntry{ShellUnsafe, same()}
tab['}'] = tabEntry{ShellUnsafe, same()}
tab['~'] = tabEntry{ShellUnsafe, same()}
tab[127] = tabEntry{HasBackslash, oct()}
// Check our work. All indexes should have been set.
for i, e := range tab {
if e.level == 0 || e.fn == nil {
panic(fmt.Sprintf("tabEntry %d not set!", i))
}
}
}
// literal return this exact string.
func literal(s string) func(s rune) string {
return func(rune) string { return s }
}
// same converts the rune to a string.
func same() func(r rune) string {
return func(r rune) string { return string(r) }
}
// oct returns the octal representing the value.
func oct() func(r rune) string {
return func(r rune) string { return fmt.Sprintf(`\%03o`, r) }
}
// Redact returns a string that can be used in a shell single-quoted
// string. It may not be an exact representation, but it is safe
// to include on a command line.
//
// Redacted chars are changed to "X".
// If anything is redacted, the string is surrounded by double quotes
// ("air quotes") and the string "(redacted)" is added to the end.
// If nothing is redacted, but it contains spaces, it is surrounded
// by double quotes.
//
// Example: `s` -> `s`
// Example: `space cadet.txt` -> `"space cadet.txt"`
// Example: `drink a \t soda` -> `"drink a X soda"(redacted)`
// Example: `smile☺` -> `"smile☺`
func Redact(tainted string) string {
if tainted == "" {
return `""`
}
var b strings.Builder
b.Grow(len(tainted) + 10)
redacted := false
needsQuote := false
for _, r := range tainted {
if r == ' ' {
b.WriteRune(r)
needsQuote = true
} else if r == '\'' {
b.WriteRune('X')
redacted = true
} else if r == '"' {
b.WriteRune('\\')
b.WriteRune(r)
needsQuote = true
} else if unicode.IsPrint(r) {
b.WriteRune(r)
} else {
b.WriteRune('X')
redacted = true
}
}
if redacted {
return `"` + b.String() + `"(redacted)`
}
if needsQuote {
return `"` + b.String() + `"`
}
return tainted
}
// RedactMany returns the list after processing each element with Redact().
func RedactMany(items []string) []string {
var r []string
for _, n := range items {
r = append(r, Redact(n))
}
return r
}
// Shell returns the string formatted so that it is safe to be pasted
// into a command line to produce the desired filename as an argument
// to the command.
func Shell(tainted string) string {
if tainted == "" {
return `""`
}
var b strings.Builder
b.Grow(len(tainted) + 10)
level := Unknown
for _, r := range tainted {
if r < 128 {
level = max(level, tab[r].level)
b.WriteString(tab[r].fn(r))
} else {
level = max(level, DoubleQuote)
b.WriteString(escapeRune(r))
}
}
s := b.String()
if level == None {
return tainted
} else if level == SingleQuote {
// A single quoted string accepts all chars except the single
// quote itself, which must be replaced with: '"'"'
return "'" + strings.Join(strings.Split(s, "'"), `'"'"'`) + "'"
} else if level == DoubleQuote {
// A double-quoted string may include \xxx escapes and other
// things. Sadly bash doesn't interpret those, but printf will!
return `$(printf '%q' '` + s + `')`
}
// should not happen
return fmt.Sprintf("%q", s)
}
// escapeRune returns a string of octal escapes that represent the rune.
func escapeRune(r rune) string {
b := []byte(string(rune(r))) // Convert to the indivdual bytes, utf8-encoded.
// fmt.Printf("rune: len=%d %s %v\n", len(s), s, []byte(s))
switch len(b) {
case 1:
return fmt.Sprintf(`\%03o`, b[0])
case 2:
return fmt.Sprintf(`\%03o\%03o`, b[0], b[1])
case 3:
return fmt.Sprintf(`\%03o\%03o\%03o`, b[0], b[1], b[2])
case 4:
return fmt.Sprintf(`\%03o\%03o\%03o\%03o`, b[0], b[1], b[2], b[3])
default:
return string(rune(r))
}
}
// ShellMany returns the list after processing each element with Shell().
func ShellMany(items []string) []string {
var r []string
for _, n := range items {
r = append(r, Redact(n))
}
return r
}
// FirstFew returns the first few names. If any are truncated, it is
// noted by appending "...". The exact definition of "few" may change
// over time, and may be based on the number of chars not the list
func FirstFew(sl []string) string {
s, _ := FirstFewFlag(sl)
return s
}
// FirstFewFlag is like FirstFew but returns true if truncation done.
func FirstFewFlag(sl []string) (string, bool) {
const maxitems = 2
const maxlen = 70
if len(sl) < maxitems || len(strings.Join(sl, " ")) < maxlen {
return strings.Join(sl, " "), false
}
return strings.Join(sl[:maxitems], " ") + " (and others)", true
}