diff --git a/go.mod b/go.mod index 304c4da..6f47d17 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module pilens.dev/pilens go 1.27.1 +toolchain go1.27.2 + require ( golang.org/x/crypto v0.57.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/internal/api/analysis.go b/internal/api/analysis.go index 276a49a..03c0730 100644 --- a/internal/api/analysis.go +++ b/internal/api/analysis.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "pilens.dev/pilens/internal/capture" "strconv" ) @@ -12,6 +13,7 @@ func (s *Server) analysisRoutes(priv func(string, http.HandlerFunc)) { priv("GET /api/captures/{id}/follow", s.withSession(s.follow)) priv("GET /api/captures/{id}/dns", s.withSession(s.dns)) priv("GET /api/captures/{id}/tls", s.withSession(s.tls)) + priv("GET /api/captures/{id}/findings", s.withSession(s.findings)) } func qint(r *http.Request, k string, def int) int { @@ -85,3 +87,15 @@ func (s *Server) tls(w http.ResponseWriter, r *http.Request, id string) { writeJSON(w, map[string]any{"handshakes": handshakes, "certificates": certs, "note": "Observed metadata only. In capture-only mode TLS payloads are not decrypted; interception status is 'encrypted pass-through' for every session."}) } + +func (s *Server) findings(w http.ResponseWriter, r *http.Request, id string) { + f, err := s.Engine.Findings(r.Context(), id, int64(qint(r, "min", 1))) + if err != nil { + engineErr(w, err) + return + } + if f == nil { + f = []capture.Finding{} + } + writeJSON(w, f) +} diff --git a/internal/capture/analysis.go b/internal/capture/analysis.go index 74b3adc..a2dc67e 100644 --- a/internal/capture/analysis.go +++ b/internal/capture/analysis.go @@ -592,3 +592,110 @@ func cipherName(code string) string { } return code } + +// ---------------------------------------------------------------- findings (diagnostics) + +// Finding is one observed, evidence-backed diagnostic. These are facts about the +// capture (a retransmission happened, a SERVFAIL was returned), never a claim of +// malicious intent; the Note spells out benign explanations. +type Finding struct { + Rule string `json:"rule"` + Title string `json:"title"` + Severity string `json:"severity"` // info | low | medium + Count int64 `json:"count"` + Flows int `json:"flows"` + Examples []int64 `json:"examples"` // packet numbers + Filter string `json:"filter"` // display filter reproducing the evidence + Note string `json:"note"` +} + +type findingRule struct { + rule, title, sev, filter, note string +} + +var findingRules = []findingRule{ + {"tcp.retransmission", "TCP retransmissions", "medium", "tcp.analysis.retransmission || tcp.analysis.fast_retransmission", + "The sender re-sent data it believed was lost. Usually packet loss or congestion on the path, not an attack. A high rate degrades throughput."}, + {"tcp.syn_retransmission", "Connection-setup retries (SYN retransmissions)", "medium", "tcp.flags.syn==1 && tcp.flags.ack==0 && tcp.analysis.retransmission", + "A client retried the opening SYN, so the first attempt(s) got no reply. Indicates an unreachable or slow service, a filtered port, or loss."}, + {"tcp.zero_window", "TCP zero window", "medium", "tcp.analysis.zero_window", + "A receiver advertised a full buffer, pausing the sender. Often a slow or overloaded endpoint."}, + {"tcp.reset", "TCP resets", "low", "tcp.flags.reset==1", + "A connection was aborted with RST. Common and often normal (a server closing an idle socket), but a burst can indicate refused connections."}, + {"tcp.dup_ack", "Duplicate ACKs", "low", "tcp.analysis.duplicate_ack", + "Repeated ACKs for the same sequence, the receiver's signal that it is missing a segment. Correlates with retransmissions and loss."}, + {"tcp.lost_segment", "Missing segments (not captured)", "low", "tcp.analysis.lost_segment", + "tshark saw a gap in the TCP sequence. On an appliance this often means the capture itself dropped packets, not the network — check the capture drop counters."}, + {"tcp.out_of_order", "Out-of-order segments", "info", "tcp.analysis.out_of_order", + "Segments arrived out of sequence. Normal on multipath or reordering networks."}, + {"dns.servfail", "DNS SERVFAIL responses", "medium", "dns.flags.rcode==2", + "The resolver reported a server failure. Can mean DNSSEC validation failure, an upstream outage, or a misconfigured name."}, + {"dns.nxdomain", "DNS NXDOMAIN responses", "info", "dns.flags.rcode==3 && !mdns", + "The name does not exist. Extremely common (typos, telemetry probing non-existent names); only an unusual volume is noteworthy."}, + {"tls.fatal_alert", "Fatal TLS alerts", "medium", "tls.alert_message.level==2", + "A TLS session ended with a fatal alert (e.g. handshake_failure, bad_certificate). The connection did not establish a secure channel."}, + {"icmp.unreachable", "ICMP destination unreachable", "low", "icmp.type==3 || icmpv6.type==1", + "A router or host reported a destination or port could not be reached."}, +} + +func (e *Engine) Findings(ctx context.Context, id string, minCount int64) ([]Finding, error) { + if minCount < 1 { + minCount = 1 + } + fields := []string{"ip.src", "ipv6.src", "ip.dst", "ipv6.dst", "tcp.stream"} + var out []Finding + for _, fr := range findingRules { + rows, err := e.extractFields(ctx, id, fr.filter, fields, 50000) + if err != nil { + // A rule referencing a field this tshark build lacks shouldn't fail the page. + continue + } + if int64(len(rows)) < minCount { + continue + } + f := Finding{Rule: fr.rule, Title: fr.title, Severity: fr.sev, Filter: fr.filter, Note: fr.note, Count: int64(len(rows))} + flows := map[string]bool{} + for i, c := range rows { + if i < 12 { + n, _ := splitNumSeg(c[0]) + f.Examples = append(f.Examples, n) + } + if len(c) >= 7 { + key := c[6] // tcp.stream when present + if key == "" { + key = firstNonEmpty(c[2], c[3]) + ">" + firstNonEmpty(c[4], c[5]) + } + flows[key] = true + } + } + f.Flows = len(flows) + out = append(out, f) + } + // Capture drops are a finding of their own, read from the session record. + var drops string + e.DB.QueryRowContext(ctx, `SELECT drops FROM capture_sessions WHERE id=?`, id).Scan(&drops) + if total := parseDropTotal(drops); total > 0 { + out = append(out, Finding{Rule: "capture.drops", Title: "Packets dropped during capture", Severity: "medium", Count: total, + Note: "The kernel or dumpcap could not keep up and discarded packets before they were written. The capture is incomplete; analysis counts are lower bounds. Reduce load or use a capture filter.", Filter: ""}) + } + return out, nil +} + +// parseDropTotal sums the "dropped" figures from dumpcap's per-interface report. +func parseDropTotal(s string) int64 { + var total int64 + for _, line := range strings.Split(s, "\n") { + // ...received/dropped on interface 'X': 1654/3 (...) + if i := strings.Index(line, ": "); i >= 0 { + frac := strings.Fields(line[i+2:]) + if len(frac) > 0 { + if parts := strings.SplitN(frac[0], "/", 2); len(parts) == 2 { + if d, err := strconv.ParseInt(parts[1], 10, 64); err == nil { + total += d + } + } + } + } + } + return total +} diff --git a/internal/capture/engine_test.go b/internal/capture/engine_test.go index bf6420f..03f017a 100644 --- a/internal/capture/engine_test.go +++ b/internal/capture/engine_test.go @@ -248,4 +248,14 @@ func TestAnalysisFromFixture(t *testing.T) { if _, _, err := e.TLS(ctx, s.ID, 100); err != nil { t.Fatalf("tls: %v", err) } + // Findings must run without error; the fixture has a 404 and some resets. + fs, err := e.Findings(ctx, s.ID, 1) + if err != nil { + t.Fatalf("findings: %v", err) + } + for _, f := range fs { + if f.Count < 1 || f.Severity == "" || f.Title == "" { + t.Fatalf("malformed finding: %+v", f) + } + } } diff --git a/web/dist/.gitkeep b/web/dist/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/web/src/App.tsx b/web/src/App.tsx index af6d235..5801d84 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,6 +13,7 @@ import Conversations from "./pages/Conversations"; import DnsExplorer from "./pages/DnsExplorer"; import TlsExplorer from "./pages/TlsExplorer"; import Statistics from "./pages/Statistics"; +import Findings from "./pages/Findings"; import Settings from "./pages/Settings"; export interface Prefs { theme: "dark" | "light" | "system"; density: "compact" | "comfortable"; scale: number; motion: "system" | "reduce" } @@ -28,6 +29,7 @@ const NAV: [string, string, string][] = [ ["/dns", "DNS Explorer", "◇"], ["/tls", "TLS Explorer", "⛨"], ["/statistics", "Statistics", "▦"], + ["/findings", "Findings", "⚑"], ["/library", "Capture Library", "▤"], ["/devices", "Devices", "⌬"], ["", "", ""], @@ -97,6 +99,7 @@ export default function App() { if (path.startsWith("/dns")) return ; if (path.startsWith("/tls")) return ; if (path.startsWith("/statistics")) return ; + if (path.startsWith("/findings")) return ; if (path === "/library") return ; if (path === "/devices") return ; if (path === "/network") return ; diff --git a/web/src/api.ts b/web/src/api.ts index 690e1c5..f4bdc87 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -96,3 +96,5 @@ export interface FollowResult { seg: number; stream: number; chunks: StreamChunk export interface DNSRecord { num: number; seg: number; time: number; client: string; server: string; query: string; type: string; response: boolean; rcode: string; answers: string } export interface TLSRecord { num: number; seg: number; time: number; client: string; server: string; server_port: number; sni: string; version: string; cipher: string; alpn: string; stream: number | null } export interface CertRecord { num: number; seg: number; time: number; server: string; subject: string; issuer: string; sans: string; not_before: string; not_after: string } + +export interface Finding { rule: string; title: string; severity: "info" | "low" | "medium"; count: number; flows: number; examples: number[]; filter: string; note: string } diff --git a/web/src/pages/Findings.tsx b/web/src/pages/Findings.tsx new file mode 100644 index 0000000..982d033 --- /dev/null +++ b/web/src/pages/Findings.tsx @@ -0,0 +1,47 @@ +import { type Finding, type Session } from "../api"; +import { Badge, go, usePoll } from "../ui"; +import { SessionPicker } from "../components/chrome"; + +const sevRank: Record = { medium: 0, low: 1, info: 2 }; + +export default function Findings({ sessionId }: { sessionId: string }) { + const [sessions] = usePoll("/api/captures", 5000); + const id = sessionId || sessions?.[0]?.id || ""; + const [findings, err] = usePoll(id ? `/api/captures/${id}/findings?min=1` : null); + const sorted = [...(findings ?? [])].sort((a, b) => (sevRank[a.severity] - sevRank[b.severity]) || b.count - a.count); + + return ( +
+
+

Findings

Observable network events with evidence. These are facts about the capture, not judgements: each one lists benign explanations.

+ +
+
+ This is a forensic aid, not an intrusion-detection system. + A finding means a pattern was observed (a retransmission, a SERVFAIL). It does not imply an attack or a fault. Use the evidence to investigate. +
+ {err &&
{err}
} + {findings && sorted.length === 0 &&
No notable events observed in this capture.
} +
+ {sorted.map((f) => ( +
+
+
{f.severity}

{f.title}

+
+ {f.count.toLocaleString()} packet{f.count === 1 ? "" : "s"}{f.flows ? ` · ${f.flows} flow${f.flows === 1 ? "" : "s"}` : ""} + {f.filter && } +
+
+

{f.note}

+
+ Detection:{f.filter || "capture drop counters"} + {f.examples.length > 0 && <>· e.g. packets{f.examples.slice(0, 8).map((n) => ( + { e.preventDefault(); go(`/analyse/${id}?filter=frame.number==${n}`); }}>#{n} + ))}} +
+
+ ))} +
+
+ ); +}