Container security hardening (phase 2) (#24068)

* Create frigate and go2rtc runtime users in the image

* Add single fix-ownership helper for volume permission migration

* Add init-usermod oneshot for PUID and PGID remapping

* Chown newly created runtime directories to the frigate user

* Run sentinel-guarded ownership sweep during prepare

* Add host-side volume permission migration script

* Guard log directory ownership for user-mode startup

* Fall back to plain s6-log when running without root

* Assert PUID remapping and sweep sentinel in CI smoke test

* Skip the ownership sweep in the devcontainer

* Pin FRIGATE_RUN_AS_ROOT in ownership tests

* Do not record the sweep as complete when a chown failed

* Validate PUID and PGID in the migration script

* Treat a failed ownership scan as an incomplete sweep

* Reject PUID and PGID of 0 during remapping

* Handle symlinks, dry runs, and sentinel write failures in the sweep

* Treat an absent sweep root as an incomplete sweep
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent c004d0a1c9
commit 40793b8478
18 changed files with 448 additions and 5 deletions
+2
View File
@@ -86,6 +86,7 @@ from frigate.timeline import TimelineProcessor
from frigate.track.object_processing import TrackedObjectProcessor
from frigate.util.builtin import empty_and_close_queue
from frigate.util.image import UntrackedSharedMemory
from frigate.util.ownership import chown_to_runtime
from frigate.util.process import FrigateProcess
from frigate.util.services import set_file_limit
from frigate.version import VERSION
@@ -149,6 +150,7 @@ class FrigateApp:
if not os.path.exists(d) and not os.path.islink(d):
logger.info(f"Creating directory: {d}")
os.makedirs(d, exist_ok=True)
chown_to_runtime(d)
else:
logger.debug(f"Skipping directory: {d}")
+59
View File
@@ -0,0 +1,59 @@
"""Tests for runtime ownership helpers."""
import unittest
from unittest.mock import patch
from frigate.util import ownership
class FakePwEntry:
pw_uid = 1500
pw_gid = 1500
# The devcontainer image exports FRIGATE_RUN_AS_ROOT, so any test that has to
# reach past the escape-hatch check pins the variable instead of inheriting it.
class TestGetRuntimeIds(unittest.TestCase):
@patch("frigate.util.ownership.os.geteuid", return_value=1000)
def test_returns_none_when_not_root(self, _):
assert ownership.get_runtime_ids() is None
@patch.dict("os.environ", {"FRIGATE_RUN_AS_ROOT": "true"})
@patch("frigate.util.ownership.os.geteuid", return_value=0)
def test_returns_none_with_escape_hatch(self, _):
assert ownership.get_runtime_ids() is None
@patch.dict("os.environ", {"FRIGATE_RUN_AS_ROOT": "false"})
@patch("frigate.util.ownership.pwd.getpwnam", side_effect=KeyError)
@patch("frigate.util.ownership.os.geteuid", return_value=0)
def test_returns_none_outside_frigate_image(self, *_):
assert ownership.get_runtime_ids() is None
@patch.dict("os.environ", {"FRIGATE_RUN_AS_ROOT": "false"})
@patch("frigate.util.ownership.pwd.getpwnam", return_value=FakePwEntry())
@patch("frigate.util.ownership.os.geteuid", return_value=0)
def test_returns_frigate_ids_as_root(self, *_):
assert ownership.get_runtime_ids() == (1500, 1500)
class TestChownToRuntime(unittest.TestCase):
@patch("frigate.util.ownership.os.chown")
@patch("frigate.util.ownership.get_runtime_ids", return_value=None)
def test_noop_when_no_runtime_ids(self, _, chown):
ownership.chown_to_runtime("/config/test")
chown.assert_not_called()
@patch("frigate.util.ownership.os.chown")
@patch("frigate.util.ownership.get_runtime_ids", return_value=(1500, 1500))
def test_chowns_to_runtime_ids(self, _, chown):
ownership.chown_to_runtime("/config/test")
chown.assert_called_once_with("/config/test", 1500, 1500)
@patch("frigate.util.ownership.os.chown", side_effect=OSError("ro fs"))
@patch("frigate.util.ownership.get_runtime_ids", return_value=(1500, 1500))
def test_swallows_oserror(self, *_):
ownership.chown_to_runtime("/config/test") # must not raise
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
"""Helpers for aligning created files with the non-root runtime user."""
import logging
import os
import pwd
logger = logging.getLogger(__name__)
RUNTIME_USER = "frigate"
def get_runtime_ids() -> tuple[int, int] | None:
"""Return (uid, gid) that services run as, or None when chown is not applicable.
None when: not root (docker --user, so the host already mapped us),
FRIGATE_RUN_AS_ROOT=true (escape hatch must not mutate ownership),
or outside the Frigate container image (no frigate user).
"""
if os.geteuid() != 0:
return None
if os.environ.get("FRIGATE_RUN_AS_ROOT", "false") == "true":
return None
try:
user = pwd.getpwnam(RUNTIME_USER)
except KeyError:
return None
return (user.pw_uid, user.pw_gid)
def chown_to_runtime(path: str) -> None:
"""Best-effort chown of path to the runtime user."""
ids = get_runtime_ids()
if ids is None:
return
try:
os.chown(path, *ids)
except OSError as err:
logger.warning(f"Unable to set ownership of {path}: {err}")