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.
- kekkai/__init__.py +7 -0
- kekkai/cli.py +1038 -0
- kekkai/config.py +403 -0
- kekkai/dojo.py +419 -0
- kekkai/dojo_import.py +213 -0
- kekkai/github/__init__.py +16 -0
- kekkai/github/commenter.py +198 -0
- kekkai/github/models.py +56 -0
- kekkai/github/sanitizer.py +112 -0
- kekkai/installer/__init__.py +39 -0
- kekkai/installer/errors.py +23 -0
- kekkai/installer/extract.py +161 -0
- kekkai/installer/manager.py +252 -0
- kekkai/installer/manifest.py +189 -0
- kekkai/installer/verify.py +86 -0
- kekkai/manifest.py +77 -0
- kekkai/output.py +218 -0
- kekkai/paths.py +46 -0
- kekkai/policy.py +326 -0
- kekkai/runner.py +70 -0
- kekkai/scanners/__init__.py +67 -0
- kekkai/scanners/backends/__init__.py +14 -0
- kekkai/scanners/backends/base.py +73 -0
- kekkai/scanners/backends/docker.py +178 -0
- kekkai/scanners/backends/native.py +240 -0
- kekkai/scanners/base.py +110 -0
- kekkai/scanners/container.py +144 -0
- kekkai/scanners/falco.py +237 -0
- kekkai/scanners/gitleaks.py +237 -0
- kekkai/scanners/semgrep.py +227 -0
- kekkai/scanners/trivy.py +246 -0
- kekkai/scanners/url_policy.py +163 -0
- kekkai/scanners/zap.py +340 -0
- kekkai/threatflow/__init__.py +94 -0
- kekkai/threatflow/artifacts.py +476 -0
- kekkai/threatflow/chunking.py +361 -0
- kekkai/threatflow/core.py +438 -0
- kekkai/threatflow/mermaid.py +374 -0
- kekkai/threatflow/model_adapter.py +491 -0
- kekkai/threatflow/prompts.py +277 -0
- kekkai/threatflow/redaction.py +228 -0
- kekkai/threatflow/sanitizer.py +643 -0
- kekkai/triage/__init__.py +33 -0
- kekkai/triage/app.py +168 -0
- kekkai/triage/audit.py +203 -0
- kekkai/triage/ignore.py +269 -0
- kekkai/triage/models.py +185 -0
- kekkai/triage/screens.py +341 -0
- kekkai/triage/widgets.py +169 -0
- kekkai_cli-1.0.0.dist-info/METADATA +135 -0
- kekkai_cli-1.0.0.dist-info/RECORD +90 -0
- kekkai_cli-1.0.0.dist-info/WHEEL +5 -0
- kekkai_cli-1.0.0.dist-info/entry_points.txt +3 -0
- kekkai_cli-1.0.0.dist-info/top_level.txt +3 -0
- kekkai_core/__init__.py +3 -0
- kekkai_core/ci/__init__.py +11 -0
- kekkai_core/ci/benchmarks.py +354 -0
- kekkai_core/ci/metadata.py +104 -0
- kekkai_core/ci/validators.py +92 -0
- kekkai_core/docker/__init__.py +17 -0
- kekkai_core/docker/metadata.py +153 -0
- kekkai_core/docker/sbom.py +173 -0
- kekkai_core/docker/security.py +158 -0
- kekkai_core/docker/signing.py +135 -0
- kekkai_core/redaction.py +84 -0
- kekkai_core/slsa/__init__.py +13 -0
- kekkai_core/slsa/verify.py +121 -0
- kekkai_core/windows/__init__.py +29 -0
- kekkai_core/windows/chocolatey.py +335 -0
- kekkai_core/windows/installer.py +256 -0
- kekkai_core/windows/scoop.py +165 -0
- kekkai_core/windows/validators.py +220 -0
- portal/__init__.py +19 -0
- portal/api.py +155 -0
- portal/auth.py +103 -0
- portal/enterprise/__init__.py +32 -0
- portal/enterprise/audit.py +435 -0
- portal/enterprise/licensing.py +342 -0
- portal/enterprise/rbac.py +276 -0
- portal/enterprise/saml.py +595 -0
- portal/ops/__init__.py +53 -0
- portal/ops/backup.py +553 -0
- portal/ops/log_shipper.py +469 -0
- portal/ops/monitoring.py +517 -0
- portal/ops/restore.py +469 -0
- portal/ops/secrets.py +408 -0
- portal/ops/upgrade.py +591 -0
- portal/tenants.py +340 -0
- portal/uploads.py +259 -0
- 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))
|