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.
- github_license_scanner-0.1.0.dist-info/METADATA +307 -0
- github_license_scanner-0.1.0.dist-info/RECORD +24 -0
- github_license_scanner-0.1.0.dist-info/WHEEL +4 -0
- github_license_scanner-0.1.0.dist-info/entry_points.txt +3 -0
- github_license_scanner-0.1.0.dist-info/licenses/LICENSE +21 -0
- gls/__init__.py +1 -0
- gls/auth.py +229 -0
- gls/cli.py +358 -0
- gls/config.py +161 -0
- gls/dependency_scanner.py +621 -0
- gls/deploy_advisor.py +286 -0
- gls/docs/LEGAL_DISCLAIMER.md +57 -0
- gls/docs/PRIVACY.md +69 -0
- gls/docs/TERMS.md +52 -0
- gls/github_api.py +514 -0
- gls/history_store.py +177 -0
- gls/i18n.py +530 -0
- gls/license_analyzer.py +855 -0
- gls/models.py +148 -0
- gls/rate_limit.py +64 -0
- gls/report.py +150 -0
- gls/sbom_export.py +312 -0
- gls/spdx_engine.py +441 -0
- gls/webui.py +1828 -0
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)")
|