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,86 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Find and load a CodeGuard config file."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .schema import Config, SchemaError
|
|
10
|
+
|
|
11
|
+
if sys.version_info >= (3, 11):
|
|
12
|
+
import tomllib
|
|
13
|
+
else: # pragma: no cover
|
|
14
|
+
import tomli as tomllib
|
|
15
|
+
|
|
16
|
+
_FILENAMES = ("codeguard.toml", ".codeguard.toml")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConfigError(Exception):
|
|
20
|
+
"""A config file was found but is malformed or has unknown keys."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def find_config(start: Path | None = None) -> Path | None:
|
|
24
|
+
"""Walk up from *start* (cwd by default) looking for a config file.
|
|
25
|
+
|
|
26
|
+
Returns the first ``codeguard.toml`` / ``.codeguard.toml`` found, or the
|
|
27
|
+
first ``pyproject.toml`` that contains a ``[tool.codeguard]`` table.
|
|
28
|
+
"""
|
|
29
|
+
here = (start or Path.cwd()).resolve()
|
|
30
|
+
for directory in (here, *here.parents):
|
|
31
|
+
for name in _FILENAMES:
|
|
32
|
+
candidate = directory / name
|
|
33
|
+
if candidate.is_file():
|
|
34
|
+
return candidate
|
|
35
|
+
pyproject = directory / "pyproject.toml"
|
|
36
|
+
if pyproject.is_file() and _has_tool_table(pyproject):
|
|
37
|
+
return pyproject
|
|
38
|
+
if (directory / ".git").exists():
|
|
39
|
+
break
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _has_tool_table(pyproject: Path) -> bool:
|
|
44
|
+
try:
|
|
45
|
+
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
46
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
47
|
+
return False
|
|
48
|
+
return isinstance(data.get("tool"), dict) and "codeguard" in data["tool"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_config(path: Path | None) -> Config:
|
|
52
|
+
"""Load and validate the config at *path*.
|
|
53
|
+
|
|
54
|
+
``None`` -> an empty default :class:`Config`. Raises :class:`ConfigError`
|
|
55
|
+
on a parse error or a schema violation.
|
|
56
|
+
"""
|
|
57
|
+
if path is None:
|
|
58
|
+
return Config()
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
62
|
+
except tomllib.TOMLDecodeError as exc:
|
|
63
|
+
raise ConfigError(f"{path}: invalid TOML: {exc}") from exc
|
|
64
|
+
except OSError as exc:
|
|
65
|
+
raise ConfigError(f"{path}: {exc}") from exc
|
|
66
|
+
|
|
67
|
+
tool_table = raw.get("tool", {})
|
|
68
|
+
if isinstance(tool_table, dict) and isinstance(tool_table.get("codeguard"), dict):
|
|
69
|
+
table = tool_table["codeguard"]
|
|
70
|
+
elif isinstance(raw.get("codeguard"), dict):
|
|
71
|
+
table = raw["codeguard"]
|
|
72
|
+
elif path.name == "pyproject.toml":
|
|
73
|
+
table = {}
|
|
74
|
+
else:
|
|
75
|
+
table = raw
|
|
76
|
+
|
|
77
|
+
if not isinstance(table, dict):
|
|
78
|
+
raise ConfigError(f"{path}: [codeguard] must be a table")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
cfg = Config.from_dict(table)
|
|
82
|
+
except SchemaError as exc:
|
|
83
|
+
raise ConfigError(f"{path}: {exc}") from exc
|
|
84
|
+
|
|
85
|
+
cfg.source_dir = str(path.parent)
|
|
86
|
+
return cfg
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The ``codeguard.toml`` schema, as validated dataclasses.
|
|
3
|
+
|
|
4
|
+
A single ``[tool.codeguard]`` table (or the top-level table of a standalone
|
|
5
|
+
``codeguard.toml``) maps to :class:`Config`. :func:`Config.from_dict` validates
|
|
6
|
+
and raises :class:`~codeguard.config.loader.ConfigError` with a precise message
|
|
7
|
+
on anything unknown or malformed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field, fields
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from codeguard.engine.finding import Severity
|
|
16
|
+
from codeguard.lang.base import Language
|
|
17
|
+
|
|
18
|
+
_SEVERITIES = {s.value for s in Severity}
|
|
19
|
+
_LANGUAGES = {lang.value for lang in Language}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SchemaError(Exception):
|
|
23
|
+
"""A schema violation, re-raised as ConfigError by the loader."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _expect_str_list(value: Any, key: str) -> list[str]:
|
|
27
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
28
|
+
raise SchemaError(f"{key!r} must be a list of strings")
|
|
29
|
+
return list(value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _expect_bool(value: Any, key: str) -> bool:
|
|
33
|
+
if not isinstance(value, bool):
|
|
34
|
+
raise SchemaError(f"{key!r} must be true or false")
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _expect_severity(value: Any, key: str) -> str:
|
|
39
|
+
if value not in _SEVERITIES:
|
|
40
|
+
raise SchemaError(f"{key!r} must be one of {sorted(_SEVERITIES)}, got {value!r}")
|
|
41
|
+
return str(value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class RuleSettings:
|
|
46
|
+
"""Per-rule tuning from ``[tool.codeguard.rules.<ID>]``."""
|
|
47
|
+
|
|
48
|
+
severity: str | None = None
|
|
49
|
+
confidence_min: float | None = None
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, rule_id: str, data: dict[str, Any]) -> RuleSettings:
|
|
53
|
+
out = cls()
|
|
54
|
+
for k, v in data.items():
|
|
55
|
+
if k == "severity":
|
|
56
|
+
out.severity = _expect_severity(v, f"rules.{rule_id}.severity")
|
|
57
|
+
elif k == "confidence_min":
|
|
58
|
+
if not isinstance(v, (int, float)) or not (0.0 <= float(v) <= 1.0):
|
|
59
|
+
raise SchemaError(f"rules.{rule_id}.confidence_min must be in [0.0, 1.0]")
|
|
60
|
+
out.confidence_min = float(v)
|
|
61
|
+
else:
|
|
62
|
+
raise SchemaError(f"unknown key rules.{rule_id}.{k}")
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class RuleOverride:
|
|
68
|
+
"""A path-scoped override from ``[[tool.codeguard.overrides]]``."""
|
|
69
|
+
|
|
70
|
+
path: str
|
|
71
|
+
disable: list[str] = field(default_factory=list)
|
|
72
|
+
enable: list[str] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_dict(cls, data: dict[str, Any]) -> RuleOverride:
|
|
76
|
+
if "path" not in data or not isinstance(data["path"], str):
|
|
77
|
+
raise SchemaError("each [[overrides]] needs a string 'path'")
|
|
78
|
+
out = cls(path=data["path"])
|
|
79
|
+
for k, v in data.items():
|
|
80
|
+
if k == "path":
|
|
81
|
+
continue
|
|
82
|
+
if k == "disable":
|
|
83
|
+
out.disable = _expect_str_list(v, "overrides.disable")
|
|
84
|
+
elif k == "enable":
|
|
85
|
+
out.enable = _expect_str_list(v, "overrides.enable")
|
|
86
|
+
else:
|
|
87
|
+
raise SchemaError(f"unknown key overrides.{k}")
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class Config:
|
|
93
|
+
"""The whole ``[tool.codeguard]`` table."""
|
|
94
|
+
|
|
95
|
+
include: list[str] = field(default_factory=list)
|
|
96
|
+
exclude: list[str] = field(default_factory=list)
|
|
97
|
+
languages: list[str] = field(default_factory=list)
|
|
98
|
+
gitignore: bool = True
|
|
99
|
+
fail_on: str = "info"
|
|
100
|
+
output: str = "human"
|
|
101
|
+
jobs: int = 0 # 0 = auto
|
|
102
|
+
baseline: str | None = None
|
|
103
|
+
rule_paths: list[str] = field(default_factory=list)
|
|
104
|
+
enable: list[str] = field(default_factory=list)
|
|
105
|
+
disable: list[str] = field(default_factory=list)
|
|
106
|
+
severity_remap: dict[str, str] = field(default_factory=dict)
|
|
107
|
+
rules: dict[str, RuleSettings] = field(default_factory=dict)
|
|
108
|
+
overrides: list[RuleOverride] = field(default_factory=list)
|
|
109
|
+
#: Directory the config was loaded from (for resolving relative paths).
|
|
110
|
+
source_dir: str | None = None
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_dict(cls, data: dict[str, Any]) -> Config:
|
|
114
|
+
out = cls()
|
|
115
|
+
known = {f.name for f in fields(cls)} - {
|
|
116
|
+
"rules",
|
|
117
|
+
"overrides",
|
|
118
|
+
"source_dir",
|
|
119
|
+
"enable",
|
|
120
|
+
"disable",
|
|
121
|
+
}
|
|
122
|
+
for key, value in data.items():
|
|
123
|
+
if key in ("include", "exclude", "languages", "rule_paths"):
|
|
124
|
+
lst = _expect_str_list(value, key)
|
|
125
|
+
if key == "languages":
|
|
126
|
+
bad = set(lst) - _LANGUAGES
|
|
127
|
+
if bad:
|
|
128
|
+
raise SchemaError(f"unknown language(s): {sorted(bad)}")
|
|
129
|
+
setattr(out, key, lst)
|
|
130
|
+
elif key == "gitignore":
|
|
131
|
+
out.gitignore = _expect_bool(value, key)
|
|
132
|
+
elif key == "fail_on":
|
|
133
|
+
if value != "never":
|
|
134
|
+
_expect_severity(value, key)
|
|
135
|
+
out.fail_on = str(value)
|
|
136
|
+
elif key == "output":
|
|
137
|
+
if value not in {"human", "json", "json-legacy", "sarif"}:
|
|
138
|
+
raise SchemaError(f"'output' must be a valid format, got {value!r}")
|
|
139
|
+
out.output = str(value)
|
|
140
|
+
elif key == "jobs":
|
|
141
|
+
if not isinstance(value, int) or value < 0:
|
|
142
|
+
raise SchemaError("'jobs' must be a non-negative integer (0 = auto)")
|
|
143
|
+
out.jobs = value
|
|
144
|
+
elif key == "baseline":
|
|
145
|
+
if not isinstance(value, str):
|
|
146
|
+
raise SchemaError("'baseline' must be a string path")
|
|
147
|
+
out.baseline = value
|
|
148
|
+
elif key == "severity_remap":
|
|
149
|
+
if not isinstance(value, dict):
|
|
150
|
+
raise SchemaError("'severity_remap' must be a table of rule-id -> severity")
|
|
151
|
+
for rid, sev in value.items():
|
|
152
|
+
_expect_severity(sev, f"severity_remap.{rid}")
|
|
153
|
+
out.severity_remap = dict(value)
|
|
154
|
+
elif key == "rules":
|
|
155
|
+
if not isinstance(value, dict):
|
|
156
|
+
raise SchemaError("'[rules]' must be a table")
|
|
157
|
+
for rid, rdata in value.items():
|
|
158
|
+
if rid == "enable":
|
|
159
|
+
out.enable = _expect_str_list(rdata, "rules.enable")
|
|
160
|
+
elif rid == "disable":
|
|
161
|
+
out.disable = _expect_str_list(rdata, "rules.disable")
|
|
162
|
+
elif isinstance(rdata, dict):
|
|
163
|
+
out.rules[rid] = RuleSettings.from_dict(rid, rdata)
|
|
164
|
+
else:
|
|
165
|
+
raise SchemaError(f"'[rules.{rid}]' must be a table")
|
|
166
|
+
elif key == "overrides":
|
|
167
|
+
if not isinstance(value, list):
|
|
168
|
+
raise SchemaError("'overrides' must be an array of tables")
|
|
169
|
+
out.overrides = [RuleOverride.from_dict(o) for o in value]
|
|
170
|
+
elif key not in known:
|
|
171
|
+
raise SchemaError(f"unknown key {key!r}")
|
|
172
|
+
return out
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""CodeGuard rule engine."""
|
|
3
|
+
|
|
4
|
+
from .context import RuleContext
|
|
5
|
+
from .finding import Category, Finding, Fix, Location, Severity, TextEdit, Triage
|
|
6
|
+
from .registry import REGISTRY, RuleRegistry
|
|
7
|
+
from .rule import AstRule, Rule, TreeSitterRule
|
|
8
|
+
from .runner import AnalysisRunner
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"REGISTRY",
|
|
12
|
+
"AnalysisRunner",
|
|
13
|
+
"AstRule",
|
|
14
|
+
"Category",
|
|
15
|
+
"Finding",
|
|
16
|
+
"Fix",
|
|
17
|
+
"Location",
|
|
18
|
+
"Rule",
|
|
19
|
+
"RuleContext",
|
|
20
|
+
"RuleRegistry",
|
|
21
|
+
"Severity",
|
|
22
|
+
"TextEdit",
|
|
23
|
+
"TreeSitterRule",
|
|
24
|
+
"Triage",
|
|
25
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Baseline files -- freeze the findings that exist today so CI only fails on new ones.
|
|
3
|
+
|
|
4
|
+
A baseline is a JSON file of finding fingerprints. ``scan --baseline path`` marks
|
|
5
|
+
any finding whose fingerprint is in the file as ``baselined`` (excluded from the
|
|
6
|
+
exit code, still shown). ``codeguard baseline create / update / prune`` manages
|
|
7
|
+
the file.
|
|
8
|
+
|
|
9
|
+
Fingerprints (see :mod:`codeguard.engine.fingerprint`) are stable across
|
|
10
|
+
reformatting and line moves, so a baselined finding stays matched as the file
|
|
11
|
+
around it changes -- and a genuinely new problem is not.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .finding import Finding
|
|
22
|
+
|
|
23
|
+
SCHEMA_VERSION = 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _now() -> str:
|
|
27
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Baseline:
|
|
32
|
+
"""The contents of a baseline file."""
|
|
33
|
+
|
|
34
|
+
fingerprints: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
35
|
+
tool_version: str = "0.0.0"
|
|
36
|
+
created: str = field(default_factory=_now)
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------------
|
|
39
|
+
# Construction
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_findings(cls, findings: list[Finding], *, tool_version: str = "0.0.0") -> Baseline:
|
|
44
|
+
b = cls(tool_version=tool_version)
|
|
45
|
+
b._add(findings)
|
|
46
|
+
return b
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def load(cls, path: Path) -> Baseline:
|
|
50
|
+
"""Load a baseline file. Raises ``ValueError`` on a malformed file."""
|
|
51
|
+
try:
|
|
52
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
53
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
54
|
+
raise ValueError(f"{path}: cannot read baseline: {exc}") from exc
|
|
55
|
+
if not isinstance(data, dict) or "fingerprints" not in data:
|
|
56
|
+
raise ValueError(f"{path}: not a CodeGuard baseline file")
|
|
57
|
+
fps = data.get("fingerprints", {})
|
|
58
|
+
if not isinstance(fps, dict):
|
|
59
|
+
raise ValueError(f"{path}: 'fingerprints' must be an object")
|
|
60
|
+
return cls(
|
|
61
|
+
fingerprints={str(k): dict(v) for k, v in fps.items()},
|
|
62
|
+
tool_version=str(data.get("tool_version", "0.0.0")),
|
|
63
|
+
created=str(data.get("created", _now())),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
# Query / mutate
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def __contains__(self, fingerprint: object) -> bool:
|
|
71
|
+
return fingerprint in self.fingerprints
|
|
72
|
+
|
|
73
|
+
def __len__(self) -> int:
|
|
74
|
+
return len(self.fingerprints)
|
|
75
|
+
|
|
76
|
+
def _add(self, findings: list[Finding]) -> None:
|
|
77
|
+
for f in findings:
|
|
78
|
+
if not f.fingerprint or f.fingerprint in self.fingerprints:
|
|
79
|
+
continue
|
|
80
|
+
self.fingerprints[f.fingerprint] = {
|
|
81
|
+
"rule_id": f.rule_id,
|
|
82
|
+
"file": f.location.file,
|
|
83
|
+
"first_seen": _now(),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
def updated_with(self, findings: list[Finding]) -> Baseline:
|
|
87
|
+
"""Return a copy with any new findings added (existing ``first_seen`` kept)."""
|
|
88
|
+
out = Baseline(
|
|
89
|
+
fingerprints=dict(self.fingerprints),
|
|
90
|
+
tool_version=self.tool_version,
|
|
91
|
+
created=self.created,
|
|
92
|
+
)
|
|
93
|
+
out._add(findings)
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
def pruned(self, live_fingerprints: set[str]) -> Baseline:
|
|
97
|
+
"""Return a copy with entries that no longer correspond to a finding removed."""
|
|
98
|
+
return Baseline(
|
|
99
|
+
fingerprints={k: v for k, v in self.fingerprints.items() if k in live_fingerprints},
|
|
100
|
+
tool_version=self.tool_version,
|
|
101
|
+
created=self.created,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
# Serialise
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> dict[str, object]:
|
|
109
|
+
return {
|
|
110
|
+
"version": SCHEMA_VERSION,
|
|
111
|
+
"created": self.created,
|
|
112
|
+
"tool_version": self.tool_version,
|
|
113
|
+
"fingerprints": dict(sorted(self.fingerprints.items())),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
def save(self, path: Path) -> None:
|
|
117
|
+
path.write_text(json.dumps(self.to_dict(), indent=2) + "\n", encoding="utf-8")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def apply_baseline(findings: list[Finding], baseline: Baseline) -> list[Finding]:
|
|
121
|
+
"""Return *findings* with those present in *baseline* marked ``baselined``."""
|
|
122
|
+
return [f.as_baselined() if f.fingerprint in baseline else f for f in findings]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""``RuleContext`` -- everything a rule needs to analyse one file."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from codeguard.lang.base import Language, LanguageSupport
|
|
10
|
+
from codeguard.lang.node import SourceNode
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class RuleContext:
|
|
15
|
+
"""Per-file analysis context handed to :meth:`~codeguard.engine.rule.Rule.analyze`.
|
|
16
|
+
|
|
17
|
+
Attributes
|
|
18
|
+
----------
|
|
19
|
+
filename:
|
|
20
|
+
Path (or ``"<stdin>"``) the source came from.
|
|
21
|
+
source:
|
|
22
|
+
Raw source text.
|
|
23
|
+
language:
|
|
24
|
+
The detected :class:`~codeguard.lang.base.Language`.
|
|
25
|
+
lang:
|
|
26
|
+
The parser backend for *language* (for structural ``query()`` access).
|
|
27
|
+
root:
|
|
28
|
+
The parsed tree as a uniform :class:`~codeguard.lang.node.SourceNode`.
|
|
29
|
+
dataflow:
|
|
30
|
+
Reserved for the intraprocedural taint pass (a later milestone); always
|
|
31
|
+
``None`` today.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
filename: str
|
|
35
|
+
source: str
|
|
36
|
+
language: Language
|
|
37
|
+
lang: LanguageSupport
|
|
38
|
+
root: SourceNode
|
|
39
|
+
dataflow: None = None
|
|
40
|
+
_lines: list[str] | None = field(default=None, repr=False, compare=False)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def lines(self) -> list[str]:
|
|
44
|
+
"""Source split into lines, cached."""
|
|
45
|
+
if self._lines is None:
|
|
46
|
+
self._lines = self.source.splitlines()
|
|
47
|
+
return self._lines
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def python_ast(self) -> ast.Module:
|
|
51
|
+
"""The native :class:`ast.Module` for a Python file.
|
|
52
|
+
|
|
53
|
+
Raises
|
|
54
|
+
------
|
|
55
|
+
TypeError
|
|
56
|
+
If this context is not for Python source.
|
|
57
|
+
"""
|
|
58
|
+
node = self.root.native
|
|
59
|
+
if not isinstance(node, ast.Module):
|
|
60
|
+
raise TypeError(f"python_ast requested for a {self.language.value} context")
|
|
61
|
+
return node
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""File discovery -- decide which files a scan should look at.
|
|
3
|
+
|
|
4
|
+
Rules never touch the filesystem; the runner asks this module for a list of
|
|
5
|
+
files. Discovery honours ``.gitignore`` (repo root), a built-in skip list for
|
|
6
|
+
directories that never contain first-party source, and user ``--include`` /
|
|
7
|
+
``--exclude`` globs. Directory symlinks are not followed and every file is
|
|
8
|
+
de-duplicated by real path.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from collections.abc import Iterable, Sequence
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import pathspec
|
|
20
|
+
|
|
21
|
+
from codeguard.lang.registry import language_for_path
|
|
22
|
+
|
|
23
|
+
#: Directory names that never hold first-party source. Pruned before globbing.
|
|
24
|
+
DEFAULT_SKIP_DIRS: frozenset[str] = frozenset(
|
|
25
|
+
{
|
|
26
|
+
".git",
|
|
27
|
+
".hg",
|
|
28
|
+
".svn",
|
|
29
|
+
".bzr",
|
|
30
|
+
"node_modules",
|
|
31
|
+
".venv",
|
|
32
|
+
"venv",
|
|
33
|
+
"env",
|
|
34
|
+
".env",
|
|
35
|
+
"__pycache__",
|
|
36
|
+
".mypy_cache",
|
|
37
|
+
".ruff_cache",
|
|
38
|
+
".pytest_cache",
|
|
39
|
+
".tox",
|
|
40
|
+
".nox",
|
|
41
|
+
".eggs",
|
|
42
|
+
"dist",
|
|
43
|
+
"build",
|
|
44
|
+
"site-packages",
|
|
45
|
+
".cache",
|
|
46
|
+
".idea",
|
|
47
|
+
".vscode",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
#: Filename globs skipped by default (generated / vendored artefacts).
|
|
52
|
+
DEFAULT_SKIP_FILES: tuple[str, ...] = ("*.min.js", "*.bundle.js", "*.d.ts")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class DiscoveryConfig:
|
|
57
|
+
"""Inputs that shape a discovery pass."""
|
|
58
|
+
|
|
59
|
+
include: list[str] = field(default_factory=list)
|
|
60
|
+
exclude: list[str] = field(default_factory=list)
|
|
61
|
+
respect_gitignore: bool = True
|
|
62
|
+
skip_dirs: frozenset[str] = DEFAULT_SKIP_DIRS
|
|
63
|
+
skip_files: tuple[str, ...] = DEFAULT_SKIP_FILES
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _load_gitignore(root: Path) -> pathspec.PathSpec[Any] | None:
|
|
67
|
+
patterns: list[str] = []
|
|
68
|
+
for rel in (".gitignore", ".git/info/exclude"):
|
|
69
|
+
p = root / rel
|
|
70
|
+
if p.is_file():
|
|
71
|
+
patterns.extend(p.read_text(encoding="utf-8", errors="replace").splitlines())
|
|
72
|
+
if not patterns:
|
|
73
|
+
return None
|
|
74
|
+
return pathspec.PathSpec.from_lines("gitignore", patterns)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _spec(patterns: Iterable[str]) -> pathspec.PathSpec[Any] | None:
|
|
78
|
+
patterns = [p for p in patterns if p.strip()]
|
|
79
|
+
return pathspec.PathSpec.from_lines("gitignore", patterns) if patterns else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def discover(
|
|
83
|
+
paths: Sequence[str | Path],
|
|
84
|
+
config: DiscoveryConfig | None = None,
|
|
85
|
+
*,
|
|
86
|
+
root: str | Path | None = None,
|
|
87
|
+
) -> list[Path]:
|
|
88
|
+
"""Return the sorted, de-duplicated list of files to scan.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
paths:
|
|
93
|
+
Files or directories given on the command line.
|
|
94
|
+
config:
|
|
95
|
+
Discovery settings. Defaults are used when omitted.
|
|
96
|
+
root:
|
|
97
|
+
Base directory for ``.gitignore`` lookup and relative-path matching.
|
|
98
|
+
Defaults to the current working directory.
|
|
99
|
+
"""
|
|
100
|
+
cfg = config or DiscoveryConfig()
|
|
101
|
+
base = Path(root or os.getcwd()).resolve()
|
|
102
|
+
|
|
103
|
+
gitignore = _load_gitignore(base) if cfg.respect_gitignore else None
|
|
104
|
+
include_spec = _spec(cfg.include)
|
|
105
|
+
exclude_spec = _spec([*cfg.exclude, *cfg.skip_files])
|
|
106
|
+
|
|
107
|
+
seen: set[Path] = set()
|
|
108
|
+
out: list[Path] = []
|
|
109
|
+
|
|
110
|
+
def rel(p: Path) -> str:
|
|
111
|
+
try:
|
|
112
|
+
return p.resolve().relative_to(base).as_posix()
|
|
113
|
+
except ValueError:
|
|
114
|
+
return p.name
|
|
115
|
+
|
|
116
|
+
def want(p: Path) -> bool:
|
|
117
|
+
if language_for_path(p) is None:
|
|
118
|
+
return False
|
|
119
|
+
r = rel(p)
|
|
120
|
+
if exclude_spec and exclude_spec.match_file(r):
|
|
121
|
+
return False
|
|
122
|
+
if gitignore and gitignore.match_file(r):
|
|
123
|
+
return False
|
|
124
|
+
if include_spec and not include_spec.match_file(r):
|
|
125
|
+
return False
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
def add(p: Path) -> None:
|
|
129
|
+
real = p.resolve()
|
|
130
|
+
if real in seen:
|
|
131
|
+
return
|
|
132
|
+
seen.add(real)
|
|
133
|
+
out.append(p)
|
|
134
|
+
|
|
135
|
+
for raw in paths:
|
|
136
|
+
start = Path(raw)
|
|
137
|
+
if start.is_file():
|
|
138
|
+
# An explicitly named file bypasses include/exclude/gitignore
|
|
139
|
+
# (the user asked for it) but still must be a supported language.
|
|
140
|
+
if language_for_path(start) is not None:
|
|
141
|
+
add(start)
|
|
142
|
+
continue
|
|
143
|
+
if not start.is_dir():
|
|
144
|
+
continue
|
|
145
|
+
for dirpath, dirnames, filenames in os.walk(start, followlinks=False):
|
|
146
|
+
d = Path(dirpath)
|
|
147
|
+
dirnames[:] = [
|
|
148
|
+
name
|
|
149
|
+
for name in dirnames
|
|
150
|
+
if name not in cfg.skip_dirs
|
|
151
|
+
and not (d / name).is_symlink()
|
|
152
|
+
and not (gitignore and gitignore.match_file(rel(d / name) + "/"))
|
|
153
|
+
]
|
|
154
|
+
for name in filenames:
|
|
155
|
+
fp = d / name
|
|
156
|
+
if not fp.is_symlink() and want(fp):
|
|
157
|
+
add(fp)
|
|
158
|
+
|
|
159
|
+
out.sort(key=lambda p: p.as_posix())
|
|
160
|
+
return out
|