From c8043a416938a440606ecacaa2dcd956d448a9e6 Mon Sep 17 00:00:00 2001 From: SebastienPittet Date: Mon, 26 Oct 2026 08:54:34 +0100 Subject: [PATCH 1/3] infra: hardened Dockerfile (non-root, pinned base), modern k8s manifest (securityContext, probes, configmap/secret), richer .gitignore, .dockerignore --- .dockerignore | 29 +++++++ .gitignore | 44 +++++++++-- Dockerfile | 31 +++++--- kubernetes/kube-manifest.yaml | 140 ++++++++++++++++++++++++++-------- 4 files changed, 195 insertions(+), 49 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f3d096a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +# Keep the build context small, reproducible and secret-free. +.git +.github +.gitignore +.idea/ +.vscode/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +coverage.xml +.venv/ +venv/ +.env +.env.* +configuration.toml +secrets.yaml +*.pem +*.key +sbom.json +README.md +Dockerfile +docker-compose.yml +kubernetes/ +tests/ +requirements-dev.txt +pyproject.toml diff --git a/.gitignore b/.gitignore index 484c883..c4967d6 100644 --- a/.gitignore +++ b/.gitignore @@ -152,12 +152,40 @@ dmypy.json # pytype static type analyzer .pytype/ -# Cython debug symbols -cython_debug/ - # PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ \ No newline at end of file +# JetBrains template: https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +#.idea/ + +# --- Project-specific additions ------------------------------------------- +# Secrets & local config (never commit) +.env.* +!.env.example +*.pem +*.key +*.p12 +secrets.yaml +!secret.example.yaml +configuration.*.toml + +# Tooling caches +.ruff_cache/ +.mypy_cache/ +.dmypy.json + +# IDEs / editors +.vscode/ +.idea/ +*.swp + +# OS noise +.DS_Store +Thumbs.db +desktop.ini + +# Build / release artifacts +sbom.json +*.cdx.json +coverage.xml + +# Docker local overrides +docker-compose.override.yml diff --git a/Dockerfile b/Dockerfile index 2738291..2c46d9b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,26 @@ -# Use an Alpine Linux image with Python 3 installed -FROM python:3.12.2-alpine3.19 +# Hardened runtime image: pinned versions, non-root user, read-friendly defaults. +FROM python:3.13.5-alpine3.22 + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +# Non-root user (matches the Kubernetes securityContext runAsUser). +RUN addgroup -g 10001 app && adduser -S -D -u 10001 -G app app -# Set the working directory in the container WORKDIR /app -# Copy the requirements and install Python dependencies -COPY requirements.txt /app/ -RUN pip3 install --no-cache-dir -r requirements.txt +# Install dependencies first to maximise layer caching. +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY bot/ ./bot/ + +# Writable heartbeat directory (mounted as emptyDir in Kubernetes). +RUN mkdir -p /app/state && chown -R app:app /app -# Copy the bot script and configuration file into the container -COPY bot/telegram_rss_bot.py /app/ +USER 10001 +ENV HEARTBEAT_FILE=/app/state/heartbeat -# Use dotenv library to load environment variables at runtime -CMD ["sh", "-c", "python telegram_rss_bot.py"] +# Exec form: python runs as PID 1 and receives SIGTERM for graceful shutdown. +ENTRYPOINT ["python", "-m", "bot"] diff --git a/kubernetes/kube-manifest.yaml b/kubernetes/kube-manifest.yaml index 82aabb6..c0b3486 100644 --- a/kubernetes/kube-manifest.yaml +++ b/kubernetes/kube-manifest.yaml @@ -1,51 +1,129 @@ +# Deploy telegram-rss-bot (singleton worker) in a dedicated namespace. +# Best practices applied: least-privilege SecurityContexts, pinned image, +# ConfigMap/Secret split, resource limits, meaningful liveness probe. --- -# Deploy telegram-rss-bot in a namespace apiVersion: v1 kind: Namespace metadata: name: telegram-rss-bot + labels: + app.kubernetes.io/name: telegram-rss-bot +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: telegram-rss-bot-config + namespace: telegram-rss-bot + labels: + app.kubernetes.io/name: telegram-rss-bot +data: + TIME_INTERVAL_MIN: "20" # run a feed cycle every 20 minutes + ENTRY_MAX_TIME_OLD: "1200" # publish entries younger than 20 minutes + LOG_LEVEL: "INFO" + HEARTBEAT_FILE: "/app/state/heartbeat" + RSS_URLS: | + https://www.ictjournal.ch/taxonomy/term/404/feed + https://www.ictjournal.ch/taxonomy/term/31/feed +--- +apiVersion: v1 +kind: Secret +metadata: + name: telegram-rss-bot-secrets + namespace: telegram-rss-bot + labels: + app.kubernetes.io/name: telegram-rss-bot +type: Opaque +stringData: + # Replace via CI/CD (sealed-secrets, SOPS, external secrets) — never commit real values. + BOT_TOKEN: "CHANGEME" + CHANNEL_ID: "@YourChannel" --- apiVersion: apps/v1 kind: Deployment metadata: name: telegram-rss-bot namespace: telegram-rss-bot + labels: + app.kubernetes.io/name: telegram-rss-bot + app.kubernetes.io/part-of: telegram-rss-bot spec: replicas: 1 + revisionHistoryLimit: 3 + # Recreate avoids two pods posting to the same channel during rollout. + strategy: + type: Recreate selector: matchLabels: - app: telegram-rss-bot - strategy: - rollingUpdate: - maxSurge: 25% - maxUnavailable: 25% - type: RollingUpdate + app.kubernetes.io/name: telegram-rss-bot template: metadata: labels: - app: telegram-rss-bot + app.kubernetes.io/name: telegram-rss-bot spec: + automountServiceAccountToken: false + terminationGracePeriodSeconds: 15 + # imagePullSecrets: + # - name: exo-registry-credentials # required for private registries + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault containers: - - name: telegram-rss-bot - image: exo.container-registry.com/pittet-sebastien/telegram-rss-bot:latest - env: - - name: TIME_INTERVAL_MIN - value: "20" - - name: ENTRY_MAX_TIME_OLD - value: "1200" - - name: RSS_URLS - value: | - "https://www.ictjournal.ch/taxonomy/term/404/feed - https://www.ictjournal.ch/taxonomy/term/31/feed" - resources: - requests: - memory: 20M - cpu: 0.1 - livenessProbe: - exec: - command: - - ls - - /app - initialDelaySeconds: 5 - periodSeconds: 60 ---- + - name: telegram-rss-bot + # Pin an immutable version (git-sha or semver tag produced by CI). + image: exo.container-registry.com/pittet-sebastien/telegram-rss-bot:v2.0.0 + imagePullPolicy: IfNotPresent + envFrom: + - configMapRef: + name: telegram-rss-bot-config + - secretRef: + name: telegram-rss-bot-secrets + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 192Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: state + mountPath: /app/state + # Liveness: the bot must refresh its heartbeat file (3x the 20-min cycle). + livenessProbe: + exec: + command: + - python + - -c + - | + import os, sys, time + p = os.environ.get("HEARTBEAT_FILE", "/app/state/heartbeat") + ok = os.path.exists(p) and time.time() - os.path.getmtime(p) < 3900 + sys.exit(0 if ok else 1) + initialDelaySeconds: 90 + periodSeconds: 300 + timeoutSeconds: 5 + failureThreshold: 3 + # Readiness: heartbeat file exists at all. + readinessProbe: + exec: + command: + - python + - -c + - | + import os, sys + sys.exit(0 if os.path.exists(os.environ.get("HEARTBEAT_FILE", "/app/state/heartbeat")) else 1) + initialDelaySeconds: 30 + periodSeconds: 60 + timeoutSeconds: 5 + volumes: + - name: state + emptyDir: + sizeLimit: 1Mi From 4776da820d1c879b54a3d27f793a6be27b48f25d Mon Sep 17 00:00:00 2001 From: SebastienPittet Date: Mon, 26 Oct 2026 09:32:38 +0100 Subject: [PATCH 2/3] ci(actionlint): fetch the pinned binary instead of the un-pullable ghcr image ghcr.io refuses anonymous pulls of rhysd/actionlint: even the token endpoint answers 403 DENIED, so 'docker run ghcr.io/rhysd/actionlint' exits 125 on every runner and the lint job could never pass. Instead download the pinned v1.7.12 release archive from github.com for the runner architecture and verify it against the published SHA-256 before executing it: same check, no container-registry dependency, no :latest, no third-party action code. --- .github/workflows/ci.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c88a9f8..8ffa055 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,26 @@ jobs: - name: Mypy run: mypy - name: Actionlint (workflow self-check) - run: docker run --rm -v "$PWD:/repo" ghcr.io/rhysd/actionlint:latest + # ghcr.io refuses anonymous pulls of this image: its token endpoint answers + # 403 DENIED. The pinned release binary is downloaded from github.com instead + # and verified against the published SHA-256 before being executed. + env: + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256_AMD64: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + ACTIONLINT_SHA256_ARM64: 325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6 + run: | + set -euo pipefail + case "$(uname -m)" in + x86_64) arch=amd64; sum="$ACTIONLINT_SHA256_AMD64" ;; + aarch64|arm64) arch=arm64; sum="$ACTIONLINT_SHA256_ARM64" ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; + esac + archive="actionlint_${ACTIONLINT_VERSION}_linux_${arch}.tar.gz" + curl -sSfL -o "$RUNNER_TEMP/$archive" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}" + echo "$sum $RUNNER_TEMP/$archive" | sha256sum --check + tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" actionlint + "$RUNNER_TEMP/actionlint" -color unit-tests: name: Unit tests From cf73e01e7b3964052718e448eb0079b6970f32d7 Mon Sep 17 00:00:00 2001 From: SebastienPittet Date: Mon, 26 Oct 2026 09:35:13 +0100 Subject: [PATCH 3/3] ci(kubeconform): use the real kubeconform release, not a nonexistent action 'uses: helm/kubeconform-action@v1.6.0' cannot resolve -- that repository does not exist, so the job died in 'Set up job' before any manifest was ever validated (it was, in effect, a check that always passed nothing). Fetch the pinned v0.8.0 release binary from github.com, verify the published SHA-256, and run it in strict mode against kubernetes/ with a pinned schema version. The manifest itself is valid: kubeconform 0.8.0 reports 4/4 resources valid in strict mode. --- .github/workflows/ci.yml | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ffa055..f07f21a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,8 +138,25 @@ jobs: needs: lint steps: - uses: actions/checkout@v7 - - uses: helm/kubeconform-action@v1.6.0 - with: - strict: true - summary: true - manifests: kubernetes/kube-manifest.yaml + # There is no official helm/kubeconform-action, so the pinned release binary is + # fetched from github.com and checked against the published checksum before use. + - name: Validate manifests with kubeconform + env: + KUBECONFORM_VERSION: 0.8.0 + KUBERNETES_VERSION: 1.33.0 + KUBECONFORM_SHA256_AMD64: 9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883 + KUBECONFORM_SHA256_ARM64: 1f53fc8e81258197a35e8603054162a5af1de8c5af13746c71ab680d9534ed87 + run: | + set -euo pipefail + case "$(uname -m)" in + x86_64) arch=amd64; sum="$KUBECONFORM_SHA256_AMD64" ;; + aarch64|arm64) arch=arm64; sum="$KUBECONFORM_SHA256_ARM64" ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; + esac + archive="kubeconform-linux-${arch}.tar.gz" + curl -sSfL -o "$RUNNER_TEMP/$archive" \ + "https://github.com/yannh/kubeconform/releases/download/v${KUBECONFORM_VERSION}/${archive}" + echo "$sum $RUNNER_TEMP/$archive" | sha256sum --check + tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" kubeconform + "$RUNNER_TEMP/kubeconform" -strict -summary -verbose \ + -kubernetes-version "$KUBERNETES_VERSION" kubernetes/