awwall 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+
awwall/_doctor.py ADDED
@@ -0,0 +1,145 @@
1
+ """Stack-aware `doctor` for awwall.
2
+
3
+ GENERATED BY gen_aw_doctor.py -- DO NOT EDIT.
4
+ Regenerate it with the generator named above; a hand-edit here is reverted by
5
+ the next run and fails the parity gate.
6
+
7
+ Why a doctor exists at all: the aw* bricks are designed to COMPOSE, so the
8
+ interesting failures live BETWEEN them. "awwall is installed" is not the useful
9
+ fact -- "awwall is installed and the thing it pairs with is not" is. This reports
10
+ the whole stack, not just itself.
11
+
12
+ stdlib only, on purpose: a diagnostic that cannot run because a dependency is
13
+ missing is worthless precisely when you need it.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import importlib.util
18
+ import os
19
+ import shutil
20
+ import sys
21
+
22
+ #: Frozen from AitherOS/config/ecosystem.yaml at generation time. A shipped
23
+ #: package cannot read the registry, and a doctor that guessed at the family
24
+ #: would go stale in silence. Regenerate to update.
25
+ SELF = 'awwall'
26
+ FAMILY = [
27
+ 'awask', 'awbac', 'awbrowse', 'awdit', 'awevolve', 'awfind', 'awgit',
28
+ 'awgraph', 'awiam', 'awkno', 'awm', 'awmail', 'awnboard', 'awnest',
29
+ 'awnet', 'awnode', 'awpredict', 'awprism', 'awreason', 'awrecover',
30
+ 'awrecurse', 'awrelay', 'awrena', 'awrepl', 'awresearch', 'awrise',
31
+ 'awrun', 'awseal', 'awshare', 'awtunnel',
32
+ ]
33
+ PAIRS_WITH = ['awbac', 'awdit', 'adk', 'awiam', 'awnboard', 'awnest', 'awnet', 'awnode', 'awtunnel']
34
+
35
+ #: This brick's OWN config, read out of its source at generation time.
36
+ #: ENV_REQUIRED is `os.environ["X"]` -- absent, that is a KeyError the moment
37
+ #: the line runs. ENV_OPTIONAL is `os.getenv("X")`, which returns None and lets
38
+ #: the caller cope. Only this brick's namespace is listed: reporting the
39
+ #: platform-wide vars it also touches would be noise, and a doctor that floods
40
+ #: gets ignored.
41
+ ENV_REQUIRED = []
42
+ ENV_OPTIONAL = []
43
+
44
+
45
+ def _installed(mod: str) -> "str | None":
46
+ """Version if importable, else None. Never raises -- a broken sibling must
47
+ not take the diagnostic down with it."""
48
+ try:
49
+ if importlib.util.find_spec(mod) is None:
50
+ return None
51
+ except (ImportError, ValueError):
52
+ return None
53
+ try:
54
+ from importlib.metadata import PackageNotFoundError, version
55
+ try:
56
+ return version(mod)
57
+ except PackageNotFoundError:
58
+ return "installed"
59
+ except Exception:
60
+ return "installed"
61
+
62
+
63
+ def report(out=None) -> int:
64
+ """Print the stack picture. 0 = this brick and its pairs are present."""
65
+ out = out or sys.stdout
66
+ print(f"{SELF} doctor", file=out)
67
+
68
+ mine = _installed(SELF)
69
+ print(f" self {SELF} {mine or 'NOT IMPORTABLE'}", file=out)
70
+ shim = shutil.which(SELF)
71
+ print(f" command {shim or 'not on PATH'}", file=out)
72
+
73
+ # The stack. Siblings this brick pairs with are called out separately,
74
+ # because a missing pair is a REASON, while a missing unrelated brick is
75
+ # just a fact about your machine.
76
+ missing_pairs, present = [], []
77
+ for name in FAMILY:
78
+ v = _installed(name)
79
+ if v:
80
+ present.append(name)
81
+ elif name in PAIRS_WITH:
82
+ missing_pairs.append(name)
83
+ print(f" stack {len(present)}/{len(FAMILY)} aw* packages installed",
84
+ file=out)
85
+ if present:
86
+ print(f" {' '.join(sorted(present))}", file=out)
87
+
88
+ missing_req = [v for v in ENV_REQUIRED if not os.environ.get(v)]
89
+ if ENV_REQUIRED or ENV_OPTIONAL:
90
+ have = sum(1 for v in ENV_REQUIRED + ENV_OPTIONAL if os.environ.get(v))
91
+ total = len(ENV_REQUIRED) + len(ENV_OPTIONAL)
92
+ print(f" config {have}/{total} of this brick's own vars set", file=out)
93
+ if missing_req:
94
+ # Not a preference. os.environ[...] raises the moment it runs.
95
+ print(f" MISSING REQUIRED: {' '.join(missing_req)}", file=out)
96
+
97
+ local = _local_checks()
98
+ for line in local:
99
+ print(f" {line}", file=out)
100
+
101
+ if mine is None:
102
+ print(f"\nverdict: {SELF} itself is not importable. Reinstall it before "
103
+ f"anything else here means much.", file=out)
104
+ return 1
105
+ if missing_req:
106
+ print(f"\nverdict: {SELF} is missing required config "
107
+ f"({', '.join(missing_req)}). Those are read with os.environ[...], "
108
+ f"so the code path that needs them raises rather than degrades.",
109
+ file=out)
110
+ return 1
111
+ if missing_pairs:
112
+ print(f"\nverdict: {SELF} works, but pairs with "
113
+ f"{', '.join(sorted(missing_pairs))} which "
114
+ f"{'is' if len(missing_pairs) == 1 else 'are'} not installed. "
115
+ f"That is a capability you are missing, not an error.", file=out)
116
+ return 0
117
+ print(f"\nverdict: {SELF} and everything it pairs with are present.", file=out)
118
+ return 0
119
+
120
+
121
+ def _local_checks() -> "list[str]":
122
+ """Per-brick checks, if this package defines them.
123
+
124
+ Kept as a HOOK rather than generated guesses: the generator knows the family
125
+ from the registry, but it does not know what awwall needs at runtime, and a
126
+ doctor that invented config requirements would be confidently wrong. A
127
+ package supplies `_doctor_local()` returning display lines; absent, the
128
+ stack picture above still stands on its own.
129
+ """
130
+ try:
131
+ mod = importlib.import_module(f"{SELF}.doctor_local")
132
+ except Exception:
133
+ return []
134
+ try:
135
+ return list(mod._doctor_local())
136
+ except Exception as exc: # noqa: BLE001
137
+ return [f"local checks raised {type(exc).__name__}: {exc}"]
138
+
139
+
140
+ def main(argv: "list[str] | None" = None) -> int:
141
+ return report()
142
+
143
+
144
+ if __name__ == "__main__":
145
+ raise SystemExit(main())
awwall/cli.py ADDED
@@ -0,0 +1,294 @@
1
+ """awwall CLI: check and manage egress allowlists."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from awwall import AllowRule, Policy
9
+
10
+ DEFAULT_POLICY_FILE = Path.home() / ".awwall" / "policy.json"
11
+
12
+ def load_policy(policy_file=None):
13
+ """Load policy from file or return empty policy."""
14
+ if policy_file is None:
15
+ policy_file = DEFAULT_POLICY_FILE
16
+
17
+ if isinstance(policy_file, str):
18
+ policy_file = Path(policy_file)
19
+
20
+ if policy_file.exists():
21
+ try:
22
+ with open(policy_file, encoding='utf-8') as f:
23
+ data = json.load(f)
24
+ return Policy.from_dict(data)
25
+ except Exception as e:
26
+ print(f"ERROR: Failed to load policy: {e}", file=sys.stderr)
27
+ return None
28
+
29
+ return Policy()
30
+
31
+ def save_policy(policy, policy_file=None):
32
+ """Save policy to file."""
33
+ if policy_file is None:
34
+ policy_file = DEFAULT_POLICY_FILE
35
+
36
+ if isinstance(policy_file, str):
37
+ policy_file = Path(policy_file)
38
+
39
+ policy_file.parent.mkdir(parents=True, exist_ok=True)
40
+
41
+ with open(policy_file, 'w', encoding='utf-8') as f:
42
+ json.dump(policy.to_dict(), f, indent=2)
43
+
44
+ def cmd_allow(args):
45
+ """Add an allowed host to the policy."""
46
+ policy = load_policy(args.policy_file)
47
+ if policy is None:
48
+ return 2
49
+
50
+ # Determine rule type
51
+ if args.type:
52
+ rule_type = args.type
53
+ else:
54
+ if '*' in args.host:
55
+ rule_type = 'glob'
56
+ elif args.host.startswith('.') or '.' not in args.host:
57
+ rule_type = 'domain' if '.' not in args.host or args.host.startswith('.') else 'exact'
58
+ else:
59
+ rule_type = 'domain' if args.as_domain else 'exact'
60
+
61
+ new_rule = AllowRule(args.host, rule_type, args.description or "")
62
+
63
+ # Check for duplicates
64
+ for rule in policy.rules:
65
+ if rule.pattern.lower() == args.host.lower() and rule.rule_type == rule_type:
66
+ print(f"Rule already exists: {rule.pattern} ({rule_type})", file=sys.stderr)
67
+ return 1
68
+
69
+ policy.rules.append(new_rule)
70
+ save_policy(policy, args.policy_file)
71
+ print(f"Added: {args.host} ({rule_type})")
72
+ return 0
73
+
74
+ def cmd_check(args):
75
+ """Check if a host is allowed."""
76
+ policy = load_policy(args.policy_file)
77
+ if policy is None:
78
+ return 2
79
+
80
+ allowed, rule = policy.check(args.host)
81
+
82
+ if allowed:
83
+ if args.verbose:
84
+ print(f"ALLOWED: {args.host} (matches: {rule.pattern})")
85
+ return 0
86
+ else:
87
+ if args.verbose:
88
+ print(f"DENIED: {args.host} (no rule matched)")
89
+ return 1
90
+
91
+ def cmd_explain(args):
92
+ """Explain why a host was allowed or denied."""
93
+ policy = load_policy(args.policy_file)
94
+ if policy is None:
95
+ return 2
96
+
97
+ allowed, rule = policy.check(args.host)
98
+
99
+ if allowed:
100
+ print(f"ALLOWED: {args.host}")
101
+ print(f" Matched rule: {rule.pattern} (type: {rule.rule_type})")
102
+ if rule.description:
103
+ print(f" Description: {rule.description}")
104
+ return 0
105
+ else:
106
+ print(f"DENIED: {args.host}")
107
+ if policy.rules:
108
+ print(f" Reason: No rule matched (checked {len(policy.rules)} rule(s))")
109
+ else:
110
+ print(" Reason: Policy is empty (default deny)")
111
+ return 1
112
+
113
+ def cmd_emit(args):
114
+ """Emit policy in different formats."""
115
+ policy = load_policy(args.policy_file)
116
+ if policy is None:
117
+ return 2
118
+
119
+ if args.format == 'hosts':
120
+ output = policy.to_hosts_format()
121
+ elif args.format == 'iptables':
122
+ output = policy.to_iptables_format()
123
+ elif args.format == 'json':
124
+ output = json.dumps(policy.to_dict(), indent=2)
125
+ else:
126
+ print(f"Unknown format: {args.format}", file=sys.stderr)
127
+ return 1
128
+
129
+ if args.output:
130
+ with open(args.output, 'w', encoding='utf-8') as f:
131
+ f.write(output)
132
+ print(f"Wrote {args.format} format to {args.output}")
133
+ return 0
134
+ else:
135
+ print(output, end='')
136
+ return 0
137
+
138
+ def cmd_list(args):
139
+ """List all rules in the policy."""
140
+ policy = load_policy(args.policy_file)
141
+ if policy is None:
142
+ return 2
143
+
144
+ if not policy.rules:
145
+ print("No rules defined (default: deny everything)")
146
+ return 0
147
+
148
+ print(f"Policy rules ({len(policy.rules)} total):\n")
149
+ for i, rule in enumerate(policy.rules, 1):
150
+ desc = f" - {rule.description}" if rule.description else ""
151
+ print(f" {i}. {rule.pattern} [{rule.rule_type}]{desc}")
152
+
153
+ return 0
154
+
155
+ def cmd_self_test(args):
156
+ """Run self-tests to verify the policy engine."""
157
+ print("Running awwall self-tests...")
158
+
159
+ # Test 1: Default deny (empty policy)
160
+ empty_policy = Policy()
161
+ allowed, _ = empty_policy.check("example.com")
162
+ assert not allowed, "FAIL: Empty policy should deny all hosts"
163
+ print(" [PASS] Empty policy denies all")
164
+
165
+ # Test 2: Exact match
166
+ exact_policy = Policy([AllowRule("example.com", "exact")])
167
+ allowed, rule = exact_policy.check("example.com")
168
+ assert allowed and rule.pattern == "example.com", "FAIL: Exact match failed"
169
+ print(" [PASS] Exact match works")
170
+
171
+ # Test 3: Exact match negative (should deny similar)
172
+ allowed, _ = exact_policy.check("api.example.com")
173
+ assert not allowed, "FAIL: Exact match should not match subdomain"
174
+ print(" [PASS] Exact match rejects subdomains")
175
+
176
+ # Test 4: Domain match (includes subdomains)
177
+ domain_policy = Policy([AllowRule("example.com", "domain")])
178
+ allowed, _ = domain_policy.check("api.example.com")
179
+ assert allowed, "FAIL: Domain match should include subdomains"
180
+ print(" [PASS] Domain match includes subdomains")
181
+
182
+ # Test 5: Domain doesn't match different domain
183
+ allowed, _ = domain_policy.check("notexample.com")
184
+ assert not allowed, "FAIL: Domain match should not match different domain"
185
+ print(" [PASS] Domain match rejects different domain")
186
+
187
+ # Test 6: Glob pattern
188
+ glob_policy = Policy([AllowRule("*.example.com", "glob")])
189
+ allowed, _ = glob_policy.check("api.example.com")
190
+ assert allowed, "FAIL: Glob pattern failed"
191
+ print(" [PASS] Glob pattern works")
192
+
193
+ # Test 7: Glob denies non-matching
194
+ allowed, _ = glob_policy.check("api.other.com")
195
+ assert not allowed, "FAIL: Glob should deny non-matching"
196
+ print(" [PASS] Glob rejects non-matching")
197
+
198
+ # Test 8: Case insensitivity
199
+ allowed, _ = exact_policy.check("EXAMPLE.COM")
200
+ assert allowed, "FAIL: Policy should be case-insensitive"
201
+ print(" [PASS] Case insensitive matching")
202
+
203
+ # Test 9: Whitespace trimming
204
+ allowed, _ = exact_policy.check(" example.com ")
205
+ assert allowed, "FAIL: Policy should trim whitespace"
206
+ print(" [PASS] Whitespace trimming works")
207
+
208
+ # Test 10: Malformed policy rejection
209
+ try:
210
+ Policy.from_dict({"rules": "not-a-list"})
211
+ assert False, "FAIL: Should reject malformed policy"
212
+ except ValueError:
213
+ print(" [PASS] Rejects malformed policy")
214
+
215
+ # Test 11: Fail-closed on missing policy file
216
+ missing_policy = load_policy("/nonexistent/path/policy.json")
217
+ allowed, _ = missing_policy.check("example.com")
218
+ assert not allowed, "FAIL: Missing policy file should use empty (deny-all) policy"
219
+ print(" [PASS] Missing policy file defaults to deny-all")
220
+
221
+ print("\nAll self-tests passed!")
222
+ return 0
223
+
224
+ def main():
225
+ """Main CLI entry point."""
226
+ # GENERATED doctor intercept (gen_aw_doctor.py) -- do not edit
227
+ _dv = locals().get("argv")
228
+ if (_dv if _dv is not None else __import__("sys").argv[1:])[:1] == ["doctor"]:
229
+ from ._doctor import report
230
+ return report()
231
+ parser = argparse.ArgumentParser(
232
+ prog='awwall',
233
+ description='Egress allowlist that fails closed: declare what a workload '
234
+ 'may reach, watch everything else fail with the rule that '
235
+ 'denied it.'
236
+ )
237
+
238
+ parser.add_argument(
239
+ '--policy-file', type=str,
240
+ help=f'Policy file (default: {DEFAULT_POLICY_FILE})')
241
+ parser.add_argument('--self-test', action='store_true', help='Run self-tests')
242
+
243
+ subparsers = parser.add_subparsers(dest='command', help='Command to run')
244
+
245
+ # allow command
246
+ allow_parser = subparsers.add_parser('allow', help='Add a host to the allowlist')
247
+ allow_parser.add_argument('host', help='Hostname to allow')
248
+ allow_parser.add_argument(
249
+ '--type', choices=['exact', 'domain', 'glob'],
250
+ help='Rule type (auto-detect if not specified)')
251
+ allow_parser.add_argument(
252
+ '--as-domain', action='store_true',
253
+ help='Treat as domain rule (default for multi-part hosts)')
254
+ allow_parser.add_argument('--description', help='Rule description')
255
+ allow_parser.set_defaults(func=cmd_allow)
256
+
257
+ # check command
258
+ check_parser = subparsers.add_parser('check', help='Check if a host is allowed')
259
+ check_parser.add_argument('host', help='Hostname to check')
260
+ check_parser.add_argument(
261
+ '-v', '--verbose', action='store_true', help='Verbose output')
262
+ check_parser.set_defaults(func=cmd_check)
263
+
264
+ # explain command
265
+ explain_parser = subparsers.add_parser(
266
+ 'explain', help='Explain why a host is allowed or denied')
267
+ explain_parser.add_argument('host', help='Hostname to explain')
268
+ explain_parser.set_defaults(func=cmd_explain)
269
+
270
+ # emit command
271
+ emit_parser = subparsers.add_parser('emit', help='Emit policy in different formats')
272
+ emit_parser.add_argument(
273
+ '--format', choices=['hosts', 'iptables', 'json'],
274
+ default='json', help='Output format')
275
+ emit_parser.add_argument('--output', help='Output file (default: stdout)')
276
+ emit_parser.set_defaults(func=cmd_emit)
277
+
278
+ # list command
279
+ list_parser = subparsers.add_parser('list', help='List all rules in the policy')
280
+ list_parser.set_defaults(func=cmd_list)
281
+
282
+ args = parser.parse_args()
283
+
284
+ if args.self_test:
285
+ return cmd_self_test(args)
286
+ elif hasattr(args, 'func'):
287
+ return args.func(args)
288
+ else:
289
+ parser.print_help()
290
+ return 0
291
+
292
+ if __name__ == '__main__':
293
+ sys.exit(main())
294
+