Files
linux-scripts/salt-state-linter.sh
T
chiefgeek a1a17e81a1 Sync all scripts from website downloads — 352 scripts total
Includes updated JS challenge scripts with Claude-User whitelist,
same-site referer bypass, Blackbox-Exporter allowed bot, and all
new exporters, cheat sheets, and automation scripts.
2026-05-25 03:31:08 +02:00

529 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
##############################################################################
#### Salt State Linter ####
#### ####
#### Validates Salt .sls state files for common mistakes — duplicate ####
#### state IDs, YAML syntax issues, missing requisites, Jinja errors, ####
#### and anti-patterns. No dependencies beyond bash. ####
#### ####
#### Usage: ####
#### ./salt-state-linter.sh /srv/salt/ ####
#### ./salt-state-linter.sh /srv/salt/nginx/init.sls ####
#### ./salt-state-linter.sh --json /srv/salt/ ####
#### ####
#### Author: Phil Connor ####
#### Contact: contact@mylinux.work ####
#### License: MIT ####
#### Version: 1.0 ####
##############################################################################
set -o pipefail
readonly VERSION="1.0"
readonly SCRIPT_NAME="$(basename "$0")"
# --- defaults ---
OUTPUT_JSON=false
QUIET=false
USE_COLOR=true
EXCLUDE_PATTERNS=()
# --- counters ---
FILES_SCANNED=0
TOTAL_ERRORS=0
TOTAL_WARNINGS=0
# --- result collection ---
declare -a RESULTS_TEXT=()
declare -a RESULTS_JSON=()
# --- colors ---
setup_colors() {
if [[ "$USE_COLOR" == true ]] && [[ -t 1 ]]; then
RED='\033[0;31m'
YLW='\033[0;33m'
GRN='\033[0;32m'
BLD='\033[1m'
RST='\033[0m'
else
RED='' YLW='' GRN='' BLD='' RST=''
fi
}
# --- usage ---
usage() {
cat <<EOF
Usage: $SCRIPT_NAME [OPTIONS] PATH [PATH...]
Validate Salt .sls state files for common mistakes.
Options:
--json Output results as JSON
--quiet Only show errors (no warnings)
--no-color Disable colored output
--exclude PATTERN Exclude files matching pattern (can repeat)
-h, --help Show this help
--version Show version
Exit codes:
0 No errors found (warnings are OK)
1 Errors found
2 Usage error
EOF
}
# --- record finding ---
record() {
local file="$1" line="$2" level="$3" rule="$4" message="$5"
if [[ "$level" == "error" ]]; then
((TOTAL_ERRORS++))
local tag="ERROR"
local color="$RED"
else
((TOTAL_WARNINGS++))
if [[ "$QUIET" == true ]]; then
return
fi
local tag="WARN"
local color="$YLW"
fi
RESULTS_TEXT+=("${color}${file}:${line}: ${tag}: ${message}${RST}")
# escape strings for JSON
local jfile jmsg
jfile="$(printf '%s' "$file" | sed 's/\\/\\\\/g; s/"/\\"/g')"
jmsg="$(printf '%s' "$message" | sed 's/\\/\\\\/g; s/"/\\"/g')"
RESULTS_JSON+=("{\"file\":\"${jfile}\",\"line\":${line},\"level\":\"${level}\",\"rule\":\"${rule}\",\"message\":\"${jmsg}\"}")
}
# --- check: duplicate state IDs ---
check_duplicate_ids() {
local file="$1"
declare -A seen_ids=()
local lineno=0
while IFS= read -r line; do
((lineno++))
# state IDs: start at column 0, alphanumeric/underscore/hyphen, end with colon
# skip comments, Jinja blocks, blank lines, lines starting with whitespace
[[ "$line" =~ ^[[:space:]] ]] && continue
[[ "$line" =~ ^# ]] && continue
[[ "$line" =~ ^\{[%\{] ]] && continue
[[ -z "$line" ]] && continue
[[ "$line" == "---" ]] && continue
[[ "$line" =~ ^include: ]] && continue
if [[ "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_.-]*):(.*)$ ]]; then
local id="${BASH_REMATCH[1]}"
# skip salt module function lines (e.g., pkg.installed:)
# state IDs don't contain dots typically; module.function does
[[ "$id" == *.* ]] && continue
if [[ -n "${seen_ids[$id]+x}" ]]; then
record "$file" "$lineno" "error" "duplicate-id" \
"Duplicate state ID '${id}' (first seen line ${seen_ids[$id]})"
else
seen_ids["$id"]="$lineno"
fi
fi
done < "$file"
}
# --- check: tab indentation ---
check_tabs() {
local file="$1"
local lineno=0
while IFS= read -r line; do
((lineno++))
if [[ "$line" == *$'\t'* ]]; then
record "$file" "$lineno" "error" "tab-indent" \
"Tab character found — use spaces for YAML indentation"
fi
done < "$file"
}
# --- check: trailing whitespace ---
check_trailing_whitespace() {
local file="$1"
local lineno=0
while IFS= read -r line; do
((lineno++))
if [[ "$line" =~ [[:space:]]$ ]] && [[ -n "$line" ]]; then
record "$file" "$lineno" "warning" "trailing-whitespace" \
"Trailing whitespace"
fi
done < "$file"
}
# --- check: cmd.run without guards ---
check_cmd_no_guard() {
local file="$1"
local -a lines=()
local total=0
while IFS= read -r line; do
lines+=("$line")
((total++))
done < "$file"
local i=0
while (( i < total )); do
local line="${lines[$i]}"
local lineno=$((i + 1))
# match cmd.run, cmd.script, cmd.wait in state function position (indented)
if [[ "$line" =~ ^[[:space:]]+(cmd\.(run|script)):$ ]] || \
[[ "$line" =~ ^[[:space:]]+-(\ )+cmd\.(run|script)$ ]]; then
local has_guard=false
local j=$((i + 1))
# look ahead up to 15 lines for unless/onlyif/creates
while (( j < total && j <= i + 15 )); do
local next="${lines[$j]}"
# stop if we hit another state ID or module function at same/lower indent
[[ "$next" =~ ^[a-zA-Z] ]] && break
if [[ "$next" =~ ^[[:space:]]+-(\ )+(unless|onlyif|creates): ]] || \
[[ "$next" =~ ^[[:space:]]+(unless|onlyif|creates): ]]; then
has_guard=true
break
fi
((j++))
done
if [[ "$has_guard" == false ]]; then
record "$file" "$lineno" "warning" "cmd-no-guard" \
"cmd.run without unless/onlyif/creates (non-idempotent)"
fi
fi
((i++))
done
}
# --- check: bare wildcard targeting ---
check_bare_wildcard() {
local file="$1"
local lineno=0
while IFS= read -r line; do
((lineno++))
if [[ "$line" =~ salt[[:space:]]+[\"\']\*[\"\'] ]] || \
[[ "$line" =~ salt[[:space:]]+\'\*\' ]] || \
[[ "$line" =~ \"tgt\":[[:space:]]*\"\*\" ]]; then
record "$file" "$lineno" "warning" "bare-wildcard" \
"Bare '*' targeting in SLS file — verify this is intentional"
fi
done < "$file"
}
# --- check: hardcoded secrets ---
check_hardcoded_secrets() {
local file="$1"
local lineno=0
while IFS= read -r line; do
((lineno++))
# skip comments and Jinja-only lines
[[ "$line" =~ ^[[:space:]]*# ]] && continue
# check for password/secret patterns with inline values (not pillar refs)
if [[ "$line" =~ (password|passwd|secret|api_key|private_key|token):[[:space:]]+[^\{] ]]; then
local matched="${BASH_REMATCH[1]}"
# skip if the value is a pillar/grains reference
if [[ ! "$line" =~ (pillar|salt\[|grains) ]]; then
record "$file" "$lineno" "warning" "hardcoded-secret" \
"Possible hardcoded secret on line with '${matched}:' — use pillar data instead"
fi
fi
done < "$file"
}
# --- check: unclosed Jinja blocks ---
check_jinja_syntax() {
local file="$1"
local content
content="$(<"$file")"
# count opening and closing Jinja block tags
local open_block close_block open_var close_var
open_block=$(grep -o '{%' <<< "$content" 2>/dev/null | wc -l)
close_block=$(grep -o '%}' <<< "$content" 2>/dev/null | wc -l)
open_var=$(grep -o '{{' <<< "$content" 2>/dev/null | wc -l)
close_var=$(grep -o '}}' <<< "$content" 2>/dev/null | wc -l)
if (( open_block != close_block )); then
record "$file" 1 "error" "jinja-block" \
"Mismatched Jinja block tags: ${open_block} '{%' vs ${close_block} '%}'"
fi
if (( open_var != close_var )); then
record "$file" 1 "error" "jinja-variable" \
"Mismatched Jinja variable tags: ${open_var} '{{' vs ${close_var} '}}'"
fi
}
# --- check: YAML boolean gotchas ---
check_yaml_booleans() {
local file="$1"
local lineno=0
while IFS= read -r line; do
((lineno++))
# skip comments
[[ "$line" =~ ^[[:space:]]*# ]] && continue
# skip Jinja lines
[[ "$line" =~ ^\{[%\{] ]] && continue
# match key: value where value is bare yes/no/true/false (case-insensitive)
# must be a YAML mapping value, not inside a Jinja expression
if [[ "$line" =~ ^[[:space:]]+(-[[:space:]]+)?[a-zA-Z_][a-zA-Z0-9_]*:[[:space:]]+(yes|no|true|false|Yes|No|True|False|YES|NO|TRUE|FALSE)[[:space:]]*$ ]]; then
local val="${BASH_REMATCH[2]}"
# skip if the key itself is a known Salt boolean field
if [[ "$line" =~ (enabled|enable|reload|watch_in|recurse|makedirs|replace|follow_symlinks|backup): ]]; then
continue
fi
record "$file" "$lineno" "warning" "yaml-boolean" \
"Bare YAML boolean '${val}' — quote it if a string value is intended"
fi
done < "$file"
}
# --- check: long lines ---
check_long_lines() {
local file="$1"
local lineno=0
while IFS= read -r line; do
((lineno++))
if (( ${#line} > 200 )); then
record "$file" "$lineno" "warning" "long-line" \
"Line exceeds 200 characters (${#line} chars)"
fi
done < "$file"
}
# --- check: file.managed without source or contents ---
check_file_managed_no_source() {
local file="$1"
local -a lines=()
local total=0
while IFS= read -r line; do
lines+=("$line")
((total++))
done < "$file"
local i=0
while (( i < total )); do
local line="${lines[$i]}"
local lineno=$((i + 1))
if [[ "$line" =~ ^[[:space:]]+(file\.managed):$ ]] || \
[[ "$line" =~ ^[[:space:]]+-(\ )+file\.managed$ ]]; then
local has_source=false
local j=$((i + 1))
while (( j < total && j <= i + 20 )); do
local next="${lines[$j]}"
# stop at next state ID or module function
[[ "$next" =~ ^[a-zA-Z] ]] && break
if [[ "$next" =~ ^[[:space:]]+-(\ )+(source|contents|contents_pillar|contents_grains|contents_newline): ]] || \
[[ "$next" =~ ^[[:space:]]+(source|contents|contents_pillar|contents_grains|contents_newline): ]]; then
has_source=true
break
fi
((j++))
done
if [[ "$has_source" == false ]]; then
record "$file" "$lineno" "warning" "file-managed-no-source" \
"file.managed without source or contents parameter"
fi
fi
((i++))
done
}
# --- check: missing requisite targets ---
check_missing_requisites() {
local file="$1"
# first pass: collect all state IDs in the file
declare -A state_ids=()
local lineno=0
while IFS= read -r line; do
((lineno++))
[[ "$line" =~ ^[[:space:]] ]] && continue
[[ "$line" =~ ^# ]] && continue
[[ "$line" =~ ^\{[%\{] ]] && continue
[[ -z "$line" ]] && continue
[[ "$line" == "---" ]] && continue
[[ "$line" =~ ^include: ]] && continue
if [[ "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_.-]*):(.*)$ ]]; then
local id="${BASH_REMATCH[1]}"
[[ "$id" == *.* ]] && continue
state_ids["$id"]=1
fi
done < "$file"
# second pass: find requisite references
lineno=0
local in_requisite=false
while IFS= read -r line; do
((lineno++))
# match requisite target lines like: - pkg: nginx_install
if [[ "$line" =~ ^[[:space:]]+-[[:space:]]+(pkg|file|service|cmd|user|group|git|pip|cron|mount|kmod):[[:space:]]+([a-zA-Z_][a-zA-Z0-9_.-]*)[[:space:]]*$ ]]; then
local req_target="${BASH_REMATCH[2]}"
# skip if target contains Jinja
[[ "$req_target" =~ \{ ]] && continue
if [[ -z "${state_ids[$req_target]+x}" ]]; then
record "$file" "$lineno" "warning" "missing-requisite" \
"Requisite target '${req_target}' not found as state ID in this file"
fi
fi
done < "$file"
}
# --- process a single file ---
lint_file() {
local file="$1"
# check exclude patterns
for pattern in "${EXCLUDE_PATTERNS[@]}"; do
if [[ "$file" == *${pattern}* ]]; then
return
fi
done
((FILES_SCANNED++))
check_duplicate_ids "$file"
check_tabs "$file"
check_trailing_whitespace "$file"
check_cmd_no_guard "$file"
check_bare_wildcard "$file"
check_hardcoded_secrets "$file"
check_jinja_syntax "$file"
check_yaml_booleans "$file"
check_long_lines "$file"
check_file_managed_no_source "$file"
check_missing_requisites "$file"
}
# --- collect .sls files ---
collect_files() {
local path="$1"
if [[ -f "$path" ]]; then
if [[ "$path" == *.sls ]]; then
echo "$path"
else
echo "Warning: ${path} is not an .sls file, skipping" >&2
fi
elif [[ -d "$path" ]]; then
find "$path" -type f -name '*.sls' | sort
else
echo "Error: ${path} does not exist" >&2
return 1
fi
}
# --- JSON output ---
print_json() {
echo "{"
echo " \"files_scanned\": ${FILES_SCANNED},"
echo " \"errors\": ${TOTAL_ERRORS},"
echo " \"warnings\": ${TOTAL_WARNINGS},"
echo " \"results\": ["
local count=${#RESULTS_JSON[@]}
local i=0
for entry in "${RESULTS_JSON[@]}"; do
((i++))
if (( i < count )); then
echo " ${entry},"
else
echo " ${entry}"
fi
done
echo " ]"
echo "}"
}
# --- text output ---
print_text() {
for line in "${RESULTS_TEXT[@]}"; do
echo -e "$line"
done
echo ""
echo -e "${BLD}Scanned ${FILES_SCANNED} files${RST}"
echo -e "Errors: ${RED}${TOTAL_ERRORS}${RST}"
echo -e "Warnings: ${YLW}${TOTAL_WARNINGS}${RST}"
}
# =============================================
# Main
# =============================================
main() {
local paths=()
while [[ $# -gt 0 ]]; do
case "$1" in
--json) OUTPUT_JSON=true; shift ;;
--quiet) QUIET=true; shift ;;
--no-color) USE_COLOR=false; shift ;;
--exclude)
[[ -z "${2:-}" ]] && { echo "Error: --exclude requires a pattern" >&2; exit 2; }
EXCLUDE_PATTERNS+=("$2"); shift 2 ;;
-h|--help) usage; exit 0 ;;
--version) echo "$SCRIPT_NAME $VERSION"; exit 0 ;;
-*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
*) paths+=("$1"); shift ;;
esac
done
if [[ ${#paths[@]} -eq 0 ]]; then
echo "Error: no path specified" >&2
usage >&2
exit 2
fi
setup_colors
# collect and lint all files
local all_files=()
for p in "${paths[@]}"; do
while IFS= read -r f; do
all_files+=("$f")
done < <(collect_files "$p")
done
if [[ ${#all_files[@]} -eq 0 ]]; then
echo "No .sls files found" >&2
exit 2
fi
for f in "${all_files[@]}"; do
lint_file "$f"
done
# output results
if [[ "$OUTPUT_JSON" == true ]]; then
print_json
else
print_text
fi
# exit code
if (( TOTAL_ERRORS > 0 )); then
exit 1
fi
exit 0
}
main "$@"