Phase 5: Findings diagnostics panel; pin Go toolchain 1.27.2 (0 called stdlib vulns); npm audit clean

This commit is contained in:
Andy Dixon
2026-10-09 22:01:03 +01:00
parent 68702f1b2e
commit fa5445e4a4
8 changed files with 185 additions and 0 deletions
+2
View File
@@ -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
+14
View File
@@ -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)
}
+107
View File
@@ -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
}
+10
View File
@@ -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)
}
}
}
View File
+3
View File
@@ -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 <DnsExplorer sessionId={path.split("/")[2] ?? ""} />;
if (path.startsWith("/tls")) return <TlsExplorer sessionId={path.split("/")[2] ?? ""} />;
if (path.startsWith("/statistics")) return <Statistics sessionId={path.split("/")[2] ?? ""} />;
if (path.startsWith("/findings")) return <Findings sessionId={path.split("/")[2] ?? ""} />;
if (path === "/library") return <Library />;
if (path === "/devices") return <Devices />;
if (path === "/network") return <NetworkPage />;
+2
View File
@@ -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 }
+47
View File
@@ -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<string, number> = { medium: 0, low: 1, info: 2 };
export default function Findings({ sessionId }: { sessionId: string }) {
const [sessions] = usePoll<Session[]>("/api/captures", 5000);
const id = sessionId || sessions?.[0]?.id || "";
const [findings, err] = usePoll<Finding[]>(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 (
<div className="page">
<div className="page-head">
<div><h1>Findings</h1><p>Observable network events with evidence. These are facts about the capture, not judgements: each one lists benign explanations.</p></div>
<SessionPicker sessions={sessions} id={id} base="/findings" />
</div>
<div className="banner info" style={{ marginBottom: "1rem" }}><div>
<b>This is a forensic aid, not an intrusion-detection system.</b>
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.
</div></div>
{err && <div className="banner bad"><div>{err}</div></div>}
{findings && sorted.length === 0 && <div className="empty">No notable events observed in this capture.</div>}
<div className="stack">
{sorted.map((f) => (
<div className="card" key={f.rule}>
<div className="row spread">
<div className="row"><Badge kind={f.severity === "medium" ? "warn" : f.severity === "low" ? "plain" : "ok"}>{f.severity}</Badge><h2 style={{ fontSize: "1rem" }}>{f.title}</h2></div>
<div className="row">
<span className="mono">{f.count.toLocaleString()} packet{f.count === 1 ? "" : "s"}{f.flows ? ` · ${f.flows} flow${f.flows === 1 ? "" : "s"}` : ""}</span>
{f.filter && <button className="small" onClick={() => go(`/analyse/${id}?filter=${encodeURIComponent(f.filter)}`)}>View packets</button>}
</div>
</div>
<p className="hint" style={{ margin: ".5rem 0 .3rem" }}>{f.note}</p>
<div className="row" style={{ gap: ".4rem" }}>
<span className="hint">Detection:</span><code style={{ fontSize: ".8rem" }}>{f.filter || "capture drop counters"}</code>
{f.examples.length > 0 && <><span className="hint">· e.g. packets</span>{f.examples.slice(0, 8).map((n) => (
<a key={n} href="#" className="mono" onClick={(e) => { e.preventDefault(); go(`/analyse/${id}?filter=frame.number==${n}`); }}>#{n}</a>
))}</>}
</div>
</div>
))}
</div>
</div>
);
}