SIP Lint Rules - NormB/sipnab GitHub Wiki

Every rule the conformance linter runs, the RFC section behind it, and how to turn it off.

Each rule carries a stable identifier such as SIP-3261-8.1.1.7-BRANCH-COOKIE. Quote that identifier in a carrier ticket, and use it to suppress the rule in CI. Identifiers never change meaning, and nothing reuses a retired one.

What makes these rules different

Most SIP linters read messages against a grammar. They catch a malformed Contact header. They cannot catch the far end sending payload type 8 when the SDP declared payload type 0, because they never see the RTP.

sipnab holds the signaling and the media in one process. The OBS- rules compare what the SDP declared against what the wire carried, and that class of defect stays invisible to any tool that reads only text.

Conformance is not the same question as outcome

src/sip/diagnosis.rs answers "why did this call fail". This linter answers "does this traffic obey the specification". A call can complete over messages that break four MUSTs, and a fully conformant call can hit a busy signal. Keep the two questions apart.

Reading a finding

A finding carries the rule identifier, a severity, a basis, the RFC number and section as separate fields, the index of the message it came from, what the capture held, what the section calls for, and why the difference matters.

The RFC number and the section are data, not prose inside a sentence. That choice caught a mistake while this module was still new: three sources place the angle-bracket rule for a Contact URI in RFC 3261 section 20.10, and the sentence actually sits in the preamble of RFC 3261 section 20, above section 20.1. A citation nothing can read is a citation nothing can check.

Severity and basis are separate axes

Severity says how much attention a finding deserves: error, warning, notice, info.

Basis says what kind of claim the rule makes, and the four values do not overlap:

Basis Meaning
must The cited section says MUST or MUST NOT, and the message disobeys it.
should The cited section says SHOULD or RECOMMENDED. Deviating stays legal.
interop No specification forbids this. Deployed equipment mishandles it anyway.
observation The declaration and the observed media disagree.

Keeping must apart from interop matters more than it looks. A reader who cannot tell a broken MUST from a vendor-compatibility hint learns to discount both, and the MUST is the one worth acting on.

Rulesets

Run a named subset instead of the whole catalog. Name it in the rulesets parameter of the MCP lint_dialog tool, or in LintConfig::with_ruleset when you drive the linter from Rust:

Name Contents
all Every rule. The default.
must Only MUST violations. Defensible in a carrier ticket without argument.
rfc MUST and SHOULD. Excludes the vendor heuristics and the media rules.
interop Only the "this breaks real equipment" heuristics.
observation Only the declaration-versus-observation rules. obs is the same name, shortened.
syntax Only the rules that read a single message with no dialog context.

Suppression

A suppression pattern is an exact rule identifier, or a prefix ending in *:

# Our carrier rewrites Contact and we have stopped arguing about it
SIP-3261-19.1.1-URI-PARAM-DEMOTED

# No media in these captures, so the observation rules have nothing to read
OBS-*

Patterns separate on commas, spaces or newlines, and # starts a comment.

Put them in a .sipnablint and check it in beside the config it belongs to. Both surfaces read the same file: the MCP lint tools take a suppression_file parameter, and the binary takes --lint-suppress-file. In the ordinary case you need not tell either one where the file is — see the discovery rule below.

Discovery honors the .sipnablint beside calls.pcap, and reports which file it applied and how many findings it silenced:

sipnab -N -I calls.pcap --lint --lint-fail-on error

An explicit list, for a CI job whose suppressions differ from the tree's:

sipnab -N -I calls.pcap --lint --lint-suppress-file ci.sipnablint

Everything, including what the project has agreed to live with:

sipnab -N -I calls.pcap --lint --lint-no-suppress

A file named with --lint-suppress-file that sipnab cannot open is a hard error rather than a full-catalog run: pointing at a suppression list states an intent, and linting with every rule on would read as "my suppressions matched nothing".

sipnab looks for one next to the capture, then climbs toward the project root — the nearest ancestor holding a .git — and stops there. A capture living outside any project, such as a corpus mount or a shared drop, picks up nothing from above itself: adopting a stranger's suppression list would switch off rules nobody on this project turned off, and the run would come back clean for a reason four directories away.

