Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
4a9b862
feat(opensearch-web): add destination IP filter to query pipeline and…
liamadale Apr 25, 2026
c05bdf6
fix(opensearch-web): skip em-dash placeholder IPs in overview table
liamadale Apr 25, 2026
af6c7ff
feat(opensearch-web): redesign sidebar nav, persist filters per-tab, …
liamadale Apr 25, 2026
67754f1
ci(djlint): add HTML linting to pre-commit and CI pipeline
liamadale Apr 24, 2026
92bfd5b
style(templates): fix all djlint lint errors across 13 HTML files
liamadale Apr 24, 2026
0fca72d
feat(mantis): rewrite escalation detection and surface is_escalated o…
liamadale Apr 29, 2026
8b44a2d
feat(mantis-explorer): add new Flask web app for student activity exp…
liamadale Apr 29, 2026
5b90f0f
feat(hub): register mantis-explorer in run_all.py and hub landing page
liamadale Apr 29, 2026
33603bc
fix(mantis-explorer): add missing TICKETS_BY_ID re-export to data.py
liamadale Apr 29, 2026
0aee678
Merge branch 'main' into dev
liamadale Apr 29, 2026
0c0889e
perf(base): switch bool query to filter context and add 30s timeout
liamadale Apr 30, 2026
e8e7171
test(base): update assertions from bool.must to bool.filter
liamadale Apr 30, 2026
d7a4407
perf(filter-loader): add mtime-based cache to avoid re-parsing YAML o…
liamadale Apr 30, 2026
69d034d
fix(querier): unpack tuple from build_extra_must before passing to bu…
liamadale Apr 30, 2026
e68eb6a
fix(web): log protocol name and error in cross-protocol query excepti…
liamadale Apr 30, 2026
b59ce05
perf(mantis-web): replace linear scan and per-request sorts in data.py
liamadale Apr 30, 2026
80c9ab2
perf(mantis): parallelize index pagination and reuse HTTP sessions
liamadale Apr 30, 2026
1133f9a
test(mantis-web): add TestGetTicketsForIp covering dict-lookup path
liamadale Apr 30, 2026
ab98ecb
feat(dashboard): add Tickets tab and sensor filter modal
liamadale Apr 30, 2026
2ab3d2a
feat(dashboard): wire sensor filter and date controls into toolbar
liamadale Apr 30, 2026
1affe8f
feat(dashboard/opensearch): replace protocol bar with time-series are…
liamadale Apr 30, 2026
55c9c29
refactor(dashboard/opensearch): prune low-signal malcolm panels and u…
liamadale Apr 30, 2026
c35d84f
feat(dashboard/overview): redesign with alert trend chart and triage …
liamadale Apr 30, 2026
a94ddf7
feat(dashboard/mantis): switch to date range filter and drop blocklis…
liamadale Apr 30, 2026
afc7c47
docs(readme): update screenshots and app descriptions for current UI
liamadale Apr 30, 2026
f1902c1
refactor(threat-model): rename mantis_web app to threat_model
liamadale Apr 30, 2026
08808fe
docs(readme): add column headers to documentation table rows
liamadale Apr 30, 2026
b1c6ab3
feat(hub): redesign landing page with list layout and live data fresh…
liamadale Apr 30, 2026
3ab3b8b
chore: remove root-level standalone app launcher shims
liamadale Apr 30, 2026
258e6b9
fix(threat-model): simplify CLI command shown in help popover
liamadale Apr 30, 2026
08fbf98
feat(hub): show version and git update status in footer
liamadale Apr 30, 2026
06708e1
chore: bump version to 1.0.0
liamadale Apr 30, 2026
a2f3cea
feat(mantis-explorer): add warning modal about escalated count accuracy
liamadale Apr 30, 2026
21eef06
feat(shared): add shared static blueprint serving tokens, base CSS, t…
liamadale Apr 30, 2026
326b094
refactor(web): migrate all apps to shared static assets, remove dupli…
liamadale Apr 30, 2026
575a18b
refactor: remove theme toggle buttons from all web app navbars
liamadale Apr 30, 2026
a5676a8
feat: add Hub settings page with theme dropdown
liamadale Apr 30, 2026
afac740
fix: use CSS variables for ECharts colors so charts work on light theme
liamadale Apr 30, 2026
8c4cf0a
feat: add 8 community themes (Gruvbox, Tokyo Night, Catppuccin)
liamadale Apr 30, 2026
34dfbdb
fix: correct surface hierarchy for Catppuccin and Gruvbox themes
liamadale Apr 30, 2026
65f3b4e
fix(mantis-explorer): show notice modal only once per session
liamadale Apr 30, 2026
fa1dfa9
fix(threat-model): show notice modal only once per session
liamadale Apr 30, 2026
56e3467
feat: add notice button to nav bar in mantis explorer and threat model
liamadale Apr 30, 2026
06f4da4
feat(opensearch_web): redesign search bar layout with two-row pill-ba…
liamadale Apr 30, 2026
d328bca
feat(opensearch_web): redesign sensor selector as single clickable bu…
liamadale Apr 30, 2026
9c63aad
refactor: make brand link navigate to hub and remove separate home bu…
liamadale Apr 30, 2026
a4768e5
refactor: rename hub heading to PISCES Toolkit with toolbox icon
liamadale Apr 30, 2026
f8e489e
feat: add author attribution to hub footer
liamadale Apr 30, 2026
40363aa
Merge branch 'main' into dev
liamadale Apr 30, 2026
cb2c356
fix(dashboard): sanitise date query params to prevent reflected XSS
liamadale Apr 30, 2026
8cd887a
fix(dashboard): break CodeQL taint chain by returning parsed date iso…
liamadale Apr 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,30 +31,37 @@ Four browser-based apps served from a central hub. Launch everything with one co

