Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
48 changes: 48 additions & 0 deletions ghost-ai-scanner/agent/install/scan_authorize_fetch.py.frag
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# =============================================================
# FRAGMENT: scan_authorize_fetch.py.frag
# VERSION: 1.0.0
# UPDATED: 2026-05-11
# OWNER: Giggso Inc (Ravi Venugopal)
# PURPOSE: At scan start, fetch this user's S3 authorized-provider
# list (written by the dashboard's [Authorize] button) and
# merge it into AUTH_LIST. Providers in the list are filtered
# out by every scan_*() emitter via _is_authorized() — so
# authorised tools never reach the dashboard, ending the
# noise loop at source.
# Storage layout:
# s3://<bucket>/config/authorized/{email_safe}.json
# Fetched via the presigned GET URL configured in
# ~/.patronai/config.json under "authorized_list_url".
# (Server's url_refresh_loop mints this alongside the
# existing upload URL — extend when wiring this in.)
# AUDIT LOG:
# v1.0.0 2026-05-11 Initial.
# =============================================================

def _fetch_remote_authorized() -> list:
"""Best-effort: pull the per-user authorized list from S3.
Returns a list of provider strings; empty on any failure so the
scan still runs with whatever local AUTH_LIST already had."""
url = _cfg.get("authorized_list_url", "").strip()
if not url:
return []
try:
import urllib.request
# 5s timeout — scans must not stall on a slow / dead S3 endpoint.
req = urllib.request.Request(url, headers={"User-Agent": "patronai-agent"})
with urllib.request.urlopen(req, timeout=5) as resp:
doc = json.loads(resp.read().decode())
providers = doc.get("providers", [])
if isinstance(providers, list):
return [str(p).strip().lower() for p in providers if p]
except Exception:
# Silent — agent must never block a scan on a remote-config failure.
return []
return []


# Merge remote list into AUTH_LIST. Local file remains the ground truth
# for offline operation; remote entries are additive.
_remote_auth = _fetch_remote_authorized()
if _remote_auth:
AUTH_LIST = sorted(set(AUTH_LIST) | set(_remote_auth))
32 changes: 27 additions & 5 deletions ghost-ai-scanner/agent/install/scan_footer.py.frag
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
# and prints the JSON to stdout. The bash wrapper PUTs it to S3.
# AUDIT LOG:
# v1.0.0 2026-04-25 Initial. Group 2 — fragment refactor.
# v2.0.0 2026-04-26 Phase 1A. Calls 4 new emitters (mcp_configs,
# agents_workflows, tools_code, vector_dbs).
# Adds scan_kind tag (`baseline` first run, then
# `recurring`). Clears first_run flag once the
# payload is built. Adds repo discovery summary.
# v2.0.0 2026-04-26 Phase 1A. Four new emitters + scan_kind tag.
# v2.1.0 2026-05-11 Add snapshot_hash — SHA-256 over the canonical
# findings list. Server uses it for cheap "same
# state as last cycle" detection (short-circuits
# redundant explode + write). Companion to the
# server-side findings_compact job. Enables future
# v3 agent delta-emission (send hash only, omit
# findings array if hash matches the previous send).
# =============================================================

_findings: list = []
Expand All @@ -38,6 +41,24 @@ def _count(kind: str) -> int:

_scan_kind = "baseline" if IS_FIRST_RUN else "recurring"


def _snapshot_hash(findings_list):
"""SHA-256 over the canonical sort of (type, key) tuples per finding.
Server short-circuits when this matches the previous scan's hash —
no explode, no findings_store write, no false-noise alerts."""
import hashlib
keys = []
for _f in findings_list:
_t = _f.get("type", "")
# Pick the most stable distinguishing field per category.
_k = (_f.get("domain") or _f.get("name") or _f.get("plugin_id")
or _f.get("image") or _f.get("server_name")
or _f.get("filename") or _f.get("signal") or "")
keys.append(f"{_t}|{_k}")
keys.sort()
return hashlib.sha256("\n".join(keys).encode()).hexdigest()[:16]


