-
Notifications
You must be signed in to change notification settings - Fork 5
feat: add optional pentest sandbox setup (egress-firewalled Docker overlay) #121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
niklashaug
wants to merge
2
commits into
peerigon:main
Choose a base branch
from
niklashaug:feat/pentest-sandbox
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| # Pentest sandbox overlay - runs your normal docker-compose stack with | ||
| # internet egress genuinely blocked at the network-namespace level, so a | ||
| # dynamic AI pentest (or anything else run against this stack) can't | ||
| # accidentally hit real third-party services. | ||
| # | ||
| # How it works: `<service>-firewall` shares your main app service's network | ||
| # namespace (`network_mode: service:<service>`) and, once, inserts iptables | ||
| # OUTPUT rules into that shared namespace: allow loopback/established | ||
| # traffic and RFC1918 ranges (so service-to-service calls keep working), | ||
| # default-deny everything else. This only affects traffic the app container | ||
| # itself originates - inbound traffic via the published port is a different | ||
| # iptables path and is unaffected, so you (or a pentest tool) can still | ||
| # reach the app normally from the host. See docker/apply-firewall.sh. | ||
| # | ||
| # We deliberately do NOT use a Docker `internal: true` network for this: | ||
| # Docker does not publish ports for a network marked internal, which would | ||
| # make the app unreachable from the host too - and adding a second, | ||
| # non-internal network alongside an internal one doesn't help either, | ||
| # since Docker then routes the container's default outbound traffic | ||
| # through the non-internal network, restoring full internet access. Both | ||
| # were verified empirically; the namespace-sharing sidecar below is the | ||
| # approach that actually works. | ||
| # | ||
| # ============================== CUSTOMIZE ================================== | ||
| # 1. Replace every `server` below with your main app/API service's name - | ||
| # the one thing a pentest tool should actually be able to reach and test. | ||
| # 2. Give it its own host port (it must not collide with your normal dev | ||
| # stack's port for the same service, since this overlay is meant to run | ||
| # ALONGSIDE it under a different compose project name - see | ||
| # scripts/run-pentest.sh). | ||
| # 3. For every OTHER service in your docker-compose.yml that publishes a | ||
| # fixed host port (db, cache, auth provider, mail catcher, ...), add a | ||
| # block dropping that port here too, or this stack will fail to start | ||
| # because it fights your normal dev stack over the same host ports. | ||
| # Ask your AI assistant to generate this list from your actual | ||
| # docker-compose.yml - example shown below, commented out. | ||
| # 4. If your app needs a local stand-in for a third-party integration to | ||
| # keep working without real network access (e.g. a mail catcher for an | ||
| # email provider), wire its env vars here so the app talks to that | ||
| # local stand-in instead of trying the real service. | ||
| # ============================================================================= | ||
|
|
||
| services: | ||
| server: # <- rename to your main app/API service | ||
| ports: !override | ||
| - "127.0.0.1:17999:7999" # <- host:container, keep in sync with scripts/run-pentest.sh | ||
|
|
||
| # Repeat for every other service with a fixed host port in your compose | ||
| # file, e.g.: | ||
| # mongo: | ||
| # ports: !override [] | ||
| # mailcatcher: | ||
| # ports: !override [] | ||
|
|
||
| server-firewall: # <- rename prefix to match your main service if you like | ||
| build: | ||
| context: ./docker | ||
| dockerfile: Dockerfile.firewall | ||
| network_mode: "service:server" # <- rename to your main app/API service | ||
| cap_add: | ||
| - NET_ADMIN | ||
| depends_on: | ||
| - server # <- rename to your main app/API service | ||
| restart: "no" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| # Tiny image for the pentest-only egress-firewall sidecar (see | ||
| # docker-compose.pentest.yml). Not part of the normal app build. | ||
| FROM alpine:3.20 | ||
| RUN apk add --no-cache iptables | ||
| COPY apply-firewall.sh /usr/local/bin/apply-firewall.sh | ||
| RUN chmod +x /usr/local/bin/apply-firewall.sh | ||
| ENTRYPOINT ["/usr/local/bin/apply-firewall.sh"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| #!/bin/sh | ||
| # Applies an outbound-only firewall to whichever network namespace this | ||
| # process runs in. Meant to run in a sidecar with `network_mode: | ||
| # service:<target>` and `cap_add: [NET_ADMIN]`, so the rules apply to the | ||
| # target container's namespace rather than to a namespace of its own. | ||
| # | ||
| # Effect: the target container can still be reached from the host (inbound | ||
| # traffic via a published port is unaffected - it's a different iptables | ||
| # path) and can still talk to other containers on its private network | ||
| # ranges (db, cache, auth provider, mail catcher, ...), but any outbound | ||
| # connection to a real internet host is dropped, regardless of what env | ||
| # vars/API base URLs the app is configured with. | ||
| set -eu | ||
|
|
||
| iptables -F OUTPUT | ||
|
|
||
| # Always allow loopback and replies to connections that came in from outside | ||
| # (e.g. the host hitting the published port). | ||
| iptables -A OUTPUT -o lo -j ACCEPT | ||
| iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT | ||
|
|
||
| # Allow traffic to private/container-network address ranges (RFC1918) so | ||
| # service-to-service calls within the compose stack keep working. | ||
| iptables -A OUTPUT -d 10.0.0.0/8 -j ACCEPT | ||
| iptables -A OUTPUT -d 172.16.0.0/12 -j ACCEPT | ||
| iptables -A OUTPUT -d 192.168.0.0/16 -j ACCEPT | ||
| iptables -A OUTPUT -d 127.0.0.0/8 -j ACCEPT | ||
|
|
||
| # Default-deny everything else outbound (real internet hosts). | ||
| iptables -P OUTPUT DROP | ||
|
|
||
| echo "firewall-applied" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,22 @@ | ||
| import typescriptPreset from "@peerigon/configs/eslint/presets/typescript"; | ||
| import nodeRules from "@peerigon/configs/eslint/rules/node"; | ||
| import vitestRules from "@peerigon/configs/eslint/rules/vitest"; | ||
| import stylesNoDefaultExport from "@peerigon/configs/eslint/styles/no-default-export"; | ||
|
|
||
| export default [...typescriptPreset, ...vitestRules, ...stylesNoDefaultExport]; | ||
| const nodeGlobals = nodeRules[0]?.languageOptions?.["globals"] ?? {}; | ||
|
|
||
| export default [ | ||
| ...typescriptPreset, | ||
| ...vitestRules, | ||
| ...stylesNoDefaultExport, | ||
| { | ||
| // Standalone CLI scripts (e.g. the pentest sandbox setup), not app code. | ||
| files: ["scripts/**/*.mjs"], | ||
| languageOptions: { | ||
| globals: nodeGlobals, | ||
| }, | ||
| rules: { | ||
| "unicorn/no-process-exit": "off", | ||
| }, | ||
| }, | ||
| ]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| // Probes real external hosts from inside a container to verify that the | ||
| // pentest sandbox's egress firewall (docker/apply-firewall.sh) actually | ||
| // blocks internet access. Exit code 0 = nothing reachable. | ||
| // | ||
| // CUSTOMIZE FOR YOUR PROJECT: add the real third-party hosts your app talks | ||
| // to (email/SMS/push providers, payment providers, analytics, ...) so this | ||
| // actually proves *your* integrations can't leak - not just some defaults. | ||
|
|
||
| const targets = [ | ||
| // Generic control hosts - always keep at least one of these. | ||
| "https://1.1.1.1", | ||
| "https://example.com", | ||
|
|
||
| // Add your project's real third-party API hosts here, e.g.: | ||
| // 'https://api.brevo.com', | ||
| // 'https://exp.host', | ||
| ]; | ||
|
|
||
| const TIMEOUT_MS = 4000; | ||
|
|
||
| /** @param {unknown} error */ | ||
| function describeError(error) { | ||
| if (error instanceof Error) { | ||
| const cause = error.cause; | ||
|
|
||
| if (cause && typeof cause === "object" && "code" in cause) { | ||
| return String(cause.code); | ||
| } | ||
| if ("code" in error && typeof error.code === "string") { | ||
| return error.code; | ||
| } | ||
|
|
||
| return error.name; | ||
| } | ||
|
|
||
| return String(error); | ||
| } | ||
|
|
||
| /** @param {string} url */ | ||
| async function probe(url) { | ||
| const controller = new AbortController(); | ||
| const timer = setTimeout(() => { | ||
| controller.abort(); | ||
| }, TIMEOUT_MS); | ||
|
|
||
| try { | ||
| await fetch(url, { signal: controller.signal }); | ||
| console.log(`REACHABLE ${url}`); | ||
| return true; | ||
| } catch (error) { | ||
| console.log(`BLOCKED ${url} (${describeError(error)})`); | ||
| return false; | ||
| } finally { | ||
| clearTimeout(timer); | ||
| } | ||
| } | ||
|
|
||
| const results = await Promise.all(targets.map((url) => probe(url))); | ||
| const reachableCount = results.filter(Boolean).length; | ||
| const blockedCount = results.length - reachableCount; | ||
|
|
||
| console.log( | ||
| `\nsummary: ${String(blockedCount)}/${String(targets.length)} blocked, ${String(reachableCount)}/${String(targets.length)} reachable`, | ||
| ); | ||
| process.exit(reachableCount > 0 ? 1 : 0); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| #!/usr/bin/env bash | ||
| # Runs a dynamic Strix scan against this project's pentest sandbox stack - | ||
| # but ONLY after proving its egress firewall actually blocks internet | ||
| # access. If that cannot be confirmed, Strix is never started. | ||
| # | ||
| # Order of operations (each step gates the next): | ||
| # 1. Tear down any leftover pentest stack from a previous run. | ||
| # 2. Bring up the full stack plus the firewall sidecar (see | ||
| # docker-compose.pentest.yml / docker/apply-firewall.sh for how and why). | ||
| # 3. Wait for the firewall sidecar to report it applied its rules. | ||
| # 4. Gate: verify no real external host is reachable from the live app | ||
| # container. If this fails, abort and tear down - Strix never starts. | ||
| # 5. Wait for the app to become reachable on its published port. | ||
| # 6. Only now: run Strix against it. | ||
| # 7. Tear the stack down on exit (success, failure, or Ctrl-C), unless | ||
| # KEEP_STACK=1. | ||
| # | ||
| # Usage: | ||
| # ./scripts/run-pentest.sh | ||
| # ./scripts/run-pentest.sh --instruction "..." | ||
| # KEEP_STACK=1 ./scripts/run-pentest.sh # leave the stack up afterwards | ||
|
|
||
| set -euo pipefail | ||
| cd "$(dirname "$0")/.." | ||
|
|
||
| # ============================== CUSTOMIZE =================================== | ||
| # Fill these in for your project (see docker-compose.pentest.yml too). | ||
| PENTEST_SERVICE="${PENTEST_SERVICE:-server}" # your main app/API service's compose name | ||
| PENTEST_HOST_PORT="${PENTEST_HOST_PORT:-17999}" # host port this overlay publishes it on | ||
| PENTEST_HEALTH_PATH="${PENTEST_HEALTH_PATH:-/}" # cheap endpoint that 2xx's once the app+DB are ready | ||
| PENTEST_CODE_TARGET="${PENTEST_CODE_TARGET:-.}" # local path Strix should also read as source | ||
| PENTEST_PROJECT="${PENTEST_PROJECT:-$(basename "$(pwd)")-pentest}" | ||
| # ============================================================================= | ||
|
|
||
| STRIX_SCAN_MODE="${STRIX_SCAN_MODE:-deep}" | ||
| STRIX_MAX_BUDGET="${STRIX_MAX_BUDGET:-20}" | ||
| TARGET_URL="http://localhost:${PENTEST_HOST_PORT}" | ||
|
|
||
| COMPOSE=(docker compose -p "$PENTEST_PROJECT" -f docker-compose.yml -f docker-compose.pentest.yml) | ||
| STACK_UP=0 | ||
|
|
||
| cleanup() { | ||
| local exit_code=$? | ||
| if [[ "$STACK_UP" == "1" && "${KEEP_STACK:-0}" != "1" ]]; then | ||
| echo "==> Tearing down pentest stack ($PENTEST_PROJECT)..." | ||
| "${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true | ||
| fi | ||
| exit $exit_code | ||
| } | ||
| trap cleanup EXIT | ||
|
|
||
| echo "==> Preflight checks..." | ||
| command -v docker >/dev/null || { echo "FAIL: docker not found" >&2; exit 1; } | ||
| command -v strix >/dev/null || { echo "FAIL: strix CLI not found (see docs.strix.ai)" >&2; exit 1; } | ||
|
|
||
| # Strix can be configured either via STRIX_LLM/LLM_API_KEY env vars (BYO key) | ||
| # or via `strix auth` (model-subscription sign-in, persisted under | ||
| # ~/.strix/). Only fail here if NEITHER is present. | ||
| if [[ -z "${STRIX_LLM:-}" && -z "${LLM_API_KEY:-}" ]]; then | ||
| if [[ ! -f "$HOME/.strix/cli-config.json" && ! -f "$HOME/.strix/subscription-auth.json" ]]; then | ||
| echo "FAIL: no STRIX_LLM/LLM_API_KEY env vars and no ~/.strix config found. Run 'strix auth' or set STRIX_LLM/LLM_API_KEY." >&2 | ||
| exit 1 | ||
| fi | ||
| echo "==> No STRIX_LLM/LLM_API_KEY env override - using existing ~/.strix configuration (e.g. 'strix auth' sign-in)." | ||
| fi | ||
|
|
||
| echo "==> Clearing any leftover pentest stack from a previous run..." | ||
| "${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true | ||
|
|
||
| echo "==> Bringing up the pentest stack (app + egress-firewall sidecar)..." | ||
| "${COMPOSE[@]}" up -d --build | ||
| STACK_UP=1 | ||
|
|
||
| echo "==> Waiting for the egress-firewall sidecar to apply its rules..." | ||
| FW_ATTEMPTS=30 | ||
| until "${COMPOSE[@]}" logs "${PENTEST_SERVICE}-firewall" 2>/dev/null | grep -q "firewall-applied" || [[ $FW_ATTEMPTS -eq 0 ]]; do | ||
| FW_ATTEMPTS=$((FW_ATTEMPTS - 1)) | ||
| sleep 1 | ||
| done | ||
| if [[ $FW_ATTEMPTS -eq 0 ]]; then | ||
| echo "ABORT: ${PENTEST_SERVICE}-firewall sidecar never reported success. Strix will NOT be started." >&2 | ||
| "${COMPOSE[@]}" logs "${PENTEST_SERVICE}-firewall" >&2 || true | ||
| exit 1 | ||
| fi | ||
|
|
||
| echo "==> Gate: verifying the live ${PENTEST_SERVICE} container cannot reach the internet..." | ||
| if ! PENTEST_SERVICE="$PENTEST_SERVICE" PENTEST_PROJECT="$PENTEST_PROJECT" ./scripts/verify-pentest-network-isolation.sh; then | ||
| echo "ABORT: network isolation could not be verified. Strix will NOT be started." >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| echo "==> Gate passed. Waiting for the app to become reachable on ${TARGET_URL}${PENTEST_HEALTH_PATH}..." | ||
| HEALTH_URL="${TARGET_URL}${PENTEST_HEALTH_PATH}" | ||
| ATTEMPTS=150 | ||
| until curl -fsS "${HEALTH_URL}" >/dev/null 2>&1 || [[ $ATTEMPTS -eq 0 ]]; do | ||
| ATTEMPTS=$((ATTEMPTS - 1)) | ||
| printf '.' | ||
| sleep 2 | ||
| done | ||
| echo | ||
| if [[ $ATTEMPTS -eq 0 ]]; then | ||
| echo "ABORT: app never became reachable on ${HEALTH_URL}. Strix will NOT be started." >&2 | ||
| echo "---- ${PENTEST_SERVICE} logs (last 100 lines) ----" >&2 | ||
| "${COMPOSE[@]}" logs --tail=100 "$PENTEST_SERVICE" >&2 || true | ||
| exit 1 | ||
| fi | ||
|
|
||
| echo "==> Isolation confirmed and app is ready — starting Strix..." | ||
| strix -n \ | ||
| -t "$PENTEST_CODE_TARGET" \ | ||
| -t "$TARGET_URL" \ | ||
| --scan-mode "$STRIX_SCAN_MODE" \ | ||
| --max-budget "$STRIX_MAX_BUDGET" \ | ||
| "$@" |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
currently thinking if the
/scriptsdirectory is a good place for all of this because this will be inline in future projects, maybe this should be nested inside of some .template folder or something so it's clear that those scripts come from the template and a project can have its own/scriptsdirectory if it wants to