Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ jobs:

This action is not hardened against prompt injection attacks and should only be used to review trusted PRs. We recommend [configuring your repository](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#controlling-changes-from-forks-to-workflows-in-public-repositories) to use the "Require approval for all external contributors" option to ensure workflows only run after a maintainer has reviewed the PR.

For production workflows, pin GitHub Actions dependencies to full 40-character commit SHAs instead of mutable tags or branches. This includes replacing the quick-start `anthropics/claude-code-security-review@main` reference with a reviewed commit SHA once you adopt the action.

## Configuration Options

### Action Inputs
Expand Down
5 changes: 5 additions & 0 deletions claudecode/github_action_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from claudecode.prompts import get_security_audit_prompt
from claudecode.findings_filter import FindingsFilter
from claudecode.json_parser import parse_json_with_fallbacks
from claudecode.workflow_security import find_unpinned_github_actions
from claudecode.constants import (
EXIT_CONFIGURATION_ERROR,
DEFAULT_CLAUDE_MODEL,
Expand Down Expand Up @@ -600,6 +601,10 @@ def main():

# Filter findings to reduce false positives
original_findings = results.get('findings', [])
workflow_findings = find_unpinned_github_actions(pr_diff)
if workflow_findings:
logger.info(f"Detected {len(workflow_findings)} unpinned workflow action references")
original_findings = original_findings + workflow_findings

# Prepare PR context for better filtering
pr_context = {
Expand Down
87 changes: 87 additions & 0 deletions claudecode/test_main_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,93 @@ def test_main_successful_audit_no_findings(self, mock_client_class, mock_runner_
assert output['repo'] == 'owner/repo'
assert len(output['findings']) == 0
assert output['filtering_summary']['total_original_findings'] == 0

@patch('pathlib.Path.cwd')
@patch('claudecode.github_action_audit.get_security_audit_prompt')
@patch('claudecode.github_action_audit.FindingsFilter')
@patch('claudecode.github_action_audit.SimpleClaudeRunner')
@patch('claudecode.github_action_audit.GitHubActionClient')
def test_main_adds_unpinned_workflow_action_findings(
self,
mock_client_class,
mock_runner_class,
mock_filter_class,
mock_prompt_func,
mock_cwd,
capsys,
):
"""Test deterministic workflow findings are included with Claude findings."""
mock_client = Mock()
mock_client.get_pr_data.return_value = {
'number': 123,
'title': 'Add workflow',
'body': 'Description',
}
mock_client.get_pr_diff.return_value = """diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
--- /dev/null
+++ b/.github/workflows/security.yml
@@ -0,0 +1,5 @@
+name: Security
+jobs:
+ scan:
+ steps:
+ - uses: actions/checkout@v4
"""
mock_client._is_excluded.return_value = False
mock_client_class.return_value = mock_client

mock_runner = Mock()
mock_runner.validate_claude_available.return_value = (True, "")
mock_runner.run_security_audit.return_value = (
True,
"",
{
'findings': [],
'analysis_summary': {
'files_reviewed': 1,
'high_severity': 0,
'medium_severity': 0,
'low_severity': 0,
},
},
)
mock_runner_class.return_value = mock_runner

mock_filter = Mock()

def pass_through_findings(findings, _pr_context):
return (
True,
{
'filtered_findings': findings,
'excluded_findings': [],
'analysis_summary': {},
},
Mock(),
)

mock_filter.filter_findings.side_effect = pass_through_findings
mock_filter_class.return_value = mock_filter

mock_prompt_func.return_value = "security prompt"
mock_cwd.return_value = Path("/test/repo")

with patch.dict(os.environ, {
'GITHUB_REPOSITORY': 'owner/repo',
'PR_NUMBER': '123',
'GITHUB_TOKEN': 'test-token',
}):
with pytest.raises(SystemExit) as exc_info:
main()

assert exc_info.value.code == 0

captured = capsys.readouterr()
output = json.loads(captured.out)
assert output['filtering_summary']['total_original_findings'] == 1
assert output['findings'][0]['category'] == 'unpinned_github_action'
assert output['findings'][0]['line'] == 5
assert "actions/checkout@v4" in output['findings'][0]['description']

@patch('pathlib.Path.cwd')
@patch('claudecode.github_action_audit.get_security_audit_prompt')
Expand Down
100 changes: 100 additions & 0 deletions claudecode/test_workflow_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Tests for deterministic workflow security checks."""

from claudecode.workflow_security import find_unpinned_github_actions


def test_find_unpinned_github_actions_reports_mutable_refs():
diff = """diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
new file mode 100644
--- /dev/null
+++ b/.github/workflows/security.yml
@@ -0,0 +1,8 @@
+name: Security
+jobs:
+ scan:
+ steps:
+ - uses: actions/checkout@v4
+ - uses: anthropics/claude-code-security-review@main
"""

findings = find_unpinned_github_actions(diff)

assert [finding["line"] for finding in findings] == [5, 6]
assert findings[0]["file"] == ".github/workflows/security.yml"
assert findings[0]["category"] == "unpinned_github_action"
assert "actions/checkout@v4" in findings[0]["description"]
assert findings[1]["severity"] == "MEDIUM"


def test_find_unpinned_github_actions_handles_quotes_and_inline_comments():
diff = """diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,3 +7,6 @@
jobs:
lint:
steps:
+ - name: Checkout
+ uses: "actions/checkout@v4" # trusted action
+ - uses: 'actions/setup-node@v4'
"""

findings = find_unpinned_github_actions(diff)

assert [finding["line"] for finding in findings] == [11, 12]
assert "actions/checkout@v4" in findings[0]["description"]
assert "actions/setup-node@v4" in findings[1]["description"]


def test_find_unpinned_github_actions_reports_reusable_workflows():
diff = """diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,2 +1,4 @@
name: CI
jobs:
+ shared:
+ uses: org/reusable/.github/workflows/ci.yml@main
"""

findings = find_unpinned_github_actions(diff)

assert len(findings) == 1
assert findings[0]["line"] == 4
assert (
"org/reusable/.github/workflows/ci.yml@main"
in findings[0]["description"]
)


def test_find_unpinned_github_actions_ignores_full_sha_and_local_actions():
pinned_sha = "a" * 40
diff = f"""diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -10,3 +10,7 @@
+ - uses: actions/checkout@{pinned_sha}
+ - uses: ./actions/setup
+ - uses: docker://alpine:3.20
+ - uses: owner/action/path@{pinned_sha}
"""

assert find_unpinned_github_actions(diff) == []


def test_find_unpinned_github_actions_only_checks_added_workflow_lines():
diff = """diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,4 +20,4 @@
- - uses: actions/setup-python@v5
- name: unchanged
run: echo ok
diff --git a/docs/example.yml b/docs/example.yml
--- a/docs/example.yml
+++ b/docs/example.yml
@@ -1,2 +1,3 @@
+ - uses: actions/checkout@v4
"""

assert find_unpinned_github_actions(diff) == []
102 changes: 102 additions & 0 deletions claudecode/workflow_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Deterministic checks for GitHub Actions workflow security."""

import re
from typing import Any, Dict, List, Optional


_DIFF_HEADER_RE = re.compile(r"^diff --git a/(.*?) b/(.*?)$")
_HUNK_HEADER_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
_USES_RE = re.compile(r"^\+\s*(?:-\s*)?uses:\s*[\"']?([^\"'\s#]+)")
_FULL_SHA_RE = re.compile(r"^[a-fA-F0-9]{40}$")


def find_unpinned_github_actions(diff_text: str) -> List[Dict[str, Any]]:
"""Find newly added workflow actions that are not pinned to a full SHA.

The check only inspects added lines in GitHub Actions workflow files. Local
actions and Docker actions are ignored because they do not use repository
refs.
"""

findings: List[Dict[str, Any]] = []
current_file: Optional[str] = None
current_line: Optional[int] = None

for raw_line in diff_text.splitlines():
header_match = _DIFF_HEADER_RE.match(raw_line)
if header_match:
current_file = header_match.group(2)
current_line = None
continue

hunk_match = _HUNK_HEADER_RE.match(raw_line)
if hunk_match:
current_line = int(hunk_match.group(1))
continue

if current_line is None:
continue

if raw_line.startswith("+") and not raw_line.startswith("+++"):
if _is_workflow_file(current_file):
finding = _finding_for_added_uses_line(
raw_line, current_file, current_line
)
if finding:
findings.append(finding)
current_line += 1
elif raw_line.startswith("-") and not raw_line.startswith("---"):
continue
else:
current_line += 1

return findings


def _is_workflow_file(path: Optional[str]) -> bool:
if not path:
return False
normalized = path.replace("\\", "/").lower()
return normalized.startswith(".github/workflows/") and normalized.endswith(
(".yml", ".yaml")
)


def _finding_for_added_uses_line(
line: str, file_path: str, line_number: int
) -> Optional[Dict[str, Any]]:
match = _USES_RE.match(line)
if not match:
return None

action_ref = match.group(1).rstrip(",")
if action_ref.startswith(("./", "docker://")):
return None

if "@" not in action_ref:
return None

action_name, ref = action_ref.rsplit("@", 1)
if _FULL_SHA_RE.fullmatch(ref):
return None

return {
"file": file_path,
"line": line_number,
"severity": "MEDIUM",
"category": "unpinned_github_action",
"description": (
f"GitHub Actions workflow uses {action_name}@{ref}, which is a "
"mutable ref instead of a full commit SHA."
),
"exploit_scenario": (
"If the referenced action tag or branch is moved or the action "
"repository is compromised, this workflow can execute unexpected "
"code in CI with the workflow's permissions and secrets."
),
"recommendation": (
f"Pin {action_name} to a full 40-character commit SHA and update it "
"through a reviewed dependency update process."
),
"confidence": 0.9,
}