Files
felhom-controller/controller/scripts/secret_in_markup_gate.py
T
admin 681cc663ef
gates / gates (push) Failing after 13s
decoy sweep: eight holes in this repo's gates, all measured, all fixed (R-421)
Every gate was DECOYED - the label constructed without the fact, the gate run, the verdict recorded.
No verdict here was reached by reading, because reading is exactly how the five prior instances hid.

SCOPE IS A FACT TOO, and it was the big one. Six gates decided what to look at with os.listdir - one
directory level. Every one was green AND CORRECT, because no template subdirectory exists today; every
one would have gone blind the moment anyone added templates/partials/, which is an ordinary act. A
single planted file carrying an emoji, a native confirm(), hand-rolled row markup, a dangling JS id
reference, a templated secret and an unregistered retrieval promise passed all six.

THE CONTROL IS WHAT MAKES THAT A MEASUREMENT: mojibake and docker-v already used os.walk, saw the
identical planted file, and convicted. So the cause was the listing, not the decoy.

COMMENTS ARE NOT CODE, AND COMMENTS ARE NOT CONTROLS. debug-routes matched `case subpath == "x"` in
raw text, so a case left in a commented-out block counted as a live handler - which is R-400's
original defect (seven dead controls on the page an operator opens when something is already wrong)
reached through the one door its own gate could not see. app-row-dedup's MUST_USE check had the same
shape: a commented-out {{template "app_list_row"}} satisfied it.

Stripping is deliberately crude in debug_route_gate, and that is correct there: its own docstring
insists on ten lines that cannot rot. A // inside a string literal truncates that line, which can
only ever HIDE a reference, never invent one - it fails in the safe direction.

NOT FIXED, and left open with its decoy rather than quietly patched: R-425, offbox-rename scans a
fixed three-entry FILES list, so banned NAS branding in a NEW offbox template passes. The scope was
correct when written and silently narrows every time the feature grows a file.

test_gate_decoys.py holds 10 decoys and declares COVERS, which felhom.eu's new decoy-coverage gate
AST-parses - a substring search for coverage would be the very shape this sweep exists to find.

No Go code. No version bump. No image. No golden owed.
Survey: felhom.eu/documentation/audits/AUDIT-gate-decoys-2026-09-01.md
2026-09-01 12:39:17 +02:00

