stacktrace-cli 0.6.0__py3-none-any.whl → 0.7.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.
- stacktrace_cli/__init__.py +1 -1
- stacktrace_cli/cli.py +5 -0
- stacktrace_cli/correlate/acquire.py +60 -67
- stacktrace_cli/correlate/composition.py +4 -4
- stacktrace_cli/correlate/orchestrate.py +30 -38
- stacktrace_cli/daemon/identity.py +17 -1
- stacktrace_cli/daemon/presentation.py +6 -1
- stacktrace_cli/daemon/store.py +60 -15
- stacktrace_cli/daemon/webhook.py +36 -5
- stacktrace_cli/detector/blocked.py +12 -0
- stacktrace_cli/detector/deterministic.py +388 -182
- stacktrace_cli/detector/finding.py +3 -0
- stacktrace_cli/detector/render.py +5 -5
- stacktrace_cli/detector/rules.py +47 -8
- stacktrace_cli/detector/run.py +2 -2
- stacktrace_cli/monitor/inventory.py +495 -0
- stacktrace_cli/monitor/render.py +49 -1
- stacktrace_cli/monitor/server.py +40 -4
- stacktrace_cli/monitor/site/app.js +377 -81
- stacktrace_cli/monitor/site/index.html +27 -9
- stacktrace_cli/monitor/site/inventory.js +558 -0
- stacktrace_cli/monitor/site/styles.css +142 -29
- stacktrace_cli/monitor/state.py +45 -2
- stacktrace_cli/monitor/watch.py +156 -65
- stacktrace_cli/remote/cli.py +87 -0
- stacktrace_cli/remote/client.py +21 -4
- stacktrace_cli/remote/payload.py +10 -5
- stacktrace_cli/remote/policy_service.py +1497 -0
- stacktrace_cli/remote/policy_worker.py +64 -0
- stacktrace_cli/remote/redact.py +100 -2
- stacktrace_cli/remote/sync.py +11 -0
- stacktrace_cli/telemetry/events.py +12 -30
- stacktrace_cli/webhook/cli.py +35 -4
- {stacktrace_cli-0.6.0.dist-info → stacktrace_cli-0.7.0.dist-info}/METADATA +3 -3
- {stacktrace_cli-0.6.0.dist-info → stacktrace_cli-0.7.0.dist-info}/RECORD +37 -33
- {stacktrace_cli-0.6.0.dist-info → stacktrace_cli-0.7.0.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.6.0.dist-info → stacktrace_cli-0.7.0.dist-info}/entry_points.txt +0 -0
stacktrace_cli/daemon/webhook.py
CHANGED
|
@@ -9,6 +9,7 @@ import threading
|
|
|
9
9
|
from collections.abc import Callable
|
|
10
10
|
from contextlib import suppress
|
|
11
11
|
from dataclasses import dataclass
|
|
12
|
+
from datetime import UTC, datetime
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
|
|
14
15
|
import httpx
|
|
@@ -18,7 +19,7 @@ from stacktrace_cli.detector.secrets import find_secrets
|
|
|
18
19
|
from stacktrace_cli.telemetry import emit_derived
|
|
19
20
|
from stacktrace_cli.webhook.config import DEFAULT_FORMAT, ConfigError, load_webhook_config
|
|
20
21
|
|
|
21
|
-
from .store import
|
|
22
|
+
from .store import FindingStore, QueuedRecord
|
|
22
23
|
|
|
23
24
|
WEBHOOK_SUBSCRIBER = "webhook"
|
|
24
25
|
|
|
@@ -88,7 +89,7 @@ class WebhookSubscriber:
|
|
|
88
89
|
if not rows:
|
|
89
90
|
break
|
|
90
91
|
row = rows[0]
|
|
91
|
-
if row.
|
|
92
|
+
if row.deliverable:
|
|
92
93
|
config, stopped = self._config()
|
|
93
94
|
if config is None:
|
|
94
95
|
return WebhookReport(delivered, skipped, stopped)
|
|
@@ -111,7 +112,7 @@ class WebhookSubscriber:
|
|
|
111
112
|
else:
|
|
112
113
|
skipped += 1
|
|
113
114
|
self.store.advance_cursor(partition, row.seq, subscriber=WEBHOOK_SUBSCRIBER)
|
|
114
|
-
if row.
|
|
115
|
+
if row.deliverable:
|
|
115
116
|
_report_delivered(partition, row)
|
|
116
117
|
return WebhookReport(delivered=delivered, skipped=skipped)
|
|
117
118
|
|
|
@@ -246,6 +247,7 @@ def _payload(agent_kind: str, row: QueuedRecord) -> dict[str, object]:
|
|
|
246
247
|
"version": 1,
|
|
247
248
|
"type": "finding",
|
|
248
249
|
"event_id": row.event_id,
|
|
250
|
+
"detected_at": row.created_at,
|
|
249
251
|
"rule_id": rule_id,
|
|
250
252
|
"severity": severity,
|
|
251
253
|
"confidence": confidence,
|
|
@@ -380,6 +382,26 @@ def _severity_colour(severity: str) -> str:
|
|
|
380
382
|
return _SEVERITY_COLOURS.get(severity.lower(), _DEFAULT_COLOUR)
|
|
381
383
|
|
|
382
384
|
|
|
385
|
+
def _slack_date(value: object) -> str | None:
|
|
386
|
+
"""A viewer-local date token made only from a valid offset timestamp.
|
|
387
|
+
|
|
388
|
+
The fallback is regenerated from the parsed value rather than copying raw
|
|
389
|
+
input into Slack's control syntax. A malformed legacy row still delivers;
|
|
390
|
+
only its date label is omitted.
|
|
391
|
+
"""
|
|
392
|
+
if not isinstance(value, str):
|
|
393
|
+
return None
|
|
394
|
+
try:
|
|
395
|
+
moment = datetime.fromisoformat(value)
|
|
396
|
+
if moment.tzinfo is None:
|
|
397
|
+
return None
|
|
398
|
+
epoch = int(moment.timestamp())
|
|
399
|
+
fallback = moment.astimezone(UTC).isoformat()
|
|
400
|
+
except (OSError, OverflowError, ValueError):
|
|
401
|
+
return None
|
|
402
|
+
return f"<!date^{epoch}^{{date_short_pretty}} at {{time_secs}}|{fallback}>"
|
|
403
|
+
|
|
404
|
+
|
|
383
405
|
#: Dropped rather than escaped: mrkdwn has no escape these honour reliably,
|
|
384
406
|
#: and no real component type or evidence kind contains one.
|
|
385
407
|
#:
|
|
@@ -436,6 +458,10 @@ def _slack_payload(payload: dict[str, object]) -> dict[str, object]:
|
|
|
436
458
|
assert isinstance(session, dict)
|
|
437
459
|
title = _mrkdwn(payload.get("title") or payload["rule_id"])
|
|
438
460
|
severity = str(payload["severity"])
|
|
461
|
+
detected = _slack_date(payload.get("detected_at"))
|
|
462
|
+
identity_parts = [f"finding ID {_mrkdwn(payload['event_id'])}"]
|
|
463
|
+
if detected is not None:
|
|
464
|
+
identity_parts.insert(0, f"Detected {detected}")
|
|
439
465
|
|
|
440
466
|
fields: list[dict[str, str]] = []
|
|
441
467
|
subject = payload.get("subject")
|
|
@@ -457,8 +483,9 @@ def _slack_payload(payload: dict[str, object]) -> dict[str, object]:
|
|
|
457
483
|
if isinstance(kinds, list) and kinds:
|
|
458
484
|
joined = ", ".join(_mrkdwn(kind) for kind in kinds)
|
|
459
485
|
fields.append({"type": "mrkdwn", "text": f"*Evidence*\n{joined}"})
|
|
486
|
+
# Severity only (ADR-0088). Confidence is nearly always `high`, so in a
|
|
487
|
+
# channel it is a field that says nothing; the JSON format still carries it.
|
|
460
488
|
fields.append({"type": "mrkdwn", "text": f"*Severity*\n{_mrkdwn(severity)}"})
|
|
461
|
-
fields.append({"type": "mrkdwn", "text": f"*Confidence*\n{_mrkdwn(payload['confidence'])}"})
|
|
462
489
|
|
|
463
490
|
blocks: list[dict[str, object]] = [
|
|
464
491
|
{"type": "section", "text": {"type": "mrkdwn", "text": f"*{title}*"}},
|
|
@@ -472,7 +499,11 @@ def _slack_payload(payload: dict[str, object]) -> dict[str, object]:
|
|
|
472
499
|
f"{_mrkdwn(session['agent_kind'])}"
|
|
473
500
|
f" \u00b7 session {_mrkdwn(session['session_id'])}"
|
|
474
501
|
),
|
|
475
|
-
}
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
"type": "mrkdwn",
|
|
505
|
+
"text": " \u00b7 ".join(identity_parts),
|
|
506
|
+
},
|
|
476
507
|
],
|
|
477
508
|
},
|
|
478
509
|
]
|
|
@@ -35,6 +35,8 @@ from __future__ import annotations
|
|
|
35
35
|
import re
|
|
36
36
|
from dataclasses import dataclass
|
|
37
37
|
|
|
38
|
+
from stacktrace_cli.detector.rules import RELIABILITY_RULES
|
|
39
|
+
|
|
38
40
|
#: Who can act on a block. Not a severity — the thing severity is derived from.
|
|
39
41
|
#:
|
|
40
42
|
#: `user` is the account owner or whoever drove the session; `upstream` is a
|
|
@@ -71,6 +73,10 @@ class Reason:
|
|
|
71
73
|
"""One way work stops, and what a reader should do about it."""
|
|
72
74
|
|
|
73
75
|
code: str
|
|
76
|
+
#: The rule a finding for this reason is filed under -- one per reason, so
|
|
77
|
+
#: anything that reads only the rule id still tells a spend limit from a
|
|
78
|
+
#: throttle (ADR-0083). `code` stays the evidence `kind`.
|
|
79
|
+
rule_id: str
|
|
74
80
|
pattern: re.Pattern[str]
|
|
75
81
|
attribution: Attribution
|
|
76
82
|
#: The finding's title. States the cause, never the symptom — a title
|
|
@@ -149,6 +155,8 @@ class Reason:
|
|
|
149
155
|
for word in self.specific_vocabulary:
|
|
150
156
|
if word != word.casefold() or not _SAFE_SPECIFIC.fullmatch(word):
|
|
151
157
|
raise ValueError(f"{self.code}: {word!r} is not a lowercase, path-free specific")
|
|
158
|
+
if self.rule_id not in RELIABILITY_RULES:
|
|
159
|
+
raise ValueError(f"{self.code}: {self.rule_id!r} is not a reliability rule")
|
|
152
160
|
|
|
153
161
|
def specific(self, text: str | None) -> str | None:
|
|
154
162
|
"""Which rule refused, where the client said so and we have a word for it.
|
|
@@ -190,6 +198,7 @@ class Reason:
|
|
|
190
198
|
REASONS: tuple[Reason, ...] = (
|
|
191
199
|
Reason(
|
|
192
200
|
code="quota_exhausted",
|
|
201
|
+
rule_id="stacktrace-blocked-quota",
|
|
193
202
|
label="Spend limit reached",
|
|
194
203
|
pattern=re.compile(
|
|
195
204
|
r"hit your (individual |team )?(spend|usage) limit"
|
|
@@ -227,6 +236,7 @@ REASONS: tuple[Reason, ...] = (
|
|
|
227
236
|
),
|
|
228
237
|
Reason(
|
|
229
238
|
code="policy_blocked",
|
|
239
|
+
rule_id="stacktrace-blocked-policy",
|
|
230
240
|
label="Safety check refused",
|
|
231
241
|
pattern=re.compile(
|
|
232
242
|
r"could not evaluate this action and is blocking it for safety"
|
|
@@ -290,6 +300,7 @@ REASONS: tuple[Reason, ...] = (
|
|
|
290
300
|
),
|
|
291
301
|
Reason(
|
|
292
302
|
code="provider_throttled",
|
|
303
|
+
rule_id="stacktrace-blocked-throttled",
|
|
293
304
|
label="Rate limited",
|
|
294
305
|
# Anchored on what a client or a service *writes* when it throttles,
|
|
295
306
|
# never on a bare status code. Measured on 76 real sessions, `429`
|
|
@@ -347,6 +358,7 @@ REASONS: tuple[Reason, ...] = (
|
|
|
347
358
|
),
|
|
348
359
|
Reason(
|
|
349
360
|
code="upstream_refused",
|
|
361
|
+
rule_id="stacktrace-blocked-upstream",
|
|
350
362
|
label="Service refused",
|
|
351
363
|
# Service-generic, not GitHub-shaped. The first two literals are how
|
|
352
364
|
# GitHub words it and the rest are the status lines every HTTP service
|