inpguard 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- inpguard-0.1.0/LICENSE +21 -0
- inpguard-0.1.0/PKG-INFO +196 -0
- inpguard-0.1.0/README.md +166 -0
- inpguard-0.1.0/inpguard/__init__.py +30 -0
- inpguard-0.1.0/inpguard/cli.py +113 -0
- inpguard-0.1.0/inpguard/corpus.py +155 -0
- inpguard-0.1.0/inpguard/lint.py +568 -0
- inpguard-0.1.0/inpguard/spec.py +87 -0
- inpguard-0.1.0/inpguard.egg-info/PKG-INFO +196 -0
- inpguard-0.1.0/inpguard.egg-info/SOURCES.txt +14 -0
- inpguard-0.1.0/inpguard.egg-info/dependency_links.txt +1 -0
- inpguard-0.1.0/inpguard.egg-info/entry_points.txt +2 -0
- inpguard-0.1.0/inpguard.egg-info/top_level.txt +1 -0
- inpguard-0.1.0/pyproject.toml +43 -0
- inpguard-0.1.0/setup.cfg +4 -0
- inpguard-0.1.0/tests/test_lint.py +293 -0
inpguard-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Onur Cetinkaya
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
inpguard-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inpguard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Defensive linting for EPANET .inp water-network model files
|
|
5
|
+
Author: Onur Çetinkaya
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/onurcetinkaya-ce/inpguard
|
|
8
|
+
Project-URL: Source, https://github.com/onurcetinkaya-ce/inpguard
|
|
9
|
+
Project-URL: Issues, https://github.com/onurcetinkaya-ce/inpguard/issues
|
|
10
|
+
Project-URL: Security, https://github.com/onurcetinkaya-ce/inpguard/security/policy
|
|
11
|
+
Keywords: epanet,water,scada,ics,security,lint
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Intended Audience :: System Administrators
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Hydrology
|
|
24
|
+
Classifier: Topic :: Security
|
|
25
|
+
Classifier: Topic :: Utilities
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# inpguard
|
|
32
|
+
|
|
33
|
+
Defensive linting for EPANET `.inp` water-network model files.
|
|
34
|
+
|
|
35
|
+
`inpguard` is a dependency-free Python library and CLI that inspects an EPANET
|
|
36
|
+
input file **before** it reaches a hydraulic solver. It reports malformed
|
|
37
|
+
structure, dangling cross-references, numeric values that overflow single
|
|
38
|
+
precision, embedded control bytes that desynchronise C string handling, and
|
|
39
|
+
other input hazards.
|
|
40
|
+
|
|
41
|
+
It is a *linter*, not a fuzzer and not an exploit. It never executes the file.
|
|
42
|
+
|
|
43
|
+
## Why this exists
|
|
44
|
+
|
|
45
|
+
Water utilities exchange `.inp` files constantly — with consultants, with
|
|
46
|
+
contractors, between distribution-system teams, and increasingly through
|
|
47
|
+
automated pipelines. The format is plain text, section-delimited, and widely
|
|
48
|
+
reimplemented in memory-unsafe languages. Most parsers assume the file is
|
|
49
|
+
benign because historically it always was.
|
|
50
|
+
|
|
51
|
+
That assumption is aging badly. Water systems are now an explicit target class,
|
|
52
|
+
and the people who steward these models — civil and environmental engineers —
|
|
53
|
+
are trained in hydraulics, not in adversarial input handling. The realistic
|
|
54
|
+
failure mode isn't a nation-state exploit; it's a subtle malformed file that
|
|
55
|
+
silently corrupts a hydraulic model, or a parser that crashes and takes a
|
|
56
|
+
modelling session with it.
|
|
57
|
+
|
|
58
|
+
`inpguard` gives the engineering side a two-second sanity check that speaks in
|
|
59
|
+
byte offsets and line numbers rather than stack traces.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install inpguard
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
For development, install from a clone:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
git clone https://github.com/onurcetinkaya-ce/inpguard.git
|
|
71
|
+
cd inpguard
|
|
72
|
+
pip install -e .
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
No runtime dependencies. Python 3.9+.
|
|
76
|
+
|
|
77
|
+
## Use
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
inpguard check network.inp
|
|
81
|
+
inpguard check models/*.inp --json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
network.inp: 412 bytes, 34 lines, 6 sections
|
|
86
|
+
ERROR INP032 line 14: link endpoint 'J9' is not a defined node
|
|
87
|
+
WARN INP022 line 12: value 1e39 in column 4 exceeds float32 range and will overflow to inf on a single-precision parser
|
|
88
|
+
1 error(s), 1 warning(s)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Exit codes: `0` clean, `1` error findings, `2` I/O or usage failure.
|
|
92
|
+
|
|
93
|
+
As a library:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from inpguard import lint_text
|
|
97
|
+
|
|
98
|
+
report = lint_text(open("network.inp").read())
|
|
99
|
+
for finding in report.sorted_findings():
|
|
100
|
+
print(finding)
|
|
101
|
+
print(report.ok())
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Rule reference
|
|
105
|
+
|
|
106
|
+
| Code | Severity | Meaning |
|
|
107
|
+
|------|----------|---------|
|
|
108
|
+
| INP001 | ERROR | File exceeds the 64 MiB ceiling (checked before parsing) |
|
|
109
|
+
| INP002 | ERROR | Physical line exceeds 8 KiB |
|
|
110
|
+
| INP003 | ERROR | Forbidden control byte (NUL, vertical tab, form feed, etc.) |
|
|
111
|
+
| INP004 | ERROR | Line is not valid UTF-8 |
|
|
112
|
+
| INP005 | ERROR | Empty or malformed section header |
|
|
113
|
+
| INP006 | ERROR | Data before the first section header |
|
|
114
|
+
| INP007 | ERROR | Section exceeds its row ceiling |
|
|
115
|
+
| INP008 | ERROR | Too many sections |
|
|
116
|
+
| INP010 | WARN | Unknown section name |
|
|
117
|
+
| INP011 | WARN | Section appears more than once |
|
|
118
|
+
| INP012 | WARN | Token exceeds 256 bytes (fixed-buffer hazard) |
|
|
119
|
+
| INP013 | WARN | No `[END]` section |
|
|
120
|
+
| INP014 | WARN | Cross-reference limit reached; reference findings are a lower bound |
|
|
121
|
+
| INP020 | ERROR | Duplicate node/link identifier |
|
|
122
|
+
| INP021 | ERROR | Non-finite numeric literal (`nan`, `inf`) |
|
|
123
|
+
| INP022 | WARN | Value overflows IEEE-754 single precision |
|
|
124
|
+
| INP023 | WARN | Numeric literal uses digit separators (`1_000`) |
|
|
125
|
+
| INP030 | WARN | Reference to an undefined pattern |
|
|
126
|
+
| INP031 | WARN | Reference to an undefined curve |
|
|
127
|
+
| INP032 | ERROR | Link endpoint is not a defined node |
|
|
128
|
+
|
|
129
|
+
## Design notes
|
|
130
|
+
|
|
131
|
+
**Line splitting is deliberately narrow.** The file is split on LF and CRLF
|
|
132
|
+
only. It does *not* split on `\v`, `\f`, `\x1c`–`\x1e`, `\u2028`, or `\u0085`,
|
|
133
|
+
which `str.splitlines()` does honour. If the linter and the target parser
|
|
134
|
+
disagree about where a record ends, an attacker can craft a file that reads one
|
|
135
|
+
way to the linter and another way to the parser. The corpus contains an
|
|
136
|
+
explicit `unicode_line_sep` case asserting this.
|
|
137
|
+
|
|
138
|
+
**The UTF-8 BOM is stripped, not rejected.** Windows and spreadsheet tooling
|
|
139
|
+
emits BOMs routinely. A linter that rejects every such file gets switched off
|
|
140
|
+
in production within a week, and a linter that is switched off protects nobody.
|
|
141
|
+
|
|
142
|
+
**Limits are checked before the work they bound.** An oversized file is
|
|
143
|
+
reported and abandoned without walking its body.
|
|
144
|
+
|
|
145
|
+
**Findings carry byte offsets.** A finding you cannot locate is a finding
|
|
146
|
+
nobody acts on.
|
|
147
|
+
|
|
148
|
+
## Corpus
|
|
149
|
+
|
|
150
|
+
`inpguard/corpus.py` ships eleven cases: a clean model, a truncated transfer, a
|
|
151
|
+
Windows CRLF+BOM export, and adversarial inputs for dangling references,
|
|
152
|
+
duplicate identifiers, float32 overflow, NaN/Inf literals, embedded NULs,
|
|
153
|
+
Unicode line-separator smuggling, unknown sections, and oversized tokens.
|
|
154
|
+
|
|
155
|
+
These are not exploits. They are malformed or hostile *inputs* that a parser
|
|
156
|
+
should reject or survive. The corpus exists so that the check has something to
|
|
157
|
+
fail against, and so that a parser author can reproduce a report.
|
|
158
|
+
|
|
159
|
+
## Validation
|
|
160
|
+
|
|
161
|
+
Validated against 17 `.inp` files it did not author, ~37,400 lines in total,
|
|
162
|
+
from two independent publishing bodies.
|
|
163
|
+
|
|
164
|
+
**Nine benchmark distribution networks** — C-Town, d-town, Modena, Jilin, Zhi
|
|
165
|
+
Jiang, KL, ky1, ky8, RuralNetwork — sourced from the DiTEC-WDN corpus
|
|
166
|
+
(Truong et al., *Scientific Data* 2025): **zero errors, zero warnings**. Sizes
|
|
167
|
+
range from 234 to 7,731 lines.
|
|
168
|
+
|
|
169
|
+
**Eight files from the USEPA/WNTR reference suite**, including the 847 KB
|
|
170
|
+
`Net6_plus.inp` benchmark: five lint completely clean; the three that do not are
|
|
171
|
+
malformed on purpose and produce exactly one finding each, the one they were
|
|
172
|
+
built to contain.
|
|
173
|
+
|
|
174
|
+
The first run against those files produced 66 false positives and exposed three
|
|
175
|
+
real bugs (inline `;` comments, `[PUMPS]`/`[VALVES]` column indexing, repeated
|
|
176
|
+
sections). Each is fixed and carries a regression test. Adversarial self-testing
|
|
177
|
+
separately found and fixed a resource-amplification bug in this tool.
|
|
178
|
+
|
|
179
|
+
See `VALIDATION.md` for the full tables, method, and a candid statement of what
|
|
180
|
+
a clean result does and does not demonstrate.
|
|
181
|
+
|
|
182
|
+
## Status
|
|
183
|
+
|
|
184
|
+
`0.1.0` — alpha. The rule set is deliberately conservative: it flags only what
|
|
185
|
+
is unambiguous in the format specification. Rules that would warn on a
|
|
186
|
+
legitimate file exported from a common modelling tool are bugs, not features.
|
|
187
|
+
|
|
188
|
+
Output and memory are bounded: findings are capped per rule code (with the number
|
|
189
|
+
of elided occurrences reported, never silently dropped), and cross-reference
|
|
190
|
+
checking is capped with an explicit `INP014` rather than growing without limit.
|
|
191
|
+
A 3.2 MB adversarial file that once produced 400,002 findings and 287.9 MB peak
|
|
192
|
+
RSS now produces 106 findings and 38.7 MB. See `VALIDATION.md`.
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT.
|
inpguard-0.1.0/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# inpguard
|
|
2
|
+
|
|
3
|
+
Defensive linting for EPANET `.inp` water-network model files.
|
|
4
|
+
|
|
5
|
+
`inpguard` is a dependency-free Python library and CLI that inspects an EPANET
|
|
6
|
+
input file **before** it reaches a hydraulic solver. It reports malformed
|
|
7
|
+
structure, dangling cross-references, numeric values that overflow single
|
|
8
|
+
precision, embedded control bytes that desynchronise C string handling, and
|
|
9
|
+
other input hazards.
|
|
10
|
+
|
|
11
|
+
It is a *linter*, not a fuzzer and not an exploit. It never executes the file.
|
|
12
|
+
|
|
13
|
+
## Why this exists
|
|
14
|
+
|
|
15
|
+
Water utilities exchange `.inp` files constantly — with consultants, with
|
|
16
|
+
contractors, between distribution-system teams, and increasingly through
|
|
17
|
+
automated pipelines. The format is plain text, section-delimited, and widely
|
|
18
|
+
reimplemented in memory-unsafe languages. Most parsers assume the file is
|
|
19
|
+
benign because historically it always was.
|
|
20
|
+
|
|
21
|
+
That assumption is aging badly. Water systems are now an explicit target class,
|
|
22
|
+
and the people who steward these models — civil and environmental engineers —
|
|
23
|
+
are trained in hydraulics, not in adversarial input handling. The realistic
|
|
24
|
+
failure mode isn't a nation-state exploit; it's a subtle malformed file that
|
|
25
|
+
silently corrupts a hydraulic model, or a parser that crashes and takes a
|
|
26
|
+
modelling session with it.
|
|
27
|
+
|
|
28
|
+
`inpguard` gives the engineering side a two-second sanity check that speaks in
|
|
29
|
+
byte offsets and line numbers rather than stack traces.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install inpguard
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
For development, install from a clone:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git clone https://github.com/onurcetinkaya-ce/inpguard.git
|
|
41
|
+
cd inpguard
|
|
42
|
+
pip install -e .
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
No runtime dependencies. Python 3.9+.
|
|
46
|
+
|
|
47
|
+
## Use
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
inpguard check network.inp
|
|
51
|
+
inpguard check models/*.inp --json
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
network.inp: 412 bytes, 34 lines, 6 sections
|
|
56
|
+
ERROR INP032 line 14: link endpoint 'J9' is not a defined node
|
|
57
|
+
WARN INP022 line 12: value 1e39 in column 4 exceeds float32 range and will overflow to inf on a single-precision parser
|
|
58
|
+
1 error(s), 1 warning(s)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Exit codes: `0` clean, `1` error findings, `2` I/O or usage failure.
|
|
62
|
+
|
|
63
|
+
As a library:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from inpguard import lint_text
|
|
67
|
+
|
|
68
|
+
report = lint_text(open("network.inp").read())
|
|
69
|
+
for finding in report.sorted_findings():
|
|
70
|
+
print(finding)
|
|
71
|
+
print(report.ok())
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Rule reference
|
|
75
|
+
|
|
76
|
+
| Code | Severity | Meaning |
|
|
77
|
+
|------|----------|---------|
|
|
78
|
+
| INP001 | ERROR | File exceeds the 64 MiB ceiling (checked before parsing) |
|
|
79
|
+
| INP002 | ERROR | Physical line exceeds 8 KiB |
|
|
80
|
+
| INP003 | ERROR | Forbidden control byte (NUL, vertical tab, form feed, etc.) |
|
|
81
|
+
| INP004 | ERROR | Line is not valid UTF-8 |
|
|
82
|
+
| INP005 | ERROR | Empty or malformed section header |
|
|
83
|
+
| INP006 | ERROR | Data before the first section header |
|
|
84
|
+
| INP007 | ERROR | Section exceeds its row ceiling |
|
|
85
|
+
| INP008 | ERROR | Too many sections |
|
|
86
|
+
| INP010 | WARN | Unknown section name |
|
|
87
|
+
| INP011 | WARN | Section appears more than once |
|
|
88
|
+
| INP012 | WARN | Token exceeds 256 bytes (fixed-buffer hazard) |
|
|
89
|
+
| INP013 | WARN | No `[END]` section |
|
|
90
|
+
| INP014 | WARN | Cross-reference limit reached; reference findings are a lower bound |
|
|
91
|
+
| INP020 | ERROR | Duplicate node/link identifier |
|
|
92
|
+
| INP021 | ERROR | Non-finite numeric literal (`nan`, `inf`) |
|
|
93
|
+
| INP022 | WARN | Value overflows IEEE-754 single precision |
|
|
94
|
+
| INP023 | WARN | Numeric literal uses digit separators (`1_000`) |
|
|
95
|
+
| INP030 | WARN | Reference to an undefined pattern |
|
|
96
|
+
| INP031 | WARN | Reference to an undefined curve |
|
|
97
|
+
| INP032 | ERROR | Link endpoint is not a defined node |
|
|
98
|
+
|
|
99
|
+
## Design notes
|
|
100
|
+
|
|
101
|
+
**Line splitting is deliberately narrow.** The file is split on LF and CRLF
|
|
102
|
+
only. It does *not* split on `\v`, `\f`, `\x1c`–`\x1e`, `\u2028`, or `\u0085`,
|
|
103
|
+
which `str.splitlines()` does honour. If the linter and the target parser
|
|
104
|
+
disagree about where a record ends, an attacker can craft a file that reads one
|
|
105
|
+
way to the linter and another way to the parser. The corpus contains an
|
|
106
|
+
explicit `unicode_line_sep` case asserting this.
|
|
107
|
+
|
|
108
|
+
**The UTF-8 BOM is stripped, not rejected.** Windows and spreadsheet tooling
|
|
109
|
+
emits BOMs routinely. A linter that rejects every such file gets switched off
|
|
110
|
+
in production within a week, and a linter that is switched off protects nobody.
|
|
111
|
+
|
|
112
|
+
**Limits are checked before the work they bound.** An oversized file is
|
|
113
|
+
reported and abandoned without walking its body.
|
|
114
|
+
|
|
115
|
+
**Findings carry byte offsets.** A finding you cannot locate is a finding
|
|
116
|
+
nobody acts on.
|
|
117
|
+
|
|
118
|
+
## Corpus
|
|
119
|
+
|
|
120
|
+
`inpguard/corpus.py` ships eleven cases: a clean model, a truncated transfer, a
|
|
121
|
+
Windows CRLF+BOM export, and adversarial inputs for dangling references,
|
|
122
|
+
duplicate identifiers, float32 overflow, NaN/Inf literals, embedded NULs,
|
|
123
|
+
Unicode line-separator smuggling, unknown sections, and oversized tokens.
|
|
124
|
+
|
|
125
|
+
These are not exploits. They are malformed or hostile *inputs* that a parser
|
|
126
|
+
should reject or survive. The corpus exists so that the check has something to
|
|
127
|
+
fail against, and so that a parser author can reproduce a report.
|
|
128
|
+
|
|
129
|
+
## Validation
|
|
130
|
+
|
|
131
|
+
Validated against 17 `.inp` files it did not author, ~37,400 lines in total,
|
|
132
|
+
from two independent publishing bodies.
|
|
133
|
+
|
|
134
|
+
**Nine benchmark distribution networks** — C-Town, d-town, Modena, Jilin, Zhi
|
|
135
|
+
Jiang, KL, ky1, ky8, RuralNetwork — sourced from the DiTEC-WDN corpus
|
|
136
|
+
(Truong et al., *Scientific Data* 2025): **zero errors, zero warnings**. Sizes
|
|
137
|
+
range from 234 to 7,731 lines.
|
|
138
|
+
|
|
139
|
+
**Eight files from the USEPA/WNTR reference suite**, including the 847 KB
|
|
140
|
+
`Net6_plus.inp` benchmark: five lint completely clean; the three that do not are
|
|
141
|
+
malformed on purpose and produce exactly one finding each, the one they were
|
|
142
|
+
built to contain.
|
|
143
|
+
|
|
144
|
+
The first run against those files produced 66 false positives and exposed three
|
|
145
|
+
real bugs (inline `;` comments, `[PUMPS]`/`[VALVES]` column indexing, repeated
|
|
146
|
+
sections). Each is fixed and carries a regression test. Adversarial self-testing
|
|
147
|
+
separately found and fixed a resource-amplification bug in this tool.
|
|
148
|
+
|
|
149
|
+
See `VALIDATION.md` for the full tables, method, and a candid statement of what
|
|
150
|
+
a clean result does and does not demonstrate.
|
|
151
|
+
|
|
152
|
+
## Status
|
|
153
|
+
|
|
154
|
+
`0.1.0` — alpha. The rule set is deliberately conservative: it flags only what
|
|
155
|
+
is unambiguous in the format specification. Rules that would warn on a
|
|
156
|
+
legitimate file exported from a common modelling tool are bugs, not features.
|
|
157
|
+
|
|
158
|
+
Output and memory are bounded: findings are capped per rule code (with the number
|
|
159
|
+
of elided occurrences reported, never silently dropped), and cross-reference
|
|
160
|
+
checking is capped with an explicit `INP014` rather than growing without limit.
|
|
161
|
+
A 3.2 MB adversarial file that once produced 400,002 findings and 287.9 MB peak
|
|
162
|
+
RSS now produces 106 findings and 38.7 MB. See `VALIDATION.md`.
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""inpguard — defensive linting for EPANET .inp water-network model files.
|
|
2
|
+
|
|
3
|
+
Water utilities exchange .inp files constantly: between utilities, with
|
|
4
|
+
consultants, with contractors, and increasingly through automated pipelines.
|
|
5
|
+
Most parsers handle that input with the assumption that it is benign. This
|
|
6
|
+
package makes the malicious and the merely-malformed cases visible *before* the
|
|
7
|
+
file reaches a parser.
|
|
8
|
+
|
|
9
|
+
>>> from inpguard import lint_text
|
|
10
|
+
>>> report = lint_text(open("network.inp").read())
|
|
11
|
+
>>> print(report.ok())
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .lint import ERROR, INFO, WARN, Finding, Report, lint_bytes, lint_text
|
|
15
|
+
from .spec import KNOWN_SECTIONS, LIMITS
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ERROR",
|
|
21
|
+
"INFO",
|
|
22
|
+
"WARN",
|
|
23
|
+
"Finding",
|
|
24
|
+
"Report",
|
|
25
|
+
"KNOWN_SECTIONS",
|
|
26
|
+
"LIMITS",
|
|
27
|
+
"lint_bytes",
|
|
28
|
+
"lint_text",
|
|
29
|
+
"__version__",
|
|
30
|
+
]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""CLI entry point: `inpguard check network.inp`.
|
|
2
|
+
|
|
3
|
+
Exit codes are the contract for CI:
|
|
4
|
+
0 clean (no ERROR findings)
|
|
5
|
+
1 at least one ERROR finding
|
|
6
|
+
2 usage or I/O failure
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import __version__, lint_bytes
|
|
17
|
+
from .lint import Report
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _render_text(report: Report, path: str) -> str:
|
|
21
|
+
lines = [f"{path}: {report.bytes_read} bytes, {report.lines_read} lines, "
|
|
22
|
+
f"{len(report.sections)} sections"]
|
|
23
|
+
findings = report.sorted_findings()
|
|
24
|
+
if not findings:
|
|
25
|
+
lines.append(" clean")
|
|
26
|
+
return "\n".join(lines)
|
|
27
|
+
for finding in findings:
|
|
28
|
+
lines.append(f" {finding}")
|
|
29
|
+
for code, count in sorted(report.suppressed.items()):
|
|
30
|
+
lines.append(
|
|
31
|
+
f" NOTE {code:12s} {count} further finding(s) of this code suppressed"
|
|
32
|
+
)
|
|
33
|
+
errors = len(report.errors)
|
|
34
|
+
warns = len(report.warnings)
|
|
35
|
+
lines.append(f" {errors} error(s), {warns} warning(s)")
|
|
36
|
+
return "\n".join(lines)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _render_json(report: Report, path: str) -> str:
|
|
40
|
+
payload = {
|
|
41
|
+
"path": path,
|
|
42
|
+
"bytes": report.bytes_read,
|
|
43
|
+
"lines": report.lines_read,
|
|
44
|
+
"ok": report.ok(),
|
|
45
|
+
"sections": [s.name for s in report.sections],
|
|
46
|
+
"findings": [
|
|
47
|
+
{
|
|
48
|
+
"severity": f.severity,
|
|
49
|
+
"code": f.code,
|
|
50
|
+
"message": f.message,
|
|
51
|
+
"offset": f.offset,
|
|
52
|
+
"line": f.line,
|
|
53
|
+
"section": f.section,
|
|
54
|
+
}
|
|
55
|
+
for f in report.sorted_findings()
|
|
56
|
+
],
|
|
57
|
+
"suppressed": dict(sorted(report.suppressed.items())),
|
|
58
|
+
}
|
|
59
|
+
return json.dumps(payload, indent=2)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
63
|
+
parser = argparse.ArgumentParser(
|
|
64
|
+
prog="inpguard",
|
|
65
|
+
description="Defensive linting for EPANET .inp water-network model files.",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument("--version", action="version", version=f"inpguard {__version__}")
|
|
68
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
69
|
+
|
|
70
|
+
check = sub.add_parser("check", help="lint one or more .inp files")
|
|
71
|
+
check.add_argument("files", nargs="+", type=Path)
|
|
72
|
+
check.add_argument("--json", action="store_true", help="machine-readable output")
|
|
73
|
+
check.add_argument(
|
|
74
|
+
"--allow-references",
|
|
75
|
+
action="store_true",
|
|
76
|
+
help="skip cross-section reference checks (partial files)",
|
|
77
|
+
)
|
|
78
|
+
check.add_argument(
|
|
79
|
+
"--max-float",
|
|
80
|
+
type=float,
|
|
81
|
+
default=None,
|
|
82
|
+
help="override the single-precision ceiling",
|
|
83
|
+
)
|
|
84
|
+
return parser
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main(argv: list[str] | None = None) -> int:
|
|
88
|
+
args = build_parser().parse_args(argv)
|
|
89
|
+
|
|
90
|
+
worst = 0
|
|
91
|
+
for path in args.files:
|
|
92
|
+
try:
|
|
93
|
+
raw = path.read_bytes()
|
|
94
|
+
except OSError as exc:
|
|
95
|
+
print(f"{path}: cannot read: {exc}", file=sys.stderr)
|
|
96
|
+
worst = max(worst, 2)
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
report = lint_bytes(
|
|
100
|
+
raw,
|
|
101
|
+
strict_references=not args.allow_references,
|
|
102
|
+
float_ceiling=args.max_float,
|
|
103
|
+
)
|
|
104
|
+
out = _render_json(report, str(path)) if args.json else _render_text(report, str(path))
|
|
105
|
+
print(out)
|
|
106
|
+
if not report.ok():
|
|
107
|
+
worst = max(worst, 1)
|
|
108
|
+
|
|
109
|
+
return worst
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""CORPUS — a regression corpus of hand-built EPANET .inp inputs.
|
|
2
|
+
|
|
3
|
+
Every case here is a file a real water utility could plausibly receive: a clean
|
|
4
|
+
export from a modelling tool, a truncated transfer, a legacy file with CRLF
|
|
5
|
+
endings, and a set of adversarial files exercising specific parser assumptions.
|
|
6
|
+
|
|
7
|
+
The adversarial cases are NOT exploits. They are malformed or hostile *inputs*
|
|
8
|
+
that a parser should reject or survive. Publishing them is the point: the
|
|
9
|
+
defender needs a test suite, and the vendor needs a reproducible report.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
CLEAN = """\
|
|
17
|
+
[TITLE]
|
|
18
|
+
METU campus distribution model - synthetic
|
|
19
|
+
|
|
20
|
+
[JUNCTIONS]
|
|
21
|
+
;ID Elev Dem Pattern
|
|
22
|
+
J1 10.0 5.0 P1
|
|
23
|
+
J2 12.5 3.5 P1
|
|
24
|
+
|
|
25
|
+
[RESERVOIRS]
|
|
26
|
+
;ID Head
|
|
27
|
+
R1 55.0
|
|
28
|
+
|
|
29
|
+
[PIPES]
|
|
30
|
+
;ID Node1 Node2 Length Diam Rough Mloss Status
|
|
31
|
+
P1 R1 J1 120.0 300 130 0 Open
|
|
32
|
+
P2 J1 J2 240.0 200 130 0 Open
|
|
33
|
+
|
|
34
|
+
[PATTERNS]
|
|
35
|
+
;ID Factor
|
|
36
|
+
P1 1.0
|
|
37
|
+
P1 1.2
|
|
38
|
+
|
|
39
|
+
[OPTIONS]
|
|
40
|
+
Units LPS
|
|
41
|
+
Headloss H-W
|
|
42
|
+
|
|
43
|
+
[END]
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
# Truncated download / interrupted transfer: valid prefix, no terminator.
|
|
47
|
+
TRUNCATED = CLEAN.split("[OPTIONS]")[0]
|
|
48
|
+
|
|
49
|
+
# Legacy export with CRLF and a BOM.
|
|
50
|
+
CRLF_BOM = "\ufeff" + CLEAN.replace("\n", "\r\n")
|
|
51
|
+
|
|
52
|
+
# Dangling reference: pipe names a node that does not exist.
|
|
53
|
+
DANGLING_NODE = """\
|
|
54
|
+
[JUNCTIONS]
|
|
55
|
+
J1 10.0 5.0
|
|
56
|
+
|
|
57
|
+
[RESERVOIRS]
|
|
58
|
+
R1 55.0
|
|
59
|
+
|
|
60
|
+
[PIPES]
|
|
61
|
+
P1 R1 J9 120.0 300 130 0 Open
|
|
62
|
+
|
|
63
|
+
[END]
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
# Duplicate identifiers: depends on whether the parser overwrites or appends.
|
|
67
|
+
DUPLICATE_ID = """\
|
|
68
|
+
[JUNCTIONS]
|
|
69
|
+
J1 10.0 5.0
|
|
70
|
+
J1 12.0 4.0
|
|
71
|
+
|
|
72
|
+
[END]
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
# Numeric bombs: values that overflow single precision.
|
|
76
|
+
NUMERIC_BOMB = """\
|
|
77
|
+
[JUNCTIONS]
|
|
78
|
+
J1 10.0 5.0
|
|
79
|
+
|
|
80
|
+
[PIPES]
|
|
81
|
+
P1 J1 J1 1e39 300 130 0 Open
|
|
82
|
+
|
|
83
|
+
[END]
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
# NaN/Inf literals: strtod accepts these; many downstream models do not.
|
|
87
|
+
NONFINITE = """\
|
|
88
|
+
[JUNCTIONS]
|
|
89
|
+
J1 10.0 nan
|
|
90
|
+
J2 inf 5.0
|
|
91
|
+
|
|
92
|
+
[END]
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
# Embedded NUL: truncates C string handling, classic parser desync.
|
|
96
|
+
EMBEDDED_NUL = "[JUNCTIONS]\n J1\x0010.0 5.0\n\n[END]\n"
|
|
97
|
+
|
|
98
|
+
# Line-separator smuggling: the linter must NOT split these as newlines,
|
|
99
|
+
# otherwise an attacker can make the linter and the parser disagree.
|
|
100
|
+
UNICODE_LINE_SEP = "[JUNCTIONS]\n J1 10.0 5.0\u2028 J2 99.0 99.0\n\n[END]\n"
|
|
101
|
+
|
|
102
|
+
# Unknown section: an extension the parser may or may not understand.
|
|
103
|
+
UNKNOWN_SECTION = """\
|
|
104
|
+
[JUNCTIONS]
|
|
105
|
+
J1 10.0 5.0
|
|
106
|
+
|
|
107
|
+
[MYSTERY_EXTENSION]
|
|
108
|
+
whatever 1 2 3
|
|
109
|
+
|
|
110
|
+
[END]
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
# Long token: fixed-buffer parsers may truncate or overflow.
|
|
114
|
+
LONG_TOKEN = "[JUNCTIONS]\n " + ("A" * 5000) + " 10.0 5.0\n\n[END]\n"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class Case:
|
|
119
|
+
name: str
|
|
120
|
+
data: str
|
|
121
|
+
expect_codes: frozenset[str]
|
|
122
|
+
expect_ok: bool
|
|
123
|
+
note: str
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
CASES: tuple[Case, ...] = (
|
|
127
|
+
Case("clean", CLEAN, frozenset(), True, "well-formed file; must lint clean"),
|
|
128
|
+
Case("truncated", TRUNCATED, frozenset({"INP013"}), True,
|
|
129
|
+
"interrupted transfer; no [END]"),
|
|
130
|
+
Case("crlf_bom", CRLF_BOM, frozenset(), True,
|
|
131
|
+
"Windows export with BOM; must not false-positive"),
|
|
132
|
+
Case("dangling_node", DANGLING_NODE, frozenset({"INP032"}), False,
|
|
133
|
+
"referential integrity failure"),
|
|
134
|
+
Case("duplicate_id", DUPLICATE_ID, frozenset({"INP020"}), False,
|
|
135
|
+
"identifier collision"),
|
|
136
|
+
Case("numeric_bomb", NUMERIC_BOMB, frozenset({"INP022"}), True,
|
|
137
|
+
"float32 overflow"),
|
|
138
|
+
Case("nonfinite", NONFINITE, frozenset({"INP021"}), False,
|
|
139
|
+
"NaN/Inf literals"),
|
|
140
|
+
Case("embedded_nul", EMBEDDED_NUL, frozenset({"INP003"}), False,
|
|
141
|
+
"NUL truncates C strings"),
|
|
142
|
+
Case("unicode_line_sep", UNICODE_LINE_SEP, frozenset(), True,
|
|
143
|
+
"U+2028 must not split lines"),
|
|
144
|
+
Case("unknown_section", UNKNOWN_SECTION, frozenset({"INP010"}), True,
|
|
145
|
+
"undocumented extension"),
|
|
146
|
+
Case("long_token", LONG_TOKEN, frozenset({"INP012"}), True,
|
|
147
|
+
"fixed-buffer truncation hazard"),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get(name: str) -> Case:
|
|
152
|
+
for case in CASES:
|
|
153
|
+
if case.name == name:
|
|
154
|
+
return case
|
|
155
|
+
raise KeyError(name)
|