Docs Look up a tool in the MCP security bundle

Look up a tool in the MCP security bundle

On this page

The security bundle answers who sent what: scanner and fraud findings, the busiest sources, one endpoint’s activity, registration problems, pass/fail expectations, and fail2ban rules.

Load only these tools with --mcp-tools security, or combine bundles as Choosing which tools load describes. Every tool on this page follows the rules every tool follows, and each section states only what its tool does differently. The MCP tool reference lists the other bundles and finds any tool by name.

ToolParametersReturns
top_talkersby, limit?, filter?, prefix_digits?The busiest IPs, user agents or dialled prefixes, ranked, each share stated against the population behind it
describe_endpointip?, user?, limit?Everything one participant did: dialogs by method and state, INVITE outcomes, REGISTER state, banners, streams, findings
diagnose_registrationcall_idWhether an endpoint registered, hit a rejection, is looping on auth, or got a short expiry
evaluate_expectationsrules?, rules_toml?, suppression_file?A pass/fail verdict per rule plus an exit code for a build. A rule whose population is empty fails rather than passing quietly
security_findingskinds?, since?, limit?Recent scanner / fraud / digest / reg_flood findings, plus the detectors this server runs
generate_fail2ban_rulefinding_idA fail2ban filter and jail derived from ONE recorded finding, with that finding attached as the evidence

Survey — what is in this capture

Start here when you have a capture and no particular suspect. These answer “what is in it”, “how much”, and “is the server even seeing traffic”.

top_talkers

Ranks the busiest participants: IPs, user agents, or dialled prefixes.

aggregate_dialogs and group_dialogs group DIALOGS, and a dialog has two ends. Asking “who is busiest” of a dialog-shaped answer forces the caller to pick one end and ignore the other, which reports a trunk’s traffic as belonging to whichever side the schema happened to name first. This counts PARTICIPANTS instead.

NameTypeLegal valuesIf omitted
bystringip, ua or prefix. Anything else fails with invalid_params naming the legal set.Required.
limitinteger?Bounded by --mcp-max-rows.The usual row default.
filterstring?Alias or DSL, the same vocabulary every other tool takes.The whole store.
prefix_digitsinteger?How many leading digits make a prefix bucket.4.

Returns:

FieldTypeDescription
bystringEcho of the dimension ranked on, so an answer is self-describing.
talkersobject[]Busiest first, bounded by limit.
distinct_talkersusizeTalkers seen across the whole match, whether or not each got a row.
total_matchedusizeDialogs the filter matched across the whole store.
truncatedbooltrue when distinct_talkers exceeds the rows returned.
capture_identityobjectWhich capture answered — see capture_status.
schema_versionu321 for this shape.

Each talkers row carries key (the address, banner or prefix), dialogs, messages, invites, answered, failed, and share_pct. messages counts what sipnab credits to the row: messages SENT for an ip, messages carrying the banner for a ua, every message of the dialog for a prefix. answered and failed are subsets of invites, so a dimension carrying no INVITE dialogs reports zero for all three. share_pct is null — never 0 — when nothing matched, because a zero there would read as a talker measured to be idle.

The example runs against tests/pcap-samples/sipp-branch-scenario.pcapng, whose 1334 REGISTER dialogs run between two addresses:

// top_talkers { "by": "ip", "limit": 3 }
{
  "schema_version": 1,
  "by": "ip",
  "talkers": [
    { "key": "192.0.2.20", "dialogs": 1334, "messages": 5082,
      "invites": 0, "answered": 0, "failed": 0, "share_pct": 100.0 },
    { "key": "192.0.2.10", "dialogs": 1334, "messages": 3907,
      "invites": 0, "answered": 0, "failed": 0, "share_pct": 100.0 }
  ],
  "distinct_talkers": 2,
  "total_matched": 1334,
  "truncated": false,
  "capture_identity": {
    "node": "capture-01",
    "instance": "eb70c18d2bf4734944323-2",
    "dialog_generation": 9015,
    "stream_generation": 0
  }
}

The shares deliberately sum above 100%. One dialog counts for every talker in it, so a two-ended call adds to two rows — here both ends took part in all 1334 dialogs and both read 100%. total_matched is the number that partitions: compare a row’s dialogs against it rather than against the other rows. by: "prefix" is the exception and does partition, because sipnab credits one bucket per dialog and a call has one dialled number.

