Skip to content

Commit 98a15b5

Browse files
authored
Merge pull request #43 from onflow/claude/gracious-goldberg-7dc0ec
Add local security scanning (static analysis + AI audit)
2 parents 7d9d39a + efa4068 commit 98a15b5

16 files changed

Lines changed: 757 additions & 0 deletions

.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Local security scan output — NEVER commit. This repo is public and the
2+
# contracts hold real value; findings must stay local.
3+
/security/reports/
4+
5+
# Local Claude Code state and vendored audit skills (security/vendor-skills.sh).
6+
# This is a public repo — keep all local Claude artifacts out of it.
7+
/.claude/
8+
9+
# Aderyn default report locations
10+
/report.md
11+
/solidity/report.md

Makefile

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,78 @@ solidity-build:
1212
.PHONY: solidity-test
1313
solidity-test:
1414
cd solidity && FOUNDRY_PROFILE=ci forge test -vvv
15+
16+
# ---------------------------------------------------------------------------
17+
# Security scanning (LOCAL ONLY, containerized)
18+
#
19+
# This repository is PUBLIC and the contracts hold real value. Security scans
20+
# are intentionally NOT run in CI — public Actions logs, issues, PR comments,
21+
# and artifacts would leak live vulnerabilities. Every scanner runs inside a
22+
# locked-down Docker container (see security/docker/) so untrusted analysis —
23+
# especially community AI skills — cannot read host files or secrets:
24+
# static tier -> no network at all
25+
# AI tier -> egress restricted to the Anthropic API; needs a Claude
26+
# credential (see Credentials in docs/security-scanning.md)
27+
# Reports stay in security/reports/ (gitignored) and are never committed/posted.
28+
#
29+
# Requires Docker. Build the image once with `make security-build`.
30+
# ---------------------------------------------------------------------------
31+
32+
SKILL ?= solidity-auditor
33+
34+
# Run all security scans (local only).
35+
.PHONY: security
36+
security: security-build security-static security-ai
37+
38+
# Build the pinned scanner toolchain image.
39+
.PHONY: security-build
40+
security-build:
41+
./security/scan.sh build
42+
43+
# Store your Claude Code OAuth token in the macOS Keychain (one-time, per-dev).
44+
.PHONY: security-set-token
45+
security-set-token:
46+
./security/scan.sh set-token
47+
48+
# Verify a Claude credential is available (preflight for the AI tier).
49+
.PHONY: security-check-cred
50+
security-check-cred:
51+
./security/scan.sh check-cred
52+
53+
# Run all non-AI static analyzers (sealed, no network).
54+
.PHONY: security-static
55+
security-static: security-slither security-aderyn security-solhint
56+
57+
.PHONY: security-slither
58+
security-slither:
59+
./security/scan.sh slither
60+
61+
.PHONY: security-aderyn
62+
security-aderyn:
63+
./security/scan.sh aderyn
64+
65+
.PHONY: security-solhint
66+
security-solhint:
67+
./security/scan.sh solhint
68+
69+
.PHONY: security-ai
70+
security-ai: security-check-cred security-ai-review security-ai-audit security-ai-skills security-ai-summarize
71+
72+
# AI reviews (need a Claude credential — see docs). Output is local + gitignored.
73+
.PHONY: security-ai-review
74+
security-ai-review:
75+
./security/scan.sh ai-review
76+
77+
.PHONY: security-ai-audit
78+
security-ai-audit:
79+
./security/scan.sh ai-audit
80+
81+
# Skills-based audit. Override the skill: `make security-ai-skills SKILL=scv-scan`
82+
.PHONY: security-ai-skills
83+
security-ai-skills:
84+
./security/scan.sh ai-skills $(SKILL)
85+
86+
# Summarize all reports in security/reports/ by severity (stdout only).
87+
.PHONY: security-ai-summarize
88+
security-ai-summarize:
89+
./security/scan.sh summarize

