Skill: suhteevah/containerlint (version 1.0.1)

--- name: ContainerLint version: 1.0.0 description: "Docker & container security anti-pattern analyzer -- detects Dockerfile issues, missing health checks, resource limit gaps, privileged containers, insecure networking, and orchestration anti-patterns" homepage: https://containerlint.pages.dev metadata: { "openclaw": { "emoji": "\ud83d\udc33", "primaryEnv": "CONTAINERLINT_LICENSE_KEY", "requires": { "bins": ["git", "bash", "python3", "jq"] }, "configPaths": ["~/.openclaw/openclaw.json"], "install": [ { "id": "lefthook", "kind": "brew", "formula": "lefthook", "bins": ["lefthook"], "label": "Install lefthook (git hooks manager)" } ], "os": ["darwin", "linux", "win32"] } } user-invocable: true disable-model-invocation: false --- # ContainerLint -- Docker & Container Security Anti-Pattern Analyzer ContainerLint scans codebases for Docker and container security anti-patterns, Dockerfile issues, missing health checks, resource limit gaps, privileged containers, insecure networking, and orchestration misconfigurations. It uses regex-based pattern matching against 90 container-specific patterns across 6 categories, lefthook for git hook integration, and produces markdown reports with actionable remediation guidance. 100% local. Zero telemetry. ## Commands ### Free Tier (No license required) #### `containerlint scan [file|directory]` One-shot container security scan of files or directories. **How to execute:** ```bash bash "<SKILL_DIR>/scripts/dispatcher.sh" --path [target] ``` **What it does:** 1. Accepts a file path or directory (defaults to current directory) 2. Discovers all source files (skips .git, node_modules, binaries, images, .min.js) 3. Runs 30 container security patterns against each file (free tier limit) 4. Calculates a container security score (0-100) per file and overall 5. Grades: A (90-100), B (80-89), C (70-79), D (60-69), F (<60) 6. Outputs findings with: file, line number, check ID, severity, description, recommendation 7. Exit code 0 if score >= 70, exit code 1 if container security is poor 8. Free tier limited to first 30 patterns (DF + SC categories) **Example usage scenarios:** - "Scan my code for Dockerfile issues" -> runs `containerlint scan .` - "Check this file for container anti-patterns" -> runs `containerlint scan docker-compose.yml` - "Find privileged containers" -> runs `containerlint scan .` - "Audit container security in my project" -> runs `containerlint scan .` - "Check for missing health checks" -> runs `containerlint scan .` ### Pro Tier ($19/user/month -- requires CONTAINERLINT_LICENSE_KEY) #### `containerlint scan --tier pro [file|directory]` Extended scan with 60 patterns covering Dockerfile, security context, health checks, and resource management. **How to execute:** ```bash bash "<SKILL_DIR>/scripts/dispatcher.sh" --path [target] --tier pro ``` **What it does:** 1. Validates Pro+ license 2. Runs 60 container security patterns (DF, SC, HC, RS categories) 3. Detects missing health checks and readiness probes 4. Identifies resource limit gaps and unbounded containers 5. Full category breakdown reporting #### `containerlint scan --format json [directory]` Generate JSON output for CI/CD integration. ```bash bash "<SKILL_DIR>/scripts/dispatcher.sh" --path [directory] --format json ``` #### `containerlint scan --format html [directory]` Generate HTML report for browser viewing. ```bash bash "<SKILL_DIR>/scripts/dispatcher.sh" --path [directory] --format html ``` #### `containerlint scan --category HC [directory]` Filter scan to a specific check category (DF, SC, HC, RS, NW, OR). ```bash bash "<SKILL_DIR>/scripts/dispatcher.sh" --path [directory] --category HC ``` ### Team Tier ($39/user/month -- requires CONTAINERLINT_LICENSE_KEY with team tier) #### `containerlint scan --tier team [directory]` Full scan with all 90 patterns across all 6 categories including networking and orchestration. **How to execute:** ```bash bash "<SKILL_DIR>/scripts/dispatcher.sh" --path [directory] --tier team ``` **What it does:** 1. Validates Team+ license 2. Runs all 90 patterns across 6 categories 3. Includes networking checks (host networking, exposed ports, insecure registries) 4. Includes orchestration checks (compose anti-patterns, missing restart policies) 5. Full category breakdown with per-file results #### `containerlint scan --verbose [directory]` Verbose output showing every matched line and pattern details. ```bash bash "<SKILL_DIR>/scripts/dispatcher.sh" --path [directory] --verbose ``` #### `containerlint status` Show license and configuration information. ```bash bash "<SKILL_DIR>/scripts/dispatcher.sh" status ``` ## Check Categories ContainerLint detects 90 container security anti-patterns across 6 categories: | Category | Code | Patterns | Description | Severity Range | |----------|------|----------|-------------|----------------| | **Dockerfile Best Practices** | DF | 15 | Missing USER directive, ADD instead of COPY, latest tag, missing .dockerignore patterns, multiple FROM without alias | medium -- high | | **Security Context** | SC | 15 | Privileged mode, running as root, exposed secrets, capability escalation, no seccomp profile | high -- critical | | **Health & Readiness** | HC | 15 | No HEALTHCHECK, missing readiness probes, no liveness checks, no startup probes | medium -- high | | **Resource Management** | RS | 15 | No resource limits, no memory limits, no CPU limits, unbounded storage, no ephemeral storage limits | medium -- high | | **Networking & Exposure** | NW | 15 | Exposing all ports, host networking, no network policy, publishing on 0.0.0.0, insecure registries | medium -- critical | | **Orchestration & Compose** | OR | 15 | No restart policy, no replicas, hardcoded IPs in compose, no volume mounts for secrets, latest tag in compose | low -- high | ## Tier-Based Pattern Access | Tier | Patterns | Categories | |------|----------|------------| | **Free** | 30 | DF, SC | | **Pro** | 60 | DF, SC, HC, RS | | **Team** | 90 | DF, SC, HC, RS, NW, OR | | **Enterprise** | 90 | DF, SC, HC, RS, NW, OR + priority support | ## Scoring ContainerLint uses a deductive scoring system starting at 100 (perfect): | Severity | Point Deduction | Description | |----------|-----------------|-------------| | **Critical** | -25 per finding | Severe security vulnerability (privileged mode, exposed secrets) | | **High** | -15 per finding | Significant security problem (running as root, no resource limits) | | **Medium** | -8 per finding | Moderate concern (latest tag, missing health check) | | **Low** | -3 per finding | Informational / best practice suggestion | ### Grading Scale | Grade | Score Range | Meaning | |-------|-------------|---------| | **A** | 90-100 | Excellent container security | | **B** | 80-89 | Good security with minor issues | | **C** | 70-79 | Acceptable but needs improvement | | **D** | 60-69 | Poor container security | | **F** | Below 60 | Critical security problems | - **Pass threshold:** 70 (Grade C or better) - Exit code 0 = pass (score >= 70) - Exit code 1 = fail (score < 70) ## Configuration Users can configure ContainerLint in `~/.openclaw/openclaw.json`: ```json { "skills": { "entries": { "containerlint": { "enabled": true, "apiKey": "YOUR_LICENSE_KEY_HERE", "config": { "severityThreshold": "medium", "ignorePatterns": ["**/test/**", "**/fixtures/**", "**/*.test.*"], "ignoreChecks": [], "reportFormat": "text" } } } } } ``` ## Important Notes - **Free tier** works immediately with no configuration - **All scanning happens locally** -- no code is sent to external servers - **License validation is offline** -- no phone-home or network calls - Pattern matching only -- no AST parsing, no external dependencies beyond bash - Supports scanning all file types in a single pass - Git hooks use **lefthook** which must be installed (see install metadata above) - Exit codes: 0 = pass (score >= 70), 1 = fail (for CI/CD integration) - Output formats: text (default), json, html ## Error Handling - If lefthook is not installed and user tries hooks, prompt to install it - If license key is invalid or expired, show clear message with link to https://containerlint.pages.dev/renew - If a file is binary, skip it automatically with no warning - If no scannable files found in target, report clean scan with info message - If an invalid category is specified with --category, show available categories ## When to Use ContainerLint The user might say things like: - "Scan my code for Dockerfile issues" - "Check my container security" - "Find privileged containers" - "Detect missing health checks" - "Are there any hardcoded secrets in my Docker files?" - "Check for missing resource limits" - "Audit my container security practices" - "Find insecure Docker configurations" - "Check for missing network policies" - "Scan for container anti-patterns" - "Run a container security audit" - "Generate a container security report" - "Check if my containers have proper resource limits" - "Find containers running as root" - "Check my docker-compose for anti-patterns"

path: config/lefthook.yml
content: # ContainerLint -- lefthook configuration
# Installed by: containerlint hooks install
# Documentation: https://containerlint.pages.dev/docs/hooks
#
# This configuration integrates ContainerLint with git hooks via lefthook.
# Pre-commit: scans staged files for container security issues (blocks on critical/high)
# Pre-push: runs full scan of the working tree before push

pre-commit:
  parallel: true
  commands:
    containerlint-check:
      run: |
        CONTAINERLINT_SKILL_DIR="${CONTAINERLINT_SKILL_DIR:-$HOME/.openclaw/skills/containerlint}"
        if [[ -f "$CONTAINERLINT_SKILL_DIR/scripts/analyzer.sh" ]]; then
          source "$CONTAINERLINT_SKILL_DIR/scripts/patterns.sh"
          source "$CONTAINERLINT_SKILL_DIR/scripts/analyzer.sh"
          hook_containerlint_check
        else
          echo "[ContainerLint] Skill not found at $CONTAINERLINT_SKILL_DIR -- skipping scan"
        fi
      fail_text: |
        Container security issues detected in staged files!
        Run 'containerlint scan' to see details.
        Or 'git commit --no-verify' to skip (NOT recommended).

pre-push:
  parallel: true
  commands:
    containerlint-full-scan:
      run: |
        CONTAINERLINT_SKILL_DIR="${CONTAINERLINT_SKILL_DIR:-$HOME/.openclaw/skills/containerlint}"
        if [[ -f "$CONTAINERLINT_SKILL_DIR/scripts/dispatcher.sh" ]]; then
          bash "$CONTAINERLINT_SKILL_DIR/scripts/dispatcher.sh" --path . --format text
        else
          echo "[ContainerLint] Skill not found at $CONTAINERLINT_SKILL_DIR -- skipping scan"
        fi
      fail_text: |
        ContainerLint detected container security issues in your codebase.
        Run 'containerlint scan --verbose' for detailed findings.
        Fix critical/high issues before pushing.


path: scripts/analyzer.sh
content: #!/usr/bin/env bash
# ContainerLint -- Core Analysis Engine
# Provides: file discovery, pattern scanning, score calculation, grades,
#           category breakdown, file-by-file results, tier-based filtering,
#           report generation, and multi-format output (text, json, html).
#
# This file is sourced by dispatcher.sh and by the lefthook pre-commit hook.
# Requires patterns.sh to be sourced first.

set -euo pipefail

# --- Colors (safe to re-declare; sourcing scripts may set these) --------------

RED="${RED:-\033[0;31m}"
GREEN="${GREEN:-\033[0;32m}"
YELLOW="${YELLOW:-\033[1;33m}"
BLUE="${BLUE:-\033[0;34m}"
CYAN="${CYAN:-\033[0;36m}"
MAGENTA="${MAGENTA:-\033[0;35m}"
BOLD="${BOLD:-\033[1m}"
DIM="${DIM:-\033[2m}"
NC="${NC:-\033[0m}"

SCRIPT_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
SKILL_DIR="${SKILL_DIR:-$(dirname "$SCRIPT_DIR")}"
CONTAINERLINT_VERSION="${VERSION:-1.0.0}"

# Global findings array
# Each finding: FILE|LINE|SEVERITY|CHECK_ID|DESCRIPTION|RECOMMENDATION|MATCHED_TEXT
declare -a FINDINGS=()