_payload = {
"event_type": "ENDPOINT_SCAN",
"source": "patronai_scan_agent",
Expand All @@ -51,6 +72,7 @@ _payload = {
"os_name": OS_NAME,
"timestamp": NOW,
"scan_kind": _scan_kind,
"snapshot_hash": _snapshot_hash(_findings),
"authorized": AUTH_LIST,
"repos_discovered": [{"name": r.get("name"),
"remote_host": r.get("remote_host"),
Expand Down
108 changes: 108 additions & 0 deletions ghost-ai-scanner/dashboard/ui/ai_posture_card.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# =============================================================
# FILE: dashboard/ui/ai_posture_card.py
# VERSION: 1.0.0
# UPDATED: 2026-05-11
# OWNER: Giggso Inc (Ravi Venugopal)
# PURPOSE: Single aggregated card replacing the numeric KPI row at
# the top of the Inventory / Exec views.
# One risk score, one band colour, one "what needs action"
# breakdown. Drives the shift from "events log" UX to
# "decision surface" UX.
# DEPENDS: streamlit, scoring.risk_score
# AUDIT LOG:
# v1.0.0 2026-05-11 Initial.
# =============================================================

import os
import sys

import streamlit as st

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))

from scoring.risk_score import risk_score, risk_band, posture_breakdown # noqa: E402


_CATEGORY_LABEL = {
"process": "AI processes running",
"package": "AI packages installed",
"ide_plugin": "IDE plugins detected",
"browser": "AI service browser hits",
"container_image": "Container images",
"container_log_signal": "Container traffic / key signals",
"shell_history": "Past shell commands",
"mcp_server": "MCP servers configured",
"agent_workflow": "Agent workflows (n8n / Flowise / langflow)",
"agent_scheduled": "Scheduled agents (cron / launchd)",
"tool_registration": "@tool decorators in code",
"vector_db": "Local vector DBs",
}


def _band_colour(band: str) -> str:
return {
"CRITICAL": "#cf222e",
"HIGH": "#bc4c00",
"MEDIUM": "#9a6700",
"LOW": "#1f6feb",
"CLEAN": "#1a7f37",
}.get(band, "#57606A")


def render_ai_posture(rows: list, device_label: str = "this fleet") -> None:
"""Render the aggregated AI Posture card.
`rows` must be the COMPACTED rows (findings_current view) — one
per signature, with severity/category/occurrences/last_seen.
Falls back gracefully if older raw-finding rows are passed."""
score = risk_score(rows)
band = risk_band(score)
bdown = posture_breakdown(rows)
open_categories = sum(1 for v in bdown.values() if v["count"] > 0)

st.markdown(
f"<div style='border:1px solid #d0d7de;border-radius:8px;"
f"padding:18px 20px;margin:8px 0 18px;background:#ffffff'>"
f"<div style='display:flex;justify-content:space-between;"
f"align-items:baseline;margin-bottom:14px'>"
f"<div style='font-family:JetBrains Mono;font-size:12px;"
f"letter-spacing:0.05em;text-transform:uppercase;color:#57606A'>"
f"AI POSTURE — {device_label}</div>"
f"<div style='font-family:JetBrains Mono;font-size:13px;"
f"font-weight:600;color:{_band_colour(band)}'>"
f"RISK SCORE: {score} / 100 &nbsp;·&nbsp; {band}</div>"
f"</div>",
unsafe_allow_html=True,
)

if open_categories == 0:
st.markdown(
"<div style='font-family:JetBrains Mono;font-size:13px;"
"color:#1a7f37'>✓ No open AI findings. Posture is clean.</div></div>",
unsafe_allow_html=True,
)
return

# Render one row per non-empty category, sorted by severity then count.
sev_rank = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1}
items = sorted(
bdown.items(),
key=lambda kv: (-sev_rank.get(kv[1]["max_severity"], 0),
-kv[1]["count"]),
)
rows_html = []
for cat, info in items:
if info["count"] == 0:
continue
label = _CATEGORY_LABEL.get(cat, cat.replace("_", " ").title())
sev = info["max_severity"]
sev_clr = _band_colour(sev)
rows_html.append(
f"<div style='display:flex;justify-content:space-between;"
f"align-items:center;padding:8px 0;border-top:1px solid #eaeef2'>"
f"<div><span style='color:{sev_clr};font-weight:600'>● </span>"
f"<span style='font-size:13px'>{info['count']} {label}</span></div>"
f"<div style='font-family:JetBrains Mono;font-size:11px;"
f"color:#57606A'>max sev: {sev}</div>"
f"</div>"
)
st.markdown("".join(rows_html) + "</div>", unsafe_allow_html=True)
102 changes: 102 additions & 0 deletions ghost-ai-scanner/dashboard/ui/category_grouped_risks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# =============================================================
# FILE: dashboard/ui/category_grouped_risks.py
# VERSION: 1.0.0
# UPDATED: 2026-05-11
# OWNER: Giggso Inc (Ravi Venugopal)
# PURPOSE: Collapsible category-grouped view of open findings.
# Replaces the row-soup. Each category is a parent row with
# count + max-severity + last-seen. Expand to see per-signature
# children. Bulk actions per category: Authorize, Suppress,
# Show cleanup hint.
# DEPENDS: streamlit, services.authorize, cleanup_hints
# AUDIT LOG:
# v1.0.0 2026-05-11 Initial.
# =============================================================

import os
import sys
from collections import defaultdict

import streamlit as st

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))