sipnab orders rows by dialogs, then by messages, then by key, so two runs over one capture cannot disagree. key arrives fenced for by: "ua" alone, because a banner is a string a stranger typed. An address and a prefix bucket are values sipnab derived, and come back verbatim.

ip reads senders off the MESSAGES, not off the dialog’s opening addresses: a proxy that re-originates mid-dialog is invisible in the dialog record and would otherwise never appear. ua reads User-Agent from requests and Server from responses, both sender-written and therefore fenced. prefix strips a leading + before taking digits, so +15551234 and 15551234 are one destination rather than two.

Find — narrow to the calls that matter

You know something is wrong but not which call. These cut a capture down to the dialogs worth reading: by symptom, by text, by time, or by relationship to a call you already have.

describe_endpoint

Everything the capture holds about one participant.

The rest of this surface keys on Call-ID, which is the right shape for “why did this call fail” and the wrong shape for the question that comes first. An operator handed a complaint holds a phone, a trunk or a subscriber — an address or a name — and wants to know what that thing has been doing. Agents reason in entities too. Answering from a dialog-centric surface meant listing every dialog, filtering client-side, and re-deriving per-entity facts the stores already hold.

NameTypeLegal valuesIf omitted
ipstring?The endpoint’s address, v4 or v6. Mutually exclusive with user. An unparseable value fails with ip '...' is not an address.Give user instead.
userstring?The user part of a SIP URI — alice from sip:alice@example.com. Mutually exclusive with ip.Give ip instead.
limitu32?Bounds recent_dialogs, problem_call_ids and findings only. Ceiling is --mcp-max-rows (1000 by default), 0 means the default.50.

Exactly one selector, and the tool refuses both. A caller passing an address and a name means either their intersection or their union, the two give different answers, and picking one silently would answer a question nobody asked:

{ "code": -32602,
  "message": "give exactly one of ip or user, not both: the two select different sets, and whether you meant their intersection or their union changes the answer" }

The example runs against tests/pcap-samples/sip-rtp-g711.pcap:

// describe_endpoint { "ip": "10.0.2.20", "limit": 1 }
{
  "schema_version": 1,
  "endpoint_kind": "ip",
  "endpoint": "10.0.2.20",
  "dialogs": 2,
  "by_method": { "INVITE": 2 },
  "by_state": { "Completed": 1, "InCall": 1 },
  "messages_sent": 5,
  "messages_received": 5,
  "calls": {
    "invites": 2, "with_final_status": 2, "failed": 0,
    "failure_rate_pct": 0.0, "by_final_status": { "200": 2 }
  },
  "registration": {
    "applicable": false, "dialogs": 0, "succeeded": 0, "failed": 0,
    "auth_loops": 0, "problem_call_ids": []
  },
  "user_agents": [],
  "stack": {
    "branch_cookie": "z9hG4bK", "branch_shape": "hex",
    "tag_shape": "other", "callid_has_host": true,
    "confidence": 0, "requests_read": 5, "mixed": false
  },
  "streams": {
    "count": 2, "orphaned": 0, "packets": 839, "lost_packets": 0,
    "max_jitter_ms": 0.0061987502747274615,
    "codecs": ["⟦untrusted-capture-data⟧PCMA⟦/untrusted-capture-data⟧",
               "⟦untrusted-capture-data⟧PCMU⟦/untrusted-capture-data⟧"]
  },
  "findings": {
    "selectable": true, "findings": [], "total_matched": 0, "armed_kinds": [],
    "note": "No detection rule is armed on this server, so no finding could have been recorded. ..."
  },
  "recent_dialogs": [ /* one fenced dialog summary */ ],
  "truncated": true
}

contact_rewrite — was the private Contact rewritten

A phone behind NAT puts its own private address in the Contact of a REGISTER. The registrar must notice that the packet arrived from a public address the header does not name, and route later requests to where it came from. When it does, everything works. When it does not, every inbound call goes to an unroutable host and the phone never rings.

