grison turns raw security-scanner exports into clean markdown findings, gives you a plain-files workspace to triage and edit them in, and syncs the result into Ghostwriter (findings + evidence) and BookStack (methodology) with a git-style 3-way merge.
Markdown in the middle is the point: findings are files, so the transform layer is whatever edits files best — a human in any editor, a script, or an LLM let loose on the workspace. grison itself has no AI subsystem; its job is to validate what changed and sync it safely.
Supported scanner exports (auto-detected): Acunetix, Burp, Nessus, Nmap, OpenVAS, Qualys, sslyze, ZAP.
pipx install grison # or: uv tool install grison
grison --install-completion # optional: shell tab completion (once, then restart shell)For development: uv sync && uv run pytest (Python 3.11+, managed with
uv).
mkdir engagement && cd engagement
grison parse scan.nessus burp.xml # first run scaffolds the workspace
# triage findings/inbox/*.md, edit freely, move the keepers into place:
cp findings/inbox/sqli-login.md findings/reports/6-acme-q3/
grison status findings/ # validity report per record
grison sync # reconcile with Ghostwriter + BookStack| Command | Does |
|---|---|
grison parse <path…> |
scanner export(s) → markdown findings in findings/inbox/. Offline. Auto-detects the scanner (--scanner to force), --min-severity high,critical or medium-critical to filter, --dry-run to preview. |
grison status <path…> |
per-record validity: schema, enums, CVSS vector↔score, CWE ids, Ghostwriter HTML whitelist. Offline. |
grison sync |
reconcile the workspace with Ghostwriter (findings) and BookStack (methodology). Direction is derived per record — see below. --dry-run previews the plan. |
There is deliberately no pull/push (sync derives direction), no init (the first
run of any verb scaffolds the workspace), and no validate (status reports, sync
enforces). Moving a finding between tiers is a plain cp/mv.
The directory tree is the data model — location is identity:
findings/
inbox/
library/
reports/<id>-<slug>/
narrative/
evidence/
methodology/
library/<book>/<chapter>/
checklists/<engagement>/
.grison/
| Path | Mirrors | Notes |
|---|---|---|
findings/inbox/ |
nothing | parse output; triage here, then cp into a tier |
findings/library/ |
Ghostwriter finding library | reusable templates |
findings/reports/<id>-<slug>/ |
Ghostwriter reported findings | one dir per existing report; grison never creates reports |
findings/reports/…/narrative/ |
Ghostwriter report extraFields |
one editable markdown file per report section (exec summary, methodology, …); 3-way per section. .report.yml holds the read-only metadata mirror |
findings/reports/…/evidence/ |
Ghostwriter evidence | images attached to a finding |
methodology/library/ |
BookStack books/chapters/pages | markdown-native, mirrors verbatim |
methodology/checklists/ |
nothing | per-engagement working copies, cp -r from library |
.grison/ |
— | creds + sync state; auto-gitignored, never commit |
BookStack's structure mirrors losslessly: library/<book>/<page>.md for pages at a
book's root, library/<book>/<chapter>/<page>.md for chaptered pages. Every book and
chapter — including empty ones — materializes as a directory holding a .book.yml /
.chapter.yml mirror (ids, name, description, shelf membership, chapter order;
pull-only). Page sort order (priority) and page tags live in the page frontmatter
and sync in both directions. Moving a file between chapter directories moves the page
on BookStack; a page moved into a chapter remotely relocates the local file on the
next sync.
Sync matches records by remote id stored in the file, not by filename (filenames are cosmetic). A file whose directory disagrees with its stored id is a move and becomes a new record at the destination.
One tier-agnostic schema: structured facts in YAML frontmatter, prose in fixed ##
sections.
---
grison:
kind: finding
tier: instance # library | instance
gw: { table: reportedFinding, id: 183, report_id: 6 }
synced: { hash: sha256:…, at: 2026-07-14T12:00:00Z } # the 3-way merge base
severity: high # informational|low|medium|high|critical
finding_type: web # network|physical|wireless|web|mobile|cloud|host
cvss: { vector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", score: 9.8 }
cwe: ["CWE-79"] # validated against an embedded CWE index
affected_entities: | # instances only
https://app.example/
evidence: # instances only
- { file: evidence/shell.png, caption: Shell, friendly_name: shell, gw: { id: 38, hash: sha256:… } }
---
# {title}
## Description
## Impact
## Mitigation
## Replication Steps
## ReferencesThe pydantic models in grison/model/ are the schema. CVSS is accepted as well-formed
3.0 or 3.1, as authored. CWE ids are validated against a vendored index of the MITRE
catalog, so everything works fully offline.
Nobody chooses push or pull — the stored merge base (synced.hash) determines it per
record:
- only local changed → push
- only remote changed → pull
- both changed → collision: the remote version is written to an
x.remote.mdsidecar, local is never overwritten; resolve, thengrison sync --force-local <file>or--force-remote <file> - both converged under a stale base → repair the base, write nothing
Guardrails stop anomalous or destructive outcomes, never routine ones — no
confirmation prompts, --dry-run is opt-in. Three layers:
- Validation — silent when green; a record must be valid to sync.
- Trip-wires — fire only on anomaly: mass-change guard, structure drift, collision, duplicate identity, broken evidence link.
- Snapshots — every remote write batch is snapshotted first, with a paired
rollback.py, so every write is reversible.
Ghostwriter's rich-text fields use a small closed vocabulary, and the converter fails
loudly on anything outside it rather than corrupt silently. Inline: **bold**,
`code`, *em*, [links](…). Block: paragraphs and unordered lists (one level
of - sub-bullets). Rejected inside a field: tables, ordered lists, images,
headings (the ## section headers are grison structure mapping to Ghostwriter's
separate fields, not field content). Constructs grison canonicalizes on purpose —
editor highlight spans, non-standard link rel/target — are reported as sync
warnings when dropped, never absorbed silently. BookStack pages are markdown-native
and mirror verbatim, no conversion; pages authored in the WYSIWYG editor are skipped
loudly until converted to markdown in BookStack.
Every remote field has an explicit verdict — "not synced" always means grison can neither lose it nor damage it (partial updates never touch unlisted columns).
Two-way synced (in the merge base — edits on either side propagate, both-changed
collides): finding title, severity, type, CVSS vector + score, all five prose
sections, affected entities; tags and CWE ids (Ghostwriter's tag table, CWE:<n>
convention); evidence images with caption / friendly name / description (per-image
merge base); report narrative sections (report.extraFields), each section its own
merge base; BookStack page body, name, priority, tags, and chapter/book location.
Mirrored read-only (pull-only; local edits are detected and rejected, never
silently discarded): report metadata (.report.yml — project, client, dates,
lifecycle flags); BookStack book/chapter/shelf structure (.book.yml,
.chapter.yml, .shelves/*.yml — carry a READ-ONLY header; hand-edits error with a
.remote.yml sidecar).
Deliberately not synced (remote-owned, unreachable by grison's writes):
Ghostwriter report-finding position (per-severity kanban order, server renumbers),
complete/assignedTo QA state, finding/report comments, Observations,
report templates and reportConfiguration, report-level evidence rows (never
deleted by grison), dead-in-corpus columns (hostDetectionTechniques,
networkDetectionTechniques, findingGuidance, per-finding extraFields);
BookStack container tags/covers/templates, page template flag, revision history.
Renaming books/chapters happens in BookStack, not by renaming local dirs (a rename
tripwire blocks the ambiguous case). Severity/finding-type id mappings are verified
against the live instance at sync start and abort on drift.
The first run in a directory scaffolds .grison/env — a commented template, chmod 600,
auto-gitignored. Paste values there, or set the same keys as environment variables
(env vars override the file; useful for CI):
| Key | For |
|---|---|
GRISON_GW_URL, GRISON_GW_TOKEN |
Ghostwriter — GraphQL API token |
GRISON_BS_URL, GRISON_BS_TOKEN_ID, GRISON_BS_TOKEN_SECRET |
BookStack — REST API token; only needed for methodology sync |
GRISON_CF_CLIENT_ID, GRISON_CF_CLIENT_SECRET |
optional — Cloudflare Access service token, if your deployment sits behind CF Access |
MIT. Named after the mustelid.