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.
Files changed (87) hide show
  1. grison-0.1.0/.gitignore +18 -0
  2. grison-0.1.0/LICENSE +21 -0
  3. grison-0.1.0/PKG-INFO +133 -0
  4. grison-0.1.0/README.md +107 -0
  5. grison-0.1.0/grison/__init__.py +9 -0
  6. grison-0.1.0/grison/cli.py +304 -0
  7. grison-0.1.0/grison/markdown/__init__.py +33 -0
  8. grison-0.1.0/grison/markdown/converter.py +303 -0
  9. grison-0.1.0/grison/markdown/document.py +124 -0
  10. grison-0.1.0/grison/markdown/mapping.py +125 -0
  11. grison-0.1.0/grison/model/__init__.py +39 -0
  12. grison-0.1.0/grison/model/cvss.py +172 -0
  13. grison-0.1.0/grison/model/cwe.py +46 -0
  14. grison-0.1.0/grison/model/data/cwe.json +1452 -0
  15. grison-0.1.0/grison/model/enums.py +78 -0
  16. grison-0.1.0/grison/model/finding.py +153 -0
  17. grison-0.1.0/grison/ports.py +39 -0
  18. grison-0.1.0/grison/remote/__init__.py +2 -0
  19. grison-0.1.0/grison/remote/bookstack.py +93 -0
  20. grison-0.1.0/grison/remote/bootstrap.py +70 -0
  21. grison-0.1.0/grison/remote/bsmap.py +105 -0
  22. grison-0.1.0/grison/remote/creds.py +90 -0
  23. grison-0.1.0/grison/remote/ghostwriter.py +270 -0
  24. grison-0.1.0/grison/remote/gwmap.py +174 -0
  25. grison-0.1.0/grison/remote/methodology.py +340 -0
  26. grison-0.1.0/grison/remote/snapshot.py +129 -0
  27. grison-0.1.0/grison/remote/sync.py +657 -0
  28. grison-0.1.0/grison/scanners/__init__.py +57 -0
  29. grison-0.1.0/grison/scanners/acunetix.py +128 -0
  30. grison-0.1.0/grison/scanners/base.py +56 -0
  31. grison-0.1.0/grison/scanners/burp.py +99 -0
  32. grison-0.1.0/grison/scanners/detect.py +82 -0
  33. grison-0.1.0/grison/scanners/ir/__init__.py +14 -0
  34. grison-0.1.0/grison/scanners/ir/finding.py +31 -0
  35. grison-0.1.0/grison/scanners/ir/severity.py +75 -0
  36. grison-0.1.0/grison/scanners/nessus.py +125 -0
  37. grison-0.1.0/grison/scanners/nmap.py +142 -0
  38. grison-0.1.0/grison/scanners/openvas.py +128 -0
  39. grison-0.1.0/grison/scanners/qualys.py +127 -0
  40. grison-0.1.0/grison/scanners/sslyze.py +404 -0
  41. grison-0.1.0/grison/scanners/zap.py +155 -0
  42. grison-0.1.0/grison/sinks/__init__.py +8 -0
  43. grison-0.1.0/grison/sinks/file_sink.py +104 -0
  44. grison-0.1.0/grison/sinks/pipeline.py +91 -0
  45. grison-0.1.0/grison/validate.py +66 -0
  46. grison-0.1.0/grison/workspace.py +43 -0
  47. grison-0.1.0/pyproject.toml +58 -0
  48. grison-0.1.0/scripts/gen_cwe_index.py +94 -0
  49. grison-0.1.0/tests/fixtures/scanners/acunetix_sample.xml +62 -0
  50. grison-0.1.0/tests/fixtures/scanners/burp_sample.xml +17 -0
  51. grison-0.1.0/tests/fixtures/scanners/nessus_sample.xml +14 -0
  52. grison-0.1.0/tests/fixtures/scanners/nmap_sample.xml +20 -0
  53. grison-0.1.0/tests/fixtures/scanners/openvas_sample.xml +22 -0
  54. grison-0.1.0/tests/fixtures/scanners/qualys_sample.xml +18 -0
  55. grison-0.1.0/tests/fixtures/scanners/sslyze_sample.json +112 -0
  56. grison-0.1.0/tests/fixtures/scanners/zap_sample.xml +28 -0
  57. grison-0.1.0/tests/test_bookstack.py +164 -0
  58. grison-0.1.0/tests/test_cli.py +95 -0
  59. grison-0.1.0/tests/test_converter.py +137 -0
  60. grison-0.1.0/tests/test_converter_corpus.py +55 -0
  61. grison-0.1.0/tests/test_cvss.py +91 -0
  62. grison-0.1.0/tests/test_cwe.py +55 -0
  63. grison-0.1.0/tests/test_detect.py +57 -0
  64. grison-0.1.0/tests/test_document.py +102 -0
  65. grison-0.1.0/tests/test_file_sink.py +62 -0
  66. grison-0.1.0/tests/test_ghostwriter.py +297 -0
  67. grison-0.1.0/tests/test_gwmap.py +94 -0
  68. grison-0.1.0/tests/test_mapping.py +74 -0
  69. grison-0.1.0/tests/test_methodology.py +290 -0
  70. grison-0.1.0/tests/test_model.py +117 -0
  71. grison-0.1.0/tests/test_pipeline.py +69 -0
  72. grison-0.1.0/tests/test_remote_bootstrap.py +61 -0
  73. grison-0.1.0/tests/test_scanner_acunetix.py +177 -0
  74. grison-0.1.0/tests/test_scanner_acunetix_cvss.py +42 -0
  75. grison-0.1.0/tests/test_scanner_base.py +28 -0
  76. grison-0.1.0/tests/test_scanner_burp.py +21 -0
  77. grison-0.1.0/tests/test_scanner_nessus.py +44 -0
  78. grison-0.1.0/tests/test_scanner_nmap.py +35 -0
  79. grison-0.1.0/tests/test_scanner_openvas.py +22 -0
  80. grison-0.1.0/tests/test_scanner_qualys.py +23 -0
  81. grison-0.1.0/tests/test_scanner_sslyze.py +208 -0
  82. grison-0.1.0/tests/test_scanner_zap.py +47 -0
  83. grison-0.1.0/tests/test_smoke.py +18 -0
  84. grison-0.1.0/tests/test_sync.py +117 -0
  85. grison-0.1.0/tests/test_sync_push.py +496 -0
  86. grison-0.1.0/tests/test_validate.py +17 -0
  87. grison-0.1.0/uv.lock +468 -0
@@ -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
+ ]