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 +58 -0
- rhada/auditor.py +145 -0
- rhada/cli.py +392 -0
- rhada/compliance.py +447 -0
- rhada/dist.py +1131 -0
- rhada/elf.py +1095 -0
- rhada/models.py +504 -0
- rhada/osv.py +397 -0
- rhada/pe.py +459 -0
- rhada/py.typed +1 -0
- rhada/reporter.py +211 -0
- rhada/sbom.py +460 -0
- rhada/spdx.py +1299 -0
- rhada/workspace.py +1260 -0
- rhada-0.0.1.dist-info/METADATA +160 -0
- rhada-0.0.1.dist-info/RECORD +18 -0
- rhada-0.0.1.dist-info/WHEEL +4 -0
- rhada-0.0.1.dist-info/entry_points.txt +2 -0
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()
|