github-license-scanner 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.
gls/models.py ADDED
@@ -0,0 +1,148 @@
1
+ """
2
+ Shared data models for the GitHub License Scanner.
3
+
4
+ All modules import dataclasses from here to avoid circular dependencies
5
+ and keep a single source of truth for scan-related structures.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field, asdict
11
+ from typing import Any
12
+
13
+
14
+ @dataclass
15
+ class Dependency:
16
+ """A single package declared in a dependency manifest file."""
17
+
18
+ name: str
19
+ version_spec: str | None
20
+ ecosystem: str # npm | pypi | cargo | go | rubygems | composer | maven | gradle
21
+ source_file: str # path inside the repository, e.g. "package.json"
22
+ is_dev: bool = False # True for dev/test/build-only dependencies
23
+
24
+
25
+ @dataclass
26
+ class PackageLicense:
27
+ """License information resolved for one dependency."""
28
+
29
+ name: str
30
+ ecosystem: str
31
+ license_id: str | None # SPDX-like id or raw registry string
32
+ risk: str # permissive | weak_copyleft | strong_copyleft | unknown
33
+ source_file: str
34
+ license_url: str | None = None
35
+ version_spec: str | None = None
36
+ is_dev: bool = False
37
+
38
+ def to_dict(self) -> dict[str, Any]:
39
+ return asdict(self)
40
+
41
+
42
+ @dataclass
43
+ class ReplacementSuggestion:
44
+ """Suggested permissive alternative for a problematic package."""
45
+
46
+ package: str
47
+ ecosystem: str
48
+ license_id: str | None
49
+ alternatives: list[str]
50
+ note: str
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ return asdict(self)
54
+
55
+
56
+ @dataclass
57
+ class DeployAdvice:
58
+ """One deploy platform recommendation with rationale."""
59
+
60
+ platform: str
61
+ score: int # higher is better match
62
+ reasons: list[str]
63
+ docs_url: str
64
+
65
+ def to_dict(self) -> dict[str, Any]:
66
+ return asdict(self)
67
+
68
+
69
+ @dataclass
70
+ class ScanResult:
71
+ """Full analysis result for a single GitHub repository."""
72
+
73
+ owner: str
74
+ repo: str
75
+ url: str
76
+ repo_license: str | None
77
+ packages: list[PackageLicense] = field(default_factory=list)
78
+ # license_id -> list of packages using that license
79
+ grouped: dict[str, list[PackageLicense]] = field(default_factory=dict)
80
+ can_sell_closed: bool = True
81
+ forces_open_source: bool = False
82
+ has_weak_copyleft: bool = False
83
+ has_unknown_licenses: bool = False
84
+ verdict_summary: str = ""
85
+ copyright_notice: str = ""
86
+ replacements: list[ReplacementSuggestion] = field(default_factory=list)
87
+ deploy_advice: list[DeployAdvice] = field(default_factory=list)
88
+ scanned_at: str = ""
89
+ errors: list[str] = field(default_factory=list)
90
+ # False when GitHub/network failed before a reliable verdict
91
+ scan_complete: bool = True
92
+ # Extra metadata used by deploy advisor / UI
93
+ primary_language: str | None = None
94
+ description: str | None = None
95
+ topics: list[str] = field(default_factory=list)
96
+ dependency_files: list[str] = field(default_factory=list)
97
+ has_dockerfile: bool = False
98
+ # Risk score 0 (safest) … 100 (highest copyleft / uncertainty pressure)
99
+ risk_score: int = 0
100
+ risk_score_label: str = "n/a"
101
+ # Counts after prod/dev split (filled by analyzer)
102
+ prod_package_count: int = 0
103
+ dev_package_count: int = 0
104
+ # Strong copyleft only in dev deps (warning, does not force open by default)
105
+ strong_copyleft_dev_only: bool = False
106
+ # True when AGPL/network-copyleft signals were detected (extra SaaS caution)
107
+ has_network_copyleft: bool = False
108
+
109
+ def to_history_entry(self) -> dict[str, Any]:
110
+ """Compact dict suitable for history.json persistence."""
111
+ return {
112
+ "owner": self.owner,
113
+ "repo": self.repo,
114
+ "url": self.url,
115
+ "repo_license": self.repo_license,
116
+ "scanned_at": self.scanned_at,
117
+ "can_sell_closed": self.can_sell_closed,
118
+ "forces_open_source": self.forces_open_source,
119
+ "scan_complete": self.scan_complete,
120
+ "package_count": len(self.packages),
121
+ "risk_score": self.risk_score,
122
+ "has_network_copyleft": self.has_network_copyleft,
123
+ # Truncate free text for privacy / storage hygiene
124
+ "verdict_summary": (self.verdict_summary or "")[:500],
125
+ }
126
+
127
+ def risk_counts(self, *, prod_only: bool = False) -> dict[str, int]:
128
+ """Count packages by risk category."""
129
+ counts = {
130
+ "permissive": 0,
131
+ "weak_copyleft": 0,
132
+ "strong_copyleft": 0,
133
+ "unknown": 0,
134
+ }
135
+ for pkg in self.packages:
136
+ if prod_only and pkg.is_dev:
137
+ continue
138
+ key = pkg.risk if pkg.risk in counts else "unknown"
139
+ counts[key] += 1
140
+ return counts
141
+
142
+ @property
143
+ def prod_packages(self) -> list[PackageLicense]:
144
+ return [p for p in self.packages if not p.is_dev]
145
+
146
+ @property
147
+ def dev_packages(self) -> list[PackageLicense]:
148
+ return [p for p in self.packages if p.is_dev]
gls/rate_limit.py ADDED
@@ -0,0 +1,64 @@
1
+ """
2
+ Simple in-process sliding-window rate limiter.
3
+
4
+ Used to reduce abuse of the scan pipeline (GitHub API + registries) when the
5
+ web UI is exposed beyond localhost. Not a substitute for reverse-proxy limits
6
+ (nginx, Cloudflare, etc.) in production.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ import time
13
+ from collections import defaultdict, deque
14
+
15
+
16
+ class SlidingWindowRateLimiter:
17
+ """Thread-safe sliding window counter keyed by client id."""
18
+
19
+ def __init__(self, max_calls: int, window_seconds: float) -> None:
20
+ if max_calls < 1:
21
+ raise ValueError("max_calls must be >= 1")
22
+ if window_seconds <= 0:
23
+ raise ValueError("window_seconds must be > 0")
24
+ self.max_calls = max_calls
25
+ self.window_seconds = float(window_seconds)
26
+ self._events: dict[str, deque[float]] = defaultdict(deque)
27
+ self._lock = threading.Lock()
28
+
29
+ def _prune(self, key: str, now: float) -> None:
30
+ q = self._events[key]
31
+ cutoff = now - self.window_seconds
32
+ while q and q[0] < cutoff:
33
+ q.popleft()
34
+ if not q and key in self._events:
35
+ # Keep empty deques small by deleting idle keys occasionally
36
+ pass
37
+
38
+ def allow(self, key: str) -> bool:
39
+ """Return True and record a call if under the limit."""
40
+ now = time.monotonic()
41
+ with self._lock:
42
+ self._prune(key, now)
43
+ q = self._events[key]
44
+ if len(q) >= self.max_calls:
45
+ return False
46
+ q.append(now)
47
+ return True
48
+
49
+ def remaining(self, key: str) -> int:
50
+ """How many calls remain in the current window for this key."""
51
+ now = time.monotonic()
52
+ with self._lock:
53
+ self._prune(key, now)
54
+ return max(0, self.max_calls - len(self._events[key]))
55
+
56
+ def retry_after_seconds(self, key: str) -> float:
57
+ """Seconds until the oldest event exits the window (0 if allowed)."""
58
+ now = time.monotonic()
59
+ with self._lock:
60
+ self._prune(key, now)
61
+ q = self._events[key]
62
+ if len(q) < self.max_calls:
63
+ return 0.0
64
+ return max(0.0, (q[0] + self.window_seconds) - now)
gls/report.py ADDED
@@ -0,0 +1,150 @@
1
+ """
2
+ Markdown report export for scan results.
3
+
4
+ Used by the web UI (download / copy) and the CLI (`scan --markdown`).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .models import ScanResult
10
+
11
+
12
+ def render_markdown_report(result: ScanResult) -> str:
13
+ """Build a full Markdown compliance-style report from a ScanResult."""
14
+ lines: list[str] = []
15
+ title = f"{result.owner}/{result.repo}" if result.owner else result.url
16
+ lines.append(f"# License scan: {title}")
17
+ lines.append("")
18
+ lines.append(f"- **URL:** {result.url or 'n/a'}")
19
+ lines.append(f"- **Scanned at:** {result.scanned_at or 'n/a'}")
20
+ lines.append(f"- **Repo license:** {result.repo_license or 'Unknown'}")
21
+ lines.append(f"- **Primary language:** {result.primary_language or 'n/a'}")
22
+ lines.append(
23
+ f"- **Risk score:** {result.risk_score}/100 ({result.risk_score_label})"
24
+ )
25
+ lines.append(
26
+ f"- **Scan complete:** {'Yes' if result.scan_complete else 'No (incomplete)'}"
27
+ )
28
+ lines.append(
29
+ f"- **Can sell closed-source (heuristic):** "
30
+ f"{'Possibly (with caveats)' if result.can_sell_closed and result.scan_complete else 'No / undetermined'}"
31
+ )
32
+ lines.append(
33
+ f"- **Forces open source (heuristic):** {'Yes' if result.forces_open_source else 'No'}"
34
+ )
35
+ if result.has_network_copyleft:
36
+ lines.append(
37
+ "- **Network/SaaS copyleft signal (AGPL/SSPL):** Yes — review carefully"
38
+ )
39
+ if result.strong_copyleft_dev_only:
40
+ lines.append(
41
+ "- **Note:** Strong copyleft found only in dev/test dependencies"
42
+ )
43
+ lines.append(
44
+ f"- **Packages:** {len(result.packages)} "
45
+ f"(prod={result.prod_package_count}, dev={result.dev_package_count})"
46
+ )
47
+ lines.append("")
48
+
49
+ lines.append("## Verdict")
50
+ lines.append("")
51
+ lines.append(result.verdict_summary or "_No verdict._")
52
+ lines.append("")
53
+
54
+ counts = result.risk_counts()
55
+ prod_counts = result.risk_counts(prod_only=True)
56
+ lines.append("## Risk summary")
57
+ lines.append("")
58
+ lines.append("| Category | All | Production only |")
59
+ lines.append("|----------|-----|-----------------|")
60
+ for key in ("permissive", "weak_copyleft", "strong_copyleft", "unknown"):
61
+ lines.append(f"| {key} | {counts[key]} | {prod_counts[key]} |")
62
+ lines.append("")
63
+
64
+ if result.errors:
65
+ lines.append("## Notes / errors")
66
+ lines.append("")
67
+ for err in result.errors:
68
+ lines.append(f"- {err}")
69
+ lines.append("")
70
+
71
+ if result.deploy_advice:
72
+ lines.append("## Deploy recommendations")
73
+ lines.append("")
74
+ for adv in result.deploy_advice:
75
+ lines.append(f"### {adv.platform} (score {adv.score})")
76
+ for reason in adv.reasons:
77
+ lines.append(f"- {reason}")
78
+ lines.append(f"- Docs: {adv.docs_url}")
79
+ lines.append("")
80
+
81
+ if result.replacements:
82
+ lines.append("## Replacement suggestions")
83
+ lines.append("")
84
+ for rep in result.replacements:
85
+ lines.append(
86
+ f"- **{rep.package}** (`{rep.ecosystem}`, {rep.license_id or '?'})"
87
+ )
88
+ for alt in rep.alternatives:
89
+ lines.append(f" - → {alt}")
90
+ if rep.note:
91
+ lines.append(f" - _{rep.note}_")
92
+ lines.append("")
93
+
94
+ lines.append("## Packages by license")
95
+ lines.append("")
96
+ if not result.packages:
97
+ lines.append("_No packages resolved._")
98
+ lines.append("")
99
+ else:
100
+ for lic, pkgs in result.grouped.items():
101
+ lines.append(f"### {lic} ({len(pkgs)})")
102
+ lines.append("")
103
+ lines.append("| Package | Ecosystem | Scope | Risk | Source |")
104
+ lines.append("|---------|-----------|-------|------|--------|")
105
+ for p in sorted(pkgs, key=lambda x: (x.is_dev, x.name.lower())):
106
+ scope = "dev" if p.is_dev else "prod"
107
+ lines.append(
108
+ f"| {p.name} | {p.ecosystem} | {scope} | {p.risk} | "
109
+ f"`{p.source_file}` |"
110
+ )
111
+ lines.append("")
112
+
113
+ if result.dependency_files:
114
+ lines.append("## Dependency files analyzed")
115
+ lines.append("")
116
+ for path in result.dependency_files:
117
+ lines.append(f"- `{path}`")
118
+ lines.append("")
119
+
120
+ lines.append("## Copyright notice")
121
+ lines.append("")
122
+ lines.append("```")
123
+ lines.append((result.copyright_notice or "").rstrip())
124
+ lines.append("```")
125
+ lines.append("")
126
+ lines.append("---")
127
+ lines.append("")
128
+ lines.append("## Legal disclaimer")
129
+ lines.append("")
130
+ lines.append(
131
+ "This report is generated by **automated heuristics** for developer "
132
+ "guidance only. It is **not legal advice**, not a license-compatibility "
133
+ "opinion, and not a warranty of non-infringement or fitness for a "
134
+ "particular purpose. Dual-licensing, linking models, SaaS/network use "
135
+ "(AGPL/SSPL), additional permissions, CLA/DCO overlays, and contractual "
136
+ "terms can change obligations. **Consult a qualified attorney** before "
137
+ "commercial closed-source distribution or reliance on any verdict."
138
+ )
139
+ lines.append("")
140
+ lines.append(
141
+ "Copyright notice templates in this report are **not claims of ownership**. "
142
+ "GitHub account names are not proof of copyright ownership."
143
+ )
144
+ lines.append("")
145
+ lines.append(
146
+ "_Generated by GitHub License Scanner. See docs/LEGAL_DISCLAIMER.md, "
147
+ "docs/PRIVACY.md, and docs/TERMS.md._"
148
+ )
149
+ lines.append("")
150
+ return "\n".join(lines)
gls/sbom_export.py ADDED
@@ -0,0 +1,312 @@
1
+ """
2
+ SBOM export for scan results.
3
+
4
+ Formats:
5
+ - CycloneDX 1.5 JSON (application + library components)
6
+ - SPDX 2.3 JSON document (packages + relationships)
7
+
8
+ These documents are generated from *declared* dependencies and registry
9
+ license metadata. They are not a full binary SBOM from a build graph.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ import uuid
17
+ from datetime import datetime, timezone
18
+ from typing import Any
19
+ from urllib.parse import quote
20
+
21
+ from .models import PackageLicense, ScanResult
22
+ from .spdx_engine import expression_to_spdx_ids
23
+
24
+
25
+ TOOL_NAME = "github-license-scanner"
26
+ TOOL_VERSION = "1.2.0"
27
+
28
+
29
+ def _now_iso() -> str:
30
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
31
+
32
+
33
+ def _purl(pkg: PackageLicense) -> str | None:
34
+ """Best-effort package URL (purl)."""
35
+ name = pkg.name
36
+ ver = (pkg.version_spec or "").lstrip("=^~><! ")
37
+ # Strip complex version ranges for purl version field
38
+ if any(c in ver for c in " ,|"):
39
+ ver = ""
40
+ eco = pkg.ecosystem
41
+ if eco == "npm":
42
+ # scoped: @scope/name
43
+ if name.startswith("@") and "/" in name:
44
+ scope, n = name[1:].split("/", 1)
45
+ base = f"pkg:npm/{quote(scope)}/{quote(n)}"
46
+ else:
47
+ base = f"pkg:npm/{quote(name)}"
48
+ return f"{base}@{quote(ver)}" if ver else base
49
+ if eco == "pypi":
50
+ base = f"pkg:pypi/{quote(name)}"
51
+ return f"{base}@{quote(ver)}" if ver else base
52
+ if eco == "cargo":
53
+ base = f"pkg:cargo/{quote(name)}"
54
+ return f"{base}@{quote(ver)}" if ver else base
55
+ if eco == "go":
56
+ base = f"pkg:golang/{name}" # path may contain /
57
+ return f"{base}@{quote(ver)}" if ver else base
58
+ if eco == "rubygems":
59
+ base = f"pkg:gem/{quote(name)}"
60
+ return f"{base}@{quote(ver)}" if ver else base
61
+ if eco == "composer":
62
+ base = f"pkg:composer/{name}"
63
+ return f"{base}@{quote(ver)}" if ver else base
64
+ if eco in {"maven", "gradle"} and ":" in name:
65
+ group, artifact = name.split(":", 1)
66
+ base = f"pkg:maven/{quote(group)}/{quote(artifact)}"
67
+ return f"{base}@{quote(ver)}" if ver else base
68
+ return None
69
+
70
+
71
+ def _bom_ref(pkg: PackageLicense, idx: int) -> str:
72
+ purl = _purl(pkg)
73
+ if purl:
74
+ return purl
75
+ return f"{pkg.ecosystem}:{pkg.name}#{idx}"
76
+
77
+
78
+ def render_cyclonedx_json(result: ScanResult) -> str:
79
+ """Serialize a ScanResult as CycloneDX 1.5 JSON."""
80
+ serial = f"urn:uuid:{uuid.uuid4()}"
81
+ components: list[dict[str, Any]] = []
82
+ for i, pkg in enumerate(result.packages):
83
+ licenses = []
84
+ if pkg.license_id:
85
+ ids = expression_to_spdx_ids(pkg.license_id)
86
+ if ids:
87
+ for lid in ids:
88
+ licenses.append({"license": {"id": lid}})
89
+ else:
90
+ licenses.append({"license": {"name": pkg.license_id}})
91
+
92
+ comp: dict[str, Any] = {
93
+ "type": "library",
94
+ "bom-ref": _bom_ref(pkg, i),
95
+ "name": pkg.name,
96
+ "purl": _purl(pkg),
97
+ "scope": "optional" if pkg.is_dev else "required",
98
+ "properties": [
99
+ {"name": "gls:ecosystem", "value": pkg.ecosystem},
100
+ {"name": "gls:risk", "value": pkg.risk},
101
+ {"name": "gls:source_file", "value": pkg.source_file},
102
+ ],
103
+ }
104
+ if pkg.version_spec:
105
+ # CycloneDX version is a single version; store range as property
106
+ if re_is_single_version(pkg.version_spec):
107
+ comp["version"] = pkg.version_spec.lstrip("=v")
108
+ else:
109
+ comp["properties"].append(
110
+ {"name": "gls:version_spec", "value": pkg.version_spec}
111
+ )
112
+ if licenses:
113
+ comp["licenses"] = licenses
114
+ if pkg.license_url:
115
+ comp["externalReferences"] = [
116
+ {"type": "website", "url": pkg.license_url}
117
+ ]
118
+ # Drop null purl
119
+ if not comp.get("purl"):
120
+ comp.pop("purl", None)
121
+ components.append(comp)
122
+
123
+ metadata_licenses = []
124
+ if result.repo_license:
125
+ for lid in expression_to_spdx_ids(result.repo_license) or [result.repo_license]:
126
+ metadata_licenses.append({"license": {"id": lid if _looks_spdx(lid) else None, "name": lid}})
127
+ if metadata_licenses[-1]["license"].get("id") is None:
128
+ metadata_licenses[-1] = {"license": {"name": lid}}
129
+
130
+ doc: dict[str, Any] = {
131
+ "bomFormat": "CycloneDX",
132
+ "specVersion": "1.5",
133
+ "serialNumber": serial,
134
+ "version": 1,
135
+ "metadata": {
136
+ "timestamp": _now_iso(),
137
+ "tools": {
138
+ "components": [
139
+ {
140
+ "type": "application",
141
+ "name": TOOL_NAME,
142
+ "version": TOOL_VERSION,
143
+ }
144
+ ]
145
+ },
146
+ "component": {
147
+ "type": "application",
148
+ "name": f"{result.owner}/{result.repo}" if result.owner else (result.url or "unknown"),
149
+ "bom-ref": f"app:{result.owner}/{result.repo}" if result.owner else "app:unknown",
150
+ "purl": (
151
+ f"pkg:github/{result.owner}/{result.repo}"
152
+ if result.owner and result.repo
153
+ else None
154
+ ),
155
+ "licenses": metadata_licenses or None,
156
+ "externalReferences": (
157
+ [{"type": "vcs", "url": result.url}] if result.url else []
158
+ ),
159
+ "properties": [
160
+ {"name": "gls:risk_score", "value": str(result.risk_score)},
161
+ {
162
+ "name": "gls:forces_open_source",
163
+ "value": str(result.forces_open_source).lower(),
164
+ },
165
+ {
166
+ "name": "gls:scan_complete",
167
+ "value": str(result.scan_complete).lower(),
168
+ },
169
+ {
170
+ "name": "gls:disclaimer",
171
+ "value": "Automated heuristic SBOM — not legal advice",
172
+ },
173
+ ],
174
+ },
175
+ },
176
+ "components": components,
177
+ }
178
+ # Clean nulls in component
179
+ meta_comp = doc["metadata"]["component"]
180
+ if not meta_comp.get("purl"):
181
+ meta_comp.pop("purl", None)
182
+ if not meta_comp.get("licenses"):
183
+ meta_comp.pop("licenses", None)
184
+
185
+ return json.dumps(doc, indent=2, ensure_ascii=False)
186
+
187
+
188
+ def re_is_single_version(spec: str) -> bool:
189
+ s = (spec or "").strip().lstrip("=")
190
+ return bool(re.fullmatch(r"v?\d+(?:\.\d+)*(?:[-+][A-Za-z0-9.]+)?", s))
191
+
192
+
193
+ def _looks_spdx(lid: str) -> bool:
194
+ return bool(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.+_-]*", lid or ""))
195
+
196
+
197
+ def render_spdx_json(result: ScanResult) -> str:
198
+ """Serialize a ScanResult as SPDX 2.3 JSON."""
199
+ doc_name = (
200
+ f"SPDXRef-DOCUMENT-{result.owner}-{result.repo}"
201
+ if result.owner
202
+ else "SPDXRef-DOCUMENT"
203
+ )
204
+ created = _now_iso()
205
+ packages: list[dict[str, Any]] = []
206
+
207
+ # Root package = repository
208
+ root_spdx_id = "SPDXRef-Package-Root"
209
+ root_lic = result.repo_license or "NOASSERTION"
210
+ packages.append(
211
+ {
212
+ "SPDXID": root_spdx_id,
213
+ "name": f"{result.owner}/{result.repo}" if result.owner else "root",
214
+ "downloadLocation": result.url or "NOASSERTION",
215
+ "filesAnalyzed": False,
216
+ "licenseConcluded": root_lic if result.repo_license else "NOASSERTION",
217
+ "licenseDeclared": root_lic if result.repo_license else "NOASSERTION",
218
+ "copyrightText": "NOASSERTION",
219
+ "externalRefs": (
220
+ [
221
+ {
222
+ "referenceCategory": "PACKAGE-MANAGER",
223
+ "referenceType": "purl",
224
+ "referenceLocator": f"pkg:github/{result.owner}/{result.repo}",
225
+ }
226
+ ]
227
+ if result.owner
228
+ else []
229
+ ),
230
+ "comment": (
231
+ f"risk_score={result.risk_score}; forces_open={result.forces_open_source}; "
232
+ "generated by github-license-scanner (heuristic, not legal advice)"
233
+ ),
234
+ }
235
+ )
236
+
237
+ relationships = [
238
+ {
239
+ "spdxElementId": "SPDXRef-DOCUMENT",
240
+ "relationshipType": "DESCRIBES",
241
+ "relatedSpdxElement": root_spdx_id,
242
+ }
243
+ ]
244
+
245
+ for i, pkg in enumerate(result.packages):
246
+ spdx_id = f"SPDXRef-Package-{i}"
247
+ lic = pkg.license_id or "NOASSERTION"
248
+ purl = _purl(pkg)
249
+ ext = []
250
+ if purl:
251
+ ext.append(
252
+ {
253
+ "referenceCategory": "PACKAGE-MANAGER",
254
+ "referenceType": "purl",
255
+ "referenceLocator": purl,
256
+ }
257
+ )
258
+ packages.append(
259
+ {
260
+ "SPDXID": spdx_id,
261
+ "name": pkg.name,
262
+ "versionInfo": pkg.version_spec or "NOASSERTION",
263
+ "downloadLocation": pkg.license_url or "NOASSERTION",
264
+ "filesAnalyzed": False,
265
+ "licenseConcluded": lic,
266
+ "licenseDeclared": lic,
267
+ "copyrightText": "NOASSERTION",
268
+ "externalRefs": ext,
269
+ "comment": (
270
+ f"ecosystem={pkg.ecosystem}; risk={pkg.risk}; "
271
+ f"scope={'dev' if pkg.is_dev else 'prod'}; source={pkg.source_file}"
272
+ ),
273
+ }
274
+ )
275
+ relationships.append(
276
+ {
277
+ "spdxElementId": root_spdx_id,
278
+ "relationshipType": "DEPENDS_ON",
279
+ "relatedSpdxElement": spdx_id,
280
+ }
281
+ )
282
+
283
+ doc: dict[str, Any] = {
284
+ "spdxVersion": "SPDX-2.3",
285
+ "dataLicense": "CC0-1.0",
286
+ "SPDXID": "SPDXRef-DOCUMENT",
287
+ "name": doc_name,
288
+ "documentNamespace": f"https://github.com/NezbiT/github-license-scanner/spdx/{uuid.uuid4()}",
289
+ "creationInfo": {
290
+ "created": created,
291
+ "creators": [f"Tool: {TOOL_NAME}-{TOOL_VERSION}", "Organization: local"],
292
+ "licenseListVersion": "3.22",
293
+ "comment": "Automated heuristic export — not legal advice",
294
+ },
295
+ "packages": packages,
296
+ "relationships": relationships,
297
+ }
298
+ return json.dumps(doc, indent=2, ensure_ascii=False)
299
+
300
+
301
+ def render_sbom(result: ScanResult, fmt: str = "cyclonedx") -> str:
302
+ """
303
+ Render SBOM text for the given format.
304
+
305
+ fmt: cyclonedx | spdx | cdx | spdx-json
306
+ """
307
+ key = (fmt or "cyclonedx").lower().strip()
308
+ if key in {"cyclonedx", "cdx", "cyclonedx-json"}:
309
+ return render_cyclonedx_json(result)
310
+ if key in {"spdx", "spdx-json", "spdx23"}:
311
+ return render_spdx_json(result)
312
+ raise ValueError(f"Unknown SBOM format: {fmt!r} (use cyclonedx or spdx)")