Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ This makes it well-suited for:
| **Network Monitor** | Load multiple PCAPs as ordered snapshots to track device, IP, protocol, and topology changes over time — useful for repeated audits or ongoing capture sessions |
| **Subnet Detection & Labelling** | Infer subnet structure from traffic patterns or define CIDRs manually; group observed IPs by subnet across all snapshots |
| **Node Role Annotation** | Annotate any IP or device with a role label (e.g. "SCADA Controller", "Historian"); AI-suggested from traffic signals, human-confirmable |
| **Custom Signature Rules** | YAML-based detection rules matched against IP, CIDR, port, JA3, hostname, app, and protocol fields; live-reloaded without restart |
| **Custom Signature Rules** | YAML-based detection rules matched against IP, CIDR, port, JA3, hostname, app, protocol, payload byte patterns, and regex patterns; live-reloaded without restart |
| **Export Options** | PDF report (with live topology capture), per-conversation PCAP, bulk PCAP export, and CSV export |
| **Real-time Processing** | Asynchronous analysis with detailed progress tracking |
| **Multi-protocol Support** | TCP, UDP, ICMP, and application-layer protocols including TLS, HTTP, DNS, QUIC, and L2 protocols (ARP, STP, LLDP, CDP) |
Expand Down Expand Up @@ -310,9 +310,35 @@ Rules fire when **all** specified match fields are satisfied. All fields are opt
| `app` | string | Case-insensitive nDPI application name | `"Telegram"`, `"TOR"` |
| `protocol` | string | Case-insensitive transport protocol | `"TCP"`, `"UDP"` |

### Payload inspection

Rules can also match against packet payload bytes using `payload_contains` (exact byte strings) or `payload_regex` (regular expressions). These can be combined with `match` fields — all criteria must pass.

#### `payload_contains`

```yaml
payload_contains:
- ascii: "GET /admin" # plain ASCII string
- hex: "255044462d" # hex bytes (%PDF-)
```

Multiple entries are OR-matched by default. Set `match_all: true` on the rule to require all entries to match (AND).

#### `payload_regex`

```yaml
payload_regex:
- pattern: "Authorization:\\s*Basic\\s+[A-Za-z0-9+/=]+"
case_insensitive: true # optional, default false
```

Patterns are standard Java regular expressions applied against the ASCII/UTF-8 decoded payload of each packet. The same `match_all` flag applies across `payload_regex` entries. Regex syntax errors are caught when the rule file is saved — the editor displays an inline error identifying the rule and pattern index.

---

Click **Custom Detection Rules** in the navbar to open the built-in YAML editor. Changes take effect on the next analysis run — no restart required.

A full set of demo rules covering every match field is in [`signatures.sample.yml`](signatures.sample.yml). The script [`sample-files/gen_demo.py`](sample-files/gen_demo.py) generates a PCAP that triggers all 12 rules.
A full set of demo rules covering every match field, payload byte pattern, and regex pattern is in [`signatures.sample.yml`](signatures.sample.yml). The script [`sample-files/gen_demo.py`](sample-files/gen_demo.py) generates a PCAP (`demo_all_rules.pcap`) that triggers all 21 rules.

## Sample Files

