stacktrace-cli 0.6.1__py3-none-any.whl → 0.7.1__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/cli.py +13 -9
- stacktrace_cli/daemon/webhook.py +2 -1
- stacktrace_cli/detector/deterministic.py +147 -10
- stacktrace_cli/detector/finding.py +3 -0
- stacktrace_cli/monitor/server.py +7 -0
- stacktrace_cli/monitor/site/app.js +14 -8
- stacktrace_cli/monitor/site/apple-icon.png +0 -0
- stacktrace_cli/monitor/site/favicon.ico +0 -0
- stacktrace_cli/monitor/site/icon.svg +7 -0
- stacktrace_cli/monitor/site/index.html +3 -6
- stacktrace_cli/monitor/site/inventory.js +19 -15
- stacktrace_cli/monitor/site/styles.css +3 -13
- stacktrace_cli/remote/cli.py +183 -21
- stacktrace_cli/remote/client.py +18 -84
- stacktrace_cli/remote/config.py +5 -1
- stacktrace_cli/remote/policy_service.py +1622 -0
- stacktrace_cli/remote/policy_worker.py +64 -0
- stacktrace_cli/remote/sync.py +7 -1
- {stacktrace_cli-0.6.1.dist-info → stacktrace_cli-0.7.1.dist-info}/METADATA +3 -3
- {stacktrace_cli-0.6.1.dist-info → stacktrace_cli-0.7.1.dist-info}/RECORD +23 -18
- {stacktrace_cli-0.6.1.dist-info → stacktrace_cli-0.7.1.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.6.1.dist-info → stacktrace_cli-0.7.1.dist-info}/entry_points.txt +0 -0
stacktrace_cli/__init__.py
CHANGED
stacktrace_cli/daemon/cli.py
CHANGED
|
@@ -210,14 +210,13 @@ def _reject_unobservable_kinds(config: DaemonConfig) -> None:
|
|
|
210
210
|
|
|
211
211
|
|
|
212
212
|
@daemon.command()
|
|
213
|
-
|
|
213
|
+
@click.option("--verbose", is_flag=True, help="Include detailed daemon diagnostics.")
|
|
214
|
+
def status(verbose: bool) -> None:
|
|
214
215
|
"""Report whether this endpoint is working, without reading a log.
|
|
215
216
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
grows means retention is not pruning; and a configuration without its source
|
|
220
|
-
makes fleet-wide drift undebuggable (`docs/specs/daemon-scanning.md`).
|
|
217
|
+
The default output is a quick health check. Pass ``--verbose`` for the
|
|
218
|
+
diagnostic details used to investigate a quiet or misconfigured machine
|
|
219
|
+
(`docs/specs/daemon-scanning.md`).
|
|
221
220
|
"""
|
|
222
221
|
from stacktrace_cli.build import version_label
|
|
223
222
|
|
|
@@ -242,7 +241,8 @@ def status() -> None:
|
|
|
242
241
|
if live is not None:
|
|
243
242
|
click.echo(f"version: {live.build}")
|
|
244
243
|
click.echo(f"installed: {version_label()}")
|
|
245
|
-
|
|
244
|
+
if verbose:
|
|
245
|
+
click.echo(f"started: {live.started_at}")
|
|
246
246
|
|
|
247
247
|
try:
|
|
248
248
|
# One `_store()` call apiece, all against the same file: if the first
|
|
@@ -251,9 +251,10 @@ def status() -> None:
|
|
|
251
251
|
# them can print a partial line before that first failure.
|
|
252
252
|
click.echo(f"watermark: {_watermark_line(paths)}")
|
|
253
253
|
click.echo(f"queue depth: {_queue_line(paths)}")
|
|
254
|
-
click.echo(f"cursors: {_cursor_line(paths)}")
|
|
255
254
|
click.echo(f"parked: {_parked_line(paths)}")
|
|
256
|
-
|
|
255
|
+
if verbose:
|
|
256
|
+
click.echo(f"cursors: {_cursor_line(paths)}")
|
|
257
|
+
click.echo(f"database: {_database_line(paths)}")
|
|
257
258
|
except (sqlite3.DatabaseError, ValueError) as exc:
|
|
258
259
|
# A corrupt file or a schema newer than this build reads raises here
|
|
259
260
|
# (`_check_version`'s own refusal) -- precisely the moment the daemon
|
|
@@ -263,6 +264,9 @@ def status() -> None:
|
|
|
263
264
|
click.echo(f"database: unreadable — {exc}")
|
|
264
265
|
click.echo(f"last error: {last_error(paths.log) or 'none reported'}")
|
|
265
266
|
|
|
267
|
+
if not verbose:
|
|
268
|
+
return
|
|
269
|
+
|
|
266
270
|
try:
|
|
267
271
|
config = _effective_config(live.config if live is not None else None)
|
|
268
272
|
except ValueError as exc:
|
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.
|
stacktrace_cli/monitor/server.py
CHANGED
|
@@ -43,6 +43,10 @@ _ASSETS: dict[str, str] = {
|
|
|
43
43
|
"/app.js": "app.js",
|
|
44
44
|
"/styles.css": "styles.css",
|
|
45
45
|
"/inventory.js": "inventory.js",
|
|
46
|
+
# The same icons Fleet's web app ships, copied rather than shared.
|
|
47
|
+
"/favicon.ico": "favicon.ico",
|
|
48
|
+
"/icon.svg": "icon.svg",
|
|
49
|
+
"/apple-icon.png": "apple-icon.png",
|
|
46
50
|
# Vendored rather than fetched, so they are served rather than linked. An
|
|
47
51
|
# allow-list of exact names is why they have to be named here: a font
|
|
48
52
|
# missing from it 404s and the page falls back silently, which is the
|
|
@@ -57,6 +61,9 @@ _CONTENT_TYPES: dict[str, str] = {
|
|
|
57
61
|
"app.js": "text/javascript; charset=utf-8",
|
|
58
62
|
"styles.css": "text/css; charset=utf-8",
|
|
59
63
|
"inventory.js": "text/javascript; charset=utf-8",
|
|
64
|
+
"favicon.ico": "image/x-icon",
|
|
65
|
+
"icon.svg": "image/svg+xml",
|
|
66
|
+
"apple-icon.png": "image/png",
|
|
60
67
|
"fonts/dm-sans-latin.woff2": "font/woff2",
|
|
61
68
|
"fonts/dm-mono-400-latin.woff2": "font/woff2",
|
|
62
69
|
"fonts/dm-mono-500-latin.woff2": "font/woff2",
|
|
@@ -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);
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
|
2
|
+
<rect width="48" height="48" rx="11" fill="#17130e"></rect>
|
|
3
|
+
<g transform="translate(24 24) scale(0.72) translate(-24 -24)"><path d="M38 8H18A8.5 8.5 0 0 0 18 25H20.5" stroke="#ede3d0" stroke-width="5" stroke-linecap="round" fill="none"></path>
|
|
4
|
+
<path d="M27.5 25H30A8.5 8.5 0 0 1 30 42H12" stroke="#ede3d0" stroke-width="5" stroke-linecap="round" fill="none"></path>
|
|
5
|
+
<circle cx="38" cy="8" r="5" fill="#e8681f"></circle>
|
|
6
|
+
<circle cx="12" cy="42" r="5" fill="#e8681f"></circle></g>
|
|
7
|
+
</svg>
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
6
|
<title>Stacktrace — monitor</title>
|
|
7
|
+
<link rel="icon" href="favicon.ico" sizes="any">
|
|
8
|
+
<link rel="icon" href="icon.svg" type="image/svg+xml">
|
|
9
|
+
<link rel="apple-touch-icon" href="apple-icon.png">
|
|
7
10
|
<link rel="stylesheet" href="styles.css">
|
|
8
11
|
</head>
|
|
9
12
|
<body>
|
|
@@ -86,12 +89,6 @@
|
|
|
86
89
|
</div>
|
|
87
90
|
</section>
|
|
88
91
|
|
|
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
92
|
<div class="logs">
|
|
96
93
|
<section class="logcol">
|
|
97
94
|
<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; }
|