Suppression never hides itself. Every lint_dialog and validate_message response names the file it applied and counts what that file silenced, next to separate counts for the severity floor and the per-rule cap. A short finding list always says why it is short — the MCP tool reference names the exact fields.

One further guard rail keeps CI readable: a single rule reports at most 25 findings per dialog by default. A dialog retransmitting an INVITE eleven times trips a message rule eleven times, and every one of them is true, but printing all eleven buries the other rules. Change the cap with --lint-max-per-rule N, or with [limits] lint_max_per_rule in the config file.

Observation rules

These read the media against the declaration. Every one of them needs RTP or RTCP that sipnab attributed to the dialog.

Rule Severity Cites Fires when
OBS-3264-6.1-PT-UNDECLARED error RFC 3264 section 6.1 The wire carries an RTP payload type that no offer or answer in the dialog declared. Comfort noise stays exempt on payload type 13 (RFC 3389) and on the reserved payload type 19 that earlier profiles used for it, because equipment sends both without ever listing them.
OBS-4566-5.14-MEDIA-PORT-MISMATCH warning RFC 4566 section 5.14 RTP arrives at a declared media address on a port nobody advertised. The RTCP port one higher stays exempt.
OBS-3264-6.1-DIRECTION-UNMET warning RFC 3264 section 6.1 Both ends negotiated sendrecv, media flowed, and one negotiated endpoint received none of it.
OBS-4566-6-PTIME-MISMATCH notice RFC 4566 section 6 The packetization on the wire differs from a=ptime by more than half.
OBS-5761-5.1.1-RTCP-MUX-UNANSWERED error RFC 5761 section 5.1.1 An offer asked for a=rtcp-mux, the answer stayed silent, and RTCP arrived on the RTP port regardless.
OBS-3551-4.2-FRAME-SIZE-IMPOSSIBLE warning RFC 3551 section 4.2 The payload size implies more media per packet than RFC 3551 section 4.2 asks a receiver to accept, or more media than the elapsed time between packets.

One-sided thresholds

Three of these rules compare one duration against another, and every comparison runs in one direction only.

Silence suppression, comfort noise and a congested path all stretch the gap between packets. None of them compresses it. A rule that fires on "slower than declared" therefore fires on a large share of ordinary traffic, and a rule that fires on "carrying more media than time elapsed" fires only on the impossible.

The packetization measurement reads payload size rather than arrival times wherever the codec has a fixed octet rate, for the same reason: 20 ms packets stay 20 ms packets however far apart they arrive.

Codec shapes

Deriving a duration from a payload size needs a codec with a fixed octet rate. RFC 3551 Table 1 supplies five, and only those five participate:

Codec Octets per millisecond Frame
PCMU 8 sample-based
PCMA 8 sample-based
G722 8 sample-based
G729 1 10 octets per 10 ms
GSM 1.65 33 octets per 20 ms

Opus, AMR and the other variable-rate codecs have no such number, so the two size rules skip them rather than invent a threshold.

Syntactic rules

These read one message on its own.

