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,205 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Finding -- the atomic output unit of a CodeGuard analysis run."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import enum
|
|
7
|
+
from dataclasses import dataclass, field, replace
|
|
8
|
+
|
|
9
|
+
_SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Severity(str, enum.Enum):
|
|
13
|
+
"""Finding severity, in descending order of urgency."""
|
|
14
|
+
|
|
15
|
+
CRITICAL = "critical"
|
|
16
|
+
HIGH = "high"
|
|
17
|
+
MEDIUM = "medium"
|
|
18
|
+
LOW = "low"
|
|
19
|
+
INFO = "info"
|
|
20
|
+
|
|
21
|
+
def __lt__(self, other: Severity) -> bool: # type: ignore[override]
|
|
22
|
+
return _SEVERITY_ORDER.index(self.value) > _SEVERITY_ORDER.index(other.value)
|
|
23
|
+
|
|
24
|
+
def __le__(self, other: Severity) -> bool: # type: ignore[override]
|
|
25
|
+
return self == other or self < other
|
|
26
|
+
|
|
27
|
+
def __gt__(self, other: Severity) -> bool: # type: ignore[override]
|
|
28
|
+
return other < self
|
|
29
|
+
|
|
30
|
+
def __ge__(self, other: Severity) -> bool: # type: ignore[override]
|
|
31
|
+
return self == other or self > other
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Category(str, enum.Enum):
|
|
35
|
+
"""Rule category."""
|
|
36
|
+
|
|
37
|
+
SECURITY = "security"
|
|
38
|
+
QUALITY = "quality"
|
|
39
|
+
PERFORMANCE = "performance"
|
|
40
|
+
AI_SMELL = "ai-smell"
|
|
41
|
+
META = "meta"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Location:
|
|
46
|
+
"""Precise source location of a finding.
|
|
47
|
+
|
|
48
|
+
Line and column numbers are **1-indexed** to match what editors and SARIF
|
|
49
|
+
expect. ``col`` is the start column; ``end_line`` / ``end_col`` are optional
|
|
50
|
+
end positions.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
file: str
|
|
54
|
+
line: int
|
|
55
|
+
col: int
|
|
56
|
+
end_line: int | None = None
|
|
57
|
+
end_col: int | None = None
|
|
58
|
+
|
|
59
|
+
def __post_init__(self) -> None:
|
|
60
|
+
if self.line < 1:
|
|
61
|
+
raise ValueError(f"line must be >= 1, got {self.line}")
|
|
62
|
+
if self.col < 1:
|
|
63
|
+
raise ValueError(f"col must be >= 1, got {self.col}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class TextEdit:
|
|
68
|
+
"""A single replacement in a source file, for an autofix.
|
|
69
|
+
|
|
70
|
+
Reserved for the autofix milestone; no rule emits one today.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
start_line: int
|
|
74
|
+
start_col: int
|
|
75
|
+
end_line: int
|
|
76
|
+
end_col: int
|
|
77
|
+
replacement: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class Fix:
|
|
82
|
+
"""A suggested code change that resolves a finding.
|
|
83
|
+
|
|
84
|
+
Reserved for the autofix milestone; ``Finding.fix`` is always ``None`` today.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
description: str
|
|
88
|
+
edits: tuple[TextEdit, ...]
|
|
89
|
+
safe: bool = True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class Triage:
|
|
94
|
+
"""A verdict on whether a finding is a true positive.
|
|
95
|
+
|
|
96
|
+
Reserved for a post-2.0 offline triage layer; ``Finding.triage`` is always
|
|
97
|
+
``None`` today.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
verdict: str # "true" | "false" | "uncertain"
|
|
101
|
+
rationale: str
|
|
102
|
+
source: str # "heuristic" | "offline-model" | "human"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class Finding:
|
|
107
|
+
"""A single diagnostic produced by a rule.
|
|
108
|
+
|
|
109
|
+
``rule_id`` is a stable public contract -- it will never be renumbered.
|
|
110
|
+
Tools, IDE plugins, and inline suppressions key on it.
|
|
111
|
+
|
|
112
|
+
Attributes
|
|
113
|
+
----------
|
|
114
|
+
rule_id:
|
|
115
|
+
Stable rule identifier, e.g. ``CG-SEC-001``.
|
|
116
|
+
title:
|
|
117
|
+
Short (<= 80 char) human-readable title.
|
|
118
|
+
description:
|
|
119
|
+
Full explanation of what was detected and why it matters.
|
|
120
|
+
severity:
|
|
121
|
+
How urgent this finding is.
|
|
122
|
+
category:
|
|
123
|
+
Broad category the rule belongs to.
|
|
124
|
+
location:
|
|
125
|
+
Where in the source the finding was detected.
|
|
126
|
+
cwe:
|
|
127
|
+
CWE identifier, e.g. ``CWE-89``.
|
|
128
|
+
owasp:
|
|
129
|
+
OWASP category reference, e.g. ``A03:2021 - Injection``.
|
|
130
|
+
fix_suggestion:
|
|
131
|
+
Actionable one-sentence fix. Optional but strongly encouraged.
|
|
132
|
+
confidence:
|
|
133
|
+
Detection confidence in ``[0.0, 1.0]``. ``1.0`` means the rule is
|
|
134
|
+
certain; ``< 1.0`` signals heuristic detection.
|
|
135
|
+
suppressed:
|
|
136
|
+
True when a ``# codeguard: ignore[RULE-ID]`` comment (or the file-level
|
|
137
|
+
form) applied to this finding. Suppressed findings are still returned so
|
|
138
|
+
callers can audit suppression usage.
|
|
139
|
+
fingerprint:
|
|
140
|
+
Stable identity of this finding across reformatting and line moves,
|
|
141
|
+
assigned by the runner. Empty until assigned.
|
|
142
|
+
fix:
|
|
143
|
+
A suggested autofix, or ``None``. Reserved for a later milestone.
|
|
144
|
+
triage:
|
|
145
|
+
A true/false-positive verdict, or ``None``. Reserved for a later
|
|
146
|
+
milestone.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
rule_id: str
|
|
150
|
+
title: str
|
|
151
|
+
description: str
|
|
152
|
+
severity: Severity
|
|
153
|
+
category: Category
|
|
154
|
+
location: Location
|
|
155
|
+
cwe: str | None = None
|
|
156
|
+
owasp: str | None = None
|
|
157
|
+
fix_suggestion: str | None = None
|
|
158
|
+
confidence: float = 1.0
|
|
159
|
+
suppressed: bool = False
|
|
160
|
+
baselined: bool = False
|
|
161
|
+
fingerprint: str = ""
|
|
162
|
+
fix: Fix | None = field(default=None)
|
|
163
|
+
triage: Triage | None = field(default=None)
|
|
164
|
+
|
|
165
|
+
def __post_init__(self) -> None:
|
|
166
|
+
if not self.rule_id:
|
|
167
|
+
raise ValueError("rule_id must not be empty")
|
|
168
|
+
if not (0.0 <= self.confidence <= 1.0):
|
|
169
|
+
raise ValueError(f"confidence must be in [0.0, 1.0], got {self.confidence!r}")
|
|
170
|
+
|
|
171
|
+
def as_suppressed(self) -> Finding:
|
|
172
|
+
"""Return a copy of this finding with ``suppressed=True``."""
|
|
173
|
+
return replace(self, suppressed=True)
|
|
174
|
+
|
|
175
|
+
def with_fingerprint(self, fingerprint: str) -> Finding:
|
|
176
|
+
"""Return a copy of this finding with ``fingerprint`` set."""
|
|
177
|
+
return replace(self, fingerprint=fingerprint)
|
|
178
|
+
|
|
179
|
+
def as_baselined(self) -> Finding:
|
|
180
|
+
"""Return a copy of this finding marked as present in the baseline."""
|
|
181
|
+
return replace(self, baselined=True)
|
|
182
|
+
|
|
183
|
+
def to_dict(self) -> dict: # type: ignore[type-arg]
|
|
184
|
+
"""Serialise to a plain dict suitable for JSON output."""
|
|
185
|
+
return {
|
|
186
|
+
"rule_id": self.rule_id,
|
|
187
|
+
"title": self.title,
|
|
188
|
+
"description": self.description,
|
|
189
|
+
"severity": self.severity.value,
|
|
190
|
+
"category": self.category.value,
|
|
191
|
+
"location": {
|
|
192
|
+
"file": self.location.file,
|
|
193
|
+
"line": self.location.line,
|
|
194
|
+
"col": self.location.col,
|
|
195
|
+
"end_line": self.location.end_line,
|
|
196
|
+
"end_col": self.location.end_col,
|
|
197
|
+
},
|
|
198
|
+
"cwe": self.cwe,
|
|
199
|
+
"owasp": self.owasp,
|
|
200
|
+
"fix_suggestion": self.fix_suggestion,
|
|
201
|
+
"confidence": self.confidence,
|
|
202
|
+
"suppressed": self.suppressed,
|
|
203
|
+
"baselined": self.baselined,
|
|
204
|
+
"fingerprint": self.fingerprint,
|
|
205
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Stable finding fingerprints.
|
|
3
|
+
|
|
4
|
+
A fingerprint identifies "the same finding" across reformatting, whitespace
|
|
5
|
+
changes, and line moves, so a baseline entry or a suppression stays matched to
|
|
6
|
+
its finding. It deliberately *does* change when the finding's own statement or
|
|
7
|
+
its enclosing scope changes -- that is what makes diff/baseline workflows
|
|
8
|
+
meaningful.
|
|
9
|
+
|
|
10
|
+
Scheme ``codeguard/v1``:
|
|
11
|
+
|
|
12
|
+
sha256(rule_id \\0 relative_path \\0 scope \\0 normalized_statement)[:16]
|
|
13
|
+
|
|
14
|
+
where *scope* is the dotted name of the enclosing function/class (Python) and
|
|
15
|
+
*normalized_statement* is the finding's own source line with string and number
|
|
16
|
+
literals masked and comments / whitespace stripped. Line numbers are
|
|
17
|
+
deliberately excluded so a finding survives being moved within its scope.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import ast
|
|
23
|
+
import hashlib
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
|
|
27
|
+
SCHEME = "codeguard/v1"
|
|
28
|
+
|
|
29
|
+
_STRING_RE = re.compile(r"""(['"]).*?(?<!\\)\1""", re.DOTALL)
|
|
30
|
+
_NUMBER_RE = re.compile(r"(?<![A-Za-z_])\d[\d_]*(?:\.\d[\d_]*)?")
|
|
31
|
+
_COMMENT_RE = re.compile(r"(#|//).*$")
|
|
32
|
+
_WS_RE = re.compile(r"\s+")
|
|
33
|
+
|
|
34
|
+
_SCOPE_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def relative_path(file: str, *, root: str | None = None) -> str:
|
|
38
|
+
"""Normalise *file* to a forward-slash path relative to *root* (cwd by default).
|
|
39
|
+
|
|
40
|
+
Fingerprints assume runs happen from the project root (as pre-commit and CI
|
|
41
|
+
do). Paths that cannot be made relative are returned unchanged.
|
|
42
|
+
"""
|
|
43
|
+
if file in ("<stdin>", "<string>"):
|
|
44
|
+
return file
|
|
45
|
+
base = root or os.getcwd()
|
|
46
|
+
try:
|
|
47
|
+
rel = os.path.relpath(file, base)
|
|
48
|
+
except ValueError:
|
|
49
|
+
rel = file
|
|
50
|
+
return rel.replace(os.sep, "/")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _normalize(text: str) -> str:
|
|
54
|
+
text = _STRING_RE.sub("STR", text)
|
|
55
|
+
text = _COMMENT_RE.sub("", text)
|
|
56
|
+
text = _NUMBER_RE.sub("NUM", text)
|
|
57
|
+
return _WS_RE.sub("", text)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _normalized_statement(source: str, line: int) -> str:
|
|
61
|
+
lines = source.splitlines()
|
|
62
|
+
if 1 <= line <= len(lines):
|
|
63
|
+
return _normalize(lines[line - 1])
|
|
64
|
+
return ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def python_scope(tree: ast.AST, line: int) -> str:
|
|
68
|
+
"""Return the dotted name of the innermost function/class enclosing *line*.
|
|
69
|
+
|
|
70
|
+
``""`` for module level. E.g. ``"MyClass.method"`` or ``"handler"``.
|
|
71
|
+
"""
|
|
72
|
+
path: list[str] = []
|
|
73
|
+
|
|
74
|
+
def visit(node: ast.AST, prefix: list[str]) -> None:
|
|
75
|
+
for child in ast.iter_child_nodes(node):
|
|
76
|
+
if isinstance(child, _SCOPE_NODES):
|
|
77
|
+
start = child.lineno
|
|
78
|
+
end = getattr(child, "end_lineno", start)
|
|
79
|
+
if start <= line <= end:
|
|
80
|
+
prefix.append(child.name)
|
|
81
|
+
path[:] = list(prefix)
|
|
82
|
+
visit(child, prefix)
|
|
83
|
+
prefix.pop()
|
|
84
|
+
else:
|
|
85
|
+
visit(child, prefix)
|
|
86
|
+
|
|
87
|
+
visit(tree, [])
|
|
88
|
+
return ".".join(path)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def compute(rule_id: str, rel_path: str, source: str, line: int, *, scope: str = "") -> str:
|
|
92
|
+
"""Return the 16-hex-char fingerprint for a finding."""
|
|
93
|
+
payload = f"{rule_id}\0{rel_path}\0{scope}\0{_normalized_statement(source, line)}"
|
|
94
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Git helpers for diff-aware scanning.
|
|
3
|
+
|
|
4
|
+
``scan --diff <ref>`` and ``codeguard ci`` only look at files that changed, so a
|
|
5
|
+
pull request is checked in seconds and only *new* problems surface.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
_CANDIDATE_BASES = (
|
|
14
|
+
"origin/main",
|
|
15
|
+
"origin/master",
|
|
16
|
+
"main",
|
|
17
|
+
"master",
|
|
18
|
+
"origin/HEAD",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _git(args: list[str], *, root: Path) -> str | None:
|
|
23
|
+
try:
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
["git", "-C", str(root), *args],
|
|
26
|
+
capture_output=True,
|
|
27
|
+
text=True,
|
|
28
|
+
check=False,
|
|
29
|
+
)
|
|
30
|
+
except (OSError, FileNotFoundError):
|
|
31
|
+
return None
|
|
32
|
+
if result.returncode != 0:
|
|
33
|
+
return None
|
|
34
|
+
return result.stdout
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_git_repo(root: Path) -> bool:
|
|
38
|
+
return _git(["rev-parse", "--git-dir"], root=root) is not None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def default_base(root: Path) -> str | None:
|
|
42
|
+
"""Best guess at the branch a PR would target."""
|
|
43
|
+
for ref in _CANDIDATE_BASES:
|
|
44
|
+
if _git(["rev-parse", "--verify", "--quiet", ref], root=root) is not None:
|
|
45
|
+
return ref
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def changed_files(
|
|
50
|
+
ref: str,
|
|
51
|
+
*,
|
|
52
|
+
root: Path,
|
|
53
|
+
use_merge_base: bool = True,
|
|
54
|
+
include_untracked: bool = True,
|
|
55
|
+
) -> list[Path]:
|
|
56
|
+
"""Return the files that changed relative to *ref* (added / modified, not deleted).
|
|
57
|
+
|
|
58
|
+
``use_merge_base`` compares against ``git merge-base ref HEAD`` (``ref...HEAD``),
|
|
59
|
+
which is what you want for a pull request. Set it False to diff against the
|
|
60
|
+
literal *ref*.
|
|
61
|
+
"""
|
|
62
|
+
spec = f"{ref}...HEAD" if use_merge_base else f"{ref}..HEAD"
|
|
63
|
+
names: set[str] = set()
|
|
64
|
+
|
|
65
|
+
committed = _git(["diff", "--name-only", "--diff-filter=d", spec], root=root)
|
|
66
|
+
if committed:
|
|
67
|
+
names.update(line for line in committed.splitlines() if line)
|
|
68
|
+
|
|
69
|
+
# Uncommitted (working tree + index) changes vs HEAD, so a local run before
|
|
70
|
+
# committing still sees the edits.
|
|
71
|
+
working = _git(["diff", "--name-only", "--diff-filter=d", "HEAD"], root=root)
|
|
72
|
+
if working:
|
|
73
|
+
names.update(line for line in working.splitlines() if line)
|
|
74
|
+
|
|
75
|
+
if include_untracked:
|
|
76
|
+
untracked = _git(["ls-files", "--others", "--exclude-standard"], root=root)
|
|
77
|
+
if untracked:
|
|
78
|
+
names.update(line for line in untracked.splitlines() if line)
|
|
79
|
+
|
|
80
|
+
return sorted((root / name) for name in names if (root / name).is_file())
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Apply configuration to a raw finding list.
|
|
3
|
+
|
|
4
|
+
Keeps the CLI thin: given the findings from a scan plus a
|
|
5
|
+
:class:`~codeguard.config.schema.Config`, produce the findings to report
|
|
6
|
+
(severity remapped, per-path rules applied, confidence floors enforced) and
|
|
7
|
+
decide whether any of them should fail the run.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import replace
|
|
13
|
+
|
|
14
|
+
import pathspec
|
|
15
|
+
|
|
16
|
+
from codeguard.config.schema import Config
|
|
17
|
+
from codeguard.engine.finding import Finding, Severity
|
|
18
|
+
from codeguard.engine.fingerprint import relative_path
|
|
19
|
+
|
|
20
|
+
_SEV = {s.value: s for s in Severity}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _matches(path_glob: str, rel_file: str) -> bool:
|
|
24
|
+
spec = pathspec.PathSpec.from_lines("gitignore", [path_glob])
|
|
25
|
+
return spec.match_file(rel_file)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def apply_config(
|
|
29
|
+
findings: list[Finding], config: Config, *, root: str | None = None
|
|
30
|
+
) -> list[Finding]:
|
|
31
|
+
"""Return the findings to report after severity remap + per-path overrides.
|
|
32
|
+
|
|
33
|
+
Findings suppressed by a path override are returned with ``suppressed=True``
|
|
34
|
+
(like inline suppressions) so ``--show-suppressed`` and SARIF still see them.
|
|
35
|
+
"""
|
|
36
|
+
remap = dict(config.severity_remap)
|
|
37
|
+
per_rule = config.rules
|
|
38
|
+
|
|
39
|
+
out: list[Finding] = []
|
|
40
|
+
for f in findings:
|
|
41
|
+
rel_file = relative_path(f.location.file, root=root)
|
|
42
|
+
new_sev = f.severity
|
|
43
|
+
if f.rule_id in remap:
|
|
44
|
+
new_sev = _SEV[remap[f.rule_id]]
|
|
45
|
+
rs = per_rule.get(f.rule_id)
|
|
46
|
+
if rs and rs.severity:
|
|
47
|
+
new_sev = _SEV[rs.severity]
|
|
48
|
+
|
|
49
|
+
suppressed = f.suppressed
|
|
50
|
+
for ov in config.overrides:
|
|
51
|
+
if _matches(ov.path, rel_file):
|
|
52
|
+
if ov.disable and (f.rule_id in ov.disable or "ALL" in ov.disable):
|
|
53
|
+
suppressed = True
|
|
54
|
+
if ov.enable and f.rule_id not in ov.enable:
|
|
55
|
+
suppressed = True
|
|
56
|
+
|
|
57
|
+
if new_sev != f.severity or suppressed != f.suppressed:
|
|
58
|
+
f = replace(f, severity=new_sev, suppressed=suppressed)
|
|
59
|
+
|
|
60
|
+
if rs and rs.confidence_min is not None and f.confidence < rs.confidence_min:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
out.append(f)
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def gating_findings(findings: list[Finding], fail_on: str) -> list[Finding]:
|
|
68
|
+
"""The findings that should fail the run: active, not baselined, and at or
|
|
69
|
+
above the ``fail_on`` threshold. ``fail_on == "never"`` -> always empty.
|
|
70
|
+
"""
|
|
71
|
+
if fail_on == "never":
|
|
72
|
+
return []
|
|
73
|
+
threshold = _SEV[fail_on]
|
|
74
|
+
return [f for f in findings if not f.suppressed and not f.baselined and f.severity >= threshold]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Rule registry — the central catalogue of all registered CodeGuard rules."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .rule import Rule
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RuleRegistry:
|
|
14
|
+
"""Central registry that maps rule IDs to :class:`~codeguard.engine.rule.Rule` instances.
|
|
15
|
+
|
|
16
|
+
Rules self-register at import time via :meth:`register`. The module-level
|
|
17
|
+
:data:`REGISTRY` singleton is what the runner uses; tests may construct
|
|
18
|
+
isolated registries to avoid cross-rule interference.
|
|
19
|
+
|
|
20
|
+
Example
|
|
21
|
+
-------
|
|
22
|
+
::
|
|
23
|
+
|
|
24
|
+
from codeguard.engine.registry import REGISTRY
|
|
25
|
+
|
|
26
|
+
for rule in REGISTRY:
|
|
27
|
+
print(rule.id, rule.title)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._rules: dict[str, Rule] = {}
|
|
32
|
+
|
|
33
|
+
def register(self, rule: Rule) -> None:
|
|
34
|
+
"""Register *rule*.
|
|
35
|
+
|
|
36
|
+
Raises
|
|
37
|
+
------
|
|
38
|
+
ValueError
|
|
39
|
+
If another rule with the same ``id`` is already registered.
|
|
40
|
+
Rule IDs are a public contract; duplicates are a hard error.
|
|
41
|
+
TypeError
|
|
42
|
+
If *rule* does not have a non-empty ``id`` attribute.
|
|
43
|
+
"""
|
|
44
|
+
if not getattr(rule, "id", None):
|
|
45
|
+
raise TypeError(f"{rule!r} does not have a non-empty 'id' attribute")
|
|
46
|
+
if rule.id in self._rules:
|
|
47
|
+
existing = self._rules[rule.id]
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"Rule ID conflict: {rule.id!r} is already registered by "
|
|
50
|
+
f"{existing.__class__.__qualname__}. "
|
|
51
|
+
f"Rule IDs are permanent — claim a new one."
|
|
52
|
+
)
|
|
53
|
+
self._rules[rule.id] = rule
|
|
54
|
+
|
|
55
|
+
def get(self, rule_id: str) -> Rule | None:
|
|
56
|
+
"""Return the rule for *rule_id*, or ``None`` if not registered."""
|
|
57
|
+
return self._rules.get(rule_id)
|
|
58
|
+
|
|
59
|
+
def all(self) -> list[Rule]:
|
|
60
|
+
"""Return all registered rules, sorted by ID."""
|
|
61
|
+
return sorted(self._rules.values(), key=lambda r: r.id)
|
|
62
|
+
|
|
63
|
+
def __iter__(self) -> Iterator[Rule]:
|
|
64
|
+
return iter(self.all())
|
|
65
|
+
|
|
66
|
+
def __len__(self) -> int:
|
|
67
|
+
return len(self._rules)
|
|
68
|
+
|
|
69
|
+
def __contains__(self, rule_id: object) -> bool:
|
|
70
|
+
return rule_id in self._rules
|
|
71
|
+
|
|
72
|
+
def __repr__(self) -> str:
|
|
73
|
+
ids = sorted(self._rules)
|
|
74
|
+
return f"RuleRegistry({ids})"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
#: Module-level singleton used by the runner and all rules.
|
|
78
|
+
REGISTRY: RuleRegistry = RuleRegistry()
|