Skip to main content

inpguard

Defensive linting for EPANET .inp water-network model files.

inpguard is a dependency-free Python library and CLI that inspects an EPANET input file before it reaches a hydraulic solver. It reports malformed structure, dangling cross-references, numeric values that overflow single precision, embedded control bytes that desynchronise C string handling, and other input hazards.

It is a linter, not a fuzzer and not an exploit. It never executes the file.

Why this exists

Water utilities exchange .inp files constantly — with consultants, with contractors, between distribution-system teams, and increasingly through automated pipelines. The format is plain text, section-delimited, and widely reimplemented in memory-unsafe languages. Most parsers assume the file is benign because historically it always was.

That assumption is aging badly. Water systems are now an explicit target class, and the people who steward these models — civil and environmental engineers — are trained in hydraulics, not in adversarial input handling. The realistic failure mode isn't a nation-state exploit; it's a subtle malformed file that silently corrupts a hydraulic model, or a parser that crashes and takes a modelling session with it.

inpguard gives the engineering side a two-second sanity check that speaks in byte offsets and line numbers rather than stack traces.

Install

pip install inpguard

For development, install from a clone:

git clone https://github.com/onurcetinkaya-ce/inpguard.git
cd inpguard
pip install -e .

No runtime dependencies. Python 3.9+.

Use

inpguard check network.inp
inpguard check models/*.inp --json
network.inp: 412 bytes, 34 lines, 6 sections
  ERROR INP032      line 14: link endpoint 'J9' is not a defined node
  WARN  INP022      line 12: value 1e39 in column 4 exceeds float32 range and will overflow to inf on a single-precision parser
  1 error(s), 1 warning(s)

Exit codes: 0 clean, 1 error findings, 2 I/O or usage failure.

As a library:

from inpguard import lint_text

report = lint_text(open("network.inp").read())
for finding in report.sorted_findings():
    print(finding)
print(report.ok())

Rule reference

Code Severity Meaning
INP001 ERROR File exceeds the 64 MiB ceiling (checked before parsing)
INP002 ERROR Physical line exceeds 8 KiB
INP003 ERROR Forbidden control byte (NUL, vertical tab, form feed, etc.)
INP004 ERROR Line is not valid UTF-8
INP005 ERROR Empty or malformed section header
INP006 ERROR Data before the first section header
INP007 ERROR Section exceeds its row ceiling
INP008 ERROR Too many sections
INP010 WARN Unknown section name
INP011 WARN Section appears more than once
INP012 WARN Token exceeds 256 bytes (fixed-buffer hazard)
INP013 WARN No [END] section
INP014 WARN Cross-reference limit reached; reference findings are a lower bound
INP020 ERROR Duplicate node/link identifier
INP021 ERROR Non-finite numeric literal (nan, inf)
INP022 WARN Value overflows IEEE-754 single precision
INP023 WARN Numeric literal uses digit separators (1_000)
INP030 WARN Reference to an undefined pattern
INP031 WARN Reference to an undefined curve
INP032 ERROR Link endpoint is not a defined node

Design notes

Line splitting is deliberately narrow. The file is split on LF and CRLF only. It does not split on \v, \f, \x1c–\x1e, \u2028, or \u0085, which str.splitlines() does honour. If the linter and the target parser disagree about where a record ends, an attacker can craft a file that reads one way to the linter and another way to the parser. The corpus contains an explicit unicode_line_sep case asserting this.

The UTF-8 BOM is stripped, not rejected. Windows and spreadsheet tooling emits BOMs routinely. A linter that rejects every such file gets switched off in production within a week, and a linter that is switched off protects nobody.

Limits are checked before the work they bound. An oversized file is reported and abandoned without walking its body.

Findings carry byte offsets. A finding you cannot locate is a finding nobody acts on.

Corpus

inpguard/corpus.py ships eleven cases: a clean model, a truncated transfer, a Windows CRLF+BOM export, and adversarial inputs for dangling references, duplicate identifiers, float32 overflow, NaN/Inf literals, embedded NULs, Unicode line-separator smuggling, unknown sections, and oversized tokens.

These are not exploits. They are malformed or hostile inputs that a parser should reject or survive. The corpus exists so that the check has something to fail against, and so that a parser author can reproduce a report.

Validation

Validated against 17 .inp files it did not author, ~37,400 lines in total, from two independent publishing bodies.

Nine benchmark distribution networks — C-Town, d-town, Modena, Jilin, Zhi Jiang, KL, ky1, ky8, RuralNetwork — sourced from the DiTEC-WDN corpus (Truong et al., Scientific Data 2025): zero errors, zero warnings. Sizes range from 234 to 7,731 lines.

Eight files from the USEPA/WNTR reference suite, including the 847 KB Net6_plus.inp benchmark: five lint completely clean; the three that do not are malformed on purpose and produce exactly one finding each, the one they were built to contain.

The first run against those files produced 66 false positives and exposed three real bugs (inline ; comments, [PUMPS]/[VALVES] column indexing, repeated sections). Each is fixed and carries a regression test. Adversarial self-testing separately found and fixed a resource-amplification bug in this tool.

See VALIDATION.md for the full tables, method, and a candid statement of what a clean result does and does not demonstrate.

Status

0.1.0 — alpha. The rule set is deliberately conservative: it flags only what is unambiguous in the format specification. Rules that would warn on a legitimate file exported from a common modelling tool are bugs, not features.

Output and memory are bounded: findings are capped per rule code (with the number of elided occurrences reported, never silently dropped), and cross-reference checking is capped with an explicit INP014 rather than growing without limit. A 3.2 MB adversarial file that once produced 400,002 findings and 287.9 MB peak RSS now produces 106 findings and 38.7 MB. See VALIDATION.md.

License

MIT.

Metadata

Release files for inpguard 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for inpguard 0.1.0
File Size Uploaded Provenance Actions
inpguard-0.1.0.tar.gz 20.7 kB With provenance Details for inpguard-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for inpguard 0.1.0
File Interpreter ABI Platform Actions
inpguard-0.1.0-py3-none-any.whl Python 3 none any Details for inpguard-0.1.0-py3-none-any.whl

Total release size: 37.6 kB

Release files / inpguard-0.1.0.tar.gz

Download URL inpguard-0.1.0.tar.gz
Size 20.7 kB
Tags Source
SHA-256 checksum
How to use checksums
ac3e746903459e74d81629d5d14346c07e5bf3675d7e9e71544dc5469ddb1894
BLAKE2b-256 checksum
How to use checksums
29d33e44edbfa9f1e1174f34eef706e5ce27e5207dcbdcb4215601223e55ce27
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 7, 2026.

Transparency log

Release files / inpguard-0.1.0-py3-none-any.whl

Download URL inpguard-0.1.0-py3-none-any.whl
Size 17.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
29b5d227b21cffe83d1a54f62323fa9395727d56bcee51db7d4c53135da93f9b
BLAKE2b-256 checksum
How to use checksums
70a64c1bd7579cfced24959ac618c27af377c92dea4ab773f0158f593bfd783a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 7, 2026.

Transparency log

Security for inpguard 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for inpguard-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 7, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page