Rule Severity Basis Cites Fires when
SIP-3261-8.1.1-MANDATORY-HEADER-MISSING error must RFC 3261 section 8.1.1 One of Call-ID, CSeq, From, To or Via is absent.
SIP-3261-20.16-CSEQ-MALFORMED error must RFC 3261 section 20.16 A CSeq arrives that nothing can read as a number and a method.
SIP-3261-20.14-CONTENT-LENGTH-MISMATCH error must RFC 3261 section 20.14 Content-Length exceeds the body that arrived.
SIP-3261-25.1-HEADER-CONTROL-BYTE error must RFC 3261 section 25.1 A header name or value holds a control byte other than tab.
SIP-3261-20-URI-BRACKETS error must RFC 3261 section 20 A Contact, From or To URI holds a comma or a question mark outside angle brackets.
SIP-3261-19.1.1-URI-PARAM-DEMOTED warning interop RFC 3261 section 19.1.1 A URI parameter — transport, user, method, ttl, maddr or lr — sits outside the angle brackets, where the receiver reads it as a header parameter.
SIP-3261-8.1.1.6-MAX-FORWARDS-MISSING warning must RFC 3261 section 8.1.1.6 A request carries no Max-Forwards.
SIP-3261-20.22-MAX-FORWARDS-RANGE notice should RFC 3261 section 20.22 Max-Forwards reads zero, exceeds the recommended 70, or holds no integer.
SIP-3261-8.1.1.7-BRANCH-COOKIE warning must RFC 3261 section 8.1.1.7 A request's top Via branch lacks the z9hG4bK magic cookie, or carries no branch at all.
SIP-3261-8.1.1.5-CSEQ-METHOD-MISMATCH error must RFC 3261 section 8.1.1.5 The CSeq method disagrees with the request line.
SIP-3261-12.1.1-CONTACT-MISSING-IN-2XX error must RFC 3261 section 12.1.1 A 2xx answer to INVITE carries no Contact, so the dialog it creates has no remote target for the ACK or the BYE.
SIP-3262-3-RELIABLE-PROVISIONAL-WITHOUT-RSEQ error must RFC 3262 section 3 A provisional demands 100rel and carries no RSeq, so the receiver has to acknowledge a response it cannot name.
SIP-4028-7.1-SESSION-EXPIRES-BELOW-MIN-SE error must RFC 4028 section 7.1 One message carries a Session-Expires smaller than the Min-SE beside it, so it asks for a refresh interval it has already declared too short.
SIP-4028-4-SESSION-EXPIRES-TOO-SMALL warning must RFC 4028 section 4 Session-Expires sits below the 90-second absolute minimum.
SIP-4028-5-MIN-SE-TOO-SMALL warning must RFC 4028 section 5 Min-SE sits below 90 seconds, wherever it appears.
SIP-4028-9-REFRESHER-MISSING warning must RFC 4028 section 9 A 2xx answer to INVITE negotiates Session-Expires and names no refresher.
SIP-7989-5-SESSION-ID-MALFORMED error must RFC 7989 section 5 A Session-ID half is not 32 characters of [0-9a-f], so it is not a sess-uuid at all. Correlation drops the half, and across a B2BUA there may be nothing well formed left to match one leg to the other.
SIP-7989-5-SESSION-ID-UPPERCASE warning must RFC 7989 section 5 A Session-ID UUID arrives in uppercase hexadecimal. sipnab compares case-insensitively and still correlates on it; any peer, SBC or log pipeline comparing the header byte for byte sees two identifiers for one session.
SIP-7989-11-SESSION-ID-LEGACY-FORM notice interop RFC 7989 section 11 A Session-ID carries no remote parameter, the obsoleted RFC 7329 single-UUID form. RFC 7989 section 5 makes remote a MUST with a section 11 exception for interworking with that older form, which one message cannot confirm — so this names the peer as an interop observation rather than asserting a violation. Correlation then works in one direction only.
SIP-3261-7.3.1-SINGULAR-HEADER-REPEATED error must RFC 3261 section 7.3.1 A header field whose value is not defined as a comma-separated list arrives on more than one row. RFC 3261 section 7.3.1 names WWW-Authenticate, Authorization, Proxy-Authenticate and Proxy-Authorization as its own exception, so the rule skips those four.
SIP-3261-16.6-RECORD-ROUTE-NOT-LOOSE error must RFC 3261 section 16.6 A Record-Route URI carries no lr parameter, so the hop it records is a strict route.
SIP-3261-8.1.1.7-VIA-BRANCH-DUPLICATE error must RFC 3261 section 8.1.1.7 Two Via header field values in one request carry the same branch. Requests only: a response copies the request's stack verbatim (RFC 3261 section 8.2.6.2).

What the corpus can and cannot vouch for

Three of these rules report zero against the local corpus, and the zeros do not all mean the same thing.

SIP-3261-12.1.1-CONTACT-MISSING-IN-2XX is well exercised: the corpus holds 1,989 2xx answers to INVITE, every one of them carrying a Contact. The rule reaches its own code path 1,989 times and declines each time, which is the strongest evidence available short of a positive hit.

The two RFC 3262 rules are not exercised. The corpus contains exactly one reliable provisional and one PRACK, so their silence rests on their unit tests rather than on real traffic. Both fire on crafted input and both stay quiet under their guards, but nobody should read their zero as a measurement. Saying so is the point: an unexercised rule and a rule with nothing to find produce the same row, and only this note tells them apart.

