Author SHA1 Message Date
sneak 1658fa10ac Harden the lint-guard shell scanner against silent evasions (closes #121)
check / check (pull_request) Successful in 2m46s
The guard test's shell scanner was weaker than its commit message
claimed. Two holes are closed.

shellCode now treats `<<` as a here-document only when it is a real
redirection: outside single and double quotes, followed by a delimiter
word. A `<<` inside a quoted string no longer opens a phantom
here-document that swallows the rest of the file, and a here-document
still open at end of file is a loud error rather than a silent
truncation.

assertLinterIsContainerised now cuts the joined line into the simple
commands the shell would run -- on `;`, `&&`, `||` and `|` -- and
requires the command that names the linter to begin with docker. So
`docker info; golangci-lint run` and `docker info || golangci-lint run`
are rejected, while script/lint-fix's `docker run ... golangci-lint`
still passes.

The scanner comment now names the inherent limits of a text scan.
Dockerfile.lint's citation is corrected from `lll` to `revive`, the
finding the recorded evidence actually named.

Model: opus-4-8
2026-09-21 13:07:01 +00:00
2 changed files with 187 additions and 35 deletions
+5 -5
View File
@@ -72,11 +72,11 @@ RUN [ -n "$CHECK_EPOCH" ] || exit 1
# running, and exits 0 reporting `0 issues.` on a tree the real config # running, and exits 0 reporting `0 issues.` on a tree the real config
# fails. Demonstrated on this repo at this pin, recorded on # fails. Demonstrated on this repo at this pin, recorded on
# https://git.eeqj.de/sneak/vaultik/pulls/114: with a planted # https://git.eeqj.de/sneak/vaultik/pulls/114: with a planted
# over-length line, `script/lint` exits 1 naming the `lll` finding with # over-length line, `script/lint` exits 1 naming the `revive` finding
# `linters:` and exits 0 with `linterz:`. A set-but-ineffective config # with `linters:` and exits 0 with `linterz:`. A set-but-ineffective
# quietly falling back to defaults is precisely the false-green class # config quietly falling back to defaults is precisely the false-green
# this gate exists to eliminate, so it must not sit in the gate's own # class this gate exists to eliminate, so it must not sit in the gate's
# configuration. # own configuration.
# #
# `config verify` catches it, and it does so OFFLINE at this pinned # `config verify` catches it, and it does so OFFLINE at this pinned
# version -- verified, not assumed. Under `docker run --network none` # version -- verified, not assumed. Under `docker run --network none`
+182 -30
View File
@@ -1,6 +1,8 @@
package main_test package main_test
import ( import (
"errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -252,29 +254,64 @@ func TestNoHostLintPathRemains(t *testing.T) {
} }
name := filepath.Join("script", entry.Name()) name := filepath.Join("script", entry.Name())
for _, line := range shellCode(readRepoFile(t, name)) {
lines, err := shellCode(readRepoFile(t, name))
require.NoError(t, err, "scanning %s", name)
for _, line := range lines {
assertLinterIsContainerised(t, name, line) assertLinterIsContainerised(t, name, line)
} }
} }
} }
// assertLinterIsContainerised fails if the line runs the linter without // assertLinterIsContainerised fails unless every command that names the
// handing it to docker first. Position matters: docker has to come // linter on this joined line is a docker command. Merely mentioning
// before the binary, or the line is running the host linter and merely // docker somewhere on the line is not enough; see linterRunsInDocker.
// mentioning docker afterwards.
func assertLinterIsContainerised(t *testing.T, name, line string) { func assertLinterIsContainerised(t *testing.T, name, line string) {
t.Helper() t.Helper()
at := strings.Index(line, linterBinary) assert.True(t, linterRunsInDocker(line),
if at < 0 { "%s runs %s outside a container; every command that names the"+
return " linter must begin with docker (line: %s)", name, linterBinary,
line)
}
// linterRunsInDocker reports whether the linter, wherever it appears on
// this joined shell line, is only ever the argument of a docker command.
// The line is cut into the simple commands the shell would run -- on
// `;`, `&&`, `||` and `|` -- and every command that names the linter
// must begin with `docker`. This is what distinguishes the one
// legitimate invocation, script/lint-fix's `docker run ... golangci-lint
// run ...`, from evasions like `docker info; golangci-lint run` or
// `docker info || golangci-lint run`, where the linter sits in a command
// of its own that docker does not introduce.
func linterRunsInDocker(line string) bool {
for _, command := range splitShellCommands(line) {
if !strings.Contains(command, linterBinary) {
continue
} }
docker := strings.Index(line, "docker") if !strings.HasPrefix(strings.TrimSpace(command), "docker") {
return false
}
}
assert.True(t, docker >= 0 && docker < at, return true
"%s runs %s on the host; every lint run happens in a container"+ }
" (line: %s)", name, linterBinary, line)
// splitShellCommands breaks a joined shell line into the separate simple
// commands the shell would run, cutting at the `;`, `&&`, `||` and `|`
// operators (`||` before `|`, so the two-character operator is not split
// twice). It is deliberately blind to quoting and to `$(...)`: no line
// under guard puts one of these operators inside a string, and a scan
// that tried to account for that would be the kind of half-parser this
// file avoids.
func splitShellCommands(line string) []string {
for _, op := range []string{"&&", "||", "|", ";"} {
line = strings.ReplaceAll(line, op, "\n")
}
return strings.Split(line, "\n")
} }
// TestShellCodeSeesCodeAndNotProse keeps the scanner above honest. It // TestShellCodeSeesCodeAndNotProse keeps the scanner above honest. It
@@ -287,20 +324,70 @@ func assertLinterIsContainerised(t *testing.T, name, line string) {
func TestShellCodeSeesCodeAndNotProse(t *testing.T) { func TestShellCodeSeesCodeAndNotProse(t *testing.T) {
t.Parallel() t.Parallel()
// A `<<` inside quotes is not a here-document, so the code after it
// is still scanned; a real `<<EOF` opens one and its body is dropped.
script := strings.Join([]string{ script := strings.Join([]string{
"#!/bin/sh", "#!/bin/sh",
"# a comment naming golangci-lint", "# a comment naming golangci-lint",
"cat >&2 <<EOF", "cat >&2 <<EOF",
"prose naming golangci-lint, printed not executed", "prose naming golangci-lint, printed not executed",
"EOF", "EOF",
`echo "a left shift << is not a here-document"`,
"docker run --rm \\", "docker run --rm \\",
" \"$image\" \\", " \"$image\" \\",
" golangci-lint run ./...", " golangci-lint run ./...",
}, "\n") }, "\n")
lines, err := shellCode(script)
require.NoError(t, err)
assert.Equal(t, assert.Equal(t,
[]string{"cat >&2 <<EOF", `docker run --rm "$image" golangci-lint run ./...`}, []string{
shellCode(script)) "cat >&2 <<EOF",
`echo "a left shift << is not a here-document"`,
`docker run --rm "$image" golangci-lint run ./...`,
},
lines)
// A here-document still open at end of file must be a loud error,
// not a silent truncation of everything the scanner has yet to see.
unterminated := strings.Join([]string{
"cat <<EOF",
"body line naming golangci-lint, no terminator follows",
}, "\n")
_, err = shellCode(unterminated)
require.Error(t, err)
}
// TestLinterCommandMustBeginWithDocker pins the property that a mention
// of docker somewhere on the line is not enough: the command that
// actually runs the linter has to be a docker command. The two evasions
// from the issue place the linter in a command of its own, joined to a
// harmless docker command by `;` or `||`; both must be rejected. The
// containerised invocation script/lint-fix writes -- docker run with the
// linter as its argument -- must still be accepted.
func TestLinterCommandMustBeginWithDocker(t *testing.T) {
t.Parallel()
rejected := []string{
"docker info >/dev/null; golangci-lint run ./...",
"docker info || golangci-lint run ./...",
"docker build . && golangci-lint run ./... | tee log",
}
for _, line := range rejected {
assert.False(t, linterRunsInDocker(line),
"a linter command docker does not introduce must be rejected: %s",
line)
}
accepted := []string{
`docker run --rm "$image" golangci-lint run ./...`,
`docker run --rm --user x --volume "$ROOT:/src" img golangci-lint run --fix ./...`,
}
for _, line := range accepted {
assert.True(t, linterRunsInDocker(line),
"a docker-introduced linter command must be accepted: %s", line)
}
} }
// assertEpochExpandedInto fails unless some instruction runs the named // assertEpochExpandedInto fails unless some instruction runs the named
@@ -410,7 +497,9 @@ func indexContaining(found []string, want string) int {
// shellCode returns a POSIX shell script's executable lines: comments // shellCode returns a POSIX shell script's executable lines: comments
// dropped, here-document bodies dropped, and backslash continuations // dropped, here-document bodies dropped, and backslash continuations
// joined so a multi-line command is a single string. Whitespace is // joined so a multi-line command is a single string. Whitespace is
// collapsed, as it is for Dockerfile instructions. // collapsed, as it is for Dockerfile instructions. A here-document left
// open at end of file is an error rather than a silent truncation of
// everything after its opener.
// //
// Both exclusions are load-bearing rather than tidiness. The scripts // Both exclusions are load-bearing rather than tidiness. The scripts
// name golangci-lint in prose to state that the host binary is never // name golangci-lint in prose to state that the host binary is never
@@ -418,7 +507,11 @@ func indexContaining(found []string, want string) int {
// container invocation -- script/lint-fix's `docker run`, whose linter // container invocation -- script/lint-fix's `docker run`, whose linter
// command sits several lines below the word `docker` -- be recognised // command sits several lines below the word `docker` -- be recognised
// as containerised. // as containerised.
func shellCode(contents string) []string { //
// This is a text scan, not a shell: it cannot see a linter name
// assembled at runtime, one split across a continuation, a script in a
// subdirectory of script/, or anything in the Makefile.
func shellCode(contents string) ([]string, error) {
var ( var (
out []string out []string
joined string joined string
@@ -452,23 +545,82 @@ func shellCode(contents string) []string {
joined = "" joined = ""
} }
return out if terminate != "" {
} return nil, fmt.Errorf("%w: terminator %q", errUnterminatedHeredoc,
terminate)
// heredocTerminator returns the terminator of the here-document a
// command opens, or "" if it opens none. Only the first on a line is
// recognised; nothing in script/ opens two.
func heredocTerminator(line string) string {
_, after, opens := strings.Cut(line, "<<")
if !opens {
return ""
} }
// `<<-` strips leading tabs from the body; the terminator word is return out, nil
// the same either way, and callers compare against trimmed lines. }
word, _, _ := strings.Cut(strings.TrimPrefix(after, "-"), " ")
return strings.Trim(word, `'"`) // errUnterminatedHeredoc is what shellCode returns when a here-document
// is still open at end of file. Its callers require its absence, so an
// unterminated body -- which would otherwise be swallowed silently --
// fails the guard loudly.
var errUnterminatedHeredoc = errors.New(
"here-document opened but never closed before end of file")
// heredocTerminator returns the delimiter word of the here-document the
// command opens, or "" if it opens none. A `<<` only opens one when it
// is a real redirection: outside single and double quotes, and followed
// by a delimiter word. A `<<` inside a quoted string, or an arithmetic
// left shift like `$((x << 2))`, is not a here-document; the former is
// the case this guards, the latter appears in no script here. Only the
// first opener on a line is recognised; nothing in script/ opens two.
func heredocTerminator(line string) string {
var quote byte // 0 when outside quotes, else '\'' or '"'
for i := 0; i+1 < len(line); i++ {
c := line[i]
switch {
case quote != 0:
if c == quote {
quote = 0
}
case c == '\'' || c == '"':
quote = c
case c == '<' && line[i+1] == '<':
return heredocWord(line[i+2:])
}
}
return ""
}
// heredocWord extracts the delimiter that follows `<<` or `<<-`: it drops
// an optional `-`, skips blanks, then reads the delimiter -- quoted or
// bare -- and returns it with quotes removed. `<<-'EOF'` and `<< EOF`
// both yield "EOF". It returns "" when no word follows, so a bare `<<`
// opens nothing.
func heredocWord(after string) string {
after = strings.TrimLeft(strings.TrimPrefix(after, "-"), " \t")
var (
word strings.Builder
quote byte
)
for i := range len(after) {
c := after[i]
switch {
case quote != 0:
if c == quote {
quote = 0
} else {
word.WriteByte(c)
}
case c == '\'' || c == '"':
quote = c
case c == ' ' || c == '\t':
return word.String()
default:
word.WriteByte(c)
}
}
return word.String()
} }
// readRepoFile reads a file by its path relative to the repository // readRepoFile reads a file by its path relative to the repository