semgrep's github-actions-mutable-action-tag rule (now running, after the
pip install was fixed) flags actions/checkout@v3 as a mutable tag that
could be repointed upstream (supply-chain risk). Pin all four uses to the
SHA the v3 tag currently resolves to (v3.6.0), preserving behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Trusted-proxy hardening (defense-in-depth for Authelia forward-auth):
- AuthMiddleware now only honors Remote-* identity headers when REMOTE_ADDR
is in a configured TRUSTED_PROXIES allowlist; otherwise it refuses with 403
and logs an 'untrusted_proxy' security event. Previously anything that could
reach PHP directly could spoof Remote-User/Remote-Groups and log in as admin.
- New config TRUSTED_PROXIES (comma-separated, from .env). Empty = enforcement
off, so this is backward compatible until the allowlist is set on a host.
Requirements checks (so a PHP upgrade dropping an extension can't silently
break features like avatars again):
- config/requirements.php: single source of truth for min PHP version and
required extensions (ldap, mysqli, curl, mbstring, fileinfo, json).
- scripts/check_requirements.php: CI script that fails the build if the
environment doesn't satisfy them.
- New 'requirements' CI job installs those extensions and runs the check;
deploy now depends on it.
- api/health.php: adds php_extensions + php_version checks so production
monitoring surfaces the drift (returns 503 if a required extension is gone).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Continued fixes from the multi-agent review:
- recurring tickets cron: now that the parse error is fixed the job runs,
exposing two latent bugs. (1) next_run_at was only advanced after the
full success path, so any failure (e.g. a NULL created_by passed to the
non-nullable assignTicket() $assignedBy -> TypeError) left it in the past
and re-created a duplicate ticket every cron cycle. Added an atomic
claimForRun() (conditional UPDATE gated on still-due) called BEFORE
creation, which also prevents overlapping runs from double-creating.
(2) The cron used a raw mysqli with no utf8mb4, corrupting non-ASCII
content; it now uses Database::getConnection(). Also guard the assignment
so created_by NULL falls back to the assignee.
- bulk delete: attachment files were unlinked inside the DB transaction, so
an atomic-mode rollback restored rows but the files were already gone.
deleteTicket() can now defer file removal to the caller, and
BulkOperationsModel deletes files only after a successful commit.
- UserModel: back-tick the `groups` column (reserved word on MySQL 8.0.2+).
- create_ticket_api.php: stop leaking raw DB/exception messages to callers;
log server-side and return a generic error. (Also includes a pre-existing
working-tree tweak that adds title to the manual-ticket dedupe hash.)
- CI: semgrep install failed under PEP 668; add --break-system-packages.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- tainted-filename: filenames in upload_attachment.php and user_avatar.php
are derived exclusively from (int)-cast integers; no user string reaches
the filesystem path. Semgrep's taint engine tracks all use-sites of the
variable, producing findings on every file_exists/readfile/unlink call.
- tainted-callable: index.php audit-log query passes \$sql to prepare();
\$sql is assembled from hardcoded SQL fragments with ? placeholders and
explicit (int) LIMIT/OFFSET casts. User values are bound via bind_param,
never interpolated. Semgrep cannot see through the WHERE-builder logic.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- index.php: replace SQL string interpolation with concatenation + explicit
(int) casts for LIMIT/OFFSET; add nosemgrep for tainted-sql false positive
(WHERE clause built from hardcoded fragments with bound params only)
- api/upload_attachment.php: add realpath() path-traversal guard after mkdir
- api/user_avatar.php: make (int) cast explicit at cache-path construction;
add nosemgrep for tainted-filename false positive (integer-only input)
- assets/js/ticket.js: add nosemgrep for insertAdjacentHTML — all dynamic
content already escaped via lt.escHtml() before insertion
- .gitea/workflows/security.yml: exclude echoed-request rule globally —
all echo in API context is json_encode() output, not HTML; htmlentities()
fix semgrep suggests would corrupt JSON responses
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- lint.yml: add notify-failure Matrix alert job; add Tag deployed commit
step (main branch only) with deploy-YYYY.MM.DD-N tagging via Gitea API;
add permissions: contents: write to deploy job
- security.yml: new workflow running semgrep with p/php and p/owasp-top-ten
configs on push, PR, and weekly schedule
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a deploy job that runs only when both php-lint and js-lint succeed.
Calls the CT132 webhook directly with HMAC-SHA256 signature from the
WEBHOOK_SECRET repo secret. Disabled the direct push webhooks that
previously deployed on every push regardless of lint status.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>