Session timers, and the section number that is easy to get wrong

RFC 4028 numbers its behavior sections 7 UAC, 8 Proxy, 9 UAS — and the refresher obligation belongs to the UAS, so it cites RFC 4028 section 9. Recalling it as section 8 sends a reader to the proxy's rules, which say something different about the same header field. The citation here came from the table of contents in RFC 4028 rather than from memory, for the same reason the angle-bracket rule cites the RFC 3261 section 20 preamble instead of section 20.10.

All four session-timer rules read one message on its own. Two header fields that contradict each other sit in the same request, a floor is a fixed number, and a 2xx either names a refresher or does not. None of it needs the dialog.

The refresher rule is the one with false-positive potential, so three guards fence it: the message has to be a response, its status has to be 2xx, and its CSeq method has to be INVITE.

Measured against the local corpus, where all four rules report zero. That number means nothing on its own — a rule that never fires and a rule with nothing to find produce the same row — so a probe took it apart. The corpus holds 1,849 messages carrying Session-Expires, of which 471 are 2xx answers to INVITE, and every one of those 471 names a refresher. The rule reaches its own code path 471 times and declines each time, which is silence with evidence behind it rather than a rule that cannot fire. The 358 values in the corpus that carry no refresher are all requests, where a UAC proposes a timer and RFC 4028 section 9 places no obligation. No Session-Expires anywhere in the corpus sits below 90, so both floor rules are quiet for the same checkable reason. A request offering a timer is the UAC proposing rather than answering, and RFC 4028 section 9 puts the obligation on the answer.

Why the bracket rules split in two

RFC 3261 section 20 gives one sentence for three characters, and the three do not behave alike.

A comma or a question mark in a bare URI breaks the MUST outright: the receiver splits the value there and reads a different URI than the one sent. That is SIP-3261-20-URI-BRACKETS, and it goes in the must ruleset.

A semicolon is different. Contact: sip:a@b;transport=tcp parses as perfectly legal SIP that means something the sender did not intend, because transport is a URI parameter and outside the brackets it lands on the header. Nothing on the wire breaks a MUST, so SIP-3261-19.1.1-URI-PARAM-DEMOTED reports as interop and stays out of the must ruleset. It remains the defect most often found at the bottom of a "calls reach the wrong trunk" ticket.

A bare From: sip:[email protected];tag=1928301774 trips neither, and RFC 3261 uses that form in its own examples. tag is a header parameter, so it belongs where it sits.

Dialog rules

These read a dialog's messages against each other.

Rule Severity Basis Cites Fires when
SIP-3261-8.1.1.2-TO-TAG-IN-INITIAL-REQUEST warning must RFC 3261 section 8.1.1.2 A REGISTER carries a To tag, or the dialog's first request carries one and its own transaction answers with a different tag.
SIP-3261-17.1.1.3-ACK-CSEQ-MISMATCH error must RFC 3261 section 17.1.1.3 An ACK carries a sequence number belonging to no INVITE in the dialog.
SDP-3264-6.1-ANSWER-NO-COMMON-FORMAT error must RFC 3264 section 6.1 An answer shares no media format with the offer, on a stream it did not decline.
SDP-3264-6.1-ANSWER-EXTRA-FORMAT info interop RFC 3264 section 6.1 An answer lists a format the offer never carried.
SDP-3264-6.1-ANSWER-DIRECTION-ILLEGAL error must RFC 3264 section 6.1 The answer's direction attribute contradicts what the offer's admits.
SIP-3262-4-PRACK-MISSING warning must RFC 3262 section 4 A reliable provisional went unacknowledged in a dialog whose INVITE reached a final response, so the PRACK is absent rather than merely off the end of the capture.
SDP-3264-8.4-HOLD-CONNECTION-ZERO warning should RFC 3264 section 8.4 A re-offer blanks the connection address to signal hold.
SIP-3261-17.1.1.3-ACK-BRANCH-MISMATCH error must RFC 3261 section 17.1.1.3 An ACK to a non-2xx carries a branch other than its INVITE's. A 2xx ACK is exempt and must be — see below.
SIP-3261-12.1.1-RECORD-ROUTE-NOT-COPIED error must RFC 3261 section 12.1.1 A 2xx to the dialog-forming request drops or reorders a Record-Route value the request carried.
SDP-3264-8.3.2-DYNAMIC-PT-REBOUND error must RFC 3264 section 8.3.2 A dynamic payload type (96-127) means one codec in one body and a different codec in a later body of the same m= line.
SDP-3264-7-TELEPHONE-EVENT-ONE-WAY warning interop RFC 3264 section 7 An offer declares telephone-event on an audio stream the answer accepted and shares a codec with, and the answer omits it.
SDP-3264-8.2-REJECTED-STREAM-ATTRIBUTES notice interop RFC 3264 section 8.2 A stream the answer declined with port zero still carries a=rtpmap, a=fmtp, a=crypto, a=candidate, a=ptime, a=rtcp-mux or a=rtcp.
SDP-7587-7-OPUS-RTPMAP-RATE error must RFC 7587 section 7 An a=rtpmap names opus at a clock rate other than 48000, or with a channel count other than 2.

