doorcheck 0.1.0a1__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.
- doorcheck/__init__.py +1 -0
- doorcheck/__main__.py +6 -0
- doorcheck/adapters/__init__.py +26 -0
- doorcheck/adapters/base.py +183 -0
- doorcheck/adapters/guarddog.py +173 -0
- doorcheck/adapters/osv.py +86 -0
- doorcheck/adapters/process.py +69 -0
- doorcheck/adapters/registry.py +62 -0
- doorcheck/adapters/semgrep.py +145 -0
- doorcheck/adapters/zizmor.py +84 -0
- doorcheck/capabilities/__init__.py +6 -0
- doorcheck/capabilities/call_arguments.py +40 -0
- doorcheck/capabilities/call_bindings.py +138 -0
- doorcheck/capabilities/calls.py +71 -0
- doorcheck/capabilities/frontend.py +23 -0
- doorcheck/capabilities/http_values.py +78 -0
- doorcheck/capabilities/model.py +82 -0
- doorcheck/capabilities/panels.py +39 -0
- doorcheck/capabilities/python_code.py +704 -0
- doorcheck/capabilities/python_scopes.py +47 -0
- doorcheck/capabilities/scan.py +198 -0
- doorcheck/capabilities/source_text.py +44 -0
- doorcheck/capabilities/static_paths.py +29 -0
- doorcheck/capabilities/webhooks.py +34 -0
- doorcheck/checks.py +57 -0
- doorcheck/cli.py +144 -0
- doorcheck/cli_compare.py +77 -0
- doorcheck/cli_hosts.py +86 -0
- doorcheck/cli_output.py +80 -0
- doorcheck/cli_report.py +55 -0
- doorcheck/cli_scan.py +84 -0
- doorcheck/comparison.py +160 -0
- doorcheck/dependencies.py +131 -0
- doorcheck/errors.py +15 -0
- doorcheck/fetch/__init__.py +12 -0
- doorcheck/fetch/archive.py +182 -0
- doorcheck/fetch/errors.py +13 -0
- doorcheck/fetch/installed.py +137 -0
- doorcheck/fetch/marketplace.py +51 -0
- doorcheck/fetch/transport.py +32 -0
- doorcheck/findings.py +140 -0
- doorcheck/hosts.py +71 -0
- doorcheck/https.py +89 -0
- doorcheck/messages/__init__.py +126 -0
- doorcheck/messages/en.json +248 -0
- doorcheck/messages/glossaries.py +46 -0
- doorcheck/messages/locales.json +326 -0
- doorcheck/messages/localization.py +177 -0
- doorcheck/messages/shipped-locales.json +1 -0
- doorcheck/messages/source-hashes.json +248 -0
- doorcheck/messages/translation-context.json +19 -0
- doorcheck/presentation/__init__.py +1 -0
- doorcheck/presentation/brand/dark_icon.png +0 -0
- doorcheck/presentation/brand/dark_icon@2x.png +0 -0
- doorcheck/presentation/brand/dark_logo.png +0 -0
- doorcheck/presentation/brand/dark_logo@2x.png +0 -0
- doorcheck/presentation/brand/icon.png +0 -0
- doorcheck/presentation/brand/icon@2x.png +0 -0
- doorcheck/presentation/brand/logo.png +0 -0
- doorcheck/presentation/brand/logo.svg +1 -0
- doorcheck/presentation/brand/logo@2x.png +0 -0
- doorcheck/presentation/icons/info.svg +1 -0
- doorcheck/presentation/icons/mark.svg +1 -0
- doorcheck/presentation/icons/none.svg +1 -0
- doorcheck/presentation/icons/notice.svg +1 -0
- doorcheck/presentation/icons/review.svg +1 -0
- doorcheck/presentation/icons/serious.svg +1 -0
- doorcheck/presentation/icons/unavailable.svg +1 -0
- doorcheck/presentation/tokens.css +60 -0
- doorcheck/presentation/tokens.json +87 -0
- doorcheck/py.typed +0 -0
- doorcheck/pypi.py +70 -0
- doorcheck/report.py +206 -0
- doorcheck/requirements.py +105 -0
- doorcheck/revocation.py +240 -0
- doorcheck/rules/__init__.py +6 -0
- doorcheck/rules/file_view.py +367 -0
- doorcheck/rules/helpers.py +253 -0
- doorcheck/rules/path_flow.py +96 -0
- doorcheck/rules/project.py +216 -0
- doorcheck/rules/scan.py +109 -0
- doorcheck/rules/source.py +49 -0
- doorcheck/sarif.py +116 -0
- doorcheck/schemas/comparison-v1.schema.json +59 -0
- doorcheck/schemas/host-summary-v1.schema.json +152 -0
- doorcheck/schemas/report-v1.schema.json +302 -0
- doorcheck/text.py +34 -0
- doorcheck/tree.py +68 -0
- doorcheck-0.1.0a1.dist-info/METADATA +157 -0
- doorcheck-0.1.0a1.dist-info/RECORD +93 -0
- doorcheck-0.1.0a1.dist-info/WHEEL +4 -0
- doorcheck-0.1.0a1.dist-info/entry_points.txt +10 -0
- doorcheck-0.1.0a1.dist-info/licenses/LICENSE +201 -0
doorcheck/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Doorcheck: see what a Home Assistant integration can do before you let it in."""
|
doorcheck/__main__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from doorcheck.adapters.base import (
|
|
2
|
+
Adapter,
|
|
3
|
+
Audience,
|
|
4
|
+
Command,
|
|
5
|
+
Parsed,
|
|
6
|
+
ScanInput,
|
|
7
|
+
ToolError,
|
|
8
|
+
ToolRun,
|
|
9
|
+
prepare,
|
|
10
|
+
run,
|
|
11
|
+
)
|
|
12
|
+
from doorcheck.adapters.registry import Loaded, load
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Adapter",
|
|
16
|
+
"Audience",
|
|
17
|
+
"Command",
|
|
18
|
+
"Loaded",
|
|
19
|
+
"Parsed",
|
|
20
|
+
"ScanInput",
|
|
21
|
+
"ToolError",
|
|
22
|
+
"ToolRun",
|
|
23
|
+
"load",
|
|
24
|
+
"prepare",
|
|
25
|
+
"run",
|
|
26
|
+
]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""The one interface every scanning tool plugs into.
|
|
2
|
+
|
|
3
|
+
An adapter builds a command and reads that command's output; running it
|
|
4
|
+
(timeout, a clean environment, exit codes, the pinned version) is shared here,
|
|
5
|
+
so each tool's quirks stay inside its own adapter.
|
|
6
|
+
|
|
7
|
+
A run always ends in a Check: a tool that is missing, too slow or misbehaving
|
|
8
|
+
gives a failed check instead of an empty list, so a gap never looks clean.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Protocol
|
|
19
|
+
|
|
20
|
+
from doorcheck.adapters.process import OutputLimitError, capture
|
|
21
|
+
from doorcheck.checks import Check, Problem, Status, status_for
|
|
22
|
+
from doorcheck.dependencies import Dependencies, resolve
|
|
23
|
+
from doorcheck.errors import DoorcheckError
|
|
24
|
+
from doorcheck.findings import Finding, param
|
|
25
|
+
from doorcheck.pypi import Resolve, newest_version
|
|
26
|
+
|
|
27
|
+
TIMEOUT_SECONDS = 300
|
|
28
|
+
STDERR_SHOWN = 300
|
|
29
|
+
MAX_OUTPUT_BYTES = 20 * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ToolError(DoorcheckError):
|
|
33
|
+
"""A tool couldn't run, failed, or wrote output its adapter doesn't understand."""
|
|
34
|
+
|
|
35
|
+
key = "problem.tool_failed"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Audience(StrEnum):
|
|
39
|
+
USERS = "users" # about the code an install puts on disk
|
|
40
|
+
AUTHORS = "authors" # about how the project is built, such as its CI workflows
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class ScanInput:
|
|
45
|
+
"""What every tool scans. Dependencies are resolved once, for all of them."""
|
|
46
|
+
|
|
47
|
+
root: Path
|
|
48
|
+
dependencies: Dependencies = field(default_factory=Dependencies)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def prepare(root: Path, resolve_one: Resolve = newest_version) -> ScanInput:
|
|
52
|
+
return ScanInput(root, resolve(root, resolve_one))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class Command:
|
|
57
|
+
argv: tuple[str, ...]
|
|
58
|
+
cwd: Path
|
|
59
|
+
ok_exit_codes: frozenset[int] = frozenset({0})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class Parsed:
|
|
64
|
+
findings: tuple[Finding, ...] = ()
|
|
65
|
+
problems: tuple[Problem, ...] = ()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class ToolRun:
|
|
70
|
+
check: Check
|
|
71
|
+
findings: tuple[Finding, ...] = ()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Adapter(Protocol):
|
|
75
|
+
name: str
|
|
76
|
+
version: str # the version doorcheck is tested with; any other is refused
|
|
77
|
+
executable: str
|
|
78
|
+
audience: Audience
|
|
79
|
+
experimental: bool # off unless asked for, until test data shows it catches something
|
|
80
|
+
|
|
81
|
+
def version_command(self) -> Sequence[str]: ...
|
|
82
|
+
|
|
83
|
+
def command(self, scan: ScanInput, work: Path) -> Command | Parsed:
|
|
84
|
+
"""The scan to run. When there is nothing for the tool to run on, the result
|
|
85
|
+
itself: empty, or listing what couldn't be covered. `work` is an empty
|
|
86
|
+
scratch folder the adapter may write inputs to."""
|
|
87
|
+
...
|
|
88
|
+
|
|
89
|
+
def parse(self, output: bytes, scan: ScanInput) -> Parsed: ...
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def run(adapter: Adapter, scan: ScanInput, timeout: float = TIMEOUT_SECONDS) -> ToolRun:
|
|
93
|
+
try:
|
|
94
|
+
result = _result(adapter, scan, timeout)
|
|
95
|
+
except (ToolError, OSError) as error:
|
|
96
|
+
key = error.key if isinstance(error, ToolError) else "problem.tool_failed"
|
|
97
|
+
problem = Problem(key, params={"detail": param(str(error))})
|
|
98
|
+
return ToolRun(Check(adapter.name, adapter.version, Status.FAILED, (problem,)))
|
|
99
|
+
if result is None:
|
|
100
|
+
return ToolRun(Check(adapter.name, adapter.version, Status.NOTHING_TO_SCAN))
|
|
101
|
+
check = Check(adapter.name, adapter.version, status_for(result.problems), result.problems)
|
|
102
|
+
return ToolRun(check, result.findings)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _result(adapter: Adapter, scan: ScanInput, timeout: float) -> Parsed | None:
|
|
106
|
+
output = raw_output(adapter, scan, timeout)
|
|
107
|
+
if isinstance(output, Parsed):
|
|
108
|
+
return output if output.findings or output.problems else None
|
|
109
|
+
try:
|
|
110
|
+
return adapter.parse(output, scan)
|
|
111
|
+
except (
|
|
112
|
+
ValueError,
|
|
113
|
+
KeyError,
|
|
114
|
+
TypeError,
|
|
115
|
+
IndexError,
|
|
116
|
+
AttributeError,
|
|
117
|
+
StopIteration,
|
|
118
|
+
RecursionError,
|
|
119
|
+
) as error:
|
|
120
|
+
message = f"{adapter.name} output not understood: {error!r}"
|
|
121
|
+
raise ToolError(message, "problem.tool_output_not_understood") from error
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def raw_output(
|
|
125
|
+
adapter: Adapter, scan: ScanInput, timeout: float = TIMEOUT_SECONDS
|
|
126
|
+
) -> bytes | Parsed:
|
|
127
|
+
"""What the tool prints for the scan, or the adapter's result if it didn't need to run."""
|
|
128
|
+
with tempfile.TemporaryDirectory() as work, tempfile.TemporaryDirectory() as home:
|
|
129
|
+
check_version(adapter, Path(home), timeout)
|
|
130
|
+
command = adapter.command(scan, Path(work))
|
|
131
|
+
if isinstance(command, Parsed):
|
|
132
|
+
return command
|
|
133
|
+
return _execute(adapter.name, command, Path(home), timeout)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def check_version(adapter: Adapter, home: Path, timeout: float) -> None:
|
|
137
|
+
command = Command(tuple(adapter.version_command()), home)
|
|
138
|
+
reported = _execute(adapter.name, command, home, timeout).decode(errors="replace")
|
|
139
|
+
if adapter.version not in reported.split():
|
|
140
|
+
found = reported.strip()[:STDERR_SHOWN]
|
|
141
|
+
message = f"{adapter.name} {adapter.version} is required, found: {found}"
|
|
142
|
+
raise ToolError(message, "problem.tool_wrong_version")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _execute(name: str, command: Command, home: Path, timeout: float) -> bytes:
|
|
146
|
+
try:
|
|
147
|
+
done = capture(command.argv, command.cwd, _environment(home), timeout, MAX_OUTPUT_BYTES)
|
|
148
|
+
except FileNotFoundError as error:
|
|
149
|
+
message = f"{name} is not installed: {command.argv[0]}"
|
|
150
|
+
raise ToolError(message, "problem.tool_not_installed") from error
|
|
151
|
+
except subprocess.TimeoutExpired as error:
|
|
152
|
+
message = f"{name} took longer than {timeout} seconds"
|
|
153
|
+
raise ToolError(message, "problem.tool_timed_out") from error
|
|
154
|
+
except OutputLimitError as error:
|
|
155
|
+
raise ToolError(
|
|
156
|
+
f"{name} output exceeds {MAX_OUTPUT_BYTES} bytes", "problem.tool_output_too_large"
|
|
157
|
+
) from error
|
|
158
|
+
if done.returncode not in command.ok_exit_codes:
|
|
159
|
+
detail = done.stderr.decode(errors="replace").strip()[-STDERR_SHOWN:]
|
|
160
|
+
raise ToolError(f"{name} failed with exit code {done.returncode}: {detail}")
|
|
161
|
+
return done.stdout
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _environment(home: Path) -> dict[str, str]:
|
|
165
|
+
"""Only what tools need. A fresh HOME keeps them from reading the user's settings
|
|
166
|
+
or tokens; the proxy and certificate settings (paths, not secrets) keep
|
|
167
|
+
downloads working behind a proxy."""
|
|
168
|
+
kept = {"PATH", "LANG", "HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"}
|
|
169
|
+
kept |= {"SSL_CERT_FILE", "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"}
|
|
170
|
+
env = {key: value for key, value in os.environ.items() if key in kept}
|
|
171
|
+
return {**env, "HOME": str(home.resolve()), "NO_COLOR": "1"}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def relative(path: str, target: Path) -> str:
|
|
175
|
+
"""A tool's path as a path inside the scanned folder; anything outside is refused.
|
|
176
|
+
Links aren't resolved: a finding is placed where the tool saw it."""
|
|
177
|
+
given = Path(path)
|
|
178
|
+
if given.is_absolute():
|
|
179
|
+
given = given.relative_to(target.absolute()) # ValueError when outside
|
|
180
|
+
normal = os.path.normpath(given.as_posix())
|
|
181
|
+
if normal == "." or normal == ".." or normal.startswith("../"):
|
|
182
|
+
raise ValueError(f"path outside the scanned folder: {path!r}")
|
|
183
|
+
return normal
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""GuardDog, Datadog's heuristics for malicious packages, in two places:
|
|
2
|
+
the integration's own code, and the PyPI packages its manifest installs.
|
|
3
|
+
|
|
4
|
+
Both are experimental: off unless asked for. On the benchmark corpus GuardDog's
|
|
5
|
+
`threat-*` rules matched no known bug and fired on 11 of 75 benign
|
|
6
|
+
integrations, on unit symbols, Cyrillic labels and town-name tables, and its
|
|
7
|
+
package-metadata rules (typosquatting, a maintainer's lapsed email domain)
|
|
8
|
+
flagged only benign packages. That changes when the malicious test set shows a
|
|
9
|
+
rule earns it. Until then code heuristics are information and metadata rules
|
|
10
|
+
are notices.
|
|
11
|
+
|
|
12
|
+
A check GuardDog couldn't run, and a requirement it didn't report on at all,
|
|
13
|
+
are recorded as not covered: a gap in the scan is shown, never passed off as clean.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from doorcheck.adapters.base import (
|
|
22
|
+
Audience,
|
|
23
|
+
Command,
|
|
24
|
+
Parsed,
|
|
25
|
+
ScanInput,
|
|
26
|
+
relative,
|
|
27
|
+
)
|
|
28
|
+
from doorcheck.checks import Problem
|
|
29
|
+
from doorcheck.dependencies import MANIFEST, Pin, pins_file, unresolved_problem
|
|
30
|
+
from doorcheck.findings import Confidence, Finding, Location, Severity, evidence, param
|
|
31
|
+
from doorcheck.requirements import normalized
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class GuardDog:
|
|
35
|
+
"""The integration's own files."""
|
|
36
|
+
|
|
37
|
+
name = "guarddog"
|
|
38
|
+
version = "3.2.0" # renovate: datasource=pypi depName=guarddog
|
|
39
|
+
audience = Audience.USERS
|
|
40
|
+
experimental = True
|
|
41
|
+
|
|
42
|
+
def __init__(self, executable: str = "guarddog") -> None:
|
|
43
|
+
self.executable = executable
|
|
44
|
+
|
|
45
|
+
def version_command(self) -> Sequence[str]:
|
|
46
|
+
return (self.executable, "--version")
|
|
47
|
+
|
|
48
|
+
def command(self, scan: ScanInput, work: Path) -> Command | Parsed:
|
|
49
|
+
argv = (self.executable, "pypi", "scan", ".", "--output-format", "json")
|
|
50
|
+
return Command(argv, scan.root)
|
|
51
|
+
|
|
52
|
+
def parse(self, output: bytes, scan: ScanInput) -> Parsed:
|
|
53
|
+
report = json.loads(output)
|
|
54
|
+
findings = tuple(
|
|
55
|
+
_code_finding(rule, match, self.name, Location(*_place(match, scan.root)), {})
|
|
56
|
+
for rule, matches in sorted(report["results"].items())
|
|
57
|
+
if isinstance(matches, list) # rules with no match, and metadata rules, aren't
|
|
58
|
+
for match in matches
|
|
59
|
+
)
|
|
60
|
+
return Parsed(findings, tuple(_check_failures(report["errors"], None, {})))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class GuardDogDependencies:
|
|
64
|
+
"""The PyPI packages manifest.json installs, at the version a fresh install gets.
|
|
65
|
+
GuardDog misreads a range with more than one bound as a version, so it gets
|
|
66
|
+
exact pins (doorcheck.dependencies)."""
|
|
67
|
+
|
|
68
|
+
name = "guarddog-dependencies"
|
|
69
|
+
version = GuardDog.version
|
|
70
|
+
audience = Audience.USERS
|
|
71
|
+
experimental = True
|
|
72
|
+
|
|
73
|
+
def __init__(self, executable: str = "guarddog") -> None:
|
|
74
|
+
self.executable = executable
|
|
75
|
+
|
|
76
|
+
def version_command(self) -> Sequence[str]:
|
|
77
|
+
return (self.executable, "--version")
|
|
78
|
+
|
|
79
|
+
def command(self, scan: ScanInput, work: Path) -> Command | Parsed:
|
|
80
|
+
dependencies = scan.dependencies
|
|
81
|
+
if not dependencies.pins:
|
|
82
|
+
return Parsed(problems=tuple(map(unresolved_problem, dependencies.unresolved)))
|
|
83
|
+
(work / "requirements.txt").write_text(pins_file(dependencies.pins))
|
|
84
|
+
argv = (self.executable, "pypi", "verify", "requirements.txt", "--output-format", "json")
|
|
85
|
+
return Command(argv, work)
|
|
86
|
+
|
|
87
|
+
def parse(self, output: bytes, scan: ScanInput) -> Parsed:
|
|
88
|
+
pins = {normalized(pin.requirement.name): pin for pin in scan.dependencies.pins}
|
|
89
|
+
unreported = dict(pins)
|
|
90
|
+
findings: list[Finding] = []
|
|
91
|
+
problems = list(map(unresolved_problem, scan.dependencies.unresolved))
|
|
92
|
+
for item in json.loads(output):
|
|
93
|
+
package = normalized(item["dependency"])
|
|
94
|
+
unreported.pop(package, None)
|
|
95
|
+
here = Location(MANIFEST, pins[package].line if package in pins else 1)
|
|
96
|
+
about = {"package": param(item["dependency"]), "version": param(item["version"])}
|
|
97
|
+
found, failed = _package_results(item["result"], self.name, here, about)
|
|
98
|
+
findings += found
|
|
99
|
+
problems += failed
|
|
100
|
+
# GuardDog leaves out, without an error, a package it couldn't download.
|
|
101
|
+
problems += [_not_checked(pin) for pin in unreported.values()]
|
|
102
|
+
return Parsed(tuple(findings), tuple(problems))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _package_results(
|
|
106
|
+
result: Any, tool: str, here: Location, about: dict[str, str]
|
|
107
|
+
) -> tuple[list[Finding], list[Problem]]:
|
|
108
|
+
if "results" not in result and not result["errors"]:
|
|
109
|
+
raise ValueError("guarddog reported neither results nor errors")
|
|
110
|
+
findings = []
|
|
111
|
+
for rule, matches in sorted(result.get("results", {}).items()): # absent if not downloaded
|
|
112
|
+
if isinstance(matches, list):
|
|
113
|
+
findings += [
|
|
114
|
+
_code_finding(rule, m, tool, here, {**about, "file": param(m["location"])})
|
|
115
|
+
for m in matches
|
|
116
|
+
]
|
|
117
|
+
elif isinstance(matches, str) and matches:
|
|
118
|
+
findings.append(_metadata_finding(rule, matches, tool, here, about))
|
|
119
|
+
return findings, _check_failures(result["errors"], here, about)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _code_finding(
|
|
123
|
+
rule: str, match: Any, tool: str, here: Location, about: dict[str, str]
|
|
124
|
+
) -> Finding:
|
|
125
|
+
return Finding(
|
|
126
|
+
rule_id=f"{tool}/{_rule_name(rule)}",
|
|
127
|
+
severity=Severity.INFO,
|
|
128
|
+
confidence=Confidence.LOW,
|
|
129
|
+
location=here,
|
|
130
|
+
message_key="finding.tool_reported",
|
|
131
|
+
tool=tool,
|
|
132
|
+
evidence=evidence(match["code"]) or None,
|
|
133
|
+
params={**about, "message": param(match["message"])},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _metadata_finding(
|
|
138
|
+
rule: str, message: str, tool: str, here: Location, about: dict[str, str]
|
|
139
|
+
) -> Finding:
|
|
140
|
+
return Finding(
|
|
141
|
+
rule_id=f"{tool}/{_rule_name(rule)}",
|
|
142
|
+
severity=Severity.NOTICE,
|
|
143
|
+
confidence=Confidence.LOW,
|
|
144
|
+
location=here,
|
|
145
|
+
message_key="finding.tool_reported",
|
|
146
|
+
tool=tool,
|
|
147
|
+
params={**about, "message": param(message)},
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _check_failures(errors: Any, here: Location | None, about: dict[str, str]) -> list[Problem]:
|
|
152
|
+
return [
|
|
153
|
+
Problem(
|
|
154
|
+
"problem.tool_check_failed",
|
|
155
|
+
here,
|
|
156
|
+
{**about, "check": param(rule), "detail": param(str(message))},
|
|
157
|
+
)
|
|
158
|
+
for rule, message in sorted(errors.items())
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _not_checked(pin: Pin) -> Problem:
|
|
163
|
+
params = {"requirement": param(str(pin.requirement))}
|
|
164
|
+
return Problem("problem.dependency_not_checked", Location(MANIFEST, pin.line), params)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _place(match: Any, target: Path) -> tuple[str, int]:
|
|
168
|
+
path, _, line = match["location"].rpartition(":")
|
|
169
|
+
return relative(path, target), int(line)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _rule_name(rule: str) -> str:
|
|
173
|
+
return rule.replace("_", "-")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""OSV-Scanner, for known vulnerabilities in the PyPI packages an integration installs.
|
|
2
|
+
|
|
3
|
+
OSV-Scanner needs exact versions, so it gets each requirement pinned to the
|
|
4
|
+
version a fresh install gets (doorcheck.dependencies), rebuilt from its parts
|
|
5
|
+
so a manifest can't pass requirement-file options such as `-r /etc/passwd`.
|
|
6
|
+
A requirement that couldn't be pinned is reported as not covered.
|
|
7
|
+
|
|
8
|
+
OSV also lists packages known to be malicious (`MAL-` advisories). Those carry no
|
|
9
|
+
severity score, so they are rated here instead of by score.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from doorcheck.adapters.base import Audience, Command, Parsed, ScanInput
|
|
18
|
+
from doorcheck.dependencies import MANIFEST, pins_file, unresolved_problem
|
|
19
|
+
from doorcheck.findings import Confidence, Finding, Location, Severity, param
|
|
20
|
+
from doorcheck.requirements import normalized
|
|
21
|
+
|
|
22
|
+
MALICIOUS = "MAL-"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OsvScanner:
|
|
26
|
+
name = "osv-scanner"
|
|
27
|
+
version = "2.7.0" # also pinned with its SHA-256 in .github/workflows/ci.yml
|
|
28
|
+
audience = Audience.USERS
|
|
29
|
+
experimental = False
|
|
30
|
+
|
|
31
|
+
def __init__(self, executable: str = "osv-scanner") -> None:
|
|
32
|
+
self.executable = executable
|
|
33
|
+
|
|
34
|
+
def version_command(self) -> Sequence[str]:
|
|
35
|
+
return (self.executable, "--version")
|
|
36
|
+
|
|
37
|
+
def command(self, scan: ScanInput, work: Path) -> Command | Parsed:
|
|
38
|
+
dependencies = scan.dependencies
|
|
39
|
+
if not dependencies.pins:
|
|
40
|
+
return Parsed(problems=tuple(map(unresolved_problem, dependencies.unresolved)))
|
|
41
|
+
(work / "requirements.txt").write_text(pins_file(dependencies.pins))
|
|
42
|
+
flags = ("--format", "json", "--no-resolve", "-L", "requirements.txt")
|
|
43
|
+
# Exit code 1 means vulnerabilities were found.
|
|
44
|
+
return Command((self.executable, "scan", "source", *flags), work, frozenset({0, 1}))
|
|
45
|
+
|
|
46
|
+
def parse(self, output: bytes, scan: ScanInput) -> Parsed:
|
|
47
|
+
lines = {normalized(pin.requirement.name): pin.line for pin in scan.dependencies.pins}
|
|
48
|
+
findings = tuple(
|
|
49
|
+
_finding(entry["package"], group, entry["vulnerabilities"], lines)
|
|
50
|
+
for result in json.loads(output)["results"]
|
|
51
|
+
for entry in result["packages"]
|
|
52
|
+
for group in entry["groups"]
|
|
53
|
+
)
|
|
54
|
+
return Parsed(findings, tuple(map(unresolved_problem, scan.dependencies.unresolved)))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _finding(package: Any, group: Any, vulns: Any, lines: dict[str, int]) -> Finding:
|
|
58
|
+
ids = group["ids"]
|
|
59
|
+
summary = next((str(v.get("summary") or "") for v in vulns if v["id"] in ids), "")
|
|
60
|
+
name, version = package["name"], package["version"]
|
|
61
|
+
malicious = any(str(i).startswith(MALICIOUS) for i in ids)
|
|
62
|
+
return Finding(
|
|
63
|
+
rule_id="osv/malicious-package" if malicious else "osv/known-vulnerability",
|
|
64
|
+
severity=Severity.SERIOUS if malicious else _severity(group.get("max_severity")),
|
|
65
|
+
confidence=Confidence.HIGH,
|
|
66
|
+
location=Location(MANIFEST, lines.get(normalized(name), 1)),
|
|
67
|
+
message_key="finding.malicious_package" if malicious else "finding.known_vulnerability",
|
|
68
|
+
tool=OsvScanner.name,
|
|
69
|
+
params={
|
|
70
|
+
"package": param(name),
|
|
71
|
+
"version": param(version),
|
|
72
|
+
"ids": param(", ".join(ids)),
|
|
73
|
+
"summary": param(summary),
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _severity(score: object) -> Severity:
|
|
79
|
+
"""From the group's highest CVSS score; an unscored advisory is still worth a look."""
|
|
80
|
+
try:
|
|
81
|
+
value = float(str(score))
|
|
82
|
+
except ValueError:
|
|
83
|
+
return Severity.REVIEW
|
|
84
|
+
if value >= 7:
|
|
85
|
+
return Severity.SERIOUS
|
|
86
|
+
return Severity.REVIEW if value >= 4 else Severity.NOTICE
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Bound tool output and stop its process group when a run ends (POSIX hosts)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import selectors
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
CHUNK_BYTES = 64 * 1024
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OutputLimitError(Exception):
|
|
16
|
+
"""The combined stdout and stderr exceeded the allowed bytes."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def capture(
|
|
20
|
+
argv: Sequence[str], cwd: Path, env: Mapping[str, str], timeout: float, limit: int
|
|
21
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
22
|
+
deadline = time.monotonic() + timeout
|
|
23
|
+
with subprocess.Popen( # noqa: S603 - adapter-owned argv, never a shell
|
|
24
|
+
argv,
|
|
25
|
+
cwd=cwd,
|
|
26
|
+
env=env,
|
|
27
|
+
stdin=subprocess.DEVNULL,
|
|
28
|
+
stdout=subprocess.PIPE,
|
|
29
|
+
stderr=subprocess.PIPE,
|
|
30
|
+
start_new_session=True,
|
|
31
|
+
) as process:
|
|
32
|
+
try:
|
|
33
|
+
output, errors = _read(process, deadline, limit)
|
|
34
|
+
code = process.wait(timeout=deadline - time.monotonic())
|
|
35
|
+
return subprocess.CompletedProcess(argv, code, bytes(output), bytes(errors))
|
|
36
|
+
finally:
|
|
37
|
+
_stop(process)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _read(
|
|
41
|
+
process: subprocess.Popen[bytes], deadline: float, limit: int
|
|
42
|
+
) -> tuple[bytearray, bytearray]:
|
|
43
|
+
# PIPE guarantees these streams; the assertions only narrow their static types.
|
|
44
|
+
assert process.stdout is not None # pragma: no mutate - PIPE invariant # noqa: S101
|
|
45
|
+
assert process.stderr is not None # pragma: no mutate - PIPE invariant # noqa: S101
|
|
46
|
+
output, errors = bytearray(), bytearray()
|
|
47
|
+
with selectors.DefaultSelector() as selector:
|
|
48
|
+
selector.register(process.stdout, selectors.EVENT_READ, output)
|
|
49
|
+
selector.register(process.stderr, selectors.EVENT_READ, errors)
|
|
50
|
+
while selector.get_map():
|
|
51
|
+
remaining = deadline - time.monotonic()
|
|
52
|
+
if remaining <= 0:
|
|
53
|
+
raise subprocess.TimeoutExpired(process.args, 0)
|
|
54
|
+
for key, _ in selector.select(remaining):
|
|
55
|
+
budget = limit - len(output) - len(errors)
|
|
56
|
+
chunk = os.read(key.fd, min(CHUNK_BYTES, budget + 1))
|
|
57
|
+
if len(chunk) > budget:
|
|
58
|
+
raise OutputLimitError
|
|
59
|
+
if chunk:
|
|
60
|
+
key.data.extend(chunk)
|
|
61
|
+
else:
|
|
62
|
+
selector.unregister(key.fileobj)
|
|
63
|
+
return output, errors
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _stop(process: subprocess.Popen[bytes]) -> None:
|
|
67
|
+
# Descendants may still hold a pipe open after the main tool exits.
|
|
68
|
+
with suppress(ProcessLookupError):
|
|
69
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Adapters are found through the `doorcheck.adapters` entry-point group, so a new
|
|
2
|
+
tool plugs in by declaring one in its own package; the pipeline doesn't change.
|
|
3
|
+
|
|
4
|
+
An installed package can add an adapter, so a broken one is reported by name
|
|
5
|
+
instead of stopping every other tool from running.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from importlib.metadata import EntryPoint, entry_points
|
|
10
|
+
|
|
11
|
+
from doorcheck.adapters.base import Adapter, Audience
|
|
12
|
+
|
|
13
|
+
GROUP = "doorcheck.adapters"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Loaded:
|
|
18
|
+
adapters: dict[str, Adapter]
|
|
19
|
+
problems: dict[str, str] # entry-point name -> why it couldn't be loaded
|
|
20
|
+
|
|
21
|
+
def selected(self, audience: Audience, experimental: bool = False) -> list[Adapter]:
|
|
22
|
+
"""The adapters for one audience; experimental ones only when asked for."""
|
|
23
|
+
return [
|
|
24
|
+
adapter
|
|
25
|
+
for _, adapter in sorted(self.adapters.items())
|
|
26
|
+
if adapter.audience is audience and (experimental or not adapter.experimental)
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load(points: tuple[EntryPoint, ...] | None = None) -> Loaded:
|
|
31
|
+
adapters: dict[str, Adapter] = {}
|
|
32
|
+
problems: dict[str, str] = {}
|
|
33
|
+
for point in points if points is not None else tuple(entry_points(group=GROUP)):
|
|
34
|
+
try:
|
|
35
|
+
adapter = point.load()()
|
|
36
|
+
problem = _problem(adapter, point.name)
|
|
37
|
+
except Exception as error: # a third-party plugin can fail in any way
|
|
38
|
+
problems[point.name] = f"{type(error).__name__}: {error}"
|
|
39
|
+
continue
|
|
40
|
+
if problem:
|
|
41
|
+
problems[point.name] = problem
|
|
42
|
+
continue
|
|
43
|
+
adapters[point.name] = adapter
|
|
44
|
+
return Loaded(adapters, problems)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _problem(adapter: object, name: str) -> str:
|
|
48
|
+
called = getattr(adapter, "name", None)
|
|
49
|
+
if called != name:
|
|
50
|
+
return f"adapter calls itself {called!r}"
|
|
51
|
+
if not isinstance(getattr(adapter, "audience", None), Audience):
|
|
52
|
+
return "adapter doesn't say who its results are for (audience)"
|
|
53
|
+
if not isinstance(getattr(adapter, "experimental", None), bool):
|
|
54
|
+
return "adapter doesn't say whether it is experimental"
|
|
55
|
+
for field in ("version", "executable"):
|
|
56
|
+
value = getattr(adapter, field, None)
|
|
57
|
+
if not isinstance(value, str) or not value:
|
|
58
|
+
return f"adapter has no valid {field}"
|
|
59
|
+
for method in ("version_command", "command", "parse"):
|
|
60
|
+
if not callable(getattr(adapter, method, None)):
|
|
61
|
+
return f"adapter has no callable {method}"
|
|
62
|
+
return ""
|