stacktrace-cli 0.6.1__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/daemon/webhook.py +2 -1
- stacktrace_cli/detector/deterministic.py +147 -10
- stacktrace_cli/detector/finding.py +3 -0
- stacktrace_cli/monitor/site/app.js +14 -8
- stacktrace_cli/monitor/site/index.html +0 -6
- stacktrace_cli/monitor/site/inventory.js +19 -15
- stacktrace_cli/monitor/site/styles.css +3 -13
- stacktrace_cli/remote/cli.py +87 -0
- stacktrace_cli/remote/client.py +21 -4
- stacktrace_cli/remote/policy_service.py +1497 -0
- stacktrace_cli/remote/policy_worker.py +64 -0
- {stacktrace_cli-0.6.1.dist-info → stacktrace_cli-0.7.0.dist-info}/METADATA +3 -3
- {stacktrace_cli-0.6.1.dist-info → stacktrace_cli-0.7.0.dist-info}/RECORD +16 -14
- {stacktrace_cli-0.6.1.dist-info → stacktrace_cli-0.7.0.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.6.1.dist-info → stacktrace_cli-0.7.0.dist-info}/entry_points.txt +0 -0
stacktrace_cli/__init__.py
CHANGED
stacktrace_cli/daemon/webhook.py
CHANGED
|
@@ -483,8 +483,9 @@ def _slack_payload(payload: dict[str, object]) -> dict[str, object]:
|
|
|
483
483
|
if isinstance(kinds, list) and kinds:
|
|
484
484
|
joined = ", ".join(_mrkdwn(kind) for kind in kinds)
|
|
485
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.
|
|
486
488
|
fields.append({"type": "mrkdwn", "text": f"*Severity*\n{_mrkdwn(severity)}"})
|
|
487
|
-
fields.append({"type": "mrkdwn", "text": f"*Confidence*\n{_mrkdwn(payload['confidence'])}"})
|
|
488
489
|
|
|
489
490
|
blocks: list[dict[str, object]] = [
|
|
490
491
|
{"type": "section", "text": {"type": "mrkdwn", "text": f"*{title}*"}},
|
|
@@ -38,11 +38,13 @@ import os
|
|
|
38
38
|
import re
|
|
39
39
|
import shlex
|
|
40
40
|
from collections.abc import Sequence
|
|
41
|
+
from urllib.parse import urlsplit
|
|
41
42
|
|
|
42
43
|
from stacktrace_cli.correlate.composition import Advisory, Component, Composition
|
|
43
44
|
from stacktrace_cli.correlate.join import Resolution
|
|
44
45
|
from stacktrace_cli.correlate.observed import (
|
|
45
46
|
_MAX_SHELL_DEPTH,
|
|
47
|
+
_segment_capabilities,
|
|
46
48
|
capabilities,
|
|
47
49
|
command_segments,
|
|
48
50
|
command_segments_with_ops,
|
|
@@ -90,6 +92,7 @@ def _credential_egress(call: ToolCallLike, ref: SessionRef) -> Detection | None:
|
|
|
90
92
|
if not matches:
|
|
91
93
|
return None
|
|
92
94
|
kinds = sorted({m.kind for m in matches})
|
|
95
|
+
target = _outside_target(call)
|
|
93
96
|
return Detection(
|
|
94
97
|
rule_id="stacktrace-credential-egress",
|
|
95
98
|
severity="high",
|
|
@@ -109,10 +112,13 @@ def _credential_egress(call: ToolCallLike, ref: SessionRef) -> Detection | None:
|
|
|
109
112
|
# arguments object, and a key is chosen by whoever wrote the
|
|
110
113
|
# call: an MCP server's schema, or a literal like
|
|
111
114
|
# `{"/private/customer/path": "sk-..."}` in a body the agent
|
|
112
|
-
# was handed.
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
|
|
115
|
+
# was handed. The offset is withheld too: a reader of an alert
|
|
116
|
+
# cannot use a character position, and the span already
|
|
117
|
+
# locates the call. Where it went is what they act on
|
|
118
|
+
# (ADR-0086).
|
|
119
|
+
detail=f"sent to {target}"
|
|
120
|
+
if target
|
|
121
|
+
else f"in the arguments of {tool_name_for(call)}",
|
|
116
122
|
)
|
|
117
123
|
for match in matches
|
|
118
124
|
),
|
|
@@ -131,6 +137,122 @@ def _command(call: ToolCallLike) -> str | None:
|
|
|
131
137
|
return command if isinstance(command, str) and command else None
|
|
132
138
|
|
|
133
139
|
|
|
140
|
+
#: Vendor CLIs that talk to one service, and the name a reader knows it by.
|
|
141
|
+
#: A CLI that reaches whatever it is pointed at -- `curl`, `git`, `npm` -- is
|
|
142
|
+
#: absent, because its name says nothing about who answered; the URL it was
|
|
143
|
+
#: given does (`_shell_target`).
|
|
144
|
+
_SERVICE_CLIS: dict[str, str] = {
|
|
145
|
+
"gh": "GitHub",
|
|
146
|
+
"glab": "GitLab",
|
|
147
|
+
"aws": "AWS",
|
|
148
|
+
"gcloud": "Google Cloud",
|
|
149
|
+
"az": "Azure",
|
|
150
|
+
"kubectl": "Kubernetes",
|
|
151
|
+
"vercel": "Vercel",
|
|
152
|
+
"netlify": "Netlify",
|
|
153
|
+
"railway": "Railway",
|
|
154
|
+
"flyctl": "Fly",
|
|
155
|
+
"heroku": "Heroku",
|
|
156
|
+
"wrangler": "Cloudflare",
|
|
157
|
+
"stripe": "Stripe",
|
|
158
|
+
"supabase": "Supabase",
|
|
159
|
+
"firebase": "Firebase",
|
|
160
|
+
"doctl": "DigitalOcean",
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
#: An MCP server or tool name that may be named: identifier characters only,
|
|
165
|
+
#: so never a sentence (no spaces) and never a path (no slashes) (ADR-0086).
|
|
166
|
+
_NAMEABLE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}")
|
|
167
|
+
|
|
168
|
+
#: A host as `urlsplit` returns it: lowercase letters, digits, dots, hyphens.
|
|
169
|
+
_HOSTNAME = re.compile(r"[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _host(value: object) -> str | None:
|
|
173
|
+
"""The host of an `http(s)://` URL, and nothing else of it.
|
|
174
|
+
|
|
175
|
+
Scheme, userinfo, port, path and query all stay behind: they are where
|
|
176
|
+
tokens and customer identifiers sit, and the host is what answered.
|
|
177
|
+
"""
|
|
178
|
+
if not isinstance(value, str) or not value.lower().startswith(("http://", "https://")):
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
parts = urlsplit(value)
|
|
182
|
+
host = parts.hostname
|
|
183
|
+
except ValueError:
|
|
184
|
+
return None
|
|
185
|
+
if not host or not _HOSTNAME.fullmatch(host):
|
|
186
|
+
return None
|
|
187
|
+
# `hostname` is lowercased, which hides a case-sensitive credential shape
|
|
188
|
+
# (`AKIA…`) from `find_secrets`, so the host is scanned as it was written.
|
|
189
|
+
if find_secrets(parts.netloc.rpartition("@")[2]):
|
|
190
|
+
return None
|
|
191
|
+
return host
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _shell_target(command: str) -> str | None:
|
|
195
|
+
"""The one service or host a shell command reached, or None.
|
|
196
|
+
|
|
197
|
+
A vendor CLI names its service (`GitHub (gh)`). Any other segment that
|
|
198
|
+
reaches the network must name exactly one URL host. A command that reached
|
|
199
|
+
two places (`gh … && curl …`), or a network segment with no URL (`git
|
|
200
|
+
push origin`), names nothing: the record does not say which one answered,
|
|
201
|
+
and naming the wrong one sends the reader to the wrong service.
|
|
202
|
+
|
|
203
|
+
Reads `command_segments`, the one shell parser, and asks it only which
|
|
204
|
+
programs ran, whether each reached the network, and its URL arguments.
|
|
205
|
+
"""
|
|
206
|
+
targets: set[str] = set()
|
|
207
|
+
for program, arguments in command_segments(command):
|
|
208
|
+
if program in _SERVICE_CLIS:
|
|
209
|
+
targets.add(f"{_SERVICE_CLIS[program]} ({program})")
|
|
210
|
+
continue
|
|
211
|
+
if not any(
|
|
212
|
+
name == "network_egress" for name, _ in _segment_capabilities(program, arguments)
|
|
213
|
+
):
|
|
214
|
+
continue
|
|
215
|
+
hosts = {host for argument in arguments if (host := _host(argument))}
|
|
216
|
+
if len(hosts) != 1:
|
|
217
|
+
return None
|
|
218
|
+
targets |= hosts
|
|
219
|
+
return targets.pop() if len(targets) == 1 else None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _outside_target(call: ToolCallLike) -> str | None:
|
|
223
|
+
"""Who answered this call, where the record names one (ADR-0086).
|
|
224
|
+
|
|
225
|
+
`linear MCP · search_issues` for an MCP call, the URL's host for WebFetch,
|
|
226
|
+
and `_shell_target` for a shell command. The server and tool names are the
|
|
227
|
+
session's and the server's own words, so they are named only when shaped
|
|
228
|
+
like identifiers, and nothing secret-shaped is named at all.
|
|
229
|
+
|
|
230
|
+
The one place a finding names the tool an MCP server chose, and only for
|
|
231
|
+
the citations ADR-0086 lists. Everywhere else `tool_name_for` holds.
|
|
232
|
+
"""
|
|
233
|
+
# A denied call reached nothing, so nothing answered it, whatever its
|
|
234
|
+
# error text says.
|
|
235
|
+
if denied(call):
|
|
236
|
+
return None
|
|
237
|
+
target: str | None
|
|
238
|
+
if call.mcp_server:
|
|
239
|
+
if not _NAMEABLE.fullmatch(call.mcp_server):
|
|
240
|
+
return None
|
|
241
|
+
# `customer-support-mcp` already says what it is.
|
|
242
|
+
server = call.mcp_server
|
|
243
|
+
target = server if server.lower().endswith("mcp") else f"{server} MCP"
|
|
244
|
+
if _NAMEABLE.fullmatch(call.tool_name):
|
|
245
|
+
target += f" · {call.tool_name}"
|
|
246
|
+
elif call.tool_name == "WebFetch":
|
|
247
|
+
target = _host((call.arguments or {}).get("url"))
|
|
248
|
+
else:
|
|
249
|
+
command = _command(call)
|
|
250
|
+
target = _shell_target(command) if command else None
|
|
251
|
+
if target is None or find_secrets(target):
|
|
252
|
+
return None
|
|
253
|
+
return target
|
|
254
|
+
|
|
255
|
+
|
|
134
256
|
def _block_body(call: ToolCallLike) -> str | None:
|
|
135
257
|
"""The body `_agent_blocked` classifies a block from.
|
|
136
258
|
|
|
@@ -298,9 +420,18 @@ def _blocked_remediation(
|
|
|
298
420
|
|
|
299
421
|
def _block_span(session: SessionLike, turn: TurnLike) -> str:
|
|
300
422
|
"""A turn-level span: the same session and turn key a call span is built
|
|
301
|
-
from,
|
|
302
|
-
|
|
303
|
-
|
|
423
|
+
from, with index 0.
|
|
424
|
+
|
|
425
|
+
The index is there because the span grammar requires one: the upload
|
|
426
|
+
contract and Fleet both refuse a span that does not end in `:<n>`, so a
|
|
427
|
+
block reported in a reply never reached Fleet. It cannot collide with a
|
|
428
|
+
call span, because this is only ever built for a turn with no calls.
|
|
429
|
+
|
|
430
|
+
Accepted: a reply-only block that rang under the retired
|
|
431
|
+
`stacktrace-agent-blocked` rule (before 0.6.0) is matched by that rule's
|
|
432
|
+
old anchor, which had no index, so it can ring once more on a machine
|
|
433
|
+
that upgrades from before 0.6.0 straight to this version."""
|
|
434
|
+
return f"{session.session_id}:{turn.key}:0"
|
|
304
435
|
|
|
305
436
|
|
|
306
437
|
def block_spans_by_turn(session: SessionLike) -> dict[str, TurnLike]:
|
|
@@ -439,12 +570,16 @@ def _agent_blocked(session: SessionLike, ref: SessionRef) -> tuple[Detection, ..
|
|
|
439
570
|
if not denied(call):
|
|
440
571
|
worked_at.append((turn.position, index))
|
|
441
572
|
continue
|
|
573
|
+
# Who refused, where a service did. A spend limit or a safety
|
|
574
|
+
# check is the client refusing, and naming the host the call was
|
|
575
|
+
# aimed at would blame it instead.
|
|
576
|
+
target = _outside_target(call) if reason.attribution == "upstream" else None
|
|
442
577
|
found.setdefault(reason.code, []).append(
|
|
443
578
|
Evidence(
|
|
444
579
|
span=call.span,
|
|
445
580
|
kind=reason.code,
|
|
446
581
|
detail=_blocked_detail(
|
|
447
|
-
tool_name_for(call),
|
|
582
|
+
target or tool_name_for(call),
|
|
448
583
|
reason.specific(_block_body(call)),
|
|
449
584
|
),
|
|
450
585
|
)
|
|
@@ -1581,7 +1716,7 @@ def _credential_persisted(session: SessionLike, ref: SessionRef) -> tuple[Detect
|
|
|
1581
1716
|
Evidence(
|
|
1582
1717
|
span=call.span,
|
|
1583
1718
|
kind=match.kind,
|
|
1584
|
-
detail=f"
|
|
1719
|
+
detail=f"written to a file by {tool_name_for(call)}",
|
|
1585
1720
|
)
|
|
1586
1721
|
for match in matches
|
|
1587
1722
|
),
|
|
@@ -1773,6 +1908,8 @@ def _tool_retry_storm(session: SessionLike, ref: SessionRef) -> tuple[Detection,
|
|
|
1773
1908
|
last_key = key
|
|
1774
1909
|
|
|
1775
1910
|
if counters.get(key, 0) >= _RETRY_THRESHOLD:
|
|
1911
|
+
# What the agent kept calling, not the tool it called it with.
|
|
1912
|
+
target = _outside_target(call) or tool_name_for(call)
|
|
1776
1913
|
det = Detection(
|
|
1777
1914
|
rule_id="stacktrace-tool-retry-storm",
|
|
1778
1915
|
severity="low",
|
|
@@ -1783,7 +1920,7 @@ def _tool_retry_storm(session: SessionLike, ref: SessionRef) -> tuple[Detection,
|
|
|
1783
1920
|
Evidence(
|
|
1784
1921
|
span=span,
|
|
1785
1922
|
kind="identical_retry",
|
|
1786
|
-
detail=f"{
|
|
1923
|
+
detail=f"{target} called with identical parameters",
|
|
1787
1924
|
)
|
|
1788
1925
|
for span in storm_spans
|
|
1789
1926
|
),
|
|
@@ -97,6 +97,9 @@ def tool_name_for(call: ToolCallLike) -> str:
|
|
|
97
97
|
the ADR puts it — `Detection.components` where it was placed, a
|
|
98
98
|
`Participant.name` where it was not.
|
|
99
99
|
|
|
100
|
+
ADR-0086 is the one exception: `deterministic._outside_target` names the
|
|
101
|
+
server and tool in the citations of the rules it lists, and nowhere else.
|
|
102
|
+
|
|
100
103
|
Lives here rather than beside one rule because both stages need it, and a
|
|
101
104
|
second copy of this argument is a second chance to get it wrong — which is
|
|
102
105
|
the class `test_untrusted_content_never_travels.py` exists to close.
|
|
@@ -700,16 +700,22 @@ function eventsFrom(sessions) {
|
|
|
700
700
|
|
|
701
701
|
/* Components *invoked*, by kind. The demo's strip counted what was installed;
|
|
702
702
|
* this snapshot carries only what was reached, so the chip says "invoked"
|
|
703
|
-
* rather than borrowing a word for a number it does not hold.
|
|
703
|
+
* rather than borrowing a word for a number it does not hold.
|
|
704
|
+
*
|
|
705
|
+
* Every kind counted here is one `renderComposition` draws a chip for, so the
|
|
706
|
+
* chips add up to the total. A built-in sub-agent has no component type and
|
|
707
|
+
* the category `subagent`, which no chip named; anything else unnamed is
|
|
708
|
+
* "Other" rather than a number the total holds and no chip shows. */
|
|
704
709
|
function compositionOf(sessions) {
|
|
705
710
|
const byType = {};
|
|
706
711
|
const seen = new Set();
|
|
707
712
|
for (const session of sessions) {
|
|
708
713
|
for (const row of session.activity || []) {
|
|
709
714
|
if (unplaceable(row)) continue;
|
|
710
|
-
const
|
|
715
|
+
const raw = row.component_type || row.category;
|
|
716
|
+
const type = raw === "subagent" ? "agent" : COMPONENT_KINDS[raw] ? raw : "other";
|
|
711
717
|
const key = type + "\n" + (row.component_identity || row.component_name || row.tool_name);
|
|
712
|
-
if (!
|
|
718
|
+
if (!raw || seen.has(key)) continue;
|
|
713
719
|
seen.add(key);
|
|
714
720
|
byType[type] = (byType[type] || 0) + 1;
|
|
715
721
|
}
|
|
@@ -779,7 +785,7 @@ let lastPolicies = [];
|
|
|
779
785
|
let lastComplete = false;
|
|
780
786
|
let lastSessionCount = 0;
|
|
781
787
|
|
|
782
|
-
/* Whether the reader opened or closed the list with "
|
|
788
|
+
/* Whether the reader opened or closed the list with "Scanned Policies", or
|
|
783
789
|
* `null` while they have not and the list follows the view: open while a
|
|
784
790
|
* range loads or a pass checks, and when a finished pass found nothing;
|
|
785
791
|
* folded only once a finished pass has findings, so the cards lead.
|
|
@@ -838,8 +844,8 @@ const POLICY_WORD = { checking: "checking", clear: "clear", idle: "" };
|
|
|
838
844
|
* the pass has finished -- the false silence ADR-0004 rules out.
|
|
839
845
|
*
|
|
840
846
|
* Finished, a clean window keeps the list, every policy ticked clear, and a
|
|
841
|
-
* window with findings folds it to its one line so the cards lead. "
|
|
842
|
-
*
|
|
847
|
+
* window with findings folds it to its one line so the cards lead. "Scanned
|
|
848
|
+
* Policies" opens or folds the list for the view on screen (`policyListOpen`). */
|
|
843
849
|
function renderPolicyCheck(view) {
|
|
844
850
|
lastPolicyView = view;
|
|
845
851
|
const node = el("policy-check");
|
|
@@ -892,7 +898,7 @@ function renderPolicyCheck(view) {
|
|
|
892
898
|
const open = policyListOpen === null ? !findings : policyListOpen;
|
|
893
899
|
lastPolicyOpen = open;
|
|
894
900
|
const toggle = '<button type="button" class="pc-toggle" aria-expanded="' + open + '">' +
|
|
895
|
-
"
|
|
901
|
+
"Scanned Policies " + (open ? "▴" : "▾") + "</button>";
|
|
896
902
|
const found = {};
|
|
897
903
|
for (const alert of alerts) found[alert.rule_id] = (found[alert.rule_id] || 0) + 1;
|
|
898
904
|
const rows = lastPolicies.map(function (policy) {
|
|
@@ -910,7 +916,7 @@ function renderPolicyCheck(view) {
|
|
|
910
916
|
(open ? '<ul class="pc-list">' + rows + "</ul>" : ""));
|
|
911
917
|
}
|
|
912
918
|
|
|
913
|
-
/* "
|
|
919
|
+
/* "Scanned Policies": open the list if it is folded, fold it if it is open. */
|
|
914
920
|
function togglePolicyList() {
|
|
915
921
|
policyListOpen = !lastPolicyOpen;
|
|
916
922
|
renderPolicyCheck(lastPolicyView);
|
|
@@ -86,12 +86,6 @@
|
|
|
86
86
|
</div>
|
|
87
87
|
</section>
|
|
88
88
|
|
|
89
|
-
<div class="panel-head panel-head-logs">
|
|
90
|
-
<span class="panel-kick">Live</span>
|
|
91
|
-
<h2>What your agents are doing</h2>
|
|
92
|
-
<span class="panel-note">read from this machine's own transcripts</span>
|
|
93
|
-
</div>
|
|
94
|
-
|
|
95
89
|
<div class="logs">
|
|
96
90
|
<section class="logcol">
|
|
97
91
|
<header class="logcol-head">
|
|
@@ -39,9 +39,10 @@ function typeLabel(type, many) {
|
|
|
39
39
|
return titled(type);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/* The scanner's labels, in tile order. A label outside these
|
|
43
|
-
*
|
|
44
|
-
|
|
42
|
+
/* The scanner's labels, in tile order. A label outside these -- UNKNOWN among
|
|
43
|
+
* them -- gets a tile of its own only when a vulnerability carries it, rather
|
|
44
|
+
* than being folded into one: the page never re-grades. */
|
|
45
|
+
const INV_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
|
|
45
46
|
|
|
46
47
|
/* Full class names rather than built by concatenation, so the check that every
|
|
47
48
|
* class the page emits is styled can see each one. */
|
|
@@ -72,7 +73,7 @@ function checkCell(row) {
|
|
|
72
73
|
case "not_checked":
|
|
73
74
|
return { text: "Not checked", cls: "inv-check inv-check-warn" };
|
|
74
75
|
case "no_package":
|
|
75
|
-
return { text: "
|
|
76
|
+
return { text: "None", cls: "inv-check inv-check-dim" };
|
|
76
77
|
default:
|
|
77
78
|
return { text: "Checking…", cls: "inv-check inv-check-dim" };
|
|
78
79
|
}
|
|
@@ -95,6 +96,10 @@ const invOpen = new Set();
|
|
|
95
96
|
|
|
96
97
|
/* --- the URL -------------------------------------------------------------- */
|
|
97
98
|
|
|
99
|
+
/* The Vulnerabilities tab's views. A bookmarked one no longer listed reads
|
|
100
|
+
* as the default rather than as a view with no chip pressed. */
|
|
101
|
+
const INV_VIEWS = ["vulnerabilities", "posture"];
|
|
102
|
+
|
|
98
103
|
/* Which filters each tab writes to the hash. */
|
|
99
104
|
const INV_HASH_KEYS = {
|
|
100
105
|
inventory: ["agent", "type", "show", "inactive"],
|
|
@@ -109,6 +114,7 @@ function readHash(hash) {
|
|
|
109
114
|
const filter = Object.assign({}, INV_DEFAULTS);
|
|
110
115
|
for (const key of INV_HASH_KEYS[tab] || []) {
|
|
111
116
|
if (!params.has(key)) continue;
|
|
117
|
+
if (key === "view" && !INV_VIEWS.includes(params.get(key))) continue;
|
|
112
118
|
filter[key] = key === "inactive" ? params.get(key) === "1" : params.get(key);
|
|
113
119
|
}
|
|
114
120
|
return { tab: INV_TABS.includes(tab) ? tab : "live", filter: filter };
|
|
@@ -188,14 +194,17 @@ const instances = (issues) =>
|
|
|
188
194
|
issues.reduce((sum, issue) => sum + issue.affected.length + issue.subjects.length, 0);
|
|
189
195
|
|
|
190
196
|
/* The Vulnerabilities tab's tiles. Distinct vulnerabilities by label, then
|
|
191
|
-
* posture issues
|
|
192
|
-
*
|
|
197
|
+
* posture issues as instances: two trusted directories are two posture issues
|
|
198
|
+
* on one row. */
|
|
193
199
|
function severityTiles(doc, filter) {
|
|
194
200
|
const scope = Object.assign({}, filter, { severity: "" });
|
|
195
201
|
const counts = new Map(INV_SEVERITIES.map((s) => [s, 0]));
|
|
196
202
|
for (const v of vulnerabilityRows(doc, scope)) {
|
|
197
203
|
counts.set(label(v.severity), (counts.get(label(v.severity)) || 0) + 1);
|
|
198
204
|
}
|
|
205
|
+
/* The selected label keeps its tile, at 0 if nothing in scope carries it,
|
|
206
|
+
* so a filter that is on always has a tile to show it and to clear it. */
|
|
207
|
+
if (filter.severity && !counts.has(filter.severity)) counts.set(filter.severity, 0);
|
|
199
208
|
const checked = doc.checked === "done";
|
|
200
209
|
const tiles = Array.from(counts.entries()).map(([severity, count]) => ({
|
|
201
210
|
key: "severity", value: severity, label: titled(severity.toLowerCase()),
|
|
@@ -205,10 +214,6 @@ function severityTiles(doc, filter) {
|
|
|
205
214
|
key: "view", value: "posture", label: "Posture issues",
|
|
206
215
|
count: instances(issueRows(doc.posture, doc, scope)),
|
|
207
216
|
});
|
|
208
|
-
tiles.push({
|
|
209
|
-
key: "view", value: "observations", label: "Observations",
|
|
210
|
-
count: instances(issueRows(doc.observations, doc, scope)),
|
|
211
|
-
});
|
|
212
217
|
return tiles;
|
|
213
218
|
}
|
|
214
219
|
|
|
@@ -405,7 +410,7 @@ function renderVulnerabilitiesTab(doc) {
|
|
|
405
410
|
html += '<p class="inv-summary">' + esc(vulnerabilitiesLine(doc, invFilter)) + "</p>";
|
|
406
411
|
html += '<div class="inv-filters"><span class="inv-group">' +
|
|
407
412
|
chip("view", "vulnerabilities", "Vulnerabilities") + chip("view", "posture", "Posture issues") +
|
|
408
|
-
|
|
413
|
+
"</span>" + agentSelect("vul-agent", doc) +
|
|
409
414
|
"</div>";
|
|
410
415
|
|
|
411
416
|
if (invFilter.view === "vulnerabilities") {
|
|
@@ -428,9 +433,8 @@ function renderVulnerabilitiesTab(doc) {
|
|
|
428
433
|
return html + table(["Vulnerability", "Severity", "Fixed in", "Affects"], body);
|
|
429
434
|
}
|
|
430
435
|
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
if (!issues.length) return html + emptyBody(posture ? "No posture issues." : "No observations.");
|
|
436
|
+
const issues = issueRows(doc.posture, doc, invFilter);
|
|
437
|
+
if (!issues.length) return html + emptyBody("No posture issues.");
|
|
434
438
|
const body = issues.map((issue) =>
|
|
435
439
|
'<tr class="inv-row"><td>' + esc(issue.summary) +
|
|
436
440
|
(issue.sent_to_fleet ? "" : ' <span class="inv-marker">Not sent to Fleet</span>') +
|
|
@@ -439,7 +443,7 @@ function renderVulnerabilitiesTab(doc) {
|
|
|
439
443
|
"</td><td>" + esc(fmt(issue.affected.length + issue.subjects.length)) + "</td></tr>" +
|
|
440
444
|
'<tr class="inv-under"><td colspan="4">' + componentLines(issue.affected) +
|
|
441
445
|
subjectLines(issue.subjects) + "</td></tr>").join("");
|
|
442
|
-
return html + table([
|
|
446
|
+
return html + table(["Posture issue", "Severity", "Fix", "Affects"], body);
|
|
443
447
|
}
|
|
444
448
|
|
|
445
449
|
/* The one note these tabs draw above themselves: that they describe this
|
|
@@ -299,7 +299,7 @@ body.watch-mode .hero, body.watch-mode .band, body.watch-mode .status, body.watc
|
|
|
299
299
|
ran; an alert is a sentence, a citation list and a remediation, and it was
|
|
300
300
|
the one being wrapped. Order in the markup is trace-then-alerts, so the
|
|
301
301
|
ratio reads 2:3. */
|
|
302
|
-
.logs { display:grid; grid-template-columns:minmax(0,2fr) minmax(0,3fr); gap:1.2rem; align-items:start; }
|
|
302
|
+
.logs { display:grid; grid-template-columns:minmax(0,2fr) minmax(0,3fr); gap:1.2rem; align-items:start; margin-top:1.6rem; }
|
|
303
303
|
@media (max-width:860px){ .logs { grid-template-columns:1fr; } }
|
|
304
304
|
|
|
305
305
|
/* Each feed is a panel with a real edge. Unbordered columns read as loose text
|
|
@@ -539,10 +539,8 @@ body.watch-mode .hero, body.watch-mode .band, body.watch-mode .status, body.watc
|
|
|
539
539
|
/* ---------- overview section: two trend tables ---------- */
|
|
540
540
|
.overview { margin:0 0 0.4rem; }
|
|
541
541
|
/* ---------- section headers ----------
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
coral dot are stacktrace-site's `.eyebrow`; the rule under the title is what
|
|
545
|
-
gives the section its weight against the run bar above it. */
|
|
542
|
+
The rule under the title is what gives the section its weight against the
|
|
543
|
+
run bar above it. */
|
|
546
544
|
.panel-head {
|
|
547
545
|
display:flex; align-items:baseline; gap:0.7rem; flex-wrap:wrap;
|
|
548
546
|
margin:0 0 0.9rem; padding-bottom:0.5rem;
|
|
@@ -551,13 +549,6 @@ body.watch-mode .hero, body.watch-mode .band, body.watch-mode .status, body.watc
|
|
|
551
549
|
.panel-head h2 {
|
|
552
550
|
margin:0; font-size:1.35rem; font-weight:700; letter-spacing:-0.03em; color:var(--ink);
|
|
553
551
|
}
|
|
554
|
-
.panel-kick {
|
|
555
|
-
display:inline-flex; align-items:center; gap:0.45em;
|
|
556
|
-
font-family:var(--mono); font-size:0.62rem; font-weight:500;
|
|
557
|
-
letter-spacing:0.14em; text-transform:uppercase; color:var(--ink-dim);
|
|
558
|
-
}
|
|
559
|
-
.panel-kick::before { content:""; width:7px; height:7px; border-radius:50%; background:var(--coral); }
|
|
560
|
-
.panel-note { margin-left:auto; font-family:var(--mono); font-size:0.68rem; color:var(--ink-faint); }
|
|
561
552
|
/* The window reads at nearly the heading's weight, because every number under
|
|
562
553
|
it is "in this window" and a range mistaken for the default is a page
|
|
563
554
|
misread rather than a detail missed. */
|
|
@@ -578,7 +569,6 @@ body.watch-mode .hero, body.watch-mode .band, body.watch-mode .status, body.watc
|
|
|
578
569
|
.range-btn[aria-pressed="true"] {
|
|
579
570
|
color:var(--paper); background:var(--ink); border-color:var(--ink);
|
|
580
571
|
}
|
|
581
|
-
.panel-head-logs { margin-top:2rem; }
|
|
582
572
|
|
|
583
573
|
.ov-win { font-family:var(--mono); font-size:0.66rem; font-weight:400; color:var(--ink-faint); font-style:italic; }
|
|
584
574
|
.ov-grid { display:grid; grid-template-columns:1fr 1fr; gap:1.1rem; }
|
stacktrace_cli/remote/cli.py
CHANGED
|
@@ -37,6 +37,11 @@ from stacktrace_cli.remote.config import (
|
|
|
37
37
|
save_remote_config,
|
|
38
38
|
)
|
|
39
39
|
from stacktrace_cli.remote.policy import compile_remote_policy
|
|
40
|
+
from stacktrace_cli.remote.policy_service import (
|
|
41
|
+
ApplyResult,
|
|
42
|
+
PolicyServiceError,
|
|
43
|
+
default_policy_service,
|
|
44
|
+
)
|
|
40
45
|
from stacktrace_cli.remote.spool import clear_pending_uploads
|
|
41
46
|
from stacktrace_cli.remote.sync import (
|
|
42
47
|
NOT_CONFIGURED,
|
|
@@ -195,6 +200,88 @@ def policy_compile(
|
|
|
195
200
|
)
|
|
196
201
|
|
|
197
202
|
|
|
203
|
+
@policy.command("apply")
|
|
204
|
+
def policy_apply() -> None:
|
|
205
|
+
"""Run one privileged Fleet policy reconciliation."""
|
|
206
|
+
try:
|
|
207
|
+
result = default_policy_service().apply()
|
|
208
|
+
except PolicyServiceError as exc:
|
|
209
|
+
raise click.ClickException(exc.code) from exc
|
|
210
|
+
click.echo(_policy_apply_message(result))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@policy.command("install-service")
|
|
214
|
+
@click.option("--user", required=True, help="Developer account whose Claude configuration is read.")
|
|
215
|
+
@click.option("--api-url", required=True, help="Administrator-confirmed Fleet API origin.")
|
|
216
|
+
def policy_install_service(user: str, api_url: str) -> None:
|
|
217
|
+
"""Enroll and install automatic privileged policy reconciliation."""
|
|
218
|
+
try:
|
|
219
|
+
result = default_policy_service().install(
|
|
220
|
+
user=user,
|
|
221
|
+
expected_api_url=api_url,
|
|
222
|
+
)
|
|
223
|
+
except PolicyServiceError as exc:
|
|
224
|
+
raise click.ClickException(exc.code) from exc
|
|
225
|
+
if result.action == "no_policy":
|
|
226
|
+
click.echo("Installed policy service; Fleet has no published policy")
|
|
227
|
+
elif result.action == "coalesced":
|
|
228
|
+
click.echo("Installed policy service; policy reconciliation already running")
|
|
229
|
+
else:
|
|
230
|
+
click.echo(f"Installed service and policy revision {result.revision}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@policy.command("uninstall-service")
|
|
234
|
+
def policy_uninstall_service() -> None:
|
|
235
|
+
"""Remove automatic reconciliation while retaining the installed policy."""
|
|
236
|
+
try:
|
|
237
|
+
default_policy_service().uninstall()
|
|
238
|
+
except PolicyServiceError as exc:
|
|
239
|
+
raise click.ClickException(exc.code) from exc
|
|
240
|
+
click.echo("Uninstalled policy service; managed policy and enrollment were retained")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@policy.command("status")
|
|
244
|
+
def policy_status() -> None:
|
|
245
|
+
"""Show content-free local policy deployment status."""
|
|
246
|
+
try:
|
|
247
|
+
status = default_policy_service().status()
|
|
248
|
+
except PolicyServiceError as exc:
|
|
249
|
+
raise click.ClickException(exc.code) from exc
|
|
250
|
+
click.echo(f"Service: {'installed' if status.service_installed else 'not installed'}")
|
|
251
|
+
click.echo(f"User: {status.user}")
|
|
252
|
+
last_seen_revision = (
|
|
253
|
+
str(status.last_seen_revision) if status.last_seen_revision is not None else "none"
|
|
254
|
+
)
|
|
255
|
+
installed_revision = (
|
|
256
|
+
str(status.installed_revision) if status.installed_revision is not None else "none"
|
|
257
|
+
)
|
|
258
|
+
click.echo(f"Last seen revision: {last_seen_revision}")
|
|
259
|
+
click.echo(f"Installed revision: {installed_revision}")
|
|
260
|
+
click.echo(f"Artifact: {'verified' if status.artifact_verified else 'not verified'}")
|
|
261
|
+
last_checked = status.last_checked_at.isoformat() if status.last_checked_at else "never"
|
|
262
|
+
click.echo(f"Last checked: {last_checked}")
|
|
263
|
+
last_compile = (
|
|
264
|
+
status.last_successful_compile_at.isoformat()
|
|
265
|
+
if status.last_successful_compile_at
|
|
266
|
+
else "never"
|
|
267
|
+
)
|
|
268
|
+
click.echo(f"Last successful compile: {last_compile}")
|
|
269
|
+
if status.last_error_code is not None:
|
|
270
|
+
last_error_at = status.last_error_at.isoformat() if status.last_error_at else "unknown"
|
|
271
|
+
click.echo(f"Last error: {status.last_error_code} at {last_error_at}")
|
|
272
|
+
click.echo("Effective: not verified; use Claude /status or claude doctor")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _policy_apply_message(result: ApplyResult) -> str:
|
|
276
|
+
if result.action == "coalesced":
|
|
277
|
+
return "Policy reconciliation already running"
|
|
278
|
+
if result.action == "no_policy":
|
|
279
|
+
return "Fleet has no published policy; existing managed policy was retained"
|
|
280
|
+
if result.revision is None:
|
|
281
|
+
return f"Policy reconciliation {result.action}"
|
|
282
|
+
return f"{result.action.capitalize()} policy revision {result.revision}"
|
|
283
|
+
|
|
284
|
+
|
|
198
285
|
@sync.command()
|
|
199
286
|
@click.option(
|
|
200
287
|
"--kind",
|
stacktrace_cli/remote/client.py
CHANGED
|
@@ -136,6 +136,12 @@ class MeResult:
|
|
|
136
136
|
token: TokenResult
|
|
137
137
|
|
|
138
138
|
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class PolicyEnvelope:
|
|
141
|
+
document: JsonObject | None
|
|
142
|
+
revision: int
|
|
143
|
+
|
|
144
|
+
|
|
139
145
|
@dataclass(frozen=True)
|
|
140
146
|
class AgentStatusResult:
|
|
141
147
|
"""One agent kind composed on an asset.
|
|
@@ -245,12 +251,23 @@ class RemoteClient:
|
|
|
245
251
|
),
|
|
246
252
|
)
|
|
247
253
|
|
|
248
|
-
def
|
|
254
|
+
def get_policy_envelope(self) -> PolicyEnvelope:
|
|
249
255
|
data = self._request("GET", "/api/v1/policy")
|
|
250
|
-
document
|
|
256
|
+
if "document" not in data:
|
|
257
|
+
raise RemoteClientError("remote backend response has invalid document field")
|
|
258
|
+
document = data["document"]
|
|
251
259
|
if document is not None and not isinstance(document, dict):
|
|
252
260
|
raise RemoteClientError("remote backend response has invalid document field")
|
|
253
|
-
|
|
261
|
+
revision = data.get("revision")
|
|
262
|
+
if isinstance(revision, bool) or not isinstance(revision, int) or revision < 0:
|
|
263
|
+
raise RemoteClientError("remote backend response has invalid revision field")
|
|
264
|
+
if (document is None) != (revision == 0):
|
|
265
|
+
raise RemoteClientError("remote backend response has invalid document field")
|
|
266
|
+
return PolicyEnvelope(document=document, revision=revision)
|
|
267
|
+
|
|
268
|
+
def get_policy_document(self) -> JsonObject | None:
|
|
269
|
+
"""Compatibility surface for the manual one-shot compiler."""
|
|
270
|
+
return self.get_policy_envelope().document
|
|
254
271
|
|
|
255
272
|
def get_asset(self, asset_id: str) -> AssetStatusResult:
|
|
256
273
|
data = self._request("GET", f"/api/v1/assets/{asset_id}")
|
|
@@ -341,7 +358,7 @@ def _required_object(data: JsonObject, key: str) -> JsonObject:
|
|
|
341
358
|
|
|
342
359
|
def _required_str(data: JsonObject, key: str) -> str:
|
|
343
360
|
value = data.get(key)
|
|
344
|
-
if not isinstance(value, str):
|
|
361
|
+
if not isinstance(value, str) or not value:
|
|
345
362
|
raise RemoteClientError(f"remote backend response missing string field: {key}")
|
|
346
363
|
return value
|
|
347
364
|
|