Skip to content

Commit fbcf594

Browse files
authored
chore(release): v1.1.0 (#49)
Promotes 103+ commits accumulated on dev since v1.0.0. See CHANGELOG.md for full release notes.
1 parent 22acfb1 commit fbcf594

97 files changed

Lines changed: 8085 additions & 1458 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,19 @@ jobs:
3030
continue-on-error: ${{ env.ADVISORY == 'true' }}
3131

3232
- name: Lint (ruff check)
33-
run: uv run ruff check src/ apps/ mcp/ tests/ *.py
33+
run: uv run ruff check src/ apps/ mcp_servers/ tests/ *.py
3434
continue-on-error: ${{ env.ADVISORY == 'true' }}
3535

3636
- name: Format check (ruff format) — advisory
37-
run: uv run ruff format --check src/ apps/ mcp/ tests/ *.py
37+
run: uv run ruff format --check src/ apps/ mcp_servers/ tests/ *.py
3838
continue-on-error: true
3939

4040
- name: Dependency audit (pip-audit) — advisory
4141
run: uv run pip-audit
4242
continue-on-error: true
4343

4444
- name: SAST (bandit) — advisory
45-
run: uv run bandit -r src/ apps/ mcp/ -c pyproject.toml
45+
run: uv run bandit -r src/ apps/ mcp_servers/ -c pyproject.toml
4646
continue-on-error: true
4747

4848
- name: Lint HTML (djlint check)

CHANGELOG.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Changelog
2+
3+
All notable changes to this project are documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [Unreleased]
9+
10+
## [1.1.0] — 2026-06-14
11+
12+
### Added
13+
14+
- **Incident correlator** — new Phase 1 orchestrator (`src/correlator/`) that assembles
15+
incident context from OpenSearch, Mantis tickets, and enrichment in one call.
16+
Exposed via the MCP `investigate` tool and `/investigate` web pages in
17+
opensearch_web.
18+
- **Public IP profiler**`profile_device` now supports public IPs, with sensor
19+
presence and reverse DNS surfaced in web device cards.
20+
- **Mantis Explorer** — new Flask app for browsing student activity, registered in
21+
`run_all.py` and the Hub landing page.
22+
- **Hub redesign** — list layout, live data-freshness indicators, settings page with
23+
theme dropdown, version + git update status in the footer.
24+
- **Theme system** — 8 community themes (Gruvbox, Tokyo Night, Catppuccin variants),
25+
CSS-variable-driven ECharts colors so charts render correctly on every theme.
26+
- **Shared static blueprint**`apps/shared/` serves tokens, base CSS, and logos
27+
across all four web apps; per-app duplicate assets removed.
28+
- **Dashboard** — alert trend chart, triage workqueues, per-sensor log time-series,
29+
Tickets tab, sensor filter modal, date-range controls wired into the toolbar.
30+
- **MCP OpenSearch tools**`investigate`, `histogram`, `aggregate`,
31+
`bulk_enrich_ips`, `count`, `list_filter_categories`, `list_fp_filters`,
32+
`delete_fp_filter`, `get_notice_summary`, `build_share_urls`,
33+
`compare_to_baseline`, `enrich_top_talkers`.
34+
- **MCP querier** — port/proto filters, multi-value IP/sensor parameters, absolute
35+
timestamps, `truncated` flag surfaced on all search results.
36+
- **Wildcard filters**`notice_note` and `weird_name` accept ES wildcard syntax,
37+
dispatched to `wildcard` queries with exact-match fallback.
38+
- **Filter loader** — validates category/subcategory pairs against
39+
`filters/categories.yaml`.
40+
- **FP manager**`delete_ip_from_filter` extracted into reusable module.
41+
- **Investigate UX** — escalation indicators on ticket cards, Investigate entry-points
42+
on IP pivot and notice/Suricata records, public device cards, profile buttons.
43+
- **Web UX** — sidebar nav with per-tab persisted filters, sticky-column rendering,
44+
src/dest/both IP role toggle on ip_pivot, error banners for OpenSearch/Mantis,
45+
destination IP filter in search bar.
46+
- **Enricher**`prewarm_enrichment_cache` background warmer, parallel execution
47+
and result caching on the web enrich path.
48+
49+
### Changed
50+
51+
- **Querier refactor**`src/querier/zeek_modules/base.py` split into focused
52+
modules; silent `None` returns replaced with typed exceptions.
53+
- **Web concurrency** — overview route switched from `ThreadPoolExecutor` fan-out
54+
to `asyncio.gather`; single-flight dedup, shared thread pool, and ETag support
55+
added; `bool.must` switched to `bool.filter` context for cacheability.
56+
- **MCP package rename**`mcp/``mcp_servers/` to resolve a namespace collision
57+
with the upstream `mcp` package. **Breaking** for anyone importing the old path.
58+
- **App rename**`mantis_web``threat_model`. **Breaking** for any external
59+
bookmarks or imports referencing the old name.
60+
- **Pivot/profile/investigate** and `aggregate` MCP tools consolidated.
61+
- **Mantis Explorer** — escalation detection rewritten; `is_escalated` surfaced on
62+
tickets; warning modal added about escalated-count accuracy.
63+
- **Enricher clients** — persistent HTTP sessions, retry adapter, shared console,
64+
`atexit` cleanup across all enricher modules.
65+
- **Dashboard / OpenSearch panels** — low-signal Malcolm panels pruned; protocol
66+
bar replaced with time-series area charts; unified to horizontal bars.
67+
- **Hub branding** — heading renamed to "PISCES Toolkit" with toolbox icon; brand
68+
link navigates to hub; redundant home button removed.
69+
- **OpenSearch web** — search bar redesigned as two-row pill layout; sensor
70+
selector reworked as single clickable button; Investigate button moved to the
71+
global search bar; auth history section replaced with search-all-logs.
72+
73+
### Fixed
74+
75+
- **OpenSearch mapping drift**`terms` aggregations now use a `_source` Painless
76+
script via `source_terms_script()`, surviving indices whose mapping for the
77+
same field disagrees (keyword vs text + `.keyword` subfield on rolled-over
78+
write index).
79+
- **Zeek notice/weird** — exact and wildcard filters now target the `.keyword`
80+
subfield.
81+
- **Dashboard XSS** — date query parameters sanitised; CodeQL taint chain broken
82+
by returning parsed ISO-format date.
83+
- **MCP dest_ip** — pushed into the ES query instead of being post-filtered.
84+
- **Web exceptions** — bare `except` handlers that silently swallowed tracebacks
85+
now log the exception.
86+
- **Cross-protocol query handler** — logs protocol name and error on failure.
87+
- **Querier**`FilesModule` IP filter flag corrected; `SuricataAlert` summary
88+
type fix; `build_extra_must` tuple correctly unpacked before
89+
`build_base_query`.
90+
- **Correlator** — parallel profile fetches, timeline key override, ticket
91+
deduplication.
92+
- **OpenSearch web** — doubled `script_name` prefix removed from investigate
93+
HTMX paths; em-dash placeholder IPs skipped in overview table.
94+
- **Threat model / Mantis Explorer** — one-time-per-session notice modal.
95+
- **Surface hierarchy** — Catppuccin and Gruvbox themes corrected.
96+
97+
### Performance
98+
99+
- **HTTP timeout** — sync and async OpenSearch clients bumped from 30s → 60s to
100+
accommodate slower script aggregations.
101+
- **OpenSearch client cache** — session reused across queries; query construction
102+
optimised.
103+
- **Filter loader** — mtime-based cache avoids re-parsing YAML on every query.
104+
- **Mantis** — index pagination parallelised; HTTP sessions reused; linear scan
105+
and per-request sorts replaced with dict lookups in `data.py`.
106+
- **Filter loading / remapping / post-filtering** — redundant work removed.
107+
108+
### Removed
109+
110+
- Root-level standalone app launcher shims.
111+
- `cryptography` dependency dropped; `geoip2` moved to the `offline-enrichment`
112+
extra.
113+
- `pytest` moved out of main dependencies into dev dependencies.
114+
- Theme toggle buttons removed from per-app navbars (now centralised in Hub
115+
settings).
116+
117+
### CI
118+
119+
- `djlint` HTML linting added to pre-commit and the CI pipeline.
120+
121+
## [1.0.0] — 2026-XX-XX
122+
123+
Initial tagged release. Dashboard redesign, theming, threat model rename, and
124+
Mantis Explorer (PR #40).
125+
126+
[Unreleased]: https://github.com/liamadale/pisces-scripts/compare/v1.1.0...HEAD
127+
[1.1.0]: https://github.com/liamadale/pisces-scripts/compare/v1.0.0...v1.1.0
128+
[1.0.0]: https://github.com/liamadale/pisces-scripts/releases/tag/v1.0.0

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines and how to ope
8585

8686
To report a vulnerability, follow the process in [SECURITY.md](SECURITY.md).
8787

88+
Release notes for each version are recorded in [CHANGELOG.md](CHANGELOG.md).
89+
8890
---
8991

9092
## Development Transparency — Use of AI Tooling

apps/dashboard_web/opensearch/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from apps.dashboard_web import cache as dcache
44
from apps.dashboard_web.opensearch.aggregations import (
55
agg_conn_volume_over_time,
6+
agg_logs_by_sensor_over_time,
67
agg_notice_over_time,
78
agg_opensearch_sensors,
89
agg_opensearch_top_ips,
@@ -30,6 +31,7 @@ def section():
3031
"conn_over_time": agg_conn_volume_over_time(time_range, sensors),
3132
"sensors": agg_opensearch_sensors(time_range),
3233
"top_ips": agg_opensearch_top_ips(time_range, sensors),
34+
"sensor_trend": agg_logs_by_sensor_over_time(time_range, sensors),
3335
}
3436
except Exception as exc:
3537
data = {"error": str(exc)}

apps/dashboard_web/opensearch/aggregations.py

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,22 @@
22

33
from src.querier.zeek_modules.base import (
44
FILTERS_DIR,
5+
OpenSearchAuthError,
6+
OpenSearchConnectionError,
57
build_base_query,
68
load_with_remap,
79
query_opensearch,
810
)
911

1012

13+
def _safe_query(body: dict, params: dict) -> dict | None:
14+
"""query_opensearch wrapper that returns None on connectivity/auth errors."""
15+
try:
16+
return query_opensearch(body, params)
17+
except (OpenSearchConnectionError, OpenSearchAuthError):
18+
return None
19+
20+
1121
def parse_sensors(raw: str) -> list | None:
1222
"""Parse a comma-separated sensor string into a list, or None for 'all'."""
1323
if not raw or raw.strip().lower() == "all":
@@ -53,7 +63,7 @@ def agg_opensearch_sensors(time_range: str) -> dict:
5363
}
5464
}
5565
}
56-
raw = query_opensearch(body, params)
66+
raw = _safe_query(body, params)
5767
buckets = raw.get("aggregations", {}).get("sensors", {}).get("buckets", []) if raw else []
5868
return {
5969
"labels": [b["key"] for b in buckets],
@@ -81,7 +91,7 @@ def agg_opensearch_notice_count(time_range: str, sensors: list | None = None) ->
8191
body["size"] = 0
8292
body.pop("sort", None)
8393
body.pop("_source", None)
84-
raw = query_opensearch(body, params)
94+
raw = _safe_query(body, params)
8595
if not raw:
8696
return 0
8797
return raw.get("hits", {}).get("total", {}).get("value", 0)
@@ -127,7 +137,7 @@ def agg_suricata_alert_count(time_range: str, sensors: list | None = None) -> in
127137
body["size"] = 0
128138
body.pop("sort", None)
129139
body.pop("_source", None)
130-
raw = query_opensearch(body, params)
140+
raw = _safe_query(body, params)
131141
if not raw:
132142
return 0
133143
return raw.get("hits", {}).get("total", {}).get("value", 0)
@@ -160,7 +170,7 @@ def agg_suricata_over_time(time_range: str, sensors: list | None = None) -> dict
160170
}
161171
}
162172
}
163-
raw = query_opensearch(body, params)
173+
raw = _safe_query(body, params)
164174
buckets = raw.get("aggregations", {}).get("over_time", {}).get("buckets", []) if raw else []
165175
return {
166176
"timestamps": [b["key_as_string"] for b in buckets],
@@ -199,7 +209,7 @@ def agg_notice_over_time(time_range: str, sensors: list | None = None) -> dict:
199209
}
200210
}
201211
}
202-
raw = query_opensearch(body, params)
212+
raw = _safe_query(body, params)
203213
buckets = raw.get("aggregations", {}).get("over_time", {}).get("buckets", []) if raw else []
204214
return {
205215
"timestamps": [b["key_as_string"] for b in buckets],
@@ -235,7 +245,7 @@ def agg_conn_volume_over_time(time_range: str, sensors: list | None = None) -> d
235245
}
236246
}
237247
}
238-
raw = query_opensearch(body, params)
248+
raw = _safe_query(body, params)
239249
buckets = raw.get("aggregations", {}).get("over_time", {}).get("buckets", []) if raw else []
240250
return {
241251
"timestamps": [b["key_as_string"] for b in buckets],
@@ -244,6 +254,68 @@ def agg_conn_volume_over_time(time_range: str, sensors: list | None = None) -> d
244254
}
245255

