Skip to content

Commit f7a6083

Browse files
committed
chore: harden security practices
1 parent 0bca378 commit f7a6083

File tree

1 file changed

+52
-37
lines changed

1 file changed

+52
-37
lines changed

.github/workflows/claude.yml

Lines changed: 52 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ name: claude-review
44
# The prompt is extracted as the text after "@claude" in the comment body.
55
#
66
# Security model:
7-
# - Only write/admin users can trigger (enforced by permission check on the App token)
7+
# - Only write/admin/maintain users can trigger (enforced by explicit collaborator permission gate)
88
# - Network sandbox: Squid proxy (L7 domain allowlist) + iptables (L3 egress block)
99
# - Claude CLI pre-installed before network lockdown
1010
#
@@ -15,8 +15,6 @@ name: claude-review
1515
on:
1616
issue_comment:
1717
types: [created]
18-
pull_request_review_comment:
19-
types: [created]
2018

2119
permissions: {}
2220

@@ -25,7 +23,7 @@ jobs:
2523
name: claude-review
2624
if: |
2725
contains(github.event.comment.body, '@claude') &&
28-
(github.event.issue.pull_request || github.event_name == 'pull_request_review_comment')
26+
github.event.issue.pull_request
2927
runs-on: ubuntu-latest
3028
timeout-minutes: 60
3129
permissions:
@@ -39,12 +37,32 @@ jobs:
3937

4038
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
4139
with:
40+
# Always use default branch contents for workflow runtime files.
41+
ref: ${{ github.event.repository.default_branch }}
4242
persist-credentials: false
4343
fetch-depth: 0
4444

4545
- name: Install uv # Required by internal skill scripts
4646
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b
4747

48+
- name: Enforce actor repository permissions
49+
run: |
50+
PERMISSION=$(gh api "repos/${REPO}/collaborators/${ACTOR}/permission" --jq '.permission' 2>/dev/null || echo "none")
51+
echo "Actor permission level: ${PERMISSION}"
52+
53+
case "$PERMISSION" in
54+
admin|write|maintain)
55+
;;
56+
*)
57+
echo "::error::Actor '${ACTOR}' must have write/admin/maintain permission to trigger this workflow (got '${PERMISSION}')"
58+
exit 1
59+
;;
60+
esac
61+
env:
62+
GH_TOKEN: ${{ github.token }}
63+
REPO: ${{ github.repository }}
64+
ACTOR: ${{ github.actor }}
65+
4866
- name: Clone private repositories
4967
run: |
5068
gh repo clone zama-ai/zama-marketplace /tmp/zama-marketplace
@@ -54,12 +72,14 @@ jobs:
5472

5573
- name: Fetch PR/issue metadata
5674
run: |
75+
CONTEXT_EOF="CTX_$(openssl rand -hex 8)"
76+
5777
if [[ -n "$ISSUE_PR_URL" ]]; then
5878
PR_NUMBER="$ISSUE_NUMBER_INPUT"
5979
PR_DATA=$(gh pr view "$PR_NUMBER" --json title,author,headRefName,baseRefName,state,additions,deletions,commits,files)
6080
6181
{
62-
echo "FORMATTED_CONTEXT<<EOF"
82+
echo "FORMATTED_CONTEXT<<${CONTEXT_EOF}"
6383
echo "PR Title: $(echo "$PR_DATA" | jq -r '.title')"
6484
echo "PR Author: $(echo "$PR_DATA" | jq -r '.author.login')"
6585
echo "PR Number: ${PR_NUMBER}"
@@ -69,24 +89,24 @@ jobs:
6989
echo "PR Deletions: $(echo "$PR_DATA" | jq -r '.deletions')"
7090
echo "Total Commits: $(echo "$PR_DATA" | jq -r '.commits | length')"
7191
echo "Changed Files: $(echo "$PR_DATA" | jq '.files | length') files"
72-
echo "EOF"
92+
echo "${CONTEXT_EOF}"
7393
} >> "$GITHUB_ENV"
7494
else
7595
{
76-
echo "FORMATTED_CONTEXT<<EOF"
96+
echo "FORMATTED_CONTEXT<<${CONTEXT_EOF}"
7797
echo "Issue Title: ${ISSUE_TITLE_INPUT}"
7898
echo "Issue Author: ${ISSUE_AUTHOR_INPUT}"
7999
echo "Issue State: ${ISSUE_STATE_INPUT^^}"
80-
echo "EOF"
100+
echo "${CONTEXT_EOF}"
81101
} >> "$GITHUB_ENV"
82102
fi
83103
env:
84-
GH_TOKEN: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
85-
ISSUE_PR_URL: ${{ github.event.issue.pull_request.url || '' }}
86-
ISSUE_NUMBER_INPUT: ${{ github.event.issue.number }}
87-
ISSUE_TITLE_INPUT: ${{ github.event.issue.title }}
88-
ISSUE_AUTHOR_INPUT: ${{ github.event.issue.user.login }}
89-
ISSUE_STATE_INPUT: ${{ github.event.issue.state }}
104+
GH_TOKEN: ${{ github.token }}
105+
ISSUE_PR_URL: ${{ github.event.issue.pull_request.url || github.event.pull_request.url || '' }}
106+
ISSUE_NUMBER_INPUT: ${{ github.event.issue.number || github.event.pull_request.number }}
107+
ISSUE_TITLE_INPUT: ${{ github.event.issue.title || github.event.pull_request.title || '' }}
108+
ISSUE_AUTHOR_INPUT: ${{ github.event.issue.user.login || github.event.pull_request.user.login || '' }}
109+
ISSUE_STATE_INPUT: ${{ github.event.issue.state || github.event.pull_request.state || '' }}
90110