# Global file tracking
declare -A FILE_FINDING_COUNTS=()

# ============================================================================
# File Discovery
# ============================================================================

# Check if a file should be skipped (binary, generated, vendor, etc.)
should_skip_file() {
  local filepath="$1"
  local basename_f
  basename_f=$(basename "$filepath")
  local basename_lower
  basename_lower=$(echo "$basename_f" | tr '[:upper:]' '[:lower:]')
  local ext="${basename_lower##*.}"

  # Skip binary/image/compiled file extensions
  case "$ext" in
    png|jpg|jpeg|gif|bmp|ico|svg|webp|mp3|mp4|avi|mov|mkv|wav|flac|ogg) return 0 ;;
    zip|tar|gz|bz2|xz|7z|rar|tgz) return 0 ;;
    exe|dll|so|dylib|o|a|class|pyc|pyo|wasm) return 0 ;;
    woff|woff2|ttf|eot|otf) return 0 ;;
    pdf|doc|docx|xls|xlsx|ppt|pptx) return 0 ;;
    lock) return 0 ;;  # package-lock.json, yarn.lock, etc.
    map) return 0 ;;   # source maps
  esac

  # Skip minified files
  case "$basename_lower" in
    *.min.js|*.min.css|*.min.mjs) return 0 ;;
    *.bundle.js|*.chunk.js) return 0 ;;
  esac

  # Skip known non-source files
  case "$basename_lower" in
    package-lock.json|yarn.lock|pnpm-lock.yaml|composer.lock|gemfile.lock|cargo.lock|poetry.lock) return 0 ;;
    .ds_store|thumbs.db|desktop.ini) return 0 ;;
  esac

  return 1
}

# Check if a path is in a directory that should be skipped
is_excluded_dir() {
  local filepath="$1"

  case "$filepath" in
    */.git/*|*/.git) return 0 ;;
    */node_modules/*|*/node_modules) return 0 ;;
    */vendor/*|*/vendor) return 0 ;;
    */.next/*|*/.next) return 0 ;;
    */dist/*|*/dist) return 0 ;;
    */build/*|*/build) return 0 ;;
    */coverage/*|*/coverage) return 0 ;;
    */__pycache__/*|*/__pycache__) return 0 ;;
    */.venv/*|*/.venv) return 0 ;;
    */venv/*|*/venv) return 0 ;;
    */.tox/*|*/.tox) return 0 ;;
    */.eggs/*|*/.eggs) return 0 ;;
    */.terraform/*|*/.terraform) return 0 ;;
    */.cache/*|*/.cache) return 0 ;;
    */target/*|*/target) return 0 ;;
  esac

  return 1
}

# Check if a file is ignored by .gitignore
is_gitignored() {
  local filepath="$1"

  if command -v git &>/dev/null; then
    if git rev-parse --git-dir &>/dev/null 2>&1; then
      if git check-ignore -q "$filepath" 2>/dev/null; then
        return 0
      fi
    fi
  fi

  return 1
}

# Find all scannable files in a directory tree
find_scannable_files() {
  local search_dir="$1"
  local -n _result_files="$2"
  _result_files=()

  if [[ -f "$search_dir" ]]; then
    if ! should_skip_file "$search_dir"; then
      _result_files+=("$search_dir")
    fi
    return 0
  fi

  while IFS= read -r -d '' file; do
    # Skip excluded directories
    is_excluded_dir "$file" && continue

    # Skip binary/generated files
    should_skip_file "$file" && continue

    # Skip gitignored files
    is_gitignored "$file" && continue

    _result_files+=("$file")
  done < <(find "$search_dir" -maxdepth 10 \
    \( -name ".git" -o -name "node_modules" -o -name "vendor" -o -name ".next" \
       -o -name "dist" -o -name "build" -o -name "coverage" -o -name "__pycache__" \
       -o -name ".venv" -o -name "venv" -o -name ".tox" -o -name ".terraform" \
       -o -name ".cache" -o -name "target" \) -prune -o \
    -type f -print0 2>/dev/null)
}

# ============================================================================
# Pattern Scanning
# ============================================================================

# Scan a single file against patterns for given categories.
# Appends findings to global FINDINGS array.
# Tracks per-file finding counts in FILE_FINDING_COUNTS.
#
# Arguments:
#   $1 -- filepath to scan
#   $2 -- space-separated list of category codes (e.g., "DF SC HC RS")
#   $3 -- optional: specific category filter (only scan this category)
scan_file() {
  local filepath="$1"
  local categories="$2"
  local category_filter="${3:-}"

  local file_count_before=${#FINDINGS[@]}

  for category in $categories; do
    # If a specific category filter is set, skip non-matching categories
    if [[ -n "$category_filter" ]]; then
      local filter_upper
      filter_upper=$(echo "$category_filter" | tr '[:lower:]' '[:upper:]')
      if [[ "$category" != "$filter_upper" ]]; then
        continue
      fi
    fi

    local patterns_name
    patterns_name=$(get_containerlint_patterns_for_category "$category")

    if [[ -z "$patterns_name" ]]; then
      continue
    fi

    local -n _patterns_ref="$patterns_name"

    for entry in "${_patterns_ref[@]}"; do
      IFS='|' read -r regex severity check_id description recommendation <<< "$entry"

      # Use grep -nE to find matches with line numbers
      local matches
      matches=$(grep -nE "$regex" "$filepath" 2>/dev/null || true)

      if [[ -n "$matches" ]]; then
        while IFS= read -r match_line; do
          [[ -z "$match_line" ]] && continue
          local line_num="${match_line%%:*}"
          local matched_text="${match_line#*:}"

          # Trim whitespace
          matched_text=$(echo "$matched_text" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')

          # Truncate long lines
          if [[ ${#matched_text} -gt 120 ]]; then
            matched_text="${matched_text:0:117}..."
          fi

          local finding="${filepath}|${line_num}|${severity}|${check_id}|${description}|${recommendation}|${matched_text}"
          FINDINGS+=("$finding")

        done <<< "$matches"
      fi
    done
  done

  # Track per-file counts
  local file_count_after=${#FINDINGS[@]}
  local file_new_findings=$((file_count_after - file_count_before))
  if [[ $file_new_findings -gt 0 ]]; then
    FILE_FINDING_COUNTS["$filepath"]=$file_new_findings
  fi
}

# ============================================================================
# Score Calculation & Grading
# ============================================================================

# Calculate a container security score based on findings.
# Score starts at 100 (clean) and decreases with each finding.
# Severity weights: critical=25, high=15, medium=8, low=3
calculate_score() {
  local score=100

  for finding in "${FINDINGS[@]}"; do
    IFS='|' read -r _file _line severity _check_id _desc _rec _text <<< "$finding"
    local points
    points=$(severity_to_points "$severity")
    score=$((score - points))
  done

  # Floor at 0
  if [[ $score -lt 0 ]]; then
    score=0
  fi

  echo "$score"
}

# Convert score to letter grade
# A: 90-100, B: 80-89, C: 70-79, D: 60-69, F: <60
score_to_grade() {
  local score="$1"
  if [[ $score -ge 90 ]]; then echo "A"
  elif [[ $score -ge 80 ]]; then echo "B"
  elif [[ $score -ge 70 ]]; then echo "C"
  elif [[ $score -ge 60 ]]; then echo "D"
  else echo "F"
  fi
}

# Grade color for terminal output
grade_color() {
  local grade="$1"
  case "$grade" in
    A) echo "$GREEN" ;;
    B) echo "$GREEN" ;;
    C) echo "$YELLOW" ;;
    D) echo "$YELLOW" ;;
    F) echo "$RED" ;;
    *) echo "$NC" ;;
  esac
}

# Severity color for terminal output
severity_color() {
  local severity="$1"
  case "$severity" in
    critical) echo "$RED" ;;
    high)     echo "$MAGENTA" ;;
    medium)   echo "$YELLOW" ;;
    low)      echo "$CYAN" ;;
    *)        echo "$NC" ;;
  esac
}

# ============================================================================
# Severity Counting
# ============================================================================

# Count findings by severity level
count_by_severity() {
  local target_severity="$1"
  local count=0
  for finding in "${FINDINGS[@]}"; do
    IFS='|' read -r _ _ severity _ _ _ _ <<< "$finding"
    if [[ "$severity" == "$target_severity" ]]; then
      count=$((count + 1))
    fi
  done
  echo "$count"
}

# Count findings by category prefix
count_by_category() {
  local target_prefix="$1"
  local count=0
  for finding in "${FINDINGS[@]}"; do
    IFS='|' read -r _ _ _ check_id _ _ _ <<< "$finding"
    local prefix="${check_id%%-*}"
    if [[ "$prefix" == "$target_prefix" ]]; then
      count=$((count + 1))
    fi
  done
  echo "$count"
}

# ============================================================================
# Text Output
# ============================================================================

# Print findings to stdout in text format
print_findings_text() {
  local verbose="${1:-false}"

  local count=0
  local crit_count high_count med_count low_count
  crit_count=$(count_by_severity "critical")
  high_count=$(count_by_severity "high")
  med_count=$(count_by_severity "medium")
  low_count=$(count_by_severity "low")

  for finding in "${FINDINGS[@]}"; do
    IFS='|' read -r file line severity check_id description recommendation matched_text <<< "$finding"
    count=$((count + 1))

    local sev_col
    sev_col=$(severity_color "$severity")

    echo -e "  ${sev_col}${severity^^}${NC} ${DIM}${check_id}${NC} ${file}:${line}"
    echo -e "    ${description}"
    if [[ "$verbose" == "true" ]]; then
      echo -e "    ${DIM}Matched: ${matched_text}${NC}"
    fi
    echo -e "    ${DIM}Fix: ${recommendation}${NC}"
    echo ""
  done

  echo -e "${BOLD}--- Summary ---${NC}"
  echo -e "  Total findings: ${BOLD}$count${NC}"
  if [[ $crit_count -gt 0 ]]; then
    echo -e "  ${RED}Critical: $crit_count${NC}"
  fi
  if [[ $high_count -gt 0 ]]; then
    echo -e "  ${MAGENTA}High: $high_count${NC}"
  fi
  if [[ $med_count -gt 0 ]]; then
    echo -e "  ${YELLOW}Medium: $med_count${NC}"
  fi
  if [[ $low_count -gt 0 ]]; then
    echo -e "  ${CYAN}Low: $low_count${NC}"
  fi
}

# Print category breakdown in text format
print_category_breakdown_text() {
  echo ""
  echo -e "${BOLD}--- Category Breakdown ---${NC}"

  for cat in DF SC HC RS NW OR; do
    local cat_count
    cat_count=$(count_by_category "$cat")
    if [[ $cat_count -gt 0 ]]; then
      local label
      label=$(get_containerlint_category_label "$cat")
      echo -e "  ${BOLD}$cat${NC} ($label): $cat_count findings"
    fi
  done
}

