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,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
+ )
@@ -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