probity 0.1.0__py3-none-any.whl

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 (71) hide show
  1. probity/__init__.py +6 -0
  2. probity/__main__.py +7 -0
  3. probity/cli.py +251 -0
  4. probity/commands/__init__.py +11 -0
  5. probity/commands/registry.py +36 -0
  6. probity/connectors/__init__.py +3 -0
  7. probity/connectors/base.py +21 -0
  8. probity/connectors/cyclonedx_connector.py +59 -0
  9. probity/connectors/mock_assets.py +66 -0
  10. probity/connectors/mock_backup.py +45 -0
  11. probity/connectors/mock_cloud.py +45 -0
  12. probity/connectors/mock_governance.py +58 -0
  13. probity/connectors/mock_idp.py +45 -0
  14. probity/connectors/mock_pipeline.py +48 -0
  15. probity/connectors/mock_sbom.py +52 -0
  16. probity/connectors/mock_sca.py +53 -0
  17. probity/connectors/mock_siem.py +55 -0
  18. probity/connectors/mock_tls.py +45 -0
  19. probity/connectors/mock_training.py +49 -0
  20. probity/connectors/osv_connector.py +95 -0
  21. probity/connectors/registry.py +36 -0
  22. probity/connectors/restic_connector.py +75 -0
  23. probity/connectors/sslyze_connector.py +122 -0
  24. probity/connectors/testssl_connector.py +110 -0
  25. probity/connectors/trivy_connector.py +84 -0
  26. probity/connectors/veeam_connector.py +67 -0
  27. probity/controls/__init__.py +61 -0
  28. probity/controls/base.py +27 -0
  29. probity/controls/c01_security_policy.py +18 -0
  30. probity/controls/c02_asset_inventory.py +94 -0
  31. probity/controls/c03_logging.py +93 -0
  32. probity/controls/c04_detection.py +102 -0
  33. probity/controls/c05_incident_procedure.py +18 -0
  34. probity/controls/c06_backups.py +104 -0
  35. probity/controls/c07_restore.py +110 -0
  36. probity/controls/c08_immutable.py +90 -0
  37. probity/controls/c09_sbom.py +109 -0
  38. probity/controls/c10_cves.py +95 -0
  39. probity/controls/c11_supplier_risk.py +87 -0
  40. probity/controls/c12_vuln_scanning.py +85 -0
  41. probity/controls/c13_cicd_security.py +80 -0
  42. probity/controls/c14_patch_management.py +95 -0
  43. probity/controls/c15_disclosure.py +18 -0
  44. probity/controls/c16_training.py +85 -0
  45. probity/controls/c17_encryption.py +72 -0
  46. probity/controls/c18_tls.py +88 -0
  47. probity/controls/c19_access.py +73 -0
  48. probity/controls/c20_mfa.py +76 -0
  49. probity/controls/freshness.py +41 -0
  50. probity/controls/soft.py +126 -0
  51. probity/engine/__init__.py +6 -0
  52. probity/engine/runner.py +45 -0
  53. probity/model/__init__.py +16 -0
  54. probity/model/enums.py +21 -0
  55. probity/model/fact.py +39 -0
  56. probity/model/finding.py +80 -0
  57. probity/plugins.py +22 -0
  58. probity/py.typed +0 -0
  59. probity/report/__init__.py +6 -0
  60. probity/report/history.py +101 -0
  61. probity/report/html_report.py +140 -0
  62. probity/report/json_report.py +12 -0
  63. probity/report/registry.py +49 -0
  64. probity/report/text_report.py +20 -0
  65. probity/scan_addons/__init__.py +12 -0
  66. probity/scan_addons/registry.py +34 -0
  67. probity-0.1.0.dist-info/METADATA +121 -0
  68. probity-0.1.0.dist-info/RECORD +71 -0
  69. probity-0.1.0.dist-info/WHEEL +4 -0
  70. probity-0.1.0.dist-info/entry_points.txt +2 -0
  71. probity-0.1.0.dist-info/licenses/LICENSE +661 -0
