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.
Files changed (45) hide show
  1. security_use/__init__.py +9 -1
  2. security_use/auth/__init__.py +16 -0
  3. security_use/auth/client.py +223 -0
  4. security_use/auth/config.py +177 -0
  5. security_use/auth/oauth.py +317 -0
  6. security_use/cli.py +699 -34
  7. security_use/compliance/__init__.py +10 -0
  8. security_use/compliance/mapper.py +275 -0
  9. security_use/compliance/models.py +50 -0
  10. security_use/dependency_scanner.py +76 -30
  11. security_use/fixers/iac_fixer.py +173 -95
  12. security_use/iac/rules/azure.py +246 -0
  13. security_use/iac/rules/gcp.py +255 -0
  14. security_use/iac/rules/kubernetes.py +429 -0
  15. security_use/iac/rules/registry.py +56 -0
  16. security_use/parsers/__init__.py +18 -0
  17. security_use/parsers/base.py +2 -0
  18. security_use/parsers/composer.py +101 -0
  19. security_use/parsers/conda.py +97 -0
  20. security_use/parsers/dotnet.py +89 -0
  21. security_use/parsers/gradle.py +90 -0
  22. security_use/parsers/maven.py +108 -0
  23. security_use/parsers/npm.py +196 -0
  24. security_use/parsers/yarn.py +108 -0
  25. security_use/reporter.py +29 -1
  26. security_use/sbom/__init__.py +10 -0
  27. security_use/sbom/generator.py +340 -0
  28. security_use/sbom/models.py +40 -0
  29. security_use/scanner.py +15 -2
  30. security_use/sensor/__init__.py +125 -0
  31. security_use/sensor/alert_queue.py +207 -0
  32. security_use/sensor/config.py +217 -0
  33. security_use/sensor/dashboard_alerter.py +246 -0
  34. security_use/sensor/detector.py +415 -0
  35. security_use/sensor/endpoint_analyzer.py +339 -0
  36. security_use/sensor/middleware.py +521 -0
  37. security_use/sensor/models.py +140 -0
  38. security_use/sensor/webhook.py +227 -0
  39. security_use-0.2.9.dist-info/METADATA +531 -0
  40. security_use-0.2.9.dist-info/RECORD +60 -0
  41. security_use-0.2.9.dist-info/licenses/LICENSE +21 -0
  42. security_use-0.1.1.dist-info/METADATA +0 -92
  43. security_use-0.1.1.dist-info/RECORD +0 -30
  44. {security_use-0.1.1.dist-info → security_use-0.2.9.dist-info}/WHEEL +0 -0
  45. {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
- self._render_errors(result.errors)
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,10 @@
1
+ """SBOM (Software Bill of Materials) generation module."""
2
+
3
+ from security_use.sbom.generator import SBOMGenerator
4
+ from security_use.sbom.models import SBOMFormat, SBOMOutput
5
+
6
+ __all__ = [
7
+ "SBOMGenerator",
8
+ "SBOMFormat",
9
+ "SBOMOutput",
10
+ ]
@@ -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("&", "&amp;")
336
+ .replace("<", "&lt;")
337
+ .replace(">", "&gt;")
338
+ .replace('"', "&quot;")
339
+ .replace("'", "&apos;")
340
+ )