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