cvm-caspar 1.0.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 (235) hide show
  1. cli/__init__.py +1 -0
  2. cli/_discovery.py +43 -0
  3. cli/_knowledge.py +194 -0
  4. cli/_output.py +944 -0
  5. cli/_publish.py +68 -0
  6. cli/commands/__init__.py +1 -0
  7. cli/commands/build_cmds.py +191 -0
  8. cli/commands/chain_cmds.py +197 -0
  9. cli/commands/demo_cmds.py +258 -0
  10. cli/commands/init_cmds.py +116 -0
  11. cli/commands/manage_cmds.py +380 -0
  12. cli/commands/plugin_cmds.py +438 -0
  13. cli/commands/publish_cmds.py +40 -0
  14. cli/commands/report_cmds.py +342 -0
  15. cli/commands/scan_cmds.py +622 -0
  16. cli/commands/serve_cmds.py +176 -0
  17. cli/main.py +136 -0
  18. config_assessment/__init__.py +1 -0
  19. config_assessment/api/__init__.py +8 -0
  20. config_assessment/api/app.py +71 -0
  21. config_assessment/api/chains.py +112 -0
  22. config_assessment/api/deps.py +36 -0
  23. config_assessment/api/findings.py +191 -0
  24. config_assessment/api/job_runner.py +139 -0
  25. config_assessment/api/routers/__init__.py +0 -0
  26. config_assessment/api/routers/builds.py +91 -0
  27. config_assessment/api/routers/chains.py +63 -0
  28. config_assessment/api/routers/dimensions.py +200 -0
  29. config_assessment/api/routers/findings.py +146 -0
  30. config_assessment/api/routers/health.py +32 -0
  31. config_assessment/api/routers/hosts.py +147 -0
  32. config_assessment/api/routers/jobs.py +56 -0
  33. config_assessment/api/routers/knowledge.py +114 -0
  34. config_assessment/api/routers/maintenance.py +80 -0
  35. config_assessment/api/routers/manage.py +260 -0
  36. config_assessment/api/routers/plugins.py +107 -0
  37. config_assessment/api/routers/posture.py +263 -0
  38. config_assessment/api/routers/reports.py +75 -0
  39. config_assessment/api/routers/scans.py +202 -0
  40. config_assessment/api/routers/targets.py +86 -0
  41. config_assessment/api/routers/trends.py +45 -0
  42. config_assessment/api/routers/watch.py +200 -0
  43. config_assessment/api/schemas.py +74 -0
  44. config_assessment/api/schemas_jobs.py +77 -0
  45. config_assessment/api/schemas_maintenance.py +29 -0
  46. config_assessment/api/schemas_manage.py +124 -0
  47. config_assessment/api/schemas_watch.py +82 -0
  48. config_assessment/api/scoring_explain.py +217 -0
  49. config_assessment/api/watch_runner.py +252 -0
  50. config_assessment/build/__init__.py +1 -0
  51. config_assessment/build/benchmark_extractor.py +395 -0
  52. config_assessment/build/build_utils.py +224 -0
  53. config_assessment/build/chain_pipeline.py +417 -0
  54. config_assessment/build/curated_build.py +75 -0
  55. config_assessment/build/generic_build.py +80 -0
  56. config_assessment/build/llm_client.py +452 -0
  57. config_assessment/build/plugin_detector.py +151 -0
  58. config_assessment/build/plugin_scaffolder.py +268 -0
  59. config_assessment/build/rag.py +331 -0
  60. config_assessment/core/__init__.py +1 -0
  61. config_assessment/core/ccss.py +43 -0
  62. config_assessment/core/collectors/__init__.py +44 -0
  63. config_assessment/core/collectors/exposure.py +305 -0
  64. config_assessment/core/collectors/permissions.py +180 -0
  65. config_assessment/core/db/__init__.py +1 -0
  66. config_assessment/core/db/ccss_canonical.sql.gz +0 -0
  67. config_assessment/core/db/database.py +1229 -0
  68. config_assessment/core/db/doctor.py +155 -0
  69. config_assessment/core/db/reseed.py +124 -0
  70. config_assessment/core/db/schema.sql +209 -0
  71. config_assessment/core/engines/__init__.py +81 -0
  72. config_assessment/core/engines/aggregation.py +247 -0
  73. config_assessment/core/engines/assessment.py +235 -0
  74. config_assessment/core/engines/attack_chain.py +78 -0
  75. config_assessment/core/engines/categorization.py +74 -0
  76. config_assessment/core/engines/chain_authoring.py +171 -0
  77. config_assessment/core/engines/dimensions.py +308 -0
  78. config_assessment/core/engines/knowledge.py +65 -0
  79. config_assessment/core/engines/reporting.py +32 -0
  80. config_assessment/core/engines/scoring.py +341 -0
  81. config_assessment/core/input_resolver.py +838 -0
  82. config_assessment/core/inventory.py +132 -0
  83. config_assessment/core/manifest.py +103 -0
  84. config_assessment/core/models.py +233 -0
  85. config_assessment/core/runtime.py +311 -0
  86. config_assessment/core/target.py +130 -0
  87. config_assessment/core/unknown_directives.py +311 -0
  88. config_assessment/core/watch.py +157 -0
  89. config_assessment/core/watch_loop.py +61 -0
  90. config_assessment/core/watch_session.py +65 -0
  91. config_assessment/enrichment/__init__.py +1 -0
  92. config_assessment/enrichment/cve_enricher.py +659 -0
  93. config_assessment/enrichment/exploit_enricher.py +163 -0
  94. config_assessment/enrichment/ttp_enricher.py +119 -0
  95. config_assessment/enrichment/version_prefetch.py +96 -0
  96. config_assessment/fetch/__init__.py +0 -0
  97. config_assessment/fetch/benchmark_fetcher.py +394 -0
  98. config_assessment/fetch/catalog.json +568 -0
  99. config_assessment/fetch/ssg_source.py +414 -0
  100. config_assessment/parsers/__init__.py +1 -0
  101. config_assessment/parsers/arm_json.py +109 -0
  102. config_assessment/parsers/bicep_flat.py +139 -0
  103. config_assessment/parsers/dockerfile.py +105 -0
  104. config_assessment/parsers/hcl_flat.py +210 -0
  105. config_assessment/parsers/key_value.py +108 -0
  106. config_assessment/parsers/yaml_flat.py +99 -0
  107. config_assessment/plugins/__init__.py +22 -0
  108. config_assessment/plugins/apache_httpd/__init__.py +195 -0
  109. config_assessment/plugins/apache_httpd/build_apache.py +728 -0
  110. config_assessment/plugins/apache_httpd/build_llm.py +338 -0
  111. config_assessment/plugins/apache_httpd/build_narratives.py +113 -0
  112. config_assessment/plugins/apache_httpd/cce-apache-httpd2.2-5.20130214.xlsx +0 -0
  113. config_assessment/plugins/apache_httpd/chains.json +125 -0
  114. config_assessment/plugins/apache_httpd/llm_pipeline.py +704 -0
  115. config_assessment/plugins/apache_httpd/narrative_pipeline.py +312 -0
  116. config_assessment/plugins/apache_httpd/parser.py +198 -0
  117. config_assessment/plugins/apache_httpd/refresh_cve.py +158 -0
  118. config_assessment/plugins/apache_httpd/rules.py +123 -0
  119. config_assessment/plugins/apache_httpd/validate_mae.py +266 -0
  120. config_assessment/plugins/azure_iac/__init__.py +122 -0
  121. config_assessment/plugins/azure_iac/build_azure.py +283 -0
  122. config_assessment/plugins/azure_iac/canon.py +35 -0
  123. config_assessment/plugins/docker/__init__.py +68 -0
  124. config_assessment/plugins/docker/build_docker.py +72 -0
  125. config_assessment/plugins/docker/chains.json +13 -0
  126. config_assessment/plugins/docker/parser.py +8 -0
  127. config_assessment/plugins/docker/rules.py +37 -0
  128. config_assessment/plugins/dockerfile/__init__.py +97 -0
  129. config_assessment/plugins/dockerfile/build_dockerfile.py +34 -0
  130. config_assessment/plugins/dockerfile/parser.py +8 -0
  131. config_assessment/plugins/dockerfile/rules.py +67 -0
  132. config_assessment/plugins/dummy/__init__.py +71 -0
  133. config_assessment/plugins/kubernetes/__init__.py +106 -0
  134. config_assessment/plugins/kubernetes/build_kubernetes.py +35 -0
  135. config_assessment/plugins/kubernetes/chains.json +10 -0
  136. config_assessment/plugins/kubernetes/parser.py +9 -0
  137. config_assessment/plugins/kubernetes/rules.py +98 -0
  138. config_assessment/plugins/mysql/__init__.py +69 -0
  139. config_assessment/plugins/mysql/build_mysql.py +39 -0
  140. config_assessment/plugins/mysql/parser.py +8 -0
  141. config_assessment/plugins/mysql/rules.py +36 -0
  142. config_assessment/plugins/nginx/__init__.py +177 -0
  143. config_assessment/plugins/nginx/build_nginx.py +376 -0
  144. config_assessment/plugins/nginx/chains.json +26 -0
  145. config_assessment/plugins/nginx/parser.py +272 -0
  146. config_assessment/plugins/nginx/rules.py +118 -0
  147. config_assessment/plugins/postgresql/__init__.py +68 -0
  148. config_assessment/plugins/postgresql/build_postgresql.py +41 -0
  149. config_assessment/plugins/postgresql/chains.json +57 -0
  150. config_assessment/plugins/postgresql/parser.py +8 -0
  151. config_assessment/plugins/postgresql/rules.py +48 -0
  152. config_assessment/plugins/redis/U_Redis_Enterprise_6-x_STIG_V2R2_Manual-xccdf.xml +2173 -0
  153. config_assessment/plugins/redis/__init__.py +68 -0
  154. config_assessment/plugins/redis/build_redis.py +25 -0
  155. config_assessment/plugins/redis/chains.json +57 -0
  156. config_assessment/plugins/redis/parser.py +8 -0
  157. config_assessment/plugins/redis/rules.py +44 -0
  158. config_assessment/plugins/ssh/__init__.py +165 -0
  159. config_assessment/plugins/ssh/build_ssh.py +221 -0
  160. config_assessment/plugins/ssh/parser.py +193 -0
  161. config_assessment/plugins/ssh/rules.py +96 -0
  162. config_assessment/plugins/tomcat/U_Apache_Tomcat_Application_Server_9_STIG_V3R4_Manual-xccdf.xml +1620 -0
  163. config_assessment/plugins/tomcat/__init__.py +68 -0
  164. config_assessment/plugins/tomcat/build_tomcat.py +68 -0
  165. config_assessment/plugins/tomcat/chains.json +35 -0
  166. config_assessment/plugins/tomcat/parser.py +8 -0
  167. config_assessment/plugins/tomcat/rules.py +55 -0
  168. config_assessment/plugins/ubuntu/__init__.py +93 -0
  169. config_assessment/plugins/ubuntu/build_ubuntu.py +34 -0
  170. config_assessment/plugins/ubuntu/parser.py +10 -0
  171. config_assessment/plugins/ubuntu/rules.py +140 -0
  172. config_assessment/plugins/ubuntu2204/__init__.py +141 -0
  173. config_assessment/plugins/ubuntu2204/build_ubuntu2204.py +34 -0
  174. config_assessment/plugins/ubuntu2204/rules.py +283 -0
  175. config_assessment/reports/__init__.py +1 -0
  176. config_assessment/reports/remediation.py +178 -0
  177. config_assessment/reports/report_dashboard.py +942 -0
  178. config_assessment/reports/report_dashboard_online.py +419 -0
  179. config_assessment/reports/report_html.py +507 -0
  180. config_assessment/reports/scan_features.py +225 -0
  181. cvm_caspar-1.0.0.dist-info/METADATA +149 -0
  182. cvm_caspar-1.0.0.dist-info/RECORD +235 -0
  183. cvm_caspar-1.0.0.dist-info/WHEEL +4 -0
  184. cvm_caspar-1.0.0.dist-info/entry_points.txt +2 -0
  185. cvm_caspar-1.0.0.dist-info/licenses/LICENSE +202 -0
  186. cvm_caspar-1.0.0.dist-info/licenses/NOTICE +53 -0
  187. frontend/dist/assets/charts-BLacFBYH.js +73 -0
  188. frontend/dist/assets/index-CUhIFMOg.css +1 -0
  189. frontend/dist/assets/index-T2YLv5jH.js +358 -0
  190. frontend/dist/assets/query-CKBowX9j.js +9 -0
  191. frontend/dist/assets/react-DBpqIJvm.js +59 -0
  192. frontend/dist/favicon.svg +11 -0
  193. frontend/dist/index.html +18 -0
  194. frontend-v2/dist/assets/JobConsole-B5TzVvBO.js +1 -0
  195. frontend-v2/dist/assets/Pager-DGkcPZxi.js +1 -0
  196. frontend-v2/dist/assets/arrow-right-CoUZZYja.js +1 -0
  197. frontend-v2/dist/assets/assessment-CWjDYtm4.js +1 -0
  198. frontend-v2/dist/assets/build-CWwe6xXy.js +1 -0
  199. frontend-v2/dist/assets/chains-Bp-2RuOM.js +1 -0
  200. frontend-v2/dist/assets/chains-CHoELElb.js +1 -0
  201. frontend-v2/dist/assets/charts-DaqLp_gX.js +59 -0
  202. frontend-v2/dist/assets/circle-slash-CXTugZTs.js +1 -0
  203. frontend-v2/dist/assets/dimensions-BAF2npX5.js +1 -0
  204. frontend-v2/dist/assets/dimensions._dimensionId-C3_eBojp.js +1 -0
  205. frontend-v2/dist/assets/dimensions._dimensionId-DFLMAtbl.js +2 -0
  206. frontend-v2/dist/assets/dimensions.index-BBjA5Bzn.js +1 -0
  207. frontend-v2/dist/assets/download-Dh1LJG-8.js +1 -0
  208. frontend-v2/dist/assets/file-code-corner-BWMYzPBG.js +1 -0
  209. frontend-v2/dist/assets/findings-2alsHeTl.js +1 -0
  210. frontend-v2/dist/assets/findings-lLEMYnEK.js +1 -0
  211. frontend-v2/dist/assets/forms-DgdCPcVc.js +1 -0
  212. frontend-v2/dist/assets/history-S4IgFFeE.js +1 -0
  213. frontend-v2/dist/assets/index-BZutiq_g.css +2 -0
  214. frontend-v2/dist/assets/index-BaMm0UHG.js +10 -0
  215. frontend-v2/dist/assets/knowledge-xLSviV7d.js +1 -0
  216. frontend-v2/dist/assets/link-BbeBO0_K.js +1 -0
  217. frontend-v2/dist/assets/loader-circle-Ba83xNCK.js +1 -0
  218. frontend-v2/dist/assets/plugins-CzXMrQwW.js +1 -0
  219. frontend-v2/dist/assets/plus-AkERmfFa.js +1 -0
  220. frontend-v2/dist/assets/preload-helper-DbwQHY1D.js +1 -0
  221. frontend-v2/dist/assets/primitives-BW89Tfln.js +1 -0
  222. frontend-v2/dist/assets/reports-BQ3qjrcj.js +1 -0
  223. frontend-v2/dist/assets/routes-DQ3bHfQq.js +1 -0
  224. frontend-v2/dist/assets/search-2QqRG79P.js +1 -0
  225. frontend-v2/dist/assets/settings-DSkJ7P-3.js +1 -0
  226. frontend-v2/dist/assets/shield-alert-C7uviNzh.js +1 -0
  227. frontend-v2/dist/assets/states-xAIpco0A.js +1 -0
  228. frontend-v2/dist/assets/targets-UiGpvtxc.js +1 -0
  229. frontend-v2/dist/assets/trash-2-S2IA3E-_.js +1 -0
  230. frontend-v2/dist/assets/types-CQXP9NZP.js +1 -0
  231. frontend-v2/dist/assets/useNavigate-BqxVW_T_.js +1 -0
  232. frontend-v2/dist/assets/watch-mlOhZRdb.js +1 -0
  233. frontend-v2/dist/favicon.ico +0 -0
  234. frontend-v2/dist/index.html +23 -0
  235. frontend-v2/dist/robots.txt +14 -0
cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+
cli/_discovery.py ADDED
@@ -0,0 +1,43 @@
1
+ """
2
+ cli/_discovery.py — plugin auto-discovery.
3
+
4
+ Where plugins live on disk and how they get imported/registered.
5
+ Split out of cli/main.py (which re-exports these names for compatibility).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import logging
12
+ import os
13
+ from pathlib import Path
14
+
15
+ logger = logging.getLogger("ccss")
16
+
17
+
18
+ def _plugin_dirs() -> list[Path]:
19
+ """Directories to scan for plugins: the built-in package dir, plus the
20
+ external $CASPAR_PLUGINS_DIR (a mounted volume) when set, so fetched
21
+ plugins persist outside the image."""
22
+ dirs = [Path(__file__).parent.parent / "config_assessment" / "plugins"]
23
+ external = os.environ.get("CASPAR_PLUGINS_DIR")
24
+ if external:
25
+ dirs.append(Path(external))
26
+ return dirs
27
+
28
+
29
+ def _discover_plugins() -> None:
30
+ seen: set[str] = set()
31
+ for plugins_dir in _plugin_dirs():
32
+ if not plugins_dir.exists():
33
+ continue
34
+ for plugin_dir in sorted(plugins_dir.iterdir()):
35
+ name = plugin_dir.name
36
+ if name in seen:
37
+ continue # built-in dir wins on a name clash
38
+ if plugin_dir.is_dir() and (plugin_dir / "__init__.py").exists():
39
+ seen.add(name)
40
+ try:
41
+ importlib.import_module(f"config_assessment.plugins.{name}")
42
+ except Exception as exc:
43
+ logger.warning("Plugin '%s' not loaded: %s", name, exc)
cli/_knowledge.py ADDED
@@ -0,0 +1,194 @@
1
+ """
2
+ cli/_knowledge.py — the target's build-time RAG knowledge base.
3
+
4
+ Knowledge (benchmark + service manual + shared NISTIR/CCSS reference) is
5
+ ingested ONCE at build time (`plugin add --manual`) and DISCOVERED from disk
6
+ on every scan — never passed as a runtime flag. Layer 3 (--assess-unknown)
7
+ retrieves from it. Split out of cli/main.py (which re-exports these names).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from pathlib import Path
14
+
15
+ import click
16
+
17
+ from cli._discovery import _plugin_dirs
18
+
19
+ logger = logging.getLogger("ccss")
20
+
21
+
22
+ def _find_benchmark_file(target_name: str) -> Path | None:
23
+ """Locate the benchmark PDF/XML shipped inside a plugin directory, so it can
24
+ ground the --assess-unknown RAG. Best-effort: returns the first match."""
25
+ for base in _plugin_dirs():
26
+ pdir = base / target_name
27
+ if not pdir.is_dir():
28
+ continue
29
+ for pat in ("*.pdf", "*.xml"):
30
+ hits = sorted(pdir.glob(pat))
31
+ if hits:
32
+ return hits[0]
33
+ return None
34
+
35
+
36
+ def _find_knowledge_docs(target_name: str) -> list[Path]:
37
+ """Every knowledge document available to the RAG for this target, discovered
38
+ from disk — no runtime flag needed. This is the build-time-knowledge model:
39
+ you drop the docs next to the plugin (or ship the shared CCSS reference) and
40
+ they are always retrievable at assessment time.
41
+
42
+ Collected, de-duplicated, in priority order:
43
+ 1. the plugin's own docs — benchmark, service manual, any PDF/XML/txt in
44
+ `plugins/<target>/` (and a `docs/` subdir if present);
45
+ 2. the shared CCSS reference (NISTIR 7502) at the project root, relevant
46
+ to every target.
47
+ """
48
+ docs: list[Path] = []
49
+ seen: set[str] = set()
50
+
51
+ def _add(p: Path) -> None:
52
+ rp = str(p.resolve())
53
+ if p.is_file() and rp not in seen:
54
+ seen.add(rp)
55
+ docs.append(p)
56
+
57
+ # The plugin dir on disk may use '_' where the target name uses '-'
58
+ # (apache-httpd → apache_httpd), so try both spellings.
59
+ variants = {target_name, target_name.replace("-", "_"),
60
+ target_name.replace("_", "-")}
61
+ for base in _plugin_dirs():
62
+ for name in variants:
63
+ pdir = base / name
64
+ if not pdir.is_dir():
65
+ continue
66
+ # A PDF ingested with a .md sidecar (extracted at build time) is
67
+ # indexed via the sidecar — same content, better chunks, and no
68
+ # double-indexing of the pair.
69
+ md_stems = {p.stem for pat in ("*.md", "docs/*.md")
70
+ for p in pdir.glob(pat)}
71
+ for pat in ("*.md", "*.pdf", "*.xml", "*.txt",
72
+ "docs/*.md", "docs/*.pdf", "docs/*.txt"):
73
+ for hit in sorted(pdir.glob(pat)):
74
+ if hit.suffix.lower() == ".pdf" and hit.stem in md_stems:
75
+ continue
76
+ _add(hit)
77
+
78
+ # Shared CCSS reference (NISTIR 7502) — applies to every service.
79
+ for root in {Path.cwd(), Path(__file__).resolve().parent.parent}:
80
+ for name in ("nistir7502.pdf", "documentosccss/nistir7502.pdf"):
81
+ _add(root / name)
82
+
83
+ return docs
84
+
85
+
86
+ class _CombinedRAG:
87
+ """Query several RAG indexes and merge their top sections. Lets
88
+ --assess-unknown draw context from the benchmark AND user-supplied --docs."""
89
+
90
+ def __init__(self, indexes: list) -> None:
91
+ self._indexes = indexes
92
+
93
+ def query(self, text: str, top_k: int = 3) -> list:
94
+ out: list = []
95
+ for idx in self._indexes:
96
+ try:
97
+ out.extend(idx.query(text, top_k=top_k))
98
+ except Exception:
99
+ continue
100
+ return out[: top_k * max(1, len(self._indexes))]
101
+
102
+
103
+ def _assess_unknown_directives(result, docs_path: str | None) -> None:
104
+ """Layer 3: build a RAG index over this target's KNOWLEDGE BASE and run the
105
+ LLM over the surfaced unknown directives. Mutates result.unknown_directives.
106
+ Degrades gracefully — any failure just leaves the LLM fields empty.
107
+
108
+ The RAG context is the knowledge already gathered for the target: its
109
+ benchmark, service manual, and the shared CCSS reference — discovered from
110
+ disk, not passed each run. --docs only ADDS an extra document on top."""
111
+ from config_assessment.build.llm_client import make_client
112
+ from config_assessment.build.rag import BenchmarkIndex
113
+ from config_assessment.core.unknown_directives import assess_unknown_with_llm
114
+
115
+ sources = _find_knowledge_docs(result.target_name)
116
+ if docs_path:
117
+ sources.append(Path(docs_path)) # optional extra, added on top
118
+
119
+ indexes = []
120
+ for src in sources:
121
+ if src and src.exists():
122
+ try:
123
+ indexes.append(BenchmarkIndex(str(src)))
124
+ except Exception as exc:
125
+ logger.warning("Could not index %s for RAG: %s", src, exc)
126
+ rag = _CombinedRAG(indexes) if indexes else None
127
+
128
+ click.echo(click.style(
129
+ f" Assessing {len(result.unknown_directives)} uncovered directive(s) "
130
+ f"with LLM{f' + RAG ({len(indexes)} docs)' if rag else ''} "
131
+ f"(non-deterministic)…", dim=True))
132
+ llm = make_client(backend="ollama", fallback_to_stub=True)
133
+ assess_unknown_with_llm(
134
+ result.unknown_directives, service=result.target_name,
135
+ llm=llm, rag_index=rag)
136
+
137
+
138
+ def _pdf_to_markdown(pdf: Path, out_md: Path) -> bool:
139
+ """Extract a PDF's text to a markdown sidecar via `pdftotext -layout`
140
+ (deterministic — no LLM rewrites the knowledge base). The .md is what the
141
+ RAG indexes (better chunks) and what a human audits: you can READ exactly
142
+ what grounds the LLM. Best-effort; returns False when pdftotext is absent."""
143
+ import shutil as _sh
144
+ import subprocess
145
+ if not _sh.which("pdftotext"):
146
+ return False
147
+ try:
148
+ subprocess.run(["pdftotext", "-layout", str(pdf), str(out_md)],
149
+ timeout=120, check=True, capture_output=True)
150
+ return out_md.is_file() and out_md.stat().st_size > 0
151
+ except (OSError, subprocess.SubprocessError):
152
+ return False
153
+
154
+
155
+ def _ingest_manual(manual: str, plugin_dir: Path) -> Path | None:
156
+ """Copy/download a service manual into the plugin dir, so it becomes part of
157
+ the target's RAG knowledge base at BUILD time (retrieved on every scan, not
158
+ passed each run). `manual` is a local path OR an http(s) URL to a PDF/text
159
+ (e.g. https://archive.apache.org/dist/httpd/docs/…). A PDF also gets a
160
+ readable .md sidecar (extracted once, here — build-time), which the RAG
161
+ prefers over re-reading the PDF every scan. Best-effort."""
162
+ import shutil
163
+ from urllib.parse import urlparse
164
+ from urllib.request import urlopen, Request
165
+
166
+ plugin_dir.mkdir(parents=True, exist_ok=True)
167
+ if manual.startswith(("http://", "https://")):
168
+ fname = Path(urlparse(manual).path).name or "manual.pdf"
169
+ dest = plugin_dir / f"manual_{fname}"
170
+ try:
171
+ req = Request(manual, headers={"User-Agent": "caspar-plugin-add"})
172
+ with urlopen(req, timeout=30) as r, open(dest, "wb") as f:
173
+ shutil.copyfileobj(r, f)
174
+ except Exception as exc:
175
+ click.echo(click.style(
176
+ f" Could not download manual from {manual}: {exc}", fg="yellow"),
177
+ err=True)
178
+ return None
179
+ else:
180
+ src = Path(manual)
181
+ if not src.exists():
182
+ click.echo(click.style(f" Manual not found: {manual}", fg="yellow"),
183
+ err=True)
184
+ return None
185
+ dest = plugin_dir / f"manual_{src.name}"
186
+ shutil.copy(src, dest)
187
+
188
+ click.echo(f" Manual saved: {click.style(str(dest), fg='cyan')}")
189
+ if dest.suffix.lower() == ".pdf":
190
+ sidecar = dest.with_suffix(".md")
191
+ if _pdf_to_markdown(dest, sidecar):
192
+ click.echo(f" Extracted: {click.style(str(sidecar), fg='cyan')}"
193
+ + click.style(" (what the RAG reads — auditable)", dim=True))
194
+ return dest