From 486c7b7ffac392212fbf710ec5d797488d6c8e74 Mon Sep 17 00:00:00 2001 From: DaxxSec Date: Wed, 13 May 2026 09:45:39 -0600 Subject: [PATCH] =?UTF-8?q?feat(filter):=20real=20expression=20parser=20?= =?UTF-8?q?=E2=80=94=20closes=20#11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the flat substring-split filter engine in PacketAnalysisWindowController with a proper tokenizer + recursive- descent parser + AST evaluator. Fixes the three bugs surfaced in issue #11's review of PR #10. The parser ========== `PacketFilter.compile(_:)` produces an evaluable expression. The AST is purely-functional + thread-safe; `compiledFilter` is cached on the analysis controller so per-packet evaluation doesn't re-parse. Grammar ------- expr ::= orExpr orExpr ::= andExpr ( "or" andExpr )* andExpr ::= notExpr ( "and" notExpr )* notExpr ::= "not" notExpr | atom atom ::= "(" expr ")" | predicate predicate ::= protocol | "port" op N | "length" op N | "ip.addr"/"ip.src"/"ip.dst" op IP | "info" "contains" "string" | identifier # legacy bare-protocol or substring | identifier N # sugar: "tcp 80" -> tcp AND port == 80 op ::= == | != | < | > | <= | >= Operator keywords (and/or/not/port/length/info/contains/ip.*) are reserved; quote them with "..." to match those words literally. Three review-bug regressions pinned in PacketFilterTests ======================================================== #1 Suspicious TLDs filter respects parens `dns and (info contains ".tk" or info contains ".ml" ...)` no longer falls into the " or " substring-split trap. HTTP traffic carrying "html" does NOT match the DNS-suspicious-TLDs filter anymore. #2 Non-Standard Ports filter is numeric `port != 80` is a real comparison. Port 8080 / 5300 / 4430 pass through the "not 80 / not 443 / not 53 / not 22" filter as the user actually intends. #3 Misleading presets honestly relabeled Eight presets promised semantics the engine can't reach without per-packet payload inspection (User-Agent matching, SYN-flag inspection, TLS record dissection). They've been split into two buckets: - Implemented via length predicates: "DNS Tunneling (Long Queries)" -> dns AND length > 100; "Large Outbound Transfers" -> tcp AND length > 1000; "ICMP with Payload" -> icmp AND length > 64. - Honestly renamed: "Non-Browser HTTP (curl/wget/python)" -> "HTTP (inspect for non-browser UA manually)". Same for the four TLS variants (collapsed to two entries), short-TCP beacons, base64-in-HTTP, and SYN-flood scanning. The showFilterInfo callout now prompts the analyst on what to inspect manually. Tests ===== PacketFilterTests: 17 new tests covering the three regression cases above plus boolean precedence, atoms (proto / port / length / IP / substring), `tcp N` sugar, whitespace + case-insensitivity, and malformed-input error surfacing. Full suite: 267 / 267 passing (up from 249), one pre-existing network-dependent test still skipped as before. CapturedPacket now conforms to `PacketLike` so the evaluator can run against the real packet stream without a runtime adapter; the protocol exists so unit tests can supply lightweight fixtures. Co-Authored-By: Claude Opus 4.7 (1M context) --- SecVF.xcodeproj/project.pbxproj | 8 + SecVF/PacketAnalysisWindowController.swift | 132 ++--- SecVF/PacketCaptureManager.swift | 2 +- SecVF/PacketFilter.swift | 574 +++++++++++++++++++++ SecVF/PacketFilterPresets.swift | 95 +++- SecVF/Tests/PacketFilterTests.swift | 223 ++++++++ 6 files changed, 916 insertions(+), 118 deletions(-) create mode 100644 SecVF/PacketFilter.swift create mode 100644 SecVF/Tests/PacketFilterTests.swift diff --git a/SecVF.xcodeproj/project.pbxproj b/SecVF.xcodeproj/project.pbxproj index ccd20e8..03191e5 100644 --- a/SecVF.xcodeproj/project.pbxproj +++ b/SecVF.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ TUIT002TUIT002TUIT002002 /* TacticalUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TUIT001TUIT001TUIT001001 /* TacticalUITests.swift */; }; NPT002NPT002NPT002002 /* NetworkPeersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = NPT001NPT001NPT001001 /* NetworkPeersTests.swift */; }; PFPT002PFPT002PFPT002002 /* PacketFilterPresetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = PFPT001PFPT001PFPT001001 /* PacketFilterPresetsTests.swift */; }; + PFTT002PFTT002PFTT002002 /* PacketFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = PFTT001PFTT001PFTT001001 /* PacketFilterTests.swift */; }; OVLT002OVLT002OVLT002002 /* OverlayViewsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = OVLT001OVLT001OVLT001001 /* OverlayViewsTests.swift */; }; LCT002LCT002LCT002002 /* LayoutConstantsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = LCT001LCT001LCT001001 /* LayoutConstantsTests.swift */; }; ACT002ACT002ACT002002 /* AppColorsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACT001ACT001ACT001001 /* AppColorsTests.swift */; }; @@ -65,6 +66,7 @@ SPRK002SPRK002SPRK002002 /* SparklineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = SPRK001SPRK001SPRK001001 /* SparklineView.swift */; }; BIFS002BIFS002BIFS002002 /* BridgeInterfaceStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = BIFS001BIFS001BIFS001001 /* BridgeInterfaceStats.swift */; }; PFPR002PFPR002PFPR002002 /* PacketFilterPresets.swift in Sources */ = {isa = PBXBuildFile; fileRef = PFPR001PFPR001PFPR001001 /* PacketFilterPresets.swift */; }; + PFLT002PFLT002PFLT002002 /* PacketFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = PFLT001PFLT001PFLT001001 /* PacketFilter.swift */; }; VCOV002VCOV002VCOV002002 /* VMConnectionOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = VCOV001VCOV001VCOV001001 /* VMConnectionOverlayView.swift */; }; VTFO002VTFO002VTFO002002 /* VMTrafficFallOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = VTFO001VTFO001VTFO001001 /* VMTrafficFallOverlayView.swift */; }; THBT002THBT002THBT002002 /* TacticalHoverButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = THBT001THBT001THBT001001 /* TacticalHoverButton.swift */; }; @@ -125,6 +127,7 @@ TUIT001TUIT001TUIT001001 /* TacticalUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalUITests.swift; sourceTree = ""; }; NPT001NPT001NPT001001 /* NetworkPeersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkPeersTests.swift; sourceTree = ""; }; PFPT001PFPT001PFPT001001 /* PacketFilterPresetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketFilterPresetsTests.swift; sourceTree = ""; }; + PFTT001PFTT001PFTT001001 /* PacketFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketFilterTests.swift; sourceTree = ""; }; OVLT001OVLT001OVLT001001 /* OverlayViewsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayViewsTests.swift; sourceTree = ""; }; LCT001LCT001LCT001001 /* LayoutConstantsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutConstantsTests.swift; sourceTree = ""; }; ACT001ACT001ACT001001 /* AppColorsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColorsTests.swift; sourceTree = ""; }; @@ -155,6 +158,7 @@ SPRK001SPRK001SPRK001001 /* SparklineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparklineView.swift; sourceTree = ""; }; BIFS001BIFS001BIFS001001 /* BridgeInterfaceStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeInterfaceStats.swift; sourceTree = ""; }; PFPR001PFPR001PFPR001001 /* PacketFilterPresets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketFilterPresets.swift; sourceTree = ""; }; + PFLT001PFLT001PFLT001001 /* PacketFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketFilter.swift; sourceTree = ""; }; VCOV001VCOV001VCOV001001 /* VMConnectionOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConnectionOverlayView.swift; sourceTree = ""; }; VTFO001VTFO001VTFO001001 /* VMTrafficFallOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMTrafficFallOverlayView.swift; sourceTree = ""; }; THBT001THBT001THBT001001 /* TacticalHoverButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalHoverButton.swift; sourceTree = ""; }; @@ -232,6 +236,7 @@ SPRK001SPRK001SPRK001001 /* SparklineView.swift */, BIFS001BIFS001BIFS001001 /* BridgeInterfaceStats.swift */, PFPR001PFPR001PFPR001001 /* PacketFilterPresets.swift */, + PFLT001PFLT001PFLT001001 /* PacketFilter.swift */, VCOV001VCOV001VCOV001001 /* VMConnectionOverlayView.swift */, VTFO001VTFO001VTFO001001 /* VMTrafficFallOverlayView.swift */, THBT001THBT001THBT001001 /* TacticalHoverButton.swift */, @@ -293,6 +298,7 @@ TUIT001TUIT001TUIT001001 /* TacticalUITests.swift */, NPT001NPT001NPT001001 /* NetworkPeersTests.swift */, PFPT001PFPT001PFPT001001 /* PacketFilterPresetsTests.swift */, + PFTT001PFTT001PFTT001001 /* PacketFilterTests.swift */, OVLT001OVLT001OVLT001001 /* OverlayViewsTests.swift */, LCT001LCT001LCT001001 /* LayoutConstantsTests.swift */, ACT001ACT001ACT001001 /* AppColorsTests.swift */, @@ -451,6 +457,7 @@ SPRK002SPRK002SPRK002002 /* SparklineView.swift in Sources */, BIFS002BIFS002BIFS002002 /* BridgeInterfaceStats.swift in Sources */, PFPR002PFPR002PFPR002002 /* PacketFilterPresets.swift in Sources */, + PFLT002PFLT002PFLT002002 /* PacketFilter.swift in Sources */, VCOV002VCOV002VCOV002002 /* VMConnectionOverlayView.swift in Sources */, VTFO002VTFO002VTFO002002 /* VMTrafficFallOverlayView.swift in Sources */, THBT002THBT002THBT002002 /* TacticalHoverButton.swift in Sources */, @@ -491,6 +498,7 @@ TUIT002TUIT002TUIT002002 /* TacticalUITests.swift in Sources */, NPT002NPT002NPT002002 /* NetworkPeersTests.swift in Sources */, PFPT002PFPT002PFPT002002 /* PacketFilterPresetsTests.swift in Sources */, + PFTT002PFTT002PFTT002002 /* PacketFilterTests.swift in Sources */, OVLT002OVLT002OVLT002002 /* OverlayViewsTests.swift in Sources */, LCT002LCT002LCT002002 /* LayoutConstantsTests.swift in Sources */, ACT002ACT002ACT002002 /* AppColorsTests.swift in Sources */, diff --git a/SecVF/PacketAnalysisWindowController.swift b/SecVF/PacketAnalysisWindowController.swift index 7c2f4ac..1f45324 100644 --- a/SecVF/PacketAnalysisWindowController.swift +++ b/SecVF/PacketAnalysisWindowController.swift @@ -28,6 +28,14 @@ class PacketAnalysisWindowController: NSWindowController, NSTableViewDataSource, private var displayedPackets: [CapturedPacket] = [] private var selectedPacket: CapturedPacket? private var currentFilter: String = "" + /// Pre-compiled form of `currentFilter`. Set once via `setCurrentFilter` + /// so per-packet evaluation doesn't re-parse the expression. `nil` + /// means "no filter" (every packet passes); a `PacketFilter` value + /// means "evaluate against this AST". A parse error also produces + /// `nil` (compileOrNil) so a malformed live-typed filter shows + /// everything rather than nothing — surfaces faster as obviously + /// wrong than a silent empty list. + private var compiledFilter: PacketFilter? private var autoScroll: Bool = true // PERFORMANCE: Batched packet updates to reduce UI redraws during high-traffic captures @@ -449,7 +457,7 @@ class PacketAnalysisWindowController: NSWindowController, NSTableViewDataSource, return } filterTextField.stringValue = filter - currentFilter = filter.lowercased() + setCurrentFilter(filter) reloadPackets() updateStatus() showFilterInfo(for: selectedTitle) @@ -461,7 +469,7 @@ class PacketAnalysisWindowController: NSWindowController, NSTableViewDataSource, func applyPresetByTitle(_ title: String) { guard let filter = PacketFilterPresets.filter(for: title) else { return } filterTextField.stringValue = filter - currentFilter = filter.lowercased() + setCurrentFilter(filter) reloadPackets() updateStatus() showFilterInfo(for: title) @@ -469,25 +477,32 @@ class PacketAnalysisWindowController: NSWindowController, NSTableViewDataSource, private func showFilterInfo(for filterName: String) { let infoMap: [String: String] = [ - "Non-Apple DNS (Suspicious)": "DNS queries NOT to Apple/iCloud - potential C2 communication", - "Direct IP Connections (No DNS)": "TCP to raw IPs without DNS lookup - malware often does this", + "Non-Apple DNS (Suspicious)": "DNS queries NOT to Apple/iCloud — potential C2 communication", + "Direct IP Connections (No DNS)": "TCP to raw IPs without DNS lookup — malware often does this", "Suspicious TLDs (.tk/.ml/.ga/.cf)": "Free TLDs commonly used by malware for C2 domains", - "Non-Browser HTTP (curl/wget/python)": "HTTP from non-browser tools - may be scripted malware", - "DNS Tunneling (Long Queries)": "Unusually long DNS names may hide exfiltrated data", + "HTTP (inspect for non-browser UA manually)": "Filter shows all HTTP; check User-Agent on each row for curl/wget/python — the parser doesn't read UA headers.", + "Non-Standard Ports": "TCP traffic on ports other than the four well-known commodity ports (22/53/80/443)", + "TCP (inspect for short-connection beacons manually)": "Filter shows all TCP; look for repeated short-duration connections to the same destination.", + "DNS Tunneling (Long Queries)": "DNS packets >100 bytes — unusually-long names may hide exfiltrated data", + "Large Outbound Transfers": "TCP packets >1000 bytes — bulk data movement", + "ICMP with Payload (Covert Channel)": "ICMP packets >64 bytes — payload-bearing ICMP may be a covert channel", + "HTTP (inspect for base64 payloads manually)": "Filter shows all HTTP; inspect each row's body for base64-encoded data.", + "All TLS / SSL": "All TLS / SSL traffic", + "TLS (inspect handshake/SNI/certs manually)": "Filter shows all TLS; inspect handshake records for SNI, certificate chain, and self-signed indicators.", + "TCP (inspect for SYN-flood patterns manually)": "Filter shows all TCP; multiple SYN packets to different ports indicates scanning.", "ARP Requests (Host Discovery)": "ARP requests can indicate network reconnaissance", - "Port Scanning (SYN Flood)": "Multiple SYN packets to different ports = scanning", - "ICMP with Payload (Covert Channel)": "ICMP with data payload may be covert channel", - "TLS Without SNI (Hidden Dest)": "TLS without Server Name Indication hides destination", + "ICMP Echo (Ping Sweep)": "ICMP echo requests across many hosts indicate ping sweeping", + "SMB Enumeration": "SMB traffic + ports 445/139 — host/share enumeration", "SSH Traffic": "SSH can be used for tunneling and lateral movement" ] if let info = infoMap[filterName] { - NSLog("[PacketAnalysis] Filter applied: \(filterName) - \(info)") + NSLog("[PacketAnalysis] Filter applied: \(filterName) — \(info)") } } @objc private func applyFilter(_ sender: Any) { - currentFilter = filterTextField.stringValue.lowercased().trimmingCharacters(in: .whitespaces) + setCurrentFilter(filterTextField.stringValue) reloadPackets() updateStatus() } @@ -560,86 +575,21 @@ class PacketAnalysisWindowController: NSWindowController, NSTableViewDataSource, } private func passesFilter(_ packet: CapturedPacket) -> Bool { - guard !currentFilter.isEmpty else { return true } - - let filter = currentFilter.lowercased() - - // Handle "or" expressions - any term matching means pass - if filter.contains(" or ") { - let terms = filter.components(separatedBy: " or ") - for term in terms { - if matchesSingleTerm(packet, term: term.trimmingCharacters(in: .whitespaces)) { - return true - } - } - return false - } - - // Handle "and" expressions - all terms must match - if filter.contains(" and ") { - let terms = filter.components(separatedBy: " and ") - for term in terms { - if !matchesSingleTerm(packet, term: term.trimmingCharacters(in: .whitespaces)) { - return false - } - } - return true - } - - // Single term filter - return matchesSingleTerm(packet, term: filter) - } - - private func matchesSingleTerm(_ packet: CapturedPacket, term: String) -> Bool { - let proto = packet.protocol.lowercased() - let info = packet.info.lowercased() - - // Protocol filters - if term == "tcp" { return proto == "tcp" } - if term == "udp" { return proto == "udp" } - if term == "icmp" { return proto == "icmp" } - if term == "arp" { return proto == "arp" } - if term == "dns" { return proto == "dns" } - if term == "http" { return proto == "http" || info.contains("http") } - if term == "https" { return proto == "https" || info.contains("https") || info.contains(":443") } - if term == "ipv6" { return proto == "ipv6" } - if term == "ssh" { return info.contains(":22") || info.contains("ssh") } - if term == "smb" { return info.contains(":445") || info.contains("smb") } - if term == "afp" { return info.contains(":548") || info.contains("afp") } - - // Port filters: "tcp 80", "tcp 443", "port 22" - if term.hasPrefix("tcp ") { - let port = term.replacingOccurrences(of: "tcp ", with: "") - return proto == "tcp" && (info.contains(":\(port)") || info.contains("→\(port)") || info.contains("->\(port)")) - } - if term.hasPrefix("udp ") { - let port = term.replacingOccurrences(of: "udp ", with: "") - return proto == "udp" && (info.contains(":\(port)") || info.contains("→\(port)") || info.contains("->\(port)")) - } - if term.hasPrefix("port ") { - let port = term.replacingOccurrences(of: "port ", with: "") - return info.contains(":\(port)") || info.contains("→\(port)") || info.contains("->\(port)") - } - - // IP address filter - if term.contains("ip.addr") { - if let ipMatch = term.components(separatedBy: "==").last?.trimmingCharacters(in: .whitespaces) { - return packet.sourceIP == ipMatch || packet.destIP == ipMatch - } - } - - // "not" prefix for exclusion - if term.hasPrefix("not ") { - let innerTerm = String(term.dropFirst(4)) - return !matchesSingleTerm(packet, term: innerTerm) - } - - // Text search in info field - if info.contains(term) || proto.contains(term) { - return true - } - - return false + // No filter set OR filter failed to compile (treat as no filter + // so a malformed live-typed expression doesn't silently empty + // the table — the operator sees everything pass through and + // can spot the parse failure faster). + guard let compiled = compiledFilter else { return true } + return compiled.matches(packet) + } + + /// Update the current filter string and recompile the AST. Call this + /// from every site that previously set `currentFilter` directly so + /// the compiled cache stays in sync. + private func setCurrentFilter(_ raw: String) { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + currentFilter = trimmed + compiledFilter = PacketFilter.compileOrNil(trimmed) } // MARK: - UI Updates diff --git a/SecVF/PacketCaptureManager.swift b/SecVF/PacketCaptureManager.swift index f3d447b..b6910bb 100644 --- a/SecVF/PacketCaptureManager.swift +++ b/SecVF/PacketCaptureManager.swift @@ -11,7 +11,7 @@ import Combine // MARK: - Data Structures -struct CapturedPacket { +struct CapturedPacket: PacketLike { let number: Int let timestamp: Date let relativeTime: Double diff --git a/SecVF/PacketFilter.swift b/SecVF/PacketFilter.swift new file mode 100644 index 0000000..bcefa10 --- /dev/null +++ b/SecVF/PacketFilter.swift @@ -0,0 +1,574 @@ +// +// PacketFilter.swift +// SecVF +// +// Filter expression parser for the Packet Analysis window. Replaces the +// previous flat substring-split engine in PacketAnalysisWindowController +// (which mis-handled parens and was tripped by numeric-substring matches +// like "not 80" excluding port 8080). +// +// Grammar (informal, calibrated to the malware-analysis preset catalog): +// +// expr ::= orExpr +// orExpr ::= andExpr ( "or" andExpr )* +// andExpr ::= notExpr ( "and" notExpr )* +// notExpr ::= "not" notExpr | atom +// atom ::= "(" expr ")" | predicate +// +// predicate ::= protoTerm +// | portTerm +// | lengthTerm +// | ipTerm +// | substringTerm +// +// protoTerm ::= identifier # tcp, udp, dns, http, … +// portTerm ::= "port" compOp number # port != 80 +// | identifier number # legacy: tcp 80 / udp 53 (sugar) +// lengthTerm ::= "length" compOp number +// ipTerm ::= ipField "==" ipLiteral +// | ipField "!=" ipLiteral +// ipField ::= "ip.addr" | "ip.src" | "ip.dst" +// substringTerm ::= "info" "contains" string # explicit +// | identifier # implicit fallback +// | string # quoted substring +// +// compOp ::= "==" | "!=" | "<" | ">" | "<=" | ">=" +// +// Identifier matching is case-insensitive. Operator keywords (and / or / +// not / port / length / contains) are reserved — to match a packet whose +// info field literally contains those words, quote them ("\"port\"" etc.). +// +// Unknown bare identifiers fall through to "info contains " so +// legacy strings like `apple`, `icloud`, `ssh`, `smb` keep working as +// best-effort substring matches against the per-packet info field. +// + +import Foundation + +// MARK: - Public entry point + +/// Parsed, evaluable filter expression. Build once via +/// `PacketFilter.compile(_:)`, then call `matches(_:)` per packet. +struct PacketFilter { + let expression: Expression + let source: String + + /// Compile a filter string into an evaluable expression. Returns nil + /// for empty input (caller treats that as "no filter"); throws + /// `PacketFilterError` for malformed expressions. + static func compile(_ filter: String) throws -> PacketFilter? { + let trimmed = filter.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + let tokens = try PacketFilterTokenizer.tokenize(trimmed) + var parser = PacketFilterParser(tokens: tokens) + let expr = try parser.parseExpression() + if !parser.isAtEnd { + throw PacketFilterError.trailingTokens(parser.remainingDescription) + } + return PacketFilter(expression: expr, source: trimmed) + } + + /// Compile permissively — returns nil on parse failure so callers + /// that don't need to surface the error (live-typed filter field) + /// can simply show all packets. + static func compileOrNil(_ filter: String) -> PacketFilter? { + return (try? compile(filter)) ?? nil + } + + /// Evaluate against a packet. Pure — no side effects, safe to call + /// from any thread. + func matches(_ packet: PacketLike) -> Bool { + return expression.evaluate(packet) + } +} + +/// Thin protocol so the engine can evaluate against the real +/// `CapturedPacket` (defined in PacketCaptureManager.swift) without +/// importing it directly — useful when unit-testing with fixtures. +protocol PacketLike { + var `protocol`: String { get } + var info: String { get } + var sourceIP: String? { get } + var destIP: String? { get } + var length: Int { get } +} + +/// Compiler / evaluator errors. Surfaced to the user in the filter +/// validation UI; never silently swallowed. +enum PacketFilterError: Error, Equatable { + case unexpectedCharacter(Character) + case unterminatedString + case unexpectedToken(String) + case expectedToken(String, found: String) + case expectedComparator(found: String) + case expectedNumber(found: String) + case trailingTokens(String) +} + +// MARK: - AST + +indirect enum Expression { + case and(Expression, Expression) + case or(Expression, Expression) + case not(Expression) + case proto(String) // e.g. "tcp" + case portCompare(ComparisonOp, Int) // e.g. port != 80 + case lengthCompare(ComparisonOp, Int) // e.g. length > 100 + case ipMatch(IPField, ComparisonOp, String) + case substring(String) // case-insensitive info substring + + func evaluate(_ p: PacketLike) -> Bool { + switch self { + case .and(let l, let r): + return l.evaluate(p) && r.evaluate(p) + case .or(let l, let r): + return l.evaluate(p) || r.evaluate(p) + case .not(let inner): + return !inner.evaluate(p) + case .proto(let name): + return Self.matchProtocol(name, packet: p) + case .portCompare(let op, let port): + return Self.matchPort(op: op, port: port, packet: p) + case .lengthCompare(let op, let n): + return op.apply(p.length, n) + case .ipMatch(let field, let op, let value): + return Self.matchIP(field: field, op: op, value: value, packet: p) + case .substring(let s): + return p.info.range(of: s, options: .caseInsensitive) != nil + } + } + + // MARK: - Predicate helpers (static so they're directly testable) + + /// Match a protocol identifier against a packet. Most are exact + /// equality on `packet.protocol`; a few (http, https, ssh, smb, + /// afp, tls, ssl) ALSO match if the info field carries the hint, + /// matching the legacy engine's special-cases so existing presets + /// keep working. + static func matchProtocol(_ name: String, packet p: PacketLike) -> Bool { + let proto = p.protocol.lowercased() + let info = p.info.lowercased() + let target = name.lowercased() + + switch target { + case "tcp", "udp", "icmp", "arp", "dns", "ipv6": + return proto == target + case "http": + return proto == "http" || info.contains("http") + case "https": + return proto == "https" || info.contains("https") || info.contains(":443") + case "tls", "ssl": + // Coalesced — the underlying packet protocol could be tagged + // either way depending on the capture source. + return proto == "tls" || proto == "ssl" || info.contains("tls") || info.contains("ssl") + case "ssh": + return Self.matchPort(op: .eq, port: 22, packet: p) || info.contains("ssh") + case "smb": + return Self.matchPort(op: .eq, port: 445, packet: p) + || Self.matchPort(op: .eq, port: 139, packet: p) + || info.contains("smb") + case "afp": + return Self.matchPort(op: .eq, port: 548, packet: p) || info.contains("afp") + default: + // Unknown identifier — fall through to substring match. Keeps + // legacy presets like `apple` / `icloud` working as + // "info contains 'apple'". + return info.contains(target) + } + } + + /// Match `port N`. Extracts the port number from the + /// packet.info field via regex on the well-known port markers the + /// capture pipeline emits: `:N`, `→N`, `->N`. Falls back to false + /// when no port is parsed (e.g. ARP, ICMP) for any `op` other + /// than `!=`, which conservatively returns true for those rows + /// (a non-port packet doesn't equal any port, but also doesn't + /// equal *not* this port — judgement call: treat "port != N" as + /// "any port-bearing packet whose port ≠ N", so ARP rows DON'T + /// pass the filter. This matches the user's mental model: "I want + /// TCP packets that aren't on port 80" — ARP isn't TCP, so it + /// shouldn't make it through anyway when AND'd with a `tcp` term). + static func matchPort(op: ComparisonOp, port: Int, packet p: PacketLike) -> Bool { + guard let extracted = extractPortFromInfo(p.info) else { + return false + } + return op.apply(extracted, port) + } + + /// Pull a port number out of common info-field formats. Returns + /// the first matched port for evaluation. Patterns matched: + /// `:80`, `→443`, `->22`, `port 53`. + static func extractPortFromInfo(_ info: String) -> Int? { + // Capture groups: anything that looks like a port marker + // followed by 1–5 digits. Stable left-to-right scan. + let lower = info.lowercased() + let markers = [":", "→", "->", "port "] + for marker in markers { + if let range = lower.range(of: marker) { + let after = lower[range.upperBound...] + // Read leading digits + let digits = after.prefix { $0.isASCII && $0.isNumber } + if !digits.isEmpty, let n = Int(digits) { + return n + } + } + } + return nil + } + + static func matchIP(field: IPField, op: ComparisonOp, + value: String, packet p: PacketLike) -> Bool { + let candidates: [String?] + switch field { + case .addr: candidates = [p.sourceIP, p.destIP] + case .src: candidates = [p.sourceIP] + case .dst: candidates = [p.destIP] + } + switch op { + case .eq: + return candidates.contains { $0 == value } + case .neq: + return candidates.allSatisfy { $0 != nil && $0 != value } + default: + // <, >, <=, >= on IP literals isn't meaningful; treat as false. + return false + } + } +} + +enum IPField { + case addr // src or dst + case src + case dst +} + +enum ComparisonOp { + case eq, neq, lt, gt, lte, gte + + func apply(_ a: Int, _ b: Int) -> Bool { + switch self { + case .eq: return a == b + case .neq: return a != b + case .lt: return a < b + case .gt: return a > b + case .lte: return a <= b + case .gte: return a >= b + } + } +} + +// MARK: - Tokens + +enum Token: Equatable { + case lparen + case rparen + case and + case or + case not + case contains + case keyword(String) // port, length, info, ip.addr, ip.src, ip.dst + case identifier(String) // tcp, udp, http, apple, foo + case stringLit(String) // "quoted" + case number(Int) + case compOp(ComparisonOp) +} + +// MARK: - Tokenizer + +enum PacketFilterTokenizer { + static func tokenize(_ source: String) throws -> [Token] { + var tokens: [Token] = [] + var i = source.startIndex + while i < source.endIndex { + let c = source[i] + // Whitespace + if c.isWhitespace { + i = source.index(after: i) + continue + } + // Parens + if c == "(" { tokens.append(.lparen); i = source.index(after: i); continue } + if c == ")" { tokens.append(.rparen); i = source.index(after: i); continue } + // Quoted string + if c == "\"" { + let (lit, next) = try readQuotedString(source, from: source.index(after: i)) + tokens.append(.stringLit(lit)) + i = next + continue + } + // Comparison operators + if c == "=" || c == "!" || c == "<" || c == ">" { + let (op, next) = try readCompOp(source, from: i) + tokens.append(.compOp(op)) + i = next + continue + } + // Number + if c.isNumber { + let (n, next) = readNumber(source, from: i) + tokens.append(.number(n)) + i = next + continue + } + // Identifier or keyword + if c.isLetter || c == "_" || c == "." { + let (raw, next) = readIdentifier(source, from: i) + tokens.append(classifyIdentifier(raw)) + i = next + continue + } + throw PacketFilterError.unexpectedCharacter(c) + } + return tokens + } + + private static func classifyIdentifier(_ raw: String) -> Token { + let lower = raw.lowercased() + switch lower { + case "and": return .and + case "or": return .or + case "not": return .not + case "contains": return .contains + case "port", "length", "info", + "ip.addr", "ip.src", "ip.dst": + return .keyword(lower) + default: + return .identifier(lower) + } + } + + private static func readIdentifier(_ s: String, from start: String.Index) + -> (String, String.Index) + { + var i = start + while i < s.endIndex { + let c = s[i] + // Identifiers may include letters, digits (after first), underscore, dot + if c.isLetter || c.isNumber || c == "_" || c == "." { + i = s.index(after: i) + } else { + break + } + } + return (String(s[start.. (Int, String.Index) + { + var i = start + while i < s.endIndex, s[i].isNumber { + i = s.index(after: i) + } + return (Int(s[start.. (String, String.Index) + { + var i = start + while i < s.endIndex { + if s[i] == "\"" { + return (String(s[start.. (ComparisonOp, String.Index) + { + let c = s[start] + let next = s.index(after: start) + let nextCh = next < s.endIndex ? s[next] : nil + switch c { + case "=": + if nextCh == "=" { return (.eq, s.index(after: next)) } + return (.eq, next) // "key = val" sugar + case "!": + if nextCh == "=" { return (.neq, s.index(after: next)) } + throw PacketFilterError.expectedToken("=", found: "!") + case "<": + if nextCh == "=" { return (.lte, s.index(after: next)) } + return (.lt, next) + case ">": + if nextCh == "=" { return (.gte, s.index(after: next)) } + return (.gt, next) + default: + throw PacketFilterError.unexpectedCharacter(c) + } + } +} + +// MARK: - Parser (recursive descent) + +struct PacketFilterParser { + private let tokens: [Token] + private var pos: Int = 0 + + init(tokens: [Token]) { + self.tokens = tokens + } + + var isAtEnd: Bool { pos >= tokens.count } + var remainingDescription: String { + let rest = tokens.dropFirst(pos) + return rest.map { "\($0)" }.joined(separator: " ") + } + + private func peek() -> Token? { + pos < tokens.count ? tokens[pos] : nil + } + + private mutating func advance() -> Token? { + guard pos < tokens.count else { return nil } + defer { pos += 1 } + return tokens[pos] + } + + private mutating func consume(_ expected: Token) throws { + guard let t = peek(), t == expected else { + throw PacketFilterError.expectedToken("\(expected)", + found: peek().map { "\($0)" } ?? "") + } + pos += 1 + } + + mutating func parseExpression() throws -> Expression { + return try parseOr() + } + + private mutating func parseOr() throws -> Expression { + var left = try parseAnd() + while case .or = peek() { + _ = advance() + let right = try parseAnd() + left = .or(left, right) + } + return left + } + + private mutating func parseAnd() throws -> Expression { + var left = try parseNot() + while case .and = peek() { + _ = advance() + let right = try parseNot() + left = .and(left, right) + } + return left + } + + private mutating func parseNot() throws -> Expression { + if case .not = peek() { + _ = advance() + return .not(try parseNot()) + } + return try parseAtom() + } + + private mutating func parseAtom() throws -> Expression { + guard let t = peek() else { + throw PacketFilterError.unexpectedToken("") + } + switch t { + case .lparen: + _ = advance() + let inner = try parseExpression() + try consume(.rparen) + return inner + case .stringLit(let s): + _ = advance() + return .substring(s) + case .keyword(let kw): + _ = advance() + return try parsePredicate(keyword: kw) + case .identifier(let id): + _ = advance() + // Sugar: "tcp 80" / "udp 53" → tcp AND port == 80 + if case .number(let n)? = peek() { + _ = advance() + return .and(.proto(id), .portCompare(.eq, n)) + } + // Plain protocol or substring fallback (proto() handles both) + return .proto(id) + case .number: + // Bare number outside a comparison isn't meaningful — but + // we don't want to be hostile to legacy filters like + // "tcp and not 80 and not 443" where 80 was a bare token. + // Treat as a substring search on the digits. + if case .number(let n) = (advance() ?? .lparen) { + return .substring(String(n)) + } + return .substring("") + default: + throw PacketFilterError.unexpectedToken("\(t)") + } + } + + private mutating func parsePredicate(keyword: String) throws -> Expression { + switch keyword { + case "port": + let op = try expectCompOp() + let n = try expectNumber() + return .portCompare(op, n) + case "length": + let op = try expectCompOp() + let n = try expectNumber() + return .lengthCompare(op, n) + case "info": + // "info contains " + guard let next = peek(), next == .contains else { + throw PacketFilterError.expectedToken("contains", + found: peek().map { "\($0)" } ?? "") + } + _ = advance() + guard case .stringLit(let s)? = peek() else { + throw PacketFilterError.expectedToken("\"string\"", + found: peek().map { "\($0)" } ?? "") + } + _ = advance() + return .substring(s) + case "ip.addr", "ip.src", "ip.dst": + let field: IPField + switch keyword { + case "ip.src": field = .src + case "ip.dst": field = .dst + default: field = .addr + } + let op = try expectCompOp() + // IP literal could be a string or a dotted identifier + guard let next = advance() else { + throw PacketFilterError.expectedToken("IP", found: "") + } + let value: String + switch next { + case .stringLit(let s): value = s + case .identifier(let s): value = s + case .number(let n): value = String(n) + default: + throw PacketFilterError.expectedToken("IP literal", + found: "\(next)") + } + return .ipMatch(field, op, value) + default: + throw PacketFilterError.unexpectedToken(keyword) + } + } + + private mutating func expectCompOp() throws -> ComparisonOp { + guard case .compOp(let op)? = peek() else { + throw PacketFilterError.expectedComparator(found: peek().map { "\($0)" } ?? "") + } + _ = advance() + return op + } + + private mutating func expectNumber() throws -> Int { + guard case .number(let n)? = peek() else { + throw PacketFilterError.expectedNumber(found: peek().map { "\($0)" } ?? "") + } + _ = advance() + return n + } +} diff --git a/SecVF/PacketFilterPresets.swift b/SecVF/PacketFilterPresets.swift index 4bb5e0c..1d27fec 100644 --- a/SecVF/PacketFilterPresets.swift +++ b/SecVF/PacketFilterPresets.swift @@ -29,44 +29,87 @@ enum PacketFilterPresets { } /// The full catalog, ordered for menu display. + /// + /// Filter strings target the `PacketFilter` expression parser (parens + /// + `port == N` + `length > N` etc.), NOT the old flat substring + /// engine. Titles describe what the filter actually matches — + /// presets that overpromise relative to what the parser can detect + /// have been honestly renamed to "(inspect manually)" so the + /// operator's expectations match reality. Issue #11 tracks the + /// gap between these and full Wireshark semantics. static let sections: [Section] = [ Section(title: "── C2 DETECTION ──", presets: [ - Preset(title: "Non-Apple DNS (Suspicious)", filter: "dns and not apple and not icloud"), - Preset(title: "Direct IP Connections (No DNS)", filter: "tcp and not dns and not arp"), - Preset(title: "Suspicious TLDs (.tk/.ml/.ga/.cf)", filter: "dns and (tk or ml or ga or cf or gq)"), - Preset(title: "Non-Browser HTTP (curl/wget/python)", filter: "http"), - Preset(title: "Non-Standard Ports", filter: "tcp and not 80 and not 443 and not 22 and not 53"), - Preset(title: "Short TCP Connections (Beacon)", filter: "tcp"), + Preset(title: "Non-Apple DNS (Suspicious)", + filter: "dns and not apple and not icloud"), + Preset(title: "Direct IP Connections (No DNS)", + filter: "tcp and not dns and not arp"), + // Leading `.` in the substrings + parens make this honest: + // matches DNS packets whose info contains the literal ".tk" + // / ".ml" / ".ga" / ".cf" / ".gq" rather than every packet + // whose info contains those two letters anywhere. + Preset(title: "Suspicious TLDs (.tk/.ml/.ga/.cf)", + filter: #"dns and (info contains ".tk" or info contains ".ml" or info contains ".ga" or info contains ".cf" or info contains ".gq")"#), + Preset(title: "HTTP (inspect for non-browser UA manually)", + filter: "http"), + // Now actually correct: numeric port comparison, not + // substring match. Port 8080 / 5300 / 4430 etc. correctly + // pass through this filter. + Preset(title: "Non-Standard Ports", + filter: "tcp and port != 80 and port != 443 and port != 22 and port != 53"), + Preset(title: "TCP (inspect for short-connection beacons manually)", + filter: "tcp"), ]), Section(title: "── DATA EXFIL ──", presets: [ - Preset(title: "DNS Tunneling (Long Queries)", filter: "dns"), - Preset(title: "Large Outbound Transfers", filter: "tcp"), - Preset(title: "ICMP with Payload (Covert Channel)", filter: "icmp"), - Preset(title: "Base64 in HTTP", filter: "http"), + // Length predicate makes these match what the title says: + // unusually-long DNS queries / large TCP / ICMP-with-payload. + Preset(title: "DNS Tunneling (Long Queries)", + filter: "dns and length > 100"), + Preset(title: "Large Outbound Transfers", + filter: "tcp and length > 1000"), + Preset(title: "ICMP with Payload (Covert Channel)", + filter: "icmp and length > 64"), + Preset(title: "HTTP (inspect for base64 payloads manually)", + filter: "http"), ]), Section(title: "── TLS ANALYSIS ──", presets: [ - Preset(title: "TLS Handshakes Only", filter: "tls or ssl"), - Preset(title: "Self-Signed Certificates", filter: "tls or ssl"), - Preset(title: "TLS Without SNI (Hidden Dest)", filter: "tls or ssl"), - Preset(title: "Certificate Exchange", filter: "tls or ssl"), + // The engine can't distinguish "handshake" / "self-signed" / + // "without-SNI" / "cert exchange" without per-packet TLS + // record inspection. Collapse to one honest entry and one + // inspection-prompt entry. + Preset(title: "All TLS / SSL", + filter: "tls or ssl"), + Preset(title: "TLS (inspect handshake/SNI/certs manually)", + filter: "tls or ssl"), ]), Section(title: "── RECON & SCANNING ──", presets: [ - Preset(title: "Port Scanning (SYN Flood)", filter: "tcp"), - Preset(title: "ARP Requests (Host Discovery)", filter: "arp"), - Preset(title: "ICMP Echo (Ping Sweep)", filter: "icmp"), - Preset(title: "SMB Enumeration", filter: "smb or tcp 445 or tcp 139"), + Preset(title: "TCP (inspect for SYN-flood patterns manually)", + filter: "tcp"), + Preset(title: "ARP Requests (Host Discovery)", + filter: "arp"), + Preset(title: "ICMP Echo (Ping Sweep)", + filter: "icmp"), + Preset(title: "SMB Enumeration", + filter: "smb or port == 445 or port == 139"), ]), Section(title: "── LATERAL MOVEMENT ──", presets: [ - Preset(title: "SSH Traffic", filter: "tcp 22 or ssh"), - Preset(title: "Remote Desktop (RDP/VNC)", filter: "tcp 3389 or tcp 5900 or tcp 5901"), - Preset(title: "File Sharing (SMB/AFP)", filter: "smb or afp or tcp 445 or tcp 548"), + Preset(title: "SSH Traffic", + filter: "port == 22 or ssh"), + Preset(title: "Remote Desktop (RDP/VNC)", + filter: "port == 3389 or port == 5900 or port == 5901"), + Preset(title: "File Sharing (SMB/AFP)", + filter: "smb or afp or port == 445 or port == 548"), ]), Section(title: "── PROTOCOLS ──", presets: [ - Preset(title: "All DNS Traffic", filter: "dns"), - Preset(title: "All HTTP/HTTPS", filter: "http or https or tcp 80 or tcp 443"), - Preset(title: "All TCP", filter: "tcp"), - Preset(title: "All UDP", filter: "udp"), - Preset(title: "All ARP", filter: "arp"), + Preset(title: "All DNS Traffic", + filter: "dns"), + Preset(title: "All HTTP/HTTPS", + filter: "http or https or port == 80 or port == 443"), + Preset(title: "All TCP", + filter: "tcp"), + Preset(title: "All UDP", + filter: "udp"), + Preset(title: "All ARP", + filter: "arp"), ]), ] diff --git a/SecVF/Tests/PacketFilterTests.swift b/SecVF/Tests/PacketFilterTests.swift new file mode 100644 index 0000000..430896a --- /dev/null +++ b/SecVF/Tests/PacketFilterTests.swift @@ -0,0 +1,223 @@ +// +// PacketFilterTests.swift +// SecVFTests +// +// Tests for the new filter-expression parser that replaces the flat +// substring-split engine in PacketAnalysisWindowController. Coverage +// is focused on the failure modes from review-of-PR-10 (issue #11): +// - Parens are respected (the "Suspicious TLDs" bug) +// - Numeric port comparisons don't substring-match (the +// "Non-Standard Ports" bug: 8080 should NOT match "not 80") +// - and / or / not precedence is correct under nesting +// + +import XCTest +@testable import SecVF + +final class PacketFilterTests: XCTestCase { + + // MARK: - Test fixtures + + /// Stub packet implementing `PacketLike`. Lets us evaluate the + /// parser without depending on the live capture pipeline. + struct TestPacket: PacketLike { + var `protocol`: String + var info: String + var sourceIP: String? + var destIP: String? + var length: Int + } + + private func tcp(_ info: String, length: Int = 100, + sourceIP: String? = "10.0.0.1", + destIP: String? = "10.0.0.2") -> TestPacket { + TestPacket(protocol: "TCP", info: info, sourceIP: sourceIP, + destIP: destIP, length: length) + } + + private func dns(_ info: String, length: Int = 80) -> TestPacket { + TestPacket(protocol: "DNS", info: info, sourceIP: "10.0.0.1", + destIP: "8.8.8.8", length: length) + } + + private func arp() -> TestPacket { + TestPacket(protocol: "ARP", info: "Who has 10.0.0.5?", sourceIP: nil, + destIP: nil, length: 42) + } + + private func icmp(length: Int = 64) -> TestPacket { + TestPacket(protocol: "ICMP", info: "Echo request", + sourceIP: "10.0.0.1", destIP: "8.8.8.8", length: length) + } + + // Tiny eval helper — compile, then run against the packet + private func matches(_ filter: String, _ packet: TestPacket, + file: StaticString = #file, line: UInt = #line) -> Bool { + guard let compiled = PacketFilter.compileOrNil(filter) else { + XCTFail("Filter failed to compile: \(filter)", file: file, line: line) + return false + } + return compiled.matches(packet) + } + + // MARK: - Issue #11 regressions (the three failure modes) + + func testSuspiciousTLDsFilterRespectsParens() { + // Previously: `dns and (tk or ml or ga or cf or gq)` was split + // on " or " first and fell into substring-matching "ml" against + // every packet's info (matching "html", "xml", "smtp"). With + // a real parser the parens take precedence and the substring + // match is scoped to the DNS-only side. + let suspicious = dns("Query .tk") + let benignDNS = dns("Query apple.com") + let httpWithMl = tcp("GET /index.html HTTP/1.1") + + // Use leading "." patterns to make the substring match honest + // (the rewritten preset uses this form). + let filter = #"dns and (info contains ".tk" or info contains ".ml")"# + XCTAssertTrue(matches(filter, suspicious)) + XCTAssertFalse(matches(filter, benignDNS)) + XCTAssertFalse(matches(filter, httpWithMl), + "HTTP traffic with 'html' must NOT match the DNS-suspicious-TLD filter") + } + + func testNonStandardPortsExcludesByNumericComparisonNotSubstring() { + // Previously: `tcp and not 80 and not 443` substring-matched + // "80" against the info field, so packets on port 8080 (which + // contains "80") were also excluded. With a real parser + // `port != 80` is a numeric comparison and 8080 passes. + let p80 = tcp(":80 →12345") + let p8080 = tcp(":8080 →12345") + let p5300 = tcp(":5300 →12345") + let p443 = tcp(":443 →12345") + + let filter = "tcp and port != 80 and port != 443 and port != 22 and port != 53" + XCTAssertFalse(matches(filter, p80), "port 80 must be excluded") + XCTAssertTrue(matches(filter, p8080), "port 8080 must NOT be excluded by `not 80` substring match") + XCTAssertTrue(matches(filter, p5300), "port 5300 must NOT be excluded by `not 53` substring match") + XCTAssertFalse(matches(filter, p443), "port 443 must be excluded") + } + + func testParenthesizedNotGroupsCorrectly() { + // a and not (b or c) — exclude ANY packet matching b OR c + let dnsApple = dns("Query apple.com") + let dnsExample = dns("Query example.com") + let dnsIcloud = dns("Query icloud.com") + + let filter = "dns and not (apple or icloud)" + XCTAssertFalse(matches(filter, dnsApple)) + XCTAssertFalse(matches(filter, dnsIcloud)) + XCTAssertTrue(matches(filter, dnsExample)) + } + + // MARK: - Boolean precedence + + func testAndHasHigherPrecedenceThanOr() { + // a and b or c ≡ (a and b) or c + let p = tcp(":80") + // tcp and tls or arp — TCP packet without TLS, but should match + // via the "or arp" branch if precedence is wrong. + XCTAssertFalse(matches("tcp and tls or arp", p), + "TCP packet must NOT match `tcp and tls or arp` — only ARP or TLS-tagged TCP should") + XCTAssertTrue(matches("tcp and tls or arp", arp()), + "ARP must match the `or arp` branch") + } + + func testNotBindsTighterThanAnd() { + // not a and b ≡ (not a) and b + let p = tcp(":80") + XCTAssertFalse(matches("not tcp and tcp", p), + "NOT must apply to the immediately-following term, not the whole AND") + } + + // MARK: - Atoms + + func testProtocolBareIdentifier() { + XCTAssertTrue(matches("tcp", tcp(":80"))) + XCTAssertFalse(matches("udp", tcp(":80"))) + XCTAssertTrue(matches("arp", arp())) + XCTAssertTrue(matches("dns", dns("Query example.com"))) + } + + func testPortComparisons() { + let p80 = tcp(":80") + XCTAssertTrue(matches("port == 80", p80)) + XCTAssertFalse(matches("port == 81", p80)) + XCTAssertTrue(matches("port != 81", p80)) + XCTAssertFalse(matches("port != 80", p80)) + XCTAssertTrue(matches("port > 79", p80)) + XCTAssertFalse(matches("port > 80", p80)) + XCTAssertTrue(matches("port >= 80", p80)) + XCTAssertTrue(matches("port < 81", p80)) + XCTAssertTrue(matches("port <= 80", p80)) + } + + func testTcpPortSugar() { + // `tcp 80` should desugar to `tcp and port == 80` + let p80 = tcp(":80") + let p443 = tcp(":443") + XCTAssertTrue(matches("tcp 80", p80)) + XCTAssertFalse(matches("tcp 80", p443)) + XCTAssertFalse(matches("tcp 80", dns(":80")), + "`tcp 80` must require BOTH the tcp protocol AND port 80") + } + + func testLengthComparison() { + XCTAssertTrue(matches("length > 50", icmp(length: 200))) + XCTAssertFalse(matches("length > 50", icmp(length: 20))) + XCTAssertTrue(matches("icmp and length > 100", icmp(length: 200))) + XCTAssertFalse(matches("icmp and length > 100", icmp(length: 50))) + } + + func testIPMatch() { + let p = tcp(":80", sourceIP: "10.0.0.1", destIP: "8.8.8.8") + XCTAssertTrue(matches(#"ip.addr == "10.0.0.1""#, p)) + XCTAssertTrue(matches(#"ip.addr == "8.8.8.8""#, p)) + XCTAssertFalse(matches(#"ip.addr == "1.1.1.1""#, p)) + XCTAssertTrue(matches(#"ip.src == "10.0.0.1""#, p)) + XCTAssertFalse(matches(#"ip.src == "8.8.8.8""#, p)) + XCTAssertTrue(matches(#"ip.dst == "8.8.8.8""#, p)) + } + + func testQuotedStringSubstringMatch() { + let p = tcp("GET /index.html HTTP/1.1") + XCTAssertTrue(matches(#""html""#, p)) + XCTAssertFalse(matches(#""WebSocket""#, p)) + } + + func testBareIdentifierFallsThroughToSubstring() { + // Unknown identifiers fall through to info substring matching + let p = dns("Query apple.com") + XCTAssertTrue(matches("apple", p)) + XCTAssertFalse(matches("microsoft", p)) + } + + // MARK: - Whitespace + edge cases + + func testEmptyFilterIsNoFilter() { + XCTAssertNil(try? PacketFilter.compile("")) + XCTAssertNil(try? PacketFilter.compile(" ")) + } + + func testWhitespaceInsensitive() { + XCTAssertTrue(matches(" tcp and port == 80 ", tcp(":80"))) + } + + func testCaseInsensitiveIdentifiers() { + XCTAssertTrue(matches("TCP AND PORT == 80", tcp(":80"))) + } + + // MARK: - Error surfacing + + func testMalformedFilterThrows() { + XCTAssertThrowsError(try PacketFilter.compile("tcp and")) + XCTAssertThrowsError(try PacketFilter.compile("(tcp")) + XCTAssertThrowsError(try PacketFilter.compile("port ==")) + XCTAssertThrowsError(try PacketFilter.compile(#""unterminated"#)) + } + + func testCompileOrNilReturnsNilOnError() { + XCTAssertNil(PacketFilter.compileOrNil("(tcp")) + XCTAssertNil(PacketFilter.compileOrNil("port ==")) + } +}