Modular Decoder Reference
PacketSnitch protocol decoders are modular, drop-in plugins. Each decoder is a single .js file that exports a self-describing DECODER_SPEC describing itself plus the two functions needed to participate in the Conv → Decodes sub-tab and the right-sidebar infoPanel. Drop the file into one of two locations and PacketSnitch picks it up automatically — no manifest, no rebuild, no central registry to edit. New here? See the Conv → Decodes sub-tab usage guide or the FAQ for an end-to-end walkthrough.
Table of Contents
- Why modular?
- TL;DR — Write a Decoder
- Install Locations
- The DECODER_SPEC Contract
- Output Kinds
- Auto-detect Priority Tiers
- The Decoder Permissiveness Error Contract
- Junk-Data Detection
- Auto-discovery and the Preload Bridge
- Helper / Decoder Pairing
- Context Menu Contributions
- Reserved Surfaces
- Python Backend Seam
- Reference Decoders
- Quick Sanity Checklist
Why modular?
Every protocol decoder used to live in a hand-maintained switch in src/ui/panels/data-tools-panel.js. Adding a protocol meant editing four files, regenerating the dropdown, and shipping a binary. The new registry flips that:
| Concern | Old (switch) | New (registry) |
|---|---|---|
| Add a new protocol | Edit 4 files + webpack rebuild | Drop one .js file into <userData>/decoders/ |
| Remove a protocol | Edit 4 files, risk leftover case |
Delete the file (next restart) |
| Ship your own decoder | Fork + build | Copy a .js file + restart |
| Mismatch between dropdown and sidebar | Frequent (no congruence rule) | Impossible (decode + detailRender are both required) |
| Auto-detect | Hand-coded decode<X>FromBytes wrappers |
priority tier on the spec, walked in order |
| Per-decoder tests | Hard, required shared fixtures | One Jest file per decoder, no fixtures needed |
The bundling model is the key trick: the renderer never inlines decoder files into the webpack bundle. Instead, the main process copies every bundled decoder into <userData>/decoders/ on first launch, and the preload decodersapi bridge (window.decodersapi.loadUserDecoders({})) re-loads them at startup. User drop-ins live in the same directory and follow the same contract — there is no separate “user” code path.
TL;DR — Write a Decoder
// src/ui/decoders/protocols/sillyproto.js (or in user <userData>/decoders/)
function decodeSillyprotoFromBytes(bytes) {
// ...heuristics over `bytes` (Uint8Array). Return null if not yours.
if (looksLikeSillyproto(bytes)) {
return {
protocol: "sillyproto",
fields: [
{ name: "Magic", value: bytes[0].toString(16) },
{ name: "Length", value: bytes[2] | (bytes[3] << 8) },
],
};
}
return null;
}
function renderSillyprotoTable(_protocol, transportData) {
const { createTable } = require("./shared");
createTable(
[
{ name: "Magic", value: transportData?.Magic ?? "" },
{ name: "Length", value: transportData?.Length ?? "" },
],
["Field", "Value"],
"sillyprotoInfo",
);
}
const DECODER_SPEC = {
key: "sillyproto",
label: "SillyProto (Custom)",
outputKind: "table",
hints: {
protocols: ["sillyproto", "silly-proto"],
ports: [9100, 9101],
fileExtensions: ["silly"],
mimeTypes: ["application/x-sillyproto"],
priority: "normal", // see "Auto-detect Priority Tiers" below
},
decode: decodeSillyprotoFromBytes,
detailRender: renderSillyprotoTable,
aliases: ["silly-proto"],
source: "user", // optional — "bundled" / "user" / internal
};
module.exports = {
decodeSillyprotoFromBytes,
renderSillyprotoTable,
DECODER_SPEC,
};
That’s the entire contract. Once the file is in one of the locations below and PacketSnitch restarts, your protocol shows up in the Decodes dropdown, auto-detection picks it up via the hints you declared, and the infoPanel detail table renders for matching packets.
Install Locations
| Location | When to use | Notes |
|---|---|---|
src/ui/decoders/protocols/<name>.js (in the repo) |
Shipping a built-in | The forge.config.js extraResource list now ships src/ui/decoders/protocols/ as raw files next to the asar. Requires a rebuild (npm run build:backend or npm run make) and a PR. The main process copies this file into <userData>/decoders/ on first run so it can be loaded plugin-style at runtime — see seedBundledDecodersToUserDir in src/main.js. |
<userData>/decoders/<name>.js |
Quick iteration / user drop-in | The user data directory is app.getPath("userData")/decoders/ — ~/.config/PacketSnitch/decoders/ on Linux, %APPDATA%\PacketSnitch\decoders\ on Windows. The main process seeds every bundled file into this directory on first launch (skipping any that already exist so user edits are preserved). Restart PacketSnitch after adding or removing files; the preload decodersapi bridge loads them and the renderer registers them. |
Files must end in
.jsand must export aDECODER_SPEC(orspec) from the module. Names starting with_or.are skipped.The legacy
src/ui/decoders/conv/<name>.jsandsrc/ui/decoders/main/<name>.jsshim sites are **transitional and are removed as the migration lands. Both are now thinDEPRECATED SHIMre-exports ofprotocols/<name>and exist only to bridge call sites that pre-date the migration. New decoder wiring shouldrequire('protocols/<name>')directly, not the shim layers. SeeAGENTS.md§ 7.1 for the future-consolidation plan.
Override the bundled decoders directory
For testing / packaging tweaks you can override where the main process looks for bundled decoders with the PACKETSNITCH_DECODERS_DIR environment variable:
PACKETSNITCH_DECODERS_DIR=/path/to/alternative/bundled/decoders npm start
Useful for staging a fresh-built decoder before committing it back to the repo.
The DECODER_SPEC Contract
| Field | Type | Required | Notes |
|---|---|---|---|
key |
string | yes | Lowercase, alphanumeric + -/_. The canonical lookup key. |
label |
string | yes | Human-readable name shown in the dropdown. |
decode |
(bytes: Uint8Array, ctx?) => object \| null |
yes | Tries to decode bytes. Returns null if the payload isn’t yours; otherwise an object the rest of the renderer can consume (e.g. { protocol, fields, treeData, imageDataUrl }). Never throw — return null on any failure. The optional second ctx argument is reserved for future context hints (transport / endpoint / stream) and is currently always undefined. |
detailRender |
(protocol, transportData) => void |
yes | Renders the infoPanel sidebar table (or tree/text/image) for a matched packet. Pull helpers from ./shared (dotField, createTable). |
outputKind |
'table' \| 'tree' \| 'text' \| 'image' |
no (default 'table') |
UI hint for which renderer template to use. |
hints |
{ protocols?, ports?, fileExtensions?, mimeTypes?, priority? } |
no | Drives auto-detection. Strings are lowercased + trimmed. Ports must be finite integers between 1 and 65535. priority must be one of 'high' \| 'normal' \| 'low' \| 'image' (default 'normal'). |
aliases |
string[] |
no | Extra lookup keys that resolve to this spec (e.g. 'bonjour' aliases 'mdns'). |
source |
'bundled' \| 'user' |
internal | Set by the loader, not the author. |
actions |
array | reserved | Reserved for future toolbar actions (copy row, follow stream, etc.). Leave as [] for now. |
modifiers |
array | reserved | Reserved for future per-decoder view modifiers. Leave as [] for now. |
contextMenu |
(ctxHelpers) => Array \| { items: Array } |
optional | If implemented, contributes right-click context-menu items. See Context Menu Contributions. |
The validateDecoderSpec function in
registry.js
is the source of truth for these rules. Invalid specs are logged and
skipped — they never crash startup.
The congruence rule
Every spec must provide both decode and detailRender. The
auto-detect.js cascade and the infoPanel loop are now registry-driven:
half a decoder (e.g. only a conv decode, no detail renderer) will show up in
the dropdown but produce a blank sidebar. If you migrate a legacy
src/ui/decoders/main/<name>.js or src/ui/decoders/conv/<name>.js into a
spec, write the missing half — never ship half a decoder. The pilot
iso8583 migration in protocols/iso8583.js is the reference example: the
detail renderer was newly written to fill a hole that existed for years.
Output Kinds
outputKind |
What the renderer expects | Notes |
|---|---|---|
'table' |
decode returns { protocol, fields: [{ name, value }] }. detailRender writes rows via createTable. |
Default. Most existing decoders. |
'tree' |
decode returns { protocol, treeData }. detailRender renders a recursive node tree. |
Used by hierarchical formats (BER/DER, JSON, XML). |
'text' |
decode returns { protocol, text } or { fields }. detailRender writes a preformatted block. |
Plain-text protocols. |
'image' |
decode returns { protocol, imageDataUrl }. detailRender sets src on an <img>. |
Carved image payloads. |
Auto-detect Priority Tiers
Auto-detect walks every registered spec in priority order. The tier is consumed by src/ui/decoders/conv/auto-detect.js:
| Tier | Meaning | Examples |
|---|---|---|
'high' |
Strong structural signature. Runs in the early pass (link-layer frames, magic-byte ICS, image formats, protocols with very tight first-byte gates). | jpeg, png, gif, webp, iso8583, kerberos, dhcp, arp |
'normal' |
Standard decoders. Run in the mid pass after high-confidence gating. | http, ssh, smtp, dns, bittorrent, … |
'low' |
Permissive wire format. Auto-detect holds these back so they only return a match when a metadata hint corroborates it OR when no other decoder matched. | msgpack, protobuf, ber, der, yaml |
'image' |
Image-format decoder. Mirrors 'high' but participates in the image-magic early check AND the ExifReader fallback. |
jpeg, png, gif, webp |
Unknown / missing values fall back to 'normal'. getSpecsByPriority() on the registry returns the four groups in registration order so callers don’t need to hand-code which decoder belongs where.
Hint shape
hints: {
protocols: ["http"], // app-proto names that select this
ports: [80, 8080], // transport ports that select this
fileExtensions: ["pcap"], // filename extensions that select
mimeTypes: ["text/html"], // MIME types that select
priority: "normal", // tier — see the table above
}
All four list fields are optional and can be combined. decode always runs last; hints only feed selection (which decoder is consulted, and when in the auto-detect chain).
The Decoder Permissiveness Error Contract
The Decodes subtab is wired to a user-controlled slider (dataTools.decoderPermissiveness, default 50) that decides how strictly partial decoder results are accepted. The slider is opt-in: a decoder that publishes result.errors: string[] (or result.errorCount: number) opts in to the budget check. Decoders that don’t yet publish either field are unaffected by the slider.
The budget is computed by applyDecoderPermissiveness(result, sliderValue) in
src/ui/decoders/shared/perm.js:
budget(p) = floor(MAX_ERRORS_AT_ZERO_AT_ZERO * (1 - p / 100))
| Slider | Budget | Behaviour |
|---|---|---|
0 (Strict) |
0 |
Any reported error fails the result (returned null). |
50 (default) |
5 |
Up to 5 parse warnings tolerated; 6+ drop the result. |
100 (Loose) |
Number.POSITIVE_INFINITY |
Every non-null result is accepted. |
MAX_ERRORS_AT_ZERO_AT_ZERO is exported from perm.js so tests can compute boundary fixtures without hard-coding a magic number; the default of 10 was chosen so real parse warnings (length-prefix off, trailing junk, …) don’t trip the gate at the midpoint while still letting strict users reject heavily-warned decodes.
How to opt in
Return an errors array (or errorCount shortcut) on your result object:
function decodeFooFromBytes(bytes) {
try {
const out = parseHeader(bytes);
if (out.lengthPrefixMismatch) {
return {
protocol: "foo",
fields: out.fields,
// opt into the slider:
errors: ["length prefix was off by 2"],
};
}
return { protocol: "foo", fields: out.fields };
} catch (error) {
return {
protocol: "foo",
fields: [],
errors: [error.message || "parse failed"],
};
}
}
Both the per-packet runProtoDecoder path and the multi-packet stacked runProtoDecoderForStreamPackets path apply the filter, so the slider behaves consistently whether you’re decoding a single selected packet or following an entire stream.
Junk-Data Detection
Underneath the protocol dropdown the subtab always shows a payload-status badge (#data-tools-proto-junk-badge) that flips between a healthy green default and an amber .is-warning state based on three tripwires defined in the same perm.js module:
| Tripwire | Threshold | Reason text |
|---|---|---|
Entropy ≥ 7.5 bits/byte |
JUNK_HIGH_ENTROPY_BITS_PER_BYTE = 7.5 |
“High entropy — looks encrypted or compressed” |
NUL ratio ≥ 0.50 |
JUNK_HIGH_NUL_RATIO = 0.50 |
“Heavy null padding — fragmented or unfilled payload” |
Printable ratio ≤ 0.10 and ≥ 64 bytes |
JUNK_LOW_PRINTABLE_RATIO = 0.10 |
“Mostly non-printable bytes — likely not text” |
Empty / sub-16-byte payloads are not flagged (we’d be guessing). The first matching tripwire wins so the reason text reads consistently.
The badge also surfaces a decoder mismatch verdict (computeDecoderMismatchVerdict) when the selected protocol is rejected by the active context (transport / decoded-protocol stack from the right-sidebar hints), so the analyst gets one coherent story: payload looks healthy AND protocol matches, or payload is junk / the decoder was the wrong choice.
Junk helpers — public API
src/ui/decoders/shared/perm.js exports the following pure helpers for tests and other renderer code:
| Function | Returns |
|---|---|
applyDecoderPermissiveness(result, slider) |
result if within budget, otherwise null |
errorBudgetForPermissiveness(slider) |
Computed budget for the slider value |
clampPermissiveness(slider) |
[0..100] clamped value (defaults to 50) |
countDecoderErrors(result) |
result.errors.length or result.errorCount |
shannonEntropyBytes(bytes) |
Bits/byte, 0..8 |
printableRatioOfBytes(bytes) |
Fraction of printable + whitespace bytes |
nulRatioOfBytes(bytes) |
Fraction of NUL bytes |
detectJunk(bytes) |
{ isJunk, reason, entropy, printableRatio, nulRatio } |
All helpers are pure (no DOM, no globals) so they load identically in the renderer bundle and in Jest without stub setup.
Auto-discovery and the Preload Bridge
The renderer never touches fs itself — the decodersapi bridge in src/preload.js wraps the filesystem walk:
// Renderer side:
const payload = await window.decodersapi.loadUserDecoders({});
// payload = { success, userDecodersDir, specs: [{ file, spec }], failed: [{ file, error }] }
for (const { spec } of payload.specs) {
registry.registerDecoder(spec);
}
The main process seeds every bundled decoder into <userData>/decoders/ on first run, so the bridge has something to scan even before any user edits. The bridge reuses the scan/validate logic from userdir-loader-core.js, which is shared with Jest so the test path and the production path are byte-for-byte identical.
Seed process on first launch
- Resolve the bundled decoders directory in priority order:
PACKETSNITCH_DECODERS_DIRenv override →process.resourcesPath/protocols/(the actualextraResourcelayout shipped by electron-forge — only the basename of the source path is preserved, same convention asthemes/→resources/themes/andsrc/ui/fragments/→resources/fragments/) →process.resourcesPath/src/ui/decoders/protocols/andprocess.resourcesPath/ui/decoders/protocols/(source-tree-preserved layouts, second-chance fallback) → the asar path → the dev path (src/ui/decoders/protocols/). - Walk the bundled tree recursively. The
common/subdirectory is seeded next to the user-decoder files so user-dir decoders can resolverequire('./common/...')siblings. - Copy each file into
<userData>/decoders/, skipping any that already exist (user edits win) and skipping symlinks (so a malicious bundled dir cannot write outside the user decoders directory). - Log
[Decoders] Seeded N bundled decoder file(s) to <userDecodersDir>on completion; any read errors are surfaced in the same line.
The renderer never sees the seeder — it only loads whatever is in <userData>/decoders/ at startup.
Webpack / Node split
protocols/loader.js historically had two modes:
| Mode | Host | Behaviour |
|---|---|---|
Webpack (require.context('./', false, /\.js$/)) |
Renderer bundle | Build-time inclusion (removed — see below) |
Node (fs.readdirSync + require) |
Jest, preload bridge | Runtime walk via Node fs |
The webpack path has been removed from production because it forced decoder files into the renderer bundle, so deleting one would break npm start. The preload bridge is now the single production discovery path. Jest still exercises the Node path so the registry is unit-testable without Electron.
Helper / Decoder Pairing
Decoders can pull shared helpers from protocols/common/<name>.js to avoid duplicating bytesToHex / ASN.1 / DNS-name / image-decode / XML-tree / YAML parsing logic. Those helpers are not auto-discovered — they are loaded unconditionally by protocols/index.js so the renderer facade re-exports them — but a decoder file that require()s a helper will fail to load if its helper sibling is missing.
Because the user-dir seeder copies every .js under protocols/ into <userData>/decoders/ (including the common/ subdir), the pair is always shipped together. If you remove a decoder, do NOT remove its helper, and vice versa, unless you have audited every other decoder that depends on it.
The current pairings are:
Helper (protocols/common/<name>.js) |
Required by |
|---|---|
asn1.js |
ber.js, der.js, kerberos.js, ldap.js, snmp.js |
dns-helpers.js |
dns.js, llmnr.js |
dns-wire.js |
llmnr.js, mdns.js |
exif-helpers.js |
none directly (consumed by the image-helpers + jpeg.js ExifReader fallback) |
image-helpers.js |
gif.js, jpeg.js, png.js, webp.js |
smb-helpers.js |
bittorrent.js, dhcp.js, dhcpv6.js, dns.js, epmap.js, kerberos.js, ldap.js, llmnr.js, nbdgm.js, nbns.js, radius.js, smb.js, snmp.js, stp.js |
xml-tree.js |
html.js, xml.js |
yaml-parser.js |
yaml.js |
Every decoder and helper file ships a paired note at the top of the file declaring its counterpart (e.g. // Pair: requires common/smb-helpers). When you add a new decoder that pulls a helper, add the same note to the helper so the pairing is visible from both ends.
Context Menu Contributions
A decoder can contribute right-click context-menu items (the Convert menu in the Host Data view) by exporting a contextMenu(ctxHelpers) function. Each call returns either an array of items or { items: [...] }:
const DECODER_SPEC = {
key: "myproto",
// ...
contextMenu(ctxHelpers) {
return [
{
id: "myproto-follow-stream",
label: "Follow MyProto stream",
visible: (ctx) => ctx.protocol === "myproto",
onClick: (ctx) => ctx.helpers.followStream(ctx.streamKey),
// ownerKey is filled in automatically by the registry
},
];
},
};
Item shape:
| Field | Type | Notes |
|---|---|---|
id |
string | DOM id of the existing menu button (renderer-side wiring required). |
label |
string | Human-readable label (logs / docs). |
visible? |
(ctx) => boolean |
Optional visibility predicate. |
onClick? |
(ctx) => void |
Optional click handler. |
requires? |
string[] | Other decoder keys that must also be registered. |
ownerKey |
string | Filled in by the registry (spec.key of the contributor). |
Failures in a single spec never crash the walk — one broken drop-in must never take down the right-click menu. Adding/removing a decoder automatically adds/removes its menu contributions with no central switch to edit.
Reserved Surfaces
actions— future per-decoder toolbar actions.modifiers— future per-decoder view modifiers.
Today these are arrays that the renderer ignores. When we add actions / modifiers they’ll be addressed by spec.key, so it’s safe to leave them empty.
Python Backend Seam
Modular decoder specs only cover the renderer side. The backend still parses PCAPs through src/backend/decoders/<name>.py modules, each exporting decode<Name>(rawPayload). Adding a new protocol typically requires both sides:
- Backend — add
src/backend/decoders/<name>.pyexportingdecode<Name>(rawPayload)that returns a dict the renderer can read (e.g.{"proto": "sillyproto", "field_1": ..., "field_2": ...}). - Renderer spec — add
src/ui/decoders/protocols/<name>.jswith theDECODER_SPECfrom this reference, mapping the backend dict intodetailRenderrows viadotField(...)from./shared.
If you only have renderer-side heuristics (e.g. you decode a custom carved-file format that no PCAP ever contains), the backend module is optional — the spec’s decode function will still match when the byte heuristics fire.
Reference Decoders
| Decoder | Spec file | Notes |
|---|---|---|
| MNDP | protocols/mndp.js |
Simple UDP port-5678 heuristic + detail table. |
| HSRP | protocols/hsrp.js |
Multicast first-hop redundancy protocol. |
| ISO 8583 | protocols/iso8583.js |
Reference for the congruence rule — the detail renderer was newly written to fill the missing half of the legacy iso8583 decoder. |
| Permissiveness / junk helpers | shared/perm.js |
Slider filter, junk detection, and entropy helpers. |
| Preload bridge | src/preload.js#decodersapi |
The renderer-facing loadUserDecoders IPC handler. |
Use these as templates. Every spec ships the decode + detailRender pair, declares hints, and follows the same module.exports shape.
Quick Sanity Checklist
Before merging a new spec:
keyis lowercase, unique, and matches the file basename.decodenever throws — returnsnullon any failure.detailRenderis implemented (congruence rule). For pilot-style “this used to have only one half” migrations, write the missing half rather than ship a blank sidebar.hintslists the ports / protocols / extensions / mime types you want auto-detection to match on.hints.priorityis one of'high'/'normal'/'low'/'image'.outputKindmatches whatdecodeactually returns.- A Jest test in
tests/covers at least the decode heuristic and the detail-render no-throw contract. Seetests/decoder_registry_pilots.test.jsfor a worked example. - If you opt in to the permissiveness slider,
result.errorsis populated before returning (even on the happy path, when warnings are present). - If you contribute context-menu items, the
onClickhandler returns void and thevisiblepredicate is cheap (called on every context-menu open). npm run test:frontendpasses; the dropdown order snapshot (if any) has been refreshed.src/ui/decoders/README.mdis updated if you added a new helper / decoder pairing.
