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?

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 .js and must export a DECODER_SPEC (or spec) from the module. Names starting with _ or . are skipped.

The legacy src/ui/decoders/conv/<name>.js and src/ui/decoders/main/<name>.js shim sites are **transitional and are removed as the migration lands. Both are now thin DEPRECATED SHIM re-exports of protocols/<name> and exist only to bridge call sites that pre-date the migration. New decoder wiring should require('protocols/<name>') directly, not the shim layers. See AGENTS.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

  1. Resolve the bundled decoders directory in priority order: PACKETSNITCH_DECODERS_DIR env override → process.resourcesPath/protocols/ (the actual extraResource layout shipped by electron-forge — only the basename of the source path is preserved, same convention as themes/resources/themes/ and src/ui/fragments/resources/fragments/) → process.resourcesPath/src/ui/decoders/protocols/ and process.resourcesPath/ui/decoders/protocols/ (source-tree-preserved layouts, second-chance fallback) → the asar path → the dev path (src/ui/decoders/protocols/).
  2. Walk the bundled tree recursively. The common/ subdirectory is seeded next to the user-decoder files so user-dir decoders can resolve require('./common/...') siblings.
  3. 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).
  4. 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:

  1. Backend — add src/backend/decoders/<name>.py exporting decode<Name>(rawPayload) that returns a dict the renderer can read (e.g. {"proto": "sillyproto", "field_1": ..., "field_2": ...}).
  2. Renderer spec — add src/ui/decoders/protocols/<name>.js with the DECODER_SPEC from this reference, mapping the backend dict into detailRender rows via dotField(...) 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:

  • key is lowercase, unique, and matches the file basename.
  • decode never throws — returns null on any failure.
  • detailRender is implemented (congruence rule). For pilot-style “this used to have only one half” migrations, write the missing half rather than ship a blank sidebar.
  • hints lists the ports / protocols / extensions / mime types you want auto-detection to match on.
  • hints.priority is one of 'high' / 'normal' / 'low' / 'image'.
  • outputKind matches what decode actually returns.
  • A Jest test in tests/ covers at least the decode heuristic and the detail-render no-throw contract. See tests/decoder_registry_pilots.test.js for a worked example.
  • If you opt in to the permissiveness slider, result.errors is populated before returning (even on the happy path, when warnings are present).
  • If you contribute context-menu items, the onClick handler returns void and the visible predicate is cheap (called on every context-menu open).
  • npm run test:frontend passes; the dropdown order snapshot (if any) has been refreshed.
  • src/ui/decoders/README.md is updated if you added a new helper / decoder pairing.