From c78f5ea796749df1dad46c42700cb96735970797 Mon Sep 17 00:00:00 2001 From: mbiuki Date: Thu, 14 May 2026 17:13:14 -0400 Subject: [PATCH 1/2] ci: add Claude security-review workflow for security-sensitive PRs Runs an AI-assisted /security-review on every PR that touches a security-sensitive path. Posts an abstract comment on the public PR, notifies the author privately on Slack with full findings, and fails the check so the PR can be marked required-for-merge in branch protection rules. Requires the following to be configured before this workflow is useful (see file header): secrets: ANTHROPIC_API_KEY, SLACK_BOT_TOKEN, SLACK_USER_MAP vars: SLACK_SECURITY_CHANNEL --- .github/workflows/claude-security-review.yml | 258 +++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 .github/workflows/claude-security-review.yml diff --git a/.github/workflows/claude-security-review.yml b/.github/workflows/claude-security-review.yml new file mode 100644 index 00000000000..04540e3b573 --- /dev/null +++ b/.github/workflows/claude-security-review.yml @@ -0,0 +1,258 @@ +name: Claude Security Review + +# Runs an AI-assisted security review on every PR that touches a +# security-sensitive path. 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: Run Claude security review + 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 + 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.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.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.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.parse.outputs.has_findings == 'true' + run: | + echo "::error::Security review found ${{ steps.parse.outputs.findings_count }} issue(s). See Slack for details." + exit 1 From 15ce17f7fc5bb729d782bdcd0e1eee370ae3c7c7 Mon Sep 17 00:00:00 2001 From: mbiuki Date: Fri, 15 May 2026 17:07:39 -0400 Subject: [PATCH 2/2] ci(security-review): skip Claude run if Semgrep already reviewed PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a precheck step that scans for any prior Semgrep activity on the PR — check runs, issue comments, PR reviews, or inline review comments authored by anything matching /semgrep/i. When found, the Claude review is skipped, a short "skipped" comment is posted, and the check passes (trusting Semgrep's coverage). When absent, the Claude review proceeds as before. --- .github/workflows/claude-security-review.yml | 83 +++++++++++++++++--- 1 file changed, 74 insertions(+), 9 deletions(-) diff --git a/.github/workflows/claude-security-review.yml b/.github/workflows/claude-security-review.yml index 04540e3b573..ae35448026f 100644 --- a/.github/workflows/claude-security-review.yml +++ b/.github/workflows/claude-security-review.yml @@ -1,11 +1,15 @@ name: Claude Security Review # Runs an AI-assisted security review on every PR that touches a -# security-sensitive path. 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) +# 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: @@ -82,7 +86,67 @@ jobs: 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: @@ -117,6 +181,7 @@ jobs: --model claude-opus-4-7 - name: Parse findings + if: steps.semgrep.outputs.found != 'true' id: parse run: | set -euo pipefail @@ -139,7 +204,7 @@ jobs: fi - name: Post abstract PR comment (findings) - if: steps.parse.outputs.has_findings == 'true' + if: steps.semgrep.outputs.found != 'true' && steps.parse.outputs.has_findings == 'true' uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -164,7 +229,7 @@ jobs: }); - name: Post clean PR comment (no findings) - if: steps.parse.outputs.has_findings == 'false' + if: steps.semgrep.outputs.found != 'true' && steps.parse.outputs.has_findings == 'false' uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -182,7 +247,7 @@ jobs: }); - name: Notify author privately on Slack - if: steps.parse.outputs.has_findings == 'true' + 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 }} @@ -252,7 +317,7 @@ jobs: PY - name: Fail check to block merge - if: steps.parse.outputs.has_findings == 'true' + 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