From 064fd90e2f899de65464cc5ce49e3c7ccd165adf Mon Sep 17 00:00:00 2001
From: level09
Date: Tue, 21 Apr 2026 13:59:33 +0300
Subject: [PATCH 01/51] docs: add Safe Expunging Process policy
Satisfies SLSA v1.2 Source track requirement for a documented
Safe Expunging Process. Covers scope, permitted reasons, two-maintainer
approval, procedure, and consumer notification.
---
SAFE_EXPUNGING.md | 43 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 43 insertions(+)
create mode 100644 SAFE_EXPUNGING.md
diff --git a/SAFE_EXPUNGING.md b/SAFE_EXPUNGING.md
new file mode 100644
index 000000000..cdc2eb497
--- /dev/null
+++ b/SAFE_EXPUNGING.md
@@ -0,0 +1,43 @@
+# Safe Expunging Process
+
+This document describes when and how history-altering operations are permitted on the Bayanat source repository, satisfying the SLSA v1.2 Source track "Safe Expunging Process" requirement.
+
+## Scope
+
+Applies to the public repository `sjacorg/bayanat` and the private release repository `sjacorg/bayanat.prod`, specifically to operations that remove or rewrite committed history on protected references (`main`, release tags matching `v*`).
+
+## Default
+
+History on protected references is append-only. Force-push, branch deletion, tag deletion, and retagging are blocked by repository rulesets.
+
+## Permitted Reasons to Expunge
+
+Expunging may be approved only for one of the following reasons:
+
+1. **Secret leak.** An unredacted credential, private key, or access token was committed.
+2. **Personal data leak.** Non-public personal data of an identifiable individual was committed.
+3. **Legal or safety order.** A verified order from counsel or a credible safety concern requires removal of specific content.
+4. **Malicious injection.** Attacker-introduced code or data must be removed as part of incident response.
+
+Bug fixes, style corrections, and cleanup are never valid reasons.
+
+## Approval
+
+Both maintainers must approve in writing, recorded in the security advisory created for the incident.
+
+## Procedure
+
+1. File a private security advisory at https://github.com/sjacorg/bayanat/security/advisories with the reason, affected commits, and proposed action.
+2. Record both maintainer approvals in the advisory.
+3. If the reason involves a secret, rotate it before rewriting.
+4. Rewrite with `git filter-repo` (not `filter-branch`), preserving commit signatures where possible.
+5. Temporarily bypass branch protection, force-update the protected reference, then re-enable protection.
+6. Invalidate and regenerate any affected release tags. Old tags are not reused.
+
+## Consumer Notification
+
+After any expunging action, publish a public security advisory that includes:
+
+- What was removed and why (redacted as needed).
+- New commit hashes and release tags that replace the expunged revisions.
+- Operator guidance (re-clone, re-verify signatures, check deployed commit against the new history).
From 14355e5c03ddad4fa8d8f304116754e8373c1954 Mon Sep 17 00:00:00 2001
From: level09
Date: Tue, 21 Apr 2026 15:31:19 +0300
Subject: [PATCH 02/51] fix(docker): switch nginx base to bitnamilegacy/nginx
Bitnami removed bitnami/nginx:1.24 from Docker Hub in August 2025,
breaking compose builds. Point to the bitnamilegacy mirror that
Bitnami publishes for deprecated tags.
---
nginx/Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nginx/Dockerfile b/nginx/Dockerfile
index 7b602665e..afaea6254 100644
--- a/nginx/Dockerfile
+++ b/nginx/Dockerfile
@@ -1,4 +1,4 @@
-FROM bitnami/nginx:1.24 as base
+FROM bitnamilegacy/nginx:1.24 as base
VOLUME /opt/bitnami/nginx/conf
COPY --chown=1001 nginx.conf /opt/bitnami/nginx/conf/
From 3c754fdb5c0883dda50e1c293365e01843b02018 Mon Sep 17 00:00:00 2001
From: level09
Date: Tue, 21 Apr 2026 15:31:37 +0300
Subject: [PATCH 03/51] fix(docker): install runtime deps for celery-ocr role
The celery-ocr service defined in docker-compose.yml builds with
ROLE=celery-ocr, but flask/Dockerfile only handled 'flask' and
'celery' branches, so the image had no Python runtime and the
container exited 127 with 'celery: not found'. Share the celery
install path with celery-ocr, which needs the same deps.
---
flask/Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/flask/Dockerfile b/flask/Dockerfile
index 9c11a7995..ffa0c02ce 100644
--- a/flask/Dockerfile
+++ b/flask/Dockerfile
@@ -36,7 +36,7 @@ RUN if [ "$ROLE" = "flask" ]; then \
apt-get update -y && apt-get install -yq python3.12 python3.12-dev python3.12-venv \
postgis libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0 libffi-dev \
libjpeg-dev libopenjp2-7-dev; \
- elif [ "$ROLE" = "celery" ]; then \
+ elif [ "$ROLE" = "celery" ] || [ "$ROLE" = "celery-ocr" ]; then \
apt-get update -y && apt-get install -yq python3.12 python3.12-dev python3.12-venv \
postgis libimage-exiftool-perl ffmpeg libpango-1.0-0 libharfbuzz0b \
libpangoft2-1.0-0 libffi-dev libjpeg-dev libopenjp2-7-dev; \
From 2f9e39591f9f3e1905613ed2645acb7edded2c9e Mon Sep 17 00:00:00 2001
From: level09
Date: Tue, 21 Apr 2026 15:32:09 +0300
Subject: [PATCH 04/51] fix(docker): default ENV_FILE to .env.docker
The compose file defaulted ENV_FILE to .env, which is the local
development file where POSTGRES_HOST=localhost. That mount made
the bayanat container try to reach postgres via a local socket
instead of the compose service. .env.docker already ships with
the correct service hostnames (postgres, redis) and is the
intended default for this compose file.
---
docker-compose.yml | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/docker-compose.yml b/docker-compose.yml
index 3d7cf788e..437cd5f09 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -44,14 +44,14 @@ services:
dockerfile: ./flask/Dockerfile
args:
- ROLE=flask
- - ENV_FILE=${ENV_FILE:-.env}
+ - ENV_FILE=${ENV_FILE:-.env.docker}
volumes:
- '${PWD}/backups:/app/backups/:rw'
- '${MEDIA_PATH:-./enferno/media}:/app/enferno/media/:rw'
- '${PWD}/enferno/imports:/app/enferno/imports/:rw'
- '${PWD}/logs/:/app/logs/:rw'
- '${PWD}/config.json:/app/config.json:rw'
- - '${PWD}/${ENV_FILE:-.env}:/app/.env:ro'
+ - '${PWD}/${ENV_FILE:-.env.docker}:/app/.env:ro'
depends_on:
postgres:
condition: service_healthy
@@ -72,7 +72,7 @@ services:
dockerfile: ./flask/Dockerfile
args:
- ROLE=celery
- - ENV_FILE=${ENV_FILE:-.env}
+ - ENV_FILE=${ENV_FILE:-.env.docker}
volumes_from:
- bayanat
read_only: true
@@ -93,7 +93,7 @@ services:
dockerfile: ./flask/Dockerfile
args:
- ROLE=celery-ocr
- - ENV_FILE=${ENV_FILE:-.env}
+ - ENV_FILE=${ENV_FILE:-.env.docker}
volumes_from:
- bayanat
read_only: true
From 3aeaff1535a5826f001ef04ef2a8bf95f200f557 Mon Sep 17 00:00:00 2001
From: level09
Date: Wed, 22 Apr 2026 00:04:01 +0300
Subject: [PATCH 05/51] fix(docker): exclude .git, node_modules, caches from
build context
---
.dockerignore | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/.dockerignore b/.dockerignore
index 609a1b746..384ba4b99 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,4 +1,16 @@
+.git
+node_modules
+.venv
env
+__pycache__
+*.pyc
+*.pyo
enferno/media
enferno/imports
logs
+backups
+.env
+.env.*
+.DS_Store
+*.md
+docs/node_modules
From cd51dac02b80f4795771360d83e63c19955b7f31 Mon Sep 17 00:00:00 2001
From: level09
Date: Wed, 22 Apr 2026 00:04:06 +0300
Subject: [PATCH 06/51] fix(docker): stamp head on fresh DB instead of running
migrations
flask create-db builds the full schema from models, so running
db upgrade after it conflicts on indexes the latest migrations
try to create. Detect fresh vs existing DB via flask db current.
---
flask/bin/entrypoint.sh | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/flask/bin/entrypoint.sh b/flask/bin/entrypoint.sh
index ce36533a0..08d304c8a 100644
--- a/flask/bin/entrypoint.sh
+++ b/flask/bin/entrypoint.sh
@@ -2,10 +2,14 @@
set -e
if [ "$ROLE" = "flask" ]; then
- echo ":: Creating Bayanat Database ::"
- flask create-db --create-exts
- echo ":: Running migrations ::"
- flask db upgrade
+ if [ -z "$(flask db current 2>/dev/null | grep -oE '[0-9a-f]{12}')" ]; then
+ echo ":: Fresh DB, creating schema ::"
+ flask create-db --create-exts
+ flask db stamp head
+ else
+ echo ":: Existing DB, running migrations ::"
+ flask db upgrade
+ fi
echo ":: Starting Bayanat ::"
exec uwsgi --http 0.0.0.0:5000 --protocol uwsgi --master --processes 1 --wsgi run:app
From b136add78dafdee5c1f29c14e6d9b9d7f926a5a6 Mon Sep 17 00:00:00 2001
From: level09
Date: Wed, 22 Apr 2026 00:48:36 +0300
Subject: [PATCH 07/51] refactor(docker): slim-bookworm base with dedicated uv
builder stage
Swap ubuntu:24.04 for python:3.12-slim-bookworm in the runtime stage
and use the official ghcr.io/astral-sh/uv image as the builder. Keeps
the ROLE-based conditional install for celery-only deps (exiftool,
ffmpeg) and drops incidental ubuntu bulk that is not actually used by
the app.
- builder stage installs only build headers; runtime stage installs
only shared libraries (libpq5, pango, cairo, harfbuzz, libxml2,
libxslt, libjpeg62-turbo, libopenjp2, libffi8, dejavu fonts)
- libimage-exiftool-perl is kept in the builder because pyexifinfo's
setup.py probes for the exiftool binary during wheel install
- XDG_CACHE_HOME=/tmp/.cache silences fontconfig cache warnings during
weasyprint PDF generation
Verified end-to-end against a fresh compose stack: weasyprint PDF gen,
psycopg2, pillow, lxml, exiftool, ffmpeg, nginx->uwsgi->Flask login
all work. Image sizes drop ~160MB (bayanat) and ~210MB (celery).
---
flask/Dockerfile | 99 +++++++++++++++++++++++++++---------------------
1 file changed, 55 insertions(+), 44 deletions(-)
diff --git a/flask/Dockerfile b/flask/Dockerfile
index ffa0c02ce..a0e0857a9 100644
--- a/flask/Dockerfile
+++ b/flask/Dockerfile
@@ -1,68 +1,79 @@
-# ---- use a base image to compile requirements / save image size -----
-FROM ubuntu:24.04 as base
-ENV DEBIAN_FRONTEND=noninteractive
+# ---- builder stage: compile python deps with uv -----------------
+FROM ghcr.io/astral-sh/uv:0.5.11-python3.12-bookworm-slim AS builder
-RUN apt-get update -y && \
- apt-get install -yq python3.12 python3.12-dev python3.12-venv python3-pip curl \
- libjpeg8-dev libzip-dev libxml2-dev libssl-dev libffi-dev libxslt1-dev \
- libmysqlclient-dev libncurses5-dev libpq-dev \
- libimage-exiftool-perl
+ENV UV_COMPILE_BYTECODE=1 \
+ UV_LINK_MODE=copy \
+ UV_PYTHON_DOWNLOADS=0 \
+ UV_NO_DEV=1 \
+ DEBIAN_FRONTEND=noninteractive
WORKDIR /app
-# Sets utf-8 encoding for Python
-ENV LANG=C.UTF-8
-# Turns off writing .pyc files
-ENV PYTHONDONTWRITEBYTECODE=1
-# Seems to speed things up
-ENV PYTHONUNBUFFERED=1
-# Install UV
-RUN curl -LsSf https://astral.sh/uv/install.sh | sh
-ENV PATH="/root/.local/bin:$PATH"
+# Build-time headers only; runtime libs installed in the final stage.
+# libimage-exiftool-perl is needed at build time because pyexifinfo's
+# setup.py probes for the exiftool binary during wheel install.
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ libpq-dev \
+ libffi-dev \
+ libxml2-dev \
+ libxslt1-dev \
+ libjpeg-dev \
+ zlib1g-dev \
+ libopenjp2-7-dev \
+ libimage-exiftool-perl \
+ && rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock /app/
-
RUN uv sync --frozen --no-install-project
-# ----------------- main container -------------------------
+# ---- runtime stage -----------------------------------------------
+FROM python:3.12-slim-bookworm AS runtime
-FROM ubuntu:24.04
+ENV DEBIAN_FRONTEND=noninteractive \
+ LANG=C.UTF-8 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PATH="/app/.venv/bin:$PATH" \
+ XDG_CACHE_HOME=/tmp/.cache
-ENV DEBIAN_FRONTEND=noninteractive
ARG ROLE
ENV ROLE=${ROLE}
-RUN echo "Building ${ROLE} container."
-RUN if [ "$ROLE" = "flask" ]; then \
- apt-get update -y && apt-get install -yq python3.12 python3.12-dev python3.12-venv \
- postgis libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0 libffi-dev \
- libjpeg-dev libopenjp2-7-dev; \
- elif [ "$ROLE" = "celery" ] || [ "$ROLE" = "celery-ocr" ]; then \
- apt-get update -y && apt-get install -yq python3.12 python3.12-dev python3.12-venv \
- postgis libimage-exiftool-perl ffmpeg libpango-1.0-0 libharfbuzz0b \
- libpangoft2-1.0-0 libffi-dev libjpeg-dev libopenjp2-7-dev; \
- fi
-RUN apt clean
-RUN apt autoremove
+
+# Shared runtime libs: psycopg2 (libpq5), weasyprint (pango/cairo/harfbuzz),
+# pillow (libjpeg, libopenjp2), lxml (libxml2, libxslt).
+# Celery roles also need exiftool + ffmpeg for media processing.
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libpq5 \
+ libpango-1.0-0 \
+ libpangoft2-1.0-0 \
+ libharfbuzz0b \
+ libcairo2 \
+ libxml2 \
+ libxslt1.1 \
+ libjpeg62-turbo \
+ libopenjp2-7 \
+ zlib1g \
+ libffi8 \
+ fonts-dejavu-core \
+ && if [ "$ROLE" = "celery" ] || [ "$ROLE" = "celery-ocr" ]; then \
+ apt-get install -y --no-install-recommends \
+ libimage-exiftool-perl \
+ ffmpeg; \
+ fi \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/*
WORKDIR /app
-# Sets utf-8 encoding for Python
-ENV LANG=C.UTF-8
-# Turns off writing .pyc files
-ENV PYTHONDONTWRITEBYTECODE=1
-# Seems to speed things up
-ENV PYTHONUNBUFFERED=1
+RUN useradd --system --create-home --uid 1000 ubuntu
COPY --chown=ubuntu:ubuntu . /app
-# copy UV-built virtualenv
-COPY --from=base /app/.venv /app/.venv
+COPY --from=builder --chown=ubuntu:ubuntu /app/.venv /app/.venv
COPY --chown=ubuntu:ubuntu ./flask/bin/entrypoint.sh /usr/local/bin/entrypoint.sh
-
RUN chmod 550 /usr/local/bin/entrypoint.sh
-ENV PATH="/app/.venv/bin:$PATH"
-
USER ubuntu
CMD ["/usr/local/bin/entrypoint.sh"]
From f6cd5f4334b2f545dda29606229c86a7685169a9 Mon Sep 17 00:00:00 2001
From: level09
Date: Wed, 22 Apr 2026 00:53:35 +0300
Subject: [PATCH 08/51] fix(docker): use TCP probe for nginx healthcheck
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The previous `service nginx status` check failed on the bitnami nginx
image, which has no sysvinit, so the container always reported
unhealthy even when nginx was serving correctly. Switch to a bash TCP
probe against port 80 — no external tools required (curl and wget are
not present in the image).
---
docker-compose.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/docker-compose.yml b/docker-compose.yml
index 437cd5f09..35eaf8a24 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -127,9 +127,9 @@ services:
- /opt/bitnami/nginx/logs/
- /opt/bitnami/nginx/conf/bitnami/certs/
healthcheck:
- test: [ "CMD", "service", "nginx", "status" ]
- interval: 3s
- retries: 10
+ test: [ "CMD", "bash", "-c", "exec 3<>/dev/tcp/localhost/80" ]
+ interval: 10s
+ retries: 5
volumes:
redis_data:
From 244ee1dc1d398755c44bf444f412b3591d80ec17 Mon Sep 17 00:00:00 2001
From: Nidal Alhariri
Date: Fri, 24 Apr 2026 00:14:29 +0300
Subject: [PATCH 09/51] v4.0.1: fix bulk OCR celery queue routing (#323)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
- Installer's systemd unit for `bayanat-celery` only subscribed to the
default `celery` queue, but `ocr_single` tasks route to a dedicated
`ocr` queue (`enferno/tasks/__init__.py`).
- Any bulk OCR dispatched via the admin UI (`POST /admin/api/ocr/bulk`)
or the `flask ocr process` CLI piled up in Redis with no consumer.
- Single-media OCR (per-item UI button / `flask ocr extract`) was
unaffected — it takes a sync path through
`process_media_extraction_task()`.
## Fix
Add `-Q celery,ocr` to the `ExecStart` line so the worker consumes both
queues.
## Affected versions
- v4.0.0 (GA install command ships the broken unit).
## Upgrade path
### Fresh install
Nothing special — new installs get the fixed unit.
### Existing v4.0.0 installs (in-place patch, no reinstall needed)
```
sudo sed -i.bak 's|worker --autoscale 2,5 -B$|& -Q celery,ocr|' /etc/systemd/system/bayanat-celery.service
sudo systemctl daemon-reload
sudo systemctl restart bayanat-celery
```
Verify with `sudo journalctl -u bayanat-celery --since "30 seconds ago"
| grep queues` — should list both `celery` and `ocr`.
## Test plan
- [x] Reproduced on auto-update-simplified deployment: 6 tasks stuck in
`ocr` queue with 0 processed, worker idle.
- [x] Applied the same fix to prod2 manually: queue drains, `ocr_single`
tasks processed end-to-end via Google Vision API.
- [x] All 4 test media processed successfully (confidence 24-98%, zero
failures).
- [ ] Tag `v4.0.1` after merge and update docs installer URL to pin
v4.0.1.
---
CHANGELOG.md | 6 ++++++
bayanat | 2 +-
pyproject.toml | 2 +-
3 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bcd75c710..e478eafd0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## v4.0.1
+
+### Fixed
+
+- Bulk OCR: celery worker now consumes the `ocr` queue. The systemd unit written by the installer was only subscribing to the default `celery` queue, so tasks dispatched by bulk OCR (UI and `flask ocr process`) silently piled up in Redis. Single-media OCR was not affected. Existing installs can fix in place by adding `-Q celery,ocr` to `ExecStart` in `/etc/systemd/system/bayanat-celery.service`, then `systemctl daemon-reload && systemctl restart bayanat-celery`.
+
## v4.0.0
### Database Migrations (Alembic)
diff --git a/bayanat b/bayanat
index 378e143df..a89ac1d9f 100755
--- a/bayanat
+++ b/bayanat
@@ -305,7 +305,7 @@ User=$APP_USER
Group=$APP_USER
WorkingDirectory=$CURRENT_LINK
EnvironmentFile=$SHARED_DIR/.env
-ExecStart=$CURRENT_LINK/.venv/bin/celery -A enferno.tasks worker --autoscale 2,5 -B
+ExecStart=$CURRENT_LINK/.venv/bin/celery -A enferno.tasks worker --autoscale 2,5 -B -Q celery,ocr
Restart=always
RestartSec=3
diff --git a/pyproject.toml b/pyproject.toml
index c04fca4bc..688f7d3cb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "bayanat"
-version = "4.0.0"
+version = "4.0.1"
description = "Open source data management solution for processing human rights violations and war crimes data"
readme = "README.md"
license = "AGPL-3.0-or-later"
From 80f56b9bc2def872400bbab2744613e1cd81d25b Mon Sep 17 00:00:00 2001
From: level09
Date: Fri, 1 May 2026 13:43:43 +0300
Subject: [PATCH 10/51] fix(BAY-01-001): re-check object access in revision
history endpoints
Bulletin/actor/incident history routes only checked the global
view_*_history permission, never the per-item access of the parent
record. A user with history-view permission but no group access to a
restricted item could read its full revision payload via the history
API.
Resolve the parent entity and gate on current_user.can_access(parent)
before the history query. Log denied attempts to Activity. Location
history is unaffected (Location has no role-based access).
---
enferno/admin/views/history.py | 45 +++++++++++++++++++++++++++++++++-
1 file changed, 44 insertions(+), 1 deletion(-)
diff --git a/enferno/admin/views/history.py b/enferno/admin/views/history.py
index 99c19322b..9d95cd4c1 100644
--- a/enferno/admin/views/history.py
+++ b/enferno/admin/views/history.py
@@ -1,13 +1,38 @@
from __future__ import annotations
from flask import Response
+from flask_security.decorators import current_user
from sqlalchemy import desc
-from enferno.admin.models import BulletinHistory, ActorHistory, IncidentHistory, LocationHistory
+from enferno.admin.models import (
+ Activity,
+ Actor,
+ ActorHistory,
+ Bulletin,
+ BulletinHistory,
+ Incident,
+ IncidentHistory,
+ LocationHistory,
+)
+from enferno.extensions import db
from enferno.utils.http_response import HTTPResponse
import enferno.utils.typing as t
from . import admin, require_view_history
+
+def _deny_history(parent_label: str, parent_id: int) -> Response:
+ """Log a denied history view and return a forbidden response."""
+ Activity.create(
+ current_user,
+ Activity.ACTION_VIEW,
+ Activity.STATUS_DENIED,
+ {"id": parent_id},
+ parent_label,
+ details=f"Unauthorized attempt to view history of restricted {parent_label} {parent_id}.",
+ )
+ return HTTPResponse.forbidden("Restricted Access")
+
+
# Bulletin History Helpers
@@ -23,6 +48,12 @@ def api_bulletinhistory(bulletinid: t.id) -> Response:
Returns:
- json feed of item's history / error.
"""
+ bulletin = db.session.get(Bulletin, bulletinid)
+ if not bulletin:
+ return HTTPResponse.not_found("Bulletin not found")
+ if not current_user.can_access(bulletin):
+ return _deny_history("bulletin", bulletinid)
+
result = (
BulletinHistory.query.filter_by(bulletin_id=bulletinid)
.order_by(desc(BulletinHistory.created_at))
@@ -49,6 +80,12 @@ def api_actorhistory(actorid: t.id) -> Response:
Returns:
- json feed of item's history / error.
"""
+ actor = db.session.get(Actor, actorid)
+ if not actor:
+ return HTTPResponse.not_found("Actor not found")
+ if not current_user.can_access(actor):
+ return _deny_history("actor", actorid)
+
result = (
ActorHistory.query.filter_by(actor_id=actorid).order_by(desc(ActorHistory.created_at)).all()
)
@@ -72,6 +109,12 @@ def api_incidenthistory(incidentid: t.id) -> Response:
Returns:
- json feed of item's history / error.
"""
+ incident = db.session.get(Incident, incidentid)
+ if not incident:
+ return HTTPResponse.not_found("Incident not found")
+ if not current_user.can_access(incident):
+ return _deny_history("incident", incidentid)
+
result = (
IncidentHistory.query.filter_by(incident_id=incidentid)
.order_by(desc(IncidentHistory.created_at))
From d0f4581dabd6078d62b2ca7a5e4b96698ec3a796 Mon Sep 17 00:00:00 2001
From: level09
Date: Fri, 1 May 2026 13:44:58 +0300
Subject: [PATCH 11/51] fix(BAY-01-002): enforce object-level access on
extraction PUT
The PUT /api/extraction/ endpoint resolved the Extraction row
directly by ID without re-checking the parent Media's group
membership, mirroring the GET sibling. Any DA/Admin could overwrite
text/status on extractions in groups they don't belong to, and the
success response leaked the full text+history payload back.
Resolve the parent Media, gate on current_user.can_access(media),
and trim the success response to to_compact_dict so even authorised
calls don't echo the full text/history block.
---
enferno/admin/views/media.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/enferno/admin/views/media.py b/enferno/admin/views/media.py
index 22c5554ba..3b4e5a3c6 100644
--- a/enferno/admin/views/media.py
+++ b/enferno/admin/views/media.py
@@ -772,6 +772,12 @@ def api_extraction_update(extraction_id: int):
if not extraction:
return HTTPResponse.not_found("Extraction not found")
+ media = Media.query.get(extraction.media_id)
+ if not media:
+ return HTTPResponse.not_found("Parent media not found")
+ if not current_user.can_access(media):
+ return HTTPResponse.forbidden("Restricted Access")
+
data = request.json or {}
action = data.get("action")
@@ -820,7 +826,7 @@ def api_extraction_update(extraction_id: int):
details=detail_map.get(action),
)
- return jsonify(extraction.to_dict())
+ return jsonify(extraction.to_compact_dict())
@admin.put("/api/media//orientation")
From 4b66981d5afc9ffcbbeb7589b8815bac41535d00 Mon Sep 17 00:00:00 2001
From: level09
Date: Fri, 1 May 2026 13:45:57 +0300
Subject: [PATCH 12/51] fix(BAY-01-004): contain CSV/XLS analyze paths inside
IMPORT_DIR
api_csv_analyze, api_xls_sheet and api_xls_analyze concatenated the
caller-supplied filename onto IMPORT_DIR with no containment check, so
'../../../../app/.env' resolved outside the import directory and the
resulting file content was returned as parsed CSV. With Admin
credentials this leaked SECRET_KEY, SECURITY_TOTP_SECRETS and
SECURITY_PASSWORD_SALT.
Add _resolve_import_path() that joins via werkzeug.safe_join, resolves
symlinks, and asserts the candidate is inside IMPORT_DIR. Reject with
400 on traversal or missing filename, with a warning log.
---
enferno/data_import/views.py | 39 ++++++++++++++++++++++++++++++------
1 file changed, 33 insertions(+), 6 deletions(-)
diff --git a/enferno/data_import/views.py b/enferno/data_import/views.py
index d1f3eaf78..3d25046e8 100644
--- a/enferno/data_import/views.py
+++ b/enferno/data_import/views.py
@@ -249,15 +249,38 @@ def api_local_csv_delete() -> str:
return ""
+def _resolve_import_path(filename: Optional[str]) -> Optional[str]:
+ """
+ Resolve a user-supplied filename to a path inside IMPORT_DIR.
+
+ Returns the resolved POSIX path string, or None if the filename is
+ missing or escapes the import directory (traversal attempt).
+ """
+ if not filename:
+ return None
+ import_dir = Path(current_app.config.get("IMPORT_DIR")).resolve()
+ joined = safe_join(str(import_dir), filename)
+ if joined is None:
+ return None
+ candidate = Path(joined).resolve()
+ try:
+ candidate.relative_to(import_dir)
+ except ValueError:
+ return None
+ return candidate.as_posix()
+
+
@imports.post("/api/csv/analyze")
@roles_required("Admin")
def api_csv_analyze() -> Response:
"""API endpoint to analyze a csv file."""
# locate file
filename = request.json.get("file").get("filename")
- import_dir = Path(current_app.config.get("IMPORT_DIR"))
+ filepath = _resolve_import_path(filename)
+ if filepath is None:
+ logger.warning("Rejected CSV analyze for invalid path: %r", filename)
+ return HTTPResponse.error("Invalid file path", status=400)
- filepath = (import_dir / filename).as_posix()
result = SheetImport.parse_csv(filepath)
if result:
@@ -272,9 +295,11 @@ def api_csv_analyze() -> Response:
def api_xls_sheet() -> Response:
"""API endpoint to get sheets from an excel file."""
filename = request.json.get("file").get("filename")
- import_dir = Path(current_app.config.get("IMPORT_DIR"))
+ filepath = _resolve_import_path(filename)
+ if filepath is None:
+ logger.warning("Rejected XLS sheets for invalid path: %r", filename)
+ return HTTPResponse.error("Invalid file path", status=400)
- filepath = (import_dir / filename).as_posix()
sheets = SheetImport.get_sheets(filepath)
return HTTPResponse.success(data=sheets)
@@ -286,9 +311,11 @@ def api_xls_analyze() -> Response:
"""API endpoint to analyze an excel file."""
# locate file
filename = request.json.get("file").get("filename")
- import_dir = Path(current_app.config.get("IMPORT_DIR"))
+ filepath = _resolve_import_path(filename)
+ if filepath is None:
+ logger.warning("Rejected XLS analyze for invalid path: %r", filename)
+ return HTTPResponse.error("Invalid file path", status=400)
- filepath = (import_dir / filename).as_posix()
sheet = request.json.get("sheet")
result = SheetImport.parse_excel(filepath, sheet)
From 6b69734563dc19d46aca8e9f2eb1d23bb289d955 Mon Sep 17 00:00:00 2001
From: level09
Date: Fri, 1 May 2026 13:48:58 +0300
Subject: [PATCH 13/51] fix(BAY-01-007): rate-limit failed logins by username
and IP
The /login endpoint had no server-side throttle: Flask-Limiter was
only attached to /csrf, the session-scoped failure counter is reset
by starting a new session, and reCAPTCHA is off by default. An
attacker could issue an unbounded number of POSTs against /login.
Add a Redis-backed throttle keyed independently on (username) and
(ip) with a 15-minute sliding window: 10 failures per username,
30 per IP. Throttle check runs in before_app_request for POST /login
and returns 429 once either ceiling is hit. Counters increment in
after_app_request on failed login and clear on success. Failed
attempts are logged via the regular logger; reCAPTCHA stays as an
optional secondary friction layer.
---
enferno/user/views.py | 36 +++++++++++++++++++++--
enferno/utils/rate_limit_utils.py | 47 +++++++++++++++++++++++++++++++
2 files changed, 80 insertions(+), 3 deletions(-)
diff --git a/enferno/user/views.py b/enferno/user/views.py
index 428c41128..186cb2771 100644
--- a/enferno/user/views.py
+++ b/enferno/user/views.py
@@ -11,6 +11,7 @@
from sqlalchemy.orm.attributes import flag_modified
from enferno.admin.constants import Constants
+from enferno.extensions import rds
from enferno.settings import Config
from enferno.user.forms import ExtendedLoginForm, ExtendedChangePasswordForm
from enferno.user.models import User, Session
@@ -19,6 +20,15 @@
from flask_login import user_logged_out
from enferno.utils.http_response import HTTPResponse
+from enferno.utils.logging_utils import get_logger
+from enferno.utils.rate_limit_utils import (
+ clear_login_failures,
+ get_real_ip,
+ is_login_throttled,
+ record_login_failure,
+)
+
+logger = get_logger()
bp_user = Blueprint("users", __name__, static_folder="../static")
@@ -29,9 +39,10 @@ def build_oauth_client():
@bp_user.before_app_request
-def before_request() -> None:
+def before_request() -> Optional[Response]:
"""
- Attach user object to global context, display custom captcha form after certain failed attempts
+ Attach user object to global context, display custom captcha form after certain
+ failed attempts, and reject login POSTs once throttle limits have been crossed.
"""
g.user = current_user
@@ -40,16 +51,35 @@ def before_request() -> None:
else:
current_app.extensions["security"].login_form = LoginForm
+ if request.method == "POST" and request.path == "/login":
+ ip = get_real_ip()
+ username = (request.form.get("username") or "").strip()
+ if is_login_throttled(rds, username, ip):
+ logger.warning("Login throttled username=%r ip=%s", username, ip)
+ return HTTPResponse.error(
+ "Too many failed login attempts. Please try again later.",
+ status=429,
+ )
+ return None
+
@bp_user.after_app_request
def after_app_request(response) -> Response:
"""
- Record failed login attempts into the session
+ Record failed login attempts into the session and into the Redis-backed
+ login throttle. Clear per-username counters on successful authentication.
"""
if request.path == "/login" and request.method == "POST":
+ ip = get_real_ip()
+ username = (request.form.get("username") or "").strip()
# failed login
if not g.identity.id:
session["failed"] = session.get("failed", 0) + 1
+ record_login_failure(rds, username, ip)
+ logger.warning("Failed login attempt username=%r ip=%s", username, ip)
+ else:
+ if current_user.is_authenticated:
+ clear_login_failures(rds, current_user.username)
return response
diff --git a/enferno/utils/rate_limit_utils.py b/enferno/utils/rate_limit_utils.py
index 3495262b9..33643e4dd 100644
--- a/enferno/utils/rate_limit_utils.py
+++ b/enferno/utils/rate_limit_utils.py
@@ -64,3 +64,50 @@ def ratelimit_handler(e):
"error": "Too Many Requests",
"message": str(e.description),
}, 429
+
+
+# Login throttle
+# Counters are kept in Redis with a 15-minute sliding TTL. We key by IP and
+# by username independently so a single attacker cannot pivot across either.
+LOGIN_FAIL_WINDOW_SEC = 900
+LOGIN_FAIL_MAX_PER_USERNAME = 10
+LOGIN_FAIL_MAX_PER_IP = 30
+
+
+def _ip_key(ip: str) -> str:
+ return f"loginfail:ip:{ip}"
+
+
+def _user_key(username: str) -> str:
+ return f"loginfail:user:{username.lower().strip()}"
+
+
+def is_login_throttled(rds, username: str, ip: str) -> bool:
+ """Return True if either the IP or username has exceeded its window quota."""
+ if ip:
+ ip_count = rds.get(_ip_key(ip))
+ if ip_count and int(ip_count) >= LOGIN_FAIL_MAX_PER_IP:
+ return True
+ if username:
+ user_count = rds.get(_user_key(username))
+ if user_count and int(user_count) >= LOGIN_FAIL_MAX_PER_USERNAME:
+ return True
+ return False
+
+
+def record_login_failure(rds, username: str, ip: str) -> None:
+ """Increment failure counters for the given username and IP, refreshing TTL."""
+ if ip:
+ k = _ip_key(ip)
+ rds.incr(k)
+ rds.expire(k, LOGIN_FAIL_WINDOW_SEC)
+ if username:
+ k = _user_key(username)
+ rds.incr(k)
+ rds.expire(k, LOGIN_FAIL_WINDOW_SEC)
+
+
+def clear_login_failures(rds, username: str) -> None:
+ """Drop the per-username counter on a successful login."""
+ if username:
+ rds.delete(_user_key(username))
From 9800fbed642a5242f5b9bbb63a80915848a8154c Mon Sep 17 00:00:00 2001
From: level09
Date: Fri, 1 May 2026 13:50:54 +0300
Subject: [PATCH 14/51] fix(BAY-01-008): sanitize imported rich-text fields
Interactive CRUD runs Bulletin.description / ActorProfile.description
through SanitizedField, but the import helpers wrote raw, untrusted
strings straight to those columns. Both fields are rendered with
v-html in BulletinCard and ActorProfiles, so an attacker-controlled
CSV cell or external metadata payload could carry stored XSS that
fires when an analyst opens the record.
Wrap the import-side writes in sanitize_string() so the same bleach
allowlist used by SanitizedField applies before persistence.
Covers sheet_import.set_description (actor profile), and the three
bulletin.description sinks in media_import (update_description,
video info description, text_content).
---
enferno/data_import/utils/media_import.py | 6 ++++--
enferno/data_import/utils/sheet_import.py | 3 ++-
2 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/enferno/data_import/utils/media_import.py b/enferno/data_import/utils/media_import.py
index 4a1754817..4c3915725 100644
--- a/enferno/data_import/utils/media_import.py
+++ b/enferno/data_import/utils/media_import.py
@@ -9,6 +9,7 @@
from enferno.admin.models import Media, Bulletin, Source, Label, Location, Activity
from enferno.data_import.models import DataImport
from enferno.user.models import User, Role
+from enferno.utils.validation_utils import sanitize_string
from enferno.utils.data_helpers import get_file_hash, media_check_duplicates
from enferno.utils.date_helper import DateHelper
import arrow, shutil
@@ -568,6 +569,7 @@ def create_bulletin(self, info: dict) -> None:
db.session.add(bulletin)
def update_description(description):
+ description = sanitize_string(description or "")
if bulletin.description:
bulletin.description += f" {description}"
else:
@@ -665,12 +667,12 @@ def update_description(description):
bulletin.publish_date = upload_date
if description := info.get("description"):
- bulletin.description = description
+ bulletin.description = sanitize_string(description)
else:
bulletin.source_link = info.get("old_path")
if info.get("text_content"):
- bulletin.description = info.get("text_content")
+ bulletin.description = sanitize_string(info.get("text_content"))
if info.get("transcription"):
update_description(info.get("transcription"))
diff --git a/enferno/data_import/utils/sheet_import.py b/enferno/data_import/utils/sheet_import.py
index 10e994af3..357e16708 100644
--- a/enferno/data_import/utils/sheet_import.py
+++ b/enferno/data_import/utils/sheet_import.py
@@ -27,6 +27,7 @@
from enferno.utils.base import DatabaseException
from enferno.utils.date_helper import DateHelper
+from enferno.utils.validation_utils import sanitize_string
from enferno.user.models import Role, User
import enferno.utils.typing as t
@@ -554,7 +555,7 @@ def set_description(self, map_item: Any) -> None:
description += "\n"
if description:
- self.actor_profile.description = description
+ self.actor_profile.description = sanitize_string(description)
if old_description:
self.actor_profile.description += old_description
self.data_import.add_to_log("Processed description")
From 903b19e6eb2ad2c8fcdc74f2f736cf9f74984289 Mon Sep 17 00:00:00 2001
From: level09
Date: Fri, 1 May 2026 14:07:44 +0300
Subject: [PATCH 15/51] test(BAY-01): regression tests for Wave 1 pentest fixes
One test file covering 001 / 002 / 004 / 007 / 008. Each test mirrors
the auditor's PoC payload so a future regression flips the test. Mix
of e2e (history endpoint, extraction PUT, traversal endpoints) and
unit (login throttle helpers, sanitize_string) depending on what was
practical to wire through fixtures.
7ASec retest map: run 'uv run pytest tests/test_pentest_fixes.py -v'.
---
tests/test_pentest_fixes.py | 249 ++++++++++++++++++++++++++++++++++++
1 file changed, 249 insertions(+)
create mode 100644 tests/test_pentest_fixes.py
diff --git a/tests/test_pentest_fixes.py b/tests/test_pentest_fixes.py
new file mode 100644
index 000000000..ae4a83c2b
--- /dev/null
+++ b/tests/test_pentest_fixes.py
@@ -0,0 +1,249 @@
+"""
+Regression tests for 7ASecurity BAY-01 pentest findings.
+
+Each test mirrors the auditor's PoC and asserts the patched behaviour.
+Run all of these with:
+ uv run pytest tests/test_pentest_fixes.py -v
+"""
+
+from uuid import uuid4
+
+import pytest
+from flask_security.utils import hash_password
+
+from tests.factories import BulletinFactory
+
+# ---------------------------------------------------------------------------
+# BAY-01-001 Revision history bypasses object-level access
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def history_viewer_outside_group(app, session, isolated_session_store):
+ """User with view_simple_history but no role intersection on any item."""
+ from enferno.admin.models import Activity
+ from enferno.user.models import User
+
+ u = User(username=f"hv-{uuid4().hex[:8]}", password=hash_password("password"), active=1)
+ u.view_simple_history = True
+ u.fs_uniquifier = uuid4().hex
+ session.add(u)
+ session.commit()
+ user_id = u.id
+ with app.app_context():
+ with app.test_client(user=u) as client:
+ yield client
+ session.query(Activity).filter(Activity.user_id == user_id).delete(synchronize_session=False)
+ session.delete(u)
+ session.commit()
+
+
+def _make_restricted_bulletin(session, role_name="TestRole"):
+ from enferno.user.models import Role
+
+ bulletin = BulletinFactory()
+ session.add(bulletin)
+ session.commit()
+ role = session.query(Role).filter(Role.name == role_name).first()
+ assert role, f"Role {role_name} not found"
+ bulletin.roles.append(role)
+ session.commit()
+ return bulletin
+
+
+def test_bay_01_001_history_blocked_when_outside_group(
+ session, create_test_role, history_viewer_outside_group
+):
+ bulletin = _make_restricted_bulletin(session)
+ resp = history_viewer_outside_group.get(f"/admin/api/bulletinhistory/{bulletin.id}")
+ assert resp.status_code == 403
+
+
+def test_bay_01_001_history_404_for_missing_bulletin(history_viewer_outside_group):
+ resp = history_viewer_outside_group.get("/admin/api/bulletinhistory/99999999")
+ assert resp.status_code == 404
+
+
+# ---------------------------------------------------------------------------
+# BAY-01-002 OCR extraction PUT IDOR
+# ---------------------------------------------------------------------------
+
+
+def _make_media_with_extraction(session, bulletin_role_name=None):
+ from enferno.admin.models import Extraction, Media
+ from enferno.user.models import Role
+
+ bulletin = BulletinFactory()
+ session.add(bulletin)
+ session.commit()
+ if bulletin_role_name:
+ role = session.query(Role).filter(Role.name == bulletin_role_name).first()
+ bulletin.roles.append(role)
+ session.commit()
+
+ media = Media(
+ media_file=f"test-{uuid4().hex}.png",
+ media_file_type="image/png",
+ etag=uuid4().hex,
+ bulletin_id=bulletin.id,
+ )
+ session.add(media)
+ session.commit()
+
+ extraction = Extraction(
+ media_id=media.id,
+ text="original text",
+ status="processed",
+ history=[],
+ )
+ session.add(extraction)
+ session.commit()
+ return media, extraction
+
+
+def test_bay_01_002_extraction_put_blocked_outside_group(session, create_test_role, da_client):
+ """DA without TestRole cannot mutate extraction on a bulletin restricted to TestRole."""
+ _, extraction = _make_media_with_extraction(session, bulletin_role_name="TestRole")
+ resp = da_client.put(
+ f"/admin/api/extraction/{extraction.id}",
+ json={"action": "transcribe", "text": "tampered"},
+ )
+ assert resp.status_code == 403
+
+
+def test_bay_01_002_extraction_put_404_for_missing(da_client):
+ resp = da_client.put(
+ "/admin/api/extraction/99999999",
+ json={"action": "transcribe", "text": "x"},
+ )
+ assert resp.status_code == 404
+
+
+# ---------------------------------------------------------------------------
+# BAY-01-004 Path traversal in CSV/XLS analyze
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "endpoint, payload",
+ [
+ ("/import/api/csv/analyze", {"file": {"filename": "../../../../app/.env"}}),
+ ("/import/api/xls/sheets", {"file": {"filename": "../../../etc/passwd"}}),
+ (
+ "/import/api/xls/analyze",
+ {"file": {"filename": "../../../../app/.env"}, "sheet": "Sheet1"},
+ ),
+ ],
+)
+def test_bay_01_004_path_traversal_rejected(admin_client, endpoint, payload):
+ resp = admin_client.post(endpoint, json=payload)
+ assert resp.status_code == 400
+
+
+def test_bay_01_004_resolver_unit():
+ """_resolve_import_path returns None on traversal, path on legit input."""
+ from enferno.data_import.views import _resolve_import_path
+
+ # The resolver is called inside an app context by tests above; here we
+ # just sanity-check the symbol exists and rejects empty input.
+ assert _resolve_import_path("") is None
+ assert _resolve_import_path(None) is None
+
+
+# ---------------------------------------------------------------------------
+# BAY-01-007 Login rate limit
+# ---------------------------------------------------------------------------
+
+
+class _FakeRedis:
+ def __init__(self):
+ self.store = {}
+
+ def incr(self, k):
+ self.store[k] = int(self.store.get(k, 0)) + 1
+ return self.store[k]
+
+ def get(self, k):
+ return self.store.get(k)
+
+ def delete(self, k):
+ self.store.pop(k, None)
+
+ def expire(self, k, _seconds):
+ return True
+
+
+def test_bay_01_007_throttle_fires_on_username_quota():
+ from enferno.utils.rate_limit_utils import (
+ LOGIN_FAIL_MAX_PER_USERNAME,
+ is_login_throttled,
+ record_login_failure,
+ )
+
+ rds = _FakeRedis()
+ for _ in range(LOGIN_FAIL_MAX_PER_USERNAME):
+ record_login_failure(rds, "alice", "1.2.3.4")
+ assert is_login_throttled(rds, "alice", "1.2.3.4")
+
+
+def test_bay_01_007_throttle_fires_on_ip_quota_across_users():
+ from enferno.utils.rate_limit_utils import (
+ LOGIN_FAIL_MAX_PER_IP,
+ is_login_throttled,
+ record_login_failure,
+ )
+
+ rds = _FakeRedis()
+ for i in range(LOGIN_FAIL_MAX_PER_IP):
+ record_login_failure(rds, f"u{i}", "9.9.9.9")
+ assert is_login_throttled(rds, "newcomer", "9.9.9.9")
+
+
+def test_bay_01_007_clear_on_success_resets_username_counter():
+ from enferno.utils.rate_limit_utils import (
+ LOGIN_FAIL_MAX_PER_USERNAME,
+ clear_login_failures,
+ is_login_throttled,
+ record_login_failure,
+ )
+
+ rds = _FakeRedis()
+ for _ in range(LOGIN_FAIL_MAX_PER_USERNAME):
+ record_login_failure(rds, "alice", "1.2.3.4")
+ assert is_login_throttled(rds, "alice", "1.2.3.4")
+ clear_login_failures(rds, "alice")
+ # IP counter still set, but username counter cleared - on a different IP it should pass
+ assert not is_login_throttled(rds, "alice", "5.5.5.5")
+
+
+# ---------------------------------------------------------------------------
+# BAY-01-008 Stored XSS via import sanitization gap
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "payload",
+ [
+ "",
+ "safe",
+ "",
+ 'click',
+ ],
+)
+def test_bay_01_008_sanitize_strips_xss(payload):
+ from enferno.utils.validation_utils import sanitize_string
+
+ cleaned = sanitize_string(payload)
+ assert "onerror" not in cleaned
+ assert "", ""],
+)
+def test_bay_01_039_handle_mismatch_sanitizes_description(payload):
+ from enferno.data_import.utils.sheet_import import SheetImport
+ from enferno.admin.models import ActorProfile
+
+ si = SheetImport.__new__(SheetImport)
+ si.data_import = type("D", (), {"add_to_log": lambda self, msg: None})()
+ si.actor_profile = ActorProfile()
+ si.actor_profile.description = ""
+
+ si.handle_mismatch("type", payload)
+
+ desc = si.actor_profile.description
+ assert "
- The update will take about 60 seconds. The app will be briefly
- unavailable. A pre-update database snapshot will be taken
- automatically.
+ To update, run this on the server as an administrator:
+
sudo bayanat update {{ latest }}
- Cancel
-
- Update now
-
+ Close
diff --git a/enferno/tasks/maintenance.py b/enferno/tasks/maintenance.py
index 26da251e8..e78a8be5b 100644
--- a/enferno/tasks/maintenance.py
+++ b/enferno/tasks/maintenance.py
@@ -1,11 +1,9 @@
# -*- coding: utf-8 -*-
import json
import os
-import subprocess
from datetime import date, datetime, timedelta, timezone
import requests
-from packaging.version import Version
from enferno.admin.constants import Constants
from enferno.admin.models import Activity, Location
@@ -45,16 +43,6 @@ def _current_version() -> str:
return "0.0.0"
-def _is_patch_bump(current: str, target: str) -> bool:
- try:
- c, t = Version(current), Version(target)
- except Exception:
- return False
- if t <= c:
- return False
- return c.major == t.major and c.minor == t.minor
-
-
@celery.task
def check_for_updates():
"""Poll GitHub releases. Cache latest. Notify admins on new tag. Optionally auto-apply patch."""
@@ -87,21 +75,6 @@ def check_for_updates():
if _redis_get_str(UPDATE_NOTIFIED_KEY) == latest_tag:
return
- auto_apply = bool(getattr(cfg, "AUTO_APPLY_PATCH_UPDATES", False))
-
- if auto_apply and _is_patch_bump(current, latest_tag):
- logger.info(f"auto-applying patch update {current} -> {latest_tag}")
- try:
- subprocess.run(
- ["sudo", "-n", "/usr/local/sbin/bayanat-start-update"],
- check=True,
- timeout=10,
- )
- rds.set(UPDATE_NOTIFIED_KEY, latest_tag)
- return
- except Exception as e:
- logger.warning(f"auto-apply failed, falling back to notification: {e}")
-
Notification.create_for_admins(
title=f"Update available: {latest_tag}",
message=f"A new Bayanat release is available. {release.get('html_url', '')}",
diff --git a/enferno/utils/config_utils.py b/enferno/utils/config_utils.py
index 4b999dce6..7e40a505d 100644
--- a/enferno/utils/config_utils.py
+++ b/enferno/utils/config_utils.py
@@ -166,7 +166,6 @@ class ConfigManager:
"twitter.com",
],
"YTDLP_COOKIES": "",
- "AUTO_APPLY_PATCH_UPDATES": False,
"NOTIFICATIONS": NOTIFICATIONS_DEFAULT_CONFIG, # Import from notification_config.py
}
)
@@ -241,7 +240,6 @@ class ConfigManager:
"YTDLP_PROXY": "Proxy URL to use with Web Import",
"YTDLP_ALLOWED_DOMAINS": "Allowed Domains for Web Import",
"YTDLP_COOKIES": "Cookies to use with Web Import",
- "AUTO_APPLY_PATCH_UPDATES": "Auto-apply patch releases",
"NOTIFICATIONS": "Notifications",
}
)
@@ -289,7 +287,6 @@ def serialize():
"SECURITY_ZXCVBN_MINIMUM_SCORE": cfg.SECURITY_ZXCVBN_MINIMUM_SCORE,
"DISABLE_MULTIPLE_SESSIONS": cfg.DISABLE_MULTIPLE_SESSIONS,
"SESSION_RETENTION_PERIOD": cfg.SESSION_RETENTION_PERIOD,
- "AUTO_APPLY_PATCH_UPDATES": cfg.AUTO_APPLY_PATCH_UPDATES,
"RECAPTCHA_ENABLED": cfg.RECAPTCHA_ENABLED,
"RECAPTCHA_PUBLIC_KEY": cfg.RECAPTCHA_PUBLIC_KEY,
"RECAPTCHA_PRIVATE_KEY": ConfigManager.MASK_STRING if cfg.RECAPTCHA_PRIVATE_KEY else "",
diff --git a/tests/test_update_check.py b/tests/test_update_check.py
index f0b12a9aa..ba53db471 100644
--- a/tests/test_update_check.py
+++ b/tests/test_update_check.py
@@ -1,6 +1,6 @@
from unittest.mock import MagicMock, patch
-from enferno.tasks.maintenance import _strip_v, _is_patch_bump
+from enferno.tasks.maintenance import _strip_v
def test_strip_v_prefix():
@@ -9,27 +9,6 @@ def test_strip_v_prefix():
assert _strip_v("") == ""
-def test_is_patch_bump_true():
- assert _is_patch_bump("4.1.0", "4.1.1") is True
- assert _is_patch_bump("4.1.2", "4.1.10") is True
-
-
-def test_is_patch_bump_false_for_minor():
- assert _is_patch_bump("4.1.0", "4.2.0") is False
-
-
-def test_is_patch_bump_false_for_major():
- assert _is_patch_bump("4.1.0", "5.0.0") is False
-
-
-def test_is_patch_bump_false_for_same():
- assert _is_patch_bump("4.1.0", "4.1.0") is False
-
-
-def test_is_patch_bump_false_for_downgrade():
- assert _is_patch_bump("4.1.5", "4.1.3") is False
-
-
def _github_response(tag):
return MagicMock(
raise_for_status=lambda: None,
@@ -37,60 +16,35 @@ def _github_response(tag):
)
-def test_auto_apply_patch_triggers_wrapper_when_flag_on():
+def test_new_release_notifies_admins():
+ """Update check is notify-only: a new tag caches the latest and notifies
+ admins. It never triggers a privileged update (BAY-01-013)."""
from enferno.tasks import maintenance
fake_redis = MagicMock()
fake_redis.get.return_value = None # nothing notified yet
with (
- patch.object(maintenance, "cfg", MagicMock(AUTO_APPLY_PATCH_UPDATES=True)),
patch.object(maintenance, "requests") as req,
patch.object(maintenance, "rds", fake_redis),
- patch.object(maintenance, "subprocess") as sp,
patch.object(maintenance, "_current_version", return_value="4.1.0"),
patch.object(maintenance, "Notification") as notif,
):
req.get.return_value = _github_response("v4.1.1")
maintenance.check_for_updates.run()
- sp.run.assert_called_once()
- args = sp.run.call_args.args[0]
- assert args == ["sudo", "-n", "/usr/local/sbin/bayanat-start-update"]
- notif.create_for_admins.assert_not_called()
-
-
-def test_auto_apply_off_falls_back_to_notification():
- from enferno.tasks import maintenance
-
- fake_redis = MagicMock()
- fake_redis.get.return_value = None
- with (
- patch.object(maintenance, "cfg", MagicMock(AUTO_APPLY_PATCH_UPDATES=False)),
- patch.object(maintenance, "requests") as req,
- patch.object(maintenance, "rds", fake_redis),
- patch.object(maintenance, "subprocess") as sp,
- patch.object(maintenance, "_current_version", return_value="4.1.0"),
- patch.object(maintenance, "Notification") as notif,
- ):
- req.get.return_value = _github_response("v4.1.1")
- maintenance.check_for_updates.run()
- sp.run.assert_not_called()
notif.create_for_admins.assert_called_once()
-def test_auto_apply_on_but_minor_bump_still_notifies():
+def test_same_version_does_not_notify():
from enferno.tasks import maintenance
fake_redis = MagicMock()
fake_redis.get.return_value = None
with (
- patch.object(maintenance, "cfg", MagicMock(AUTO_APPLY_PATCH_UPDATES=True)),
patch.object(maintenance, "requests") as req,
patch.object(maintenance, "rds", fake_redis),
- patch.object(maintenance, "subprocess") as sp,
- patch.object(maintenance, "_current_version", return_value="4.1.0"),
+ patch.object(maintenance, "_current_version", return_value="4.1.1"),
patch.object(maintenance, "Notification") as notif,
):
- req.get.return_value = _github_response("v4.2.0")
+ req.get.return_value = _github_response("v4.1.1")
maintenance.check_for_updates.run()
- sp.run.assert_not_called()
- notif.create_for_admins.assert_called_once()
+ notif.create_for_admins.assert_not_called()
diff --git a/tests/test_update_endpoints.py b/tests/test_update_endpoints.py
index 5f71d849a..8dc74aa35 100644
--- a/tests/test_update_endpoints.py
+++ b/tests/test_update_endpoints.py
@@ -1,15 +1,4 @@
import json
-import time
-from unittest.mock import patch
-
-
-def _fresh_session(client):
- """Mark the test-client session as freshly authenticated so
- `@auth_required(within=15)` passes. Flask-Security stores the primary
- auth timestamp in session key 'fs_paa'.
- """
- with client.session_transaction() as sess:
- sess["fs_paa"] = time.time()
def test_available_returns_cached(admin_client):
@@ -73,27 +62,8 @@ def test_status_terminal_when_success(admin_client, tmp_path, monkeypatch):
assert data["running"] is False
-def test_start_calls_wrapper(admin_client):
- _fresh_session(admin_client)
- with patch("enferno.admin.views.system.subprocess.run") as run:
- resp = admin_client.post("/admin/api/updates/start")
- assert resp.status_code == 200
- run.assert_called_once()
- args = run.call_args.args[0]
- assert args == ["sudo", "-n", "/usr/local/sbin/bayanat-start-update"]
-
-
-def test_start_requires_fresh_auth(admin_client):
- # No _fresh_session call -> session is stale -> auth_required(within=15)
- # should reject with redirect/401/403.
- with patch("enferno.admin.views.system.subprocess.run") as run:
- resp = admin_client.post("/admin/api/updates/start")
- assert resp.status_code in (302, 401, 403)
- run.assert_not_called()
-
-
-def test_non_admin_cannot_start(da_client):
- _fresh_session(da_client)
- resp = da_client.post("/admin/api/updates/start")
- # roles_required returns 403 (Forbidden) for wrong-role users
- assert resp.status_code in (401, 403)
+def test_start_endpoint_removed(admin_client):
+ """The privileged web-triggered update endpoint is gone (BAY-01-013).
+ Updates are applied via the root CLI only."""
+ resp = admin_client.post("/admin/api/updates/start")
+ assert resp.status_code == 404
From a9af9db52674670e3944aef3ccd5791ded3e3494 Mon Sep 17 00:00:00 2001
From: level09
Date: Sun, 24 May 2026 19:46:12 +0200
Subject: [PATCH 40/51] chore: gitignore release signing keys
---
.gitignore | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.gitignore b/.gitignore
index fea7c8a77..f63a36744 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,9 @@ backups/*
cookies.txt
*.egg-info/
+
+# Release signing: NEVER commit secret keys. The pinned public key is baked
+# into the installer/updater, not stored as a loose file in the repo.
+*.key
+bayanat-release.key
+bayanat-release.pub
From 09f3f60b51a6c135bab51fb7b0f25e4d0c166141 Mon Sep 17 00:00:00 2001
From: level09
Date: Sun, 24 May 2026 20:22:27 +0200
Subject: [PATCH 41/51] fix(BAY-01-024): neutralize CSV formula injection in
exports
---
enferno/tasks/exports.py | 4 +++-
enferno/utils/csv_utils.py | 12 ++++++++++++
tests/test_pentest_fixes.py | 36 ++++++++++++++++++++++++++++++++++++
3 files changed, 51 insertions(+), 1 deletion(-)
diff --git a/enferno/tasks/exports.py b/enferno/tasks/exports.py
index be5d5f184..1a671263f 100644
--- a/enferno/tasks/exports.py
+++ b/enferno/tasks/exports.py
@@ -14,7 +14,7 @@
from enferno.admin.models import Actor, Bulletin, Incident
from enferno.export.models import Export
from enferno.tasks import BULK_CHUNK_SIZE, celery, cfg, chunk_list
-from enferno.utils.csv_utils import convert_list_attributes
+from enferno.utils.csv_utils import convert_list_attributes, escape_csv_formula_cell
from enferno.utils.date_helper import DateHelper
from enferno.utils.logging_utils import get_logger
from enferno.utils.pdf_utils import PDFUtil
@@ -257,6 +257,8 @@ def generate_csv_file(export_id: t.id) -> t.id | Literal[False]:
else:
csv_df = pd.concat([csv_df, df], ignore_index=True)
+ # Neutralize spreadsheet formula injection before writing (BAY-01-024).
+ csv_df = csv_df.map(escape_csv_formula_cell)
csv_df.to_csv(f"{file_path}.csv")
export_request.file_id = dir_id
diff --git a/enferno/utils/csv_utils.py b/enferno/utils/csv_utils.py
index c8a4e8274..1a3a156b9 100644
--- a/enferno/utils/csv_utils.py
+++ b/enferno/utils/csv_utils.py
@@ -1,6 +1,18 @@
from typing import Iterable, Optional
+def escape_csv_formula_cell(value):
+ """Neutralize spreadsheet formula injection (BAY-01-024).
+
+ Prefix any string cell starting with =, +, -, or @ with a single quote so
+ Excel/LibreOffice treat it as inert text rather than an executable formula.
+ Non-string values pass through unchanged.
+ """
+ if isinstance(value, str) and value[:1] in ("=", "+", "-", "@"):
+ return "'" + value
+ return value
+
+
def convert_list_attributes(dictionary: dict) -> dict:
"""
convert dictionary list attributes into named attributes based on their index.
diff --git a/tests/test_pentest_fixes.py b/tests/test_pentest_fixes.py
index b1bece9c6..ed96bb735 100644
--- a/tests/test_pentest_fixes.py
+++ b/tests/test_pentest_fixes.py
@@ -313,3 +313,39 @@ def test_bay_01_039_handle_mismatch_sanitizes_description(payload):
assert "