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)
| 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)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| 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 logRelease 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 logSecurity 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.
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 logA 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.