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 +121 -0
- awwall/_doctor.py +145 -0
- awwall/cli.py +294 -0
- awwall-0.1.0.dist-info/METADATA +242 -0
- awwall-0.1.0.dist-info/RECORD +12 -0
- awwall-0.1.0.dist-info/WHEEL +5 -0
- awwall-0.1.0.dist-info/entry_points.txt +2 -0
- awwall-0.1.0.dist-info/licenses/LICENSE +21 -0
- awwall-0.1.0.dist-info/top_level.txt +3 -0
- scripts/check_moat_boundary.py +254 -0
- tests/__init__.py +1 -0
- tests/test_awwall.py +318 -0
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
|
+
|