From e3cedf38251a0531551162697e277312ec317e37 Mon Sep 17 00:00:00 2001 From: Graham Hosking <142685548+ITSpecialist111@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:10:22 +0100 Subject: [PATCH] Read app options again and release 1.1.1 --- .gitattributes | 5 +++++ .github/workflows/ci.yaml | 13 ++++++++++++- README.md | 2 +- automation_inspector/CHANGELOG.md | 6 ++++++ automation_inspector/Dockerfile | 8 +++++++- automation_inspector/app/__init__.py | 2 +- automation_inspector/config.yaml | 2 +- automation_inspector/docker-entrypoint.sh | 14 ++++++++++++++ tests/test_project_contract.py | 13 ++++++++++++- 9 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 .gitattributes create mode 100644 automation_inspector/docker-entrypoint.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0b3a137 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf + +# Shell scripts run inside Linux containers and must never be checked out with +# CRLF endings, which would make them unrunnable. +*.sh text eol=lf diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c058de1..d800483 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -87,4 +87,15 @@ jobs: - name: Verify image metadata run: | test "$(docker image inspect automation-inspector:ci --format '{{ index .Config.Labels "io.hass.type" }}')" = "app" - test "$(docker image inspect automation-inspector:ci --format '{{ .Config.User }}')" = "inspector" \ No newline at end of file + + - name: Verify the app drops to the unprivileged user + run: | + test "$(docker run --rm automation-inspector:ci id -un)" = "inspector" + + - name: Verify Supervisor-owned options are readable after the privilege drop + run: | + docker volume create ai-ci-data + docker run --rm --entrypoint sh -v ai-ci-data:/data automation-inspector:ci -c \ + 'printf "{\"refresh_interval\": 987}" > /data/options.json && chown root:root /data/options.json && chmod 600 /data/options.json' + test "$(docker run --rm -v ai-ci-data:/data automation-inspector:ci python -c 'from app.settings import Settings; print(Settings.load().refresh_interval)')" = "987" + docker volume rm ai-ci-data \ No newline at end of file diff --git a/README.md b/README.md index 9841041..4f8b9de 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ If a refresh fails after a successful run, the report remains available with the - No telemetry, analytics, CDN, remote font, or third-party JavaScript. - No Home Assistant write/service commands are issued. - Home Assistant configuration is mounted read-only. -- The production container runs as an unprivileged user with `/tmp` on `tmpfs`. +- The production container drops to an unprivileged user before starting the app, with `/tmp` on `tmpfs`. - Direct host-port publication was removed; access is through authenticated, admin-only Ingress. - The frontend uses a strict CSP with a per-response nonce and does not use `innerHTML`. - API responses use ETags, `nosniff`, a restrictive permissions policy, and no wildcard CORS. diff --git a/automation_inspector/CHANGELOG.md b/automation_inspector/CHANGELOG.md index 2c53c5d..91b5e87 100644 --- a/automation_inspector/CHANGELOG.md +++ b/automation_inspector/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.1.1 — 2026-07-28 + +### Fixed + +- Apply the configured App options again. Supervisor writes `/data/options.json` as root with mode `0600`, so the unprivileged container user could not read it and every option silently fell back to its built-in default. The container now stages a readable copy of the options and then drops privileges, so `refresh_interval`, `request_timeout`, `include_disabled`, `inspect_traces`, and `scan_automations_file` take effect. + ## 1.1.0 — 2026-07-28 ### Added diff --git a/automation_inspector/Dockerfile b/automation_inspector/Dockerfile index cf35b54..2202c3f 100644 --- a/automation_inspector/Dockerfile +++ b/automation_inspector/Dockerfile @@ -17,19 +17,25 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ PIP_NO_CACHE_DIR=1 \ + AI_OPTIONS_PATH=/tmp/options.json \ HOME=/tmp WORKDIR /opt/automation-inspector COPY requirements.txt ./requirements.txt RUN python -m pip install --requirement requirements.txt \ + && apk add --no-cache su-exec \ && addgroup -S inspector \ && adduser -S -D -H -G inspector inspector COPY --chown=inspector:inspector app/ ./app/ COPY --chown=inspector:inspector www/ ./www/ +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod 0555 /usr/local/bin/docker-entrypoint.sh -USER inspector +# The entrypoint starts as root only to stage the Supervisor-owned options +# file, then execs the application as the unprivileged inspector user. +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] EXPOSE 1234 STOPSIGNAL SIGTERM diff --git a/automation_inspector/app/__init__.py b/automation_inspector/app/__init__.py index 6ddc460..d87cdf9 100644 --- a/automation_inspector/app/__init__.py +++ b/automation_inspector/app/__init__.py @@ -1,3 +1,3 @@ """Automation Inspector application package.""" -APP_VERSION = "1.1.0" +APP_VERSION = "1.1.1" diff --git a/automation_inspector/config.yaml b/automation_inspector/config.yaml index b14bf8d..361dfac 100644 --- a/automation_inspector/config.yaml +++ b/automation_inspector/config.yaml @@ -1,5 +1,5 @@ name: Automation Inspector -version: 1.1.0 +version: 1.1.1 slug: automation_inspector description: >- Audits automation and script dependencies, targets, compatibility, and recent diff --git a/automation_inspector/docker-entrypoint.sh b/automation_inspector/docker-entrypoint.sh new file mode 100644 index 0000000..423a434 --- /dev/null +++ b/automation_inspector/docker-entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Prepare unprivileged access to the app options, then drop privileges. +# +# Supervisor writes /data/options.json as root with mode 0600 +# (supervisor/utils/json.py: write_json_file -> jsonfile.chmod(0o600)), so the +# unprivileged runtime user cannot read it. Copy it into tmpfs owned by that +# user instead of relaxing permissions on the Supervisor-managed file. +set -eu + +if [ -f /data/options.json ]; then + install -o inspector -g inspector -m 0400 /data/options.json "${AI_OPTIONS_PATH}" +fi + +exec su-exec inspector:inspector "$@" diff --git a/tests/test_project_contract.py b/tests/test_project_contract.py index 92e1814..24c89f9 100644 --- a/tests/test_project_contract.py +++ b/tests/test_project_contract.py @@ -47,8 +47,19 @@ def test_dashboard_uses_safe_dom_and_nonce_bootstrap() -> None: def test_container_is_non_root_and_health_checked() -> None: dockerfile = (APP / "Dockerfile").read_text(encoding="utf-8") + entrypoint = (APP / "docker-entrypoint.sh").read_text(encoding="utf-8") assert "FROM python:3.14.6-alpine3.24" in dockerfile - assert "USER inspector" in dockerfile + assert "adduser -S -D -H -G inspector inspector" in dockerfile + assert 'ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]' in dockerfile + assert "AI_OPTIONS_PATH=/tmp/options.json" in dockerfile assert "HEALTHCHECK" in dockerfile assert 'io.hass.type="app"' in dockerfile + + # Supervisor writes /data/options.json as root with mode 0600, so the + # entrypoint must stage a readable copy before dropping privileges. + assert "/data/options.json" in entrypoint + assert "exec su-exec inspector:inspector" in entrypoint + # read_text normalizes newlines, so assert on raw bytes: CRLF would make + # the script unrunnable inside the Alpine container. + assert b"\r\n" not in (APP / "docker-entrypoint.sh").read_bytes()