drydock-cli 3.0.68__tar.gz → 3.0.70__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.68/drydock_cli.egg-info → drydock_cli-3.0.70}/PKG-INFO +1 -1
- drydock_cli-3.0.70/drydock/builtin_skills/stig-remediate.md +16 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/rmf_graph.py +32 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/stig.py +115 -5
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/tools/__init__.py +16 -4
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/tui/app.py +44 -5
- {drydock_cli-3.0.68 → drydock_cli-3.0.70/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock_cli.egg-info/SOURCES.txt +4 -1
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/pyproject.toml +1 -1
- drydock_cli-3.0.70/tests/test_rmf_stig_graph.py +39 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_skills.py +1 -1
- drydock_cli-3.0.70/tests/test_xccdf.py +56 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/LICENSE +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/NOTICE +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/README.md +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/agent.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/builtin_skills/stig-assess.md +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/cli.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/config.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/detect.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/extract.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/guards.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/providers.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/rmf.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/skills.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock/web.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/setup.cfg +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_config.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_e2e_connected.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_extract.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_rmf.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_rmf_graph.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_stig.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.68 → drydock_cli-3.0.70}/tests/test_web_tools.py +0 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: stig-remediate
|
|
3
|
+
description: Generate a remediation script for an Open STIG finding
|
|
4
|
+
---
|
|
5
|
+
Generate a remediation script for an Open STIG finding. Input: $ARGS (a checklist
|
|
6
|
+
path and a rule id, e.g. "sys.ckl V-230222").
|
|
7
|
+
|
|
8
|
+
1. Call StigRule (the path + rule_id) to read the rule's Fix Text and Check Content.
|
|
9
|
+
2. Determine the target OS — infer from the STIG/checklist name, or ask if unclear.
|
|
10
|
+
3. Translate the prose Fix Text into an IDEMPOTENT, executable remediation script in
|
|
11
|
+
the requested format (default: Bash for Linux, PowerShell for Windows; Ansible if
|
|
12
|
+
asked). It must bring the system into compliance with the Check Content. Start it
|
|
13
|
+
with a comment header citing the rule id and what it fixes.
|
|
14
|
+
4. Write the script to a file (Write tool), e.g. remediate_<rule>.sh, and tell the
|
|
15
|
+
user the path. Do NOT run it — the operator reviews and runs remediation.
|
|
16
|
+
Only GENERATE the script; never execute remediation yourself.
|
|
@@ -118,6 +118,38 @@ def component_id(name: str) -> str:
|
|
|
118
118
|
return f"component:{name.strip().lower()}"
|
|
119
119
|
|
|
120
120
|
|
|
121
|
+
def stig_id(name: str) -> str:
|
|
122
|
+
return f"stig:{name.strip().lower()}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def rule_node(rid: str) -> str:
|
|
126
|
+
return f"stigrule:{rid.strip().lower()}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def ingest_checklist(g: "RmfGraph", checklist) -> dict:
|
|
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."""
|
|
134
|
+
host = (checklist.asset.get("HOST_NAME") or checklist.asset.get("host_name") or "target")
|
|
135
|
+
hnode = component_id(host)
|
|
136
|
+
g.add_node(hnode, "Component", name=host, ip=checklist.asset.get("HOST_IP"))
|
|
137
|
+
sname = checklist.stig_name or "STIG"
|
|
138
|
+
snode = stig_id(sname)
|
|
139
|
+
g.add_node(snode, "STIG", name=sname, version=checklist.stig_version)
|
|
140
|
+
g.add_edge(snode, "APPLIES_TO", hnode)
|
|
141
|
+
for r in checklist.rules:
|
|
142
|
+
rid = r.rule_id or r.group_id
|
|
143
|
+
if not rid:
|
|
144
|
+
continue
|
|
145
|
+
rn = rule_node(rid)
|
|
146
|
+
g.add_node(rn, "STIGRule", rule_id=r.rule_id, group_id=r.group_id,
|
|
147
|
+
severity=r.severity, status=r.status, cci=r.cci, title=r.title)
|
|
148
|
+
g.add_edge(rn, "PART_OF", snode)
|
|
149
|
+
g.add_edge(rn, "EVALUATES", hnode)
|
|
150
|
+
return {"rules": len(checklist.rules), "host": host}
|
|
151
|
+
|
|
152
|
+
|
|
121
153
|
def build_from_catalog(catalog: dict, *, families: list[str] | None = None) -> RmfGraph:
|
|
122
154
|
"""Deterministically build Control + Objective nodes (and ASSESSES edges) from
|
|
123
155
|
an OSCAL 800-53 catalog. The typed backbone the user's components/vulns attach to."""
|
|
@@ -12,6 +12,7 @@ Stdlib only (xml.etree + json). All logic original to Drydock.
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
import json
|
|
15
|
+
import re
|
|
15
16
|
import xml.etree.ElementTree as ET
|
|
16
17
|
from dataclasses import dataclass, field
|
|
17
18
|
from pathlib import Path
|
|
@@ -43,6 +44,7 @@ class Rule:
|
|
|
43
44
|
status: str # canonical
|
|
44
45
|
finding_details: str = ""
|
|
45
46
|
comments: str = ""
|
|
47
|
+
cci: str = "" # Control Correlation Identifier (maps up to a NIST control)
|
|
46
48
|
_raw: Any = field(default=None, repr=False) # ET element or dict, for edit-in-place
|
|
47
49
|
|
|
48
50
|
def summary(self) -> str:
|
|
@@ -55,6 +57,8 @@ class Checklist:
|
|
|
55
57
|
def __init__(self, fmt: str) -> None:
|
|
56
58
|
self.fmt = fmt
|
|
57
59
|
self.asset: dict = {}
|
|
60
|
+
self.stig_name: str = ""
|
|
61
|
+
self.stig_version: str = ""
|
|
58
62
|
self.rules: list[Rule] = []
|
|
59
63
|
self._tree: Any = None # ckl: ElementTree
|
|
60
64
|
self._data: Any = None # cklb: dict
|
|
@@ -121,13 +125,16 @@ def _set_child_text(el: ET.Element, tag: str, text: str) -> None:
|
|
|
121
125
|
|
|
122
126
|
|
|
123
127
|
# ── parsing ─────────────────────────────────────────────────────────────────
|
|
124
|
-
def
|
|
128
|
+
def _checklist_from_tree(tree: Any) -> Checklist:
|
|
125
129
|
cl = Checklist("ckl")
|
|
126
|
-
cl._tree =
|
|
127
|
-
root =
|
|
130
|
+
cl._tree = tree
|
|
131
|
+
root = tree.getroot()
|
|
128
132
|
asset = root.find("ASSET")
|
|
129
133
|
if asset is not None:
|
|
130
134
|
cl.asset = {c.tag: (c.text or "") for c in asset}
|
|
135
|
+
info = {si.findtext("SID_NAME"): si.findtext("SID_DATA") for si in root.iter("SI_DATA")}
|
|
136
|
+
cl.stig_name = info.get("title") or info.get("stigid") or ""
|
|
137
|
+
cl.stig_version = info.get("version") or ""
|
|
131
138
|
for vuln in root.iter("VULN"):
|
|
132
139
|
data: dict[str, str] = {}
|
|
133
140
|
for sd in vuln.findall("STIG_DATA"):
|
|
@@ -138,17 +145,120 @@ def parse_ckl(path: str | Path) -> Checklist:
|
|
|
138
145
|
check_content=data.get("Check_Content", ""), fix_text=data.get("Fix_Text", ""),
|
|
139
146
|
status=canonical_status(vuln.findtext("STATUS")),
|
|
140
147
|
finding_details=vuln.findtext("FINDING_DETAILS") or "",
|
|
141
|
-
comments=vuln.findtext("COMMENTS") or "", _raw=vuln,
|
|
148
|
+
comments=vuln.findtext("COMMENTS") or "", cci=data.get("CCI_REF", ""), _raw=vuln,
|
|
142
149
|
))
|
|
143
150
|
return cl
|
|
144
151
|
|
|
145
152
|
|
|
153
|
+
def parse_ckl(path: str | Path) -> Checklist:
|
|
154
|
+
return _checklist_from_tree(ET.parse(path))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── XCCDF benchmark (the raw STIG) → a blank .ckl ───────────────────────────
|
|
158
|
+
_VULN_DISCUSS = re.compile(r"<VulnDiscussion>(.*?)</VulnDiscussion>", re.S | re.I)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _lname(el) -> str:
|
|
162
|
+
return el.tag.split("}")[-1]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _kids(parent, name):
|
|
166
|
+
return [c for c in parent if _lname(c) == name]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _ktext(parent, name) -> str:
|
|
170
|
+
for c in parent:
|
|
171
|
+
if _lname(c) == name:
|
|
172
|
+
return (c.text or "").strip()
|
|
173
|
+
return ""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def parse_xccdf(path: str | Path) -> dict:
|
|
177
|
+
"""Parse a DISA STIG XCCDF benchmark (namespace-agnostic) into
|
|
178
|
+
{title, version, release, stigid, rules:[...]}."""
|
|
179
|
+
root = ET.parse(path).getroot()
|
|
180
|
+
title = _ktext(root, "title")
|
|
181
|
+
version = _ktext(root, "version")
|
|
182
|
+
stigid = root.get("id", "")
|
|
183
|
+
release = ""
|
|
184
|
+
for pt in _kids(root, "plain-text"):
|
|
185
|
+
if pt.get("id") == "release-info":
|
|
186
|
+
release = (pt.text or "").strip()
|
|
187
|
+
rules = []
|
|
188
|
+
for group in _kids(root, "Group"):
|
|
189
|
+
gid = group.get("id", "")
|
|
190
|
+
gtitle = _ktext(group, "title")
|
|
191
|
+
for rule in _kids(group, "Rule"):
|
|
192
|
+
desc = _ktext(rule, "description")
|
|
193
|
+
m = _VULN_DISCUSS.search(desc)
|
|
194
|
+
check = ""
|
|
195
|
+
for ch in _kids(rule, "check"):
|
|
196
|
+
check = _ktext(ch, "check-content") or check
|
|
197
|
+
rules.append({
|
|
198
|
+
"group_id": gid, "group_title": gtitle,
|
|
199
|
+
"rule_id": rule.get("id", ""), "rule_ver": _ktext(rule, "version"),
|
|
200
|
+
"severity": rule.get("severity", ""), "weight": rule.get("weight", ""),
|
|
201
|
+
"title": _ktext(rule, "title"),
|
|
202
|
+
"discussion": (m.group(1).strip() if m else desc),
|
|
203
|
+
"check": check, "fix": _ktext(rule, "fixtext"),
|
|
204
|
+
"ccis": [(c.text or "").strip() for c in _kids(rule, "ident")
|
|
205
|
+
if "cci" in (c.get("system", "") + (c.text or "")).lower()],
|
|
206
|
+
})
|
|
207
|
+
return {"title": title, "version": version, "release": release,
|
|
208
|
+
"stigid": stigid, "rules": rules}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def xccdf_to_checklist(path: str | Path, *, host: str = "") -> Checklist:
|
|
212
|
+
"""Build a blank .ckl (all rules Not_Reviewed) from a STIG XCCDF benchmark —
|
|
213
|
+
the inverse of what STIG Viewer does, so the catalog can be assessed directly."""
|
|
214
|
+
bench = parse_xccdf(path)
|
|
215
|
+
stigref = f"{bench['title']} :: Version {bench['version']}, {bench['release']}".strip(" :,")
|
|
216
|
+
root = ET.Element("CHECKLIST")
|
|
217
|
+
asset = ET.SubElement(root, "ASSET")
|
|
218
|
+
for tag, val in [("ROLE", "None"), ("ASSET_TYPE", "Computing"), ("HOST_NAME", host),
|
|
219
|
+
("HOST_IP", ""), ("HOST_MAC", ""), ("HOST_FQDN", ""),
|
|
220
|
+
("TARGET_COMMENT", ""), ("TECH_AREA", ""), ("TARGET_KEY", ""),
|
|
221
|
+
("WEB_OR_DATABASE", "false"), ("WEB_DB_SITE", ""), ("WEB_DB_INSTANCE", "")]:
|
|
222
|
+
ET.SubElement(asset, tag).text = val
|
|
223
|
+
istig = ET.SubElement(ET.SubElement(root, "STIGS"), "iSTIG")
|
|
224
|
+
info = ET.SubElement(istig, "STIG_INFO")
|
|
225
|
+
for name, data in [("version", bench["version"]), ("classification", "UNCLASSIFIED"),
|
|
226
|
+
("stigid", bench["stigid"]), ("description", ""),
|
|
227
|
+
("releaseinfo", bench["release"]), ("title", bench["title"])]:
|
|
228
|
+
sid = ET.SubElement(info, "SI_DATA")
|
|
229
|
+
ET.SubElement(sid, "SID_NAME").text = name
|
|
230
|
+
ET.SubElement(sid, "SID_DATA").text = data
|
|
231
|
+
for rd in bench["rules"]:
|
|
232
|
+
vuln = ET.SubElement(istig, "VULN")
|
|
233
|
+
for attr, val in [("Vuln_Num", rd["group_id"]), ("Severity", rd["severity"]),
|
|
234
|
+
("Group_Title", rd["group_title"]), ("Rule_ID", rd["rule_id"]),
|
|
235
|
+
("Rule_Ver", rd["rule_ver"]), ("Rule_Title", rd["title"]),
|
|
236
|
+
("Vuln_Discuss", rd["discussion"]), ("Check_Content", rd["check"]),
|
|
237
|
+
("Fix_Text", rd["fix"]), ("Weight", rd["weight"]), ("STIGRef", stigref)]:
|
|
238
|
+
sd = ET.SubElement(vuln, "STIG_DATA")
|
|
239
|
+
ET.SubElement(sd, "VULN_ATTRIBUTE").text = attr
|
|
240
|
+
ET.SubElement(sd, "ATTRIBUTE_DATA").text = val or ""
|
|
241
|
+
for cci in rd["ccis"]:
|
|
242
|
+
sd = ET.SubElement(vuln, "STIG_DATA")
|
|
243
|
+
ET.SubElement(sd, "VULN_ATTRIBUTE").text = "CCI_REF"
|
|
244
|
+
ET.SubElement(sd, "ATTRIBUTE_DATA").text = cci
|
|
245
|
+
ET.SubElement(vuln, "STATUS").text = "Not_Reviewed"
|
|
246
|
+
ET.SubElement(vuln, "FINDING_DETAILS").text = ""
|
|
247
|
+
ET.SubElement(vuln, "COMMENTS").text = ""
|
|
248
|
+
ET.SubElement(vuln, "SEVERITY_OVERRIDE").text = ""
|
|
249
|
+
ET.SubElement(vuln, "SEVERITY_JUSTIFICATION").text = ""
|
|
250
|
+
return _checklist_from_tree(ET.ElementTree(root))
|
|
251
|
+
|
|
252
|
+
|
|
146
253
|
def parse_cklb(path: str | Path) -> Checklist:
|
|
147
254
|
cl = Checklist("cklb")
|
|
148
255
|
cl._data = json.loads(Path(path).read_text("utf-8"))
|
|
149
256
|
cl.asset = cl._data.get("target_data", {}) or {}
|
|
150
257
|
for stig in cl._data.get("stigs", []) or []:
|
|
258
|
+
cl.stig_name = cl.stig_name or stig.get("stig_name", stig.get("display_name", ""))
|
|
259
|
+
cl.stig_version = cl.stig_version or str(stig.get("version", ""))
|
|
151
260
|
for rule in stig.get("rules", []) or []:
|
|
261
|
+
ccis = rule.get("ccis") or ([rule["cci"]] if rule.get("cci") else [])
|
|
152
262
|
cl.rules.append(Rule(
|
|
153
263
|
group_id=rule.get("group_id", ""), rule_id=rule.get("rule_id", ""),
|
|
154
264
|
title=rule.get("rule_title", rule.get("group_title", "")),
|
|
@@ -156,7 +266,7 @@ def parse_cklb(path: str | Path) -> Checklist:
|
|
|
156
266
|
check_content=rule.get("check_content", ""), fix_text=rule.get("fix_text", ""),
|
|
157
267
|
status=canonical_status(rule.get("status")),
|
|
158
268
|
finding_details=rule.get("finding_details", ""),
|
|
159
|
-
comments=rule.get("comments", ""), _raw=rule,
|
|
269
|
+
comments=rule.get("comments", ""), cci=(ccis[0] if ccis else ""), _raw=rule,
|
|
160
270
|
))
|
|
161
271
|
return cl
|
|
162
272
|
|
|
@@ -353,10 +353,11 @@ SCHEMAS = [
|
|
|
353
353
|
"input_schema": {
|
|
354
354
|
"type": "object",
|
|
355
355
|
"properties": {
|
|
356
|
-
"op": {"type": "string", "description": "component | implements | resides_on | vulnerability"},
|
|
356
|
+
"op": {"type": "string", "description": "component | implements | resides_on | vulnerability | satisfies"},
|
|
357
357
|
"component": {"type": "string"},
|
|
358
|
-
"control": {"type": "string", "description": "control id, for op=implements"},
|
|
358
|
+
"control": {"type": "string", "description": "control id, for op=implements/satisfies"},
|
|
359
359
|
"parent": {"type": "string", "description": "parent component/boundary, for op=resides_on"},
|
|
360
|
+
"rule": {"type": "string", "description": "STIG rule id, for op=satisfies"},
|
|
360
361
|
"id": {"type": "string", "description": "vulnerability/STIG/CVE id, for op=vulnerability"},
|
|
361
362
|
"severity": {"type": "string"},
|
|
362
363
|
"os": {"type": "string"}, "ip": {"type": "string"}, "data_type": {"type": "string"},
|
|
@@ -1246,6 +1247,9 @@ def tool_graphquery(params: dict, config: dict) -> str:
|
|
|
1246
1247
|
if objs:
|
|
1247
1248
|
out.append("Assessment objectives:\n" + "\n".join(f" - {o}" for o in objs))
|
|
1248
1249
|
out.append("Implemented by: " + (", ".join(impl) if impl else "(no components recorded)"))
|
|
1250
|
+
rules = [_gattrs(g, rn).get("rule_id", rn) for rn in g.neighbors(node, "SATISFIED_BY", direction="out")]
|
|
1251
|
+
if rules:
|
|
1252
|
+
out.append("Satisfied by STIG rules: " + ", ".join(rules))
|
|
1249
1253
|
return "\n".join(out)
|
|
1250
1254
|
if op == "family":
|
|
1251
1255
|
ctrls = [_gattrs(g, c).get("control_id", c)
|
|
@@ -1301,8 +1305,16 @@ def tool_graphadd(params: dict, config: dict) -> str:
|
|
|
1301
1305
|
g.add_node(rmf_graph.component_id(comp), "Component", name=comp)
|
|
1302
1306
|
g.add_edge(vid, "AFFECTS", rmf_graph.component_id(comp))
|
|
1303
1307
|
g.save(path); return f"Recorded vulnerability {params['id']}" + (f" affecting {comp}." if comp else ".")
|
|
1304
|
-
|
|
1305
|
-
|
|
1308
|
+
if op == "satisfies" and params.get("control") and params.get("rule"):
|
|
1309
|
+
cn, rn = rmf_graph.control_id(params["control"]), rmf_graph.rule_node(params["rule"])
|
|
1310
|
+
if not g.get(cn): # ref node if the catalog isn't bootstrapped
|
|
1311
|
+
g.add_node(cn, "Control", control_id=params["control"].upper())
|
|
1312
|
+
g.add_node(rn, "STIGRule", rule_id=params["rule"])
|
|
1313
|
+
g.add_edge(cn, "SATISFIED_BY", rn)
|
|
1314
|
+
g.save(path); return f"Recorded: {params['control'].upper()} SATISFIED_BY {params['rule']}."
|
|
1315
|
+
return ("GraphAdd ops: component (`component`) | implements (`component`,`control`) | "
|
|
1316
|
+
"resides_on (`component`,`parent`) | vulnerability (`id`, optional `component`) | "
|
|
1317
|
+
"satisfies (`control`,`rule` — a STIG rule satisfies a NIST control).")
|
|
1306
1318
|
|
|
1307
1319
|
|
|
1308
1320
|
def tool_websearch(params: dict, config: dict) -> str:
|
|
@@ -496,7 +496,7 @@ class DrydockApp(App):
|
|
|
496
496
|
" /loop /loop <count> <prompt> — repeat a prompt (Esc stops)\n"
|
|
497
497
|
" /mcp list connected MCP servers and their tools\n"
|
|
498
498
|
" /rmf RMF automation — /rmf bootstrap, then /rmf-control etc.\n"
|
|
499
|
-
" /stig
|
|
499
|
+
" /stig /stig new <xccdf> → blank .ckl; summarize; /stig-assess\n"
|
|
500
500
|
" /clear reset the conversation\n"
|
|
501
501
|
" /quit exit\n"
|
|
502
502
|
"Type a task and press Enter. ↑/↓ recall history · Esc stops · Ctrl+O expands tools."
|
|
@@ -524,14 +524,53 @@ class DrydockApp(App):
|
|
|
524
524
|
|
|
525
525
|
parts = arg.split()
|
|
526
526
|
if not parts:
|
|
527
|
-
self._info("usage
|
|
528
|
-
"
|
|
529
|
-
"
|
|
527
|
+
self._info("usage:\n"
|
|
528
|
+
" /stig new <benchmark-xccdf.xml> [out.ckl] — blank .ckl from a STIG\n"
|
|
529
|
+
" /stig <path.ckl|.cklb> [status] — summary / list by status\n"
|
|
530
|
+
" /stig graph <path.ckl> — ingest into the RMF graph\n"
|
|
531
|
+
"Assess: /loop <n> /stig-assess <path>")
|
|
530
532
|
return
|
|
531
533
|
import os as _os
|
|
534
|
+
cwd = self.config.get("cwd") or "."
|
|
535
|
+
_abs = lambda p: p if _os.path.isabs(p) else _os.path.join(cwd, p) # noqa: E731
|
|
536
|
+
# /stig new <xccdf> [out.ckl] — generate a BLANK .ckl from a STIG XCCDF benchmark
|
|
537
|
+
if parts[0].lower() == "new" and len(parts) > 1:
|
|
538
|
+
xp = _abs(parts[1])
|
|
539
|
+
out = _abs(parts[2]) if len(parts) > 2 else \
|
|
540
|
+
_os.path.splitext(_abs(parts[1]))[0].replace("-xccdf", "") + ".ckl"
|
|
541
|
+
try:
|
|
542
|
+
cl = stig.xccdf_to_checklist(xp)
|
|
543
|
+
cl.save(out)
|
|
544
|
+
except Exception as e: # noqa: BLE001
|
|
545
|
+
self._mount(ErrorMessage(f"could not parse the STIG XCCDF: {e}"))
|
|
546
|
+
return
|
|
547
|
+
self._info(
|
|
548
|
+
f"✓ Generated {out} from the STIG benchmark — {len(cl.rules)} rules, "
|
|
549
|
+
"all Not_Reviewed. Now pull in the app's evidence and assess:\n"
|
|
550
|
+
f" /graphrag build <app-docs> · /loop {len(cl.rules)} /stig-assess {out}"
|
|
551
|
+
)
|
|
552
|
+
return
|
|
553
|
+
# /stig graph <path> — ingest the checklist into the RMF typed graph
|
|
554
|
+
if parts[0].lower() == "graph" and len(parts) > 1:
|
|
555
|
+
from drydock import rmf_graph
|
|
556
|
+
gp = parts[1] if _os.path.isabs(parts[1]) else _os.path.join(cwd, parts[1])
|
|
557
|
+
try:
|
|
558
|
+
cl = stig.load(gp)
|
|
559
|
+
g = rmf_graph.RmfGraph.load(rmf_graph.graph_path(cwd))
|
|
560
|
+
r = rmf_graph.ingest_checklist(g, cl)
|
|
561
|
+
g.save(rmf_graph.graph_path(cwd))
|
|
562
|
+
except Exception as e: # noqa: BLE001
|
|
563
|
+
self._mount(ErrorMessage(f"could not graph checklist: {e}"))
|
|
564
|
+
return
|
|
565
|
+
self._info(
|
|
566
|
+
f"✓ Ingested {r['rules']} STIG rules for host '{r['host']}' into the "
|
|
567
|
+
f"RMF graph (STIG/STIG-Rule nodes, PART_OF/APPLIES_TO/EVALUATES). "
|
|
568
|
+
"Link rules to controls with GraphAdd satisfies; trace via GraphQuery."
|
|
569
|
+
)
|
|
570
|
+
return
|
|
532
571
|
path = parts[0]
|
|
533
572
|
if not _os.path.isabs(path):
|
|
534
|
-
path = _os.path.join(
|
|
573
|
+
path = _os.path.join(cwd, path)
|
|
535
574
|
try:
|
|
536
575
|
cl = stig.load(path)
|
|
537
576
|
except Exception as e: # noqa: BLE001
|
|
@@ -30,6 +30,7 @@ drydock/builtin_skills/rmf-control.md
|
|
|
30
30
|
drydock/builtin_skills/rmf-poam.md
|
|
31
31
|
drydock/builtin_skills/rmf-review.md
|
|
32
32
|
drydock/builtin_skills/stig-assess.md
|
|
33
|
+
drydock/builtin_skills/stig-remediate.md
|
|
33
34
|
drydock/tools/__init__.py
|
|
34
35
|
drydock/tui/__init__.py
|
|
35
36
|
drydock/tui/app.py
|
|
@@ -74,6 +75,7 @@ tests/test_providers_unreachable.py
|
|
|
74
75
|
tests/test_read_index.py
|
|
75
76
|
tests/test_rmf.py
|
|
76
77
|
tests/test_rmf_graph.py
|
|
78
|
+
tests/test_rmf_stig_graph.py
|
|
77
79
|
tests/test_runaway_repetition.py
|
|
78
80
|
tests/test_skills.py
|
|
79
81
|
tests/test_stig.py
|
|
@@ -87,4 +89,5 @@ tests/test_tools_undo.py
|
|
|
87
89
|
tests/test_tui.py
|
|
88
90
|
tests/test_tuning.py
|
|
89
91
|
tests/test_vision_input.py
|
|
90
|
-
tests/test_web_tools.py
|
|
92
|
+
tests/test_web_tools.py
|
|
93
|
+
tests/test_xccdf.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.70"
|
|
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,39 @@
|
|
|
1
|
+
"""RMF Phase 2 STIG ontology — ingest a checklist into the typed graph and
|
|
2
|
+
trace Control <-SATISFIED_BY-> STIG-Rule."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from drydock import stig, rmf_graph
|
|
6
|
+
from drydock.tools import tool_graphadd, tool_graphquery
|
|
7
|
+
|
|
8
|
+
_CKL = ('<?xml version="1.0"?><CHECKLIST><ASSET><HOST_NAME>web01</HOST_NAME></ASSET>'
|
|
9
|
+
'<STIGS><iSTIG><STIG_INFO><SI_DATA><SID_NAME>title</SID_NAME><SID_DATA>RHEL 9 STIG</SID_DATA></SI_DATA></STIG_INFO>'
|
|
10
|
+
'<VULN><STIG_DATA><VULN_ATTRIBUTE>Vuln_Num</VULN_ATTRIBUTE><ATTRIBUTE_DATA>V-1</ATTRIBUTE_DATA></STIG_DATA>'
|
|
11
|
+
'<STIG_DATA><VULN_ATTRIBUTE>Rule_ID</VULN_ATTRIBUTE><ATTRIBUTE_DATA>SV-1r1</ATTRIBUTE_DATA></STIG_DATA>'
|
|
12
|
+
'<STIG_DATA><VULN_ATTRIBUTE>CCI_REF</VULN_ATTRIBUTE><ATTRIBUTE_DATA>CCI-000366</ATTRIBUTE_DATA></STIG_DATA>'
|
|
13
|
+
'<STATUS>Open</STATUS></VULN></iSTIG></STIGS></CHECKLIST>')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_parse_captures_stig_info_and_cci(tmp_path):
|
|
17
|
+
p = tmp_path / "s.ckl"; p.write_text(_CKL)
|
|
18
|
+
cl = stig.load(p)
|
|
19
|
+
assert cl.stig_name == "RHEL 9 STIG" and cl.rules[0].cci == "CCI-000366"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_ingest_checklist_builds_typed_nodes(tmp_path):
|
|
23
|
+
(tmp_path / "s.ckl").write_text(_CKL)
|
|
24
|
+
cl = stig.load(tmp_path / "s.ckl")
|
|
25
|
+
g = rmf_graph.RmfGraph()
|
|
26
|
+
r = rmf_graph.ingest_checklist(g, cl)
|
|
27
|
+
assert r["rules"] == 1 and r["host"] == "web01"
|
|
28
|
+
rn = rmf_graph.rule_node("SV-1r1")
|
|
29
|
+
assert g.get(rn)["type"] == "STIGRule"
|
|
30
|
+
assert rmf_graph.stig_id("RHEL 9 STIG") in g.neighbors(rn, "PART_OF")
|
|
31
|
+
assert rmf_graph.component_id("web01") in g.neighbors(rn, "EVALUATES")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_satisfies_links_control_to_rule(tmp_path):
|
|
35
|
+
rmf_graph.RmfGraph().save(rmf_graph.graph_path(str(tmp_path)))
|
|
36
|
+
cfg = {"cwd": str(tmp_path)}
|
|
37
|
+
assert "SATISFIED_BY" in tool_graphadd({"op": "satisfies", "control": "CM-6", "rule": "SV-1r1"}, cfg)
|
|
38
|
+
out = tool_graphquery({"op": "control", "id": "CM-6"}, cfg)
|
|
39
|
+
assert "Satisfied by STIG rules" in out and "SV-1r1" in out
|
|
@@ -55,7 +55,7 @@ def test_empty_body_skipped(tmp_path):
|
|
|
55
55
|
def test_no_user_or_project_skills_yields_only_builtins(tmp_path):
|
|
56
56
|
# With no user/project skills, only the bundled built-ins (RMF) are present.
|
|
57
57
|
loaded = skills.load_skills(str(tmp_path))
|
|
58
|
-
assert set(loaded) == {"rmf-control", "rmf-categorize", "rmf-review", "rmf-poam", "stig-assess"}
|
|
58
|
+
assert set(loaded) == {"rmf-control", "rmf-categorize", "rmf-review", "rmf-poam", "stig-assess", "stig-remediate"}
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
def test_create_skill_user_scope(tmp_path, monkeypatch):
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""XCCDF benchmark (raw STIG) -> blank .ckl generator — the inverse of STIG
|
|
2
|
+
Viewer, closing the loop: STIG benchmark -> assess -> completed .ckl."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
|
|
7
|
+
from drydock import stig
|
|
8
|
+
|
|
9
|
+
# A minimal but real-shaped DISA STIG XCCDF (namespaced, like the live files).
|
|
10
|
+
_XCCDF = '''<?xml version="1.0"?>
|
|
11
|
+
<Benchmark xmlns="http://checklists.nist.gov/xccdf/1.1" id="ASD_STIG">
|
|
12
|
+
<title>Application Security and Development STIG</title>
|
|
13
|
+
<version>6</version>
|
|
14
|
+
<plain-text id="release-info">Release: 1 Benchmark Date: 24 Jan 2024</plain-text>
|
|
15
|
+
<Group id="V-222387">
|
|
16
|
+
<title>SRG-APP-000001</title>
|
|
17
|
+
<Rule id="SV-222387r879511_rule" severity="medium" weight="10.0">
|
|
18
|
+
<version>APSC-DV-000010</version>
|
|
19
|
+
<title>The application must limit the number of concurrent sessions.</title>
|
|
20
|
+
<description><VulnDiscussion>Limiting sessions reduces risk.</VulnDiscussion><FalsePositives></FalsePositives></description>
|
|
21
|
+
<ident system="http://cyber.mil/cci">CCI-000054</ident>
|
|
22
|
+
<fixtext fixref="F-1">Configure the application to limit concurrent sessions.</fixtext>
|
|
23
|
+
<check system="C-1"><check-content>Review the application configuration for a session limit.</check-content></check>
|
|
24
|
+
</Rule>
|
|
25
|
+
</Group>
|
|
26
|
+
</Benchmark>'''
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_parse_xccdf(tmp_path):
|
|
30
|
+
p = tmp_path / "b-xccdf.xml"; p.write_text(_XCCDF)
|
|
31
|
+
b = stig.parse_xccdf(p)
|
|
32
|
+
assert b["title"].startswith("Application Security") and b["version"] == "6"
|
|
33
|
+
assert "Release: 1" in b["release"]
|
|
34
|
+
r = b["rules"][0]
|
|
35
|
+
assert r["group_id"] == "V-222387" and r["rule_id"] == "SV-222387r879511_rule"
|
|
36
|
+
assert r["rule_ver"] == "APSC-DV-000010" and r["severity"] == "medium"
|
|
37
|
+
assert r["discussion"] == "Limiting sessions reduces risk." # VulnDiscussion extracted
|
|
38
|
+
assert "session limit" in r["check"] and "concurrent sessions" in r["fix"]
|
|
39
|
+
assert r["ccis"] == ["CCI-000054"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_xccdf_to_blank_ckl(tmp_path):
|
|
43
|
+
p = tmp_path / "b-xccdf.xml"; p.write_text(_XCCDF)
|
|
44
|
+
cl = stig.xccdf_to_checklist(p, host="appsrv1")
|
|
45
|
+
assert len(cl.rules) == 1 and cl.counts()["not_reviewed"] == 1
|
|
46
|
+
assert cl.rules[0].rule_id == "SV-222387r879511_rule" and cl.rules[0].cci == "CCI-000054"
|
|
47
|
+
out = tmp_path / "gen.ckl"; cl.save(out)
|
|
48
|
+
ET.parse(out) # valid XML
|
|
49
|
+
# round-trips through the engine and is assessable
|
|
50
|
+
re = stig.load(out)
|
|
51
|
+
assert re.rules[0].check_content.startswith("Review the application")
|
|
52
|
+
assert re.asset["HOST_NAME"] == "appsrv1"
|
|
53
|
+
re.update("V-222387", status="open", finding_details="no limit configured"); re.save(out)
|
|
54
|
+
assert stig.load(out).rules[0].status == "open"
|
|
55
|
+
# the regenerated .ckl preserves the DISA status enum
|
|
56
|
+
assert "<STATUS>Open</STATUS>" in out.read_text()
|
|
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
|