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.
Files changed (93) hide show
  1. doorcheck/__init__.py +1 -0
  2. doorcheck/__main__.py +6 -0
  3. doorcheck/adapters/__init__.py +26 -0
  4. doorcheck/adapters/base.py +183 -0
  5. doorcheck/adapters/guarddog.py +173 -0
  6. doorcheck/adapters/osv.py +86 -0
  7. doorcheck/adapters/process.py +69 -0
  8. doorcheck/adapters/registry.py +62 -0
  9. doorcheck/adapters/semgrep.py +145 -0
  10. doorcheck/adapters/zizmor.py +84 -0
  11. doorcheck/capabilities/__init__.py +6 -0
  12. doorcheck/capabilities/call_arguments.py +40 -0
  13. doorcheck/capabilities/call_bindings.py +138 -0
  14. doorcheck/capabilities/calls.py +71 -0
  15. doorcheck/capabilities/frontend.py +23 -0
  16. doorcheck/capabilities/http_values.py +78 -0
  17. doorcheck/capabilities/model.py +82 -0
  18. doorcheck/capabilities/panels.py +39 -0
  19. doorcheck/capabilities/python_code.py +704 -0
  20. doorcheck/capabilities/python_scopes.py +47 -0
  21. doorcheck/capabilities/scan.py +198 -0
  22. doorcheck/capabilities/source_text.py +44 -0
  23. doorcheck/capabilities/static_paths.py +29 -0
  24. doorcheck/capabilities/webhooks.py +34 -0
  25. doorcheck/checks.py +57 -0
  26. doorcheck/cli.py +144 -0
  27. doorcheck/cli_compare.py +77 -0
  28. doorcheck/cli_hosts.py +86 -0
  29. doorcheck/cli_output.py +80 -0
  30. doorcheck/cli_report.py +55 -0
  31. doorcheck/cli_scan.py +84 -0
  32. doorcheck/comparison.py +160 -0
  33. doorcheck/dependencies.py +131 -0
  34. doorcheck/errors.py +15 -0
  35. doorcheck/fetch/__init__.py +12 -0
  36. doorcheck/fetch/archive.py +182 -0
  37. doorcheck/fetch/errors.py +13 -0
  38. doorcheck/fetch/installed.py +137 -0
  39. doorcheck/fetch/marketplace.py +51 -0
  40. doorcheck/fetch/transport.py +32 -0
  41. doorcheck/findings.py +140 -0
  42. doorcheck/hosts.py +71 -0
  43. doorcheck/https.py +89 -0
  44. doorcheck/messages/__init__.py +126 -0
  45. doorcheck/messages/en.json +248 -0
  46. doorcheck/messages/glossaries.py +46 -0
  47. doorcheck/messages/locales.json +326 -0
  48. doorcheck/messages/localization.py +177 -0
  49. doorcheck/messages/shipped-locales.json +1 -0
  50. doorcheck/messages/source-hashes.json +248 -0
  51. doorcheck/messages/translation-context.json +19 -0
  52. doorcheck/presentation/__init__.py +1 -0
  53. doorcheck/presentation/brand/dark_icon.png +0 -0
  54. doorcheck/presentation/brand/dark_icon@2x.png +0 -0
  55. doorcheck/presentation/brand/dark_logo.png +0 -0
  56. doorcheck/presentation/brand/dark_logo@2x.png +0 -0
  57. doorcheck/presentation/brand/icon.png +0 -0
  58. doorcheck/presentation/brand/icon@2x.png +0 -0
  59. doorcheck/presentation/brand/logo.png +0 -0
  60. doorcheck/presentation/brand/logo.svg +1 -0
  61. doorcheck/presentation/brand/logo@2x.png +0 -0
  62. doorcheck/presentation/icons/info.svg +1 -0
  63. doorcheck/presentation/icons/mark.svg +1 -0
  64. doorcheck/presentation/icons/none.svg +1 -0
  65. doorcheck/presentation/icons/notice.svg +1 -0
  66. doorcheck/presentation/icons/review.svg +1 -0
  67. doorcheck/presentation/icons/serious.svg +1 -0
  68. doorcheck/presentation/icons/unavailable.svg +1 -0
  69. doorcheck/presentation/tokens.css +60 -0
  70. doorcheck/presentation/tokens.json +87 -0
  71. doorcheck/py.typed +0 -0
  72. doorcheck/pypi.py +70 -0
  73. doorcheck/report.py +206 -0
  74. doorcheck/requirements.py +105 -0
  75. doorcheck/revocation.py +240 -0
  76. doorcheck/rules/__init__.py +6 -0
  77. doorcheck/rules/file_view.py +367 -0
  78. doorcheck/rules/helpers.py +253 -0
  79. doorcheck/rules/path_flow.py +96 -0
  80. doorcheck/rules/project.py +216 -0
  81. doorcheck/rules/scan.py +109 -0
  82. doorcheck/rules/source.py +49 -0
  83. doorcheck/sarif.py +116 -0
  84. doorcheck/schemas/comparison-v1.schema.json +59 -0
  85. doorcheck/schemas/host-summary-v1.schema.json +152 -0
  86. doorcheck/schemas/report-v1.schema.json +302 -0
  87. doorcheck/text.py +34 -0
  88. doorcheck/tree.py +68 -0
  89. doorcheck-0.1.0a1.dist-info/METADATA +157 -0
  90. doorcheck-0.1.0a1.dist-info/RECORD +93 -0
  91. doorcheck-0.1.0a1.dist-info/WHEEL +4 -0
  92. doorcheck-0.1.0a1.dist-info/entry_points.txt +10 -0
  93. 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,6 @@
1
+ """Run the local scanner with python -m doorcheck."""
2
+
3
+ from doorcheck.cli import entrypoint
4
+
5
+ if __name__ == "__main__":
6
+ entrypoint()
@@ -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 ""