rhada 0.0.1__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.
rhada/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ """Rhada: Legal compliance, SBOM generation, and security auditor for Python and compiled distributions."""
2
+
3
+ __version__ = "0.0.1"
4
+
5
+ from rhada.auditor import compute_exit_code, run_audit
6
+ from rhada.elf import is_pe_file, parse_pe_binary
7
+ from rhada.models import (
8
+ AuditConfig,
9
+ AuditMode,
10
+ AuditReport,
11
+ BinaryInfo,
12
+ BinaryLinkageReport,
13
+ BundlerType,
14
+ ComplianceFinding,
15
+ Component,
16
+ ComponentScope,
17
+ ComponentType,
18
+ ConsoleFormat,
19
+ DistributionLayout,
20
+ ElfBinaryInfo,
21
+ ExitCode,
22
+ LicenseInfo,
23
+ LicenseRiskCategory,
24
+ LinkageIssue,
25
+ LinkageStatus,
26
+ SbomFormat,
27
+ Severity,
28
+ Vulnerability,
29
+ )
30
+
31
+ __all__ = [
32
+ "AuditConfig",
33
+ "AuditMode",
34
+ "AuditReport",
35
+ "BinaryInfo",
36
+ "BinaryLinkageReport",
37
+ "BundlerType",
38
+ "ComplianceFinding",
39
+ "Component",
40
+ "ComponentScope",
41
+ "ComponentType",
42
+ "ConsoleFormat",
43
+ "DistributionLayout",
44
+ "ElfBinaryInfo",
45
+ "ExitCode",
46
+ "LicenseInfo",
47
+ "LicenseRiskCategory",
48
+ "LinkageIssue",
49
+ "LinkageStatus",
50
+ "SbomFormat",
51
+ "Severity",
52
+ "Vulnerability",
53
+ "__version__",
54
+ "compute_exit_code",
55
+ "is_pe_file",
56
+ "parse_pe_binary",
57
+ "run_audit",
58
+ ]
rhada/auditor.py ADDED
@@ -0,0 +1,145 @@
1
+ """Core orchestrator for Rhada source-workspace and compiled-distribution audits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ from rhada.compliance import evaluate_compliance, write_third_party_notices
8
+ from rhada.dist import correlate_workspace_and_dist, inspect_compiled_distribution
9
+ from rhada.models import (
10
+ AuditConfig,
11
+ AuditMode,
12
+ AuditReport,
13
+ BundlerType,
14
+ DistributionLayout,
15
+ ExitCode,
16
+ )
17
+ from rhada.osv import OsvClient
18
+ from rhada.reporter import write_json_report
19
+ from rhada.sbom import write_sbom
20
+ from rhada.workspace import inspect_workspace
21
+
22
+
23
+ def compute_exit_code(report: AuditReport, config: AuditConfig) -> ExitCode:
24
+ """
25
+ Determine the standardised CLI exit code based on policy thresholds:
26
+ - 0: OK
27
+ - 1: VULNERABILITY_FAILURE
28
+ - 2: LICENSE_FAILURE
29
+ - 3: COMBINED_POLICY_FAILURE (both vulnerability and licence failures)
30
+ - 4: LINKAGE_FAILURE (when fail_on_linkage is enabled and linkage is not self-contained)
31
+ """
32
+ has_vuln_failure = any(
33
+ v.severity.meets_threshold(config.fail_on_severity) for v in report.all_vulnerabilities
34
+ )
35
+ has_license_failure = any(
36
+ f.severity.meets_threshold(config.fail_on_license) for f in report.compliance_findings
37
+ )
38
+
39
+ if has_vuln_failure and has_license_failure:
40
+ return ExitCode.COMBINED_POLICY_FAILURE
41
+ if has_vuln_failure:
42
+ return ExitCode.VULNERABILITY_FAILURE
43
+ if has_license_failure:
44
+ return ExitCode.LICENSE_FAILURE
45
+
46
+ if (
47
+ config.fail_on_linkage
48
+ and report.linkage_report is not None
49
+ and not report.linkage_report.is_self_contained
50
+ ):
51
+ return ExitCode.LINKAGE_FAILURE
52
+
53
+ return ExitCode.OK
54
+
55
+
56
+ def run_audit(
57
+ config: AuditConfig,
58
+ http_client: httpx.Client | None = None,
59
+ ) -> AuditReport:
60
+ """
61
+ Execute a full Rhada compliance, SBOM, binary linkage, and security audit.
62
+ - When `config.dist_path` is None: runs in Source-Only mode against `config.project_path`.
63
+ - When `config.dist_path` is provided: inspects the compiled Nuitka/PyInstaller distribution,
64
+ correlates bundled modules with workspace metadata, verifies ELF linkage, and audits
65
+ both Python and native components.
66
+ """
67
+ project_path = config.project_path.resolve()
68
+ ws = inspect_workspace(project_path, include_dev=config.include_dev)
69
+
70
+ if config.dist_path is None:
71
+ # Source-Only Mode
72
+ active = [c for c in ws.components.values() if c.is_bundled]
73
+ excluded = [c for c in ws.components.values() if not c.is_bundled]
74
+ linkage_rep = None
75
+ if ws.elf_binaries:
76
+ from rhada.elf import verify_binary_linkage
77
+
78
+ linkage_rep = verify_binary_linkage(ws.elf_binaries, dist_root=None)
79
+ report = AuditReport(
80
+ target_name=ws.project_name,
81
+ target_version=ws.project_version,
82
+ mode=AuditMode.SOURCE,
83
+ bundler_type=BundlerType.NONE,
84
+ layout=DistributionLayout.SOURCE_WORKSPACE,
85
+ project_path=str(project_path),
86
+ dist_path=None,
87
+ components=active,
88
+ excluded_components=excluded,
89
+ linkage_report=linkage_rep,
90
+ )
91
+ else:
92
+ # Compiled Distribution Audit Mode
93
+ dist_path = config.dist_path.resolve()
94
+ dist_res = inspect_compiled_distribution(
95
+ dist_path,
96
+ compilation_report_path=config.compilation_report_path,
97
+ )
98
+ active, excluded = correlate_workspace_and_dist(
99
+ ws,
100
+ dist_res,
101
+ include_dev=config.include_dev,
102
+ )
103
+ target_name = ws.project_name if ws.project_name != "unknown-project" else dist_path.stem
104
+ report = AuditReport(
105
+ target_name=target_name,
106
+ target_version=ws.project_version,
107
+ mode=AuditMode.COMPILED_DIST,
108
+ bundler_type=dist_res.bundler_type,
109
+ layout=dist_res.layout,
110
+ project_path=str(project_path),
111
+ dist_path=str(dist_path),
112
+ components=active,
113
+ excluded_components=excluded,
114
+ linkage_report=dist_res.linkage_report,
115
+ warnings=list(dist_res.warnings),
116
+ )
117
+
118
+ # Evaluate legal & licensing compliance
119
+ report.compliance_findings = evaluate_compliance(
120
+ report.components,
121
+ mode=report.mode,
122
+ layout=report.layout,
123
+ config=config,
124
+ )
125
+
126
+ # Check security vulnerabilities via OSV.dev unless offline
127
+ if not config.offline:
128
+ osv_client = OsvClient(timeout=config.osv_timeout, http_client=http_client)
129
+ osv_warnings = osv_client.audit_components(report.components)
130
+ report.warnings.extend(osv_warnings)
131
+
132
+ # Compute exit code
133
+ report.exit_code = compute_exit_code(report, config)
134
+
135
+ # Generate requested output artefacts
136
+ if config.sbom_output is not None:
137
+ write_sbom(report, config.sbom_output, fmt=config.sbom_format)
138
+
139
+ if config.notices_output is not None:
140
+ write_third_party_notices(report, config.notices_output)
141
+
142
+ if config.report_output is not None:
143
+ write_json_report(report, config.report_output)
144
+
145
+ return report
rhada/cli.py ADDED
@@ -0,0 +1,392 @@
1
+ """Command-line interface for Rhada compliance, SBOM, binary linkage, and security auditing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from typer.core import TyperGroup
14
+
15
+ from rhada import __version__
16
+ from rhada.auditor import run_audit
17
+ from rhada.compliance import generate_third_party_notices, write_third_party_notices
18
+ from rhada.elf import is_binary_file, parse_binary, verify_binary_linkage
19
+ from rhada.models import AuditConfig, ConsoleFormat, ExitCode, SbomFormat, Severity
20
+ from rhada.reporter import render_console_report
21
+ from rhada.sbom import serialize_sbom, write_sbom
22
+
23
+
24
+ def _configure_streams() -> None:
25
+ """Ensure standard output and standard error streams use UTF-8 encoding on Windows."""
26
+ for stream in (sys.stdout, sys.stderr):
27
+ if hasattr(stream, "reconfigure"):
28
+ with contextlib.suppress(AttributeError, OSError, ValueError):
29
+ stream.reconfigure(encoding="utf-8", errors="replace")
30
+
31
+
32
+ _configure_streams()
33
+
34
+
35
+ def _safe_echo_text(text: str, *, err: bool = False, nl: bool = True) -> None:
36
+ """Echo text to stdout/stderr with UTF-8 fallback to prevent Windows charmap/cp1252 encode errors."""
37
+ target_stream = sys.stderr if err else sys.stdout
38
+ try:
39
+ typer.echo(text, err=err, nl=nl)
40
+ except UnicodeEncodeError:
41
+ raw_bytes = (text + ("\n" if nl else "")).encode("utf-8", errors="replace")
42
+ if hasattr(target_stream, "buffer"):
43
+ target_stream.buffer.write(raw_bytes)
44
+ target_stream.buffer.flush()
45
+ else:
46
+ encoding = getattr(target_stream, "encoding", "utf-8") or "utf-8"
47
+ fallback_text = raw_bytes.decode(encoding, errors="replace")
48
+ typer.echo(fallback_text, err=err, nl=False)
49
+
50
+
51
+ class DefaultToAuditGroup(TyperGroup):
52
+ """Typer group that defaults to the 'audit' command when invoked with flags directly."""
53
+
54
+ def parse_args(self, ctx: typer.Context, args: list[str]) -> list[str]:
55
+ if args and args[0] not in self.commands and args[0] not in ("--help", "-h", "--version"):
56
+ args = ["audit", *args]
57
+ elif not args:
58
+ args = ["audit"]
59
+ return super().parse_args(ctx, args)
60
+
61
+
62
+ app = typer.Typer(
63
+ cls=DefaultToAuditGroup,
64
+ name="rhada",
65
+ help="Rhada: Legal compliance, SBOM generation, binary linkage, and security auditor.",
66
+ context_settings={"help_option_names": ["-h", "--help"]},
67
+ add_completion=False,
68
+ no_args_is_help=False,
69
+ )
70
+
71
+ # Alias retained for compatibility with test runners and external callers
72
+ cli = app
73
+
74
+
75
+ def _version_callback(value: bool) -> None:
76
+ if value:
77
+ typer.echo(f"rhada, version {__version__}")
78
+ raise typer.Exit()
79
+
80
+
81
+ def _split_csv_values(values: list[str] | None) -> set[str]:
82
+ result: set[str] = set()
83
+ if not values:
84
+ return result
85
+ for item in values:
86
+ for part in item.split(","):
87
+ cleaned = part.strip()
88
+ if cleaned:
89
+ result.add(cleaned)
90
+ return result
91
+
92
+
93
+ @app.callback()
94
+ def main_callback(
95
+ version: Annotated[
96
+ bool,
97
+ typer.Option(
98
+ "--version",
99
+ callback=_version_callback,
100
+ is_eager=True,
101
+ help="Show the version and exit.",
102
+ ),
103
+ ] = False,
104
+ ) -> None:
105
+ """Rhada: Legal compliance, SBOM generation, binary linkage, and security auditor."""
106
+
107
+
108
+ @app.command("audit")
109
+ def audit_cmd(
110
+ project_path: Annotated[
111
+ Path,
112
+ typer.Option(
113
+ "--project",
114
+ "-p",
115
+ help="Path to the Python or uv project workspace root.",
116
+ ),
117
+ ] = Path("."),
118
+ dist_path: Annotated[
119
+ Path | None,
120
+ typer.Option(
121
+ "--dist",
122
+ "-d",
123
+ help="Path to a compiled Nuitka or PyInstaller distribution (directory or monolithic binary).",
124
+ ),
125
+ ] = None,
126
+ compilation_report: Annotated[
127
+ Path | None,
128
+ typer.Option(
129
+ "--compilation-report",
130
+ help="Optional path to a Nuitka compilation-report.xml file.",
131
+ ),
132
+ ] = None,
133
+ include_dev: Annotated[
134
+ bool,
135
+ typer.Option(
136
+ "--include-dev",
137
+ help="Include development and build dependencies in source audit mode.",
138
+ ),
139
+ ] = False,
140
+ offline: Annotated[
141
+ bool,
142
+ typer.Option(
143
+ "--offline",
144
+ help="Skip online OSV.dev vulnerability lookups.",
145
+ ),
146
+ ] = False,
147
+ sbom_output: Annotated[
148
+ Path | None,
149
+ typer.Option(
150
+ "--sbom",
151
+ help="Write a machine-readable SBOM JSON file to the specified path.",
152
+ ),
153
+ ] = None,
154
+ sbom_format: Annotated[
155
+ SbomFormat,
156
+ typer.Option(
157
+ "--sbom-format",
158
+ case_sensitive=False,
159
+ help="SBOM standard format.",
160
+ ),
161
+ ] = SbomFormat.CYCLONEDX,
162
+ notices_output: Annotated[
163
+ Path | None,
164
+ typer.Option(
165
+ "--notices",
166
+ help="Write a THIRD_PARTY_NOTICES attribution file to the specified path.",
167
+ ),
168
+ ] = None,
169
+ report_output: Annotated[
170
+ Path | None,
171
+ typer.Option(
172
+ "--report-json",
173
+ "--output",
174
+ "-o",
175
+ help="Write the full machine-readable JSON audit report to disk.",
176
+ ),
177
+ ] = None,
178
+ console_format: Annotated[
179
+ ConsoleFormat,
180
+ typer.Option(
181
+ "--format",
182
+ case_sensitive=False,
183
+ help="Standard output format.",
184
+ ),
185
+ ] = ConsoleFormat.TABLE,
186
+ fail_on_severity: Annotated[
187
+ Severity,
188
+ typer.Option(
189
+ "--fail-on-severity",
190
+ case_sensitive=False,
191
+ help="Minimum vulnerability severity that triggers a non-zero exit code.",
192
+ ),
193
+ ] = Severity.HIGH,
194
+ fail_on_license: Annotated[
195
+ Severity,
196
+ typer.Option(
197
+ "--fail-on-license",
198
+ "--fail-on-licence",
199
+ case_sensitive=False,
200
+ help="Minimum licence compliance finding severity that triggers a non-zero exit code.",
201
+ ),
202
+ ] = Severity.HIGH,
203
+ fail_on_linkage: Annotated[
204
+ bool,
205
+ typer.Option(
206
+ "--fail-on-linkage",
207
+ help="Fail with exit code 4 if unresolved ELF/PE binary dependencies or insecure RPATHs exist.",
208
+ ),
209
+ ] = False,
210
+ allow_unknown_license: Annotated[
211
+ bool,
212
+ typer.Option(
213
+ "--allow-unknown-license",
214
+ "--allow-unknown-licence",
215
+ help="Downgrade unknown or unasserted licence findings to LOW severity.",
216
+ ),
217
+ ] = False,
218
+ allowed_licenses: Annotated[
219
+ list[str] | None,
220
+ typer.Option(
221
+ "--allow-license",
222
+ "--allow-licence",
223
+ help="Approved SPDX licence IDs (repeatable or comma-separated).",
224
+ ),
225
+ ] = None,
226
+ denied_licenses: Annotated[
227
+ list[str] | None,
228
+ typer.Option(
229
+ "--deny-license",
230
+ "--deny-licence",
231
+ help="Forbidden SPDX licence IDs (repeatable or comma-separated).",
232
+ ),
233
+ ] = None,
234
+ ) -> None:
235
+ """Run a legal compliance, SBOM, binary linkage, and security audit."""
236
+ if not project_path.exists():
237
+ typer.echo(f"Error: Project path does not exist: {project_path}", err=True)
238
+ raise typer.Exit(code=int(ExitCode.USAGE_OR_IO_ERROR))
239
+ if dist_path is not None and not dist_path.exists():
240
+ typer.echo(f"Error: Distribution path does not exist: {dist_path}", err=True)
241
+ raise typer.Exit(code=int(ExitCode.USAGE_OR_IO_ERROR))
242
+
243
+ config = AuditConfig(
244
+ project_path=project_path,
245
+ dist_path=dist_path,
246
+ compilation_report_path=compilation_report,
247
+ include_dev=include_dev,
248
+ offline=offline,
249
+ fail_on_severity=fail_on_severity,
250
+ fail_on_license=fail_on_license,
251
+ fail_on_linkage=fail_on_linkage,
252
+ allow_unknown_license=allow_unknown_license,
253
+ allowed_licenses=_split_csv_values(allowed_licenses),
254
+ denied_licenses=_split_csv_values(denied_licenses),
255
+ sbom_output=sbom_output,
256
+ sbom_format=sbom_format.value,
257
+ notices_output=notices_output,
258
+ report_output=report_output,
259
+ )
260
+
261
+ try:
262
+ report = run_audit(config)
263
+ except (OSError, ValueError, RuntimeError) as exc:
264
+ typer.echo(f"Error executing audit: {exc}", err=True)
265
+ raise typer.Exit(code=int(ExitCode.USAGE_OR_IO_ERROR)) from exc
266
+
267
+ if console_format == ConsoleFormat.JSON:
268
+ _safe_echo_text(json.dumps(report.to_dict(), indent=2))
269
+ else:
270
+ render_console_report(report, console=Console(safe_box=True))
271
+
272
+ raise typer.Exit(code=int(report.exit_code))
273
+
274
+
275
+ @app.command("sbom")
276
+ def sbom_cmd(
277
+ project_path: Annotated[
278
+ Path,
279
+ typer.Option("--project", "-p", help="Path to the Python or uv project workspace root."),
280
+ ] = Path("."),
281
+ dist_path: Annotated[
282
+ Path | None,
283
+ typer.Option("--dist", "-d", help="Path to a compiled distribution directory or binary."),
284
+ ] = None,
285
+ sbom_format: Annotated[
286
+ SbomFormat,
287
+ typer.Option("--format", "-f", case_sensitive=False, help="SBOM standard format."),
288
+ ] = SbomFormat.CYCLONEDX,
289
+ output_path: Annotated[
290
+ Path | None,
291
+ typer.Option("--output", "-o", help="Output file path (writes to stdout if omitted)."),
292
+ ] = None,
293
+ offline: Annotated[
294
+ bool,
295
+ typer.Option("--offline", help="Skip OSV vulnerability enrichment."),
296
+ ] = False,
297
+ ) -> None:
298
+ """Generate a CycloneDX 1.5 or SPDX 2.3 SBOM for a source workspace or compiled distribution."""
299
+ if not project_path.exists() or (dist_path is not None and not dist_path.exists()):
300
+ typer.echo("Error: Specified project or dist path does not exist.", err=True)
301
+ raise typer.Exit(code=int(ExitCode.USAGE_OR_IO_ERROR))
302
+
303
+ config = AuditConfig(
304
+ project_path=project_path,
305
+ dist_path=dist_path,
306
+ offline=offline,
307
+ sbom_format=sbom_format.value,
308
+ )
309
+ try:
310
+ report = run_audit(config)
311
+ if output_path is not None:
312
+ write_sbom(report, output_path, fmt=sbom_format.value)
313
+ typer.echo(f"SBOM ({sbom_format.value}) written to {output_path}")
314
+ else:
315
+ _safe_echo_text(serialize_sbom(report, fmt=sbom_format.value), nl=False)
316
+ except (OSError, ValueError, RuntimeError) as exc:
317
+ typer.echo(f"Error generating SBOM: {exc}", err=True)
318
+ raise typer.Exit(code=int(ExitCode.USAGE_OR_IO_ERROR)) from exc
319
+
320
+
321
+ @app.command("notices")
322
+ def notices_cmd(
323
+ project_path: Annotated[
324
+ Path,
325
+ typer.Option("--project", "-p", help="Path to the Python or uv project workspace root."),
326
+ ] = Path("."),
327
+ dist_path: Annotated[
328
+ Path | None,
329
+ typer.Option("--dist", "-d", help="Path to a compiled distribution directory or binary."),
330
+ ] = None,
331
+ output_path: Annotated[
332
+ Path | None,
333
+ typer.Option("--output", "-o", help="Write THIRD_PARTY_NOTICES to a file instead of stdout."),
334
+ ] = None,
335
+ ) -> None:
336
+ """Generate a THIRD_PARTY_NOTICES attribution file."""
337
+ if not project_path.exists() or (dist_path is not None and not dist_path.exists()):
338
+ typer.echo("Error: Specified project or dist path does not exist.", err=True)
339
+ raise typer.Exit(code=int(ExitCode.USAGE_OR_IO_ERROR))
340
+
341
+ config = AuditConfig(
342
+ project_path=project_path,
343
+ dist_path=dist_path,
344
+ offline=True,
345
+ )
346
+ try:
347
+ report = run_audit(config)
348
+ if output_path is not None:
349
+ write_third_party_notices(report, output_path)
350
+ typer.echo(f"THIRD_PARTY_NOTICES written to {output_path}")
351
+ else:
352
+ _safe_echo_text(generate_third_party_notices(report), nl=False)
353
+ except (OSError, ValueError, RuntimeError) as exc:
354
+ typer.echo(f"Error generating THIRD_PARTY_NOTICES: {exc}", err=True)
355
+ raise typer.Exit(code=int(ExitCode.USAGE_OR_IO_ERROR)) from exc
356
+
357
+
358
+ @app.command("inspect-elf")
359
+ @app.command("inspect-binary")
360
+ def inspect_elf_cmd(
361
+ target: Annotated[
362
+ Path,
363
+ typer.Argument(help="Target ELF/PE binary (.so, .dll, .pyd, .exe) or distribution directory to inspect."),
364
+ ],
365
+ ) -> None:
366
+ """Inspect ELF and Windows PE binaries and verify dynamic linkage in a file or directory."""
367
+ if not target.exists():
368
+ typer.echo(f"Error: Target path does not exist: {target}", err=True)
369
+ raise typer.Exit(code=int(ExitCode.USAGE_OR_IO_ERROR))
370
+
371
+ binaries = []
372
+ if target.is_file():
373
+ if not is_binary_file(target):
374
+ typer.echo(f"Error: {target} is not a valid ELF or PE binary.", err=True)
375
+ raise typer.Exit(code=int(ExitCode.USAGE_OR_IO_ERROR))
376
+ binaries.append(parse_binary(target))
377
+ linkage = verify_binary_linkage(binaries, dist_root=None)
378
+ else:
379
+ binaries.extend(parse_binary(p) for p in sorted(target.rglob("*")) if is_binary_file(p))
380
+ linkage = verify_binary_linkage(binaries, dist_root=target)
381
+
382
+ _safe_echo_text(json.dumps(linkage.to_dict(), indent=2))
383
+
384
+
385
+ def main() -> None:
386
+ """Entrypoint for the rhada CLI."""
387
+ _configure_streams()
388
+ app()
389
+
390
+
391
+ if __name__ == "__main__":
392
+ main()