# Print file-by-file results
print_file_results_text() {
  if [[ ${#FILE_FINDING_COUNTS[@]} -eq 0 ]]; then
    return
  fi

  echo ""
  echo -e "${BOLD}--- Files with Findings ---${NC}"

  for file in "${!FILE_FINDING_COUNTS[@]}"; do
    echo -e "  ${FILE_FINDING_COUNTS[$file]} findings in ${DIM}$file${NC}"
  done
}

# Print score and grade in text format
print_score_text() {
  local score="$1"
  local grade
  grade=$(score_to_grade "$score")
  local gc
  gc=$(grade_color "$grade")

  echo ""
  echo -e "${BOLD}Container Security Score: ${gc}${score}/100${NC} (Grade: ${gc}${BOLD}${grade}${NC})"

  if [[ $score -ge 70 ]]; then
    echo -e "${GREEN}PASS${NC} -- Score is above threshold (70)"
  else
    echo -e "${RED}FAIL${NC} -- Score is below threshold (70)"
  fi
}

# ============================================================================
# JSON Output
# ============================================================================

# Generate JSON output for all findings
generate_json_output() {
  local target="$1"
  local files_scanned="$2"
  local score="$3"
  local grade="$4"
  local pass_status="$5"

  local crit_count high_count med_count low_count
  crit_count=$(count_by_severity "critical")
  high_count=$(count_by_severity "high")
  med_count=$(count_by_severity "medium")
  low_count=$(count_by_severity "low")

  local date_str
  date_str=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%Y-%m-%d 2>/dev/null || echo "unknown")

  # Build findings JSON array
  local findings_json=""
  local idx=0
  for finding in "${FINDINGS[@]}"; do
    IFS='|' read -r file line severity check_id description recommendation matched_text <<< "$finding"

    # Escape special characters for JSON
    local esc_desc esc_rec esc_match esc_file
    esc_file=$(echo "$file" | sed 's/\\/\\\\/g; s/"/\\"/g')
    esc_desc=$(echo "$description" | sed 's/\\/\\\\/g; s/"/\\"/g')
    esc_rec=$(echo "$recommendation" | sed 's/\\/\\\\/g; s/"/\\"/g')
    esc_match=$(echo "$matched_text" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g')

    [[ $idx -gt 0 ]] && findings_json="${findings_json},"
    findings_json="${findings_json}
    {
      \"file\": \"${esc_file}\",
      \"line\": ${line},
      \"severity\": \"${severity}\",
      \"check_id\": \"${check_id}\",
      \"category\": \"${check_id%%-*}\",
      \"description\": \"${esc_desc}\",
      \"recommendation\": \"${esc_rec}\",
      \"matched_text\": \"${esc_match}\"
    }"
    idx=$((idx + 1))
  done

  # Build category breakdown
  local categories_json=""
  local cat_idx=0
  for cat in DF SC HC RS NW OR; do
    local cat_count
    cat_count=$(count_by_category "$cat")
    local label
    label=$(get_containerlint_category_label "$cat")

    [[ $cat_idx -gt 0 ]] && categories_json="${categories_json},"
    categories_json="${categories_json}
    {
      \"code\": \"${cat}\",
      \"name\": \"${label}\",
      \"count\": ${cat_count}
    }"
    cat_idx=$((cat_idx + 1))
  done

  cat <<JSON_EOF
{
  "tool": "ContainerLint",
  "version": "${CONTAINERLINT_VERSION}",
  "timestamp": "${date_str}",
  "target": "${target}",
  "score": ${score},
  "grade": "${grade}",
  "pass": ${pass_status},
  "summary": {
    "files_scanned": ${files_scanned},
    "total_findings": ${#FINDINGS[@]},
    "critical": ${crit_count},
    "high": ${high_count},
    "medium": ${med_count},
    "low": ${low_count}
  },
  "categories": [${categories_json}
  ],
  "findings": [${findings_json}
  ]
}
JSON_EOF
}

# ============================================================================
# HTML Output
# ============================================================================

# Generate HTML report output
generate_html_output() {
  local target="$1"
  local files_scanned="$2"
  local score="$3"
  local grade="$4"

  local crit_count high_count med_count low_count
  crit_count=$(count_by_severity "critical")
  high_count=$(count_by_severity "high")
  med_count=$(count_by_severity "medium")
  low_count=$(count_by_severity "low")

  local date_str
  date_str=$(date +%Y-%m-%d 2>/dev/null || echo "unknown")

  # Determine grade color
  local grade_css_color
  case "$grade" in
    A|B) grade_css_color="#22c55e" ;;
    C)   grade_css_color="#eab308" ;;
    D)   grade_css_color="#f97316" ;;
    F)   grade_css_color="#ef4444" ;;
    *)   grade_css_color="#6b7280" ;;
  esac

  # Build findings table rows
  local findings_rows=""
  local idx=0
  for finding in "${FINDINGS[@]}"; do
    IFS='|' read -r file line severity check_id description recommendation matched_text <<< "$finding"
    idx=$((idx + 1))

    local sev_color
    case "$severity" in
      critical) sev_color="#ef4444" ;;
      high)     sev_color="#a855f7" ;;
      medium)   sev_color="#eab308" ;;
      low)      sev_color="#06b6d4" ;;
      *)        sev_color="#6b7280" ;;
    esac

    # HTML-escape text
    local esc_file esc_desc esc_rec
    esc_file=$(echo "$file" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g')
    esc_desc=$(echo "$description" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g')
    esc_rec=$(echo "$recommendation" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g')

    findings_rows="${findings_rows}
      <tr>
        <td>${idx}</td>
        <td><code>${esc_file}:${line}</code></td>
        <td><span style=\"color:${sev_color};font-weight:bold\">${severity^^}</span></td>
        <td><code>${check_id}</code></td>
        <td>${esc_desc}</td>
        <td><em>${esc_rec}</em></td>
      </tr>"
  done

  cat <<HTML_EOF
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ContainerLint Report -- ${target}</title>
  <style>
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 1100px; margin: 0 auto; padding: 2rem; background: #f8fafc; color: #1e293b; }
    h1 { color: #0f172a; border-bottom: 3px solid #0891b2; padding-bottom: 0.5rem; }
    h2 { color: #334155; margin-top: 2rem; }
    .score-badge { display: inline-block; padding: 0.5rem 1.5rem; border-radius: 0.5rem; color: white; font-size: 1.5rem; font-weight: bold; background: ${grade_css_color}; }
    .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin: 1rem 0; }
    .summary-card { background: white; padding: 1rem; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); text-align: center; }
    .summary-card .number { font-size: 2rem; font-weight: bold; }
    .summary-card .label { color: #64748b; font-size: 0.875rem; }
    table { width: 100%; border-collapse: collapse; margin: 1rem 0; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 0.5rem; overflow: hidden; }
    th { background: #0e7490; color: white; padding: 0.75rem; text-align: left; font-size: 0.875rem; }
    td { padding: 0.75rem; border-bottom: 1px solid #e2e8f0; font-size: 0.875rem; }
    tr:hover { background: #f1f5f9; }
    code { background: #e2e8f0; padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.8rem; }
    .footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 0.75rem; }
  </style>
</head>
<body>
  <h1>ContainerLint Report</h1>
  <p>Target: <code>${target}</code> | Date: ${date_str} | Version: ${CONTAINERLINT_VERSION}</p>

  <div class="score-badge">${score}/100 (${grade})</div>

  <h2>Summary</h2>
  <div class="summary-grid">
    <div class="summary-card"><div class="number">${files_scanned}</div><div class="label">Files Scanned</div></div>
    <div class="summary-card"><div class="number">${#FINDINGS[@]}</div><div class="label">Total Findings</div></div>
    <div class="summary-card"><div class="number" style="color:#ef4444">${crit_count}</div><div class="label">Critical</div></div>
    <div class="summary-card"><div class="number" style="color:#a855f7">${high_count}</div><div class="label">High</div></div>
    <div class="summary-card"><div class="number" style="color:#eab308">${med_count}</div><div class="label">Medium</div></div>
    <div class="summary-card"><div class="number" style="color:#06b6d4">${low_count}</div><div class="label">Low</div></div>
  </div>

  <h2>Category Breakdown</h2>
  <table>
    <tr><th>Category</th><th>Code</th><th>Findings</th></tr>
    <tr><td>Dockerfile Best Practices</td><td>DF</td><td>$(count_by_category "DF")</td></tr>
    <tr><td>Security Context</td><td>SC</td><td>$(count_by_category "SC")</td></tr>
    <tr><td>Health & Readiness</td><td>HC</td><td>$(count_by_category "HC")</td></tr>
    <tr><td>Resource Management</td><td>RS</td><td>$(count_by_category "RS")</td></tr>
    <tr><td>Networking & Exposure</td><td>NW</td><td>$(count_by_category "NW")</td></tr>
    <tr><td>Orchestration & Compose</td><td>OR</td><td>$(count_by_category "OR")</td></tr>
  </table>

  <h2>Findings</h2>
  <table>
    <tr><th>#</th><th>Location</th><th>Severity</th><th>Check</th><th>Description</th><th>Recommendation</th></tr>
    ${findings_rows}
  </table>

  <div class="footer">
    Generated by <a href="https://containerlint.pages.dev">ContainerLint</a> v${CONTAINERLINT_VERSION} on ${date_str}.
  </div>
</body>
</html>
HTML_EOF
}

# ============================================================================
# Markdown Report Generation
# ============================================================================

# Generate a markdown report using the template file
generate_markdown_report() {
  local target="$1"
  local files_scanned="$2"

  local score
  score=$(calculate_score)
  local grade
  grade=$(score_to_grade "$score")
  local date_str
  date_str=$(date +%Y-%m-%d 2>/dev/null || echo "unknown")

  # Count severities
  local crit_count high_count med_count low_count
  crit_count=$(count_by_severity "critical")
  high_count=$(count_by_severity "high")
  med_count=$(count_by_severity "medium")
  low_count=$(count_by_severity "low")

  local total_issues=${#FINDINGS[@]}

  # Build category breakdown
  local cat_breakdown=""
  for cat in DF SC HC RS NW OR; do
    local cat_count
    cat_count=$(count_by_category "$cat")
    local label
    label=$(get_containerlint_category_label "$cat")
    cat_breakdown="${cat_breakdown}| ${label} (${cat}) | ${cat_count} |"$'\n'
  done

  # Build findings table
  local findings_table=""
  local idx=0
  for finding in "${FINDINGS[@]}"; do
    idx=$((idx + 1))
    IFS='|' read -r file line severity check_id description recommendation matched_text <<< "$finding"
    findings_table="${findings_table}| ${idx} | ${file} | ${line} | ${severity} | ${check_id} | ${description} |"$'\n'
  done

  # Build recommendations
  local recommendations=""
  if [[ $crit_count -gt 0 ]]; then
    recommendations="${recommendations}1. **CRITICAL:** ${crit_count} critical findings -- address immediately (container escape, secret exposure)"$'\n'
  fi
  if [[ $high_count -gt 0 ]]; then
    recommendations="${recommendations}2. **HIGH:** ${high_count} high-severity findings -- fix privileged containers, exposed ports, missing user directives"$'\n'
  fi
  if [[ $med_count -gt 0 ]]; then
    recommendations="${recommendations}3. **MEDIUM:** ${med_count} medium findings -- harden security context and add health checks"$'\n'
  fi
  if [[ $low_count -gt 0 ]]; then
    recommendations="${recommendations}4. **LOW:** ${low_count} low findings -- review for best practices and false positives"$'\n'
  fi

  # Get project name
  local project_name
  project_name=$(basename "$(cd "$target" 2>/dev/null && pwd)" 2>/dev/null || echo "$target")

  # Read template and substitute
  local template_file="$SKILL_DIR/templates/report.md.tmpl"
  if [[ ! -f "$template_file" ]]; then
    echo -e "${RED}[ContainerLint]${NC} Report template not found: $template_file" >&2
    return 1
  fi

  local report
  report=$(cat "$template_file")
  report="${report//\{\{PROJECT\}\}/$project_name}"
  report="${report//\{\{VERSION\}\}/$CONTAINERLINT_VERSION}"
  report="${report//\{\{DATE\}\}/$date_str}"
  report="${report//\{\{SCORE\}\}/$score}"
  report="${report//\{\{GRADE\}\}/$grade}"
  report="${report//\{\{FILES_SCANNED\}\}/$files_scanned}"
  report="${report//\{\{TOTAL_ISSUES\}\}/$total_issues}"
  report="${report//\{\{CRITICAL_COUNT\}\}/$crit_count}"
  report="${report//\{\{HIGH_COUNT\}\}/$high_count}"
  report="${report//\{\{MEDIUM_COUNT\}\}/$med_count}"
  report="${report//\{\{LOW_COUNT\}\}/$low_count}"
  report="${report//\{\{CATEGORY_BREAKDOWN\}\}/$cat_breakdown}"
  report="${report//\{\{FINDINGS_TABLE\}\}/$findings_table}"
  report="${report//\{\{RECOMMENDATIONS\}\}/$recommendations}"

  echo "$report"
}

# ============================================================================
# Main Scan Function
# ============================================================================

# do_containerlint_scan <target> <tier_level> <format> <verbose> <category_filter>
#
# Arguments:
#   target          -- file or directory to scan
#   tier_level      -- numeric tier (0=free, 1=pro, 2=team, 3=enterprise)
#   format          -- output format: text, json, html
#   verbose         -- "true" or "false"
#   category_filter -- optional category code to filter (e.g., "DF")
do_containerlint_scan() {
  local target="${1:-.}"
  local tier_level="${2:-0}"
  local format="${3:-text}"
  local verbose="${4:-false}"
  local category_filter="${5:-}"

  FINDINGS=()
  FILE_FINDING_COUNTS=()

  # Determine which categories are available for this tier
  local categories
  categories=$(get_containerlint_categories_for_tier "$tier_level")

  # Validate category filter if provided
  if [[ -n "$category_filter" ]]; then
    local filter_upper
    filter_upper=$(echo "$category_filter" | tr '[:lower:]' '[:upper:]')
    if ! is_valid_containerlint_category "$filter_upper"; then
      echo -e "${RED}[ContainerLint]${NC} Invalid category: ${BOLD}$category_filter${NC}" >&2
      echo -e "  Valid categories: DF, SC, HC, RS, NW, OR" >&2
      return 1
    fi

    # Check if the filtered category is available for this tier
    local cat_available=false
    for available_cat in $categories; do
      if [[ "$available_cat" == "$filter_upper" ]]; then
        cat_available=true
        break
      fi
    done

    if [[ "$cat_available" == false ]]; then
      echo -e "${RED}[ContainerLint]${NC} Category ${BOLD}$filter_upper${NC} is not available on your current tier." >&2
      echo -e "  Available categories for your tier: ${BOLD}$categories${NC}" >&2
      echo -e "  Upgrade at: ${CYAN}https://containerlint.pages.dev/pricing${NC}" >&2
      return 1
    fi
  fi

  # Discover files
  local -a files=()
  find_scannable_files "$target" files

  local total_files=${#files[@]}

  if [[ $total_files -eq 0 ]]; then
    if [[ "$format" == "json" ]]; then
      generate_json_output "$target" 0 100 "A" "true"
    elif [[ "$format" == "html" ]]; then
      generate_html_output "$target" 0 100 "A"
    else
      echo -e "${GREEN}[ContainerLint]${NC} No scannable files found in ${BOLD}$target${NC}"
      echo -e "  Clean scan -- no files to check."
    fi
    return 0
  fi

  # Only print progress info for text format
  if [[ "$format" == "text" ]]; then
    local pattern_count=0
    for cat in $categories; do
      local cnt
      cnt=$(containerlint_category_count "$cat")
      pattern_count=$((pattern_count + cnt))
    done

    echo -e "${BLUE}[ContainerLint]${NC} Scanning ${BOLD}$total_files${NC} files with ${BOLD}$pattern_count${NC} patterns..."
    if [[ -n "$category_filter" ]]; then
      echo -e "  ${DIM}Category filter: ${category_filter^^}${NC}"
    fi
    echo ""
  fi

  # Scan each file
  for filepath in "${files[@]}"; do
    scan_file "$filepath" "$categories" "$category_filter"
  done

  # Calculate results
  local score
  score=$(calculate_score)
  local grade
  grade=$(score_to_grade "$score")
  local pass_status
  if [[ $score -ge 70 ]]; then
    pass_status="true"
  else
    pass_status="false"
  fi

  # Output results in requested format
  case "$format" in
    json)
      generate_json_output "$target" "$total_files" "$score" "$grade" "$pass_status"
      ;;
    html)
      generate_html_output "$target" "$total_files" "$score" "$grade"
      ;;
    text|*)
      if [[ ${#FINDINGS[@]} -eq 0 ]]; then
        echo -e "${GREEN}${BOLD}No container security issues detected!${NC}"
        echo ""
        echo -e "Container Security Score: ${GREEN}${BOLD}100/100${NC} (Grade: ${GREEN}${BOLD}A${NC})"
        echo -e "${GREEN}PASS${NC}"
        return 0
      fi

      print_findings_text "$verbose"
      print_category_breakdown_text
      print_file_results_text
      print_score_text "$score"
      ;;
  esac

  # Return exit code based on score
  if [[ $score -lt 70 ]]; then
    return 1
  fi
  return 0
}

# ============================================================================
# Hook Integration
# ============================================================================

# Called by lefthook pre-commit hook
hook_containerlint_check() {
  local tier_level="${1:-0}"

  # Get list of staged files
  local staged_files
  staged_files=$(git diff --cached --name-only --diff-filter=ACMR 2>/dev/null || true)

  if [[ -z "$staged_files" ]]; then
    return 0
  fi

  FINDINGS=()
  FILE_FINDING_COUNTS=()

  # Determine categories for tier
  local categories
  categories=$(get_containerlint_categories_for_tier "$tier_level")

  local file_count=0
  while IFS= read -r filepath; do
    [[ -z "$filepath" ]] && continue
    [[ ! -f "$filepath" ]] && continue

    # Skip excluded dirs and binary files
    is_excluded_dir "$filepath" && continue
    should_skip_file "$filepath" && continue

    scan_file "$filepath" "$categories" ""
    file_count=$((file_count + 1))
  done <<< "$staged_files"

  if [[ ${#FINDINGS[@]} -eq 0 ]]; then
    echo -e "${GREEN}[ContainerLint]${NC} No container security issues in $file_count staged files."
    return 0
  fi

  # Check if any critical or high findings exist
  local has_blocking=false
  for finding in "${FINDINGS[@]}"; do
    IFS='|' read -r _ _ severity _ _ _ _ <<< "$finding"
    if [[ "$severity" == "critical" || "$severity" == "high" ]]; then
      has_blocking=true
      break
    fi
  done

  echo -e "${RED}${BOLD}[ContainerLint] Container security issues detected in staged files!${NC}"
  echo ""
  print_findings_text "false"

  local score
  score=$(calculate_score)
  print_score_text "$score"

  if [[ "$has_blocking" == true ]]; then
    echo ""
    echo -e "${RED}Commit blocked.${NC} Fix container security issues before committing."
    echo -e "  Run: ${CYAN}containerlint scan${NC} for details"
    echo -e "  Skip: ${DIM}git commit --no-verify${NC} (not recommended)"
    return 1
  fi

  return 0
}


path: scripts/dispatcher.sh
content: #!/usr/bin/env bash
# ContainerLint -- Main CLI Dispatcher (Entry Point)
# Usage: dispatcher.sh [options]
#
# Options:
#   --path <path>          File or directory to scan (default: .)
#   --format <fmt>         Output format: text, json, html (default: text)
#   --tier <tier>          Override tier: free, pro, team, enterprise
#   --license-key <key>    Provide license key directly
#   --verbose              Show detailed per-match output
#   --category <cat>       Filter to specific category: DF, SC, HC, RS, NW, OR
#   --help, -h             Show help
#   --version, -v          Show version
#   status                 Show license and configuration info
#   patterns               List all detection patterns

set -euo pipefail

# --- Script location ----------------------------------------------------------

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
VERSION="1.0.0"

# --- Colors -------------------------------------------------------------------

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'

# --- Logging helpers ----------------------------------------------------------

log_info()    { echo -e "${BLUE}[ContainerLint]${NC} $*"; }
log_success() { echo -e "${GREEN}[ContainerLint]${NC} $*"; }
log_warn()    { echo -e "${YELLOW}[ContainerLint]${NC} $*"; }
log_error()   { echo -e "${RED}[ContainerLint]${NC} $*" >&2; }

# --- Version & Help -----------------------------------------------------------

show_version() {
  echo -e "${BOLD}ContainerLint${NC} v${VERSION}"
  echo "  Docker & container security anti-pattern analyzer"
  echo "  https://containerlint.pages.dev"
}

show_help() {
  show_version
  echo ""
  echo -e "${BOLD}USAGE${NC}"
  echo "  dispatcher.sh [options]"
  echo "  dispatcher.sh status"
  echo "  dispatcher.sh patterns"
  echo ""
  echo -e "${BOLD}OPTIONS${NC}"
  echo -e "  --path <path>          ${DIM}File or directory to scan (default: .)${NC}"
  echo -e "  --format <fmt>         ${DIM}Output: text, json, html (default: text)${NC}"
  echo -e "  --tier <tier>          ${DIM}Override: free, pro, team, enterprise${NC}"
  echo -e "  --license-key <key>    ${DIM}Provide license key directly${NC}"
  echo -e "  --verbose              ${DIM}Detailed per-match output${NC}"
  echo -e "  --category <cat>       ${DIM}Filter: DF, SC, HC, RS, NW, OR${NC}"
  echo -e "  --help, -h             ${DIM}Show this help${NC}"
  echo -e "  --version, -v          ${DIM}Show version${NC}"
  echo ""
  echo -e "${BOLD}COMMANDS${NC}"
  echo -e "  status                 ${DIM}Show license and configuration info${NC}"
  echo -e "  patterns               ${DIM}List all detection patterns${NC}"
  echo ""
  echo -e "${BOLD}TIER SYSTEM${NC}"
  echo -e "  ${GREEN}FREE${NC}        30 patterns  DF, SC categories"
  echo -e "  ${CYAN}PRO${NC}  \$19/mo  60 patterns  DF, SC, HC, RS"
  echo -e "  ${MAGENTA}TEAM${NC} \$39/mo  90 patterns  DF, SC, HC, RS, NW, OR"
  echo -e "  ${BOLD}ENTERPRISE${NC}   90 patterns  All + priority support"
  echo ""
  echo -e "${BOLD}CHECK CATEGORIES${NC}"
  echo "  DF  Dockerfile Best Practices  -- latest tag, ADD vs COPY, chmod 777, stale apt cache"
  echo "  SC  Security Context           -- privileged mode, root user, hardcoded secrets, host PID/IPC"
  echo "  HC  Health & Readiness         -- missing HEALTHCHECK, no graceful shutdown, probe config"
  echo "  RS  Resource Management        -- no memory/CPU limits, unlimited PIDs, missing ulimits"
  echo "  NW  Networking & Exposure      -- exposed database ports, host networking, missing TLS"
  echo "  OR  Orchestration & Compose    -- no restart policy, :latest in compose, plaintext secrets"
  echo ""
  echo -e "${BOLD}SCORING${NC}"
  echo "  100 = perfect, 0 = worst"
  echo "  Critical: -25, High: -15, Medium: -8, Low: -3"
  echo "  A(90+), B(80-89), C(70-79), D(60-69), F(<60)"
  echo "  Pass threshold: 70 (exit 0=pass, 1=fail)"
  echo ""
  echo -e "${BOLD}EXAMPLES${NC}"
  echo "  dispatcher.sh --path ."
  echo "  dispatcher.sh --path src/ --format json"
  echo "  dispatcher.sh --path . --category DF --verbose"
  echo "  dispatcher.sh --path . --format html > report.html"
  echo ""
  echo -e "Get a license at ${CYAN}https://containerlint.pages.dev/pricing${NC}"
}

# --- Tier hierarchy -----------------------------------------------------------

declare -A TIER_LEVELS=(
  [free]=0
  [pro]=1
  [team]=2
  [enterprise]=3
)

# --- License integration ------------------------------------------------------

# Source license module and determine the effective tier.
# Priority: --tier flag > --license-key flag > env var > config file > free
resolve_tier() {
  local tier_override="$1"
  local license_key_flag="$2"

  # If tier is explicitly overridden, validate license for that tier
  if [[ -n "$tier_override" ]]; then
    local required_level="${TIER_LEVELS[$tier_override]:-0}"
    if [[ $required_level -gt 0 ]]; then
      # Need to validate license
      source "$SCRIPT_DIR/license.sh"

      # If a key was provided via flag, set it in env
      if [[ -n "$license_key_flag" ]]; then
        export CONTAINERLINT_LICENSE_KEY="$license_key_flag"
      fi

      if ! check_containerlint_license "$tier_override"; then
        exit 1
      fi
    fi
    echo "$required_level"
    return
  fi

  # Auto-detect tier from license
  source "$SCRIPT_DIR/license.sh"

  # If a key was provided via flag, set it in env
  if [[ -n "$license_key_flag" ]]; then
    export CONTAINERLINT_LICENSE_KEY="$license_key_flag"
  fi

  local detected_tier
  detected_tier=$(get_containerlint_tier)
  echo "${TIER_LEVELS[$detected_tier]:-0}"
}

# --- Pattern listing ----------------------------------------------------------

do_list_patterns() {
  source "$SCRIPT_DIR/patterns.sh"

  echo -e "${BOLD}--- ContainerLint Detection Patterns ---${NC}"
  echo ""
  echo -e "${BOLD}$(containerlint_pattern_count) patterns across 6 categories${NC}"
  echo ""

  containerlint_list_patterns "all"
}

# --- Status display -----------------------------------------------------------

do_show_status() {
  show_version
  echo ""
  source "$SCRIPT_DIR/license.sh"
  show_containerlint_status
}

# --- Hooks management ---------------------------------------------------------

do_hooks_install() {
  if ! command -v lefthook &>/dev/null; then
    log_error "lefthook not installed."
    echo "  Install: brew install lefthook"
    return 1
  fi

  if ! git rev-parse --git-dir &>/dev/null 2>&1; then
    log_error "Not inside a git repository."
    return 1
  fi

  local repo_root
  repo_root=$(git rev-parse --show-toplevel)

  log_info "Installing hooks in ${BOLD}$repo_root${NC}"

  local config="$repo_root/lefthook.yml"

  if [[ -f "$config" ]]; then
    if grep -q "containerlint" "$config" 2>/dev/null; then
      log_success "Hooks already configured."
      return 0
    fi

    # Append to existing config
    cat >> "$config" <<'HOOKS'

# --- ContainerLint hooks ----------------------------------------
    containerlint-check:
      run: |
        CONTAINERLINT_SKILL_DIR="${CONTAINERLINT_SKILL_DIR:-$HOME/.openclaw/skills/containerlint}"
        if [[ -f "$CONTAINERLINT_SKILL_DIR/scripts/analyzer.sh" ]]; then
          source "$CONTAINERLINT_SKILL_DIR/scripts/patterns.sh"
          source "$CONTAINERLINT_SKILL_DIR/scripts/analyzer.sh"
          hook_containerlint_check
        else
          echo "[ContainerLint] Skill not found at $CONTAINERLINT_SKILL_DIR -- skipping scan"
        fi
      fail_text: |
        Container security issues detected in staged files!
        Run 'containerlint scan' to see details.
        Or 'git commit --no-verify' to skip (NOT recommended).
HOOKS
    echo -e "${GREEN}+${NC} Appended ContainerLint hooks to existing lefthook.yml"
  else
    cp "$SKILL_DIR/config/lefthook.yml" "$config"
    echo -e "${GREEN}+${NC} Created lefthook.yml"
  fi

  (cd "$repo_root" && lefthook install)
  echo ""
  log_success "Hooks installed! Staged files will be scanned for container security issues on every commit."
}

do_hooks_uninstall() {
  if ! git rev-parse --git-dir &>/dev/null 2>&1; then
    log_error "Not inside a git repository."
    return 1
  fi

  local repo_root
  repo_root=$(git rev-parse --show-toplevel)
  local config="$repo_root/lefthook.yml"

  if [[ -f "$config" ]]; then
    if grep -q "containerlint" "$config" 2>/dev/null; then
      local tmp
      tmp=$(mktemp)
      sed '/# --- ContainerLint hooks/,/fail_text:.*$/d' "$config" > "$tmp"
      grep -v "containerlint" "$tmp" > "$config" 2>/dev/null || mv "$tmp" "$config"
      rm -f "$tmp"
      echo -e "${GREEN}+${NC} Removed ContainerLint hooks"
    else
      log_warn "No ContainerLint hooks found"
    fi
  else
    log_warn "No lefthook.yml found"
  fi
}

# --- Main scan dispatch -------------------------------------------------------

do_scan() {
  local target="$1"
  local tier_level="$2"
  local format="$3"
  local verbose="$4"
  local category_filter="$5"

  # Source dependencies
  source "$SCRIPT_DIR/patterns.sh"
  source "$SCRIPT_DIR/analyzer.sh"

  # Run the scan
  do_containerlint_scan "$target" "$tier_level" "$format" "$verbose" "$category_filter"
}

# --- CLI Argument Parsing -----------------------------------------------------

main() {
  local scan_path="."
  local format="text"
  local tier_override=""
  local license_key_flag=""
  local verbose="false"
  local category_filter=""
  local command=""

  # Parse arguments
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --path)
        shift
        scan_path="${1:-.}"
        ;;
      --format)
        shift
        format="${1:-text}"
        ;;
      --tier)
        shift
        tier_override="${1:-}"
        ;;
      --license-key)
        shift
        license_key_flag="${1:-}"
        ;;
      --verbose)
        verbose="true"
        ;;
      --category)
        shift
        category_filter="${1:-}"
        ;;
      --help|-h|help)
        show_help
        exit 0
        ;;
      --version|-v)
        show_version
        exit 0
        ;;
      status)
        command="status"
        ;;
      patterns)
        command="patterns"
        ;;
      scan)
        # "scan" is the default; just consume the word
        ;;
      check|audit)
        # Aliases for scan
        ;;
      hooks)
        shift
        local subcmd="${1:-}"
        case "$subcmd" in
          install)
            source "$SCRIPT_DIR/license.sh"
            if [[ -n "$license_key_flag" ]]; then
              export CONTAINERLINT_LICENSE_KEY="$license_key_flag"
            fi
            check_containerlint_license "pro" || exit 1
            do_hooks_install
            exit $?
            ;;
          uninstall)
            do_hooks_uninstall
            exit $?
            ;;
          *)
            log_error "Unknown hooks subcommand: $subcmd"
            echo "  Usage: dispatcher.sh hooks [install|uninstall]"
            exit 1
            ;;
        esac
        ;;
      report)
        # Generate markdown report
        shift || true
        local report_target="${1:-.}"
        source "$SCRIPT_DIR/license.sh"
        if [[ -n "$license_key_flag" ]]; then
          export CONTAINERLINT_LICENSE_KEY="$license_key_flag"
        fi
        check_containerlint_license "pro" || exit 1
        source "$SCRIPT_DIR/patterns.sh"
        source "$SCRIPT_DIR/analyzer.sh"
        local tier_num
        tier_num=$(get_tier_level "$(get_containerlint_tier)")
        local categories
        categories=$(get_containerlint_categories_for_tier "$tier_num")
        # Discover and scan files
        local -a files=()
        find_scannable_files "$report_target" files
        for filepath in "${files[@]}"; do
          scan_file "$filepath" "$categories" ""
        done
        generate_markdown_report "$report_target" "${#files[@]}"
        exit 0
        ;;
      -*)
        log_error "Unknown option: $1"
        echo ""
        show_help
        exit 1
        ;;
      *)
        # Treat as path if no command matched
        if [[ -z "$command" && ("$1" == "." || -e "$1") ]]; then
          scan_path="$1"
        else
          log_error "Unknown command: $1"
          echo ""
          show_help
          exit 1
        fi
        ;;
    esac
    shift || true
  done

  # Handle special commands
  case "${command:-}" in
    status)
      do_show_status
      exit 0
      ;;
    patterns)
      do_list_patterns
      exit 0
      ;;
  esac

  # Validate format
  case "$format" in
    text|json|html) ;;
    *)
      log_error "Invalid format: $format. Use text, json, or html."
      exit 1
      ;;
  esac

  # Validate tier override if provided
  if [[ -n "$tier_override" ]]; then
    case "$tier_override" in
      free|pro|team|enterprise) ;;
      *)
        log_error "Invalid tier: $tier_override. Use free, pro, team, or enterprise."
        exit 1
        ;;
    esac
  fi

  # Resolve tier level
  local tier_level
  tier_level=$(resolve_tier "$tier_override" "$license_key_flag")

  # Show banner for text output
  if [[ "$format" == "text" ]]; then
    show_version
    echo ""

    # Show tier info
    local tier_name="Free"
    case "$tier_level" in
      0) tier_name="Free (30 patterns)" ;;
      1) tier_name="Pro (60 patterns)" ;;
      2) tier_name="Team (90 patterns)" ;;
      3) tier_name="Enterprise (90 patterns)" ;;
    esac
    echo -e "  Tier: ${BOLD}$tier_name${NC}"
    echo ""
  fi

  # Run scan
  do_scan "$scan_path" "$tier_level" "$format" "$verbose" "$category_filter"
  exit $?
}

