crowbar-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.
crowbar/mcp.py ADDED
@@ -0,0 +1,387 @@
1
+ """MCP stdio server. Zero dependencies; calls the query layer directly."""
2
+ from __future__ import annotations
3
+
4
+ from crowbar import __version__
5
+
6
+ import json
7
+ import os
8
+ import sys
9
+ import traceback
10
+
11
+ from . import catalog, datasets, db, paths, query
12
+
13
+ # Hard safety ceiling for a single tool response (~bytes). The query layer
14
+ # keeps normal responses far smaller; this only catches pathological results
15
+ # (e.g. cb_sql with a huge limit) and never emits invalid JSON.
16
+ MAX_CHARS = 120_000
17
+
18
+
19
+ def fit_response(result) -> tuple[str, bool]:
20
+ """Serialise a tool result, replacing an over-ceiling payload with a
21
+ valid-JSON overflow notice instead of slicing mid-structure."""
22
+ text = json.dumps(result, indent=2, ensure_ascii=False, default=str)
23
+ if len(text) <= MAX_CHARS:
24
+ return text, False
25
+ notice = {
26
+ "error": "result_too_large",
27
+ "bytes": len(text),
28
+ "limit": MAX_CHARS,
29
+ "hint": "narrow the query or lower 'limit'; fetch one record with cb_cve",
30
+ }
31
+ return json.dumps(notice, indent=2), True
32
+
33
+ TOOLS = [
34
+ {
35
+ "name": "cb_search",
36
+ "description": ("Full-text search across every CVE (2002-today). Ranked by relevance "
37
+ "blended with CISA KEV, EPSS and public-exploit availability. Filters: "
38
+ "kev, exploit, min_epss, min_cvss, severity, year, since, cwe, product, "
39
+ "vendor, ssvc, poc."),
40
+ "inputSchema": {
41
+ "type": "object",
42
+ "properties": {
43
+ "query": {"type": "string"},
44
+ "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 15},
45
+ "offset": {"type": "integer", "minimum": 0, "default": 0},
46
+ "sort": {"type": "string", "enum": ["relevance", "newest", "oldest", "cvss", "epss"]},
47
+ "kev": {"type": "boolean"},
48
+ "exploit": {"type": "boolean"},
49
+ "min_epss": {"type": "number"},
50
+ "min_cvss": {"type": "number"},
51
+ "severity": {"type": "string"},
52
+ "year": {"type": "integer"},
53
+ "since": {"type": "string"},
54
+ "cwe": {"type": "string"},
55
+ "product": {"type": "string"},
56
+ "vendor": {"type": "string"},
57
+ "ssvc": {"type": "string", "description": "none | poc | active"},
58
+ "poc": {"type": "boolean", "description": "only CVEs with a public PoC"},
59
+ },
60
+ },
61
+ "fn": lambda con, a: query.search(
62
+ con, a.get("query", ""), limit=a.get("limit", 15),
63
+ offset=a.get("offset", 0), sort=a.get("sort", "relevance"),
64
+ kev=bool(a.get("kev")), exploit=bool(a.get("exploit")),
65
+ epss=a.get("min_epss"), cvss=a.get("min_cvss"), severity=a.get("severity"),
66
+ year=a.get("year"), since=a.get("since"), cwe=a.get("cwe"),
67
+ product=a.get("product"), vendor=a.get("vendor"), ssvc=a.get("ssvc"),
68
+ poc=bool(a.get("poc"))),
69
+ },
70
+ {
71
+ "name": "cb_cve",
72
+ "description": ("One CVE in full: CVSS, EPSS, KEV, CWE, CPE version ranges, references, "
73
+ "Exploit-DB / Nuclei / Metasploit hits, SSVC decision points."),
74
+ "inputSchema": {"type": "object", "properties": {"id": {"type": "string"}},
75
+ "required": ["id"]},
76
+ "fn": lambda con, a: query.get_cve(con, a["id"]),
77
+ },
78
+ {
79
+ "name": "cb_product",
80
+ "description": ("CVEs affecting a product/vendor. Pass version to test NVD CPE version "
81
+ "ranges, e.g. {name: nginx, version: 1.18.0}."),
82
+ "inputSchema": {
83
+ "type": "object",
84
+ "properties": {"name": {"type": "string"},
85
+ "version": {"type": "string"},
86
+ "limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 25}},
87
+ "required": ["name"],
88
+ },
89
+ "fn": lambda con, a: query.products(con, a["name"], a.get("version"),
90
+ a.get("limit", 25)),
91
+ },
92
+ {
93
+ "name": "cb_kev",
94
+ "description": "CISA Known Exploited Vulnerabilities catalog.",
95
+ "inputSchema": {"type": "object", "properties": {
96
+ "query": {"type": "string"},
97
+ "limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 25}}},
98
+ "fn": lambda con, a: query.kev_list(con, a.get("query"), a.get("limit", 25)),
99
+ },
100
+ {
101
+ "name": "cb_exploit",
102
+ "description": ("Search the Exploit-DB index (46k entries), offline. To download the "
103
+ "source for a numeric id, use the CLI: `crowbar exploit <id>`. Nothing is executed."),
104
+ "inputSchema": {"type": "object", "properties": {
105
+ "query": {"type": "string"},
106
+ "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20}},
107
+ "required": ["query"]},
108
+ "fn": lambda con, a: query.exploit_lookup(con, a["query"])
109
+ if a["query"].isdigit()
110
+ else query.exploit_search(con, a["query"], a.get("limit", 20)),
111
+ },
112
+ {
113
+ "name": "cb_nuclei",
114
+ "description": "Search Nuclei templates; returns YAML paths for exact matchers.",
115
+ "inputSchema": {"type": "object", "properties": {
116
+ "query": {"type": "string"},
117
+ "domain": {"type": "string"},
118
+ "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20}},
119
+ "required": ["query"]},
120
+ "fn": lambda con, a: query.nuclei_search(con, a["query"], a.get("limit", 20),
121
+ a.get("domain")),
122
+ },
123
+ {
124
+ "name": "cb_msf",
125
+ "description": "Search Metasploit module paths.",
126
+ "inputSchema": {"type": "object", "properties": {
127
+ "query": {"type": "string"},
128
+ "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 30}},
129
+ "required": ["query"]},
130
+ "fn": lambda con, a: query.msf_search(con, a["query"], a.get("limit", 30)),
131
+ },
132
+ {
133
+ "name": "cb_models",
134
+ "description": "LLM catalog: context window, price per M tokens, capabilities.",
135
+ "inputSchema": {"type": "object", "properties": {
136
+ "query": {"type": "string"}, "provider": {"type": "string"},
137
+ "limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 40}}},
138
+ "fn": lambda con, a: query.models_list(con, a.get("query"), a.get("provider"),
139
+ a.get("limit", 40)),
140
+ },
141
+ {
142
+ "name": "cb_attack",
143
+ "description": "MITRE ATT&CK technique lookup (enterprise, mobile, ics).",
144
+ "inputSchema": {"type": "object", "properties": {
145
+ "query": {"type": "string"}, "domain": {"type": "string"},
146
+ "limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10}},
147
+ "required": ["query"]},
148
+ "fn": lambda con, a: query.attack_lookup(con, a["query"], a.get("limit", 10),
149
+ a.get("domain")),
150
+ },
151
+ {
152
+ "name": "cb_cwe",
153
+ "description": "CWE definitions (id or keyword).",
154
+ "inputSchema": {"type": "object", "properties": {
155
+ "query": {"type": "string"},
156
+ "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20}}},
157
+ "fn": lambda con, a: query.cwe_get(con, a.get("query"), a.get("limit", 20)),
158
+ },
159
+ {
160
+ "name": "cb_os",
161
+ "description": ("Vendor OS advisories: Windows (MSRC Security Update Guide) and Apple "
162
+ "iOS/iPadOS/macOS security releases. Filter by platform, impact (RCE, "
163
+ "EoP, DoS, InfoDisclosure, Spoofing, Bypass) or exploited."),
164
+ "inputSchema": {"type": "object", "properties": {
165
+ "query": {"type": "string"},
166
+ "platform": {"type": "string", "enum": ["windows", "ios", "macos", "apple"]},
167
+ "impact": {"type": "string"},
168
+ "exploited": {"type": "boolean"},
169
+ "since": {"type": "string"},
170
+ "limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 40}}},
171
+ "fn": lambda con, a: query.os_search(con, a.get("query"), a.get("platform"),
172
+ a.get("impact"), bool(a.get("exploited")),
173
+ a.get("since"), a.get("limit", 40)),
174
+ },
175
+ {
176
+ "name": "cb_advisory",
177
+ "description": ("OSV/GHSA advisories with introduced/fixed version ranges. Filter by "
178
+ "ecosystem (npm, PyPI, Go, Maven, NuGet, RubyGems, crates.io, Packagist, "
179
+ "Debian, Alpine) or exact package name."),
180
+ "inputSchema": {"type": "object", "properties": {
181
+ "query": {"type": "string"}, "ecosystem": {"type": "string"},
182
+ "package": {"type": "string"},
183
+ "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 30}}},
184
+ "fn": lambda con, a: query.advisory_search(con, a.get("query"), a.get("ecosystem"),
185
+ a.get("package"), a.get("limit", 30)),
186
+ },
187
+ {
188
+ "name": "cb_code",
189
+ "description": "Full-text search inside Exploit-DB source code; returns path + snippet.",
190
+ "inputSchema": {"type": "object", "properties": {
191
+ "query": {"type": "string"},
192
+ "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20}},
193
+ "required": ["query"]},
194
+ "fn": lambda con, a: query.code_search(con, a["query"], a.get("limit", 20)),
195
+ },
196
+ {
197
+ "name": "cb_binary",
198
+ "description": ("Living-off-the-land binary abuse: GTFOBins (unix), LOLBAS (windows), "
199
+ "PayloadsAllTheThings (web payloads)."),
200
+ "inputSchema": {"type": "object", "properties": {
201
+ "query": {"type": "string"},
202
+ "source": {"type": "string", "enum": ["gtfobins", "lolbas", "payloads"]},
203
+ "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25}},
204
+ "required": ["query"]},
205
+ "fn": lambda con, a: query.toolkit_search(con, a["query"], a.get("source"),
206
+ a.get("limit", 25)),
207
+ },
208
+ {
209
+ "name": "cb_debian",
210
+ "description": "Debian security tracker: per-release status and fixed version for a package.",
211
+ "inputSchema": {"type": "object", "properties": {
212
+ "package": {"type": "string"}, "cve": {"type": "string"}},
213
+ "required": ["package"]},
214
+ "fn": lambda con, a: query.debian_lookup(con, a["package"], a.get("cve")),
215
+ },
216
+ {
217
+ "name": "cb_poc",
218
+ "description": ("Public GitHub PoC repositories (PoC-in-GitHub metadata). Never "
219
+ "executed. Filter by CVE, language or minimum stars, or list the "
220
+ "CVEs with the most PoCs."),
221
+ "inputSchema": {"type": "object", "properties": {
222
+ "query": {"type": "string"},
223
+ "cve": {"type": "string"},
224
+ "language": {"type": "string"},
225
+ "min_stars": {"type": "integer", "minimum": 0, "default": 0},
226
+ "top": {"type": "boolean", "description": "CVEs ranked by PoC count"},
227
+ "limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 25}}},
228
+ "fn": lambda con, a: (query.poc_cves(con, a.get("limit", 25))
229
+ if a.get("top")
230
+ else query.poc_search(con, a.get("query", ""), cve=a.get("cve"),
231
+ language=a.get("language"),
232
+ min_stars=a.get("min_stars", 0),
233
+ limit=a.get("limit", 25))),
234
+ },
235
+ {
236
+ "name": "cb_sources",
237
+ "description": ("Source provenance: which upstream source filled the index, its URL, "
238
+ "license, SHA-256, load time and row count."),
239
+ "inputSchema": {"type": "object", "properties": {}},
240
+ "fn": lambda con, a: [dict(r) for r in con.execute(
241
+ "SELECT source, url, license, sha256, fetched_at, loaded_at, record_count, "
242
+ "status, note FROM source_state ORDER BY source")],
243
+ },
244
+ {
245
+ "name": "cb_tables",
246
+ "description": "Data map: every index table, what it holds, and which command/dataset feeds it.",
247
+ "inputSchema": {"type": "object", "properties": {}},
248
+ "fn": lambda con, a: {"tables": catalog.TABLES, "datasets": catalog.GROUPS},
249
+ },
250
+ {
251
+ "name": "cb_sql",
252
+ "description": ("Read-only SQL. Tables: cve, cve_cwe, cve_product, cpe_match, cve_ref, "
253
+ "cve_exploit, cve_nuclei, cve_msf, kev, exploit, shellcode, nuclei, msf, "
254
+ "attack, cwe, capec, model, ssvc, advisory, package_range, debian, "
255
+ "os_advisory, toolkit, cve_cna, cve_meta, poc, poc_orphan, "
256
+ "cve_alias, source_state, exploit_code_fts, toolkit_fts, meta."),
257
+ "inputSchema": {"type": "object", "properties": {
258
+ "query": {"type": "string"},
259
+ "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 100}},
260
+ "required": ["query"]},
261
+ "fn": lambda con, a: query.sql_readonly(con, a["query"], a.get("limit", 100)),
262
+ },
263
+ {
264
+ "name": "cb_stats",
265
+ "description": ("Corpus health and freshness. `index.age_days` is days since the last "
266
+ "update; `index.stale` means older than the 8-day NVD change window, so "
267
+ "recent CVEs may be missing or outdated - say so and run `crowbar update`."),
268
+ "inputSchema": {"type": "object", "properties": {}},
269
+ # the per-dataset freshness map is large; index_status() is the summary agents need
270
+ "fn": lambda con, a: {**{k: v for k, v in query.stats(con).items() if k != "freshness"},
271
+ "index": datasets.index_status()},
272
+ },
273
+ ]
274
+ BY_NAME = {t["name"]: t for t in TOOLS}
275
+
276
+
277
+ class BadArgs(ValueError):
278
+ """Client-supplied arguments failed the tool's inputSchema."""
279
+
280
+
281
+ def validate_args(schema: dict, args: dict) -> dict:
282
+ """Enforce the inputSchema the server advertises: required args must be
283
+ present, and integer args are clamped to their declared min/max so an
284
+ unbounded or negative `limit` can't materialize a whole table."""
285
+ props = schema.get("properties") or {}
286
+ for name in schema.get("required") or []:
287
+ if name not in args:
288
+ raise BadArgs(f"missing required argument: {name}")
289
+ for name, spec in props.items():
290
+ if name in args and spec.get("type") == "integer" and isinstance(args[name], int):
291
+ lo, hi = spec.get("minimum"), spec.get("maximum")
292
+ if lo is not None:
293
+ args[name] = max(lo, args[name])
294
+ if hi is not None:
295
+ args[name] = min(hi, args[name])
296
+ return args
297
+
298
+
299
+ # ---------------------------------------------------------------- protocol
300
+ def respond(msg_id, result=None, error=None) -> None:
301
+ payload = {"jsonrpc": "2.0", "id": msg_id}
302
+ if error is not None:
303
+ payload["error"] = error
304
+ else:
305
+ payload["result"] = result
306
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False, default=str) + "\n")
307
+ sys.stdout.flush()
308
+
309
+
310
+ def handle(req: dict) -> None:
311
+ method, rid = req.get("method"), req.get("id")
312
+ if method == "initialize":
313
+ respond(rid, {
314
+ "protocolVersion": req.get("params", {}).get("protocolVersion") or "2025-06-18",
315
+ "capabilities": {"tools": {"listChanged": False}},
316
+ "serverInfo": {"name": "crowbar", "version": __version__},
317
+ "instructions": ("Local vulnerability intelligence: CVEs 2002-today, KEV, EPSS, "
318
+ "CPE version ranges, Exploit-DB (index + source code), Nuclei, "
319
+ "Metasploit, OSV/GHSA, Debian tracker, MSRC Windows and Apple "
320
+ "iOS/macOS advisories, GTFOBins/LOLBAS, ATT&CK, CWE, CAPEC, LLM "
321
+ "catalog. Start with cb_stats; use cb_map-equivalent `crowbar map` in CLI "
322
+ "for the table map."),
323
+ })
324
+ return
325
+ if method in ("notifications/initialized", "notifications/cancelled"):
326
+ return
327
+ if method == "ping":
328
+ respond(rid, {})
329
+ return
330
+ if method == "tools/list":
331
+ respond(rid, {"tools": [{"name": t["name"], "description": t["description"],
332
+ "inputSchema": t["inputSchema"]} for t in TOOLS]})
333
+ return
334
+ if method == "tools/call":
335
+ params = req.get("params") or {}
336
+ tool = BY_NAME.get(params.get("name"))
337
+ if not tool:
338
+ respond(rid, error={"code": -32602, "message": f"unknown tool: {params.get('name')}"})
339
+ return
340
+ try:
341
+ args = validate_args(tool["inputSchema"], params.get("arguments") or {})
342
+ except BadArgs as exc:
343
+ respond(rid, error={"code": -32602, "message": str(exc)})
344
+ return
345
+ con = db.connect()
346
+ try:
347
+ result = tool["fn"](con, args)
348
+ text, over = fit_response(result)
349
+ respond(rid, {"content": [{"type": "text", "text": text}], "isError": over})
350
+ except Exception as exc: # noqa: BLE001
351
+ # Full detail (incl. filesystem paths) to stderr only; the client
352
+ # gets the exception type and a root-redacted message.
353
+ print(f"crowbar tool {tool['name']} failed:\n{traceback.format_exc()}",
354
+ file=sys.stderr)
355
+ msg = f"{type(exc).__name__}: {exc}".replace(str(paths.ROOT), "<root>")
356
+ respond(rid, {"content": [{"type": "text", "text": msg}], "isError": True})
357
+ finally:
358
+ con.close()
359
+ return
360
+ if rid is not None:
361
+ respond(rid, error={"code": -32601, "message": f"method not found: {method}"})
362
+
363
+
364
+ def serve() -> None:
365
+ if os.environ.get("CROWBAR_DEBUG"):
366
+ print(f"crowbar mcp: {len(TOOLS)} tools, root={paths.ROOT}", file=sys.stderr)
367
+ if paths.DB.exists() and datasets.refresh_in_background():
368
+ print("crowbar mcp: index over a day old, refreshing in the background", file=sys.stderr)
369
+ for line in sys.stdin:
370
+ line = line.strip()
371
+ if not line:
372
+ continue
373
+ try:
374
+ req = json.loads(line)
375
+ except json.JSONDecodeError:
376
+ respond(None, error={"code": -32700, "message": "parse error"})
377
+ continue
378
+ try:
379
+ handle(req)
380
+ except Exception as exc: # noqa: BLE001
381
+ if os.environ.get("CROWBAR_DEBUG"):
382
+ traceback.print_exc()
383
+ respond(req.get("id"), error={"code": -32603, "message": str(exc)})
384
+
385
+
386
+ if __name__ == "__main__":
387
+ serve()
crowbar/paths.py ADDED
@@ -0,0 +1,54 @@
1
+ """Path resolution for a relocatable harness (local disk, symlink, or USB drive)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import pathlib
6
+ from datetime import date
7
+
8
+ # Resolve through symlinks so `.crowbar/bin/crowbar` still finds the real corpus root.
9
+ _REAL = pathlib.Path(__file__).resolve().parents[1]
10
+ # A source checkout keeps its corpus beside the code. An installed package (pip,
11
+ # uvx, nix) sits in site-packages or a read-only store, so it uses ~/.crowbar.
12
+ _DEFAULT = _REAL if (_REAL / "bin" / "crowbar").exists() else pathlib.Path.home() / ".crowbar"
13
+ ROOT = pathlib.Path(os.environ.get("CROWBAR_HOME") or _DEFAULT).expanduser()
14
+
15
+ DATA = ROOT / "data"
16
+ INDEX = ROOT / "index"
17
+ DB = INDEX / "crowbar.db"
18
+ MANIFEST = INDEX / "manifest.json"
19
+ STATE = INDEX / "state.json"
20
+ SKILL = ROOT / "skills" / "crowbar"
21
+ HOME_LINK = pathlib.Path.home() / ".crowbar"
22
+ DRIVE_MARKER = ".crowbar-drive"
23
+
24
+ SCHEMA_VERSION = 3
25
+ FIRST_YEAR = 2002
26
+ YEARS = list(range(FIRST_YEAR, date.today().year + 1))
27
+
28
+
29
+ def ensure_dirs() -> None:
30
+ INDEX.mkdir(parents=True, exist_ok=True)
31
+
32
+
33
+ def is_drive_root() -> bool:
34
+ return (ROOT / DRIVE_MARKER).exists()
35
+
36
+
37
+ def state() -> dict:
38
+ import json
39
+
40
+ if STATE.exists():
41
+ try:
42
+ return json.loads(STATE.read_text())
43
+ except json.JSONDecodeError:
44
+ pass
45
+ return {}
46
+
47
+
48
+ def save_state(**kw) -> None:
49
+ import json
50
+
51
+ cur = state()
52
+ cur.update(kw)
53
+ ensure_dirs()
54
+ STATE.write_text(json.dumps(cur, indent=2, sort_keys=True) + "\n")