drydock-cli 3.0.73__tar.gz → 3.0.74__tar.gz
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.
- {drydock_cli-3.0.73/drydock_cli.egg-info → drydock_cli-3.0.74}/PKG-INFO +1 -1
- drydock_cli-3.0.74/drydock/cci.py +109 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/rmf_graph.py +13 -5
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/tui/app.py +27 -14
- {drydock_cli-3.0.73 → drydock_cli-3.0.74/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock_cli.egg-info/SOURCES.txt +2 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/pyproject.toml +1 -1
- drydock_cli-3.0.74/tests/test_cci.py +82 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/LICENSE +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/NOTICE +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/README.md +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/agent.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/builtin_skills/stig-assess.md +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/builtin_skills/stig-remediate.md +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/cli.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/config.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/detect.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/extract.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/guards.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/providers.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/rmf.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/skills.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/stig.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/web.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/setup.cfg +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_config.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_e2e_connected.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_extract.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_rmf.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_rmf_graph.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_rmf_stig_graph.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_stig.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_web_tools.py +0 -0
- {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_xccdf.py +0 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""CCI → NIST 800-53 control mapping.
|
|
2
|
+
|
|
3
|
+
DISA's Control Correlation Identifiers (CCIs) are the bridge between a STIG rule
|
|
4
|
+
and the NIST control it enforces: every STIG rule cites a CCI, and the CCI maps
|
|
5
|
+
to an 800-53 control. This builds that map from DISA's `U_CCI_List.xml` so
|
|
6
|
+
`/stig graph` can auto-create `Control —SATISFIED_BY→ STIG-Rule` edges.
|
|
7
|
+
|
|
8
|
+
The mappings are U.S. Government public-domain data (facts, not copyrightable).
|
|
9
|
+
The list is fetched once and cached locally; offline-safe. Stdlib only.
|
|
10
|
+
|
|
11
|
+
All logic original to Drydock.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
import xml.etree.ElementTree as ET
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
# DISA U_CCI_List.xml (official list, mirrored). Government public-domain data.
|
|
23
|
+
_CCI_URL = ("https://raw.githubusercontent.com/CyberSecDef/Cyber.Trackr.Live/master/"
|
|
24
|
+
"cyber.trackr.live/resources/data/cci/U_CCI_List.xml")
|
|
25
|
+
_UA = "drydock-cli"
|
|
26
|
+
_RE_CONTROL = re.compile(r"\s*([A-Za-z]{2})-(\d+)(?:\s*\(?(\d+)\)?|\.(\d+))?")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cache_path(cwd: str) -> Path:
|
|
30
|
+
return Path(cwd) / ".drydock" / "rmf" / "cci_map.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _ln(el) -> str:
|
|
34
|
+
return el.tag.split("}")[-1]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _norm_control(index: str) -> str | None:
|
|
38
|
+
"""'AC-10' -> 'ac-10'; 'AC-2 (1)' / 'AC-2.1' -> 'ac-2.1'; junk -> None."""
|
|
39
|
+
m = _RE_CONTROL.match(index or "")
|
|
40
|
+
if not m:
|
|
41
|
+
return None
|
|
42
|
+
fam, num = m.group(1).lower(), m.group(2)
|
|
43
|
+
enh = m.group(3) or m.group(4)
|
|
44
|
+
return f"{fam}-{num}.{enh}" if enh else f"{fam}-{num}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_cci_list(path: str | Path) -> dict[str, str]:
|
|
48
|
+
"""Parse U_CCI_List.xml -> {CCI-id: control-id}. Picks the highest 800-53
|
|
49
|
+
revision reference (excludes 800-53A, which lists assessment procedures)."""
|
|
50
|
+
root = ET.parse(path).getroot()
|
|
51
|
+
out: dict[str, str] = {}
|
|
52
|
+
for item in (e for e in root.iter() if _ln(e) == "cci_item"):
|
|
53
|
+
cid = item.get("id")
|
|
54
|
+
best: str | None = None
|
|
55
|
+
best_ver = -1
|
|
56
|
+
for ref in item.iter():
|
|
57
|
+
if _ln(ref) != "reference":
|
|
58
|
+
continue
|
|
59
|
+
title = ref.get("title", "")
|
|
60
|
+
if not title.startswith("NIST SP 800-53") or "800-53A" in title:
|
|
61
|
+
continue
|
|
62
|
+
try:
|
|
63
|
+
ver = int(ref.get("version") or 0)
|
|
64
|
+
except ValueError:
|
|
65
|
+
ver = 0
|
|
66
|
+
ctl = _norm_control(ref.get("index", ""))
|
|
67
|
+
if ctl and ver >= best_ver:
|
|
68
|
+
best, best_ver = ctl, ver
|
|
69
|
+
if cid and best:
|
|
70
|
+
out[cid] = best
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fetch_to(path: str | Path, *, timeout: float = 60.0) -> None:
|
|
75
|
+
req = urllib.request.Request(_CCI_URL, headers={"User-Agent": _UA})
|
|
76
|
+
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (trusted host)
|
|
77
|
+
data = r.read()
|
|
78
|
+
Path(path).write_bytes(data)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_map(cwd: str, *, refresh: bool = False) -> dict[str, str]:
|
|
82
|
+
"""Return the cached {CCI: control} map, building it from U_CCI_List.xml on
|
|
83
|
+
first use (or --refresh). Offline-safe: returns the cache if the fetch fails,
|
|
84
|
+
or {} if there's no cache and no network."""
|
|
85
|
+
cp = cache_path(cwd)
|
|
86
|
+
if cp.exists() and not refresh:
|
|
87
|
+
try:
|
|
88
|
+
return json.loads(cp.read_text("utf-8"))
|
|
89
|
+
except (OSError, ValueError):
|
|
90
|
+
pass
|
|
91
|
+
cp.parent.mkdir(parents=True, exist_ok=True) # ensure .drydock/rmf/ exists before writing
|
|
92
|
+
raw = cp.with_suffix(".xml")
|
|
93
|
+
try:
|
|
94
|
+
fetch_to(raw)
|
|
95
|
+
except (urllib.error.URLError, OSError):
|
|
96
|
+
if cp.exists():
|
|
97
|
+
try:
|
|
98
|
+
return json.loads(cp.read_text("utf-8"))
|
|
99
|
+
except (OSError, ValueError):
|
|
100
|
+
return {}
|
|
101
|
+
return {}
|
|
102
|
+
mapping = parse_cci_list(raw)
|
|
103
|
+
cp.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
cp.write_text(json.dumps(mapping), "utf-8")
|
|
105
|
+
try:
|
|
106
|
+
raw.unlink()
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
return mapping
|
|
@@ -126,11 +126,11 @@ def rule_node(rid: str) -> str:
|
|
|
126
126
|
return f"stigrule:{rid.strip().lower()}"
|
|
127
127
|
|
|
128
128
|
|
|
129
|
-
def ingest_checklist(g: "RmfGraph", checklist) -> dict:
|
|
129
|
+
def ingest_checklist(g: "RmfGraph", checklist, cci_map: dict | None = None) -> dict:
|
|
130
130
|
"""Add a parsed STIG Checklist to the typed graph: STIG + STIG-Rule nodes,
|
|
131
|
-
PART_OF (rule→stig), APPLIES_TO (stig→host), EVALUATES (rule→host).
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
PART_OF (rule→stig), APPLIES_TO (stig→host), EVALUATES (rule→host). When a
|
|
132
|
+
CCI→control map is supplied, also auto-creates Control —SATISFIED_BY→ STIG-Rule
|
|
133
|
+
edges via each rule's CCI. Returns {rules, host, linked}."""
|
|
134
134
|
host = (checklist.asset.get("HOST_NAME") or checklist.asset.get("host_name") or "target")
|
|
135
135
|
hnode = component_id(host)
|
|
136
136
|
g.add_node(hnode, "Component", name=host, ip=checklist.asset.get("HOST_IP"))
|
|
@@ -138,6 +138,7 @@ def ingest_checklist(g: "RmfGraph", checklist) -> dict:
|
|
|
138
138
|
snode = stig_id(sname)
|
|
139
139
|
g.add_node(snode, "STIG", name=sname, version=checklist.stig_version)
|
|
140
140
|
g.add_edge(snode, "APPLIES_TO", hnode)
|
|
141
|
+
linked = 0
|
|
141
142
|
for r in checklist.rules:
|
|
142
143
|
rid = r.rule_id or r.group_id
|
|
143
144
|
if not rid:
|
|
@@ -147,7 +148,14 @@ def ingest_checklist(g: "RmfGraph", checklist) -> dict:
|
|
|
147
148
|
severity=r.severity, status=r.status, cci=r.cci, title=r.title)
|
|
148
149
|
g.add_edge(rn, "PART_OF", snode)
|
|
149
150
|
g.add_edge(rn, "EVALUATES", hnode)
|
|
150
|
-
|
|
151
|
+
ctl = (cci_map or {}).get(r.cci)
|
|
152
|
+
if ctl:
|
|
153
|
+
cn = control_id(ctl)
|
|
154
|
+
if not g.get(cn):
|
|
155
|
+
g.add_node(cn, "Control", control_id=ctl.upper())
|
|
156
|
+
g.add_edge(cn, "SATISFIED_BY", rn)
|
|
157
|
+
linked += 1
|
|
158
|
+
return {"rules": len(checklist.rules), "host": host, "linked": linked}
|
|
151
159
|
|
|
152
160
|
|
|
153
161
|
def build_from_catalog(catalog: dict, *, families: list[str] | None = None) -> RmfGraph:
|
|
@@ -563,21 +563,10 @@ class DrydockApp(App):
|
|
|
563
563
|
return
|
|
564
564
|
# /stig graph <path> — ingest the checklist into the RMF typed graph
|
|
565
565
|
if parts[0].lower() == "graph" and len(parts) > 1:
|
|
566
|
-
from drydock import rmf_graph
|
|
567
566
|
gp = parts[1] if _os.path.isabs(parts[1]) else _os.path.join(cwd, parts[1])
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
r = rmf_graph.ingest_checklist(g, cl)
|
|
572
|
-
g.save(rmf_graph.graph_path(cwd))
|
|
573
|
-
except Exception as e: # noqa: BLE001
|
|
574
|
-
self._mount(ErrorMessage(f"could not graph checklist: {e}"))
|
|
575
|
-
return
|
|
576
|
-
self._info(
|
|
577
|
-
f"✓ Ingested {r['rules']} STIG rules for host '{r['host']}' into the "
|
|
578
|
-
f"RMF graph (STIG/STIG-Rule nodes, PART_OF/APPLIES_TO/EVALUATES). "
|
|
579
|
-
"Link rules to controls with GraphAdd satisfies; trace via GraphQuery."
|
|
580
|
-
)
|
|
567
|
+
self._info("Ingesting the checklist into the RMF graph "
|
|
568
|
+
"(fetching the DISA CCI→800-53 map on first use)…")
|
|
569
|
+
self.run_worker(lambda: self._stig_graph(cwd, gp), thread=True)
|
|
581
570
|
return
|
|
582
571
|
path = parts[0]
|
|
583
572
|
if not _os.path.isabs(path):
|
|
@@ -643,6 +632,30 @@ class DrydockApp(App):
|
|
|
643
632
|
"catalog download; after that it works offline.)")
|
|
644
633
|
self.call_from_thread(self._info, msg)
|
|
645
634
|
|
|
635
|
+
def _stig_graph(self, cwd: str, path: str) -> None:
|
|
636
|
+
"""Worker-thread body: ingest a checklist into the RMF graph, auto-linking
|
|
637
|
+
rules to NIST controls via the DISA CCI map. Reports back on the UI."""
|
|
638
|
+
from drydock import cci, rmf_graph, stig
|
|
639
|
+
|
|
640
|
+
try:
|
|
641
|
+
cl = stig.load(path)
|
|
642
|
+
cci_map = cci.load_map(cwd) # fetch+cache once; offline-safe ({} on failure)
|
|
643
|
+
g = rmf_graph.RmfGraph.load(rmf_graph.graph_path(cwd))
|
|
644
|
+
r = rmf_graph.ingest_checklist(g, cl, cci_map)
|
|
645
|
+
g.save(rmf_graph.graph_path(cwd))
|
|
646
|
+
link_note = (
|
|
647
|
+
f"auto-linked {r['linked']}/{r['rules']} rules to NIST controls via CCI "
|
|
648
|
+
"(Control —SATISFIED_BY→ rule). Trace with GraphQuery control <id>."
|
|
649
|
+
if r["linked"] else
|
|
650
|
+
"(no CCI→control links — the CCI map was unavailable offline; rules are "
|
|
651
|
+
"still in the graph. Re-run online, or use GraphAdd satisfies.)"
|
|
652
|
+
)
|
|
653
|
+
msg = (f"✓ Ingested {r['rules']} STIG rules for host '{r['host']}' into the "
|
|
654
|
+
f"RMF graph (STIG/STIG-Rule + PART_OF/APPLIES_TO/EVALUATES); {link_note}")
|
|
655
|
+
except Exception as e: # noqa: BLE001
|
|
656
|
+
msg = f"could not graph checklist: {e}"
|
|
657
|
+
self.call_from_thread(self._info, msg)
|
|
658
|
+
|
|
646
659
|
def _cmd_mcp(self) -> None:
|
|
647
660
|
"""List connected MCP servers and the tools they expose."""
|
|
648
661
|
from drydock import mcp
|
|
@@ -6,6 +6,7 @@ drydock/__init__.py
|
|
|
6
6
|
drydock/__main__.py
|
|
7
7
|
drydock/agent.py
|
|
8
8
|
drydock/bash_safety.py
|
|
9
|
+
drydock/cci.py
|
|
9
10
|
drydock/cli.py
|
|
10
11
|
drydock/compaction.py
|
|
11
12
|
drydock/config.py
|
|
@@ -49,6 +50,7 @@ tests/test_bash_output_bounding.py
|
|
|
49
50
|
tests/test_bash_process_group.py
|
|
50
51
|
tests/test_bash_safety.py
|
|
51
52
|
tests/test_bash_timeout_network.py
|
|
53
|
+
tests/test_cci.py
|
|
52
54
|
tests/test_cli_agents.py
|
|
53
55
|
tests/test_compact_command.py
|
|
54
56
|
tests/test_compaction.py
|
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|
|
7
7
|
# PyPI distribution name is drydock-cli (the established install name, continued
|
|
8
8
|
# from the retired v2 fork); the import package + CLI command stay `drydock`.
|
|
9
9
|
name = "drydock-cli"
|
|
10
|
-
version = "3.0.
|
|
10
|
+
version = "3.0.74"
|
|
11
11
|
description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""CCI → NIST 800-53 control map (the STIG-rule ↔ control bridge)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from drydock import cci, rmf_graph, stig
|
|
5
|
+
|
|
6
|
+
_CCI_XML = '''<?xml version="1.0"?><cci_list><cci_items>
|
|
7
|
+
<cci_item id="CCI-000366"><references>
|
|
8
|
+
<reference title="NIST SP 800-53" version="3" index="CM-6"/>
|
|
9
|
+
<reference title="NIST SP 800-53A" version="1" index="CM-6.1 (ii)"/>
|
|
10
|
+
<reference title="NIST SP 800-53 Revision 4" version="4" index="CM-6 b"/></references></cci_item>
|
|
11
|
+
<cci_item id="CCI-000054"><references>
|
|
12
|
+
<reference title="NIST SP 800-53 Revision 5" version="5" index="AC-10"/></references></cci_item>
|
|
13
|
+
<cci_item id="CCI-001133"><references>
|
|
14
|
+
<reference title="NIST SP 800-53 Revision 4" version="4" index="SC-10 (1)"/></references></cci_item>
|
|
15
|
+
<cci_item id="CCI-999999"><references>
|
|
16
|
+
<reference title="NIST SP 800-53A" version="1" index="ZZ-9.9"/></references></cci_item>
|
|
17
|
+
</cci_items></cci_list>'''
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_parse_picks_highest_rev_excludes_53A(tmp_path):
|
|
21
|
+
p = tmp_path / "cci.xml"; p.write_text(_CCI_XML)
|
|
22
|
+
m = cci.parse_cci_list(p)
|
|
23
|
+
assert m["CCI-000366"] == "cm-6" # rev4 base, 800-53A excluded
|
|
24
|
+
assert m["CCI-000054"] == "ac-10"
|
|
25
|
+
assert m["CCI-001133"] == "sc-10.1" # enhancement normalized
|
|
26
|
+
assert "CCI-999999" not in m # only 800-53A ref → no control
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_norm_control_formats():
|
|
30
|
+
assert cci._norm_control("AC-10") == "ac-10"
|
|
31
|
+
assert cci._norm_control("AC-2 (1)") == "ac-2.1"
|
|
32
|
+
assert cci._norm_control("AC-2.1") == "ac-2.1"
|
|
33
|
+
assert cci._norm_control("garbage") is None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_load_map_caches_and_is_offline_safe(tmp_path, monkeypatch):
|
|
37
|
+
# no network: with no cache, returns {} (never raises)
|
|
38
|
+
def boom(*a, **k):
|
|
39
|
+
raise OSError("offline")
|
|
40
|
+
monkeypatch.setattr(cci, "fetch_to", boom)
|
|
41
|
+
assert cci.load_map(str(tmp_path)) == {}
|
|
42
|
+
# seed a cache → load_map returns it without fetching
|
|
43
|
+
cp = cci.cache_path(str(tmp_path)); cp.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
cp.write_text('{"CCI-000366": "cm-6"}')
|
|
45
|
+
assert cci.load_map(str(tmp_path)) == {"CCI-000366": "cm-6"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_ingest_auto_links_via_cci(tmp_path):
|
|
49
|
+
(tmp_path / "s.ckl").write_text(
|
|
50
|
+
'<?xml version="1.0"?><CHECKLIST><ASSET><HOST_NAME>h1</HOST_NAME></ASSET><STIGS><iSTIG>'
|
|
51
|
+
'<STIG_INFO></STIG_INFO><VULN>'
|
|
52
|
+
'<STIG_DATA><VULN_ATTRIBUTE>Rule_ID</VULN_ATTRIBUTE><ATTRIBUTE_DATA>SV-9</ATTRIBUTE_DATA></STIG_DATA>'
|
|
53
|
+
'<STIG_DATA><VULN_ATTRIBUTE>CCI_REF</VULN_ATTRIBUTE><ATTRIBUTE_DATA>CCI-000366</ATTRIBUTE_DATA></STIG_DATA>'
|
|
54
|
+
'<STATUS>Open</STATUS></VULN></iSTIG></STIGS></CHECKLIST>')
|
|
55
|
+
cl = stig.load(tmp_path / "s.ckl")
|
|
56
|
+
g = rmf_graph.RmfGraph()
|
|
57
|
+
r = rmf_graph.ingest_checklist(g, cl, {"CCI-000366": "cm-6"})
|
|
58
|
+
assert r["linked"] == 1
|
|
59
|
+
assert rmf_graph.rule_node("SV-9") in g.neighbors(rmf_graph.control_id("cm-6"), "SATISFIED_BY")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_ingest_without_map_does_not_link(tmp_path):
|
|
63
|
+
(tmp_path / "s.ckl").write_text(
|
|
64
|
+
'<?xml version="1.0"?><CHECKLIST><STIGS><iSTIG><STIG_INFO></STIG_INFO><VULN>'
|
|
65
|
+
'<STIG_DATA><VULN_ATTRIBUTE>Rule_ID</VULN_ATTRIBUTE><ATTRIBUTE_DATA>SV-9</ATTRIBUTE_DATA></STIG_DATA>'
|
|
66
|
+
'<STATUS>Open</STATUS></VULN></iSTIG></STIGS></CHECKLIST>')
|
|
67
|
+
cl = stig.load(tmp_path / "s.ckl")
|
|
68
|
+
r = rmf_graph.ingest_checklist(rmf_graph.RmfGraph(), cl)
|
|
69
|
+
assert r["linked"] == 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_load_map_creates_dir_and_caches(tmp_path, monkeypatch):
|
|
73
|
+
"""Regression: the fetch must create .drydock/rmf/ before writing — a real
|
|
74
|
+
fetch failed because the dir didn't exist yet (found via hands-on TUI test)."""
|
|
75
|
+
def fake_fetch(path, **kw):
|
|
76
|
+
# a real fetch writes to .drydock/rmf/cci_map.xml — the dir must exist
|
|
77
|
+
from pathlib import Path
|
|
78
|
+
Path(path).write_text(_CCI_XML)
|
|
79
|
+
monkeypatch.setattr(cci, "fetch_to", fake_fetch)
|
|
80
|
+
m = cci.load_map(str(tmp_path))
|
|
81
|
+
assert m["CCI-000366"] == "cm-6" # parsed from the "fetched" list
|
|
82
|
+
assert cci.cache_path(str(tmp_path)).exists() # cached as json
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|