The observation is not the finding. Measured against the private corpus on 2026-09-08, 1,660 of 2,226 REGISTER contacts carry a private host — 74.6% — and that estate works. A rule firing on the observation alone would report three quarters of a healthy fleet.

FieldTypeDescription
observation.contact_hoststring?The host the Contact named, as written. Absent for Contact: *, which RFC 3261 section 10.2.2 defines as every binding and which names no host
observation.contact_host_privatebool?Whether that host is one the public internet does not route to. Absent when the Contact names a domain: a name resolves somewhere the capture cannot see, and judging it either way would be a guess
observation.source_publicboolWhether the REGISTER arrived from a public address
observation.rewrite_requiredboolThe two together — the shape that calls for a NAT rewrite
verdictstringrewritten, not-rewritten or no-later-requests
requests_to_contactusizeLater requests addressed to the private Contact host
requests_to_sourceusizeLater requests addressed to where the REGISTER came from
is_findingboolThe conjunction: the shape called for a rewrite and no rewrite happened. This is the field to alert on

Any request to the private host is a finding, not a majority of them. A proxy that sends some requests to the source and some to the header still sends some nobody receives, and 99 deliverable requests do not make the hundredth deliverable.

Silence is not success. A capture that ended before the first inbound call reports no-later-requests, which is distinct from rewritten on purpose: reading it as success is how a capture that stopped too early becomes a clean bill of health.

Corroborated against the registered AoR, not the address. A request the registrar sent to the private Contact carries no address belonging to the endpoint — that is exactly what going to the wrong host means — so nothing address-based can find it. The tie is the user part in the request URI and To, matched case-sensitively per RFC 3261 section 19.1.4.

diagnose_registration reports the observation for a single call. The corroborated finding lives here, because settling it needs what happened across the endpoint’s other dialogs.

stack — which library built these requests

A banner names a PRODUCT and one product ships more than one stack. top_talkers by=ua reports Asterisk PBX 20.15.2 as a single row at share_pct: 100.0 across 507 dialogs, and tshark finds 18 distinct source addresses behind that one label — running two different stacks, which need different configuration, different NAT handling and different re-INVITE handling. The banner cannot tell them apart. The syntax can.

FieldTypeDescription
branch_cookiestring?The Via branch’s cookie: the mandatory z9hG4bK (RFC 3261 section 8.1.1.7) plus any vendor extension before the per-transaction part. Echoed from the wire, bounded to MAX_FINGERPRINT_CHARS (32)
branch_shapestring?What follows the cookie: uuid, as-hex, hex or other
tag_shapestring?The same vocabulary, for the From tag
callid_has_hostbool?Whether the Call-ID carries an @host part
inferencestring?The library the observations point at — pjproject or chan_sip. Absent when they point nowhere
confidenceu8How many of the three observations agree with inference, out of three. 0 when sipnab inferred nothing
requests_readusizeThe denominator. A fingerprint from one request and one from four hundred are different claims
mixedboolTrue when this endpoint’s own requests disagreed. A shared address, or a proxy relaying for others, carries both populations

It names a library, never a product. z9hG4bKPj is pjproject’s, and pjproject ships inside Asterisk’s res_pjsip, Grandstream UCM and FreePBX alike — so naming a vendor from the shape would be a guess wearing the shape of a measurement. The same discipline find_correlated applies with identifier_match.

Two observations are the floor for an inference. The magic cookie alone is mandatory for every compliant sender, so it distinguishes nothing, and callid_has_host alone splits the world roughly in half. One agreeing observation infers nothing, and an unrecognized stack reports its shapes with no inference at all. sipnab does not force it into the nearest label.

Read from requests only. A response echoes the request’s branch, From tag and Call-ID verbatim, so reading one would fingerprint the party that SENT the request as though it were the party that answered — and on any dialog those are two different stacks. An endpoint that only answers reports requests_read: 0 and infers nothing.

limit bounds the page and never the counts. dialogs, by_method, by_state, calls and streams describe every match, and only recent_dialogs shortens — with truncated saying so. recent_dialogs runs newest first, because an operator chasing a complaint wants what just happened, and list_dialogs already pages the whole history oldest-first for anyone sweeping it.

