security-use 0.1.1__py3-none-any.whl → 0.2.9__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.
- security_use/__init__.py +9 -1
- security_use/auth/__init__.py +16 -0
- security_use/auth/client.py +223 -0
- security_use/auth/config.py +177 -0
- security_use/auth/oauth.py +317 -0
- security_use/cli.py +699 -34
- security_use/compliance/__init__.py +10 -0
- security_use/compliance/mapper.py +275 -0
- security_use/compliance/models.py +50 -0
- security_use/dependency_scanner.py +76 -30
- security_use/fixers/iac_fixer.py +173 -95
- security_use/iac/rules/azure.py +246 -0
- security_use/iac/rules/gcp.py +255 -0
- security_use/iac/rules/kubernetes.py +429 -0
- security_use/iac/rules/registry.py +56 -0
- security_use/parsers/__init__.py +18 -0
- security_use/parsers/base.py +2 -0
- security_use/parsers/composer.py +101 -0
- security_use/parsers/conda.py +97 -0
- security_use/parsers/dotnet.py +89 -0
- security_use/parsers/gradle.py +90 -0
- security_use/parsers/maven.py +108 -0
- security_use/parsers/npm.py +196 -0
- security_use/parsers/yarn.py +108 -0
- security_use/reporter.py +29 -1
- security_use/sbom/__init__.py +10 -0
- security_use/sbom/generator.py +340 -0
- security_use/sbom/models.py +40 -0
- security_use/scanner.py +15 -2
- security_use/sensor/__init__.py +125 -0
- security_use/sensor/alert_queue.py +207 -0
- security_use/sensor/config.py +217 -0
- security_use/sensor/dashboard_alerter.py +246 -0
- security_use/sensor/detector.py +415 -0
- security_use/sensor/endpoint_analyzer.py +339 -0
- security_use/sensor/middleware.py +521 -0
- security_use/sensor/models.py +140 -0
- security_use/sensor/webhook.py +227 -0
- security_use-0.2.9.dist-info/METADATA +531 -0
- security_use-0.2.9.dist-info/RECORD +60 -0
- security_use-0.2.9.dist-info/licenses/LICENSE +21 -0
- security_use-0.1.1.dist-info/METADATA +0 -92
- security_use-0.1.1.dist-info/RECORD +0 -30
- {security_use-0.1.1.dist-info → security_use-0.2.9.dist-info}/WHEEL +0 -0
- {security_use-0.1.1.dist-info → security_use-0.2.9.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""npm package.json and package-lock.json parser."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from security_use.parsers.base import Dependency, DependencyParser
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NpmParser(DependencyParser):
|
|
11
|
+
"""Parser for npm package.json files."""
|
|
12
|
+
|
|
13
|
+
def parse(self, content: str) -> list[Dependency]:
|
|
14
|
+
"""Parse npm package.json content.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
content: The package.json file content.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
List of dependencies found.
|
|
21
|
+
"""
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
data = json.loads(content)
|
|
26
|
+
except json.JSONDecodeError:
|
|
27
|
+
return dependencies
|
|
28
|
+
|
|
29
|
+
# Parse regular dependencies
|
|
30
|
+
for name, version in data.get("dependencies", {}).items():
|
|
31
|
+
parsed_version = self._parse_version(version)
|
|
32
|
+
if parsed_version:
|
|
33
|
+
dependencies.append(
|
|
34
|
+
Dependency(
|
|
35
|
+
name=name,
|
|
36
|
+
version=parsed_version,
|
|
37
|
+
version_spec=version,
|
|
38
|
+
ecosystem="npm",
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Parse dev dependencies
|
|
43
|
+
for name, version in data.get("devDependencies", {}).items():
|
|
44
|
+
parsed_version = self._parse_version(version)
|
|
45
|
+
if parsed_version:
|
|
46
|
+
dependencies.append(
|
|
47
|
+
Dependency(
|
|
48
|
+
name=name,
|
|
49
|
+
version=parsed_version,
|
|
50
|
+
version_spec=version,
|
|
51
|
+
ecosystem="npm",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Parse peer dependencies
|
|
56
|
+
for name, version in data.get("peerDependencies", {}).items():
|
|
57
|
+
parsed_version = self._parse_version(version)
|
|
58
|
+
if parsed_version:
|
|
59
|
+
dependencies.append(
|
|
60
|
+
Dependency(
|
|
61
|
+
name=name,
|
|
62
|
+
version=parsed_version,
|
|
63
|
+
version_spec=version,
|
|
64
|
+
ecosystem="npm",
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Parse overrides (may have pinned versions)
|
|
69
|
+
for name, version in data.get("overrides", {}).items():
|
|
70
|
+
if isinstance(version, str):
|
|
71
|
+
parsed_version = self._parse_version(version)
|
|
72
|
+
if parsed_version:
|
|
73
|
+
dependencies.append(
|
|
74
|
+
Dependency(
|
|
75
|
+
name=name,
|
|
76
|
+
version=parsed_version,
|
|
77
|
+
version_spec=version,
|
|
78
|
+
ecosystem="npm",
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return dependencies
|
|
83
|
+
|
|
84
|
+
def _parse_version(self, version_spec: str) -> Optional[str]:
|
|
85
|
+
"""Extract a concrete version from a version specifier.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
version_spec: npm version specifier (e.g., "^1.2.3", "~1.2.3", "1.2.3")
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Concrete version string or None if can't be determined.
|
|
92
|
+
"""
|
|
93
|
+
if not version_spec or not isinstance(version_spec, str):
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
# Skip workspace references, URLs, git refs, etc.
|
|
97
|
+
if version_spec.startswith(("workspace:", "file:", "git:", "git+", "http:", "https:")):
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
# Skip "latest", "*", etc.
|
|
101
|
+
if version_spec in ("*", "latest", "next", "canary"):
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
# Remove npm: prefix if present
|
|
105
|
+
if version_spec.startswith("npm:"):
|
|
106
|
+
version_spec = version_spec.split("@")[-1]
|
|
107
|
+
|
|
108
|
+
# Extract version from common patterns
|
|
109
|
+
# ^1.2.3, ~1.2.3, >=1.2.3, >1.2.3, =1.2.3, 1.2.3
|
|
110
|
+
match = re.match(r'^[\^~>=<]*(\d+\.\d+\.\d+(?:-[\w.]+)?)', version_spec)
|
|
111
|
+
if match:
|
|
112
|
+
return match.group(1)
|
|
113
|
+
|
|
114
|
+
# Handle version ranges like "1.2.3 - 2.0.0" - use the lower bound
|
|
115
|
+
range_match = re.match(r'^(\d+\.\d+\.\d+)\s*-', version_spec)
|
|
116
|
+
if range_match:
|
|
117
|
+
return range_match.group(1)
|
|
118
|
+
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def supported_filenames(cls) -> list[str]:
|
|
123
|
+
"""Return supported filenames."""
|
|
124
|
+
return ["package.json"]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class NpmLockParser(DependencyParser):
|
|
128
|
+
"""Parser for npm package-lock.json files."""
|
|
129
|
+
|
|
130
|
+
def parse(self, content: str) -> list[Dependency]:
|
|
131
|
+
"""Parse npm package-lock.json content.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
content: The package-lock.json file content.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
List of dependencies found with exact versions.
|
|
138
|
+
"""
|
|
139
|
+
dependencies = []
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
data = json.loads(content)
|
|
143
|
+
except json.JSONDecodeError:
|
|
144
|
+
return dependencies
|
|
145
|
+
|
|
146
|
+
lock_version = data.get("lockfileVersion", 1)
|
|
147
|
+
|
|
148
|
+
if lock_version >= 2:
|
|
149
|
+
# npm v7+ lockfile format - use "packages" field
|
|
150
|
+
packages = data.get("packages", {})
|
|
151
|
+
for path, pkg_data in packages.items():
|
|
152
|
+
if not path: # Skip root package
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
# Extract package name from path (e.g., "node_modules/lodash")
|
|
156
|
+
name = path.split("node_modules/")[-1]
|
|
157
|
+
version = pkg_data.get("version")
|
|
158
|
+
|
|
159
|
+
if name and version:
|
|
160
|
+
dependencies.append(
|
|
161
|
+
Dependency(
|
|
162
|
+
name=name,
|
|
163
|
+
version=version,
|
|
164
|
+
ecosystem="npm",
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
# npm v6 lockfile format - use "dependencies" field
|
|
169
|
+
self._parse_dependencies_v1(data.get("dependencies", {}), dependencies)
|
|
170
|
+
|
|
171
|
+
return dependencies
|
|
172
|
+
|
|
173
|
+
def _parse_dependencies_v1(
|
|
174
|
+
self, deps: dict, result: list[Dependency], prefix: str = ""
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Recursively parse dependencies from npm v6 lockfile format."""
|
|
177
|
+
for name, dep_data in deps.items():
|
|
178
|
+
version = dep_data.get("version")
|
|
179
|
+
if version:
|
|
180
|
+
result.append(
|
|
181
|
+
Dependency(
|
|
182
|
+
name=name,
|
|
183
|
+
version=version,
|
|
184
|
+
ecosystem="npm",
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Parse nested dependencies
|
|
189
|
+
nested = dep_data.get("dependencies", {})
|
|
190
|
+
if nested:
|
|
191
|
+
self._parse_dependencies_v1(nested, result, f"{prefix}{name}/")
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def supported_filenames(cls) -> list[str]:
|
|
195
|
+
"""Return supported filenames."""
|
|
196
|
+
return ["package-lock.json"]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Parser for Yarn lock files (yarn.lock)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from security_use.parsers.base import Dependency, DependencyParser
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class YarnLockParser(DependencyParser):
|
|
10
|
+
"""Parser for yarn.lock files."""
|
|
11
|
+
|
|
12
|
+
# Regex to match package entries in yarn.lock v1 format
|
|
13
|
+
# Example: "package-name@^1.0.0":
|
|
14
|
+
PACKAGE_HEADER_RE = re.compile(
|
|
15
|
+
r'^"?(?P<name>@?[^@\s]+)@(?P<version_spec>[^":\s]+)"?:?\s*$'
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Regex to match resolved version
|
|
19
|
+
VERSION_RE = re.compile(r'^\s+version\s+"?(?P<version>[^"\s]+)"?\s*$')
|
|
20
|
+
|
|
21
|
+
def parse(self, content: str) -> list[Dependency]:
|
|
22
|
+
"""Parse yarn.lock content."""
|
|
23
|
+
dependencies = []
|
|
24
|
+
current_package: Optional[str] = None
|
|
25
|
+
current_version_spec: Optional[str] = None
|
|
26
|
+
current_line: Optional[int] = None
|
|
27
|
+
|
|
28
|
+
for line_num, line in enumerate(content.splitlines(), start=1):
|
|
29
|
+
# Check for package header
|
|
30
|
+
header_match = self.PACKAGE_HEADER_RE.match(line)
|
|
31
|
+
if header_match:
|
|
32
|
+
current_package = header_match.group("name")
|
|
33
|
+
current_version_spec = header_match.group("version_spec")
|
|
34
|
+
current_line = line_num
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
# Check for version line
|
|
38
|
+
if current_package:
|
|
39
|
+
version_match = self.VERSION_RE.match(line)
|
|
40
|
+
if version_match:
|
|
41
|
+
version = version_match.group("version")
|
|
42
|
+
dependencies.append(
|
|
43
|
+
Dependency(
|
|
44
|
+
name=current_package,
|
|
45
|
+
version=version,
|
|
46
|
+
version_spec=current_version_spec,
|
|
47
|
+
line_number=current_line,
|
|
48
|
+
ecosystem="npm",
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
current_package = None
|
|
52
|
+
current_version_spec = None
|
|
53
|
+
current_line = None
|
|
54
|
+
|
|
55
|
+
# Reset on empty line (end of entry)
|
|
56
|
+
if not line.strip():
|
|
57
|
+
current_package = None
|
|
58
|
+
current_version_spec = None
|
|
59
|
+
current_line = None
|
|
60
|
+
|
|
61
|
+
return dependencies
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def supported_filenames(cls) -> list[str]:
|
|
65
|
+
"""Return supported filenames."""
|
|
66
|
+
return ["yarn.lock"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PnpmLockParser(DependencyParser):
|
|
70
|
+
"""Parser for pnpm-lock.yaml files."""
|
|
71
|
+
|
|
72
|
+
# Simple regex for YAML package entries
|
|
73
|
+
# Matches: /package-name@version or /@scope/package-name@version
|
|
74
|
+
PACKAGE_RE = re.compile(r"^ ['\"]?/?(?P<name>@?[^@\s:]+)@(?P<version>[^'\":\s]+)")
|
|
75
|
+
|
|
76
|
+
def parse(self, content: str) -> list[Dependency]:
|
|
77
|
+
"""Parse pnpm-lock.yaml content."""
|
|
78
|
+
dependencies = []
|
|
79
|
+
seen_packages: set[str] = set()
|
|
80
|
+
|
|
81
|
+
for line_num, line in enumerate(content.splitlines(), start=1):
|
|
82
|
+
match = self.PACKAGE_RE.match(line)
|
|
83
|
+
if match:
|
|
84
|
+
name = match.group("name")
|
|
85
|
+
version = match.group("version")
|
|
86
|
+
|
|
87
|
+
# Deduplicate
|
|
88
|
+
key = f"{name}@{version}"
|
|
89
|
+
if key in seen_packages:
|
|
90
|
+
continue
|
|
91
|
+
seen_packages.add(key)
|
|
92
|
+
|
|
93
|
+
dependencies.append(
|
|
94
|
+
Dependency(
|
|
95
|
+
name=name,
|
|
96
|
+
version=version,
|
|
97
|
+
version_spec=f"={version}",
|
|
98
|
+
line_number=line_num,
|
|
99
|
+
ecosystem="npm",
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return dependencies
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def supported_filenames(cls) -> list[str]:
|
|
107
|
+
"""Return supported filenames."""
|
|
108
|
+
return ["pnpm-lock.yaml"]
|
security_use/reporter.py
CHANGED
|
@@ -76,7 +76,14 @@ class TableReporter(ReportGenerator):
|
|
|
76
76
|
self._render_iac_findings(result.iac_findings)
|
|
77
77
|
|
|
78
78
|
if result.errors:
|
|
79
|
-
|
|
79
|
+
# Separate parse/unsupported file errors from real errors
|
|
80
|
+
unsupported = [e for e in result.errors if "Failed to parse" in e or "not a valid" in e]
|
|
81
|
+
real_errors = [e for e in result.errors if e not in unsupported]
|
|
82
|
+
|
|
83
|
+
if unsupported:
|
|
84
|
+
self._render_unsupported(unsupported)
|
|
85
|
+
if real_errors:
|
|
86
|
+
self._render_errors(real_errors)
|
|
80
87
|
|
|
81
88
|
return self.console.export_text()
|
|
82
89
|
|
|
@@ -168,6 +175,27 @@ class TableReporter(ReportGenerator):
|
|
|
168
175
|
|
|
169
176
|
self.console.print(table)
|
|
170
177
|
|
|
178
|
+
def _render_unsupported(self, files: list[str]) -> None:
|
|
179
|
+
"""Render unsupported files panel."""
|
|
180
|
+
text = Text()
|
|
181
|
+
for item in files:
|
|
182
|
+
# Extract just the filename from the error message
|
|
183
|
+
if "Failed to parse" in item:
|
|
184
|
+
filename = item.replace("Failed to parse ", "").replace(": Invalid YAML/JSON", "")
|
|
185
|
+
text.append(f"• {filename}\n", style="dim")
|
|
186
|
+
elif "not a valid" in item:
|
|
187
|
+
filename = item.split(" is not")[0]
|
|
188
|
+
text.append(f"• {filename}\n", style="dim")
|
|
189
|
+
else:
|
|
190
|
+
text.append(f"• {item}\n", style="dim")
|
|
191
|
+
|
|
192
|
+
panel = Panel(
|
|
193
|
+
text,
|
|
194
|
+
title="Unsupported Files (skipped)",
|
|
195
|
+
border_style="dim",
|
|
196
|
+
)
|
|
197
|
+
self.console.print(panel)
|
|
198
|
+
|
|
171
199
|
def _render_errors(self, errors: list[str]) -> None:
|
|
172
200
|
"""Render errors panel."""
|
|
173
201
|
error_text = Text()
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""SBOM generator for creating CycloneDX and SPDX documents."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from security_use.dependency_scanner import DependencyScanner
|
|
10
|
+
from security_use.parsers.base import Dependency
|
|
11
|
+
from security_use.sbom.models import SBOMComponent, SBOMFormat, SBOMOutput
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SBOMGenerator:
|
|
15
|
+
"""Generate Software Bill of Materials in various formats."""
|
|
16
|
+
|
|
17
|
+
# Ecosystem to PURL type mapping
|
|
18
|
+
PURL_TYPES = {
|
|
19
|
+
"PyPI": "pypi",
|
|
20
|
+
"npm": "npm",
|
|
21
|
+
"Maven": "maven",
|
|
22
|
+
"NuGet": "nuget",
|
|
23
|
+
"Packagist": "composer",
|
|
24
|
+
"conda": "conda",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
"""Initialize the SBOM generator."""
|
|
29
|
+
self.scanner = DependencyScanner()
|
|
30
|
+
|
|
31
|
+
def generate(
|
|
32
|
+
self,
|
|
33
|
+
path: Path,
|
|
34
|
+
format: SBOMFormat = SBOMFormat.CYCLONEDX_JSON,
|
|
35
|
+
include_vulnerabilities: bool = False,
|
|
36
|
+
) -> SBOMOutput:
|
|
37
|
+
"""Generate an SBOM for the given path.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
path: Path to the project directory.
|
|
41
|
+
format: Output format for the SBOM.
|
|
42
|
+
include_vulnerabilities: Include vulnerability information (VEX).
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
SBOMOutput containing the generated SBOM.
|
|
46
|
+
"""
|
|
47
|
+
# Scan for dependencies
|
|
48
|
+
result = self.scanner.scan_path(path)
|
|
49
|
+
|
|
50
|
+
# Convert to SBOM components
|
|
51
|
+
components = self._create_components(result.scanned_files, include_vulnerabilities)
|
|
52
|
+
|
|
53
|
+
# Generate output in requested format
|
|
54
|
+
if format == SBOMFormat.CYCLONEDX_JSON:
|
|
55
|
+
content = self._generate_cyclonedx_json(components, path)
|
|
56
|
+
elif format == SBOMFormat.CYCLONEDX_XML:
|
|
57
|
+
content = self._generate_cyclonedx_xml(components, path)
|
|
58
|
+
elif format == SBOMFormat.SPDX_JSON:
|
|
59
|
+
content = self._generate_spdx_json(components, path)
|
|
60
|
+
elif format == SBOMFormat.SPDX_TV:
|
|
61
|
+
content = self._generate_spdx_tv(components, path)
|
|
62
|
+
else:
|
|
63
|
+
raise ValueError(f"Unsupported format: {format}")
|
|
64
|
+
|
|
65
|
+
return SBOMOutput(
|
|
66
|
+
format=format,
|
|
67
|
+
content=content,
|
|
68
|
+
component_count=len(components),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _create_components(
|
|
72
|
+
self,
|
|
73
|
+
scanned_files: list[str],
|
|
74
|
+
include_vulnerabilities: bool = False,
|
|
75
|
+
) -> list[SBOMComponent]:
|
|
76
|
+
"""Create SBOM components from scanned files."""
|
|
77
|
+
components: list[SBOMComponent] = []
|
|
78
|
+
seen: set[tuple[str, str]] = set()
|
|
79
|
+
|
|
80
|
+
for file_path in scanned_files:
|
|
81
|
+
try:
|
|
82
|
+
path = Path(file_path)
|
|
83
|
+
content = path.read_text(encoding="utf-8")
|
|
84
|
+
dependencies = self.scanner.parse_dependencies(content, path.name)
|
|
85
|
+
|
|
86
|
+
for dep in dependencies:
|
|
87
|
+
if dep.version is None:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
key = (dep.name.lower(), dep.version)
|
|
91
|
+
if key in seen:
|
|
92
|
+
continue
|
|
93
|
+
seen.add(key)
|
|
94
|
+
|
|
95
|
+
component = SBOMComponent(
|
|
96
|
+
name=dep.name,
|
|
97
|
+
version=dep.version,
|
|
98
|
+
ecosystem=dep.ecosystem,
|
|
99
|
+
purl=self._create_purl(dep),
|
|
100
|
+
)
|
|
101
|
+
components.append(component)
|
|
102
|
+
|
|
103
|
+
except Exception:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
return components
|
|
107
|
+
|
|
108
|
+
def _create_purl(self, dep: Dependency) -> str:
|
|
109
|
+
"""Create a Package URL (PURL) for a dependency."""
|
|
110
|
+
purl_type = self.PURL_TYPES.get(dep.ecosystem, "generic")
|
|
111
|
+
|
|
112
|
+
# Handle scoped packages (e.g., @scope/package for npm)
|
|
113
|
+
if dep.name.startswith("@"):
|
|
114
|
+
# npm scoped package
|
|
115
|
+
parts = dep.name.split("/")
|
|
116
|
+
namespace = parts[0][1:] # Remove @
|
|
117
|
+
name = parts[1] if len(parts) > 1 else parts[0]
|
|
118
|
+
return f"pkg:{purl_type}/{namespace}/{name}@{dep.version}"
|
|
119
|
+
|
|
120
|
+
# Handle Maven coordinates (group:artifact)
|
|
121
|
+
if ":" in dep.name:
|
|
122
|
+
parts = dep.name.split(":")
|
|
123
|
+
group = parts[0]
|
|
124
|
+
artifact = parts[1] if len(parts) > 1 else parts[0]
|
|
125
|
+
return f"pkg:{purl_type}/{group}/{artifact}@{dep.version}"
|
|
126
|
+
|
|
127
|
+
return f"pkg:{purl_type}/{dep.name}@{dep.version}"
|
|
128
|
+
|
|
129
|
+
def _generate_cyclonedx_json(
|
|
130
|
+
self,
|
|
131
|
+
components: list[SBOMComponent],
|
|
132
|
+
path: Path,
|
|
133
|
+
) -> str:
|
|
134
|
+
"""Generate CycloneDX 1.5 JSON format."""
|
|
135
|
+
bom = {
|
|
136
|
+
"bomFormat": "CycloneDX",
|
|
137
|
+
"specVersion": "1.5",
|
|
138
|
+
"serialNumber": f"urn:uuid:{uuid.uuid4()}",
|
|
139
|
+
"version": 1,
|
|
140
|
+
"metadata": {
|
|
141
|
+
"timestamp": datetime.utcnow().isoformat() + "Z",
|
|
142
|
+
"tools": [
|
|
143
|
+
{
|
|
144
|
+
"vendor": "security-use",
|
|
145
|
+
"name": "security-use",
|
|
146
|
+
"version": "0.2.9",
|
|
147
|
+
}
|
|
148
|
+
],
|
|
149
|
+
"component": {
|
|
150
|
+
"type": "application",
|
|
151
|
+
"name": path.name,
|
|
152
|
+
"version": "0.0.0",
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
"components": [
|
|
156
|
+
{
|
|
157
|
+
"type": "library",
|
|
158
|
+
"name": c.name,
|
|
159
|
+
"version": c.version,
|
|
160
|
+
"purl": c.purl,
|
|
161
|
+
"bom-ref": c.purl,
|
|
162
|
+
}
|
|
163
|
+
for c in components
|
|
164
|
+
],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return json.dumps(bom, indent=2)
|
|
168
|
+
|
|
169
|
+
def _generate_cyclonedx_xml(
|
|
170
|
+
self,
|
|
171
|
+
components: list[SBOMComponent],
|
|
172
|
+
path: Path,
|
|
173
|
+
) -> str:
|
|
174
|
+
"""Generate CycloneDX 1.5 XML format."""
|
|
175
|
+
lines = [
|
|
176
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
177
|
+
'<bom xmlns="http://cyclonedx.org/schema/bom/1.5"',
|
|
178
|
+
f' serialNumber="urn:uuid:{uuid.uuid4()}"',
|
|
179
|
+
' version="1">',
|
|
180
|
+
" <metadata>",
|
|
181
|
+
f" <timestamp>{datetime.utcnow().isoformat()}Z</timestamp>",
|
|
182
|
+
" <tools>",
|
|
183
|
+
" <tool>",
|
|
184
|
+
" <vendor>security-use</vendor>",
|
|
185
|
+
" <name>security-use</name>",
|
|
186
|
+
" <version>0.2.9</version>",
|
|
187
|
+
" </tool>",
|
|
188
|
+
" </tools>",
|
|
189
|
+
" <component type=\"application\">",
|
|
190
|
+
f" <name>{self._xml_escape(path.name)}</name>",
|
|
191
|
+
" <version>0.0.0</version>",
|
|
192
|
+
" </component>",
|
|
193
|
+
" </metadata>",
|
|
194
|
+
" <components>",
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
for c in components:
|
|
198
|
+
lines.extend([
|
|
199
|
+
f' <component type="library" bom-ref="{self._xml_escape(c.purl or c.name)}">',
|
|
200
|
+
f" <name>{self._xml_escape(c.name)}</name>",
|
|
201
|
+
f" <version>{self._xml_escape(c.version)}</version>",
|
|
202
|
+
])
|
|
203
|
+
if c.purl:
|
|
204
|
+
lines.append(f" <purl>{self._xml_escape(c.purl)}</purl>")
|
|
205
|
+
lines.append(" </component>")
|
|
206
|
+
|
|
207
|
+
lines.extend([
|
|
208
|
+
" </components>",
|
|
209
|
+
"</bom>",
|
|
210
|
+
])
|
|
211
|
+
|
|
212
|
+
return "\n".join(lines)
|
|
213
|
+
|
|
214
|
+
def _generate_spdx_json(
|
|
215
|
+
self,
|
|
216
|
+
components: list[SBOMComponent],
|
|
217
|
+
path: Path,
|
|
218
|
+
) -> str:
|
|
219
|
+
"""Generate SPDX 2.3 JSON format."""
|
|
220
|
+
spdx_id = f"SPDXRef-DOCUMENT"
|
|
221
|
+
doc_namespace = f"https://security-use.dev/sbom/{uuid.uuid4()}"
|
|
222
|
+
|
|
223
|
+
packages = []
|
|
224
|
+
relationships = []
|
|
225
|
+
|
|
226
|
+
# Root package
|
|
227
|
+
root_spdx_id = "SPDXRef-RootPackage"
|
|
228
|
+
packages.append({
|
|
229
|
+
"SPDXID": root_spdx_id,
|
|
230
|
+
"name": path.name,
|
|
231
|
+
"versionInfo": "0.0.0",
|
|
232
|
+
"downloadLocation": "NOASSERTION",
|
|
233
|
+
"filesAnalyzed": False,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
relationships.append({
|
|
237
|
+
"spdxElementId": spdx_id,
|
|
238
|
+
"relatedSpdxElement": root_spdx_id,
|
|
239
|
+
"relationshipType": "DESCRIBES",
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
for i, c in enumerate(components):
|
|
243
|
+
pkg_spdx_id = f"SPDXRef-Package-{i}"
|
|
244
|
+
packages.append({
|
|
245
|
+
"SPDXID": pkg_spdx_id,
|
|
246
|
+
"name": c.name,
|
|
247
|
+
"versionInfo": c.version,
|
|
248
|
+
"downloadLocation": "NOASSERTION",
|
|
249
|
+
"filesAnalyzed": False,
|
|
250
|
+
"externalRefs": [
|
|
251
|
+
{
|
|
252
|
+
"referenceCategory": "PACKAGE-MANAGER",
|
|
253
|
+
"referenceType": "purl",
|
|
254
|
+
"referenceLocator": c.purl,
|
|
255
|
+
}
|
|
256
|
+
] if c.purl else [],
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
relationships.append({
|
|
260
|
+
"spdxElementId": root_spdx_id,
|
|
261
|
+
"relatedSpdxElement": pkg_spdx_id,
|
|
262
|
+
"relationshipType": "DEPENDS_ON",
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
doc = {
|
|
266
|
+
"spdxVersion": "SPDX-2.3",
|
|
267
|
+
"dataLicense": "CC0-1.0",
|
|
268
|
+
"SPDXID": spdx_id,
|
|
269
|
+
"name": f"{path.name} SBOM",
|
|
270
|
+
"documentNamespace": doc_namespace,
|
|
271
|
+
"creationInfo": {
|
|
272
|
+
"created": datetime.utcnow().isoformat() + "Z",
|
|
273
|
+
"creators": ["Tool: security-use-0.2.9"],
|
|
274
|
+
},
|
|
275
|
+
"packages": packages,
|
|
276
|
+
"relationships": relationships,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return json.dumps(doc, indent=2)
|
|
280
|
+
|
|
281
|
+
def _generate_spdx_tv(
|
|
282
|
+
self,
|
|
283
|
+
components: list[SBOMComponent],
|
|
284
|
+
path: Path,
|
|
285
|
+
) -> str:
|
|
286
|
+
"""Generate SPDX 2.3 tag-value format."""
|
|
287
|
+
doc_namespace = f"https://security-use.dev/sbom/{uuid.uuid4()}"
|
|
288
|
+
timestamp = datetime.utcnow().isoformat() + "Z"
|
|
289
|
+
|
|
290
|
+
lines = [
|
|
291
|
+
"SPDXVersion: SPDX-2.3",
|
|
292
|
+
"DataLicense: CC0-1.0",
|
|
293
|
+
"SPDXID: SPDXRef-DOCUMENT",
|
|
294
|
+
f"DocumentName: {path.name} SBOM",
|
|
295
|
+
f"DocumentNamespace: {doc_namespace}",
|
|
296
|
+
"Creator: Tool: security-use-0.2.9",
|
|
297
|
+
f"Created: {timestamp}",
|
|
298
|
+
"",
|
|
299
|
+
"##### Root Package",
|
|
300
|
+
"",
|
|
301
|
+
"PackageName: " + path.name,
|
|
302
|
+
"SPDXID: SPDXRef-RootPackage",
|
|
303
|
+
"PackageVersion: 0.0.0",
|
|
304
|
+
"PackageDownloadLocation: NOASSERTION",
|
|
305
|
+
"FilesAnalyzed: false",
|
|
306
|
+
"",
|
|
307
|
+
"Relationship: SPDXRef-DOCUMENT DESCRIBES SPDXRef-RootPackage",
|
|
308
|
+
"",
|
|
309
|
+
]
|
|
310
|
+
|
|
311
|
+
for i, c in enumerate(components):
|
|
312
|
+
pkg_id = f"SPDXRef-Package-{i}"
|
|
313
|
+
lines.extend([
|
|
314
|
+
f"##### Package: {c.name}",
|
|
315
|
+
"",
|
|
316
|
+
f"PackageName: {c.name}",
|
|
317
|
+
f"SPDXID: {pkg_id}",
|
|
318
|
+
f"PackageVersion: {c.version}",
|
|
319
|
+
"PackageDownloadLocation: NOASSERTION",
|
|
320
|
+
"FilesAnalyzed: false",
|
|
321
|
+
])
|
|
322
|
+
if c.purl:
|
|
323
|
+
lines.append(f"ExternalRef: PACKAGE-MANAGER purl {c.purl}")
|
|
324
|
+
lines.extend([
|
|
325
|
+
"",
|
|
326
|
+
f"Relationship: SPDXRef-RootPackage DEPENDS_ON {pkg_id}",
|
|
327
|
+
"",
|
|
328
|
+
])
|
|
329
|
+
|
|
330
|
+
return "\n".join(lines)
|
|
331
|
+
|
|
332
|
+
def _xml_escape(self, s: str) -> str:
|
|
333
|
+
"""Escape special characters for XML."""
|
|
334
|
+
return (
|
|
335
|
+
s.replace("&", "&")
|
|
336
|
+
.replace("<", "<")
|
|
337
|
+
.replace(">", ">")
|
|
338
|
+
.replace('"', """)
|
|
339
|
+
.replace("'", "'")
|
|
340
|
+
)
|