feat(formatters/sh): surface missing shfmt/shellcheck + add SETE/LEAK checks (U-H2)
This commit is contained in:
118
formatters/sh
118
formatters/sh
@@ -1,31 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
# Formatter: Shell — shfmt + shellcheck
|
||||
# Formatter: Shell — shfmt + shellcheck + SETE/LEAK checks
|
||||
# Contract: $1 = absolute file path, format in place, lint, exit 0 if clean / 1 if errors
|
||||
# Missing tools: exit 0 silently
|
||||
#
|
||||
# SETE-1: shebanged script with no `set -e` / `set -euo pipefail` anywhere
|
||||
# SETE-2: sh/bash -c string with 2+ ;- or newline-separated commands and no set -e inside
|
||||
# LEAK-1: command substitution reading a secret-named file interpolated into
|
||||
# echo/printf/die/error/fail argument
|
||||
#
|
||||
# Missing shfmt AND shellcheck: emit one warning to stderr, still exit 0
|
||||
# (a missing tool must not block an edit). Any SETE/LEAK finding: print
|
||||
# "path:line: ID message" to stderr, exit non-zero.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
FILE="$1"
|
||||
[ -z "$FILE" ] && exit 0
|
||||
[ -f "$FILE" ] || exit 0
|
||||
|
||||
HAD_ERRORS=0
|
||||
HAS_SHFMT=0
|
||||
HAS_SHELLCHECK=0
|
||||
|
||||
# Format with shfmt if available
|
||||
if command -v shfmt >/dev/null 2>&1; then
|
||||
shfmt -w "$FILE" 2>/dev/null
|
||||
HAS_SHFMT=1
|
||||
shfmt -w "$FILE" 2>/dev/null
|
||||
fi
|
||||
|
||||
# Lint with shellcheck if available
|
||||
if command -v shellcheck >/dev/null 2>&1; then
|
||||
ERRORS=$(shellcheck -f gcc "$FILE" 2>&1)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "$ERRORS" >&2
|
||||
HAD_ERRORS=1
|
||||
fi
|
||||
HAS_SHELLCHECK=1
|
||||
if ! ERRORS=$(shellcheck -f gcc "$FILE" 2>&1); then
|
||||
echo "$ERRORS" >&2
|
||||
HAD_ERRORS=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# If neither tool is installed, silently pass
|
||||
if ! command -v shfmt >/dev/null 2>&1 && ! command -v shellcheck >/dev/null 2>&1; then
|
||||
exit 0
|
||||
# --- Custom checks: SETE-1, SETE-2, LEAK-1 ---
|
||||
# Always run, regardless of shfmt/shellcheck presence. Reads $FILE, emits
|
||||
# one "path:line: ID message" line per finding to stdout, exits non-zero on
|
||||
# any finding.
|
||||
|
||||
if ! CUSTOM_OUT=$(
|
||||
python3 - "$FILE" <<'PYEOF'
|
||||
import re
|
||||
import sys
|
||||
|
||||
filepath = sys.argv[1]
|
||||
findings = []
|
||||
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
|
||||
content = f.read()
|
||||
lines = content.splitlines()
|
||||
except OSError:
|
||||
sys.exit(0)
|
||||
|
||||
# SETE-1: shebanged script with no `set -e` / `set -euo pipefail` anywhere
|
||||
first = lines[0] if lines else ''
|
||||
if first.startswith('#!'):
|
||||
has_sete = False
|
||||
for line in lines:
|
||||
if re.search(r'(^|[^A-Za-z0-9_])set\s+-[A-Za-z]*[eE]', line):
|
||||
has_sete = True
|
||||
break
|
||||
if not has_sete:
|
||||
findings.append((1, "SETE-1 executable shell script without 'set -e'; failed earlier commands will not abort"))
|
||||
|
||||
SECRET_RE = re.compile(r'TOKEN|SECRET|KEY|PASSWORD|_JSON_FILE|CREDENTIAL', re.IGNORECASE)
|
||||
SINK_RE = re.compile(r'\b(echo|printf|die|error|fail)(?=\s|\()')
|
||||
FILE_READ_RE = re.compile(
|
||||
r'(\$\(|`)\s*(cat|<)\s*"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?"\s*(\)|`)'
|
||||
)
|
||||
|
||||
def count_top_level_commands(s):
|
||||
"""Count ;- or newline-separated top-level commands in a shell string,
|
||||
ignoring separators inside single or double quotes."""
|
||||
in_single = False
|
||||
in_double = False
|
||||
separators = 0
|
||||
for c in s:
|
||||
if not in_double and c == "'":
|
||||
in_single = not in_single
|
||||
elif not in_single and c == '"':
|
||||
in_double = not in_double
|
||||
elif c in ';\n' and not in_single and not in_double:
|
||||
separators += 1
|
||||
return separators + 1
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
# SETE-2: sh/bash -c with 2+ ;- or newline-separated commands and no set -e inside
|
||||
m = re.search(r'\b(?:sh|bash)\s+-c\s+(["\'])([^"\']*)\1', line)
|
||||
if m:
|
||||
s = m.group(2)
|
||||
if not re.search(r'(^|[^A-Za-z0-9_])set\s+-[A-Za-z]*[eE]', s):
|
||||
n = count_top_level_commands(s)
|
||||
if n >= 2:
|
||||
findings.append((i, "SETE-2 sh/bash -c with " + str(n) + " ;-separated commands and no 'set -e' inside; container exit reflects only the last command"))
|
||||
|
||||
# LEAK-1: command substitution reading a secret-named file, inside echo/printf/die/error/fail
|
||||
sink_match = SINK_RE.search(line)
|
||||
if sink_match:
|
||||
cmd = sink_match.group(1)
|
||||
for cmdsub in FILE_READ_RE.finditer(line):
|
||||
var = cmdsub.group(3)
|
||||
if SECRET_RE.search(var):
|
||||
findings.append((i, "LEAK-1 secret variable '$" + var + "' read into string passed to '" + cmd + "'; could print credential document"))
|
||||
|
||||
for line_no, msg in findings:
|
||||
print(filepath + ":" + str(line_no) + ": " + msg)
|
||||
|
||||
sys.exit(1 if findings else 0)
|
||||
PYEOF
|
||||
); then
|
||||
echo "$CUSTOM_OUT" >&2
|
||||
HAD_ERRORS=1
|
||||
fi
|
||||
|
||||
# If neither shfmt nor shellcheck is installed, warn ONCE so the silent pass
|
||||
# is no longer silent. A missing tool must not block an edit (would be a worse
|
||||
# bug than the one this warning surfaces).
|
||||
if [ "$HAS_SHFMT" -eq 0 ] && [ "$HAS_SHELLCHECK" -eq 0 ]; then
|
||||
echo "formatters/sh: shfmt and shellcheck not installed; install via 'apt install shfmt shellcheck' (or equivalent). Custom checks still ran." >&2
|
||||
fi
|
||||
|
||||
exit $HAD_ERRORS
|
||||
|
||||
Reference in New Issue
Block a user