Date: 2026-05-07
Scope: full repo at main (commit 3728106), against SPEC.md as of the same
SHA. Focus is on real correctness, security, robustness, and maintainability
issues — not cosmetics, naming, or stylistic nits.
This document is meant to be referenced on each fix loop alongside SPEC.md. Findings are ordered by severity (highest first) and each carries:
R01-Nxx) — quote when committing a fix.open / accepted-by-design / fixed-in-<sha> (update as we
resolve).When a finding is closed, set Status to fixed-in-<sha> and append a short
note. Do not delete entries — they're history.
open finding whose severity ≥ MEDIUM.fixed-in-<sha> and add the SHA + a one-line note..env857df15 — src/Auth/LocalAdmin.php now reads
LOCAL_ADMIN_PASSWORD_HASH and verifies with password_verify().
Per the operator's explicit choice this is a clean break: NO plaintext
LOCAL_ADMIN_PASSWORD fallback. LocalAdmin::isEnabled() returns false
until the hash is provided, so /auth/local 404s instead of silently
re-using stale plaintext config. Email comparison stays timing-safe
(hash_equals, trimmed). Hash recipe documented in README.md Quick
setup step 3 and doc/admin-manual.md §3.5 (host-PHP and
docker run php:8.3-cli one-liners). New tests/Auth/LocalAdminTest.php
(9 cases, +13 assertions) covers the happy path, wrong-password /
wrong-email rejection, and an explicit regression guard that the legacy
LOCAL_ADMIN_PASSWORD env var alone does NOT enable the fallback.src/Auth/LocalAdmin.php lines 51-66 (verify(), password()).env.example lines 25-33 (the comment acknowledging the trade-off)hash_equals(self::password(), $password). There is
no hashing at rest. Anyone who can read .env (compromised backup, leaked
Docker image layer, mis-configured docroot, container exec, debugging
session) gets immediate admin..env world-readable on the host) becomes a
full-app compromise. The app already supports OIDC, so this fallback is the
weakest link.LOCAL_ADMIN_PASSWORD_HASH instead.
Verify with password_verify(). Offer a one-liner in the admin manual to
generate the hash.LOCAL_ADMIN_PASSWORD
accepted with a logged warning for one release, then removed)./7fd849b — twig guard tightened from
currentUser is null or currentUser.isAdmin to
currentUser is not null and currentUser.isAdmin; new
TwigViewTest::testHomeForAnonymousUserHidesRuntimePanel asserts the
panel and its leaked strings are absent for currentUser=null. Closes
R01-N31 as a side effect (the in-page /healthz hint lived inside the
same <details>).views/home.twig lines 112-138 — the "Runtime" <details>
panel.currentUser is null, the panel still renders. It exposes:
PHP version (PHP_VERSION), APP_ENV, the absolute path of the SQLite
file, the schema version, OIDC configured y/n, local-admin enabled y/n.currentUser is not null and
currentUser.isAdmin. Keep /healthz as the public liveness probe (text
"ok").f565c86 — went with the explicit env-bootstrap
branch. New src/Auth/BootstrapAdmin.php exposes
isConfigured() + matches(oid, email), reading the new
BOOTSTRAP_ADMIN_OID / BOOTSTRAP_ADMIN_EMAIL env vars
(case-insensitive, trimmed, hash_equals). AuthController::callback
now gates promotion on users.countAdmins() === 0 &&
BootstrapAdmin::matches($oid, $email) — with both env vars blank the
OIDC path NEVER auto-promotes (operators must seed via the local-admin
fallback or by flipping is_admin in app.sqlite). Switching from
count() === 0 to countAdmins() === 0 also closes the SQLite
DEFERRED-tx race the original finding called out: even if two
flows concurrently see the same count, the BootstrapAdmin match
eliminates the "wrong user wins" outcome — only the configured
principal is ever promoted. The local-admin path is unchanged: it
is itself an explicit env-bootstrap (LOCAL_ADMIN_EMAIL +
LOCAL_ADMIN_PASSWORD_HASH) and forceAdmin: true continues to
keep the configured local user admin. Both paths' BOOTSTRAP_ADMIN
audit rows now carry via=oidc / via=local so /audit can
distinguish them. Operator docs: new doc/admin-manual.md §3.5
"Nominating the first administrator (OIDC)" (old §3.5 → §3.6),
README's quick-setup paragraph rewritten, .env.example gains
the new env block. New tests/Auth/BootstrapAdminTest.php (8
cases) pins the matcher: unconfigured, oid-only, email-only,
both-set, case-insensitive trim, blank-incoming-fields-don't-match,
whitespace-only-env-treated-as-unset, either-channel-wins.
Tests: 184 / 502 (was 176 / 484).src/Controllers/AuthController.php lines 86-116 (OIDC) and 217-254
(local).src/Repositories/UserRepository.php lines 50-78.users.count() === 0 inside the same transaction as the upsert, but
SQLite default BEGIN is DEFERRED, so two concurrent first-time logins
could both observe count=0 and both get promoted. More importantly: if the
app is reachable on the public internet before the intended admin signs
in, an attacker with a valid Entra account in the tenant (or the local
admin password if that path is enabled) can win the race./users, which is itself admin-only. If the
attacker is the first user, the legit operator has no path back without
database access.BOOTSTRAP_ADMIN_OID or
BOOTSTRAP_ADMIN_EMAIL) and only auto-promote when the signing user
matches.BEGIN IMMEDIATE for the upsert tx to serialise the check
(cheap on SQLite WAL).doc/admin-manual.md regardless.SESSION_SECRET env var is documented but unused296883c — removed from .env.example,
SPEC.md §8, README.md, and doc/admin-manual.md §3.3 (subsequent
subsections renumbered). Took the "remove it" path called out in the
Suggested-fix list. Existing deployments' .env files keep their dead
SESSION_SECRET= line; harmless, can be deleted at any time..env / .env.example line 11.src/Auth/SessionGuard.php — no reference.SESSION_SECRET is documented as the salt for "session cookie
name / CSRF tokens" but nothing in the code reads it. CSRF tokens are
sourced from random_bytes(32) (good); the session id is whatever PHP
generates (good). The env var is dead..env.example will
assume rotating SESSION_SECRET invalidates active sessions / CSRF
tokens. It does not. False sense of security; rotations don't actually
protect against the threats they imagine they're protecting against..env.example, SPEC §8, and the admin manual; ORsession_id() derivation, or use it to
HMAC-derive CSRF tokens so deploy-time rotation is meaningful.public/index.php lines 220, 245-247 — HSTS only when APP_BASE_URL
starts with https://.src/Auth/SessionGuard.php lines 41-58 — secure cookie flag mirrors
the same flag.APP_BASE_URL is set to an http:// URL, the session
cookie's secure flag is dropped and HSTS is not emitted. A reverse-proxy
misconfig (e.g., terminating TLS at the proxy but not setting
X-Forwarded-Proto) silently degrades to HTTP behaviour.X-Forwarded-Proto when behind a known proxy (config-driven trust
list) and treat the request as HTTPS for cookie / HSTS purposes.public/index.php when APP_BASE_URL
is HTTPS.doc/admin-manual.md.e295432 — new migration 005_auth_throttle.sql
AuthThrottleRepository keyed on (ip_address, email). loginLocal
now checks lockoutFor() BEFORE LocalAdmin::verify() (so a slow
password_verify() can't be turned into an oracle while locked); on
failure it calls recordFailure(), on success it clear()s the bucket.
Policy in the pure-static computeLockout(attempts, now): 1-4 → no
lock, 5-9 → +5 min, 10-19 → +30 min, 20+ → +1 hour. Counter rolls
over after 15 minutes of no failures, so honest mistypes don't get
punished forever. Email key is lowercased + trimmed so case variation
doesn't dodge the bucket; IPs separate, so two attacker IPs don't
share a lock. Throttle hits emit a LOGIN_FAILED audit row with
reason=local_admin_throttled_until_<iso> and the form renders an
amber "Too many failed attempts" notice when redirected with
?throttled=1. New tests/Repositories/AuthThrottleRepositoryTest.php
(13 cases) pins the threshold matrix, the 4:59 / 5:00 lockout
boundary, the idle-window reset, IP / email bucketing, and
purgeExpired()'s selectivity. Tests: 176 / 484 (was 163 / 417).src/Controllers/AuthController.php::loginLocal lines 194-276./auth/local.
Attackers can brute-force the password as fast as the server can respond.
The audit log records LOGIN_FAILED rows (forensics value), but nothing
blocks the attempt.(ip, email) with a short backoff window
after N failures (e.g. 5 in 60 s → 30 s pause; 20 in 10 min → block IP
for 1 h).auth_throttle) so a process restart
doesn't reset it.X-Forwarded-For ignored; audit IP is wrong behind proxiessrc/Http/Request.php::ip() lines 103-107.Request::ip() returns $_SERVER['REMOTE_ADDR'] only. When
deployed behind any reverse proxy / load balancer, every audit row will
record the proxy's IP, not the user's.X-Forwarded-For. Default empty (no trust). Document in admin
manual.src/Auth/SessionGuard.php lines 27-60, 109-116.session.gc_maxlifetime = 28800 (8h) and the session cookie has
lifetime=0 (browser-session). Once authenticated, a user stays signed in
until the browser closes or 8h passes since last GC tick. The CSRF token
is generated once and reused for the lifetime of the session.$_SESSION['login_at'] (already set!) and an idle timestamp; on
each request, if now - last_active > 30 min, force re-auth.login() (after session_regenerate_id) so
pre-login tokens can't be replayed.SameSite=Lax, not Strictsrc/Auth/SessionGuard.php lines 47, 56-58.Lax allows the cookie on top-level cross-site GETs (a click
from a phishing page). The OIDC redirect flow doesn't need cross-site GET
cookies (it's a top-level navigation back to the app's own origin).SameSite=Strict. Validate the OIDC callback
flow still works (the library's state/PKCE check is what actually defends
here; Strict is defence-in-depth).src/Repositories/TaskRepository.php line 60 (MAX(sort_order) FROM
tasks WHERE sprint_id = ' . $sprintId) and line 100 (moveToSprint).src/Repositories/SprintWorkerRepository.php line 56
(MAX(sort_order) FROM sprint_workers WHERE sprint_id = ' . $sprintId).src/Controllers/SprintController.php lines 1029-1033 — uses
placeholders, OK.? placeholders; never
concatenate. Mechanical change, low risk.AuditRepository::distinctColumn interpolates a column name4ae1817 — distinctColumn() now opens with an
in_array($col, ['action', 'entity_type'], true) whitelist; anything
else throws \InvalidArgumentException before the interpolated SQL is
prepared. The phpdoc was also tightened to the literal-string union so
static analysers carry the contract. New
tests/Repositories/AuditRepositoryTest.php (4 cases) covers the happy
paths for both supported columns and two reflection-based regression
guards (rejects an unsupported known column like user_email, rejects
a SQL-injection attempt). Tests: 163 / 417 (was 159 / 413).src/Repositories/AuditRepository.php lines 109-122."SELECT DISTINCT {$col} FROM audit_log ORDER BY {$col} ASC"
interpolates $col. Today the only callers are distinctActions() and
distinctEntityTypes() with literal strings, but the method is private
with no enforced whitelist.if (!in_array($col, ['action','entity_type'], true)) throw … at the top
of distinctColumn. Cheap, ends the foot-gun.from_date/to_date filters not validatedsrc/Repositories/AuditRepository.php lines 149-156.src/Controllers/AuditController.php lines 39-49."{$date}T00:00:00Z" and
bound. Any garbage string just sorts lexically against the ISO-8601
occurred_at column — silently producing empty result sets without an
error. Not an injection (parameters are bound), but a bad UX and a way to
hide events from a casual auditor (e.g., admin types 2024/01/01 instead
of 2024-01-01 and "sees" no rows).Y-m-d server-side; on parse failure, drop
the filter and flash an error. Same DateTimeImmutable::createFromFormat
pattern already used in SprintController::isIsoDate.public/index.php lines 38, 248-254.ob_start() is called at the top, but the flush is in the
happy path. On a fatal error mid-request (uncaught throwable that escapes
dispatch(), OOM, etc.), PHP's output_buffering may flush whatever was
in the buffer to the response — without the security headers added at
lines 240-243.var_dump
could land in the buffer when display_errors=1. In production with
display_errors=0 the risk is small, but the headers (CSP, X-Frame, etc.)
still won't be applied to whatever does land.set_exception_handler and a register_shutdown_function
that discards the buffer (ob_clean()) and emits a minimal 500 page
with the security headers.ob_start() and rely on Response::send() to be the single
output point; use headers_sent() checks before writing anything.$_SESSIONsrc/Controllers/ImportController.php lines 102-115 (write),
244-261 (read). Session storage path is on disk under
/var/www/data/sessions/.fromArray() reconstruction). And on disk these
files are readable by www-data only — but they linger 30 min by design.IMPORT_PREVIEW_ABANDONED audit row when a token
expires unused.target="_blank" task URL anchor uses rel="noopener" onlyd16bff4 — the user-controlled task link in
views/sprints/_task_list.twig now emits rel="noopener noreferrer",
so the Referer header no longer leaks the originating
/sprints/{id} URL when an admin clicks an attacker-set t.url.
Scope intentionally narrow: the /sprints/{id}/present anchor in
views/sprints/show.twig was left as rel="noopener" because it is
same-origin and the Referer leak the finding describes does not
apply.views/sprints/_task_list.twig line 213.rel="noopener" blocks window.opener access, but the
Referer header still leaks the origin URL (/sprints/{id}) to the
external link target.t.url) reveals that
internal sprint exists.rel="noopener noreferrer".composer.json line 12 (^3.4), composer.lock (not read).setReadDataOnly(false) (already correct — needed for fill colour
parsing) but explicitly call LIBXML_NOENT | LIBXML_DTDLOAD to off if
we ever reach to lower-level XML APIs.composer audit in CI.state/PKCE storage in session — verify lifetimesrc/Auth/OidcClient.php + src/Controllers/AuthController.php
callback.jumbojett/openid-connect-php stores its state and PKCE
code_verifier in $_SESSION. SessionGuard::login() calls
session_regenerate_id(true) after the callback completes — but the
state/verifier are written before the redirect to Entra and read after the
bounce back. If two browser tabs initiate auth flows simultaneously, the
second overwrites the first's state. Not a direct vuln but a usability
hazard.src/Controllers/AuthController.php lines 75-77.email = $claims->email ?? $claims->preferred_username. In Entra,
preferred_username is not guaranteed to be a verified email; in some
tenant configurations it can be controlled by the user. We don't check
email_verified either.user_email in
audit_log), a user can spoof their actor email. The entra_oid (or
sub) is the immutable identifier and is correctly used as the lookup
key — so the user identity is safe, but the audit trail's display
isn't.claims.email only when claims.email_verified === true.entra:<oid>.report-uri / report-topublic/index.php lines 227-237.report-uri /csp-report (or report-to)
directive; route it to a thin endpoint that drops the JSON in the audit
log under entity_type='csp_violation'. Cheap; high signal.Response::redirect() accepts arbitrary location stringssrc/Http/Response.php lines 54-57.?next= from user input and redirect off-origin.assert() or runtime check that the value starts with / or matches
APP_BASE_URL.Response::external($url) for the rare cases that
actually need to leave the origin.src/Http/View.php line 30 (autoescape => 'html'); various
views.autoescape: html. Spot-checks confirm no |raw, |safe, or
unsafe attr filters are used (one place uses |json_encode|e('html_attr')
for the data-sprint-choices payload — correct usage).|raw on a
user field opens stored XSS in a place where it's particularly painful
(the audit viewer renders attacker-controlled content).autoescape config in a CI test that loads View and asserts the
flag.grep -rn '|raw' views/ must
return nothing (or only allow-listed lines).public/index.php lines 73-85; src/Db/Migrator.php.pdo->exec($sql) (multi-
statement). On a fresh deploy, the first request after the new code
ships does the migration; if that request crashes mid-migration (timeout,
OOM), partial state may persist. SQLite + the migrator's BEGIN..COMMIT
protects most cases, but ALTER TABLE … ADD COLUMN is a DDL that's
conditionally inside a tx — see SQLite caveats.php bin/migrate.php before starting Apache).users table not deleted; emails not redacted on demoteUserController header comment as
intentional ("users are never deleted; they'd orphan audit rows").src/Controllers/UserController.php lines 16-25.email and display_name in the users table forever; the audit
log's user_email column also retains it. There is no redact / tombstone
flow.users.tombstoned_at column; when set, hide name/email from UI
and replace with (former user). Audit rows keep the old email
verbatim — the audit log is the authoritative trail and erasure laws
typically permit retention for legal/security purposes.doc/admin-manual.md.src/Http/Request.php lines 52-62, 84-101.file_get_contents('php://input') reads whatever PHP's
post_max_size allows (default 8 MB). json_decode with depth=64 is
reasonable, but a 7 MB JSON list with 1M entries will be parsed before
any controller-level cap.Content-Length first; refuse >1 MB with 413./sprints/{id}/week-cells).X-Permitted-Cross-Domain-Policies headerpublic/index.php lines 240-243.X-Permitted-Cross-Domain-Policies: none.
Optional: Permissions-Policy to disable geolocation / camera / etc.home.twig "deleted" flash relies on query string truthinessviews/home.twig lines 11-15; public/index.php line 150;
src/Controllers/SprintController.php::delete line 1114./?deleted=<urlencoded
name>. Anyone can manually craft a URL with ?deleted=foo and see the
green "Sprint foo was deleted" chip — no actual deletion happened. Twig
auto-escape protects against XSS, so this is purely a UX/spoofing nit.$_SESSION['flash_*'],
the import controller already does this), unset on render.Dockerfile line 57; src/Auth/SessionGuard.php lines 33-39.gc_maxlifetime. Not a session-validity issue
(the session contains login_at + cookie expiry, see R01-N08), but a
storage hygiene one.find /var/www/data/sessions -mmin +480 -delete
in the container, or accept it as low-impact.SettingsController::update builds $changed[] but never uses itsrc/Controllers/SettingsController.php lines 95-127.$changed[] is appended to but the variable is never read.
Audit happens on a per-key basis already; the local accumulator is dead.?updated=N instead of always ?updated=1).Migrator::apply reads file via file_get_contents without size capsrc/Db/Migrator.php lines 101-125.import_upload.twig reveals colour-mapping rules to anyone with the linkviews/sprints/import_upload.twig lines 42-52.SessionGuard::requireAdmin) so this is
not exposed publicly. Listed for completeness.home.twig link to /healthz is a tiny info-disclosure7fd849b — folded into the R01-N02 fix; the
<a href="/healthz"> line lives inside the now admin-only Runtime
panel, so anonymous users no longer see it.views/home.twig line 135./healthz. Liveness probes
are normally undocumented to discourage scanning; the route returns
literal "ok" anyway, so the disclosure cost is tiny. Combined with R01-N02
the fix is the same: gate the runtime panel.views/sprints/settings.twig line 181 (data-confirm-name=…).data- attribute so the JS can compare against typed input.
An attacker who can read the page already has the name from the title
bar — but the JS guard is bypassable trivially. The server-side check at
SprintController::delete lines 1001-1004 is the real defence.data-csrf attribute reads CSRF token in HTMLviews/sprints/show.twig line 9, present.twig line 7,
settings.twig line 9.data-csrf attribute so
vanilla JS (fetch calls) can attach it as X-CSRF-Token. This is the
standard pattern; not a leak (the token is per-session and JS-readable
by design).Migrator discovery regex permits unintended filenamessrc/Db/Migrator.php line 91.^(\d{3,})_[A-Za-z0-9_\-]+\.sql$ — accepts e.g.
001_evil_inject.sql if dropped into migrations/. That is, a bad actor
with write access to migrations/ can run arbitrary SQL on next request.
This is an "if attacker has filesystem access, game over" scenario, but
worth noting that the migration runner does no signature / allow-list
check.gh-pr-deploys workflow that lets non-admins land
files in migrations/), reconsider.These are not findings; recorded so we don't re-investigate later:
?
placeholders except the integer-cast cases noted in R01-N10.verifyCsrf (or
the JSON variant). The audit hits 18/18 mutations per Phase 7.html context); no |raw usage in
views/.script-src 'self', style-src 'self', no
unsafe-eval / unsafe-inline); Alpine CSP build, vendored htmx /
Sortable. frame-ancestors 'none'. form-action allows
login.microsoftonline.com (needed for OIDC).X-Frame-Options, X-Content-Type-Options, Referrer-Policy
all set per request.PRAGMA foreign_keys = ON set on every PDO
connection.BEGIN…
COMMIT and rolls back on throw.sprint→tasks→task_assignments, sprint→sprint_workers→
sprint_worker_days, sprint→sprint_workers→task_assignments,
sprint→sprint_weeks→sprint_worker_days, plus Phase 22 tasks(linked_
task_id)→SET NULL) snapshot before the parent delete fires.hash_equals.bin2hex(random_bytes(32)) — 256 bits.HttpOnly, SameSite=Lax, Secure (when behind
HTTPS, see R01-N05). No need to mark non-essential cookies.is_uploaded_file + 5 MB cap. Reasonable.{ok, data|error} per spec §7; consistent across
endpoints.CapacityCalculator::isHalfStep and the
per-cell range checks are tight.eval, no exec, no system: confirmed by grep.A reasonable cadence (do not treat as binding):
7fd849b. Also
closes R01-N31.SESSION_SECRET doc cleanup)296883c.rel="noopener noreferrer")d16bff4.4ae1817.857df15
(hash-only, no plaintext fallback per operator decision).e295432.f565c86.Each fix should ship as its own commit per SPEC.md §14, with a follow-up SPEC update if behaviour or config surface changes.