kekkai-cli 1.0.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.
Files changed (90) hide show
  1. kekkai/__init__.py +7 -0
  2. kekkai/cli.py +1038 -0
  3. kekkai/config.py +403 -0
  4. kekkai/dojo.py +419 -0
  5. kekkai/dojo_import.py +213 -0
  6. kekkai/github/__init__.py +16 -0
  7. kekkai/github/commenter.py +198 -0
  8. kekkai/github/models.py +56 -0
  9. kekkai/github/sanitizer.py +112 -0
  10. kekkai/installer/__init__.py +39 -0
  11. kekkai/installer/errors.py +23 -0
  12. kekkai/installer/extract.py +161 -0
  13. kekkai/installer/manager.py +252 -0
  14. kekkai/installer/manifest.py +189 -0
  15. kekkai/installer/verify.py +86 -0
  16. kekkai/manifest.py +77 -0
  17. kekkai/output.py +218 -0
  18. kekkai/paths.py +46 -0
  19. kekkai/policy.py +326 -0
  20. kekkai/runner.py +70 -0
  21. kekkai/scanners/__init__.py +67 -0
  22. kekkai/scanners/backends/__init__.py +14 -0
  23. kekkai/scanners/backends/base.py +73 -0
  24. kekkai/scanners/backends/docker.py +178 -0
  25. kekkai/scanners/backends/native.py +240 -0
  26. kekkai/scanners/base.py +110 -0
  27. kekkai/scanners/container.py +144 -0
  28. kekkai/scanners/falco.py +237 -0
  29. kekkai/scanners/gitleaks.py +237 -0
  30. kekkai/scanners/semgrep.py +227 -0
  31. kekkai/scanners/trivy.py +246 -0
  32. kekkai/scanners/url_policy.py +163 -0
  33. kekkai/scanners/zap.py +340 -0
  34. kekkai/threatflow/__init__.py +94 -0
  35. kekkai/threatflow/artifacts.py +476 -0
  36. kekkai/threatflow/chunking.py +361 -0
  37. kekkai/threatflow/core.py +438 -0
  38. kekkai/threatflow/mermaid.py +374 -0
  39. kekkai/threatflow/model_adapter.py +491 -0
  40. kekkai/threatflow/prompts.py +277 -0
  41. kekkai/threatflow/redaction.py +228 -0
  42. kekkai/threatflow/sanitizer.py +643 -0
  43. kekkai/triage/__init__.py +33 -0
  44. kekkai/triage/app.py +168 -0
  45. kekkai/triage/audit.py +203 -0
  46. kekkai/triage/ignore.py +269 -0
  47. kekkai/triage/models.py +185 -0
  48. kekkai/triage/screens.py +341 -0
  49. kekkai/triage/widgets.py +169 -0
  50. kekkai_cli-1.0.0.dist-info/METADATA +135 -0
  51. kekkai_cli-1.0.0.dist-info/RECORD +90 -0
  52. kekkai_cli-1.0.0.dist-info/WHEEL +5 -0
  53. kekkai_cli-1.0.0.dist-info/entry_points.txt +3 -0
  54. kekkai_cli-1.0.0.dist-info/top_level.txt +3 -0
  55. kekkai_core/__init__.py +3 -0
  56. kekkai_core/ci/__init__.py +11 -0
  57. kekkai_core/ci/benchmarks.py +354 -0
  58. kekkai_core/ci/metadata.py +104 -0
  59. kekkai_core/ci/validators.py +92 -0
  60. kekkai_core/docker/__init__.py +17 -0
  61. kekkai_core/docker/metadata.py +153 -0
  62. kekkai_core/docker/sbom.py +173 -0
  63. kekkai_core/docker/security.py +158 -0
  64. kekkai_core/docker/signing.py +135 -0
  65. kekkai_core/redaction.py +84 -0
  66. kekkai_core/slsa/__init__.py +13 -0
  67. kekkai_core/slsa/verify.py +121 -0
  68. kekkai_core/windows/__init__.py +29 -0
  69. kekkai_core/windows/chocolatey.py +335 -0
  70. kekkai_core/windows/installer.py +256 -0
  71. kekkai_core/windows/scoop.py +165 -0
  72. kekkai_core/windows/validators.py +220 -0
  73. portal/__init__.py +19 -0
  74. portal/api.py +155 -0
  75. portal/auth.py +103 -0
  76. portal/enterprise/__init__.py +32 -0
  77. portal/enterprise/audit.py +435 -0
  78. portal/enterprise/licensing.py +342 -0
  79. portal/enterprise/rbac.py +276 -0
  80. portal/enterprise/saml.py +595 -0
  81. portal/ops/__init__.py +53 -0
  82. portal/ops/backup.py +553 -0
  83. portal/ops/log_shipper.py +469 -0
  84. portal/ops/monitoring.py +517 -0
  85. portal/ops/restore.py +469 -0
  86. portal/ops/secrets.py +408 -0
  87. portal/ops/upgrade.py +591 -0
  88. portal/tenants.py +340 -0
  89. portal/uploads.py +259 -0
  90. portal/web.py +384 -0
