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
kekkai/output.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Rich CLI output utilities for Kekkai.
|
|
2
|
+
|
|
3
|
+
Provides professional terminal rendering with TTY-awareness,
|
|
4
|
+
branded theming, and security-focused sanitization.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
from rich.theme import Theme
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"console",
|
|
24
|
+
"splash",
|
|
25
|
+
"print_scan_summary",
|
|
26
|
+
"sanitize_for_terminal",
|
|
27
|
+
"sanitize_error",
|
|
28
|
+
"ScanSummaryRow",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
|
|
32
|
+
|
|
33
|
+
KEKKAI_THEME = Theme(
|
|
34
|
+
{
|
|
35
|
+
"info": "dim cyan",
|
|
36
|
+
"warning": "yellow",
|
|
37
|
+
"danger": "bold red",
|
|
38
|
+
"success": "bold green",
|
|
39
|
+
"header": "bold white on blue",
|
|
40
|
+
"muted": "dim white",
|
|
41
|
+
"brand": "bold cyan",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
console = Console(theme=KEKKAI_THEME)
|
|
46
|
+
|
|
47
|
+
BANNER_ASCII = r"""
|
|
48
|
+
_ __ _ _ _
|
|
49
|
+
| |/ /___ | | _| | ____ _(_)
|
|
50
|
+
| ' // _ \| |/ / |/ / _` | |
|
|
51
|
+
| . \ __/| <| < (_| | |
|
|
52
|
+
|_|\_\___|_|\_\_|\_\__,_|_|
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
VERSION = "1.0.0"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def splash(*, force_plain: bool = False) -> str:
|
|
59
|
+
"""Render the Kekkai splash banner.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
force_plain: If True, return plain text regardless of TTY.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Banner string for display.
|
|
66
|
+
"""
|
|
67
|
+
if force_plain or not console.is_terminal:
|
|
68
|
+
return f"Kekkai v{VERSION} - Local-First AppSec Orchestrator"
|
|
69
|
+
|
|
70
|
+
banner_text = Text(BANNER_ASCII.strip(), style="brand")
|
|
71
|
+
panel = Panel(
|
|
72
|
+
banner_text,
|
|
73
|
+
subtitle=f"[muted]v{VERSION} — Local-First AppSec Orchestrator[/muted]",
|
|
74
|
+
border_style="blue",
|
|
75
|
+
padding=(0, 2),
|
|
76
|
+
)
|
|
77
|
+
with console.capture() as capture:
|
|
78
|
+
console.print(panel)
|
|
79
|
+
result: str = capture.get()
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def splash_minimal() -> str:
|
|
84
|
+
"""Return minimal splash for non-TTY environments."""
|
|
85
|
+
return f"Kekkai v{VERSION} - Local-First AppSec Orchestrator"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class ScanSummaryRow:
|
|
90
|
+
"""A row in the scan summary table."""
|
|
91
|
+
|
|
92
|
+
scanner: str
|
|
93
|
+
success: bool
|
|
94
|
+
findings_count: int
|
|
95
|
+
duration_ms: int
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def print_scan_summary(
|
|
99
|
+
rows: Sequence[ScanSummaryRow],
|
|
100
|
+
*,
|
|
101
|
+
force_plain: bool = False,
|
|
102
|
+
) -> str:
|
|
103
|
+
"""Render scan results as a formatted table.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
rows: Scan result rows to display.
|
|
107
|
+
force_plain: If True, return plain text regardless of TTY.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Formatted table string.
|
|
111
|
+
"""
|
|
112
|
+
if force_plain or not console.is_terminal:
|
|
113
|
+
lines = ["Scan Summary:"]
|
|
114
|
+
for row in rows:
|
|
115
|
+
status = "OK" if row.success else "FAIL"
|
|
116
|
+
scanner_name = sanitize_for_terminal(row.scanner)
|
|
117
|
+
lines.append(
|
|
118
|
+
f" {scanner_name}: {status}, {row.findings_count} findings, {row.duration_ms}ms"
|
|
119
|
+
)
|
|
120
|
+
return "\n".join(lines)
|
|
121
|
+
|
|
122
|
+
table = Table(title="Scan Summary", show_header=True, header_style="bold")
|
|
123
|
+
table.add_column("Scanner", style="cyan")
|
|
124
|
+
table.add_column("Status", style="green")
|
|
125
|
+
table.add_column("Findings", justify="right")
|
|
126
|
+
table.add_column("Duration", justify="right", style="muted")
|
|
127
|
+
|
|
128
|
+
for row in rows:
|
|
129
|
+
status = "[green]✓[/green]" if row.success else "[red]✗[/red]"
|
|
130
|
+
table.add_row(
|
|
131
|
+
sanitize_for_terminal(row.scanner),
|
|
132
|
+
status,
|
|
133
|
+
str(row.findings_count),
|
|
134
|
+
f"{row.duration_ms}ms",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
with console.capture() as capture:
|
|
138
|
+
console.print(table)
|
|
139
|
+
result: str = capture.get()
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def sanitize_for_terminal(text: str) -> str:
|
|
144
|
+
"""Strip ANSI escape sequences from untrusted content.
|
|
145
|
+
|
|
146
|
+
Prevents terminal escape injection attacks where malicious content
|
|
147
|
+
could manipulate terminal display or hide warnings.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
text: Potentially untrusted text to sanitize.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Text with all ANSI escape sequences removed.
|
|
154
|
+
"""
|
|
155
|
+
return ANSI_ESCAPE_PATTERN.sub("", text)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def sanitize_error(error: str | Exception, *, max_length: int = 200) -> str:
|
|
159
|
+
"""Sanitize error messages for user display.
|
|
160
|
+
|
|
161
|
+
Removes sensitive information like full paths and stack traces
|
|
162
|
+
to prevent information disclosure.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
error: Error message or exception to sanitize.
|
|
166
|
+
max_length: Maximum length of returned message.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Sanitized, truncated error message.
|
|
170
|
+
"""
|
|
171
|
+
message = str(error) if isinstance(error, Exception) else error
|
|
172
|
+
message = ANSI_ESCAPE_PATTERN.sub("", message)
|
|
173
|
+
message = re.sub(r"/[^\s:]+", "[path]", message)
|
|
174
|
+
message = re.sub(r"\\[^\s:]+", "[path]", message)
|
|
175
|
+
message = re.sub(r"line \d+", "line [N]", message, flags=re.IGNORECASE)
|
|
176
|
+
|
|
177
|
+
if len(message) > max_length:
|
|
178
|
+
message = message[:max_length] + "..."
|
|
179
|
+
|
|
180
|
+
return message
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def print_quick_start() -> str:
|
|
184
|
+
"""Render Quick Start guide panel.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Formatted Quick Start panel string.
|
|
188
|
+
"""
|
|
189
|
+
if not console.is_terminal:
|
|
190
|
+
return (
|
|
191
|
+
"Quick Start:\n"
|
|
192
|
+
" 1. kekkai scan --repo . # Scan current directory\n"
|
|
193
|
+
" 2. kekkai threatflow # Generate threat model\n"
|
|
194
|
+
" 3. kekkai dojo up # Start DefectDojo\n"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
content = Text()
|
|
198
|
+
content.append("1. ", style="bold cyan")
|
|
199
|
+
content.append("kekkai scan --repo .", style="green")
|
|
200
|
+
content.append(" # Scan current directory\n")
|
|
201
|
+
content.append("2. ", style="bold cyan")
|
|
202
|
+
content.append("kekkai threatflow", style="green")
|
|
203
|
+
content.append(" # Generate threat model\n")
|
|
204
|
+
content.append("3. ", style="bold cyan")
|
|
205
|
+
content.append("kekkai dojo up", style="green")
|
|
206
|
+
content.append(" # Start DefectDojo")
|
|
207
|
+
|
|
208
|
+
panel = Panel(
|
|
209
|
+
content,
|
|
210
|
+
title="[bold]Quick Start[/bold]",
|
|
211
|
+
border_style="green",
|
|
212
|
+
padding=(1, 2),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
with console.capture() as capture:
|
|
216
|
+
console.print(panel)
|
|
217
|
+
result: str = capture.get()
|
|
218
|
+
return result
|
kekkai/paths.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def app_base_dir() -> Path:
|
|
8
|
+
override = os.environ.get("KEKKAI_HOME")
|
|
9
|
+
if override:
|
|
10
|
+
return Path(override).expanduser().resolve()
|
|
11
|
+
return Path("~/.kekkai").expanduser().resolve()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def config_path() -> Path:
|
|
15
|
+
return app_base_dir() / "kekkai.toml"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def bin_dir() -> Path:
|
|
19
|
+
"""Get the directory for installed tool binaries."""
|
|
20
|
+
path = app_base_dir() / "bin"
|
|
21
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
return path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ensure_dir(path: Path) -> None:
|
|
26
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def safe_join(base: Path, *parts: str) -> Path:
|
|
30
|
+
base_resolved = base.expanduser().resolve()
|
|
31
|
+
candidate = base_resolved.joinpath(*parts).resolve()
|
|
32
|
+
try:
|
|
33
|
+
candidate.relative_to(base_resolved)
|
|
34
|
+
except ValueError as exc:
|
|
35
|
+
raise ValueError("path escapes base directory") from exc
|
|
36
|
+
return candidate
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_within_base(base: Path, path: Path) -> bool:
|
|
40
|
+
base_resolved = base.expanduser().resolve()
|
|
41
|
+
path_resolved = path.expanduser().resolve()
|
|
42
|
+
try:
|
|
43
|
+
path_resolved.relative_to(base_resolved)
|
|
44
|
+
except ValueError:
|
|
45
|
+
return False
|
|
46
|
+
return True
|
kekkai/policy.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Policy enforcement module for CI-grade security gates.
|
|
2
|
+
|
|
3
|
+
Evaluates scan findings against configurable thresholds and produces
|
|
4
|
+
machine-readable results for CI/CD integration.
|
|
5
|
+
|
|
6
|
+
ASVS Requirements:
|
|
7
|
+
- V2.3.2: Business logic limits (threshold validation)
|
|
8
|
+
- V16.3.3: Log attempts to bypass controls
|
|
9
|
+
- V16.5.3: Fail securely (errors default to failure)
|
|
10
|
+
- V15.3.5: Strict typing for comparisons
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import asdict, dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from .scanners.base import Finding
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Exit codes for CI mode
|
|
29
|
+
EXIT_SUCCESS = 0
|
|
30
|
+
EXIT_POLICY_VIOLATION = 1
|
|
31
|
+
EXIT_SCAN_ERROR = 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class PolicyConfig:
|
|
36
|
+
"""Configuration for policy enforcement.
|
|
37
|
+
|
|
38
|
+
Thresholds define the maximum number of findings allowed per severity.
|
|
39
|
+
Setting a threshold to 0 means any finding of that severity fails the policy.
|
|
40
|
+
Setting to -1 (or None) means no limit for that severity.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
fail_on_critical: bool = True
|
|
44
|
+
fail_on_high: bool = True
|
|
45
|
+
fail_on_medium: bool = False
|
|
46
|
+
fail_on_low: bool = False
|
|
47
|
+
fail_on_info: bool = False
|
|
48
|
+
|
|
49
|
+
max_critical: int = 0
|
|
50
|
+
max_high: int = 0
|
|
51
|
+
max_medium: int = -1 # -1 = no limit
|
|
52
|
+
max_low: int = -1
|
|
53
|
+
max_info: int = -1
|
|
54
|
+
|
|
55
|
+
# Total findings limit across all severities
|
|
56
|
+
max_total: int = -1
|
|
57
|
+
|
|
58
|
+
def validate(self) -> list[str]:
|
|
59
|
+
"""Validate policy configuration, return list of errors."""
|
|
60
|
+
errors: list[str] = []
|
|
61
|
+
|
|
62
|
+
# Validate threshold values are integers and reasonable
|
|
63
|
+
for attr in ("max_critical", "max_high", "max_medium", "max_low", "max_info", "max_total"):
|
|
64
|
+
value = getattr(self, attr)
|
|
65
|
+
if not isinstance(value, int):
|
|
66
|
+
errors.append(f"{attr} must be an integer, got {type(value).__name__}")
|
|
67
|
+
elif value < -1:
|
|
68
|
+
errors.append(f"{attr} must be >= -1, got {value}")
|
|
69
|
+
|
|
70
|
+
return errors
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class PolicyViolation:
|
|
75
|
+
"""A single policy violation."""
|
|
76
|
+
|
|
77
|
+
severity: str
|
|
78
|
+
count: int
|
|
79
|
+
threshold: int
|
|
80
|
+
message: str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class SeverityCount:
|
|
85
|
+
"""Count of findings by severity."""
|
|
86
|
+
|
|
87
|
+
critical: int = 0
|
|
88
|
+
high: int = 0
|
|
89
|
+
medium: int = 0
|
|
90
|
+
low: int = 0
|
|
91
|
+
info: int = 0
|
|
92
|
+
unknown: int = 0
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def total(self) -> int:
|
|
96
|
+
return self.critical + self.high + self.medium + self.low + self.info + self.unknown
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class PolicyResult:
|
|
101
|
+
"""Result of policy evaluation."""
|
|
102
|
+
|
|
103
|
+
passed: bool
|
|
104
|
+
exit_code: int
|
|
105
|
+
violations: list[PolicyViolation] = field(default_factory=list)
|
|
106
|
+
counts: SeverityCount = field(default_factory=SeverityCount)
|
|
107
|
+
scan_errors: list[str] = field(default_factory=list)
|
|
108
|
+
|
|
109
|
+
def to_dict(self) -> dict[str, object]:
|
|
110
|
+
"""Convert to dictionary for JSON serialization."""
|
|
111
|
+
return {
|
|
112
|
+
"passed": self.passed,
|
|
113
|
+
"exit_code": self.exit_code,
|
|
114
|
+
"violations": [asdict(v) for v in self.violations],
|
|
115
|
+
"counts": asdict(self.counts),
|
|
116
|
+
"scan_errors": self.scan_errors,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
def to_json(self, indent: int = 2) -> str:
|
|
120
|
+
"""Convert to JSON string."""
|
|
121
|
+
return json.dumps(self.to_dict(), indent=indent, sort_keys=True)
|
|
122
|
+
|
|
123
|
+
def write_json(self, path: Path) -> None:
|
|
124
|
+
"""Write result to JSON file."""
|
|
125
|
+
path.write_text(self.to_json())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def count_findings_by_severity(findings: Sequence[Finding]) -> SeverityCount:
|
|
129
|
+
"""Count findings grouped by severity level."""
|
|
130
|
+
counts = {
|
|
131
|
+
"critical": 0,
|
|
132
|
+
"high": 0,
|
|
133
|
+
"medium": 0,
|
|
134
|
+
"low": 0,
|
|
135
|
+
"info": 0,
|
|
136
|
+
"unknown": 0,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for finding in findings:
|
|
140
|
+
severity = finding.severity.value.lower()
|
|
141
|
+
if severity in counts:
|
|
142
|
+
counts[severity] += 1
|
|
143
|
+
else:
|
|
144
|
+
counts["unknown"] += 1
|
|
145
|
+
|
|
146
|
+
return SeverityCount(**counts)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def evaluate_policy(
|
|
150
|
+
findings: Sequence[Finding],
|
|
151
|
+
config: PolicyConfig,
|
|
152
|
+
scan_errors: Sequence[str] | None = None,
|
|
153
|
+
) -> PolicyResult:
|
|
154
|
+
"""Evaluate findings against policy configuration.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
findings: List of findings from scanners
|
|
158
|
+
config: Policy configuration with thresholds
|
|
159
|
+
scan_errors: Optional list of scan errors to include
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
PolicyResult with pass/fail status and violations
|
|
163
|
+
"""
|
|
164
|
+
# Validate config first - fail securely on invalid config
|
|
165
|
+
config_errors = config.validate()
|
|
166
|
+
if config_errors:
|
|
167
|
+
logger.warning("Invalid policy config: %s", config_errors)
|
|
168
|
+
return PolicyResult(
|
|
169
|
+
passed=False,
|
|
170
|
+
exit_code=EXIT_SCAN_ERROR,
|
|
171
|
+
scan_errors=[f"Invalid policy configuration: {e}" for e in config_errors],
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
errors = list(scan_errors) if scan_errors else []
|
|
175
|
+
counts = count_findings_by_severity(findings)
|
|
176
|
+
violations: list[PolicyViolation] = []
|
|
177
|
+
|
|
178
|
+
# Check severity thresholds
|
|
179
|
+
severity_checks = [
|
|
180
|
+
("critical", config.fail_on_critical, config.max_critical, counts.critical),
|
|
181
|
+
("high", config.fail_on_high, config.max_high, counts.high),
|
|
182
|
+
("medium", config.fail_on_medium, config.max_medium, counts.medium),
|
|
183
|
+
("low", config.fail_on_low, config.max_low, counts.low),
|
|
184
|
+
("info", config.fail_on_info, config.max_info, counts.info),
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
for severity, fail_on, max_count, actual_count in severity_checks:
|
|
188
|
+
# Skip if not configured to fail on this severity
|
|
189
|
+
if not fail_on:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
# -1 means no limit
|
|
193
|
+
if max_count == -1:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
# Check threshold - use strict integer comparison (ASVS V15.3.5)
|
|
197
|
+
if not isinstance(actual_count, int) or not isinstance(max_count, int):
|
|
198
|
+
logger.warning(
|
|
199
|
+
"Policy bypass attempt: non-integer comparison for %s (count=%r, max=%r)",
|
|
200
|
+
severity,
|
|
201
|
+
actual_count,
|
|
202
|
+
max_count,
|
|
203
|
+
)
|
|
204
|
+
violations.append(
|
|
205
|
+
PolicyViolation(
|
|
206
|
+
severity=severity,
|
|
207
|
+
count=actual_count if isinstance(actual_count, int) else 0,
|
|
208
|
+
threshold=max_count if isinstance(max_count, int) else 0,
|
|
209
|
+
message=f"Type error in {severity} threshold check",
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
if actual_count > max_count:
|
|
215
|
+
violations.append(
|
|
216
|
+
PolicyViolation(
|
|
217
|
+
severity=severity,
|
|
218
|
+
count=actual_count,
|
|
219
|
+
threshold=max_count,
|
|
220
|
+
message=f"Found {actual_count} {severity} findings (max allowed: {max_count})",
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Check total threshold
|
|
225
|
+
if config.max_total >= 0 and counts.total > config.max_total:
|
|
226
|
+
violations.append(
|
|
227
|
+
PolicyViolation(
|
|
228
|
+
severity="total",
|
|
229
|
+
count=counts.total,
|
|
230
|
+
threshold=config.max_total,
|
|
231
|
+
message=f"Found {counts.total} total findings (max: {config.max_total})",
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Determine final status
|
|
236
|
+
if errors:
|
|
237
|
+
# Scan errors = fail securely (ASVS V16.5.3)
|
|
238
|
+
return PolicyResult(
|
|
239
|
+
passed=False,
|
|
240
|
+
exit_code=EXIT_SCAN_ERROR,
|
|
241
|
+
violations=violations,
|
|
242
|
+
counts=counts,
|
|
243
|
+
scan_errors=errors,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if violations:
|
|
247
|
+
return PolicyResult(
|
|
248
|
+
passed=False,
|
|
249
|
+
exit_code=EXIT_POLICY_VIOLATION,
|
|
250
|
+
violations=violations,
|
|
251
|
+
counts=counts,
|
|
252
|
+
scan_errors=errors,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return PolicyResult(
|
|
256
|
+
passed=True,
|
|
257
|
+
exit_code=EXIT_SUCCESS,
|
|
258
|
+
violations=[],
|
|
259
|
+
counts=counts,
|
|
260
|
+
scan_errors=[],
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def parse_fail_on(fail_on_str: str) -> PolicyConfig:
|
|
265
|
+
"""Parse --fail-on shorthand to PolicyConfig.
|
|
266
|
+
|
|
267
|
+
Examples:
|
|
268
|
+
"critical" -> fail on critical only
|
|
269
|
+
"critical,high" -> fail on critical and high
|
|
270
|
+
"medium" -> fail on critical, high, and medium
|
|
271
|
+
"""
|
|
272
|
+
parts = [p.strip().lower() for p in fail_on_str.split(",") if p.strip()]
|
|
273
|
+
|
|
274
|
+
if not parts:
|
|
275
|
+
return PolicyConfig()
|
|
276
|
+
|
|
277
|
+
# Determine cascade: if "medium" is specified, also fail on high and critical
|
|
278
|
+
severities = {"critical", "high", "medium", "low", "info"}
|
|
279
|
+
cascade_order = ["critical", "high", "medium", "low", "info"]
|
|
280
|
+
|
|
281
|
+
fail_on = {
|
|
282
|
+
"critical": False,
|
|
283
|
+
"high": False,
|
|
284
|
+
"medium": False,
|
|
285
|
+
"low": False,
|
|
286
|
+
"info": False,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
for part in parts:
|
|
290
|
+
if part not in severities:
|
|
291
|
+
logger.warning("Unknown severity in --fail-on: %s", part)
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
# Enable this severity and all higher ones
|
|
295
|
+
idx = cascade_order.index(part)
|
|
296
|
+
for sev in cascade_order[: idx + 1]:
|
|
297
|
+
fail_on[sev] = True
|
|
298
|
+
|
|
299
|
+
return PolicyConfig(
|
|
300
|
+
fail_on_critical=fail_on["critical"],
|
|
301
|
+
fail_on_high=fail_on["high"],
|
|
302
|
+
fail_on_medium=fail_on["medium"],
|
|
303
|
+
fail_on_low=fail_on["low"],
|
|
304
|
+
fail_on_info=fail_on["info"],
|
|
305
|
+
max_critical=0 if fail_on["critical"] else -1,
|
|
306
|
+
max_high=0 if fail_on["high"] else -1,
|
|
307
|
+
max_medium=0 if fail_on["medium"] else -1,
|
|
308
|
+
max_low=0 if fail_on["low"] else -1,
|
|
309
|
+
max_info=0 if fail_on["info"] else -1,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def default_ci_policy() -> PolicyConfig:
|
|
314
|
+
"""Default policy for CI mode: fail on critical and high."""
|
|
315
|
+
return PolicyConfig(
|
|
316
|
+
fail_on_critical=True,
|
|
317
|
+
fail_on_high=True,
|
|
318
|
+
fail_on_medium=False,
|
|
319
|
+
fail_on_low=False,
|
|
320
|
+
fail_on_info=False,
|
|
321
|
+
max_critical=0,
|
|
322
|
+
max_high=0,
|
|
323
|
+
max_medium=-1,
|
|
324
|
+
max_low=-1,
|
|
325
|
+
max_info=-1,
|
|
326
|
+
)
|
kekkai/runner.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess # nosec B404
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from kekkai_core import redact
|
|
10
|
+
|
|
11
|
+
from .config import PipelineStep
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class StepResult:
|
|
16
|
+
name: str
|
|
17
|
+
args: list[str]
|
|
18
|
+
exit_code: int
|
|
19
|
+
duration_ms: int
|
|
20
|
+
stdout: str
|
|
21
|
+
stderr: str
|
|
22
|
+
timed_out: bool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_step(
|
|
26
|
+
step: PipelineStep,
|
|
27
|
+
cwd: Path,
|
|
28
|
+
env_allowlist: list[str],
|
|
29
|
+
timeout_seconds: int,
|
|
30
|
+
) -> StepResult:
|
|
31
|
+
if not isinstance(step.args, list) or not step.args:
|
|
32
|
+
raise ValueError("step args must be a non-empty list")
|
|
33
|
+
if not all(isinstance(arg, str) for arg in step.args):
|
|
34
|
+
raise ValueError("step args must be strings")
|
|
35
|
+
|
|
36
|
+
env = {key: os.environ[key] for key in env_allowlist if key in os.environ}
|
|
37
|
+
start = time.monotonic()
|
|
38
|
+
try:
|
|
39
|
+
completed = subprocess.run( # noqa: S603 # nosec B603
|
|
40
|
+
step.args,
|
|
41
|
+
cwd=str(cwd),
|
|
42
|
+
env=env,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
timeout=timeout_seconds,
|
|
46
|
+
check=False,
|
|
47
|
+
)
|
|
48
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
49
|
+
return StepResult(
|
|
50
|
+
name=step.name,
|
|
51
|
+
args=list(step.args),
|
|
52
|
+
exit_code=completed.returncode,
|
|
53
|
+
duration_ms=duration_ms,
|
|
54
|
+
stdout=redact(completed.stdout),
|
|
55
|
+
stderr=redact(completed.stderr),
|
|
56
|
+
timed_out=False,
|
|
57
|
+
)
|
|
58
|
+
except subprocess.TimeoutExpired as exc:
|
|
59
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
60
|
+
stdout = redact(exc.stdout.decode() if isinstance(exc.stdout, bytes) else exc.stdout or "")
|
|
61
|
+
stderr = redact(exc.stderr.decode() if isinstance(exc.stderr, bytes) else exc.stderr or "")
|
|
62
|
+
return StepResult(
|
|
63
|
+
name=step.name,
|
|
64
|
+
args=list(step.args),
|
|
65
|
+
exit_code=124,
|
|
66
|
+
duration_ms=duration_ms,
|
|
67
|
+
stdout=stdout,
|
|
68
|
+
stderr=stderr,
|
|
69
|
+
timed_out=True,
|
|
70
|
+
)
|