codeintely-cli 0.1.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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,178 @@
1
+ """Auto-installs gitleaks/osv-scanner/trivy on first use, so a plain
2
+ `pip install codeintely-cli` gets full coverage (secrets, dependency CVEs,
3
+ IaC misconfig) without a user separately installing three more tools by
4
+ hand — the actual point of "include gitleaks/osv-scanner/trivy" rather
5
+ than just reporting them as skipped.
6
+
7
+ An existing PATH install is always preferred over downloading a second
8
+ copy (shutil.which() checked first) — this only downloads when nothing
9
+ is already available. Downloaded binaries are cached under
10
+ ~/.codeintely/bin/ and reused on every later run; only the very first
11
+ `codeintely scan` after install pays the download cost.
12
+
13
+ Versions are pinned to the exact same releases backend/Dockerfile uses
14
+ in production (gitleaks v8.21.2, osv-scanner v2.4.0, trivy v0.74.0) —
15
+ same detection behavior locally as the hosted GitHub App, not
16
+ independently-drifting versions. Asset names/archive layouts below were
17
+ confirmed against the real GitHub releases for these exact versions, not
18
+ assumed from a naming convention.
19
+
20
+ osv-scanner is pinned to v2.4.0 specifically, not the also-real v1.9.2:
21
+ confirmed live that scanners.py's `scan source ...` invocation has no
22
+ `source` subcommand in v1.9.2 (real syntax there is just `scan
23
+ [directory]`) — it silently produces zero findings instead of erroring,
24
+ since the failed run leaves the pre-created output file empty and the
25
+ caller reads "empty file" as "clean scan." v2.4.0 is what this was
26
+ actually written and tested against.
27
+ """
28
+
29
+ import io
30
+ import logging
31
+ import platform
32
+ import shutil
33
+ import stat
34
+ import sys
35
+ import tarfile
36
+ import urllib.error
37
+ import urllib.request
38
+ import zipfile
39
+ from pathlib import Path
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ CACHE_DIR = Path.home() / ".codeintely" / "bin"
44
+ DOWNLOAD_TIMEOUT_SECONDS = 180 # trivy's Windows build alone is ~170MB
45
+
46
+ GITLEAKS_VERSION = "8.21.2"
47
+ OSV_SCANNER_VERSION = "2.4.0"
48
+ TRIVY_VERSION = "0.74.0"
49
+
50
+
51
+ def _os_arch() -> tuple[str | None, str | None]:
52
+ system = platform.system() # "Windows" | "Linux" | "Darwin"
53
+ machine = platform.machine().lower()
54
+ if machine in ("amd64", "x86_64"):
55
+ arch = "x64"
56
+ elif machine in ("arm64", "aarch64"):
57
+ arch = "arm64"
58
+ else:
59
+ arch = None # a real, if rare, unsupported case (e.g. 32-bit) — no asset built for it
60
+ return (system if system in ("Windows", "Linux", "Darwin") else None), arch
61
+
62
+
63
+ def _gitleaks_asset(system: str, arch: str) -> str | None:
64
+ os_name = {"Windows": "windows", "Linux": "linux", "Darwin": "darwin"}[system]
65
+ # Confirmed live: this exact pinned version has no windows_arm64 build
66
+ # (later releases added one) — a real, narrow gap, not a bug.
67
+ if system == "Windows" and arch == "arm64":
68
+ return None
69
+ ext = "zip" if system == "Windows" else "tar.gz"
70
+ return f"gitleaks_{GITLEAKS_VERSION}_{os_name}_{arch}.{ext}"
71
+
72
+
73
+ def _osv_scanner_asset(system: str, arch: str) -> str | None:
74
+ os_name = {"Windows": "windows", "Linux": "linux", "Darwin": "darwin"}[system]
75
+ arch_name = "amd64" if arch == "x64" else "arm64"
76
+ ext = ".exe" if system == "Windows" else ""
77
+ return f"osv-scanner_{os_name}_{arch_name}{ext}"
78
+
79
+
80
+ def _trivy_asset(system: str, arch: str) -> str | None:
81
+ # Confirmed live: real, inconsistent-but-real casing per OS — "Linux"
82
+ # and "macOS" are capitalized, "windows" isn't.
83
+ os_name = {"Windows": "windows", "Linux": "Linux", "Darwin": "macOS"}[system]
84
+ arch_name = "64bit" if arch == "x64" else "ARM64"
85
+ ext = "zip" if system == "Windows" else "tar.gz"
86
+ return f"trivy_{TRIVY_VERSION}_{os_name}-{arch_name}.{ext}"
87
+
88
+
89
+ # {tool_name: (release_base_url, asset_fn, is_raw_binary)} — is_raw_binary
90
+ # distinguishes osv-scanner (the GitHub asset itself IS the binary, no
91
+ # archive) from gitleaks/trivy (a .zip/.tar.gz to extract from).
92
+ _TOOLS = {
93
+ "gitleaks": (
94
+ f"https://github.com/gitleaks/gitleaks/releases/download/v{GITLEAKS_VERSION}",
95
+ _gitleaks_asset, False,
96
+ ),
97
+ "osv-scanner": (
98
+ f"https://github.com/google/osv-scanner/releases/download/v{OSV_SCANNER_VERSION}",
99
+ _osv_scanner_asset, True,
100
+ ),
101
+ "trivy": (
102
+ f"https://github.com/aquasecurity/trivy/releases/download/v{TRIVY_VERSION}",
103
+ _trivy_asset, False,
104
+ ),
105
+ }
106
+
107
+
108
+ def _download(url: str) -> bytes | None:
109
+ try:
110
+ with urllib.request.urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECONDS) as resp:
111
+ return resp.read()
112
+ except (urllib.error.URLError, OSError, TimeoutError):
113
+ return None
114
+
115
+
116
+ def _extract_binary(archive_bytes: bytes, asset_name: str, member_name: str, dest_path: Path) -> bool:
117
+ try:
118
+ if asset_name.endswith(".zip"):
119
+ with zipfile.ZipFile(io.BytesIO(archive_bytes)) as zf:
120
+ with zf.open(member_name) as src, open(dest_path, "wb") as dst:
121
+ shutil.copyfileobj(src, dst)
122
+ elif asset_name.endswith(".tar.gz"):
123
+ with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as tf:
124
+ member = tf.getmember(member_name)
125
+ extracted = tf.extractfile(member)
126
+ if extracted is None:
127
+ return False
128
+ with extracted, open(dest_path, "wb") as dst:
129
+ shutil.copyfileobj(extracted, dst)
130
+ else:
131
+ # osv-scanner: the downloaded asset already IS the binary.
132
+ dest_path.write_bytes(archive_bytes)
133
+ except (zipfile.BadZipFile, tarfile.TarError, KeyError, OSError):
134
+ return False
135
+ return True
136
+
137
+
138
+ def ensure_binary(name: str) -> str | None:
139
+ """Returns a usable path to `name` — an existing PATH install if
140
+ found, otherwise auto-downloads the pinned version into
141
+ ~/.codeintely/bin/ (reused on every later call/run). Returns None
142
+ only when neither a PATH install nor a download succeeds
143
+ (unsupported platform/architecture, no network, GitHub unreachable)
144
+ — callers treat that the same as "not installed."
145
+ """
146
+ existing = shutil.which(name)
147
+ if existing:
148
+ return existing
149
+
150
+ exe_name = f"{name}.exe" if sys.platform == "win32" else name
151
+ cached = CACHE_DIR / exe_name
152
+ if cached.is_file():
153
+ return str(cached)
154
+
155
+ system, arch = _os_arch()
156
+ if system is None or arch is None:
157
+ return None
158
+
159
+ base_url, asset_fn, is_raw_binary = _TOOLS[name]
160
+ asset = asset_fn(system, arch)
161
+ if asset is None:
162
+ return None
163
+
164
+ print(f"codeintely: downloading {name} (one-time, cached under {CACHE_DIR})...", file=sys.stderr)
165
+ data = _download(f"{base_url}/{asset}")
166
+ if data is None:
167
+ logger.error("Could not download %s from %s/%s", name, base_url, asset)
168
+ return None
169
+
170
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
171
+ member_name = exe_name if not is_raw_binary else asset
172
+ if not _extract_binary(data, asset, member_name, cached):
173
+ logger.error("Downloaded %s but couldn't extract/save it", name)
174
+ return None
175
+
176
+ if sys.platform != "win32":
177
+ cached.chmod(cached.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
178
+ return str(cached)
codeintely_cli/main.py ADDED
@@ -0,0 +1,76 @@
1
+ """`codeintely` CLI entry point (P2d). See pyproject.toml's [project.scripts]."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from . import __version__, output, scanners
8
+
9
+ # Windows consoles commonly default stdout to a legacy codepage (cp1252)
10
+ # that can't encode the ✓ in a clean report or non-ASCII bytes a scanner
11
+ # might legitimately emit (a file path, a finding message) — confirmed
12
+ # live: a bare `print()` of this module's own "No findings" line crashed
13
+ # with UnicodeEncodeError before this reconfigure. errors="replace" over a
14
+ # stricter mode since a scan report should never crash on output — a
15
+ # mangled character is a much smaller problem than the tool being unusable
16
+ # on a stock Windows terminal.
17
+ if hasattr(sys.stdout, "reconfigure"):
18
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
19
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
20
+
21
+ SEVERITY_ORDER = output.SEVERITY_ORDER
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(
26
+ prog="codeintely",
27
+ description="CodeIntely local security scanner — the same custom rule pack the GitHub App uses, run locally.",
28
+ )
29
+ parser.add_argument("--version", action="version", version=f"codeintely-cli {__version__}")
30
+ subparsers = parser.add_subparsers(dest="command", required=True)
31
+
32
+ scan = subparsers.add_parser("scan", help="Scan a local directory (default: current directory).")
33
+ scan.add_argument("path", nargs="?", default=".", help="Directory to scan (default: .)")
34
+ scan.add_argument("--format", choices=["text", "json"], default="text", help="Output format (default: text)")
35
+ scan.add_argument(
36
+ "--severity-threshold", choices=SEVERITY_ORDER, default="HIGH",
37
+ help="Exit non-zero only when a finding at or above this severity exists (default: HIGH) — matches "
38
+ "the same bar the GitHub App's PR check run uses.",
39
+ )
40
+ scan.add_argument("--no-color", action="store_true", help="Disable ANSI color in text output.")
41
+ return parser
42
+
43
+
44
+ def _exceeds_threshold(findings: list[dict], threshold: str) -> bool:
45
+ threshold_index = SEVERITY_ORDER.index(threshold)
46
+ return any(
47
+ SEVERITY_ORDER.index(f["severity"]) <= threshold_index
48
+ for f in findings if f["severity"] in SEVERITY_ORDER
49
+ )
50
+
51
+
52
+ def run_scan(args) -> int:
53
+ target_dir = Path(args.path).resolve()
54
+ if not target_dir.is_dir():
55
+ print(f"error: {target_dir} is not a directory", file=sys.stderr)
56
+ return 2
57
+
58
+ findings, skipped = scanners.run_all(target_dir)
59
+
60
+ if args.format == "json":
61
+ print(output.render_json(findings, skipped, target_dir))
62
+ else:
63
+ print(output.render_text(findings, skipped, target_dir, use_color=not args.no_color))
64
+
65
+ return 1 if _exceeds_threshold(findings, args.severity_threshold) else 0
66
+
67
+
68
+ def main() -> None:
69
+ parser = build_parser()
70
+ args = parser.parse_args()
71
+ if args.command == "scan":
72
+ sys.exit(run_scan(args))
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()
@@ -0,0 +1,63 @@
1
+ """Text/JSON reporting for the CLI. Stdlib-only ANSI coloring — no `rich`/
2
+ `click` dependency, keeping `pip install codeintely-cli` lean.
3
+ """
4
+
5
+ import json
6
+ import sys
7
+
8
+ SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW"]
9
+ _SEVERITY_COLOR = {"CRITICAL": "\033[1;31m", "HIGH": "\033[31m", "MEDIUM": "\033[33m", "LOW": "\033[90m"}
10
+ _RESET = "\033[0m"
11
+ _BOLD = "\033[1m"
12
+
13
+
14
+ def _supports_color() -> bool:
15
+ # Modern Windows terminals (10+) handle ANSI codes fine when attached
16
+ # to a real console; the only case to avoid coloring is output being
17
+ # redirected/piped (a file, `| less`, CI log capture), where isatty()
18
+ # is False on every platform.
19
+ return sys.stdout.isatty()
20
+
21
+
22
+ def render_text(findings: list[dict], skipped: list[str], target_dir, use_color: bool) -> str:
23
+ lines = []
24
+ color = use_color and _supports_color()
25
+
26
+ def c(code, text):
27
+ return f"{code}{text}{_RESET}" if color else text
28
+
29
+ lines.append(c(_BOLD, f"codeintely scan — {target_dir}"))
30
+ lines.append("")
31
+
32
+ if skipped:
33
+ lines.append(f"Skipped (not installed): {', '.join(skipped)} — install for fuller coverage.")
34
+ lines.append("")
35
+
36
+ if not findings:
37
+ lines.append(c("\033[32m", "No findings. ✓"))
38
+ return "\n".join(lines)
39
+
40
+ findings_sorted = sorted(
41
+ findings, key=lambda f: SEVERITY_ORDER.index(f["severity"]) if f["severity"] in SEVERITY_ORDER else 99
42
+ )
43
+ for f in findings_sorted:
44
+ sev = f["severity"]
45
+ sev_label = c(_SEVERITY_COLOR.get(sev, ""), f"[{sev}]")
46
+ location = f"{f['file_path']}:{f['line_number']}" if f.get("line_number") else f["file_path"]
47
+ lines.append(f"{sev_label} {c(_BOLD, f['rule_id'])} ({f['scanner']}) — {location}")
48
+ lines.append(f" {f['message']}")
49
+ lines.append("")
50
+
51
+ counts = {sev: sum(1 for f in findings if f["severity"] == sev) for sev in SEVERITY_ORDER}
52
+ summary = ", ".join(f"{counts[s]} {s}" for s in SEVERITY_ORDER if counts[s])
53
+ lines.append(c(_BOLD, f"{len(findings)} finding(s): {summary}"))
54
+ return "\n".join(lines)
55
+
56
+
57
+ def render_json(findings: list[dict], skipped: list[str], target_dir) -> str:
58
+ return json.dumps({
59
+ "target": str(target_dir),
60
+ "skipped_scanners": skipped,
61
+ "finding_count": len(findings),
62
+ "findings": findings,
63
+ }, indent=2)
@@ -0,0 +1,220 @@
1
+ rules:
2
+ # --- SSRF, command injection, insecure deserialization, path traversal ---
3
+ #
4
+ # These four use Semgrep's taint-mode (mode: taint), not used anywhere
5
+ # else in this rule pack until now — evaluated and validated directly
6
+ # (real semgrep runs against real tainted/untainted fixtures, not just
7
+ # read the docs) per PLANNING.md's own prerequisite note that plain
8
+ # pattern-matching alone over-flags this vulnerability class (e.g. ANY
9
+ # `requests.get($URL)` call, tainted or not) while taint-mode only fires
10
+ # when a source (request input) actually reaches a sink (the dangerous
11
+ # call), which is the real question for all four of these. Confirmed
12
+ # empirically that Semgrep's taint engine correctly tracks propagation
13
+ # through os.path.join(), f-string building, and variable reassignment —
14
+ # not just direct source-to-sink calls.
15
+ #
16
+ # The same `pattern-sources` list is repeated across all four rules
17
+ # rather than factored into a YAML anchor/alias — semgrep's own rule
18
+ # parser rejects `pattern-sources: *anchor` ("Expected a list for
19
+ # pattern-sources") even though it's valid YAML that PyYAML resolves
20
+ # fine, confirmed by testing `semgrep --validate` directly.
21
+
22
+ - id: python-ssrf-tainted-url
23
+ mode: taint
24
+ languages: [python]
25
+ severity: ERROR
26
+ message: >-
27
+ A URL built from request input reaches an outbound HTTP call with no
28
+ visible allowlist/validation in between. If an attacker controls this
29
+ URL, they can make this server issue requests to internal-only hosts
30
+ (cloud metadata endpoints, internal admin APIs, localhost services) —
31
+ Server-Side Request Forgery. Validate the URL against an explicit
32
+ allowlist of permitted hosts/schemes before making the request, not
33
+ just that it parses as a URL.
34
+ metadata:
35
+ owasp_category: "A10:2021 - Server-Side Request Forgery"
36
+ cwe: "CWE-918"
37
+ internal_severity: CRITICAL
38
+ pattern-sources:
39
+ - pattern: request.GET.get(...)
40
+ - pattern: request.GET[...]
41
+ - pattern: request.POST.get(...)
42
+ - pattern: request.POST[...]
43
+ - pattern: request.data.get(...)
44
+ - pattern: request.data[...]
45
+ - pattern: request.query_params.get(...)
46
+ pattern-sinks:
47
+ - pattern: requests.get(...)
48
+ - pattern: requests.post(...)
49
+ - pattern: requests.put(...)
50
+ - pattern: requests.delete(...)
51
+ - pattern: requests.head(...)
52
+ - pattern: requests.patch(...)
53
+ - pattern: requests.request(...)
54
+ - pattern: urllib.request.urlopen(...)
55
+ - pattern: httpx.get(...)
56
+ - pattern: httpx.post(...)
57
+
58
+ - id: python-command-injection-shell-true
59
+ mode: taint
60
+ languages: [python]
61
+ severity: ERROR
62
+ message: >-
63
+ Request input reaches a shell command execution call (`os.system` —
64
+ always shell-interpreted — or `subprocess.*` with `shell=True`). With
65
+ `shell=True`, the string is parsed by a real shell, so shell
66
+ metacharacters in the tainted value (`;`, `|`, `&&`, backticks) let an
67
+ attacker run arbitrary commands, not just influence one argument.
68
+ Pass a list of arguments to `subprocess.run`/`.call`/`.Popen` with
69
+ `shell=False` (the default) instead — a list's elements are never
70
+ shell-interpreted, so this class of injection isn't possible even
71
+ with attacker-controlled values in it.
72
+ metadata:
73
+ owasp_category: "A03:2021 - Injection"
74
+ cwe: "CWE-78"
75
+ internal_severity: CRITICAL
76
+ pattern-sources:
77
+ - pattern: request.GET.get(...)
78
+ - pattern: request.GET[...]
79
+ - pattern: request.POST.get(...)
80
+ - pattern: request.POST[...]
81
+ - pattern: request.data.get(...)
82
+ - pattern: request.data[...]
83
+ - pattern: request.query_params.get(...)
84
+ pattern-sinks:
85
+ - pattern: os.system(...)
86
+ - patterns:
87
+ - pattern: subprocess.run(...)
88
+ - pattern-either:
89
+ - pattern: subprocess.run(..., shell=True, ...)
90
+ - patterns:
91
+ - pattern: subprocess.call(...)
92
+ - pattern-either:
93
+ - pattern: subprocess.call(..., shell=True, ...)
94
+ - patterns:
95
+ - pattern: subprocess.Popen(...)
96
+ - pattern-either:
97
+ - pattern: subprocess.Popen(..., shell=True, ...)
98
+ - patterns:
99
+ - pattern: subprocess.check_output(...)
100
+ - pattern-either:
101
+ - pattern: subprocess.check_output(..., shell=True, ...)
102
+ - patterns:
103
+ - pattern: subprocess.check_call(...)
104
+ - pattern-either:
105
+ - pattern: subprocess.check_call(..., shell=True, ...)
106
+
107
+ - id: python-pickle-loads-tainted
108
+ mode: taint
109
+ languages: [python]
110
+ severity: ERROR
111
+ message: >-
112
+ Request input reaches `pickle.loads()`/`pickle.load()`. Unpickling is
113
+ not just parsing — it can execute arbitrary code during
114
+ deserialization (via a crafted object's `__reduce__`), so unpickling
115
+ anything an attacker can influence is equivalent to running their
116
+ code. Never unpickle request-derived data; use a safe format (JSON)
117
+ for anything crossing a trust boundary, or cryptographically sign the
118
+ pickled payload and verify the signature before unpickling if pickle
119
+ is genuinely required.
120
+ metadata:
121
+ owasp_category: "A08:2021 - Software and Data Integrity Failures"
122
+ cwe: "CWE-502"
123
+ internal_severity: CRITICAL
124
+ pattern-sources:
125
+ - pattern: request.GET.get(...)
126
+ - pattern: request.GET[...]
127
+ - pattern: request.POST.get(...)
128
+ - pattern: request.POST[...]
129
+ - pattern: request.data.get(...)
130
+ - pattern: request.data[...]
131
+ - pattern: request.query_params.get(...)
132
+ pattern-sinks:
133
+ - pattern: pickle.loads(...)
134
+ - pattern: pickle.load(...)
135
+
136
+ - id: python-path-traversal-tainted
137
+ mode: taint
138
+ languages: [python]
139
+ severity: ERROR
140
+ message: >-
141
+ Request input reaches `open()` (directly or via `os.path.join()`/an
142
+ f-string building the path) with no visible traversal check. A value
143
+ like `../../etc/passwd` or an absolute path lets an attacker read (or
144
+ write, for a write-mode open) any file the process can access, not
145
+ just files under the intended directory. Resolve the final path
146
+ (`os.path.realpath`/`Path.resolve()`) and verify it's still inside
147
+ the intended base directory before opening, or map the input against
148
+ an explicit allowlist of permitted filenames instead of using it in
149
+ the path directly.
150
+ metadata:
151
+ owasp_category: "A01:2021 - Broken Access Control"
152
+ cwe: "CWE-22"
153
+ internal_severity: HIGH
154
+ pattern-sources:
155
+ - pattern: request.GET.get(...)
156
+ - pattern: request.GET[...]
157
+ - pattern: request.POST.get(...)
158
+ - pattern: request.POST[...]
159
+ - pattern: request.data.get(...)
160
+ - pattern: request.data[...]
161
+ - pattern: request.query_params.get(...)
162
+ pattern-sinks:
163
+ - pattern: open(...)
164
+
165
+ # --- Insecure deserialization (non-taint half) ------------------------
166
+ #
167
+ # yaml.load() without a safe Loader is dangerous regardless of where the
168
+ # input came from (PyYAML's default/full Loader can construct arbitrary
169
+ # Python objects from the YAML content itself) — a single dangerous call
170
+ # shape, not a data-flow question, matching the existing style of
171
+ # django-debug-true/sbom.py's setup.py auditor rather than taint-mode.
172
+
173
+ - id: python-insecure-yaml-load
174
+ languages: [python]
175
+ severity: ERROR
176
+ message: >-
177
+ `yaml.load()` without an explicit safe `Loader` can construct
178
+ arbitrary Python objects from the YAML content (PyYAML's default
179
+ full/unsafe loader supports tags like `!!python/object/apply` that
180
+ execute code during loading) — dangerous for ANY YAML content this
181
+ process didn't itself generate, not just request input. Use
182
+ `yaml.safe_load()` or `yaml.load(data, Loader=yaml.SafeLoader)`.
183
+ metadata:
184
+ owasp_category: "A08:2021 - Software and Data Integrity Failures"
185
+ cwe: "CWE-502"
186
+ internal_severity: HIGH
187
+ patterns:
188
+ - pattern: yaml.load(...)
189
+ - pattern-not: yaml.load(..., Loader=yaml.SafeLoader, ...)
190
+ - pattern-not: yaml.load(..., Loader=yaml.CSafeLoader, ...)
191
+
192
+ # --- XXE ----------------------------------------------------------------
193
+ #
194
+ # Scoped to lxml specifically: modern Python's stdlib XML parsers
195
+ # (xml.etree.ElementTree, xml.dom.minidom, xml.sax) have disabled
196
+ # external entity resolution by default since Python 3.7.1 — flagging
197
+ # them would be claiming coverage for a risk that, measured against
198
+ # today's Python, mostly doesn't apply. lxml's etree.XMLParser DOES
199
+ # resolve external entities by default, which is the real, current XXE
200
+ # surface worth flagging — "measure before claiming coverage" cuts both
201
+ # ways: don't pad with a stdlib rule that would mostly be noise either.
202
+
203
+ - id: python-xxe-lxml-parser-resolves-entities
204
+ languages: [python]
205
+ severity: ERROR
206
+ message: >-
207
+ This `lxml.etree.XMLParser` doesn't set `resolve_entities=False` —
208
+ lxml resolves external entities by default, so parsing any XML this
209
+ process didn't itself generate can be used for XXE: reading local
210
+ files via a crafted `<!ENTITY>` declaration, or triggering outbound
211
+ requests from the parsing server (a form of SSRF). Add
212
+ `resolve_entities=False` (and `no_network=True`/`resolve_entities=False`
213
+ together is the safest combination) when constructing the parser.
214
+ metadata:
215
+ owasp_category: "A05:2021 - Security Misconfiguration"
216
+ cwe: "CWE-611"
217
+ internal_severity: HIGH
218
+ patterns:
219
+ - pattern: etree.XMLParser(...)
220
+ - pattern-not: etree.XMLParser(..., resolve_entities=False, ...)