An answer listing an extra codec stays legal

A widely repeated claim holds that an answer containing a codec absent from the offer breaks RFC 3264. It does not. RFC 3264 section 6.1 permits the extra listing in as many words, and explains why it rarely helps: the answerer cannot send with a format the offer never listed.

So the MUST violation is the absence of any shared format, which SDP-3264-6.1-ANSWER-NO-COMMON-FORMAT reports as an error. The extra listing reports separately, as info, under interop. Equipment that reads the answer as the negotiated set picks one of the extras and sends media the far end drops, which is worth knowing and is not a broken MUST.

Hold by blanking the address

sipnab has always found hold through a=sendonly and a=inactive. RFC 3264 section 8.4 describes a third mechanism that RFC 2543 defined and section 8.4 discourages: setting the connection address to 0.0.0.0. Until this rule, a call held that way looked to sipnab like a call that simply stopped.

RFC 3264 section 8.4 keeps one legitimate use — an initial offer from an agent that does not yet know its own address — so the first SDP body in a dialog stays exempt and a later one does not. A stream declined with port zero stays exempt as well.

Why the To tag rule is hard to trigger

One message cannot say whether a request sits inside a dialog. A message-scoped version of this rule fires on every re-INVITE in every capture that starts mid-call, which is most captures.

Two shapes settle it. A REGISTER never sits inside a dialog, so a To tag there is wrong wherever the capture started. Otherwise the rule needs the answer to that same transaction, matched on the Via branch that RFC 3261 section 8.1.1.7 defines, to carry a different tag — proof that the responder treated the request as new and chose its own.

An earlier form of this rule compared against every response in the dialog and fired on 2,182 dialogs of the validation corpus, 2,160 of them SUBSCRIBE. The cause was not subscriptions. A SUBSCRIBE dialog carries NOTIFY requests in the reverse direction, and a response to a NOTIFY correctly carries the subscriber's tag, which is not the tag the SUBSCRIBE addressed. Matching on the transaction took the count to zero.

What the corpus says about the nine newest rules

Measured against the local validation corpus: 62 readable captures, 39,239 dialogs, 3,371 of them carrying SDP and 272 with RTP linked to them.

Rule Dialogs Reading
SIP-3261-17.1.1.3-ACK-BRANCH-MISMATCH 129 (0.3%) Real hits, and the rate is the one that matters: a rule that read every ACK rather than only the non-2xx ones would have fired on a large share of the 39,239.
SIP-3261-12.1.1-RECORD-ROUTE-NOT-COPIED 55 (0.1%) Real hits.
SDP-7587-7-OPUS-RTPMAP-RATE 16 Real hits.
SDP-3264-8.3.2-DYNAMIC-PT-REBOUND 2 Real hits, and rare, which is what a rule about a re-INVITE changing a codec binding should be.
SIP-3261-16.6-RECORD-ROUTE-NOT-LOOSE 0 Exercised and declined. RECORD-ROUTE-NOT-COPIED fires 55 times on the same corpus, and it only reaches its own body when a request carries a bracketed Record-Route — the exact values this rule reads. So the corpus does hold recorded routes, and every one of them carries lr.
SIP-3261-7.3.1-SINGULAR-HEADER-REPEATED 0 Not exercised. Its silence rests on its unit tests, not on this traffic.
SIP-3261-8.1.1.7-VIA-BRANCH-DUPLICATE 0 Not exercised, and a corpus with no looping request is the expected shape of one.
SDP-3264-7-TELEPHONE-EVENT-ONE-WAY 0 Not exercised.
SDP-3264-8.2-REJECTED-STREAM-ATTRIBUTES 0 Not exercised.