An ip matches on MESSAGES, not on the dialog’s opening addresses. A dialog records the socket pair its first message arrived on, so a proxy re-originating mid-dialog, a re-INVITE from a second interface, and a BYE from the far side all belong to the call while carrying different addresses. Matching the dialog record alone would silently drop them, and the dropped ones are exactly the transfers and hand-offs an operator is looking for.

A user matches the From or To user part EXACTLY. RFC 3261 section 19.1.4 makes the user part case-sensitive, so Alice and alice name two URIs, and folding the case here would file one endpoint’s traffic under the name of a second.

Three fields answer differently for a user, and each says so rather than inventing a number:

FieldFor a userWhy
messages_sent, messages_receivedAlways 0A URI user part names a party, not a socket. Which party sent a given message does not follow from it, and a count derived from something else under this name would be worse than zero.
streamsThe streams linked to that user’s dialogsA user has no media identity of its own, so the Call-IDs are the only bridge. An ip matches the media 5-tuple directly and therefore sees orphans too.
findings.selectablefalse, with a noteThe alert engine files every finding against a source IP, so no key selects a user. Re-ask with the address.

That selectable flag matters because an empty list has three readings, not one. selectable: false means nobody could ask. An empty armed_kinds means nothing was watching, and the note says so in the same words security_findings uses. Only on a server with a detector armed does an empty list mean the endpoint stayed clean.

failure_rate_pct guards its denominator: it comes back null, not 0.0, when nothing reached a final status. A zero there hands a clean bill of health to an endpoint nobody has measured.

registration.applicable does the same job for REGISTER — false means the endpoint sent none, so the four counts under it read as “no input” rather than “no failures”. A registration counts by the REQUEST rather than by the dialog’s method, because a REGISTER can arrive inside a dialog something else opened once Call-ID reuse is in play, and a registration nobody examined reports as a healthy one. The Call-IDs in problem_call_ids go straight into diagnose_registration.

user_agents attributes a banner to whoever wrote it: User-Agent off requests the address SENT and Server off responses it sent, per RFC 3261 section 20.41 and RFC 3261 section 20.35. Reading them off received messages would file the far end’s software under this endpoint. For a user only User-Agent on requests whose From user matches counts, the one case where the URI identifies the party that wrote the header. Values arrive fenced, and so do the summaries in recent_dialogs.

Diagnose one call

You have a call-id. These explain what happened to that one call – its signaling, its media, and the specific negotiations that tend to break.

diagnose_registration

“Is this phone online?” — a different question from “why did this call fail?”.

Parameters: call_id (string, required) — a Call-ID the store holds. No optional parameters. The call need not carry a REGISTER, and the answer says so when it does not.

Read applicable first. It is false for a dialog carrying no REGISTER, and the response then holds only call_id, reason and schema_version — no hints, no registration_failure. Reporting a healthy registration for a call that never attempted one would be worse than admitting the question does not apply:

// diagnose_registration { "call_id": "1-1966@10.0.2.20" }   — an INVITE dialog
{
  "schema_version": 1,
  "call_id": "1-1966@10.0.2.20",
  "applicable": false,
  "reason": "this dialog carries no REGISTER request"
}

When it does apply, registration_failure is null for a registration that worked, and otherwise names the kind — rejected, shortened_expiry or an auth loop reported through auth_loop. evidence lists the message indexes behind the verdict, ready for get_message. The example runs against tests/pcap-samples/sip-auth-failure.pcapng:

// diagnose_registration { "call_id": "auth-fail-register-synth@203.0.113.1" }
{
  "schema_version": 1,
  "call_id": "auth-fail-register-synth@203.0.113.1",
  "applicable": true,
  "final_status_code": null,
  "auth_loop": null,
  "registration_failure": {
    "kind": "rejected",
    "code": 403,
    "evidence": [0, 3],
    "requested_expiry_sec": null,
    "granted_expiry_sec": null
  },
  "contact_rewrite": {
    "contact_host": "203.0.113.1:42952",
    "contact_host_private": false,
    "source_public": true,
    "rewrite_required": false
  },
  "hints": [
    "Call failed: 403 Forbidden.",
    "Registration rejected: 403 Forbidden. The endpoint answered an authentication challenge and the registrar refused the credentials it offered, so the fault is in the account, its password or its permission to register — none of which is a reachability problem."
  ]
}

