drydock-cli 3.0.64__tar.gz → 3.0.65__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.64/drydock_cli.egg-info → drydock_cli-3.0.65}/PKG-INFO +1 -1
- drydock_cli-3.0.65/drydock/stig.py +173 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/tools/__init__.py +139 -10
- {drydock_cli-3.0.64 → drydock_cli-3.0.65/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock_cli.egg-info/SOURCES.txt +2 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/pyproject.toml +1 -1
- drydock_cli-3.0.65/tests/test_stig.py +70 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/LICENSE +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/NOTICE +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/README.md +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/agent.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/cli.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/config.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/detect.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/extract.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/guards.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/providers.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/rmf.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/rmf_graph.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/skills.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/tui/app.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/web.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/setup.cfg +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_config.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_extract.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_rmf.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_rmf_graph.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_web_tools.py +0 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""STIG checklist engine — parse + generate DISA `.ckl` (XML) and `.cklb` (JSON).
|
|
2
|
+
|
|
3
|
+
These checklists carry hostnames, IPs, and finding statuses that are almost
|
|
4
|
+
always CUI, so all parsing/generation stays local. The model never sees raw XML:
|
|
5
|
+
this exposes a compact per-rule view (id, severity, check content, fix text,
|
|
6
|
+
status) for the LLM to assess one at a time, and writes results back by editing
|
|
7
|
+
the parsed structure IN PLACE — so regenerating produces a well-formed file that
|
|
8
|
+
re-imports into STIG Viewer / eMASS without breaking the schema.
|
|
9
|
+
|
|
10
|
+
Stdlib only (xml.etree + json). All logic original to Drydock.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import xml.etree.ElementTree as ET
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
# Canonical statuses + how each format spells them.
|
|
21
|
+
STATUSES = ("open", "not_a_finding", "not_applicable", "not_reviewed")
|
|
22
|
+
_CKL_OUT = {"open": "Open", "not_a_finding": "NotAFinding",
|
|
23
|
+
"not_applicable": "Not_Applicable", "not_reviewed": "Not_Reviewed"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def canonical_status(s: str | None) -> str:
|
|
27
|
+
k = (s or "").strip().lower().replace(" ", "_")
|
|
28
|
+
return {
|
|
29
|
+
"open": "open", "notafinding": "not_a_finding", "not_a_finding": "not_a_finding",
|
|
30
|
+
"not_applicable": "not_applicable", "notapplicable": "not_applicable",
|
|
31
|
+
"not_reviewed": "not_reviewed", "notreviewed": "not_reviewed",
|
|
32
|
+
}.get(k, "not_reviewed")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Rule:
|
|
37
|
+
group_id: str # Vuln_Num, e.g. V-230222
|
|
38
|
+
rule_id: str # SV-230222r...
|
|
39
|
+
title: str
|
|
40
|
+
severity: str # high/medium/low (CAT I/II/III)
|
|
41
|
+
check_content: str
|
|
42
|
+
fix_text: str
|
|
43
|
+
status: str # canonical
|
|
44
|
+
finding_details: str = ""
|
|
45
|
+
comments: str = ""
|
|
46
|
+
_raw: Any = field(default=None, repr=False) # ET element or dict, for edit-in-place
|
|
47
|
+
|
|
48
|
+
def summary(self) -> str:
|
|
49
|
+
return f"{self.group_id} ({self.rule_id}) sev={self.severity} status={self.status} — {self.title}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Checklist:
|
|
53
|
+
"""A parsed checklist with edit-in-place fidelity. Format is 'ckl' or 'cklb'."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, fmt: str) -> None:
|
|
56
|
+
self.fmt = fmt
|
|
57
|
+
self.asset: dict = {}
|
|
58
|
+
self.rules: list[Rule] = []
|
|
59
|
+
self._tree: Any = None # ckl: ElementTree
|
|
60
|
+
self._data: Any = None # cklb: dict
|
|
61
|
+
|
|
62
|
+
def by_id(self, ident: str) -> Rule | None:
|
|
63
|
+
ident = ident.strip().lower()
|
|
64
|
+
for r in self.rules:
|
|
65
|
+
if ident in (r.group_id.lower(), r.rule_id.lower()):
|
|
66
|
+
return r
|
|
67
|
+
# tolerate a base rule id without the trailing revision (SV-230222 vs SV-230222r1)
|
|
68
|
+
for r in self.rules:
|
|
69
|
+
if r.rule_id.lower().startswith(ident) or r.group_id.lower().startswith(ident):
|
|
70
|
+
return r
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def update(self, ident: str, *, status: str | None = None,
|
|
74
|
+
finding_details: str | None = None, comments: str | None = None) -> bool:
|
|
75
|
+
r = self.by_id(ident)
|
|
76
|
+
if r is None:
|
|
77
|
+
return False
|
|
78
|
+
if status is not None:
|
|
79
|
+
r.status = canonical_status(status)
|
|
80
|
+
if finding_details is not None:
|
|
81
|
+
r.finding_details = finding_details
|
|
82
|
+
if comments is not None:
|
|
83
|
+
r.comments = comments
|
|
84
|
+
self._write_back(r)
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
def counts(self) -> dict:
|
|
88
|
+
c = {s: 0 for s in STATUSES}
|
|
89
|
+
for r in self.rules:
|
|
90
|
+
c[r.status] = c.get(r.status, 0) + 1
|
|
91
|
+
return c
|
|
92
|
+
|
|
93
|
+
# ── per-format write-back (edit the raw structure) ──────────────────────
|
|
94
|
+
def _write_back(self, r: Rule) -> None:
|
|
95
|
+
if self.fmt == "cklb":
|
|
96
|
+
d = r._raw
|
|
97
|
+
d["status"] = r.status
|
|
98
|
+
d["finding_details"] = r.finding_details
|
|
99
|
+
d["comments"] = r.comments
|
|
100
|
+
else: # ckl
|
|
101
|
+
vuln = r._raw
|
|
102
|
+
_set_child_text(vuln, "STATUS", _CKL_OUT[r.status])
|
|
103
|
+
_set_child_text(vuln, "FINDING_DETAILS", r.finding_details)
|
|
104
|
+
_set_child_text(vuln, "COMMENTS", r.comments)
|
|
105
|
+
|
|
106
|
+
def to_text(self) -> str:
|
|
107
|
+
if self.fmt == "cklb":
|
|
108
|
+
return json.dumps(self._data, indent=2)
|
|
109
|
+
ET.indent(self._tree, space=" ")
|
|
110
|
+
return ET.tostring(self._tree.getroot(), encoding="unicode")
|
|
111
|
+
|
|
112
|
+
def save(self, path: str | Path) -> None:
|
|
113
|
+
Path(path).write_text(self.to_text(), encoding="utf-8")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _set_child_text(el: ET.Element, tag: str, text: str) -> None:
|
|
117
|
+
child = el.find(tag)
|
|
118
|
+
if child is None:
|
|
119
|
+
child = ET.SubElement(el, tag)
|
|
120
|
+
child.text = text or ""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── parsing ─────────────────────────────────────────────────────────────────
|
|
124
|
+
def parse_ckl(path: str | Path) -> Checklist:
|
|
125
|
+
cl = Checklist("ckl")
|
|
126
|
+
cl._tree = ET.parse(path)
|
|
127
|
+
root = cl._tree.getroot()
|
|
128
|
+
asset = root.find("ASSET")
|
|
129
|
+
if asset is not None:
|
|
130
|
+
cl.asset = {c.tag: (c.text or "") for c in asset}
|
|
131
|
+
for vuln in root.iter("VULN"):
|
|
132
|
+
data: dict[str, str] = {}
|
|
133
|
+
for sd in vuln.findall("STIG_DATA"):
|
|
134
|
+
data[sd.findtext("VULN_ATTRIBUTE") or ""] = sd.findtext("ATTRIBUTE_DATA") or ""
|
|
135
|
+
cl.rules.append(Rule(
|
|
136
|
+
group_id=data.get("Vuln_Num", ""), rule_id=data.get("Rule_ID", ""),
|
|
137
|
+
title=data.get("Rule_Title", ""), severity=data.get("Severity", ""),
|
|
138
|
+
check_content=data.get("Check_Content", ""), fix_text=data.get("Fix_Text", ""),
|
|
139
|
+
status=canonical_status(vuln.findtext("STATUS")),
|
|
140
|
+
finding_details=vuln.findtext("FINDING_DETAILS") or "",
|
|
141
|
+
comments=vuln.findtext("COMMENTS") or "", _raw=vuln,
|
|
142
|
+
))
|
|
143
|
+
return cl
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_cklb(path: str | Path) -> Checklist:
|
|
147
|
+
cl = Checklist("cklb")
|
|
148
|
+
cl._data = json.loads(Path(path).read_text("utf-8"))
|
|
149
|
+
cl.asset = cl._data.get("target_data", {}) or {}
|
|
150
|
+
for stig in cl._data.get("stigs", []) or []:
|
|
151
|
+
for rule in stig.get("rules", []) or []:
|
|
152
|
+
cl.rules.append(Rule(
|
|
153
|
+
group_id=rule.get("group_id", ""), rule_id=rule.get("rule_id", ""),
|
|
154
|
+
title=rule.get("rule_title", rule.get("group_title", "")),
|
|
155
|
+
severity=rule.get("severity", ""),
|
|
156
|
+
check_content=rule.get("check_content", ""), fix_text=rule.get("fix_text", ""),
|
|
157
|
+
status=canonical_status(rule.get("status")),
|
|
158
|
+
finding_details=rule.get("finding_details", ""),
|
|
159
|
+
comments=rule.get("comments", ""), _raw=rule,
|
|
160
|
+
))
|
|
161
|
+
return cl
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def load(path: str | Path) -> Checklist:
|
|
165
|
+
"""Parse a .ckl (XML) or .cklb (JSON) checklist."""
|
|
166
|
+
ext = Path(path).suffix.lower()
|
|
167
|
+
if ext == ".cklb":
|
|
168
|
+
return parse_cklb(path)
|
|
169
|
+
if ext == ".ckl":
|
|
170
|
+
return parse_ckl(path)
|
|
171
|
+
# sniff: JSON → cklb, else ckl
|
|
172
|
+
head = Path(path).read_text("utf-8", "ignore").lstrip()[:1]
|
|
173
|
+
return parse_cklb(path) if head == "{" else parse_ckl(path)
|
|
@@ -270,6 +270,58 @@ SCHEMAS = [
|
|
|
270
270
|
"required": ["message"],
|
|
271
271
|
},
|
|
272
272
|
},
|
|
273
|
+
{
|
|
274
|
+
"name": "StigRules",
|
|
275
|
+
"description": (
|
|
276
|
+
"List the rules in a DISA STIG checklist (.ckl / .cklb) with their "
|
|
277
|
+
"status, optionally filtered by status (e.g. not_reviewed). Use it to "
|
|
278
|
+
"see what needs assessing. Pair with StigRule to read one, StigSet to "
|
|
279
|
+
"record a result."
|
|
280
|
+
),
|
|
281
|
+
"input_schema": {
|
|
282
|
+
"type": "object",
|
|
283
|
+
"properties": {
|
|
284
|
+
"path": {"type": "string", "description": "Path to the .ckl/.cklb file."},
|
|
285
|
+
"status": {"type": "string", "description": "Optional filter: open|not_a_finding|not_applicable|not_reviewed"},
|
|
286
|
+
},
|
|
287
|
+
"required": ["path"],
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
"name": "StigRule",
|
|
292
|
+
"description": (
|
|
293
|
+
"Read ONE STIG rule's full detail — Check Content + Fix Text — so you "
|
|
294
|
+
"can assess it against system evidence. Assess one rule at a time."
|
|
295
|
+
),
|
|
296
|
+
"input_schema": {
|
|
297
|
+
"type": "object",
|
|
298
|
+
"properties": {
|
|
299
|
+
"path": {"type": "string"},
|
|
300
|
+
"rule_id": {"type": "string", "description": "Vuln_Num (V-…) or Rule_ID (SV-…)."},
|
|
301
|
+
},
|
|
302
|
+
"required": ["path", "rule_id"],
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
"name": "StigSet",
|
|
307
|
+
"description": (
|
|
308
|
+
"Record an assessment result for a STIG rule and save the checklist: "
|
|
309
|
+
"set status (open / not_a_finding / not_applicable / not_reviewed) and "
|
|
310
|
+
"the finding_details / comments narrative. Regenerates a valid "
|
|
311
|
+
".ckl/.cklb that re-imports into STIG Viewer / eMASS."
|
|
312
|
+
),
|
|
313
|
+
"input_schema": {
|
|
314
|
+
"type": "object",
|
|
315
|
+
"properties": {
|
|
316
|
+
"path": {"type": "string"},
|
|
317
|
+
"rule_id": {"type": "string"},
|
|
318
|
+
"status": {"type": "string", "description": "open | not_a_finding | not_applicable | not_reviewed"},
|
|
319
|
+
"finding_details": {"type": "string"},
|
|
320
|
+
"comments": {"type": "string"},
|
|
321
|
+
},
|
|
322
|
+
"required": ["path", "rule_id"],
|
|
323
|
+
},
|
|
324
|
+
},
|
|
273
325
|
{
|
|
274
326
|
"name": "GraphQuery",
|
|
275
327
|
"description": (
|
|
@@ -1102,6 +1154,78 @@ def _rmf_graph(config):
|
|
|
1102
1154
|
return rmf_graph, rmf_graph.RmfGraph.load(path), path
|
|
1103
1155
|
|
|
1104
1156
|
|
|
1157
|
+
def _gattrs(g, nid) -> dict:
|
|
1158
|
+
n = g.get(nid)
|
|
1159
|
+
return n["attrs"] if n else {}
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _stig_path(params, config):
|
|
1163
|
+
p = (params.get("path") or "").strip()
|
|
1164
|
+
return _resolve_path(p, config) if p else None
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def tool_stigrules(params: dict, config: dict) -> str:
|
|
1168
|
+
"""List the rules in a STIG checklist (.ckl/.cklb), optionally by status."""
|
|
1169
|
+
from drydock import stig
|
|
1170
|
+
path = _stig_path(params, config)
|
|
1171
|
+
if not path:
|
|
1172
|
+
return "Error: StigRules needs a `path` to a .ckl/.cklb checklist."
|
|
1173
|
+
try:
|
|
1174
|
+
cl = stig.load(path)
|
|
1175
|
+
except Exception as e: # noqa: BLE001
|
|
1176
|
+
return f"Could not read checklist: {e}"
|
|
1177
|
+
status = params.get("status")
|
|
1178
|
+
sf = stig.canonical_status(status) if status else None
|
|
1179
|
+
rules = [r for r in cl.rules if sf is None or r.status == sf]
|
|
1180
|
+
head = (f"{path}: {len(cl.rules)} rules — " +
|
|
1181
|
+
", ".join(f"{k}={v}" for k, v in cl.counts().items()))
|
|
1182
|
+
lines = [head] + [f" {r.summary()}" for r in rules[:400]]
|
|
1183
|
+
if len(rules) > 400:
|
|
1184
|
+
lines.append(f" … +{len(rules) - 400} more")
|
|
1185
|
+
return "\n".join(lines)
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def tool_stigrule(params: dict, config: dict) -> str:
|
|
1189
|
+
"""Full detail of ONE STIG rule (check content + fix text) for assessment."""
|
|
1190
|
+
from drydock import stig
|
|
1191
|
+
path = _stig_path(params, config)
|
|
1192
|
+
rid = (params.get("rule_id") or "").strip()
|
|
1193
|
+
if not path or not rid:
|
|
1194
|
+
return "Error: StigRule needs `path` and `rule_id`."
|
|
1195
|
+
try:
|
|
1196
|
+
cl = stig.load(path)
|
|
1197
|
+
except Exception as e: # noqa: BLE001
|
|
1198
|
+
return f"Could not read checklist: {e}"
|
|
1199
|
+
r = cl.by_id(rid)
|
|
1200
|
+
if r is None:
|
|
1201
|
+
return f"No rule {rid} in {path}."
|
|
1202
|
+
return (f"{r.group_id} ({r.rule_id}) — {r.title}\nSeverity: {r.severity}\n"
|
|
1203
|
+
f"Status: {r.status}\nCheck Content:\n{r.check_content}\n\n"
|
|
1204
|
+
f"Fix Text:\n{r.fix_text}")
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def tool_stigset(params: dict, config: dict) -> str:
|
|
1208
|
+
"""Set a STIG rule's status + finding details/comments and save the file."""
|
|
1209
|
+
from drydock import stig
|
|
1210
|
+
path = _stig_path(params, config)
|
|
1211
|
+
rid = (params.get("rule_id") or "").strip()
|
|
1212
|
+
if not path or not rid:
|
|
1213
|
+
return "Error: StigSet needs `path` and `rule_id`."
|
|
1214
|
+
status = params.get("status")
|
|
1215
|
+
if status and stig.canonical_status(status) not in stig.STATUSES:
|
|
1216
|
+
return f"status must be one of {stig.STATUSES}."
|
|
1217
|
+
try:
|
|
1218
|
+
cl = stig.load(path)
|
|
1219
|
+
ok = cl.update(rid, status=status, finding_details=params.get("finding_details"),
|
|
1220
|
+
comments=params.get("comments"))
|
|
1221
|
+
if not ok:
|
|
1222
|
+
return f"No rule {rid} in {path}."
|
|
1223
|
+
cl.save(path)
|
|
1224
|
+
except Exception as e: # noqa: BLE001
|
|
1225
|
+
return f"Could not update checklist: {e}"
|
|
1226
|
+
return f"✓ {rid} set to {stig.canonical_status(status) if status else 'unchanged'} in {path}."
|
|
1227
|
+
|
|
1228
|
+
|
|
1105
1229
|
def tool_graphquery(params: dict, config: dict) -> str:
|
|
1106
1230
|
"""Traverse the RMF typed ontology graph (read-only)."""
|
|
1107
1231
|
rmf_graph, g, _ = _rmf_graph(config)
|
|
@@ -1115,8 +1239,8 @@ def tool_graphquery(params: dict, config: dict) -> str:
|
|
|
1115
1239
|
n = g.get(node)
|
|
1116
1240
|
if not n:
|
|
1117
1241
|
return f"No control {ident} in the graph."
|
|
1118
|
-
objs = [g
|
|
1119
|
-
impl = [g
|
|
1242
|
+
objs = [_gattrs(g, o).get("prose", "") for o in g.neighbors(node, "ASSESSES", direction="in")]
|
|
1243
|
+
impl = [_gattrs(g, c).get("name", c) for c in g.neighbors(node, "IMPLEMENTS", direction="in")]
|
|
1120
1244
|
out = [f"{n['attrs'].get('control_id', ident)} — {n['attrs'].get('title','')} "
|
|
1121
1245
|
f"(Family: {n['attrs'].get('family','')})"]
|
|
1122
1246
|
if objs:
|
|
@@ -1124,28 +1248,28 @@ def tool_graphquery(params: dict, config: dict) -> str:
|
|
|
1124
1248
|
out.append("Implemented by: " + (", ".join(impl) if impl else "(no components recorded)"))
|
|
1125
1249
|
return "\n".join(out)
|
|
1126
1250
|
if op == "family":
|
|
1127
|
-
ctrls = [g
|
|
1251
|
+
ctrls = [_gattrs(g, c).get("control_id", c)
|
|
1128
1252
|
for c in g.of_type("Control")
|
|
1129
|
-
if g
|
|
1130
|
-
or ident.lower() in g
|
|
1253
|
+
if _gattrs(g, c).get("family", "").lower().startswith(ident.lower())
|
|
1254
|
+
or ident.lower() in _gattrs(g, c).get("family", "").lower()]
|
|
1131
1255
|
return f"Controls in '{ident}': " + (", ".join(sorted(ctrls)) or "(none)")
|
|
1132
1256
|
if op in ("component", "implementers", "inherited"):
|
|
1133
1257
|
comp = rmf_graph.component_id(ident)
|
|
1134
1258
|
if op == "implementers":
|
|
1135
1259
|
node = rmf_graph.control_id(ident)
|
|
1136
|
-
comps = [g
|
|
1260
|
+
comps = [_gattrs(g, c).get("name", c) for c in g.neighbors(node, "IMPLEMENTS", direction="in")]
|
|
1137
1261
|
return f"Components implementing {ident}: " + (", ".join(comps) or "(none)")
|
|
1138
1262
|
if op == "inherited":
|
|
1139
1263
|
inh = g.inherited_controls(comp)
|
|
1140
|
-
names = [g
|
|
1264
|
+
names = [_gattrs(g, c).get("control_id", c) if g.get(c) else c for c in inh]
|
|
1141
1265
|
return (f"{ident} inherits {len(names)} control(s) from its parent system(s): "
|
|
1142
1266
|
+ (", ".join(names) or "(none — no RESIDES_ON recorded)"))
|
|
1143
1267
|
n = g.get(comp)
|
|
1144
1268
|
if not n:
|
|
1145
1269
|
return f"No component '{ident}' in the graph (add it with GraphAdd)."
|
|
1146
|
-
impl = [g
|
|
1147
|
-
res = [g
|
|
1148
|
-
vulns = [g
|
|
1270
|
+
impl = [_gattrs(g, c).get("control_id", c) for c in g.neighbors(comp, "IMPLEMENTS", direction="out")]
|
|
1271
|
+
res = [_gattrs(g, p).get("name", p) for p in g.neighbors(comp, "RESIDES_ON", direction="out")]
|
|
1272
|
+
vulns = [_gattrs(g, v).get("vuln_id", v) for v in g.neighbors(comp, "AFFECTS", direction="in")]
|
|
1149
1273
|
return (f"Component {ident} ({n['attrs'].get('os','')}): implements "
|
|
1150
1274
|
f"{', '.join(impl) or 'none'}; resides on {', '.join(res) or 'nothing'}; "
|
|
1151
1275
|
f"flaws {', '.join(vulns) or 'none'}.")
|
|
@@ -1258,6 +1382,9 @@ _TOOLS = [
|
|
|
1258
1382
|
("Knowledge", tool_knowledge, True),
|
|
1259
1383
|
("GraphQuery", tool_graphquery, True),
|
|
1260
1384
|
("GraphAdd", tool_graphadd, False),
|
|
1385
|
+
("StigRules", tool_stigrules, True),
|
|
1386
|
+
("StigRule", tool_stigrule, True),
|
|
1387
|
+
("StigSet", tool_stigset, False),
|
|
1261
1388
|
("WebSearch", tool_websearch, True),
|
|
1262
1389
|
("WebFetch", tool_webfetch, True),
|
|
1263
1390
|
("GitStatus", tool_gitstatus, True),
|
|
@@ -1275,6 +1402,7 @@ def register_all():
|
|
|
1275
1402
|
"todo": tool_todo, "task": tool_task, "Dispatch": tool_dispatch,
|
|
1276
1403
|
"Knowledge": tool_knowledge,
|
|
1277
1404
|
"GraphQuery": tool_graphquery, "GraphAdd": tool_graphadd,
|
|
1405
|
+
"StigRules": tool_stigrules, "StigRule": tool_stigrule, "StigSet": tool_stigset,
|
|
1278
1406
|
"WebSearch": tool_websearch, "WebFetch": tool_webfetch,
|
|
1279
1407
|
"GitStatus": tool_gitstatus, "GitDiff": tool_gitdiff,
|
|
1280
1408
|
"GitLog": tool_gitlog, "GitCommit": tool_gitcommit,
|
|
@@ -1283,6 +1411,7 @@ def register_all():
|
|
|
1283
1411
|
# GitCommit + GraphAdd write).
|
|
1284
1412
|
read_only = name in (
|
|
1285
1413
|
"Read", "Glob", "Grep", "task", "Dispatch", "Knowledge", "GraphQuery",
|
|
1414
|
+
"StigRules", "StigRule",
|
|
1286
1415
|
"WebSearch", "WebFetch", "GitStatus", "GitDiff", "GitLog",
|
|
1287
1416
|
)
|
|
1288
1417
|
register(ToolDef(name=name, schema=schema, func=func, read_only=read_only))
|
|
@@ -20,6 +20,7 @@ drydock/providers.py
|
|
|
20
20
|
drydock/rmf.py
|
|
21
21
|
drydock/rmf_graph.py
|
|
22
22
|
drydock/skills.py
|
|
23
|
+
drydock/stig.py
|
|
23
24
|
drydock/tool_registry.py
|
|
24
25
|
drydock/tuning.py
|
|
25
26
|
drydock/web.py
|
|
@@ -73,6 +74,7 @@ tests/test_rmf.py
|
|
|
73
74
|
tests/test_rmf_graph.py
|
|
74
75
|
tests/test_runaway_repetition.py
|
|
75
76
|
tests/test_skills.py
|
|
77
|
+
tests/test_stig.py
|
|
76
78
|
tests/test_stop.py
|
|
77
79
|
tests/test_streaming_newlines.py
|
|
78
80
|
tests/test_subagent.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.65"
|
|
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,70 @@
|
|
|
1
|
+
"""STIG .ckl/.cklb engine — parse, edit-in-place, round-trip; + the tools."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from drydock import stig
|
|
7
|
+
from drydock.tools import tool_stigrules, tool_stigrule, tool_stigset
|
|
8
|
+
|
|
9
|
+
_CKL = '''<?xml version="1.0" encoding="UTF-8"?>
|
|
10
|
+
<CHECKLIST><ASSET><HOST_NAME>web01</HOST_NAME><HOST_IP>10.0.0.5</HOST_IP></ASSET>
|
|
11
|
+
<STIGS><iSTIG><STIG_INFO></STIG_INFO>
|
|
12
|
+
<VULN>
|
|
13
|
+
<STIG_DATA><VULN_ATTRIBUTE>Vuln_Num</VULN_ATTRIBUTE><ATTRIBUTE_DATA>V-230222</ATTRIBUTE_DATA></STIG_DATA>
|
|
14
|
+
<STIG_DATA><VULN_ATTRIBUTE>Rule_ID</VULN_ATTRIBUTE><ATTRIBUTE_DATA>SV-230222r1</ATTRIBUTE_DATA></STIG_DATA>
|
|
15
|
+
<STIG_DATA><VULN_ATTRIBUTE>Severity</VULN_ATTRIBUTE><ATTRIBUTE_DATA>medium</ATTRIBUTE_DATA></STIG_DATA>
|
|
16
|
+
<STIG_DATA><VULN_ATTRIBUTE>Rule_Title</VULN_ATTRIBUTE><ATTRIBUTE_DATA>Enable FIPS</ATTRIBUTE_DATA></STIG_DATA>
|
|
17
|
+
<STIG_DATA><VULN_ATTRIBUTE>Check_Content</VULN_ATTRIBUTE><ATTRIBUTE_DATA>Run fips-mode-setup --check</ATTRIBUTE_DATA></STIG_DATA>
|
|
18
|
+
<STIG_DATA><VULN_ATTRIBUTE>Fix_Text</VULN_ATTRIBUTE><ATTRIBUTE_DATA>Run fips-mode-setup --enable</ATTRIBUTE_DATA></STIG_DATA>
|
|
19
|
+
<STATUS>Not_Reviewed</STATUS><FINDING_DETAILS></FINDING_DETAILS><COMMENTS></COMMENTS></VULN>
|
|
20
|
+
</iSTIG></STIGS></CHECKLIST>'''
|
|
21
|
+
|
|
22
|
+
_CKLB = {"title": "RHEL9", "target_data": {"host_name": "web01"}, "stigs": [{"stig_name": "RHEL 9",
|
|
23
|
+
"rules": [{"group_id": "V-230222", "rule_id": "SV-230222r1", "rule_title": "Enable FIPS",
|
|
24
|
+
"severity": "medium", "check_content": "fips-mode-setup --check",
|
|
25
|
+
"fix_text": "fips-mode-setup --enable", "status": "not_reviewed",
|
|
26
|
+
"finding_details": "", "comments": ""}]}]}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_ckl_roundtrip_edit_in_place(tmp_path):
|
|
30
|
+
p = tmp_path / "r.ckl"; p.write_text(_CKL)
|
|
31
|
+
cl = stig.load(p)
|
|
32
|
+
assert cl.fmt == "ckl" and cl.asset["HOST_NAME"] == "web01"
|
|
33
|
+
assert cl.rules[0].check_content == "Run fips-mode-setup --check"
|
|
34
|
+
assert cl.update("V-230222", status="open", finding_details="FIPS off.", comments="by drydock")
|
|
35
|
+
cl.save(p)
|
|
36
|
+
re = stig.load(p)
|
|
37
|
+
assert re.rules[0].status == "open" and re.rules[0].finding_details == "FIPS off."
|
|
38
|
+
# other STIG_DATA preserved (schema fidelity)
|
|
39
|
+
assert "Vuln_Num" in p.read_text() and "<STATUS>Open</STATUS>" in p.read_text()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_cklb_roundtrip(tmp_path):
|
|
43
|
+
p = tmp_path / "r.cklb"; p.write_text(json.dumps(_CKLB))
|
|
44
|
+
cl = stig.load(p)
|
|
45
|
+
assert cl.fmt == "cklb"
|
|
46
|
+
cl.update("SV-230222r1", status="not_a_finding", finding_details="FIPS on.")
|
|
47
|
+
cl.save(p)
|
|
48
|
+
data = json.loads(p.read_text()) # still valid JSON
|
|
49
|
+
assert data["stigs"][0]["rules"][0]["status"] == "not_a_finding"
|
|
50
|
+
assert stig.load(p).rules[0].finding_details == "FIPS on."
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_status_canonicalization():
|
|
54
|
+
assert stig.canonical_status("NotAFinding") == "not_a_finding"
|
|
55
|
+
assert stig.canonical_status("Not_Applicable") == "not_applicable"
|
|
56
|
+
assert stig.canonical_status("garbage") == "not_reviewed"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_stig_tools(tmp_path):
|
|
60
|
+
p = tmp_path / "r.ckl"; p.write_text(_CKL); cfg = {"cwd": str(tmp_path)}
|
|
61
|
+
out = tool_stigrules({"path": "r.ckl"}, cfg)
|
|
62
|
+
assert "V-230222" in out and "not_reviewed=1" in out
|
|
63
|
+
detail = tool_stigrule({"path": "r.ckl", "rule_id": "V-230222"}, cfg)
|
|
64
|
+
assert "Check Content" in detail and "fips-mode-setup --check" in detail
|
|
65
|
+
set_out = tool_stigset({"path": "r.ckl", "rule_id": "V-230222", "status": "open",
|
|
66
|
+
"finding_details": "FIPS disabled."}, cfg)
|
|
67
|
+
assert "set to open" in set_out
|
|
68
|
+
assert stig.load(p).rules[0].status == "open"
|
|
69
|
+
# filter by status
|
|
70
|
+
assert "not_reviewed=0" in tool_stigrules({"path": "r.ckl"}, cfg)
|
|
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
|