drydock-cli 3.0.72__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.72/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.72 → drydock_cli-3.0.74}/drydock/rmf_graph.py +13 -5
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/stig.py +29 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/tui/app.py +29 -27
- {drydock_cli-3.0.72 → drydock_cli-3.0.74/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock_cli.egg-info/SOURCES.txt +2 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/pyproject.toml +1 -1
- drydock_cli-3.0.74/tests/test_cci.py +82 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_mcp.py +30 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_rmf_graph.py +26 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_stig.py +48 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_web_tools.py +33 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_xccdf.py +29 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/LICENSE +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/NOTICE +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/README.md +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/agent.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/builtin_skills/stig-assess.md +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/builtin_skills/stig-remediate.md +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/cli.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/config.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/detect.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/extract.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/guards.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/providers.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/rmf.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/skills.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/web.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/setup.cfg +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_config.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_e2e_connected.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_extract.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_rmf.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_rmf_stig_graph.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_vision_input.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:
|
|
@@ -281,3 +281,32 @@ def load(path: str | Path) -> Checklist:
|
|
|
281
281
|
# sniff: JSON → cklb, else ckl
|
|
282
282
|
head = Path(path).read_text("utf-8", "ignore").lstrip()[:1]
|
|
283
283
|
return parse_cklb(path) if head == "{" else parse_ckl(path)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def summary_lines(cl: "Checklist", label: str, status: str | None = None) -> list[str]:
|
|
287
|
+
"""Build the `/stig` summary view: counts, an optional status listing (never
|
|
288
|
+
silently truncated), and scale-aware next-step hints. Pure/presentation so it
|
|
289
|
+
can be unit-tested without the TUI."""
|
|
290
|
+
host = cl.asset.get("HOST_NAME") or cl.asset.get("host_name") or "?"
|
|
291
|
+
c = cl.counts()
|
|
292
|
+
lines = [f"STIG checklist {label} (host: {host}, format: {cl.fmt})",
|
|
293
|
+
f" {len(cl.rules)} rules — " + " · ".join(f"{k}={v}" for k, v in c.items())]
|
|
294
|
+
if status:
|
|
295
|
+
sf = canonical_status(status)
|
|
296
|
+
hits = [r for r in cl.rules if r.status == sf]
|
|
297
|
+
lines.append(f"\n{sf} ({len(hits)}):")
|
|
298
|
+
lines += [f" {r.group_id} ({r.severity}) — {r.title}" for r in hits[:50]]
|
|
299
|
+
if len(hits) > 50: # never silently truncate
|
|
300
|
+
lines.append(f" … showing first 50 of {len(hits)}; "
|
|
301
|
+
"open the .ckl in STIG Viewer for the full list.")
|
|
302
|
+
return lines
|
|
303
|
+
nr = c.get("not_reviewed", 0)
|
|
304
|
+
if nr:
|
|
305
|
+
lines.append(f"\nAssess the {nr} un-reviewed rule(s) — one focused turn each:"
|
|
306
|
+
f"\n /loop {nr} /stig-assess {label}")
|
|
307
|
+
if nr > 40:
|
|
308
|
+
lines.append(f" (large checklist — that's {nr} model turns; you can run it in "
|
|
309
|
+
"batches and re-run /stig to see remaining counts.)")
|
|
310
|
+
if c.get("open"):
|
|
311
|
+
lines.append(f"List open findings to remediate: /stig {label} open")
|
|
312
|
+
return lines
|
|
@@ -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):
|
|
@@ -587,19 +576,8 @@ class DrydockApp(App):
|
|
|
587
576
|
except Exception as e: # noqa: BLE001
|
|
588
577
|
self._mount(ErrorMessage(f"could not read checklist: {e}"))
|
|
589
578
|
return
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
lines = [f"STIG checklist {path} (host: {host}, format: {cl.fmt})",
|
|
593
|
-
f" {len(cl.rules)} rules — " + " · ".join(f"{k}={v}" for k, v in c.items())]
|
|
594
|
-
if len(parts) > 1:
|
|
595
|
-
sf = stig.canonical_status(parts[1])
|
|
596
|
-
hits = [r for r in cl.rules if r.status == sf]
|
|
597
|
-
lines.append(f"\n{sf} ({len(hits)}):")
|
|
598
|
-
lines += [f" {r.group_id} ({r.severity}) — {r.title}" for r in hits[:50]]
|
|
599
|
-
else:
|
|
600
|
-
lines.append("Assess un-reviewed rules: /loop "
|
|
601
|
-
f"{c.get('not_reviewed', 0) or 1} /stig-assess {parts[0]}")
|
|
602
|
-
self._info("\n".join(lines))
|
|
579
|
+
status = parts[1] if len(parts) > 1 else None
|
|
580
|
+
self._info("\n".join(stig.summary_lines(cl, parts[0], status)))
|
|
603
581
|
|
|
604
582
|
def _cmd_rmf(self, arg: str) -> None:
|
|
605
583
|
"""RMF automation: bootstrap the NIST 800-53 catalog into the knowledge
|
|
@@ -654,6 +632,30 @@ class DrydockApp(App):
|
|
|
654
632
|
"catalog download; after that it works offline.)")
|
|
655
633
|
self.call_from_thread(self._info, msg)
|
|
656
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
|
+
|
|
657
659
|
def _cmd_mcp(self) -> None:
|
|
658
660
|
"""List connected MCP servers and the tools they expose."""
|
|
659
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
|
|
@@ -55,3 +55,33 @@ def test_bad_server_is_skipped(tmp_path):
|
|
|
55
55
|
schemas = mcp.connect_all(str(tmp_path), log=logs.append)
|
|
56
56
|
assert schemas == []
|
|
57
57
|
assert any("failed to start" in m for m in logs)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_content_to_text_joins_text_blocks():
|
|
61
|
+
out = mcp._content_to_text({"content": [{"type": "text", "text": "line1"},
|
|
62
|
+
{"type": "text", "text": "line2"}]})
|
|
63
|
+
assert out == "line1\nline2"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_content_to_text_non_text_block_is_json():
|
|
67
|
+
out = mcp._content_to_text({"content": [{"type": "image", "data": "abc"}]})
|
|
68
|
+
assert '"type": "image"' in out and '"data": "abc"' in out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_content_to_text_error_and_empty_and_nondict():
|
|
72
|
+
assert mcp._content_to_text({"content": [{"type": "text", "text": "boom"}],
|
|
73
|
+
"isError": True}).startswith("[tool reported an error]")
|
|
74
|
+
assert mcp._content_to_text({"content": []}) == "(empty result)"
|
|
75
|
+
assert mcp._content_to_text("raw string") == "raw string"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_load_config_project_overrides_user(tmp_path, monkeypatch):
|
|
79
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
80
|
+
user = tmp_path / ".drydock"; user.mkdir()
|
|
81
|
+
(user / "mcp.json").write_text('{"mcpServers": {"db": {"command": "user-cmd"}}}')
|
|
82
|
+
proj = tmp_path / "proj" / ".drydock"; proj.mkdir(parents=True)
|
|
83
|
+
(proj / "mcp.json").write_text('{"mcpServers": {"db": {"command": "proj-cmd"},'
|
|
84
|
+
' "nocmd": {"args": []}}}')
|
|
85
|
+
cfg = mcp.load_config(str(tmp_path / "proj"))
|
|
86
|
+
assert cfg["db"]["command"] == "proj-cmd" # project wins
|
|
87
|
+
assert "nocmd" not in cfg # missing command → skipped
|
|
@@ -60,3 +60,29 @@ def test_graphadd_and_graphquery_tools(tmp_path):
|
|
|
60
60
|
|
|
61
61
|
def test_graphquery_empty_graph_is_graceful(tmp_path):
|
|
62
62
|
assert "empty" in tool_graphquery({"op": "control", "id": "AC-2"}, {"cwd": str(tmp_path)}).lower()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_inherited_controls_handles_cycle():
|
|
66
|
+
"""A RESIDES_ON cycle (misconfigured topology) must not infinite-loop."""
|
|
67
|
+
g = G.RmfGraph()
|
|
68
|
+
a, b = G.component_id("a"), G.component_id("b")
|
|
69
|
+
g.add_node(a, "Component"); g.add_node(b, "Component")
|
|
70
|
+
g.add_edge(a, "RESIDES_ON", b)
|
|
71
|
+
g.add_edge(b, "RESIDES_ON", a) # cycle
|
|
72
|
+
g.add_edge(b, "IMPLEMENTS", G.control_id("pe-3"))
|
|
73
|
+
got = g.inherited_controls(a) # must terminate
|
|
74
|
+
assert G.control_id("pe-3") in got
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_inherited_controls_missing_node_is_empty():
|
|
78
|
+
g = G.RmfGraph()
|
|
79
|
+
assert g.inherited_controls(G.component_id("ghost")) == []
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_neighbors_direction_and_missing():
|
|
83
|
+
g = G.RmfGraph()
|
|
84
|
+
g.add_edge("x", "REL", "y")
|
|
85
|
+
assert g.neighbors("x", "REL") == ["y"]
|
|
86
|
+
assert g.neighbors("y", "REL", direction="in") == ["x"]
|
|
87
|
+
assert g.neighbors("y", "REL", direction="out") == []
|
|
88
|
+
assert g.neighbors("nope") == []
|
|
@@ -93,3 +93,51 @@ def test_empty_and_minimal_checklists(tmp_path):
|
|
|
93
93
|
assert stig.load(tmp_path / "e.ckl").rules == []
|
|
94
94
|
(tmp_path / "m.cklb").write_text('{"stigs":[{"rules":[{"group_id":"V-5"}]}]}')
|
|
95
95
|
assert stig.load(tmp_path / "m.cklb").rules[0].group_id == "V-5"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_cklb_writeback_only_changes_status_fields(tmp_path):
|
|
99
|
+
"""Editing a .cklb must change ONLY status/finding_details/comments and keep
|
|
100
|
+
everything else byte-stable (eMASS round-trip fidelity)."""
|
|
101
|
+
import json
|
|
102
|
+
data = {"title": "t", "stigs": [{"stig_name": "Demo", "version": "1", "rules": [
|
|
103
|
+
{"group_id": "V-1", "rule_id": "SV-1", "rule_title": "Rule one",
|
|
104
|
+
"severity": "high", "status": "not_reviewed", "check_content": "do x",
|
|
105
|
+
"fix_text": "fix x", "finding_details": "", "comments": "", "weight": "10.0"}]}]}
|
|
106
|
+
p = tmp_path / "a.cklb"; p.write_text(json.dumps(data))
|
|
107
|
+
cl = stig.load(p)
|
|
108
|
+
cl.update("V-1", status="open", finding_details="found it")
|
|
109
|
+
cl.save(p)
|
|
110
|
+
re = json.loads(p.read_text())
|
|
111
|
+
rule = re["stigs"][0]["rules"][0]
|
|
112
|
+
assert rule["status"] == "open" and rule["finding_details"] == "found it"
|
|
113
|
+
# untouched fields preserved exactly
|
|
114
|
+
assert rule["severity"] == "high" and rule["check_content"] == "do x"
|
|
115
|
+
assert rule["weight"] == "10.0" and rule["rule_title"] == "Rule one"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_summary_lines_scale_and_no_silent_truncation(tmp_path):
|
|
119
|
+
import json
|
|
120
|
+
# 60 rules: 55 open, 5 not_reviewed → exercises >40 hint, open hint, >50 cap note
|
|
121
|
+
rules = [{"group_id": f"V-{i}", "rule_id": f"SV-{i}", "rule_title": f"r{i}",
|
|
122
|
+
"severity": "medium", "status": "open" if i < 55 else "not_reviewed"}
|
|
123
|
+
for i in range(60)]
|
|
124
|
+
(tmp_path / "b.cklb").write_text(json.dumps(
|
|
125
|
+
{"stigs": [{"stig_name": "S", "version": "1", "rules": rules}]}))
|
|
126
|
+
cl = stig.load(tmp_path / "b.cklb")
|
|
127
|
+
summ = "\n".join(stig.summary_lines(cl, "b.cklb"))
|
|
128
|
+
assert "/loop 5 /stig-assess b.cklb" in summ # exact not_reviewed count
|
|
129
|
+
assert "List open findings" in summ # open hint shown
|
|
130
|
+
open_view = "\n".join(stig.summary_lines(cl, "b.cklb", "open"))
|
|
131
|
+
assert "showing first 50 of 55" in open_view # no silent truncation
|
|
132
|
+
assert open_view.count("V-") <= 51 # capped list + the note
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_summary_lines_large_checklist_hint(tmp_path):
|
|
136
|
+
import json
|
|
137
|
+
rules = [{"group_id": f"V-{i}", "rule_id": f"SV-{i}", "rule_title": "x",
|
|
138
|
+
"severity": "low", "status": "not_reviewed"} for i in range(286)]
|
|
139
|
+
(tmp_path / "big.cklb").write_text(json.dumps(
|
|
140
|
+
{"stigs": [{"stig_name": "ASD", "version": "6", "rules": rules}]}))
|
|
141
|
+
cl = stig.load(tmp_path / "big.cklb")
|
|
142
|
+
summ = "\n".join(stig.summary_lines(cl, "big.cklb"))
|
|
143
|
+
assert "/loop 286" in summ and "286 model turns" in summ
|
|
@@ -70,3 +70,36 @@ def test_search_parses_ddg_html(monkeypatch):
|
|
|
70
70
|
assert res and res[0]["title"] == "Example A"
|
|
71
71
|
assert res[0]["url"] == "https://example.com/a" # uddg redirect unwrapped
|
|
72
72
|
assert res[0]["snippet"] == "snippet A text"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _FakeResp:
|
|
76
|
+
def __init__(self, body: bytes, ctype: str):
|
|
77
|
+
self._body = body
|
|
78
|
+
self.headers = {"Content-Type": ctype}
|
|
79
|
+
def read(self, *a): return self._body
|
|
80
|
+
def __enter__(self): return self
|
|
81
|
+
def __exit__(self, *a): return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_fetch_strips_html_script_and_tags(monkeypatch):
|
|
85
|
+
import urllib.request
|
|
86
|
+
html_doc = (b"<html><head><script>var x=1; evil()</script><style>a{}</style></head>"
|
|
87
|
+
b"<body><h1>Title</h1><p>Hello & welcome</p></body></html>")
|
|
88
|
+
monkeypatch.setattr(urllib.request, "urlopen",
|
|
89
|
+
lambda *a, **k: _FakeResp(html_doc, "text/html"))
|
|
90
|
+
out = web.fetch("example.com")
|
|
91
|
+
assert "Title" in out and "Hello & welcome" in out
|
|
92
|
+
assert "evil()" not in out and "<p>" not in out # script + tags gone
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_fetch_truncates_long_text(monkeypatch):
|
|
96
|
+
import urllib.request
|
|
97
|
+
big = b"<html><body>" + b"x " * 5000 + b"</body></html>"
|
|
98
|
+
monkeypatch.setattr(urllib.request, "urlopen",
|
|
99
|
+
lambda *a, **k: _FakeResp(big, "text/html"))
|
|
100
|
+
out = web.fetch("example.com", max_chars=500)
|
|
101
|
+
assert "truncated" in out and len(out) < 700
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_format_search_empty():
|
|
105
|
+
assert "No web results" in web.format_search("rare query", [])
|
|
@@ -54,3 +54,32 @@ def test_xccdf_to_blank_ckl(tmp_path):
|
|
|
54
54
|
assert stig.load(out).rules[0].status == "open"
|
|
55
55
|
# the regenerated .ckl preserves the DISA status enum
|
|
56
56
|
assert "<STATUS>Open</STATUS>" in out.read_text()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_xccdf_scale_many_rules(tmp_path):
|
|
60
|
+
"""A full DISA STIG (e.g. the ASD STIG) has hundreds of rules. Verify the
|
|
61
|
+
XCCDF->.ckl path handles scale: all rules parsed, well-formed, round-trips,
|
|
62
|
+
full id/title fidelity. (Validated live against U_ASD_STIG_V6R1 = 286 rules.)"""
|
|
63
|
+
rules = "".join(
|
|
64
|
+
f'<Group id="V-{i:05d}"><title>SRG-APP-{i:06d}</title>'
|
|
65
|
+
f'<Rule id="SV-{i:05d}r1_rule" severity="medium" weight="10.0">'
|
|
66
|
+
f'<version>APSC-DV-{i:06d}</version><title>Requirement {i}.</title>'
|
|
67
|
+
f'<description><VulnDiscussion>Discussion {i}.</VulnDiscussion></description>'
|
|
68
|
+
f'<ident system="http://cyber.mil/cci">CCI-{i:06d}</ident>'
|
|
69
|
+
f'<fixtext fixref="F-{i}">Fix {i}.</fixtext>'
|
|
70
|
+
f'<check system="C-{i}"><check-content>Check {i}.</check-content></check>'
|
|
71
|
+
f'</Rule></Group>'
|
|
72
|
+
for i in range(250)
|
|
73
|
+
)
|
|
74
|
+
xccdf = ('<?xml version="1.0"?><Benchmark xmlns="http://checklists.nist.gov/xccdf/1.1" '
|
|
75
|
+
'id="BIG"><title>Big STIG</title><version>1</version>'
|
|
76
|
+
f'<plain-text id="release-info">Release: 1</plain-text>{rules}</Benchmark>')
|
|
77
|
+
p = tmp_path / "big-xccdf.xml"; p.write_text(xccdf)
|
|
78
|
+
cl = stig.xccdf_to_checklist(p, host="srv")
|
|
79
|
+
assert len(cl.rules) == 250
|
|
80
|
+
assert all(r.rule_id and r.title for r in cl.rules) # full fidelity
|
|
81
|
+
out = tmp_path / "big.ckl"; cl.save(out)
|
|
82
|
+
ET.parse(out) # valid at scale
|
|
83
|
+
re = stig.load(out)
|
|
84
|
+
assert len(re.rules) == 250 and re.counts()["not_reviewed"] == 250
|
|
85
|
+
assert re.rules[123].rule_id == "SV-00123r1_rule" # order + content preserved
|
|
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
|