devguard-scanner 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.
Files changed (67) hide show
  1. devguard/__init__.py +5 -0
  2. devguard/ai/__init__.py +0 -0
  3. devguard/ai/investigator.py +380 -0
  4. devguard/ai/prompts.py +113 -0
  5. devguard/ai/providers/__init__.py +0 -0
  6. devguard/ai/providers/anthropic.py +163 -0
  7. devguard/ai/providers/openai_compat.py +157 -0
  8. devguard/ai/registry.py +160 -0
  9. devguard/ai/run.py +119 -0
  10. devguard/ai/sdk.py +102 -0
  11. devguard/ai/testing.py +126 -0
  12. devguard/ai/tools.py +389 -0
  13. devguard/analyzers/__init__.py +0 -0
  14. devguard/analyzers/base.py +135 -0
  15. devguard/analyzers/carriers.py +345 -0
  16. devguard/analyzers/command.py +343 -0
  17. devguard/analyzers/content.py +240 -0
  18. devguard/analyzers/filesystem.py +142 -0
  19. devguard/analyzers/hidden.py +273 -0
  20. devguard/analyzers/imports.py +102 -0
  21. devguard/analyzers/jsonc.py +221 -0
  22. devguard/analyzers/surfaces/__init__.py +0 -0
  23. devguard/analyzers/surfaces/ai_assistants.py +318 -0
  24. devguard/analyzers/surfaces/autoload.py +142 -0
  25. devguard/analyzers/surfaces/common.py +242 -0
  26. devguard/analyzers/surfaces/deps.py +159 -0
  27. devguard/analyzers/surfaces/devcontainer.py +195 -0
  28. devguard/analyzers/surfaces/env.py +53 -0
  29. devguard/analyzers/surfaces/git.py +236 -0
  30. devguard/analyzers/surfaces/npm.py +304 -0
  31. devguard/analyzers/surfaces/python_pkg.py +138 -0
  32. devguard/analyzers/surfaces/vscode.py +515 -0
  33. devguard/cli.py +302 -0
  34. devguard/core/__init__.py +0 -0
  35. devguard/core/config.py +147 -0
  36. devguard/core/filetype.py +313 -0
  37. devguard/core/models.py +195 -0
  38. devguard/core/redact.py +72 -0
  39. devguard/core/safefs.py +280 -0
  40. devguard/core/scoring.py +60 -0
  41. devguard/core/service.py +211 -0
  42. devguard/correlate/__init__.py +0 -0
  43. devguard/correlate/chains.py +160 -0
  44. devguard/mcp/__init__.py +0 -0
  45. devguard/mcp/server.py +442 -0
  46. devguard/report/__init__.py +0 -0
  47. devguard/report/escape.py +31 -0
  48. devguard/report/markdown.py +242 -0
  49. devguard/report/sarif.py +96 -0
  50. devguard/report/terminal.py +47 -0
  51. devguard/report/writer.py +52 -0
  52. devguard/rules/__init__.py +0 -0
  53. devguard/rules/catalog.py +96 -0
  54. devguard/rules/content/ai_instructions.yaml +111 -0
  55. devguard/rules/content/cross_language.yaml +228 -0
  56. devguard/rules/content/docs.yaml +63 -0
  57. devguard/rules/content/javascript.yaml +171 -0
  58. devguard/rules/content/obfuscation.yaml +138 -0
  59. devguard/rules/content/python.yaml +144 -0
  60. devguard/rules/content/shell.yaml +109 -0
  61. devguard/rules/content/windows.yaml +95 -0
  62. devguard/rules/data/vscode_settings.yaml +81 -0
  63. devguard_scanner-0.1.0.dist-info/METADATA +84 -0
  64. devguard_scanner-0.1.0.dist-info/RECORD +67 -0
  65. devguard_scanner-0.1.0.dist-info/WHEEL +4 -0
  66. devguard_scanner-0.1.0.dist-info/entry_points.txt +7 -0
  67. devguard_scanner-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,304 @@
