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.
- cli/__init__.py +1 -0
- cli/_discovery.py +43 -0
- cli/_knowledge.py +194 -0
- cli/_output.py +944 -0
- cli/_publish.py +68 -0
- cli/commands/__init__.py +1 -0
- cli/commands/build_cmds.py +191 -0
- cli/commands/chain_cmds.py +197 -0
- cli/commands/demo_cmds.py +258 -0
- cli/commands/init_cmds.py +116 -0
- cli/commands/manage_cmds.py +380 -0
- cli/commands/plugin_cmds.py +438 -0
- cli/commands/publish_cmds.py +40 -0
- cli/commands/report_cmds.py +342 -0
- cli/commands/scan_cmds.py +622 -0
- cli/commands/serve_cmds.py +176 -0
- cli/main.py +136 -0
- config_assessment/__init__.py +1 -0
- config_assessment/api/__init__.py +8 -0
- config_assessment/api/app.py +71 -0
- config_assessment/api/chains.py +112 -0
- config_assessment/api/deps.py +36 -0
- config_assessment/api/findings.py +191 -0
- config_assessment/api/job_runner.py +139 -0
- config_assessment/api/routers/__init__.py +0 -0
- config_assessment/api/routers/builds.py +91 -0
- config_assessment/api/routers/chains.py +63 -0
- config_assessment/api/routers/dimensions.py +200 -0
- config_assessment/api/routers/findings.py +146 -0
- config_assessment/api/routers/health.py +32 -0
- config_assessment/api/routers/hosts.py +147 -0
- config_assessment/api/routers/jobs.py +56 -0
- config_assessment/api/routers/knowledge.py +114 -0
- config_assessment/api/routers/maintenance.py +80 -0
- config_assessment/api/routers/manage.py +260 -0
- config_assessment/api/routers/plugins.py +107 -0
- config_assessment/api/routers/posture.py +263 -0
- config_assessment/api/routers/reports.py +75 -0
- config_assessment/api/routers/scans.py +202 -0
- config_assessment/api/routers/targets.py +86 -0
- config_assessment/api/routers/trends.py +45 -0
- config_assessment/api/routers/watch.py +200 -0
- config_assessment/api/schemas.py +74 -0
- config_assessment/api/schemas_jobs.py +77 -0
- config_assessment/api/schemas_maintenance.py +29 -0
- config_assessment/api/schemas_manage.py +124 -0
- config_assessment/api/schemas_watch.py +82 -0
- config_assessment/api/scoring_explain.py +217 -0
- config_assessment/api/watch_runner.py +252 -0
- config_assessment/build/__init__.py +1 -0
- config_assessment/build/benchmark_extractor.py +395 -0
- config_assessment/build/build_utils.py +224 -0
- config_assessment/build/chain_pipeline.py +417 -0
- config_assessment/build/curated_build.py +75 -0
- config_assessment/build/generic_build.py +80 -0
- config_assessment/build/llm_client.py +452 -0
- config_assessment/build/plugin_detector.py +151 -0
- config_assessment/build/plugin_scaffolder.py +268 -0
- config_assessment/build/rag.py +331 -0
- config_assessment/core/__init__.py +1 -0
- config_assessment/core/ccss.py +43 -0
- config_assessment/core/collectors/__init__.py +44 -0
- config_assessment/core/collectors/exposure.py +305 -0
- config_assessment/core/collectors/permissions.py +180 -0
- config_assessment/core/db/__init__.py +1 -0
- config_assessment/core/db/ccss_canonical.sql.gz +0 -0
- config_assessment/core/db/database.py +1229 -0
- config_assessment/core/db/doctor.py +155 -0
- config_assessment/core/db/reseed.py +124 -0
- config_assessment/core/db/schema.sql +209 -0
- config_assessment/core/engines/__init__.py +81 -0
- config_assessment/core/engines/aggregation.py +247 -0
- config_assessment/core/engines/assessment.py +235 -0
- config_assessment/core/engines/attack_chain.py +78 -0
- config_assessment/core/engines/categorization.py +74 -0
- config_assessment/core/engines/chain_authoring.py +171 -0
- config_assessment/core/engines/dimensions.py +308 -0
- config_assessment/core/engines/knowledge.py +65 -0
- config_assessment/core/engines/reporting.py +32 -0
- config_assessment/core/engines/scoring.py +341 -0
- config_assessment/core/input_resolver.py +838 -0
- config_assessment/core/inventory.py +132 -0
- config_assessment/core/manifest.py +103 -0
- config_assessment/core/models.py +233 -0
- config_assessment/core/runtime.py +311 -0
- config_assessment/core/target.py +130 -0
- config_assessment/core/unknown_directives.py +311 -0
- config_assessment/core/watch.py +157 -0
- config_assessment/core/watch_loop.py +61 -0
- config_assessment/core/watch_session.py +65 -0
- config_assessment/enrichment/__init__.py +1 -0
- config_assessment/enrichment/cve_enricher.py +659 -0
- config_assessment/enrichment/exploit_enricher.py +163 -0
- config_assessment/enrichment/ttp_enricher.py +119 -0
- config_assessment/enrichment/version_prefetch.py +96 -0
- config_assessment/fetch/__init__.py +0 -0
- config_assessment/fetch/benchmark_fetcher.py +394 -0
- config_assessment/fetch/catalog.json +568 -0
- config_assessment/fetch/ssg_source.py +414 -0
- config_assessment/parsers/__init__.py +1 -0
- config_assessment/parsers/arm_json.py +109 -0
- config_assessment/parsers/bicep_flat.py +139 -0
- config_assessment/parsers/dockerfile.py +105 -0
- config_assessment/parsers/hcl_flat.py +210 -0
- config_assessment/parsers/key_value.py +108 -0
- config_assessment/parsers/yaml_flat.py +99 -0
- config_assessment/plugins/__init__.py +22 -0
- config_assessment/plugins/apache_httpd/__init__.py +195 -0
- config_assessment/plugins/apache_httpd/build_apache.py +728 -0
- config_assessment/plugins/apache_httpd/build_llm.py +338 -0
- config_assessment/plugins/apache_httpd/build_narratives.py +113 -0
- config_assessment/plugins/apache_httpd/cce-apache-httpd2.2-5.20130214.xlsx +0 -0
- config_assessment/plugins/apache_httpd/chains.json +125 -0
- config_assessment/plugins/apache_httpd/llm_pipeline.py +704 -0
- config_assessment/plugins/apache_httpd/narrative_pipeline.py +312 -0
- config_assessment/plugins/apache_httpd/parser.py +198 -0
- config_assessment/plugins/apache_httpd/refresh_cve.py +158 -0
- config_assessment/plugins/apache_httpd/rules.py +123 -0
- config_assessment/plugins/apache_httpd/validate_mae.py +266 -0
- config_assessment/plugins/azure_iac/__init__.py +122 -0
- config_assessment/plugins/azure_iac/build_azure.py +283 -0
- config_assessment/plugins/azure_iac/canon.py +35 -0
- config_assessment/plugins/docker/__init__.py +68 -0
- config_assessment/plugins/docker/build_docker.py +72 -0
- config_assessment/plugins/docker/chains.json +13 -0
- config_assessment/plugins/docker/parser.py +8 -0
- config_assessment/plugins/docker/rules.py +37 -0
- config_assessment/plugins/dockerfile/__init__.py +97 -0
- config_assessment/plugins/dockerfile/build_dockerfile.py +34 -0
- config_assessment/plugins/dockerfile/parser.py +8 -0
- config_assessment/plugins/dockerfile/rules.py +67 -0
- config_assessment/plugins/dummy/__init__.py +71 -0
- config_assessment/plugins/kubernetes/__init__.py +106 -0
- config_assessment/plugins/kubernetes/build_kubernetes.py +35 -0
- config_assessment/plugins/kubernetes/chains.json +10 -0
- config_assessment/plugins/kubernetes/parser.py +9 -0
- config_assessment/plugins/kubernetes/rules.py +98 -0
- config_assessment/plugins/mysql/__init__.py +69 -0
- config_assessment/plugins/mysql/build_mysql.py +39 -0
- config_assessment/plugins/mysql/parser.py +8 -0
- config_assessment/plugins/mysql/rules.py +36 -0
- config_assessment/plugins/nginx/__init__.py +177 -0
- config_assessment/plugins/nginx/build_nginx.py +376 -0
- config_assessment/plugins/nginx/chains.json +26 -0
- config_assessment/plugins/nginx/parser.py +272 -0
- config_assessment/plugins/nginx/rules.py +118 -0
- config_assessment/plugins/postgresql/__init__.py +68 -0
- config_assessment/plugins/postgresql/build_postgresql.py +41 -0
- config_assessment/plugins/postgresql/chains.json +57 -0
- config_assessment/plugins/postgresql/parser.py +8 -0
- config_assessment/plugins/postgresql/rules.py +48 -0
- config_assessment/plugins/redis/U_Redis_Enterprise_6-x_STIG_V2R2_Manual-xccdf.xml +2173 -0
- config_assessment/plugins/redis/__init__.py +68 -0
- config_assessment/plugins/redis/build_redis.py +25 -0
- config_assessment/plugins/redis/chains.json +57 -0
- config_assessment/plugins/redis/parser.py +8 -0
- config_assessment/plugins/redis/rules.py +44 -0
- config_assessment/plugins/ssh/__init__.py +165 -0
- config_assessment/plugins/ssh/build_ssh.py +221 -0
- config_assessment/plugins/ssh/parser.py +193 -0
- config_assessment/plugins/ssh/rules.py +96 -0
- config_assessment/plugins/tomcat/U_Apache_Tomcat_Application_Server_9_STIG_V3R4_Manual-xccdf.xml +1620 -0
- config_assessment/plugins/tomcat/__init__.py +68 -0
- config_assessment/plugins/tomcat/build_tomcat.py +68 -0
- config_assessment/plugins/tomcat/chains.json +35 -0
- config_assessment/plugins/tomcat/parser.py +8 -0
- config_assessment/plugins/tomcat/rules.py +55 -0
- config_assessment/plugins/ubuntu/__init__.py +93 -0
- config_assessment/plugins/ubuntu/build_ubuntu.py +34 -0
- config_assessment/plugins/ubuntu/parser.py +10 -0
- config_assessment/plugins/ubuntu/rules.py +140 -0
- config_assessment/plugins/ubuntu2204/__init__.py +141 -0
- config_assessment/plugins/ubuntu2204/build_ubuntu2204.py +34 -0
- config_assessment/plugins/ubuntu2204/rules.py +283 -0
- config_assessment/reports/__init__.py +1 -0
- config_assessment/reports/remediation.py +178 -0
- config_assessment/reports/report_dashboard.py +942 -0
- config_assessment/reports/report_dashboard_online.py +419 -0
- config_assessment/reports/report_html.py +507 -0
- config_assessment/reports/scan_features.py +225 -0
- cvm_caspar-1.0.0.dist-info/METADATA +149 -0
- cvm_caspar-1.0.0.dist-info/RECORD +235 -0
- cvm_caspar-1.0.0.dist-info/WHEEL +4 -0
- cvm_caspar-1.0.0.dist-info/entry_points.txt +2 -0
- cvm_caspar-1.0.0.dist-info/licenses/LICENSE +202 -0
- cvm_caspar-1.0.0.dist-info/licenses/NOTICE +53 -0
- frontend/dist/assets/charts-BLacFBYH.js +73 -0
- frontend/dist/assets/index-CUhIFMOg.css +1 -0
- frontend/dist/assets/index-T2YLv5jH.js +358 -0
- frontend/dist/assets/query-CKBowX9j.js +9 -0
- frontend/dist/assets/react-DBpqIJvm.js +59 -0
- frontend/dist/favicon.svg +11 -0
- frontend/dist/index.html +18 -0
- frontend-v2/dist/assets/JobConsole-B5TzVvBO.js +1 -0
- frontend-v2/dist/assets/Pager-DGkcPZxi.js +1 -0
- frontend-v2/dist/assets/arrow-right-CoUZZYja.js +1 -0
- frontend-v2/dist/assets/assessment-CWjDYtm4.js +1 -0
- frontend-v2/dist/assets/build-CWwe6xXy.js +1 -0
- frontend-v2/dist/assets/chains-Bp-2RuOM.js +1 -0
- frontend-v2/dist/assets/chains-CHoELElb.js +1 -0
- frontend-v2/dist/assets/charts-DaqLp_gX.js +59 -0
- frontend-v2/dist/assets/circle-slash-CXTugZTs.js +1 -0
- frontend-v2/dist/assets/dimensions-BAF2npX5.js +1 -0
- frontend-v2/dist/assets/dimensions._dimensionId-C3_eBojp.js +1 -0
- frontend-v2/dist/assets/dimensions._dimensionId-DFLMAtbl.js +2 -0
- frontend-v2/dist/assets/dimensions.index-BBjA5Bzn.js +1 -0
- frontend-v2/dist/assets/download-Dh1LJG-8.js +1 -0
- frontend-v2/dist/assets/file-code-corner-BWMYzPBG.js +1 -0
- frontend-v2/dist/assets/findings-2alsHeTl.js +1 -0
- frontend-v2/dist/assets/findings-lLEMYnEK.js +1 -0
- frontend-v2/dist/assets/forms-DgdCPcVc.js +1 -0
- frontend-v2/dist/assets/history-S4IgFFeE.js +1 -0
- frontend-v2/dist/assets/index-BZutiq_g.css +2 -0
- frontend-v2/dist/assets/index-BaMm0UHG.js +10 -0
- frontend-v2/dist/assets/knowledge-xLSviV7d.js +1 -0
- frontend-v2/dist/assets/link-BbeBO0_K.js +1 -0
- frontend-v2/dist/assets/loader-circle-Ba83xNCK.js +1 -0
- frontend-v2/dist/assets/plugins-CzXMrQwW.js +1 -0
- frontend-v2/dist/assets/plus-AkERmfFa.js +1 -0
- frontend-v2/dist/assets/preload-helper-DbwQHY1D.js +1 -0
- frontend-v2/dist/assets/primitives-BW89Tfln.js +1 -0
- frontend-v2/dist/assets/reports-BQ3qjrcj.js +1 -0
- frontend-v2/dist/assets/routes-DQ3bHfQq.js +1 -0
- frontend-v2/dist/assets/search-2QqRG79P.js +1 -0
- frontend-v2/dist/assets/settings-DSkJ7P-3.js +1 -0
- frontend-v2/dist/assets/shield-alert-C7uviNzh.js +1 -0
- frontend-v2/dist/assets/states-xAIpco0A.js +1 -0
- frontend-v2/dist/assets/targets-UiGpvtxc.js +1 -0
- frontend-v2/dist/assets/trash-2-S2IA3E-_.js +1 -0
- frontend-v2/dist/assets/types-CQXP9NZP.js +1 -0
- frontend-v2/dist/assets/useNavigate-BqxVW_T_.js +1 -0
- frontend-v2/dist/assets/watch-mlOhZRdb.js +1 -0
- frontend-v2/dist/favicon.ico +0 -0
- frontend-v2/dist/index.html +23 -0
- 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
|