Skip to content
Draft
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
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,72 @@ Ask your AI coding assistant:
Merge the "template" remote into the current branch as described in the AGENTS.md file.
```

## Optional: Security / Pentest Sandbox Setup

Only relevant once your project has a `docker-compose.yml` with a running app service - skip this section until then.

This template ships a pentest sandbox: a docker-compose overlay that runs your app with internet egress genuinely blocked (via a firewall sidecar, not just app config), so a dynamic AI pentest (e.g. [Strix](https://strix.ai)) or anything else run against it can't accidentally hit real third-party services (email/SMS/push providers, payment providers, ...). See [peerigon/IT#335](https://github.com/peerigon/IT/issues/335) for the background and process this is part of.

Files: `docker-compose.pentest.yml`, `docker/apply-firewall.sh`, `docker/Dockerfile.firewall`, `scripts/run-pentest.sh`, `scripts/verify-pentest-network-isolation.sh`, `scripts/network-isolation-check.mjs`.

<details>

```markdown
# Customize Pentest Sandbox Setup

First, read this project's actual `docker-compose.yml` and gather:

1. **Main app/API service name** - the one thing a pentest tool should
test (e.g. `server`, `api`, `backend`).
2. **Its container port** and a **free host port** to publish it on for
this sandbox (must not collide with the normal dev stack).
3. **A cheap, unauthenticated health-check path** that only returns 2xx
once the app (and its DB connection) is actually ready.
4. **Every other service** in docker-compose.yml that publishes a fixed
host port (db, cache, auth provider, mail catcher, ...) - these need
their ports dropped in the overlay so this sandbox can run alongside
the normal dev stack without port conflicts.
5. **Any third-party integration** (email, push, payments, ...) that
needs a local stand-in (e.g. a mail catcher) to keep working without
real network access - wire its env vars into the overlay too.
6. **What the main service's container image can run** - the isolation
check pipes a Node script into the container via `docker compose exec
... node --input-type=module -`. If the image isn't Node-based, adapt
`scripts/verify-pentest-network-isolation.sh` and
`scripts/network-isolation-check.mjs` to use what it does have
(python3, curl, ...).

## Files to Update

### docker-compose.pentest.yml

- Replace every `server` placeholder with the real service name from (1).
- Set the port mapping from (2).
- Add a `<service>: ports: !override []` block for each service from (4).
- Add env vars / local stand-ins from (5).

### scripts/run-pentest.sh

- Set `PENTEST_SERVICE`, `PENTEST_HOST_PORT`, `PENTEST_HEALTH_PATH` to
match what you configured above (or leave them as env var overrides for
whoever runs it, whichever you prefer).

### scripts/network-isolation-check.mjs

- Add the real third-party API hosts your app talks to, so the check
actually proves those specific integrations can't leak.

## After Customizing

Run `./scripts/run-pentest.sh` (needs Docker + a Strix installation - see
[docs.strix.ai](https://docs.strix.ai) - and either `STRIX_LLM`/
`LLM_API_KEY` env vars or a prior `strix auth` sign-in). It brings up the
sandbox, proves egress is blocked, and only then starts Strix against it;
tears the stack down again on exit either way.
```

</details>

## GitHub rulesets (blueprints)

The JSON files under [`.github/rulesets/`](./.github/rulesets/) are **blueprints** for GitHub rulesets. GitHub does not apply them from the repository; import or recreate them in your repo’s (or organization’s) ruleset settings. See [`.github/rulesets/README.md`](./.github/rulesets/README.md) for details.
64 changes: 64 additions & 0 deletions docker-compose.pentest.yml
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"
7 changes: 7 additions & 0 deletions docker/Dockerfile.firewall
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"]
32 changes: 32 additions & 0 deletions docker/apply-firewall.sh
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"
19 changes: 18 additions & 1 deletion eslint.config.js
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",
},
},
];
65 changes: 65 additions & 0 deletions scripts/network-isolation-check.mjs

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently thinking if the /scripts directory 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 /scripts directory if it wants to

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);
114 changes: 114 additions & 0 deletions scripts/run-pentest.sh
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" \
"$@"
Loading
Loading