{"slug": "building-scamshield-ai-week-2-docker-security-firewall-bypasses-and-fail-closed", "title": "Building ScamShield AI: Week 2 — Docker Security, Firewall Bypasses, and Fail-Closed Architecture", "summary": "A developer building ScamShield AI, an evidence-grounded threat intelligence platform for analyzing Indian scam SMS and WhatsApp messages, hardened the platform's Mitmproxy-based proxy container by reordering iptables rules so private-range and cloud-metadata drops are enforced before stateful connection acceptance, closing an SSRF bypass against endpoints like 169.254.169.254. The developer also rewrote the container entrypoint to fail closed, aborting startup with exit code 1 if iptables or NET_ADMIN is unavailable or any rule fails verification, and fixed a boot crash caused by mitmproxy 10.4.2 deprecating the --termlog-verbosity flag in favor of --set termlog_verbosity=info.", "body_md": "If you caught Week 1, you know I'm building **ScamShield AI** — an evidence-grounded threat intelligence platform designed to safely analyze Indian scam SMS messages and WhatsApp forwards (fake KYC alerts, electricity cut-off threats, UPI traps).\n\nTo safely \"click\" a scammer's link without letting them compromise our backend infrastructure, we are building an isolated Docker sandbox using **Playwright (Headless Chromium)** and **Mitmproxy (The Network Guard)**.\n\nThis week was entirely about **hardening Stage 1: The Proxy Container**. \n\nI learned the hard way that *building* a Docker container is trivial, but *securing* it against a determined attacker probing from the inside is a completely different engineering challenge.\n\nHere are my biggest technical takeaways, bugs, and architectural wins from Week 2.\n\n`iptables` Rule Ordering is a Strict Security Boundary\nIn `iptables` (the Linux kernel packet filter), rules are evaluated sequentially from top to bottom. **The first match wins.**\n\nI originally wrote my firewall rules in this intuitive order:\n\n```\n1. Allow loopback (lo)\n2. Allow ESTABLISHED, RELATED traffic\n3. DROP private IPs (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.169.254)\n```\n\nAn attacker exploiting a network connection could potentially match an existing state or craft packets that trigger state-tracking rules prematurely. Because the `ESTABLISHED, RELATED` rule was placed at `#2`, any connection matching that condition is accepted immediately. It never reaches the `DROP` rules sitting at `#3`.\n\nOn a threat-intelligence scanner inspecting hostile URLs, this is a dangerous bypass that risks **Server-Side Request Forgery (SSRF)** against cloud metadata endpoints (`169.254.169.254`).\n\nWe refactored the firewall initialization into **6 strict phases**. All prohibited destination drops are enforced in **Phase 1**, before stateful connection acceptance is even evaluated.\n\n```\nPhase 1: DROP 169.254.169.254/32 (AWS/Cloud Metadata)\n         DROP 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (RFC1918 Private Ranges)\n         DROP 100.64.0.0/10 (CGNAT)\n...\nPhase 4: ACCEPT ESTABLISHED, RELATED\nPhase 5: DROP ALL (Default Policy Fallback)\n```\n\nIf a packet targets an internal address, the kernel drops it immediately on line 1. No connection tracking rule can resurrect it.\n\nMy original container `entrypoint.sh` had logic that looked roughly like this:\n\n```\nif command -v iptables >/dev/null 2>&1; then\n    apply_firewall_rules\nelse\n    echo \"Warning: iptables not found, continuing anyway...\"\nfi\n\nexec run_proxy \"$@\"\n```\n\nIf the container was accidentally started without the `NET_ADMIN` Linux capability, or if a base image update broke `iptables`, the script simply printed a warning to stdout and happily booted the proxy without a firewall.\n\n**This is classic \"Fail-Open\" design, and in security software, it is fatal.** A security boundary that silently degrades to unprotected execution is worse than no boundary at all, because it gives you false confidence.\n\nWe rewrote the entire initialization script to be strictly **Fail-Closed**:\n\n```\nset -euo pipefail\n\n# Fail immediately if NET_ADMIN is missing\nif ! iptables -L -n >/dev/null 2>&1; then\n    echo \"[FATAL] iptables unavailable or insufficient permissions (missing NET_ADMIN?). Aborting.\" >&2\n    exit 1\nfi\n\n# Apply rules and verify every single one with iptables -C\napply_firewall_rules || {\n    echo \"[FATAL] Failed to enforce firewall policy. Aborting startup.\" >&2\n    exit 1\n}\n```\n\nNow, if a single firewall rule fails to apply, the container terminates immediately with exit code `1`. The sandbox refuses to run without its armor.\n\nDuring a full Docker rebuild, our proxy container suddenly crashed on boot:\n\n```\nmitmdump: error: unrecognized arguments: --termlog-verbosity\n```\n\nWe had updated to `mitmproxy 10.4.2`, which completely deprecated the `--termlog-verbosity` CLI flag in favor of options syntax: `--set termlog_verbosity=info`.\n\nBecause our test suite was testing startup shell logic using **mocked binaries**. Our test mock for `mitmdump` was a dummy script that recorded incoming flags and exited `0` without validating whether those flags were actually accepted by the real `mitmproxy` CLI!\n\nOur tests were **falsely green**.\n\nWe updated the configuration to the modern `--set` syntax, added negative assertions to the mocks to disallow deprecated arguments, and verified startup against the real binary.\n\nTo adhere to the principle of least privilege, our proxy container must drop root privileges after configuring `iptables` and run the worker process as an unprivileged user named `proxy` with explicit UID `1002`.\n\nIn our Dockerfile, we had:\n\n```\nRUN groupadd -g 1002 proxy || true && \\\n    useradd -u 1002 -g proxy proxy || true\n```\n\nWhen checking running processes inside the container with `docker top`, something looked wrong:\n\n``` bash\n$ docker top scamshield-proxy\nUID     PID     PPID    CMD\n13      14820   14798   mitmdump -s /app/addon.py ...\n```\n\nThe process wasn't running as UID `1002`. It was running as **UID 13**!\n\nThe official `python:3.11-slim` image is built on Debian. Debian has shipped with a built-in system user named `proxy` (UID `13`) since the 1990s (originally for Squid/inn). \n\nWhen our Dockerfile executed `groupadd ... || true`, it failed silently because group `proxy` already existed, and `useradd` skipped creation. The container fell back to the system user UID `13`.\n\nNever rely on implicit user creation in base images. We explicitly modify existing system users or pick a collision-free name:\n\n```\nRUN if id \"proxy\" >/dev/null 2>&1; then \\\n        userdel -f proxy && groupdel proxy || true; \\\n    fi && \\\n    groupadd -g 1002 proxy && \\\n    useradd -u 1002 -g proxy -s /usr/sbin/nologin -d /nonexistent proxy\n```\n\nAlways inspect `docker top` and `/proc/<pid>/status` in the running container to verify that your process is actually running with the exact UID/GID you intended.\n\n| Component | Status | Details | \n|---|---|---|\n| **Regression Test Suite** | 🟢 **Passing** | 57/57 tests green across entrypoint logic, proxy addons, and preflight gates. | \n| **Firewall Startup** | 🟢 **Verified** | Container boots as root, sets 19 `iptables` rules, verifies with`iptables -C` , drops to UID`1002` , and starts`mitmdump` . | \n| **CLI Arguments** | 🟢 **Verified** | Migrated to `mitmproxy 10.x` options syntax (`--set` ). | \n| **Live Network Egress Tests** | 🟡 **In Progress** | Writing integration tests to confirm traffic to forbidden IPs actually drops at the packet level. | \n\n`ip6tables` policies to prevent IPv6 leak bypasses.\nThis week, I am executing our **Network Gate Verification Suite**. Starting a container cleanly is not proof that packets are blocked; proof requires testing real network requests from inside the sandbox:\n\n`curl -m 2 http://169.254.169.254` `curl -m 2 http://10.0.0.1` `curl https://example.com` Once the network gate passes, we begin **Stage 2: The Browser Container** (locking down Headless Chromium with Playwright).\n\nHave you ever had a unit test suite stay completely green while your production container was fundamentally broken due to mocked tools? How do you structure your egress firewall rules for scraping or threat intelligence workloads?\n\nDrop your thoughts in the comments below! 👇\n\n*Follow the project on GitHub: [github.com/Siddharthrane07/scamshield](https://github.com/Siddharthrane07/scamshield)*", "url": "https://wpnews.pro/news/building-scamshield-ai-week-2-docker-security-firewall-bypasses-and-fail-closed", "canonical_source": "https://dev.to/siddharthrane07/building-scamshield-ai-week-2-docker-security-firewall-bypasses-and-fail-closed-architecture-aka", "published_at": "2026-11-03 18:49:33+00:00", "updated_at": "2026-11-03 18:53:42.575345+00:00", "lang": "en", "topics": ["ai-safety", "ai-tools", "developer-tools"], "entities": ["ScamShield AI", "Docker", "Playwright", "Mitmproxy", "iptables"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/building-scamshield-ai-week-2-docker-security-firewall-bypasses-and-fail-closed", "markdown": "https://wpnews.pro/news/building-scamshield-ai-week-2-docker-security-firewall-bypasses-and-fail-closed.md", "text": "https://wpnews.pro/news/building-scamshield-ai-week-2-docker-security-firewall-bypasses-and-fail-closed.txt", "jsonld": "https://wpnews.pro/news/building-scamshield-ai-week-2-docker-security-firewall-bypasses-and-fail-closed.jsonld"}}