From 7c25f78ca674c882551a07803cb50d729c92e34a Mon Sep 17 00:00:00 2001 From: Justin Bowen Date: Mon, 10 Aug 2026 17:23:34 -0500 Subject: [PATCH] chore: install pre-commit/pre-push git hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repo had no .pre-commit-config.yaml, no install-hooks target, and no hooks at all — every commit was completely ungated. Wires up the standard PenguinTech pre-commit framework (gitleaks, shellcheck, ruff, hadolint, actionlint at commit time; bandit + Dockerfile rootless check at push time), adds make install-hooks/verify-hooks (setup now depends on install-hooks), and adds the canonical [tool.ruff] block. Also replaces a stale husky snippet in docs/hub_module/TESTING.md (husky is unused anywhere in this org) with the make install-hooks flow. Fixes a worktree-path bug in the verify() helper: it resolved hooks via "$(git rev-parse --show-toplevel)/.git/hooks/", which is wrong inside a linked worktree (.git there is a file, not a directory) and made every hook falsely report NOT INSTALLED. Now resolves via `git rev-parse --git-common-dir`. Node/ESLint intentionally left out of this pass — see PR description. --- .pre-commit-config.yaml | 88 ++++++++++ Makefile | 15 +- docs/hub_module/TESTING.md | 17 +- pyproject.toml | 17 ++ scripts/hooks/check-dockerfile-rootless.sh | 67 ++++++++ scripts/install-pre-commit.sh | 125 +++++++++++++++ scripts/lib/detect-os.sh | 178 +++++++++++++++++++++ 7 files changed, 496 insertions(+), 11 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 pyproject.toml create mode 100755 scripts/hooks/check-dockerfile-rootless.sh create mode 100755 scripts/install-pre-commit.sh create mode 100755 scripts/lib/detect-os.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..a5f0b3c93 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,88 @@ +# PenguinTech standard git hooks. +# +# Install: make install-hooks +# Run all: pre-commit run --all-files +# +# Two stages, matching devops.md "Git Hooks (Mandatory)": +# pre-commit — fast checks only (<30s), blocks the commit +# pre-push — heavier security scans, blocks the push +# +# Revs are pinned. Update deliberately via `pre-commit autoupdate` or Renovate, +# never by floating to a branch. +default_install_hook_types: [pre-commit, pre-push] +default_stages: [pre-commit] + +repos: + # ── Hygiene ──────────────────────────────────────────────────────────────── + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-case-conflict + - id: check-added-large-files + args: [--maxkb=1024] + - id: check-yaml + args: [--allow-multiple-documents] + - id: check-json + - id: check-executables-have-shebangs + - id: detect-private-key + + # ── Secrets (mandatory, every repo) ──────────────────────────────────────── + - repo: https://github.com/gitleaks/gitleaks + rev: v8.21.2 + hooks: + - id: gitleaks + + # ── Shell (Bash must stay 3.2-compatible — see general.md) ───────────────── + - repo: https://github.com/koalaman/shellcheck-precommit + rev: v0.10.0 + hooks: + - id: shellcheck + args: [--severity=warning] + + # ── Python — ruff ONLY, never alongside flake8/black/isort ───────────────── + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.4 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + # ── Node/TS — NOT wired up yet, see PR description ────────────────────────── + # Of the 8 package.json dirs in the repo, only admin/hub_module/frontend has + # both an eslint.config.js and a "lint" script. Its committed node_modules/ + # install is broken (eslint 8.57.1 present per package.json but missing + # lib/cli.js — `npx eslint .` fails with MODULE_NOT_FOUND before it can lint + # anything), so an ESLint hook here would fail on every touch regardless of + # code quality. Fix the install (and stop committing node_modules/ — see PR + # description) before adding this section back. + + # ── Dockerfiles ──────────────────────────────────────────────────────────── + - repo: https://github.com/hadolint/hadolint + rev: v2.13.1-beta + hooks: + - id: hadolint-docker + + # ── GitHub Actions ───────────────────────────────────────────────────────── + - repo: https://github.com/rhysd/actionlint + rev: v1.7.4 + hooks: + - id: actionlint + + # ── Security (pre-push — heavier, keeps commits fast) ────────────────────── + - repo: https://github.com/PyCQA/bandit + rev: 1.8.0 + hooks: + - id: bandit + stages: [pre-push] + + - repo: local + hooks: + - id: dockerfile-rootless + name: Dockerfile runs as non-root + entry: scripts/hooks/check-dockerfile-rootless.sh + language: script + files: (^|/)Dockerfile[^/]*$ + stages: [pre-push] diff --git a/Makefile b/Makefile index 7f15386de..3cc72ff23 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,15 @@ -.PHONY: dev test test-unit test-integration test-e2e test-functional test-security \ - smoke-test lint build docker-build docker-push deploy-dev deploy-prod \ - seed-mock-data clean pre-commit +.PHONY: dev setup install-hooks verify-hooks test test-unit test-integration test-e2e \ + test-functional test-security smoke-test lint build docker-build docker-push \ + deploy-dev deploy-prod seed-mock-data clean pre-commit + +setup: install-hooks + @echo "Setup complete" + +install-hooks: ## Install pre-commit framework + register pre-commit and pre-push hooks + @./scripts/install-pre-commit.sh + +verify-hooks: ## Report whether pre-commit/pre-push hooks are installed and non-empty + @./scripts/install-pre-commit.sh --verify dev: docker-compose up diff --git a/docs/hub_module/TESTING.md b/docs/hub_module/TESTING.md index f7943ae27..dd5e78126 100644 --- a/docs/hub_module/TESTING.md +++ b/docs/hub_module/TESTING.md @@ -719,17 +719,18 @@ jobs: ### Pre-commit Hooks -```bash -# Install husky -npm install --save-dev husky - -# Add pre-commit hook -npx husky add .husky/pre-commit "npm test" +Hooks are managed repo-wide by the `pre-commit` framework (`.pre-commit-config.yaml` at +the repo root) — not husky, and not a hand-written `.husky/` script. Install once: -# Add pre-push hook -npx husky add .husky/pre-push "./test-api.sh" +```bash +make install-hooks # installs the pre-commit framework + registers pre-commit/pre-push hooks +make verify-hooks # confirms both hooks are installed and non-empty ``` +`pre-commit` then runs automatically on `git commit` (lint + secrets, <30s) and +`git push` (heavier security scans). See `.pre-commit-config.yaml` for the full hook +list, including the ESLint hook scoped to `admin/hub_module/frontend`. + --- ## Debugging Tests diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..4749387d1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +# Repo-wide tool configuration only — this is not a distributable Python +# package (the repo is a multi-service monorepo with per-module +# requirements.txt files, see core/*/requirements.txt), so there is no +# [build-system]/[project] table here. +# +# Canonical [tool.ruff] block — see backend-python.md. Keep identical to +# every other PenguinTech repo's pyproject.toml; the git hook (ruff-pre-commit +# in .pre-commit-config.yaml) and this config must agree. +[tool.ruff] +target-version = "py313" # matches python:3.13 base image used by nearly all services +line-length = 100 # matches Prettier printWidth + dart format + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "N", "D", "UP", "B", "ASYNC", "S"] + +[tool.ruff.lint.pydocstyle] +convention = "google" diff --git a/scripts/hooks/check-dockerfile-rootless.sh b/scripts/hooks/check-dockerfile-rootless.sh new file mode 100755 index 000000000..6fa91d034 --- /dev/null +++ b/scripts/hooks/check-dockerfile-rootless.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# check-dockerfile-rootless.sh — fail any Dockerfile that ends up running as root. +# +# devops-containers.md requires a non-root process in every container. An +# explicit, approved exception is allowed but must be annotated so the decision +# is visible in review rather than implied by silence. +# +# Usage: check-dockerfile-rootless.sh ... (invoked by pre-commit) +set -uo pipefail + +status=0 + +for file in "$@"; do + [[ -f "$file" ]] || continue + + # An approved exception suppresses the check for the whole file. + # + # The pattern is deliberately strict. A loose `#.*ROOT EXCEPTION` match + # would also fire on comment-shaped lines that are not Dockerfile comments + # at all — most importantly heredoc bodies, where the token can be smuggled + # into file content and silently disable the check: + # + # RUN cat <<'EOF' > /etc/motd + # # ROOT EXCEPTION (approved) + # EOF + # + # Requiring the trailing colon plus a non-empty reason means an exception + # has to be written deliberately, and heredoc payloads do not match by + # accident. A bypass is also never silent — see the notice below. + exception="$(grep -nE '^[[:space:]]*#[[:space:]]*ROOT EXCEPTION \(approved\):[[:space:]]*[^[:space:]]' "$file" | head -1)" + if [[ -n "$exception" ]]; then + echo "$file: rootless check BYPASSED by approved exception" + echo " ${exception}" + continue + fi + + # A malformed annotation must not fail open — it reads as an exception to a + # human but matches nothing above, so call it out explicitly. + if grep -qE '^[[:space:]]*#.*ROOT EXCEPTION' "$file"; then + echo "$file: malformed ROOT EXCEPTION annotation — not honoured" + echo " Required form: # ROOT EXCEPTION (approved): " + status=1 + continue + fi + + # The effective user is whatever the last USER instruction sets. Strip any + # group suffix ("appuser:appgroup") before deciding. + last_user="$(grep -iE '^[[:space:]]*USER[[:space:]]+' "$file" | tail -1 | awk '{print $2}')" + last_user="${last_user%%:*}" + + if [[ -z "$last_user" ]]; then + echo "$file: no USER instruction — container would run as root" + echo " Add a non-root USER, or annotate: # ROOT EXCEPTION (approved): " + status=1 + elif [[ "$last_user" == \$* || "$last_user" == *'${'* ]]; then + # Resolved at build time from an ARG/ENV — cannot be verified statically. + echo "$file: final USER is build-arg '$last_user' — cannot verify it is non-root" + echo " Use a literal non-root USER, or annotate: # ROOT EXCEPTION (approved): " + status=1 + elif [[ "$last_user" == "root" || "$last_user" == "0" || "$last_user" == 0:* ]]; then + echo "$file: final USER is '$last_user' — container runs as root" + echo " Switch to a non-root user, or annotate: # ROOT EXCEPTION (approved): " + status=1 + fi +done + +exit "$status" diff --git a/scripts/install-pre-commit.sh b/scripts/install-pre-commit.sh new file mode 100755 index 000000000..6887aea4b --- /dev/null +++ b/scripts/install-pre-commit.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# ============================================================================= +# install-pre-commit.sh — Install the pre-commit framework and wire up hooks +# +# Installs pre-commit system-wide (macOS, Ubuntu/Debian, WSL, Fedora/RHEL) and +# registers both hook types in the current repo: +# pre-commit — fast lint + secrets checks +# pre-push — heavier security scans +# +# Usage: +# ./install-pre-commit.sh # Install framework + hooks +# ./install-pre-commit.sh --hooks-only # Skip the framework install +# ./install-pre-commit.sh --verify # Report state, change nothing +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=lib/detect-os.sh +source "$SCRIPT_DIR/lib/detect-os.sh" +detect_os + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; CYAN='\033[0;36m'; NC='\033[0m' +info() { echo -e "${CYAN}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +err() { echo -e "${RED}[ERR]${NC} $*" >&2; exit 1; } + +install_framework() { + if command -v pre-commit >/dev/null 2>&1; then + ok "pre-commit already installed ($(pre-commit --version))" + return + fi + + info "Installing pre-commit via $PKG_MANAGER..." + case "$PKG_MANAGER" in + brew) + brew install pre-commit + ;; + apt) + # Same path for native Ubuntu/Debian and WSL — no WSL special-casing needed. + sudo apt-get update -q + sudo apt-get install -y -q pre-commit + ;; + dnf|yum) + sudo "$PKG_MANAGER" install -y pre-commit + ;; + *) + warn "Unknown package manager '$PKG_MANAGER' — falling back to uv" + ;; + esac + + # Distro packages lag; fall back to a userspace install rather than failing. + if ! command -v pre-commit >/dev/null 2>&1; then + if command -v uv >/dev/null 2>&1; then + info "Package manager did not provide pre-commit — installing via uv" + uv tool install pre-commit + else + python3 -m pip install --user pre-commit + fi + fi + + command -v pre-commit >/dev/null 2>&1 || err "pre-commit install failed" + ok "pre-commit installed ($(pre-commit --version))" +} + +install_hooks() { + local root + root="$(git rev-parse --show-toplevel 2>/dev/null)" \ + || err "Not inside a git repository" + cd "$root" + + [[ -f .pre-commit-config.yaml ]] \ + || err "No .pre-commit-config.yaml at $root — create one before installing hooks" + + pre-commit install + pre-commit install --hook-type pre-push + ok "Hooks registered in $root (pre-commit + pre-push)" + + info "Validating configuration..." + pre-commit validate-config + ok "Configuration valid" +} + +verify() { + local root git_dir hook target + root="$(git rev-parse --show-toplevel 2>/dev/null)" || err "Not inside a git repository" + + # Hooks live under the *common* git dir, not "$root/.git" — in a linked + # worktree, "$root/.git" is a file (pointing at + # /.git/worktrees/), not a directory, so a naive + # "$root/.git/hooks/$hook" path never resolves and every hook falsely + # reports NOT INSTALLED even when `pre-commit install` succeeded. + git_dir="$(git rev-parse --git-common-dir 2>/dev/null)" || err "Not inside a git repository" + [[ "$git_dir" = /* ]] || git_dir="$root/$git_dir" + + command -v pre-commit >/dev/null 2>&1 \ + && ok "framework: $(pre-commit --version)" \ + || warn "framework: NOT INSTALLED" + + [[ -f "$root/.pre-commit-config.yaml" ]] \ + && ok "config: .pre-commit-config.yaml present" \ + || warn "config: MISSING" + + # A hook that exists but is empty is a silent no-op — treat it as a failure. + for hook in pre-commit pre-push; do + target="$git_dir/hooks/$hook" + if [[ ! -f "$target" ]]; then + warn "$hook: NOT INSTALLED" + elif [[ ! -s "$target" ]]; then + warn "$hook: EMPTY (0 bytes) — silent no-op, reports success and checks nothing" + elif [[ ! -x "$target" ]]; then + warn "$hook: not executable" + else + ok "$hook: installed" + fi + done +} + +case "${1:-install}" in + install|"") install_framework; install_hooks ;; + --hooks-only) install_hooks ;; + --verify) verify ;; + *) err "Unknown argument: $1 (use: install | --hooks-only | --verify)" ;; +esac diff --git a/scripts/lib/detect-os.sh b/scripts/lib/detect-os.sh new file mode 100755 index 000000000..77d7f89eb --- /dev/null +++ b/scripts/lib/detect-os.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# detect-os.sh — Shared OS/arch detection library +# +# Source this file; do not execute it directly. +# Usage: source "$(dirname "${BASH_SOURCE[0]}")/lib/detect-os.sh" +# +# Exports after sourcing: +# OS — macos | ubuntu | debian | fedora | ... +# OS_VERSION — version string (e.g. "24.04", "14.5") +# PKG_MANAGER — brew | apt | dnf | yum +# ARCH — amd64 | arm64 +# GOOS — darwin | linux (matches Go/tool naming) +# SHELL_RC — absolute path to user shell RC file +# PIP_FLAGS — pip install flags appropriate for the OS +# +# Helper functions (available after sourcing): +# is_macos() — returns 0 if macOS +# is_linux() — returns 0 if Linux +# is_wsl2() — returns 0 if inside WSL2 +# sha256check() — portable SHA256 verification +# http_get() — portable file download (curl-based) + +# ── OS + package manager ────────────────────────────────────────────────────── + +detect_os() { + local kernel + kernel="$(uname -s)" + + case "$kernel" in + Darwin) + OS="macos" + OS_VERSION="$(sw_vers -productVersion 2>/dev/null || echo 'unknown')" + PKG_MANAGER="brew" + GOOS="darwin" + ;; + Linux) + if [[ -f /etc/os-release ]]; then + # shellcheck source=/dev/null + . /etc/os-release + OS="${ID:-linux}" + OS_VERSION="${VERSION_ID:-unknown}" + else + OS="linux" + OS_VERSION="unknown" + fi + GOOS="linux" + + if command -v apt-get &>/dev/null; then + PKG_MANAGER="apt" + elif command -v dnf &>/dev/null; then + PKG_MANAGER="dnf" + elif command -v yum &>/dev/null; then + PKG_MANAGER="yum" + else + echo "[detect-os] ERROR: No supported package manager found (apt/dnf/yum)" >&2 + return 1 + fi + ;; + *) + echo "[detect-os] ERROR: Unsupported kernel: $kernel" >&2 + return 1 + ;; + esac + + export OS OS_VERSION PKG_MANAGER GOOS +} + +# ── CPU architecture ────────────────────────────────────────────────────────── + +detect_arch() { + case "$(uname -m)" in + x86_64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) + echo "[detect-os] ERROR: Unsupported architecture: $(uname -m)" >&2 + return 1 + ;; + esac + export ARCH +} + +# ── Shell RC file ───────────────────────────────────────────────────────────── + +detect_shell_rc() { + if [[ "$GOOS" == "darwin" ]]; then + # macOS defaults to zsh since Catalina + SHELL_RC="$HOME/.zshrc" + else + SHELL_RC="$HOME/.bashrc" + fi + export SHELL_RC +} + +# ── pip install flags ───────────────────────────────────────────────────────── + +detect_pip_flags() { + # Both macOS (Homebrew Python 3.12+) and Ubuntu 22.04+ enforce PEP 668 + PIP_FLAGS="--break-system-packages --user" + export PIP_FLAGS +} + +# ── Helper: portable SHA256 checksum verification ──────────────────────────── +# +# Usage: sha256check +# Returns 0 on match, 1 on mismatch. + +sha256check() { + local archive="$1" checksums="$2" + local basename + basename="$(basename "$archive")" + + # Exact-filename match on the last whitespace-separated field, stripping any + # leading "*" (binary-mode checksum files, e.g. hadolint's checksums.sha256). + # A plain substring grep is unsafe: some releases (e.g. golangci-lint) also list + # derived files like ".tar.gz.sbom.json" whose name is a superstring of the + # binary's, so a substring match pulls in extra lines sha256sum can't find locally. + local line + line="$(awk -v f="$basename" '{n=$NF; sub(/^\*/, "", n); if (n == f) print}' "$checksums" 2>/dev/null || true)" + + # On macOS, always prefer shasum — sha256sum may exist (e.g. from coreutils) but + # the BSD variant doesn't accept GNU flags (--check, --status). + if [[ "$GOOS" == "darwin" ]]; then + if ! command -v shasum &>/dev/null; then + echo "[detect-os] ERROR: shasum not found on macOS" >&2 + return 1 + fi + if [[ -z "$line" ]]; then + # Bare-hash format (e.g. hadolint): single hash on first line, no filename + local hash + hash="$(head -1 "$checksums" | awk '{print $1}')" + [[ -z "$hash" ]] && return 1 + echo "${hash} ${basename}" | ( cd "$(dirname "$archive")" && shasum -a 256 -c --status ) + else + ( cd "$(dirname "$archive")" && echo "$line" | shasum -a 256 -c --status ) + fi + elif command -v sha256sum &>/dev/null; then + if [[ -z "$line" ]]; then + # Bare-hash format: single hash on first line, no filename + local hash + hash="$(head -1 "$checksums" | awk '{print $1}')" + [[ -z "$hash" ]] && return 1 + echo "${hash} ${basename}" | ( cd "$(dirname "$archive")" && sha256sum --check --status ) + else + ( cd "$(dirname "$archive")" && echo "$line" | sha256sum --check --status ) + fi + else + echo "[detect-os] ERROR: No sha256sum or shasum found" >&2 + return 1 + fi +} + +export -f sha256check + +# ── Helper: portable file download ─────────────────────────────────────────── +# +# Usage: http_get + +http_get() { + local url="$1" out="$2" + curl -fsSL --retry 2 "$url" -o "$out" +} + +export -f http_get + +# ── Helper predicates ───────────────────────────────────────────────────────── + +is_macos() { [[ "$GOOS" == "darwin" ]]; } +is_linux() { [[ "$GOOS" == "linux" ]]; } +is_wsl2() { grep -qiE "microsoft|wsl" /proc/version 2>/dev/null; } + +export -f is_macos is_linux is_wsl2 + +# ── Run detection on source ─────────────────────────────────────────────────── + +detect_os +detect_arch +detect_shell_rc +detect_pip_flags