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,67 @@
|
|
|
1
|
+
from .backends import (
|
|
2
|
+
BackendType,
|
|
3
|
+
DockerBackend,
|
|
4
|
+
NativeBackend,
|
|
5
|
+
ScannerBackend,
|
|
6
|
+
ToolNotFoundError,
|
|
7
|
+
ToolVersionError,
|
|
8
|
+
detect_tool,
|
|
9
|
+
docker_available,
|
|
10
|
+
)
|
|
11
|
+
from .base import (
|
|
12
|
+
Finding,
|
|
13
|
+
ScanContext,
|
|
14
|
+
Scanner,
|
|
15
|
+
ScanResult,
|
|
16
|
+
Severity,
|
|
17
|
+
dedupe_findings,
|
|
18
|
+
)
|
|
19
|
+
from .falco import FalcoScanner, create_falco_scanner
|
|
20
|
+
from .gitleaks import GitleaksScanner
|
|
21
|
+
from .semgrep import SemgrepScanner
|
|
22
|
+
from .trivy import TrivyScanner
|
|
23
|
+
from .url_policy import UrlPolicy, UrlPolicyError, validate_target_url
|
|
24
|
+
from .zap import ZapScanner, create_zap_scanner
|
|
25
|
+
|
|
26
|
+
# Core scanners (SAST/SCA) - always available
|
|
27
|
+
SCANNER_REGISTRY: dict[str, type] = {
|
|
28
|
+
"trivy": TrivyScanner,
|
|
29
|
+
"semgrep": SemgrepScanner,
|
|
30
|
+
"gitleaks": GitleaksScanner,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Optional scanners (DAST/runtime) - require explicit configuration
|
|
34
|
+
# These are NOT in the default registry to prevent accidental use
|
|
35
|
+
OPTIONAL_SCANNERS: dict[str, type] = {
|
|
36
|
+
"zap": ZapScanner,
|
|
37
|
+
"falco": FalcoScanner,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"BackendType",
|
|
42
|
+
"create_falco_scanner",
|
|
43
|
+
"create_zap_scanner",
|
|
44
|
+
"dedupe_findings",
|
|
45
|
+
"detect_tool",
|
|
46
|
+
"docker_available",
|
|
47
|
+
"DockerBackend",
|
|
48
|
+
"FalcoScanner",
|
|
49
|
+
"Finding",
|
|
50
|
+
"GitleaksScanner",
|
|
51
|
+
"NativeBackend",
|
|
52
|
+
"OPTIONAL_SCANNERS",
|
|
53
|
+
"ScanContext",
|
|
54
|
+
"ScannerBackend",
|
|
55
|
+
"ScanResult",
|
|
56
|
+
"Scanner",
|
|
57
|
+
"SCANNER_REGISTRY",
|
|
58
|
+
"SemgrepScanner",
|
|
59
|
+
"Severity",
|
|
60
|
+
"ToolNotFoundError",
|
|
61
|
+
"ToolVersionError",
|
|
62
|
+
"TrivyScanner",
|
|
63
|
+
"UrlPolicy",
|
|
64
|
+
"UrlPolicyError",
|
|
65
|
+
"validate_target_url",
|
|
66
|
+
"ZapScanner",
|
|
67
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .base import BackendType, ScannerBackend
|
|
2
|
+
from .docker import DockerBackend, docker_available
|
|
3
|
+
from .native import NativeBackend, ToolNotFoundError, ToolVersionError, detect_tool
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"BackendType",
|
|
7
|
+
"detect_tool",
|
|
8
|
+
"docker_available",
|
|
9
|
+
"DockerBackend",
|
|
10
|
+
"NativeBackend",
|
|
11
|
+
"ScannerBackend",
|
|
12
|
+
"ToolNotFoundError",
|
|
13
|
+
"ToolVersionError",
|
|
14
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BackendType(str, Enum):
|
|
14
|
+
DOCKER = "docker"
|
|
15
|
+
NATIVE = "native"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ExecutionResult:
|
|
20
|
+
"""Result of executing a scanner command."""
|
|
21
|
+
|
|
22
|
+
exit_code: int
|
|
23
|
+
stdout: str
|
|
24
|
+
stderr: str
|
|
25
|
+
duration_ms: int
|
|
26
|
+
timed_out: bool
|
|
27
|
+
backend: BackendType
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ScannerBackend(ABC):
|
|
31
|
+
"""Abstract base class for scanner execution backends."""
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def backend_type(self) -> BackendType:
|
|
36
|
+
"""Return the backend type."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def is_available(self) -> tuple[bool, str]:
|
|
41
|
+
"""Check if this backend is available.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Tuple of (available, reason)
|
|
45
|
+
"""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def execute(
|
|
50
|
+
self,
|
|
51
|
+
tool: str,
|
|
52
|
+
args: list[str],
|
|
53
|
+
repo_path: Path,
|
|
54
|
+
output_path: Path,
|
|
55
|
+
timeout_seconds: int = 600,
|
|
56
|
+
env: dict[str, str] | None = None,
|
|
57
|
+
network_required: bool = False,
|
|
58
|
+
) -> ExecutionResult:
|
|
59
|
+
"""Execute a scanner tool.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
tool: Tool name or image reference
|
|
63
|
+
args: Command arguments
|
|
64
|
+
repo_path: Path to repository to scan
|
|
65
|
+
output_path: Path for output files
|
|
66
|
+
timeout_seconds: Execution timeout
|
|
67
|
+
env: Environment variables to pass
|
|
68
|
+
network_required: Whether network access is needed
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
ExecutionResult with output and status
|
|
72
|
+
"""
|
|
73
|
+
...
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess # nosec B404
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .base import BackendType, ExecutionResult, ScannerBackend
|
|
9
|
+
|
|
10
|
+
_docker_available_cache: tuple[bool, str] | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def docker_available(force_check: bool = False) -> tuple[bool, str]:
|
|
14
|
+
"""Check if Docker is available and running.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
force_check: Bypass cache and re-check
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Tuple of (available, reason)
|
|
21
|
+
"""
|
|
22
|
+
global _docker_available_cache
|
|
23
|
+
|
|
24
|
+
if _docker_available_cache is not None and not force_check:
|
|
25
|
+
return _docker_available_cache
|
|
26
|
+
|
|
27
|
+
docker_path = shutil.which("docker")
|
|
28
|
+
if not docker_path:
|
|
29
|
+
_docker_available_cache = (False, "Docker not found in PATH")
|
|
30
|
+
return _docker_available_cache
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
result = subprocess.run( # noqa: S603 # nosec B603
|
|
34
|
+
[docker_path, "info"],
|
|
35
|
+
capture_output=True,
|
|
36
|
+
text=True,
|
|
37
|
+
timeout=10,
|
|
38
|
+
check=False,
|
|
39
|
+
)
|
|
40
|
+
if result.returncode == 0:
|
|
41
|
+
_docker_available_cache = (True, "Docker available")
|
|
42
|
+
return _docker_available_cache
|
|
43
|
+
_docker_available_cache = (False, f"Docker not running: {result.stderr.strip()}")
|
|
44
|
+
return _docker_available_cache
|
|
45
|
+
except subprocess.TimeoutExpired:
|
|
46
|
+
_docker_available_cache = (False, "Docker info timed out")
|
|
47
|
+
return _docker_available_cache
|
|
48
|
+
except OSError as e:
|
|
49
|
+
_docker_available_cache = (False, f"Docker error: {e}")
|
|
50
|
+
return _docker_available_cache
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DockerBackend(ScannerBackend):
|
|
54
|
+
"""Docker-based scanner execution backend."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
read_only: bool = True,
|
|
59
|
+
no_new_privileges: bool = True,
|
|
60
|
+
memory_limit: str = "2g",
|
|
61
|
+
cpu_limit: str = "2",
|
|
62
|
+
) -> None:
|
|
63
|
+
self._read_only = read_only
|
|
64
|
+
self._no_new_privileges = no_new_privileges
|
|
65
|
+
self._memory_limit = memory_limit
|
|
66
|
+
self._cpu_limit = cpu_limit
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def backend_type(self) -> BackendType:
|
|
70
|
+
return BackendType.DOCKER
|
|
71
|
+
|
|
72
|
+
def is_available(self) -> tuple[bool, str]:
|
|
73
|
+
return docker_available()
|
|
74
|
+
|
|
75
|
+
def execute(
|
|
76
|
+
self,
|
|
77
|
+
tool: str,
|
|
78
|
+
args: list[str],
|
|
79
|
+
repo_path: Path,
|
|
80
|
+
output_path: Path,
|
|
81
|
+
timeout_seconds: int = 600,
|
|
82
|
+
env: dict[str, str] | None = None,
|
|
83
|
+
network_required: bool = False,
|
|
84
|
+
skip_repo_mount: bool = False,
|
|
85
|
+
workdir: str | None = None,
|
|
86
|
+
output_mount: str | None = None,
|
|
87
|
+
user: str | None = "1000:1000",
|
|
88
|
+
) -> ExecutionResult:
|
|
89
|
+
"""Execute scanner in Docker container.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
tool: Docker image reference (e.g., "aquasec/trivy:latest")
|
|
93
|
+
args: Command arguments to run in container
|
|
94
|
+
repo_path: Path to repository (mounted read-only at /repo)
|
|
95
|
+
output_path: Path for output (mounted read-write at /output)
|
|
96
|
+
timeout_seconds: Execution timeout
|
|
97
|
+
env: Environment variables
|
|
98
|
+
network_required: Whether to allow network access
|
|
99
|
+
skip_repo_mount: Skip mounting repo (for DAST scanners)
|
|
100
|
+
workdir: Override working directory
|
|
101
|
+
output_mount: Override output mount point
|
|
102
|
+
user: User to run as (default: 1000:1000)
|
|
103
|
+
"""
|
|
104
|
+
docker_path = shutil.which("docker")
|
|
105
|
+
if not docker_path:
|
|
106
|
+
return ExecutionResult(
|
|
107
|
+
exit_code=127,
|
|
108
|
+
stdout="",
|
|
109
|
+
stderr="Docker not found",
|
|
110
|
+
duration_ms=0,
|
|
111
|
+
timed_out=False,
|
|
112
|
+
backend=self.backend_type,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
cmd = [docker_path, "run", "--rm"]
|
|
116
|
+
|
|
117
|
+
if user:
|
|
118
|
+
cmd.extend(["--user", user])
|
|
119
|
+
|
|
120
|
+
if self._read_only:
|
|
121
|
+
cmd.extend(["--read-only", "--tmpfs", "/tmp:rw,noexec,nosuid,size=512m"]) # nosec B108 # noqa: S108
|
|
122
|
+
|
|
123
|
+
if not network_required:
|
|
124
|
+
cmd.extend(["--network", "none"])
|
|
125
|
+
|
|
126
|
+
if self._no_new_privileges:
|
|
127
|
+
cmd.append("--security-opt=no-new-privileges")
|
|
128
|
+
|
|
129
|
+
if self._memory_limit:
|
|
130
|
+
cmd.extend(["--memory", self._memory_limit])
|
|
131
|
+
|
|
132
|
+
if self._cpu_limit:
|
|
133
|
+
cmd.extend(["--cpus", self._cpu_limit])
|
|
134
|
+
|
|
135
|
+
if not skip_repo_mount:
|
|
136
|
+
cmd.extend(["-v", f"{repo_path.resolve()}:/repo:ro"])
|
|
137
|
+
|
|
138
|
+
mount_point = output_mount or "/output"
|
|
139
|
+
cmd.extend(["-v", f"{output_path.resolve()}:{mount_point}:rw"])
|
|
140
|
+
cmd.extend(["-w", workdir or "/repo"])
|
|
141
|
+
|
|
142
|
+
if env:
|
|
143
|
+
for key, value in env.items():
|
|
144
|
+
cmd.extend(["-e", f"{key}={value}"])
|
|
145
|
+
|
|
146
|
+
cmd.append(tool)
|
|
147
|
+
cmd.extend(args)
|
|
148
|
+
|
|
149
|
+
start = time.monotonic()
|
|
150
|
+
try:
|
|
151
|
+
proc = subprocess.run( # noqa: S603 # nosec B603
|
|
152
|
+
cmd,
|
|
153
|
+
capture_output=True,
|
|
154
|
+
text=True,
|
|
155
|
+
timeout=timeout_seconds,
|
|
156
|
+
check=False,
|
|
157
|
+
)
|
|
158
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
159
|
+
return ExecutionResult(
|
|
160
|
+
exit_code=proc.returncode,
|
|
161
|
+
stdout=proc.stdout,
|
|
162
|
+
stderr=proc.stderr,
|
|
163
|
+
duration_ms=duration_ms,
|
|
164
|
+
timed_out=False,
|
|
165
|
+
backend=self.backend_type,
|
|
166
|
+
)
|
|
167
|
+
except subprocess.TimeoutExpired as exc:
|
|
168
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
169
|
+
stdout = exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "")
|
|
170
|
+
stderr = exc.stderr.decode() if isinstance(exc.stderr, bytes) else (exc.stderr or "")
|
|
171
|
+
return ExecutionResult(
|
|
172
|
+
exit_code=124,
|
|
173
|
+
stdout=stdout,
|
|
174
|
+
stderr=stderr,
|
|
175
|
+
duration_ms=duration_ms,
|
|
176
|
+
timed_out=True,
|
|
177
|
+
backend=self.backend_type,
|
|
178
|
+
)
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess # nosec B404
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from .base import BackendType, ExecutionResult, ScannerBackend
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ToolNotFoundError(RuntimeError):
|
|
19
|
+
"""Raised when a required tool is not found in PATH."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ToolVersionError(RuntimeError):
|
|
23
|
+
"""Raised when tool version doesn't meet requirements."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ToolInfo:
|
|
28
|
+
"""Information about a detected tool."""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
path: str
|
|
32
|
+
version: str
|
|
33
|
+
version_tuple: tuple[int, ...]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Minimum versions required for native execution
|
|
37
|
+
MIN_VERSIONS: dict[str, tuple[int, ...]] = {
|
|
38
|
+
"trivy": (0, 40, 0),
|
|
39
|
+
"semgrep": (1, 50, 0),
|
|
40
|
+
"gitleaks": (8, 18, 0),
|
|
41
|
+
"zap-cli": (0, 10, 0),
|
|
42
|
+
"falco": (0, 35, 0),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Version extraction patterns
|
|
46
|
+
VERSION_PATTERNS: dict[str, re.Pattern[str]] = {
|
|
47
|
+
"trivy": re.compile(r"Version:\s*(\d+\.\d+\.\d+)"),
|
|
48
|
+
"semgrep": re.compile(r"(\d+\.\d+\.\d+)"),
|
|
49
|
+
"gitleaks": re.compile(r"v?(\d+\.\d+\.\d+)"),
|
|
50
|
+
"zap-cli": re.compile(r"(\d+\.\d+\.\d+)"),
|
|
51
|
+
"falco": re.compile(r"(\d+\.\d+\.\d+)"),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_version(version_str: str) -> tuple[int, ...]:
|
|
56
|
+
"""Parse version string to tuple of integers."""
|
|
57
|
+
parts = version_str.split(".")
|
|
58
|
+
result: list[int] = []
|
|
59
|
+
for part in parts:
|
|
60
|
+
try:
|
|
61
|
+
result.append(int(part))
|
|
62
|
+
except ValueError:
|
|
63
|
+
break
|
|
64
|
+
return tuple(result) if result else (0,)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def detect_tool(
|
|
68
|
+
name: str,
|
|
69
|
+
min_version: tuple[int, ...] | None = None,
|
|
70
|
+
version_args: list[str] | None = None,
|
|
71
|
+
) -> ToolInfo:
|
|
72
|
+
"""Detect a tool in PATH and validate its version.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
name: Tool name to search for
|
|
76
|
+
min_version: Minimum required version tuple (e.g., (0, 40, 0))
|
|
77
|
+
version_args: Arguments to get version (default: ["--version"])
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
ToolInfo with path and version details
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ToolNotFoundError: If tool not found in PATH
|
|
84
|
+
ToolVersionError: If version doesn't meet minimum requirement
|
|
85
|
+
"""
|
|
86
|
+
tool_path = shutil.which(name)
|
|
87
|
+
|
|
88
|
+
# Also check ~/.kekkai/bin/ for installed tools
|
|
89
|
+
if not tool_path:
|
|
90
|
+
from kekkai.paths import bin_dir
|
|
91
|
+
|
|
92
|
+
kekkai_bin = bin_dir() / name
|
|
93
|
+
if kekkai_bin.exists() and os.access(kekkai_bin, os.X_OK):
|
|
94
|
+
tool_path = str(kekkai_bin)
|
|
95
|
+
|
|
96
|
+
if not tool_path:
|
|
97
|
+
raise ToolNotFoundError(f"{name} not found in PATH")
|
|
98
|
+
|
|
99
|
+
tool_path = os.path.realpath(tool_path)
|
|
100
|
+
if not os.path.isfile(tool_path):
|
|
101
|
+
raise ToolNotFoundError(f"{name} path is not a file: {tool_path}")
|
|
102
|
+
if not os.access(tool_path, os.X_OK):
|
|
103
|
+
raise ToolNotFoundError(f"{name} is not executable: {tool_path}")
|
|
104
|
+
|
|
105
|
+
version_cmd = version_args or ["--version"]
|
|
106
|
+
try:
|
|
107
|
+
result = subprocess.run( # noqa: S603 # nosec B603
|
|
108
|
+
[tool_path, *version_cmd],
|
|
109
|
+
capture_output=True,
|
|
110
|
+
text=True,
|
|
111
|
+
timeout=10,
|
|
112
|
+
check=False,
|
|
113
|
+
)
|
|
114
|
+
version_output = result.stdout + result.stderr
|
|
115
|
+
except (subprocess.TimeoutExpired, OSError) as e:
|
|
116
|
+
raise ToolVersionError(f"Failed to get {name} version: {e}") from e
|
|
117
|
+
|
|
118
|
+
pattern = VERSION_PATTERNS.get(name, re.compile(r"(\d+\.\d+\.\d+)"))
|
|
119
|
+
match = pattern.search(version_output)
|
|
120
|
+
if not match:
|
|
121
|
+
raise ToolVersionError(f"Could not parse {name} version from: {version_output[:200]}")
|
|
122
|
+
|
|
123
|
+
version_str = match.group(1)
|
|
124
|
+
version_tuple = _parse_version(version_str)
|
|
125
|
+
|
|
126
|
+
min_ver = min_version or MIN_VERSIONS.get(name)
|
|
127
|
+
if min_ver and version_tuple < min_ver:
|
|
128
|
+
min_ver_str = ".".join(str(v) for v in min_ver)
|
|
129
|
+
raise ToolVersionError(f"{name} version {version_str} is below minimum {min_ver_str}")
|
|
130
|
+
|
|
131
|
+
return ToolInfo(
|
|
132
|
+
name=name,
|
|
133
|
+
path=tool_path,
|
|
134
|
+
version=version_str,
|
|
135
|
+
version_tuple=version_tuple,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class NativeBackend(ScannerBackend):
|
|
140
|
+
"""Native binary scanner execution backend."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, env_allowlist: list[str] | None = None) -> None:
|
|
143
|
+
self._env_allowlist = env_allowlist or [
|
|
144
|
+
"PATH",
|
|
145
|
+
"HOME",
|
|
146
|
+
"USER",
|
|
147
|
+
"LANG",
|
|
148
|
+
"LC_ALL",
|
|
149
|
+
"TMPDIR",
|
|
150
|
+
"XDG_CONFIG_HOME",
|
|
151
|
+
"XDG_CACHE_HOME",
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def backend_type(self) -> BackendType:
|
|
156
|
+
return BackendType.NATIVE
|
|
157
|
+
|
|
158
|
+
def is_available(self) -> tuple[bool, str]:
|
|
159
|
+
return (True, "Native execution always available")
|
|
160
|
+
|
|
161
|
+
def execute(
|
|
162
|
+
self,
|
|
163
|
+
tool: str,
|
|
164
|
+
args: list[str],
|
|
165
|
+
repo_path: Path,
|
|
166
|
+
output_path: Path,
|
|
167
|
+
timeout_seconds: int = 600,
|
|
168
|
+
env: dict[str, str] | None = None,
|
|
169
|
+
network_required: bool = False,
|
|
170
|
+
) -> ExecutionResult:
|
|
171
|
+
"""Execute scanner tool natively.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
tool: Tool name (will be resolved via PATH)
|
|
175
|
+
args: Command arguments (must be list, no shell expansion)
|
|
176
|
+
repo_path: Path to repository to scan
|
|
177
|
+
output_path: Path for output files
|
|
178
|
+
timeout_seconds: Execution timeout
|
|
179
|
+
env: Additional environment variables
|
|
180
|
+
network_required: Ignored for native execution (always has network)
|
|
181
|
+
"""
|
|
182
|
+
if not isinstance(args, list):
|
|
183
|
+
return ExecutionResult(
|
|
184
|
+
exit_code=1,
|
|
185
|
+
stdout="",
|
|
186
|
+
stderr="Arguments must be a list (no shell expansion)",
|
|
187
|
+
duration_ms=0,
|
|
188
|
+
timed_out=False,
|
|
189
|
+
backend=self.backend_type,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
tool_path = shutil.which(tool)
|
|
193
|
+
if not tool_path:
|
|
194
|
+
return ExecutionResult(
|
|
195
|
+
exit_code=127,
|
|
196
|
+
stdout="",
|
|
197
|
+
stderr=f"Tool not found: {tool}",
|
|
198
|
+
duration_ms=0,
|
|
199
|
+
timed_out=False,
|
|
200
|
+
backend=self.backend_type,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
safe_env = {key: os.environ[key] for key in self._env_allowlist if key in os.environ}
|
|
204
|
+
if env:
|
|
205
|
+
safe_env.update(env)
|
|
206
|
+
|
|
207
|
+
cmd = [tool_path, *args]
|
|
208
|
+
|
|
209
|
+
start = time.monotonic()
|
|
210
|
+
try:
|
|
211
|
+
proc = subprocess.run( # noqa: S603 # nosec B603
|
|
212
|
+
cmd,
|
|
213
|
+
cwd=str(repo_path),
|
|
214
|
+
env=safe_env,
|
|
215
|
+
capture_output=True,
|
|
216
|
+
text=True,
|
|
217
|
+
timeout=timeout_seconds,
|
|
218
|
+
check=False,
|
|
219
|
+
)
|
|
220
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
221
|
+
return ExecutionResult(
|
|
222
|
+
exit_code=proc.returncode,
|
|
223
|
+
stdout=proc.stdout,
|
|
224
|
+
stderr=proc.stderr,
|
|
225
|
+
duration_ms=duration_ms,
|
|
226
|
+
timed_out=False,
|
|
227
|
+
backend=self.backend_type,
|
|
228
|
+
)
|
|
229
|
+
except subprocess.TimeoutExpired as exc:
|
|
230
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
231
|
+
stdout = exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "")
|
|
232
|
+
stderr = exc.stderr.decode() if isinstance(exc.stderr, bytes) else (exc.stderr or "")
|
|
233
|
+
return ExecutionResult(
|
|
234
|
+
exit_code=124,
|
|
235
|
+
stdout=stdout,
|
|
236
|
+
stderr=stderr,
|
|
237
|
+
duration_ms=duration_ms,
|
|
238
|
+
timed_out=True,
|
|
239
|
+
backend=self.backend_type,
|
|
240
|
+
)
|
kekkai/scanners/base.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Severity(str, Enum):
|
|
14
|
+
CRITICAL = "critical"
|
|
15
|
+
HIGH = "high"
|
|
16
|
+
MEDIUM = "medium"
|
|
17
|
+
LOW = "low"
|
|
18
|
+
INFO = "info"
|
|
19
|
+
UNKNOWN = "unknown"
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_string(cls, value: str) -> Severity:
|
|
23
|
+
normalized = value.lower().strip()
|
|
24
|
+
mapping = {
|
|
25
|
+
"critical": cls.CRITICAL,
|
|
26
|
+
"high": cls.HIGH,
|
|
27
|
+
"medium": cls.MEDIUM,
|
|
28
|
+
"moderate": cls.MEDIUM,
|
|
29
|
+
"low": cls.LOW,
|
|
30
|
+
"info": cls.INFO,
|
|
31
|
+
"informational": cls.INFO,
|
|
32
|
+
"warning": cls.LOW,
|
|
33
|
+
}
|
|
34
|
+
return mapping.get(normalized, cls.UNKNOWN)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Finding:
|
|
39
|
+
scanner: str
|
|
40
|
+
title: str
|
|
41
|
+
severity: Severity
|
|
42
|
+
description: str
|
|
43
|
+
file_path: str | None = None
|
|
44
|
+
line: int | None = None
|
|
45
|
+
rule_id: str | None = None
|
|
46
|
+
cwe: str | None = None
|
|
47
|
+
cve: str | None = None
|
|
48
|
+
package_name: str | None = None
|
|
49
|
+
package_version: str | None = None
|
|
50
|
+
fixed_version: str | None = None
|
|
51
|
+
extra: dict[str, str] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
def dedupe_hash(self) -> str:
|
|
54
|
+
parts = [
|
|
55
|
+
self.scanner,
|
|
56
|
+
self.title,
|
|
57
|
+
self.file_path or "",
|
|
58
|
+
str(self.line or ""),
|
|
59
|
+
self.rule_id or "",
|
|
60
|
+
self.cve or "",
|
|
61
|
+
self.package_name or "",
|
|
62
|
+
self.package_version or "",
|
|
63
|
+
]
|
|
64
|
+
content = "|".join(parts)
|
|
65
|
+
return hashlib.sha256(content.encode()).hexdigest()[:16]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class ScanContext:
|
|
70
|
+
repo_path: Path
|
|
71
|
+
output_dir: Path
|
|
72
|
+
run_id: str
|
|
73
|
+
commit_sha: str | None = None
|
|
74
|
+
timeout_seconds: int = 600
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class ScanResult:
|
|
79
|
+
scanner: str
|
|
80
|
+
success: bool
|
|
81
|
+
findings: list[Finding]
|
|
82
|
+
raw_output_path: Path | None = None
|
|
83
|
+
error: str | None = None
|
|
84
|
+
duration_ms: int = 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@runtime_checkable
|
|
88
|
+
class Scanner(Protocol):
|
|
89
|
+
@property
|
|
90
|
+
def name(self) -> str: ...
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def scan_type(self) -> str:
|
|
94
|
+
"""DefectDojo scan type for import."""
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
def run(self, ctx: ScanContext) -> ScanResult: ...
|
|
98
|
+
|
|
99
|
+
def parse(self, raw_output: str) -> list[Finding]: ...
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def dedupe_findings(findings: Sequence[Finding]) -> list[Finding]:
|
|
103
|
+
seen: set[str] = set()
|
|
104
|
+
result: list[Finding] = []
|
|
105
|
+
for f in findings:
|
|
106
|
+
h = f.dedupe_hash()
|
|
107
|
+
if h not in seen:
|
|
108
|
+
seen.add(h)
|
|
109
|
+
result.append(f)
|
|
110
|
+
return result
|