See OVERVIEW.md for a high-level description of the
repository's purpose, components, and scope before making behavioral
changes.
A remediation orchestrator, exposed as an MCP server. Connect an MCP client (Claude Desktop, Claude Code, or similar) and submit a natural-language request, for example:
Log into host a.b.c, check installed packages, find CVEs, and suggest a fix - Ansible if possible, and tell me what compliance controls it maps to.
prescryb supplies the primitives (SSH inventory, CVE matching, live advisory
lookups, compliance-topic mapping, Ansible playbook rendering). The
connected model does the reasoning: which findings matter, which CVEs to
dig into, which playbook to generate. prescryb never applies anything to the
target host - every tool is read-only against it, or pure text/data
generation.
| Tool | What it does |
|---|---|
inventory_host(host, user="", port=22, hostname="", identity_file="", trust_unknown_host=False) |
SSH in, detect the distro, list installed packages with versions. |
check_cves(system, packages) |
Batch-match package versions against OSV.dev using its ecosystem-aware version comparison (not name-only matching). Each match is enriched with an EPSS exploitation-probability score. |
fetch_advisory(cve_id) |
Fetch the current NVD record for one CVE - description, CVSS, CWE, references - live, not from training data. |
fetch_epss(cve_ids) |
Batch-fetch EPSS exploitation-probability scores for CVE IDs not already covered by check_cves (e.g. from fetch_advisory or a web search). |
map_compliance(area) |
Map a free-text topic ("ssh", "sudo", "kernel modules", ...) to CIS/DISA STIG topic areas and, if present in the konstruktoid.hardening GitHub repo, the matching role - plus the MITRE ATT&CK techniques and mitigations that area addresses. |
lookup_cce(target, keyword, cce_id) |
Look up NIST CCE (Common Configuration Enumeration) entries for a platform (e.g. "rhel8"), sourced from the community JSON conversion at konstruktoid/cce-web. |
list_cce_targets() |
List every platform lookup_cce can query. |
list_local_docs() |
List documents (Markdown/text/PDF) found under the local, gitignored LOCAL_DOCS_DIR. |
search_local_docs(query, top_k=5) |
RAG-style semantic search over those local documents, embedded entirely on-machine. |
generate_playbook(system, cve_matches, compliance_areas, hosts_alias) |
Render a suggest-only Ansible playbook: CVE fixes become package-upgrade tasks, compliance areas become roles: references. |
Typical flow: inventory_host, then check_cves on the returned packages,
then optionally fetch_advisory on interesting CVEs, then map_compliance
(and lookup_cce) for any insecure-config areas noticed, then optionally
search_local_docs for relevant internal runbook/policy context, then
generate_playbook to produce something to review.
flowchart TD
U["Operator: natural-language request"] --> M["Connected model\n(Claude Desktop / Claude Code)"]
M --> A["inventory_host\nSSH in, list packages"]
A --> B["check_cves\nOSV.dev match + EPSS score"]
B --> C{"Interesting\nCVE?"}
C -->|yes| D["fetch_advisory\nNVD detail for one CVE"]
C -->|no| E
D --> E["map_compliance / lookup_cce\nCIS/DISA STIG + ATT&CK mapping"]
E --> F["search_local_docs\ninternal runbooks (optional)"]
F --> G["generate_playbook\nsuggest-only Ansible playbook"]
G --> H["Model reasons over the results\nand presents findings + playbook"]
H --> R["Operator reviews\n(ansible-playbook --check --diff)\nand applies manually"]
style R fill:#f9f,stroke:#333,stroke-width:1px
Only the tool boxes are prescryb: inventory_host, check_cves,
fetch_advisory, map_compliance/lookup_cce, search_local_docs, and
generate_playbook - each a read-only lookup or pure text/data generation
call. The operator and connected model are not part of prescryb, and
nothing in this chain touches the target host beyond inventory_host's
read-only SSH session - applying the generated playbook is a deliberate,
separate step the operator takes outside prescryb.
uv syncClaude Code:
claude mcp add prescryb -- uv --directory /path/to/prescryb run prescrybClaude Desktop (claude_desktop_config.json):
{
"mcpServers": {
"prescryb": {
"command": "uv",
"args": ["--directory", "/path/to/prescryb", "run", "prescryb"]
}
}
}inventory_host never accepts a password argument. MCP tool-call arguments
can be logged by clients and are visible to the connected model, so
credentials must never flow through them. Auth works exactly like running
ssh host yourself:
- Host/user/port/identity files are resolved from
~/.ssh/config. - Keys come from an SSH agent or the default identity files.
inventory_host'shostname/identity_filearguments override the resolved address/key path directly, for hosts you do not want to add to~/.ssh/config(only a path is passed, never key contents).- Unknown host keys are rejected unless you pass
trust_unknown_host=True- prefer running
ssh hostmanually once to pin the key instead.
- prefer running
qemu/vm.sh boots a cloud image (almalinux10, trixie, resolute) under
QEMU with a cloud-init seed and an SSH port forward on 127.0.0.1
(2201, 2202, 2203). It needs qemu-system-x86_64, qemu-img, cloud-localds
(cloud-image-utils), curl and ssh; KVM is used when /dev/kvm is writable.
Images are checksum-verified and cached in qemu/.state/.
qemu/vm.sh up trixie
qemu/vm.sh ssh trixie true # retry until the guest answers
claude 'inventory 127.0.0.1 port 2202 user prescryb with identity file
qemu/.state/id_ed25519 (trust_unknown_host is fine, it is a throwaway VM),
check any vulnerabilities and suggest a fix, include compliance mapping if
possible, write the playbook suggestion to /tmp/ and print the file location'
qemu/vm.sh destroy trixieFor a host already reachable via ssh (resolved through ~/.ssh/config,
an agent key, or the default identity file), specify the hostname
directly; no port or identity-file configuration is required:
Inventory prod-web-01, check installed packages, find CVEs, and suggest a fix - Ansible if possible, and tell me what compliance controls it maps to.
If the host is not in ~/.ssh/config yet, either add a Host block or pass
user/port/hostname/identity_file straight to inventory_host for a
one-off connection - same as the molecule example below.
molecule converge -s defaultFind the ssh_port/ssh_user from that scenario's molecule.yml platform
entry (e.g. ssh_port: 22201, ssh_user: almalinux for the almalinux10
platform) and the private key molecule login uses to connect - either add
a Host block to ~/.ssh/config, or skip the file entirely and pass them
straight to inventory_host for a one-off, ephemeral connection:
Inventory 127.0.0.1, port 22201, user almalinux, identity_file /path/to/molecule's/generated/key, check installed packages, find CVEs, and suggest a fix - Ansible if possible, and tell me what compliance controls it maps to.
Run molecule destroy -s default when finished; prescryb will not do it
for you, and will not touch the instance beyond reading it.
map_compliance names topic areas (e.g. "SSH Server Configuration") and, if
found in the konstruktoid/ansible-collection-hardening GitHub repository, a link to the
Ansible role that implements it.
By default it queries the GitHub API against
konstruktoid/ansible-collection-hardening.
Override with:
export HARDENING_COLLECTION_REPO=owner/repoSet GITHUB_TOKEN to raise the (otherwise low) unauthenticated GitHub API
rate limit. If a role is not found in the repo, map_compliance still
returns the topic/framework/role name so you know what to install
(ansible-galaxy collection install konstruktoid.hardening).
lookup_cce looks up NIST Common Configuration Enumeration
entries - unique identifiers for individual configuration checks, distinct
from the topic-area CIS/DISA STIG mapping above. NIST only publishes CCE as
spreadsheets, so this reads the pre-converted JSON exports hosted by the
community project konstruktoid/cce-web
instead of parsing Excel.
Coverage is per-platform, not per-topic, and thin for this project's target
distros: only RHEL-family (rhel6/rhel7/rhel8 - AlmaLinux/Rocky use the
matching upstream RHEL number) and SUSE
(SLES12-DISA-STIG/SLES15-DISA-STIG/SLES15-PCI-DSS) have usable data.
Debian, Ubuntu, Alpine, and Arch have no CCE data upstream at all. A few
older cce-web exports (e.g. rhel4, rhel5, apache-httpd2.2) lost
their column headers in the upstream Excel-to-JSON conversion; those are
reported as unsupported rather than returning garbled fields. Call
list_cce_targets to see every published platform, including non-Linux
ones (firefox, win2k8r2, ...).
Override the source repo with:
export CCE_REPO=owner/repoAlongside CIS/DISA STIG, map_compliance and generate_playbook also cite
the MITRE ATT&CK technique(s) mitigated by a
topic area's hardening (e.g. "ssh" maps to T1110 Brute Force and
T1021.004 Remote Services: SSH) and, where ATT&CK defines one, the
corresponding mitigation (e.g. M1032 Multi-factor Authentication) with a
link to attack.mitre.org. Unlike CIS/DISA STIG rule numbers, ATT&CK
technique and mitigation IDs are MITRE's own public catalog, so they are
cited directly rather than needing a licensed benchmark lookup. This mapping
is static (built into attack.py), not fetched live.
- OSV.dev is the sole CVE-matching source. It resolves
{name, ecosystem, version}server-side against the ecosystem's actual version ordering, so a match reflects the exact installed version rather than "any CVE that mentions this package name." Coverage is mature for Debian, Ubuntu, Alpine; thinner for RHEL-family (AlmaLinux, Rocky) and SUSE.check_cvesreturns awarningfield flagging thinner-coverage ecosystems, and returns nothing (rather than a guess) for distros with no ecosystem mapping at all - an empty result there means "not checked," not "clean." - NVD (
fetch_advisory) is used only to enrich a CVE you already have the ID for. SetNVD_API_KEYto raise the (otherwise low) unauthenticated rate limit. - EPSS (
epss_score/epss_percentileon everycheck_cvesmatch, orfetch_epssfor CVE IDs from elsewhere) estimates the probability of exploitation in the next 30 days - independent of, and a useful complement to, CVSS/severity: a LOW-severity CVE can carry a high EPSS score, and vice versa. This lets findings be sorted/filtered bycve_id,severity, orepss_score. No API key needed. CVEs with no EPSS record (very new, reserved, or rejected IDs) simply haveepss_scoreunset - not an error. A FIRST.org outage surfaces as awarningoncheck_cvesrather than failing the CVE match itself. - Severity: OSV gives a raw CVSS vector string (
cvss_vector), not a precomputed label, for most OS-package entries.severityis only populated when the source explicitly labels it; otherwise it is"UNKNOWN"and the vector is left for you (or the model) to interpret, rather than guessing.
search_local_docs and list_local_docs are a RAG-style complement to the
network-backed tools above: semantic search over documents an operator
places in a local directory (default local_docs/, override with
LOCAL_DOCS_DIR) - internal runbooks, hardening policy, past remediation
notes - that no public source knows about. Supported formats: Markdown
(.md/.markdown), plain text (.txt), and PDF (.pdf); dotfiles,
dotdirs (e.g. .git/), and symlinks are skipped.
mkdir local_docs
cp ~/ssh-hardening-runbook.pdf local_docs/local_docs/ is already listed in .gitignore - these are operator-supplied
and machine-local, never committed to this repository.
Documents are split into paragraph-preferring chunks and embedded locally
with sentence-transformers (default model
sentence-transformers/all-MiniLM-L6-v2, override with LOCAL_DOCS_MODEL).
Embedding and ranking happen entirely on this machine - the only network
access this feature needs is downloading the model weights from Hugging Face
the first time it runs; after that first run indexing and search are fully
offline (set HF_HUB_OFFLINE=1 to force it, once the model is cached). That
is a claim about prescryb, not about the client: search_local_docs
returns the matched chunks as its tool result, so the query and those chunks
reach the connected MCP client and whatever model it is driving, exactly
like every other tool's output. Put nothing in LOCAL_DOCS_DIR you would
not send to that client. The chunk index is rebuilt in memory whenever a file under
LOCAL_DOCS_DIR is added, removed, or modified, and reused otherwise.
A file that can't be read (e.g. a corrupt PDF), yields no extractable text
(e.g. a scanned, image-only PDF that would need OCR first), or exceeds the
20 MB per-file limit, is skipped rather than failing the whole index;
list_local_docs's skipped field names it and why. A skipped file still
appears in documents - it is listed, but none of its content is
searchable. Indexing is also capped at 500 files: LOCAL_DOCS_DIR should
point at a directory dedicated to this purpose, not something broad like a
home directory - both caps exist to bound indexing cost and to stop an
overbroad directory from pulling unrelated local files into search results.
Indexing stops at 5000 chunks overall, whichever files that spans, so a few
very large documents cannot exhaust memory on their own. Both tools'
truncated field is true if either cap left content out of the index.
uv syncinstallssentence-transformersandpypdf; this pulls intorch, so the sync is noticeably larger than the rest of this project's dependencies.- Outbound HTTPS to
huggingface.cothe first timesearch_local_docs(orlist_local_docs, which also builds the index) runs, to download the embedding model - about 90 MB for the default model, cached under~/.cache/huggingface(override withHF_HOME) so later runs, including after a restart, need no network. No connectivity is required beyond that:prescrybitself sends document content and queries nowhere, though search results do go back to the connected MCP client as tool output. - At least one file under
LOCAL_DOCS_DIR- the tools return an empty result, not an error, if the directory is missing or empty.
Check host db-01 for CVEs, and see if our internal runbooks say anything relevant before you suggest a fix.
This drives inventory_host and check_cves as usual, then
search_local_docs against whatever's in local_docs/ (e.g. an internal
Postgres patching runbook) so the connected model can fold that guidance
into its suggestion alongside the CVE data. The runbook is never uploaded
anywhere for indexing; the matched excerpts do reach that model, since
answering with them is the point.
Output is always a full playbook as text, prefixed with a comment header
citing every CVE/compliance source used. It is never executed by prescryb.
Review it - ansible-playbook --syntax-check, then --check --diff - before
running it anywhere.
Package-upgrade tasks use the module for the target's package manager
(ansible.builtin.apt/dnf/zypper/community.general.apk). Version pins
are only applied where the module supports them; Arch/pacman targets get
state: latest since pacman does not support the same pinning syntax.
| Variable | Default | Purpose |
|---|---|---|
HARDENING_COLLECTION_REPO |
konstruktoid/ansible-collection-hardening |
GitHub owner/repo queried for compliance-mapped Ansible roles. |
CCE_REPO |
konstruktoid/cce-web |
GitHub owner/repo queried for CCE JSON exports by lookup_cce/list_cce_targets. |
GITHUB_TOKEN |
unset | Raises GitHub API rate limits for map_compliance, lookup_cce, and list_cce_targets. |
NVD_API_KEY |
unset | Raises NVD API rate limits for fetch_advisory. |
LOCAL_DOCS_DIR |
local_docs |
Directory search_local_docs/list_local_docs read from. |
LOCAL_DOCS_MODEL |
sentence-transformers/all-MiniLM-L6-v2 |
Hugging Face model ID used to embed local documents. |
Install with the test extras (pytest, ruff, ty, numpy for the local-docs tests):
uv sync --extra testBefore considering any change to src/ or tests/ done, run all of:
uv run ruff check .
uv run ruff format --check .
uv run ty check
uv run pytestruff/ty apply to tests/ as well as src/; tests/per-file-ignores in
pyproject.toml only relaxes docstring (D103), assert (S101), and
private-member-access (SLF001) rules there, since those are normal in test
code.
Test layout: one tests/test_<module>.py per src/prescryb/<module>.py
that has coverage, following pytest's plain function style already used
throughout - no test classes, no third-party mocking/fixture library beyond
pytest's own monkeypatch and tmp_path. Network-facing code (paramiko,
httpx calls to OSV/NVD/EPSS/GitHub/cce-web) is exercised through small
hand-written fakes substituted via monkeypatch, not real sockets. Pure
data-transform helpers (parsers, extractors, dataclass<->dict round-trips,
playbook rendering) are called directly.
Coverage is currently uneven: parsing/extraction/rendering logic across
ssh.py, cve.py, playbook.py, cce.py, server.py, and all of docs.py
has tests; the async network-calling functions in epss.py,
advisories.py, compliance.py, and most of cce.py (fetch_target,
list_targets, resolve_target) do not yet - adding those would need an
httpx.MockTransport fixture rather than monkeypatch alone.
.github/workflows/lint.yml runs ruff check, ruff format --check,
ty check, pytest, and pip-audit on every push/PR.
- Does not apply playbooks or otherwise mutate the target host.
- Does not accept passwords as tool arguments.
- Does not fabricate CIS/DISA STIG rule numbers.
- Does not guess CVEs for ecosystems OSV does not cover; it reports that instead.