<img src="docs/assets/pisces-scripts-opensearch-webapp.png" width="700" alt="OpenSearch Web UI">

**Mantis** — ticket browser with threat modelling dashboard, disposition scoring, and known malicious IP tracking.
**Threat Model** — threat modelling dashboard with disposition scoring and known malicious IP tracking.

<img src="docs/assets/pisces-scripts-mantis-webapp.png" width="700" alt="Mantis Web UI">
<img src="docs/assets/pisces-scripts-threatmodel-one-webapp.png" width="700" alt="Threat Model Web UI — overview">
<img src="docs/assets/pisces-scripts-threatmodel-two-webapp.png" width="700" alt="Threat Model Web UI — detail">

**Dashboard** — aggregated analytics dashboard.

<img src="docs/assets/pisces-scripts-dashboard-webapp.png" width="700" alt="Dashboard Web UI">

| App | What it's for |
|---|---|
| **OpenSearch** | Cross-protocol IP activity matrix, per-protocol drill-down, inline enrichment |
| **Mantis** | Ticket browser with disposition scoring and known malicious IP tracking |
| **Threat Model** | Threat modelling dashboard with disposition scoring and known malicious IP tracking |
| **Dashboard** | Aggregated analytics dashboard |
| **Mantis Explorer** | Ticket browser and search across the PISCES ticket history |

---

## Documentation

**Setup**

| | |
| Guide | Description |
|---|---|
| [VM Setup](docs/vm-setup.md) | Create an Ubuntu VM and connect to the cyber range network |
| [Getting Started](docs/getting-started.md) | Install, configure, and launch the toolkit on Ubuntu |
| [MCP Getting Started](docs/getting-started-mcp.md) | Connect Claude Code, kiro-cli, or another AI assistant to the PISCES backends |

**Using the toolkit**

| | |
| Guide | Description |
|---|---|
| [Web UI Workflow](docs/workflow.md) | End-to-end triage walkthrough using the browser-based UI |
| [CLI Workflow](docs/cli-workflow.md) | Terminal-based querier walkthrough — alerts, enrichment, filters, tickets |
Expand All @@ -64,7 +71,7 @@ Four browser-based apps served from a central hub. Launch everything with one co

**Reference**

| | |
| Guide | Description |
|---|---|
| [Advanced Usage](docs/advanced-usage.md) | Full CLI flag reference for all tools |
| [MCP Server Reference](docs/mcp-servers.md) | Full tool reference for all three MCP servers |
Expand Down
18 changes: 18 additions & 0 deletions apps/dashboard_web/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""PISCES Dashboard web application package."""

from datetime import date


def safe_date_param(value: str) -> str:
"""Validate a date query parameter, returning '' for invalid values.

