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
codeguard/__init__.py
ADDED
codeguard/cli/_run.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Shared implementation behind ``codeguard scan`` and ``codeguard ci``."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from codeguard import __version__
|
|
15
|
+
from codeguard.cli import formatters as fmt
|
|
16
|
+
from codeguard.config import ConfigError, find_config, load_config
|
|
17
|
+
from codeguard.engine.baseline import Baseline, apply_baseline
|
|
18
|
+
from codeguard.engine.discovery import DiscoveryConfig, discover
|
|
19
|
+
from codeguard.engine.finding import Finding
|
|
20
|
+
from codeguard.engine.gitdiff import changed_files, default_base, is_git_repo
|
|
21
|
+
from codeguard.engine.policy import apply_config, gating_findings
|
|
22
|
+
from codeguard.engine.registry import REGISTRY
|
|
23
|
+
from codeguard.engine.runner import AnalysisRunner
|
|
24
|
+
|
|
25
|
+
EXIT_OK = 0
|
|
26
|
+
EXIT_FINDINGS = 1
|
|
27
|
+
EXIT_USAGE = 2
|
|
28
|
+
EXIT_CONFIG = 3
|
|
29
|
+
EXIT_INTERNAL = 4
|
|
30
|
+
|
|
31
|
+
SEVERITIES = ["critical", "high", "medium", "low", "info"]
|
|
32
|
+
FORMATS = ["human", "json", "json-legacy", "sarif", "github", "rdjson", "junit"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def console(no_color: bool) -> Console:
|
|
36
|
+
return Console(stderr=True, no_color=no_color)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class RunOptions:
|
|
41
|
+
paths: tuple[Path, ...]
|
|
42
|
+
config_path: Path | None = None
|
|
43
|
+
output_format: str | None = None
|
|
44
|
+
rule_ids: tuple[str, ...] = ()
|
|
45
|
+
excludes: tuple[str, ...] = ()
|
|
46
|
+
includes: tuple[str, ...] = ()
|
|
47
|
+
fail_on: str | None = None
|
|
48
|
+
exit_zero: bool = False
|
|
49
|
+
min_severity: str | None = None
|
|
50
|
+
show_suppressed: bool = False
|
|
51
|
+
no_gitignore: bool = False
|
|
52
|
+
jobs: int | None = None
|
|
53
|
+
quiet: bool = False
|
|
54
|
+
no_color: bool = False
|
|
55
|
+
stdin_filename: str = "stdin.py"
|
|
56
|
+
output: Path | None = None
|
|
57
|
+
baseline_path: Path | None = None
|
|
58
|
+
now: str | None = None # pin the date for `until=` suppression expiry
|
|
59
|
+
diff_ref: str | None = None
|
|
60
|
+
diff_auto: bool = False # `ci` -- pick a base branch if diff_ref not given
|
|
61
|
+
sarif_out: Path | None = None # `ci` -- also write SARIF here
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def execute(opt: RunOptions) -> int:
|
|
65
|
+
"""Run a scan and return the process exit code."""
|
|
66
|
+
err = console(opt.no_color)
|
|
67
|
+
|
|
68
|
+
first = Path(opt.paths[0]) if opt.paths and str(opt.paths[0]) != "-" else Path.cwd()
|
|
69
|
+
scan_root = first if first.is_dir() else first.parent
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
cfg_file = opt.config_path or find_config(scan_root)
|
|
73
|
+
config = load_config(cfg_file)
|
|
74
|
+
except ConfigError as exc:
|
|
75
|
+
err.print(f"[bold red]Config error:[/bold red] {exc}")
|
|
76
|
+
return EXIT_CONFIG
|
|
77
|
+
|
|
78
|
+
root = Path(config.source_dir) if config.source_dir else scan_root
|
|
79
|
+
|
|
80
|
+
output_format = (opt.output_format or config.output).lower()
|
|
81
|
+
fail_on = opt.fail_on
|
|
82
|
+
if opt.min_severity and not fail_on:
|
|
83
|
+
err.print("[yellow]warning:[/yellow] --severity is deprecated; use --fail-on.")
|
|
84
|
+
fail_on = opt.min_severity
|
|
85
|
+
threshold = (fail_on or config.fail_on).lower()
|
|
86
|
+
|
|
87
|
+
n_jobs = opt.jobs if opt.jobs is not None else config.jobs
|
|
88
|
+
if n_jobs == 0:
|
|
89
|
+
n_jobs = os.cpu_count() or 1
|
|
90
|
+
|
|
91
|
+
selected = list(opt.rule_ids) or config.enable or None
|
|
92
|
+
if selected is not None:
|
|
93
|
+
bad = [r for r in selected if r not in REGISTRY]
|
|
94
|
+
if bad:
|
|
95
|
+
err.print(f"[bold red]Error:[/bold red] unknown rule ID(s): {', '.join(bad)}.")
|
|
96
|
+
return EXIT_USAGE
|
|
97
|
+
disabled = set(config.disable)
|
|
98
|
+
active_ids = [
|
|
99
|
+
r.id
|
|
100
|
+
for r in REGISTRY.all()
|
|
101
|
+
if (selected is None or r.id in selected) and r.id not in disabled
|
|
102
|
+
]
|
|
103
|
+
now = None
|
|
104
|
+
if opt.now:
|
|
105
|
+
from datetime import date
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
now = date.fromisoformat(opt.now)
|
|
109
|
+
except ValueError:
|
|
110
|
+
err.print(f"[bold red]Error:[/bold red] --now must be YYYY-MM-DD, got {opt.now!r}")
|
|
111
|
+
return EXIT_USAGE
|
|
112
|
+
runner = AnalysisRunner(rule_ids=active_ids, now=now)
|
|
113
|
+
|
|
114
|
+
disc = DiscoveryConfig(
|
|
115
|
+
include=[*config.include, *opt.includes],
|
|
116
|
+
exclude=[*config.exclude, *opt.excludes],
|
|
117
|
+
respect_gitignore=config.gitignore and not opt.no_gitignore,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# --- collect --------------------------------------------------------
|
|
121
|
+
diff_ref = opt.diff_ref
|
|
122
|
+
if diff_ref is None and opt.diff_auto:
|
|
123
|
+
if not is_git_repo(root):
|
|
124
|
+
err.print("[bold red]Error:[/bold red] not a git repository; `ci` needs one.")
|
|
125
|
+
return EXIT_USAGE
|
|
126
|
+
diff_ref = default_base(root)
|
|
127
|
+
if diff_ref is None:
|
|
128
|
+
err.print("[yellow]warning:[/yellow] no base branch found; scanning all files.")
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
findings = _collect(opt, runner, disc, n_jobs, root, diff_ref, err)
|
|
132
|
+
except SystemExit as exc: # _collect signalled a usage error
|
|
133
|
+
return int(exc.code or EXIT_USAGE)
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
err.print(f"[bold red]Internal error:[/bold red] {exc}")
|
|
136
|
+
return EXIT_INTERNAL
|
|
137
|
+
|
|
138
|
+
findings = apply_config(findings, config, root=str(root))
|
|
139
|
+
|
|
140
|
+
# --- baseline ------------------------------------------------------
|
|
141
|
+
baseline_path = opt.baseline_path
|
|
142
|
+
if baseline_path is None and config.baseline:
|
|
143
|
+
candidate = root / config.baseline
|
|
144
|
+
if candidate.is_file():
|
|
145
|
+
baseline_path = candidate
|
|
146
|
+
if baseline_path is not None:
|
|
147
|
+
try:
|
|
148
|
+
baseline = Baseline.load(baseline_path)
|
|
149
|
+
except ValueError as exc:
|
|
150
|
+
err.print(f"[bold red]Config error:[/bold red] {exc}")
|
|
151
|
+
return EXIT_CONFIG
|
|
152
|
+
findings = apply_baseline(findings, baseline)
|
|
153
|
+
|
|
154
|
+
findings.sort(key=lambda f: (f.location.file, f.location.line, f.location.col, f.rule_id))
|
|
155
|
+
|
|
156
|
+
# --- render -------------------------------------------------------
|
|
157
|
+
text = _render(output_format, findings, opt, err)
|
|
158
|
+
|
|
159
|
+
if opt.sarif_out is not None:
|
|
160
|
+
opt.sarif_out.write_text(
|
|
161
|
+
fmt.format_sarif(findings, tool_version=__version__), encoding="utf-8"
|
|
162
|
+
)
|
|
163
|
+
if not opt.quiet:
|
|
164
|
+
err.print(f"SARIF written to [bold]{opt.sarif_out}[/bold]")
|
|
165
|
+
|
|
166
|
+
if opt.output:
|
|
167
|
+
opt.output.write_text(text, encoding="utf-8")
|
|
168
|
+
if not opt.quiet:
|
|
169
|
+
err.print(f"Results written to [bold]{opt.output}[/bold]")
|
|
170
|
+
else:
|
|
171
|
+
click.echo(text, nl=False)
|
|
172
|
+
|
|
173
|
+
# --- exit code --------------------------------------------------
|
|
174
|
+
if opt.exit_zero:
|
|
175
|
+
return EXIT_OK
|
|
176
|
+
return EXIT_FINDINGS if gating_findings(findings, threshold) else EXIT_OK
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _collect(
|
|
180
|
+
opt: RunOptions,
|
|
181
|
+
runner: AnalysisRunner,
|
|
182
|
+
disc: DiscoveryConfig,
|
|
183
|
+
jobs: int,
|
|
184
|
+
root: Path,
|
|
185
|
+
diff_ref: str | None,
|
|
186
|
+
err: Console,
|
|
187
|
+
) -> list[Finding]:
|
|
188
|
+
targets = list(opt.paths) or [Path(".")]
|
|
189
|
+
|
|
190
|
+
if any(str(p) == "-" for p in targets):
|
|
191
|
+
source = sys.stdin.read()
|
|
192
|
+
try:
|
|
193
|
+
return runner.run(source, filename=opt.stdin_filename)
|
|
194
|
+
except SyntaxError as exc:
|
|
195
|
+
err.print(f"[yellow]warning:[/yellow] stdin: syntax error on line {exc.lineno}")
|
|
196
|
+
return []
|
|
197
|
+
|
|
198
|
+
for p in targets:
|
|
199
|
+
if not p.exists():
|
|
200
|
+
err.print(f"[bold red]Error:[/bold red] path does not exist: {p}")
|
|
201
|
+
raise SystemExit(EXIT_USAGE)
|
|
202
|
+
|
|
203
|
+
files = discover(targets, disc, root=root)
|
|
204
|
+
|
|
205
|
+
if diff_ref is not None:
|
|
206
|
+
changed = {p.resolve() for p in changed_files(diff_ref, root=root)}
|
|
207
|
+
files = [f for f in files if f.resolve() in changed]
|
|
208
|
+
|
|
209
|
+
return runner.run_files(files, jobs=jobs)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _render(output_format: str, findings: list[Finding], opt: RunOptions, err: Console) -> str:
|
|
213
|
+
ss = opt.show_suppressed
|
|
214
|
+
if output_format == "human":
|
|
215
|
+
return fmt.format_human(findings, show_suppressed=ss)
|
|
216
|
+
if output_format == "json":
|
|
217
|
+
return fmt.format_json(findings, show_suppressed=ss, tool_version=__version__)
|
|
218
|
+
if output_format == "json-legacy":
|
|
219
|
+
if not opt.quiet:
|
|
220
|
+
err.print("[yellow]warning:[/yellow] --format json-legacy is deprecated.")
|
|
221
|
+
return fmt.format_json_legacy(findings, show_suppressed=ss)
|
|
222
|
+
if output_format == "sarif":
|
|
223
|
+
return fmt.format_sarif(findings, tool_version=__version__)
|
|
224
|
+
if output_format == "github":
|
|
225
|
+
return fmt.format_github(findings, show_suppressed=ss)
|
|
226
|
+
if output_format == "rdjson":
|
|
227
|
+
return fmt.format_rdjson(findings, show_suppressed=ss, tool_version=__version__)
|
|
228
|
+
if output_format == "junit":
|
|
229
|
+
return fmt.format_junit(findings, show_suppressed=ss)
|
|
230
|
+
raise click.BadParameter(f"unknown format: {output_format}")
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Auxiliary CLI commands: list-rules, explain, validate, init."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
import codeguard.rules # noqa: F401 -- register built-in rules
|
|
13
|
+
from codeguard import __version__
|
|
14
|
+
from codeguard.cli.formatters import finding_help_uri
|
|
15
|
+
from codeguard.config import ConfigError, find_config, load_config
|
|
16
|
+
from codeguard.engine.finding import Finding
|
|
17
|
+
from codeguard.engine.registry import REGISTRY
|
|
18
|
+
|
|
19
|
+
_STARTER_TOML = """\
|
|
20
|
+
# CodeGuard configuration -- https://mevichitra.github.io/codeguard/configuration/
|
|
21
|
+
# (In pyproject.toml, use the [tool.codeguard] table instead of [codeguard].)
|
|
22
|
+
|
|
23
|
+
[codeguard]
|
|
24
|
+
exclude = ["**/*.min.js", "tests/fixtures/**"]
|
|
25
|
+
fail_on = "high" # exit 1 only on findings at or above this severity
|
|
26
|
+
gitignore = true
|
|
27
|
+
|
|
28
|
+
[codeguard.rules]
|
|
29
|
+
disable = []
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def register(group: click.Group) -> None:
|
|
34
|
+
group.add_command(_list_rules)
|
|
35
|
+
group.add_command(_explain)
|
|
36
|
+
group.add_command(_validate)
|
|
37
|
+
group.add_command(_init)
|
|
38
|
+
group.add_command(_baseline)
|
|
39
|
+
group.add_command(_suppressions)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@click.command("list-rules")
|
|
43
|
+
@click.option("--format", "fmt", type=click.Choice(["table", "json"]), default="table")
|
|
44
|
+
@click.option("--language", "language", default=None, help="Filter by language.")
|
|
45
|
+
@click.option("--category", "category", default=None, help="Filter by category.")
|
|
46
|
+
@click.option("--severity", "severity", default=None, help="Filter by severity.")
|
|
47
|
+
def _list_rules(fmt: str, language: str | None, category: str | None, severity: str | None) -> None:
|
|
48
|
+
"""List every registered rule."""
|
|
49
|
+
rows: list[dict[str, object]] = []
|
|
50
|
+
for rule in REGISTRY.all():
|
|
51
|
+
langs = sorted(lang.value for lang in rule.languages)
|
|
52
|
+
if language and language.lower() not in langs:
|
|
53
|
+
continue
|
|
54
|
+
if category and rule.category.value != category.lower():
|
|
55
|
+
continue
|
|
56
|
+
if severity and rule.severity.value != severity.lower():
|
|
57
|
+
continue
|
|
58
|
+
rows.append(
|
|
59
|
+
{
|
|
60
|
+
"id": rule.id,
|
|
61
|
+
"title": rule.title,
|
|
62
|
+
"severity": rule.severity.value,
|
|
63
|
+
"category": rule.category.value,
|
|
64
|
+
"languages": langs,
|
|
65
|
+
"cwe": rule.cwe,
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if fmt == "json":
|
|
70
|
+
click.echo(json.dumps(rows, indent=2))
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
if not rows:
|
|
74
|
+
click.echo("No rules match.")
|
|
75
|
+
return
|
|
76
|
+
width = max(len(str(row["id"])) for row in rows)
|
|
77
|
+
for row in rows:
|
|
78
|
+
langs_str = ",".join(row["languages"]) # type: ignore[arg-type]
|
|
79
|
+
click.echo(f"{row['id']:<{width}} {row['severity']:<8} {langs_str:<20} {row['title']}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@click.command("explain")
|
|
83
|
+
@click.argument("rule_id")
|
|
84
|
+
def _explain(rule_id: str) -> None:
|
|
85
|
+
"""Show the full description of a rule."""
|
|
86
|
+
rule = REGISTRY.get(rule_id)
|
|
87
|
+
if rule is None:
|
|
88
|
+
click.echo(f"Unknown rule: {rule_id}", err=True)
|
|
89
|
+
sys.exit(2)
|
|
90
|
+
langs = ", ".join(sorted(lang.value for lang in rule.languages))
|
|
91
|
+
click.echo(f"{rule.id} -- {rule.title}\n")
|
|
92
|
+
click.echo(f"Severity: {rule.severity.value}")
|
|
93
|
+
click.echo(f"Category: {rule.category.value}")
|
|
94
|
+
click.echo(f"Languages: {langs}")
|
|
95
|
+
if rule.cwe:
|
|
96
|
+
click.echo(f"CWE: {rule.cwe}")
|
|
97
|
+
if rule.owasp:
|
|
98
|
+
click.echo(f"OWASP: {rule.owasp}")
|
|
99
|
+
click.echo(f"Docs: {rule.help_uri or finding_help_uri(rule.id)}\n")
|
|
100
|
+
click.echo(rule.description)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@click.command("validate")
|
|
104
|
+
@click.option(
|
|
105
|
+
"--config",
|
|
106
|
+
"config_path",
|
|
107
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
108
|
+
default=None,
|
|
109
|
+
help="Config file to validate (default: discovered).",
|
|
110
|
+
)
|
|
111
|
+
def _validate(config_path: Path | None) -> None:
|
|
112
|
+
"""Validate a codeguard.toml (or pyproject [tool.codeguard])."""
|
|
113
|
+
path = config_path or find_config()
|
|
114
|
+
if path is None:
|
|
115
|
+
click.echo("No config file found. Nothing to validate.")
|
|
116
|
+
return
|
|
117
|
+
try:
|
|
118
|
+
cfg = load_config(path)
|
|
119
|
+
except ConfigError as exc:
|
|
120
|
+
click.echo(f"Invalid: {exc}", err=True)
|
|
121
|
+
sys.exit(3)
|
|
122
|
+
|
|
123
|
+
unknown = [
|
|
124
|
+
rid
|
|
125
|
+
for rid in {*cfg.enable, *cfg.disable, *cfg.severity_remap, *cfg.rules}
|
|
126
|
+
if rid not in REGISTRY
|
|
127
|
+
]
|
|
128
|
+
if unknown:
|
|
129
|
+
click.echo(f"Invalid: {path}: unknown rule ID(s): {', '.join(sorted(unknown))}", err=True)
|
|
130
|
+
sys.exit(3)
|
|
131
|
+
conflict = set(cfg.enable) & set(cfg.disable)
|
|
132
|
+
if conflict:
|
|
133
|
+
click.echo(
|
|
134
|
+
f"Invalid: {path}: rule(s) both enabled and disabled: {', '.join(sorted(conflict))}",
|
|
135
|
+
err=True,
|
|
136
|
+
)
|
|
137
|
+
sys.exit(3)
|
|
138
|
+
|
|
139
|
+
click.echo(f"OK: {path}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@click.command("init")
|
|
143
|
+
@click.option("--force", is_flag=True, help="Overwrite an existing codeguard.toml.")
|
|
144
|
+
def _init(force: bool) -> None:
|
|
145
|
+
"""Write a starter codeguard.toml in the current directory."""
|
|
146
|
+
target = Path("codeguard.toml")
|
|
147
|
+
if target.exists() and not force:
|
|
148
|
+
click.echo("codeguard.toml already exists (use --force to overwrite).", err=True)
|
|
149
|
+
sys.exit(2)
|
|
150
|
+
target.write_text(_STARTER_TOML, encoding="utf-8")
|
|
151
|
+
click.echo(f"Wrote {target}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# baseline
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _scan_for_baseline(paths: tuple[Path, ...], config_path: Path | None) -> list[Finding]:
|
|
160
|
+
"""Full scan (all rules, config applied) used to build/refresh a baseline."""
|
|
161
|
+
from codeguard.config import load_config
|
|
162
|
+
from codeguard.engine.discovery import DiscoveryConfig, discover
|
|
163
|
+
from codeguard.engine.policy import apply_config
|
|
164
|
+
from codeguard.engine.runner import AnalysisRunner
|
|
165
|
+
|
|
166
|
+
targets = [Path(p) for p in paths] or [Path(".")]
|
|
167
|
+
first = targets[0]
|
|
168
|
+
root = first if first.is_dir() else first.parent
|
|
169
|
+
|
|
170
|
+
cfg_file = config_path or find_config(root)
|
|
171
|
+
config = load_config(cfg_file)
|
|
172
|
+
cfg_root = Path(config.source_dir) if config.source_dir else root
|
|
173
|
+
|
|
174
|
+
disc = DiscoveryConfig(
|
|
175
|
+
include=list(config.include),
|
|
176
|
+
exclude=list(config.exclude),
|
|
177
|
+
respect_gitignore=config.gitignore,
|
|
178
|
+
)
|
|
179
|
+
findings = AnalysisRunner().run_files(discover(targets, disc, root=cfg_root))
|
|
180
|
+
findings = apply_config(findings, config, root=str(cfg_root))
|
|
181
|
+
return [f for f in findings if not f.suppressed]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@click.group("baseline")
|
|
185
|
+
def _baseline() -> None:
|
|
186
|
+
"""Create and maintain a baseline file (findings that must not fail CI)."""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@_baseline.command("create")
|
|
190
|
+
@click.argument("paths", nargs=-1, type=click.Path(path_type=Path))
|
|
191
|
+
@click.option(
|
|
192
|
+
"--config",
|
|
193
|
+
"config_path",
|
|
194
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
195
|
+
default=None,
|
|
196
|
+
)
|
|
197
|
+
@click.option(
|
|
198
|
+
"--output",
|
|
199
|
+
"-o",
|
|
200
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
201
|
+
default=Path(".codeguard-baseline.json"),
|
|
202
|
+
show_default=True,
|
|
203
|
+
)
|
|
204
|
+
def _baseline_create(paths: tuple[Path, ...], config_path: Path | None, output: Path) -> None:
|
|
205
|
+
"""Snapshot every current finding into a new baseline file."""
|
|
206
|
+
from codeguard.engine.baseline import Baseline
|
|
207
|
+
|
|
208
|
+
findings = _scan_for_baseline(paths, config_path)
|
|
209
|
+
Baseline.from_findings(findings, tool_version=__version__).save(output)
|
|
210
|
+
click.echo(f"Wrote {output} ({len(findings)} finding(s) baselined)")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@_baseline.command("update")
|
|
214
|
+
@click.argument("paths", nargs=-1, type=click.Path(path_type=Path))
|
|
215
|
+
@click.option(
|
|
216
|
+
"--config",
|
|
217
|
+
"config_path",
|
|
218
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
219
|
+
default=None,
|
|
220
|
+
)
|
|
221
|
+
@click.option(
|
|
222
|
+
"--baseline",
|
|
223
|
+
"-b",
|
|
224
|
+
"baseline_file",
|
|
225
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
226
|
+
default=Path(".codeguard-baseline.json"),
|
|
227
|
+
show_default=True,
|
|
228
|
+
)
|
|
229
|
+
def _baseline_update(
|
|
230
|
+
paths: tuple[Path, ...], config_path: Path | None, baseline_file: Path
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Add newly-appeared findings to the baseline (keeps existing entries)."""
|
|
233
|
+
from codeguard.engine.baseline import Baseline
|
|
234
|
+
|
|
235
|
+
if not baseline_file.exists():
|
|
236
|
+
click.echo(f"No baseline at {baseline_file}; run `baseline create` first.", err=True)
|
|
237
|
+
sys.exit(2)
|
|
238
|
+
findings = _scan_for_baseline(paths, config_path)
|
|
239
|
+
before = len(Baseline.load(baseline_file))
|
|
240
|
+
updated = Baseline.load(baseline_file).updated_with(findings)
|
|
241
|
+
updated.save(baseline_file)
|
|
242
|
+
click.echo(f"Updated {baseline_file} (+{len(updated) - before} new entry/entries)")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@_baseline.command("prune")
|
|
246
|
+
@click.argument("paths", nargs=-1, type=click.Path(path_type=Path))
|
|
247
|
+
@click.option(
|
|
248
|
+
"--config",
|
|
249
|
+
"config_path",
|
|
250
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
251
|
+
default=None,
|
|
252
|
+
)
|
|
253
|
+
@click.option(
|
|
254
|
+
"--baseline",
|
|
255
|
+
"-b",
|
|
256
|
+
"baseline_file",
|
|
257
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
258
|
+
default=Path(".codeguard-baseline.json"),
|
|
259
|
+
show_default=True,
|
|
260
|
+
)
|
|
261
|
+
def _baseline_prune(paths: tuple[Path, ...], config_path: Path | None, baseline_file: Path) -> None:
|
|
262
|
+
"""Drop baseline entries whose finding no longer occurs."""
|
|
263
|
+
from codeguard.engine.baseline import Baseline
|
|
264
|
+
|
|
265
|
+
if not baseline_file.exists():
|
|
266
|
+
click.echo(f"No baseline at {baseline_file}.", err=True)
|
|
267
|
+
sys.exit(2)
|
|
268
|
+
live = {f.fingerprint for f in _scan_for_baseline(paths, config_path) if f.fingerprint}
|
|
269
|
+
before = len(Baseline.load(baseline_file))
|
|
270
|
+
pruned = Baseline.load(baseline_file).pruned(live)
|
|
271
|
+
pruned.save(baseline_file)
|
|
272
|
+
click.echo(f"Pruned {baseline_file} (-{before - len(pruned)} stale entry/entries)")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# suppressions
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@click.group("suppressions")
|
|
281
|
+
def _suppressions() -> None:
|
|
282
|
+
"""Inspect `# codeguard: ignore[...]` comments across the codebase."""
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@_suppressions.command("list")
|
|
286
|
+
@click.argument("paths", nargs=-1, type=click.Path(path_type=Path))
|
|
287
|
+
@click.option(
|
|
288
|
+
"--config",
|
|
289
|
+
"config_path",
|
|
290
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
291
|
+
default=None,
|
|
292
|
+
)
|
|
293
|
+
@click.option("--expired", is_flag=True, help="Show only expired suppressions (exit 1 if any).")
|
|
294
|
+
@click.option("--unused", is_flag=True, help="Show only suppressions that suppress nothing.")
|
|
295
|
+
@click.option("--format", "fmt", type=click.Choice(["table", "json"]), default="table")
|
|
296
|
+
@click.option(
|
|
297
|
+
"--now",
|
|
298
|
+
"now_str",
|
|
299
|
+
metavar="YYYY-MM-DD",
|
|
300
|
+
default=None,
|
|
301
|
+
help="Date used to evaluate `until=` (default: today).",
|
|
302
|
+
)
|
|
303
|
+
def _suppressions_list(
|
|
304
|
+
paths: tuple[Path, ...],
|
|
305
|
+
config_path: Path | None,
|
|
306
|
+
expired: bool,
|
|
307
|
+
unused: bool,
|
|
308
|
+
fmt: str,
|
|
309
|
+
now_str: str | None,
|
|
310
|
+
) -> None:
|
|
311
|
+
"""List every suppression comment with its status (active / expired / unused)."""
|
|
312
|
+
import json as _json
|
|
313
|
+
from datetime import date
|
|
314
|
+
|
|
315
|
+
from codeguard.config import load_config
|
|
316
|
+
from codeguard.engine.discovery import DiscoveryConfig, discover
|
|
317
|
+
from codeguard.engine.runner import AnalysisRunner
|
|
318
|
+
from codeguard.engine.suppressions import SuppressionSet
|
|
319
|
+
from codeguard.lang.registry import language_for_path
|
|
320
|
+
|
|
321
|
+
today = date.fromisoformat(now_str) if now_str else date.today()
|
|
322
|
+
|
|
323
|
+
targets = [Path(p) for p in paths] or [Path(".")]
|
|
324
|
+
first = targets[0]
|
|
325
|
+
root = first if first.is_dir() else first.parent
|
|
326
|
+
config = load_config(config_path or find_config(root))
|
|
327
|
+
cfg_root = Path(config.source_dir) if config.source_dir else root
|
|
328
|
+
disc = DiscoveryConfig(
|
|
329
|
+
include=list(config.include),
|
|
330
|
+
exclude=list(config.exclude),
|
|
331
|
+
respect_gitignore=config.gitignore,
|
|
332
|
+
)
|
|
333
|
+
files = discover(targets, disc, root=cfg_root)
|
|
334
|
+
runner = AnalysisRunner()
|
|
335
|
+
|
|
336
|
+
rows: list[dict[str, object]] = []
|
|
337
|
+
for path in files:
|
|
338
|
+
if language_for_path(path) is None:
|
|
339
|
+
continue
|
|
340
|
+
source = path.read_text(encoding="utf-8", errors="replace")
|
|
341
|
+
suppset = SuppressionSet.parse(source)
|
|
342
|
+
if not suppset.all():
|
|
343
|
+
continue
|
|
344
|
+
try:
|
|
345
|
+
findings = runner.run(source, filename=str(path), now=today)
|
|
346
|
+
except SyntaxError:
|
|
347
|
+
findings = []
|
|
348
|
+
suppressed_at = {(f.rule_id, f.location.line) for f in findings if f.suppressed}
|
|
349
|
+
suppressed_rules = {f.rule_id for f in findings if f.suppressed}
|
|
350
|
+
|
|
351
|
+
for supp in suppset.all():
|
|
352
|
+
if supp.is_expired(today):
|
|
353
|
+
status = "expired"
|
|
354
|
+
elif supp.file_level:
|
|
355
|
+
status = "active" if supp.rule_ids & suppressed_rules else "unused"
|
|
356
|
+
else:
|
|
357
|
+
status = (
|
|
358
|
+
"active"
|
|
359
|
+
if any((rid, supp.line) in suppressed_at for rid in supp.rule_ids)
|
|
360
|
+
else "unused"
|
|
361
|
+
)
|
|
362
|
+
rows.append(
|
|
363
|
+
{
|
|
364
|
+
"file": str(path),
|
|
365
|
+
"line": supp.line,
|
|
366
|
+
"rules": sorted(supp.rule_ids),
|
|
367
|
+
"scope": "file" if supp.file_level else "line",
|
|
368
|
+
"reason": supp.reason,
|
|
369
|
+
"until": supp.until.isoformat() if supp.until else None,
|
|
370
|
+
"status": status,
|
|
371
|
+
}
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
if expired:
|
|
375
|
+
rows = [r for r in rows if r["status"] == "expired"]
|
|
376
|
+
if unused:
|
|
377
|
+
rows = [r for r in rows if r["status"] == "unused"]
|
|
378
|
+
|
|
379
|
+
if fmt == "json":
|
|
380
|
+
click.echo(_json.dumps(rows, indent=2))
|
|
381
|
+
elif not rows:
|
|
382
|
+
click.echo("No suppressions." if not (expired or unused) else "None.")
|
|
383
|
+
else:
|
|
384
|
+
for r in rows:
|
|
385
|
+
rules = ",".join(r["rules"]) # type: ignore[arg-type]
|
|
386
|
+
reason = r["reason"] or "(no reason)"
|
|
387
|
+
click.echo(f"{r['file']}:{r['line']} {r['status']:<8} {rules:<24} {reason}")
|
|
388
|
+
|
|
389
|
+
if expired and rows:
|
|
390
|
+
sys.exit(1)
|