91111
- name: Build custom system prompt
92112
run: |
@@ -95,11 +115,12 @@ jobs:
95115
<formatted_context>
96116
${FORMATTED_CONTEXT}
97117
</formatted_context>"
118+
PROMPT_EOF="PROMPT_$(openssl rand -hex 8)"
98119
99120
{
100-
echo "CUSTOM_SYSTEM_PROMPT<<EOF"
121+
echo "CUSTOM_SYSTEM_PROMPT<<${PROMPT_EOF}"
101122
echo "$SYSTEM_PROMPT"
102-
echo "EOF"
123+
echo "${PROMPT_EOF}"
103124
} >> "$GITHUB_ENV"
104125
105126
# ── Phase 2: Authenticate & install CLI (before lockdown) ──────────
@@ -183,30 +204,33 @@ jobs:
183204
184205
- name: Lock down iptables
185206
run: |
186-
RUNNER_UID=$(id -u)
187-
# Save current firewall rules so they can be restored during always-run cleanup.
188-
sudo iptables-save | tee /tmp/iptables-pre-claude.rules > /dev/null
189-
190207
# Resolve Squid container's IP dynamically to avoid allowing the entire 172.16/12 range
191208
SQUID_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' sandbox-proxy)
192209
if [ -z "$SQUID_IP" ]; then
193210
echo "::error::Could not determine Squid container IP"; exit 1
194211
fi
195212
echo "Squid IP: $SQUID_IP"
196213
197-
# Allow established connections and loopback (covers local DNS via 127.0.0.53)
214+
# IPv4: allow only proxy traffic, then block all runner egress paths.
198215
sudo iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
199-
sudo iptables -A OUTPUT -o lo -j ACCEPT
216+
sudo iptables -A OUTPUT -o lo -p tcp --dport 3128 -j ACCEPT
200217
201218
# Allow traffic to Squid container only (single host, port 3128)
202219
sudo iptables -A OUTPUT -d "$SQUID_IP" -p tcp --dport 3128 -j ACCEPT
203220
204-
# Block new outbound TCP from runner UID — forces proxy use
205-
sudo iptables -A OUTPUT -m owner --uid-owner "$RUNNER_UID" -p tcp --syn -j REJECT --reject-with tcp-reset
206-
207-
# Block UDP and ICMP from runner UID — prevents DNS tunneling and ICMP exfiltration
208-
sudo iptables -A OUTPUT -m owner --uid-owner "$RUNNER_UID" -p udp -j DROP
209-
sudo iptables -A OUTPUT -m owner --uid-owner "$RUNNER_UID" -p icmp -j DROP
221+
# Block all remaining outbound traffic — deny-by-default after explicit proxy allows.
222+
sudo iptables -A OUTPUT -p tcp --syn -j REJECT --reject-with tcp-reset
223+
sudo iptables -A OUTPUT -p udp -j DROP
224+
sudo iptables -A OUTPUT -p icmp -j DROP
225+
226+
# IPv6: mirror egress restrictions if IPv6 tooling is present on the runner.
227+
if command -v ip6tables >/dev/null 2>&1; then
228+
sudo ip6tables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
229+
sudo ip6tables -A OUTPUT -o lo -p tcp --dport 3128 -j ACCEPT
230+
sudo ip6tables -A OUTPUT -p tcp --syn -j REJECT --reject-with tcp-reset
231+
sudo ip6tables -A OUTPUT -p udp -j DROP
232+
sudo ip6tables -A OUTPUT -p ipv6-icmp -j DROP
233+
fi
210234
211235
# Verify: direct blocked, proxy works
212236
if curl -sf --max-time 5 -o /dev/null https://google.com 2>/dev/null; then
@@ -276,15 +300,6 @@ jobs:
276300
GH_TOKEN: ${{ steps.oidc-exchange.outputs.app_token }}
277301
HTTPS_PROXY: http://localhost:3128
278302

279-
- name: Restore iptables
280-
if: always()
281-
run: |
282-
if [ -f /tmp/iptables-pre-claude.rules ]; then
283-
sudo sh -c 'iptables-restore < /tmp/iptables-pre-claude.rules' || echo "::warning::Failed to restore iptables"
284-
else
285-
echo "::warning::No iptables backup found at /tmp/iptables-pre-claude.rules"
286-
fi
287-
288303
- name: Stop Squid proxy
289304
if: always()
290305
run: docker rm -f sandbox-proxy 2>/dev/null || true

0 commit comments

Comments
 (0)