Prevents user-supplied strings from reaching exception messages
(reflected XSS via error rendering).
"""
v = value.strip()
if not v:
return ""
try:
return date.fromisoformat(v).isoformat()
except ValueError:
return ""
26 changes: 25 additions & 1 deletion apps/dashboard_web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,35 @@ def api_cache_clear():
dcache.invalidate()
return "", 204

@app.route("/api/dashboard/sensors")
def api_sensor_summary():
"""Sensor browser modal content — terms agg on host.name."""
from apps.dashboard_web.opensearch.aggregations import agg_opensearch_sensors

time_range = request.args.get("time_range", "now-24h")
data = agg_opensearch_sensors(time_range)
# Build bucket-like dicts matching the sensor_summary.html template
buckets = [
{"key": label, "doc_count": count}
for label, count in zip(data["labels"], data["counts"])
]
current = [
s.strip()
for s in request.args.get("sensor", "").split(",")
if s.strip() and s.strip().lower() != "all"
]
return render_template("sensor_summary.html", buckets=buckets, current_sensors=current)

from apps.dashboard_web.mantis import bp as mantis_bp
from apps.dashboard_web.opensearch import bp as opensearch_bp
from apps.dashboard_web.overview import bp as overview_bp
from apps.dashboard_web.tickets import bp as tickets_bp

for bp in [overview_bp, opensearch_bp, mantis_bp]:
for bp in [overview_bp, opensearch_bp, mantis_bp, tickets_bp]:
app.register_blueprint(bp)

from apps.shared.blueprints import make_shared_static_blueprint

app.register_blueprint(make_shared_static_blueprint())

return app
19 changes: 10 additions & 9 deletions apps/dashboard_web/mantis/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from flask import Blueprint, render_template, request

from apps.dashboard_web import cache as dcache
from apps.dashboard_web import safe_date_param
from apps.dashboard_web.mantis.aggregations import (
agg_mantis_attack_types,
agg_mantis_blocklists,
agg_mantis_infra_count,
agg_mantis_timeline,
agg_mantis_top_ips,
Expand All @@ -14,20 +14,21 @@

@bp.route("/api/dashboard/mantis")
def section():
time_range = request.args.get("time_range", "now-24h")
cached = dcache.get("mantis", {"time_range": time_range})
since = safe_date_param(request.args.get("since", ""))
until = safe_date_param(request.args.get("until", ""))
cache_key = {"since": since, "until": until}
cached = dcache.get("mantis", cache_key)
if cached is not None:
return cached
try:
data = {
"attack_types": agg_mantis_attack_types(),
"timeline": agg_mantis_timeline(),
"blocklists": agg_mantis_blocklists(),
"top_ips": agg_mantis_top_ips(),
"attack_types": agg_mantis_attack_types(since, until),
"timeline": agg_mantis_timeline(since, until),
"top_ips": agg_mantis_top_ips(since, until),
"infra_count": agg_mantis_infra_count(),
}
except Exception as exc:
data = {"error": str(exc)}
html = render_template("mantis/section.html", data=data, time_range=time_range)
dcache.put("mantis", {"time_range": time_range}, html)
html = render_template("mantis/section.html", data=data)
dcache.put("mantis", cache_key, html)
return html
47 changes: 27 additions & 20 deletions apps/dashboard_web/mantis/aggregations.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,52 @@

import collections

from apps.mantis_web.data import INFRA_ROWS, MALICIOUS_ROWS, _raw_tickets
from apps.threat_model.data import INFRA_ROWS, MALICIOUS_ROWS, _raw_tickets


def agg_mantis_attack_types() -> list:
def _filter_tickets(since: str, until: str) -> list:
"""Filter _raw_tickets by created_at date range."""
tickets = _raw_tickets
if since:
tickets = [t for t in tickets if (t.get("created_at", "") or "") >= since]
if until:
tickets = [t for t in tickets if (t.get("created_at", "") or "") <= until + "T23:59:59"]
return tickets


def _filter_malicious(since: str, until: str) -> list:
"""Filter MALICIOUS_ROWS by last_seen date range."""
rows = MALICIOUS_ROWS
if since:
rows = [r for r in rows if (r.get("last_seen", "") or "") >= since]
if until:
rows = [r for r in rows if (r.get("first_seen", "") or "") <= until]
return rows


def agg_mantis_attack_types(since: str = "", until: str = "") -> list:
"""Counter over attack_types in MALICIOUS_ROWS."""
counter = collections.Counter()
for row in MALICIOUS_ROWS:
for row in _filter_malicious(since, until):
for at in row.get("attack_types", []):
counter[at] += 1
return [{"name": k.replace("_", " ").title(), "value": v} for k, v in counter.most_common()]


def agg_mantis_timeline() -> dict:
def agg_mantis_timeline(since: str = "", until: str = "") -> dict:
"""Monthly ticket volume from _raw_tickets."""
counter = collections.Counter()
for t in _raw_tickets:
for t in _filter_tickets(since, until):
created = t.get("created_at", "")
if created and len(created) >= 7:
counter[created[:7]] += 1
months = sorted(counter.keys())
return {"months": months, "counts": [counter[m] for m in months]}


def agg_mantis_blocklists() -> dict:
"""Counter over blocklists in MALICIOUS_ROWS."""
counter = collections.Counter()
for row in MALICIOUS_ROWS:
for bl in row.get("blocklists", []):
counter[bl] += 1
items = counter.most_common()
return {
"labels": [k for k, _ in items],
"counts": [v for _, v in items],
}


def agg_mantis_top_ips(n: int = 10) -> list:
def agg_mantis_top_ips(since: str = "", until: str = "", n: int = 10) -> list:
"""Top N malicious IPs by ticket count."""
return sorted(MALICIOUS_ROWS, key=lambda r: -r.get("tickets", 0))[:n]
return sorted(_filter_malicious(since, until), key=lambda r: -r.get("tickets", 0))[:n]


def agg_mantis_infra_count() -> int:
Expand Down
61 changes: 19 additions & 42 deletions apps/dashboard_web/mantis/templates/mantis/section.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
{% else %}

<div class="stat-row">
<div class="stat-card">
<div class="stat-box">
<div class="stat-label">Infrastructure IPs</div>
<div class="stat-value">{{ data.infra_count }}</div>
<div class="stat-value">{{ data.infra_count | intcomma }}</div>
</div>
</div>

Expand All @@ -21,20 +21,11 @@
</div>

<div class="chart-card">
<div class="chart-title">Blocklist Sources</div>
{% if not data.blocklists.labels %}
<div class="chart-empty">No blocklist data</div>
{% else %}
<div id="chart-blocklists" style="height:{{ [data.blocklists.labels|length * 36 + 40, 200]|max }}px;"></div>
{% endif %}
</div>

<div class="chart-card" style="grid-column: 1 / -1;">
<div class="chart-title">Ticket Volume Timeline (monthly)</div>
{% if not data.timeline.months %}
<div class="chart-empty">No ticket data</div>
{% else %}
<div id="chart-timeline" style="height:220px;"></div>
<div id="chart-timeline" style="height:320px;"></div>
{% endif %}
</div>

Expand Down Expand Up @@ -79,17 +70,17 @@
<script>
(function() {
var ATTACK_TYPES = {{ data.attack_types | tojson }};
var BLOCKLISTS = {{ data.blocklists | tojson }};
var TIMELINE = {{ data.timeline | tojson }};

var AXIS_LABEL = { color: '#c8ccd8', fontSize: 12 };
var AXIS_DIM = { color: '#5a6278', fontSize: 11 };
var GRID_LINE = { lineStyle: { color: '#2a3045' } };
var TOOLTIP_STYLE = {
trigger: 'axis', axisPointer: { type: 'shadow' },
backgroundColor: '#1a1f2e', borderColor: '#2a3045',
textStyle: { color: '#c8ccd8' }
};
var S = getComputedStyle(document.documentElement);
var C_TEXT = S.getPropertyValue('--on-surface').trim();
var C_TEXT_DIM = S.getPropertyValue('--on-surface-dim').trim();
var C_OUTLINE = S.getPropertyValue('--outline').trim();
var C_SURFACE = S.getPropertyValue('--surface-container').trim();
var C_BG = S.getPropertyValue('--surface').trim();

var AXIS_DIM = { color: C_TEXT_DIM, fontSize: 11 };
var GRID_LINE = { lineStyle: { color: C_OUTLINE } };

/* Attack type donut */
if (ATTACK_TYPES.length) {
Expand All @@ -99,13 +90,13 @@
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: '#1a1f2e', borderColor: '#2a3045',
textStyle: { color: '#c8ccd8' },
backgroundColor: C_SURFACE, borderColor: C_OUTLINE,
textStyle: { color: C_TEXT },
formatter: '{b}: {c} ({d}%)'
},
legend: {
type: 'scroll', orient: 'vertical', right: '2%', top: 'middle',
textStyle: { color: '#c8ccd8', fontSize: 11 }
textStyle: { color: C_TEXT, fontSize: 11 }
},
series: [{
type: 'pie',
Expand All @@ -114,38 +105,24 @@
data: ATTACK_TYPES.map(function(d, i) {
return { name: d.name, value: d.value, itemStyle: { color: DONUT_COLORS[i % DONUT_COLORS.length] } };
}),
itemStyle: { borderColor: '#0d1117', borderWidth: 2 },
itemStyle: { borderColor: C_BG, borderWidth: 2 },
label: { show: false }
}]
});
window.addEventListener('resize', function() { c1.resize(); });
}

/* Blocklists bar */
if (BLOCKLISTS.labels.length) {
var c2 = echarts.init(document.getElementById('chart-blocklists'));
c2.setOption({
backgroundColor: 'transparent',
tooltip: TOOLTIP_STYLE,
grid: { left: 10, right: 20, top: 10, bottom: 10, containLabel: true },
xAxis: { type: 'value', axisLabel: AXIS_DIM, splitLine: GRID_LINE },
yAxis: { type: 'category', data: BLOCKLISTS.labels.slice().reverse(), axisLabel: AXIS_LABEL },
series: [{ type: 'bar', data: BLOCKLISTS.counts.slice().reverse(), itemStyle: { color: '#d29922' }, barMaxWidth: 28 }]
});
window.addEventListener('resize', function() { c2.resize(); });
}

/* Timeline area chart */
if (TIMELINE.months.length) {
var c3 = echarts.init(document.getElementById('chart-timeline'));
c3.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', backgroundColor: '#1a1f2e', borderColor: '#2a3045', textStyle: { color: '#c8ccd8' } },
tooltip: { trigger: 'axis', backgroundColor: C_SURFACE, borderColor: C_OUTLINE, textStyle: { color: C_TEXT } },
grid: { left: 10, right: 20, top: 10, bottom: 10, containLabel: true },
xAxis: {
type: 'category', data: TIMELINE.months,
axisLabel: { color: '#5a6278', rotate: 35, fontSize: 11 },
axisLine: { lineStyle: { color: '#2a3045' } }
axisLabel: { color: C_TEXT_DIM, rotate: 35, fontSize: 11 },
axisLine: { lineStyle: { color: C_OUTLINE } }
},
yAxis: { type: 'value', axisLabel: AXIS_DIM, splitLine: GRID_LINE },
series: [{
Expand Down
18 changes: 13 additions & 5 deletions apps/dashboard_web/opensearch/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

from apps.dashboard_web import cache as dcache
from apps.dashboard_web.opensearch.aggregations import (
agg_opensearch_protocols,
agg_conn_volume_over_time,
agg_notice_over_time,
agg_opensearch_sensors,
agg_opensearch_top_ips,
agg_suricata_over_time,
parse_sensors,
)
from apps.dashboard_web.opensearch.malcolm import get_all_panels

Expand All @@ -14,19 +17,24 @@
@bp.route("/api/dashboard/opensearch")
def section():
time_range = request.args.get("time_range", "now-24h")
cached = dcache.get("opensearch", {"time_range": time_range})
sensor_raw = request.args.get("sensor", "")
sensors = parse_sensors(sensor_raw)
cache_key = {"time_range": time_range, "sensor": sensor_raw}
cached = dcache.get("opensearch", cache_key)
if cached is not None:
return cached
try:
data = {
"protocols": agg_opensearch_protocols(time_range),
"notice_over_time": agg_notice_over_time(time_range, sensors),
"suricata_over_time": agg_suricata_over_time(time_range, sensors),
"conn_over_time": agg_conn_volume_over_time(time_range, sensors),
"sensors": agg_opensearch_sensors(time_range),
"top_ips": agg_opensearch_top_ips(time_range),
"top_ips": agg_opensearch_top_ips(time_range, sensors),
}
except Exception as exc:
data = {"error": str(exc)}
html = render_template("opensearch/section.html", data=data, time_range=time_range)
dcache.put("opensearch", {"time_range": time_range}, html)
dcache.put("opensearch", cache_key, html)
return html


Expand Down
Loading
Loading