1
+ """npm / yarn / pnpm / bun: lifecycle scripts and package-manager config that executes code (TC-04.1, TC-04.2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import tomllib
7
+
8
+ import yaml
9
+
10
+ from devguard.analyzers.base import AnalysisContext, FileView
11
+ from devguard.analyzers.command import resolve_ref
12
+ from devguard.analyzers.surfaces.common import assess_and_trigger, load_jsonc
13
+ from devguard.analyzers.surfaces.deps import package_json_dependency_findings
14
+ from devguard.core.models import Finding, Severity
15
+ from devguard.core.redact import make_excerpt
16
+ from devguard.rules.catalog import build_finding, rule
17
+
18
+ R_LIFECYCLE = rule(
19
+ "DG-NPM-LIFECYCLE-001",
20
+ "Install-time lifecycle script",
21
+ "low",
22
+ "high",
23
+ "lifecycle",
24
+ "TC-04.1",
25
+ "package.json defines a script that npm/yarn/pnpm run automatically during install (preinstall, install, "
26
+ "postinstall, prepare). It runs with the user's privileges as soon as dependencies are installed.",
27
+ "Native modules and git-hook managers use install scripts legitimately.",
28
+ "Review the script and every file it runs before installing. Use `npm install --ignore-scripts` if unsure.",
29
+ )
30
+ R_NPMRC_EXEC = rule(
31
+ "DG-NPM-RC-EXEC-001",
32
+ "npm configuration executes code",
33
+ "high",
34
+ "high",
35
+ "lifecycle",
36
+ "TC-04.2",
37
+ "The project .npmrc sets an option that injects code or replaces the script shell "
38
+ "(node-options, script-shell, onload-script, init-module).",
39
+ )
40
+ R_REGISTRY = rule(
41
+ "DG-NPM-REGISTRY-001",
42
+ "Package registry overridden",
43
+ "low",
44
+ "medium",
45
+ "deps",
46
+ "TC-11.4",
47
+ "The project points the package manager at a non-default registry. Every package then comes from that "
48
+ "server, which can serve modified packages.",
49
+ "Companies use private registries (Artifactory, Verdaccio, GitHub Packages).",
50
+ )
51
+ R_YARNPATH = rule(
52
+ "DG-YARN-PATH-001",
53
+ "Repository supplies the Yarn executable",
54
+ "high",
55
+ "medium",
56
+ "lifecycle",
57
+ "TC-04.2",
58
+ "`yarnPath` makes every `yarn` command run a JavaScript file from this repository instead of the installed Yarn.",
59
+ "Yarn Berry projects commit `.yarn/releases/yarn-<version>.cjs` legitimately; any other path is unusual. "
60
+ "Compare a committed release with the official one for that version.",
61
+ )
62
+ R_YARN_PLUGIN = rule(
63
+ "DG-YARN-PLUGIN-001",
64
+ "Yarn loads a plugin from the repository",
65
+ "medium",
66
+ "medium",
67
+ "lifecycle",
68
+ "TC-04.2",
69
+ "`.yarnrc.yml` loads a plugin file from this repository; plugins run inside every yarn command.",
70
+ "Official plugins live in `.yarn/plugins/@yarnpkg/`.",
71
+ )
72
+ R_PNPMFILE = rule(
73
+ "DG-PNPM-HOOKS-001",
74
+ "pnpm hook file runs on every install",
75
+ "medium",
76
+ "high",
77
+ "lifecycle",
78
+ "TC-04.2",
79
+ "`.pnpmfile.cjs` is executed by pnpm during every install to rewrite package manifests.",
80
+ )
81
+ R_BUN_PRELOAD = rule(
82
+ "DG-BUN-PRELOAD-001",
83
+ "Bun preloads a repository script",
84
+ "medium",
85
+ "high",
86
+ "lifecycle",
87
+ "TC-04.2",
88
+ "`bunfig.toml` preloads a script before every `bun` run or test.",
89
+ )
90
+
91
+ LIFECYCLE = ("preinstall", "install", "postinstall", "preprepare", "prepare", "postprepare", "prepublish")
92
+ _BENIGN_LIFECYCLE = re.compile(
93
+ r"^\s*(npx\s+)?(husky(\s+install)?(\s+\S+)?|node-gyp\s+rebuild|node-gyp-build|patch-package|tsc(\s+-[-\w]+(\s+\S+)?)*"
94
+ r"|ngcc|prebuild-install(\s+\|\|\s+node-gyp\s+rebuild)?|opencollective[-\s]postinstall.*|simple-git-hooks"
95
+ r"|lefthook\s+install|electron-builder\s+install-app-deps|svelte-kit\s+sync|nuxt\s+prepare|prisma\s+generate"
96
+ r"|is-ci\s+\|\|\s+husky(\s+install)?|lerna\s+bootstrap|npm\s+run\s+build)\s*$"
97
+ )
98
+ _DEFAULT_REGISTRIES = ("https://registry.npmjs.org", "https://registry.yarnpkg.com", "https://registry.npmmirror.com")
99
+ _CANONICAL_YARN = re.compile(r"^\.yarn/releases/yarn-[\w.-]+\.c?js$")
100
+ _CANONICAL_PLUGIN = re.compile(r"^\.yarn/plugins/@yarnpkg/plugin-[\w-]+\.c?js$")
101
+ _RAW_IP_OR_HTTP = re.compile(r"^http://|^https?://(\d{1,3}\.){3}\d{1,3}")
102
+
103
+
104
+ class NpmAnalyzer:
105
+ name = "npm"
106
+
107
+ def applies(self, fv: FileView) -> bool:
108
+ return (
109
+ fv.text is not None
110
+ and fv.name in ("package.json", ".npmrc", ".yarnrc.yml", ".yarnrc", ".pnpmfile.cjs", "bunfig.toml")
111
+ and not fv.record.vendored
112
+ )
113
+
114
+ def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
115
+ if fv.name == "package.json":
116
+ return self._package_json(fv, ctx)
117
+ if fv.name == ".npmrc":
118
+ return self._npmrc(fv, ctx)
119
+ if fv.name == ".yarnrc.yml":
120
+ return self._yarnrc_yml(fv, ctx)
121
+ if fv.name == ".yarnrc":
122
+ return self._yarnrc_v1(fv, ctx)
123
+ if fv.name == ".pnpmfile.cjs":
124
+ ctx.add_trigger("pnpm-hooks", fv.rel, line=1, label=".pnpmfile.cjs", autorun=True, references=[fv.rel])
125
+ ctx.autoloaded.add(fv.rel)
126
+ return [build_finding(R_PNPMFILE.id, [fv.evidence_at_line(1)])]
127
+ return self._bunfig(fv, ctx)
128
+
129
+ def _package_json(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
130
+ out: list[Finding] = []
131
+ root = load_jsonc(fv, ctx, out)
132
+ if root is None or not isinstance(root.value, dict):
133
+ return out
134
+ base = [fv.parent]
135
+ scripts = root.get("scripts")
136
+ table: dict[str, str] = {}
137
+ for name, node in scripts.items() if scripts else []:
138
+ if not isinstance(node.value, str):
139
+ continue
140
+ table[name] = node.value
141
+ autorun = name in LIFECYCLE
142
+ if autorun:
143
+ benign = bool(_BENIGN_LIFECYCLE.match(node.value))
144
+ out.append(
145
+ build_finding(
146
+ R_LIFECYCLE.id,
147
+ [fv.evidence_at_line(node.line)],
148
+ severity=Severity.INFO if benign else None,
149
+ detail=f"{name}: {make_excerpt(node.value, limit=160)}",
150
+ )
151
+ )
152
+ out += assess_and_trigger(
153
+ ctx,
154
+ fv,
155
+ node.value,
156
+ node.line,
157
+ kind="npm-lifecycle" if autorun else "npm-script",
158
+ label=name,
159
+ autorun=autorun,
160
+ context=f"npm script {name!r}",
161
+ base_dirs=base,
162
+ )
163
+ ctx.npm_scripts[fv.parent] = table
164
+ out += package_json_dependency_findings(root, fv)
165
+ return out
166
+
167
+ def _npmrc(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
168
+ out: list[Finding] = []
169
+ for i, line in enumerate(fv.lines, 1):
170
+ s = line.strip()
171
+ if not s or s.startswith((";", "#")) or "=" not in s:
172
+ continue
173
+ key, value = (p.strip() for p in s.split("=", 1))
174
+ lkey = key.lower()
175
+ if lkey in ("node-options", "script-shell", "onload-script", "init-module", "shell"):
176
+ out.append(
177
+ build_finding(
178
+ R_NPMRC_EXEC.id, [fv.evidence_at_line(i)], detail=f"{key}={make_excerpt(value, limit=120)}"
179
+ )
180
+ )
181
+ ref = resolve_ref(value.split()[-1] if value.split() else value, ctx.inventory, [fv.parent])
182
+ ctx.add_trigger(
183
+ "npmrc",
184
+ fv.rel,
185
+ line=i,
186
+ label=key,
187
+ command=make_excerpt(value),
188
+ autorun=True,
189
+ references=[ref] if ref else [],
190
+ )
191
+ elif lkey == "registry" or lkey.endswith(":registry"):
192
+ out += _registry_finding(fv, i, key, value)
193
+ return out
194
+
195
+ def _yarnrc_yml(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
196
+ try:
197
+ data = yaml.safe_load(fv.text or "") or {}
198
+ except yaml.YAMLError as exc:
199
+ ctx.warn("npm", f"invalid YAML: {exc}", fv.rel)
200
+ return []
201
+ if not isinstance(data, dict):
202
+ return []
203
+ out: list[Finding] = []
204
+ yarn_path = data.get("yarnPath")
205
+ if isinstance(yarn_path, str):
206
+ out += self._yarn_path(fv, ctx, yarn_path, _line_of(fv, "yarnPath"))
207
+ for plugin in data.get("plugins") or []:
208
+ path = plugin.get("path") if isinstance(plugin, dict) else plugin if isinstance(plugin, str) else None
209
+ if not isinstance(path, str):
210
+ continue
211
+ line = _line_of(fv, path)
212
+ ref = resolve_ref(path, ctx.inventory, [fv.parent])
213
+ canonical = bool(_CANONICAL_PLUGIN.match(path))
214
+ out.append(
215
+ build_finding(
216
+ R_YARN_PLUGIN.id,
217
+ [fv.evidence_at_line(line)],
218
+ severity=Severity.INFO if canonical else None,
219
+ detail=f"Plugin: {path}",
220
+ )
221
+ )
222
+ ctx.add_trigger(
223
+ "yarn-plugin",
224
+ fv.rel,
225
+ line=line,
226
+ label=path,
227
+ autorun=True,
228
+ references=[ref] if ref and not canonical else [],
229
+ )
230
+ reg = data.get("npmRegistryServer")
231
+ if isinstance(reg, str):
232
+ out += _registry_finding(fv, _line_of(fv, "npmRegistryServer"), "npmRegistryServer", reg)
233
+ return out
234
+
235
+ def _yarnrc_v1(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
236
+ out: list[Finding] = []
237
+ for i, line in enumerate(fv.lines, 1):
238
+ m = re.match(r'^\s*(yarn-path|registry|"registry")\s+"?([^"\s]+)"?', line)
239
+ if not m:
240
+ continue
241
+ if m.group(1) == "yarn-path":
242
+ out += self._yarn_path(fv, ctx, m.group(2), i)
243
+ else:
244
+ out += _registry_finding(fv, i, "registry", m.group(2))
245
+ return out
246
+
247
+ def _yarn_path(self, fv: FileView, ctx: AnalysisContext, path: str, line: int | None) -> list[Finding]:
248
+ canonical = bool(_CANONICAL_YARN.match(path))
249
+ ref = resolve_ref(path, ctx.inventory, [fv.parent])
250
+ ctx.add_trigger(
251
+ "yarn-path",
252
+ fv.rel,
253
+ line=line,
254
+ label="yarnPath",
255
+ command=make_excerpt(path),
256
+ autorun=True,
257
+ references=[ref] if ref and not canonical else [],
258
+ )
259
+ return [
260
+ build_finding(
261
+ R_YARNPATH.id,
262
+ [fv.evidence_at_line(line)],
263
+ severity=Severity.INFO if canonical else None,
264
+ confidence=None,
265
+ detail=f"yarnPath: {make_excerpt(path, limit=120)}",
266
+ )
267
+ ]
268
+
269
+ def _bunfig(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
270
+ try:
271
+ data = tomllib.loads(fv.text or "")
272
+ except tomllib.TOMLDecodeError as exc:
273
+ ctx.warn("npm", f"invalid TOML: {exc}", fv.rel)
274
+ return []
275
+ out: list[Finding] = []
276
+ preloads: list[str] = []
277
+ for section in (data, data.get("test") or {}, data.get("run") or {}):
278
+ value = section.get("preload") if isinstance(section, dict) else None
279
+ preloads += [value] if isinstance(value, str) else [v for v in value or [] if isinstance(v, str)]
280
+ for p in preloads:
281
+ line = _line_of(fv, p)
282
+ ref = resolve_ref(p, ctx.inventory, [fv.parent])
283
+ out.append(build_finding(R_BUN_PRELOAD.id, [fv.evidence_at_line(line)], detail=f"Preload: {p}"))
284
+ ctx.add_trigger("bun-preload", fv.rel, line=line, label=p, autorun=True, references=[ref] if ref else [])
285
+ return out
286
+
287
+
288
+ def _registry_finding(fv: FileView, line: int | None, key: str, value: str) -> list[Finding]:
289
+ v = value.strip().strip("\"'").rstrip("/")
290
+ if not v or v.startswith(_DEFAULT_REGISTRIES):
291
+ return []
292
+ sev = Severity.MEDIUM if _RAW_IP_OR_HTTP.match(v) else None
293
+ return [
294
+ build_finding(
295
+ R_REGISTRY.id, [fv.evidence_at_line(line)], severity=sev, detail=f"{key} = {make_excerpt(v, limit=120)}"
296
+ )
297
+ ]
298
+
299
+
300
+ def _line_of(fv: FileView, needle: str) -> int | None:
301
+ for i, line in enumerate(fv.lines, 1):
302
+ if needle in line:
303
+ return i
304
+ return None
@@ -0,0 +1,138 @@
1
+ """Python packaging and interpreter auto-execution points (TC-04.3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+
7
+ from devguard.analyzers.base import AnalysisContext, FileView
8
+ from devguard.core.models import Finding, Severity
9
+ from devguard.core.redact import make_excerpt
10
+ from devguard.core.safefs import normalize_rel
11
+ from devguard.rules.catalog import build_finding, rule
12
+
13
+ R_SETUP_PY = rule(
14
+ "DG-PY-SETUP-001",
15
+ "setup.py executes during installation",
16
+ "info",
17
+ "high",
18
+ "lifecycle",
19
+ "TC-04.3",
20
+ "setup.py is a Python program that runs when the package is built or installed from source. Its "
21
+ "contents are checked by the Python rules and linked to this trigger.",
22
+ )
23
+ R_PTH = rule(
24
+ "DG-PY-PTH-EXEC-001",
25
+ ".pth file executes code at interpreter start-up",
26
+ "high",
27
+ "medium",
28
+ "lifecycle",
29
+ "TC-04.3",
30
+ "Lines starting with `import` in a .pth file run every time Python starts if the file is placed in "
31
+ "site-packages. Committing such a file is unusual.",
32
+ "Some packages (e.g. coverage, editable installs) ship .pth hooks inside site-packages.",
33
+ )
34
+ R_STARTUP = rule(
35
+ "DG-PY-STARTUP-HOOK-001",
36
+ "Python start-up customization module",
37
+ "medium",
38
+ "medium",
39
+ "lifecycle",
40
+ "TC-04.3",
41
+ "sitecustomize.py / usercustomize.py are imported automatically by every Python process whose path "
42
+ "includes this directory.",
43
+ )
44
+ R_BACKEND = rule(
45
+ "DG-PY-INTREE-BACKEND-001",
46
+ "Build backend loaded from the repository",
47
+ "medium",
48
+ "medium",
49
+ "lifecycle",
50
+ "TC-04.3",
51
+ "pyproject.toml sets `backend-path`, so pip runs a build backend that lives inside this repository "
52
+ "when installing from source.",
53
+ "A few projects bootstrap their own backend this way.",
54
+ )
55
+ R_CONFTEST = rule(
56
+ "DG-PY-CONFTEST-001",
57
+ "pytest conftest.py runs during test collection",
58
+ "info",
59
+ "high",
60
+ "lifecycle",
61
+ "TC-04.3",
62
+ "conftest.py is imported automatically by pytest (and by IDE test discovery). Its contents are checked "
63
+ "by the Python rules and linked to this trigger.",
64
+ )
65
+
66
+
67
+ class PythonPackagingAnalyzer:
68
+ name = "python-packaging"
69
+
70
+ def applies(self, fv: FileView) -> bool:
71
+ if fv.text is None or fv.record.vendored:
72
+ return False
73
+ return fv.name in ("setup.py", "pyproject.toml", "sitecustomize.py", "usercustomize.py", "conftest.py") or (
74
+ fv.suffix == ".pth"
75
+ )
76
+
77
+ def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
78
+ name = fv.name
79
+ if name == "setup.py":
80
+ ctx.add_trigger("python-setup", fv.rel, line=1, label="setup.py", autorun=True, references=[fv.rel])
81
+ return [build_finding(R_SETUP_PY.id, [fv.evidence_at_line(1)])]
82
+ if name == "conftest.py":
83
+ ctx.add_trigger("pytest-conftest", fv.rel, line=1, label="conftest.py", autorun=False, references=[fv.rel])
84
+ return [build_finding(R_CONFTEST.id, [fv.evidence_at_line(1)])]
85
+ if name in ("sitecustomize.py", "usercustomize.py"):
86
+ ctx.add_trigger("python-startup", fv.rel, line=1, label=name, autorun=True, references=[fv.rel])
87
+ ctx.autoloaded.add(fv.rel)
88
+ return [build_finding(R_STARTUP.id, [fv.evidence_at_line(1)])]
89
+ if fv.suffix == ".pth":
90
+ out: list[Finding] = []
91
+ for i, line in enumerate(fv.lines, 1):
92
+ if line.lstrip().startswith(("import ", "import\t")):
93
+ in_site = "site-packages/" in fv.rel
94
+ out.append(
95
+ build_finding(R_PTH.id, [fv.evidence_at_line(i)], severity=Severity.MEDIUM if in_site else None)
96
+ )
97
+ ctx.add_trigger(
98
+ "python-pth",
99
+ fv.rel,
100
+ line=i,
101
+ label=fv.name,
102
+ command=make_excerpt(line),
103
+ autorun=True,
104
+ references=[fv.rel],
105
+ )
106
+ break
107
+ return out
108
+ return self._pyproject(fv, ctx)
109
+
110
+ def _pyproject(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
111
+ try:
112
+ data = tomllib.loads(fv.text or "")
113
+ except tomllib.TOMLDecodeError as exc:
114
+ ctx.warn("python-packaging", f"invalid TOML: {exc}", fv.rel)
115
+ return []
116
+ bs = data.get("build-system") or {}
117
+ paths = bs.get("backend-path") if isinstance(bs, dict) else None
118
+ if not paths:
119
+ return []
120
+ line = next((i for i, ln in enumerate(fv.lines, 1) if "backend-path" in ln), None)
121
+ backend = str(bs.get("build-backend", ""))
122
+ refs = []
123
+ module = backend.split(":")[0].replace(".", "/")
124
+ for p in paths if isinstance(paths, list) else [paths]:
125
+ if not isinstance(p, str):
126
+ continue
127
+ for cand in (f"{p}/{module}.py", f"{p}/{module}/__init__.py"):
128
+ full = normalize_rel(f"{fv.parent}/{cand}" if fv.parent else cand)
129
+ if full and ctx.inventory.exists(full):
130
+ refs.append(full)
131
+ ctx.add_trigger("python-backend", fv.rel, line=line, label=backend, autorun=True, references=refs)
132
+ return [
133
+ build_finding(
134
+ R_BACKEND.id,
135
+ [fv.evidence_at_line(line)],
136
+ detail=f"backend {make_excerpt(backend, limit=80)!r} from {paths!r}.",
137
+ )
138
+ ]