docs/security-scanning.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Security Scanning
2+
3+
Local, containerized security scanning for the Solidity codebase, set up per the
4+
[Continuous Security Testing/Auditing](https://www.notion.so/3521aee1232480958886c3666758b9f0)
5+
recommendation. Supplementary to formal audits, not a replacement.
6+
7+
## Threat model: why local + containerized + pinned
8+
9+
This repository is **public** and the contracts **hold real value**, which drives
10+
two distinct controls:
11+
12+
1. **No public CI.** Every place a GitHub Action surfaces output is public on a
13+
public repo — issues, PR comments, **Actions logs, and artifacts**. Scanners
14+
print findings to the log, so running them in CI would leak live, unfixed
15+
vulnerabilities. All scanning runs locally; reports go to `security/reports/`
16+
(gitignored) and are never committed or posted.
17+
18+
2. **The AI skills are an untrusted supply chain.** The community skill repos are
19+
third-party and community-writable, and their content is loaded as
20+
*instructions* into a tool-enabled agent. Two mitigations:
21+
- **Pinning (integrity):** `security/vendor-skills.sh` checks out exact,
22+
reviewed commit SHAs and fails closed if a commit is gone — so a poisoned
23+
upstream commit can't silently run. Bump a SHA only after reviewing the diff.
24+
- **Containment:** every scanner runs inside a locked-down Docker container so
25+
a malicious skill can't read host files/secrets or exfiltrate:
26+
- **Static tier** (Slither/Aderyn/Solhint): `--network none`, `--cap-drop ALL`,
27+
source mounted **read-only**. Fully airtight.
28+
- **AI tier** (review/audit/skills/summarize): egress restricted to the
29+
**Anthropic API only** (`security/docker/init-firewall.sh`),
30+
the Claude credential is the only secret present, source mounted read-only.
31+
32+
Pinning + container = integrity + containment. Note what containment does **not**
33+
fix: a poisoned skill can still produce dishonest *output* (e.g. hide a finding),
34+
so AI results stay advisory and should be cross-checked against the static tools.
35+
36+
## Prerequisites
37+
38+
- **Docker.** Build the pinned toolchain image once:
39+
```sh
40+
make security-build
41+
```
42+
The image (`security/docker/Dockerfile`) bundles Foundry, Slither, Aderyn,
43+
Solhint, and the Claude CLI at pinned versions, with `solc` pre-cached so the
44+
static tier compiles with no network.
45+
- **AI tier only:** a credential, resolved at run time — no permanent env var
46+
needed. See [Credentials](#credentials) below. The static tier needs nothing.
47+
48+
## Credentials
49+
50+
The AI tier authenticates with a **Claude Code OAuth token** (tied to your own
51+
Claude subscription — no separate API billing). The token is **per-developer**,
52+
so each person stores their own; there is no shared key. It is fetched at run
53+
time and injected into the sealed container by reference (`-e NAME`, never on the
54+
command line), and is never written to disk in the repo or kept in your shell.
55+
56+
One-time setup, per developer (default: macOS Keychain, zero extra tooling):
57+
58+
```sh
59+
claude setup-token # mint a long-lived token tied to your account
60+
make security-set-token # paste it once; stored encrypted in your login Keychain
61+
```
62+
63+
After that, `make security-ai-*` fetches it automatically.
64+
65+
Credential resolution (precedence, highest first):
66+
67+
1. `CLAUDE_CODE_OAUTH_TOKEN` in the environment → used directly (CI / power users).
68+
2. `ANTHROPIC_API_KEY` in the environment → used directly.
69+
3. **1Password**, *only if* `FCM_OP_TOKEN_REF='op://<your-vault>/<item>/credential'`
70+
is set (use your own/Private vault — the token is personal, not shared).
71+
4. **macOS Keychain** (the default) — what `make security-set-token` writes.
72+
73+
## Make targets
74+
75+
| Target | Tier | Notes |
76+
|--------|------|-------|
77+
| `make security` | all | everything: build + all static + all AI |
78+
| `make security-build` || build the scanner image (run once / after updates) |
79+
| `make security-set-token` || store your Claude OAuth token in the Keychain (one-time) |
80+
| `make security-check-cred` || verify a Claude credential is available (AI-tier preflight) |
81+
| `make security-static` | static | all static analyzers: Slither + Aderyn + Solhint |
82+
| `make security-slither` | static | report → `security/reports/slither-report-<ts>.txt` |
83+
| `make security-aderyn` | static | report → `security/reports/aderyn-report-<ts>.md` |
84+
| `make security-solhint` | static | report → `security/reports/solhint-report-<ts>.txt` |
85+
| `make security-ai` | AI | all AI tiers: review + audit + skills + summarize |
86+
| `make security-ai-review` | AI | reviews current branch changes |
87+
| `make security-ai-audit` | AI | full-codebase audit |
88+
| `make security-ai-skills` | AI | `SKILL=<name>`; vendors pinned skills, then audits |
89+
| `make security-ai-summarize` | AI | rolls up all reports by severity to stdout |
90+
91+
## How it fits together
92+
93+
- **Prompts** live in `security/prompts/` and encode the vault/curator threat
94+
model. They forbid creating issues/comments or writing findings to tracked files.
95+
- **`security/scan.sh`** is the single dispatcher: it builds the image on demand
96+
and runs each tool with the right isolation flags (static = no network, AI =
97+
API-only egress + key).
98+
- **`security/docker/`** holds the `Dockerfile`, the two entrypoints
99+
(`entry-static.sh`, `entry-ai.sh`), and the egress allowlist
100+
(`init-firewall.sh`).
101+
- **`security/vendor-skills.sh`** clones the pinned skill SHAs on the host, into a
102+
gitignored `.claude/skills/`, which is mounted read-only into the sealed
103+
container (so the container needs no GitHub egress).
104+
105+
## Reports
106+
107+
All reports are written under `security/reports/` (gitignored). **Do not commit or
108+
share these files** — they may describe live, unfixed vulnerabilities. AI is
109+
non-deterministic; run audits more than once and cross-check.
110+
111+
## Updating a skill
112+
113+
Review the upstream diff, then update the corresponding `*_SHA` in
114+
`security/vendor-skills.sh`. Never point at a floating branch.
115+
116+
## Config files (safe to commit)
117+
118+
- `solidity/slither.config.json` — filters out `lib/`, `test/`, `script/`.
119+
- `solidity/.solhint.json`, `solidity/.solhintignore` — Solhint rules/ignores.

security/docker/Dockerfile

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Security scanning toolchain — all tools pinned, solc pre-cached for offline use.
2+
#
3+
# Built once (`make security-build`) and reused by every scan. The static tier
4+
# runs this image with `--network none`; the AI tier runs it with egress
5+
# restricted to the Anthropic API only (see security/docker/init-firewall.sh).
6+
#
7+
# Tools are pinned to exact versions so a rebuild is reproducible and a poisoned
8+
# upstream release can't silently change what runs.
9+
10+
FROM node:24-bookworm-slim
11+
12+
# Pinned tool versions ------------------------------------------------------
13+
ARG SLITHER_VERSION=0.11.5
14+
ARG SOLHINT_VERSION=6.2.1
15+
ARG ADERYN_VERSION=0.6.8
16+
ARG CLAUDE_VERSION=2.1.146
17+
ARG SOLC_VERSION=0.8.35
18+
ARG FOUNDRY_VERSION=stable
19+
20+
ENV DEBIAN_FRONTEND=noninteractive \
21+
FOUNDRY_DIR=/root/.foundry \
22+
PATH=/root/.foundry/bin:/opt/slither/bin:/usr/local/bin:$PATH
23+
24+
# Base packages: python (slither), git, curl, jq, and the firewall tooling
25+
# (iptables/ipset/dnsutils) used only by the AI tier's egress allowlist.
26+
RUN apt-get update && apt-get install -y --no-install-recommends \
27+
ca-certificates curl git jq xz-utils \
28+
python3 python3-venv python3-pip \
29+
iptables ipset dnsutils \
30+
&& rm -rf /var/lib/apt/lists/*
31+
32+
# Slither (isolated venv) ---------------------------------------------------
33+
RUN python3 -m venv /opt/slither \
34+
&& /opt/slither/bin/pip install --no-cache-dir "slither-analyzer==${SLITHER_VERSION}"
35+
36+
# Solhint + Claude Code CLI (pinned) ---------------------------------------
37+
RUN npm install -g "solhint@${SOLHINT_VERSION}" "@anthropic-ai/claude-code@${CLAUDE_VERSION}"
38+
39+
# Aderyn (pinned release binary, arm64 linux) ------------------------------
40+
RUN curl -fsSL "https://github.com/Cyfrin/aderyn/releases/download/aderyn-v${ADERYN_VERSION}/aderyn-aarch64-unknown-linux-gnu.tar.xz" \
41+
-o /tmp/aderyn.tar.xz \
42+
&& mkdir -p /tmp/aderyn-extract \
43+
&& tar -xJf /tmp/aderyn.tar.xz -C /tmp/aderyn-extract \
44+
&& install -m 0755 "$(find /tmp/aderyn-extract -type f -name aderyn | head -1)" /usr/local/bin/aderyn \
45+
&& rm -rf /tmp/aderyn.tar.xz /tmp/aderyn-extract \
46+
&& aderyn --version
47+
48+
# Foundry (forge) -----------------------------------------------------------
49+
RUN curl -L https://foundry.paradigm.xyz | bash \
50+
&& /root/.foundry/bin/foundryup --install "${FOUNDRY_VERSION}" \
51+
&& forge --version
52+
53+
# Pre-cache solc so the static tier can compile with no network. Warming a
54+
# throwaway project forces foundry-compilers to download solc into ~/.svm,
55+
# which Slither (via crytic-compile/forge) and Aderyn both reuse offline.
56+
RUN mkdir -p /tmp/warm/src \
57+
&& printf '[profile.default]\nsolc_version = "%s"\n' "${SOLC_VERSION}" > /tmp/warm/foundry.toml \
58+
&& printf 'pragma solidity %s; contract Warm {}' "${SOLC_VERSION}" > /tmp/warm/src/Warm.sol \
59+
&& (cd /tmp/warm && forge build) \
60+
&& rm -rf /tmp/warm
61+
62+
# Make forge/slither resolvable regardless of shell/PATH handling.
63+
RUN ln -sf /root/.foundry/bin/forge /usr/local/bin/forge \
64+
&& ln -sf /root/.foundry/bin/cast /usr/local/bin/cast \
65+
&& ln -sf /opt/slither/bin/slither /usr/local/bin/slither
66+
67+
# Entrypoints + egress firewall (copied last to keep heavy layers cached).
68+
COPY entry-static.sh entry-ai.sh init-firewall.sh /usr/local/bin/
69+
RUN chmod +x /usr/local/bin/entry-static.sh /usr/local/bin/entry-ai.sh /usr/local/bin/init-firewall.sh
70+
71+
WORKDIR /work

security/docker/entry-ai.sh

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Entrypoint for the AI tier. Brings up the egress allowlist (api.anthropic.com
4+
# only), then copies the read-only source into a writable workdir and runs the
5+
# requested command. The only secret in the container is the Claude credential
6+
# (CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY), and the only reachable network
7+
# destination is the Anthropic API.
8+
9+
set -euo pipefail
10+
11+
# Set NO_FIREWALL=1 only for local debugging — never in normal use.
12+
if [ "${SECURITY_NO_FIREWALL:-0}" != "1" ]; then
13+
/usr/local/bin/init-firewall.sh
14+
fi
15+
16+
# Keep Claude's traffic to the Anthropic API only. Without this, it would attempt
17+
# telemetry / auto-update / error-reporting calls that the egress allowlist
18+
# blocks, stalling the run on connection timeouts.
19+
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
20+
export DISABLE_AUTOUPDATER=1
21+
export DISABLE_TELEMETRY=1
22+
export DISABLE_ERROR_REPORTING=1
23+
export DISABLE_BUG_COMMAND=1
24+
25+
cp -a /repo/. /work/
26+
cd /work
27+
28+
# Heads-up on stderr (not stdout, so it stays out of the saved report). Claude in
29+
# headless mode prints nothing until it finishes.
30+
echo ">> Scan running in a sealed container. Claude produces no output until it" >&2
31+
echo " finishes — this is normal and may take a couple of minutes. Please wait." >&2
32+
33+
exec "$@"

security/docker/entry-static.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Entrypoint for the static tier. The host source is mounted read-only at /repo;
4+
# copy it into a writable workdir so Foundry/Slither/Aderyn can compile without
5+
# touching the host, then run the requested command. No network is available.
6+
7+
set -euo pipefail
8+
9+
cp -a /repo/. /work/
10+
cd /work
11+
exec "$@"

security/docker/init-firewall.sh

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Egress allowlist for the AI tier: permit ONLY DNS + HTTPS to the Anthropic API,
4+
# default-drop everything else. Requires NET_ADMIN. Run once at container start.
5+
#
6+
# The scan agent cannot run arbitrary shell (its tool allowlist excludes Bash
7+
# beyond git/forge), so a poisoned skill cannot undo these rules from inside the
8+
# agent. This caps exfiltration: even if a skill tries to phone home, there is no
9+
# route off-box except to api.anthropic.com.
10+
11+
set -euo pipefail
12+
13+
iptables -F
14+
iptables -P INPUT DROP
15+
iptables -P FORWARD DROP
16+
iptables -P OUTPUT DROP
17+
18+
# Loopback
19+
iptables -A INPUT -i lo -j ACCEPT
20+
iptables -A OUTPUT -o lo -j ACCEPT
21+
22+
# Established/related return traffic
23+
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
24+
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
25+
26+
# DNS (needed to resolve the API host)
27+
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
28+
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
29+
30+
# Allow HTTPS only to the resolved Anthropic API IPs
31+
# Progress messages go to stderr so they don't pollute the report (captured from
32+
# stdout).
33+
allow_host() {
34+
local host="$1" ip
35+
for ip in $(getent ahostsv4 "$host" | awk '{print $1}' | sort -u); do
36+
iptables -A OUTPUT -p tcp -d "$ip" --dport 443 -j ACCEPT
37+
echo " allow $host -> $ip:443" >&2
38+
done
39+
}
40+
41+
echo ">> egress firewall: allowing api.anthropic.com only" >&2
42+
allow_host api.anthropic.com
43+
44+
# Reject (not silently drop) any other egress so a blocked connection fails fast
45+
# instead of hanging on a timeout. The policy DROP above remains a backstop.
46+
iptables -A OUTPUT -p tcp -j REJECT --reject-with tcp-reset
47+
iptables -A OUTPUT -j REJECT --reject-with icmp-port-unreachable
48+
49+
echo ">> egress firewall active (api.anthropic.com only; others rejected)" >&2

0 commit comments

Comments
 (0)