diff --git a/AGENTS.md b/AGENTS.md index 3a274be..820d56b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ per-app `mix precommit`, which still formats as you work. **Lens pass (every push, and before handing off).** `make verify` runs `check` first (cheapest), then per app: one suite run that exports its coverage, then -that app's sensors (sobelow, coverage, CRAP, mutation, as those yaks land), which +that app's sensors (sobelow, coverage, CRAP; mutation as that yak lands), which run cheapest-first and read the export rather than re-running the suite. Measured 2026-11-01: ~11s wall (app 178 tests + blogex 156 tests + coverage floor). diff --git a/Makefile b/Makefile index cef69d1..02bdd37 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,8 @@ check: @$(MAKE) -C blogex MISE_BIN=$(MISE_BIN) check @echo "==> check: toolchain pins" @bash scripts/check-pins.sh + @echo "==> check: Lens sensor wiring" + @bash scripts/check-lens-sensors.sh # Precommit target for CI/pre-commit hooks precommit: check diff --git a/app/.sobelow-skips b/app/.sobelow-skips new file mode 100644 index 0000000..24fe77a --- /dev/null +++ b/app/.sobelow-skips @@ -0,0 +1,30 @@ +# Sobelow per-finding skips. Enabled by `--skip` in `app/Makefile`. +# +# Rule: every entry is preceded by a rationale comment saying what the check +# cannot see and why the protection is real (or why the finding is a false +# positive). An unexplained skip is a defect; `scripts/check-lens-sensors.sh` +# enforces this. Fingerprints are sobelow's own; a code change that moves a +# skip's line invalidates it and the sensor fails loudly rather than staying +# quiet. +# +# Baseline 2026-10-28, sobelow 0.17.0: 3 findings (2 High, 1 Low), all skipped. + +# Config.CSP - High - lib/firehose_web/router.ex:13 (pipeline :browser) +# No CSP exists. put_secure_browser_headers does not set one and the root +# layout has an inline theme script with no nonce, so choosing a policy is a +# design decision rather than a mechanical fix. +# Follow-up yak: app: add a Content-Security-Policy (sobelow Config.CSP) +Config.CSP: Missing Content-Security-Policy,lib/firehose_web/router.ex:13,486EC13 + +# Config.HTTPS - High - config/prod.exs +# No force_ssl: production TLS is terminated by the Dokku proxy. Whether the +# endpoint should also force SSL/HSTS (and trust X-Forwarded-Proto) is a +# deployment decision, not a mechanical fix. +# Follow-up yak: app: enforce HTTPS in production (sobelow Config.HTTPS) +Config.HTTPS: HTTPS Not Enabled,config/prod.exs:0,2B5C077 + +# Traversal.FileModule - Low - lib/firehose_web/live/microprints_live.ex:331 +# False positive: the client path is only read after Enum.find matches it +# against the server-scanned microprint list, and source_dir comes from +# @source_dirs. Both Path.join inputs are server-computed, never client text. +Traversal.FileModule: Directory Traversal in `File.read`,lib/firehose_web/live/microprints_live.ex:331,7ED2FE5 diff --git a/app/Makefile b/app/Makefile index 2bb9fb9..d9eb780 100644 --- a/app/Makefile +++ b/app/Makefile @@ -3,7 +3,7 @@ MISE_BIN ?= mise MISE_EXEC = $(MISE_BIN) exec -- -.PHONY: check precommit deps assets compile test test.coverage coverage crap format format.check credo verify +.PHONY: check precommit deps assets compile test test.coverage sobelow coverage crap format format.check credo verify # Run all static analysis checks (no database required). `format.check` only # reports: a gate that rewrites the files it judges can never fail. Fix @@ -159,6 +159,10 @@ credo: # VERIFY_SENSORS — that variable is the single source of truth for what this # target reports as wired. # - sobelow (yak: sobelow in the Lens pass) +# WIRED below (sobelow, ~1s). Fails loudly on findings and +# when the task is missing — no fallback of any kind. +# Per-finding skips exist only in .sobelow-skips, each with a +# written rationale (pinned by scripts/check-lens-sensors.sh). # - coverage (yak: coverage floors + framework-glue exclusions policy) # WIRED below (test.coverage + coverage). # - crap (yak: CRAP scores with ex_crap (both apps)) @@ -171,7 +175,17 @@ credo: # # The suite runs FIRST, because it is what produces the coverage export the # sensors read; sensors still run cheapest-first among themselves. -VERIFY_SENSORS := coverage crap +VERIFY_SENSORS := sobelow coverage crap + +# Sobelow security scan. `--exit` is load-bearing: without it sobelow prints +# findings and exits 0 (verified 2026-10-28, sobelow 0.17.0: 3 findings -> 0, +# `--exit` -> 1; it defaults to the `low` confidence threshold). `--skip` +# applies only the individually rationalised entries in .sobelow-skips. +# There is deliberately no guard or fallback: if the dep is absent, `mix +# sobelow` is a hard Mix error ("The task could not be found") and the +# verify loop's `|| exit 1` fails the pass. +sobelow: + $(MISE_EXEC) mix sobelow --skip --exit verify: @echo "==> verify(app): suite (coverage exported), then sensors [$(VERIFY_SENSORS)]" diff --git a/app/mix.exs b/app/mix.exs index 2b41fa7..a709c56 100644 --- a/app/mix.exs +++ b/app/mix.exs @@ -82,7 +82,8 @@ defmodule Firehose.MixProject do git: "https://gitea.apps.sustainabledelivery.com/QWAN/microprints-phoenix.git", branch: "main"}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, - {:ex_crap, "~> 0.1.1", only: [:dev, :test], runtime: false} + {:ex_crap, "~> 0.1.1", only: [:dev, :test], runtime: false}, + {:sobelow, "~> 0.16", only: [:dev, :test], runtime: false} ] end diff --git a/app/mix.lock b/app/mix.lock index fa58e95..ba6f0ea 100644 --- a/app/mix.lock +++ b/app/mix.lock @@ -53,6 +53,7 @@ "req": {:hex, :req, "0.7.5", "0c3293cbf7717aba20b1b1b3ea13fe8d3fdf6f1557490f390c3f1ef6dd9db955", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "576853c4bf403faa3c1fa5d093e0759227083b78f8a6864c67d62c0a9c45514d"}, "rustler": {:hex, :rustler, "0.38.0", "7a8906998ff0d28e3021c0a73264abcda719bda344b2e58307c6805b0f87c9b4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "704c03c1bf66be12b031c5a389347b91c81c5cb819a24b068b0de36fe4a5652a"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.10.0", "8f07ff0865cb09a6a0f75e11701e9a46e331e05f737da4e5b01374dcd25b68c0", [:mix], [{:rustler, "~> 0.28", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "c68c9dc732c12acfdf4a7fa06f4e6f48a641179e6be49a474c8a60809929c430"}, + "sobelow": {:hex, :sobelow, "0.17.0", "57436898f5b5eac05244ddb301927b1e6521d7de5df3023929d0fe2818d11055", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "02914e7cdc5bd9bdcb1a2be7f08d793fd765f8c238c924f29857d3996d435928"}, "swoosh": {:hex, :swoosh, "1.29.0", "b938791267d8a55415344018aa0421d214f9bc36c93cbe27305d3284a853785c", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d23ea419c6c67604628c0e93a8d5c6d13bcc442137870671e1b05325b2956a13"}, "tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, diff --git a/doc/improve-verification.md b/doc/improve-verification.md index 7f39b5d..40c57b1 100644 --- a/doc/improve-verification.md +++ b/doc/improve-verification.md @@ -129,14 +129,24 @@ Expected gaps in firehose, by module type: ### Phase 6 — Sobelow -- [ ] Add to the Lens pass for `app` (Phoenix surface). blogex is a library +- [x] Add to the Lens pass for `app` (Phoenix surface). blogex is a library without endpoints; decide whether to run it there anyway — in ex_crap's own history sobelow caught a `String.to_atom` in a library, so it's not - strictly web-only value. -- [ ] **Fail when sobelow is not installed** (stickies: user explicitly + strictly web-only value. Decided 2026-10-28: **app only**; blogex's + sobelow scope is a separate yak. +- [x] **Fail when sobelow is not installed** (stickies: user explicitly removed the skip — a silently-skipped check is a lying sensor). -- [ ] Fix findings or add per-check skips *with rationale comments* + `make -C app sobelow` has no fallback: a missing task is a hard Mix + error, and `verify`'s `|| exit 1` loop fails the pass. +- [x] Fix findings or add per-check skips *with rationale comments* (stickies: `Config.CSP` skipped because the plug is invisible to sobelow). + Baseline 2026-10-28 (sobelow 0.17.0), 3 findings: `Config.HTTPS` (High), + `Config.CSP` (High), `Traversal.FileModule` (Low). All three are skipped + in `app/.sobelow-skips`, each with a written rationale: HTTPS and CSP are + deployment/design decisions with follow-up yaks, and the Traversal flag + is a false positive (the path is matched against the server-scanned list + before `File.read`). Unlike stickies, the CSP skip is *not* "the plug is + invisible" — this app genuinely has no CSP yet. ### Phase 7 — Boundary diff --git a/scripts/check-lens-sensors.sh b/scripts/check-lens-sensors.sh new file mode 100755 index 0000000..39710e0 --- /dev/null +++ b/scripts/check-lens-sensors.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# check-lens-sensors.sh - pin the Lens sensor wiring that must not drift. +# +# Why this exists: `make verify` echoes VERIFY_SENSORS and runs each name from +# the app's sensor loop. A sensor missing from that list is never run, and a +# sensor target that can fall back (or skip the tool when it is absent) exits 0 +# when it did not check anything - in both cases the pass reports a check it +# did not run. AGENTS.md: "A gate that reports a check it did not run is worse +# than no gate." +# +# This script is static and grep-only: no mise, no elixir, no network, no +# database. It pins load-bearing text (the VERIFY_SENSORS line and the sobelow +# recipe) and the rationale rule for per-finding skips. Renaming the target or +# the variable means updating this script in the same commit - that is the +# point: the wiring stays a reviewed decision rather than an accident. +# +# Run standalone, or via root `make check`. +set -euo pipefail + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +app_makefile="$root/app/Makefile" +skips="$root/app/.sobelow-skips" +status=0 + +# Verified 2026-10-28 against sobelow 0.17.0: +# `cd app && mix sobelow` with 3 findings -> exit 0 (findings alone are NOT +# fatal); `cd app && mix sobelow --exit` -> exit 1. `--exit` defaults to the +# `low` confidence threshold, which is what we want: every finding counts. +SOBELOW_FAIL_FLAG='--exit' + +fail() { echo "FAIL: $*" >&2; status=1; } + +# --- T1: sobelow is wired and listed first in VERIFY_SENSORS --------------- +# `make verify` echoes this list, so a missing name means the pass reports a +# sensor it never runs. Cheapest-first ordering is part of the contract. +check_verify_sensors() { + local line sensors i + line=$(grep -E '^VERIFY_SENSORS[[:space:]]*:=' "$app_makefile" || true) + if [ -z "$line" ]; then + fail "app/Makefile has no VERIFY_SENSORS := line" + return + fi + + sensors=$(printf '%s' "${line#*:=}" | tr -s '[:space:]' ' ') + local -a order + read -r -a order <<<"$sensors" + + idx() { + local n + for n in "${!order[@]}"; do + if [ "${order[$n]}" = "$1" ]; then + echo "$n" + return + fi + done + echo 999 + } + + for sensor in sobelow coverage crap; do + [ "$(idx "$sensor")" -ne 999 ] || fail "VERIFY_SENSORS does not list '$sensor' (line: $line)" + done + [ "$(idx sobelow)" -lt "$(idx coverage)" ] || + fail "VERIFY_SENSORS puts coverage before sobelow (cheapest first)" + [ "$(idx sobelow)" -lt "$(idx crap)" ] || + fail "VERIFY_SENSORS puts crap before sobelow (cheapest first)" +} + +# --- T2: the sobelow target fails loudly and has no skip/fallback path ----- +# The stickies regression: the user explicitly removed a "not installed, +# skipping" fallback because a silently-skipped check is a lying sensor. +check_sobelow_recipe() { + local recipe + recipe=$(awk ' + /^sobelow:/ { found = 1; next } + found && /^\t/ { print; next } + found { exit } + ' "$app_makefile") + + if [ -z "$recipe" ]; then + fail "app/Makefile has no sobelow: target with a recipe" + return + fi + + case "$recipe" in + *"mix sobelow"*) ;; + *) fail "sobelow recipe does not invoke 'mix sobelow': $recipe" ;; + esac + + case "$recipe" in + *"$SOBELOW_FAIL_FLAG"*) ;; + *) fail "sobelow recipe is missing $SOBELOW_FAIL_FLAG, so findings would exit 0: $recipe" ;; + esac + + local banned + for banned in '||' 'command -v sobelow' 'skipping' '2>/dev/null'; do + case "$recipe" in + *"$banned"*) fail "sobelow recipe contains a fallback/skip path ('$banned'): $recipe" ;; + esac + done +} + + +# --- T3: every per-finding skip carries a written rationale ---------------- +# Sobelow 0.17 keeps per-finding skips as fingerprinted lines in +# `app/.sobelow-skips` (enabled with `--skip`); `.sobelow-conf` holds only +# options. A skip entry without a reason is a defect: the next reader cannot +# tell a false positive from a debt someone hid. Comments are preserved by +# sobelow's skip loader (they sort first and never match a fingerprint). +check_skips() { + [ -f "$skips" ] || return 0 + + local prev="" line n=0 + while IFS= read -r line || [ -n "$line" ]; do + n=$((n + 1)) + case "$line" in + "" | "#"*) + prev="$line" + continue + ;; + esac + case "$prev" in + "#"*) ;; + *) fail "app/.sobelow-skips:$n: skip entry has no rationale comment on the line above: $line" ;; + esac + prev="$line" + done <"$skips" +} + +check_verify_sensors +check_sobelow_recipe +check_skips + +if [ $status -eq 0 ]; then + echo "lens sensors ok: sobelow wired first in VERIFY_SENSORS, fail-loud recipe, app skips rationalised" +else + echo >&2 "fix the Lens sensor wiring/skips above, then re-run." +fi +exit $status