main "$@"


path: scripts/license.sh
content: #!/usr/bin/env bash
# ContainerLint -- License Validation Module
# Offline JWT-based license validation with 3 base64 decode fallbacks.
# Validates product claim, tier level, and expiry date.

set -euo pipefail

# --- Colors -------------------------------------------------------------------

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'

# --- Configuration ------------------------------------------------------------

OPENCLAW_CONFIG="${HOME}/.openclaw/openclaw.json"
CONTAINERLINT_LICENSE_KEY="${CONTAINERLINT_LICENSE_KEY:-}"

# --- Tier Hierarchy -----------------------------------------------------------
# free=0, pro=1, team=2, enterprise=3
# Higher tier level grants access to more patterns and features.

declare -A TIER_LEVELS=(
  [free]=0
  [pro]=1
  [team]=2
  [enterprise]=3
)

# --- Key Retrieval ------------------------------------------------------------

# Retrieve the ContainerLint license key from environment variable or config file.
# Priority: ENV var > openclaw.json (python3 > node > jq fallbacks)
get_containerlint_key() {
  # 1. Check environment variable
  if [[ -n "$CONTAINERLINT_LICENSE_KEY" ]]; then
    echo "$CONTAINERLINT_LICENSE_KEY"
    return 0
  fi

  # 2. Check openclaw.json config file
  if [[ -f "$OPENCLAW_CONFIG" ]]; then
    local key=""

    # Try python3 first
    if command -v python3 &>/dev/null; then
      key=$(python3 -c "
import json
try:
    with open('$OPENCLAW_CONFIG') as f:
        cfg = json.load(f)
    print(cfg.get('skills', {}).get('entries', {}).get('containerlint', {}).get('apiKey', ''))
except: pass
" 2>/dev/null)

    # Try node second
    elif command -v node &>/dev/null; then
      key=$(node -e "
try {
  const cfg = require('$OPENCLAW_CONFIG');
  console.log(cfg?.skills?.entries?.containerlint?.apiKey || '');
} catch(e) {}
" 2>/dev/null)

    # Try jq third
    elif command -v jq &>/dev/null; then
      key=$(jq -r '.skills.entries.containerlint.apiKey // ""' "$OPENCLAW_CONFIG" 2>/dev/null)
    fi

    if [[ -n "${key:-}" ]]; then
      echo "$key"
      return 0
    fi
  fi

  return 1
}

# --- JWT Decode ---------------------------------------------------------------

# Decode the payload (second segment) of a JWT token.
# Uses 3 fallback methods for base64 decoding:
#   1. base64 -d   (GNU/Linux coreutils)
#   2. base64 -D   (BSD/macOS)
#   3. openssl base64 -d  (universal fallback)
#
# Handles URL-safe base64 (tr '_-' '/+') and padding normalization.
decode_jwt_payload() {
  local token="$1"

  # --- JWT Structure Validation ---
  # A valid JWT must have exactly 3 dot-separated parts: header.payload.signature
  local dot_count
  dot_count=$(echo "$token" | tr -cd '.' | wc -c)
  if [[ "$dot_count" -ne 2 ]]; then
    echo "[JWT] Malformed [REDACTED_SECRET] 3 parts (header.payload.signature), got $((dot_count + 1))" >&2
    return 1
  fi

  local header payload signature
  header=$(echo "$token" | cut -d. -f1)
  payload=$(echo "$token" | cut -d. -f2)
  signature=$(echo "$token" | cut -d. -f3)

  # Signature must be non-empty
  if [[ -z "$signature" ]]; then
    echo "[JWT] Rejected: signature segment is empty (unsigned token)" >&2
    return 1
  fi

  # --- Signature Verification (HMAC-SHA256 via openssl, if available) ---
  # NOTE: Full cryptographic verification requires the shared secret. When
  # CLAWHUB_JWT_SECRET is set, we verify the HMAC-SHA256 signature. Without
  # the secret, we still enforce structural integrity (3-part, non-empty sig)
  # which blocks trivially forged tokens (e.g., alg:none attacks).
  if [[ -n "${CLAWHUB_JWT_SECRET:-}" ]] && command -v openssl &>/dev/null; then
    local signing_input="${header}.${payload}"
    local expected_sig
    expected_sig=$(printf '%s' "$signing_input" \
      | openssl dgst -sha256 -hmac "$CLAWHUB_JWT_SECRET" -binary 2>/dev/null \
      | openssl base64 -e 2>/dev/null \
      | tr '/+' '_-' | tr -d '=')
    if [[ "$expected_sig" != "$signature" ]]; then
      echo "[JWT] Signature verification failed" >&2
      return 1
    fi
  fi

  # --- Decode Payload ---
  local padded="$payload"
  local mod=$((${#payload} % 4))
  if [[ $mod -eq 2 ]]; then padded="${payload}=="; fi
  if [[ $mod -eq 3 ]]; then padded="${payload}="; fi

  local decoded
  decoded=$(echo "$padded" | tr '_-' '/+' | base64 -d 2>/dev/null || \
            echo "$padded" | tr '_-' '/+' | base64 -D 2>/dev/null || \
            echo "$padded" | tr '_-' '/+' | openssl base64 -d 2>/dev/null) || {
    echo "[JWT] Failed to decode payload" >&2
    return 1
  }

  # --- Expiry Check ---
  # Check exp claim inline to catch expired tokens at decode time
  local exp_val=""
  if command -v python3 &>/dev/null; then
    exp_val=$(python3 -c "import json; print(json.loads('$decoded').get('exp', ''))" 2>/dev/null || true)
  elif command -v node &>/dev/null; then
    exp_val=$(node -e "console.log(JSON.parse('$decoded').exp || '')" 2>/dev/null || true)
  elif command -v jq &>/dev/null; then
    exp_val=$(echo "$decoded" | jq -r '.exp // ""' 2>/dev/null || true)
  fi

  if [[ -n "$exp_val" && "$exp_val" != "null" ]]; then
    local now
    now=$(date +%s)
    if [[ "$now" -gt "$exp_val" ]]; then
      echo "[JWT] Token expired (exp=$exp_val, now=$now)" >&2
      return 1
    fi
  fi

  echo "$decoded"
}

# --- Field Extraction ---------------------------------------------------------

# Extract a single field from a JSON string.
# Uses python3 > node > jq > grep fallbacks.
extract_field() {
  local json="$1" field="$2"

  if command -v python3 &>/dev/null; then
    python3 -c "import json; print(json.loads('$json').get('$field', ''))" 2>/dev/null
  elif command -v node &>/dev/null; then
    node -e "console.log(JSON.parse('$json')['$field'] || '')" 2>/dev/null
  elif command -v jq &>/dev/null; then
    echo "$json" | jq -r ".$field // \"\"" 2>/dev/null
  else
    # Last resort: simple grep extraction
    echo "$json" | grep -oP "\"$field\"\s*:\s*\"?\K[^\",$}]+" 2>/dev/null || echo ""
  fi
}

# --- License Validation -------------------------------------------------------

# Validate a license key by decoding its JWT payload and checking the product
# field. Returns the decoded payload JSON on success.
validate_license() {
  local key="$1"
  local payload

  if ! payload=$(decode_jwt_payload "$key"); then
    return 1
  fi

  local product
  product=$(extract_field "$payload" "product")

  # Validate product matches containerlint
  if [[ "$product" != "containerlint" && -n "$product" ]]; then
    return 1
  fi

  echo "$payload"
  return 0
}

# --- Full License Check -------------------------------------------------------

# Perform a complete license check for a required tier.
# Validates: key presence, JWT decode, product match, tier level, expiry.
# Prints user-friendly error messages on failure.
check_containerlint_license() {
  local required_tier="${1:-pro}"
  local required_level="${TIER_LEVELS[$required_tier]:-1}"

  # Step 1: Retrieve the license key
  local key
  if ! key=$(get_containerlint_key) || [[ -z "$key" ]]; then
    echo -e "${RED}${BOLD}--- ContainerLint License Required ---${NC}"
    echo ""
    echo -e "This feature requires a ${BOLD}${required_tier}${NC} license."
    echo ""
    echo -e "Get your license at: ${CYAN}${BOLD}https://containerlint.pages.dev/pricing${NC}"
    echo ""
    echo "Then configure it:"
    echo -e "  ${DIM}# In ~/.openclaw/openclaw.json:${NC}"
    echo -e "  ${CYAN}\"skills\": { \"entries\": { \"containerlint\": { \"apiKey\": \"YOUR_KEY\" } } }${NC}"
    echo ""
    echo -e "  ${DIM}# Or set the environment variable:${NC}"
    echo -e "  ${CYAN}export CONTAINERLINT_LICENSE_KEY=\"YOUR_KEY\"${NC}"
    return 1
  fi

  # Step 2: Decode JWT payload
  local payload
  if ! payload=$(decode_jwt_payload "$key"); then
    echo -e "${RED}[ContainerLint]${NC} Invalid license key format."
    echo -e "Get a valid license at: ${CYAN}https://containerlint.pages.dev/pricing${NC}"
    return 1
  fi

  # Step 3: Extract and validate fields
  local product tier expiry
  product=$(extract_field "$payload" "product")
  tier=$(extract_field "$payload" "tier")
  expiry=$(extract_field "$payload" "exp")

  # Step 4: Validate product
  if [[ -n "$product" && "$product" != "containerlint" ]]; then
    echo -e "${RED}[ContainerLint]${NC} License is for product '${BOLD}$product${NC}', not containerlint."
    echo -e "Get a ContainerLint license at: ${CYAN}https://containerlint.pages.dev/pricing${NC}"
    return 1
  fi

  # Step 5: Validate tier level
  local actual_level="${TIER_LEVELS[$tier]:-0}"
  if [[ "$actual_level" -lt "$required_level" ]]; then
    echo -e "${RED}[ContainerLint]${NC} This feature requires ${BOLD}$required_tier${NC} tier. You have: ${BOLD}$tier${NC}"
    echo -e "Upgrade at: ${CYAN}https://containerlint.pages.dev/upgrade${NC}"
    return 1
  fi

  # Step 6: Check expiry
  if [[ -n "$expiry" ]]; then
    local now
    now=$(date +%s)
    if [[ "$now" -gt "$expiry" ]]; then
      echo -e "${RED}[ContainerLint]${NC} License expired. Renew at: ${CYAN}https://containerlint.pages.dev/renew${NC}"
      return 1
    fi
  fi

  return 0
}

# --- Tier Detection -----------------------------------------------------------

# Get the current tier from the license key.
# Returns "free" if no valid key is found.
get_containerlint_tier() {
  local key
  if ! key=$(get_containerlint_key) || [[ -z "$key" ]]; then
    echo "free"
    return 0
  fi

  local payload
  if ! payload=$(decode_jwt_payload "$key" 2>/dev/null); then
    echo "free"
    return 0
  fi

  local tier
  tier=$(extract_field "$payload" "tier")
  echo "${tier:-free}"
}

# --- Tier Level Lookup --------------------------------------------------------

# Convert a tier name to its numeric level for comparison.
get_tier_level() {
  local tier="${1:-free}"
  echo "${TIER_LEVELS[$tier]:-0}"
}

# --- Status Display -----------------------------------------------------------

# Display license status information to the user.
show_containerlint_status() {
  local key
  if ! key=$(get_containerlint_key) || [[ -z "$key" ]]; then
    echo -e "License: ${YELLOW}Free tier${NC}"
    echo -e "  Patterns: 30 (DF, SC categories)"
    echo -e "  Upgrade at: ${CYAN}https://containerlint.pages.dev/pricing${NC}"
    return
  fi

  local payload tier expiry seats product
  payload=$(decode_jwt_payload "$key" 2>/dev/null) || {
    echo -e "License: ${RED}Invalid key${NC}"
    return
  }
  product=$(extract_field "$payload" "product")
  tier=$(extract_field "$payload" "tier")
  expiry=$(extract_field "$payload" "exp")
  seats=$(extract_field "$payload" "seats")

  # Validate product
  if [[ -n "$product" && "$product" != "containerlint" ]]; then
    echo -e "License: ${RED}Wrong product ($product)${NC}"
    echo -e "  This key is for $product, not ContainerLint."
    echo -e "  Get a ContainerLint license at: ${CYAN}https://containerlint.pages.dev/pricing${NC}"
    return
  fi

  # Determine status
  local now status_color status_text
  now=$(date +%s)
  status_color="$GREEN"; status_text="Active"
  if [[ -n "$expiry" && "$now" -gt "$expiry" ]]; then
    status_color="$RED"; status_text="Expired"
  fi

  # Format expiry date
  local expiry_date="Never"
  if [[ -n "$expiry" ]]; then
    expiry_date=$(date -d "@$expiry" +%Y-%m-%d 2>/dev/null || \
                  date -r "$expiry" +%Y-%m-%d 2>/dev/null || \
                  echo "unknown")
  fi

  echo -e "License: ${status_color}${BOLD}${tier^^}${NC} (${status_color}${status_text}${NC})"
  echo -e "  Product: containerlint"
  echo -e "  Seats: ${seats:-unlimited}"
  echo -e "  Expires: $expiry_date"
  echo ""

  case "$tier" in
    pro)
      echo -e "  Patterns: 60 (DF, SC, HC, RS categories)"
      echo -e "  Formats: text, json, html"
      echo -e "  Upgrade to Team for: NW, OR categories + full 90 patterns"
      ;;
    team)
      echo -e "  Patterns: 90 (all 6 categories)"
      echo -e "  Formats: text, json, html"
      echo -e "  All features available"
      ;;
    enterprise)
      echo -e "  Patterns: 90 (all 6 categories)"
      echo -e "  Formats: text, json, html"
      echo -e "  All features + priority support"
      ;;
  esac
}


path: scripts/patterns.sh
content: #!/usr/bin/env bash
# ContainerLint -- Docker & Container Security Anti-Pattern Definitions
# 90 patterns across 6 categories, 15 patterns each.
#
# Format per line:
#   REGEX|SEVERITY|CHECK_ID|DESCRIPTION|RECOMMENDATION
#
# Severity levels:
#   critical -- Security vulnerability or data exposure from container misconfiguration
#   high     -- Significant security issue that will weaken container posture
#   medium   -- Moderate concern that should be addressed
#   low      -- Best practice suggestion
#
# IMPORTANT: All regexes use POSIX ERE syntax (grep -E compatible).
# - Use [[:space:]] instead of \s
# - Use [[:alnum:]] instead of \w
# - NEVER use pipe (|) for alternation inside regex -- it conflicts with
#   the field delimiter. Use separate patterns or character classes instead.

set -euo pipefail

# ===========================================================================
# DF -- Dockerfile Best Practices (15 patterns: DF-001 to DF-015)
# Free tier
# ===========================================================================

declare -a CONTAINERLINT_DF_PATTERNS=(
  'FROM[[:space:]]+[[:alnum:]_./-]+:latest|high|DF-001|Using :latest tag in FROM makes builds non-reproducible|Pin a specific image version tag (e.g., node:20.11-alpine)'
  'FROM[[:space:]]+[[:alnum:]_./-]+[[:space:]]*$|medium|DF-002|FROM without explicit tag defaults to :latest|Always specify an explicit image version tag in FROM directives'
  'ADD[[:space:]]+https?://|high|DF-003|ADD with URL downloads are uncached and unverified|Use RUN curl or RUN wget with checksum verification instead of ADD'
  'ADD[[:space:]]+[^-]|medium|DF-004|ADD has implicit tar extraction and URL fetch behavior|Use COPY instead of ADD unless you need tar auto-extraction'
  'RUN[[:space:]]+apt-get[[:space:]]+install[[:space:]]+-y[[:space:]]+[[:alnum:]]|medium|DF-005|apt-get install without version pins creates non-reproducible builds|Pin package versions: apt-get install -y package=version'
  'RUN[[:space:]]+apt-get[[:space:]]+update[[:space:]]*$|high|DF-006|apt-get update in separate RUN layer creates stale cache|Combine apt-get update and install in a single RUN layer'
  'RUN[[:space:]]+chmod[[:space:]]+777|high|DF-007|chmod 777 grants world-writable permissions inside container|Use least-privilege permissions (e.g., chmod 755 or chmod 644)'
  'RUN[[:space:]]+curl[[:space:]].*[[:space:]]-k|high|DF-008|curl with -k disables SSL certificate verification|Remove -k flag and use proper TLS certificate verification'
  'COPY[[:space:]]+\.[[:space:]]+\.|medium|DF-009|COPY . . copies entire build context including unneeded files|Use .dockerignore and copy only required files or directories'
  'ENV[[:space:]]+NODE_ENV[[:space:]]*=[[:space:]]*development|medium|DF-010|NODE_ENV set to development in Dockerfile|Set NODE_ENV=production or omit it from Dockerfile for production builds'
  'RUN[[:space:]]+npm[[:space:]]+install[[:space:]]*$|medium|DF-011|npm install without --production includes devDependencies|Use npm ci --only=production for reproducible production builds'
  'WORKDIR[[:space:]]+[^/]|low|DF-012|WORKDIR with relative path depends on previous WORKDIR state|Use absolute paths in WORKDIR directives for clarity'
  'FROM[[:space:]]+ubuntu[[:space:]]*$|medium|DF-013|Using Ubuntu base without version tag pulls latest by default|Pin to a specific version: FROM ubuntu:22.04'
  'LABEL[[:space:]]+maintainer|low|DF-014|LABEL maintainer is deprecated in favor of OCI standard labels|Use LABEL org.opencontainers.image.authors instead'
  'WORKDIR[[:space:]]+/[[:space:]]*$|medium|DF-015|WORKDIR / sets working directory to filesystem root|Use a dedicated application directory: WORKDIR /app'
)

# ===========================================================================
# SC -- Security Context (15 patterns: SC-001 to SC-015)
# Free tier
# ===========================================================================

declare -a CONTAINERLINT_SC_PATTERNS=(
  'privileged[[:space:]]*:[[:space:]]*true|critical|SC-001|Container running in privileged mode has full host access|Remove privileged: true and use specific capabilities instead'
  'USER[[:space:]]+root|high|SC-002|Container explicitly runs as root user|Use a non-root USER directive (e.g., USER 1001 or USER appuser)'
  'ENV[[:space:]]+[[:alnum:]_]*PASSWORD[[:space:]]*=|critical|SC-003|Password hardcoded in ENV directive is visible in image layers|Use Docker secrets or runtime environment variables for passwords'
  'ENV[[:space:]]+[[:alnum:]_]*SECRET[[:space:]]*=|critical|SC-004|Secret hardcoded in ENV directive is visible in image layers|Use Docker secrets or a secrets manager for sensitive values'
  'ENV[[:space:]]+[[:alnum:]_]*API_KEY[[:space:]]*=|critical|SC-005|API key hardcoded in ENV directive is visible in image layers|Use Docker secrets or runtime environment variables for API keys'
  'ENV[[:space:]]+[[:alnum:]_]*TOKEN[[:space:]]*=|high|SC-006|Token hardcoded in ENV directive is visible in image layers|Use Docker secrets or runtime environment variables for tokens'
  'ARG[[:space:]]+[[:alnum:]_]*PASSWORD|high|SC-007|Password in ARG is visible in build history|Use --secret flag in docker build or multi-stage builds for secrets'
  'allowPrivilegeEscalation[[:space:]]*:[[:space:]]*true|critical|SC-008|allowPrivilegeEscalation lets processes gain more privileges|Set allowPrivilegeEscalation: false in security context'
  'readOnlyRootFilesystem[[:space:]]*:[[:space:]]*false|high|SC-009|Writable root filesystem allows attackers to modify binaries|Set readOnlyRootFilesystem: true and use emptyDir for write paths'
  'runAsNonRoot[[:space:]]*:[[:space:]]*false|high|SC-010|runAsNonRoot: false allows the container to run as UID 0|Set runAsNonRoot: true and specify a non-root runAsUser'
  'RUN[[:space:]]+echo[[:space:]]+["\x27].*[pP]ass|high|SC-011|Password echoed in RUN layer is stored in image history|Use build secrets or mount secrets at runtime instead'
  'hostPID[[:space:]]*:[[:space:]]*true|critical|SC-012|hostPID exposes all host processes to the container|Remove hostPID: true -- containers should use their own PID namespace'
  'hostIPC[[:space:]]*:[[:space:]]*true|high|SC-013|hostIPC allows container to access host shared memory segments|Remove hostIPC: true unless absolutely required for IPC'
  'COPY[[:space:]]+.*\.pem|high|SC-014|Copying private key files into image exposes credentials|Mount certificates at runtime; never bake private keys into images'
  'COPY[[:space:]]+.*id_rsa|critical|SC-015|Copying SSH private key into image exposes credentials permanently|Use SSH agent forwarding or BuildKit secrets for build-time SSH access'
)

# ===========================================================================
# HC -- Health & Readiness (15 patterns: HC-001 to HC-015)
# Pro tier
# ===========================================================================

declare -a CONTAINERLINT_HC_PATTERNS=(
  'HEALTHCHECK[[:space:]]+NONE|high|HC-001|HEALTHCHECK NONE explicitly disables health monitoring|Define a proper HEALTHCHECK CMD for container health monitoring'
  'livenessProbe[[:space:]]*:[[:space:]]*\{\}|high|HC-002|Empty livenessProbe object provides no health checking|Define exec, httpGet, or tcpSocket action for livenessProbe'
  'readinessProbe[[:space:]]*:[[:space:]]*\{\}|high|HC-003|Empty readinessProbe object provides no readiness checking|Define exec, httpGet, or tcpSocket action for readinessProbe'
  'startupProbe[[:space:]]*:[[:space:]]*\{\}|medium|HC-004|Empty startupProbe object provides no startup checking|Define startupProbe with sufficient failureThreshold for slow-starting apps'
  'initialDelaySeconds[[:space:]]*:[[:space:]]*0[[:space:]]*$|medium|HC-005|Zero initial delay may fail on slow-starting containers|Set initialDelaySeconds to allow time for application startup'
  'periodSeconds[[:space:]]*:[[:space:]]*1[[:space:]]*$|medium|HC-006|Probe period of 1 second creates excessive health check load|Use periodSeconds >= 10 to reduce probe overhead'
  'failureThreshold[[:space:]]*:[[:space:]]*1[[:space:]]*$|medium|HC-007|failureThreshold of 1 restarts on first failure without retry|Set failureThreshold >= 3 to allow transient failures'
  'timeoutSeconds[[:space:]]*:[[:space:]]*[0-9][0-9][0-9]|medium|HC-008|Very high probe timeout (100s+) delays failure detection|Reduce timeoutSeconds to 5-10 seconds for faster failure detection'
  'STOPSIGNAL[[:space:]]+SIGKILL|high|HC-009|STOPSIGNAL SIGKILL prevents graceful application shutdown|Use STOPSIGNAL SIGTERM to allow graceful connection draining'
  'terminationGracePeriodSeconds[[:space:]]*:[[:space:]]*0|high|HC-010|Zero termination grace period prevents graceful pod shutdown|Set terminationGracePeriodSeconds to at least 30 for production workloads'
  'CMD[[:space:]]+\["sleep"|medium|HC-011|Using sleep as CMD indicates container is not running a proper service|Replace sleep with actual application process or use init system'
  'ENTRYPOINT[[:space:]]+[^[]|medium|HC-012|Shell form ENTRYPOINT does not receive Unix signals properly|Use exec form ENTRYPOINT ["executable", "param1"] for proper signal handling'
  'stop_grace_period[[:space:]]*:[[:space:]]*0|high|HC-013|Zero grace period kills container immediately without cleanup|Set stop_grace_period to at least 10s for graceful shutdown'
  'HEALTHCHECK.*--retries[[:space:]]*=[[:space:]]*1[[:space:]]|medium|HC-014|HEALTHCHECK with 1 retry restarts on transient failures|Use --retries=3 or higher to tolerate transient failures'
  'restart_policy.*max_attempts[[:space:]]*:[[:space:]]*0|medium|HC-015|Max restart attempts set to 0 means unlimited restarts of failing container|Set a reasonable max_attempts limit to prevent crash loops'
)

# ===========================================================================
# RS -- Resource Management (15 patterns: RS-001 to RS-015)
# Pro tier
# ===========================================================================

declare -a CONTAINERLINT_RS_PATTERNS=(
  'resources[[:space:]]*:[[:space:]]*\{\}|high|RS-001|Empty resources block sets no CPU or memory limits|Define both requests and limits for CPU and memory'
  'limits[[:space:]]*:[[:space:]]*\{\}|high|RS-002|Empty limits block allows unbounded resource usage|Set memory and cpu limits to prevent resource exhaustion'
  'mem_limit[[:space:]]*:[[:space:]]*0|critical|RS-003|Memory limit of 0 means unlimited -- container can exhaust host memory|Set a reasonable memory limit (e.g., mem_limit: 512m)'
  'memswap_limit[[:space:]]*:[[:space:]]*-1|high|RS-004|Unlimited swap allows container to use all host swap|Set memswap_limit equal to mem_limit to disable swap'
  'pids_limit[[:space:]]*:[[:space:]]*-1|medium|RS-005|Unlimited PIDs can enable fork bomb attacks|Set pids_limit to a reasonable value (e.g., 200)'
  '--oom-kill-disable|high|RS-006|Disabling OOM killer can hang the host when memory is exhausted|Remove --oom-kill-disable and set appropriate memory limits instead'
  'shm_size[[:space:]]*:[[:space:]]*["\x27]?[0-9]+g|medium|RS-007|Large shared memory allocation may waste host resources|Set shm_size to the minimum required (default 64m is often sufficient)'
  'requests[[:space:]]*:[[:space:]]*\{\}|medium|RS-008|Empty requests block prevents proper scheduling|Define CPU and memory requests for proper Kubernetes scheduling'
  'storage[[:space:]]*:[[:space:]]*["\x27]?[0-9]+Ti|high|RS-009|Terabyte-level storage claim is likely misconfigured|Verify storage size is correct; use Gi instead of Ti if appropriate'
  '--memory[[:space:]]*=[[:space:]]*0|high|RS-010|Docker run memory limit of 0 means unlimited|Set a reasonable memory limit: --memory=512m'
  '--cpus[[:space:]]*=[[:space:]]*0|medium|RS-011|Docker run CPU limit of 0 means unlimited|Set CPU limit: --cpus=1.0 or appropriate for workload'
  'LimitNOFILE[[:space:]]*=[[:space:]]*infinity|medium|RS-012|Unlimited file descriptors can exhaust kernel resources|Set LimitNOFILE to a bounded value matching application needs'
  '--tmpfs.*size=0|medium|RS-013|tmpfs without size limit uses up to half of host memory|Always set a size limit for tmpfs mounts: --tmpfs /tmp:size=100m'
  'ephemeral-storage[[:space:]]*:[[:space:]]*["\x27]?[0-9]+Gi|low|RS-014|Large ephemeral storage limit may fill node disk|Right-size ephemeral-storage based on actual temporary storage needs'
  'ulimits[[:space:]]*:.*nofile[[:space:]]*:.*soft[[:space:]]*:[[:space:]]*[0-9]{6}|medium|RS-015|Very high file descriptor ulimit may waste kernel resources|Set nofile ulimits to match application requirements (e.g., 65535)'
)

# ===========================================================================
# NW -- Networking & Exposure (15 patterns: NW-001 to NW-015)
# Team tier
# ===========================================================================

declare -a CONTAINERLINT_NW_PATTERNS=(
  'EXPOSE[[:space:]]+0-65535|critical|NW-001|Exposing entire port range makes all services accessible|Expose only the specific ports your application requires'
  'hostNetwork[[:space:]]*:[[:space:]]*true|critical|NW-002|Host networking bypasses container network isolation|Use bridge or overlay networking; remove hostNetwork: true'
  'network_mode[[:space:]]*:[[:space:]]*["\x27]?host|critical|NW-003|Host network mode bypasses container network isolation|Use bridge or overlay network mode instead of host'
  '0\.0\.0\.0:[0-9]+:[0-9]+|high|NW-004|Publishing port on 0.0.0.0 binds to all network interfaces|Bind to 127.0.0.1 or a specific interface for non-public services'
  'EXPOSE[[:space:]]+22[[:space:]]|high|NW-005|Exposing SSH port 22 in container is an anti-pattern|Use docker exec or kubectl exec instead of SSH into containers'
  'EXPOSE[[:space:]]+23[[:space:]]|critical|NW-006|Exposing Telnet port 23 is a critical security risk|Never expose Telnet; use SSH or docker exec for remote access'
  'insecure-registries|critical|NW-007|Insecure registries allow unencrypted image pulls|Use TLS-secured registries for all image pulls'
  'hostPort[[:space:]]*:[[:space:]]*[0-9]|high|NW-008|hostPort binds container directly to host port|Use NodePort or LoadBalancer service type instead of hostPort'
  'EXPOSE[[:space:]]+3306|medium|NW-009|Exposing MySQL port 3306 may allow unauthorized database access|Do not expose database ports; use internal networking only'
  'EXPOSE[[:space:]]+5432|medium|NW-010|Exposing PostgreSQL port 5432 may allow unauthorized database access|Do not expose database ports; use internal networking only'
  'EXPOSE[[:space:]]+6379|medium|NW-011|Exposing Redis port 6379 -- Redis has no auth by default|Do not expose Redis ports; use internal networking and enable AUTH'
  'EXPOSE[[:space:]]+27017|medium|NW-012|Exposing MongoDB port 27017 may allow unauthorized access|Do not expose database ports; use internal networking only'
  'EXPOSE[[:space:]]+2375|critical|NW-013|Exposing Docker daemon port 2375 allows unauthenticated container control|Never expose Docker socket; use TLS on port 2376 if remote access needed'
  'dnsPolicy[[:space:]]*:[[:space:]]*["\x27]?None|medium|NW-014|DNS policy None disables all DNS resolution in the pod|Use Default or ClusterFirst DNS policy unless custom DNS is required'
  'loadBalancerSourceRanges[[:space:]]*:[[:space:]]*\[\]|high|NW-015|Empty loadBalancerSourceRanges allows traffic from any IP|Restrict loadBalancerSourceRanges to trusted CIDR blocks'
)

# ===========================================================================
# OR -- Orchestration & Compose (15 patterns: OR-001 to OR-015)
# Team tier
# ===========================================================================

declare -a CONTAINERLINT_OR_PATTERNS=(
  'restart[[:space:]]*:[[:space:]]*["\x27]?no["\x27]?[[:space:]]*$|medium|OR-001|No restart policy means container stays down after failure|Use restart: unless-stopped or restart: always for production services'
  'replicas[[:space:]]*:[[:space:]]*1[[:space:]]*$|medium|OR-002|Single replica has no redundancy for high availability|Use replicas >= 2 for production workloads requiring availability'
  'image[[:space:]]*:[[:space:]]*[[:alnum:]_./-]+:latest|high|OR-003|Using :latest tag in orchestration config makes deployments non-reproducible|Pin specific image version tags in all orchestration files'
  'image[[:space:]]*:[[:space:]]*[[:alnum:]_./-]+[[:space:]]*$|medium|OR-004|Image reference without tag defaults to :latest|Always specify an explicit image version tag'
  '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+:[0-9]+|medium|OR-005|Hardcoded IP address in port mapping reduces portability|Use service names or DNS instead of hardcoded IP addresses'
  'version[[:space:]]*:[[:space:]]*["\x27][12]\.|low|OR-006|Docker Compose v1/v2 schema is outdated|Migrate to Docker Compose v3+ or omit version field for latest'
  'volumes_from[[:space:]]*:|medium|OR-007|volumes_from shares all volumes between containers|Use named volumes with explicit mount points instead of volumes_from'
  'links[[:space:]]*:|low|OR-008|Docker links are legacy and deprecated|Use Docker networks for inter-container communication instead of links'
  'container_name[[:space:]]*:|low|OR-009|Fixed container_name prevents scaling with docker-compose up --scale|Remove container_name to allow container scaling'
  'environment[[:space:]]*:.*PASSWORD[[:space:]]*=|high|OR-010|Password in compose environment is stored in plaintext|Use Docker secrets or env_file with proper file permissions for passwords'
  'environment[[:space:]]*:.*SECRET[[:space:]]*=|high|OR-011|Secret in compose environment is stored in plaintext|Use Docker secrets or env_file with proper file permissions for secrets'
  'stdin_open[[:space:]]*:[[:space:]]*true|low|OR-012|stdin_open: true keeps STDIN open; usually only needed for debugging|Remove stdin_open: true for production deployments'
  'tty[[:space:]]*:[[:space:]]*true|low|OR-013|tty: true allocates a terminal; usually only needed for debugging|Remove tty: true for production deployments'
  'driver[[:space:]]*:[[:space:]]*["\x27]?none|medium|OR-014|Logging driver none discards all container logs|Use json-file, syslog, or a centralized logging driver'
  'COPY[[:space:]]+.*\.env|high|OR-015|Copying .env file into image exposes secrets in image layers|Never copy .env files; inject environment variables at runtime'
)

# ===========================================================================
# Utility Functions
# ===========================================================================

# Count total patterns
containerlint_pattern_count() {
  local count=0
  count=$((count + ${#CONTAINERLINT_DF_PATTERNS[@]}))
  count=$((count + ${#CONTAINERLINT_SC_PATTERNS[@]}))
  count=$((count + ${#CONTAINERLINT_HC_PATTERNS[@]}))
  count=$((count + ${#CONTAINERLINT_RS_PATTERNS[@]}))
  count=$((count + ${#CONTAINERLINT_NW_PATTERNS[@]}))
  count=$((count + ${#CONTAINERLINT_OR_PATTERNS[@]}))
  echo "$count"
}

# Count patterns in a specific category
containerlint_category_count() {
  local cat="$1"
  case "$cat" in
    DF) echo "${#CONTAINERLINT_DF_PATTERNS[@]}" ;;
    SC) echo "${#CONTAINERLINT_SC_PATTERNS[@]}" ;;
    HC) echo "${#CONTAINERLINT_HC_PATTERNS[@]}" ;;
    RS) echo "${#CONTAINERLINT_RS_PATTERNS[@]}" ;;
    NW) echo "${#CONTAINERLINT_NW_PATTERNS[@]}" ;;
    OR) echo "${#CONTAINERLINT_OR_PATTERNS[@]}" ;;
    *)  echo "0" ;;
  esac
}

# Get the bash array name for a category code
get_containerlint_patterns_for_category() {
  local cat="$1"
  case "$cat" in
    DF) echo "CONTAINERLINT_DF_PATTERNS" ;;
    SC) echo "CONTAINERLINT_SC_PATTERNS" ;;
    HC) echo "CONTAINERLINT_HC_PATTERNS" ;;
    RS) echo "CONTAINERLINT_RS_PATTERNS" ;;
    NW) echo "CONTAINERLINT_NW_PATTERNS" ;;
    OR) echo "CONTAINERLINT_OR_PATTERNS" ;;
    *)  echo "" ;;
  esac
}

# Get human-readable label for a category code
get_containerlint_category_label() {
  local cat="$1"
  case "$cat" in
    DF) echo "Dockerfile Best Practices" ;;
    SC) echo "Security Context" ;;
    HC) echo "Health & Readiness" ;;
    RS) echo "Resource Management" ;;
    NW) echo "Networking & Exposure" ;;
    OR) echo "Orchestration & Compose" ;;
    *)  echo "Unknown" ;;
  esac
}

