cra-scanner 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: cra-scanner
3
+ Version: 0.1.0
4
+ Summary: Open-source CRA Readiness Scanner CLI for assessing EU Cyber Resilience Act readiness from SBOMs and project signals.
5
+ Author-email: CyberCert <info@cybercert.example>
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: click>=8.1
10
+ Requires-Dist: rich>=13.0
11
+ Requires-Dist: cyclonedx-python-lib>=7.6
12
+ Requires-Dist: spdx-tools>=0.8
13
+
14
+ # CRA Readiness Scanner (MVP)
15
+
16
+ The CRA Readiness Scanner is an open-source CLI tool that helps engineering teams quickly assess their readiness for the EU Cyber Resilience Act (CRA) from a single SBOM or project directory.
17
+
18
+ It focuses on three things:
19
+
20
+ - SBOM presence and basic quality
21
+ - Basic vulnerability exposure (stubbed for MVP)
22
+ - Signals of good vulnerability-handling practices
23
+
24
+ ## Installation
25
+
26
+ Once published to PyPI:
27
+
28
+ ```bash
29
+ pip install cra-scanner
30
+ ```
31
+
32
+ For local development from this repository:
33
+
34
+ ```bash
35
+ cd cli
36
+ pip install -e .
37
+ cra-scanner --help
38
+ ```
39
+
40
+ ## Quick start
41
+
42
+ Scan a project directory (auto-discover SBOMs and signals):
43
+
44
+ ```bash
45
+ cra-scanner scan .
46
+ ```
47
+
48
+ Scan using an explicit SBOM and emit JSON to a file:
49
+
50
+ ```bash
51
+ cra-scanner scan . --sbom path/to/bom.json --format json --output report.json
52
+ ```
53
+
54
+ ## What the CRA Readiness Score means
55
+
56
+ The scanner returns a score from 0–100 based on:
57
+
58
+ - **SBOM (40 pts)** – existence, coverage, presence of versions.
59
+ - **Vulnerabilities (30 pts)** – placeholder in MVP.
60
+ - **Practices (30 pts)** – presence of `SECURITY.md`, Dependabot, and basic documentation signals.
61
+
62
+ The score is a directional indicator, not legal advice. It is intended to highlight gaps and next steps, not certify compliance.
63
+
@@ -0,0 +1,50 @@
1
+ # CRA Readiness Scanner (MVP)
2
+
3
+ The CRA Readiness Scanner is an open-source CLI tool that helps engineering teams quickly assess their readiness for the EU Cyber Resilience Act (CRA) from a single SBOM or project directory.
4
+
5
+ It focuses on three things:
6
+
7
+ - SBOM presence and basic quality
8
+ - Basic vulnerability exposure (stubbed for MVP)
9
+ - Signals of good vulnerability-handling practices
10
+
11
+ ## Installation
12
+
13
+ Once published to PyPI:
14
+
15
+ ```bash
16
+ pip install cra-scanner
17
+ ```
18
+
19
+ For local development from this repository:
20
+
21
+ ```bash
22
+ cd cli
23
+ pip install -e .
24
+ cra-scanner --help
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ Scan a project directory (auto-discover SBOMs and signals):
30
+
31
+ ```bash
32
+ cra-scanner scan .
33
+ ```
34
+
35
+ Scan using an explicit SBOM and emit JSON to a file:
36
+
37
+ ```bash
38
+ cra-scanner scan . --sbom path/to/bom.json --format json --output report.json
39
+ ```
40
+
41
+ ## What the CRA Readiness Score means
42
+
43
+ The scanner returns a score from 0–100 based on:
44
+
45
+ - **SBOM (40 pts)** – existence, coverage, presence of versions.
46
+ - **Vulnerabilities (30 pts)** – placeholder in MVP.
47
+ - **Practices (30 pts)** – presence of `SECURITY.md`, Dependabot, and basic documentation signals.
48
+
49
+ The score is a directional indicator, not legal advice. It is intended to highlight gaps and next steps, not certify compliance.
50
+
@@ -0,0 +1,4 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
5
+
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import click
7
+
8
+ from .scanner import ScanConfig, perform_scan
9
+
10
+
11
+ @click.group()
12
+ @click.version_option()
13
+ def cli() -> None:
14
+ """CRA Readiness Scanner CLI."""
15
+
16
+
17
+ @cli.command("scan")
18
+ @click.argument("project_root", type=click.Path(path_type=Path), default=Path("."))
19
+ @click.option("--sbom", "sbom_path", type=click.Path(path_type=Path), help="Path to an SBOM file.")
20
+ @click.option(
21
+ "--format",
22
+ "output_format",
23
+ type=click.Choice(["text", "json"], case_sensitive=False),
24
+ default="text",
25
+ show_default=True,
26
+ help="Output format.",
27
+ )
28
+ @click.option(
29
+ "--output",
30
+ "output_path",
31
+ type=click.Path(path_type=Path),
32
+ help="Optional path to write JSON report.",
33
+ )
34
+ def scan_command(
35
+ project_root: Path,
36
+ sbom_path: Optional[Path],
37
+ output_format: str,
38
+ output_path: Optional[Path],
39
+ ) -> None:
40
+ """Run CRA readiness scan on a project."""
41
+ config = ScanConfig(
42
+ project_root=project_root,
43
+ sbom_path=sbom_path,
44
+ output_format=output_format,
45
+ output_path=output_path,
46
+ )
47
+ perform_scan(config)
48
+
49
+
50
+ def main() -> None:
51
+ cli(prog_name="cra-scanner")
52
+
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import List, Optional
6
+
7
+ from .sbom import ParsedSbomFile, parse_sbom_file
8
+
9
+
10
+ @dataclass
11
+ class DiscoveryResult:
12
+ project_root: Path
13
+ sbom_found: bool
14
+ sbom_files: List[ParsedSbomFile]
15
+ has_security_md: bool
16
+ has_docs_security_section: bool
17
+ has_dependabot: bool
18
+
19
+
20
+ def discover_project(project_root: Path, explicit_sbom: Optional[Path] = None) -> DiscoveryResult:
21
+ root = project_root.resolve()
22
+
23
+ sbom_files: list[ParsedSbomFile] = []
24
+ if explicit_sbom:
25
+ parsed = parse_sbom_file(explicit_sbom)
26
+ if parsed:
27
+ sbom_files.append(parsed)
28
+ else:
29
+ for path in root.rglob("*"):
30
+ if not path.is_file():
31
+ continue
32
+ if path.suffix.lower() in {".json", ".xml", ".spdx"} and any(
33
+ token in path.name.lower() for token in ("sbom", "bom", "cyclonedx", "spdx")
34
+ ):
35
+ parsed = parse_sbom_file(path)
36
+ if parsed:
37
+ sbom_files.append(parsed)
38
+
39
+ # Simple signals for practices
40
+ has_security_md = (root / "SECURITY.md").is_file()
41
+ has_dependabot = (root / ".github" / "dependabot.yml").is_file()
42
+
43
+ has_docs_security_section = False
44
+ docs_root = root / "docs"
45
+ if docs_root.is_dir():
46
+ for md in docs_root.rglob("*.md"):
47
+ text = md.read_text(encoding="utf-8", errors="ignore").lower()
48
+ if "security" in text or "vulnerability" in text:
49
+ has_docs_security_section = True
50
+ break
51
+
52
+ return DiscoveryResult(
53
+ project_root=root,
54
+ sbom_found=len(sbom_files) > 0,
55
+ sbom_files=sbom_files,
56
+ has_security_md=has_security_md,
57
+ has_docs_security_section=has_docs_security_section,
58
+ has_dependabot=has_dependabot,
59
+ )
60
+
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, Dict
5
+
6
+
7
+ def render_json_report(report: Dict[str, Any]) -> str:
8
+ return json.dumps(report, indent=2, sort_keys=True)
9
+
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict
4
+
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+
8
+ from ..discovery import DiscoveryResult
9
+ from ..scoring import ScoreBreakdown
10
+
11
+
12
+ def render_text_report(report: Dict[str, Any], discovery: DiscoveryResult, scores: ScoreBreakdown) -> str:
13
+ console = Console(record=True)
14
+
15
+ console.rule(f"CRA Readiness Scanner v{report['meta']['scannerVersion']}")
16
+
17
+ console.print(f"[bold]Project:[/bold] {report['meta']['projectPath']}")
18
+
19
+ # SBOM section
20
+ console.print("\n[bold]SBOM Readiness[/bold]")
21
+ if not discovery.sbom_found:
22
+ console.print("No SBOM detected.")
23
+ else:
24
+ table = Table(show_header=True, header_style="bold")
25
+ table.add_column("Path")
26
+ table.add_column("Format")
27
+ table.add_column("Components", justify="right")
28
+ for f in discovery.sbom_files:
29
+ table.add_row(str(f.path), f.format, str(f.component_count))
30
+ console.print(table)
31
+
32
+ # Practices section
33
+ console.print("\n[bold]Vulnerability Handling Practices[/bold]")
34
+ console.print(f"SECURITY.md present: {'yes' if discovery.has_security_md else 'no'}")
35
+ console.print(f"Dependabot configured: {'yes' if discovery.has_dependabot else 'no'}")
36
+ console.print(f"Docs include security section: {'yes' if discovery.has_docs_security_section else 'no'}")
37
+
38
+ # Scores
39
+ console.print("\n[bold]CRA Readiness Score[/bold]")
40
+ console.print(f"Overall: [bold]{scores.overall}/100[/bold]")
41
+ console.print(f"SBOM: {scores.sbom_score}/40")
42
+ console.print(f"Vulnerabilities: {scores.vuln_score}/30")
43
+ console.print(f"Practices: {scores.practices_score}/30")
44
+
45
+ if scores.recommendations:
46
+ console.print("\n[bold]Top Recommendations[/bold]")
47
+ for rec in scores.recommendations:
48
+ console.print(f"- [bold]{rec['title']}[/bold]: {rec['description']} ({rec['craReference']})")
49
+
50
+ console.rule()
51
+
52
+ return console.export_text()
53
+
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import List, Optional
6
+
7
+ import json
8
+
9
+ from cyclonedx.model.bom import Bom
10
+
11
+
12
+ @dataclass
13
+ class ParsedSbomFile:
14
+ path: Path
15
+ format: str
16
+ component_count: int
17
+
18
+
19
+ def _parse_cyclonedx_json(path: Path) -> Optional[ParsedSbomFile]:
20
+ data = json.loads(path.read_text(encoding="utf-8"))
21
+ if data.get("bomFormat") != "CycloneDX":
22
+ return None
23
+ bom = Bom.from_json(data=data)
24
+ count = len(bom.components or [])
25
+ return ParsedSbomFile(path=path, format="cyclonedx_json", component_count=count)
26
+
27
+
28
+ def _parse_cyclonedx_xml(path: Path) -> Optional[ParsedSbomFile]:
29
+ text = path.read_text(encoding="utf-8")
30
+ if "<bom" not in text:
31
+ return None
32
+ bom = Bom.from_xml(data=text)
33
+ count = len(bom.components or [])
34
+ return ParsedSbomFile(path=path, format="cyclonedx_xml", component_count=count)
35
+
36
+
37
+ def _parse_spdx_json(path: Path) -> Optional[ParsedSbomFile]:
38
+ data = json.loads(path.read_text(encoding="utf-8"))
39
+ if not data.get("spdxVersion"):
40
+ return None
41
+ packages = data.get("packages") or []
42
+ return ParsedSbomFile(path=path, format="spdx_json", component_count=len(packages))
43
+
44
+
45
+ def _parse_spdx_tag_value(path: Path) -> Optional[ParsedSbomFile]:
46
+ text = path.read_text(encoding="utf-8", errors="ignore")
47
+ count = 0
48
+ for raw_line in text.splitlines():
49
+ line = raw_line.strip()
50
+ if line.startswith("PackageName:"):
51
+ count += 1
52
+ if count == 0:
53
+ return None
54
+ return ParsedSbomFile(path=path, format="spdx_tag_value", component_count=count)
55
+
56
+
57
+ def parse_sbom_file(path: Path) -> Optional[ParsedSbomFile]:
58
+ suffix = path.suffix.lower()
59
+ try:
60
+ if suffix == ".json":
61
+ # Try CycloneDX then SPDX JSON
62
+ parsed = _parse_cyclonedx_json(path)
63
+ if parsed:
64
+ return parsed
65
+ return _parse_spdx_json(path)
66
+ if suffix == ".xml":
67
+ return _parse_cyclonedx_xml(path)
68
+ if suffix == ".spdx":
69
+ # Tag-value format
70
+ return _parse_spdx_tag_value(path)
71
+ except Exception:
72
+ return None
73
+ return None
74
+
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from . import __version__
9
+ from .discovery import DiscoveryResult, discover_project
10
+ from .reporters.json import render_json_report
11
+ from .reporters.text import render_text_report
12
+ from .scoring import ScoreBreakdown, compute_scores
13
+
14
+
15
+ @dataclass
16
+ class ScanConfig:
17
+ project_root: Path
18
+ sbom_path: Optional[Path]
19
+ output_format: str
20
+ output_path: Optional[Path]
21
+
22
+
23
+ def perform_scan(config: ScanConfig) -> None:
24
+ discovery: DiscoveryResult = discover_project(config.project_root, config.sbom_path)
25
+ scores: ScoreBreakdown = compute_scores(discovery)
26
+
27
+ report = {
28
+ "meta": {
29
+ "scannerVersion": __version__,
30
+ "projectPath": str(config.project_root),
31
+ "timestamp": datetime.now(timezone.utc).isoformat(),
32
+ },
33
+ "scores": {
34
+ "overall": scores.overall,
35
+ "sbom": scores.sbom_score,
36
+ "vulnerabilities": scores.vuln_score,
37
+ "practices": scores.practices_score,
38
+ },
39
+ "sbom": {
40
+ "found": discovery.sbom_found,
41
+ "files": [
42
+ {
43
+ "path": str(f.path),
44
+ "format": f.format,
45
+ "componentCount": f.component_count,
46
+ }
47
+ for f in discovery.sbom_files
48
+ ],
49
+ },
50
+ "signals": {
51
+ "hasSecurityMd": discovery.has_security_md,
52
+ "hasDocsSecuritySection": discovery.has_docs_security_section,
53
+ "hasDependabot": discovery.has_dependabot,
54
+ },
55
+ "recommendations": scores.recommendations,
56
+ }
57
+
58
+ if config.output_format == "json":
59
+ output = render_json_report(report)
60
+ if config.output_path:
61
+ config.output_path.write_text(output, encoding="utf-8")
62
+ else:
63
+ print(output)
64
+ else:
65
+ text = render_text_report(report, discovery, scores)
66
+ print(text)
67
+
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, List
5
+
6
+ from .discovery import DiscoveryResult
7
+
8
+
9
+ @dataclass
10
+ class ScoreBreakdown:
11
+ overall: int
12
+ sbom_score: int
13
+ vuln_score: int
14
+ practices_score: int
15
+ recommendations: List[Dict[str, Any]]
16
+
17
+
18
+ def compute_scores(discovery: DiscoveryResult) -> ScoreBreakdown:
19
+ recommendations: list[dict[str, Any]] = []
20
+
21
+ # SBOM score (40)
22
+ sbom_score = 0
23
+ if discovery.sbom_found:
24
+ sbom_score += 20
25
+ total_components = sum(f.component_count for f in discovery.sbom_files)
26
+ if total_components > 0:
27
+ sbom_score += 10 # placeholder for coverage
28
+ sbom_score += 10 # placeholder for version completeness
29
+ else:
30
+ recommendations.append(
31
+ {
32
+ "id": "sbom_missing",
33
+ "title": "Generate an SBOM for your product",
34
+ "description": "No SBOM was detected. Generate a CycloneDX or SPDX SBOM from your build pipeline.",
35
+ "category": "sbom",
36
+ "craReference": "Annex I, Section 2 – Vulnerability handling requirements",
37
+ }
38
+ )
39
+
40
+ # Vulnerability hygiene score (30) – placeholder mid-score for now
41
+ vuln_score = 15
42
+
43
+ # Practices score (30)
44
+ practices_score = 0
45
+ if discovery.has_security_md:
46
+ practices_score += 10
47
+ else:
48
+ recommendations.append(
49
+ {
50
+ "id": "security_md_missing",
51
+ "title": "Add a SECURITY.md file",
52
+ "description": "Document how users and researchers should report vulnerabilities.",
53
+ "category": "practices",
54
+ "craReference": "Annex I, Section 2 – Vulnerability handling and disclosure",
55
+ }
56
+ )
57
+
58
+ if discovery.has_dependabot:
59
+ practices_score += 10
60
+ else:
61
+ recommendations.append(
62
+ {
63
+ "id": "dependabot_missing",
64
+ "title": "Enable automated dependency updates",
65
+ "description": "Configure Dependabot or a similar tool to keep dependencies up to date.",
66
+ "category": "practices",
67
+ "craReference": "Annex I, Section 1 – Secure development and maintenance",
68
+ }
69
+ )
70
+
71
+ if discovery.has_docs_security_section:
72
+ practices_score += 10
73
+ else:
74
+ recommendations.append(
75
+ {
76
+ "id": "docs_security_section_missing",
77
+ "title": "Document security and vulnerability handling in your docs",
78
+ "description": "Add a section describing how you monitor, remediate, and communicate vulnerabilities.",
79
+ "category": "practices",
80
+ "craReference": "Annex I, Section 2 – Vulnerability handling requirements",
81
+ }
82
+ )
83
+
84
+ overall = sbom_score + vuln_score + practices_score
85
+
86
+ return ScoreBreakdown(
87
+ overall=overall,
88
+ sbom_score=sbom_score,
89
+ vuln_score=vuln_score,
90
+ practices_score=practices_score,
91
+ recommendations=recommendations,
92
+ )
93
+
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: cra-scanner
3
+ Version: 0.1.0
4
+ Summary: Open-source CRA Readiness Scanner CLI for assessing EU Cyber Resilience Act readiness from SBOMs and project signals.
5
+ Author-email: CyberCert <info@cybercert.example>
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: click>=8.1
10
+ Requires-Dist: rich>=13.0
11
+ Requires-Dist: cyclonedx-python-lib>=7.6
12
+ Requires-Dist: spdx-tools>=0.8
13
+
14
+ # CRA Readiness Scanner (MVP)
15
+
16
+ The CRA Readiness Scanner is an open-source CLI tool that helps engineering teams quickly assess their readiness for the EU Cyber Resilience Act (CRA) from a single SBOM or project directory.
17
+
18
+ It focuses on three things:
19
+
20
+ - SBOM presence and basic quality
21
+ - Basic vulnerability exposure (stubbed for MVP)
22
+ - Signals of good vulnerability-handling practices
23
+
24
+ ## Installation
25
+
26
+ Once published to PyPI:
27
+
28
+ ```bash
29
+ pip install cra-scanner
30
+ ```
31
+
32
+ For local development from this repository:
33
+
34
+ ```bash
35
+ cd cli
36
+ pip install -e .
37
+ cra-scanner --help
38
+ ```
39
+
40
+ ## Quick start
41
+
42
+ Scan a project directory (auto-discover SBOMs and signals):
43
+
44
+ ```bash
45
+ cra-scanner scan .
46
+ ```
47
+
48
+ Scan using an explicit SBOM and emit JSON to a file:
49
+
50
+ ```bash
51
+ cra-scanner scan . --sbom path/to/bom.json --format json --output report.json
52
+ ```
53
+
54
+ ## What the CRA Readiness Score means
55
+
56
+ The scanner returns a score from 0–100 based on:
57
+
58
+ - **SBOM (40 pts)** – existence, coverage, presence of versions.
59
+ - **Vulnerabilities (30 pts)** – placeholder in MVP.
60
+ - **Practices (30 pts)** – presence of `SECURITY.md`, Dependabot, and basic documentation signals.
61
+
62
+ The score is a directional indicator, not legal advice. It is intended to highlight gaps and next steps, not certify compliance.
63
+
@@ -0,0 +1,18 @@
1
+ README.md
2
+ pyproject.toml
3
+ cra_scanner/__init__.py
4
+ cra_scanner/__main__.py
5
+ cra_scanner/cli.py
6
+ cra_scanner/discovery.py
7
+ cra_scanner/sbom.py
8
+ cra_scanner/scanner.py
9
+ cra_scanner/scoring.py
10
+ cra_scanner.egg-info/PKG-INFO
11
+ cra_scanner.egg-info/SOURCES.txt
12
+ cra_scanner.egg-info/dependency_links.txt
13
+ cra_scanner.egg-info/entry_points.txt
14
+ cra_scanner.egg-info/requires.txt
15
+ cra_scanner.egg-info/top_level.txt
16
+ cra_scanner/reporters/__init__.py
17
+ cra_scanner/reporters/json.py
18
+ cra_scanner/reporters/text.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cra-scanner = cra_scanner.cli:main
@@ -0,0 +1,4 @@
1
+ click>=8.1
2
+ rich>=13.0
3
+ cyclonedx-python-lib>=7.6
4
+ spdx-tools>=0.8
@@ -0,0 +1 @@
1
+ cra_scanner
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "cra-scanner"
3
+ version = "0.1.0"
4
+ description = "Open-source CRA Readiness Scanner CLI for assessing EU Cyber Resilience Act readiness from SBOMs and project signals."
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "CyberCert", email = "info@cybercert.example" }]
9
+ dependencies = [
10
+ "click>=8.1",
11
+ "rich>=13.0",
12
+ "cyclonedx-python-lib>=7.6",
13
+ "spdx-tools>=0.8",
14
+ ]
15
+
16
+ [project.scripts]
17
+ cra-scanner = "cra_scanner.cli:main"
18
+
19
+ [build-system]
20
+ requires = ["setuptools>=61.0"]
21
+ build-backend = "setuptools.build_meta"
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["."]
25
+ include = ["cra_scanner*"]
26
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+