fix(pcap): attribute DNS addresses by record owner

This commit is contained in:
morluto committed 2026-10-27 12:42:51 +08:00
1 parent e152349c5d
commit 25375924f7
8 files changed
+429 -21

No files matched your search

+7 -3
View File
@@ -81,9 +81,13 @@ Its separate inferred truncation lengths do not replace that limit; an unset
file-header limit remains `null`.
Addresses count once per packet, including loopback and nested IP occurrences.
DNS addresses are associated only with one question in the same response packet;
additional records may be included, so this does not prove final resolution.
Ambiguous addresses remain in a separate inventory. Protocol distribution counts
DNS address attribution requires one question in one response and complete,
consistent RR owner/type and section counts. Only answer-section A/AAAA records
matching that question (or its unambiguous answer-section CNAME chain) are
associated, with the requested address type respected. Authority, additional,
unrelated, malformed, and ambiguous addresses remain in the unattributed
inventory and make coverage partial. These packet-local relationships do not
prove final resolution or destination authenticity. Protocol distribution counts
exact nonempty `frame.protocols` stack strings. Frames for which TShark reports
an empty stack, including zero-length packets, still contribute to the packet
count without adding a protocol row. An all-empty stack inventory can have
+4 -2
View File
@@ -348,10 +348,12 @@ describe("parseTsharkJsonOutput", () => {
expect(result.dnsQueries).toEqual([
{
query: "example.com",
response_addresses: ["93.184.216.34"],
response_address_count: 1,
response_addresses: [],
response_address_count: 0,
},
]);
expect(result.unattributedDnsAddresses).toEqual(["93.184.216.34"]);
expect(result.partial).toBe(true);
expect(result.tlsSniValues).toEqual(["example.com"]);
expect(result.httpRequestUris).toEqual(["http://example.com/page"]);
expect(result.protocolDistribution).toEqual(
+134
View File
@@ -0,0 +1,134 @@
type Fields = (name: string) => readonly string[];
interface Attribution {
readonly incomplete: boolean;
readonly assigned: readonly (readonly [string, string])[];
readonly unattributed: readonly string[];
}
const dnsType = (value: string | undefined): number | null => {
if (value === undefined || !/^(?:[0-9]+|0x[0-9a-f]+)$/iu.test(value))
return null;
const type = Number(value);
return Number.isInteger(type) && type <= 65535 ? type : null;
};
// DNS compares ASCII letters without changing retained producer spellings.
const nameKey = (name: string): string =>
name.replace(/[A-Z]/gu, (letter) => letter.toLowerCase());
/**
* TShark visits answer, authority, then additional RRs, emitting one
* dns.resp.name/type per RR (including OPT), and type-specific value queues.
* NSEC/NXT bitmaps may add type fields; the cardinality check declines those
* ambiguous flat inventories instead of reconstructing missing hierarchy.
* Validate that correspondence before joining any owner to an address.
*/
export const attributeDnsAddresses = (field: Fields): Attribution => {
const ipv4 = field("dns.a");
const ipv6 = field("dns.aaaa");
const addresses = [...ipv4, ...ipv6].filter((value) => value !== "");
const unknown = (): Attribution => ({
assigned: [],
unattributed: addresses,
incomplete:
addresses.length > 0 ||
field("dns.flags.response").some(
(value) => value !== "0" && value !== "False",
) ||
field("dns.resp.name").length > 0 ||
field("dns.resp.type").length > 0 ||
["dns.count.answers", "dns.count.auth_rr", "dns.count.add_rr"].some(
(key) => field(key).some((value) => value !== "0"),
),
});
const count = (key: string): number | null => {
const values = field(key);
if (values.length !== 1 || !/^\d+$/u.test(values[0]!)) return null;
const value = Number(values[0]);
return Number.isInteger(value) && value <= 65535 ? value : null;
};
const response = field("dns.flags.response");
const questions = field("dns.qry.name");
const questionTypes = field("dns.qry.type");
const answers = count("dns.count.answers");
const authority = count("dns.count.auth_rr");
const additional = count("dns.count.add_rr");
if (answers === null || authority === null || additional === null)
return unknown();
const owners = field("dns.resp.name");
const types = field("dns.resp.type").map(dnsType);
const aliases = field("dns.cname");
if (
owners.length !== answers + authority + additional ||
types.length !== owners.length ||
types.some((value) => value === null) ||
types.filter((type) => type === 1).length !== ipv4.length ||
types.filter((type) => type === 28).length !== ipv6.length ||
types.filter((type) => type === 5).length !== aliases.length ||
[...ipv4, ...ipv6, ...aliases].some((value) => value === "")
)
return unknown();
if (addresses.length === 0)
return { assigned: [], unattributed: [], incomplete: false };
if (
response.length !== 1 ||
!["1", "True"].includes(response[0]!) ||
count("dns.count.queries") !== 1 ||
questions.length !== 1 ||
questions[0] === "" ||
questionTypes.length !== 1 ||
![1, 28, 255].includes(dnsType(questionTypes[0]) ?? -1)
)
return unknown();
let a = 0,
aaaa = 0,
cname = 0;
const records = types.map((type, index) => ({
owner: nameKey(owners[index]!),
type,
answer: index < answers,
value:
type === 1
? ipv4[a++]
: type === 28
? ipv6[aaaa++]
: type === 5
? aliases[cname++]
: undefined,
}));
const reachable = new Set([nameKey(questions[0]!)]);
const links = new Map<string, Set<string>>();
for (const record of records)
if (record.answer && record.type === 5) {
if (record.owner === "") return unknown();
const targets = links.get(record.owner) ?? new Set<string>();
targets.add(nameKey(record.value!));
links.set(record.owner, targets);
}
// A conflicting CNAME does not establish which target the question resolves.
let current = nameKey(questions[0]!);
while (links.has(current)) {
const targets = links.get(current)!;
if (targets.size !== 1) return unknown();
const next = targets.values().next().value!;
if (reachable.has(next)) return unknown();
reachable.add(next);
current = next;
}
const assigned: (readonly [string, string])[] = [];
const unattributed: string[] = [];
for (const record of records) {
if (record.type !== 1 && record.type !== 28) continue;
if (
record.answer &&
record.owner !== "" &&
reachable.has(record.owner) &&
(dnsType(questionTypes[0]) === 255 ||
dnsType(questionTypes[0]) === record.type) &&
!links.has(record.owner)
)
assigned.push([questions[0]!, record.value!]);
else unattributed.push(record.value!);
}
return { assigned, unattributed, incomplete: unattributed.length > 0 };
};
+250
View File
@@ -16,6 +16,228 @@ const capture = (layers: ReadonlyArray<Record<string, string[]>>) =>
})),
);
// Representative selected-field output for one A reply with a CNAME answer
// and unrelated additional glue. This is not a recorded real-provider run.
const cnameAndGlue = {
"dns.flags.response": ["1"],
"dns.qry.name": ["www.example"],
"dns.qry.type": ["1"],
"dns.count.queries": ["1"],
"dns.count.answers": ["2"],
"dns.count.auth_rr": ["0"],
"dns.count.add_rr": ["1"],
"dns.resp.name": ["www.example", "edge.example", "ns.example"],
"dns.resp.type": ["5", "1", "1"],
"dns.cname": ["edge.example"],
"dns.a": ["192.0.2.10", "192.0.2.53"],
};
it("reports unknown RR coverage when a DNS response omits all count and address fields", async () => {
const result = await parseTsharkJsonOutput(
capture([
{
"dns.flags.response": ["1"],
"dns.qry.name": ["www.example"],
},
]),
);
expect(result.dnsQueries[0]?.response_addresses).toEqual([]);
expect(result.unattributedDnsAddresses).toEqual([]);
expect(result.partial).toBe(true);
});
it("keeps a query with an additional OPT record complete", async () => {
const result = await parseTsharkJsonOutput(
capture([
{
...cnameAndGlue,
"dns.flags.response": ["0"],
"dns.count.answers": ["0"],
"dns.resp.name": [""],
"dns.resp.type": ["41"],
"dns.cname": [],
"dns.a": [],
},
]),
);
expect(result.partial).toBe(false);
});
it("preserves a CNAME answer while separating additional glue", async () => {
const result = await parseTsharkJsonOutput(capture([cnameAndGlue]));
expect(result.dnsQueries).toEqual([
{
query: "www.example",
response_addresses: ["192.0.2.10"],
response_address_count: 1,
},
]);
expect(result.unattributedDnsAddresses).toEqual(["192.0.2.53"]);
expect(result.unattributedDnsPackets).toBe(1);
expect(result.partial).toBe(true);
});
it("accepts hexadecimal RR type codes without changing recorded identities", async () => {
const result = await parseTsharkJsonOutput(
capture([
{
...cnameAndGlue,
"dns.qry.type": ["0x0001"],
"dns.resp.type": ["0x0005", "0x0001", "0x0001"],
},
]),
);
expect(result.dnsQueries[0]?.response_addresses).toEqual(["192.0.2.10"]);
expect(result.unattributedDnsAddresses).toEqual(["192.0.2.53"]);
});
it("retains direct answers with a root-owned OPT and compares DNS names without rewriting them", async () => {
const result = await parseTsharkJsonOutput(
capture([
{
...cnameAndGlue,
"dns.qry.name": ["WWW.Example"],
"dns.count.answers": ["1"],
"dns.resp.name": ["www.example", ""],
"dns.resp.type": ["1", "41"],
"dns.cname": [],
"dns.a": ["192.0.2.10"],
},
]),
);
expect(result.dnsQueries).toEqual([
{
query: "WWW.Example",
response_addresses: ["192.0.2.10"],
response_address_count: 1,
},
]);
expect(result.partial).toBe(false);
});
it("joins mixed answer types using their own queues and preserves multiple valid answers", async () => {
const result = await parseTsharkJsonOutput(
capture([
{
...cnameAndGlue,
"dns.qry.type": ["255"],
"dns.count.answers": ["5"],
"dns.count.add_rr": ["0"],
"dns.resp.name": [
"www.example",
"edge.example",
"edge.example",
"edge.example",
"edge.example",
],
"dns.resp.type": ["5", "28", "1", "28", "1"],
"dns.a": ["192.0.2.10", "192.0.2.11"],
"dns.aaaa": ["2001:db8::10", "2001:db8::11"],
},
]),
);
expect(result.dnsQueries[0]?.response_addresses).toEqual([
"2001:db8::10",
"192.0.2.10",
"2001:db8::11",
"192.0.2.11",
]);
expect(result.partial).toBe(false);
});
it.each([
{ "dns.resp.name": ["www.example", "unrelated.example", "ns.example"] },
{ "dns.count.answers": ["1"], "dns.count.auth_rr": ["1"] },
{ "dns.qry.type": ["28"] },
{ "dns.flags.response": ["1", "1"] },
{ "dns.count.answers": ["garbage"] },
{ "dns.count.answers": ["4"] },
{ "dns.count.answers": [] },
{ "dns.resp.name": ["www.example"] },
{ "dns.resp.type": ["5", "28", "1"] },
{ "dns.cname": [] },
{ "dns.cname": ["www.example"] },
{ "dns.count.queries": ["2"] },
// NSEC/NXT type bitmaps can contribute additional dns.resp.type fields.
{ "dns.resp.type": ["5", "1", "47", "1", "28"] },
] as Record<string, string[]>[])(
"retains all addresses when ownership is unsupported or inconsistent: %j",
async (override) => {
const result = await parseTsharkJsonOutput(
capture([{ ...cnameAndGlue, ...override }]),
);
expect(result.dnsQueries[0]?.response_addresses).toEqual([]);
expect(result.unattributedDnsAddresses).toEqual([
"192.0.2.10",
"192.0.2.53",
]);
expect(result.partial).toBe(true);
},
);
it("does not follow conflicting CNAME declarations", async () => {
const result = await parseTsharkJsonOutput(
capture([
{
...cnameAndGlue,
"dns.count.answers": ["4"],
"dns.count.add_rr": ["0"],
"dns.resp.name": [
"www.example",
"www.example",
"edge.example",
"other.example",
],
"dns.resp.type": ["5", "5", "1", "1"],
"dns.cname": ["edge.example", "other.example"],
},
]),
);
expect(result.dnsQueries[0]?.response_addresses).toEqual([]);
expect(result.unattributedDnsAddresses).toEqual(["192.0.2.10", "192.0.2.53"]);
});
it.each([
{ "dns.a": [] },
{ "dns.a": [], "dns.resp.name": [], "dns.resp.type": [] },
])(
"marks missing address/owner decoding partial even when no addresses were extracted: %j",
async (override) => {
const result = await parseTsharkJsonOutput(
capture([{ ...cnameAndGlue, ...override }]),
);
expect(result.dnsQueries[0]?.response_addresses).toEqual([]);
expect(result.unattributedDnsAddresses).toEqual([]);
expect(result.partial).toBe(true);
},
);
it.each([
{ "dns.count.answers": ["0"], "dns.resp.name": [], "dns.resp.type": [] },
{
"dns.count.answers": ["1"],
"dns.resp.name": ["www.example"],
"dns.resp.type": ["2"],
},
])(
"keeps valid empty or non-address answers complete: %j",
async (override) => {
const result = await parseTsharkJsonOutput(
capture([
{
...cnameAndGlue,
"dns.count.add_rr": ["0"],
"dns.cname": [],
"dns.a": [],
...override,
},
]),
);
expect(result.dnsQueries[0]?.response_addresses).toEqual([]);
expect(result.partial).toBe(false);
},
);
it.each([
[[], []],
[["", ""], []],
@@ -64,6 +286,13 @@ it.each(["before", "after"] as const)(
"dns.qry.name": ["api.example"],
"dns.flags.response": ["True"],
"dns.a": ["203.0.113.5"],
"dns.qry.type": ["1"],
"dns.count.queries": ["1"],
"dns.count.answers": ["1"],
"dns.count.auth_rr": ["0"],
"dns.count.add_rr": ["0"],
"dns.resp.name": ["api.example"],
"dns.resp.type": ["1"],
};
const result = await parseTsharkJsonOutput(
capture(position === "before" ? [empty, response] : [response, empty]),
@@ -112,11 +341,25 @@ it("binds interleaved DNS answers to the question in their response packet", asy
"dns.qry.name": ["alpha.example"],
"dns.flags.response": ["True"],
"dns.a": ["192.0.2.10"],
"dns.qry.type": ["1"],
"dns.count.queries": ["1"],
"dns.count.answers": ["1"],
"dns.count.auth_rr": ["0"],
"dns.count.add_rr": ["0"],
"dns.resp.name": ["alpha.example"],
"dns.resp.type": ["1"],
},
{
"dns.qry.name": ["beta.example"],
"dns.flags.response": ["True"],
"dns.a": ["192.0.2.20"],
"dns.qry.type": ["1"],
"dns.count.queries": ["1"],
"dns.count.answers": ["1"],
"dns.count.auth_rr": ["0"],
"dns.count.add_rr": ["0"],
"dns.resp.name": ["beta.example"],
"dns.resp.type": ["1"],
},
]),
);
@@ -197,6 +440,13 @@ it("preserves all DNS addresses and their exact examined count", async () => {
"dns.qry.name": ["rotating.example"],
"dns.flags.response": ["True"],
"dns.a": [`192.0.2.${String(index)}`],
"dns.qry.type": ["1"],
"dns.count.queries": ["1"],
"dns.count.answers": ["1"],
"dns.count.auth_rr": ["0"],
"dns.count.add_rr": ["0"],
"dns.resp.name": ["rotating.example"],
"dns.resp.type": ["1"],
})),
),
);
+11 -15
View File
@@ -6,6 +6,7 @@ import {
} from "../domain/analysisErrorCore.js";
import { parseRecordedTransportUrl } from "../domain/recordedTransportUrl.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import { attributeDnsAddresses } from "./PcapDnsOutput.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
const OPERATION = "inspect_pcap_capture";
@@ -222,6 +223,7 @@ export const parseTsharkJsonOutput = async (
let unresolvedHttpUriTarget = false;
const unattributed = new Set<string>();
let unattributedPackets = 0;
let unresolvedDnsOwnership = false;
let retainedBytes = 0;
const charge = (value: string): void => {
retainedBytes += 192 + 2 * Buffer.byteLength(value);
@@ -304,21 +306,15 @@ export const parseTsharkJsonOutput = async (
charge(question);
dns.set(question, new Set());
}
const addresses = [...field("dns.a"), ...field("dns.aaaa")].filter(
(value) => value !== "",
);
const response = field("dns.flags.response");
if (addresses.length > 0) {
if (
questions.size === 1 &&
response.length > 0 &&
response.every((value) => value === "True" || value === "1")
) {
const values = dns.get(questions.values().next().value!)!;
for (const address of addresses) add(values, address);
} else {
{
const attribution = attributeDnsAddresses(field);
unresolvedDnsOwnership ||= attribution.incomplete;
for (const [question, address] of attribution.assigned)
add(dns.get(question)!, address);
if (attribution.unattributed.length > 0) {
unattributedPackets += 1;
for (const address of addresses) add(unattributed, address);
for (const address of attribution.unattributed)
add(unattributed, address);
}
}
for (const value of field("tls.handshake.extensions_server_name"))
@@ -422,7 +418,7 @@ export const parseTsharkJsonOutput = async (
packetCount: packets.length,
partial:
observedUris.size > uris.size ||
unattributedPackets > 0 ||
unresolvedDnsOwnership ||
unresolvedHttpUriTarget,
};
};
+9 -1
View File
@@ -75,6 +75,14 @@ const FIELDS = [
"ipv6.dst",
"dns.flags.response",
"dns.qry.name",
"dns.qry.type",
"dns.count.queries",
"dns.count.answers",
"dns.count.auth_rr",
"dns.count.add_rr",
"dns.resp.name",
"dns.resp.type",
"dns.cname",
"dns.a",
"dns.aaaa",
"tls.handshake.extensions_server_name",
@@ -1059,7 +1067,7 @@ export class PcapProvider {
limitations: [
...this.nativeLimitations,
"Both tools read one owned snapshot; target identity comes from its admitted source descriptor",
"DNS address values are associated only with a single question in the same response packet; values may include additional DNS records, not only final resolution answers",
"DNS address attribution requires consistent RR owner/type and section counts for one question in one response; only matching answer owners or unambiguous answer-section CNAME chains are associated. Authority, additional, unrelated and ambiguous addresses are retained separately as unattributed; these packet-local relationships do not prove final resolution",
"Only bytes present in the saved capture are examined; encryption and capture truncation can hide higher-level fields",
"HTTP URI inventory examines decoded HTTP/1 and HTTP/2 request fields; HTTP/3 URI semantics are outside this profile",
...(fields.partial
@@ -29,6 +29,13 @@ const createPcapToolFixture = async (root: string) => {
"dns.qry.name": ["many.example"],
"dns.flags.response": ["True"],
"dns.a": [`198.51.100.${index}`],
"dns.qry.type": ["1"],
"dns.count.queries": ["1"],
"dns.count.answers": ["1"],
"dns.count.auth_rr": ["0"],
"dns.count.add_rr": ["0"],
"dns.resp.name": ["many.example"],
"dns.resp.type": ["1"],
"tls.handshake.extensions_server_name": [`host${index}.example`],
"http.request.full_uri": [
"http://user:[email protected]/p?q=keep",
+7
View File
@@ -24,6 +24,13 @@ export const TSHARK_JSON = JSON.stringify([
"dns.qry.name": ["api.example.com"],
"dns.flags.response": ["1"],
"dns.a": ["93.184.216.34"],
"dns.qry.type": ["1"],
"dns.count.queries": ["1"],
"dns.count.answers": ["1"],
"dns.count.auth_rr": ["0"],
"dns.count.add_rr": ["0"],
"dns.resp.name": ["api.example.com"],
"dns.resp.type": ["1"],
},
},
},