final_status_code is null here even though the registrar answered 403, because the dialog never reached a state that records one. Read registration_failure.code for the status that decided the verdict.

contact_rewrite — the observation, not the finding

A phone behind NAT registers its own private address, and the registrar must notice the packet came from a public address the header does not name and route later requests there instead. When it does not, every inbound call goes to an unroutable host and the phone never rings.

rewrite_required is the conjunction that shape needs: contact_host_private && source_public. The sample above reports false — a public Contact from a public source needs nothing rewritten, which is the honest reading and not a fault ruled out.

FieldTypeDescription
contact_hoststring?The host the Contact named, as written. Absent for Contact: *, which RFC 3261 section 10.2.2 defines as every binding and which names no host
contact_host_privatebool?Whether that host is one the public internet does not route to. Absent when the Contact names a domain, which resolves somewhere the capture cannot see
source_publicboolWhether the REGISTER arrived from a public address
rewrite_requiredboolThe two together

This is deliberately not a finding. 1,660 of 2,226 REGISTER contacts in the private corpus carry a private host — 74.6%, measured 2026-09-08 — and that estate works. What separates the working case from the broken one is which address later requests actually used, and one dialog cannot see that. Ask describe_endpoint, which crosses an endpoint’s dialogs and reports verdict and is_finding.

The block is absent when the dialog carries no REGISTER — the same applicable: false case the top of this section describes.

Conformance and rules

Whether the traffic obeys the RFCs, and which clause it broke. These turn “the call failed” into “12.1.1 requires a Contact and there is none”.

evaluate_expectations

A pass/fail verdict on the loaded capture, and an exit code for a build.

Every other tool here makes a bad day shorter. This one prevents it, and it moves sipnab from something an operator reaches for during an incident to something that runs on every commit. The rules live in a checked-in sipnab.expect.toml beside the SBC config: that file is the UX, and this tool is how an agent reasons about it.

NameTypeLegal valuesIf omitted
rulesobject[]?The suite inline. Each rule is {metric, op, value} plus optional name, scope, min_sample and grounded_only. Mutually exclusive with rules_toml.Give rules_toml instead.
rules_tomlstring?The verbatim text of a sipnab.expect.toml. Mutually exclusive with rules.Give rules instead.
suppression_filestring?A lint suppression file in --mcp-file-root, applied to the lint_errors rules.Discovers a .sipnablint beside the capture, exactly as lint_dialog does.

Passing the file’s own bytes is the point of rules_toml: the agent then judges the capture against exactly what CI judges it against, rather than against its own transcription. Passing both, or neither, fails — two suites in one call have no defined order, and a call with no suite would report a verdict on nothing.

One rule is a metric, a comparison, a threshold, and what it applies to:

FieldLegal values
metriccount (dialogs matching the scope), asr (answered over seized, as a PERCENT from 0 to 100, the same unit group_dialogs reports), lint_errors (conformance findings at or above a severity floor), mos_p<N> (a MOS percentile, mos_p0 worst to mos_p100 best).
op>=, >, <=, <, ==, !=
valueThe threshold, in the metric’s own unit.
scopefilter:<alias-or-DSL> to narrow by dialog, or severity:<info|notice|warning|error> to set the floor a lint_errors rule counts from. Omitted means the whole capture.
min_sampleObservations below which the rule reports skipped rather than a verdict.
grounded_onlyMOS metrics only. Defaults to true.
// evaluate_expectations { "rules": [
//   { "name": "no codec rejections", "metric": "count", "op": "==", "value": 0,
//     "scope": "filter:response_code == 488" },
//   { "name": "audio floor", "metric": "mos_p10", "op": ">=", "value": 4.0 },
//   { "name": "answer rate", "metric": "asr", "op": ">=", "value": 99, "min_sample": 50 } ] }
{
  "schema_version": 1,
  "verdict": "pass",
  "exit_code": 0,
  "rules_total": 3, "evaluated": 2, "passed": 2, "failed": 0, "skipped": 1,
  "dialogs_in_capture": 2,
  "streams_in_capture": 2,
  "suppressions_applied": false,
  "results": [
    { "index": 0, "name": "no codec rejections", "metric": "count",
      "unit": "dialogs", "op": "==", "threshold": 0.0,
      "scope": "filter:response_code == 488", "observed": 0.0, "sample": 2,
      "verdict": "pass", "reason": "0 is == 0 over 2 dialog(s)" },
    { "index": 1, "name": "audio floor", "metric": "mos_p10",
      "unit": "mos (1.0-5.0)", "op": ">=", "threshold": 4.0,
      "observed": 4.358100156401542, "sample": 2, "ungrounded_excluded": 0,
      "verdict": "pass", "reason": "4.3581 is >= 4 over 2 scored stream(s)" },
    { "index": 2, "name": "answer rate", "metric": "asr",
      "unit": "ratio (0.0-1.0)", "op": ">=", "threshold": 0.99,
      "observed": 1.0, "sample": 2, "min_sample": 50,
      "verdict": "skipped",
      "reason": "skipped: 2 seizure(s) is below the declared min_sample of 50" }
  ],
  "capture_identity": { "node": "capture-01", "instance": "1d1a718cb5c33b7c52754-1",
                        "dialog_generation": 13, "stream_generation": 2 }
}

