codeguard-cli 2.0.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.
- codeguard/__init__.py +7 -0
- codeguard/cli/__init__.py +2 -0
- codeguard/cli/_run.py +230 -0
- codeguard/cli/commands.py +390 -0
- codeguard/cli/formatters.py +422 -0
- codeguard/cli/main.py +206 -0
- codeguard/config/__init__.py +16 -0
- codeguard/config/loader.py +86 -0
- codeguard/config/schema.py +172 -0
- codeguard/engine/__init__.py +25 -0
- codeguard/engine/baseline.py +122 -0
- codeguard/engine/context.py +61 -0
- codeguard/engine/discovery.py +160 -0
- codeguard/engine/finding.py +205 -0
- codeguard/engine/fingerprint.py +94 -0
- codeguard/engine/gitdiff.py +80 -0
- codeguard/engine/policy.py +74 -0
- codeguard/engine/registry.py +78 -0
- codeguard/engine/rule.py +195 -0
- codeguard/engine/runner.py +267 -0
- codeguard/engine/suppressions.py +109 -0
- codeguard/lang/__init__.py +37 -0
- codeguard/lang/base.py +80 -0
- codeguard/lang/javascript.py +20 -0
- codeguard/lang/node.py +137 -0
- codeguard/lang/python_ast.py +29 -0
- codeguard/lang/registry.py +38 -0
- codeguard/lang/treesitter.py +99 -0
- codeguard/lang/typescript.py +24 -0
- codeguard/py.typed +1 -0
- codeguard/rules/__init__.py +6 -0
- codeguard/rules/_jsnodes.py +82 -0
- codeguard/rules/_pyimports.py +60 -0
- codeguard/rules/javascript/__init__.py +9 -0
- codeguard/rules/javascript/cg_sec_101_dynamic_code.py +89 -0
- codeguard/rules/javascript/cg_sec_102_child_process.py +58 -0
- codeguard/rules/javascript/cg_sec_103_dom_xss.py +67 -0
- codeguard/rules/javascript/cg_sec_104_react_dangerous_html.py +54 -0
- codeguard/rules/javascript/cg_sec_105_hardcoded_secret.py +73 -0
- codeguard/rules/javascript/cg_sec_106_weak_random.py +83 -0
- codeguard/rules/meta/__init__.py +55 -0
- codeguard/rules/security/__init__.py +8 -0
- codeguard/rules/security/cg_sec_001_sql_injection.py +110 -0
- codeguard/rules/security/cg_sec_002_hardcoded_secrets.py +184 -0
- codeguard/rules/security/cg_sec_003_eval_exec.py +104 -0
- codeguard/rules/security/cg_sec_004_unsafe_deserialization.py +156 -0
- codeguard/rules/security/cg_sec_005_shell_injection.py +157 -0
- codeguard_cli-2.0.0.dist-info/METADATA +210 -0
- codeguard_cli-2.0.0.dist-info/RECORD +52 -0
- codeguard_cli-2.0.0.dist-info/WHEEL +4 -0
- codeguard_cli-2.0.0.dist-info/entry_points.txt +2 -0
- codeguard_cli-2.0.0.dist-info/licenses/LICENSE +184 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Output formatters: human-readable, JSON, and SARIF."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from codeguard.engine.finding import Finding
|
|
11
|
+
from codeguard.engine.fingerprint import SCHEME as _FP_SCHEME
|
|
12
|
+
|
|
13
|
+
_SEVERITY_ORDER = ("critical", "high", "medium", "low", "info")
|
|
14
|
+
|
|
15
|
+
_HELP_URI_BASE = "https://mevichitra.github.io/codeguard/rules/"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def finding_help_uri(rule_id: str) -> str:
|
|
19
|
+
return f"{_HELP_URI_BASE}{rule_id.lower()}/"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _muted(f: Finding) -> bool:
|
|
23
|
+
"""A finding hidden from the default view: suppressed or baselined."""
|
|
24
|
+
return f.suppressed or f.baselined
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Human-readable (default)
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
_SEVERITY_COLOR = {
|
|
32
|
+
"critical": "bold red",
|
|
33
|
+
"high": "red",
|
|
34
|
+
"medium": "yellow",
|
|
35
|
+
"low": "blue",
|
|
36
|
+
"info": "dim",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def format_human(findings: list[Finding], *, show_suppressed: bool = False) -> str:
|
|
41
|
+
"""Return a human-readable string representation of *findings*.
|
|
42
|
+
|
|
43
|
+
Suppressed and baselined findings are omitted unless *show_suppressed* is True.
|
|
44
|
+
"""
|
|
45
|
+
from io import StringIO
|
|
46
|
+
|
|
47
|
+
from rich.console import Console
|
|
48
|
+
|
|
49
|
+
buf = StringIO()
|
|
50
|
+
console = Console(file=buf, highlight=False, markup=True)
|
|
51
|
+
|
|
52
|
+
active = [f for f in findings if not _muted(f)]
|
|
53
|
+
muted = [f for f in findings if _muted(f)]
|
|
54
|
+
n_suppressed = sum(1 for f in muted if f.suppressed)
|
|
55
|
+
n_baselined = sum(1 for f in muted if f.baselined and not f.suppressed)
|
|
56
|
+
|
|
57
|
+
if not active and not (show_suppressed and muted):
|
|
58
|
+
console.print("[bold green]✓ No findings.[/bold green]")
|
|
59
|
+
return buf.getvalue()
|
|
60
|
+
|
|
61
|
+
if show_suppressed:
|
|
62
|
+
active = list(findings)
|
|
63
|
+
|
|
64
|
+
_file_cache: dict[str, list[str]] = {}
|
|
65
|
+
|
|
66
|
+
for f in active:
|
|
67
|
+
color = _SEVERITY_COLOR.get(f.severity.value, "white")
|
|
68
|
+
loc = f"{f.location.file}:{f.location.line}:{f.location.col}"
|
|
69
|
+
sev = f.severity.value.upper()
|
|
70
|
+
tag = (
|
|
71
|
+
" [dim](suppressed)[/dim]"
|
|
72
|
+
if f.suppressed
|
|
73
|
+
else (" [dim](baselined)[/dim]" if f.baselined else "")
|
|
74
|
+
)
|
|
75
|
+
console.print(f"[dim]{loc}[/dim] [{color}][{f.rule_id}] {sev}[/{color}] {f.title}{tag}")
|
|
76
|
+
|
|
77
|
+
# Show the offending source line with a column marker.
|
|
78
|
+
try:
|
|
79
|
+
if f.location.file not in _file_cache:
|
|
80
|
+
with open(f.location.file, encoding="utf-8", errors="replace") as fh:
|
|
81
|
+
_file_cache[f.location.file] = fh.readlines()
|
|
82
|
+
lines = _file_cache[f.location.file]
|
|
83
|
+
line_idx = f.location.line - 1
|
|
84
|
+
if 0 <= line_idx < len(lines):
|
|
85
|
+
source = lines[line_idx].rstrip()
|
|
86
|
+
gutter = f"{f.location.line:>4}"
|
|
87
|
+
console.print(f" {gutter} | {source}", style="dim", markup=False)
|
|
88
|
+
pointer = " " * (len(gutter) + 3 + (f.location.col - 1)) + "^"
|
|
89
|
+
console.print(f" {pointer}", style="dim", markup=False)
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
if f.fix_suggestion:
|
|
94
|
+
console.print(f" [dim]→ {f.fix_suggestion}[/dim]")
|
|
95
|
+
|
|
96
|
+
notes = []
|
|
97
|
+
if n_suppressed:
|
|
98
|
+
notes.append(f"{n_suppressed} suppressed")
|
|
99
|
+
if n_baselined:
|
|
100
|
+
notes.append(f"{n_baselined} baselined")
|
|
101
|
+
if notes:
|
|
102
|
+
console.print(f"\n[dim]({', '.join(notes)}, hidden — use --show-suppressed)[/dim]")
|
|
103
|
+
|
|
104
|
+
_print_summary(console, [f for f in active if not _muted(f)])
|
|
105
|
+
return buf.getvalue()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _print_summary(console: Any, findings: list[Finding]) -> None:
|
|
109
|
+
counts = Counter(f.severity.value for f in findings)
|
|
110
|
+
total = len(findings)
|
|
111
|
+
parts = []
|
|
112
|
+
for sev in _SEVERITY_ORDER:
|
|
113
|
+
n = counts.get(sev, 0)
|
|
114
|
+
if n:
|
|
115
|
+
parts.append(f"[{_SEVERITY_COLOR[sev]}]{n} {sev}[/{_SEVERITY_COLOR[sev]}]")
|
|
116
|
+
summary = ", ".join(parts) if parts else "0"
|
|
117
|
+
console.print(f"\n[bold]{total} finding(s)[/bold] ({summary})")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# JSON
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
#: Bump when the envelope shape changes in a backward-incompatible way.
|
|
125
|
+
JSON_SCHEMA_VERSION = "1"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def format_json(
|
|
129
|
+
findings: list[Finding],
|
|
130
|
+
*,
|
|
131
|
+
show_suppressed: bool = False,
|
|
132
|
+
tool_version: str = "0.0.0",
|
|
133
|
+
) -> str:
|
|
134
|
+
"""Return findings as a JSON envelope object.
|
|
135
|
+
|
|
136
|
+
Shape: ``{ schema_version, tool, rules, results, summary }``. ``results`` is
|
|
137
|
+
a list of finding dicts (see :meth:`Finding.to_dict`). For the pre-2.0 bare
|
|
138
|
+
array, use :func:`format_json_legacy`.
|
|
139
|
+
"""
|
|
140
|
+
emitted = findings if show_suppressed else [f for f in findings if not _muted(f)]
|
|
141
|
+
|
|
142
|
+
rules: dict[str, dict[str, Any]] = {}
|
|
143
|
+
for f in emitted:
|
|
144
|
+
rules.setdefault(
|
|
145
|
+
f.rule_id,
|
|
146
|
+
{
|
|
147
|
+
"id": f.rule_id,
|
|
148
|
+
"title": f.title,
|
|
149
|
+
"severity": f.severity.value,
|
|
150
|
+
"category": f.category.value,
|
|
151
|
+
"cwe": f.cwe,
|
|
152
|
+
"owasp": f.owasp,
|
|
153
|
+
"help_uri": finding_help_uri(f.rule_id),
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
counts = Counter(f.severity.value for f in emitted)
|
|
158
|
+
envelope = {
|
|
159
|
+
"schema_version": JSON_SCHEMA_VERSION,
|
|
160
|
+
"tool": {"name": "CodeGuard", "version": tool_version},
|
|
161
|
+
"rules": [rules[k] for k in sorted(rules)],
|
|
162
|
+
"results": [f.to_dict() for f in emitted],
|
|
163
|
+
"summary": {
|
|
164
|
+
"findings": len(emitted),
|
|
165
|
+
"by_severity": {s: counts[s] for s in _SEVERITY_ORDER if counts.get(s)},
|
|
166
|
+
"suppressed": sum(1 for f in findings if f.suppressed),
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
return json.dumps(envelope, indent=2)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def format_json_legacy(findings: list[Finding], *, show_suppressed: bool = False) -> str:
|
|
173
|
+
"""Return findings as a bare JSON array (the pre-2.0 ``--format json`` output).
|
|
174
|
+
|
|
175
|
+
Deprecated: retained for one minor version. Prefer :func:`format_json`.
|
|
176
|
+
"""
|
|
177
|
+
emitted = findings if show_suppressed else [f for f in findings if not _muted(f)]
|
|
178
|
+
return json.dumps([f.to_dict() for f in emitted], indent=2)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# SARIF 2.1.0
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
_SARIF_SEVERITY = {
|
|
186
|
+
"critical": "error",
|
|
187
|
+
"high": "error",
|
|
188
|
+
"medium": "warning",
|
|
189
|
+
"low": "note",
|
|
190
|
+
"info": "none",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
_SECURITY_SEVERITY = {
|
|
194
|
+
"critical": "9.5",
|
|
195
|
+
"high": "8.0",
|
|
196
|
+
"medium": "5.0",
|
|
197
|
+
"low": "3.0",
|
|
198
|
+
"info": "0.0",
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _cwe_number(cwe: str) -> str | None:
|
|
203
|
+
if cwe and cwe.upper().startswith("CWE-"):
|
|
204
|
+
return cwe[4:]
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def format_sarif(findings: list[Finding], *, tool_version: str = "0.0.0") -> str:
|
|
209
|
+
"""Return findings as a SARIF 2.1.0 JSON string.
|
|
210
|
+
|
|
211
|
+
SARIF is the standard format for static analysis results understood by
|
|
212
|
+
GitHub code scanning. https://docs.oasis-open.org/sarif/sarif/v2.1.0/
|
|
213
|
+
|
|
214
|
+
Suppressed findings are included as ``suppressions`` entries per the spec.
|
|
215
|
+
Every result carries ``partialFingerprints`` so alerts stay stable across
|
|
216
|
+
reformatting and line moves.
|
|
217
|
+
"""
|
|
218
|
+
rule_map: dict[str, Finding] = {}
|
|
219
|
+
for f in findings:
|
|
220
|
+
rule_map.setdefault(f.rule_id, f)
|
|
221
|
+
|
|
222
|
+
rules = []
|
|
223
|
+
for rule_id, f in sorted(rule_map.items()):
|
|
224
|
+
tags = [f.category.value]
|
|
225
|
+
cwe_n = _cwe_number(f.cwe or "")
|
|
226
|
+
if cwe_n:
|
|
227
|
+
tags.append(f"external/cwe/cwe-{cwe_n}")
|
|
228
|
+
rule: dict[str, Any] = {
|
|
229
|
+
"id": rule_id,
|
|
230
|
+
"name": f.title,
|
|
231
|
+
"shortDescription": {"text": f.title},
|
|
232
|
+
"fullDescription": {"text": f.description},
|
|
233
|
+
"helpUri": finding_help_uri(rule_id),
|
|
234
|
+
"defaultConfiguration": {"level": _SARIF_SEVERITY.get(f.severity.value, "warning")},
|
|
235
|
+
"properties": {
|
|
236
|
+
"tags": tags,
|
|
237
|
+
"security-severity": _SECURITY_SEVERITY.get(f.severity.value, "0.0"),
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
if f.cwe:
|
|
241
|
+
rule["properties"]["cwe"] = f.cwe
|
|
242
|
+
if f.owasp:
|
|
243
|
+
rule["properties"]["owasp"] = f.owasp
|
|
244
|
+
if f.fix_suggestion:
|
|
245
|
+
rule["help"] = {"text": f.fix_suggestion}
|
|
246
|
+
rules.append(rule)
|
|
247
|
+
|
|
248
|
+
results = []
|
|
249
|
+
for f in findings:
|
|
250
|
+
region: dict[str, Any] = {
|
|
251
|
+
"startLine": f.location.line,
|
|
252
|
+
"startColumn": f.location.col,
|
|
253
|
+
}
|
|
254
|
+
if f.location.end_line is not None:
|
|
255
|
+
region["endLine"] = f.location.end_line
|
|
256
|
+
if f.location.end_col is not None:
|
|
257
|
+
region["endColumn"] = f.location.end_col
|
|
258
|
+
|
|
259
|
+
result: dict[str, Any] = {
|
|
260
|
+
"ruleId": f.rule_id,
|
|
261
|
+
"level": _SARIF_SEVERITY.get(f.severity.value, "warning"),
|
|
262
|
+
"message": {"text": f.description},
|
|
263
|
+
"locations": [
|
|
264
|
+
{
|
|
265
|
+
"physicalLocation": {
|
|
266
|
+
"artifactLocation": {"uri": f.location.file, "uriBaseId": "%SRCROOT%"},
|
|
267
|
+
"region": region,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
],
|
|
271
|
+
"properties": {
|
|
272
|
+
"confidence": f.confidence,
|
|
273
|
+
"severity": f.severity.value,
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
if f.fingerprint:
|
|
277
|
+
result["partialFingerprints"] = {_FP_SCHEME: f.fingerprint}
|
|
278
|
+
if f.suppressed:
|
|
279
|
+
result["suppressions"] = [{"kind": "inSource", "justification": "inline ignore"}]
|
|
280
|
+
elif f.baselined:
|
|
281
|
+
result["suppressions"] = [{"kind": "external", "justification": "in baseline"}]
|
|
282
|
+
results.append(result)
|
|
283
|
+
|
|
284
|
+
sarif: dict[str, Any] = {
|
|
285
|
+
"$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
|
|
286
|
+
"version": "2.1.0",
|
|
287
|
+
"runs": [
|
|
288
|
+
{
|
|
289
|
+
"tool": {
|
|
290
|
+
"driver": {
|
|
291
|
+
"name": "CodeGuard",
|
|
292
|
+
"version": tool_version,
|
|
293
|
+
"informationUri": "https://github.com/mevichitra/codeguard",
|
|
294
|
+
"rules": rules,
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
"results": results,
|
|
298
|
+
}
|
|
299
|
+
],
|
|
300
|
+
}
|
|
301
|
+
return json.dumps(sarif, indent=2)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
# GitHub Actions workflow-command annotations
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
_GH_LEVEL = {
|
|
309
|
+
"critical": "error",
|
|
310
|
+
"high": "error",
|
|
311
|
+
"medium": "warning",
|
|
312
|
+
"low": "notice",
|
|
313
|
+
"info": "notice",
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _reportable(findings: list[Finding], *, show_suppressed: bool) -> list[Finding]:
|
|
318
|
+
if show_suppressed:
|
|
319
|
+
return list(findings)
|
|
320
|
+
return [f for f in findings if not _muted(f)]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def format_github(findings: list[Finding], *, show_suppressed: bool = False) -> str:
|
|
324
|
+
"""GitHub Actions ``::error`` / ``::warning`` annotations, one per finding.
|
|
325
|
+
|
|
326
|
+
Baselined findings are downgraded to ``::notice`` so they inform without
|
|
327
|
+
cluttering the PR.
|
|
328
|
+
"""
|
|
329
|
+
lines: list[str] = []
|
|
330
|
+
for f in _reportable(findings, show_suppressed=show_suppressed):
|
|
331
|
+
level = "notice" if f.baselined else _GH_LEVEL.get(f.severity.value, "warning")
|
|
332
|
+
msg = f.description.replace("\n", " ").replace("::", ":")
|
|
333
|
+
title = f"{f.rule_id}: {f.title}"
|
|
334
|
+
lines.append(
|
|
335
|
+
f"::{level} file={f.location.file},line={f.location.line},"
|
|
336
|
+
f"col={f.location.col},title={title}::{msg}"
|
|
337
|
+
)
|
|
338
|
+
return "\n".join(lines) + ("\n" if lines else "")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
# Reviewdog Diagnostic JSON (rdjson)
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
_RDJSON_SEVERITY = {
|
|
346
|
+
"critical": "ERROR",
|
|
347
|
+
"high": "ERROR",
|
|
348
|
+
"medium": "WARNING",
|
|
349
|
+
"low": "INFO",
|
|
350
|
+
"info": "INFO",
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def format_rdjson(
|
|
355
|
+
findings: list[Finding], *, show_suppressed: bool = False, tool_version: str = "0.0.0"
|
|
356
|
+
) -> str:
|
|
357
|
+
"""Reviewdog Diagnostic Result Format -- for inline PR comments via reviewdog."""
|
|
358
|
+
diagnostics = []
|
|
359
|
+
for f in _reportable(findings, show_suppressed=show_suppressed):
|
|
360
|
+
rng: dict[str, Any] = {"start": {"line": f.location.line, "column": f.location.col}}
|
|
361
|
+
if f.location.end_line is not None and f.location.end_col is not None:
|
|
362
|
+
rng["end"] = {"line": f.location.end_line, "column": f.location.end_col}
|
|
363
|
+
diagnostics.append(
|
|
364
|
+
{
|
|
365
|
+
"message": f"{f.title}\n{f.description}"
|
|
366
|
+
+ (f"\n\nFix: {f.fix_suggestion}" if f.fix_suggestion else ""),
|
|
367
|
+
"location": {"path": f.location.file, "range": rng},
|
|
368
|
+
"severity": "INFO"
|
|
369
|
+
if f.baselined
|
|
370
|
+
else _RDJSON_SEVERITY.get(f.severity.value, "WARNING"),
|
|
371
|
+
"code": {"value": f.rule_id, "url": finding_help_uri(f.rule_id)},
|
|
372
|
+
}
|
|
373
|
+
)
|
|
374
|
+
return json.dumps(
|
|
375
|
+
{
|
|
376
|
+
"source": {
|
|
377
|
+
"name": "codeguard",
|
|
378
|
+
"url": "https://github.com/mevichitra/codeguard",
|
|
379
|
+
},
|
|
380
|
+
"diagnostics": diagnostics,
|
|
381
|
+
},
|
|
382
|
+
indent=2,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
# JUnit XML
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _xml_escape(text: str) -> str:
|
|
392
|
+
return (
|
|
393
|
+
text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def format_junit(findings: list[Finding], *, show_suppressed: bool = False) -> str:
|
|
398
|
+
"""JUnit XML -- one ``<testcase>`` per finding, so CI dashboards can chart them."""
|
|
399
|
+
active = _reportable(findings, show_suppressed=show_suppressed)
|
|
400
|
+
gating = [f for f in active if not f.baselined]
|
|
401
|
+
parts = [
|
|
402
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
403
|
+
f'<testsuites name="codeguard" tests="{len(active)}" failures="{len(gating)}">',
|
|
404
|
+
f' <testsuite name="codeguard" tests="{len(active)}" failures="{len(gating)}">',
|
|
405
|
+
]
|
|
406
|
+
for f in active:
|
|
407
|
+
loc = f"{f.location.file}:{f.location.line}:{f.location.col}"
|
|
408
|
+
name = _xml_escape(f"{f.rule_id} {loc}")
|
|
409
|
+
if f.baselined:
|
|
410
|
+
parts.append(f' <testcase name="{name}" classname="{f.rule_id}">')
|
|
411
|
+
parts.append(f' <skipped message="{_xml_escape(f.title)} (baselined)"/>')
|
|
412
|
+
parts.append(" </testcase>")
|
|
413
|
+
else:
|
|
414
|
+
parts.append(f' <testcase name="{name}" classname="{f.rule_id}">')
|
|
415
|
+
parts.append(
|
|
416
|
+
f' <failure message="{_xml_escape(f.title)}" '
|
|
417
|
+
f'type="{f.severity.value}">{_xml_escape(f.description)}</failure>'
|
|
418
|
+
)
|
|
419
|
+
parts.append(" </testcase>")
|
|
420
|
+
parts.append(" </testsuite>")
|
|
421
|
+
parts.append("</testsuites>")
|
|
422
|
+
return "\n".join(parts) + "\n"
|
codeguard/cli/main.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""CodeGuard CLI -- entry point for the ``codeguard`` command."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
# Load all built-in rules (import for side-effect)
|
|
14
|
+
import codeguard.rules # noqa: F401
|
|
15
|
+
from codeguard import __version__
|
|
16
|
+
from codeguard.cli._run import (
|
|
17
|
+
EXIT_CONFIG,
|
|
18
|
+
EXIT_FINDINGS,
|
|
19
|
+
EXIT_INTERNAL,
|
|
20
|
+
EXIT_OK,
|
|
21
|
+
EXIT_USAGE,
|
|
22
|
+
FORMATS,
|
|
23
|
+
SEVERITIES,
|
|
24
|
+
RunOptions,
|
|
25
|
+
execute,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Back-compat alias (was EXIT_ERROR for both usage and IO errors).
|
|
29
|
+
EXIT_ERROR = EXIT_USAGE
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"EXIT_CONFIG",
|
|
33
|
+
"EXIT_ERROR",
|
|
34
|
+
"EXIT_FINDINGS",
|
|
35
|
+
"EXIT_INTERNAL",
|
|
36
|
+
"EXIT_OK",
|
|
37
|
+
"EXIT_USAGE",
|
|
38
|
+
"cli",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@click.group()
|
|
43
|
+
@click.version_option(__version__, prog_name="codeguard")
|
|
44
|
+
def cli() -> None:
|
|
45
|
+
"""CodeGuard -- fast, offline static analysis for security anti-patterns."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Options shared by `scan` and `ci`
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_CmdFn = Callable[..., None]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _common_options(fn: _CmdFn) -> _CmdFn:
|
|
57
|
+
opts: list[Callable[[Any], Any]] = [
|
|
58
|
+
click.argument("paths", nargs=-1, type=click.Path(path_type=Path)),
|
|
59
|
+
click.option(
|
|
60
|
+
"--config",
|
|
61
|
+
"config_path",
|
|
62
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
63
|
+
help="Path to codeguard.toml (default: discovered).",
|
|
64
|
+
),
|
|
65
|
+
click.option(
|
|
66
|
+
"--format",
|
|
67
|
+
"output_format",
|
|
68
|
+
type=click.Choice(FORMATS, case_sensitive=False),
|
|
69
|
+
default=None,
|
|
70
|
+
help="Output format.",
|
|
71
|
+
),
|
|
72
|
+
click.option(
|
|
73
|
+
"--rule",
|
|
74
|
+
"rule_ids",
|
|
75
|
+
multiple=True,
|
|
76
|
+
metavar="RULE_ID",
|
|
77
|
+
help="Only run the specified rule(s). Repeatable.",
|
|
78
|
+
),
|
|
79
|
+
click.option(
|
|
80
|
+
"--exclude",
|
|
81
|
+
"excludes",
|
|
82
|
+
multiple=True,
|
|
83
|
+
metavar="GLOB",
|
|
84
|
+
help="Skip files matching this glob. Repeatable.",
|
|
85
|
+
),
|
|
86
|
+
click.option(
|
|
87
|
+
"--include",
|
|
88
|
+
"includes",
|
|
89
|
+
multiple=True,
|
|
90
|
+
metavar="GLOB",
|
|
91
|
+
help="Only scan files matching this glob. Repeatable.",
|
|
92
|
+
),
|
|
93
|
+
click.option(
|
|
94
|
+
"--fail-on",
|
|
95
|
+
"fail_on",
|
|
96
|
+
type=click.Choice([*SEVERITIES, "never"], case_sensitive=False),
|
|
97
|
+
default=None,
|
|
98
|
+
help="Min severity that makes the run exit 1.",
|
|
99
|
+
),
|
|
100
|
+
click.option("--exit-zero", is_flag=True, help="Always exit 0 (report-only)."),
|
|
101
|
+
click.option(
|
|
102
|
+
"--severity",
|
|
103
|
+
"min_severity",
|
|
104
|
+
type=click.Choice(SEVERITIES, case_sensitive=False),
|
|
105
|
+
default=None,
|
|
106
|
+
help="Deprecated alias of --fail-on.",
|
|
107
|
+
),
|
|
108
|
+
click.option(
|
|
109
|
+
"--baseline",
|
|
110
|
+
"baseline_path",
|
|
111
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
112
|
+
default=None,
|
|
113
|
+
help="Baseline file; findings in it do not fail the run.",
|
|
114
|
+
),
|
|
115
|
+
click.option(
|
|
116
|
+
"--show-suppressed",
|
|
117
|
+
is_flag=True,
|
|
118
|
+
help="Include suppressed / baselined findings in output.",
|
|
119
|
+
),
|
|
120
|
+
click.option("--no-gitignore", is_flag=True, help="Do not read .gitignore."),
|
|
121
|
+
click.option(
|
|
122
|
+
"--now",
|
|
123
|
+
"now",
|
|
124
|
+
metavar="YYYY-MM-DD",
|
|
125
|
+
default=None,
|
|
126
|
+
help="Pin the date used for `until=` suppression expiry.",
|
|
127
|
+
),
|
|
128
|
+
click.option(
|
|
129
|
+
"--jobs",
|
|
130
|
+
"-j",
|
|
131
|
+
type=int,
|
|
132
|
+
default=None,
|
|
133
|
+
metavar="N",
|
|
134
|
+
help="Parallel worker processes (0 = auto).",
|
|
135
|
+
),
|
|
136
|
+
click.option("--quiet", "-q", is_flag=True, help="Only print findings."),
|
|
137
|
+
click.option("--no-color", is_flag=True, help="Disable coloured output."),
|
|
138
|
+
click.option(
|
|
139
|
+
"--output",
|
|
140
|
+
"-o",
|
|
141
|
+
type=click.Path(dir_okay=False, writable=True, path_type=Path),
|
|
142
|
+
default=None,
|
|
143
|
+
help="Write output to a file instead of stdout.",
|
|
144
|
+
),
|
|
145
|
+
]
|
|
146
|
+
for opt in reversed(opts):
|
|
147
|
+
fn = opt(fn)
|
|
148
|
+
return fn
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@cli.command("scan")
|
|
152
|
+
@_common_options
|
|
153
|
+
@click.option(
|
|
154
|
+
"--diff",
|
|
155
|
+
"diff_ref",
|
|
156
|
+
metavar="REF",
|
|
157
|
+
default=None,
|
|
158
|
+
help="Only scan files changed since REF (merge-base with HEAD).",
|
|
159
|
+
)
|
|
160
|
+
@click.option(
|
|
161
|
+
"--stdin-filename", default="stdin.py", help="Filename to assume when reading from stdin ('-')."
|
|
162
|
+
)
|
|
163
|
+
def scan(**kw: object) -> None:
|
|
164
|
+
"""Scan PATHS (files or directories; default '.') and report findings.
|
|
165
|
+
|
|
166
|
+
Read from stdin with '-'. Exit codes: 0 clean, 1 findings, 2 usage,
|
|
167
|
+
3 config, 4 internal.
|
|
168
|
+
"""
|
|
169
|
+
sys.exit(execute(RunOptions(**kw))) # type: ignore[arg-type]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@cli.command("ci")
|
|
173
|
+
@_common_options
|
|
174
|
+
@click.option(
|
|
175
|
+
"--diff",
|
|
176
|
+
"diff_ref",
|
|
177
|
+
metavar="REF",
|
|
178
|
+
default=None,
|
|
179
|
+
help="Base ref to diff against (default: auto-detect the PR base branch).",
|
|
180
|
+
)
|
|
181
|
+
@click.option(
|
|
182
|
+
"--sarif",
|
|
183
|
+
"sarif_out",
|
|
184
|
+
type=click.Path(dir_okay=False, writable=True, path_type=Path),
|
|
185
|
+
default=None,
|
|
186
|
+
help="Also write a SARIF report to this path (for code-scanning upload).",
|
|
187
|
+
)
|
|
188
|
+
def ci(**kw: object) -> None:
|
|
189
|
+
"""Diff-aware scan for pull requests.
|
|
190
|
+
|
|
191
|
+
Scans only files changed since the base branch, applies the baseline, and
|
|
192
|
+
defaults to GitHub Actions annotations. Same exit-code contract as `scan`.
|
|
193
|
+
"""
|
|
194
|
+
kw.setdefault("output_format", None)
|
|
195
|
+
opt = RunOptions(diff_auto=True, **kw) # type: ignore[arg-type]
|
|
196
|
+
if opt.output_format is None:
|
|
197
|
+
opt.output_format = "github"
|
|
198
|
+
if not opt.paths:
|
|
199
|
+
opt.paths = (Path("."),)
|
|
200
|
+
sys.exit(execute(opt))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# Register the auxiliary commands (list-rules, explain, validate, init, baseline).
|
|
204
|
+
from codeguard.cli import commands as _commands # noqa: E402
|
|
205
|
+
|
|
206
|
+
_commands.register(cli)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Configuration: ``codeguard.toml`` / ``pyproject.toml [tool.codeguard]``."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from .loader import ConfigError, find_config, load_config
|
|
7
|
+
from .schema import Config, RuleOverride, RuleSettings
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Config",
|
|
11
|
+
"ConfigError",
|
|
12
|
+
"RuleOverride",
|
|
13
|
+
"RuleSettings",
|
|
14
|
+
"find_config",
|
|
15
|
+
"load_config",
|
|
16
|
+
]
|