The distinction in that last column is the point. An unexercised rule and a rule with nothing to find produce the same row, and only this note tells them apart — see the same caveat for the RFC 3262 rules above.

Why the ACK branch rule covers only non-2xx

RFC 3261 section 17.1.1.3 says an ACK "MUST contain a single Via header field, and this MUST be equal to the top Via header field of the original request". Reused verbatim, that sentence would report the correct behavior on every answered call in every capture ever taken — because the same section opens by sending the 2xx case somewhere else: "A UAC core that generates an ACK for 2xx MUST instead follow the rules described in Section 13."

An ACK to a 2xx is a new transaction. RFC 3261 section 13.2.2.4 builds it from the dialog's route set, and section 8.1.1.7 requires a new branch for it. An ACK to a non-2xx is hop-by-hop, absorbed by the same INVITE server transaction, and shares that transaction's branch — which RFC 3261 section 8.1.1.7 names as one of exactly two exceptions to branch uniqueness, alongside CANCEL.

So the rule reports only where the capture has already shown a final response between 300 and 699 for that CSeq. An ACK whose INVITE the capture never carried settles nothing, so sipnab skips it.

Why the Record-Route rule compares a suffix and not a list

RFC 3261 section 12.1.1 makes the UAS "copy all Record-Route header field values from the request into the response" and "MUST maintain the order of those values", and section 12.1.2 has the caller build its route set from the response, in reverse. A value the response dropped is a proxy removed from a path it recorded itself into. A value reordered sends every in-dialog request through the hops backwards. Both fail after the call is up, which is why they arrive as a network ticket rather than a signaling one.

The rule does not report a response carrying more values than the request. A capture taken anywhere but at the UAS sees the request before the last recording proxy inserted its value and the response after — so "the response has one extra" is the ordinary shape of a proxy-side capture, and an equality comparison would fire on most of them. The rule reports only the request's own list going missing from the tail of the response's.

The telephone-event rule is not in RFC 4733

The widely held belief is that RFC 4733 requires both ends to agree a telephone-event payload type. It does not. RFC 4733 contains no offer/answer rule at all: section 2.5.1.1 says negotiation happens "by out-of-band means, using SDP, for example" and never says what an omitted telephone-event means. This rule's author read the document end to end, and the only sender-side obligation in it is about the events parameter.

The binding text is RFC 3264 section 7: "The offerer MAY immediately cease listening for media formats that were listed in the initial offer, but not present in the answer." A MAY, so nothing here breaks — which is why the rule reports as interop and not as a MUST violation, and why it cites RFC 3264.

What makes it a one-way fault rather than simply no DTMF is that equipment sends the event anyway on the payload type it offered. The far end receives a payload type it never agreed to and either drops it or decodes it as audio, while DTMF in the other direction works — which is the whole of every "the IVR cannot hear our digits" ticket.

The rule needs the stream accepted (port non-zero) and sharing at least one audio format. A declined stream negotiated nothing, and a stream with no common format is already SDP-3264-6.1-ANSWER-NO-COMMON-FORMAT.

A declined stream that kept its attributes cites section 8.2 of RFC 3264, not section 6

RFC 3264 section 6 is where this is usually attributed, and section 6 does not say it. Its whole statement about a rejected stream is: "To reject an offered stream, the port number in the corresponding stream in the answer MUST be set to zero. Any media formats listed are ignored. At least one MUST be present, as specified by SDP." Nothing about attributes.

The attribute sentence is in RFC 3264 section 8.2: "the answer MAY omit all attributes present previously, and MAY list just a single media format." A MAY — so keeping them is legal, and the rule reports at notice under interop.

It is still worth a line, for two reasons the finding names in its observed field. An a=crypto on a declined stream is SRTP key material published for a stream that never carries a packet. And equipment that reads attributes before it reads the port allocates a relay leg and a transcoder for a stream nobody answered.