from cleanup_hints import cleanup_hint # noqa: E402
from services.authorize import authorize # noqa: E402


_SEV_RANK = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1}


def _max_sev(rows: list) -> str:
out = "LOW"
for r in rows:
s = (r.get("severity") or "LOW").upper()
if _SEV_RANK.get(s, 0) > _SEV_RANK.get(out, 0):
out = s
return out


def _group_by_category(rows: list) -> dict:
out: dict = defaultdict(list)
for r in rows:
if r.get("status") == "resolved":
continue
out[r.get("category") or "unknown"].append(r)
return out


def render_grouped_risks(rows: list, store=None, owner_email: str = "") -> None:
"""One section per category. Click to expand → per-signature rows.
`store` is required for the Authorize button to write to S3;
if None, button is hidden (read-only mode)."""
groups = _group_by_category(rows)
if not groups:
st.info("No open findings. Clean posture.")
return

st.markdown(
'<div class="card-title">OPEN FINDINGS — GROUPED</div>',
unsafe_allow_html=True,
)
# Render sorted by severity then count.
items = sorted(
groups.items(),
key=lambda kv: (-_SEV_RANK.get(_max_sev(kv[1]), 0), -len(kv[1])),
)
for cat, cat_rows in items:
max_sev = _max_sev(cat_rows)
last_seen = max(r.get("last_seen") or "" for r in cat_rows)
header = (f"{cat.replace('_', ' ').title()} — "
f"{len(cat_rows)} signature(s) · max sev {max_sev} · "
f"last seen {last_seen[:19] or '—'}")
with st.expander(header, expanded=False):
providers = sorted({r.get("provider", "") for r in cat_rows})
for r in cat_rows[:50]:
pname = r.get("provider", "")
occ = r.get("occurrences", 1)
fseen = (r.get("first_seen") or "")[:19]
lseen = (r.get("last_seen") or "")[:19]
hint = cleanup_hint(cat, r.get("os_name", ""))
st.markdown(
f"<div style='font-family:JetBrains Mono;font-size:12px;"
f"padding:6px 0;border-bottom:1px solid #f3f4f6'>"
f"<b>{pname}</b> · {occ} occurrence(s) · "
f"{fseen} → {lseen}<br>"
f"<span style='color:#57606A'>💡 {hint}</span>"
f"</div>",
unsafe_allow_html=True,
)
# Bulk Authorize for this category (only if store + email available)
if store and owner_email:
btn_key = f"auth_cat_{cat}_{owner_email}"
if st.button(
f"✓ Authorize all {len(providers)} {cat.replace('_',' ')} provider(s) for {owner_email}",
key=btn_key,
):
total = authorize(store, owner_email, providers)
st.success(
f"Authorized {len(providers)} provider(s). "
f"User's allow-list now has {total} entries. "
"Agent picks up on next scan."
)
40 changes: 25 additions & 15 deletions ghost-ai-scanner/dashboard/ui/clickable_metric.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
# =============================================================
# FILE: dashboard/ui/clickable_metric.py
# PROJECT: PatronAI — Mega-PR (drill-down everywhere)
# VERSION: 1.0.0
# UPDATED: 2026-04-27
# VERSION: 1.1.0
# UPDATED: 2026-05-11
# OWNER: Giggso Inc (Ravi Venugopal)
# PURPOSE: Drop-in replacement for `st.metric()` that adds a thin
# "↳ filter" button below the value. Clicking the button
# opens a drill-down panel via drill_panel.set_drill().
# Visually preserves Streamlit's native metric tile look so
# the dashboard rhythm stays unchanged.
# DEPENDS: streamlit, drill_panel
# AUDIT LOG:
# v1.0.0 2026-04-27 Initial. Mega-PR.
# v1.1.0 2026-05-11 Add optional sub_label — small grey volume
# indicator beneath the value (e.g. "1020 scan
# events" under a Devices=1 card). Lets the
# inventory tab show distinct-device counts as
# the headline number while preserving the raw
# row count as a secondary signal.
# =============================================================

