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.
Files changed (97) hide show
  1. {drydock_cli-3.0.73/drydock_cli.egg-info → drydock_cli-3.0.74}/PKG-INFO +1 -1
  2. drydock_cli-3.0.74/drydock/cci.py +109 -0
  3. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/rmf_graph.py +13 -5
  4. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/tui/app.py +27 -14
  5. {drydock_cli-3.0.73 → drydock_cli-3.0.74/drydock_cli.egg-info}/PKG-INFO +1 -1
  6. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock_cli.egg-info/SOURCES.txt +2 -0
  7. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/pyproject.toml +1 -1
  8. drydock_cli-3.0.74/tests/test_cci.py +82 -0
  9. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/LICENSE +0 -0
  10. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/NOTICE +0 -0
  11. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/README.md +0 -0
  12. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/__init__.py +0 -0
  13. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/__main__.py +0 -0
  14. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/agent.py +0 -0
  15. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/bash_safety.py +0 -0
  16. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/builtin_skills/__init__.py +0 -0
  17. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/builtin_skills/rmf-categorize.md +0 -0
  18. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/builtin_skills/rmf-control.md +0 -0
  19. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/builtin_skills/rmf-poam.md +0 -0
  20. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/builtin_skills/rmf-review.md +0 -0
  21. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/builtin_skills/stig-assess.md +0 -0
  22. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/builtin_skills/stig-remediate.md +0 -0
  23. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/cli.py +0 -0
  24. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/compaction.py +0 -0
  25. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/config.py +0 -0
  26. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/detect.py +0 -0
  27. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/extract.py +0 -0
  28. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/gittools.py +0 -0
  29. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/graphrag.py +0 -0
  30. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/guards.py +0 -0
  31. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/loop_detect.py +0 -0
  32. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/mcp.py +0 -0
  33. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/providers.py +0 -0
  34. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/rmf.py +0 -0
  35. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/skills.py +0 -0
  36. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/stig.py +0 -0
  37. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/tool_registry.py +0 -0
  38. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/tools/__init__.py +0 -0
  39. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/tui/__init__.py +0 -0
  40. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/tui/approval.py +0 -0
  41. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/tui/messages.py +0 -0
  42. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/tui/widgets.py +0 -0
  43. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/tuning.py +0 -0
  44. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock/web.py +0 -0
  45. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock_cli.egg-info/dependency_links.txt +0 -0
  46. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock_cli.egg-info/entry_points.txt +0 -0
  47. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock_cli.egg-info/requires.txt +0 -0
  48. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/drydock_cli.egg-info/top_level.txt +0 -0
  49. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/setup.cfg +0 -0
  50. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_approval.py +0 -0
  51. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_back_command.py +0 -0
  52. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_bash_output_bounding.py +0 -0
  53. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_bash_process_group.py +0 -0
  54. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_bash_safety.py +0 -0
  55. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_bash_timeout_network.py +0 -0
  56. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_cli_agents.py +0 -0
  57. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_compact_command.py +0 -0
  58. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_compaction.py +0 -0
  59. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_config.py +0 -0
  60. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_config_migration.py +0 -0
  61. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_context_limit_config.py +0 -0
  62. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_detect.py +0 -0
  63. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_dispatch.py +0 -0
  64. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_e2e_connected.py +0 -0
  65. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_empty_response.py +0 -0
  66. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_extract.py +0 -0
  67. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_failure_loop.py +0 -0
  68. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_first_run_setup.py +0 -0
  69. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_gittools.py +0 -0
  70. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_graphrag.py +0 -0
  71. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_guards_and_tools.py +0 -0
  72. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_hallucinated_tools.py +0 -0
  73. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_leaked_tool_call.py +0 -0
  74. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_loop_detect.py +0 -0
  75. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_mcp.py +0 -0
  76. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_oneshot_unreachable.py +0 -0
  77. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_plan_autocontinue.py +0 -0
  78. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_providers_unreachable.py +0 -0
  79. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_read_index.py +0 -0
  80. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_rmf.py +0 -0
  81. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_rmf_graph.py +0 -0
  82. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_rmf_stig_graph.py +0 -0
  83. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_runaway_repetition.py +0 -0
  84. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_skills.py +0 -0
  85. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_stig.py +0 -0
  86. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_stop.py +0 -0
  87. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_streaming_newlines.py +0 -0
  88. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_subagent.py +0 -0
  89. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_system_prompt_help.py +0 -0
  90. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_todo.py +0 -0
  91. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_tool_arg_parsing.py +0 -0
  92. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_tools_undo.py +0 -0
  93. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_tui.py +0 -0
  94. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_tuning.py +0 -0
  95. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_vision_input.py +0 -0
  96. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_web_tools.py +0 -0
  97. {drydock_cli-3.0.73 → drydock_cli-3.0.74}/tests/test_xccdf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.73
3
+ Version: 3.0.74
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.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). Returns
132
- {rules, host}. The Control —SATISFIED_BY→ STIG-Rule link is asserted
133
- separately (via GraphAdd 'satisfies') since it needs the CCI→control map."""
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
- return {"rules": len(checklist.rules), "host": host}
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
- try:
569
- cl = stig.load(gp)
570
- g = rmf_graph.RmfGraph.load(rmf_graph.graph_path(cwd))
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.73
3
+ Version: 3.0.74
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -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.73"
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