# Get space-separated list of category codes available at a given tier level.
# Tier levels: 0=free (DF, SC), 1=pro (DF, SC, HC, RS), 2/3=team/enterprise (all)
get_containerlint_categories_for_tier() {
  local tier_level="$1"
  case "$tier_level" in
    0) echo "DF SC" ;;
    1) echo "DF SC HC RS" ;;
    2|3) echo "DF SC HC RS NW OR" ;;
    *) echo "DF SC" ;;
  esac
}

# Severity to point deduction mapping
severity_to_points() {
  local severity="$1"
  case "$severity" in
    critical) echo 25 ;;
    high)     echo 15 ;;
    medium)   echo  8 ;;
    low)      echo  3 ;;
    *)        echo  0 ;;
  esac
}

# Validate that a string is a known category code
is_valid_containerlint_category() {
  local cat="$1"
  case "$cat" in
    DF|SC|HC|RS|NW|OR) return 0 ;;
    *) return 1 ;;
  esac
}

# List patterns in a given category or "all"
containerlint_list_patterns() {
  local filter="${1:-all}"

  local categories="DF SC HC RS NW OR"
  if [[ "$filter" != "all" ]]; then
    filter=$(echo "$filter" | tr '[:lower:]' '[:upper:]')
    categories="$filter"
  fi

  for cat in $categories; do
    local label
    label=$(get_containerlint_category_label "$cat")
    echo -e "${BOLD:-}--- ${cat}: ${label} ---${NC:-}"

    local arr_name
    arr_name=$(get_containerlint_patterns_for_category "$cat")
    [[ -z "$arr_name" ]] && continue

    local -n _arr="$arr_name"
    for entry in "${_arr[@]}"; do
      IFS='|' read -r regex severity check_id description recommendation <<< "$entry"
      local sev_upper
      sev_upper=$(echo "$severity" | tr '[:lower:]' '[:upper:]')
      echo "  [$sev_upper] $check_id: $description"
    done
    echo ""
  done
}


path: scripts/scan.sh
content: #!/usr/bin/env bash
# scan.sh — Minimal wrapper that sources patterns and runs the scan
# Generated by generate-skills.sh — do not edit manually
#
# Usage: bash scan.sh [file|directory]
#   Defaults to scanning current directory if no argument given.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Source patterns for containerlint
source "$SCRIPT_DIR/patterns.sh"

# Delegate to main entry point
exec bash "$SCRIPT_DIR/dispatcher.sh" scan "${@:-.}"


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)