mirror of
https://github.com/mukul975/Anthropic-Cybersecurity-Skills.git
synced 2026-06-10 21:24:56 +03:00
c21af3347e
- 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
219 lines
8.1 KiB
Python
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()
|