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,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()