PortieForcie swept my network and came back with 18 rows.
Fourteen of them were real devices. The other four were ghosts — the router’s broadcast address, my own machine answering its own multicast ping, a DNS-SD blob wearing a hostname’s clothes, a hardware address reported as a name.
Eighteen rows in a panel, and an operator would have had no idea which fourteen were real. Worse: the garbage got written to a persistent inventory, so it did not merely pollute the view — it polluted the history, and on the next run the stale rows were read back in and looked authoritative.
The decisive test was not the sweep. It was feeding the store twenty deliberately garbage-laden rows and checking what came out: seventeen clean rows.
This is the story of fixing that, and of the rule set that came out of it.
The Failure Mode Is Not Obvious
Here is the thing about discovery tools: the broken version does not crash. It does not show an error. It shows a plausible list. Every row has an IP address, a MAC, and a hostname. The hostname is just sometimes not a hostname.
That is much worse than a crash, because a crash announces itself and a plausible list does not. Nobody audits a list that looks like a list.
The reason this class of bug survives is that each discovery technique is individually reasonable:
| Technique | What it can return that is not a device |
|---|---|
| Route table | The limited broadcast address, listed beside every real route |
| IPv6 neighbour probe | The all-nodes multicast group — and the machine answers its own ping |
| DNS-SD | A blob like amzn.dmgr:11FC5C0EC6A28FFEDAA52A8ACE6F4E93:xnVDMM+4u1:558619 |
| Reverse DNS | The address echoed back when the host has no PTR record |
| NetBIOS | 327EFEE148D8@ANNMARIE␚MACBOOK — an address, a separator, and a name |
| DHCP lease | A hardware address standing in for a name |
Any one of these is a reasonable thing for a tool to report. The mistake is treating them all as the same kind of thing. An address is a fact. A name is a claim. A blob is a protocol artefact. A device id is an identifier. Putting them in one column and calling the column “hostname” is the bug.
The Fix Is One Place, Not Thirteen
The instinct with a bug like this is to patch each call site. Resist it. There were thirteen places that could produce a name, and a thirteenth patch would have arrived the moment somebody added a fourteenth technique.
The rules went into one enum, and every technique — plus the store that outlives them — calls through it:
enum DiscoveryCleanup {
static func isIPv4(_ value: String) -> Bool
static func isIPv6(_ value: String) -> Bool
static func isUsableIPv4(_ address: String) -> Bool
static func isUsableIPv6(_ address: String) -> Bool
static func isUsableAddress(_ address: String) -> Bool
static func isPlausibleHostname(_ value: String) -> Bool
static func looksLikeIdentifier(_ value: String) -> Bool
static func sanitizeHostname(_ value: String?) -> String?
static func hostname(from value: String) -> String?
static func dnsSDLabel(_ value: String) -> String
static func netbiosLabel(_ value: String?) -> String?
static func advertisedName(_ value: String?) -> String?
static func readableLabel(_ value: String) -> String
}
Two functions do most of the work, and they answer different questions.
isPlausibleHostname asks: is this a well-formed authority name? A PTR record, a DHCP lease, a certificate subject. These are meant to be hostnames, so they are accepted only if they are already well formed. That is a strict test:
- Not empty, not longer than 253 characters
- No control characters (the NetBIOS separator
␚is the common one) - Not itself an address
- No characters that mark the string as something else entirely —
:,/,\,@, quotes, brackets, spaces, and a dozen more - Every dot-separated label non-empty, at most 63 characters, and not starting or ending with a hyphen
looksLikeIdentifier asks: is this a device id masquerading as a name? A label made only of hex digits, long enough to be meaningful, is an identifier:
let components = value.split(whereSeparator: { $0 == "." || $0 == "-" })
for component in components {
let text = String(component)
guard text.count >= 12 else { continue }
guard text.allSatisfy({ $0.isHexDigit }) else { continue }
return true
}
Note the split on both . and -. The MAC-plus-suffix form 0508CDC004AA3082-FFFFFFEFFFFFFFFF is two hex components; splitting only on . would have let it through as a single non-hex-ish blob.
The Rules That Counterintuitively Allow Things
Two of these rules are the opposite of what I expected, and both came from real traffic rather than from theory.
Link-local IPv6 survives. My first instinct was that fe80::… should be rejected — it is not globally routable, it is a link-scoped address. But a link-local address identifies a host. It is bound to the interface that saw it, which is exactly the information that makes it useful. Rejecting it because it is link-local would have thrown away real devices. So only the unspecified address (::) and loopback (::1) are refused.
Underscores are allowed. _ is not a legal character in a strict DNS name. My first draft of the test suite asserted that under_score should be refused, and the test failed — correctly. DHCP lease names use underscores routinely (MYDEVICE_01), and refusing them would have discarded genuine device names. The illegal-character set is not a “is this a legal DNS name” check; it is the set of characters that mark a string as something else entirely.
That second one is worth dwelling on. I wrote the test to match my assumption, the test failed, and the code was right. The alternative — bending the code to satisfy the test I had just written — would have shipped a filter that silently dropped real DHCP clients. A test suite that only ever confirms what you already believe is decoration. When one of mine failed, that was the test doing its job.
Two Different Standards: Authority Names and Advertised Names
This distinction took the longest to get right.
A PTR record or a DHCP lease is an authority name. It is meant to be a hostname, so it either passes isPlausibleHostname or it is discarded. A resolver that answers with the address itself has not given us a name — and the panel already shows the address, so displaying it twice is pure noise.
But a DNS-SD instance name or a NetBIOS label is not meant to be a hostname. It is a display name, and it may legally contain characters that a DNS name may not. The NetBIOS separator is ␚ (\x1a) and it stands in for a space:
327EFEE148D8@ANNMARIE␚MACBOOK → ANNMARIE MACBOOK
A space, not a dot. A dot would split the label into separate DNS labels, and the first one is not the name anybody recognises.
The address half is dropped, because the MAC is already in its own column:
static func netbiosLabel(_ value: String?) -> String? {
// "327EFEE148D8@PRINTER" → "PRINTER"
// "327EFEE148D8@" → nil
}
So the two paths have different standards: an authority name must be well formed or it dies; an advertised name is sanitised and shown. Both end up through advertisedName, which applies the identifier check one last time — a device id is not a name, and showing 327EFEE148D8 in the hostname column is worse than showing nothing.
The Part Everybody Forgets: The Store
Here is the part I would underline twice.
Cleaning the output is not enough. The persistent inventory is where garbage goes to become permanent. If the store accepts a bad row, then:
- Run 1 discovers a hostname of
0508CDC004AA3082. - It is filtered out of the panel — good.
- It is written to the store — bad.
- Run 2 reads the store back, sees a row that “already passed” at some point, and shows it.
The store is the enforcement point, because it is the thing that makes yesterday’s mistake look like today’s fact. Closing that vector is what actually makes the fix stick; filtering the view is the visible half.
The same discipline applies to the route table, the reverse-DNS resolver, and the mDNS browser: they share the rules, because a rule enforced in eleven places is a rule enforced in none of them.
Verifying It Properly
The temptation with a fix like this is to run the scanner and see “fewer devices now”. Do not. A shrinking count is exactly what over-aggressive cleanup also looks like. If I had filtered too hard, the test would have looked identical: fewer rows.
So the verification had two halves:
The count. Every host the sweep found had to survive into the output. The live comparison against a reference build confirmed it — the reference returned 14 real IPs plus 4 mDNS ghost rows (18 total), the rebuilt version returned 16 real IPs and zero ghosts, and in REF but not MINE: none. Not a smaller number. The right number.
The two extra hosts (.132 and .157) were ones the reference missed — the rebuilt scanner found devices the old one did not. Sweep counts vary between 14, 16 and 17 from run to run because ICMP timing is flaky, not because filtering is aggressive. Every host the sweep actually found survived into the output.
The rules. The cleanup logic existed only as a throwaway script, which meant nothing stopped it rotting the next time somebody touched a merge point. It is now a proper test target, with each test corresponding to a shape actually seen on the wire:
final class DiscoveryCleanupTests: XCTestCase {
func testAddressesAreNeverHostnames()
func testHardwareIdentifiersAreRejected()
func testLinkLocalIPv6Survives()
func testDNSSDInstanceNamesReduceToFirstLabel()
func testNetBIOSSeparatorBecomesASpace()
func testUnderscoresInLeaseNamesAreAllowed()
// …14 in total
}
Fourteen tests, all passing. Two of them failed on their first run, and — as above — both were my assertions being wrong rather than the code.
The Bigger Lesson
There is a general shape here, and I have now seen it in several unrelated places:
A tool that produces plausible output is more dangerous than a tool that produces no output, because plausible output is trusted.
The scanner was not “broken” in any way a user could see. It produced a nice full list with IP addresses and MACs and mostly-plausible hostnames. Nobody would have suspected it of lying. The only thing that exposed it was writing down the rule that distinguishes a name from an identifier and then checking every row against it.
The same pattern bit a content pipeline of mine recently: a publishing script printed ✅ Pushed to GitHub Pages while pushing the wrong branch, so nineteen commits and twenty posts never reached the live site. The log said everything was fine. Nobody reading the logs would have known. It took a 404 to reveal it, and a 404 is an expensive way to learn a lesson.
Both of those are the same failure. Both are fixed by replacing a claim of success with a check whose failure mode is visible.
PortieForcie is a macOS network reconnaissance tool — port scanning, LAN discovery, DNS and WHOIS, traceroute, HTTP probing, SSH hardening, web fingerprinting, and CVE correlation, with a Go engine for the heavy scanning and a Swift GUI and CLI on top.
Related: Building a Discovery Cleanup Layer in Swift — the tutorial.
Comments
Leave a message below. Your comment saves to your browser.