142 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""secret_in_markup_gate.py — a secret must never be rendered into a template.
WHY THIS EXISTS. Three instances of ONE pattern shipped in two days, each found by hand:
R-249 settings_security.html {{.RetrievalPassword}} inside a display:none span
R-254 app_info.html {{.InitialCreds.Password}} inside a `hidden` span
R-254 deploy.html value="{{$val}}" in a readonly type=password input
Each was "hidden" by an instruction the browser honours when DRAWING and by nothing else, so the
plaintext sat in the response body of a page the customer merely opened. **Hiding is not containment.**
A pattern found three times is not closed by searching a fourth time; it is closed by a check.
WHAT THIS GATE DOES. It reads every template and convicts any `{{ … }}` action whose expression names
a secret (password / secret / token / credential / passphrase / apikey), unless that exact expression
is on the ALLOWLIST below with a stated reason.
⚠ WHAT IT DOES *NOT* DO, STATED PLAINLY SO NOBODY READS IT AS COMPLETE COVERAGE.
1. It is NAME-BASED, and the hole was MEASURED rather than guessed at. It catches
`{{.InitialCreds.Password}}`, and it also catches a launder through a local variable, because the
ASSIGNMENT names the secret (`{{$v := .InitialCreds.Password}}` is convicted). What it cannot see
is a secret that arrives under a NEUTRAL PAGE-DATA KEY — `data["Tagline"] = creds.Password` then
`{{.AppInfo.Tagline}}` passes this gate cleanly. Verified both ways during the 2026-08-08 session.
The third instance above (`value="{{$val}}"` inside an `{{if eq .Type "secret"}}` branch) is that
shape: this gate would NOT have caught it.
2. It reasons about TEMPLATES, not about rendered output. A handler that writes a secret into a
neutrally-named page-data key is invisible to it.
3. Runtime body-assertion — rendering a page with a sentinel and grepping the response — is the
check that catches all three, and it needs each page's data to be constructible. Four pages have
that today (settings_security, app_info, deploy, backups_restore) and each has its own test; the
other 23 page templates do NOT. Closing that gap is R-255.
So: this is the cheap layer that would have caught two of the three, plus a per-page runtime assertion
for the pages that can afford one. Together they are not a proof; they are two nets with different
holes, and the holes are named above.
"""
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
CTRL = os.path.dirname(HERE)
TPL = os.path.join(CTRL, "internal", "web", "templates")
SECRETY = re.compile(r"pass(word|phrase)|secret|token|credential|apikey|api_key", re.I)
ACTION = re.compile(r"\{\{-?\s*(.*?)\s*-?\}\}", re.S)
# Expressions that name a secret but are NOT one, each with the reason it is safe. An entry here is a
# claim someone made; it should be short enough to re-check by eye.
ALLOWLIST = {
# booleans / presence flags — the whole point of the R-249 and R-254 fixes
".HasRetrievalPassword": "boolean: whether one exists, never the value",
".InitialCredsHasPassword": "boolean: whether one exists, never the value",
".SharePasswordSet": "boolean: whether a share password is set",
".PasswordError": "an error MESSAGE for a failed password change, not a password",
".MinPassword": "the minimum LENGTH policy number",
# form field names and types, not values
'eq .Type "password"': "a field TYPE discriminator",
'eq .Type "secret"': "a field TYPE discriminator",
'eq .Type "secret_input"': "a field TYPE discriminator",
'if or .Required (eq .Type "password")': "a field TYPE discriminator",
'if and (not $isDeployed) (eq .Type "secret")': "guards the PRE-DEPLOY hidden input — a form must "
"carry what it submits (README §318); see R-254 site two",
"define \"launcher_share_password\"": "a template name",
# the CSRF token is not a secret in this sense: it is bound to the session and useless without it,
# and it MUST be in the form for the form to work.
".CSRFToken": "CSRF token — session-bound, must be in the page for any POST to work",
".CSRFField": "CSRF token — same",
}
def check(path):
convictions = []
src = open(path, encoding="utf-8").read()
for m in ACTION.finditer(src):
expr = m.group(1).strip()
# A TEMPLATE comment `{{/* ... */}}` is stripped by html/template and never reaches the
# response body, so it cannot leak anything into markup — unlike an HTML `<!-- -->` comment,
# which does ship and is deliberately NOT skipped here. Without this the gate convicted the
# prose explaining a fix, purely for containing the word "secret" (2026-08-10), which is a
# false positive that teaches people to write worse comments or to widen the ALLOWLIST —
# both of which cost more than the check is worth.
if expr.startswith("/*"):
continue
if not SECRETY.search(expr):
continue
if expr in ALLOWLIST:
continue
# `{{if .X}}` / `{{with .X}}` where .X is allowlisted is the same claim as `.X`
bare = re.sub(r"^(if|with|else if)\s+", "", expr).strip()
if bare in ALLOWLIST:
continue
line = src[: m.start()].count("\n") + 1
convictions.append((line, expr))
return convictions
# R-421 (2026-09-01): any depth, was os.listdir (one level). No template subdirectory exists
# today, so this was green and correct — and would have stayed green the moment anyone added
# templates/partials/. Measured: a planted file there passed every listdir-based gate.
def _html_at_any_depth(root):
out = []
for dirpath, _dirs, names in os.walk(root):
for fn in sorted(names):
if fn.endswith('.html'):
out.append(os.path.join(dirpath, fn))
return sorted(out)
def main():
if not os.path.isdir(TPL):
print("secret-in-markup gate INCONCLUSIVE: template dir not found: %s" % TPL)
return 2
files = [os.path.relpath(x, TPL) for x in _html_at_any_depth(TPL)]
if not files:
print("secret-in-markup gate INCONCLUSIVE: no templates found in %s" % TPL)
return 2
total = 0
bad = 0
for f in files:
total += 1
for line, expr in check(os.path.join(TPL, f)):
bad += 1
print(" %s:%d renders a secret-named expression into the markup: {{%s}}" % (f, line, expr))
if bad:
print()
print("SECRET-IN-MARKUP GATE FAILED: %d expression(s) across %d template(s)." % (bad, total))
print("A secret must not be in the response body of a page the customer merely opens —")
print("hiding it with `hidden` / display:none / type=password stops it being DRAWN and nothing else.")
print("Fix: carry a BOOLEAN in the page data and fetch the value with an explicit authenticated")
print("POST that sets Cache-Control: no-store and logs the act (see R-249's and R-254's endpoints).")
print("If the expression genuinely is not a secret, add it to ALLOWLIST with the reason.")
return 1
print("secret-in-markup gate OK — %d templates, no secret-named expression rendered" % total)
print(" (NAME-BASED: blind to a secret arriving under a neutral PAGE-DATA key — see the docstring)")
return 0
if __name__ == "__main__":
sys.exit(main())