Find every IP address, CIDR block and MAC address in a tree, normalized and classified
and named refusals where the text has more than one reading — never a guess
Useful? A star is how other developers find it — ★ GitHub · letools.dev/tools/ips-le
Somebody has to check the firewall allow-list against the change request, the connection string against the network diagram, the fetch path against the SSRF review. Usually without a checkout, always without the editor open.
grep -rE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' half-serves them. It finds
no IPv6 at all. It calls 1.2.3 an address. It reports
2001:0db8::0001 and 2001:db8::1 as two different things — they are
one address. And it calls 010.1.1.1 an address without saying which
address, which is the whole reason that string is an SSRF bypass.
ips-le .Walks a tree the way ripgrep does, reads the bytes of every text file in it, and reports every network address it finds: as written, normalized, classified, with its line, column and — where the format has one — the key it sits under. stdout is one JSON report per line; stderr is the same answer for a person.
$ ips-le crate/fixtures/documents/network.yaml
crate/fixtures/documents/network.yaml:3:11 0.0.0.0 0.0.0.0 reserved
crate/fixtures/documents/network.yaml:4:15 10.20.30.40 10.20.30.40 private
crate/fixtures/documents/network.yaml:6:11 127.0.0.1 127.0.0.1 loopback
crate/fixtures/documents/network.yaml:7:11 2001:0db8::0001 2001:db8::1 documentation
crate/fixtures/documents/network.yaml:9:5 10.0.0.0/8 10.0.0.0/8 private
crate/fixtures/documents/network.yaml:10:5 192.168.0.0/16 192.168.0.0/16 private
crate/fixtures/documents/network.yaml:11:11 169.254.169.254 169.254.169.254 link-local
crate/fixtures/documents/network.yaml:12:11 aa:bb:cc:dd:ee:ff aa:bb:cc:dd:ee:ff global
crate/fixtures/documents/network.yaml:14:11 10.0.0.7 10.0.0.7 private
crate/fixtures/documents/network.yaml:14:26 10.0.0.8 10.0.0.8 private
10 addresses in 1 file
cargo install ips-leOr build it from this repository:
git clone https://github.com/nolindnaidoo/ips-le
cd ips-le
cargo install --path crateThat puts ips-le in ~/.cargo/bin. Rust 1.88 or newer.
ips-le . # every address in the tree, as JSON
ips-le --class private --class loopback . # what should not be reachable
ips-le --kind cidr infra/ # every block, with its arithmetic
ips-le --strict config/ # fail the build on any ambiguity
cat access.log | ips-le --stdin --format log
# the point of the whole thing:
ips-le . | jq -r '.addresses[] | select(.normalized) | .normalized' | sort -uEvery line of stdout is one file's report, and every field is always present — nulls included — so a consumer writes one reader:
{
"schema": 1,
"file": "<stdin>",
"format": "yaml",
"addresses": [
{
"kind": "ipv6",
"text": "2001:0db8::0001",
"line": 3,
"column": 11,
"key": "services.cache.peer",
"normalized": "2001:db8::1",
"class": "documentation",
"cidr": null,
"refused": null
}
],
"diagnostics": [],
"summary": { "addresses": 1, "refused": 0 }
}There is no --json flag. One mode, nothing to misremember, and the
human summary is a projection of the machine one so the two cannot
drift.
One address, one form. IPv6 is normalized per
RFC 5952 —
2001:0db8:0000:0000:0000:0000:0000:0001, 2001:db8:0:0:0:0:0:1,
2001:0db8::0001 and 2001:DB8::1 all come back as 2001:db8::1.
Sorting the raw text gives four addresses; sorting the normalized form
gives one.
Four kinds. ipv4, ipv6, cidr, mac — and --kind takes the
same four names.
Ten classes, closed. A class this cannot name is a class it does not claim.
| class | IPv4 | IPv6 |
|---|---|---|
loopback |
127.0.0.0/8 | ::1 |
private |
10/8, 172.16/12, 192.168/16 | — |
link-local |
169.254/16 | fe80::/10 |
cgnat |
100.64/10 | — |
multicast |
224/4 | ff00::/8 |
broadcast |
255.255.255.255 | — (IPv6 has none) |
documentation |
192.0.2/24, 198.51.100/24, 203.0.113/24 | 2001:db8::/32 |
unique-local |
— | fc00::/7 |
reserved |
0/8, 192.0.0/24, 198.18/15, 240/4 | ::, 2001::/23, 100::/64 |
global |
everything else | everything else |
An IPv4-mapped IPv6 address takes the IPv4 class, so ::ffff:127.0.0.1
is loopback rather than global — which is the miss an allow-list
review is looking for.
Where it is. Line, column, and the key it sits under: JSON, YAML,
TOML, INI, dotenv, CSV and logs all supply one. Everything else is still
scanned — the search runs over the bytes, so a .tf, a .rules or a
rotated access.log.1 yields its addresses and only loses the key path.
Blocks, with their arithmetic. A CIDR finding carries prefix,
network, broadcast (IPv4 only — IPv6 has none), last and hosts.
hosts is a decimal string, because ::/0 holds 2^128 addresses, which
is one more than a u128 and far more than a JSON number.
{
"kind": "cidr",
"text": "10.0.0.0/8",
"normalized": "10.0.0.0/8",
"class": "private",
"cidr": {
"prefix": 8,
"network": "10.0.0.0",
"broadcast": "10.255.255.255",
"last": "10.255.255.255",
"hosts": "16777216"
}
}Where the text supports more than one reading, ips-le reports the
text, names the ambiguity, and stops.
$ ips-le --stdin <<< '010.1.1.1'
<stdin>:1:1 010.1.1.1 refused OctalHazard
0 addresses in 1 file
1 refused
Six reasons, each a place where two answers are equally defensible:
| reason | fires on |
|---|---|
octal_hazard |
010.1.1.1, 0177.0.0.1, 192.168.001.1 |
ambiguous_version |
10.0.1, 1.2.3 — unless the key says version |
integer_form |
2130706433 under an address key |
malformed_address |
256.1.1.1, 2001:db8:::1, 12345::1 |
prefix_out_of_range |
10.0.0.0/33, 2001:db8::/129 |
mac_ambiguous |
deadbeefcafe |
The two that matter most:
010.1.1.1is not resolved. A leading-zero octet is octal to some resolvers and decimal to others, so that text names two different hosts. Neither reading appears anywhere in the output — a tool that picked one would be the thing hiding the bug.2130706433is decoded only next to the flag. Under an address key it is reported asinteger_form, with127.0.0.1inside the refusal message. What you never get is a loopback address quietly appearing in a list of addresses with the flag gone.
A refusal is a finding, not a failure. It does not move the exit
code, and no filter can hide it — --class private still shows you the
octal hazard, because that is the finding a filtered report would most
regret dropping. --strict is there for the pipeline that wants an
unresolved ambiguity to stop the build.
crate/SPEC.md says exactly when each reason fires.
No DNS, no geolocation, no ASN, no WHOIS, no reachability check, no telemetry. Not behind a flag, not once. Classification is arithmetic over the bits and the IANA registries; a lookup would make the answer depend on the network the auditor happened to be sitting on.
It also never rewrites a file, and it never gives a verdict. It says what an address is, never whether it should be there.
Following grep:
| code | meaning |
|---|---|
0 |
at least one address was named |
1 |
none was |
2 |
the question was malformed |
Finding none is an answer, and so is a refusal — a file of nothing but
ambiguities exits 1. --strict turns a refusal, or a file that could
not be read, into a 2. A binary file is counted and never fails the run;
every repository holds a PNG.
if ips-le --strict --class loopback config/; then
echo "a loopback address is hardcoded in config/"
fiTaken from ips-le --help, which is the authority.
| Option | What it does |
|---|---|
--format <format> |
Force a format instead of inferring it from the file name; an unknown name still scans, it just reports no key paths |
--kind <kind> |
Report only ipv4, ipv6, cidr or mac; repeatable |
--class <class> |
Report only one class, e.g. private or global; repeatable |
--strict |
Exit 2 if anything was refused or any file could not be read, rather than reporting it and carrying on |
--stdin |
Read one document from stdin |
--hidden |
Walk hidden files and directories too |
--no-ignore |
Walk files that .gitignore excludes |
A filter narrows what this tool claims, never what it declined to claim:
a refusal survives --kind and --class, because the finding a filtered
report would hide is the one most worth seeing.
ips-le mcpTwo tools, one envelope ({ ok, data, diagnostics, meta }):
extract_ips— takes document text, returns findings. Touches no filesystem.ips_le_scan— takes a path, reads the tree.
ok means the scan ran, never that the answer was yes. A model reading
2001:0db8::0001 and 2001:db8::1 out of a diff will usually call them
two addresses; this is how it stops having to guess.
| What | Where |
|---|---|
| What the tool is allowed to say — the refusal table, the classification table, the output schema, the non-goals | crate/SPEC.md |
| How the code is written and held together — architecture, invariants, the gates, the layout | crate/AGENTS.md |
| The crate's own front page | crate/README.md |
| What changed | CHANGELOG.md · crate/CHANGELOG.md |
| The tool's page, and the other fifteen | letools.dev/tools/ips-le |
Sixteen single-purpose tools for the work in front of every model. Each ships a Rust CLI and an MCP server. One page: letools.dev
Get it out
- String-LE — Extract every string in a codebase, with its position, so a person can read them
- Numbers-LE — Extract every hardcoded number in a codebase, so a person can check them
- Units-LE — Extract every quantity with its unit, normalized, and refuse the ambiguous ones by name
- Dates-LE — Extract every date and timestamp, and the exact instant each one resolves to
- IDs-LE — Extract every UUID, ULID, NanoID, ObjectId and Snowflake, and decode the time inside
- IPs-LE — Extract every IP address, CIDR block and MAC, normalized and classified by scope
- URLs-LE — Extract every URL in a codebase, with its protocol and exact position
- Paths-LE — Extract every file path in a codebase, and say whether it still points at anything
- Colors-LE — Extract every color in a codebase, and say which ones are not in your palette
Check it
- Regex-LE — Find every regex in a codebase, and report which can be driven into catastrophic backtracking
- Versions-LE — Find where one dependency is constrained differently across a repository's manifests
- i18n-LE — Identify the i18n library a project uses, then audit its catalogs by that library's rules
- Scrape-LE — Check whether a page is scrapeable before the scraper is written, and say when it cannot tell
Guard it
- Secrets-LE — Find hardcoded credentials in a codebase, and never print one into the report
- EnvSync-LE — Compare the dotenv files in a tree, and say which keys are missing from which
- Unicode-LE — Find the Unicode that hides meaning — bidi controls, invisibles, homoglyphs, mixed scripts
Each stands on its own: no shared crate, no published core. Where two of them agree, it is because the same answer was right twice.
Contact — nolindnaidoo.com · GitHub · LinkedIn
Rust — pixelcoords and pixelactions are one loop: pixelcoords answers where, pixelactions acts there. Their own tools, their own voice — not part of the LE family.
- pixelcoords — Freeze your screen, mark regions, get pixel-exact coordinates and crops pixelcoords.dev · crates.io · docs.rs
- pixelactions — Consume human-verified coordinates, perform the interaction, confirm it landed pixelactions.dev · crates.io · docs.rs
MIT © nolindnaidoo