Skip to content

Repository files navigation

IPs-LE logo

IPs-LE

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

ips-le on crates.io crates.io downloads CI MSRV: Rust 1.88+ MIT licensed LE Tools


IPs-LE demo — the real binary, recorded by assets/demo.tape

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 .

What it does

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

Install

cargo install ips-le

Or build it from this repository:

git clone https://github.com/nolindnaidoo/ips-le
cd ips-le
cargo install --path crate

That puts ips-le in ~/.cargo/bin. Rust 1.88 or newer.

Sixty seconds

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 -u

Every 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.

What it answers

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"
  }
}

What it refuses

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.1 is 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.
  • 2130706433 is decoded only next to the flag. Under an address key it is reported as integer_form, with 127.0.0.1 inside 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.

It never touches a network

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.

Exit codes

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/"
fi

Options

Taken 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.

As an MCP server

ips-le mcp

Two 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.

Documentation

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

More from the LE family

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

Also by nolindnaidoo

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.

License

MIT © nolindnaidoo

Releases

Packages

Contributors

Languages