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.
Files changed (52) hide show
  1. codeguard/__init__.py +7 -0
  2. codeguard/cli/__init__.py +2 -0
  3. codeguard/cli/_run.py +230 -0
  4. codeguard/cli/commands.py +390 -0
  5. codeguard/cli/formatters.py +422 -0
  6. codeguard/cli/main.py +206 -0
  7. codeguard/config/__init__.py +16 -0
  8. codeguard/config/loader.py +86 -0
  9. codeguard/config/schema.py +172 -0
  10. codeguard/engine/__init__.py +25 -0
  11. codeguard/engine/baseline.py +122 -0
  12. codeguard/engine/context.py +61 -0
  13. codeguard/engine/discovery.py +160 -0
  14. codeguard/engine/finding.py +205 -0
  15. codeguard/engine/fingerprint.py +94 -0
  16. codeguard/engine/gitdiff.py +80 -0
  17. codeguard/engine/policy.py +74 -0
  18. codeguard/engine/registry.py +78 -0
  19. codeguard/engine/rule.py +195 -0
  20. codeguard/engine/runner.py +267 -0
  21. codeguard/engine/suppressions.py +109 -0
  22. codeguard/lang/__init__.py +37 -0
  23. codeguard/lang/base.py +80 -0
  24. codeguard/lang/javascript.py +20 -0
  25. codeguard/lang/node.py +137 -0
  26. codeguard/lang/python_ast.py +29 -0
  27. codeguard/lang/registry.py +38 -0
  28. codeguard/lang/treesitter.py +99 -0
  29. codeguard/lang/typescript.py +24 -0
  30. codeguard/py.typed +1 -0
  31. codeguard/rules/__init__.py +6 -0
  32. codeguard/rules/_jsnodes.py +82 -0
  33. codeguard/rules/_pyimports.py +60 -0
  34. codeguard/rules/javascript/__init__.py +9 -0
  35. codeguard/rules/javascript/cg_sec_101_dynamic_code.py +89 -0
  36. codeguard/rules/javascript/cg_sec_102_child_process.py +58 -0
  37. codeguard/rules/javascript/cg_sec_103_dom_xss.py +67 -0
  38. codeguard/rules/javascript/cg_sec_104_react_dangerous_html.py +54 -0
  39. codeguard/rules/javascript/cg_sec_105_hardcoded_secret.py +73 -0
  40. codeguard/rules/javascript/cg_sec_106_weak_random.py +83 -0
  41. codeguard/rules/meta/__init__.py +55 -0
  42. codeguard/rules/security/__init__.py +8 -0
  43. codeguard/rules/security/cg_sec_001_sql_injection.py +110 -0
  44. codeguard/rules/security/cg_sec_002_hardcoded_secrets.py +184 -0
  45. codeguard/rules/security/cg_sec_003_eval_exec.py +104 -0
  46. codeguard/rules/security/cg_sec_004_unsafe_deserialization.py +156 -0
  47. codeguard/rules/security/cg_sec_005_shell_injection.py +157 -0
  48. codeguard_cli-2.0.0.dist-info/METADATA +210 -0
  49. codeguard_cli-2.0.0.dist-info/RECORD +52 -0
  50. codeguard_cli-2.0.0.dist-info/WHEEL +4 -0
  51. codeguard_cli-2.0.0.dist-info/entry_points.txt +2 -0
  52. 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
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
+ ]