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))
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."
)
36 changes: 24 additions & 12 deletions ghost-ai-scanner/dashboard/ui/manager_tab_actions.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
# =============================================================
# FILE: dashboard/ui/manager_tab_actions.py
# VERSION: 2.0.0
# UPDATED: 2026-05-02
# VERSION: 2.1.0
# UPDATED: 2026-05-11
# OWNER: Giggso Inc
# PURPOSE: Action helpers for the Manager Risks tab.
# mark_resolved — writes RESOLVED outcome back to S3 findings store.
# escalate — POSTs events to Trinity via dispatcher.
# send_alert_email — thin shim: delegates to notify.email.send_alert.
# mark_resolved — writes RESOLVED outcome back to S3.
# escalate — POSTs to Trinity via dispatcher.
# send_alert_email — thin shim → notify.email.send_alert.
# authorize_for_user — appends to per-user authorized list
# on S3; agent picks up on next scan.
# All functions are pure — no Streamlit calls inside.
# DEPENDS: requests, alerter.dispatcher, notify.email
# DEPENDS: requests, alerter.dispatcher, notify.email, services.authorize
# AUDIT LOG:
# v1.0.0 2026-04-19 Initial — split from manager_tab_risks.py
# v2.0.0 2026-05-02 send_alert_email body collapsed to a one-line
# call into notify.email.send_alert. The duplicated
# boto3 SES path here used PATRONAI_FROM_EMAIL +
# skipped recipient verification, both inconsistent
# with the welcome / OTP paths. notify.email is
# now the single SES call site for the codebase.
# v2.0.0 2026-05-02 send_alert_email → notify.email single call site.
# v2.1.0 2026-05-11 Add authorize_for_user — closes the noise loop
# by teaching the agent which tools the operator
# has approved for a given user.
# =============================================================

import logging
Expand Down Expand Up @@ -89,3 +89,15 @@ def send_alert_email(events: list, recipients: str) -> bool:
recipient list. Thin shim — actual SES work lives in notify.email."""
from notify.email import send_alert
return send_alert(recipients=recipients, events=events)


def authorize_for_user(store, email: str, events: list) -> int:
"""Append every distinct provider in `events` to the user's
authorized list on S3. Agent fetches the list at next scan and
filters those providers from emission — closing the noise loop
at source. Idempotent; returns the new total entry count.
Server-side `findings_compact` then auto-resolves the open
findings within the stale-window cycle."""
from services.authorize import authorize # local — avoid hard dep on streamlit entry
providers = sorted({e.get("provider", "") for e in events if e.get("provider")})
return authorize(store, email, providers)
10 changes: 9 additions & 1 deletion ghost-ai-scanner/dashboard/ui/manager_tab_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from .filtered_table import search_box, apply_search_dicts
from .clickable_metric import clickable_metric, static_metric
from .drill_panel import render_drill_panel
from .ai_posture_card import render_ai_posture

_PANEL = "mgr_inventory"

Expand All @@ -46,14 +47,21 @@ def _owner_of(e: dict) -> str:


def render_inventory(events: list) -> None:
"""Asset summary KPIs, endpoint-protection banner, and asset table."""
"""AI Posture card (headline) → KPI cards → asset table."""
q = search_box("inventory", placeholder="search owner / IP / MAC …")
if q:
events = apply_search_dicts(events, q)

keys = [_asset_key(e) for e in events]
unique_keys = list(dict.fromkeys(keys))

# Headline — aggregated AI Posture card. Single risk score +
# per-category breakdown replaces the count-of-everything KPI noise.
# When compacted findings_current rows are available they're used;
# otherwise we degrade gracefully to raw events.
device_label = unique_keys[0] if len(unique_keys) == 1 else f"{len(unique_keys)} devices"
render_ai_posture(events, device_label=device_label)

# KPIs count DISTINCT DEVICES, not event rows. A laptop emitting a
# scan every 30 min must show as 1 device + N scan events, never
# as N "endpoints". The pre-2.2.0 sum() over events.asset_type was
Expand Down
27 changes: 20 additions & 7 deletions ghost-ai-scanner/dashboard/ui/manager_tab_risks.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@
import pandas as pd
import streamlit as st

from .helpers import sev_badge
from .manager_tab_actions import mark_resolved, escalate, send_alert_email
from .time_fmt import fmt as fmt_time
from .filtered_table import filtered_table
from .helpers import sev_badge
from .manager_tab_actions import mark_resolved, escalate, send_alert_email
from .time_fmt import fmt as fmt_time
from .filtered_table import filtered_table
from .category_grouped_risks import render_grouped_risks

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

Expand All @@ -32,9 +33,21 @@
_SEV_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2}


def render_risks(events: list) -> None:
"""Selectable alert table + Mark Resolved / Escalate / Email actions."""
alerts = sorted( # .get() guards missing severity on endpoint events
def render_risks(events: list, store=None, owner_email: str = "") -> None:
"""Two views in one tab:
1) Grouped view (default) — collapsible category cards with
bulk-authorize on each category. Reads compacted rows.
2) Flat alert list — legacy table with Mark/Escalate/Email.
Operator can switch between them via a single toggle."""
grouped = st.toggle("Grouped view (recommended)", value=True,
key="risks_grouped_toggle")
if grouped:
render_grouped_risks(events, store=store, owner_email=owner_email)
st.markdown("<br>", unsafe_allow_html=True)
st.caption("Switch off Grouped view above to see the flat alert table.")
return

alerts = sorted(
[e for e in events if e.get("severity") in _SEV_ORDER],
key=lambda x: (_SEV_ORDER.get(x.get("severity", ""), 9),
x.get("timestamp", "")),
Expand Down
Loading