A stream the offer already removed at port zero is exempt: that is RFC 3264 section 8.2's own mechanism for tearing a stream down, and the answer marking it zero too is what section 8.2 requires.

Opus: the decidable half, and the half that is not

RFC 7587 section 7 is unambiguous: "The RTP clock rate in a=rtpmap MUST be 48000, and the number of channels MUST be 2." Every example in the RFC writes opus/48000/2, including the one titled "16000 Hz clock rate" — an endpoint signals the narrower band with maxplaybackrate in a=fmtp, never in the rtpmap. An a=rtpmap carrying no channel count is opus/48000/1 by RFC 4566 section 6's default, and RFC 7587 section 7 admits neither.

The rule cites RFC 7587 section 7 and not section 4.1. Section 4.1 states the same 48 kHz clock as a fact about the wire — "The RTP timestamp is incremented with a 48000 Hz clock rate for all modes of Opus and all sampling rates" — and it is not RFC 2119 language. The SDP bullet in RFC 7587 section 7 is the only place the requirement is a MUST.

The observation half is deliberately absent. "Opus negotiated, and the wire carries 160-octet packets at an 8 kHz cadence" is not decidable from what a stream records. 160 octets per 20 ms is 64 kbit/s, which is exactly G.711 and is also a legal Opus CBR configuration. Separating the two needs the RTP timestamp cadence, and the stream store keeps a last timestamp and no first one, so nothing in it yields a clock rate. A rule that reported legal Opus CBR as a defect would not survive week one, and this one is decidable from the SDP alone.

The four header fields that section 7.3.1 of RFC 3261 exempts

SIP-3261-7.3.1-SINGULAR-HEADER-REPEATED reads the rule the section actually states: "Multiple header field rows with the same field-name MAY be present in a message if and only if the entire field-value for that header field is defined as a comma-separated list."

The same paragraph writes down its own exception: WWW-Authenticate, Authorization, Proxy-Authenticate and Proxy-Authorization may appear on several rows, and no sender may combine them with commas. A 407 carrying two challenges is ordinary traffic, so the rule's list omits all four.

The compact forms count. The parser expands them at parse (RFC 3261 section 7.3.3), so i: beside Call-ID: is two rows of one field — which is exactly the shape a header-smuggling attempt takes, because a parser that reads only one spelling sees a message with one Call-ID.

Two branches, one stack

RFC 3261 section 8.1.1.7 makes a branch "unique across space and time", and section 16.6 item 8 spells out what that means for a proxy: "the branch parameter will be different for different instances of a spiraled or looped request through a proxy." Two identical branches in one Via stack therefore say the request returned to an element that failed to re-derive its own value — the loop RFC 3261 section 16.3's loop-detection step exists to catch, running unbounded until Max-Forwards stops it.

The rule reports a repeated value once however many times it repeats, and reads requests only.

Validating a rule against real traffic

A rule that fires on nearly every dialog is a bug in the rule, not a discovery about the traffic. tests/corpus_lint_test.rs runs the whole catalog over a directory of captures named by SIPNAB_CORPUS, prints a hit count per rule, and fails when any rule trips more than 95% of dialogs.

That test also reports how many dialogs carried media the observation rules could read. A zero hit rate on an OBS- rule means one of two very different things — the traffic is clean, or the rule saw nothing — and the rule table alone cannot tell them apart.

Using the linter from Rust

use sipnab::sip::lint::{LintConfig, Linter, ObservedMedia, Ruleset};

let config = LintConfig::new()
    .with_ruleset(Ruleset::Must)
    .suppress_list("OBS-*, SIP-3261-19.1.1-URI-PARAM-DEMOTED");
let linter = Linter::new(config);

// Signaling only.
// let findings = linter.lint_dialog(&dialog);

// Signaling against the media observed for it.
// let media = ObservedMedia::from_streams(streams.streams_for(&dialog.call_id));
// let findings = linter.lint_dialog_with_media(&dialog, &media);

ObservedMedia::from_streams projects the RTP the stream store attributed to the dialog. RTCP arrives separately through with_rtcp, because the stream store folds reception reports into the stream they describe and keeps no record of which port they landed on — which is the question RFC 5761 section 5.1.1 asks.

See Library API for the wider crate surface.