diff --git a/.github/workflows/claude-security-review.yml b/.github/workflows/claude-security-review.yml new file mode 100644 index 00000000000..ae35448026f --- /dev/null +++ b/.github/workflows/claude-security-review.yml @@ -0,0 +1,323 @@ +name: Claude Security Review + +# Runs an AI-assisted security review on every PR that touches a +# security-sensitive path. Behavior: +# - If Semgrep has already reviewed this PR (any check run, PR comment, +# review, or review-comment authored by a Semgrep app/bot), the Claude +# review is SKIPPED and the check passes — we trust Semgrep's coverage. +# - Otherwise, Claude runs. On findings: +# - Posts an INTENTIONALLY ABSTRACT comment on the (public) PR +# - Notifies the PR author privately on Slack with full details +# - Fails the check so the PR is blocked from merge +# (only enforced when this job is added to branch-protection required checks) +# +# Required configuration (Settings → Secrets and variables → Actions): +# secrets: +# ANTHROPIC_API_KEY - Anthropic API key for Claude +# SLACK_BOT_TOKEN - Slack bot token with chat:write scope, invited to the channel +# SLACK_USER_MAP - JSON: {"github-login": "Uxxxxxxxx", ...} +# variables: +# SLACK_SECURITY_CHANNEL - Slack channel ID (e.g. C0XXXXXXX) for private security comms +# +# To make this block merges: +# Settings → Branches → main → Branch protection rule → +# "Require status checks to pass" → add "Claude Security Review / security-review" + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + # REST / API surface + - 'dotCMS/src/main/java/com/dotcms/rest/**' + - 'dotCMS/src/main/java/com/dotcms/api/**' + # Servlets, filters, login + - 'dotCMS/src/main/java/com/dotcms/servlets/**' + - 'dotCMS/src/main/java/com/dotmarketing/servlets/**' + - 'dotCMS/src/main/java/com/dotmarketing/filters/**' + - 'dotCMS/src/main/java/com/dotmarketing/cms/login/**' + # Auth / security / role + - 'dotCMS/src/main/java/com/dotcms/auth/**' + - 'dotCMS/src/main/java/com/dotcms/security/**' + - 'dotCMS/src/main/java/com/dotmarketing/business/Role*' + - 'dotCMS/src/main/java/com/dotmarketing/business/web/User*' + # DB-touching business impls + - 'dotCMS/src/main/java/com/dotcms/**/business/**' + - 'dotCMS/src/main/java/com/dotmarketing/business/**' + # Publishing (push publish — SI-75 area) + - 'dotCMS/src/main/java/com/dotcms/publisher/**' + - 'dotCMS/src/main/java/com/dotcms/publishing/**' + - 'dotCMS/src/main/java/com/dotcms/enterprise/publishing/**' + # Workflow actionlets (user-supplied scripts) + - 'dotCMS/src/main/java/com/dotmarketing/portlets/workflows/actionlet/**' + # OSGi / dynamic plugin loading + - 'dotCMS/src/main/java/com/dotcms/osgi/**' + - 'dotCMS/src/main/java/com/dotmarketing/osgi/**' + # Web app entry points & static + - 'dotCMS/src/main/webapp/**' + # SQL / build / containers / workflows + - '**/*.sql' + - '**/pom.xml' + - '**/build.gradle*' + - '**/Dockerfile*' + - 'docker/**' + - '.github/workflows/**' + +permissions: + contents: read + pull-requests: write + issues: write + checks: write + +concurrency: + group: claude-security-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + security-review: + name: security-review + runs-on: ubuntu-latest + timeout-minutes: 25 + if: github.event.pull_request.draft == false + + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Check for prior Semgrep review (skip Claude if present) + id: semgrep + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + const headSha = pr.head.sha; + const isSemgrep = (s) => /semgrep/i.test(s || ''); + + // 1) Any Semgrep check run on the current head SHA + const checks = await github.paginate(github.rest.checks.listForRef, { + owner, repo, ref: headSha, per_page: 100 + }); + const hasCheck = checks.some(c => isSemgrep(c.name) || isSemgrep(c.app && c.app.slug)); + + // 2) Any issue comment authored by a Semgrep bot / user + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: pr.number, per_page: 100 + }); + const hasComment = comments.some(c => isSemgrep(c.user && c.user.login)); + + // 3) Any PR review from Semgrep + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, repo, pull_number: pr.number, per_page: 100 + }); + const hasReview = reviews.some(r => isSemgrep(r.user && r.user.login)); + + // 4) Any inline review comment from Semgrep + const reviewComments = await github.paginate(github.rest.pulls.listReviewComments, { + owner, repo, pull_number: pr.number, per_page: 100 + }); + const hasReviewComment = reviewComments.some(c => isSemgrep(c.user && c.user.login)); + + const found = hasCheck || hasComment || hasReview || hasReviewComment; + core.info(`Semgrep signals — check:${hasCheck} comment:${hasComment} review:${hasReview} review-comment:${hasReviewComment}`); + core.setOutput('found', found ? 'true' : 'false'); + + - name: Post skip comment (Semgrep already covered this PR) + if: steps.semgrep.outputs.found == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const body = [ + '## Security Review', + '', + 'Semgrep coverage detected on this PR — skipping the Claude security review to avoid duplicate work.', + '', + '_If Semgrep coverage is removed, re-running this check will perform the Claude review._' + ].join('\n'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }); + + - name: Run Claude security review + if: steps.semgrep.outputs.found != 'true' + id: review + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + Run the /security-review skill against the diff between + base ${{ github.event.pull_request.base.sha }} and + head ${{ github.event.pull_request.head.sha }} + (PR #${{ github.event.pull_request.number }}). + + Follow the three-phase methodology (identify → parallel + false-positive filter → keep only confidence >= 8). + + After producing the markdown report, ALSO write a + machine-readable summary to $GITHUB_WORKSPACE/security-findings.json + with this schema: + + { + "findings_count": , + "high_count": , + "medium_count": , + "report_markdown": "" + } + + If no findings clear the bar, write: + {"findings_count": 0, "high_count": 0, "medium_count": 0, "report_markdown": ""} + + DO NOT post any PR comment yourself. Downstream steps handle + disclosure routing. + claude_args: | + --allowed-tools Read,Bash,Grep,Glob,Agent + --model claude-opus-4-7 + + - name: Parse findings + if: steps.semgrep.outputs.found != 'true' + id: parse + run: | + set -euo pipefail + if [[ ! -f security-findings.json ]]; then + echo "Claude did not produce security-findings.json; failing safe (no findings)." + echo "findings_count=0" >> "$GITHUB_OUTPUT" + echo "has_findings=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + count=$(jq -r '.findings_count // 0' security-findings.json) + high=$(jq -r '.high_count // 0' security-findings.json) + medium=$(jq -r '.medium_count // 0' security-findings.json) + echo "findings_count=$count" >> "$GITHUB_OUTPUT" + echo "high_count=$high" >> "$GITHUB_OUTPUT" + echo "medium_count=$medium" >> "$GITHUB_OUTPUT" + if [[ "$count" -gt 0 ]]; then + echo "has_findings=true" >> "$GITHUB_OUTPUT" + else + echo "has_findings=false" >> "$GITHUB_OUTPUT" + fi + + - name: Post abstract PR comment (findings) + if: steps.semgrep.outputs.found != 'true' && steps.parse.outputs.has_findings == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const n = '${{ steps.parse.outputs.findings_count }}'; + const body = [ + '## Security Review', + '', + `Automated security review flagged **${n} issue(s)** that require attention before this PR can merge.`, + '', + 'For security reasons, details are not posted here. The PR author has been notified privately on Slack with the full report and remediation guidance.', + '', + 'If you did not receive a Slack notification, contact the security team.', + '', + '_This check will remain red until the findings are resolved and a new commit is pushed._' + ].join('\n'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }); + + - name: Post clean PR comment (no findings) + if: steps.semgrep.outputs.found != 'true' && steps.parse.outputs.has_findings == 'false' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const body = [ + '## Security Review', + '', + 'No high-confidence security findings on the changes in this PR.' + ].join('\n'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }); + + - name: Notify author privately on Slack + if: steps.semgrep.outputs.found != 'true' && steps.parse.outputs.has_findings == 'true' + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + SLACK_USER_MAP: ${{ secrets.SLACK_USER_MAP }} + SLACK_CHANNEL: ${{ vars.SLACK_SECURITY_CHANNEL }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_NUMBER: ${{ github.event.pull_request.number }} + FINDINGS_COUNT: ${{ steps.parse.outputs.findings_count }} + run: | + set -euo pipefail + python3 - <<'PY' + import json, os, sys, urllib.request + + with open("security-findings.json") as f: + data = json.load(f) + report = data.get("report_markdown", "") or "(no report content)" + + token = os.environ["SLACK_BOT_TOKEN"] + channel = os.environ["SLACK_CHANNEL"] + author = os.environ["PR_AUTHOR"] + pr_url = os.environ["PR_URL"] + pr_t = os.environ["PR_TITLE"] + pr_n = os.environ["PR_NUMBER"] + count = os.environ["FINDINGS_COUNT"] + + user_map = json.loads(os.environ.get("SLACK_USER_MAP") or "{}") + slack_uid = user_map.get(author) + mention = f"<@{slack_uid}>" if slack_uid else f"`{author}` _(no Slack mapping — add to SLACK_USER_MAP)_" + + # Slack section text blocks are limited to ~3000 chars; chunk the report. + MAX = 2800 + chunks = [report[i:i+MAX] for i in range(0, len(report), MAX)] or [""] + report_blocks = [ + {"type": "section", "text": {"type": "mrkdwn", "text": "```\n" + c + "\n```"}} + for c in chunks[:8] # cap at 8 blocks to stay under Slack's 50-block limit + ] + + payload = { + "channel": channel, + "text": f"Security findings on PR #{pr_n}", + "blocks": [ + {"type": "header", "text": {"type": "plain_text", "text": "Security Review — Action Required"}}, + {"type": "section", "text": {"type": "mrkdwn", + "text": f"{mention}\n*PR:* <{pr_url}|{pr_t} (#{pr_n})>\n*Findings:* {count}"}}, + {"type": "divider"}, + *report_blocks, + {"type": "context", "elements": [{"type": "mrkdwn", + "text": "This PR is *blocked* from merging until the findings are resolved. Push a new commit to re-run the review. Reply in thread for help."}]} + ], + } + + req = urllib.request.Request( + "https://slack.com/api/chat.postMessage", + data=json.dumps(payload).encode(), + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json; charset=utf-8", + }, + method="POST", + ) + with urllib.request.urlopen(req) as resp: + body = json.loads(resp.read()) + if not body.get("ok"): + print("Slack post failed:", body, file=sys.stderr) + sys.exit(1) + PY + + - name: Fail check to block merge + if: steps.semgrep.outputs.found != 'true' && steps.parse.outputs.has_findings == 'true' + run: | + echo "::error::Security review found ${{ steps.parse.outputs.findings_count }} issue(s). See Slack for details." + exit 1