probity/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Janier Rodríguez
3
+
4
+ """Probity — continuous, auditable NIS2 compliance evidence."""
5
+
6
+ __version__ = "0.1.0"
probity/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Janier Rodríguez
3
+
4
+ from probity.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
probity/cli.py ADDED
@@ -0,0 +1,251 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Janier Rodríguez
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from collections.abc import Callable, Sequence
8
+ from typing import cast
9
+
10
+ from probity.commands.registry import Command, discovered_commands
11
+ from probity.connectors.base import Connector
12
+ from probity.connectors.cyclonedx_connector import CycloneDxConnector
13
+ from probity.connectors.mock_assets import MockAssetsConnector
14
+ from probity.connectors.mock_backup import MockBackupConnector
15
+ from probity.connectors.mock_cloud import MockCloudConnector
16
+ from probity.connectors.mock_governance import MockGovernanceConnector
17
+ from probity.connectors.mock_idp import MockIdpConnector
18
+ from probity.connectors.mock_pipeline import MockPipelineConnector
19
+ from probity.connectors.mock_sbom import MockSbomConnector
20
+ from probity.connectors.mock_sca import MockScaConnector
21
+ from probity.connectors.mock_siem import MockSiemConnector
22
+ from probity.connectors.mock_tls import MockTlsConnector
23
+ from probity.connectors.mock_training import MockTrainingConnector
24
+ from probity.connectors.osv_connector import OsvConnector
25
+ from probity.connectors.registry import discovered_sources
26
+ from probity.connectors.restic_connector import ResticConnector
27
+ from probity.connectors.sslyze_connector import SslyzeConnector
28
+ from probity.connectors.testssl_connector import TesttsslConnector
29
+ from probity.connectors.trivy_connector import TrivyConnector
30
+ from probity.connectors.veeam_connector import VeeamConnector
31
+ from probity.controls import ALL_CONTROLS
32
+ from probity.engine.runner import Scan
33
+ from probity.model.enums import Status
34
+ from probity.model.finding import Report
35
+ from probity.report.history import Trend, append_snapshot, compute_trend, load_snapshots
36
+ from probity.report.registry import all_formats
37
+ from probity.scan_addons.registry import discovered_addons
38
+
39
+ # Active controls come from the single-source-of-truth registry in
40
+ # probity.controls so the catalogue cannot drift between the CLI and runner.
41
+ CONTROLS = ALL_CONTROLS
42
+
43
+
44
+ def add_source_args(parser: argparse.ArgumentParser) -> None:
45
+ """Attach the shared connector-source flags to ``parser``.
46
+
47
+ Public so registered commands (e.g. the Enterprise ``watch``) can reuse the
48
+ exact same source flags as ``scan``. Identity facts come from a file source
49
+ (``--source``); live identity sources (e.g. Entra) are contributed by
50
+ externally registered connector sources, so Core never has to be edited.
51
+ """
52
+ parser.add_argument("--source", help="Path to identity source JSON (mock_idp).")
53
+ parser.add_argument("--cloud", help="Path to cloud storage source JSON (mock_cloud).")
54
+ parser.add_argument("--tls", help="Path to TLS endpoint source JSON (mock_tls).")
55
+ parser.add_argument("--testssl", help="Path to real testssl.sh --jsonfile output.")
56
+ parser.add_argument("--sslyze", help="Path to real sslyze --json_out output.")
57
+ parser.add_argument("--backup", help="Path to backup-jobs source JSON (mock_backup).")
58
+ parser.add_argument("--veeam", help="Path to real Veeam B&R job-report JSON.")
59
+ parser.add_argument("--restic", help="Path to real restic snapshots --json output.")
60
+ parser.add_argument("--sca", help="Path to dependency/CVE source JSON (mock_sca).")
61
+ parser.add_argument("--osv", help="Path to real osv-scanner --format json output.")
62
+ parser.add_argument("--sbom", help="Path to SBOM component source JSON (mock_sbom).")
63
+ parser.add_argument("--cyclonedx", help="Path to a real CycloneDX JSON BOM.")
64
+ parser.add_argument(
65
+ "--governance",
66
+ help="Path to governance records JSON (documents + suppliers) for SOFT controls.",
67
+ )
68
+ parser.add_argument(
69
+ "--assets",
70
+ help="Path to asset-management JSON (assets + vulnscans + patches) for C02/C12/C14.",
71
+ )
72
+ parser.add_argument("--trivy", help="Path to real Trivy --format json output for C12.")
73
+ parser.add_argument(
74
+ "--siem",
75
+ help="Path to SIEM JSON (log sources + detection rules) for C03/C04.",
76
+ )
77
+ parser.add_argument("--pipeline", help="Path to CI/CD pipeline config JSON for C13.")
78
+ parser.add_argument("--training", help="Path to HR/LMS training records JSON for C16.")
79
+ # Externally registered connector sources (e.g. the Enterprise package) add
80
+ # their own flags here, so the CLI never has to be edited to gain them.
81
+ for source in discovered_sources():
82
+ source.add_arguments(parser)
83
+
84
+
85
+ def _configure_scan(parser: argparse.ArgumentParser) -> None:
86
+ add_source_args(parser)
87
+ parser.add_argument("--format", choices=sorted(all_formats()), default="text")
88
+ parser.add_argument(
89
+ "--out",
90
+ help="Write the report to this file instead of stdout (required for --format pdf).",
91
+ )
92
+ parser.add_argument(
93
+ "--history",
94
+ help="Append-only JSONL store; records this scan and reports the score trend.",
95
+ )
96
+ # Externally registered scan add-ons (e.g. the Enterprise multi-framework
97
+ # coverage view) contribute their own flags here, so Core never has to be
98
+ # edited to gain post-scan behaviour.
99
+ for addon in discovered_addons():
100
+ addon.add_arguments(parser)
101
+
102
+
103
+ # Core ships a single command. Service commands (watch/serve) are Enterprise-only
104
+ # and join via the probity.commands entry point (see docs/TIERING.md).
105
+ BUILTIN_COMMANDS: tuple[Command, ...] = (
106
+ Command(
107
+ name="scan",
108
+ help="Run controls against sources and emit findings.",
109
+ configure=_configure_scan,
110
+ run=lambda args: _run_scan(args),
111
+ ),
112
+ )
113
+
114
+
115
+ def _all_commands() -> list[Command]:
116
+ """Builtin commands plus any registered via entry points (plugins appended)."""
117
+ return [*BUILTIN_COMMANDS, *discovered_commands()]
118
+
119
+
120
+ def build_parser() -> argparse.ArgumentParser:
121
+ parser = argparse.ArgumentParser(
122
+ prog="probity", description="Continuous NIS2 compliance evidence."
123
+ )
124
+ sub = parser.add_subparsers(dest="command", required=True)
125
+ for command in _all_commands():
126
+ cp = sub.add_parser(command.name, help=command.help)
127
+ command.configure(cp)
128
+ cp.set_defaults(_run=command.run)
129
+ return parser
130
+
131
+
132
+ _TREND_ARROW = {"up": "▲", "down": "▼", "flat": "▬", "first": "•"}
133
+
134
+
135
+ def _render_trend(trend: Trend) -> str:
136
+ arrow = _TREND_ARROW[trend.direction]
137
+ if trend.previous is None:
138
+ return f"Trend {arrow} first recorded scan — score {trend.current}%."
139
+ sign = "+" if trend.delta >= 0 else ""
140
+ return (
141
+ f"Trend {arrow} {trend.direction} — score {trend.current}% "
142
+ f"({sign}{trend.delta} vs previous {trend.previous}%)."
143
+ )
144
+
145
+
146
+ def _emit(report: Report, fmt: str, out: str | None) -> None:
147
+ """Render the report in ``fmt`` to ``out`` (file) or stdout via the registry.
148
+
149
+ Binary formats (e.g. PDF) always require ``--out``; text formats print to
150
+ stdout unless a path is given. The format set is the builtin formats plus any
151
+ registered by plugins, so this stays correct as Enterprise adds formats.
152
+ """
153
+ report_format = all_formats()[fmt]
154
+ rendered = report_format.render(report)
155
+ if report_format.binary:
156
+ if not out:
157
+ raise SystemExit(f"--format {fmt} requires --out FILE")
158
+ if not isinstance(rendered, bytes):
159
+ raise TypeError(f"format {fmt!r} is binary but did not render bytes")
160
+ with open(out, "wb") as fh:
161
+ fh.write(rendered)
162
+ print(f"Wrote {fmt} evidence pack to {out}")
163
+ return
164
+ if not isinstance(rendered, str):
165
+ raise TypeError(f"format {fmt!r} is text but did not render str")
166
+ if out:
167
+ with open(out, "w", encoding="utf-8") as fh:
168
+ fh.write(rendered)
169
+ print(f"Wrote {fmt} report to {out}")
170
+ else:
171
+ print(rendered)
172
+
173
+
174
+ def build_connectors(args: argparse.Namespace) -> list[Connector]:
175
+ """Build the connector list from the shared source flags on ``args``.
176
+
177
+ Public so registered commands (e.g. the Enterprise ``watch``) build the
178
+ exact same connector set as ``scan``. File sources are built first, then any
179
+ connectors contributed by externally registered sources (plugins). At least
180
+ one evidence source must resolve to a connector, or the run is refused.
181
+ """
182
+ connectors: list[Connector] = []
183
+ if args.source:
184
+ connectors.append(MockIdpConnector(args.source))
185
+ if args.cloud:
186
+ connectors.append(MockCloudConnector(args.cloud))
187
+ if args.tls:
188
+ connectors.append(MockTlsConnector(args.tls))
189
+ if args.testssl:
190
+ connectors.append(TesttsslConnector(args.testssl))
191
+ if args.sslyze:
192
+ connectors.append(SslyzeConnector(args.sslyze))
193
+ if args.backup:
194
+ connectors.append(MockBackupConnector(args.backup))
195
+ if args.veeam:
196
+ connectors.append(VeeamConnector(args.veeam))
197
+ if args.restic:
198
+ connectors.append(ResticConnector(args.restic))
199
+ if args.sca:
200
+ connectors.append(MockScaConnector(args.sca))
201
+ if args.osv:
202
+ connectors.append(OsvConnector(args.osv))
203
+ if args.sbom:
204
+ connectors.append(MockSbomConnector(args.sbom))
205
+ if args.cyclonedx:
206
+ connectors.append(CycloneDxConnector(args.cyclonedx))
207
+ if args.governance:
208
+ connectors.append(MockGovernanceConnector(args.governance))
209
+ if args.assets:
210
+ connectors.append(MockAssetsConnector(args.assets))
211
+ if args.trivy:
212
+ connectors.append(TrivyConnector(args.trivy))
213
+ if args.siem:
214
+ connectors.append(MockSiemConnector(args.siem))
215
+ if args.pipeline:
216
+ connectors.append(MockPipelineConnector(args.pipeline))
217
+ if args.training:
218
+ connectors.append(MockTrainingConnector(args.training))
219
+ # Append connectors contributed by externally registered sources (plugins).
220
+ for source in discovered_sources():
221
+ connectors.extend(source.build(args))
222
+ if not connectors:
223
+ raise SystemExit("provide at least one evidence source (e.g. --source FILE)")
224
+ return connectors
225
+
226
+
227
+ def _run_scan(args: argparse.Namespace) -> int:
228
+ report = Scan(build_connectors(args), CONTROLS).run()
229
+ _emit(report, args.format, args.out)
230
+ if args.history:
231
+ append_snapshot(report, args.history)
232
+ trend = compute_trend(load_snapshots(args.history))
233
+ print(_render_trend(trend))
234
+ # Registered scan add-ons run after the report is emitted. Each is inert
235
+ # unless its own flag is set, so default scan output is unchanged.
236
+ for addon in discovered_addons():
237
+ addon.after_scan(report, args)
238
+ failed = any(f.status is Status.FAIL for f in report.findings)
239
+ return 1 if failed else 0
240
+
241
+
242
+ def main(argv: Sequence[str] | None = None) -> int:
243
+ args = build_parser().parse_args(argv)
244
+ # Every subparser stores its command's run callable under ``_run`` via
245
+ # set_defaults, so dispatch needs no per-command branch here.
246
+ run = cast("Callable[[argparse.Namespace], int]", args._run)
247
+ return run(args)
248
+
249
+
250
+ if __name__ == "__main__":
251
+ raise SystemExit(main())
@@ -0,0 +1,11 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Janier Rodríguez
3
+
4
+ """CLI subcommand seam.
5
+
6
+ Core ships the ``scan`` command. Service commands (``watch``, ``serve``) are
7
+ Enterprise-only and register through the ``probity.commands`` entry-point group
8
+ defined in :mod:`probity.commands.registry`.
9
+ """
10
+
11
+ from __future__ import annotations
@@ -0,0 +1,36 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Janier Rodríguez
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from typing import cast
10
+
11
+ from probity.plugins import load_plugins
12
+
13
+ # Entry-point group the Enterprise package (or any third party) registers extra
14
+ # CLI subcommands under — e.g. the service-layer ``watch`` and ``serve``.
15
+ COMMAND_GROUP = "probity.commands"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Command:
20
+ """A named CLI subcommand.
21
+
22
+ ``configure`` receives the freshly created subparser to attach its own
23
+ flags; ``run`` executes the command from parsed args and returns a process
24
+ exit code. Core ships ``scan``; registered commands extend the CLI with no
25
+ edit to Core's dispatch.
26
+ """
27
+
28
+ name: str
29
+ help: str
30
+ configure: Callable[[argparse.ArgumentParser], None]
31
+ run: Callable[[argparse.Namespace], int]
32
+
33
+
34
+ def discovered_commands() -> list[Command]:
35
+ """Subcommands registered via entry points (empty in a plain Core install)."""
36
+ return cast("list[Command]", load_plugins(COMMAND_GROUP))
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Janier Rodríguez
3
+
@@ -0,0 +1,21 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Janier Rodríguez
3
+
4
+ from __future__ import annotations
5
+
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import Iterable
8
+
9
+ from probity.model.fact import Fact
10
+
11
+
12
+ class Connector(ABC):
13
+ """Reads a real system and emits Facts. Plugin unit (UDS module pattern)."""
14
+
15
+ id: str = ""
16
+ title: str = ""
17
+
18
+ @abstractmethod
19
+ def collect(self) -> Iterable[Fact]:
20
+ """Return facts observed from the source. Must not raise on an empty source."""
21
+ raise NotImplementedError
@@ -0,0 +1,59 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Janier Rodríguez
3
+
4
+ """Real SBOM connector backed by a CycloneDX JSON bill of materials.
5
+
6
+ Unlike :class:`~probity.connectors.mock_sbom.MockSbomConnector`, this reads an
7
+ actual CycloneDX BOM produced by a standard tool (`cyclonedx-py`,
8
+ `syft -o cyclonedx-json`, `cdxgen`). No credentials and no network are needed
9
+ at scan time: the operator generates the BOM (free, offline) and hands Probity
10
+ the file as auditable supply-chain evidence.
11
+
12
+ A component's mere presence in a real BOM proves an SBOM exists for it; the
13
+ BOM's own ``metadata.timestamp`` is that SBOM's generation date. This emits the
14
+ same ``sbom.component`` facts the mock does, so C09 consumes either unchanged
15
+ and keeps its fail-closed staleness check (a BOM with no timestamp leaves
16
+ ``generated_at`` blank, which C09 fails closed).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from collections.abc import Iterable
23
+ from pathlib import Path
24
+ from typing import Any, cast
25
+
26
+ from probity.connectors.base import Connector
27
+ from probity.connectors.mock_sbom import SBOM_KIND
28
+ from probity.model.fact import Fact
29
+
30
+
31
+ class CycloneDxConnector(Connector):
32
+ """Emits one ``sbom.component`` fact per component listed in the BOM."""
33
+
34
+ id = "cyclonedx"
35
+ title = "SBOM (CycloneDX JSON bill of materials)"
36
+
37
+ def __init__(self, source: str | Path | dict[str, Any]) -> None:
38
+ self._source = source
39
+
40
+ def _load(self) -> dict[str, Any]:
41
+ if isinstance(self._source, dict):
42
+ return self._source
43
+ raw = Path(self._source).read_text(encoding="utf-8")
44
+ return cast(dict[str, Any], json.loads(raw))
45
+
46
+ def collect(self) -> Iterable[Fact]:
47
+ payload = self._load()
48
+ generated_at = str(payload.get("metadata", {}).get("timestamp", ""))
49
+ for comp in payload.get("components", []):
50
+ name = comp.get("name", "")
51
+ version = comp.get("version", "")
52
+ data = {
53
+ "name": name,
54
+ "version": version,
55
+ "has_sbom": True,
56
+ "generated_at": generated_at,
57
+ "purl": comp.get("purl", ""),
58
+ }
59
+ yield Fact(kind=SBOM_KIND, key=f"{name}@{version}", data=data)
@@ -0,0 +1,66 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Janier Rodríguez
3
+
4
+ """File-backed asset-management connector for the asset-plane HARD controls.
5
+
6
+ A real deployment fuses a CMDB / cloud inventory, a vulnerability scanner, and a
7
+ patch/EDR console; this single connector stands in for all three and emits the
8
+ per-asset facts they would provide:
9
+
10
+ * ``asset.record`` — inventory entry (C02)::
11
+
12
+ {"id": "vm-1", "name": "prod-db", "type": "vm",
13
+ "managed": true, "last_seen": "2026-06-03T12:00:00+00:00"}
14
+
15
+ * ``vulnscan.target`` — scan coverage per asset (C12)::
16
+
17
+ {"id": "vm-1", "asset": "prod-db", "critical": true,
18
+ "last_scan": "2026-05-20T00:00:00+00:00", "scanner": "nessus"}
19
+
20
+ * ``patch.host`` — patch state per host (C14)::
21
+
22
+ {"id": "vm-1", "host": "prod-db", "critical": true,
23
+ "last_patched": "2026-05-28T00:00:00+00:00", "pending_critical": 0}
24
+
25
+ Dates are ISO-8601. Real connectors (cloud API, scanner export, EDR) emit the
26
+ same facts, so the controls run unchanged.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ from collections.abc import Iterable
33
+ from pathlib import Path
34
+ from typing import Any, cast
35
+
36
+ from probity.connectors.base import Connector
37
+ from probity.model.fact import Fact
38
+
39
+ ASSET_KIND = "asset.record"
40
+ VULNSCAN_KIND = "vulnscan.target"
41
+ PATCH_KIND = "patch.host"
42
+
43
+
44
+ class MockAssetsConnector(Connector):
45
+ """Emits ``asset.record``, ``vulnscan.target`` and ``patch.host`` facts."""
46
+
47
+ id = "mock_assets"
48
+ title = "Mock Asset Management (file-backed)"
49
+
50
+ def __init__(self, source: str | Path | dict[str, Any]) -> None:
51
+ self._source = source
52
+
53
+ def _load(self) -> dict[str, Any]:
54
+ if isinstance(self._source, dict):
55
+ return self._source
56
+ raw = Path(self._source).read_text(encoding="utf-8")
57
+ return cast(dict[str, Any], json.loads(raw))
58
+
59
+ def collect(self) -> Iterable[Fact]:
60
+ payload = self._load()
61
+ for asset in payload.get("assets", []):
62
+ yield Fact(kind=ASSET_KIND, key=str(asset["id"]), data=dict(asset))
63
+ for target in payload.get("vulnscans", []):
64
+ yield Fact(kind=VULNSCAN_KIND, key=str(target["id"]), data=dict(target))
65
+ for host in payload.get("patches", []):
66
+ yield Fact(kind=PATCH_KIND, key=str(host["id"]), data=dict(host))
@@ -0,0 +1,45 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Janier Rodríguez
3
+
4
+ """File-backed backup-job connector for development and tests."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from collections.abc import Iterable
10
+ from pathlib import Path
11
+ from typing import Any, cast
12
+
13
+ from probity.connectors.base import Connector
14
+ from probity.model.fact import Fact
15
+
16
+ BACKUP_KIND = "backup.job"
17
+
18
+
19
+ class MockBackupConnector(Connector):
20
+ """Emits one ``backup.job`` fact per declared backup job.
21
+
22
+ Source JSON shape::
23
+
24
+ {"backups": [
25
+ {"id": "b1", "asset": "prod-db", "critical": true,
26
+ "last_backup": "2026-06-03T00:00:00+00:00", "retention_days": 30}
27
+ ]}
28
+ """
29
+
30
+ id = "mock_backup"
31
+ title = "Mock Backup Jobs (file-backed)"
32
+
33
+ def __init__(self, source: str | Path | dict[str, Any]) -> None:
34
+ self._source = source
35
+
36
+ def _load(self) -> dict[str, Any]:
37
+ if isinstance(self._source, dict):
38
+ return self._source
39
+ raw = Path(self._source).read_text(encoding="utf-8")
40
+ return cast(dict[str, Any], json.loads(raw))
41
+
42
+ def collect(self) -> Iterable[Fact]:
43
+ payload = self._load()
44
+ for job in payload.get("backups", []):
45
+ yield Fact(kind=BACKUP_KIND, key=str(job["id"]), data=dict(job))
@@ -0,0 +1,45 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Janier Rodríguez
3
+
4
+ """File-backed cloud storage connector for development and tests."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from collections.abc import Iterable
10
+ from pathlib import Path
11
+ from typing import Any, cast
12
+
13
+ from probity.connectors.base import Connector
14
+ from probity.model.fact import Fact
15
+
16
+ STORAGE_KIND = "storage.volume"
17
+
18
+
19
+ class MockCloudConnector(Connector):
20
+ """Emits one ``storage.volume`` fact per declared volume.
21
+
22
+ Source JSON shape::
23
+
24
+ {"volumes": [
25
+ {"id": "v1", "name": "prod-db", "encrypted": true,
26
+ "kms": "managed", "contains_pii": true}
27
+ ]}
28
+ """
29
+
30
+ id = "mock_cloud"
31
+ title = "Mock Cloud Storage (file-backed)"
32
+
33
+ def __init__(self, source: str | Path | dict[str, Any]) -> None:
34
+ self._source = source
35
+
36
+ def _load(self) -> dict[str, Any]:
37
+ if isinstance(self._source, dict):
38
+ return self._source
39
+ raw = Path(self._source).read_text(encoding="utf-8")
40
+ return cast(dict[str, Any], json.loads(raw))
41
+
42
+ def collect(self) -> Iterable[Fact]:
43
+ payload = self._load()
44
+ for volume in payload.get("volumes", []):
45
+ yield Fact(kind=STORAGE_KIND, key=str(volume["id"]), data=dict(volume))
@@ -0,0 +1,58 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Janier Rodríguez
3
+
4
+ """File-backed governance connector for the SOFT (policy) controls.
5
+
6
+ SOFT controls reason over governance artifacts whose *existence and currency*
7
+ are machine-checkable but whose *adequacy* needs a human auditor. This single
8
+ connector feeds them all:
9
+
10
+ * ``governance.document`` — a policy / procedure record (C01, C05, C15)::
11
+
12
+ {"id": "pol-sec", "type": "security_policy", "title": "InfoSec Policy",
13
+ "approved_at": "2025-09-01", "review_due": "2026-09-01"}
14
+
15
+ * ``governance.supplier`` — a critical-supplier risk record (C11)::
16
+
17
+ {"id": "sup-acme", "name": "AcmeCloud", "criticality": "high",
18
+ "risk_assessed_at": "2026-01-15"}
19
+
20
+ Dates are ISO-8601 (date or datetime). A real deployment would replace this
21
+ with an export from a GRC / policy-management tool emitting the same facts.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from collections.abc import Iterable
28
+ from pathlib import Path
29
+ from typing import Any, cast
30
+
31
+ from probity.connectors.base import Connector
32
+ from probity.model.fact import Fact
33
+
34
+ DOCUMENT_KIND = "governance.document"
35
+ SUPPLIER_KIND = "governance.supplier"
36
+
37
+
38
+ class MockGovernanceConnector(Connector):
39
+ """Emits ``governance.document`` and ``governance.supplier`` facts."""
40
+
41
+ id = "mock_governance"
42
+ title = "Mock Governance Records (file-backed)"
43
+
44
+ def __init__(self, source: str | Path | dict[str, Any]) -> None:
45
+ self._source = source
46
+
47
+ def _load(self) -> dict[str, Any]:
48
+ if isinstance(self._source, dict):
49
+ return self._source
50
+ raw = Path(self._source).read_text(encoding="utf-8")
51
+ return cast(dict[str, Any], json.loads(raw))
52
+
53
+ def collect(self) -> Iterable[Fact]:
54
+ payload = self._load()
55
+ for doc in payload.get("documents", []):
56
+ yield Fact(kind=DOCUMENT_KIND, key=str(doc["id"]), data=dict(doc))
57
+ for supplier in payload.get("suppliers", []):
58
+ yield Fact(kind=SUPPLIER_KIND, key=str(supplier["id"]), data=dict(supplier))
@@ -0,0 +1,45 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Janier Rodríguez
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from collections.abc import Iterable
8
+ from pathlib import Path
9
+ from typing import Any, cast
10
+
11
+ from probity.connectors.base import Connector
12
+ from probity.model.fact import Fact
13
+
14
+ ACCOUNT_KIND = "identity.account"
15
+
16
+
17
+ class MockIdpConnector(Connector):
18
+ """File-backed identity provider connector for development and tests.
19
+
20
+ Source JSON shape::
21
+
22
+ {"accounts": [
23
+ {"id": "u1", "display_name": "Alice", "enabled": true,
24
+ "privileged": true, "mfa_enabled": true, "hr_active": true}
25
+ ]}
26
+
27
+ Every account becomes one ``identity.account`` fact.
28
+ """
29
+
30
+ id = "mock_idp"
31
+ title = "Mock Identity Provider (file-backed)"
32
+
33
+ def __init__(self, source: str | Path | dict[str, Any]) -> None:
34
+ self._source = source
35
+
36
+ def _load(self) -> dict[str, Any]:
37
+ if isinstance(self._source, dict):
38
+ return self._source
39
+ raw = Path(self._source).read_text(encoding="utf-8")
40
+ return cast(dict[str, Any], json.loads(raw))
41
+
42
+ def collect(self) -> Iterable[Fact]:
43
+ payload = self._load()
44
+ for account in payload.get("accounts", []):
45
+ yield Fact(kind=ACCOUNT_KIND, key=str(account["id"]), data=dict(account))