unit rides on every outcome, and reading it is not optional. asr here is a RATIO from 0.0 to 1.0, and the same name in group_dialogs is a PERCENT. A threshold of 0.99 means 99% under one reading and 1% under the other, so the answer names its unit rather than leaving it to this page.

An empty population FAILS, and the message says why. Three failure modes get a gate deleted rather than fixed, and each one has an answer here:

Failure modeWhat this tool does
Passing on data it never judgedA rule whose population is empty reports fail with reason beginning unevaluable:. A MOS threshold on a capture full of AMR-WB would otherwise score every stream off a placeholder and report green having measured nothing.
Failing on a sample too small to mean anythingmin_sample is a declared floor. Below it a rule reports skipped, so a three-call smoke test never trips an ASR threshold on a Friday afternoon.
Lying about coverageA suite where every rule skipped reports not_evaluated with exit code 2, distinct from a pass. A file of rules that never run is exactly the thing that stays in a repository claiming to check something.

Declaring min_sample of 1 or more is the ONLY way to have an empty population tolerated, and that asymmetry carries weight: a gate goes quiet only where its author wrote down that it may. min_sample: 0 declares no floor at all, so it leaves the empty case failing exactly as an absent one does.

// evaluate_expectations { "rules": [{ "metric": "asr", "op": ">=", "value": 95 }] }
// ... on a capture holding two REGISTER dialogs and no call attempt:
{ "verdict": "fail", "exit_code": 1, "evaluated": 1, "failed": 1,
  "results": [ { "index": 0, "metric": "asr", "observed": null, "sample": 0,
    "verdict": "fail",
    "reason": "unevaluable: no seizure was in scope, so 'asr >= 95' rests on nothing. Declare a min_sample of 1 or more to accept an empty population in writing; a gate does not pass on data it never judged" } ] }

The verdict tests the SAMPLE rather than whether a value came back. count always produces a value — zero matches is zero — so checking the value alone let count == 0 pass on a capture holding no dialogs at all, which is the exact “green on data it never judged” this design refuses.

exit_code is what a command line reports: 0 every rule that ran passed, 1 at least one failed, 2 nothing ran. CI has to tell a green run from a run that judged nothing, and a shell script comparing against 0 cannot.

Three more things the report says out loud. sample names how many observations each figure rests on. ungrounded_excluded appears on MOS rules and counts the streams whose codec has no published impairment value — with grounded_only off, notes says instead that placeholder scores entered the percentile. suppressions_applied says whether a lint suppression file was in force, so a lint_errors count of zero cannot pass for one taken with every rule armed.

A defect in the RULES is a hard error rather than a per-rule failure: an unknown metric, an unparseable filter, a severity: scope on a metric that reads no findings, grounded_only on a metric that reads no MOS, or a percentile outside 0 to 100 all fail the whole call with invalid_params naming the rule’s index.

A misspelled metric evaluating to “fail” would look identical to traffic that genuinely broke the threshold, and one evaluating to “pass” would be a gate that checks nothing while looking green. Half a gate reporting green is the outcome this refuses to produce.

