drydock-cli 3.0.63__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.63/drydock_cli.egg-info → drydock_cli-3.0.65}/PKG-INFO +8 -1
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/README.md +7 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/rmf.py +6 -0
- drydock_cli-3.0.65/drydock/rmf_graph.py +155 -0
- drydock_cli-3.0.65/drydock/stig.py +173 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/tools/__init__.py +262 -2
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/tui/app.py +4 -2
- {drydock_cli-3.0.63 → drydock_cli-3.0.65/drydock_cli.egg-info}/PKG-INFO +8 -1
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock_cli.egg-info/SOURCES.txt +4 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/pyproject.toml +1 -1
- drydock_cli-3.0.65/tests/test_rmf_graph.py +62 -0
- drydock_cli-3.0.65/tests/test_stig.py +70 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/LICENSE +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/NOTICE +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/agent.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/cli.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/config.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/detect.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/extract.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/guards.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/providers.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/skills.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock/web.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/setup.cfg +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_config.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_extract.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_rmf.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.63 → drydock_cli-3.0.65}/tests/test_web_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: drydock-cli
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.65
|
|
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
|
|
@@ -137,6 +137,13 @@ control catalog into the knowledge base and ships four RMF skills — all
|
|
|
137
137
|
/rmf-poam <finding> # generate a POA&M entry from a scan/STIG finding
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
+
Beyond text retrieval, `/rmf bootstrap` also builds a **typed ontology graph**
|
|
141
|
+
(Control / Component / Vulnerability nodes; IMPLEMENTS / RESIDES_ON / ASSESSES
|
|
142
|
+
edges). The agent records your system topology with `GraphAdd` and traces
|
|
143
|
+
relationships with `GraphQuery` — including **control inheritance** ("which
|
|
144
|
+
servers inherit physical controls from their enclave?"). Stdlib in-memory graph,
|
|
145
|
+
no Neo4j.
|
|
146
|
+
|
|
140
147
|
## Install
|
|
141
148
|
|
|
142
149
|
```bash
|
|
@@ -113,6 +113,13 @@ control catalog into the knowledge base and ships four RMF skills — all
|
|
|
113
113
|
/rmf-poam <finding> # generate a POA&M entry from a scan/STIG finding
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
+
Beyond text retrieval, `/rmf bootstrap` also builds a **typed ontology graph**
|
|
117
|
+
(Control / Component / Vulnerability nodes; IMPLEMENTS / RESIDES_ON / ASSESSES
|
|
118
|
+
edges). The agent records your system topology with `GraphAdd` and traces
|
|
119
|
+
relationships with `GraphQuery` — including **control inheritance** ("which
|
|
120
|
+
servers inherit physical controls from their enclave?"). Stdlib in-memory graph,
|
|
121
|
+
no Neo4j.
|
|
122
|
+
|
|
116
123
|
## Install
|
|
117
124
|
|
|
118
125
|
```bash
|
|
@@ -123,4 +123,10 @@ def bootstrap(cwd: str, *, families: list[str] | None = None,
|
|
|
123
123
|
store = graphrag.default_store_path(cwd)
|
|
124
124
|
stats = graphrag.add_to_index([str(d) for d in docs], store, cwd=cwd)
|
|
125
125
|
stats["family_docs"] = len(docs)
|
|
126
|
+
# Phase 2: also build the typed ontology graph (Control + Objective backbone)
|
|
127
|
+
# so the agent can TRACE relationships via GraphQuery, not just retrieve text.
|
|
128
|
+
from drydock import rmf_graph
|
|
129
|
+
g = rmf_graph.build_from_catalog(catalog, families=families)
|
|
130
|
+
g.save(rmf_graph.graph_path(cwd))
|
|
131
|
+
stats["graph"] = g.stats()
|
|
126
132
|
return stats
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""RMF typed ontology graph — schema-typed nodes + relationships for RMF
|
|
2
|
+
traceability (Phase 2 of Operation RMF Automata).
|
|
3
|
+
|
|
4
|
+
Clean-room, stdlib only: a small in-memory typed graph persisted as JSON (NOT an
|
|
5
|
+
external Neo4j) so it stays local-first and dependency-free. It complements the
|
|
6
|
+
GraphRAG text KB: GraphRAG answers "what does the doc say", this answers "trace
|
|
7
|
+
the relationships" — e.g. which controls a component inherits from its parent.
|
|
8
|
+
|
|
9
|
+
Node types: Control · Objective (assessment objective / test case) · Component ·
|
|
10
|
+
Vulnerability · STIG · STIGRule · Boundary
|
|
11
|
+
Edge types: ASSESSES (Objective→Control) · IMPLEMENTS (Component→Control) ·
|
|
12
|
+
RESIDES_ON (Component→Component/Boundary) · AFFECTS (Vuln→Component) ·
|
|
13
|
+
SATISFIED_BY (Control→STIGRule) · EVALUATES (STIGRule→Component) ·
|
|
14
|
+
PART_OF (STIGRule→STIG) · APPLIES_TO (STIG→Component) · MITIGATES
|
|
15
|
+
|
|
16
|
+
All logic original to Drydock.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def graph_path(cwd: str) -> Path:
|
|
25
|
+
return Path(cwd) / ".drydock" / "rmf" / "graph.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RmfGraph:
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self.nodes: dict[str, dict] = {} # id -> {type, attrs}
|
|
31
|
+
self.edges: list[dict] = [] # {src, rel, dst}
|
|
32
|
+
self._edge_keys: set[tuple] = set()
|
|
33
|
+
|
|
34
|
+
# ── mutation ────────────────────────────────────────────────────────────
|
|
35
|
+
def add_node(self, nid: str, ntype: str, **attrs) -> str:
|
|
36
|
+
nid = nid.strip()
|
|
37
|
+
node = self.nodes.setdefault(nid, {"type": ntype, "attrs": {}})
|
|
38
|
+
node["type"] = ntype
|
|
39
|
+
node["attrs"].update({k: v for k, v in attrs.items() if v is not None})
|
|
40
|
+
return nid
|
|
41
|
+
|
|
42
|
+
def add_edge(self, src: str, rel: str, dst: str) -> None:
|
|
43
|
+
key = (src, rel, dst)
|
|
44
|
+
if key in self._edge_keys:
|
|
45
|
+
return
|
|
46
|
+
self._edge_keys.add(key)
|
|
47
|
+
self.edges.append({"src": src, "rel": rel, "dst": dst})
|
|
48
|
+
|
|
49
|
+
# ── queries ─────────────────────────────────────────────────────────────
|
|
50
|
+
def get(self, nid: str) -> dict | None:
|
|
51
|
+
n = self.nodes.get(nid)
|
|
52
|
+
return {"id": nid, **n} if n else None
|
|
53
|
+
|
|
54
|
+
def of_type(self, ntype: str) -> list[str]:
|
|
55
|
+
return [nid for nid, n in self.nodes.items() if n["type"] == ntype]
|
|
56
|
+
|
|
57
|
+
def neighbors(self, nid: str, rel: str | None = None, *, direction: str = "out") -> list[str]:
|
|
58
|
+
out = []
|
|
59
|
+
for e in self.edges:
|
|
60
|
+
if rel and e["rel"] != rel:
|
|
61
|
+
continue
|
|
62
|
+
if direction == "out" and e["src"] == nid:
|
|
63
|
+
out.append(e["dst"])
|
|
64
|
+
elif direction == "in" and e["dst"] == nid:
|
|
65
|
+
out.append(e["src"])
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
def inherited_controls(self, component: str) -> list[str]:
|
|
69
|
+
"""Controls a component inherits from its ancestors (RESIDES_ON, transitive).
|
|
70
|
+
The PRD's "which servers inherit physical controls" / inheritance logic."""
|
|
71
|
+
inherited: list[str] = []
|
|
72
|
+
seen: set[str] = set()
|
|
73
|
+
frontier = self.neighbors(component, "RESIDES_ON", direction="out")
|
|
74
|
+
while frontier:
|
|
75
|
+
parent = frontier.pop()
|
|
76
|
+
if parent in seen:
|
|
77
|
+
continue
|
|
78
|
+
seen.add(parent)
|
|
79
|
+
inherited.extend(self.neighbors(parent, "IMPLEMENTS", direction="out"))
|
|
80
|
+
frontier.extend(self.neighbors(parent, "RESIDES_ON", direction="out"))
|
|
81
|
+
# de-dup, preserve order
|
|
82
|
+
s: set[str] = set()
|
|
83
|
+
return [c for c in inherited if not (c in s or s.add(c))]
|
|
84
|
+
|
|
85
|
+
# ── persistence ─────────────────────────────────────────────────────────
|
|
86
|
+
def save(self, path: str | Path) -> None:
|
|
87
|
+
p = Path(path)
|
|
88
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
p.write_text(json.dumps({"nodes": self.nodes, "edges": self.edges}), "utf-8")
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def load(cls, path: str | Path) -> "RmfGraph":
|
|
93
|
+
g = cls()
|
|
94
|
+
p = Path(path)
|
|
95
|
+
if p.exists():
|
|
96
|
+
try:
|
|
97
|
+
data = json.loads(p.read_text("utf-8"))
|
|
98
|
+
g.nodes = data.get("nodes", {})
|
|
99
|
+
for e in data.get("edges", []):
|
|
100
|
+
g.add_edge(e["src"], e["rel"], e["dst"])
|
|
101
|
+
except (OSError, ValueError, KeyError):
|
|
102
|
+
pass
|
|
103
|
+
return g
|
|
104
|
+
|
|
105
|
+
def stats(self) -> dict:
|
|
106
|
+
by_type: dict[str, int] = {}
|
|
107
|
+
for n in self.nodes.values():
|
|
108
|
+
by_type[n["type"]] = by_type.get(n["type"], 0) + 1
|
|
109
|
+
return {"nodes": len(self.nodes), "edges": len(self.edges), "by_type": by_type}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── id helpers (stable, typed) ──────────────────────────────────────────────
|
|
113
|
+
def control_id(cid: str) -> str:
|
|
114
|
+
return f"control:{cid.lower()}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def component_id(name: str) -> str:
|
|
118
|
+
return f"component:{name.strip().lower()}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_from_catalog(catalog: dict, *, families: list[str] | None = None) -> RmfGraph:
|
|
122
|
+
"""Deterministically build Control + Objective nodes (and ASSESSES edges) from
|
|
123
|
+
an OSCAL 800-53 catalog. The typed backbone the user's components/vulns attach to."""
|
|
124
|
+
g = RmfGraph()
|
|
125
|
+
want = {f.lower() for f in families} if families else None
|
|
126
|
+
for group in catalog.get("catalog", {}).get("groups", []) or []:
|
|
127
|
+
fid = group.get("id", "")
|
|
128
|
+
if want is not None and fid.lower() not in want:
|
|
129
|
+
continue
|
|
130
|
+
family = group.get("title", fid)
|
|
131
|
+
for control in group.get("controls", []) or []:
|
|
132
|
+
_add_control(g, control, family)
|
|
133
|
+
for enh in control.get("controls", []) or []:
|
|
134
|
+
_add_control(g, enh, family, parent=control.get("id"))
|
|
135
|
+
return g
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _add_control(g: RmfGraph, control: dict, family: str, parent: str | None = None) -> None:
|
|
139
|
+
cid = control.get("id", "")
|
|
140
|
+
if not cid:
|
|
141
|
+
return
|
|
142
|
+
node = control_id(cid)
|
|
143
|
+
g.add_node(node, "Control", control_id=cid.upper(), title=control.get("title", ""),
|
|
144
|
+
family=family)
|
|
145
|
+
if parent:
|
|
146
|
+
g.add_edge(node, "ENHANCES", control_id(parent))
|
|
147
|
+
params = {p["id"]: p.get("label") for p in control.get("params", []) or []}
|
|
148
|
+
for i, part in enumerate(control.get("parts", []) or []):
|
|
149
|
+
if part.get("name") == "assessment-objective":
|
|
150
|
+
from drydock.rmf import _part_prose
|
|
151
|
+
prose = _part_prose(part, params)
|
|
152
|
+
if prose:
|
|
153
|
+
oid = f"objective:{cid.lower()}:{i}"
|
|
154
|
+
g.add_node(oid, "Objective", prose=prose[:500])
|
|
155
|
+
g.add_edge(oid, "ASSESSES", node)
|
|
@@ -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,100 @@ 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
|
+
},
|
|
325
|
+
{
|
|
326
|
+
"name": "GraphQuery",
|
|
327
|
+
"description": (
|
|
328
|
+
"Query the RMF ontology GRAPH to TRACE relationships (typed: Control, "
|
|
329
|
+
"Component, Vulnerability, Objective). Use it for traceability the text "
|
|
330
|
+
"knowledge base can't give: a control's assessment objectives, which "
|
|
331
|
+
"components implement a control, or which controls a component INHERITS "
|
|
332
|
+
"from its parent system. ops: control <id>, family <id>, component "
|
|
333
|
+
"<name>, implementers <control>, inherited <component>."
|
|
334
|
+
),
|
|
335
|
+
"input_schema": {
|
|
336
|
+
"type": "object",
|
|
337
|
+
"properties": {
|
|
338
|
+
"op": {"type": "string", "description": "control | family | component | implementers | inherited"},
|
|
339
|
+
"id": {"type": "string", "description": "control id, family id, or component name"},
|
|
340
|
+
},
|
|
341
|
+
"required": ["op", "id"],
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
"name": "GraphAdd",
|
|
346
|
+
"description": (
|
|
347
|
+
"Record a typed fact in the RMF ontology graph as you read an SSP / "
|
|
348
|
+
"scan / checklist, so relationships can be traced later. ops: component "
|
|
349
|
+
"(a system component), implements (component implements a control), "
|
|
350
|
+
"resides_on (component resides on a parent/boundary — enables control "
|
|
351
|
+
"inheritance), vulnerability (a finding affecting a component)."
|
|
352
|
+
),
|
|
353
|
+
"input_schema": {
|
|
354
|
+
"type": "object",
|
|
355
|
+
"properties": {
|
|
356
|
+
"op": {"type": "string", "description": "component | implements | resides_on | vulnerability"},
|
|
357
|
+
"component": {"type": "string"},
|
|
358
|
+
"control": {"type": "string", "description": "control id, for op=implements"},
|
|
359
|
+
"parent": {"type": "string", "description": "parent component/boundary, for op=resides_on"},
|
|
360
|
+
"id": {"type": "string", "description": "vulnerability/STIG/CVE id, for op=vulnerability"},
|
|
361
|
+
"severity": {"type": "string"},
|
|
362
|
+
"os": {"type": "string"}, "ip": {"type": "string"}, "data_type": {"type": "string"},
|
|
363
|
+
},
|
|
364
|
+
"required": ["op"],
|
|
365
|
+
},
|
|
366
|
+
},
|
|
273
367
|
{
|
|
274
368
|
"name": "WebSearch",
|
|
275
369
|
"description": (
|
|
@@ -1053,6 +1147,164 @@ def tool_gitcommit(params: dict, config: dict) -> str:
|
|
|
1053
1147
|
return f"git commit failed: {e}"
|
|
1054
1148
|
|
|
1055
1149
|
|
|
1150
|
+
def _rmf_graph(config):
|
|
1151
|
+
from drydock import rmf_graph
|
|
1152
|
+
cwd = config.get("cwd") or os.getcwd()
|
|
1153
|
+
path = rmf_graph.graph_path(cwd)
|
|
1154
|
+
return rmf_graph, rmf_graph.RmfGraph.load(path), path
|
|
1155
|
+
|
|
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
|
+
|
|
1229
|
+
def tool_graphquery(params: dict, config: dict) -> str:
|
|
1230
|
+
"""Traverse the RMF typed ontology graph (read-only)."""
|
|
1231
|
+
rmf_graph, g, _ = _rmf_graph(config)
|
|
1232
|
+
if not g.nodes:
|
|
1233
|
+
return ("The RMF graph is empty. Run /rmf bootstrap to build the control "
|
|
1234
|
+
"backbone, and use GraphAdd to record components/relationships.")
|
|
1235
|
+
op = (params.get("op") or "").strip().lower()
|
|
1236
|
+
ident = (params.get("id") or "").strip()
|
|
1237
|
+
if op == "control":
|
|
1238
|
+
node = rmf_graph.control_id(ident)
|
|
1239
|
+
n = g.get(node)
|
|
1240
|
+
if not n:
|
|
1241
|
+
return f"No control {ident} in the graph."
|
|
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")]
|
|
1244
|
+
out = [f"{n['attrs'].get('control_id', ident)} — {n['attrs'].get('title','')} "
|
|
1245
|
+
f"(Family: {n['attrs'].get('family','')})"]
|
|
1246
|
+
if objs:
|
|
1247
|
+
out.append("Assessment objectives:\n" + "\n".join(f" - {o}" for o in objs))
|
|
1248
|
+
out.append("Implemented by: " + (", ".join(impl) if impl else "(no components recorded)"))
|
|
1249
|
+
return "\n".join(out)
|
|
1250
|
+
if op == "family":
|
|
1251
|
+
ctrls = [_gattrs(g, c).get("control_id", c)
|
|
1252
|
+
for c in g.of_type("Control")
|
|
1253
|
+
if _gattrs(g, c).get("family", "").lower().startswith(ident.lower())
|
|
1254
|
+
or ident.lower() in _gattrs(g, c).get("family", "").lower()]
|
|
1255
|
+
return f"Controls in '{ident}': " + (", ".join(sorted(ctrls)) or "(none)")
|
|
1256
|
+
if op in ("component", "implementers", "inherited"):
|
|
1257
|
+
comp = rmf_graph.component_id(ident)
|
|
1258
|
+
if op == "implementers":
|
|
1259
|
+
node = rmf_graph.control_id(ident)
|
|
1260
|
+
comps = [_gattrs(g, c).get("name", c) for c in g.neighbors(node, "IMPLEMENTS", direction="in")]
|
|
1261
|
+
return f"Components implementing {ident}: " + (", ".join(comps) or "(none)")
|
|
1262
|
+
if op == "inherited":
|
|
1263
|
+
inh = g.inherited_controls(comp)
|
|
1264
|
+
names = [_gattrs(g, c).get("control_id", c) if g.get(c) else c for c in inh]
|
|
1265
|
+
return (f"{ident} inherits {len(names)} control(s) from its parent system(s): "
|
|
1266
|
+
+ (", ".join(names) or "(none — no RESIDES_ON recorded)"))
|
|
1267
|
+
n = g.get(comp)
|
|
1268
|
+
if not n:
|
|
1269
|
+
return f"No component '{ident}' in the graph (add it with GraphAdd)."
|
|
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")]
|
|
1273
|
+
return (f"Component {ident} ({n['attrs'].get('os','')}): implements "
|
|
1274
|
+
f"{', '.join(impl) or 'none'}; resides on {', '.join(res) or 'nothing'}; "
|
|
1275
|
+
f"flaws {', '.join(vulns) or 'none'}.")
|
|
1276
|
+
return "GraphQuery ops: control <id> | family <id> | component <name> | implementers <control> | inherited <component>"
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def tool_graphadd(params: dict, config: dict) -> str:
|
|
1280
|
+
"""Record a typed fact in the RMF ontology graph (write)."""
|
|
1281
|
+
rmf_graph, g, path = _rmf_graph(config)
|
|
1282
|
+
op = (params.get("op") or "").strip().lower()
|
|
1283
|
+
comp = (params.get("component") or "").strip()
|
|
1284
|
+
if op == "component" and comp:
|
|
1285
|
+
g.add_node(rmf_graph.component_id(comp), "Component", name=comp,
|
|
1286
|
+
os=params.get("os"), ip=params.get("ip"), data_type=params.get("data_type"))
|
|
1287
|
+
g.save(path); return f"Recorded component {comp}."
|
|
1288
|
+
if op == "implements" and comp and params.get("control"):
|
|
1289
|
+
g.add_node(rmf_graph.component_id(comp), "Component", name=comp)
|
|
1290
|
+
g.add_edge(rmf_graph.component_id(comp), "IMPLEMENTS", rmf_graph.control_id(params["control"]))
|
|
1291
|
+
g.save(path); return f"Recorded: {comp} IMPLEMENTS {params['control'].upper()}."
|
|
1292
|
+
if op == "resides_on" and comp and params.get("parent"):
|
|
1293
|
+
g.add_node(rmf_graph.component_id(comp), "Component", name=comp)
|
|
1294
|
+
g.add_node(rmf_graph.component_id(params["parent"]), "Component", name=params["parent"])
|
|
1295
|
+
g.add_edge(rmf_graph.component_id(comp), "RESIDES_ON", rmf_graph.component_id(params["parent"]))
|
|
1296
|
+
g.save(path); return f"Recorded: {comp} RESIDES_ON {params['parent']}."
|
|
1297
|
+
if op == "vulnerability" and params.get("id"):
|
|
1298
|
+
vid = f"vuln:{params['id'].lower()}"
|
|
1299
|
+
g.add_node(vid, "Vulnerability", vuln_id=params["id"], severity=params.get("severity"))
|
|
1300
|
+
if comp:
|
|
1301
|
+
g.add_node(rmf_graph.component_id(comp), "Component", name=comp)
|
|
1302
|
+
g.add_edge(vid, "AFFECTS", rmf_graph.component_id(comp))
|
|
1303
|
+
g.save(path); return f"Recorded vulnerability {params['id']}" + (f" affecting {comp}." if comp else ".")
|
|
1304
|
+
return ("GraphAdd ops: component (name in `component`) | implements (`component`,`control`) "
|
|
1305
|
+
"| resides_on (`component`,`parent`) | vulnerability (`id`, optional `component`,`severity`).")
|
|
1306
|
+
|
|
1307
|
+
|
|
1056
1308
|
def tool_websearch(params: dict, config: dict) -> str:
|
|
1057
1309
|
"""Search the internet (DuckDuckGo). Read-only; clean message when offline."""
|
|
1058
1310
|
from drydock import web
|
|
@@ -1128,6 +1380,11 @@ _TOOLS = [
|
|
|
1128
1380
|
("task", tool_task, True),
|
|
1129
1381
|
("Dispatch", tool_dispatch, True),
|
|
1130
1382
|
("Knowledge", tool_knowledge, True),
|
|
1383
|
+
("GraphQuery", tool_graphquery, True),
|
|
1384
|
+
("GraphAdd", tool_graphadd, False),
|
|
1385
|
+
("StigRules", tool_stigrules, True),
|
|
1386
|
+
("StigRule", tool_stigrule, True),
|
|
1387
|
+
("StigSet", tool_stigset, False),
|
|
1131
1388
|
("WebSearch", tool_websearch, True),
|
|
1132
1389
|
("WebFetch", tool_webfetch, True),
|
|
1133
1390
|
("GitStatus", tool_gitstatus, True),
|
|
@@ -1144,14 +1401,17 @@ def register_all():
|
|
|
1144
1401
|
"Bash": tool_bash, "Glob": tool_glob, "Grep": tool_grep,
|
|
1145
1402
|
"todo": tool_todo, "task": tool_task, "Dispatch": tool_dispatch,
|
|
1146
1403
|
"Knowledge": tool_knowledge,
|
|
1404
|
+
"GraphQuery": tool_graphquery, "GraphAdd": tool_graphadd,
|
|
1405
|
+
"StigRules": tool_stigrules, "StigRule": tool_stigrule, "StigSet": tool_stigset,
|
|
1147
1406
|
"WebSearch": tool_websearch, "WebFetch": tool_webfetch,
|
|
1148
1407
|
"GitStatus": tool_gitstatus, "GitDiff": tool_gitdiff,
|
|
1149
1408
|
"GitLog": tool_gitlog, "GitCommit": tool_gitcommit,
|
|
1150
1409
|
}[name]
|
|
1151
1410
|
# Read-only w.r.t. the parent's files (GitStatus/Diff/Log inspect only;
|
|
1152
|
-
# GitCommit
|
|
1411
|
+
# GitCommit + GraphAdd write).
|
|
1153
1412
|
read_only = name in (
|
|
1154
|
-
"Read", "Glob", "Grep", "task", "Dispatch", "Knowledge",
|
|
1413
|
+
"Read", "Glob", "Grep", "task", "Dispatch", "Knowledge", "GraphQuery",
|
|
1414
|
+
"StigRules", "StigRule",
|
|
1155
1415
|
"WebSearch", "WebFetch", "GitStatus", "GitDiff", "GitLog",
|
|
1156
1416
|
)
|
|
1157
1417
|
register(ToolDef(name=name, schema=schema, func=func, read_only=read_only))
|
|
@@ -554,10 +554,12 @@ class DrydockApp(App):
|
|
|
554
554
|
|
|
555
555
|
try:
|
|
556
556
|
stats = rmf.bootstrap(cwd, families=families)
|
|
557
|
+
gstats = stats.get("graph", {})
|
|
557
558
|
msg = (
|
|
558
559
|
f"✓ RMF catalog ingested: {stats['family_docs']} family doc(s), "
|
|
559
|
-
f"{stats['chunks']} chunks
|
|
560
|
-
"
|
|
560
|
+
f"{stats['chunks']} KB chunks + a typed graph ({gstats.get('nodes', 0)} "
|
|
561
|
+
f"nodes, {gstats.get('edges', 0)} edges). Try /rmf-control AC-2 (text) "
|
|
562
|
+
"or ask the agent to trace relationships (GraphQuery / GraphAdd)."
|
|
561
563
|
)
|
|
562
564
|
except Exception as e: # noqa: BLE001
|
|
563
565
|
msg = (f"RMF bootstrap failed: {e}. (Needs internet for the one-time "
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: drydock-cli
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.65
|
|
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
|
|
@@ -137,6 +137,13 @@ control catalog into the knowledge base and ships four RMF skills — all
|
|
|
137
137
|
/rmf-poam <finding> # generate a POA&M entry from a scan/STIG finding
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
+
Beyond text retrieval, `/rmf bootstrap` also builds a **typed ontology graph**
|
|
141
|
+
(Control / Component / Vulnerability nodes; IMPLEMENTS / RESIDES_ON / ASSESSES
|
|
142
|
+
edges). The agent records your system topology with `GraphAdd` and traces
|
|
143
|
+
relationships with `GraphQuery` — including **control inheritance** ("which
|
|
144
|
+
servers inherit physical controls from their enclave?"). Stdlib in-memory graph,
|
|
145
|
+
no Neo4j.
|
|
146
|
+
|
|
140
147
|
## Install
|
|
141
148
|
|
|
142
149
|
```bash
|
|
@@ -18,7 +18,9 @@ drydock/loop_detect.py
|
|
|
18
18
|
drydock/mcp.py
|
|
19
19
|
drydock/providers.py
|
|
20
20
|
drydock/rmf.py
|
|
21
|
+
drydock/rmf_graph.py
|
|
21
22
|
drydock/skills.py
|
|
23
|
+
drydock/stig.py
|
|
22
24
|
drydock/tool_registry.py
|
|
23
25
|
drydock/tuning.py
|
|
24
26
|
drydock/web.py
|
|
@@ -69,8 +71,10 @@ tests/test_plan_autocontinue.py
|
|
|
69
71
|
tests/test_providers_unreachable.py
|
|
70
72
|
tests/test_read_index.py
|
|
71
73
|
tests/test_rmf.py
|
|
74
|
+
tests/test_rmf_graph.py
|
|
72
75
|
tests/test_runaway_repetition.py
|
|
73
76
|
tests/test_skills.py
|
|
77
|
+
tests/test_stig.py
|
|
74
78
|
tests/test_stop.py
|
|
75
79
|
tests/test_streaming_newlines.py
|
|
76
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,62 @@
|
|
|
1
|
+
"""RMF Phase 2 — the typed ontology graph + GraphQuery/GraphAdd tools.
|
|
2
|
+
Inheritance reasoning is the headline: a component inherits the controls its
|
|
3
|
+
parent system implements (RESIDES_ON → IMPLEMENTS)."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from drydock import rmf_graph as G
|
|
7
|
+
from drydock.tools import tool_graphquery, tool_graphadd
|
|
8
|
+
|
|
9
|
+
_CAT = {"catalog": {"groups": [
|
|
10
|
+
{"id": "ac", "title": "Access Control", "controls": [
|
|
11
|
+
{"id": "ac-2", "title": "Account Management",
|
|
12
|
+
"parts": [{"name": "assessment-objective", "prose": "accounts are managed"}]}]},
|
|
13
|
+
{"id": "pe", "title": "Physical and Environmental Protection", "controls": [
|
|
14
|
+
{"id": "pe-3", "title": "Physical Access Control", "parts": []}]},
|
|
15
|
+
]}}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_build_from_catalog_typed_nodes():
|
|
19
|
+
g = G.build_from_catalog(_CAT)
|
|
20
|
+
assert g.get(G.control_id("ac-2"))["attrs"]["title"] == "Account Management"
|
|
21
|
+
objs = g.neighbors(G.control_id("ac-2"), "ASSESSES", direction="in")
|
|
22
|
+
assert objs and g.get(objs[0])["type"] == "Objective"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_inheritance_reasoning():
|
|
26
|
+
g = G.build_from_catalog(_CAT)
|
|
27
|
+
g.add_node(G.component_id("web-1"), "Component", name="web-1")
|
|
28
|
+
g.add_node(G.component_id("enclave"), "Boundary", name="enclave")
|
|
29
|
+
g.add_edge(G.component_id("web-1"), "RESIDES_ON", G.component_id("enclave"))
|
|
30
|
+
g.add_edge(G.component_id("enclave"), "IMPLEMENTS", G.control_id("pe-3"))
|
|
31
|
+
inh = g.inherited_controls(G.component_id("web-1"))
|
|
32
|
+
assert G.control_id("pe-3") in inh
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_persistence_roundtrip(tmp_path):
|
|
36
|
+
g = G.build_from_catalog(_CAT)
|
|
37
|
+
g.add_edge(G.component_id("a"), "RESIDES_ON", G.component_id("b"))
|
|
38
|
+
p = tmp_path / "g.json"; g.save(p)
|
|
39
|
+
g2 = G.RmfGraph.load(p)
|
|
40
|
+
assert g2.stats()["nodes"] == g.stats()["nodes"]
|
|
41
|
+
assert {"src": G.component_id("a"), "rel": "RESIDES_ON", "dst": G.component_id("b")} in g2.edges
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_graphadd_and_graphquery_tools(tmp_path):
|
|
45
|
+
from drydock import rmf_graph
|
|
46
|
+
# seed a catalog graph
|
|
47
|
+
g = G.build_from_catalog(_CAT)
|
|
48
|
+
g.save(rmf_graph.graph_path(str(tmp_path)))
|
|
49
|
+
cfg = {"cwd": str(tmp_path)}
|
|
50
|
+
# build a system via the write tool
|
|
51
|
+
assert "RESIDES_ON" in tool_graphadd({"op": "resides_on", "component": "srv1", "parent": "encl"}, cfg)
|
|
52
|
+
assert "IMPLEMENTS" in tool_graphadd({"op": "implements", "component": "encl", "control": "PE-3"}, cfg)
|
|
53
|
+
# query inheritance via the read tool
|
|
54
|
+
out = tool_graphquery({"op": "inherited", "id": "srv1"}, cfg)
|
|
55
|
+
assert "PE-3" in out and "inherits" in out
|
|
56
|
+
# control lookup + implementers
|
|
57
|
+
assert "Account Management" in tool_graphquery({"op": "control", "id": "AC-2"}, cfg)
|
|
58
|
+
assert "encl" in tool_graphquery({"op": "implementers", "id": "PE-3"}, cfg)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_graphquery_empty_graph_is_graceful(tmp_path):
|
|
62
|
+
assert "empty" in tool_graphquery({"op": "control", "id": "AC-2"}, {"cwd": str(tmp_path)}).lower()
|
|
@@ -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
|