246256

257+
def agg_logs_by_sensor_over_time(time_range: str, sensors: list | None = None) -> dict:
258+
"""Total log count per sensor as aligned time series (terms → date_histogram)."""
259+
interval = _interval_for_range(time_range)
260+
body, params = build_base_query(
261+
must_not=[],
262+
extra_must=[],
263+
source_fields=[],
264+
limit=0,
265+
time_range=time_range,
266+
sensors=sensors,
267+
datasets=["all"],
268+
public_only=False,
269+
src_ip_filter=None,
270+
direction=None,
271+
)
272+
body["size"] = 0
273+
body.pop("sort", None)
274+
body.pop("_source", None)
275+
body["aggs"] = {
276+
"by_sensor": {
277+
"terms": {"field": "host.name", "size": 50, "order": {"_count": "desc"}},
278+
"aggs": {
279+
"over_time": {
280+
"date_histogram": {
281+
"field": "@timestamp",
282+
"fixed_interval": interval,
283+
"min_doc_count": 0,
284+
}
285+
}
286+
},
287+
}
288+
}
289+
raw = _safe_query(body, params)
290+
sensor_buckets = (
291+
raw.get("aggregations", {}).get("by_sensor", {}).get("buckets", []) if raw else []
292+
)
293+
294+
# Collect all unique timestamps in order across all sensors
295+
all_ts: dict[str, None] = {}
296+
for sb in sensor_buckets:
297+
for tb in sb.get("over_time", {}).get("buckets", []):
298+
all_ts[tb["key_as_string"]] = None
299+
timestamps = list(all_ts.keys())
300+
301+
# Build per-sensor series aligned to the shared timestamp list
302+
series = []
303+
for sb in sensor_buckets:
304+
ts_map = {
305+
tb["key_as_string"]: tb["doc_count"]
306+
for tb in sb.get("over_time", {}).get("buckets", [])
307+
}
308+
series.append(
309+
{
310+
"sensor": sb["key"],
311+
"counts": [ts_map.get(t, 0) for t in timestamps],
312+
"total": sb["doc_count"],
313+
}
314+
)
315+
316+
return {"timestamps": timestamps, "series": series, "interval": interval}
317+
318+
247319
def agg_new_ips_delta(time_range: str, sensors: list | None = None) -> dict:
248320
"""Compare unique source IPs in the current window vs the previous window.
249321
@@ -270,7 +342,7 @@ def _unique_count(tr: str) -> int:
270342
body["aggs"] = {
271343
"uniq": {"cardinality": {"field": "source.ip", "precision_threshold": 3000}}
272344
}
273-
raw = query_opensearch(body, params)
345+
raw = _safe_query(body, params)
274346
if not raw:
275347
return 0
276348
return raw.get("aggregations", {}).get("uniq", {}).get("value", 0)

apps/dashboard_web/opensearch/malcolm.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,20 @@
66

77
import concurrent.futures
88

9-
from src.querier.zeek_modules.base import build_base_query, query_opensearch
9+
from src.querier.zeek_modules.base import (
10+
OpenSearchAuthError,
11+
OpenSearchConnectionError,
12+
build_base_query,
13+
query_opensearch,
14+
)
15+
16+
17+
def _safe_query(body: dict, params: dict) -> dict | None:
18+
"""query_opensearch wrapper that returns None on connectivity/auth errors."""
19+
try:
20+
return query_opensearch(body, params)
21+
except (OpenSearchConnectionError, OpenSearchAuthError):
22+
return None
1023

1124

1225
def _terms(field: str, time_range: str, datasets: list, size: int = 20) -> dict:
@@ -27,7 +40,7 @@ def _terms(field: str, time_range: str, datasets: list, size: int = 20) -> dict:
2740
body.pop("sort", None)
2841
body.pop("_source", None)
2942
body["aggs"] = {"r": {"terms": {"field": field, "size": size, "order": {"_count": "desc"}}}}
30-
raw = query_opensearch(body, params)
43+
raw = _safe_query(body, params)
3144
buckets = raw.get("aggregations", {}).get("r", {}).get("buckets", []) if raw else []
3245
return {
3346
"labels": [b["key"] for b in buckets],
@@ -60,7 +73,7 @@ def _sum_terms(
6073
"aggs": {"total": {"sum": {"field": sum_field}}},
6174
}
6275
}
63-
raw = query_opensearch(body, params)
76+
raw = _safe_query(body, params)
6477
buckets = raw.get("aggregations", {}).get("r", {}).get("buckets", []) if raw else []
6578
buckets = sorted(buckets, key=lambda b: -b.get("total", {}).get("value", 0))
6679
return {

0 commit comments

Comments
 (0)