Files
Anthropic-Cybersecurity-Skills/skills/securing-github-actions-workflows/scripts/agent.py
T
mukul975 c21af3347e Complete folder anatomy for all 649 cybersecurity skills + update LICENSE to Mahipal
- Add scripts/agent.py and references/api-reference.md to all remaining skills
- Update all 648 LICENSE files: copyright now reads 'Mahipal'
- Add implementing-security-monitoring-with-datadog (new skill with full anatomy)
- All 649 skills now have: SKILL.md, LICENSE, scripts/agent.py, references/api-reference.md
2026-03-11 00:22:12 +01:00

219 lines
8.1 KiB
Python

#!/usr/bin/env python3
"""Agent for securing GitHub Actions workflows.
Audits GitHub Actions workflow files for security issues including
unpinned actions, excessive permissions, script injection risks,
dangerous triggers, and missing secret protections.
"""
import json
import re
import sys
from pathlib import Path
from datetime import datetime
try:
import yaml
except ImportError:
yaml = None
class GitHubActionsSecurityAgent:
"""Audits GitHub Actions workflows for security vulnerabilities."""
def __init__(self, repo_path=".", output_dir="./gha_audit"):
self.repo_path = Path(repo_path)
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
self.findings = []
def _load_workflow(self, path):
if yaml:
with open(path) as f:
return yaml.safe_load(f)
with open(path) as f:
return {"raw": f.read()}
def find_workflows(self):
"""Discover all workflow files in the repository."""
wf_dir = self.repo_path / ".github" / "workflows"
if not wf_dir.exists():
return []
return sorted(wf_dir.glob("*.yml")) + sorted(wf_dir.glob("*.yaml"))
def check_sha_pinning(self, workflow_path, content):
"""Check if actions are pinned to SHA digests."""
unpinned = []
raw = content.get("raw", "") if "raw" in content else ""
if not raw:
try:
raw = Path(workflow_path).read_text()
except Exception:
return unpinned
for line_num, line in enumerate(raw.splitlines(), 1):
m = re.search(r'uses:\s+([^@\s]+)@([^\s#]+)', line)
if m:
action, ref = m.group(1), m.group(2)
if not re.match(r'^[a-f0-9]{40}$', ref):
unpinned.append({
"action": action,
"ref": ref,
"line": line_num,
"file": str(workflow_path),
})
self.findings.append({
"severity": "medium",
"type": "Unpinned Action",
"detail": f"{action}@{ref} at line {line_num}",
"file": str(workflow_path),
})
return unpinned
def check_permissions(self, workflow_path, content):
"""Check for overly permissive GITHUB_TOKEN permissions."""
issues = []
if not isinstance(content, dict) or "raw" in content:
return issues
top_perms = content.get("permissions")
if top_perms is None:
issues.append({
"issue": "No top-level permissions defined (inherits defaults)",
"file": str(workflow_path),
})
self.findings.append({
"severity": "medium",
"type": "Missing Permissions",
"detail": "Workflow has no permissions block",
"file": str(workflow_path),
})
if top_perms == "write-all" or (isinstance(top_perms, dict) and
top_perms.get("contents") == "write" and
top_perms.get("actions") == "write"):
issues.append({"issue": "Overly permissive write-all", "file": str(workflow_path)})
self.findings.append({
"severity": "high",
"type": "Excessive Permissions",
"detail": "write-all permissions granted",
"file": str(workflow_path),
})
return issues
def check_script_injection(self, workflow_path, content):
"""Check for user-controlled input in run steps (script injection)."""
injections = []
raw = content.get("raw", "") if "raw" in content else ""
if not raw:
try:
raw = Path(workflow_path).read_text()
except Exception:
return injections
dangerous_contexts = [
"github.event.pull_request.title",
"github.event.pull_request.body",
"github.event.issue.title",
"github.event.issue.body",
"github.event.comment.body",
"github.event.review.body",
"github.head_ref",
]
in_run = False
for line_num, line in enumerate(raw.splitlines(), 1):
stripped = line.strip()
if stripped.startswith("run:") or stripped.startswith("run: |"):
in_run = True
elif in_run and not stripped.startswith("-") and not stripped.startswith("#"):
for ctx in dangerous_contexts:
if f"${{{{ {ctx}" in line or f"${{{{{ctx}" in line:
injections.append({
"context": ctx,
"line": line_num,
"file": str(workflow_path),
})
self.findings.append({
"severity": "high",
"type": "Script Injection",
"detail": f"{ctx} in run step at line {line_num}",
"file": str(workflow_path),
})
if stripped and not stripped.startswith("-") and not stripped.startswith("#") and ":" in stripped and not stripped.startswith("run"):
in_run = False
return injections
def check_dangerous_triggers(self, workflow_path, content):
"""Check for dangerous event triggers."""
issues = []
if not isinstance(content, dict) or "raw" in content:
raw = content.get("raw", "")
if "pull_request_target" in raw:
issues.append({"trigger": "pull_request_target", "file": str(workflow_path)})
self.findings.append({
"severity": "high",
"type": "Dangerous Trigger",
"detail": "pull_request_target allows fork code to run with base permissions",
"file": str(workflow_path),
})
return issues
on_block = content.get("on", content.get(True, {}))
if isinstance(on_block, dict) and "pull_request_target" in on_block:
issues.append({"trigger": "pull_request_target", "file": str(workflow_path)})
self.findings.append({
"severity": "high",
"type": "Dangerous Trigger",
"detail": "pull_request_target trigger used",
"file": str(workflow_path),
})
return issues
def audit_all(self):
"""Run all security checks on all workflow files."""
workflows = self.find_workflows()
results = []
for wf in workflows:
content = self._load_workflow(wf)
unpinned = self.check_sha_pinning(wf, content)
perms = self.check_permissions(wf, content)
injections = self.check_script_injection(wf, content)
triggers = self.check_dangerous_triggers(wf, content)
results.append({
"workflow": str(wf),
"unpinned_actions": len(unpinned),
"permission_issues": len(perms),
"script_injections": len(injections),
"dangerous_triggers": len(triggers),
})
return results
def generate_report(self):
audit = self.audit_all()
report = {
"report_date": datetime.utcnow().isoformat(),
"repository": str(self.repo_path),
"workflows_scanned": len(audit),
"audit_summary": audit,
"findings": self.findings,
"total_findings": len(self.findings),
}
out = self.output_dir / "gha_security_report.json"
with open(out, "w") as f:
json.dump(report, f, indent=2)
print(json.dumps(report, indent=2))
return report
def main():
repo = sys.argv[1] if len(sys.argv) > 1 else "."
agent = GitHubActionsSecurityAgent(repo)
agent.generate_report()
if __name__ == "__main__":
main()