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.
- ci_agent_audit/__init__.py +3 -0
- ci_agent_audit/cli.py +251 -0
- ci_agent_audit/fixer.py +79 -0
- ci_agent_audit/formatters.py +149 -0
- ci_agent_audit/models.py +113 -0
- ci_agent_audit/rules.py +1245 -0
- ci_agent_audit/signatures/aider-ci.yaml +26 -0
- ci_agent_audit/signatures/claude-code-action.yaml +35 -0
- ci_agent_audit/signatures/claude-code-security-review.yaml +15 -0
- ci_agent_audit/signatures/coderabbit.yaml +18 -0
- ci_agent_audit/signatures/codex-action.yaml +30 -0
- ci_agent_audit/signatures/continue-ci.yaml +21 -0
- ci_agent_audit/signatures/github-ai-inference.yaml +19 -0
- ci_agent_audit/signatures/goose-ci.yaml +27 -0
- ci_agent_audit/signatures/openhands-resolver.yaml +22 -0
- ci_agent_audit/signatures/pr-agent.yaml +20 -0
- ci_agent_audit/signatures/run-gemini-cli.yaml +27 -0
- ci_agent_audit/signatures/swe-agent-ci.yaml +20 -0
- ci_agent_audit/signatures_loader.py +90 -0
- ci_agent_audit/taint.py +295 -0
- ci_agent_audit/yaml_loader.py +92 -0
- ci_agent_audit-0.1.0.dist-info/METADATA +236 -0
- ci_agent_audit-0.1.0.dist-info/RECORD +27 -0
- ci_agent_audit-0.1.0.dist-info/WHEEL +5 -0
- ci_agent_audit-0.1.0.dist-info/entry_points.txt +2 -0
- ci_agent_audit-0.1.0.dist-info/licenses/LICENSE +21 -0
- ci_agent_audit-0.1.0.dist-info/top_level.txt +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())
|
ci_agent_audit/fixer.py
ADDED
|
@@ -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]
|
ci_agent_audit/models.py
ADDED
|
@@ -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
|
+
}
|