A markdown hub between security scanners and Ghostwriter + BookStack.
Project description
grison
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.
Install
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).
Quickstart
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
The three verbs
| 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 workspace
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.
Finding schema
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
## References
The 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.
How sync decides direction
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
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.
Markdown ⇄ Ghostwriter HTML
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.
What syncs, what doesn't
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.
Configuration
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 |
License
MIT. Named after the mustelid.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file grison-0.2.1.tar.gz.
File metadata
- Download URL: grison-0.2.1.tar.gz
- Upload date:
- Size: 186.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
280f971a056aee981798410e7d49c1c90acc3a97948689335a67e3a9ac465827
|
|
| MD5 |
6ed629efa755f03c07cd42896c07a394
|
|
| BLAKE2b-256 |
912534262fa21c1c43b94a288cbc2ccc881b95a932074472f95aad174d3b752c
|
File details
Details for the file grison-0.2.1-py3-none-any.whl.
File metadata
- Download URL: grison-0.2.1-py3-none-any.whl
- Upload date:
- Size: 132.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
71cda8a16b4ea494d9ee001b3c841abc8365a396b9545b806bd713bcacb81dc5
|
|
| MD5 |
f843b1136f92d76a29ec44c29807a17e
|
|
| BLAKE2b-256 |
2dd34843febcec227b1785965bdda32ab8f2bb5bfe8791cd2b9d3efd6d78fd4a
|