| 1 | package shellsafe |
| 2 | |
| 3 | import ( |
| 4 | "sort" |
| 5 | "strings" |
| 6 | |
| 7 | "mvdan.cc/sh/v3/syntax" |
| 8 | |
| 9 | "reasonix/internal/shellparse" |
| 10 | ) |
| 11 | |
| 12 | // NormalizeBashSafeRedirectsForMatch returns a copy of subject with redirect |
| 13 | // syntax removed only when the redirect cannot write to a real file. It is used |
| 14 | // for shell safety matching, never for execution. |
| 15 | // |
| 16 | // Supported safe forms are fd duplication/close (`2>&1`, `>&2`, `2>&-`, |
| 17 | // `0<&1`) and output redirects to a null sink (`>/dev/null`, `>$null`, |
| 18 | // `>nul`, `2> /dev/null`, `>>/dev/null`, `&>/dev/null`, `&>>/dev/null`). |
| 19 | // The shell execution layer normalizes these null-sink spellings to the actual |
| 20 | // sink for the resolved shell. Other redirections are left unnormalized so the |
| 21 | // usual shell-syntax guard keeps prefix/read-only matching conservative. |
| 22 | func NormalizeBashSafeRedirectsForMatch(subject string) (string, bool) { |
| 23 | file, err := shellparse.ParseBash(subject) |
| 24 | if err != nil || shellparse.HasHereDoc(file) { |
| 25 | return "", false |
| 26 | } |
| 27 | spans, ok := safeRedirectSpans(subject, file.Stmts) |
| 28 | if !ok { |
| 29 | return "", false |
| 30 | } |
| 31 | if len(spans) == 0 { |
| 32 | return subject, true |
| 33 | } |
| 34 | sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start }) |
| 35 | |
| 36 | var out strings.Builder |
| 37 | last := 0 |
| 38 | for _, span := range spans { |
| 39 | if span.start < last || span.end > len(subject) { |
| 40 | return "", false |
| 41 | } |
| 42 | out.WriteString(subject[last:span.start]) |
| 43 | last = span.end |
| 44 | } |
| 45 | out.WriteString(subject[last:]) |
| 46 | return strings.TrimSpace(out.String()), true |
| 47 | } |
| 48 | |
| 49 | type redirectSpan struct { |
| 50 | start int |
| 51 | end int |
| 52 | } |
| 53 | |
| 54 | func safeRedirectSpans(source string, stmts []*syntax.Stmt) ([]redirectSpan, bool) { |
| 55 | var spans []redirectSpan |
| 56 | for _, stmt := range stmts { |
| 57 | if !appendSafeRedirectSpans(source, stmt, &spans) { |
| 58 | return nil, false |
| 59 | } |
| 60 | } |
| 61 | return spans, true |
| 62 | } |
| 63 | |
| 64 | func appendSafeRedirectSpans(source string, stmt *syntax.Stmt, spans *[]redirectSpan) bool { |
| 65 | if stmt == nil { |
| 66 | return true |
| 67 | } |
| 68 | for _, redir := range stmt.Redirs { |
| 69 | span, ok := safeRedirectSpan(source, redir) |
| 70 | if !ok { |
| 71 | return false |
| 72 | } |
| 73 | *spans = append(*spans, span) |
| 74 | } |
| 75 | if binary, ok := stmt.Cmd.(*syntax.BinaryCmd); ok { |
| 76 | return appendSafeRedirectSpans(source, binary.X, spans) && |
| 77 | appendSafeRedirectSpans(source, binary.Y, spans) |
| 78 | } |
| 79 | return true |
| 80 | } |
| 81 | |
| 82 | func safeRedirectSpan(source string, redir *syntax.Redirect) (redirectSpan, bool) { |
| 83 | if redir == nil { |
| 84 | return redirectSpan{}, false |
| 85 | } |
| 86 | switch redir.Op { |
| 87 | case syntax.DplOut, syntax.DplIn: |
| 88 | if !isSafeFDDupWord(source, redir.Word) { |
| 89 | return redirectSpan{}, false |
| 90 | } |
| 91 | case syntax.RdrOut, syntax.AppOut, syntax.RdrClob, syntax.AppClob, syntax.RdrAll, syntax.AppAll, syntax.RdrAllClob, syntax.AppAllClob: |
| 92 | if !isNullRedirectWord(source, redir.Word) { |
| 93 | return redirectSpan{}, false |
| 94 | } |
| 95 | default: |
| 96 | return redirectSpan{}, false |
| 97 | } |
| 98 | start := int(redir.OpPos.Offset()) |
| 99 | if redir.N != nil && redir.N.Pos().IsValid() { |
| 100 | start = int(redir.N.Pos().Offset()) |
| 101 | } |
| 102 | end := int(redir.End().Offset()) |
| 103 | if start < 0 || end < start || end > len(source) { |
| 104 | return redirectSpan{}, false |
| 105 | } |
| 106 | return redirectSpan{start: start, end: end}, true |
| 107 | } |
| 108 | |
| 109 | func isSafeFDDupWord(source string, word *syntax.Word) bool { |
| 110 | value := redirectWordSource(source, word) |
| 111 | if value == "-" { |
| 112 | return true |
| 113 | } |
| 114 | if value == "" { |
| 115 | return false |
| 116 | } |
| 117 | for i := 0; i < len(value); i++ { |
| 118 | if value[i] < '0' || value[i] > '9' { |
| 119 | return false |
| 120 | } |
| 121 | } |
| 122 | return true |
| 123 | } |
| 124 | |
| 125 | func isNullRedirectWord(source string, word *syntax.Word) bool { |
| 126 | value := redirectWordSource(source, word) |
| 127 | if value == "/dev/null" { |
| 128 | return true |
| 129 | } |
| 130 | return strings.EqualFold(value, "$null") || strings.EqualFold(value, "nul") |
| 131 | } |
| 132 | |
| 133 | func redirectWordSource(source string, word *syntax.Word) string { |
| 134 | if word == nil || !word.Pos().IsValid() || !word.End().IsValid() { |
| 135 | return "" |
| 136 | } |
| 137 | start := int(word.Pos().Offset()) |
| 138 | end := int(word.End().Offset()) |
| 139 | if start < 0 || end < start || end > len(source) { |
| 140 | return "" |
| 141 | } |
| 142 | return strings.TrimSpace(source[start:end]) |
| 143 | } |
| 144 |