Nothing on the CLI reaches this evaluator yet. It and exit_code both ship and both carry tests, and no flag runs them, so a checked-in sipnab.expect.toml cannot fail a build on its own today.

Security

Scanner and abuse signals derived from the capture, and the one tool that turns them into something a firewall can act on.

security_findings

Recent findings from active detection rules (scanner, fraud, digest, reg-flood, etc.). Backed by the AlertEngine’s bounded ring buffer (default 1000 entries, kept in memory only).

NameTypeLegal valuesIf omitted
kindsstring[]?Exactly four names: scanner, fraud, digest, reg_flood. Anything else fails with invalid_params naming all four — including reg-flood with a hyphen, which suggests the underscore spelling.Findings of every kind.
sincestring?RFC 3339. Keeps findings recorded strictly after it. A malformed value fails with since must be RFC 3339.The whole retained history.
limitu32?Ceiling is --mcp-max-rows (1000 by default). Higher clamps to it, 0 means the default.50 findings.

Returns — a page object, not a bare array:

FieldTypeDescription
findingsobject[]This page of { rule_name, src_ip, detail, timestamp }, newest first.
returnedusizeRows in findings.
total_matchedusizeFindings matching kinds and since across the whole retained ring buffer.
truncatedbooltrue when matches remain after this page. There is no cursor — narrow with since, or raise limit.
armed_kindsstring[]The detectors this server runs. Empty means it runs none, so findings could only ever be empty.
detection_armedboolfalse when armed_kinds is empty, stated separately so a caller can branch on one field.
notestring?Present only when no detector runs, saying so in words.
observation_gapsobject[]What an armed detector says it cannot establish from this capture: { rule_name, reason, seen, unestablished, detail }, narrowed by kinds. Always present, empty when there is nothing to say. See below.
schema_versionu321 for this shape.

Read armed_kinds before you read findings. An empty findings list means “nothing tripped” only on a server that armed something. On any other it means nothing was watching, and the two are opposite operational states. A kinds value outside the vocabulary fails rather than answering with an empty list, which would be a third way to read []. Cross-checking server_capabilities is not necessary for this question: the answer is in the response.

The page fields carry the meanings they do everywhere else on this surface — search_messages documents the same four. armed_kinds is per detector, which is what makes it worth reading rather than a bare “detection is on”: a server started with --kill-scanner alone answers “no fraud findings” for a capture full of toll fraud, and armed_kinds: ["scanner"] is what tells an agent that the question was never asked.

Arming a rule takes a flag on the server command line, such as --kill-scanner or --digest-leak. Findings then land in a bounded in-memory ring buffer (1000 entries by default, --findings-history to change it) and go nowhere else — stopping the process discards them.

timestamp records when the rule fired during analysis, not when the packet arrived. On a replayed pcap those differ by years, so a since value copied from a dialog’s created_at returns everything.

The example runs against tests/pcap-samples/sip-auth-failure.pcapng on a server started with --digest-leak:

// security_findings {}
{
  "schema_version": 1,
  "findings": [
    {
      "rule_name": "digest",
      "src_ip": "203.0.113.101",
      "detail": "WeakAlgorithm: challenge uses algorithm=MD5 (should be SHA-256+)",
      "timestamp": "2026-08-13T16:16:02.880725566+00:00"
    }
  ],
  "returned": 1,
  "total_matched": 1,
  "truncated": false,
  "armed_kinds": ["digest"],
  "detection_armed": true,
  "observation_gaps": []
}

observation_gaps is the third reading of an empty list. An armed detector can still fail to see its evidence. reg_flood counts a failure only when the registrar’s 401/407 answers a credentialed REGISTER, so a capture that holds the REGISTERs and not the answers, such as a one-way tap or a request-only capture filter, leaves it nothing to count. It then files one entry here instead of staying silent:

  • reason: "no_answers": the capture shows REGISTERs (seen) and no final response to any of them. sipnab cannot establish any outcome.
  • reason: "unanswered": some credentialed REGISTERs (unestablished of seen) drew no final response inside --reg-flood-transaction-timeout (RFC 3261 Timer F, 32 s by default), so they count as neither failures nor successes.

