Phase 5: Findings diagnostics panel; pin Go toolchain 1.27.2 (0 called stdlib vulns); npm audit clean
This commit is contained in:
@@ -2,6 +2,8 @@ module pilens.dev/pilens
|
|||||||
|
|
||||||
go 1.27.1
|
go 1.27.1
|
||||||
|
|
||||||
|
toolchain go1.27.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"pilens.dev/pilens/internal/capture"
|
||||||
"strconv"
|
"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}/follow", s.withSession(s.follow))
|
||||||
priv("GET /api/captures/{id}/dns", s.withSession(s.dns))
|
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}/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 {
|
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,
|
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."})
|
"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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -592,3 +592,110 @@ func cipherName(code string) string {
|
|||||||
}
|
}
|
||||||
return code
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -248,4 +248,14 @@ func TestAnalysisFromFixture(t *testing.T) {
|
|||||||
if _, _, err := e.TLS(ctx, s.ID, 100); err != nil {
|
if _, _, err := e.TLS(ctx, s.ID, 100); err != nil {
|
||||||
t.Fatalf("tls: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
@@ -13,6 +13,7 @@ import Conversations from "./pages/Conversations";
|
|||||||
import DnsExplorer from "./pages/DnsExplorer";
|
import DnsExplorer from "./pages/DnsExplorer";
|
||||||
import TlsExplorer from "./pages/TlsExplorer";
|
import TlsExplorer from "./pages/TlsExplorer";
|
||||||
import Statistics from "./pages/Statistics";
|
import Statistics from "./pages/Statistics";
|
||||||
|
import Findings from "./pages/Findings";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
|
|
||||||
export interface Prefs { theme: "dark" | "light" | "system"; density: "compact" | "comfortable"; scale: number; motion: "system" | "reduce" }
|
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", "◇"],
|
["/dns", "DNS Explorer", "◇"],
|
||||||
["/tls", "TLS Explorer", "⛨"],
|
["/tls", "TLS Explorer", "⛨"],
|
||||||
["/statistics", "Statistics", "▦"],
|
["/statistics", "Statistics", "▦"],
|
||||||
|
["/findings", "Findings", "⚑"],
|
||||||
["/library", "Capture Library", "▤"],
|
["/library", "Capture Library", "▤"],
|
||||||
["/devices", "Devices", "⌬"],
|
["/devices", "Devices", "⌬"],
|
||||||
["", "", ""],
|
["", "", ""],
|
||||||
@@ -97,6 +99,7 @@ export default function App() {
|
|||||||
if (path.startsWith("/dns")) return <DnsExplorer sessionId={path.split("/")[2] ?? ""} />;
|
if (path.startsWith("/dns")) return <DnsExplorer sessionId={path.split("/")[2] ?? ""} />;
|
||||||
if (path.startsWith("/tls")) return <TlsExplorer 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("/statistics")) return <Statistics sessionId={path.split("/")[2] ?? ""} />;
|
||||||
|
if (path.startsWith("/findings")) return <Findings sessionId={path.split("/")[2] ?? ""} />;
|
||||||
if (path === "/library") return <Library />;
|
if (path === "/library") return <Library />;
|
||||||
if (path === "/devices") return <Devices />;
|
if (path === "/devices") return <Devices />;
|
||||||
if (path === "/network") return <NetworkPage />;
|
if (path === "/network") return <NetworkPage />;
|
||||||
|
|||||||
@@ -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 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 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 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 }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user