Expand Down
6 changes: 6 additions & 0 deletions backend/config/signatures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
# app - case-insensitive nDPI application name (e.g. "Telegram", "TOR", "DNS")
# protocol - case-insensitive transport protocol (e.g. "TCP", "UDP", "ICMP")
#
# Payload inspection (top-level, alongside match):
# payload_contains - list of {ascii: "..."} or {hex: "..."} byte-string patterns
# payload_regex - list of {pattern: "..."} regular expressions (applied to decoded payload)
# optional: case_insensitive: true per entry
# match_all: true - require ALL payload_contains / payload_regex entries to match (default: OR)
#
# Severity colors in the UI:
# critical -> red | high -> orange | medium -> amber | low -> purple
#
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
Expand Down Expand Up @@ -77,13 +79,48 @@ public ResponseEntity<Map<String, String>> saveSignatures(@RequestBody Map<Strin
String content = body.getOrDefault("content", "");

// Validate it's parseable YAML before writing
Map<String, Object> parsed;
try {
Yaml yaml = new Yaml(new SafeConstructor(new LoaderOptions()));
yaml.load(content);
parsed = yaml.load(content);
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid YAML: " + e.getMessage()));
}

// Validate any payload_regex patterns compile correctly
if (parsed != null) {
Object signaturesObj = parsed.get("signatures");
if (signaturesObj instanceof List) {
for (Object ruleObj : (List<?>) signaturesObj) {
if (!(ruleObj instanceof Map)) continue;
@SuppressWarnings("unchecked")
Map<String, Object> rule = (Map<String, Object>) ruleObj;
String ruleName = rule.get("name") != null ? rule.get("name").toString() : "(unnamed)";

Object regexEntriesObj = rule.get("payload_regex");
if (!(regexEntriesObj instanceof List)) continue;
List<?> regexEntries = (List<?>) regexEntriesObj;

for (int i = 0; i < regexEntries.size(); i++) {
Object entryObj = regexEntries.get(i);
if (!(entryObj instanceof Map)) continue;
@SuppressWarnings("unchecked")
Map<String, Object> entry = (Map<String, Object>) entryObj;
Object patternObj = entry.get("pattern");
if (patternObj == null) continue;
try {
Pattern.compile(patternObj.toString());
} catch (PatternSyntaxException e) {
return ResponseEntity.badRequest().body(Map.of(
"error",
"Rule \"" + ruleName + "\": invalid regex at payload_regex[" + i + "]: "
+ e.getDescription()));
}
}
}
}
}

try {
File file = new File(signaturesPath);
Files.writeString(file.toPath(), content, StandardCharsets.UTF_8);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
Expand Down Expand Up @@ -43,12 +47,21 @@
* <ul>
* <li>{@code payload_contains} — list of {@code {ascii: "..."}} or {@code {hex: "..."}} entries;
* OR-matched by default, AND-matched when {@code match_all: true} is set on the rule.
* <li>{@code payload_regex} — list of {@code {pattern: "..."}} entries (standard Java regex
* applied against the ASCII/UTF-8 decoded payload); supports optional {@code
* case_insensitive: true} per entry. Uses the same {@code match_all} flag as {@code
* payload_contains}. Payloads are capped at {@value #MAX_REGEX_PAYLOAD_BYTES} bytes per
* packet to prevent catastrophic backtracking.
* </ul>
*/
@Slf4j
@Service
public class CustomSignatureService {

static final int MAX_REGEX_PAYLOAD_BYTES = 65536;

private final Map<String, Pattern> patternCache = new ConcurrentHashMap<>();

@Value("${tracepcap.signatures.path:/app/config/signatures.yml}")
private String signaturesPath;
Comment thread
NotYuSheng marked this conversation as resolved.

Expand All @@ -73,19 +86,30 @@ public void applySignatures(List<PcapParserService.ConversationInfo> conversatio
List<Map<String, Object>> payloadContains =
(List<Map<String, Object>>) rule.get("payload_contains");

Object payloadRegexObj = rule.get("payload_regex");
@SuppressWarnings("unchecked")
List<Map<String, Object>> payloadRegex =
payloadRegexObj instanceof List ? (List<Map<String, Object>>) payloadRegexObj : null;

// A rule must specify at least one criterion
boolean hasMatch = match != null && !match.isEmpty();
boolean hasPayload = payloadContains != null && !payloadContains.isEmpty();
if (!hasMatch && !hasPayload) continue;
boolean hasRegex = payloadRegex != null && !payloadRegex.isEmpty();
if (!hasMatch && !hasPayload && !hasRegex) continue;

// All specified criteria must pass (AND between match block and payload_contains)
// All specified criteria must pass (AND between match block, payload_contains, payload_regex)
if (hasMatch && !matches(conv, match)) continue;

boolean matchAll = Boolean.TRUE.equals(rule.get("match_all"));

if (hasPayload) {
boolean matchAll = Boolean.TRUE.equals(rule.get("match_all"));
if (!payloadContainsMatch(conv.getPackets(), payloadContains, matchAll)) continue;
}

if (hasRegex) {
if (!payloadRegexMatch(conv.getPackets(), payloadRegex, matchAll)) continue;
}

if (!conv.getCustomSignatures().contains(name)) {
conv.getCustomSignatures().add(name);
matchCount++;
Expand Down Expand Up @@ -350,4 +374,80 @@ private String normalizeHex(String hex) {
if (s.startsWith("0x")) s = s.substring(2);
return s.replaceAll("[\\s:\\-]", "");
}

/**
* Returns true if at least one (OR) or all (AND when {@code matchAll=true}) of the given regex
* patterns match any packet payload in the conversation.
*
* <p>Each entry is a map with a required {@code pattern} key (standard Java regex) and an
* optional {@code case_insensitive: true} flag. Payloads are decoded from hex to ASCII/UTF-8 and
* capped at {@value #MAX_REGEX_PAYLOAD_BYTES} bytes per packet to prevent backtracking issues.
*/
private boolean payloadRegexMatch(
List<PcapParserService.PacketInfo> packets,
List<Map<String, Object>> patterns,
boolean matchAll) {
// Lazily decoded payloads — populated on first access for each packet index
String[] decoded = new String[packets.size()];
for (Map<String, Object> entry : patterns) {
Object patternObj = entry.get("pattern");
if (patternObj == null) {
if (matchAll) return false;
continue;
}
String patternStr = patternObj.toString();
if (patternStr.isBlank()) {
if (matchAll) return false;
continue;
}

final int flags = Boolean.TRUE.equals(entry.get("case_insensitive"))
? Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE
: 0;

final String cacheKey = patternStr + "::" + flags;
Pattern compiled = patternCache.computeIfAbsent(cacheKey, k -> {
try {
return Pattern.compile(patternStr, flags);
} catch (PatternSyntaxException e) {
log.warn("Invalid regex pattern '{}': {}", patternStr, e.getMessage());
return null;
}
});

if (compiled == null) {
if (matchAll) return false;
continue;
}

boolean found = false;
for (int i = 0; i < packets.size(); i++) {
String payloadHex = packets.get(i).getPayload();
if (payloadHex == null || payloadHex.isEmpty()) continue;
if (decoded[i] == null) decoded[i] = hexToAscii(payloadHex);
if (compiled.matcher(decoded[i]).find()) {
found = true;
break;
}
}

if (matchAll && !found) return false;
if (!matchAll && found) return true;
}
return matchAll;
}
Comment thread
NotYuSheng marked this conversation as resolved.

/** Decodes a hex-encoded payload to a UTF-8 string, capped at {@value #MAX_REGEX_PAYLOAD_BYTES} bytes. */
private String hexToAscii(String hex) {
if (hex == null) return "";
int hexLen = Math.min(hex.length() & ~1, MAX_REGEX_PAYLOAD_BYTES * 2);
byte[] bytes = new byte[hexLen / 2];
int out = 0;
for (int i = 0; i < hexLen; i += 2) {
int h1 = Character.digit(hex.charAt(i), 16);
int h2 = Character.digit(hex.charAt(i + 1), 16);
bytes[out++] = (h1 == -1 || h2 == -1) ? (byte) '?' : (byte) ((h1 << 4) | h2);
}
return new String(bytes, 0, out, StandardCharsets.UTF_8);
}
Comment thread
NotYuSheng marked this conversation as resolved.
}
67 changes: 58 additions & 9 deletions docs/configuration/signature-rules.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ Each rule is a YAML mapping under the top-level ``signatures`` list:
payload_contains: # optional: payload byte patterns
- ascii: <string>
- hex: <hex-string>
match_all: <bool> # optional: if true, all payload_contains entries must match
payload_regex: # optional: payload regex patterns
- pattern: <regex>
case_insensitive: <bool> # optional, default false
match_all: <bool> # optional: AND all payload_contains / payload_regex entries
device_type: <string> # optional: pin device type for matched IPs

Execution Semantics
Expand All @@ -33,11 +36,18 @@ A rule fires for a given conversation when **all** of the following hold:
2. If ``payload_contains`` is present:

- Default (``match_all`` absent or ``false``): **at least one** pattern
matches the payload.
- With ``match_all: true``: **all** patterns match the payload.
matches the payload of any packet in the conversation.
- With ``match_all: true``: **all** patterns match (each may match a
different packet).

If both ``match`` and ``payload_contains`` are specified, both conditions
must be satisfied (AND).
3. If ``payload_regex`` is present:

- Default: **at least one** pattern matches the decoded payload of any packet.
- With ``match_all: true``: **all** patterns match.

If ``payload_contains`` and ``payload_regex`` are both specified, both must
pass (AND). All three conditions — ``match``, ``payload_contains``, and
``payload_regex`` — are ANDed together.

Field Reference
---------------
Expand Down Expand Up @@ -155,12 +165,45 @@ Patterns are searched across the **raw payload bytes of each individual
packet** in the conversation (stored as hex strings). A match on any single
packet in the conversation is sufficient.

``payload_regex``
~~~~~~~~~~~~~~~~~

List of regular expression objects. Each entry requires:

- ``pattern: "<regex>"`` — a standard Java regular expression applied against
the **ASCII/UTF-8 decoded payload** of each packet.

Optional per entry:

- ``case_insensitive: true`` — enables case-insensitive (and Unicode-aware)
matching. Default ``false``.

.. code-block:: yaml

payload_regex:
- pattern: "Authorization:\\s*Basic\\s+[A-Za-z0-9+/=]+"
case_insensitive: true
- pattern: "eyJ[A-Za-z0-9_\\-]+\\.eyJ[A-Za-z0-9_\\-]+\\.[A-Za-z0-9_\\-]+"

A match on any single packet in the conversation is sufficient (OR across
packets). Use ``match_all: true`` to require all entries to match.

.. note::

Payloads are capped at 64 KB per packet before regex evaluation to prevent
catastrophic backtracking. Regex syntax errors are caught by the browser
editor on save — an inline error identifies the rule name and pattern index.

``payload_regex`` can be combined with ``payload_contains`` and/or ``match``
in the same rule; all specified conditions must be satisfied.

``match_all``
~~~~~~~~~~~~~

Boolean. Default ``false``. When ``true``, all ``payload_contains`` entries
must match (AND semantics). When ``false`` or absent, any single entry
matching is sufficient (OR semantics).
Boolean. Default ``false``. When ``true``, **all** ``payload_contains`` entries
and **all** ``payload_regex`` entries must each find a match somewhere in the
conversation's packets (AND semantics). When ``false`` or absent, any single
entry matching is sufficient (OR semantics).

``device_type``
~~~~~~~~~~~~~~~
Expand Down Expand Up @@ -190,7 +233,13 @@ container. The file is read fresh on every analysis run.
Validating Rules
----------------

The browser editor validates YAML syntax on save and highlights errors inline.
The browser editor performs two levels of validation on save:

1. **YAML syntax** — the file must be valid YAML.
2. **Regex syntax** — every ``payload_regex`` pattern is compiled and any
invalid pattern returns an inline error identifying the rule name and
pattern index (e.g. ``Rule "my_rule": invalid regex at payload_regex[0]: …``).

Semantic errors (e.g. invalid severity, malformed CIDR) are reported in the
backend logs:

Expand Down
Loading
Loading