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,195 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Abstract base classes for CodeGuard rules."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import ast
7
+ from abc import ABC, abstractmethod
8
+
9
+ from codeguard.lang.base import Language
10
+ from codeguard.lang.node import SourceNode
11
+
12
+ from .context import RuleContext
13
+ from .finding import Category, Finding, Location, Severity
14
+
15
+
16
+ class Rule(ABC):
17
+ """Abstract base class that every CodeGuard rule implements.
18
+
19
+ Each rule detects exactly one concern. Rules are self-contained: they
20
+ receive a :class:`~codeguard.engine.context.RuleContext` and return findings.
21
+ They have no knowledge of other rules and no persistent state.
22
+
23
+ To add a new rule, see CONTRIBUTING.md § "Adding a rule".
24
+
25
+ Class attributes
26
+ ----------------
27
+ id:
28
+ Stable rule identifier, e.g. ``CG-SEC-001``. Defined on the class,
29
+ never on instances. Never renumber or reuse an ID.
30
+ title:
31
+ Short (<= 80 char) human-readable title.
32
+ description:
33
+ Full explanation for developers who've never encountered this issue.
34
+ severity:
35
+ Default severity. Override per-finding when needed.
36
+ category:
37
+ Broad category -- security, quality, performance, ai-smell.
38
+ languages:
39
+ The set of languages this rule can analyse. The runner skips a rule
40
+ for any file whose language is not in this set.
41
+ cwe:
42
+ Primary CWE identifier, e.g. ``"CWE-89"``. ``None`` if not applicable.
43
+ owasp:
44
+ OWASP category, e.g. ``"A03:2021 - Injection"``. ``None`` if not applicable.
45
+ help_uri:
46
+ Link to the rule's documentation page. ``None`` falls back to a
47
+ conventional URL derived from the rule ID.
48
+ wants_dataflow:
49
+ Opt in to the intraprocedural taint pass (a later milestone). Ignored
50
+ today.
51
+ """
52
+
53
+ id: str
54
+ title: str
55
+ description: str
56
+ severity: Severity
57
+ category: Category
58
+ languages: frozenset[Language]
59
+ cwe: str | None = None
60
+ owasp: str | None = None
61
+ help_uri: str | None = None
62
+ wants_dataflow: bool = False
63
+
64
+ @abstractmethod
65
+ def analyze(self, ctx: RuleContext) -> list[Finding]:
66
+ """Analyse the file described by *ctx* and return findings.
67
+
68
+ Returns
69
+ -------
70
+ list[Finding]
71
+ Empty list means no findings. Never return ``None``.
72
+ """
73
+
74
+ def __repr__(self) -> str:
75
+ return f"{self.__class__.__name__}(id={self.id!r})"
76
+
77
+
78
+ class AstRule(Rule):
79
+ """Base class for Python rules that work directly on a :mod:`ast` tree.
80
+
81
+ Subclasses implement :meth:`check_ast` with the same signature CodeGuard
82
+ rules have always used. This class adapts it to the language-aware
83
+ :meth:`Rule.analyze` protocol and centralises the 0-indexed to 1-indexed
84
+ column conversion in :meth:`_make_finding`.
85
+ """
86
+
87
+ languages = frozenset({Language.PYTHON})
88
+
89
+ def analyze(self, ctx: RuleContext) -> list[Finding]:
90
+ return self.check_ast(ctx.python_ast, ctx.source, ctx.filename)
91
+
92
+ @abstractmethod
93
+ def check_ast(self, tree: ast.AST, source: str, filename: str) -> list[Finding]:
94
+ """Analyse *tree* and return findings.
95
+
96
+ Parameters
97
+ ----------
98
+ tree:
99
+ ``ast.AST`` for the file. Do **not** re-parse; use what you're given.
100
+ source:
101
+ Raw source text, available for line-level context if needed.
102
+ filename:
103
+ File path string -- used when constructing :class:`Location`.
104
+ """
105
+
106
+ # ------------------------------------------------------------------
107
+ # Helper for rule implementations
108
+ # ------------------------------------------------------------------
109
+
110
+ def _make_finding(
111
+ self,
112
+ *,
113
+ node: ast.AST,
114
+ filename: str,
115
+ description: str | None = None,
116
+ fix_suggestion: str | None = None,
117
+ confidence: float = 1.0,
118
+ severity: Severity | None = None,
119
+ ) -> Finding:
120
+ """Build a :class:`Finding` from an AST *node*.
121
+
122
+ Pulls ``lineno`` / ``col_offset`` / ``end_lineno`` / ``end_col_offset``
123
+ from *node* and converts the 0-indexed AST columns to CodeGuard's
124
+ 1-indexed :class:`Location` columns.
125
+ """
126
+ line: int = getattr(node, "lineno", 1)
127
+ col: int = getattr(node, "col_offset", 0)
128
+ end_line: int | None = getattr(node, "end_lineno", None)
129
+ end_col: int | None = getattr(node, "end_col_offset", None)
130
+
131
+ return Finding(
132
+ rule_id=self.id,
133
+ title=self.title,
134
+ description=description or self.description,
135
+ severity=severity if severity is not None else self.severity,
136
+ category=self.category,
137
+ location=Location(
138
+ file=filename,
139
+ line=max(line, 1),
140
+ col=col + 1,
141
+ end_line=end_line,
142
+ end_col=None if end_col is None else end_col + 1,
143
+ ),
144
+ cwe=self.cwe,
145
+ owasp=self.owasp,
146
+ fix_suggestion=fix_suggestion,
147
+ confidence=confidence,
148
+ )
149
+
150
+
151
+ class TreeSitterRule(Rule):
152
+ """Base class for rules over a tree-sitter tree (JavaScript / TypeScript).
153
+
154
+ Subclasses set ``languages`` and implement :meth:`check_tree`, working with
155
+ the uniform :class:`~codeguard.lang.node.SourceNode` API.
156
+ """
157
+
158
+ def analyze(self, ctx: RuleContext) -> list[Finding]:
159
+ return self.check_tree(ctx.root, ctx)
160
+
161
+ @abstractmethod
162
+ def check_tree(self, root: SourceNode, ctx: RuleContext) -> list[Finding]:
163
+ """Analyse *root* (the file's tree) and return findings."""
164
+
165
+ def _make_finding(
166
+ self,
167
+ *,
168
+ node: SourceNode,
169
+ ctx: RuleContext,
170
+ description: str | None = None,
171
+ fix_suggestion: str | None = None,
172
+ confidence: float = 1.0,
173
+ severity: Severity | None = None,
174
+ ) -> Finding:
175
+ """Build a :class:`Finding` from a :class:`SourceNode` (already 1-indexed)."""
176
+ start = node.start
177
+ end = node.end
178
+ return Finding(
179
+ rule_id=self.id,
180
+ title=self.title,
181
+ description=description or self.description,
182
+ severity=severity if severity is not None else self.severity,
183
+ category=self.category,
184
+ location=Location(
185
+ file=ctx.filename,
186
+ line=start.line,
187
+ col=start.col,
188
+ end_line=end.line if end else None,
189
+ end_col=end.col if end else None,
190
+ ),
191
+ cwe=self.cwe,
192
+ owasp=self.owasp,
193
+ fix_suggestion=fix_suggestion,
194
+ confidence=confidence,
195
+ )
@@ -0,0 +1,267 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Analysis runner -- executes registered rules against source files.
3
+
4
+ The runner is the only component that knows about both the registry and the
5
+ file system. Rules know nothing about files; the runner knows nothing about
6
+ what rules detect.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import warnings
12
+ from collections.abc import Iterable
13
+ from datetime import date
14
+ from pathlib import Path
15
+
16
+ from codeguard.lang.base import Language
17
+ from codeguard.lang.registry import language_for_path, support_for
18
+
19
+ from . import fingerprint as _fp
20
+ from .context import RuleContext
21
+ from .finding import Category, Finding, Location
22
+ from .registry import REGISTRY, RuleRegistry
23
+ from .rule import Rule
24
+ from .suppressions import META_EXPIRED, META_MISSING_REASON, SuppressionSet
25
+
26
+
27
+ class AnalysisRunner:
28
+ """Runs registered rules against source code.
29
+
30
+ Parameters
31
+ ----------
32
+ registry:
33
+ Which rule registry to use. Defaults to the module-level
34
+ :data:`~codeguard.engine.registry.REGISTRY` singleton.
35
+ rule_ids:
36
+ When provided, only rules whose IDs are in this collection are run.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ registry: RuleRegistry | None = None,
42
+ rule_ids: list[str] | None = None,
43
+ *,
44
+ now: date | None = None,
45
+ ) -> None:
46
+ self._registry = registry if registry is not None else REGISTRY
47
+ self._filter: set[str] | None = set(rule_ids) if rule_ids is not None else None
48
+ #: Date used for `until=` suppression expiry (default: today, per run).
49
+ self._now = now
50
+
51
+ @property
52
+ def _active_rules(self) -> list[Rule]:
53
+ if self._filter is None:
54
+ return self._registry.all()
55
+ return [r for r in self._registry.all() if r.id in self._filter]
56
+
57
+ def _rules_for(self, language: Language) -> list[Rule]:
58
+ return [r for r in self._active_rules if language in r.languages]
59
+
60
+ # ------------------------------------------------------------------
61
+ # Public API
62
+ # ------------------------------------------------------------------
63
+
64
+ def run(
65
+ self,
66
+ source: str,
67
+ filename: str = "<stdin>",
68
+ *,
69
+ language: Language | None = None,
70
+ now: date | None = None,
71
+ ) -> list[Finding]:
72
+ """Analyse *source* text and return findings, suppressions applied.
73
+
74
+ Findings are sorted by ``(location.line, rule_id)`` and carry a
75
+ fingerprint. Suppressed findings are **included** with
76
+ ``suppressed=True``. *now* (default today) dates ``until=`` expiry.
77
+
78
+ Parameters
79
+ ----------
80
+ source:
81
+ Raw source code.
82
+ filename:
83
+ Used in :class:`~codeguard.engine.finding.Location` and to detect
84
+ the language when *language* is not given.
85
+ language:
86
+ Force a language instead of detecting from *filename*. Defaults to
87
+ Python for ``"<stdin>"``.
88
+
89
+ Raises
90
+ ------
91
+ SyntaxError
92
+ If *source* is not valid for its language. :meth:`run_file` handles
93
+ this gracefully.
94
+ ValueError
95
+ If the language cannot be determined.
96
+ """
97
+ lang = language or language_for_path(filename) or Language.PYTHON
98
+ support = support_for(lang)
99
+
100
+ parsed = support.parse(source, filename)
101
+ if not parsed.ok or parsed.root is None:
102
+ err = parsed.error
103
+ raise SyntaxError(
104
+ (err.message if err else "parse error"),
105
+ (filename, err.line if err else None, err.col if err else None, None),
106
+ )
107
+
108
+ ctx = RuleContext(
109
+ filename=filename,
110
+ source=source,
111
+ language=lang,
112
+ lang=support,
113
+ root=parsed.root, # type: ignore[arg-type]
114
+ )
115
+ today = now or self._now or date.today()
116
+ suppset = SuppressionSet.parse(source)
117
+ rel = _fp.relative_path(filename)
118
+ py_tree = ctx.python_ast if lang is Language.PYTHON else None
119
+ enabled_ids = {r.id for r in self._active_rules}
120
+
121
+ def _fingerprinted(f: Finding) -> Finding:
122
+ scope = _fp.python_scope(py_tree, f.location.line) if py_tree is not None else ""
123
+ return f.with_fingerprint(
124
+ _fp.compute(f.rule_id, rel, source, f.location.line, scope=scope)
125
+ )
126
+
127
+ findings: list[Finding] = []
128
+ meta_lines: dict[str, set[int]] = {META_MISSING_REASON: set(), META_EXPIRED: set()}
129
+
130
+ for rule in self._rules_for(lang):
131
+ for finding in rule.analyze(ctx):
132
+ line = finding.location.line
133
+ verdict = suppset.outcome(finding.rule_id, line, today)
134
+ if verdict is not None:
135
+ kind, supp = verdict
136
+ if kind == "suppress":
137
+ finding = finding.as_suppressed()
138
+ if supp.reason is None:
139
+ meta_lines[META_MISSING_REASON].add(supp.line)
140
+ elif kind == "expired":
141
+ meta_lines[META_EXPIRED].add(supp.line)
142
+ findings.append(_fingerprinted(finding))
143
+
144
+ findings.extend(
145
+ self._meta_findings(meta_lines, suppset, filename, source, rel, py_tree, enabled_ids)
146
+ )
147
+
148
+ findings.sort(key=lambda f: (f.location.line, f.location.col, f.rule_id))
149
+ return findings
150
+
151
+ def _meta_findings(
152
+ self,
153
+ meta_lines: dict[str, set[int]],
154
+ suppset: SuppressionSet,
155
+ filename: str,
156
+ source: str,
157
+ rel: str,
158
+ py_tree: object,
159
+ enabled_ids: set[str],
160
+ ) -> list[Finding]:
161
+ out: list[Finding] = []
162
+ for meta_id, lines in meta_lines.items():
163
+ if meta_id not in enabled_ids or meta_id not in self._registry:
164
+ continue
165
+ rule = self._registry.get(meta_id)
166
+ assert rule is not None
167
+ for line in sorted(lines):
168
+ f = Finding(
169
+ rule_id=meta_id,
170
+ title=rule.title,
171
+ description=rule.description,
172
+ severity=rule.severity,
173
+ category=Category.META,
174
+ location=Location(file=filename, line=line, col=1),
175
+ fix_suggestion=None,
176
+ )
177
+ # a `# codeguard: ignore[CG-META-001]` on the same line still works
178
+ if suppset.outcome(meta_id, line, date.max):
179
+ f = f.as_suppressed()
180
+ scope = _fp.python_scope(py_tree, line) if py_tree is not None else "" # type: ignore[arg-type]
181
+ out.append(f.with_fingerprint(_fp.compute(meta_id, rel, source, line, scope=scope)))
182
+ return out
183
+
184
+ def run_file(self, path: Path) -> list[Finding]:
185
+ """Analyse a single file on disk.
186
+
187
+ Returns an empty list (and warns) for an unsupported extension, an
188
+ unreadable file, or a syntax error, rather than raising.
189
+ """
190
+ lang = language_for_path(path)
191
+ if lang is None:
192
+ return []
193
+
194
+ try:
195
+ source = path.read_text(encoding="utf-8", errors="replace")
196
+ except OSError as exc:
197
+ warnings.warn(f"Cannot read {path}: {exc}", stacklevel=2)
198
+ return []
199
+
200
+ try:
201
+ return self.run(source, filename=str(path), language=lang)
202
+ except SyntaxError as exc:
203
+ warnings.warn(
204
+ f"Skipping {path}: syntax error on line {exc.lineno} -- {exc.msg}",
205
+ SyntaxWarning,
206
+ stacklevel=2,
207
+ )
208
+ return []
209
+
210
+ def run_files(self, paths: Iterable[Path], *, jobs: int = 1) -> list[Finding]:
211
+ """Analyse an explicit list of files, optionally in parallel.
212
+
213
+ *jobs* > 1 fans the files out across a process pool; the output is
214
+ identical to a sequential run (findings are re-sorted after the join).
215
+ """
216
+ files = list(paths)
217
+ findings: list[Finding] = []
218
+
219
+ if jobs and jobs > 1 and len(files) > 1:
220
+ from concurrent.futures import ProcessPoolExecutor
221
+
222
+ filter_ids = sorted(self._filter) if self._filter is not None else None
223
+ now_iso = self._now.isoformat() if self._now is not None else None
224
+ with ProcessPoolExecutor(
225
+ max_workers=jobs,
226
+ initializer=_init_worker,
227
+ initargs=(filter_ids, now_iso),
228
+ ) as pool:
229
+ for result in pool.map(_scan_one, (str(p) for p in files)):
230
+ findings.extend(result)
231
+ else:
232
+ for fp in files:
233
+ findings.extend(self.run_file(fp))
234
+
235
+ findings.sort(key=lambda f: (f.location.file, f.location.line, f.location.col, f.rule_id))
236
+ return findings
237
+
238
+ def run_path(self, path: Path, *, jobs: int = 1) -> list[Finding]:
239
+ """Analyse a file, or a directory (discovery defaults, recursive)."""
240
+ if path.is_file():
241
+ return self.run_file(path)
242
+
243
+ from .discovery import discover
244
+
245
+ return self.run_files(discover([path]), jobs=jobs)
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Process-pool workers (module level so they pickle)
250
+ # ---------------------------------------------------------------------------
251
+
252
+ _WORKER_RUNNER: AnalysisRunner | None = None
253
+
254
+
255
+ def _init_worker(rule_ids: list[str] | None, now_iso: str | None) -> None:
256
+ global _WORKER_RUNNER
257
+ import codeguard.rules # noqa: F401 -- register built-in rules in the child
258
+
259
+ now = date.fromisoformat(now_iso) if now_iso else None
260
+ _WORKER_RUNNER = AnalysisRunner(rule_ids=rule_ids, now=now)
261
+
262
+
263
+ def _scan_one(path_str: str) -> list[Finding]:
264
+ assert _WORKER_RUNNER is not None # set by _init_worker
265
+ with warnings.catch_warnings():
266
+ warnings.simplefilter("ignore")
267
+ return _WORKER_RUNNER.run_file(Path(path_str))
@@ -0,0 +1,109 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Inline and file-level suppression comments.
3
+
4
+ # codeguard: ignore[CG-SEC-001] reason: the value is a constant
5
+ # codeguard: ignore[CG-SEC-001] reason: validated upstream until=2026-12-31
6
+ # codeguard: ignore-file[CG-SEC-002] reason: this module generates fixtures
7
+
8
+ The comment leader is ``#`` for Python, ``//`` for JavaScript / TypeScript.
9
+
10
+ Every suppression should carry a ``reason:``. One without a reason still
11
+ suppresses, but the runner also raises **CG-META-001**. An expired
12
+ ``until=`` suppression stops suppressing and raises **CG-META-002**.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from datetime import date
20
+
21
+ META_MISSING_REASON = "CG-META-001"
22
+ META_EXPIRED = "CG-META-002"
23
+
24
+ _KEYWORD = r"(ignore|ignore-file|disable)"
25
+ _LINE_RE = re.compile(rf"(?:#|//)\s*codeguard:\s*{_KEYWORD}\[([^\]]+)\](?P<rest>.*)$")
26
+ _UNTIL_RE = re.compile(r"until=(\d{4}-\d{2}-\d{2})")
27
+ _REASON_RE = re.compile(r"reason:\s*(?P<reason>.*?)(?:\s+until=\d{4}-\d{2}-\d{2}|\s*$)")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Suppression:
32
+ """One parsed suppression comment."""
33
+
34
+ rule_ids: frozenset[str]
35
+ reason: str | None
36
+ until: date | None
37
+ file_level: bool
38
+ line: int # 1-indexed line of the comment (== target line for inline)
39
+ raw: str
40
+
41
+ def covers(self, rule_id: str) -> bool:
42
+ return rule_id in self.rule_ids
43
+
44
+ def is_expired(self, today: date) -> bool:
45
+ return self.until is not None and today > self.until
46
+
47
+
48
+ @dataclass
49
+ class SuppressionSet:
50
+ """All suppressions found in one source file."""
51
+
52
+ inline: dict[int, list[Suppression]] = field(default_factory=dict)
53
+ file_level: list[Suppression] = field(default_factory=list)
54
+
55
+ @classmethod
56
+ def parse(cls, source: str) -> SuppressionSet:
57
+ out = cls()
58
+ for lineno, text in enumerate(source.splitlines(), start=1):
59
+ match = _LINE_RE.search(text)
60
+ if not match:
61
+ continue
62
+ keyword, ids_raw = match.group(1), match.group(2)
63
+ rest = match.group("rest")
64
+ until = None
65
+ if (u := _UNTIL_RE.search(rest)) is not None:
66
+ try:
67
+ until = date.fromisoformat(u.group(1))
68
+ except ValueError:
69
+ until = None
70
+ reason = None
71
+ if (r := _REASON_RE.search(rest)) is not None:
72
+ candidate = r.group("reason").strip()
73
+ reason = candidate or None
74
+ supp = Suppression(
75
+ rule_ids=frozenset(i.strip() for i in ids_raw.split(",") if i.strip()),
76
+ reason=reason,
77
+ until=until,
78
+ file_level=keyword in ("ignore-file", "disable"),
79
+ line=lineno,
80
+ raw=text.strip(),
81
+ )
82
+ if supp.file_level:
83
+ out.file_level.append(supp)
84
+ else:
85
+ out.inline.setdefault(lineno, []).append(supp)
86
+ return out
87
+
88
+ def all(self) -> list[Suppression]:
89
+ return [s for group in self.inline.values() for s in group] + self.file_level
90
+
91
+ def _candidates(self, rule_id: str, line: int) -> list[Suppression]:
92
+ found = [s for s in self.inline.get(line, []) if s.covers(rule_id)]
93
+ found += [s for s in self.file_level if s.covers(rule_id)]
94
+ return found
95
+
96
+ def outcome(self, rule_id: str, line: int, today: date) -> tuple[str, Suppression] | None:
97
+ """Decide what a finding's suppression comment does.
98
+
99
+ - ``("suppress", s)`` -- an active suppression applies
100
+ - ``("expired", s)`` -- only an expired suppression applies (does NOT suppress)
101
+ - ``None`` -- no suppression comment covers this finding
102
+ """
103
+ candidates = self._candidates(rule_id, line)
104
+ if not candidates:
105
+ return None
106
+ active = [s for s in candidates if not s.is_expired(today)]
107
+ if active:
108
+ return "suppress", active[0]
109
+ return "expired", candidates[0]
@@ -0,0 +1,37 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Language support layer.
3
+
4
+ Each :class:`~codeguard.lang.base.LanguageSupport` implementation wraps a parser
5
+ for one language and hands rules a uniform :class:`~codeguard.lang.node.SourceNode`.
6
+ Rules declare which languages they target; the runner only invokes a rule for a
7
+ file whose language is in that set.
8
+
9
+ Python is backed by the standard library :mod:`ast`. JavaScript and TypeScript
10
+ (added in a later milestone) are backed by tree-sitter.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .base import Language, LanguageSupport, ParseResult, Position, SyntaxErrorInfo
16
+ from .javascript import JavaScriptSupport
17
+ from .node import SourceNode
18
+ from .python_ast import PythonAstSupport
19
+ from .registry import LANGUAGES, language_for_path, support_for
20
+ from .treesitter import TreeSitterSupport
21
+ from .typescript import TypeScriptSupport
22
+
23
+ __all__ = [
24
+ "LANGUAGES",
25
+ "JavaScriptSupport",
26
+ "Language",
27
+ "LanguageSupport",
28
+ "ParseResult",
29
+ "Position",
30
+ "PythonAstSupport",
31
+ "SourceNode",
32
+ "SyntaxErrorInfo",
33
+ "TreeSitterSupport",
34
+ "TypeScriptSupport",
35
+ "language_for_path",
36
+ "support_for",
37
+ ]
codeguard/lang/base.py ADDED
@@ -0,0 +1,80 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Core types for the language support layer."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import enum
7
+ from abc import ABC, abstractmethod
8
+ from dataclasses import dataclass
9
+
10
+
11
+ class Language(str, enum.Enum):
12
+ """A source language CodeGuard can parse."""
13
+
14
+ PYTHON = "python"
15
+ JAVASCRIPT = "javascript"
16
+ TYPESCRIPT = "typescript"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Position:
21
+ """A 1-indexed ``(line, col)`` position.
22
+
23
+ Both coordinates are 1-indexed to match editors, SARIF, and CodeGuard's
24
+ :class:`~codeguard.engine.finding.Location`. Parser-native offsets (``ast``
25
+ and tree-sitter are both 0-indexed for columns) are converted at the
26
+ boundary — rules never see a 0-indexed column.
27
+ """
28
+
29
+ line: int
30
+ col: int
31
+
32
+ def __post_init__(self) -> None:
33
+ if self.line < 1:
34
+ raise ValueError(f"line must be >= 1, got {self.line}")
35
+ if self.col < 1:
36
+ raise ValueError(f"col must be >= 1, got {self.col}")
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class SyntaxErrorInfo:
41
+ """Where and why a parse failed."""
42
+
43
+ message: str
44
+ line: int | None = None
45
+ col: int | None = None
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ParseResult:
50
+ """Outcome of :meth:`LanguageSupport.parse`.
51
+
52
+ ``parse`` never raises for malformed input; it returns ``ok=False`` with an
53
+ ``error`` instead, so the runner can skip a file and warn rather than crash.
54
+ """
55
+
56
+ root: object | None
57
+ ok: bool
58
+ error: SyntaxErrorInfo | None = None
59
+
60
+
61
+ class LanguageSupport(ABC):
62
+ """Parser adapter for one :class:`Language`."""
63
+
64
+ language: Language
65
+ #: File extensions (with leading dot) this language claims.
66
+ extensions: tuple[str, ...]
67
+ #: Comment leaders used for inline suppression comments, longest first.
68
+ comment_prefixes: tuple[str, ...]
69
+
70
+ @abstractmethod
71
+ def parse(self, source: str, filename: str) -> ParseResult:
72
+ """Parse *source*. Never raises; returns ``ok=False`` on failure."""
73
+
74
+ def query(self, name: str) -> object | None:
75
+ """Return a compiled structural query by name, or ``None``.
76
+
77
+ Only meaningful for tree-sitter backends; the ``ast`` backend returns
78
+ ``None`` and rules walk the tree directly.
79
+ """
80
+ return None