Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
13 changes: 12 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

- 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions automation_inspector/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 7 additions & 1 deletion automation_inspector/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion automation_inspector/app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Automation Inspector application package."""

APP_VERSION = "1.1.0"
APP_VERSION = "1.1.1"
2 changes: 1 addition & 1 deletion automation_inspector/config.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
14 changes: 14 additions & 0 deletions automation_inspector/docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
13 changes: 12 additions & 1 deletion tests/test_project_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()