Skip to content
Merged
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
18 changes: 17 additions & 1 deletion internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,19 +207,31 @@ func ExtractCDPrefix(command string) string {
// heredocStartRe matches heredoc markers: <<EOF, <<'EOF', <<"EOF", <<-EOF, etc.
var heredocStartRe = regexp.MustCompile(`<<-?\s*(?:'(\w+)'|"(\w+)"|(\w+))`)

// shellHeredocRe matches a shell interpreter receiving a heredoc as stdin.
// This detects patterns like: bash <<'EOF', sh <<EOF, python <<'EOF', etc.
// These heredocs contain executable code and must NOT be stripped.
var shellHeredocRe = regexp.MustCompile(`(?:^|[;&|]\s*)(?:bash|sh|dash|zsh|ksh|fish|python[23]?|ruby|perl|node|php)\s+<<`)

// StripHeredocs removes heredoc bodies from a Bash command string.
// This prevents deny rules from matching against data content such as
// commit messages or PR descriptions that happen to contain denied patterns.
// However, heredocs fed as stdin to shell interpreters (bash, sh, python, etc.)
// are preserved because they contain executable code that deny rules must check.
func StripHeredocs(command string) string {
lines := strings.Split(command, "\n")
var result []string
var delim string
keepBody := false

for _, line := range lines {
if delim != "" {
// Inside a heredoc body — skip lines until closing delimiter.
if keepBody {
result = append(result, line)
}
// Inside a heredoc body — skip/keep lines until closing delimiter.
if strings.TrimSpace(line) == delim {
delim = ""
keepBody = false
}
continue
}
Expand All @@ -233,6 +245,10 @@ func StripHeredocs(command string) string {
break
}
}
// If a shell interpreter is receiving this heredoc, keep the body.
if delim != "" && shellHeredocRe.MatchString(line) {
keepBody = true
}
}

result = append(result, line)
Expand Down
40 changes: 40 additions & 0 deletions internal/engine/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,31 @@ func TestStripHeredocs(t *testing.T) {
command: "cat <<EOF\nheredoc body\nEOF\necho after",
want: "cat <<EOF\necho after",
},
{
name: "bash heredoc preserved",
command: "bash <<'EOF'\nrm -rf /\nEOF",
want: "bash <<'EOF'\nrm -rf /\nEOF",
},
{
name: "sh heredoc preserved",
command: "sh <<EOF\nrm -rf /tmp/stuff\nEOF",
want: "sh <<EOF\nrm -rf /tmp/stuff\nEOF",
},
{
name: "python heredoc preserved",
command: "python3 <<'SCRIPT'\nimport os; os.system('rm -rf /')\nSCRIPT",
want: "python3 <<'SCRIPT'\nimport os; os.system('rm -rf /')\nSCRIPT",
},
{
name: "pipe to bash heredoc preserved",
command: "echo something; bash <<'EOF'\nrm -rf /\nEOF",
want: "echo something; bash <<'EOF'\nrm -rf /\nEOF",
},
{
name: "cat heredoc still stripped",
command: "cat <<'EOF'\nrm -rf /\nEOF",
want: "cat <<'EOF'",
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -319,6 +344,21 @@ func TestHeredocContentDoesNotTriggerDeny(t *testing.T) {
cmd: "rm -rf /tmp/stuff",
want: ptr(protocol.Deny),
},
{
name: "bash heredoc with rm -rf denied",
cmd: "bash <<'EOF'\nrm -rf /\nEOF",
want: ptr(protocol.Deny),
},
{
name: "sh heredoc with git reset --hard denied",
cmd: "sh <<'EOF'\ngit reset --hard\nEOF",
want: ptr(protocol.Deny),
},
{
name: "python heredoc with DROP TABLE denied",
cmd: "python3 <<'EOF'\nDROP TABLE users\nEOF",
want: ptr(protocol.Deny),
},
}

for _, tt := range tests {
Expand Down
Loading