Compare commits

...
5 changed files with 119 additions and 8 deletions

No files matched your search

+59 -5
View File
@@ -82,15 +82,69 @@ const parse = (
bytes: Buffer,
queries: readonly string[] = ["source display"],
addresses: readonly string[] = ["192.0.2.9"],
family: "dns" | "mdns" | "llmnr" = "dns",
) =>
parseDnsMessageRaw([bytes.toString("hex"), 42, bytes.length, 0, 1], {
"dns.id_raw": ["0001", 42, 2, 0, 5],
Queries: { "dns.qry.name": queries },
Answers: { "dns.a": addresses },
});
parseDnsMessageRaw(
[bytes.toString("hex"), 42, bytes.length, 0, 1],
{
"dns.id_raw": ["0001", 42, 2, 0, 5],
Queries: { "dns.qry.name": queries },
Answers: { "dns.a": addresses },
},
family,
);
const pointer = Buffer.from([0xc0, 0x0c]);
const address = Buffer.from([192, 0, 2, 9]);
it.each([
{ family: "dns", hasAnswers: false },
{ family: "dns", hasAnswers: true },
{ family: "llmnr", hasAnswers: false },
{ family: "llmnr", hasAnswers: true },
] as const)(
"retains observed records but marks conventional TC responses incomplete: %j",
({ family, hasAnswers }) => {
const bytes = packet(
label("61"),
hasAnswers ? [rr(pointer, 1, address)] : [],
);
bytes.writeUInt16BE(0x8380, 2);
const facts = parse(bytes, ["a"], hasAnswers ? ["192.0.2.9"] : [], family);
expect(facts.questions).toEqual([{ query: "a", labels_hex: ["61"] }]);
expect(facts.assigned).toEqual(hasAnswers ? [[0, "192.0.2.9"]] : []);
expect(facts.unattributed).toEqual([]);
expect(facts.incomplete).toBe(true);
expect(facts.incomplete_reasons).toContain(
"DNS message declares truncation (TC); captured records are incomplete",
);
},
);
it.each([false, true])(
"does not infer conventional truncation from multicast DNS TC (has answers: %s)",
(hasAnswers) => {
const bytes = packet(
label("61"),
hasAnswers ? [rr(pointer, 1, address)] : [],
);
bytes.writeUInt16BE(0x8400, 2);
const addresses = hasAnswers ? ["192.0.2.9"] : [];
const complete = parse(bytes, ["a"], addresses, "mdns");
expect(complete.incomplete).toBe(false);
bytes.writeUInt16BE(0x8600, 2);
expect(parse(bytes, ["a"], addresses, "mdns")).toEqual(complete);
},
);
it("preserves ownership limitations alongside declared DNS truncation", () => {
const bytes = packet(label("61"), [], [rr(pointer, 1, address)]);
bytes.writeUInt16BE(0x8380, 2);
const facts = parse(bytes);
expect(facts.assigned).toEqual([]);
expect(facts.unattributed).toEqual(["192.0.2.9"]);
expect(facts.incomplete_reasons).toHaveLength(2);
});
it("joins compressed CNAMEs using ASCII-folded bytes while retaining original labels", () => {
const query = label("414243", "6578616d706c65");
const alias = label("616263", "6578616d706c65");
+15 -1
View File
@@ -424,10 +424,24 @@ export const parseDnsMessageRaw = (
}));
const records = decodeRecords(message, at, classValue, observed);
if (!records.ok) return unknown(records.error.reason);
return attributeAddresses(
const facts = attributeAddresses(
questions,
eligibleQuestion(decodedQuestions, flags),
records.value,
observed,
);
// A structurally complete packet can carry only part of the DNS response.
// RFC 1035 section 4.1.1 makes TC an explicit producer truncation fact.
// RFC 6762 section 18.5 gives mDNS TC distinct semantics: multicast responses
// ignore it; queries signal further Known Answers. This owner has no transport
// context to identify legacy unicast responses, so do not infer that reading.
if (family === "mdns" || (flags & 0x0200) === 0) return facts;
return {
...facts,
incomplete: true,
incomplete_reasons: [
...(facts.incomplete_reasons ?? []),
"DNS message declares truncation (TC); captured records are incomplete",
],
};
};
+20
View File
@@ -104,6 +104,26 @@ it("rejects duplicate frame identities even when protocol stacks are empty", asy
).rejects.toMatchObject({ _tag: "AnalysisProtocolError" });
});
it("marks a TC response partial without losing its observed DNS answer", async () => {
const layers = dnsResponseLayers("api.example", "203.0.113.5");
const bytes = Buffer.from(layers.dns_raw[0] as string, "hex");
bytes.writeUInt16BE(0x8380, 2);
layers.dns_raw[0] = bytes.toString("hex");
const result = await parseTsharkJsonOutput(capture([layers]));
expect(result.dnsQueries).toEqual([
{
query: "api.example",
query_labels_hex: ["617069", "6578616d706c65"],
response_addresses: ["203.0.113.5"],
response_address_count: 1,
},
]);
expect(result.partial).toBe(true);
expect(result.dnsLimitations).toContain(
"DNS message declares truncation (TC); captured records are incomplete",
);
});
it("binds interleaved DNS answers to each response packet", async () => {
const result = await parseTsharkJsonOutput(
capture([
+2 -2
View File
@@ -1194,12 +1194,12 @@ export class PcapProvider {
...(dnsLimitation === undefined ? [] : [dnsLimitation]),
...this.nativeLimitations,
"Both tools read one owned snapshot; target identity comes from its admitted source descriptor",
"DNS address attribution requires one wire-identified question in one complete DNS message; 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",
"DNS address attribution requires one wire-identified question in one decoded DNS message; declared truncation marks coverage partial. 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
? [
"One or more projected categories were capped, credential-bearing URIs were omitted or coalesced, or DNS address attribution or HTTP URI targets remain unresolved; counts retain the examined totals",
"One or more projected categories were capped, credential-bearing URIs were omitted or coalesced, DNS messages declare truncation, or DNS address attribution or HTTP URI targets remain unresolved; counts retain the examined totals",
]
: []),
...(fields.httpUriRedactionCount > 0
+23
View File
@@ -129,6 +129,29 @@ it("inspects a capture with endpoints, DNS, TLS SNI, and HTTP", async () => {
expect(result.coverage).toBe("complete");
});
it("retains DNS answers and exposes declared truncation as partial coverage", async () => {
const { provider, root } = await providerWith({
dnsJson: TSHARK_DNS_JSON.replace("00018180", "00018380"),
});
const result = await provider.execute({
operation: "inspect_pcap_capture",
input: { path: await captureFixture(root) },
});
if (!result.ok) throw new Error(result.error.message);
expect(result.value.result).toMatchObject({
coverage: "partial",
dns_queries: [
{ query: "api.example.com", response_addresses: ["93.184.216.34"] },
],
});
expect(result.value.limitations).toContain(
"DNS wire parsing: DNS message declares truncation (TC); captured records are incomplete",
);
expect(result.value.limitations).toContainEqual(
expect.stringContaining("DNS messages declare truncation"),
);
});
it.each([0, 1])(
"preserves packet-count mismatch facts while accepting an empty capture: capinfos=%s",
async (count) => {