security: wire changed-line scanner hook
Jaime Fournier <jaimef@linbsd.org>
a4cf444f4192feacd63b3816a9ecf62f95b8ae17
diff --git a/AGENTS.md b/AGENTS.md
index 0f5b749..6e459d0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -438,6 +438,11 @@ Jerboa is a niche Scheme dialect with limited training data. **Never guess — a
4. **`jerboa_verify`** — combined syntax + compile + lint + arity + duplicate check (use instead of individual tools)
5. **`jerboa_security_scan`** — for code involving FFI, shell commands, or file I/O
+Security fixes must update `data/security-rules.sexp` in the same commit when
+the vulnerable pattern is mechanically detectable. If a useful scanner rule
+would be too noisy, document the reason in the fix review or changelog instead
+of silently skipping scanner coverage.
+
### Essential Tools (use proactively)
| When | Tool |
diff --git a/data/security-rules.sexp b/data/security-rules.sexp
index 17aa7f4..c35f6f9 100644
--- a/data/security-rules.sexp
+++ b/data/security-rules.sexp
@@ -1270,6 +1270,85 @@
("pattern" . "__collect_safe")
("scope" . "ffi-boundary")
("severity" . "high"))
+ (("id" . "ffi-pointer-return-without-null-guard")
+ ("message"
+ .
+ "FFI binding or pointer dereference touches a raw pointer-sized value. Any C API that returns void*, char*, u8*, or an ftype pointer can return NULL on allocation failure, lookup miss, or invalid input; dereferencing before an explicit ftype-pointer-null?/zero-address check turns recoverable errors into crashes or memory corruption.")
+ ("pattern"
+ .
+ "foreign-procedure[^;]*(void\\*|char\\*|u8\\*)|ftype-pointer-address|foreign-ref")
+ ("scope" . "ffi-boundary")
+ ("severity" . "high"))
+ (("id" . "ffi-integer-width-ambiguous")
+ ("message"
+ .
+ "FFI binding uses a C integer type on a size/length/count/pointer-adjacent argument or result. `int`, `long`, and platform pointer-width types differ across targets; a mismatched width can truncate lengths, sign-extend counts, or mis-handle file descriptors in static/release builds.")
+ ("pattern"
+ .
+ "foreign-procedure[^;]*(int|unsigned-int|long|unsigned-long)[^;]*(size|len|length|count|width|height|offset|ptr|fd)")
+ ("scope" . "ffi-boundary")
+ ("severity" . "medium"))
+ (("id" . "ffi-pointer-arithmetic-without-bounds")
+ ("message"
+ .
+ "Pointer or bytevector operation combines a raw offset/length with foreign memory access. Check lower and upper bounds before every foreign-ref/foreign-set!/ftype-ref/ftype-set! or bytevector-copy! that uses attacker-influenced offsets, lengths, widths, or counts.")
+ ("pattern"
+ .
+ "(foreign-ref|foreign-set!|ftype-ref|ftype-set!|bytevector-copy!)[^;]*(offset|len|length|count|size|width|height|\\+)")
+ ("scope" . "ffi-boundary")
+ ("severity" . "high"))
+ (("id" . "native-fasl-load-from-dynamic-path")
+ ("message"
+ .
+ "Native/FASL load path is computed dynamically. Loading .fasl/.boot/.wpo/native artifacts from caller-controlled paths is code execution; only load release-produced, integrity-checked artifacts from the trusted installation tree.")
+ ("pattern"
+ .
+ "\\(load[^;]*(\\.fasl|\\.boot|\\.wpo|native)|\\(load-shared-object[^;]*\\((string-append|format|path-join)")
+ ("scope" . "scheme")
+ ("severity" . "critical"))
+ (("id" . "system-command-string-concat")
+ ("message"
+ .
+ "Shell command is built with string concatenation or format before system/safe-system/open-process-ports. Concatenated shell strings are command-injection prone and commonly leak secrets through argv or logs; use an argv/exec API or a single reviewed quoting helper.")
+ ("pattern"
+ .
+ "\\((system|safe-system|open-process-ports)\\s+\\((string-append|format|string-join|string-concatenate)")
+ ("scope" . "scheme")
+ ("severity" . "critical"))
+ (("id" . "resource-handle-without-with-resource")
+ ("message"
+ .
+ "sqlite-open or tcp-connect appears in an ordinary binding/definition instead of a with-resource/dynamic-wind ownership block. Exceptions between acquire and close leak file descriptors, sockets, or database handles.")
+ ("pattern"
+ .
+ "\\((let|let\\*|def|define)[^;]*(sqlite-open|tcp-connect)")
+ ("scope" . "scheme")
+ ("severity" . "medium"))
+ (("id" . "hardcoded-secret-literal")
+ ("message"
+ .
+ "A token/password/secret/key-looking binding contains a long string literal. Build/test fallbacks must be explicitly marked development-only; production secrets belong in an OS keyring, hardware-backed store, or runtime secret provider, never source or release artifacts.")
+ ("pattern"
+ .
+ "(token|TOKEN|secret|SECRET|password|PASSWORD|api[-_]?key|API[-_]?KEY|private[-_]?key|PRIVATE[-_]?KEY)[^\\n]{0,80}\"[^\"]{12,}\"")
+ ("scope" . "scheme")
+ ("severity" . "high"))
+ (("id" . "non-crypto-random-token")
+ ("message"
+ .
+ "Security token material is derived from `(random N)`, which is not a cryptographic RNG. Use the native CSPRNG wrapper for tokens, nonces, passwords, session IDs, reset codes, and keys.")
+ ("pattern"
+ .
+ "\\(random\\s+[0-9]+\\)[^;]*(token|secret|nonce|key|password|code|session)|(token|secret|nonce|key|password|code|session)[^;]*\\(random\\s+[0-9]+\\)")
+ ("scope" . "scheme")
+ ("severity" . "high"))
+ (("id" . "release-safe-mode-contract-disable")
+ ("message"
+ .
+ "Safe/contract mode is set to release outside a clearly isolated test fixture. Disabling checks in production removes argument and boundary validation exactly where malformed inputs cross trust boundaries.")
+ ("pattern" . "\\*safe-mode\\*[^;]*release|safe-mode[^;]*release")
+ ("scope" . "scheme")
+ ("severity" . "medium"))
(("id" . "secret-via-argv-flag")
("message"
.
diff --git a/docs/devex.md b/docs/devex.md
index a3ce96f..0828d16 100644
--- a/docs/devex.md
+++ b/docs/devex.md
@@ -2,7 +2,38 @@
Jerboa provides three development-time tools: a time-travel debugger for
inspecting execution history, a deterministic profiler for performance analysis,
-and a hot code reloader for live development.
+and a hot code reloader for live development. Security-sensitive contributors
+can also opt into a changed-line pre-commit scan.
+
+---
+
+## Changed-Line Security Pre-Commit Hook
+
+`support/pre-commit-security-scan.sh` is an opt-in hook template that runs
+`jerboa_security_scan` through `jmcp` against staged source files and their
+changed lines only. It uses `data/security-rules.sexp`, defaults to `medium`
+severity, and ignores unstaged worktree changes.
+
+Install it from the repository root:
+
+```sh
+cp support/pre-commit-security-scan.sh .git/hooks/pre-commit
+chmod +x .git/hooks/pre-commit
+```
+
+The hook expects `python3` plus a local `.chez/bin/scheme` checkout build,
+`dist/jmcp`, `dist/jerboa`, `jmcp`, or `jerboa` on `PATH`. Tune local
+strictness with:
+
+```sh
+JERBOA_SECURITY_SCAN_THRESHOLD=low
+JERBOA_SECURITY_SCAN_MAX_FINDINGS=100
+```
+
+Security fixes should update `data/security-rules.sexp` in the same commit when
+the bug pattern is mechanically detectable. If the pattern is not detectable
+without unacceptable false positives, document that in the fix review or
+changelog entry.
---
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index 661d3be..edadff8 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -919,6 +919,15 @@ changed-lines-only scanning into a pre-commit hook template under
fix must add its scanner rule in the same commit (write it into
`AGENTS.md`-adjacent contributing docs).
+- **Status:** complete for the repository-local rule and hook baseline.
+ `data/security-rules.sexp` now covers the requested P1-01 follow-ups:
+ FFI pointer/null/width/bounds hazards, raw/trusted-only FASL load paths,
+ system command concatenation, sqlite/tcp resource ownership, hardcoded
+ secrets, `(random N)` token material, and release-mode safe-mode disables.
+ `support/pre-commit-security-scan.sh` provides an opt-in staged-diff hook
+ for `jerboa_security_scan`; `docs/devex.md`, `docs/security-reference.md`,
+ and `AGENTS.md` document the hook and the same-commit scanner-rule policy.
+
### K3-P2-06 — Documentation consistency pass
**Serves:** G5. **Effort:** 2 days.
diff --git a/docs/security-reference.md b/docs/security-reference.md
index 1a63115..67c2411 100644
--- a/docs/security-reference.md
+++ b/docs/security-reference.md
@@ -210,11 +210,18 @@ Stale manifest entries also fail audit.
### Security Scanner Rules
`jerboa_security_scan` loads `data/security-rules.sexp`. The rule set includes
-`http-response-raw-condition-message`, which flags raw `condition-message` or
-`display-condition` values being written into HTTP response constructors.
+rules for FFI pointer/null/width/bounds hazards, raw `read`, trusted-only FASL
+paths, shell command concatenation, resource cleanup around database/socket
+handles, hardcoded secrets, non-cryptographic random token material,
+release-mode contract disables, and `http-response-raw-condition-message`.
Protocol handlers should return a generic client-safe message plus an opaque
reference from `(std security errors)` and log full details server-side.
+Changed-line scanning is available through the opt-in
+`support/pre-commit-security-scan.sh` hook template documented in
+`docs/devex.md`. Security fixes must add or update a scanner rule in the same
+commit when the pattern is mechanically detectable.
+
### Usage
```scheme
diff --git a/support/pre-commit-security-scan.sh b/support/pre-commit-security-scan.sh
new file mode 100755
index 0000000..c80f51e
--- /dev/null
+++ b/support/pre-commit-security-scan.sh
@@ -0,0 +1,130 @@
+#!/bin/sh
+# Optional pre-commit hook template for changed-line Jerboa security scans.
+#
+# Install with:
+# cp support/pre-commit-security-scan.sh .git/hooks/pre-commit
+# chmod +x .git/hooks/pre-commit
+
+set -eu
+
+root=$(git rev-parse --show-toplevel)
+threshold=${JERBOA_SECURITY_SCAN_THRESHOLD:-medium}
+max_findings=${JERBOA_SECURITY_SCAN_MAX_FINDINGS:-50}
+
+if ! command -v python3 >/dev/null 2>&1; then
+ echo "pre-commit security scan: python3 is required for MCP JSON encoding" >&2
+ exit 1
+fi
+
+if [ -x "$root/.chez/bin/scheme" ] && [ -f "$root/mcp/server.ss" ]; then
+ set -- "$root/.chez/bin/scheme" \
+ --libdirs "$root/lib:$root/vendor/jsqlite/src" \
+ --script "$root/mcp/server.ss"
+elif [ -x "$root/dist/jmcp" ]; then
+ set -- "$root/dist/jmcp"
+elif [ -x "$root/dist/jerboa" ]; then
+ set -- "$root/dist/jerboa" jmcp
+elif command -v jmcp >/dev/null 2>&1; then
+ set -- "$(command -v jmcp)"
+elif command -v jerboa >/dev/null 2>&1; then
+ set -- "$(command -v jerboa)" jmcp
+else
+ echo "pre-commit security scan: build Chez/Jerboa locally or put jmcp/jerboa on PATH" >&2
+ exit 1
+fi
+
+diff_file=$(mktemp "${TMPDIR:-/tmp}/jerboa-security-diff.XXXXXX")
+files_file=$(mktemp "${TMPDIR:-/tmp}/jerboa-security-files.XXXXXX")
+out_file=$(mktemp "${TMPDIR:-/tmp}/jerboa-security-scan.XXXXXX")
+trap 'rm -f "$diff_file" "$files_file" "$out_file"' EXIT HUP INT TERM
+
+git -C "$root" diff --cached --name-only -z --diff-filter=ACMR -- \
+ '*.ss' '*.sls' '*.scm' '*.c' '*.h' '*.rs' >"$files_file"
+
+if [ ! -s "$files_file" ]; then
+ exit 0
+fi
+
+git -C "$root" diff --cached --unified=0 -- \
+ '*.ss' '*.sls' '*.scm' '*.c' '*.h' '*.rs' >"$diff_file"
+
+python3 - "$root" "$threshold" "$max_findings" "$diff_file" "$files_file" <<'PY' | "$@" >"$out_file"
+import json
+import sys
+
+root, threshold, max_findings, diff_path, files_path = sys.argv[1:6]
+with open(diff_path, "r", encoding="utf-8") as handle:
+ diff = handle.read()
+with open(files_path, "rb") as handle:
+ raw_paths = handle.read()
+paths = [item.decode("utf-8", "surrogateescape") for item in raw_paths.split(b"\0") if item]
+
+print(json.dumps({
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "jerboa-pre-commit-security-scan", "version": "1"}}
+}))
+for offset, path in enumerate(paths, start=2):
+ print(json.dumps({
+ "jsonrpc": "2.0",
+ "id": offset,
+ "method": "tools/call",
+ "params": {
+ "name": "jerboa_security_scan",
+ "arguments": {
+ "project_path": root,
+ "file_path": path,
+ "severity_threshold": threshold,
+ "max_findings": int(max_findings),
+ "diff": diff
+ }
+ }
+ }))
+PY
+
+python3 - "$out_file" <<'PY'
+import json
+import sys
+
+scan_text = ""
+protocol_error = False
+
+with open(sys.argv[1], "r", encoding="utf-8") as handle:
+ for raw in handle:
+ raw = raw.strip()
+ if not raw.startswith("{"):
+ continue
+ try:
+ msg = json.loads(raw)
+ except json.JSONDecodeError:
+ continue
+ msg_id = msg.get("id")
+ if msg_id == 1:
+ continue
+ if not isinstance(msg_id, int):
+ continue
+ if "error" in msg:
+ print("pre-commit security scan: MCP error", file=sys.stderr)
+ print(json.dumps(msg["error"], indent=2), file=sys.stderr)
+ protocol_error = True
+ break
+ result = msg.get("result", {})
+ if result.get("isError"):
+ protocol_error = True
+ for item in result.get("content", []):
+ if item.get("type") == "text":
+ scan_text += item.get("text", "")
+
+if protocol_error:
+ if scan_text:
+ print(scan_text, file=sys.stderr)
+ sys.exit(1)
+
+if "Security scan found" in scan_text:
+ print(scan_text, file=sys.stderr)
+ sys.exit(1)
+
+if scan_text:
+ print(scan_text)
+PY