@openlucaskaka/kagent 0.1.7
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.
- package/README.md +353 -0
- package/npm/bin/kagent-serve.js +6 -0
- package/npm/bin/kagent.js +6 -0
- package/npm/lib/App.js +524 -0
- package/npm/lib/app-state.js +224 -0
- package/npm/lib/approval-choice.js +25 -0
- package/npm/lib/commands.js +59 -0
- package/npm/lib/editor.js +188 -0
- package/npm/lib/ink-runner.js +41 -0
- package/npm/lib/kagent-home.js +39 -0
- package/npm/lib/launcher.js +221 -0
- package/npm/lib/protocol.js +33 -0
- package/npm/lib/provider-setup.js +139 -0
- package/npm/lib/python-runner.js +892 -0
- package/npm/lib/runtime-client.js +390 -0
- package/npm/lib/terminal-input.js +127 -0
- package/npm/lib/terminal-text.js +19 -0
- package/npm/lib/terminal-width.js +14 -0
- package/npm/lib/transcript.js +227 -0
- package/npm/lib/ui-components.js +247 -0
- package/npm/lib/update-manager.js +334 -0
- package/package.json +39 -0
- package/pyproject.toml +55 -0
- package/src/kagent/__init__.py +90 -0
- package/src/kagent/cli/__init__.py +5 -0
- package/src/kagent/cli/__main__.py +6 -0
- package/src/kagent/cli/commands.py +112 -0
- package/src/kagent/cli/conversation.py +127 -0
- package/src/kagent/cli/interactive.py +841 -0
- package/src/kagent/cli/main.py +685 -0
- package/src/kagent/cli/memory.py +460 -0
- package/src/kagent/cli/pending_approval.py +169 -0
- package/src/kagent/cli/provider.py +27 -0
- package/src/kagent/cli/session_commands.py +401 -0
- package/src/kagent/cli/stdio_runtime.py +784 -0
- package/src/kagent/cli/trace.py +67 -0
- package/src/kagent/cli/ui.py +931 -0
- package/src/kagent/core/__init__.py +0 -0
- package/src/kagent/core/agent.py +296 -0
- package/src/kagent/core/faults.py +11 -0
- package/src/kagent/core/invariants.py +47 -0
- package/src/kagent/core/normalization.py +81 -0
- package/src/kagent/core/planning.py +48 -0
- package/src/kagent/core/state.py +73 -0
- package/src/kagent/core/summary.py +64 -0
- package/src/kagent/core/tools.py +196 -0
- package/src/kagent/core/trace.py +57 -0
- package/src/kagent/eval/__init__.py +11 -0
- package/src/kagent/eval/cases.py +229 -0
- package/src/kagent/eval/evaluator.py +216 -0
- package/src/kagent/integrations/__init__.py +3 -0
- package/src/kagent/integrations/audit.py +95 -0
- package/src/kagent/integrations/backends.py +131 -0
- package/src/kagent/integrations/memory.py +301 -0
- package/src/kagent/ops/__init__.py +0 -0
- package/src/kagent/ops/batch.py +156 -0
- package/src/kagent/ops/doctor.py +255 -0
- package/src/kagent/ops/metrics.py +214 -0
- package/src/kagent/ops/release_evidence.py +877 -0
- package/src/kagent/ops/release_manifest.py +142 -0
- package/src/kagent/ops/trace_replay.py +285 -0
- package/src/kagent/providers/__init__.py +7 -0
- package/src/kagent/providers/embeddings.py +187 -0
- package/src/kagent/providers/llm.py +770 -0
- package/src/kagent/py.typed +1 -0
- package/src/kagent/runtime/__init__.py +28 -0
- package/src/kagent/runtime/action_graph.py +543 -0
- package/src/kagent/runtime/agent.py +2089 -0
- package/src/kagent/runtime/approval.py +64 -0
- package/src/kagent/runtime/cancellation.py +64 -0
- package/src/kagent/runtime/checkpoint_state.py +146 -0
- package/src/kagent/runtime/checkpoint_storage.py +270 -0
- package/src/kagent/runtime/context.py +65 -0
- package/src/kagent/runtime/file_transaction.py +195 -0
- package/src/kagent/runtime/hooks.py +74 -0
- package/src/kagent/runtime/metadata.py +116 -0
- package/src/kagent/runtime/patch_checkpoints.py +385 -0
- package/src/kagent/runtime/policy.py +50 -0
- package/src/kagent/runtime/presentation.py +205 -0
- package/src/kagent/runtime/redaction.py +130 -0
- package/src/kagent/runtime/sandbox.py +331 -0
- package/src/kagent/runtime/skills.py +66 -0
- package/src/kagent/runtime/steering.py +56 -0
- package/src/kagent/runtime/steps.py +255 -0
- package/src/kagent/runtime/task_state.py +40 -0
- package/src/kagent/runtime/tools.py +3532 -0
- package/src/kagent/runtime/types.py +240 -0
- package/src/kagent/runtime/workspace.py +597 -0
- package/src/kagent/service/__init__.py +26 -0
- package/src/kagent/service/__main__.py +6 -0
- package/src/kagent/service/active_runs.py +178 -0
- package/src/kagent/service/cli.py +737 -0
- package/src/kagent/service/contract.py +2571 -0
- package/src/kagent/service/errors.py +71 -0
- package/src/kagent/service/idempotency.py +584 -0
- package/src/kagent/service/router.py +884 -0
- package/src/kagent/service/run.py +150 -0
- package/src/kagent/service/runtime.py +1915 -0
- package/src/kagent/service/runtime_approval.py +20 -0
- package/src/kagent/service/runtime_cancel.py +192 -0
- package/src/kagent/service/runtime_lifecycle.py +229 -0
- package/src/kagent/service/runtime_metadata.py +21 -0
- package/src/kagent/service/runtime_policy.py +115 -0
- package/src/kagent/service/runtime_recovery.py +573 -0
- package/src/kagent/service/runtime_resume.py +382 -0
- package/src/kagent/service/runtime_resume_claim.py +116 -0
- package/src/kagent/service/runtime_run.py +350 -0
- package/src/kagent/service/runtime_status.py +2007 -0
- package/src/kagent/service/safety.py +139 -0
- package/src/kagent/service/server.py +114 -0
- package/src/kagent/service/status.py +233 -0
- package/src/kagent/service/trace_store.py +322 -0
- package/src/kagent/service/transport.py +24 -0
- package/src/kagent/utils/__init__.py +0 -0
- package/src/kagent/utils/config_validation.py +21 -0
- package/src/kagent/utils/json_output.py +23 -0
- package/src/kagent/utils/paths.py +473 -0
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from kagent import __version__
|
|
12
|
+
from kagent.ops.release_manifest import verify_release_manifest
|
|
13
|
+
|
|
14
|
+
PACKAGE_NAME = "kagent"
|
|
15
|
+
SECRET_LIKE_KEYS = (
|
|
16
|
+
"api_key",
|
|
17
|
+
"apikey",
|
|
18
|
+
"authorization",
|
|
19
|
+
"bearer",
|
|
20
|
+
"password",
|
|
21
|
+
"secret",
|
|
22
|
+
"token",
|
|
23
|
+
)
|
|
24
|
+
SECRET_KEY_ALLOWLIST = {
|
|
25
|
+
"llm_api_key_configured",
|
|
26
|
+
}
|
|
27
|
+
SECRET_VALUE_PATTERNS = (
|
|
28
|
+
re.compile(r"sk-[A-Za-z0-9:_-]{6,}"),
|
|
29
|
+
re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._:/+=-]{6,}"),
|
|
30
|
+
re.compile(r"(?i)\bauthorization\s*:\s*[A-Za-z0-9._:/+=-]{6,}"),
|
|
31
|
+
)
|
|
32
|
+
SENSITIVE_URL_VALUE_PATTERN = re.compile(r"(?i)\bhttps?://[^\s\"'<>]+")
|
|
33
|
+
REQUIRED_PROVIDER_SMOKE_RUN_IDS = (
|
|
34
|
+
"approval_run_id",
|
|
35
|
+
"cli_run_id",
|
|
36
|
+
"http_run_id",
|
|
37
|
+
"resumed_run_id",
|
|
38
|
+
)
|
|
39
|
+
REQUIRED_PROVIDER_SMOKE_CAPABILITIES = (
|
|
40
|
+
"approval_resume",
|
|
41
|
+
"cli_runtime",
|
|
42
|
+
"http_runtime",
|
|
43
|
+
"metrics",
|
|
44
|
+
"timeline",
|
|
45
|
+
"trace_status",
|
|
46
|
+
)
|
|
47
|
+
REQUIRED_PROVIDER_SMOKE_SNAPSHOT_FIELDS = (
|
|
48
|
+
"llm_api_key_configured",
|
|
49
|
+
"llm_base_url_host",
|
|
50
|
+
"llm_model",
|
|
51
|
+
"llm_provider",
|
|
52
|
+
)
|
|
53
|
+
REQUIRED_STAGING_ACCEPTANCE_FIELDS = (
|
|
54
|
+
"auth_subject",
|
|
55
|
+
"base_url_host",
|
|
56
|
+
"runtime_policy_source",
|
|
57
|
+
"runtime_run_id",
|
|
58
|
+
)
|
|
59
|
+
REQUIRED_STAGING_ACCEPTANCE_VALUES = {
|
|
60
|
+
"health_status": "ok",
|
|
61
|
+
"metrics_trace_persistence": "enabled",
|
|
62
|
+
"ready_status": "ready",
|
|
63
|
+
"runtime_http_request_approval_required": "true",
|
|
64
|
+
"runtime_note_allowed": "true",
|
|
65
|
+
"runtime_run_status": "done",
|
|
66
|
+
}
|
|
67
|
+
REQUIRED_STAGING_ACCEPTANCE_POSITIVE_INTS = (
|
|
68
|
+
"metrics_runtime_runs_total",
|
|
69
|
+
"runtime_effective_tool_policy_count",
|
|
70
|
+
"runtime_summary_run_count",
|
|
71
|
+
"runtime_timeline_event_count",
|
|
72
|
+
)
|
|
73
|
+
REQUIRED_OBSERVABILITY_ACCEPTANCE_FIELDS = (
|
|
74
|
+
"base_url_host",
|
|
75
|
+
"metrics_endpoint",
|
|
76
|
+
)
|
|
77
|
+
REQUIRED_OBSERVABILITY_ACCEPTANCE_VALUES = {
|
|
78
|
+
"grafana_dashboard_status": "passed",
|
|
79
|
+
"metrics_status": "200",
|
|
80
|
+
"prometheus_rules_status": "passed",
|
|
81
|
+
"required_metrics_present": "true",
|
|
82
|
+
}
|
|
83
|
+
REQUIRED_OBSERVABILITY_ACCEPTANCE_METRICS = (
|
|
84
|
+
"kagent_build_info",
|
|
85
|
+
"kagent_request_duration_seconds_bucket",
|
|
86
|
+
"kagent_requests_total",
|
|
87
|
+
"kagent_responses_total",
|
|
88
|
+
"kagent_runtime_approval_required_total",
|
|
89
|
+
"kagent_runtime_progress_event_sink_failures_total",
|
|
90
|
+
"kagent_runtime_hook_failures_total",
|
|
91
|
+
"kagent_runtime_reconciliation_runs_total",
|
|
92
|
+
"kagent_runtime_reconciliation_traces_scanned_total",
|
|
93
|
+
"kagent_runtime_reconciliation_outcomes_total",
|
|
94
|
+
"kagent_runtime_reconciliation_errors_total",
|
|
95
|
+
"kagent_runtime_planner_attempts_total",
|
|
96
|
+
"kagent_runtime_planner_failures_total",
|
|
97
|
+
"kagent_runtime_llm_provider_requests_total",
|
|
98
|
+
"kagent_runtime_llm_provider_requests_by_status_total",
|
|
99
|
+
"kagent_runtime_approvals_by_auth_subject_total",
|
|
100
|
+
"kagent_runtime_run_duration_seconds_bucket",
|
|
101
|
+
"kagent_runtime_run_lifecycle_state_by_auth_subject_total",
|
|
102
|
+
"kagent_runtime_run_lifecycle_state_total",
|
|
103
|
+
"kagent_runtime_tool_executions_total",
|
|
104
|
+
"kagent_runtime_runs_by_auth_subject_total",
|
|
105
|
+
"kagent_runtime_runs_total",
|
|
106
|
+
"kagent_runtime_stale_pending_approvals_current",
|
|
107
|
+
)
|
|
108
|
+
REQUIRED_OBSERVABILITY_ACCEPTANCE_METRICS_SHA256 = hashlib.sha256(
|
|
109
|
+
"\n".join(sorted(REQUIRED_OBSERVABILITY_ACCEPTANCE_METRICS)).encode("utf-8")
|
|
110
|
+
).hexdigest()
|
|
111
|
+
REQUIRED_OBSERVABILITY_ACCEPTANCE_SHA_FIELDS = (
|
|
112
|
+
"grafana_dashboard_sha256",
|
|
113
|
+
"metrics_sha256",
|
|
114
|
+
"prometheus_rules_sha256",
|
|
115
|
+
)
|
|
116
|
+
MIN_OBSERVABILITY_ACCEPTANCE_REQUIRED_METRIC_COUNT = len(
|
|
117
|
+
REQUIRED_OBSERVABILITY_ACCEPTANCE_METRICS
|
|
118
|
+
)
|
|
119
|
+
REQUIRED_INTERNAL_ROLLOUT_FIELDS = (
|
|
120
|
+
"environment",
|
|
121
|
+
"expected_environment",
|
|
122
|
+
"expected_release_version",
|
|
123
|
+
"release_version",
|
|
124
|
+
"rollout_id",
|
|
125
|
+
"signed_off_at_utc",
|
|
126
|
+
)
|
|
127
|
+
REQUIRED_INTERNAL_ROLLOUT_TRUE_FIELDS = (
|
|
128
|
+
"environment_matches",
|
|
129
|
+
"required_checks_passed",
|
|
130
|
+
"required_roles_present",
|
|
131
|
+
"version_matches",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def build_release_evidence(
|
|
136
|
+
*,
|
|
137
|
+
run_checks_exit_code: int,
|
|
138
|
+
readiness_audit_path: Path,
|
|
139
|
+
release_manifest_path: Optional[Path] = None,
|
|
140
|
+
provider_smoke_evidence_path: Optional[Path] = None,
|
|
141
|
+
staging_acceptance_evidence_path: Optional[Path] = None,
|
|
142
|
+
observability_acceptance_evidence_path: Optional[Path] = None,
|
|
143
|
+
internal_rollout_evidence_path: Optional[Path] = None,
|
|
144
|
+
require_provider_smoke: bool = False,
|
|
145
|
+
require_staging_acceptance: bool = False,
|
|
146
|
+
require_observability_acceptance: bool = False,
|
|
147
|
+
require_internal_rollout: bool = False,
|
|
148
|
+
) -> Dict[str, Any]:
|
|
149
|
+
readiness_audit = _read_json_file(readiness_audit_path)
|
|
150
|
+
release_manifest = (
|
|
151
|
+
verify_release_manifest(release_manifest_path)
|
|
152
|
+
if release_manifest_path is not None
|
|
153
|
+
else {"status": "not_provided"}
|
|
154
|
+
)
|
|
155
|
+
provider_smoke = (
|
|
156
|
+
_provider_smoke_record(provider_smoke_evidence_path)
|
|
157
|
+
if provider_smoke_evidence_path is not None
|
|
158
|
+
else {"status": "not_provided"}
|
|
159
|
+
)
|
|
160
|
+
staging_acceptance = (
|
|
161
|
+
_staging_acceptance_record(staging_acceptance_evidence_path)
|
|
162
|
+
if staging_acceptance_evidence_path is not None
|
|
163
|
+
else {"status": "not_provided"}
|
|
164
|
+
)
|
|
165
|
+
observability_acceptance = (
|
|
166
|
+
_observability_acceptance_record(observability_acceptance_evidence_path)
|
|
167
|
+
if observability_acceptance_evidence_path is not None
|
|
168
|
+
else {"status": "not_provided"}
|
|
169
|
+
)
|
|
170
|
+
internal_rollout = (
|
|
171
|
+
_internal_rollout_record(internal_rollout_evidence_path)
|
|
172
|
+
if internal_rollout_evidence_path is not None
|
|
173
|
+
else {"status": "not_provided"}
|
|
174
|
+
)
|
|
175
|
+
evidence_secret_findings = _external_evidence_secret_findings(
|
|
176
|
+
{
|
|
177
|
+
"provider_smoke": provider_smoke_evidence_path,
|
|
178
|
+
"staging_acceptance": staging_acceptance_evidence_path,
|
|
179
|
+
"observability_acceptance": observability_acceptance_evidence_path,
|
|
180
|
+
"internal_rollout": internal_rollout_evidence_path,
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
runtime_policy_fingerprint_mismatch = _runtime_policy_fingerprint_mismatch(
|
|
184
|
+
{
|
|
185
|
+
"provider_smoke": provider_smoke,
|
|
186
|
+
"staging_acceptance": staging_acceptance,
|
|
187
|
+
"internal_rollout": internal_rollout,
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
failed_checks = _failed_checks(
|
|
191
|
+
run_checks_exit_code=run_checks_exit_code,
|
|
192
|
+
readiness_audit=readiness_audit,
|
|
193
|
+
release_manifest=release_manifest,
|
|
194
|
+
provider_smoke=provider_smoke,
|
|
195
|
+
staging_acceptance=staging_acceptance,
|
|
196
|
+
observability_acceptance=observability_acceptance,
|
|
197
|
+
internal_rollout=internal_rollout,
|
|
198
|
+
evidence_secret_findings=evidence_secret_findings,
|
|
199
|
+
runtime_policy_fingerprint_mismatch=runtime_policy_fingerprint_mismatch,
|
|
200
|
+
require_provider_smoke=require_provider_smoke,
|
|
201
|
+
require_staging_acceptance=require_staging_acceptance,
|
|
202
|
+
require_observability_acceptance=require_observability_acceptance,
|
|
203
|
+
require_internal_rollout=require_internal_rollout,
|
|
204
|
+
)
|
|
205
|
+
evidence_files = {
|
|
206
|
+
"readiness_audit": _file_record(readiness_audit_path),
|
|
207
|
+
}
|
|
208
|
+
if release_manifest_path is not None:
|
|
209
|
+
evidence_files["release_manifest"] = _file_record(release_manifest_path)
|
|
210
|
+
if provider_smoke_evidence_path is not None:
|
|
211
|
+
evidence_files["provider_smoke"] = _file_record(provider_smoke_evidence_path)
|
|
212
|
+
if staging_acceptance_evidence_path is not None:
|
|
213
|
+
evidence_files["staging_acceptance"] = _file_record(
|
|
214
|
+
staging_acceptance_evidence_path
|
|
215
|
+
)
|
|
216
|
+
if observability_acceptance_evidence_path is not None:
|
|
217
|
+
evidence_files["observability_acceptance"] = _file_record(
|
|
218
|
+
observability_acceptance_evidence_path
|
|
219
|
+
)
|
|
220
|
+
if internal_rollout_evidence_path is not None:
|
|
221
|
+
evidence_files["internal_rollout"] = _file_record(
|
|
222
|
+
internal_rollout_evidence_path
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
"package": PACKAGE_NAME,
|
|
227
|
+
"version": __version__,
|
|
228
|
+
"generated_at_utc": _utc_timestamp(),
|
|
229
|
+
"status": "blocked" if failed_checks else "ready",
|
|
230
|
+
"summary": {
|
|
231
|
+
"failed_checks": failed_checks,
|
|
232
|
+
"evidence_file_count": str(len(evidence_files)),
|
|
233
|
+
"evidence_secret_findings": evidence_secret_findings,
|
|
234
|
+
"runtime_policy_fingerprint_mismatch": (
|
|
235
|
+
runtime_policy_fingerprint_mismatch
|
|
236
|
+
),
|
|
237
|
+
},
|
|
238
|
+
"run_checks": _run_checks_record(run_checks_exit_code),
|
|
239
|
+
"readiness_audit": _readiness_audit_record(readiness_audit),
|
|
240
|
+
"release_manifest": release_manifest,
|
|
241
|
+
"provider_smoke": provider_smoke,
|
|
242
|
+
"staging_acceptance": staging_acceptance,
|
|
243
|
+
"observability_acceptance": observability_acceptance,
|
|
244
|
+
"internal_rollout": internal_rollout,
|
|
245
|
+
"evidence_files": evidence_files,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def main(argv: Optional[List[str]] = None) -> None:
|
|
250
|
+
parser = argparse.ArgumentParser(
|
|
251
|
+
description="Generate a redacted JSON release evidence bundle."
|
|
252
|
+
)
|
|
253
|
+
parser.add_argument(
|
|
254
|
+
"--run-checks-exit-code",
|
|
255
|
+
required=True,
|
|
256
|
+
type=int,
|
|
257
|
+
help="Exit code from scripts/run_checks.sh.",
|
|
258
|
+
)
|
|
259
|
+
parser.add_argument(
|
|
260
|
+
"--readiness-audit",
|
|
261
|
+
required=True,
|
|
262
|
+
metavar="PATH",
|
|
263
|
+
help="JSON output from scripts/production_readiness_audit.py.",
|
|
264
|
+
)
|
|
265
|
+
parser.add_argument(
|
|
266
|
+
"--release-manifest",
|
|
267
|
+
default="",
|
|
268
|
+
metavar="PATH",
|
|
269
|
+
help="Verified release manifest JSON generated by release_manifest.",
|
|
270
|
+
)
|
|
271
|
+
parser.add_argument(
|
|
272
|
+
"--provider-smoke-evidence",
|
|
273
|
+
default="",
|
|
274
|
+
metavar="PATH",
|
|
275
|
+
help="Redacted JSON output from scripts/smoke_real_llm_runtime.sh.",
|
|
276
|
+
)
|
|
277
|
+
parser.add_argument(
|
|
278
|
+
"--require-provider-smoke",
|
|
279
|
+
action="store_true",
|
|
280
|
+
help="Block release evidence when provider smoke evidence is missing.",
|
|
281
|
+
)
|
|
282
|
+
parser.add_argument(
|
|
283
|
+
"--staging-acceptance-evidence",
|
|
284
|
+
default="",
|
|
285
|
+
metavar="PATH",
|
|
286
|
+
help="Redacted JSON output from scripts/staging_acceptance.sh.",
|
|
287
|
+
)
|
|
288
|
+
parser.add_argument(
|
|
289
|
+
"--require-staging-acceptance",
|
|
290
|
+
action="store_true",
|
|
291
|
+
help="Block release evidence when staging acceptance evidence is missing.",
|
|
292
|
+
)
|
|
293
|
+
parser.add_argument(
|
|
294
|
+
"--observability-acceptance-evidence",
|
|
295
|
+
default="",
|
|
296
|
+
metavar="PATH",
|
|
297
|
+
help="Redacted JSON output from scripts/observability_acceptance.sh.",
|
|
298
|
+
)
|
|
299
|
+
parser.add_argument(
|
|
300
|
+
"--require-observability-acceptance",
|
|
301
|
+
action="store_true",
|
|
302
|
+
help="Block release evidence when observability acceptance evidence is missing.",
|
|
303
|
+
)
|
|
304
|
+
parser.add_argument(
|
|
305
|
+
"--internal-rollout-evidence",
|
|
306
|
+
default="",
|
|
307
|
+
metavar="PATH",
|
|
308
|
+
help="Redacted JSON output from scripts/internal_rollout_acceptance.py.",
|
|
309
|
+
)
|
|
310
|
+
parser.add_argument(
|
|
311
|
+
"--require-internal-rollout",
|
|
312
|
+
action="store_true",
|
|
313
|
+
help="Block release evidence when internal rollout sign-off evidence is missing.",
|
|
314
|
+
)
|
|
315
|
+
parser.add_argument("--output", default="", metavar="PATH", help="Write bundle JSON to PATH.")
|
|
316
|
+
args = parser.parse_args(argv)
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
payload = build_release_evidence(
|
|
320
|
+
run_checks_exit_code=args.run_checks_exit_code,
|
|
321
|
+
readiness_audit_path=Path(args.readiness_audit),
|
|
322
|
+
release_manifest_path=Path(args.release_manifest) if args.release_manifest else None,
|
|
323
|
+
provider_smoke_evidence_path=(
|
|
324
|
+
Path(args.provider_smoke_evidence) if args.provider_smoke_evidence else None
|
|
325
|
+
),
|
|
326
|
+
staging_acceptance_evidence_path=(
|
|
327
|
+
Path(args.staging_acceptance_evidence)
|
|
328
|
+
if args.staging_acceptance_evidence
|
|
329
|
+
else None
|
|
330
|
+
),
|
|
331
|
+
observability_acceptance_evidence_path=(
|
|
332
|
+
Path(args.observability_acceptance_evidence)
|
|
333
|
+
if args.observability_acceptance_evidence
|
|
334
|
+
else None
|
|
335
|
+
),
|
|
336
|
+
internal_rollout_evidence_path=(
|
|
337
|
+
Path(args.internal_rollout_evidence)
|
|
338
|
+
if args.internal_rollout_evidence
|
|
339
|
+
else None
|
|
340
|
+
),
|
|
341
|
+
require_provider_smoke=args.require_provider_smoke,
|
|
342
|
+
require_staging_acceptance=args.require_staging_acceptance,
|
|
343
|
+
require_observability_acceptance=args.require_observability_acceptance,
|
|
344
|
+
require_internal_rollout=args.require_internal_rollout,
|
|
345
|
+
)
|
|
346
|
+
output = json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
|
347
|
+
if args.output:
|
|
348
|
+
Path(args.output).write_text(output, encoding="utf-8")
|
|
349
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
350
|
+
parser.error(str(exc))
|
|
351
|
+
print(output, end="")
|
|
352
|
+
if payload["status"] != "ready":
|
|
353
|
+
raise SystemExit(1)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _failed_checks(
|
|
357
|
+
*,
|
|
358
|
+
run_checks_exit_code: int,
|
|
359
|
+
readiness_audit: Dict[str, Any],
|
|
360
|
+
release_manifest: Dict[str, Any],
|
|
361
|
+
provider_smoke: Dict[str, Any],
|
|
362
|
+
staging_acceptance: Dict[str, Any],
|
|
363
|
+
observability_acceptance: Dict[str, Any],
|
|
364
|
+
internal_rollout: Dict[str, Any],
|
|
365
|
+
evidence_secret_findings: List[Dict[str, str]],
|
|
366
|
+
runtime_policy_fingerprint_mismatch: Dict[str, str],
|
|
367
|
+
require_provider_smoke: bool,
|
|
368
|
+
require_staging_acceptance: bool,
|
|
369
|
+
require_observability_acceptance: bool,
|
|
370
|
+
require_internal_rollout: bool,
|
|
371
|
+
) -> List[str]:
|
|
372
|
+
failed = []
|
|
373
|
+
if run_checks_exit_code != 0:
|
|
374
|
+
failed.append("run_checks_failed")
|
|
375
|
+
if str(readiness_audit.get("status", "")) != "passed":
|
|
376
|
+
failed.append("readiness_audit_failed")
|
|
377
|
+
if release_manifest["status"] not in {"verified", "not_provided"}:
|
|
378
|
+
failed.append("release_manifest_failed")
|
|
379
|
+
if provider_smoke["status"] not in {"passed", "not_provided"}:
|
|
380
|
+
failed.append("provider_smoke_failed")
|
|
381
|
+
if require_provider_smoke and provider_smoke["status"] != "passed":
|
|
382
|
+
failed.append(f"provider_smoke_{provider_smoke['status']}")
|
|
383
|
+
if staging_acceptance["status"] not in {"passed", "not_provided"}:
|
|
384
|
+
failed.append("staging_acceptance_failed")
|
|
385
|
+
if require_staging_acceptance and staging_acceptance["status"] != "passed":
|
|
386
|
+
failed.append(f"staging_acceptance_{staging_acceptance['status']}")
|
|
387
|
+
if observability_acceptance["status"] not in {"passed", "not_provided"}:
|
|
388
|
+
failed.append("observability_acceptance_failed")
|
|
389
|
+
if (
|
|
390
|
+
require_observability_acceptance
|
|
391
|
+
and observability_acceptance["status"] != "passed"
|
|
392
|
+
):
|
|
393
|
+
failed.append(
|
|
394
|
+
f"observability_acceptance_{observability_acceptance['status']}"
|
|
395
|
+
)
|
|
396
|
+
if internal_rollout["status"] not in {"passed", "not_provided"}:
|
|
397
|
+
failed.append("internal_rollout_failed")
|
|
398
|
+
if require_internal_rollout and internal_rollout["status"] != "passed":
|
|
399
|
+
failed.append(f"internal_rollout_{internal_rollout['status']}")
|
|
400
|
+
if evidence_secret_findings:
|
|
401
|
+
failed.append("evidence_secret_detected")
|
|
402
|
+
if runtime_policy_fingerprint_mismatch:
|
|
403
|
+
failed.append("runtime_policy_fingerprint_mismatch")
|
|
404
|
+
return failed
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _run_checks_record(exit_code: int) -> Dict[str, str]:
|
|
408
|
+
return {
|
|
409
|
+
"status": "passed" if exit_code == 0 else "failed",
|
|
410
|
+
"exit_code": str(exit_code),
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _readiness_audit_record(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
415
|
+
summary = payload.get("summary", {})
|
|
416
|
+
return {
|
|
417
|
+
"status": str(payload.get("status", "unknown")),
|
|
418
|
+
"failed_checks": _string_list(summary.get("failed_checks", [])),
|
|
419
|
+
"missing_artifacts": _string_list(summary.get("missing_artifacts", [])),
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _provider_smoke_record(path: Path) -> Dict[str, Any]:
|
|
424
|
+
payload = _read_json_file(path)
|
|
425
|
+
status = str(payload.get("status", "unknown"))
|
|
426
|
+
missing_fields = (
|
|
427
|
+
_provider_smoke_missing_fields(payload) if status == "passed" else []
|
|
428
|
+
)
|
|
429
|
+
if missing_fields:
|
|
430
|
+
status = "invalid_evidence"
|
|
431
|
+
return {
|
|
432
|
+
"status": status,
|
|
433
|
+
"evidence_schema_version": str(
|
|
434
|
+
payload.get("evidence_schema_version", "")
|
|
435
|
+
),
|
|
436
|
+
"provider_snapshot": _provider_snapshot_record(payload),
|
|
437
|
+
"capability_checks": _string_map(payload.get("capability_checks", {})),
|
|
438
|
+
"runtime_effective_tool_policy_sha256": str(
|
|
439
|
+
payload.get("runtime_effective_tool_policy_sha256", "")
|
|
440
|
+
),
|
|
441
|
+
"run_ids": {
|
|
442
|
+
key: str(payload.get(key, ""))
|
|
443
|
+
for key in (
|
|
444
|
+
"approval_run_id",
|
|
445
|
+
"cli_run_id",
|
|
446
|
+
"http_run_id",
|
|
447
|
+
"resumed_run_id",
|
|
448
|
+
)
|
|
449
|
+
if str(payload.get(key, "")).strip()
|
|
450
|
+
},
|
|
451
|
+
"missing_fields": missing_fields,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _staging_acceptance_record(path: Path) -> Dict[str, Any]:
|
|
456
|
+
payload = _read_json_file(path)
|
|
457
|
+
status = str(payload.get("status", "unknown"))
|
|
458
|
+
missing_fields = (
|
|
459
|
+
_staging_acceptance_missing_fields(payload) if status == "passed" else []
|
|
460
|
+
)
|
|
461
|
+
if missing_fields:
|
|
462
|
+
status = "invalid_evidence"
|
|
463
|
+
return {
|
|
464
|
+
"evidence_schema_version": str(
|
|
465
|
+
payload.get("evidence_schema_version", "")
|
|
466
|
+
),
|
|
467
|
+
"status": status,
|
|
468
|
+
"base_url_host": str(payload.get("base_url_host", "")),
|
|
469
|
+
"health_status": str(payload.get("health_status", "")),
|
|
470
|
+
"ready_status": str(payload.get("ready_status", "")),
|
|
471
|
+
"runtime_run_id": str(payload.get("runtime_run_id", "")),
|
|
472
|
+
"auth_subject": str(payload.get("auth_subject", "")),
|
|
473
|
+
"runtime_policy_source": str(payload.get("runtime_policy_source", "")),
|
|
474
|
+
"runtime_effective_tool_policy_count": str(
|
|
475
|
+
payload.get("runtime_effective_tool_policy_count", "")
|
|
476
|
+
),
|
|
477
|
+
"runtime_effective_tool_policy_sha256": str(
|
|
478
|
+
payload.get("runtime_effective_tool_policy_sha256", "")
|
|
479
|
+
),
|
|
480
|
+
"runtime_note_allowed": str(payload.get("runtime_note_allowed", "")),
|
|
481
|
+
"runtime_http_request_approval_required": str(
|
|
482
|
+
payload.get("runtime_http_request_approval_required", "")
|
|
483
|
+
),
|
|
484
|
+
"runtime_run_status": str(payload.get("runtime_run_status", "")),
|
|
485
|
+
"runtime_timeline_event_count": str(
|
|
486
|
+
payload.get("runtime_timeline_event_count", "")
|
|
487
|
+
),
|
|
488
|
+
"runtime_summary_run_count": str(
|
|
489
|
+
payload.get("runtime_summary_run_count", "")
|
|
490
|
+
),
|
|
491
|
+
"metrics_trace_persistence": str(
|
|
492
|
+
payload.get("metrics_trace_persistence", "")
|
|
493
|
+
),
|
|
494
|
+
"metrics_runtime_runs_total": str(
|
|
495
|
+
payload.get("metrics_runtime_runs_total", "")
|
|
496
|
+
),
|
|
497
|
+
"missing_fields": missing_fields,
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _observability_acceptance_record(path: Path) -> Dict[str, Any]:
|
|
502
|
+
payload = _read_json_file(path)
|
|
503
|
+
status = str(payload.get("status", "unknown"))
|
|
504
|
+
missing_fields = (
|
|
505
|
+
_observability_acceptance_missing_fields(payload)
|
|
506
|
+
if status == "passed"
|
|
507
|
+
else []
|
|
508
|
+
)
|
|
509
|
+
if missing_fields:
|
|
510
|
+
status = "invalid_evidence"
|
|
511
|
+
return {
|
|
512
|
+
"evidence_schema_version": str(
|
|
513
|
+
payload.get("evidence_schema_version", "")
|
|
514
|
+
),
|
|
515
|
+
"status": status,
|
|
516
|
+
"base_url_host": str(payload.get("base_url_host", "")),
|
|
517
|
+
"metrics_endpoint": str(payload.get("metrics_endpoint", "")),
|
|
518
|
+
"metrics_status": str(payload.get("metrics_status", "")),
|
|
519
|
+
"required_metrics_present": str(payload.get("required_metrics_present", "")),
|
|
520
|
+
"required_metric_count": str(payload.get("required_metric_count", "")),
|
|
521
|
+
"missing_required_metrics": _string_list(
|
|
522
|
+
payload.get("missing_required_metrics")
|
|
523
|
+
),
|
|
524
|
+
"required_metrics_sha256": str(payload.get("required_metrics_sha256", "")),
|
|
525
|
+
"metrics_sha256": str(payload.get("metrics_sha256", "")),
|
|
526
|
+
"grafana_dashboard_status": str(
|
|
527
|
+
payload.get("grafana_dashboard_status", "")
|
|
528
|
+
),
|
|
529
|
+
"grafana_dashboard_sha256": str(
|
|
530
|
+
payload.get("grafana_dashboard_sha256", "")
|
|
531
|
+
),
|
|
532
|
+
"prometheus_rules_status": str(payload.get("prometheus_rules_status", "")),
|
|
533
|
+
"prometheus_rules_sha256": str(
|
|
534
|
+
payload.get("prometheus_rules_sha256", "")
|
|
535
|
+
),
|
|
536
|
+
"prometheus_query_status": str(
|
|
537
|
+
payload.get("prometheus_query_status", "")
|
|
538
|
+
),
|
|
539
|
+
"prometheus_result_count": str(
|
|
540
|
+
payload.get("prometheus_result_count", "")
|
|
541
|
+
),
|
|
542
|
+
"missing_fields": missing_fields,
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _internal_rollout_record(path: Path) -> Dict[str, Any]:
|
|
547
|
+
payload = _read_json_file(path)
|
|
548
|
+
status = str(payload.get("status", "unknown"))
|
|
549
|
+
missing_fields = (
|
|
550
|
+
_internal_rollout_missing_fields(payload) if status == "passed" else []
|
|
551
|
+
)
|
|
552
|
+
if missing_fields:
|
|
553
|
+
status = "invalid_evidence"
|
|
554
|
+
return {
|
|
555
|
+
"evidence_schema_version": str(
|
|
556
|
+
payload.get("evidence_schema_version", "")
|
|
557
|
+
),
|
|
558
|
+
"status": status,
|
|
559
|
+
"rollout_id": str(payload.get("rollout_id", "")),
|
|
560
|
+
"release_version": str(payload.get("release_version", "")),
|
|
561
|
+
"environment": str(payload.get("environment", "")),
|
|
562
|
+
"signed_off_at_utc": str(payload.get("signed_off_at_utc", "")),
|
|
563
|
+
"runtime_effective_tool_policy_sha256": str(
|
|
564
|
+
payload.get("runtime_effective_tool_policy_sha256", "")
|
|
565
|
+
),
|
|
566
|
+
"required_roles_present": str(payload.get("required_roles_present", "")),
|
|
567
|
+
"required_checks_passed": str(payload.get("required_checks_passed", "")),
|
|
568
|
+
"approver_role_count": str(payload.get("approver_role_count", "")),
|
|
569
|
+
"expected_release_version": str(
|
|
570
|
+
payload.get("expected_release_version", "")
|
|
571
|
+
),
|
|
572
|
+
"version_matches": str(payload.get("version_matches", "")),
|
|
573
|
+
"expected_environment": str(payload.get("expected_environment", "")),
|
|
574
|
+
"environment_matches": str(payload.get("environment_matches", "")),
|
|
575
|
+
"sha256": str(payload.get("sha256", "")),
|
|
576
|
+
"missing_fields": missing_fields,
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _runtime_policy_fingerprint_mismatch(
|
|
581
|
+
records_by_label: Dict[str, Dict[str, Any]],
|
|
582
|
+
) -> Dict[str, str]:
|
|
583
|
+
fingerprints = {
|
|
584
|
+
label: str(record.get("runtime_effective_tool_policy_sha256", ""))
|
|
585
|
+
for label, record in records_by_label.items()
|
|
586
|
+
if record.get("status") == "passed"
|
|
587
|
+
and _is_sha256(str(record.get("runtime_effective_tool_policy_sha256", "")))
|
|
588
|
+
}
|
|
589
|
+
if len(set(fingerprints.values())) <= 1:
|
|
590
|
+
return {}
|
|
591
|
+
return fingerprints
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _provider_snapshot_record(payload: Dict[str, Any]) -> Dict[str, str]:
|
|
595
|
+
provider_snapshot = payload.get("provider_snapshot", {})
|
|
596
|
+
if not isinstance(provider_snapshot, dict):
|
|
597
|
+
provider_snapshot = {}
|
|
598
|
+
return {
|
|
599
|
+
field: str(provider_snapshot.get(field, ""))
|
|
600
|
+
for field in REQUIRED_PROVIDER_SMOKE_SNAPSHOT_FIELDS
|
|
601
|
+
if str(provider_snapshot.get(field, "")).strip()
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _file_record(path: Path) -> Dict[str, str]:
|
|
606
|
+
data = path.read_bytes()
|
|
607
|
+
return {
|
|
608
|
+
"path": str(path),
|
|
609
|
+
"file_name": path.name,
|
|
610
|
+
"size_bytes": str(len(data)),
|
|
611
|
+
"sha256": hashlib.sha256(data).hexdigest(),
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _read_json_file(path: Path) -> Dict[str, Any]:
|
|
616
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
617
|
+
if not isinstance(payload, dict):
|
|
618
|
+
raise OSError(f"{path} must contain a JSON object")
|
|
619
|
+
return payload
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _provider_smoke_missing_fields(payload: Dict[str, Any]) -> List[str]:
|
|
623
|
+
missing = []
|
|
624
|
+
if str(payload.get("evidence_schema_version", "")) != "1":
|
|
625
|
+
missing.append("evidence_schema_version")
|
|
626
|
+
for field in REQUIRED_PROVIDER_SMOKE_RUN_IDS:
|
|
627
|
+
if not str(payload.get(field, "")).strip():
|
|
628
|
+
missing.append(field)
|
|
629
|
+
provider_snapshot = payload.get("provider_snapshot", {})
|
|
630
|
+
if not isinstance(provider_snapshot, dict):
|
|
631
|
+
provider_snapshot = {}
|
|
632
|
+
for field in REQUIRED_PROVIDER_SMOKE_SNAPSHOT_FIELDS:
|
|
633
|
+
if not str(provider_snapshot.get(field, "")).strip():
|
|
634
|
+
missing.append(f"provider_snapshot.{field}")
|
|
635
|
+
capability_checks = payload.get("capability_checks", {})
|
|
636
|
+
if not isinstance(capability_checks, dict):
|
|
637
|
+
capability_checks = {}
|
|
638
|
+
for field in REQUIRED_PROVIDER_SMOKE_CAPABILITIES:
|
|
639
|
+
if str(capability_checks.get(field, "")) != "passed":
|
|
640
|
+
missing.append(f"capability_checks.{field}")
|
|
641
|
+
if not _is_sha256(str(payload.get("runtime_effective_tool_policy_sha256", ""))):
|
|
642
|
+
missing.append("runtime_effective_tool_policy_sha256")
|
|
643
|
+
return sorted(missing)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _staging_acceptance_missing_fields(payload: Dict[str, Any]) -> List[str]:
|
|
647
|
+
missing = _schema_version_missing_fields(payload)
|
|
648
|
+
missing.extend(_missing_string_fields(payload, REQUIRED_STAGING_ACCEPTANCE_FIELDS))
|
|
649
|
+
missing.extend(_missing_value_fields(payload, REQUIRED_STAGING_ACCEPTANCE_VALUES))
|
|
650
|
+
missing.extend(
|
|
651
|
+
_missing_positive_int_fields(
|
|
652
|
+
payload,
|
|
653
|
+
REQUIRED_STAGING_ACCEPTANCE_POSITIVE_INTS,
|
|
654
|
+
)
|
|
655
|
+
)
|
|
656
|
+
if not _is_sha256(str(payload.get("runtime_effective_tool_policy_sha256", ""))):
|
|
657
|
+
missing.append("runtime_effective_tool_policy_sha256")
|
|
658
|
+
return sorted(missing)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _observability_acceptance_missing_fields(payload: Dict[str, Any]) -> List[str]:
|
|
662
|
+
missing = _schema_version_missing_fields(payload)
|
|
663
|
+
missing.extend(_missing_string_fields(
|
|
664
|
+
payload,
|
|
665
|
+
REQUIRED_OBSERVABILITY_ACCEPTANCE_FIELDS,
|
|
666
|
+
))
|
|
667
|
+
missing.extend(
|
|
668
|
+
_missing_value_fields(payload, REQUIRED_OBSERVABILITY_ACCEPTANCE_VALUES)
|
|
669
|
+
)
|
|
670
|
+
missing.extend(
|
|
671
|
+
_missing_positive_int_fields(
|
|
672
|
+
payload,
|
|
673
|
+
("required_metric_count",),
|
|
674
|
+
)
|
|
675
|
+
)
|
|
676
|
+
try:
|
|
677
|
+
required_metric_count = int(str(payload.get("required_metric_count", "")))
|
|
678
|
+
except ValueError:
|
|
679
|
+
required_metric_count = 0
|
|
680
|
+
if (
|
|
681
|
+
required_metric_count < MIN_OBSERVABILITY_ACCEPTANCE_REQUIRED_METRIC_COUNT
|
|
682
|
+
and "required_metric_count" not in missing
|
|
683
|
+
):
|
|
684
|
+
missing.append("required_metric_count")
|
|
685
|
+
missing_required_metrics = payload.get("missing_required_metrics")
|
|
686
|
+
if (
|
|
687
|
+
not isinstance(missing_required_metrics, list)
|
|
688
|
+
or len(missing_required_metrics) > 0
|
|
689
|
+
):
|
|
690
|
+
missing.append("missing_required_metrics")
|
|
691
|
+
if (
|
|
692
|
+
str(payload.get("required_metrics_sha256", ""))
|
|
693
|
+
!= REQUIRED_OBSERVABILITY_ACCEPTANCE_METRICS_SHA256
|
|
694
|
+
):
|
|
695
|
+
missing.append("required_metrics_sha256")
|
|
696
|
+
for field in REQUIRED_OBSERVABILITY_ACCEPTANCE_SHA_FIELDS:
|
|
697
|
+
if not _is_sha256(str(payload.get(field, ""))):
|
|
698
|
+
missing.append(field)
|
|
699
|
+
prometheus_query_status = str(payload.get("prometheus_query_status", ""))
|
|
700
|
+
if prometheus_query_status not in {"passed", "not_configured"}:
|
|
701
|
+
missing.append("prometheus_query_status")
|
|
702
|
+
elif prometheus_query_status == "passed" and not _is_positive_int(
|
|
703
|
+
payload.get("prometheus_result_count", "")
|
|
704
|
+
):
|
|
705
|
+
missing.append("prometheus_result_count")
|
|
706
|
+
return sorted(missing)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _internal_rollout_missing_fields(payload: Dict[str, Any]) -> List[str]:
|
|
710
|
+
missing = _schema_version_missing_fields(payload)
|
|
711
|
+
missing.extend(_missing_string_fields(payload, REQUIRED_INTERNAL_ROLLOUT_FIELDS))
|
|
712
|
+
missing.extend(
|
|
713
|
+
_missing_value_fields(
|
|
714
|
+
payload,
|
|
715
|
+
{field: "true" for field in REQUIRED_INTERNAL_ROLLOUT_TRUE_FIELDS},
|
|
716
|
+
)
|
|
717
|
+
)
|
|
718
|
+
if not _is_positive_int(payload.get("approver_role_count", "")):
|
|
719
|
+
missing.append("approver_role_count")
|
|
720
|
+
if not _is_sha256(str(payload.get("runtime_effective_tool_policy_sha256", ""))):
|
|
721
|
+
missing.append("runtime_effective_tool_policy_sha256")
|
|
722
|
+
if not _is_sha256(str(payload.get("sha256", ""))):
|
|
723
|
+
missing.append("sha256")
|
|
724
|
+
return sorted(missing)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _missing_string_fields(
|
|
728
|
+
payload: Dict[str, Any],
|
|
729
|
+
fields: tuple[str, ...],
|
|
730
|
+
) -> List[str]:
|
|
731
|
+
return [field for field in fields if not str(payload.get(field, "")).strip()]
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _schema_version_missing_fields(payload: Dict[str, Any]) -> List[str]:
|
|
735
|
+
if str(payload.get("evidence_schema_version", "")) != "1":
|
|
736
|
+
return ["evidence_schema_version"]
|
|
737
|
+
return []
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _missing_value_fields(
|
|
741
|
+
payload: Dict[str, Any],
|
|
742
|
+
expected_values: Dict[str, str],
|
|
743
|
+
) -> List[str]:
|
|
744
|
+
return [
|
|
745
|
+
field
|
|
746
|
+
for field, expected in expected_values.items()
|
|
747
|
+
if str(payload.get(field, "")) != expected
|
|
748
|
+
]
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _missing_positive_int_fields(
|
|
752
|
+
payload: Dict[str, Any],
|
|
753
|
+
fields: tuple[str, ...],
|
|
754
|
+
) -> List[str]:
|
|
755
|
+
return [field for field in fields if not _is_positive_int(payload.get(field, ""))]
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _is_positive_int(value: Any) -> bool:
|
|
759
|
+
try:
|
|
760
|
+
return int(str(value)) > 0
|
|
761
|
+
except ValueError:
|
|
762
|
+
return False
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _is_sha256(value: str) -> bool:
|
|
766
|
+
return bool(re.fullmatch(r"[0-9a-f]{64}", value))
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _external_evidence_secret_findings(
|
|
770
|
+
paths_by_label: Dict[str, Optional[Path]],
|
|
771
|
+
) -> List[Dict[str, str]]:
|
|
772
|
+
findings: List[Dict[str, str]] = []
|
|
773
|
+
for label, path in paths_by_label.items():
|
|
774
|
+
if path is None or not path.is_file():
|
|
775
|
+
continue
|
|
776
|
+
try:
|
|
777
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
778
|
+
except json.JSONDecodeError:
|
|
779
|
+
continue
|
|
780
|
+
findings.extend(_secret_findings(label, "$", payload))
|
|
781
|
+
return findings
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _secret_findings(label: str, path: str, value: Any) -> List[Dict[str, str]]:
|
|
785
|
+
findings: List[Dict[str, str]] = []
|
|
786
|
+
if isinstance(value, dict):
|
|
787
|
+
for key, item in value.items():
|
|
788
|
+
child_path = f"{path}.{_json_path_key(str(key))}"
|
|
789
|
+
if _is_secret_like_key(str(key), item):
|
|
790
|
+
findings.append(
|
|
791
|
+
{
|
|
792
|
+
"label": label,
|
|
793
|
+
"path": child_path,
|
|
794
|
+
"reason": "secret_like_key",
|
|
795
|
+
}
|
|
796
|
+
)
|
|
797
|
+
findings.extend(_secret_findings(label, child_path, item))
|
|
798
|
+
elif isinstance(value, list):
|
|
799
|
+
for index, item in enumerate(value):
|
|
800
|
+
findings.extend(_secret_findings(label, f"{path}[{index}]", item))
|
|
801
|
+
elif isinstance(value, str):
|
|
802
|
+
if _is_secret_like_value(value):
|
|
803
|
+
findings.append(
|
|
804
|
+
{
|
|
805
|
+
"label": label,
|
|
806
|
+
"path": path,
|
|
807
|
+
"reason": "secret_like_value",
|
|
808
|
+
}
|
|
809
|
+
)
|
|
810
|
+
if _is_sensitive_url_value(value):
|
|
811
|
+
findings.append(
|
|
812
|
+
{
|
|
813
|
+
"label": label,
|
|
814
|
+
"path": path,
|
|
815
|
+
"reason": "sensitive_url_value",
|
|
816
|
+
}
|
|
817
|
+
)
|
|
818
|
+
return findings
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _is_secret_like_key(key: str, value: Any) -> bool:
|
|
822
|
+
normalized = key.lower().replace("-", "_")
|
|
823
|
+
if normalized in SECRET_KEY_ALLOWLIST:
|
|
824
|
+
return False
|
|
825
|
+
if not _has_present_secret_like_key_value(value):
|
|
826
|
+
return False
|
|
827
|
+
return any(token in normalized for token in SECRET_LIKE_KEYS)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _has_present_secret_like_key_value(value: Any) -> bool:
|
|
831
|
+
if value is None:
|
|
832
|
+
return False
|
|
833
|
+
if isinstance(value, str):
|
|
834
|
+
return bool(value.strip())
|
|
835
|
+
if isinstance(value, (list, dict)):
|
|
836
|
+
return bool(value)
|
|
837
|
+
if isinstance(value, bool):
|
|
838
|
+
return value
|
|
839
|
+
return True
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _is_secret_like_value(value: str) -> bool:
|
|
843
|
+
return any(pattern.search(value) for pattern in SECRET_VALUE_PATTERNS)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _is_sensitive_url_value(value: str) -> bool:
|
|
847
|
+
return bool(SENSITIVE_URL_VALUE_PATTERN.search(value))
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _json_path_key(key: str) -> str:
|
|
851
|
+
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key):
|
|
852
|
+
return key
|
|
853
|
+
return json.dumps(key)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _string_list(value: Any) -> List[str]:
|
|
857
|
+
if not isinstance(value, list):
|
|
858
|
+
return []
|
|
859
|
+
return [str(item) for item in value]
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _string_map(value: Any) -> Dict[str, str]:
|
|
863
|
+
if not isinstance(value, dict):
|
|
864
|
+
return {}
|
|
865
|
+
return {
|
|
866
|
+
str(key): str(item)
|
|
867
|
+
for key, item in value.items()
|
|
868
|
+
if isinstance(key, str) and isinstance(item, (str, int, float, bool))
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _utc_timestamp() -> str:
|
|
873
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
if __name__ == "__main__":
|
|
877
|
+
main()
|