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/engine/rule.py
ADDED
|
@@ -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
|