Lesson 8.5 · Encoding, Crypto & Signatures· 50 min
Writing Network Signatures
Turn a sample's network code and captured traffic into Suricata rules that target durable protocol structure, then test them against pcaps.
Objectives
- Rank network indicators by durability and explain why protocol structure outlives IPs and domains
- Combine static and dynamic evidence to classify each field of a request as hard-coded, config-derived, host-derived or random
- Write Suricata rules with sticky buffers, flow, content modifiers, pcre, flowbits, thresholds and metadata
- Test a rule against a pcap of the malicious traffic and against benign traffic, and keep it fast
- Describe what still works when traffic is encrypted: SNI, JA3/JA4, certificate fields and Zeek logs
In Writing Your First YARA Rules you turned artefacts inside a file into a rule that finds the next file. Network signatures do the same job one layer out. Malware that talks to its operator has to put bytes on the wire, and those bytes pass through sensors you control: a Suricata instance on a span port, a Zeek cluster, a proxy log. A good network rule catches every infected host at once, including hosts whose copy of the sample was repacked, renamed or never reached disk.
This lesson closes Module 8. The earlier lessons gave you the decoding and configuration extraction skills to find out what a sample sends and where it gets each field from. Here you turn that knowledge into detections, test them against captured traffic, and prepare them for sharing.
Indicator levels: brittle to durable
Not all network indicators age the same way. David Bianco's "Pyramid of Pain" makes the point: the cheaper an indicator is for the attacker to change, the less it is worth to you.
| Indicator | Example | How long it lasts | Where it belongs |
|---|---|---|---|
| IP address | 203.0.113[.]44 | Days; cloud hosts and proxies rotate | Blocklists, retro-hunting in logs |
| Domain | update-check[.]example | Days to weeks; domains are cheap | DNS sinkholes, dns.query rules, intel feeds |
| Full URL | /lab/gate.php?id=ecefe37b... | Often one request | Nowhere as a literal: part of it is random |
| URI structure | /lab/gate.php?id= + 16 hex chars | Months; changing it means changing the server too | Suricata rules |
| Header set and order, odd user-agent | LabAgent/1.0 plus X-Lab: 1 | Until the author rewrites the client | Suricata rules, Zeek scripts |
| Protocol behaviour | Fixed-interval beacons, a custom handshake, a TLS fingerprint | Longest; tied to the codebase | Rules, JA4 lists, behavioural analytics |
IPs and domains still go in your triage report, defanged, because they are the fastest thing a SOC can block today. But a rule that only says "traffic to this IP" is an IOC wearing a rule's clothes. The operator moves infrastructure and the rule goes silent while the implant keeps running. The durable targets are the ones baked into the client code: the shape of the request, the fields the author hard-coded, the encoding of the payload. The server has to parse all of these, so changing them means redeploying both ends.
Static and dynamic evidence together
A single capture from a single run is a dangerous basis for a rule. Everything in it looks constant because you only have one example. The analyst's job is to classify every field of the traffic by where it comes from:
| Source | Example in a request | Constant across runs? | Constant across hosts? | Signature use |
|---|---|---|---|---|
| Hard-coded in the binary | LabAgent/1.0, /lab/gate.php, X-Lab: 1 | Yes | Yes | Match literally |
| Configuration | C2 host, beacon interval, campaign ID | Yes, per build | Yes, per build | Match per campaign, or match the format |
| Host-derived | Hash of hostname or volume serial in the ID | Yes | No | Match the format, never the value |
| Random or time-based | Nonce, session key, timestamp | No | No | Match length and character set only |
| Library-supplied | Accept, Connection, header order from WinINet | Yes | Yes | Weak; shared with legitimate software |
Dynamic analysis tells you what varies. Run the sample several times in the lab, ideally on two differently named VMs, capture with the setup from Simulating and Capturing Network Traffic, and diff the requests. A field that changes between two runs on the same host is random. A field that is stable on one host but differs on another is host-derived.
Static analysis tells you why it varies and what the full range is. Find
the networking code through the imports (InternetOpenA, HttpOpenRequestW,
WinHttpSendRequest, send, getaddrinfo; see Reading Capabilities from
Imports), or through
API hashing resolvers when the imports are
hidden. Follow the arguments back. The user-agent passed to InternetOpenA
is often a literal, perhaps XOR-encrypted
and recoverable with the scripts from Scripting String
Decryption. The id parameter might
come from a wsprintfA("%08x%08x", ...) call fed by a random number generator,
which tells you it is always 16 lowercase hex characters. That is a fact you
could not safely infer from five captured requests.
Tip: Dynamic runs miss code paths. A sample that only beacons in your lab may also have upload, error and "task result" requests with different paths or headers. Read the code that builds requests and list every format it can produce, then decide which ones your rule set must cover. The Module 7 lesson on command and control goes deeper into protocol design.
Anatomy of a Suricata rule
Suricata rules descend from Snort's syntax, but modern Suricata inspects parsed protocol fields through sticky buffers rather than raw payload offsets. A rule has an action, a header, and options in parentheses:
alert http $HOME_NET any -> $EXTERNAL_NET any (
msg:"MAL LabImplant HTTP beacon";
flow:established,to_server;
http.method; content:"GET";
http.uri; content:"/lab/gate.php?id="; startswith; fast_pattern;
pcre:"/^\/lab\/gate\.php\?id=[0-9a-f]{16}$/";
http.user_agent; content:"LabAgent/"; startswith;
threshold:type limit, track by_src, count 1, seconds 3600;
reference:url,example.invalid/TR-2026-0929-01;
classtype:trojan-activity;
sid:1000001; rev:2;
metadata:created_at 2026_09_29, updated_at 2026_09_29, confidence High;
)Rule files need each rule on one line (or lines joined with a trailing backslash). It is spread out here for reading. The parts:
| Part | Meaning |
|---|---|
alert | Action. Others are pass, drop and reject (the last two need inline IPS mode) |
http | Protocol. Suricata identifies the application layer itself, so http matches HTTP on any port. Also tcp, udp, dns, tls, quic and more |
$HOME_NET any -> $EXTERNAL_NET any | Source, source port, direction, destination, destination port. Variables come from suricata.yaml |
msg | The alert text. Write it for the person triaging the alert |
flow:established,to_server | Only inspect client-to-server data on a completed TCP session |
http.method, http.uri, http.user_agent | Sticky buffers: every following content or pcre applies to that field until another buffer is named |
content | A literal to find. Hex bytes go between pipe characters (X-Lab followed by 3a 20 for ": "); a leading ! negates |
startswith, endswith, nocase, bsize | Modifiers that anchor, relax or size-limit the preceding match |
pcre | A Perl-compatible regex over the current buffer |
threshold | Rate-limits alerts: here one per source per hour, however often the implant beacons |
reference, classtype, metadata | Context for humans and tooling; ignored when matching |
sid, rev | Unique rule ID and its revision number |
Other buffers you will reach for: http.host, http.header (all request
headers, normalised, one Name: value\r\n line each), http.header_names
(names only, which exposes header order), http.request_body,
http.stat_code, dns.query, tls.sni, tls.cert_subject,
tls.cert_issuer, ja3.hash and ja4.hash. For a raw TCP protocol with no
parser, you fall back to content on the payload with depth, offset,
distance and within.
Flowbits: state across a conversation
Some protocols only become distinctive over several messages. Flowbits set a named flag on a flow in one rule and test it in another:
alert tcp $HOME_NET any -> $EXTERNAL_NET any (msg:"LAB handshake seen"; flow:established,to_server; content:"LABHELLO|00|"; depth:9; flowbits:set,lab.hello; flowbits:noalert; sid:1000010; rev:1;)
alert tcp $EXTERNAL_NET any -> $HOME_NET any (msg:"LAB server tasking after handshake"; flow:established,to_client; flowbits:isset,lab.hello; content:"TASK"; depth:4; sid:1000011; rev:1;)The first rule never alerts; it only marks the flow. The second fires on a
four-byte string that would be far too generic alone, but is specific after
the client's handshake. xbits extends the same idea across flows, tracked
per host or IP pair.
Targeting the stable parts
With the field classification in hand, rule-writing becomes mechanical:
- Anchor on the most specific hard-coded literal, in the narrowest
buffer.
LabAgent/inhttp.user_agentis better than the same string searched anywhere in the payload. - Describe variable fields by shape, not value. A random ID becomes
[0-9a-f]{16}; a base64 blob becomes a character class and a length range. Usebsizeorurilenwhen the length is fixed. - Require two or more independent traits. The path alone collides with a legitimate application sooner or later; path plus user-agent plus a custom header will not.
- Allow for the versions you expect. Match
LabAgent/rather thanLabAgent/1.0if the version string came from the config and changes per build. - Watch for mimicry. Authors copy real browser user-agents, sometimes with a typo, an outdated version or a missing space. The typo is the signature; the rest of the string is camouflage.
Performance
Suricata does not evaluate every rule against every packet. It groups rules by protocol and port, then runs a multi-pattern matcher (MPM) over each buffer, looking for one fast pattern per rule. Only rules whose fast pattern appears are fully evaluated. This is the same idea as YARA's atoms.
- By default Suricata chooses the fast pattern itself, generally the longest
contentin the highest-priority buffer. Mark a better one withfast_patternwhen you know the most distinctive string, as the lab rule does. - A rule with no
contentat all, only apcre, has nothing to prefilter on. It is evaluated for every transaction that matches its header, which is expensive on a busy link. Always pair a regex with a literal that must also be present. - Short or common literals (
GET,.php,Mozilla) make poor fast patterns because they hit constantly. - Keep the header tight:
alert httprather thanalert ip, andflow:to_serverwhenever you inspect requests.
suricata --engine-analysis writes a report on how each loaded rule will be
prefiltered and warns about rules with poor or missing fast patterns. Run it
as part of every rule change.
TLS-era realities
Most malware traffic today is HTTPS. Without decryption at a proxy, the payload, URI and headers are invisible. What remains:
| Visible field | Suricata buffer | Notes |
|---|---|---|
| Server name (SNI) | tls.sni | Chosen by the client; brittle like a domain, but visible. Encrypted Client Hello hides it where deployed |
| DNS lookup before the connection | dns.query | Unless the sample uses DNS over HTTPS |
| Client fingerprint | ja3.hash, ja4.hash | Summarises the ClientHello. JA3 broke when browsers began shuffling extension order; JA4 sorts them and stays stable |
| Server fingerprint | ja3s.hash | Characterises the server's TLS stack |
| Certificate subject, issuer, validity | tls.cert_subject, tls.cert_issuer | Self-signed or default certificates from C2 frameworks are strong signals |
| Timing and sizes | Zeek conn.log, flow records | Regular beacons of similar size stand out over hours |
A JA3 or JA4 fingerprint identifies a TLS library and its configuration, not a program. A sample using the Windows TLS stack shares its fingerprint with every other WinHTTP client on the host, so treat it as supporting evidence. It becomes strong when the malware ships its own TLS library, as many Go and Rust implants do.
Warning: When the channel is encrypted end to end, network detection shrinks to metadata. The durable detections then move to the endpoint: YARA on memory after decryption, EDR telemetry on the process making the connection, and the host artefacts from your triage report. Record that shift in your report rather than shipping an SNI rule and calling the family covered.
Zeek: the same traffic as logs
Suricata answers "did this pattern occur?" Zeek answers "what happened?" by
turning traffic into structured logs: conn.log (every connection, with
durations and byte counts), http.log (method, host, URI, user-agent, status),
dns.log, ssl.log (SNI, version, certificate chain; JA3/JA4 via packages)
and files.log. Running zeek -r capture.pcap in an empty directory writes
these logs from a capture.
The two tools complement each other. A Suricata rule is precise and fires in
real time. Zeek logs let you hunt backwards across months with a query like
"every http.log entry whose user-agent starts with LabAgent/", or find
beaconing by grouping conn.log by source and destination and looking at the
spread of the intervals. Zeek scripts and its Intel framework can also raise
notices, so small detections do not always need a Suricata rule.
Sharing and versioning rules
Rules are code, and a shared rule set is a shared codebase:
- SIDs. The range 1000000 to 1999999 is conventionally reserved for local rules. Public sets such as Emerging Threats have their own ranges. Never reuse a SID for a different rule.
rev. Bump it on every change so sensors, SIEM correlations and colleagues know which version fired.metadata. Emerging Threats-style keys such ascreated_at,updated_at,malware_family,confidenceandsignature_severitylet tooling filter and route alerts.- Tests. Keep the pcap each rule was written from next to the rule,
together with a benign pcap, and run
suricata -ragainst both in CI. - Distribution.
suricata-updatepulls rule sources onto sensors, and the rules travel in your report's detection section, labelled with the report's TLP.
Lab: signature for a lab beacon
You will generate a harmless pcap containing HTTP "beacons" from a demo
client to 127.0.0.1, mixed with benign browser traffic, write a Suricata
rule that targets the beacon's stable fields, and test it. No packet is sent
anywhere: the script builds packets in memory and writes them to a file. You
need Python 3 and, ideally, Suricata (brew install suricata,
apt install suricata, or the Windows MSI).
-
Create a working directory with its own virtual environment:
bash mkdir m8c && cd m8c python3 -m venv .venv .venv/bin/pip install scapy dpkt -
Save
make_pcap.py. It writes nine complete TCP sessions: five beacons 60 seconds apart, each with a fresh random ID, plus four benign requests. One benign request deliberately uses the same path as the beacon, the way a real web application might:python """make_pcap.py - write a harmless pcap of lab "beacons" plus benign traffic.""" import random from scapy.all import Ether, IP, TCP, Raw, wrpcap random.seed(1337) # reproducible "random" IDs and ports CLIENT, SERVER = "127.0.0.2", "127.0.0.1" T0 = 1790676000.0 # 2026-09-29 10:00:00 UTC BROWSER_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36") packets = [] def session(t, path, headers, body=b"OK"): sport = random.randint(49152, 65535) cseq, sseq = random.getrandbits(32), random.getrandbits(32) c = Ether() / IP(src=CLIENT, dst=SERVER) s = Ether() / IP(src=SERVER, dst=CLIENT) req = f"GET {path} HTTP/1.1\r\nHost: {SERVER}\r\n" req += "".join(f"{k}: {v}\r\n" for k, v in headers) + "\r\n" req = req.encode() resp = (b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n" b"Content-Length: %d\r\nConnection: close\r\n\r\n" % len(body)) + body seq = [ c / TCP(sport=sport, dport=80, flags="S", seq=cseq), s / TCP(sport=80, dport=sport, flags="SA", seq=sseq, ack=cseq + 1), c / TCP(sport=sport, dport=80, flags="A", seq=cseq + 1, ack=sseq + 1), c / TCP(sport=sport, dport=80, flags="PA", seq=cseq + 1, ack=sseq + 1) / Raw(req), s / TCP(sport=80, dport=sport, flags="PA", seq=sseq + 1, ack=cseq + 1 + len(req)) / Raw(resp), c / TCP(sport=sport, dport=80, flags="FA", seq=cseq + 1 + len(req), ack=sseq + 1 + len(resp)), s / TCP(sport=80, dport=sport, flags="FA", seq=sseq + 1 + len(resp), ack=cseq + 2 + len(req)), c / TCP(sport=sport, dport=80, flags="A", seq=cseq + 2 + len(req), ack=sseq + 2 + len(resp)), ] for i, p in enumerate(seq): p.time = t + i * 0.001 packets.extend(seq) # Five beacons, one every 60 seconds, each with a fresh random 8-byte ID. for n in range(5): beacon_id = random.getrandbits(64).to_bytes(8, "big").hex() session(T0 + 60 * n, f"/lab/gate.php?id={beacon_id}", [("User-Agent", "LabAgent/1.0"), ("X-Lab", "1"), ("Accept", "*/*")], body=b"sleep=60") # Benign traffic, interleaved in time, for the false-positive check. session(T0 + 15, "/index.html", [("User-Agent", BROWSER_UA), ("Accept", "text/html")]) session(T0 + 95, "/static/app.js", [("User-Agent", BROWSER_UA), ("Accept", "*/*")]) session(T0 + 130, "/lab/gate.php?page=2", # path collision [("User-Agent", BROWSER_UA), ("Accept", "text/html")]) session(T0 + 200, "/health", [("User-Agent", "curl/8.7.1"), ("Accept", "*/*")]) packets.sort(key=lambda p: p.time) wrpcap("beacons.pcap", packets) print(f"wrote beacons.pcap: {len(packets)} packets, {len(packets) // 8} TCP sessions")bash .venv/bin/python make_pcap.pytext wrote beacons.pcap: 72 packets, 9 TCP sessions -
Look at the traffic the way you would a real capture. In Wireshark, filter on
http.request; or use tshark to list the beacons and the time since the previous one (tshark 4.6.4 here):bash tshark -r beacons.pcap -Y 'http.user_agent contains "LabAgent"' \ -T fields -e frame.time_delta_displayed -e http.request.uritext /lab/gate.php?id=ecefe37b9e250d03 60.000000000 /lab/gate.php?id=2a6a7b5fbb5d75b8 60.000000000 /lab/gate.php?id=5c1ed35fca2410fd 60.000000000 /lab/gate.php?id=34775758b2767375 60.000000000 /lab/gate.php?id=a29c4db3d2083355Classify the fields as the table earlier in this lesson does. The path, user-agent and
X-Labheader never change; the ID changes every request; the interval is fixed (in real malware it would come from the config, often with jitter). -
Write
lab.ruleswith a naive first draft and a rule that targets the stable parts. Each rule must be on a single line in the file:text # Naive first draft: the path alone. alert http $HOME_NET any -> $EXTERNAL_NET any (msg:"LAB naive gate.php request"; flow:established,to_server; http.uri; content:"/lab/gate.php"; classtype:trojan-activity; sid:1000000; rev:1;) # Targets the stable parts: method, URI shape, user-agent prefix, custom header. alert http $HOME_NET any -> $EXTERNAL_NET any (msg:"LAB LabAgent HTTP beacon to gate.php"; flow:established,to_server; http.method; content:"GET"; http.uri; content:"/lab/gate.php?id="; startswith; fast_pattern; pcre:"/^\/lab\/gate\.php\?id=[0-9a-f]{16}$/"; http.user_agent; content:"LabAgent/"; startswith; http.header; content:"X-Lab|3a 20|1|0d 0a|"; nocase; reference:url,example.invalid/TR-2026-0929-01; classtype:trojan-activity; sid:1000001; rev:1; metadata:created_at 2026_09_29, updated_at 2026_09_29, malware_family LabImplant, confidence High;) -
Run Suricata against the pcap. The lab traffic flows from
127.0.0.2to127.0.0.1, so setHOME_NETto the client;$EXTERNAL_NETdefaults to everything else.-Sloads only this rule file, and-k nonedisables checksum validation, which is common with generated or offloaded captures:bash mkdir -p logs suricata -r beacons.pcap -S lab.rules -l logs -k none \ --set 'vars.address-groups.HOME_NET=[127.0.0.2]' cat logs/fast.logExpect
fast.logto contain one line per alert, naming the SID, message, classification and the IP pair.logs/eve.jsonholds the same alerts with the full HTTP metadata of each transaction. -
If you do not have Suricata, the following script is a stand-in. It is not an IDS: it parses each request with dpkt and applies only the
content,nocase,startswithandpcreoptions of each rule to the matching buffers, ignoring everything else. It was used to produce the output below because Suricata was not installed on the machine that generated this lesson's output. Save it asmatch_rules.py:python """match_rules.py - a STAND-IN for Suricata. It understands only the sticky buffers http.method, http.uri, http.user_agent and http.header, and the options content (with |hex| bytes), nocase, startswith and pcre.""" import re, sys import dpkt def parse_rule(line): opts = line[line.index("(") + 1: line.rindex(")")] parts, cur, quoted = [], "", False for ch in opts: # split on ';' outside quotes if ch == '"' and not cur.endswith("\\"): quoted = not quoted if ch == ";" and not quoted: parts.append(cur.strip()); cur = "" else: cur += ch rule, checks, buf = {}, [], None for p in filter(None, parts): key, _, val = p.partition(":") if key in ("http.method", "http.uri", "http.user_agent", "http.header"): buf = key elif key == "content": raw = val.strip('"') raw = re.sub(r"\|([0-9a-fA-F ]+)\|", lambda m: bytes.fromhex(m.group(1)).decode("latin-1"), raw) checks.append({"buf": buf, "type": "content", "value": raw}) elif key in ("nocase", "startswith"): checks[-1][key] = True elif key == "pcre": body = val.strip('"') pat, flags = body[1:body.rindex("/")], body[body.rindex("/") + 1:] checks.append({"buf": buf, "type": "pcre", "re": re.compile(pat, re.I if "i" in flags else 0)}) elif key in ("msg", "sid"): rule[key] = val.strip('"') rule["checks"] = checks return rule def buffers(req): hdr = "".join(f"{k}: {v}\r\n" for k, v in req.headers.items()) return {"http.method": req.method, "http.uri": req.uri, "http.user_agent": req.headers.get("user-agent", ""), "http.header": hdr} def check(c, bufs): data = bufs[c["buf"]] if c["type"] == "pcre": return bool(c["re"].search(data)) needle = c["value"] if c.get("nocase"): data, needle = data.lower(), needle.lower() return data.startswith(needle) if c.get("startswith") else needle in data rules = [parse_rule(l) for l in open(sys.argv[1]) if l.startswith("alert")] for ts, frame in dpkt.pcap.Reader(open(sys.argv[2], "rb")): tcp = dpkt.ethernet.Ethernet(frame).data.data if not (isinstance(tcp, dpkt.tcp.TCP) and tcp.dport == 80 and tcp.data): continue req = dpkt.http.Request(tcp.data) bufs = buffers(req) hits = [r["sid"] for r in rules if all(check(c, bufs) for c in r["checks"])] ua = bufs["http.user_agent"][:14] print(f"{ts - 1790676000:6.1f}s {req.uri:36} {ua:14} " f"{'ALERT sid ' + ','.join(hits) if hits else '-'}")bash .venv/bin/python match_rules.py lab.rules beacons.pcaptext 0.0s /lab/gate.php?id=ecefe37b9e250d03 LabAgent/1.0 ALERT sid 1000000,1000001 15.0s /index.html Mozilla/5.0 (W - 60.0s /lab/gate.php?id=2a6a7b5fbb5d75b8 LabAgent/1.0 ALERT sid 1000000,1000001 95.0s /static/app.js Mozilla/5.0 (W - 120.0s /lab/gate.php?id=5c1ed35fca2410fd LabAgent/1.0 ALERT sid 1000000,1000001 130.0s /lab/gate.php?page=2 Mozilla/5.0 (W ALERT sid 1000000 180.0s /lab/gate.php?id=34775758b2767375 LabAgent/1.0 ALERT sid 1000000,1000001 200.0s /health curl/8.7.1 - 240.0s /lab/gate.php?id=a29c4db3d2083355 LabAgent/1.0 ALERT sid 1000000,1000001 -
Read the result as a detection engineer. Both rules catch all five beacons. The naive rule also fires on the browser's
/lab/gate.php?page=2, a false positive that would reach a SOC queue on every visit to a legitimate application using that path. SID 1000001 stays silent on all four benign requests, because the browser request fails the ID pattern, the user-agent and the header checks at once. Delete SID 1000000. -
Make the rule production-ready. Add
threshold:type limit, track by_src, count 1, seconds 3600;so a host that beacons every minute raises one alert an hour rather than sixty, bumprevto 2, and re-run step 5 (or step 6, which ignores thresholds). Then runsuricata --engine-analysis -S lab.rules -l logsand read the rule analysis file it writes: check which content was chosen as the fast pattern. -
Check for false positives at scale. Replay a larger benign capture through the same rule: a recording of your own browsing in the lab VM, or public benign pcaps. Any alert there is a trait you believed unique that is not.
Questions to answer: Which field of the beacon would you classify as
random, which as hard-coded, and what static analysis would confirm it? If
the next build sent LabAgent/1.1 and lowercased the header to x-lab: 1,
would SID 1000001 still fire, and which modifiers are responsible? Why is
fast_pattern placed on the URI content rather than on GET? If the author
moved the beacon to HTTPS with the Windows TLS stack, which parts of this
rule would still work, what could replace them, and what would you hand to
the endpoint team instead?
Key takeaways
- IPs and domains are IOCs with a short life; durable network rules target what the client code hard-codes: URI structure, headers, encodings and protocol behaviour.
- Classify every field as hard-coded, config-derived, host-derived, random or library-supplied, using repeated dynamic runs for what varies and static analysis for why and how much.
- A Suricata rule is action, header and options; sticky buffers such as
http.uri,http.user_agent,dns.queryandtls.snikeep matches precise, andflowbitsadd state across messages. - Require several independent traits, describe variable fields by shape, and
give every rule a distinctive literal as its fast pattern; never ship a
pcre-only rule. - Test every rule against a pcap of the malicious traffic and against benign
traffic, then version it with
sid,rev, metadata and its test captures. - Under TLS, detection shifts to SNI, JA4, certificates and timing, and ultimately to the endpoint; Zeek logs let you hunt the same traffic retrospectively.