Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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]
15 changes: 12 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
17 changes: 9 additions & 8 deletions docs/hub_module/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
67 changes: 67 additions & 0 deletions scripts/hooks/check-dockerfile-rootless.sh
Original file line number Diff line number Diff line change
@@ -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 <Dockerfile>... (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): <reason>"
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): <reason>"
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): <reason>"
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): <reason>"
status=1
fi
done

exit "$status"
125 changes: 125 additions & 0 deletions scripts/install-pre-commit.sh
Original file line number Diff line number Diff line change
@@ -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
# <main-repo>/.git/worktrees/<name>), 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
Loading