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