grison 0.1.0__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.
- grison-0.1.0/.gitignore +18 -0
- grison-0.1.0/LICENSE +21 -0
- grison-0.1.0/PKG-INFO +133 -0
- grison-0.1.0/README.md +107 -0
- grison-0.1.0/grison/__init__.py +9 -0
- grison-0.1.0/grison/cli.py +304 -0
- grison-0.1.0/grison/markdown/__init__.py +33 -0
- grison-0.1.0/grison/markdown/converter.py +303 -0
- grison-0.1.0/grison/markdown/document.py +124 -0
- grison-0.1.0/grison/markdown/mapping.py +125 -0
- grison-0.1.0/grison/model/__init__.py +39 -0
- grison-0.1.0/grison/model/cvss.py +172 -0
- grison-0.1.0/grison/model/cwe.py +46 -0
- grison-0.1.0/grison/model/data/cwe.json +1452 -0
- grison-0.1.0/grison/model/enums.py +78 -0
- grison-0.1.0/grison/model/finding.py +153 -0
- grison-0.1.0/grison/ports.py +39 -0
- grison-0.1.0/grison/remote/__init__.py +2 -0
- grison-0.1.0/grison/remote/bookstack.py +93 -0
- grison-0.1.0/grison/remote/bootstrap.py +70 -0
- grison-0.1.0/grison/remote/bsmap.py +105 -0
- grison-0.1.0/grison/remote/creds.py +90 -0
- grison-0.1.0/grison/remote/ghostwriter.py +270 -0
- grison-0.1.0/grison/remote/gwmap.py +174 -0
- grison-0.1.0/grison/remote/methodology.py +340 -0
- grison-0.1.0/grison/remote/snapshot.py +129 -0
- grison-0.1.0/grison/remote/sync.py +657 -0
- grison-0.1.0/grison/scanners/__init__.py +57 -0
- grison-0.1.0/grison/scanners/acunetix.py +128 -0
- grison-0.1.0/grison/scanners/base.py +56 -0
- grison-0.1.0/grison/scanners/burp.py +99 -0
- grison-0.1.0/grison/scanners/detect.py +82 -0
- grison-0.1.0/grison/scanners/ir/__init__.py +14 -0
- grison-0.1.0/grison/scanners/ir/finding.py +31 -0
- grison-0.1.0/grison/scanners/ir/severity.py +75 -0
- grison-0.1.0/grison/scanners/nessus.py +125 -0
- grison-0.1.0/grison/scanners/nmap.py +142 -0
- grison-0.1.0/grison/scanners/openvas.py +128 -0
- grison-0.1.0/grison/scanners/qualys.py +127 -0
- grison-0.1.0/grison/scanners/sslyze.py +404 -0
- grison-0.1.0/grison/scanners/zap.py +155 -0
- grison-0.1.0/grison/sinks/__init__.py +8 -0
- grison-0.1.0/grison/sinks/file_sink.py +104 -0
- grison-0.1.0/grison/sinks/pipeline.py +91 -0
- grison-0.1.0/grison/validate.py +66 -0
- grison-0.1.0/grison/workspace.py +43 -0
- grison-0.1.0/pyproject.toml +58 -0
- grison-0.1.0/scripts/gen_cwe_index.py +94 -0
- grison-0.1.0/tests/fixtures/scanners/acunetix_sample.xml +62 -0
- grison-0.1.0/tests/fixtures/scanners/burp_sample.xml +17 -0
- grison-0.1.0/tests/fixtures/scanners/nessus_sample.xml +14 -0
- grison-0.1.0/tests/fixtures/scanners/nmap_sample.xml +20 -0
- grison-0.1.0/tests/fixtures/scanners/openvas_sample.xml +22 -0
- grison-0.1.0/tests/fixtures/scanners/qualys_sample.xml +18 -0
- grison-0.1.0/tests/fixtures/scanners/sslyze_sample.json +112 -0
- grison-0.1.0/tests/fixtures/scanners/zap_sample.xml +28 -0
- grison-0.1.0/tests/test_bookstack.py +164 -0
- grison-0.1.0/tests/test_cli.py +95 -0
- grison-0.1.0/tests/test_converter.py +137 -0
- grison-0.1.0/tests/test_converter_corpus.py +55 -0
- grison-0.1.0/tests/test_cvss.py +91 -0
- grison-0.1.0/tests/test_cwe.py +55 -0
- grison-0.1.0/tests/test_detect.py +57 -0
- grison-0.1.0/tests/test_document.py +102 -0
- grison-0.1.0/tests/test_file_sink.py +62 -0
- grison-0.1.0/tests/test_ghostwriter.py +297 -0
- grison-0.1.0/tests/test_gwmap.py +94 -0
- grison-0.1.0/tests/test_mapping.py +74 -0
- grison-0.1.0/tests/test_methodology.py +290 -0
- grison-0.1.0/tests/test_model.py +117 -0
- grison-0.1.0/tests/test_pipeline.py +69 -0
- grison-0.1.0/tests/test_remote_bootstrap.py +61 -0
- grison-0.1.0/tests/test_scanner_acunetix.py +177 -0
- grison-0.1.0/tests/test_scanner_acunetix_cvss.py +42 -0
- grison-0.1.0/tests/test_scanner_base.py +28 -0
- grison-0.1.0/tests/test_scanner_burp.py +21 -0
- grison-0.1.0/tests/test_scanner_nessus.py +44 -0
- grison-0.1.0/tests/test_scanner_nmap.py +35 -0
- grison-0.1.0/tests/test_scanner_openvas.py +22 -0
- grison-0.1.0/tests/test_scanner_qualys.py +23 -0
- grison-0.1.0/tests/test_scanner_sslyze.py +208 -0
- grison-0.1.0/tests/test_scanner_zap.py +47 -0
- grison-0.1.0/tests/test_smoke.py +18 -0
- grison-0.1.0/tests/test_sync.py +117 -0
- grison-0.1.0/tests/test_sync_push.py +496 -0
- grison-0.1.0/tests/test_validate.py +17 -0
- grison-0.1.0/uv.lock +468 -0
grison-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.pytest_cache/
|
|
6
|
+
.ruff_cache/
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
|
|
10
|
+
# uv / venv
|
|
11
|
+
.venv/
|
|
12
|
+
|
|
13
|
+
# grison private workspace dir (creds + state) — never commit
|
|
14
|
+
.grison/
|
|
15
|
+
|
|
16
|
+
# grison workspace content — real engagement data lands here after `grison sync`
|
|
17
|
+
findings/
|
|
18
|
+
methodology/
|
grison-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Valiente Technologies
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
grison-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: grison
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A markdown hub between security scanners and Valiente's Ghostwriter + BookStack.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ValienteTechnologies/grison
|
|
6
|
+
Project-URL: Issues, https://github.com/ValienteTechnologies/grison/issues
|
|
7
|
+
Author: Valiente Technologies
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: bookstack,ghostwriter,markdown,pentest,scanner,security
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Information Technology
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: defusedxml>=0.7
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: pydantic>=2
|
|
23
|
+
Requires-Dist: pyyaml>=6
|
|
24
|
+
Requires-Dist: typer>=0.12
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# grison
|
|
28
|
+
|
|
29
|
+
grison is a markdown hub between security scanners and Valiente's infra. It parses
|
|
30
|
+
scanner exports into a single house schema of markdown findings, and reconciles a local
|
|
31
|
+
git-like workspace with Ghostwriter (findings) and BookStack (methodology) via a 3-way
|
|
32
|
+
sync. Markdown is the lingua franca — which also makes an LLM editing the workspace the
|
|
33
|
+
transform layer, so grison itself has no AI subsystem; its job is to validate and sync
|
|
34
|
+
those edits safely.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
uv sync # Python 3.11+, managed with uv
|
|
40
|
+
uv run grison --help
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## The three verbs
|
|
44
|
+
|
|
45
|
+
| Command | Does |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `grison parse <path…>` | scanner export(s) → markdown findings in `findings/inbox/` (offline; auto-detects the scanner) |
|
|
48
|
+
| `grison status <path…>` | per-record validity: schema / enums / CVSS / CWE / the GW HTML whitelist |
|
|
49
|
+
| `grison sync` | reconcile the workspace with Ghostwriter + BookStack; direction derived per record |
|
|
50
|
+
|
|
51
|
+
There is no `pull`/`push` (sync derives direction), no `init` (the first `sync`
|
|
52
|
+
bootstraps the workspace + a `.grison/env` creds template), and no `validate` (status
|
|
53
|
+
reports it, sync enforces it). Moving a finding between cells is a plain `cp`/`mv`.
|
|
54
|
+
|
|
55
|
+
## Data model — a 2×2, mirrored faithfully
|
|
56
|
+
|
|
57
|
+
The workspace tree *is* the model. Segment 1 = domain (backend), segment 2 = tier:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
findings/ # ⇄ Ghostwriter
|
|
61
|
+
inbox/ # parse output — local-only, triage then cp into a report
|
|
62
|
+
library/ # ⇄ GW finding table
|
|
63
|
+
reports/<id>-<slug>/ # ⇄ GW reportedFinding — one dir per EXISTING report
|
|
64
|
+
<rfid>-<slug>.md
|
|
65
|
+
evidence/*.png # images attached to a finding
|
|
66
|
+
methodology/ # ⇄ BookStack
|
|
67
|
+
library/<book>/<page>.md
|
|
68
|
+
checklists/<engagement>/ # per-engagement copies — local-only, cp -r from library/
|
|
69
|
+
.grison/ # creds + state; always gitignored
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Location is identity.** A file's directory fixes its remote target; sync matches by
|
|
73
|
+
remote id, not filename (filenames are cosmetic). A file whose location disagrees with
|
|
74
|
+
its stored id is a *move* → a new record — which is all the old
|
|
75
|
+
`instantiate`/`promote` verbs did. grison syncs findings + evidence into reports it
|
|
76
|
+
never creates.
|
|
77
|
+
|
|
78
|
+
## Finding markdown schema (by example)
|
|
79
|
+
|
|
80
|
+
One tier-agnostic schema; structured facts in frontmatter, prose in fixed `##` sections.
|
|
81
|
+
|
|
82
|
+
```markdown
|
|
83
|
+
---
|
|
84
|
+
grison:
|
|
85
|
+
kind: finding
|
|
86
|
+
tier: instance # library | instance
|
|
87
|
+
gw: { table: reportedFinding, id: 183, report_id: 6 }
|
|
88
|
+
synced: { hash: sha256:…, at: 2026-07-14T12:00:00Z } # the 3-way merge base
|
|
89
|
+
severity: high # informational|low|medium|high|critical
|
|
90
|
+
finding_type: web # network|physical|wireless|web|mobile|cloud|host
|
|
91
|
+
cvss: { vector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", score: 9.8 }
|
|
92
|
+
cwe: ["CWE-79"] # validated against the embedded CWE index
|
|
93
|
+
affected_entities: | # instances only
|
|
94
|
+
https://app.example/
|
|
95
|
+
evidence: # instances only; images attached to this finding
|
|
96
|
+
- { file: evidence/shell.png, caption: Shell, friendly_name: shell, gw: { id: 38, hash: sha256:… } }
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
# {title}
|
|
100
|
+
## Description
|
|
101
|
+
## Impact
|
|
102
|
+
## Mitigation
|
|
103
|
+
## Replication Steps
|
|
104
|
+
## References
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The pydantic models in `grison/model/` **are** the schema; enums carry their Ghostwriter
|
|
108
|
+
ids (derived, never stored). CVSS accepts well-formed 3.0 or 3.1 as-authored.
|
|
109
|
+
|
|
110
|
+
## Converter whitelist (markdown ⇄ Ghostwriter HTML)
|
|
111
|
+
|
|
112
|
+
GW's rich-text fields use a tiny closed vocabulary, and the converter (`grison/markdown/`)
|
|
113
|
+
fails loudly on anything outside it rather than corrupt silently:
|
|
114
|
+
|
|
115
|
+
- Inline: `**bold**` ⇄ `<strong>`, `` `code` `` ⇄ `<code>`, `*em*` ⇄ `<em>`, `[t](u)` ⇄ `<a>`.
|
|
116
|
+
- Block: paragraphs ⇄ `<p>`, `- ` lists ⇄ `<ul><li>` (never `<ol>`).
|
|
117
|
+
- Rejected in a field: tables, ordered lists, images, headings. The `##` section headers
|
|
118
|
+
are grison structure that map to GW's separate fields, not field content.
|
|
119
|
+
|
|
120
|
+
(BookStack methodology pages are markdown-native, so they mirror verbatim — no converter.)
|
|
121
|
+
|
|
122
|
+
## Sync + guardrails
|
|
123
|
+
|
|
124
|
+
Direction isn't chosen — the 3-way base (`synced.hash`) *determines* it per record:
|
|
125
|
+
only-local-changed → push, only-remote → pull, both → **collision** (the remote side is
|
|
126
|
+
written to an `x.remote.md` sidecar; local is never overwritten — resolve then
|
|
127
|
+
`sync --force-local/--force-remote <file>`), converged-under-a-stale-base → repair.
|
|
128
|
+
|
|
129
|
+
Guardrails stop **anomalous or destructive outcomes**, never routine ones (no
|
|
130
|
+
confirmation nagging; `--dry-run` is opt-in). Three layers: validation (silent when
|
|
131
|
+
green), trip-wires that fire only on anomaly (mass-change guard, structure drift,
|
|
132
|
+
collision, duplicate identity, broken link), and a pre-write **snapshot** of every remote
|
|
133
|
+
write batch (with a paired `rollback.py`) so every write is reversible.
|
grison-0.1.0/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# grison
|
|
2
|
+
|
|
3
|
+
grison is a markdown hub between security scanners and Valiente's infra. It parses
|
|
4
|
+
scanner exports into a single house schema of markdown findings, and reconciles a local
|
|
5
|
+
git-like workspace with Ghostwriter (findings) and BookStack (methodology) via a 3-way
|
|
6
|
+
sync. Markdown is the lingua franca — which also makes an LLM editing the workspace the
|
|
7
|
+
transform layer, so grison itself has no AI subsystem; its job is to validate and sync
|
|
8
|
+
those edits safely.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
uv sync # Python 3.11+, managed with uv
|
|
14
|
+
uv run grison --help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## The three verbs
|
|
18
|
+
|
|
19
|
+
| Command | Does |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `grison parse <path…>` | scanner export(s) → markdown findings in `findings/inbox/` (offline; auto-detects the scanner) |
|
|
22
|
+
| `grison status <path…>` | per-record validity: schema / enums / CVSS / CWE / the GW HTML whitelist |
|
|
23
|
+
| `grison sync` | reconcile the workspace with Ghostwriter + BookStack; direction derived per record |
|
|
24
|
+
|
|
25
|
+
There is no `pull`/`push` (sync derives direction), no `init` (the first `sync`
|
|
26
|
+
bootstraps the workspace + a `.grison/env` creds template), and no `validate` (status
|
|
27
|
+
reports it, sync enforces it). Moving a finding between cells is a plain `cp`/`mv`.
|
|
28
|
+
|
|
29
|
+
## Data model — a 2×2, mirrored faithfully
|
|
30
|
+
|
|
31
|
+
The workspace tree *is* the model. Segment 1 = domain (backend), segment 2 = tier:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
findings/ # ⇄ Ghostwriter
|
|
35
|
+
inbox/ # parse output — local-only, triage then cp into a report
|
|
36
|
+
library/ # ⇄ GW finding table
|
|
37
|
+
reports/<id>-<slug>/ # ⇄ GW reportedFinding — one dir per EXISTING report
|
|
38
|
+
<rfid>-<slug>.md
|
|
39
|
+
evidence/*.png # images attached to a finding
|
|
40
|
+
methodology/ # ⇄ BookStack
|
|
41
|
+
library/<book>/<page>.md
|
|
42
|
+
checklists/<engagement>/ # per-engagement copies — local-only, cp -r from library/
|
|
43
|
+
.grison/ # creds + state; always gitignored
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Location is identity.** A file's directory fixes its remote target; sync matches by
|
|
47
|
+
remote id, not filename (filenames are cosmetic). A file whose location disagrees with
|
|
48
|
+
its stored id is a *move* → a new record — which is all the old
|
|
49
|
+
`instantiate`/`promote` verbs did. grison syncs findings + evidence into reports it
|
|
50
|
+
never creates.
|
|
51
|
+
|
|
52
|
+
## Finding markdown schema (by example)
|
|
53
|
+
|
|
54
|
+
One tier-agnostic schema; structured facts in frontmatter, prose in fixed `##` sections.
|
|
55
|
+
|
|
56
|
+
```markdown
|
|
57
|
+
---
|
|
58
|
+
grison:
|
|
59
|
+
kind: finding
|
|
60
|
+
tier: instance # library | instance
|
|
61
|
+
gw: { table: reportedFinding, id: 183, report_id: 6 }
|
|
62
|
+
synced: { hash: sha256:…, at: 2026-07-14T12:00:00Z } # the 3-way merge base
|
|
63
|
+
severity: high # informational|low|medium|high|critical
|
|
64
|
+
finding_type: web # network|physical|wireless|web|mobile|cloud|host
|
|
65
|
+
cvss: { vector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", score: 9.8 }
|
|
66
|
+
cwe: ["CWE-79"] # validated against the embedded CWE index
|
|
67
|
+
affected_entities: | # instances only
|
|
68
|
+
https://app.example/
|
|
69
|
+
evidence: # instances only; images attached to this finding
|
|
70
|
+
- { file: evidence/shell.png, caption: Shell, friendly_name: shell, gw: { id: 38, hash: sha256:… } }
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
# {title}
|
|
74
|
+
## Description
|
|
75
|
+
## Impact
|
|
76
|
+
## Mitigation
|
|
77
|
+
## Replication Steps
|
|
78
|
+
## References
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The pydantic models in `grison/model/` **are** the schema; enums carry their Ghostwriter
|
|
82
|
+
ids (derived, never stored). CVSS accepts well-formed 3.0 or 3.1 as-authored.
|
|
83
|
+
|
|
84
|
+
## Converter whitelist (markdown ⇄ Ghostwriter HTML)
|
|
85
|
+
|
|
86
|
+
GW's rich-text fields use a tiny closed vocabulary, and the converter (`grison/markdown/`)
|
|
87
|
+
fails loudly on anything outside it rather than corrupt silently:
|
|
88
|
+
|
|
89
|
+
- Inline: `**bold**` ⇄ `<strong>`, `` `code` `` ⇄ `<code>`, `*em*` ⇄ `<em>`, `[t](u)` ⇄ `<a>`.
|
|
90
|
+
- Block: paragraphs ⇄ `<p>`, `- ` lists ⇄ `<ul><li>` (never `<ol>`).
|
|
91
|
+
- Rejected in a field: tables, ordered lists, images, headings. The `##` section headers
|
|
92
|
+
are grison structure that map to GW's separate fields, not field content.
|
|
93
|
+
|
|
94
|
+
(BookStack methodology pages are markdown-native, so they mirror verbatim — no converter.)
|
|
95
|
+
|
|
96
|
+
## Sync + guardrails
|
|
97
|
+
|
|
98
|
+
Direction isn't chosen — the 3-way base (`synced.hash`) *determines* it per record:
|
|
99
|
+
only-local-changed → push, only-remote → pull, both → **collision** (the remote side is
|
|
100
|
+
written to an `x.remote.md` sidecar; local is never overwritten — resolve then
|
|
101
|
+
`sync --force-local/--force-remote <file>`), converged-under-a-stale-base → repair.
|
|
102
|
+
|
|
103
|
+
Guardrails stop **anomalous or destructive outcomes**, never routine ones (no
|
|
104
|
+
confirmation nagging; `--dry-run` is opt-in). Three layers: validation (silent when
|
|
105
|
+
green), trip-wires that fire only on anomaly (mass-change guard, structure drift,
|
|
106
|
+
collision, duplicate identity, broken link), and a pre-write **snapshot** of every remote
|
|
107
|
+
write batch (with a paired `rollback.py`) so every write is reversible.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""grison — a markdown hub between security scanners and Valiente's infra.
|
|
2
|
+
|
|
3
|
+
Everything is a *source*, *transform*, or *sink* of markdown. See the module
|
|
4
|
+
layout (ports & adapters): ``model`` (schema), ``scanners`` (source adapters),
|
|
5
|
+
``markdown`` (serialization + HTML⇄md converter), ``sinks`` (file sink),
|
|
6
|
+
``remote`` (Ghostwriter + BookStack), and ``cli``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""grison CLI — three verbs: ``parse``, ``status``, ``sync``.
|
|
2
|
+
|
|
3
|
+
The path names the backend (``findings/`` ⇄ Ghostwriter, ``methodology/`` ⇄
|
|
4
|
+
BookStack); location decides identity; the first ``sync`` bootstraps the workspace.
|
|
5
|
+
``parse`` and ``status`` are offline; ``sync`` reconciles findings with Ghostwriter
|
|
6
|
+
and methodology with BookStack (push/pull/collision derived per record).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import fcntl
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Annotated
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from grison.model import FindingType
|
|
20
|
+
from grison.remote.bookstack import BookStackClient
|
|
21
|
+
from grison.remote.bootstrap import bootstrap_workspace
|
|
22
|
+
from grison.remote.creds import MissingCreds
|
|
23
|
+
from grison.remote.creds import load as load_creds
|
|
24
|
+
from grison.remote.ghostwriter import GhostwriterClient
|
|
25
|
+
from grison.remote.methodology import MethResult, sync_methodology
|
|
26
|
+
from grison.remote.sync import SyncResult
|
|
27
|
+
from grison.remote.sync import sync as run_sync
|
|
28
|
+
from grison.sinks import ParseSummary, run_parse
|
|
29
|
+
from grison.validate import validate_file
|
|
30
|
+
from grison.workspace import bootstrap_tree, inbox_dir
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
name="grison",
|
|
34
|
+
help="A markdown hub between security scanners and Valiente's Ghostwriter + BookStack.",
|
|
35
|
+
no_args_is_help=True,
|
|
36
|
+
add_completion=False,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.callback()
|
|
41
|
+
def _root() -> None:
|
|
42
|
+
"""grison — parse scanner artifacts to markdown, then status/sync with the remotes."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command()
|
|
46
|
+
def parse(
|
|
47
|
+
paths: Annotated[list[Path], typer.Argument(help="Scanner export file(s) or dir(s).")],
|
|
48
|
+
scanner: Annotated[
|
|
49
|
+
str | None,
|
|
50
|
+
typer.Option("--scanner", help="Force a scanner type instead of auto-detecting."),
|
|
51
|
+
] = None,
|
|
52
|
+
out: Annotated[
|
|
53
|
+
Path | None,
|
|
54
|
+
typer.Option("-o", "--out", help="Output dir (default: findings/inbox/)."),
|
|
55
|
+
] = None,
|
|
56
|
+
finding_type: Annotated[
|
|
57
|
+
FindingType | None,
|
|
58
|
+
typer.Option("--finding-type", help="Override the per-scanner finding-type default."),
|
|
59
|
+
] = None,
|
|
60
|
+
min_severity: Annotated[
|
|
61
|
+
str | None,
|
|
62
|
+
typer.Option("--min-severity", help="Keep only e.g. 'high,critical' or 'medium-critical'."),
|
|
63
|
+
] = None,
|
|
64
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview without writing.")] = False,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Turn scanner export(s) into markdown findings in findings/inbox/ (offline)."""
|
|
67
|
+
if out is None:
|
|
68
|
+
bootstrap_tree(Path.cwd()) # the binary scaffolds; no init
|
|
69
|
+
out_dir = inbox_dir(Path.cwd())
|
|
70
|
+
else:
|
|
71
|
+
out_dir = out
|
|
72
|
+
summary = run_parse(
|
|
73
|
+
paths,
|
|
74
|
+
out_dir,
|
|
75
|
+
scanner=scanner,
|
|
76
|
+
finding_type=finding_type,
|
|
77
|
+
min_severity=min_severity,
|
|
78
|
+
dry_run=dry_run,
|
|
79
|
+
)
|
|
80
|
+
_print_parse_summary(summary, out_dir, dry_run=dry_run)
|
|
81
|
+
if summary.errors:
|
|
82
|
+
raise typer.Exit(code=1)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def status(
|
|
87
|
+
paths: Annotated[list[Path], typer.Argument(help="Finding markdown file(s) or dir(s).")],
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Report per-record validity (schema / enum / CVSS / CWE / GW whitelist)."""
|
|
90
|
+
files = _resolve_md(paths)
|
|
91
|
+
if not files:
|
|
92
|
+
typer.secho("no markdown files found", fg=typer.colors.YELLOW)
|
|
93
|
+
raise typer.Exit(code=0)
|
|
94
|
+
|
|
95
|
+
invalid = 0
|
|
96
|
+
for f in files:
|
|
97
|
+
errors = validate_file(f)
|
|
98
|
+
if errors:
|
|
99
|
+
invalid += 1
|
|
100
|
+
typer.secho(f"INVALID {f}", fg=typer.colors.RED)
|
|
101
|
+
for e in errors:
|
|
102
|
+
typer.echo(f" - {e}")
|
|
103
|
+
else:
|
|
104
|
+
typer.secho(f"valid {f}", fg=typer.colors.GREEN)
|
|
105
|
+
|
|
106
|
+
valid = len(files) - invalid
|
|
107
|
+
typer.echo("")
|
|
108
|
+
fg = typer.colors.RED if invalid else typer.colors.GREEN
|
|
109
|
+
typer.secho(f"{valid} valid, {invalid} invalid", fg=fg)
|
|
110
|
+
if invalid:
|
|
111
|
+
raise typer.Exit(code=1)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command()
|
|
115
|
+
def sync(
|
|
116
|
+
dry_run: Annotated[
|
|
117
|
+
bool, typer.Option("--dry-run", help="Preview the plan, write nothing (== status).")
|
|
118
|
+
] = False,
|
|
119
|
+
force_local: Annotated[
|
|
120
|
+
Path | None,
|
|
121
|
+
typer.Option("--force-local", help="Resolve a file's collision by taking local (push)."),
|
|
122
|
+
] = None,
|
|
123
|
+
force_remote: Annotated[
|
|
124
|
+
Path | None,
|
|
125
|
+
typer.Option("--force-remote", help="Resolve a file's collision by taking remote (pull)."),
|
|
126
|
+
] = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Reconcile the workspace with Ghostwriter — push/pull/collision derived per record.
|
|
129
|
+
|
|
130
|
+
Bootstraps on first run. Direction isn't chosen: a locally-edited record pushes, a
|
|
131
|
+
remote-changed one pulls, and a record changed on both sides is surfaced (never
|
|
132
|
+
overwritten). Every remote write is snapshot-backed.
|
|
133
|
+
"""
|
|
134
|
+
root = Path.cwd()
|
|
135
|
+
boot = bootstrap_workspace(root)
|
|
136
|
+
creds = load_creds(root)
|
|
137
|
+
try:
|
|
138
|
+
creds.require_ghostwriter()
|
|
139
|
+
except MissingCreds as e:
|
|
140
|
+
if boot.env_created:
|
|
141
|
+
typer.secho(f"Scaffolded workspace + wrote {boot.env_path}", fg=typer.colors.GREEN)
|
|
142
|
+
typer.secho(str(e), fg=typer.colors.YELLOW)
|
|
143
|
+
raise typer.Exit(code=1) from None
|
|
144
|
+
|
|
145
|
+
fl = {force_local.resolve()} if force_local else set()
|
|
146
|
+
fr = {force_remote.resolve()} if force_remote else set()
|
|
147
|
+
with _workspace_lock(root): # one sync at a time per workspace (GW has no compare-and-swap)
|
|
148
|
+
with GhostwriterClient(creds) as client:
|
|
149
|
+
result = run_sync(root, client, dry_run=dry_run, force_local=fl, force_remote=fr)
|
|
150
|
+
_print_sync_summary(result, dry_run=dry_run)
|
|
151
|
+
bad = bool(
|
|
152
|
+
result.collisions or result.invalid or result.mass_change_blocked or result.errors
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if creds.bs_url and creds.bs_token_id and creds.bs_token_secret:
|
|
156
|
+
with BookStackClient(creds) as bs:
|
|
157
|
+
m = sync_methodology(root, bs, dry_run=dry_run, force_local=fl, force_remote=fr)
|
|
158
|
+
_print_meth_summary(m, dry_run=dry_run)
|
|
159
|
+
bad = bad or bool(
|
|
160
|
+
m.collisions or m.invalid or m.drift or m.artifacts
|
|
161
|
+
or m.mass_change_blocked or m.errors
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if bad:
|
|
165
|
+
raise typer.Exit(code=1)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@contextmanager
|
|
169
|
+
def _workspace_lock(root: Path) -> Iterator[None]:
|
|
170
|
+
"""Serialize sync runs per workspace via an exclusive flock on .grison/lock."""
|
|
171
|
+
lock_path = root / ".grison" / "lock"
|
|
172
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
fh = lock_path.open("w", encoding="utf-8")
|
|
174
|
+
try:
|
|
175
|
+
try:
|
|
176
|
+
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
177
|
+
except BlockingIOError:
|
|
178
|
+
typer.secho(
|
|
179
|
+
"another grison sync is already running in this workspace (.grison/lock held)",
|
|
180
|
+
fg=typer.colors.RED,
|
|
181
|
+
)
|
|
182
|
+
raise typer.Exit(code=1) from None
|
|
183
|
+
yield
|
|
184
|
+
finally:
|
|
185
|
+
fcntl.flock(fh, fcntl.LOCK_UN)
|
|
186
|
+
fh.close()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _print_meth_summary(m: MethResult, *, dry_run: bool) -> None:
|
|
190
|
+
tense = "would " if dry_run else ""
|
|
191
|
+
typer.secho(
|
|
192
|
+
f"methodology: {tense}pull {len(m.pulled)}, {tense}push {len(m.pushed)}, "
|
|
193
|
+
f"{tense}create {len(m.created)} ({len(m.unchanged)} clean, {len(m.repaired)} repaired)",
|
|
194
|
+
fg=typer.colors.GREEN,
|
|
195
|
+
)
|
|
196
|
+
if m.snapshot_dir:
|
|
197
|
+
typer.echo(f"snapshot: {m.snapshot_dir}")
|
|
198
|
+
if m.mass_change_blocked:
|
|
199
|
+
typer.secho(
|
|
200
|
+
"MASS-CHANGE GUARD tripped on methodology — writes withheld.", fg=typer.colors.RED
|
|
201
|
+
)
|
|
202
|
+
for p, why in m.drift:
|
|
203
|
+
typer.secho(f"structure-drift {p}: {why}", fg=typer.colors.RED)
|
|
204
|
+
for p, what in m.artifacts:
|
|
205
|
+
typer.secho(f"artifact {p}: {what}", fg=typer.colors.RED)
|
|
206
|
+
if m.collisions:
|
|
207
|
+
typer.secho(
|
|
208
|
+
f"{len(m.collisions)} collision(s) — hand-merge then --force-*:", fg=typer.colors.RED
|
|
209
|
+
)
|
|
210
|
+
for p in m.collisions:
|
|
211
|
+
typer.echo(f" ! {p}")
|
|
212
|
+
for p in m.invalid:
|
|
213
|
+
typer.secho(f"broken link {p}", fg=typer.colors.RED)
|
|
214
|
+
for p, reason in m.skipped:
|
|
215
|
+
typer.secho(f"skipped {p}: {reason}", fg=typer.colors.YELLOW)
|
|
216
|
+
for e in m.errors:
|
|
217
|
+
typer.secho(f" error: {e}", fg=typer.colors.RED)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _print_sync_summary(result: SyncResult, *, dry_run: bool) -> None:
|
|
221
|
+
tense = "would " if dry_run else ""
|
|
222
|
+
ev = ""
|
|
223
|
+
if result.evidence_up or result.evidence_down or result.evidence_deleted:
|
|
224
|
+
ev = f" [evidence ↑{result.evidence_up} ↓{result.evidence_down}"
|
|
225
|
+
if result.evidence_deleted:
|
|
226
|
+
ev += f" ✕{result.evidence_deleted}"
|
|
227
|
+
ev += "]"
|
|
228
|
+
typer.secho(
|
|
229
|
+
f"{tense}pull {len(result.pulled)}, {tense}push {len(result.pushed)}, "
|
|
230
|
+
f"{tense}insert {len(result.inserted)} ({len(result.unchanged)} clean, "
|
|
231
|
+
f"{len(result.repaired)} repaired){ev}",
|
|
232
|
+
fg=typer.colors.GREEN,
|
|
233
|
+
)
|
|
234
|
+
if result.snapshot_dir:
|
|
235
|
+
typer.echo(f"snapshot: {result.snapshot_dir}")
|
|
236
|
+
if result.mass_change_blocked:
|
|
237
|
+
typer.secho(
|
|
238
|
+
"MASS-CHANGE GUARD tripped — remote writes withheld. Re-run a narrower path "
|
|
239
|
+
"or confirm with a targeted sync.",
|
|
240
|
+
fg=typer.colors.RED,
|
|
241
|
+
)
|
|
242
|
+
if result.collisions:
|
|
243
|
+
typer.secho(
|
|
244
|
+
f"{len(result.collisions)} collision(s) — hand-merge then --force-local/-remote:",
|
|
245
|
+
fg=typer.colors.RED,
|
|
246
|
+
)
|
|
247
|
+
for p in result.collisions:
|
|
248
|
+
typer.echo(f" ! {p} (remote at {p.with_suffix('.remote.md').name})")
|
|
249
|
+
if result.invalid:
|
|
250
|
+
typer.secho(f"{len(result.invalid)} broken link(s) (id set, no sync base) — re-link with "
|
|
251
|
+
"--force-remote/--force-local:", fg=typer.colors.RED)
|
|
252
|
+
for p in result.invalid:
|
|
253
|
+
typer.echo(f" ? {p}")
|
|
254
|
+
for p, reason in result.skipped:
|
|
255
|
+
typer.secho(f"skipped {p}: {reason}", fg=typer.colors.YELLOW)
|
|
256
|
+
for e in result.errors:
|
|
257
|
+
typer.secho(f" error: {e}", fg=typer.colors.RED)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _resolve_md(paths: list[Path]) -> list[Path]:
|
|
261
|
+
files: list[Path] = []
|
|
262
|
+
for p in paths:
|
|
263
|
+
if p.is_dir():
|
|
264
|
+
files.extend(sorted(p.glob("*.md")))
|
|
265
|
+
elif p.is_file():
|
|
266
|
+
files.append(p)
|
|
267
|
+
return files
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _print_parse_summary(summary: ParseSummary, out_dir: Path, *, dry_run: bool) -> None:
|
|
271
|
+
n_files = sum(summary.files_parsed.values())
|
|
272
|
+
by_scanner = ", ".join(f"{k}: {v}" for k, v in sorted(summary.files_parsed.items()))
|
|
273
|
+
typer.secho(
|
|
274
|
+
f"Parsed {len(summary.findings)} finding(s) from {n_files} file(s)"
|
|
275
|
+
+ (f" ({by_scanner})" if by_scanner else ""),
|
|
276
|
+
fg=typer.colors.GREEN,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
sink = summary.sink
|
|
280
|
+
if sink is not None:
|
|
281
|
+
verb = "Would write" if dry_run else "Wrote"
|
|
282
|
+
typer.echo(f"{verb} {len(sink.written)} → {out_dir} ({len(sink.unchanged)} unchanged)")
|
|
283
|
+
|
|
284
|
+
for path, reason in summary.skipped_files:
|
|
285
|
+
typer.secho(f"skipped {path.name}: {reason}", fg=typer.colors.YELLOW)
|
|
286
|
+
|
|
287
|
+
if summary.warnings:
|
|
288
|
+
typer.secho(f"{len(summary.warnings)} warning(s):", fg=typer.colors.YELLOW)
|
|
289
|
+
for w in summary.warnings:
|
|
290
|
+
typer.echo(f" - {w}")
|
|
291
|
+
|
|
292
|
+
if summary.errors:
|
|
293
|
+
typer.secho(f"{len(summary.errors)} finding(s) failed validation:", fg=typer.colors.RED)
|
|
294
|
+
for e in summary.errors:
|
|
295
|
+
typer.echo(f" - {e}")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def main() -> None:
|
|
299
|
+
"""Console-script entry point (``grison``)."""
|
|
300
|
+
app()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
if __name__ == "__main__":
|
|
304
|
+
main()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Markdown layer: the HTML⇄markdown converter, Finding⇄document serialization,
|
|
2
|
+
and scanner-IR → house-schema mapping.
|
|
3
|
+
|
|
4
|
+
The GW field vocabulary is tiny and closed; the converter fails loudly on anything
|
|
5
|
+
outside it. A Finding's prose fields are markdown; ``##`` section headers are grison
|
|
6
|
+
structure that map to Ghostwriter's separate fields.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from grison.markdown.converter import ConverterError, html_to_md, md_to_html
|
|
12
|
+
from grison.markdown.document import (
|
|
13
|
+
DocumentError,
|
|
14
|
+
finding_to_markdown,
|
|
15
|
+
markdown_to_finding,
|
|
16
|
+
)
|
|
17
|
+
from grison.markdown.mapping import (
|
|
18
|
+
MappingResult,
|
|
19
|
+
default_finding_type,
|
|
20
|
+
ir_to_finding,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ConverterError",
|
|
25
|
+
"DocumentError",
|
|
26
|
+
"MappingResult",
|
|
27
|
+
"default_finding_type",
|
|
28
|
+
"finding_to_markdown",
|
|
29
|
+
"html_to_md",
|
|
30
|
+
"ir_to_finding",
|
|
31
|
+
"markdown_to_finding",
|
|
32
|
+
"md_to_html",
|
|
33
|
+
]
|