ci-agent-audit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """ci-agent-audit: Static security auditor for AI-powered CI/CD workflows."""
2
+
3
+ __version__ = "0.1.0"
ci_agent_audit/cli.py ADDED
@@ -0,0 +1,251 @@
1
+ """Command-line interface for ci-agent-audit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+ import sys
8
+ from typing import Sequence
9
+
10
+ import yaml
11
+
12
+ from ci_agent_audit import __version__
13
+ from ci_agent_audit.fixer import apply_fixes_to_file
14
+ from ci_agent_audit.formatters import (
15
+ format_github_annotations,
16
+ format_json,
17
+ format_sarif,
18
+ format_text,
19
+ )
20
+ from ci_agent_audit.models import Finding, Severity
21
+ from ci_agent_audit.rules import RULES_CATALOG, scan_workflow_file
22
+ from ci_agent_audit.signatures_loader import SignatureValidationError, load_signatures
23
+
24
+
25
+ def _discover_workflow_files(paths: Sequence[str]) -> list[Path]:
26
+ discovered: list[Path] = []
27
+ for raw in paths:
28
+ p = Path(raw)
29
+ if p.is_file():
30
+ discovered.append(p)
31
+ elif p.is_dir():
32
+ for ext in ("*.yml", "*.yaml"):
33
+ discovered.extend(sorted(p.rglob(ext)))
34
+ # Deduplicate while preserving order
35
+ seen: set[Path] = set()
36
+ unique: list[Path] = []
37
+ for item in discovered:
38
+ resolved = item.resolve()
39
+ if resolved not in seen:
40
+ seen.add(resolved)
41
+ unique.append(item)
42
+ return unique
43
+
44
+
45
+ def _build_parser() -> argparse.ArgumentParser:
46
+ parser = argparse.ArgumentParser(
47
+ prog="ci-agent-audit",
48
+ description=(
49
+ "Static security auditor for AI-powered CI/CD workflows (GitHub Actions & GitLab CI). "
50
+ "Detects PromptPwnd indirect prompt injections, [bot] bypasses, dangerous agent flags, "
51
+ "and unpinned MCP servers."
52
+ ),
53
+ )
54
+ parser.add_argument("--version", action="version", version=f"ci-agent-audit {__version__}")
55
+
56
+ subparsers = parser.add_subparsers(dest="command")
57
+
58
+ # `scan` command
59
+ scan_p = subparsers.add_parser("scan", help="Scan workflow files or directories for AI CI security risks")
60
+ scan_p.add_argument(
61
+ "paths",
62
+ nargs="*",
63
+ default=[".github/workflows"],
64
+ help="Workflow files or directories to scan (default: .github/workflows)",
65
+ )
66
+ scan_p.add_argument(
67
+ "--format",
68
+ choices=["text", "json", "sarif", "github"],
69
+ default="text",
70
+ help="Output format (default: text)",
71
+ )
72
+ scan_p.add_argument(
73
+ "--severity",
74
+ choices=["low", "medium", "high", "critical"],
75
+ default="low",
76
+ help="Minimum severity level to report (default: low)",
77
+ )
78
+ scan_p.add_argument(
79
+ "--fail-on",
80
+ choices=["low", "medium", "high", "critical", "none"],
81
+ default="high",
82
+ help="Minimum severity level that causes a non-zero exit code (default: high)",
83
+ )
84
+ scan_p.add_argument(
85
+ "--ignore-rule",
86
+ action="append",
87
+ default=[],
88
+ help="Rule ID to ignore (can be specified multiple times, e.g. --ignore-rule AIA006)",
89
+ )
90
+ scan_p.add_argument(
91
+ "--signatures-dir",
92
+ action="append",
93
+ default=[],
94
+ help="Additional directory of custom YAML tool signatures",
95
+ )
96
+ scan_p.add_argument(
97
+ "--output",
98
+ "-o",
99
+ help="Write formatted report to a file instead of stdout",
100
+ )
101
+ scan_p.add_argument(
102
+ "--fix",
103
+ action="store_true",
104
+ help="Apply safe, deterministic fixes in-place before scanning",
105
+ )
106
+
107
+ # `rules` command
108
+ subparsers.add_parser("rules", help="List all built-in security audit rules (AIA001 - AIA011)")
109
+
110
+ # `explain` command
111
+ explain_p = subparsers.add_parser("explain", help="Show detailed documentation and remediation for a rule")
112
+ explain_p.add_argument("rule_id", help="Rule ID (e.g. AIA001)")
113
+
114
+ # `signatures` command
115
+ subparsers.add_parser("signatures", help="List all loaded AI Action & CLI tool signatures")
116
+
117
+ # `validate-signatures` command (for contributors adding new 1-file signatures)
118
+ val_p = subparsers.add_parser(
119
+ "validate-signatures",
120
+ help="Validate all built-in and custom YAML signature files against the schema",
121
+ )
122
+ val_p.add_argument(
123
+ "dirs",
124
+ nargs="*",
125
+ default=[],
126
+ help="Optional extra signature directories to validate",
127
+ )
128
+
129
+ return parser
130
+
131
+
132
+ def main(argv: Sequence[str] | None = None) -> int:
133
+ if hasattr(sys.stdout, "reconfigure"):
134
+ try:
135
+ sys.stdout.reconfigure(encoding="utf-8")
136
+ except Exception:
137
+ pass
138
+
139
+ parser = _build_parser()
140
+ args = parser.parse_args(argv)
141
+
142
+ if not args.command:
143
+ parser.print_help()
144
+ return 0
145
+
146
+ if args.command == "rules":
147
+ for rule_id, meta in sorted(RULES_CATALOG.items()):
148
+ print(f"{rule_id} [{meta.severity.value.upper()}] {meta.name}")
149
+ print(f" {meta.short_description}")
150
+ return 0
151
+
152
+ if args.command == "explain":
153
+ rid = args.rule_id.upper()
154
+ if rid not in RULES_CATALOG:
155
+ print(f"Error: Unknown rule ID '{args.rule_id}'. Run `ci-agent-audit rules` to list rules.", file=sys.stderr)
156
+ return 2
157
+ meta = RULES_CATALOG[rid]
158
+ print(f"Rule: {meta.rule_id} ({meta.name})")
159
+ print(f"Severity: {meta.severity.value.upper()}")
160
+ print(f"Summary: {meta.short_description}\n")
161
+ print(f"Description:\n {meta.full_description}\n")
162
+ print(f"Remediation:\n {meta.remediation}\n")
163
+ if meta.references:
164
+ print("References:")
165
+ for ref in meta.references:
166
+ print(f" - {ref}")
167
+ return 0
168
+
169
+ if args.command == "signatures":
170
+ sigs = load_signatures()
171
+ print(f"Loaded {len(sigs)} AI Action/CLI signatures:")
172
+ for sig in sigs:
173
+ actions = ", ".join(sig.action_prefixes) if sig.action_prefixes else "-"
174
+ bins = ", ".join(sig.cli_binaries) if sig.cli_binaries else "-"
175
+ print(f" - {sig.id} ({sig.name} by {sig.vendor})")
176
+ print(f" Actions: {actions} | CLIs: {bins}")
177
+ return 0
178
+
179
+ if args.command == "validate-signatures":
180
+ extra = [Path(d) for d in args.dirs]
181
+ try:
182
+ sigs = load_signatures(extra_dirs=extra)
183
+ except SignatureValidationError as exc:
184
+ print(f"✗ Signature validation failed: {exc}", file=sys.stderr)
185
+ return 1
186
+ print(f"✓ Successfully validated {len(sigs)} signature file(s).")
187
+ return 0
188
+
189
+ if args.command == "scan":
190
+ extra_dirs = [Path(d) for d in args.signatures_dir]
191
+ signatures = load_signatures(extra_dirs=extra_dirs)
192
+ files = _discover_workflow_files(args.paths)
193
+
194
+ # A security gate must not report success for files it never read
195
+ if not files:
196
+ print(f"Error: No workflow files found in: {', '.join(args.paths)}", file=sys.stderr)
197
+ return 2
198
+
199
+ if args.fix:
200
+ for wf in files:
201
+ modified, notes = apply_fixes_to_file(wf)
202
+ if modified:
203
+ for note in notes:
204
+ print(f"[FIXED] {wf.as_posix()}: {note}", file=sys.stderr)
205
+
206
+ min_sev = Severity(args.severity)
207
+ ignored = {r.upper() for r in args.ignore_rule}
208
+
209
+ all_findings: list[Finding] = []
210
+ unreadable: list[Path] = []
211
+ for wf in files:
212
+ try:
213
+ file_findings = scan_workflow_file(wf, signatures=signatures)
214
+ except (yaml.YAMLError, UnicodeDecodeError, OSError) as exc:
215
+ unreadable.append(wf)
216
+ print(f"Error: Could not scan {wf.as_posix()}: {exc}", file=sys.stderr)
217
+ continue
218
+ for f in file_findings:
219
+ if f.rule_id.upper() in ignored:
220
+ continue
221
+ if f.severity.rank >= min_sev.rank:
222
+ all_findings.append(f)
223
+
224
+ if args.format == "json":
225
+ output_text = format_json(all_findings)
226
+ elif args.format == "sarif":
227
+ output_text = format_sarif(all_findings)
228
+ elif args.format == "github":
229
+ output_text = format_github_annotations(all_findings)
230
+ else:
231
+ output_text = format_text(all_findings)
232
+
233
+ if args.output:
234
+ Path(args.output).write_text(output_text, encoding="utf-8")
235
+ elif output_text:
236
+ print(output_text)
237
+
238
+ if unreadable:
239
+ return 2
240
+
241
+ if args.fail_on == "none":
242
+ return 0
243
+ fail_sev = Severity(args.fail_on)
244
+ should_fail = any(f.severity.rank >= fail_sev.rank for f in all_findings)
245
+ return 1 if should_fail else 0
246
+
247
+ return 0
248
+
249
+
250
+ if __name__ == "__main__":
251
+ raise SystemExit(main())
@@ -0,0 +1,79 @@
1
+ """Deterministic auto-fix engine (`--fix`) for common AI CI/CD workflow misconfigurations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import re
7
+
8
+ import yaml
9
+
10
+
11
+ def apply_fixes_to_file(file_path: Path) -> tuple[bool, list[str]]:
12
+ """Apply safe, deterministic fixes to a workflow file and return (modified, applied_descriptions)."""
13
+ # Bytes keep the file's own line endings, which text mode would normalize on Windows
14
+ original = file_path.read_bytes().decode("utf-8")
15
+ updated, descriptions = apply_fixes_to_text(original)
16
+ if updated != original:
17
+ file_path.write_bytes(updated.encode("utf-8"))
18
+ return True, descriptions
19
+ return False, []
20
+
21
+
22
+ def apply_fixes_to_text(content: str) -> tuple[str, list[str]]:
23
+ """Apply deterministic security remediations to workflow YAML text."""
24
+ descriptions: list[str] = []
25
+ text = content
26
+ newline = "\r\n" if "\r\n" in content else "\n"
27
+
28
+ # Fix 1 (AIA004): Upgrade vulnerable anthropics/claude-code-action versions (< 1.0.94)
29
+ # Branch refs are left alone: pinning @main to the safe minimum would be a large downgrade
30
+ def _upgrade_claude_action(match: re.Match[str]) -> str:
31
+ prefix = match.group(1)
32
+ ver = match.group(2)
33
+ cleaned = ver.lstrip("vV")
34
+ parts = cleaned.split(".")
35
+ if len(parts) == 3 and all(p.isdigit() for p in parts):
36
+ tup = (int(parts[0]), int(parts[1]), int(parts[2]))
37
+ if tup < (1, 0, 94):
38
+ descriptions.append(f"Upgraded vulnerable {prefix}@{ver} to @v1.0.94 (AIA004)")
39
+ return f"{prefix}@v1.0.94"
40
+ return match.group(0)
41
+
42
+ text = re.sub(
43
+ r"(anthropics/claude-code-action)@([A-Za-z0-9._-]+)",
44
+ _upgrade_claude_action,
45
+ text,
46
+ )
47
+
48
+ # Fix 2 (AIA003): Remove wildcard allowed_non_write_users / allowed_bots: "*"
49
+ wildcard_users_re = re.compile(
50
+ r"^([ \t]*(allowed_non_write_users|allowed_bots)[ \t]*:[ \t]*)(?:['\"]\*['\"]|\*)([ \t]*(?:#[^\r\n]*)?)(\r?)$",
51
+ re.MULTILINE,
52
+ )
53
+
54
+ def _clear_wildcard(match: re.Match[str]) -> str:
55
+ descriptions.append(f"Cleared insecure `{match.group(2)}: '*'` wildcard (AIA003)")
56
+ return f'{match.group(1)}""{match.group(3)} # Fixed by ci-agent-audit (AIA003){match.group(4)}'
57
+
58
+ text = wildcard_users_re.sub(_clear_wildcard, text)
59
+
60
+ # Fix 3 (AIA005): Replace `permissions: write-all` with least-privilege `contents: read`
61
+ write_all_re = re.compile(
62
+ r"^([ \t]*)permissions[ \t]*:[ \t]*['\"]?write-all['\"]?[ \t]*(?=\r?$)",
63
+ re.MULTILINE | re.IGNORECASE,
64
+ )
65
+
66
+ def _scope_permissions(match: re.Match[str]) -> str:
67
+ descriptions.append("Replaced `permissions: write-all` with `contents: read` (AIA005)")
68
+ indent = match.group(1)
69
+ return f"{indent}permissions:{newline}{indent} contents: read"
70
+
71
+ text = write_all_re.sub(_scope_permissions, text)
72
+
73
+ # Never hand back a workflow that the fixes turned into invalid YAML
74
+ try:
75
+ yaml.safe_load(text)
76
+ except yaml.YAMLError:
77
+ return content, []
78
+
79
+ return text, descriptions
@@ -0,0 +1,149 @@
1
+ """Output formatters for ci-agent-audit (text, json, sarif, github)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Iterable
7
+
8
+ from ci_agent_audit import __version__
9
+ from ci_agent_audit.models import Finding, Severity
10
+ from ci_agent_audit.rules import RULES_CATALOG
11
+
12
+
13
+ def format_text(findings: Iterable[Finding]) -> str:
14
+ """Format findings as human-readable terminal report."""
15
+ items = list(findings)
16
+ if not items:
17
+ return "✓ ci-agent-audit: 0 security issues found in scanned CI/CD workflows."
18
+
19
+ lines: list[str] = []
20
+ for f in items:
21
+ sev_badge = f"[{f.severity.value.upper()}]"
22
+ loc = f"{f.file_path.as_posix()}:{f.line}:{f.column}"
23
+ context = f" (job: {f.job_id})" if f.job_id else ""
24
+ lines.append(f"{sev_badge} {f.rule_id} ({f.rule_name}) at {loc}{context}")
25
+ lines.append(f" Message: {f.message}")
26
+ if f.snippet:
27
+ lines.append(f" Snippet: {f.snippet}")
28
+ lines.append(f" Remediation: {f.remediation}")
29
+ lines.append("")
30
+
31
+ counts = {s: 0 for s in Severity}
32
+ for f in items:
33
+ counts[f.severity] += 1
34
+
35
+ summary_parts = [
36
+ f"{counts[Severity.CRITICAL]} critical",
37
+ f"{counts[Severity.HIGH]} high",
38
+ f"{counts[Severity.MEDIUM]} medium",
39
+ f"{counts[Severity.LOW]} low",
40
+ ]
41
+ lines.append(
42
+ f"✗ ci-agent-audit: Found {len(items)} issue(s) ({', '.join(summary_parts)})."
43
+ )
44
+ return "\n".join(lines)
45
+
46
+
47
+ def format_json(findings: Iterable[Finding]) -> str:
48
+ """Format findings as structured JSON."""
49
+ items = [f.to_dict() for f in findings]
50
+ payload = {
51
+ "tool": "ci-agent-audit",
52
+ "version": __version__,
53
+ "total_findings": len(items),
54
+ "findings": items,
55
+ }
56
+ return json.dumps(payload, indent=2)
57
+
58
+
59
+ def format_github_annotations(findings: Iterable[Finding]) -> str:
60
+ """Format findings as GitHub Actions workflow commands (`::error`, `::warning`, `::notice`)."""
61
+ lines: list[str] = []
62
+ for f in findings:
63
+ if f.severity in (Severity.CRITICAL, Severity.HIGH):
64
+ level = "error"
65
+ elif f.severity == Severity.MEDIUM:
66
+ level = "warning"
67
+ else:
68
+ level = "notice"
69
+
70
+ title = f"{f.rule_id} ({f.rule_name})"
71
+ escaped_msg = f.message.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
72
+ file_str = f.file_path.as_posix()
73
+ lines.append(
74
+ f"::{level} file={file_str},line={f.line},col={f.column},title={title}::{escaped_msg}"
75
+ )
76
+ return "\n".join(lines)
77
+
78
+
79
+ def format_sarif(findings: Iterable[Finding]) -> str:
80
+ """Format findings as SARIF v2.1.0 for GitHub Code Scanning / Security tab."""
81
+ items = list(findings)
82
+ rules_list = []
83
+ for rule_id, meta in sorted(RULES_CATALOG.items()):
84
+ rules_list.append(
85
+ {
86
+ "id": rule_id,
87
+ "name": meta.name,
88
+ "shortDescription": {"text": meta.short_description},
89
+ "fullDescription": {"text": meta.full_description},
90
+ "help": {
91
+ "text": f"{meta.full_description}\n\nRemediation: {meta.remediation}",
92
+ },
93
+ "defaultConfiguration": {"level": meta.severity.sarif_level},
94
+ "properties": {
95
+ "tags": ["security", "ci-cd", "ai-agents", "prompt-injection"],
96
+ "security-severity": _sarif_security_score(meta.severity),
97
+ },
98
+ }
99
+ )
100
+
101
+ results = []
102
+ for f in items:
103
+ results.append(
104
+ {
105
+ "ruleId": f.rule_id,
106
+ "level": f.severity.sarif_level,
107
+ "message": {"text": f"{f.message} Remediation: {f.remediation}"},
108
+ "locations": [
109
+ {
110
+ "physicalLocation": {
111
+ "artifactLocation": {"uri": f.file_path.as_posix()},
112
+ "region": {
113
+ "startLine": max(1, f.line),
114
+ "startColumn": max(1, f.column),
115
+ },
116
+ }
117
+ }
118
+ ],
119
+ }
120
+ )
121
+
122
+ sarif_doc = {
123
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
124
+ "version": "2.1.0",
125
+ "runs": [
126
+ {
127
+ "tool": {
128
+ "driver": {
129
+ "name": "ci-agent-audit",
130
+ "version": __version__,
131
+ "informationUri": "https://github.com/layer8labs8/ci-agent-audit",
132
+ "rules": rules_list,
133
+ }
134
+ },
135
+ "results": results,
136
+ }
137
+ ],
138
+ }
139
+ return json.dumps(sarif_doc, indent=2)
140
+
141
+
142
+ def _sarif_security_score(severity: Severity) -> str:
143
+ scores = {
144
+ Severity.CRITICAL: "9.5",
145
+ Severity.HIGH: "7.8",
146
+ Severity.MEDIUM: "5.5",
147
+ Severity.LOW: "3.0",
148
+ }
149
+ return scores[severity]
@@ -0,0 +1,113 @@
1
+ """Data models for ci-agent-audit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ class Severity(str, Enum):
12
+ """Severity levels for audit findings."""
13
+
14
+ LOW = "low"
15
+ MEDIUM = "medium"
16
+ HIGH = "high"
17
+ CRITICAL = "critical"
18
+
19
+ @property
20
+ def rank(self) -> int:
21
+ ranks = {
22
+ Severity.LOW: 1,
23
+ Severity.MEDIUM: 2,
24
+ Severity.HIGH: 3,
25
+ Severity.CRITICAL: 4,
26
+ }
27
+ return ranks[self]
28
+
29
+ @property
30
+ def sarif_level(self) -> str:
31
+ if self in (Severity.HIGH, Severity.CRITICAL):
32
+ return "error"
33
+ if self == Severity.MEDIUM:
34
+ return "warning"
35
+ return "note"
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class RuleMetadata:
40
+ """Definition of a built-in security rule."""
41
+
42
+ rule_id: str
43
+ name: str
44
+ severity: Severity
45
+ short_description: str
46
+ full_description: str
47
+ remediation: str
48
+ references: list[str] = field(default_factory=list)
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class DangerousFlagPattern:
53
+ """A CLI flag or input argument pattern that enables unsafe agent execution."""
54
+
55
+ pattern: str
56
+ reason: str
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ActionSignature:
61
+ """Declarative signature for an AI GitHub Action or CLI tool (1 YAML file per tool)."""
62
+
63
+ id: str
64
+ name: str
65
+ vendor: str
66
+ description: str
67
+ action_prefixes: list[str] = field(default_factory=list)
68
+ non_agent_actions: list[str] = field(default_factory=list)
69
+ cli_binaries: list[str] = field(default_factory=list)
70
+ cli_packages: list[str] = field(default_factory=list)
71
+ cli_subcommands: list[str] = field(default_factory=list)
72
+ prompt_inputs: list[str] = field(default_factory=list)
73
+ cli_prompt_flags: list[str] = field(default_factory=list)
74
+ dangerous_inputs: dict[str, str] = field(default_factory=dict)
75
+ dangerous_arg_patterns: list[DangerousFlagPattern] = field(default_factory=list)
76
+ min_safe_version: str | None = None
77
+ advisory_url: str | None = None
78
+ source_file: str | None = None
79
+
80
+
81
+ @dataclass
82
+ class Finding:
83
+ """A security issue detected in a CI/CD workflow file."""
84
+
85
+ rule_id: str
86
+ rule_name: str
87
+ severity: Severity
88
+ file_path: Path
89
+ line: int
90
+ column: int
91
+ message: str
92
+ remediation: str
93
+ job_id: str | None = None
94
+ step_name: str | None = None
95
+ snippet: str | None = None
96
+ tool_id: str | None = None
97
+ extra: dict[str, Any] = field(default_factory=dict)
98
+
99
+ def to_dict(self) -> dict[str, Any]:
100
+ return {
101
+ "rule_id": self.rule_id,
102
+ "rule_name": self.rule_name,
103
+ "severity": self.severity.value,
104
+ "file": str(self.file_path.as_posix()),
105
+ "line": self.line,
106
+ "column": self.column,
107
+ "job_id": self.job_id,
108
+ "step_name": self.step_name,
109
+ "tool_id": self.tool_id,
110
+ "message": self.message,
111
+ "remediation": self.remediation,
112
+ "snippet": self.snippet,
113
+ }