+
+ {{ state.error }}
+
+
+ Operator intervention required. Snapshot:
+ {{ state.snapshot }}. See the update runbook for
+ recovery steps.
+
+
+
+
+ Close
+
+
+
+ `,
+});
diff --git a/enferno/static/js/mixins/global-mixin.js b/enferno/static/js/mixins/global-mixin.js
index 622715595..d203bd4e8 100644
--- a/enferno/static/js/mixins/global-mixin.js
+++ b/enferno/static/js/mixins/global-mixin.js
@@ -4,6 +4,8 @@ const globalMixin = {
'ConfirmDialog': ConfirmDialog,
'Toast': Toast,
'ProfileDropdown': ProfileDropdown,
+ 'UpdateBanner': UpdateBanner,
+ 'UpdateProgressDialog': UpdateProgressDialog,
},
data: () => ({
snackbar: false,
diff --git a/enferno/templates/layout.html b/enferno/templates/layout.html
index 8df04455f..e701bb1d5 100644
--- a/enferno/templates/layout.html
+++ b/enferno/templates/layout.html
@@ -133,6 +133,8 @@
{% include 'admin/jsapi.jinja2' %}
+
+
From c2d9b217030c1d21b4716e0b749ac647f2dd53e3 Mon Sep 17 00:00:00 2001
From: level09
Date: Sat, 18 Apr 2026 00:39:24 +0200
Subject: [PATCH 020/110] feat(ui): snapshots list page (read-only)
---
enferno/admin/templates/admin/snapshots.html | 28 +++++++++++
enferno/admin/views/system.py | 32 ++++++++++++
enferno/static/js/components/SnapshotsList.js | 49 +++++++++++++++++++
3 files changed, 109 insertions(+)
create mode 100644 enferno/admin/templates/admin/snapshots.html
create mode 100644 enferno/static/js/components/SnapshotsList.js
diff --git a/enferno/admin/templates/admin/snapshots.html b/enferno/admin/templates/admin/snapshots.html
new file mode 100644
index 000000000..71d82847f
--- /dev/null
+++ b/enferno/admin/templates/admin/snapshots.html
@@ -0,0 +1,28 @@
+{% extends 'layout.html' %} {% block content %}
+
+
+
+
+
+
+
+{% endblock %} {% block js %}
+
+
+{% endblock %}
diff --git a/enferno/admin/views/system.py b/enferno/admin/views/system.py
index c217aea96..7b743e3a1 100644
--- a/enferno/admin/views/system.py
+++ b/enferno/admin/views/system.py
@@ -250,6 +250,38 @@ def api_updates_start() -> Response:
return HTTPResponse.success(data={"status": "started"})
+@admin.get("/snapshots/")
+@auth_required(within=15, grace=0)
+@roles_required("Admin")
+def snapshots_page() -> str:
+ """Render the snapshots list page."""
+ return render_template("admin/snapshots.html")
+
+
+@admin.get("/api/snapshots/")
+@roles_required("Admin")
+def api_snapshots() -> Response:
+ """List pre-update snapshots by shelling `bayanat snapshots`."""
+ try:
+ out = subprocess.run(
+ ["sudo", "-n", "/usr/local/bin/bayanat", "snapshots"],
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=5,
+ ).stdout
+ except subprocess.TimeoutExpired:
+ return HTTPResponse.error("Listing snapshots timed out", status=504)
+ except subprocess.CalledProcessError as e:
+ return HTTPResponse.error(f"Failed to list snapshots: {e}", status=500)
+ items = []
+ for line in out.strip().splitlines()[1:]: # skip header row
+ parts = line.split()
+ if len(parts) >= 3:
+ items.append({"name": parts[0], "size": parts[1], "age": parts[2]})
+ return HTTPResponse.success(data=items)
+
+
@admin.get("/api/updates/status")
@roles_required("Admin")
def api_updates_status() -> Response:
diff --git a/enferno/static/js/components/SnapshotsList.js b/enferno/static/js/components/SnapshotsList.js
new file mode 100644
index 000000000..018f3267c
--- /dev/null
+++ b/enferno/static/js/components/SnapshotsList.js
@@ -0,0 +1,49 @@
+const SnapshotsList = Vue.defineComponent({
+ data() {
+ return {
+ items: [],
+ loading: false,
+ };
+ },
+ mounted() {
+ this.load();
+ },
+ methods: {
+ async load() {
+ this.loading = true;
+ try {
+ const resp = await axios.get('/admin/api/snapshots/');
+ this.items = resp?.data?.data ?? [];
+ } catch (_e) {
+ this.items = [];
+ } finally {
+ this.loading = false;
+ }
+ },
+ },
+ template: `
+
+
+ Pre-update Snapshots
+
+
+
+
+
+ Restore is CLI-only for safety. SSH to the server and run
+ sudo -u bayanat bayanat restore <name>.
+
+
+
+
+ `,
+});
From d4e07cda1604f32c0620b71f257ea8f3d17ece70 Mon Sep 17 00:00:00 2001
From: level09
Date: Sat, 18 Apr 2026 00:40:16 +0200
Subject: [PATCH 021/110] docs: operator runbook for bayanat update
---
docs/deployment/auto-update-runbook.md | 124 +++++++++++++++++++++++++
1 file changed, 124 insertions(+)
create mode 100644 docs/deployment/auto-update-runbook.md
diff --git a/docs/deployment/auto-update-runbook.md b/docs/deployment/auto-update-runbook.md
new file mode 100644
index 000000000..e7d49a756
--- /dev/null
+++ b/docs/deployment/auto-update-runbook.md
@@ -0,0 +1,124 @@
+# Bayanat Auto-Update Runbook
+
+Short operator reference for the `bayanat update` flow. Design notes live
+in the development spec (not shipped with the repo).
+
+## Triggering an update
+
+- **One-click from UI:** an admin-role user clicks "Update now" from the
+ "Update available: X.Y.Z" banner in the nav bar.
+- **From the shell:** `sudo -u bayanat bayanat update []`
+ (defaults to the latest GitHub release).
+- **Check only (no changes):** `sudo -u bayanat bayanat update --check`.
+
+The update runs as `bayanat-update.service`, a transient systemd unit
+that outlives Flask restarts, SSH disconnects, and browser closes. Tail
+live logs with:
+
+```
+sudo journalctl -u bayanat-update -f
+```
+
+## Opt-in auto-apply for patch releases
+
+In the admin UI under System Administration, toggle "Auto-apply patch
+releases" on. With the toggle on, any bump within the same minor line
+(e.g. `4.1.0` to `4.1.1`) installs silently every 6 hours via the same
+pipeline. Minor and major bumps (e.g. `4.1.x` to `4.2.0`) always notify
+and wait for a manual click.
+
+## Expected timing
+
+| Phase | Duration | Production impact |
+|---|---|---|
+| PREPARE (fetch + deps) | 1-5 min | None, old version serves traffic |
+| Stop services | ~3 s | 502 from Caddy begins |
+| Snapshot (`pg_dump -Fc`) | 10-60 s | 502 |
+| Migrate (`flask db upgrade`) | 1-30 s | 502 |
+| Swap + start services | ~5 s | 502 |
+| Verify (health probe) | 1-10 s | New version serving |
+| **Total visible downtime** | **~30-90 s** | |
+
+Caddy returns `502 Bad Gateway` during the maintenance window. Browsers
+retry automatically; partners see a brief "service unavailable" view.
+
+## If something goes wrong
+
+### Migration failed (Alembic transaction rolled back)
+
+Nothing to do. Services restart on the previous release automatically.
+The UI shows the `error` field. Report the broken release; the previous
+version keeps running.
+
+### Health probe failed after swap (auto-rollback succeeded)
+
+Nothing to do. The updater reverted the symlink and restarted on the
+previous release. The pre-update snapshot is retained at
+`/opt/bayanat/shared/backups/`.
+
+### NEEDS_INTERVENTION
+
+This state only happens when two independent failures compound: the new
+release was broken AND rolling back did not reach a healthy state. The
+maintenance flag stays up so users see a 502 instead of raw errors.
+Recover:
+
+```
+sudo -u bayanat bayanat status # confirm state
+sudo -u bayanat bayanat snapshots # list snapshots
+sudo -u bayanat bayanat restore pre-.dump # restores DB
+sudo systemctl start bayanat bayanat-celery
+```
+
+Then file a bug with journal logs from `journalctl -u bayanat-update`.
+
+### Stuck state (process died, state file orphaned)
+
+```
+sudo -u bayanat bayanat update --recover
+```
+
+## Snapshots
+
+- Location: `/opt/bayanat/shared/backups/pre-*.dump`
+- Format: `pg_dump -Fc` (PostgreSQL custom format)
+- Retention: last 5 snapshots OR last 30 days, whichever is greater
+- Override retention: `export BAYANAT_SNAPSHOT_RETENTION_DAYS=60`
+- List: `sudo -u bayanat bayanat snapshots` or visit
+ `/admin/snapshots/` in the UI (read-only)
+- Restore: `sudo -u bayanat bayanat restore ` (prompts for
+ confirmation; stops services; pipes through `pg_restore --clean
+ --if-exists`; restarts services). Not available from the web UI by
+ design.
+
+## Files
+
+| Path | Purpose |
+|---|---|
+| `/usr/local/bin/bayanat` | The CLI script |
+| `/usr/local/sbin/bayanat-start-update` | Root wrapper the UI invokes via sudo |
+| `/etc/sudoers.d/bayanat` | Granted commands for the `bayanat` user |
+| `/opt/bayanat/state/update.json` | Current update state (sanitized JSON) |
+| `/opt/bayanat/state/update.lock` | PID lock file |
+| `/opt/bayanat/shared/backups/` | Pre-update snapshots |
+| `/health` (Flask endpoint) | 200 = DB + Redis reachable |
+
+## Admin UI surface
+
+- Nav-bar banner chip: shows when `latest != current`
+- Progress dialog: polls `/admin/api/updates/status` every 2 s during an
+ active update
+- Settings toggle: System Administration -> "Auto-apply patch releases"
+- Snapshots page: `/admin/snapshots/` (read-only list; restore stays on
+ the CLI)
+
+## Manual CLI reference
+
+```
+bayanat update [] # default: latest GitHub release
+bayanat update --check # show current vs latest; no changes
+bayanat update --recover # recover a stuck state file
+bayanat snapshots # list pre-update snapshots
+bayanat restore # interactive restore from a snapshot
+bayanat status # version + services + update state
+```
From a6e7235ebe6687503b6162389dfc0dd0f9ac36a1 Mon Sep 17 00:00:00 2001
From: level09
Date: Sat, 18 Apr 2026 00:46:05 +0200
Subject: [PATCH 022/110] fix(cli): recover mid-phase crashes and escalate
snapshot failures
---
bayanat | 22 +++++++++++++++++++++-
1 file changed, 21 insertions(+), 1 deletion(-)
diff --git a/bayanat b/bayanat
index bc48222b3..7100faee7 100755
--- a/bayanat
+++ b/bayanat
@@ -302,6 +302,20 @@ recover_state() {
prev=$(read_field previous || true)
die "Operator intervention required. Snapshot: $snap Previous tag: $prev See: sudo -u $APP_USER bayanat snapshots"
;;
+ MIGRATE|SWITCH|ROLLBACK)
+ log "Recovery: $phase — attempting to restart services"
+ systemctl start bayanat bayanat-celery 2>/dev/null || true
+ if _wait_healthy 60; then
+ warn "Services healthy but update was interrupted mid-$phase"
+ warn "DB schema state is uncertain; re-run 'bayanat update' to finish"
+ clear_state
+ release_lock
+ else
+ export STATE_ERROR_JSON="\"interrupted during $phase; services unhealthy\""
+ write_state NEEDS_INTERVENTION "Update interrupted mid-$phase; manual recovery required"
+ exit 2
+ fi
+ ;;
*)
warn "Unknown phase '$phase' — clearing and starting fresh"
clear_state
@@ -412,7 +426,13 @@ do_migrate() {
export STATE_PROGRESS="Taking pre-update snapshot"
write_state MIGRATE "Taking pre-update snapshot"
export STATE_SNAPSHOT
- STATE_SNAPSHOT=$(snapshot_pg_dump "$prev" "$target")
+ if ! STATE_SNAPSHOT=$(snapshot_pg_dump "$prev" "$target"); then
+ export STATE_ERROR_JSON='"snapshot failed"'
+ write_state NEEDS_INTERVENTION "Pre-update snapshot failed; check backups disk and pg_dump"
+ systemctl start bayanat bayanat-celery || true
+ release_lock
+ exit 2
+ fi
log "MIGRATE: running migrations"
export STATE_PROGRESS="Running migrations"
write_state MIGRATE "Running migrations"
From 0dc7af020ffe00f72f1e6ae18f26a7b9995f23da Mon Sep 17 00:00:00 2001
From: level09
Date: Sat, 18 Apr 2026 00:46:17 +0200
Subject: [PATCH 023/110] fix(cli): clean up leaked .partial snapshot files
during retention prune
---
bayanat | 2 ++
1 file changed, 2 insertions(+)
diff --git a/bayanat b/bayanat
index 7100faee7..789cf7b1b 100755
--- a/bayanat
+++ b/bayanat
@@ -128,6 +128,8 @@ prune_snapshots() {
# $SNAPSHOT_RETENTION_DAYS days, whichever is greater.
local backups="$SHARED_DIR/backups"
[[ -d "$backups" ]] || return 0
+ # Sweep any leaked .partial snapshot files from interrupted dumps
+ rm -f "$backups"/*.dump.partial 2>/dev/null || true
local cutoff_epoch
cutoff_epoch=$(date -d "-${SNAPSHOT_RETENTION_DAYS} days" +%s 2>/dev/null \
|| date -v-"${SNAPSHOT_RETENTION_DAYS}"d +%s)
From 5781cf5a93465531af9b0c56632080d3ff4a9750 Mon Sep 17 00:00:00 2001
From: level09
Date: Sat, 18 Apr 2026 11:49:00 +0200
Subject: [PATCH 024/110] fix(cli): preserve v-prefix on git tags; strip only
for comparison
---
bayanat | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/bayanat b/bayanat
index 789cf7b1b..d79f47e35 100755
--- a/bayanat
+++ b/bayanat
@@ -887,10 +887,10 @@ cmd_update() {
--check)
local cur latest
cur=$(current_version || echo "not installed")
- latest=$(latest_remote_tag | sed 's/^v//')
+ latest=$(latest_remote_tag)
echo "current: $cur"
echo "latest: $latest"
- if [[ "$cur" == "$latest" ]]; then
+ if [[ "${cur#v}" == "${latest#v}" ]]; then
echo "up to date"
else
echo "update available"
@@ -907,10 +907,11 @@ cmd_update() {
recover_state
local target="${1:-}"
if [[ -z "$target" ]]; then
- target=$(latest_remote_tag | sed 's/^v//')
- else
- target="${target#v}"
+ target=$(latest_remote_tag)
fi
+ # Keep $target verbatim. Tags and release dir names use the v-prefix form
+ # (e.g. v4.0.0), matching the installer's convention in _clone_release and
+ # _link_shared. Stripping is only for display / comparison.
do_prepare "$target"
do_migrate
do_switch_verify
From 6d7814f0822d2ef05bbf7adb89227e7b8468b7c2 Mon Sep 17 00:00:00 2001
From: level09
Date: Sat, 18 Apr 2026 11:50:22 +0200
Subject: [PATCH 025/110] fix(cli): generate POSTGRES_* in .env + add fallback
defaults in pg helpers
---
bayanat | 18 ++++++++++++------
1 file changed, 12 insertions(+), 6 deletions(-)
diff --git a/bayanat b/bayanat
index d79f47e35..357439269 100755
--- a/bayanat
+++ b/bayanat
@@ -115,9 +115,9 @@ snapshot_pg_dump() {
pg_dump -Fc \
-h "${POSTGRES_HOST:-localhost}" \
-p "${POSTGRES_PORT:-5432}" \
- -U "$POSTGRES_USER" \
+ -U "${POSTGRES_USER:-$APP_USER}" \
-f "$partial" \
- "$POSTGRES_DB"
+ "${POSTGRES_DB:-$APP_USER}"
)
mv -Tf "$partial" "$final"
echo "$name"
@@ -177,8 +177,8 @@ restore_pg() {
pg_restore --clean --if-exists \
-h "${POSTGRES_HOST:-localhost}" \
-p "${POSTGRES_PORT:-5432}" \
- -U "$POSTGRES_USER" \
- -d "$POSTGRES_DB" \
+ -U "${POSTGRES_USER:-$APP_USER}" \
+ -d "${POSTGRES_DB:-$APP_USER}" \
"$path"
); then
systemctl start bayanat bayanat-celery
@@ -340,7 +340,7 @@ _db_ping() {
eval "$(_pg_env)"
PGPASSWORD="${POSTGRES_PASSWORD:-}" \
psql -h "${POSTGRES_HOST:-localhost}" \
- -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
+ -U "${POSTGRES_USER:-$APP_USER}" -d "${POSTGRES_DB:-$APP_USER}" \
-c 'SELECT 1' -t >/dev/null 2>&1
)
}
@@ -375,7 +375,7 @@ update_preflight() {
eval "$(_pg_env)"
PGPASSWORD="${POSTGRES_PASSWORD:-}" \
psql -h "${POSTGRES_HOST:-localhost}" \
- -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
+ -U "${POSTGRES_USER:-$APP_USER}" -d "${POSTGRES_DB:-$APP_USER}" \
-c 'SHOW server_version_num' -t 2>/dev/null \
| tr -d ' ' | cut -c1-2
)
@@ -627,6 +627,12 @@ FORCE_HTTPS=$force_https
LOG_DIR=$LOGS_DIR
BACKUPS_LOCAL_PATH=$SHARED_DIR/backups
+
+POSTGRES_HOST=localhost
+POSTGRES_PORT=5432
+POSTGRES_USER=$APP_USER
+POSTGRES_PASSWORD=
+POSTGRES_DB=$APP_USER
EOF
}
From 4ffa99f9bc81cd67f38925265e2139eef5050d94 Mon Sep 17 00:00:00 2001
From: level09
Date: Sat, 18 Apr 2026 11:55:23 +0200
Subject: [PATCH 026/110] fix(tasks): read AUTO_APPLY_PATCH_UPDATES via
settings.Config + wire through serialize/validation
---
enferno/admin/validation/models.py | 1 +
enferno/settings.py | 4 ++
enferno/tasks/maintenance.py | 6 +--
enferno/utils/config_utils.py | 1 +
tests/test_update_check.py | 68 ++++++++++++++++++++++++++++++
5 files changed, 75 insertions(+), 5 deletions(-)
diff --git a/enferno/admin/validation/models.py b/enferno/admin/validation/models.py
index d67dabc44..65057f6e2 100644
--- a/enferno/admin/validation/models.py
+++ b/enferno/admin/validation/models.py
@@ -1902,6 +1902,7 @@ class FullConfigValidationModel(ConfigValidationModel):
SECURITY_FRESHNESS: int = Field(gt=0)
SECURITY_FRESHNESS_GRACE_PERIOD: int = Field(ge=0)
DISABLE_MULTIPLE_SESSIONS: bool
+ AUTO_APPLY_PATCH_UPDATES: bool = False
RECAPTCHA_ENABLED: bool
RECAPTCHA_PUBLIC_KEY: Optional[str] = None
RECAPTCHA_PRIVATE_KEY: Optional[str] = None
diff --git a/enferno/settings.py b/enferno/settings.py
index 0cb2dca5e..c116bc6de 100644
--- a/enferno/settings.py
+++ b/enferno/settings.py
@@ -126,6 +126,10 @@ class Config(object):
DISABLE_MULTIPLE_SESSIONS = manager.get_config("DISABLE_MULTIPLE_SESSIONS")
SESSION_RETENTION_PERIOD = manager.get_config("SESSION_RETENTION_PERIOD")
+ # Auto-apply patch releases (e.g. 4.1.0 -> 4.1.1) in the background.
+ # Minor and major bumps always require manual approval regardless.
+ AUTO_APPLY_PATCH_UPDATES = manager.get_config("AUTO_APPLY_PATCH_UPDATES")
+
# Recaptcha
RECAPTCHA_ENABLED = manager.get_config("RECAPTCHA_ENABLED")
RECAPTCHA_PUBLIC_KEY = manager.get_config("RECAPTCHA_PUBLIC_KEY")
diff --git a/enferno/tasks/maintenance.py b/enferno/tasks/maintenance.py
index 615687373..26da251e8 100644
--- a/enferno/tasks/maintenance.py
+++ b/enferno/tasks/maintenance.py
@@ -14,7 +14,6 @@
from enferno.tasks import celery, cfg
from enferno.user.models import Session
from enferno.utils.backup_utils import pg_dump, upload_to_s3
-from enferno.utils.config_utils import ConfigManager
from enferno.utils.date_helper import DateHelper
from enferno.utils.logging_utils import get_logger
@@ -88,10 +87,7 @@ def check_for_updates():
if _redis_get_str(UPDATE_NOTIFIED_KEY) == latest_tag:
return
- try:
- auto_apply = ConfigManager.get_config("AUTO_APPLY_PATCH_UPDATES", False)
- except Exception:
- auto_apply = False
+ 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}")
diff --git a/enferno/utils/config_utils.py b/enferno/utils/config_utils.py
index 7cd720ce8..4b999dce6 100644
--- a/enferno/utils/config_utils.py
+++ b/enferno/utils/config_utils.py
@@ -289,6 +289,7 @@ 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 f328e4821..be52edf8a 100644
--- a/tests/test_update_check.py
+++ b/tests/test_update_check.py
@@ -1,3 +1,5 @@
+from unittest.mock import MagicMock, patch
+
from enferno.tasks.maintenance import _strip_v, _is_patch_bump
@@ -26,3 +28,69 @@ def test_is_patch_bump_false_for_same():
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,
+ json=lambda: {"tag_name": tag, "html_url": f"https://example/tag/{tag}"},
+ )
+
+
+def test_auto_apply_patch_triggers_wrapper_when_flag_on():
+ 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()
+ 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()
+ sp.run.assert_not_called()
+ notif.create_for_admins.assert_called_once()
+
+
+def test_auto_apply_on_but_minor_bump_still_notifies():
+ 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, "Notification") as notif,
+ ):
+ req.get.return_value = _github_response("v4.2.0")
+ maintenance.check_for_updates()
+ sp.run.assert_not_called()
+ notif.create_for_admins.assert_called_once()
From 03bd58dfe02241501eaa8ea29b9ab0cf651e01da Mon Sep 17 00:00:00 2001
From: level09
Date: Sat, 18 Apr 2026 11:56:46 +0200
Subject: [PATCH 027/110] docs(runbook): use sudo for update/snapshots/restore
(require_root)
---
docs/deployment/auto-update-runbook.md | 40 ++++++++++++++------------
1 file changed, 22 insertions(+), 18 deletions(-)
diff --git a/docs/deployment/auto-update-runbook.md b/docs/deployment/auto-update-runbook.md
index e7d49a756..f6a598588 100644
--- a/docs/deployment/auto-update-runbook.md
+++ b/docs/deployment/auto-update-runbook.md
@@ -7,9 +7,10 @@ in the development spec (not shipped with the repo).
- **One-click from UI:** an admin-role user clicks "Update now" from the
"Update available: X.Y.Z" banner in the nav bar.
-- **From the shell:** `sudo -u bayanat bayanat update []`
- (defaults to the latest GitHub release).
-- **Check only (no changes):** `sudo -u bayanat bayanat update --check`.
+- **From the shell (as root):** `sudo bayanat update []`
+ (defaults to the latest GitHub release). The CLI requires root to stop
+ / start services, write `/opt/bayanat`, and take snapshots.
+- **Check only (no changes, no root):** `sudo -u bayanat bayanat update --check`.
The update runs as `bayanat-update.service`, a transient systemd unit
that outlives Flask restarts, SSH disconnects, and browser closes. Tail
@@ -64,9 +65,9 @@ maintenance flag stays up so users see a 502 instead of raw errors.
Recover:
```
-sudo -u bayanat bayanat status # confirm state
-sudo -u bayanat bayanat snapshots # list snapshots
-sudo -u bayanat bayanat restore pre-.dump # restores DB
+sudo -u bayanat bayanat status # read-only; confirm state
+sudo bayanat snapshots # list snapshots (needs root)
+sudo bayanat restore pre-.dump # restores DB (needs root)
sudo systemctl start bayanat bayanat-celery
```
@@ -75,7 +76,7 @@ Then file a bug with journal logs from `journalctl -u bayanat-update`.
### Stuck state (process died, state file orphaned)
```
-sudo -u bayanat bayanat update --recover
+sudo bayanat update --recover
```
## Snapshots
@@ -84,11 +85,11 @@ sudo -u bayanat bayanat update --recover
- Format: `pg_dump -Fc` (PostgreSQL custom format)
- Retention: last 5 snapshots OR last 30 days, whichever is greater
- Override retention: `export BAYANAT_SNAPSHOT_RETENTION_DAYS=60`
-- List: `sudo -u bayanat bayanat snapshots` or visit
- `/admin/snapshots/` in the UI (read-only)
-- Restore: `sudo -u bayanat bayanat restore ` (prompts for
- confirmation; stops services; pipes through `pg_restore --clean
- --if-exists`; restarts services). Not available from the web UI by
+- List: `sudo bayanat snapshots` or visit `/admin/snapshots/` in the UI
+ (read-only)
+- Restore: `sudo bayanat restore ` (prompts for confirmation;
+ stops services; pipes through `pg_restore --clean --if-exists`;
+ restarts services). Requires root. Not available from the web UI by
design.
## Files
@@ -114,11 +115,14 @@ sudo -u bayanat bayanat update --recover
## Manual CLI reference
+Commands marked `(root)` require `sudo bayanat ...`; the others can run
+as the app user via `sudo -u bayanat bayanat ...`.
+
```
-bayanat update [] # default: latest GitHub release
-bayanat update --check # show current vs latest; no changes
-bayanat update --recover # recover a stuck state file
-bayanat snapshots # list pre-update snapshots
-bayanat restore # interactive restore from a snapshot
-bayanat status # version + services + update state
+bayanat update [] (root) default: latest GitHub release
+bayanat update --check show current vs latest; no changes
+bayanat update --recover (root) recover a stuck state file
+bayanat snapshots (root) list pre-update snapshots
+bayanat restore (root) interactive restore from a snapshot
+bayanat status version + services + update state
```
From b4d5e271e7c983e0bf7922ea82b14ed4d26b8c0d Mon Sep 17 00:00:00 2001
From: level09
Date: Sat, 18 Apr 2026 12:23:16 +0200
Subject: [PATCH 028/110] fix(installer): use pg socket by default,
self-install CLI to /usr/local/bin, exempt /health from setup redirect
---
bayanat | 22 +++++++++++++++++-----
enferno/setup/views.py | 6 +++---
2 files changed, 20 insertions(+), 8 deletions(-)
diff --git a/bayanat b/bayanat
index 357439269..591e67a6e 100755
--- a/bayanat
+++ b/bayanat
@@ -113,7 +113,7 @@ snapshot_pg_dump() {
eval "$(_pg_env)"
PGPASSWORD="${POSTGRES_PASSWORD:-}" \
pg_dump -Fc \
- -h "${POSTGRES_HOST:-localhost}" \
+ -h "${POSTGRES_HOST:-/var/run/postgresql}" \
-p "${POSTGRES_PORT:-5432}" \
-U "${POSTGRES_USER:-$APP_USER}" \
-f "$partial" \
@@ -175,7 +175,7 @@ restore_pg() {
eval "$(_pg_env)"
PGPASSWORD="${POSTGRES_PASSWORD:-}" \
pg_restore --clean --if-exists \
- -h "${POSTGRES_HOST:-localhost}" \
+ -h "${POSTGRES_HOST:-/var/run/postgresql}" \
-p "${POSTGRES_PORT:-5432}" \
-U "${POSTGRES_USER:-$APP_USER}" \
-d "${POSTGRES_DB:-$APP_USER}" \
@@ -339,7 +339,7 @@ _db_ping() {
(
eval "$(_pg_env)"
PGPASSWORD="${POSTGRES_PASSWORD:-}" \
- psql -h "${POSTGRES_HOST:-localhost}" \
+ psql -h "${POSTGRES_HOST:-/var/run/postgresql}" \
-U "${POSTGRES_USER:-$APP_USER}" -d "${POSTGRES_DB:-$APP_USER}" \
-c 'SELECT 1' -t >/dev/null 2>&1
)
@@ -374,7 +374,7 @@ update_preflight() {
server_major=$(
eval "$(_pg_env)"
PGPASSWORD="${POSTGRES_PASSWORD:-}" \
- psql -h "${POSTGRES_HOST:-localhost}" \
+ psql -h "${POSTGRES_HOST:-/var/run/postgresql}" \
-U "${POSTGRES_USER:-$APP_USER}" -d "${POSTGRES_DB:-$APP_USER}" \
-c 'SHOW server_version_num' -t 2>/dev/null \
| tr -d ' ' | cut -c1-2
@@ -628,7 +628,7 @@ FORCE_HTTPS=$force_https
LOG_DIR=$LOGS_DIR
BACKUPS_LOCAL_PATH=$SHARED_DIR/backups
-POSTGRES_HOST=localhost
+POSTGRES_HOST=/var/run/postgresql
POSTGRES_PORT=5432
POSTGRES_USER=$APP_USER
POSTGRES_PASSWORD=
@@ -791,6 +791,17 @@ exec /usr/bin/systemd-run \
EOF
}
+_install_self() {
+ # Copy this script to /usr/local/bin/bayanat so sudoers grants +
+ # bayanat-start-update can invoke it by absolute path. Safe to re-run:
+ # `install -m 0755` replaces atomically.
+ local src
+ src=$(readlink -f "$0")
+ [[ -f "$src" ]] || die "cannot resolve installer source path"
+ install -m 0755 -o root -g root "$src" /usr/local/bin/bayanat
+ log "Installed CLI at /usr/local/bin/bayanat"
+}
+
# --- Install ---
cmd_install() {
@@ -841,6 +852,7 @@ cmd_install() {
_configure_caddy "$domain"
_install_sudoers
_install_update_wrapper
+ _install_self
chown -R "$APP_USER:$APP_USER" "$BAYANAT_ROOT"
systemctl daemon-reload
diff --git a/enferno/setup/views.py b/enferno/setup/views.py
index 295dc077c..1e1f8cbc5 100644
--- a/enferno/setup/views.py
+++ b/enferno/setup/views.py
@@ -4,7 +4,6 @@
request,
redirect,
Blueprint,
- send_from_directory,
render_template,
Response,
current_app,
@@ -49,6 +48,7 @@ def handle_installation_check() -> Optional[Response]:
"/api/complete-setup",
"/admin/api/reload",
"/fs-static",
+ "/health",
]
login_flow_paths = [
"/login",
@@ -101,7 +101,7 @@ def create_admin() -> Any:
message="Admin user installed successfully",
data={"item": new_admin.to_dict()},
)
- except Exception as e:
+ except Exception:
db.session.rollback()
return HTTPResponse.error("Failed to create admin user", status=500)
@@ -123,7 +123,7 @@ def import_data() -> Response:
try:
import_default_data()
return HTTPResponse.success(message="Default data imported successfully")
- except Exception as e:
+ except Exception:
return HTTPResponse.error("Failed to import default data", status=500)
From 3df979ef3af3f8f0e2b9a1aced4a4477a24a0154 Mon Sep 17 00:00:00 2001
From: level09
Date: Sat, 18 Apr 2026 12:29:33 +0200
Subject: [PATCH 029/110] fix(cli): use socket peer auth for
pg_dump/psql/pg_restore when localhost+no-password (matches backup_utils.py)
---
bayanat | 76 +++++++++++++++++++++++++++++++++++++++------------------
1 file changed, 52 insertions(+), 24 deletions(-)
diff --git a/bayanat b/bayanat
index 591e67a6e..0c85a6e97 100755
--- a/bayanat
+++ b/bayanat
@@ -102,6 +102,8 @@ _pg_env() {
snapshot_pg_dump() {
# $1 = previous tag, $2 = target tag
# Writes shared/backups/pre--to--.dump via .partial rename.
+ # Mirrors enferno/utils/backup_utils.py:pg_dump logic: localhost + no
+ # password -> socket peer auth; else TCP with PGPASSWORD.
local prev="$1" target="$2"
local ts name partial final
ts=$(date -u +%Y%m%d-%H%M)
@@ -111,13 +113,20 @@ snapshot_pg_dump() {
log "Taking pre-update snapshot: $name"
(
eval "$(_pg_env)"
- PGPASSWORD="${POSTGRES_PASSWORD:-}" \
- pg_dump -Fc \
- -h "${POSTGRES_HOST:-/var/run/postgresql}" \
- -p "${POSTGRES_PORT:-5432}" \
- -U "${POSTGRES_USER:-$APP_USER}" \
- -f "$partial" \
- "${POSTGRES_DB:-$APP_USER}"
+ local user="${POSTGRES_USER:-$APP_USER}"
+ local db="${POSTGRES_DB:-$APP_USER}"
+ local host="${POSTGRES_HOST:-localhost}"
+ if [[ "$host" == "localhost" && -z "${POSTGRES_PASSWORD:-}" ]]; then
+ sudo -u "$user" pg_dump -Fc -f "$partial" "$db"
+ else
+ PGPASSWORD="${POSTGRES_PASSWORD:-}" \
+ pg_dump -Fc \
+ -h "$host" \
+ -p "${POSTGRES_PORT:-5432}" \
+ -U "$user" \
+ -f "$partial" \
+ "$db"
+ fi
)
mv -Tf "$partial" "$final"
echo "$name"
@@ -173,13 +182,20 @@ restore_pg() {
systemctl stop bayanat bayanat-celery
if ! (
eval "$(_pg_env)"
- PGPASSWORD="${POSTGRES_PASSWORD:-}" \
- pg_restore --clean --if-exists \
- -h "${POSTGRES_HOST:-/var/run/postgresql}" \
- -p "${POSTGRES_PORT:-5432}" \
- -U "${POSTGRES_USER:-$APP_USER}" \
- -d "${POSTGRES_DB:-$APP_USER}" \
- "$path"
+ local user="${POSTGRES_USER:-$APP_USER}"
+ local db="${POSTGRES_DB:-$APP_USER}"
+ local host="${POSTGRES_HOST:-localhost}"
+ if [[ "$host" == "localhost" && -z "${POSTGRES_PASSWORD:-}" ]]; then
+ sudo -u "$user" pg_restore --clean --if-exists -d "$db" "$path"
+ else
+ PGPASSWORD="${POSTGRES_PASSWORD:-}" \
+ pg_restore --clean --if-exists \
+ -h "$host" \
+ -p "${POSTGRES_PORT:-5432}" \
+ -U "$user" \
+ -d "$db" \
+ "$path"
+ fi
); then
systemctl start bayanat bayanat-celery
die "pg_restore failed; services restarted on existing DB"
@@ -338,10 +354,16 @@ _socket_health() {
_db_ping() {
(
eval "$(_pg_env)"
- PGPASSWORD="${POSTGRES_PASSWORD:-}" \
- psql -h "${POSTGRES_HOST:-/var/run/postgresql}" \
- -U "${POSTGRES_USER:-$APP_USER}" -d "${POSTGRES_DB:-$APP_USER}" \
- -c 'SELECT 1' -t >/dev/null 2>&1
+ local user="${POSTGRES_USER:-$APP_USER}"
+ local db="${POSTGRES_DB:-$APP_USER}"
+ local host="${POSTGRES_HOST:-localhost}"
+ if [[ "$host" == "localhost" && -z "${POSTGRES_PASSWORD:-}" ]]; then
+ sudo -u "$user" psql -d "$db" -c 'SELECT 1' -t >/dev/null 2>&1
+ else
+ PGPASSWORD="${POSTGRES_PASSWORD:-}" \
+ psql -h "$host" -U "$user" -d "$db" \
+ -c 'SELECT 1' -t >/dev/null 2>&1
+ fi
)
}
@@ -373,11 +395,17 @@ update_preflight() {
client_major=$(pg_dump --version | awk '{print $3}' | cut -d. -f1)
server_major=$(
eval "$(_pg_env)"
- PGPASSWORD="${POSTGRES_PASSWORD:-}" \
- psql -h "${POSTGRES_HOST:-/var/run/postgresql}" \
- -U "${POSTGRES_USER:-$APP_USER}" -d "${POSTGRES_DB:-$APP_USER}" \
- -c 'SHOW server_version_num' -t 2>/dev/null \
- | tr -d ' ' | cut -c1-2
+ local user="${POSTGRES_USER:-$APP_USER}"
+ local db="${POSTGRES_DB:-$APP_USER}"
+ local host="${POSTGRES_HOST:-localhost}"
+ if [[ "$host" == "localhost" && -z "${POSTGRES_PASSWORD:-}" ]]; then
+ sudo -u "$user" psql -d "$db" \
+ -c 'SHOW server_version_num' -t 2>/dev/null
+ else
+ PGPASSWORD="${POSTGRES_PASSWORD:-}" \
+ psql -h "$host" -U "$user" -d "$db" \
+ -c 'SHOW server_version_num' -t 2>/dev/null
+ fi | tr -d ' ' | cut -c1-2
)
[[ -n "$client_major" && -n "$server_major" ]] \
|| die "could not determine pg_dump/server versions"
@@ -628,7 +656,7 @@ FORCE_HTTPS=$force_https
LOG_DIR=$LOGS_DIR
BACKUPS_LOCAL_PATH=$SHARED_DIR/backups
-POSTGRES_HOST=/var/run/postgresql
+POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=$APP_USER
POSTGRES_PASSWORD=
From 487a1b234bc94c5e5df5d7fd9e83b7b2cb42f50f Mon Sep 17 00:00:00 2001
From: level09
Date: Sat, 18 Apr 2026 12:42:12 +0200
Subject: [PATCH 030/110] fix(cli): chown cloned release to bayanat so
_install_deps (uv sync) can write .venv
---
bayanat | 1 +
1 file changed, 1 insertion(+)
diff --git a/bayanat b/bayanat
index 0c85a6e97..ce170b39e 100755
--- a/bayanat
+++ b/bayanat
@@ -631,6 +631,7 @@ _clone_release() {
log "Cloning $tag..."
git clone --depth 1 --branch "$tag" "$GIT_URL" "$dest"
+ chown -R "$APP_USER:$APP_USER" "$dest"
}
_generate_env() {
From db0e227b338e33ee5e03db95a60cba8b152200d1 Mon Sep 17 00:00:00 2001
From: level09
Date: Sat, 18 Apr 2026 12:45:51 +0200
Subject: [PATCH 031/110] fix(cli): redirect snapshot log to stderr so
STATE_SNAPSHOT captures only filename
---
bayanat | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/bayanat b/bayanat
index ce170b39e..3940030db 100755
--- a/bayanat
+++ b/bayanat
@@ -110,7 +110,9 @@ snapshot_pg_dump() {
name="pre-${prev}-to-${target}-${ts}.dump"
partial="$SHARED_DIR/backups/${name}.partial"
final="$SHARED_DIR/backups/${name}"
- log "Taking pre-update snapshot: $name"
+ # Log goes to stderr so `$(snapshot_pg_dump ...)` captures only the
+ # basename from the trailing `echo "$name"`.
+ log "Taking pre-update snapshot: $name" >&2
(
eval "$(_pg_env)"
local user="${POSTGRES_USER:-$APP_USER}"
From f658b23edf240329760bdd620ffefb63be49c278 Mon Sep 17 00:00:00 2001
From: level09
Date: Sat, 18 Apr 2026 13:09:26 +0200
Subject: [PATCH 032/110] fix(security): copy CLI from release (not $0) and
require fresh auth for update start
_install_self previously relied on $0 to locate the installer script.
Under the standard 'curl ... | sudo bash -s install' pipe pattern, $0 is
'bash' and readlink resolves to the shell binary, which would then be
installed as /usr/local/bin/bayanat. Every subsequent CLI invocation and
wrapper-driven update would exec bash with 'update' as argv. Silent.
Catastrophic on first use.
Fix: _install_self now takes a tag arg and copies from
$RELEASES_DIR/$tag/bayanat (the just-cloned release, known-good source).
Also called in do_switch_verify's SUCCESS branch so the system CLI stays
in lockstep with the active release after every successful update.
Separately: @auth_required(within=15, grace=0) on POST /api/updates/start
matches the freshness discipline already used by /system-administration/
and other sensitive admin endpoints. A stale cookie is no longer enough
to trigger a privileged update.
---
bayanat | 19 ++++++++++++-------
enferno/admin/views/system.py | 8 +++++++-
tests/test_update_endpoints.py | 21 +++++++++++++++++++++
3 files changed, 40 insertions(+), 8 deletions(-)
diff --git a/bayanat b/bayanat
index 3940030db..ba26bf73b 100755
--- a/bayanat
+++ b/bayanat
@@ -488,6 +488,10 @@ do_switch_verify() {
write_state SWITCH_DONE "Verifying new release"
export STATE_PROGRESS="Waiting for health probe"
if _wait_healthy 60; then
+ # Refresh /usr/local/bin/bayanat from the now-active release so the
+ # system CLI stays in lockstep with the deployed code. Done AFTER the
+ # health probe so a bad release never overwrites a working CLI.
+ _install_self "$target"
export STATE_PROGRESS="Update successful"
write_state SUCCESS "Update to $target complete"
clear_state
@@ -823,12 +827,13 @@ EOF
}
_install_self() {
- # Copy this script to /usr/local/bin/bayanat so sudoers grants +
- # bayanat-start-update can invoke it by absolute path. Safe to re-run:
- # `install -m 0755` replaces atomically.
- local src
- src=$(readlink -f "$0")
- [[ -f "$src" ]] || die "cannot resolve installer source path"
+ # Copy the bayanat CLI from the given release directory to
+ # /usr/local/bin/bayanat. Source is $RELEASES_DIR/$tag/bayanat, NOT $0 —
+ # under `curl ... | sudo bash -s install`, $0 is "bash" and readlink
+ # resolves to the shell binary. $1 = tag.
+ local tag="${1:?_install_self requires a tag arg}"
+ local src="$RELEASES_DIR/$tag/bayanat"
+ [[ -f "$src" ]] || die "cannot find CLI source at $src"
install -m 0755 -o root -g root "$src" /usr/local/bin/bayanat
log "Installed CLI at /usr/local/bin/bayanat"
}
@@ -883,7 +888,7 @@ cmd_install() {
_configure_caddy "$domain"
_install_sudoers
_install_update_wrapper
- _install_self
+ _install_self "$tag"
chown -R "$APP_USER:$APP_USER" "$BAYANAT_ROOT"
systemctl daemon-reload
diff --git a/enferno/admin/views/system.py b/enferno/admin/views/system.py
index 7b743e3a1..e6c146052 100644
--- a/enferno/admin/views/system.py
+++ b/enferno/admin/views/system.py
@@ -234,9 +234,15 @@ def api_updates_available() -> Response:
@admin.post("/api/updates/start")
+@auth_required(within=15, grace=0)
@roles_required("Admin")
def api_updates_start() -> Response:
- """Launch `bayanat update` out-of-process via the sudoers-granted wrapper."""
+ """Launch `bayanat update` out-of-process via the sudoers-granted wrapper.
+
+ Fresh-auth required (within 15 min) to limit stale-cookie exposure: a
+ compromised admin session cannot trigger a privileged update without a
+ recent password prompt.
+ """
try:
subprocess.run(
["sudo", "-n", "/usr/local/sbin/bayanat-start-update"],
diff --git a/tests/test_update_endpoints.py b/tests/test_update_endpoints.py
index 71bd82aa1..5f71d849a 100644
--- a/tests/test_update_endpoints.py
+++ b/tests/test_update_endpoints.py
@@ -1,7 +1,17 @@
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):
from enferno.extensions import rds
@@ -64,6 +74,7 @@ def test_status_terminal_when_success(admin_client, tmp_path, monkeypatch):
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
@@ -72,7 +83,17 @@ def test_start_calls_wrapper(admin_client):
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)
From a69b6125d5d2835a87f738af72c8804c4d1498a2 Mon Sep 17 00:00:00 2001
From: level09
Date: Sat, 18 Apr 2026 13:21:22 +0200
Subject: [PATCH 033/110] fix(security): safe .env parser (no eval) + correct
snapshots-UI restore command
_pg_env previously emitted 'export KEY=VALUE' lines from shared/.env and
callers ran eval on the output. A malicious .env value (writable by the
bayanat user) could inject shell commands that run as root under
'bayanat update'. Lateral-to-root escalation path.
Replace _pg_env with _pg_load: parses POSTGRES_* keys natively in bash
and uses 'export "$key=$val"' which never interprets the value.
Verified a malicious value like 'foo; echo PWNED > /tmp/...' is stored
as a literal password instead of being executed.
Separately: the snapshots UI showed 'sudo -u bayanat bayanat restore',
which hits require_root and fails. Matches the same bug class as P4
(runbook) that was fixed in docs but missed in the Vue component.
Updated to 'sudo bayanat restore '.
---
bayanat | 36 ++++++++++++++-----
enferno/static/js/components/SnapshotsList.js | 3 +-
2 files changed, 29 insertions(+), 10 deletions(-)
diff --git a/bayanat b/bayanat
index ba26bf73b..c5d350958 100755
--- a/bayanat
+++ b/bayanat
@@ -90,13 +90,31 @@ swap_symlink() {
SNAPSHOT_RETENTION_DAYS="${BAYANAT_SNAPSHOT_RETENTION_DAYS:-30}"
readonly SNAPSHOT_RETENTION_COUNT=5
-_pg_env() {
- # Emits pg_dump / pg_restore env from shared/.env. All callers must
- # `eval "$(_pg_env)"` in a subshell.
+_pg_load() {
+ # Loads POSTGRES_* from shared/.env into the current shell env WITHOUT
+ # eval. Must be invoked inside a subshell so the exports do not leak.
+ #
+ # Why no eval: a malicious .env value (writable by the bayanat user)
+ # could smuggle shell commands that would run as root under `bayanat
+ # update`. `export "$key=$val"` treats $val as a literal string, no
+ # interpretation.
local env_file="$SHARED_DIR/.env"
[[ -f "$env_file" ]] || die "missing $env_file"
- grep -E '^(POSTGRES_HOST|POSTGRES_PORT|POSTGRES_USER|POSTGRES_PASSWORD|POSTGRES_DB)=' \
- "$env_file" | sed 's/^/export /'
+ local key val
+ while IFS='=' read -r key val; do
+ case "$key" in
+ POSTGRES_HOST|POSTGRES_PORT|POSTGRES_USER|POSTGRES_PASSWORD|POSTGRES_DB)
+ # Strip matching surrounding quotes (common .env convention).
+ if [[ ${#val} -ge 2 ]]; then
+ if [[ "${val:0:1}" == '"' && "${val: -1}" == '"' ]] \
+ || [[ "${val:0:1}" == "'" && "${val: -1}" == "'" ]]; then
+ val="${val:1:${#val}-2}"
+ fi
+ fi
+ export "$key=$val"
+ ;;
+ esac
+ done < "$env_file"
}
snapshot_pg_dump() {
@@ -114,7 +132,7 @@ snapshot_pg_dump() {
# basename from the trailing `echo "$name"`.
log "Taking pre-update snapshot: $name" >&2
(
- eval "$(_pg_env)"
+ _pg_load
local user="${POSTGRES_USER:-$APP_USER}"
local db="${POSTGRES_DB:-$APP_USER}"
local host="${POSTGRES_HOST:-localhost}"
@@ -183,7 +201,7 @@ restore_pg() {
[[ "$reply" == "yes" ]] || die "aborted"
systemctl stop bayanat bayanat-celery
if ! (
- eval "$(_pg_env)"
+ _pg_load
local user="${POSTGRES_USER:-$APP_USER}"
local db="${POSTGRES_DB:-$APP_USER}"
local host="${POSTGRES_HOST:-localhost}"
@@ -355,7 +373,7 @@ _socket_health() {
_db_ping() {
(
- eval "$(_pg_env)"
+ _pg_load
local user="${POSTGRES_USER:-$APP_USER}"
local db="${POSTGRES_DB:-$APP_USER}"
local host="${POSTGRES_HOST:-localhost}"
@@ -396,7 +414,7 @@ update_preflight() {
local client_major server_major
client_major=$(pg_dump --version | awk '{print $3}' | cut -d. -f1)
server_major=$(
- eval "$(_pg_env)"
+ _pg_load
local user="${POSTGRES_USER:-$APP_USER}"
local db="${POSTGRES_DB:-$APP_USER}"
local host="${POSTGRES_HOST:-localhost}"
diff --git a/enferno/static/js/components/SnapshotsList.js b/enferno/static/js/components/SnapshotsList.js
index 018f3267c..e704c199c 100644
--- a/enferno/static/js/components/SnapshotsList.js
+++ b/enferno/static/js/components/SnapshotsList.js
@@ -31,7 +31,8 @@ const SnapshotsList = Vue.defineComponent({
Restore is CLI-only for safety. SSH to the server and run
- sudo -u bayanat bayanat restore <name>.
+ sudo bayanat restore <name> (needs root; it stops
+ services, runs pg_restore, and starts services again).
Date: Sat, 18 Apr 2026 17:53:24 +0200
Subject: [PATCH 034/110] test: add end-to-end auto-update test script for
Hetzner VMs
Codifies the manual 45-min ad-hoc test into a single-command script.
Provisions a disposable CPX22 Ubuntu 24.04 VM, installs via curl|bash
from the test fork's v4.0.0 tag, runs S1-S4 (happy / bad-migration /
bad-health / recovery), asserts final state per scenario, tears down.
Env knobs:
KEEP_VM=1 leave VM running for iteration
VM_IP=x.y.z.w reuse existing VM, skip provision/install
SCENARIOS='S1 S2' run subset
TEST_FORK=you/yourfork point at a different test fork
Requires hcloud CLI + ssh-agent loaded + gh CLI authenticated. Full run
~8-10 minutes, costs well under one cent per cycle.
---
e2e-auto-update.sh | 229 +++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 229 insertions(+)
create mode 100755 e2e-auto-update.sh
diff --git a/e2e-auto-update.sh b/e2e-auto-update.sh
new file mode 100755
index 000000000..8f9b3a5a0
--- /dev/null
+++ b/e2e-auto-update.sh
@@ -0,0 +1,229 @@
+#!/usr/bin/env bash
+#
+# End-to-end test for the `bayanat update` pipeline on a disposable
+# Hetzner VM. Provisions → installs → runs S1-S4 → teardown.
+#
+# Requires:
+# - hcloud CLI authenticated to the right project (see `hcloud context list`)
+# - ssh-agent loaded with the private key matching the registered hcloud key
+# - a public test fork with tags v4.0.0 (baseline), v4.0.1 (additive),
+# v4.0.2 (bad migration), v4.0.3 (/health 503 at runtime), v4.0.4 (recovery)
+#
+# Usage:
+# ./e2e-auto-update.sh # full run: provision → S1-S4 → destroy
+# KEEP_VM=1 ./e2e-auto-update.sh # leave VM running at end
+# VM_IP=1.2.3.4 ./e2e-auto-update.sh # reuse an existing VM (skip provision)
+# SCENARIOS="S1 S2" ./e2e-auto-update.sh # run a subset
+# TEST_FORK=you/yourfork ./e2e-auto-update.sh
+#
+set -euo pipefail
+
+# --- Config ---
+TEST_FORK="${TEST_FORK:-level09/bayanat-update-test}"
+SSH_KEY="${SSH_KEY:-level09@Black09}"
+SERVER_TYPE="${SERVER_TYPE:-cpx22}"
+LOCATION="${LOCATION:-nbg1}"
+SCENARIOS="${SCENARIOS:-S1 S2 S3 S4}"
+KEEP_VM="${KEEP_VM:-0}"
+VM_IP="${VM_IP:-}"
+SERVER_NAME=""
+
+SSHOPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5"
+
+log() { printf '\n\033[1;34m[%s] %s\033[0m\n' "$(date +%H:%M:%S)" "$*"; }
+pass() { printf '\033[1;32m ✓ %s\033[0m\n' "$*"; }
+fail() { printf '\033[1;31m ✗ %s\033[0m\n' "$*" >&2; exit 1; }
+
+on_vm() { ssh $SSHOPTS "root@$VM_IP" "$@"; }
+
+# --- Prereqs ---
+command -v hcloud >/dev/null || { echo "hcloud CLI not found"; exit 2; }
+command -v gh >/dev/null || { echo "gh CLI not found (needed to rewrite tags)"; exit 2; }
+git ls-remote --tags "https://github.com/$TEST_FORK.git" >/dev/null \
+ || { echo "test fork $TEST_FORK not reachable"; exit 2; }
+
+# --- Tag ladder prep: hide upper tags so installer picks v4.0.0 ---
+stash_upper_tags() {
+ log "Hiding upper tags on $TEST_FORK so installer picks v4.0.0"
+ for t in v4.0.1 v4.0.2 v4.0.3 v4.0.4; do
+ gh api -X DELETE "repos/$TEST_FORK/git/refs/tags/$t" 2>&1 \
+ | grep -v '^$' | head -1 || true
+ done
+}
+
+restore_upper_tags() {
+ log "Restoring upper tags v4.0.1..v4.0.4"
+ # Must push via a configured remote (SSH auth), not an HTTPS URL.
+ for t in v4.0.1 v4.0.2 v4.0.3 v4.0.4; do
+ if ! git show-ref --tags --verify --quiet "refs/tags/$t"; then
+ echo " LOCAL TAG MISSING: $t (run the rebase block in the README)"; continue
+ fi
+ git push test-fork "+refs/tags/$t:refs/tags/$t" 2>&1 | tail -1
+ done
+ # Sanity: confirm remote has them
+ local remote_tags
+ remote_tags=$(git ls-remote --tags test-fork | awk '{print $2}' | grep -E 'v4\.0\.[1-4]$' | wc -l | tr -d ' ')
+ [[ "$remote_tags" == "4" ]] || fail "expected 4 upper tags on remote, found $remote_tags"
+ pass "4 upper tags visible on remote"
+}
+
+# --- Provision ---
+provision() {
+ SERVER_NAME="bayanat-update-test-$(date +%Y%m%d-%H%M%S)"
+ log "Provisioning Hetzner VM $SERVER_NAME ($SERVER_TYPE in $LOCATION)"
+ VM_IP=$(hcloud server create \
+ --name "$SERVER_NAME" \
+ --type "$SERVER_TYPE" \
+ --image ubuntu-24.04 \
+ --ssh-key "$SSH_KEY" \
+ --location "$LOCATION" \
+ -o json | python3 -c 'import json,sys; print(json.load(sys.stdin)["server"]["public_net"]["ipv4"]["ip"])')
+ log "IP: $VM_IP"
+ log "Waiting for SSH..."
+ until on_vm 'true' 2>/dev/null; do sleep 3; done
+ pass "SSH ready"
+}
+
+teardown() {
+ if [[ "$KEEP_VM" == "1" ]]; then
+ log "KEEP_VM=1 — leaving $SERVER_NAME (IP $VM_IP) alive"
+ return
+ fi
+ if [[ -n "$SERVER_NAME" ]]; then
+ log "Destroying $SERVER_NAME"
+ hcloud server delete "$SERVER_NAME" >/dev/null
+ pass "destroyed"
+ fi
+}
+
+# --- Install ---
+install_baseline() {
+ log "Installing v4.0.0 via curl | sudo bash -s install (validates \$0-free install)"
+ on_vm 'echo "BAYANAT_REPO='"$TEST_FORK"'" >> /etc/environment'
+ on_vm 'curl -fsSL https://raw.githubusercontent.com/'"$TEST_FORK"'/v4.0.0/bayanat | BAYANAT_REPO='"$TEST_FORK"' sudo -E bash -s install localhost' \
+ >/tmp/e2e-install.log 2>&1 \
+ || { tail -30 /tmp/e2e-install.log; fail "install failed"; }
+ pass "install succeeded"
+
+ # Work around the SETUP_COMPLETE gating until that lands in installer
+ on_vm 'echo "BAYANAT_CONFIG_FILE=/opt/bayanat/shared/config.json" >> /opt/bayanat/shared/.env
+ echo "{\"SETUP_COMPLETE\": true}" > /opt/bayanat/shared/config.json
+ chown bayanat:bayanat /opt/bayanat/shared/config.json
+ systemctl restart bayanat bayanat-celery'
+ sleep 3
+
+ local health
+ health=$(on_vm 'curl -s --unix-socket /opt/bayanat/current/bayanat.sock http://localhost/health')
+ [[ "$health" == *'"status":"ok"'* ]] || fail "/health not ok: $health"
+ pass "/health OK: $health"
+
+ local cur
+ cur=$(on_vm 'bayanat status | grep "Current version" | awk "{print \$3}"')
+ [[ "$cur" == "v4.0.0" ]] || fail "expected v4.0.0, got $cur"
+ pass "installed version: $cur"
+}
+
+# --- Scenario helpers ---
+assert_version() {
+ local expected="$1"
+ local actual
+ actual=$(on_vm 'bayanat status | grep "Current version" | awk "{print \$3}"')
+ [[ "$actual" == "$expected" ]] || fail "expected version $expected, got $actual"
+ pass "version = $expected"
+}
+
+assert_state() {
+ local expected="$1"
+ local actual
+ actual=$(on_vm 'bayanat status | grep "Update state" | awk "{print \$3}"')
+ [[ "$actual" == "$expected" ]] || fail "expected state $expected, got $actual"
+ pass "update state = $expected"
+}
+
+assert_state_file_phase() {
+ local expected="$1"
+ local phase
+ phase=$(on_vm 'python3 -c "import json; print(json.load(open(\"/opt/bayanat/state/update.json\")).get(\"phase\",\"\"))"' 2>/dev/null || echo "")
+ [[ "$phase" == "$expected" ]] || fail "expected state file phase $expected, got '$phase'"
+ pass "state file phase = $expected"
+}
+
+assert_services_active() {
+ on_vm 'systemctl is-active --quiet bayanat bayanat-celery caddy' \
+ || fail "services not all active"
+ pass "services all active"
+}
+
+clear_state_file() {
+ on_vm 'rm -f /opt/bayanat/state/update.json /opt/bayanat/state/update.lock'
+}
+
+run_update() {
+ local tag="$1"
+ log " -> bayanat update $tag"
+ on_vm 'sudo BAYANAT_REPO='"$TEST_FORK"' /usr/local/bin/bayanat update '"$tag" \
+ >/tmp/e2e-update.log 2>&1 || true # we inspect state, exit code is scenario-dependent
+ tail -5 /tmp/e2e-update.log | sed 's/^/ /'
+}
+
+# --- Scenarios ---
+S1() {
+ log "S1: happy path v4.0.0 -> v4.0.1"
+ run_update v4.0.1
+ assert_version v4.0.1
+ assert_state IDLE
+ assert_services_active
+ on_vm 'sudo -u bayanat psql -d bayanat -c "\d bulletin" | grep -q auto_update_test' \
+ || fail "auto_update_test column missing"
+ pass "auto_update_test column present"
+}
+
+S2() {
+ log "S2: bad migration v4.0.1 -> v4.0.2 -> NEEDS_INTERVENTION"
+ run_update v4.0.2
+ assert_version v4.0.1
+ assert_state_file_phase NEEDS_INTERVENTION
+ assert_services_active
+ clear_state_file
+}
+
+S3() {
+ log "S3: bad /health v4.0.1 -> v4.0.3 -> ROLLED_BACK"
+ run_update v4.0.3
+ assert_version v4.0.1
+ assert_state IDLE
+ assert_services_active
+ local health
+ health=$(on_vm 'curl -s --unix-socket /opt/bayanat/current/bayanat.sock http://localhost/health')
+ [[ "$health" == *'"status":"ok"'* ]] || fail "/health not ok after rollback"
+ pass "/health back to OK after rollback"
+}
+
+S4() {
+ log "S4: recovery v4.0.1 -> v4.0.4"
+ run_update v4.0.4
+ assert_version v4.0.4
+ assert_state IDLE
+ assert_services_active
+ on_vm 'sudo -u bayanat psql -d bayanat -c "\d bulletin" | grep -q auto_update_recovery_test' \
+ || fail "auto_update_recovery_test column missing"
+ pass "auto_update_recovery_test column present"
+}
+
+# --- Main ---
+trap 'restore_upper_tags; teardown' EXIT
+
+if [[ -z "$VM_IP" ]]; then
+ stash_upper_tags
+ provision
+ install_baseline
+ restore_upper_tags
+else
+ log "Reusing existing VM at $VM_IP"
+fi
+
+for s in $SCENARIOS; do
+ "$s"
+done
+
+log "ALL PASSED"
From 036c500b741e05703c6ee9773b5dd12b41c043d3 Mon Sep 17 00:00:00 2001
From: level09
Date: Sun, 19 Apr 2026 11:31:39 +0200
Subject: [PATCH 035/110] fix(tests): add AUTO_APPLY_PATCH_UPDATES to
TestConfig
---
enferno/settings.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/enferno/settings.py b/enferno/settings.py
index c116bc6de..f9db8f882 100644
--- a/enferno/settings.py
+++ b/enferno/settings.py
@@ -611,6 +611,9 @@ class TestConfig:
# Notifications
NOTIFICATIONS = NOTIFICATIONS_DEFAULT_CONFIG
+ # Auto-update
+ AUTO_APPLY_PATCH_UPDATES = False
+
# Dependencies (from dep_utils)
HAS_WHISPER = dep_utils.has_whisper # Use actual dependency detection
HAS_TESSERACT = dep_utils.has_tesseract # Use actual dependency detection
From 06856c7f79ae3aa065bdb0f4ba167c5e078a548f Mon Sep 17 00:00:00 2001
From: Nidal Alhariri
Date: Sun, 19 Apr 2026 11:48:46 +0200
Subject: [PATCH 036/110] Potential fix for pull request finding 'CodeQL /
Information exposure through an exception'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
---
enferno/public/views.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/enferno/public/views.py b/enferno/public/views.py
index 92175efd2..5bee348a2 100755
--- a/enferno/public/views.py
+++ b/enferno/public/views.py
@@ -40,9 +40,9 @@ def health() -> Response:
try:
db.session.execute(text("SELECT 1"))
rds.ping()
- except Exception as e:
- logger.error(f"health check failed: {e}")
- return jsonify({"status": "error", "error": str(e)[:120]}), 503
+ except Exception:
+ logger.error("health check failed", exc_info=True)
+ return jsonify({"status": "error", "error": "Service unavailable"}), 503
version = os.environ.get("BAYANAT_VERSION") or _read_version_from_pyproject()
return jsonify({"status": "ok", "version": version})
From 8615ff11b89aa81d1cee1e132db7d3a389ce60e5 Mon Sep 17 00:00:00 2001
From: level09
Date: Sun, 19 Apr 2026 12:24:37 +0200
Subject: [PATCH 037/110] fix(tests): call .run() on Celery tasks to bypass
ContextTask app init
---
tests/test_update_check.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/tests/test_update_check.py b/tests/test_update_check.py
index be52edf8a..f0b12a9aa 100644
--- a/tests/test_update_check.py
+++ b/tests/test_update_check.py
@@ -51,7 +51,7 @@ def test_auto_apply_patch_triggers_wrapper_when_flag_on():
patch.object(maintenance, "Notification") as notif,
):
req.get.return_value = _github_response("v4.1.1")
- maintenance.check_for_updates()
+ 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"]
@@ -72,7 +72,7 @@ def test_auto_apply_off_falls_back_to_notification():
patch.object(maintenance, "Notification") as notif,
):
req.get.return_value = _github_response("v4.1.1")
- maintenance.check_for_updates()
+ maintenance.check_for_updates.run()
sp.run.assert_not_called()
notif.create_for_admins.assert_called_once()
@@ -91,6 +91,6 @@ def test_auto_apply_on_but_minor_bump_still_notifies():
patch.object(maintenance, "Notification") as notif,
):
req.get.return_value = _github_response("v4.2.0")
- maintenance.check_for_updates()
+ maintenance.check_for_updates.run()
sp.run.assert_not_called()
notif.create_for_admins.assert_called_once()
From 064fd90e2f899de65464cc5ce49e3c7ccd165adf Mon Sep 17 00:00:00 2001
From: level09
Date: Tue, 21 Apr 2026 13:59:33 +0300
Subject: [PATCH 038/110] 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 039/110] 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 040/110] 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 041/110] 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 042/110] 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 043/110] 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 044/110] 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 045/110] 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 80f56b9bc2def872400bbab2744613e1cd81d25b Mon Sep 17 00:00:00 2001
From: level09
Date: Fri, 1 May 2026 13:43:43 +0300
Subject: [PATCH 046/110] 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 047/110] 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 048/110] 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 049/110] 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 050/110] 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 051/110] 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 076/110] 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 077/110] 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 "
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/enferno/templates/security/wan_verify.html b/enferno/templates/security/wan_verify.html
index 70a80beac..5dc1e0f6f 100644
--- a/enferno/templates/security/wan_verify.html
+++ b/enferno/templates/security/wan_verify.html
@@ -41,7 +41,9 @@