@@ -0,0 +1,252 @@
1
+ """Main tool installer manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import shutil
8
+ import ssl
9
+ import tempfile
10
+ import urllib.request
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+
14
+ from .errors import DownloadError, InstallerError, SecurityError, UnsupportedPlatformError
15
+ from .extract import extract_archive
16
+ from .manifest import (
17
+ ToolManifest,
18
+ get_download_url,
19
+ get_expected_hash,
20
+ get_manifest,
21
+ get_platform_key,
22
+ validate_manifest_url,
23
+ )
24
+ from .verify import MAX_DOWNLOAD_SIZE, verify_checksum, verify_file_size
25
+
26
+ if TYPE_CHECKING:
27
+ pass
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Download timeout in seconds
32
+ DOWNLOAD_TIMEOUT = 120
33
+
34
+
35
+ class ToolInstaller:
36
+ """Manages downloading and installing security tools."""
37
+
38
+ def __init__(self, install_dir: Path | None = None) -> None:
39
+ """Initialize the tool installer.
40
+
41
+ Args:
42
+ install_dir: Directory to install tools. Defaults to ~/.kekkai/bin/.
43
+ """
44
+ if install_dir is None:
45
+ from kekkai.paths import bin_dir
46
+
47
+ install_dir = bin_dir()
48
+
49
+ self.install_dir = install_dir
50
+ self.install_dir.mkdir(parents=True, exist_ok=True)
51
+
52
+ def ensure_tool(self, name: str, auto_install: bool = False) -> Path:
53
+ """Ensure a tool is installed, downloading if necessary.
54
+
55
+ Args:
56
+ name: Tool name (trivy, semgrep, gitleaks).
57
+ auto_install: If True, install without prompting.
58
+
59
+ Returns:
60
+ Path to the installed tool binary.
61
+
62
+ Raises:
63
+ InstallerError: If installation fails.
64
+ """
65
+ # Check if already installed in our bin directory
66
+ existing = self._find_installed(name)
67
+ if existing:
68
+ logger.debug("Tool %s already installed at %s", name, existing)
69
+ return existing
70
+
71
+ # Check if available in PATH
72
+ path_tool = shutil.which(name)
73
+ if path_tool:
74
+ logger.debug("Tool %s found in PATH at %s", name, path_tool)
75
+ return Path(path_tool)
76
+
77
+ # Need to download
78
+ manifest = get_manifest(name)
79
+ if not manifest:
80
+ raise InstallerError(f"Unknown tool: {name}")
81
+
82
+ if not auto_install:
83
+ logger.info("Tool %s not found. Use --auto-install or install manually.", name)
84
+ raise InstallerError(
85
+ f"Tool {name} not found. Use --auto-install to download automatically."
86
+ )
87
+
88
+ return self._download_and_install(manifest)
89
+
90
+ def _find_installed(self, name: str) -> Path | None:
91
+ """Find if tool is already installed in our bin directory.
92
+
93
+ Args:
94
+ name: Tool name.
95
+
96
+ Returns:
97
+ Path to binary if found, None otherwise.
98
+ """
99
+ candidates = [
100
+ self.install_dir / name,
101
+ self.install_dir / f"{name}.exe",
102
+ ]
103
+
104
+ for path in candidates:
105
+ if path.exists() and os.access(path, os.X_OK):
106
+ return path
107
+
108
+ return None
109
+
110
+ def _download_and_install(self, manifest: ToolManifest) -> Path:
111
+ """Download and install a tool.
112
+
113
+ Args:
114
+ manifest: Tool manifest.
115
+
116
+ Returns:
117
+ Path to installed binary.
118
+
119
+ Raises:
120
+ SecurityError: If verification fails.
121
+ DownloadError: If download fails.
122
+ """
123
+ # Get expected hash for platform
124
+ expected_hash = get_expected_hash(manifest)
125
+ if not expected_hash:
126
+ platform_key = get_platform_key()
127
+ raise UnsupportedPlatformError(
128
+ f"No binary available for {manifest.name} on {platform_key}"
129
+ )
130
+
131
+ # Skip download if hash is a placeholder
132
+ if expected_hash.startswith("placeholder_"):
133
+ raise InstallerError(
134
+ f"SHA256 hash not yet configured for {manifest.name}. "
135
+ "Please install manually or wait for manifest update."
136
+ )
137
+
138
+ # Build download URL and validate
139
+ url = get_download_url(manifest)
140
+ if not validate_manifest_url(url):
141
+ raise SecurityError(f"URL not from allowed domain: {url}")
142
+
143
+ logger.info("Downloading %s v%s from %s", manifest.name, manifest.version, url)
144
+
145
+ # Download with strict TLS
146
+ content = self._download(url, manifest.name)
147
+
148
+ # Verify checksum
149
+ verify_checksum(content, expected_hash, manifest.name)
150
+
151
+ # Extract to temp directory first
152
+ with tempfile.TemporaryDirectory() as tmp_dir:
153
+ tmp_path = Path(tmp_dir)
154
+ archive_path = tmp_path / f"{manifest.name}.archive"
155
+ archive_path.write_bytes(content)
156
+
157
+ # Extract binary
158
+ binary_name = manifest.binary_name or manifest.name
159
+ extracted_path = extract_archive(
160
+ archive_path, tmp_path, binary_name, manifest.archive_type
161
+ )
162
+
163
+ # Move to install directory
164
+ final_path = self.install_dir / extracted_path.name
165
+ shutil.move(str(extracted_path), str(final_path))
166
+
167
+ # Make executable
168
+ final_path.chmod(0o755)
169
+
170
+ logger.info("Installed %s to %s", manifest.name, final_path)
171
+ return final_path
172
+
173
+ def _download(self, url: str, tool_name: str) -> bytes:
174
+ """Download a file with strict TLS settings.
175
+
176
+ Args:
177
+ url: URL to download.
178
+ tool_name: Tool name for error messages.
179
+
180
+ Returns:
181
+ Downloaded bytes.
182
+
183
+ Raises:
184
+ DownloadError: If download fails.
185
+ SecurityError: If file too large.
186
+ """
187
+ # Create SSL context with TLS 1.2+ (1.3 preferred but 1.2 is widely supported)
188
+ ctx = ssl.create_default_context()
189
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
190
+
191
+ try:
192
+ req = urllib.request.Request( # noqa: S310 - URL validated in validate_manifest_url
193
+ url,
194
+ headers={"User-Agent": "kekkai-installer/1.0"},
195
+ )
196
+
197
+ with urllib.request.urlopen( # noqa: S310 - URL validated
198
+ req, context=ctx, timeout=DOWNLOAD_TIMEOUT
199
+ ) as resp:
200
+ if resp.status != 200:
201
+ raise DownloadError(f"Download failed: HTTP {resp.status}")
202
+
203
+ # Check content length if provided
204
+ content_length = resp.headers.get("Content-Length")
205
+ if content_length:
206
+ verify_file_size(int(content_length), tool_name)
207
+
208
+ # Read with size limit
209
+ content: bytes = resp.read(MAX_DOWNLOAD_SIZE + 1)
210
+ verify_file_size(len(content), tool_name)
211
+
212
+ return content
213
+
214
+ except urllib.error.HTTPError as e:
215
+ raise DownloadError(f"HTTP error downloading {tool_name}: {e.code}") from e
216
+ except urllib.error.URLError as e:
217
+ raise DownloadError(f"URL error downloading {tool_name}: {e.reason}") from e
218
+ except TimeoutError as e:
219
+ raise DownloadError(f"Timeout downloading {tool_name}") from e
220
+
221
+ def get_tool_path(self, name: str) -> Path | None:
222
+ """Get path to an installed tool without downloading.
223
+
224
+ Args:
225
+ name: Tool name.
226
+
227
+ Returns:
228
+ Path if installed, None otherwise.
229
+ """
230
+ # Check our install directory first
231
+ installed = self._find_installed(name)
232
+ if installed:
233
+ return installed
234
+
235
+ # Check PATH
236
+ path_tool = shutil.which(name)
237
+ if path_tool:
238
+ return Path(path_tool)
239
+
240
+ return None
241
+
242
+
243
+ # Global installer instance
244
+ _installer: ToolInstaller | None = None
245
+
246
+
247
+ def get_installer() -> ToolInstaller:
248
+ """Get the global installer instance."""
249
+ global _installer
250
+ if _installer is None:
251
+ _installer = ToolInstaller()
252
+ return _installer
@@ -0,0 +1,189 @@
1
+ """Tool manifests with SHA256 checksums for secure downloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import re
7
+ from dataclasses import dataclass
8
+
9
+ from .errors import UnsupportedPlatformError
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ToolManifest:
14
+ """Manifest for a downloadable security tool."""
15
+
16
+ name: str
17
+ version: str
18
+ url_template: str
19
+ sha256: dict[str, str] # platform_key -> hash
20
+ binary_name: str | None = None # Name inside archive (if different from tool name)
21
+ archive_type: str = "tar.gz" # "tar.gz" or "zip"
22
+
23
+
24
+ def get_platform_key() -> str:
25
+ """Get platform key for the current system.
26
+
27
+ Returns:
28
+ Platform key like 'linux_amd64', 'darwin_arm64', etc.
29
+
30
+ Raises:
31
+ UnsupportedPlatformError: If platform is not supported.
32
+ """
33
+ system = platform.system().lower()
34
+ machine = platform.machine().lower()
35
+
36
+ arch_map = {
37
+ "x86_64": "amd64",
38
+ "amd64": "amd64",
39
+ "aarch64": "arm64",
40
+ "arm64": "arm64",
41
+ }
42
+
43
+ arch = arch_map.get(machine)
44
+ if not arch:
45
+ raise UnsupportedPlatformError(f"Unsupported architecture: {machine}")
46
+
47
+ if system not in ("linux", "darwin", "windows"):
48
+ raise UnsupportedPlatformError(f"Unsupported OS: {system}")
49
+
50
+ return f"{system}_{arch}"
51
+
52
+
53
+ def get_download_url(manifest: ToolManifest) -> str:
54
+ """Build download URL for the current platform.
55
+
56
+ Args:
57
+ manifest: Tool manifest with URL template.
58
+
59
+ Returns:
60
+ Fully resolved download URL.
61
+ """
62
+ system = platform.system()
63
+ machine = platform.machine().lower()
64
+
65
+ arch_map = {
66
+ "x86_64": "64bit",
67
+ "amd64": "64bit",
68
+ "aarch64": "ARM64",
69
+ "arm64": "ARM64",
70
+ }
71
+
72
+ # Tool-specific URL formatting
73
+ os_name = system
74
+ if manifest.name == "trivy":
75
+ os_name = system
76
+ arch = arch_map.get(machine, "64bit")
77
+ elif manifest.name == "semgrep":
78
+ os_name = system.lower()
79
+ arch = "x86_64" if machine in ("x86_64", "amd64") else "aarch64"
80
+ elif manifest.name == "gitleaks":
81
+ os_name = system.lower()
82
+ arch = "x64" if machine in ("x86_64", "amd64") else "arm64"
83
+ else:
84
+ arch = machine
85
+
86
+ return manifest.url_template.format(
87
+ version=manifest.version,
88
+ os=os_name,
89
+ arch=arch,
90
+ )
91
+
92
+
93
+ # Hardcoded manifests with official GitHub release URLs
94
+ # SHA256 checksums must be verified against official releases
95
+ TOOL_MANIFESTS: dict[str, ToolManifest] = {
96
+ "trivy": ToolManifest(
97
+ name="trivy",
98
+ version="0.58.1",
99
+ url_template=(
100
+ "https://github.com/aquasecurity/trivy/releases/download/"
101
+ "v{version}/trivy_{version}_{os}-{arch}.tar.gz"
102
+ ),
103
+ sha256={
104
+ "linux_amd64": "01a6a89a6a8af9c830a1e6a762e42883e2ae68583514db81f5f2be3db3fb2ffc",
105
+ "linux_arm64": "c1a551eedd0a0e0f5024f76c64dea5e7fa7ac41d84b4ff4f1ee18ec0bf11174c",
106
+ "darwin_amd64": "8bca33df7022dfa76ea7ec03a2a19cfd86e2e6d8de8b94b5e70d9ab95f61c6e6",
107
+ "darwin_arm64": "a7dae5017cf898e6dce0b3fcefcb3e88b1f8fd78a38a54f1d6c12b8c2bb5d0f4",
108
+ },
109
+ binary_name="trivy",
110
+ ),
111
+ "semgrep": ToolManifest(
112
+ name="semgrep",
113
+ version="1.102.0",
114
+ url_template=(
115
+ "https://github.com/semgrep/semgrep/releases/download/"
116
+ "v{version}/semgrep-v{version}-{os}-{arch}.zip"
117
+ ),
118
+ sha256={
119
+ "linux_amd64": "placeholder_semgrep_linux_amd64",
120
+ "linux_arm64": "placeholder_semgrep_linux_arm64",
121
+ "darwin_amd64": "placeholder_semgrep_darwin_amd64",
122
+ "darwin_arm64": "placeholder_semgrep_darwin_arm64",
123
+ },
124
+ archive_type="zip",
125
+ binary_name="semgrep",
126
+ ),
127
+ "gitleaks": ToolManifest(
128
+ name="gitleaks",
129
+ version="8.21.2",
130
+ url_template=(
131
+ "https://github.com/gitleaks/gitleaks/releases/download/"
132
+ "v{version}/gitleaks_{version}_{os}_{arch}.tar.gz"
133
+ ),
134
+ sha256={
135
+ "linux_amd64": "placeholder_gitleaks_linux_amd64",
136
+ "linux_arm64": "placeholder_gitleaks_linux_arm64",
137
+ "darwin_amd64": "placeholder_gitleaks_darwin_amd64",
138
+ "darwin_arm64": "placeholder_gitleaks_darwin_arm64",
139
+ },
140
+ binary_name="gitleaks",
141
+ ),
142
+ }
143
+
144
+
145
+ def get_manifest(tool_name: str) -> ToolManifest | None:
146
+ """Get manifest for a tool.
147
+
148
+ Args:
149
+ tool_name: Name of the tool.
150
+
151
+ Returns:
152
+ ToolManifest if found, None otherwise.
153
+ """
154
+ return TOOL_MANIFESTS.get(tool_name)
155
+
156
+
157
+ def get_expected_hash(manifest: ToolManifest) -> str | None:
158
+ """Get expected SHA256 hash for the current platform.
159
+
160
+ Args:
161
+ manifest: Tool manifest.
162
+
163
+ Returns:
164
+ Expected hash or None if platform not supported.
165
+ """
166
+ try:
167
+ platform_key = get_platform_key()
168
+ return manifest.sha256.get(platform_key)
169
+ except UnsupportedPlatformError:
170
+ return None
171
+
172
+
173
+ def validate_manifest_url(url: str) -> bool:
174
+ """Validate that URL is from an allowed domain.
175
+
176
+ Only official GitHub releases are allowed.
177
+
178
+ Args:
179
+ url: URL to validate.
180
+
181
+ Returns:
182
+ True if URL is from allowed domain.
183
+ """
184
+ allowed_patterns = [
185
+ r"^https://github\.com/aquasecurity/trivy/releases/download/",
186
+ r"^https://github\.com/semgrep/semgrep/releases/download/",
187
+ r"^https://github\.com/gitleaks/gitleaks/releases/download/",
188
+ ]
189
+ return any(re.match(pattern, url) for pattern in allowed_patterns)
@@ -0,0 +1,86 @@
1
+ """Cryptographic verification for downloaded binaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import logging
7
+ from pathlib import Path
8
+
9
+ from .errors import SecurityError
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Maximum file size for downloads (100MB)
14
+ MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024
15
+
16
+
17
+ def compute_sha256(data: bytes) -> str:
18
+ """Compute SHA256 hash of data.
19
+
20
+ Args:
21
+ data: Bytes to hash.
22
+
23
+ Returns:
24
+ Hex-encoded SHA256 hash.
25
+ """
26
+ return hashlib.sha256(data).hexdigest()
27
+
28
+
29
+ def compute_sha256_file(file_path: Path) -> str:
30
+ """Compute SHA256 hash of a file.
31
+
32
+ Args:
33
+ file_path: Path to file.
34
+
35
+ Returns:
36
+ Hex-encoded SHA256 hash.
37
+ """
38
+ sha256 = hashlib.sha256()
39
+ with open(file_path, "rb") as f:
40
+ for chunk in iter(lambda: f.read(8192), b""):
41
+ sha256.update(chunk)
42
+ return sha256.hexdigest()
43
+
44
+
45
+ def verify_checksum(data: bytes, expected_hash: str, tool_name: str) -> None:
46
+ """Verify SHA256 checksum of downloaded data.
47
+
48
+ Args:
49
+ data: Downloaded bytes.
50
+ expected_hash: Expected SHA256 hash (hex-encoded).
51
+ tool_name: Name of tool (for error messages).
52
+
53
+ Raises:
54
+ SecurityError: If checksum doesn't match.
55
+ """
56
+ actual_hash = compute_sha256(data)
57
+
58
+ if actual_hash != expected_hash:
59
+ logger.error(
60
+ "Checksum mismatch for %s: expected %s..., got %s...",
61
+ tool_name,
62
+ expected_hash[:16],
63
+ actual_hash[:16],
64
+ )
65
+ raise SecurityError(
66
+ f"Checksum mismatch for {tool_name}: "
67
+ f"expected {expected_hash[:16]}..., got {actual_hash[:16]}..."
68
+ )
69
+
70
+ logger.info("Checksum verified for %s: %s", tool_name, actual_hash[:16])
71
+
72
+
73
+ def verify_file_size(size: int, tool_name: str) -> None:
74
+ """Verify file size is within limits.
75
+
76
+ Args:
77
+ size: File size in bytes.
78
+ tool_name: Name of tool (for error messages).
79
+
80
+ Raises:
81
+ SecurityError: If file exceeds size limit.
82
+ """
83
+ if size > MAX_DOWNLOAD_SIZE:
84
+ raise SecurityError(
85
+ f"Download size {size} exceeds maximum {MAX_DOWNLOAD_SIZE} for {tool_name}"
86
+ )
kekkai/manifest.py ADDED
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Iterable
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+
8
+ from .runner import StepResult
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ScannerManifestEntry:
13
+ """Manifest entry for a scanner execution."""
14
+
15
+ name: str
16
+ backend: str
17
+ success: bool
18
+ finding_count: int
19
+ duration_ms: int
20
+ error: str | None = None
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class RunManifest:
25
+ schema_version: int
26
+ run_id: str
27
+ repo_path: str
28
+ run_dir: str
29
+ started_at: str
30
+ finished_at: str
31
+ status: str
32
+ steps: list[dict[str, object]]
33
+ scanners: list[dict[str, object]] | None = None
34
+
35
+
36
+ def build_manifest(
37
+ *,
38
+ run_id: str,
39
+ repo_path: Path,
40
+ run_dir: Path,
41
+ started_at: str,
42
+ finished_at: str,
43
+ steps: Iterable[StepResult],
44
+ scanners: Iterable[ScannerManifestEntry] | None = None,
45
+ ) -> RunManifest:
46
+ step_entries = [
47
+ {
48
+ "name": step.name,
49
+ "args": step.args,
50
+ "exit_code": step.exit_code,
51
+ "duration_ms": step.duration_ms,
52
+ "stdout": step.stdout,
53
+ "stderr": step.stderr,
54
+ "timed_out": step.timed_out,
55
+ }
56
+ for step in steps
57
+ ]
58
+ scanner_entries = None
59
+ if scanners is not None:
60
+ scanner_entries = [asdict(s) for s in scanners]
61
+
62
+ status = "success" if all(step["exit_code"] == 0 for step in step_entries) else "failed"
63
+ return RunManifest(
64
+ schema_version=2,
65
+ run_id=run_id,
66
+ repo_path=str(repo_path),
67
+ run_dir=str(run_dir),
68
+ started_at=started_at,
69
+ finished_at=finished_at,
70
+ status=status,
71
+ steps=step_entries,
72
+ scanners=scanner_entries,
73
+ )
74
+
75
+
76
+ def write_manifest(path: Path, manifest: RunManifest) -> None:
77
+ path.write_text(json.dumps(asdict(manifest), indent=2, sort_keys=True))