from typing import Optional
Expand All @@ -25,27 +29,33 @@ def clickable_metric(container, label: str, value,
panel_key: str, drill_field: str, drill_value,
drill_label: Optional[str] = None,
delta: Optional[str] = None,
help_text: Optional[str] = None) -> None:
help_text: Optional[str] = None,
sub_label: Optional[str] = None) -> None:
"""Render an st.metric tile + a drill button beneath it.

Args:
container: Streamlit container/column to render into
label: Metric label ("Unauthorized events")
label: Metric label ("Devices")
value: Metric value (int / str)
panel_key: Drill panel id (one per page region — KPIs of one tab
typically share a panel_key so clicking another KPI
replaces the previous drill instead of stacking)
panel_key: Drill panel id (one per page region)
drill_field: Event dict key to filter on ("severity", "outcome", …)
drill_value: Value to match (e.g. "CRITICAL", "BLOCK")
drill_label: Chip text in the drill panel (defaults to f"{label}: {drill_value}")
drill_value: Value to match
drill_label: Chip text in the drill panel
delta: Same as st.metric delta
help_text: Same as st.metric help

On click, sets the drill state — the calling page is expected to
invoke render_drill_panel(panel_key, events) somewhere below the
KPI row to surface the filtered table.
sub_label: Optional small grey volume indicator under the value
— e.g. "1020 scan events". Lets the headline number
represent distinct entities while preserving the raw
row count as a secondary signal.
"""
container.metric(label, value, delta=delta, help=help_text)
if sub_label:
container.markdown(
f"<div style='font-family:JetBrains Mono;font-size:10px;"
f"color:#8B949E;margin-top:-12px;margin-bottom:4px;'>"
f"{sub_label}</div>",
unsafe_allow_html=True,
)
btn_key = f"clk_{panel_key}_{label.replace(' ', '_').lower()}"
if container.button(f"↳ filter", key=btn_key):
set_drill(
Expand Down
Loading
Loading