awwall 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.
- awwall-0.1.0/LICENSE +21 -0
- awwall-0.1.0/PKG-INFO +242 -0
- awwall-0.1.0/README.md +231 -0
- awwall-0.1.0/awwall/__init__.py +121 -0
- awwall-0.1.0/awwall/_doctor.py +145 -0
- awwall-0.1.0/awwall/cli.py +294 -0
- awwall-0.1.0/awwall.egg-info/PKG-INFO +242 -0
- awwall-0.1.0/awwall.egg-info/SOURCES.txt +14 -0
- awwall-0.1.0/awwall.egg-info/dependency_links.txt +1 -0
- awwall-0.1.0/awwall.egg-info/entry_points.txt +2 -0
- awwall-0.1.0/awwall.egg-info/top_level.txt +4 -0
- awwall-0.1.0/pyproject.toml +18 -0
- awwall-0.1.0/scripts/check_moat_boundary.py +254 -0
- awwall-0.1.0/setup.cfg +4 -0
- awwall-0.1.0/tests/__init__.py +1 -0
- awwall-0.1.0/tests/test_awwall.py +318 -0
awwall-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AitherOS Contributors
|
|
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.
|
awwall-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: awwall
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Egress allowlist that fails closed: declare what a workload may reach, watch everything else fail with the rule that denied it.
|
|
5
|
+
Author-email: Aither World <dev@aitherium.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# awwall
|
|
13
|
+
|
|
14
|
+
Egress allowlist that fails closed: declare what a workload may reach, watch everything else fail with the rule that denied it.
|
|
15
|
+
|
|
16
|
+
## What It Does
|
|
17
|
+
|
|
18
|
+
**awwall** is a Python package that provides an egress allowlist policy engine. It works by:
|
|
19
|
+
|
|
20
|
+
1. **Failing closed by default** — an empty policy denies all outbound connections
|
|
21
|
+
2. **Allowing only what you declare** — add rules for hosts you trust
|
|
22
|
+
3. **Explaining denials** — when a connection is blocked, you see exactly which rule (or lack thereof) caused it
|
|
23
|
+
4. **Multiple output formats** — emit policy as JSON, `/etc/hosts`, or shell commands for iptables
|
|
24
|
+
|
|
25
|
+
Rules come in three types:
|
|
26
|
+
- **exact** — `example.com` matches only `example.com`
|
|
27
|
+
- **domain** — `example.com` matches `example.com`, `api.example.com`, `v1.api.example.com`, etc.
|
|
28
|
+
- **glob** — `*.example.com` matches any subdomain
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install awwall
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## The Adoption Guarantee
|
|
37
|
+
|
|
38
|
+
Block one outbound host for one workload and watch the call fail closed with the rule that denied it.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# 1. Create an empty policy (denies everything)
|
|
42
|
+
$ awwall list
|
|
43
|
+
No rules defined (default: deny everything)
|
|
44
|
+
|
|
45
|
+
# 2. Check if google.com is allowed
|
|
46
|
+
$ awwall check google.com
|
|
47
|
+
$ echo $?
|
|
48
|
+
1 # Denied!
|
|
49
|
+
|
|
50
|
+
# 3. Explain why
|
|
51
|
+
$ awwall explain google.com
|
|
52
|
+
DENIED: google.com
|
|
53
|
+
Reason: Policy is empty (default deny)
|
|
54
|
+
|
|
55
|
+
# 4. Allow one host
|
|
56
|
+
$ awwall allow github.com --type domain --description "GitHub repositories"
|
|
57
|
+
Added: github.com (domain)
|
|
58
|
+
|
|
59
|
+
# 5. Check again
|
|
60
|
+
$ awwall check github.com
|
|
61
|
+
$ echo $?
|
|
62
|
+
0 # Allowed!
|
|
63
|
+
|
|
64
|
+
$ awwall check api.github.com
|
|
65
|
+
$ echo $?
|
|
66
|
+
0 # Subdomains allowed too (domain rule)
|
|
67
|
+
|
|
68
|
+
# 6. But google.com is still blocked
|
|
69
|
+
$ awwall check google.com
|
|
70
|
+
$ echo $?
|
|
71
|
+
1 # Still denied
|
|
72
|
+
|
|
73
|
+
$ awwall explain google.com
|
|
74
|
+
DENIED: google.com
|
|
75
|
+
Reason: No rule matched (checked 1 rule(s))
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## CLI Commands
|
|
79
|
+
|
|
80
|
+
### `awwall allow <host>`
|
|
81
|
+
Add a host to the allowlist.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
awwall allow api.example.com # Inferred as exact match
|
|
85
|
+
awwall allow example.com --type domain # Explicit domain rule (allows subdomains)
|
|
86
|
+
awwall allow *.cdn.com --type glob # Glob pattern
|
|
87
|
+
awwall allow example.com --description "Prod API" --type exact
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### `awwall check <host>`
|
|
91
|
+
Check if a host is allowed (exit 0 = allowed, exit 1 = denied).
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
awwall check example.com # Silent
|
|
95
|
+
awwall check example.com -v # Verbose output
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### `awwall explain <host>`
|
|
99
|
+
Explain why a host is allowed or denied.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
$ awwall explain api.example.com
|
|
103
|
+
ALLOWED: api.example.com
|
|
104
|
+
Matched rule: example.com (type: domain)
|
|
105
|
+
Description: Production API
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### `awwall emit --format <format>`
|
|
109
|
+
Emit policy in different formats.
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
awwall emit --format json # Print as JSON
|
|
113
|
+
awwall emit --format hosts # /etc/hosts format
|
|
114
|
+
awwall emit --format iptables # Shell script with iptables rules
|
|
115
|
+
awwall emit --format json --output policy.json # Save to file
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### `awwall list`
|
|
119
|
+
List all rules in the policy.
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
$ awwall list
|
|
123
|
+
Policy rules (2 total):
|
|
124
|
+
|
|
125
|
+
1. api.example.com [exact] - Production API
|
|
126
|
+
2. example.com [domain] - All subdomains
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### `awwall --self-test`
|
|
130
|
+
Run self-tests to verify the policy engine.
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
$ awwall --self-test
|
|
134
|
+
Running awwall self-tests...
|
|
135
|
+
[PASS] Empty policy denies all
|
|
136
|
+
[PASS] Exact match works
|
|
137
|
+
[PASS] Exact match rejects subdomains
|
|
138
|
+
[PASS] Domain match includes subdomains
|
|
139
|
+
[PASS] Domain match rejects different domain
|
|
140
|
+
[PASS] Glob pattern works
|
|
141
|
+
[PASS] Glob rejects non-matching
|
|
142
|
+
[PASS] Case insensitive matching
|
|
143
|
+
[PASS] Whitespace trimming works
|
|
144
|
+
[PASS] Rejects malformed policy
|
|
145
|
+
[PASS] Missing policy file defaults to deny-all
|
|
146
|
+
|
|
147
|
+
All self-tests passed!
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Policy File Format
|
|
151
|
+
|
|
152
|
+
By default, policies are stored in `~/.awwall/policy.json`:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"rules": [
|
|
157
|
+
{
|
|
158
|
+
"pattern": "api.example.com",
|
|
159
|
+
"rule_type": "exact",
|
|
160
|
+
"description": "Production API"
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"pattern": "example.com",
|
|
164
|
+
"rule_type": "domain",
|
|
165
|
+
"description": "All example.com subdomains"
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"pattern": "*.cdn.com",
|
|
169
|
+
"rule_type": "glob",
|
|
170
|
+
"description": "CDN patterns"
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Specify a different file with `--policy-file`:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
awwall --policy-file /etc/awwall/prod.json check example.com
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Exit Codes
|
|
183
|
+
|
|
184
|
+
- **0** — Success (check: host allowed, command worked)
|
|
185
|
+
- **1** — Denied (check: host blocked) or command failed
|
|
186
|
+
- **2** — Cannot judge (malformed policy, missing file in strict mode)
|
|
187
|
+
|
|
188
|
+
A policy file that cannot be parsed exits with code 2 (cannot judge), never 0. This prevents silent failures.
|
|
189
|
+
|
|
190
|
+
## Python API
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from awwall import Policy, AllowRule
|
|
194
|
+
|
|
195
|
+
# Create a policy
|
|
196
|
+
policy = Policy([
|
|
197
|
+
AllowRule("example.com", "exact"),
|
|
198
|
+
AllowRule("api.other.com", "domain"),
|
|
199
|
+
])
|
|
200
|
+
|
|
201
|
+
# Check a host
|
|
202
|
+
allowed, matching_rule = policy.check("api.other.com")
|
|
203
|
+
if allowed:
|
|
204
|
+
print(f"Allowed by rule: {matching_rule.pattern}")
|
|
205
|
+
else:
|
|
206
|
+
print("Denied: no rule matched")
|
|
207
|
+
|
|
208
|
+
# Load from file
|
|
209
|
+
policy = Policy.from_file("/path/to/policy.json")
|
|
210
|
+
|
|
211
|
+
# Load from dict
|
|
212
|
+
policy = Policy.from_dict({"rules": [...]})
|
|
213
|
+
|
|
214
|
+
# Export
|
|
215
|
+
print(policy.to_hosts_format())
|
|
216
|
+
print(policy.to_iptables_format())
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Testing
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
pytest tests/test_awwall.py -v
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The test suite includes:
|
|
226
|
+
- **Default deny verification** — empty policy blocks everything
|
|
227
|
+
- **Rule type tests** — exact, domain, and glob matching
|
|
228
|
+
- **Negative tests** — verify rules DON'T match when they shouldn't
|
|
229
|
+
- **Fail-closed proofs** — malformed policy is treated as empty (deny all)
|
|
230
|
+
- **Roundtrip tests** — export and reimport preserves semantics
|
|
231
|
+
|
|
232
|
+
## Design Principles
|
|
233
|
+
|
|
234
|
+
1. **Fail closed by default** — empty policy denies all, malformed policy denies all
|
|
235
|
+
2. **Transparent denials** — every denied connection names the rule that caused it
|
|
236
|
+
3. **Simple rules** — exact, domain suffix, and glob patterns cover 99% of real use cases
|
|
237
|
+
4. **No magic** — no attempt to detect "safe" IPs or make assumptions
|
|
238
|
+
5. **Exportable** — policy can be rendered for other tools (hosts file, iptables, etc.)
|
|
239
|
+
|
|
240
|
+
## License
|
|
241
|
+
|
|
242
|
+
MIT
|
awwall-0.1.0/README.md
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# awwall
|
|
2
|
+
|
|
3
|
+
Egress allowlist that fails closed: declare what a workload may reach, watch everything else fail with the rule that denied it.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
**awwall** is a Python package that provides an egress allowlist policy engine. It works by:
|
|
8
|
+
|
|
9
|
+
1. **Failing closed by default** — an empty policy denies all outbound connections
|
|
10
|
+
2. **Allowing only what you declare** — add rules for hosts you trust
|
|
11
|
+
3. **Explaining denials** — when a connection is blocked, you see exactly which rule (or lack thereof) caused it
|
|
12
|
+
4. **Multiple output formats** — emit policy as JSON, `/etc/hosts`, or shell commands for iptables
|
|
13
|
+
|
|
14
|
+
Rules come in three types:
|
|
15
|
+
- **exact** — `example.com` matches only `example.com`
|
|
16
|
+
- **domain** — `example.com` matches `example.com`, `api.example.com`, `v1.api.example.com`, etc.
|
|
17
|
+
- **glob** — `*.example.com` matches any subdomain
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install awwall
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## The Adoption Guarantee
|
|
26
|
+
|
|
27
|
+
Block one outbound host for one workload and watch the call fail closed with the rule that denied it.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# 1. Create an empty policy (denies everything)
|
|
31
|
+
$ awwall list
|
|
32
|
+
No rules defined (default: deny everything)
|
|
33
|
+
|
|
34
|
+
# 2. Check if google.com is allowed
|
|
35
|
+
$ awwall check google.com
|
|
36
|
+
$ echo $?
|
|
37
|
+
1 # Denied!
|
|
38
|
+
|
|
39
|
+
# 3. Explain why
|
|
40
|
+
$ awwall explain google.com
|
|
41
|
+
DENIED: google.com
|
|
42
|
+
Reason: Policy is empty (default deny)
|
|
43
|
+
|
|
44
|
+
# 4. Allow one host
|
|
45
|
+
$ awwall allow github.com --type domain --description "GitHub repositories"
|
|
46
|
+
Added: github.com (domain)
|
|
47
|
+
|
|
48
|
+
# 5. Check again
|
|
49
|
+
$ awwall check github.com
|
|
50
|
+
$ echo $?
|
|
51
|
+
0 # Allowed!
|
|
52
|
+
|
|
53
|
+
$ awwall check api.github.com
|
|
54
|
+
$ echo $?
|
|
55
|
+
0 # Subdomains allowed too (domain rule)
|
|
56
|
+
|
|
57
|
+
# 6. But google.com is still blocked
|
|
58
|
+
$ awwall check google.com
|
|
59
|
+
$ echo $?
|
|
60
|
+
1 # Still denied
|
|
61
|
+
|
|
62
|
+
$ awwall explain google.com
|
|
63
|
+
DENIED: google.com
|
|
64
|
+
Reason: No rule matched (checked 1 rule(s))
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## CLI Commands
|
|
68
|
+
|
|
69
|
+
### `awwall allow <host>`
|
|
70
|
+
Add a host to the allowlist.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
awwall allow api.example.com # Inferred as exact match
|
|
74
|
+
awwall allow example.com --type domain # Explicit domain rule (allows subdomains)
|
|
75
|
+
awwall allow *.cdn.com --type glob # Glob pattern
|
|
76
|
+
awwall allow example.com --description "Prod API" --type exact
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### `awwall check <host>`
|
|
80
|
+
Check if a host is allowed (exit 0 = allowed, exit 1 = denied).
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
awwall check example.com # Silent
|
|
84
|
+
awwall check example.com -v # Verbose output
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### `awwall explain <host>`
|
|
88
|
+
Explain why a host is allowed or denied.
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
$ awwall explain api.example.com
|
|
92
|
+
ALLOWED: api.example.com
|
|
93
|
+
Matched rule: example.com (type: domain)
|
|
94
|
+
Description: Production API
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `awwall emit --format <format>`
|
|
98
|
+
Emit policy in different formats.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
awwall emit --format json # Print as JSON
|
|
102
|
+
awwall emit --format hosts # /etc/hosts format
|
|
103
|
+
awwall emit --format iptables # Shell script with iptables rules
|
|
104
|
+
awwall emit --format json --output policy.json # Save to file
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### `awwall list`
|
|
108
|
+
List all rules in the policy.
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
$ awwall list
|
|
112
|
+
Policy rules (2 total):
|
|
113
|
+
|
|
114
|
+
1. api.example.com [exact] - Production API
|
|
115
|
+
2. example.com [domain] - All subdomains
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### `awwall --self-test`
|
|
119
|
+
Run self-tests to verify the policy engine.
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
$ awwall --self-test
|
|
123
|
+
Running awwall self-tests...
|
|
124
|
+
[PASS] Empty policy denies all
|
|
125
|
+
[PASS] Exact match works
|
|
126
|
+
[PASS] Exact match rejects subdomains
|
|
127
|
+
[PASS] Domain match includes subdomains
|
|
128
|
+
[PASS] Domain match rejects different domain
|
|
129
|
+
[PASS] Glob pattern works
|
|
130
|
+
[PASS] Glob rejects non-matching
|
|
131
|
+
[PASS] Case insensitive matching
|
|
132
|
+
[PASS] Whitespace trimming works
|
|
133
|
+
[PASS] Rejects malformed policy
|
|
134
|
+
[PASS] Missing policy file defaults to deny-all
|
|
135
|
+
|
|
136
|
+
All self-tests passed!
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Policy File Format
|
|
140
|
+
|
|
141
|
+
By default, policies are stored in `~/.awwall/policy.json`:
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{
|
|
145
|
+
"rules": [
|
|
146
|
+
{
|
|
147
|
+
"pattern": "api.example.com",
|
|
148
|
+
"rule_type": "exact",
|
|
149
|
+
"description": "Production API"
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
"pattern": "example.com",
|
|
153
|
+
"rule_type": "domain",
|
|
154
|
+
"description": "All example.com subdomains"
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"pattern": "*.cdn.com",
|
|
158
|
+
"rule_type": "glob",
|
|
159
|
+
"description": "CDN patterns"
|
|
160
|
+
}
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Specify a different file with `--policy-file`:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
awwall --policy-file /etc/awwall/prod.json check example.com
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Exit Codes
|
|
172
|
+
|
|
173
|
+
- **0** — Success (check: host allowed, command worked)
|
|
174
|
+
- **1** — Denied (check: host blocked) or command failed
|
|
175
|
+
- **2** — Cannot judge (malformed policy, missing file in strict mode)
|
|
176
|
+
|
|
177
|
+
A policy file that cannot be parsed exits with code 2 (cannot judge), never 0. This prevents silent failures.
|
|
178
|
+
|
|
179
|
+
## Python API
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from awwall import Policy, AllowRule
|
|
183
|
+
|
|
184
|
+
# Create a policy
|
|
185
|
+
policy = Policy([
|
|
186
|
+
AllowRule("example.com", "exact"),
|
|
187
|
+
AllowRule("api.other.com", "domain"),
|
|
188
|
+
])
|
|
189
|
+
|
|
190
|
+
# Check a host
|
|
191
|
+
allowed, matching_rule = policy.check("api.other.com")
|
|
192
|
+
if allowed:
|
|
193
|
+
print(f"Allowed by rule: {matching_rule.pattern}")
|
|
194
|
+
else:
|
|
195
|
+
print("Denied: no rule matched")
|
|
196
|
+
|
|
197
|
+
# Load from file
|
|
198
|
+
policy = Policy.from_file("/path/to/policy.json")
|
|
199
|
+
|
|
200
|
+
# Load from dict
|
|
201
|
+
policy = Policy.from_dict({"rules": [...]})
|
|
202
|
+
|
|
203
|
+
# Export
|
|
204
|
+
print(policy.to_hosts_format())
|
|
205
|
+
print(policy.to_iptables_format())
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Testing
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
pytest tests/test_awwall.py -v
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The test suite includes:
|
|
215
|
+
- **Default deny verification** — empty policy blocks everything
|
|
216
|
+
- **Rule type tests** — exact, domain, and glob matching
|
|
217
|
+
- **Negative tests** — verify rules DON'T match when they shouldn't
|
|
218
|
+
- **Fail-closed proofs** — malformed policy is treated as empty (deny all)
|
|
219
|
+
- **Roundtrip tests** — export and reimport preserves semantics
|
|
220
|
+
|
|
221
|
+
## Design Principles
|
|
222
|
+
|
|
223
|
+
1. **Fail closed by default** — empty policy denies all, malformed policy denies all
|
|
224
|
+
2. **Transparent denials** — every denied connection names the rule that caused it
|
|
225
|
+
3. **Simple rules** — exact, domain suffix, and glob patterns cover 99% of real use cases
|
|
226
|
+
4. **No magic** — no attempt to detect "safe" IPs or make assumptions
|
|
227
|
+
5. **Exportable** — policy can be rendered for other tools (hosts file, iptables, etc.)
|
|
228
|
+
|
|
229
|
+
## License
|
|
230
|
+
|
|
231
|
+
MIT
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""awwall: egress allowlist that fails closed."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import NamedTuple, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AllowRule(NamedTuple):
|
|
8
|
+
pattern: str
|
|
9
|
+
rule_type: str
|
|
10
|
+
description: str = ""
|
|
11
|
+
|
|
12
|
+
class Policy:
|
|
13
|
+
def __init__(self, rules: Optional[list] = None):
|
|
14
|
+
self.rules = rules or []
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def from_dict(cls, data):
|
|
18
|
+
if not isinstance(data, dict):
|
|
19
|
+
raise ValueError("Policy must be a dictionary")
|
|
20
|
+
rules_data = data.get("rules", [])
|
|
21
|
+
if not isinstance(rules_data, list):
|
|
22
|
+
raise ValueError("rules must be a list")
|
|
23
|
+
rules = []
|
|
24
|
+
for rule_dict in rules_data:
|
|
25
|
+
if not isinstance(rule_dict, dict):
|
|
26
|
+
raise ValueError("Each rule must be a dictionary")
|
|
27
|
+
pattern = rule_dict.get("pattern")
|
|
28
|
+
rule_type = rule_dict.get("rule_type")
|
|
29
|
+
description = rule_dict.get("description", "")
|
|
30
|
+
if not pattern or not rule_type:
|
|
31
|
+
raise ValueError("Each rule must have pattern and rule_type")
|
|
32
|
+
if rule_type not in ("exact", "domain", "glob"):
|
|
33
|
+
raise ValueError(f"Invalid rule_type: {rule_type}")
|
|
34
|
+
rules.append(AllowRule(pattern, rule_type, description))
|
|
35
|
+
return cls(rules)
|
|
36
|
+
|
|
37
|
+
def check(self, host):
|
|
38
|
+
host = host.strip().lower()
|
|
39
|
+
for rule in self.rules:
|
|
40
|
+
if self._rule_matches(host, rule):
|
|
41
|
+
return True, rule
|
|
42
|
+
return False, None
|
|
43
|
+
|
|
44
|
+
def _rule_matches(self, host, rule):
|
|
45
|
+
pattern = rule.pattern.lower()
|
|
46
|
+
if rule.rule_type == "exact":
|
|
47
|
+
return host == pattern
|
|
48
|
+
elif rule.rule_type == "domain":
|
|
49
|
+
if host == pattern:
|
|
50
|
+
return True
|
|
51
|
+
if host.endswith("." + pattern):
|
|
52
|
+
return True
|
|
53
|
+
return False
|
|
54
|
+
elif rule.rule_type == "glob":
|
|
55
|
+
regex = re.escape(pattern).replace(r"\*", ".*")
|
|
56
|
+
return bool(re.fullmatch(regex, host))
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
def to_dict(self):
|
|
60
|
+
return {
|
|
61
|
+
"rules": [
|
|
62
|
+
{
|
|
63
|
+
"pattern": rule.pattern,
|
|
64
|
+
"rule_type": rule.rule_type,
|
|
65
|
+
"description": rule.description
|
|
66
|
+
}
|
|
67
|
+
for rule in self.rules
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
def to_hosts_format(self):
|
|
72
|
+
lines = [
|
|
73
|
+
"# Generated by awwall - egress allowlist",
|
|
74
|
+
"# NOTE: This only represents the rules in awwall format.",
|
|
75
|
+
"# Use 'awwall emit --format iptables' for actual firewall rules.",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
for rule in self.rules:
|
|
79
|
+
if rule.rule_type in ("exact", "domain"):
|
|
80
|
+
comment = f" # {rule.description}" if rule.description else ""
|
|
81
|
+
lines.append(f"127.0.0.1 {rule.pattern}{comment}")
|
|
82
|
+
|
|
83
|
+
return "\n".join(lines) + "\n"
|
|
84
|
+
|
|
85
|
+
def to_iptables_format(self):
|
|
86
|
+
lines = [
|
|
87
|
+
"#!/bin/bash",
|
|
88
|
+
"# Generated by awwall - egress allowlist",
|
|
89
|
+
"# NOTE: These rules require integration with DNS resolution.",
|
|
90
|
+
"# Recommended: use this with conntrack + DNS interception.",
|
|
91
|
+
"",
|
|
92
|
+
"# Default: DROP outbound",
|
|
93
|
+
"iptables -P OUTPUT DROP",
|
|
94
|
+
"iptables -P FORWARD DROP",
|
|
95
|
+
"",
|
|
96
|
+
"# Allow localhost",
|
|
97
|
+
"iptables -A OUTPUT -d 127.0.0.1 -j ACCEPT",
|
|
98
|
+
"iptables -A OUTPUT -d ::1 -j ACCEPT",
|
|
99
|
+
"",
|
|
100
|
+
"# Allow related/established",
|
|
101
|
+
"iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT",
|
|
102
|
+
"",
|
|
103
|
+
"# Rules (requires DNS A/AAAA record resolution)",
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
for rule in self.rules:
|
|
107
|
+
comment = f" # {rule.description}" if rule.description else ""
|
|
108
|
+
if rule.rule_type == "exact":
|
|
109
|
+
lines.append(f"# EXACT: {rule.pattern}{comment}")
|
|
110
|
+
lines.append(f"# iptables -A OUTPUT -d <IP-of-{rule.pattern}> -j ACCEPT")
|
|
111
|
+
elif rule.rule_type == "domain":
|
|
112
|
+
lines.append(f"# DOMAIN: {rule.pattern}{comment}")
|
|
113
|
+
lines.append(f"# iptables -A OUTPUT -d <IP-of-*.{rule.pattern}> -j ACCEPT")
|
|
114
|
+
elif rule.rule_type == "glob":
|
|
115
|
+
lines.append(f"# GLOB: {rule.pattern}{comment}")
|
|
116
|
+
lines.append(f"# iptables -A OUTPUT -d <IP-matching-{rule.pattern}> -j ACCEPT")
|
|
117
|
+
|
|
118
|
+
return "\n".join(lines) + "\n"
|
|
119
|
+
|
|
120
|
+
__all__ = ["Policy", "AllowRule"]
|
|
121
|
+
|