#!/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 </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 "$@"