An entry names no source and never becomes a finding, an alert or a jail line: a REGISTER count with no outcome behind it is a volume, and reg_flood does not act on volume. A live server refreshes it every five seconds of capture time, and the end of a file input settles it.

The same tool on a server started without a detection flag answers with an empty findings list, armed_kinds: [], detection_armed: false and a note saying that nothing was watching. armed_kinds: ["digest"] above says the opposite in the same field: this server watched for digest weaknesses and for nothing else, so it answers nothing about scanners either way.

generate_fail2ban_rule

A fail2ban filter and jail derived from ONE recorded security finding.

Scoped to one finding, with that finding attached as the evidence. A rule generated from a class of findings would be a policy this tool has no standing to write. A rule generated from one, with the alert line it matches printed beside it, is something an operator can read and decide on.

NameTypeLegal valuesIf omitted
finding_idstring<rule_name>@<src_ip>@<timestamp> — the three fields security_findings already returns for every finding.Required — the call fails.

The id derives from the finding rather than coming from a counter, because an assigned id would have to survive eviction, restart and a second server reading the same capture, and none of those hold for a position in a bounded ring buffer. An agent holding “id 7” across a restart would act on a different finding.

// generate_fail2ban_rule { "finding_id": "digest@203.0.113.101@2026-08-28T11:31:03.859034298+00:00" }
{
  "schema_version": 1,
  "finding_id": "digest@203.0.113.101@2026-08-28T11:31:03.859034298+00:00",
  "evidence": {
    "rule_name": "digest",
    "src_ip": "203.0.113.101",
    "detail": "⟦untrusted-capture-data⟧WeakAlgorithm: challenge uses algorithm=MD5 (should be SHA-256+)⟦/untrusted-capture-data⟧",
    "timestamp": "2026-08-28T11:31:03.859034298+00:00"
  },
  "filter_name": "sipnab-digest",
  "filter_path": "/etc/fail2ban/filter.d/sipnab-digest.conf",
  "filter_conf": "# Generated by sipnab from finding digest@203.0.113.101@...\n[Definition]\nfailregex = ^.*\\[ALERT\\] digest src=<HOST> .*$\nignoreregex =\n",
  "jail_path": "/etc/fail2ban/jail.d/sipnab-digest.conf",
  "jail_conf": "[sipnab-digest]\nenabled  = true\nfilter   = sipnab-digest\nlogpath  = /var/log/syslog\nport     = 5060,5061\nprotocol = udp\nmaxretry = 3\nfindtime = 600\nbantime  = 3600\n",
  "log_line_format": "[ALERT] <rule_name> src=<ip> <detail>",
  "caveats": [ /* three, below */ ]
}

The failregex matches sipnab’s own alert line, the [ALERT] <rule> src=<ip> <detail> form sipnab writes. So the jail sees nothing until sipnab runs with --alert syslog, or something captures its stderr to the logpath. That is the first caveat, and it is the one that makes an otherwise-correct jail inert.

The other two caveats say what the numbers are not. maxretry, findtime and bantime are fail2ban’s conventional starting values rather than figures sipnab measured on this traffic — choose them from your own false-positive tolerance. And one finding is evidence that this detector fired, not that every future match is an attacker: the jail bans any source the detector reports, this one included. A loopback src_ip adds a fourth caveat, because a jail acting on it would ban the host from itself.

Two refusals. A server with no detector armed holds no findings at all, and says so rather than reporting an unknown id:

{ "code": -32602,
  "message": "this server holds no findings: no detection rule was armed, so nothing could have been recorded. Arm one with --kill-scanner, --fraud-detect, --digest-leak or --reg-flood and re-run the capture." }

An id the server does not hold comes back with the ids it does hold, up to the row cap, so a caller that mistyped a timestamp does not have to go back to security_findings. The scan walks the whole ring buffer for that list, because a truncated scan cannot report what it truncated — and here the truncated part is the vocabulary the caller gets offered.

detail arrives fenced: it quotes headers a sender wrote. rule_name does not, because sipnab chose it — and the tool checks that name is a bare identifier before writing it into a regular expression, since a name carrying a regular-expression operator would produce a pattern the author never wrote.