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.
- probity/__init__.py +6 -0
- probity/__main__.py +7 -0
- probity/cli.py +251 -0
- probity/commands/__init__.py +11 -0
- probity/commands/registry.py +36 -0
- probity/connectors/__init__.py +3 -0
- probity/connectors/base.py +21 -0
- probity/connectors/cyclonedx_connector.py +59 -0
- probity/connectors/mock_assets.py +66 -0
- probity/connectors/mock_backup.py +45 -0
- probity/connectors/mock_cloud.py +45 -0
- probity/connectors/mock_governance.py +58 -0
- probity/connectors/mock_idp.py +45 -0
- probity/connectors/mock_pipeline.py +48 -0
- probity/connectors/mock_sbom.py +52 -0
- probity/connectors/mock_sca.py +53 -0
- probity/connectors/mock_siem.py +55 -0
- probity/connectors/mock_tls.py +45 -0
- probity/connectors/mock_training.py +49 -0
- probity/connectors/osv_connector.py +95 -0
- probity/connectors/registry.py +36 -0
- probity/connectors/restic_connector.py +75 -0
- probity/connectors/sslyze_connector.py +122 -0
- probity/connectors/testssl_connector.py +110 -0
- probity/connectors/trivy_connector.py +84 -0
- probity/connectors/veeam_connector.py +67 -0
- probity/controls/__init__.py +61 -0
- probity/controls/base.py +27 -0
- probity/controls/c01_security_policy.py +18 -0
- probity/controls/c02_asset_inventory.py +94 -0
- probity/controls/c03_logging.py +93 -0
- probity/controls/c04_detection.py +102 -0
- probity/controls/c05_incident_procedure.py +18 -0
- probity/controls/c06_backups.py +104 -0
- probity/controls/c07_restore.py +110 -0
- probity/controls/c08_immutable.py +90 -0
- probity/controls/c09_sbom.py +109 -0
- probity/controls/c10_cves.py +95 -0
- probity/controls/c11_supplier_risk.py +87 -0
- probity/controls/c12_vuln_scanning.py +85 -0
- probity/controls/c13_cicd_security.py +80 -0
- probity/controls/c14_patch_management.py +95 -0
- probity/controls/c15_disclosure.py +18 -0
- probity/controls/c16_training.py +85 -0
- probity/controls/c17_encryption.py +72 -0
- probity/controls/c18_tls.py +88 -0
- probity/controls/c19_access.py +73 -0
- probity/controls/c20_mfa.py +76 -0
- probity/controls/freshness.py +41 -0
- probity/controls/soft.py +126 -0
- probity/engine/__init__.py +6 -0
- probity/engine/runner.py +45 -0
- probity/model/__init__.py +16 -0
- probity/model/enums.py +21 -0
- probity/model/fact.py +39 -0
- probity/model/finding.py +80 -0
- probity/plugins.py +22 -0
- probity/py.typed +0 -0
- probity/report/__init__.py +6 -0
- probity/report/history.py +101 -0
- probity/report/html_report.py +140 -0
- probity/report/json_report.py +12 -0
- probity/report/registry.py +49 -0
- probity/report/text_report.py +20 -0
- probity/scan_addons/__init__.py +12 -0
- probity/scan_addons/registry.py +34 -0
- probity-0.1.0.dist-info/METADATA +121 -0
- probity-0.1.0.dist-info/RECORD +71 -0
- probity-0.1.0.dist-info/WHEEL +4 -0
- probity-0.1.0.dist-info/entry_points.txt +2 -0
- probity-0.1.0.dist-info/licenses/LICENSE +661 -0
probity/__init__.py
ADDED
probity/__main__.py
ADDED
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,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))
|