Skip to main content

A markdown hub between security scanners and Ghostwriter + BookStack.

Project description

grison

PyPI Python License: MIT

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.md sidecar, local is never overwritten; resolve, then grison 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:

  1. Validation — silent when green; a record must be valid to sync.
  2. Trip-wires — fire only on anomaly: mass-change guard, structure drift, collision, duplicate identity, broken evidence link.
  3. 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

grison-0.2.0.tar.gz (185.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

grison-0.2.0-py3-none-any.whl (132.6 kB view details)

Uploaded Python 3

File details

Details for the file grison-0.2.0.tar.gz.

File metadata

  • Download URL: grison-0.2.0.tar.gz
  • Upload date:
  • Size: 185.9 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

Hashes for grison-0.2.0.tar.gz
Algorithm Hash digest
SHA256 341585cfa3bbd587bcadabcfc1fc75bfde16d4e7e1891c1836603e4f6a18ed2b
MD5 b2d0853c14b76ae86cd171c092b17571
BLAKE2b-256 f079d9f81237bc6a05a2746f5ddb2580a9f69279b8c7d7a961fe40eb32e43760

See more details on using hashes here.

File details

Details for the file grison-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: grison-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 132.6 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

Hashes for grison-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 657ff0e96f62a6852a252f69ab54f7c80e8c3cc0eafe991c1d5764841fa484d4
MD5 b5f83b8726e1cae80ce94b6127970dff
BLAKE2b-256 df3031270ca519f402caca59655682e5197fdb75d87f743702a6a4a978099874

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page