@matthesketh/fleet 1.6.0 → 1.8.0
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.
- package/dist/cli.js +3 -0
- package/dist/commands/guard.d.ts +1 -0
- package/dist/commands/guard.js +159 -0
- package/dist/core/routines/schema.d.ts +8 -8
- package/dist/core/routines/store.d.ts +16 -16
- package/package.json +2 -1
- package/scripts/guard/cert-expiry-watch +109 -0
- package/scripts/guard/cf-audit-monitor +178 -0
- package/scripts/guard/cf-snapshot +124 -0
- package/scripts/guard/cron.d-cf-protect +11 -0
- package/scripts/guard/dns-drift-watch +138 -0
- package/scripts/guard/fleet-guard +380 -0
- package/scripts/guard/fleet-guard-execute +197 -0
- package/scripts/guard/logrotate.d-fleet-guard +31 -0
- package/scripts/guard/notify +108 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
snapshot all cloudflare zone state (dns records + zone settings) into a
|
|
4
|
+
git repo at /var/lib/cf-snapshots/. each run commits a diff so we have a
|
|
5
|
+
full audit trail of what changed and when.
|
|
6
|
+
|
|
7
|
+
usage: cf-snapshot
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import urllib.request
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
CLAUDE_CFG = Path("/root/.claude.json")
|
|
17
|
+
SNAP_DIR = Path("/var/lib/cf-snapshots")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def read_creds():
|
|
21
|
+
cfg = json.loads(CLAUDE_CFG.read_text())
|
|
22
|
+
env = cfg.get("mcpServers", {}).get("cloudflare-mcp", {}).get("env", {}) or {}
|
|
23
|
+
api_key = env.get("CLOUDFLARE_API_KEY")
|
|
24
|
+
email = env.get("CLOUDFLARE_EMAIL")
|
|
25
|
+
if not api_key or not email:
|
|
26
|
+
sys.exit("cf creds missing in /root/.claude.json")
|
|
27
|
+
return api_key, email
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cf_get(url, api_key, email):
|
|
31
|
+
req = urllib.request.Request(url, headers={
|
|
32
|
+
"X-Auth-Email": email,
|
|
33
|
+
"X-Auth-Key": api_key,
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
})
|
|
36
|
+
with urllib.request.urlopen(req, timeout=20) as r:
|
|
37
|
+
return json.loads(r.read())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def list_zones(api_key, email):
|
|
41
|
+
out = []
|
|
42
|
+
page = 1
|
|
43
|
+
while True:
|
|
44
|
+
d = cf_get(
|
|
45
|
+
f"https://api.cloudflare.com/client/v4/zones?per_page=50&page={page}",
|
|
46
|
+
api_key, email,
|
|
47
|
+
)
|
|
48
|
+
out.extend(d.get("result") or [])
|
|
49
|
+
info = d.get("result_info") or {}
|
|
50
|
+
if page >= (info.get("total_pages") or 1):
|
|
51
|
+
break
|
|
52
|
+
page += 1
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def snapshot_zone(zone, api_key, email):
|
|
57
|
+
zid = zone["id"]
|
|
58
|
+
name = zone["name"]
|
|
59
|
+
records = cf_get(
|
|
60
|
+
f"https://api.cloudflare.com/client/v4/zones/{zid}/dns_records?per_page=100",
|
|
61
|
+
api_key, email,
|
|
62
|
+
).get("result") or []
|
|
63
|
+
settings = cf_get(
|
|
64
|
+
f"https://api.cloudflare.com/client/v4/zones/{zid}/settings",
|
|
65
|
+
api_key, email,
|
|
66
|
+
).get("result") or []
|
|
67
|
+
# strip volatile fields so diffs are meaningful
|
|
68
|
+
pruned_records = []
|
|
69
|
+
for r in records:
|
|
70
|
+
pruned_records.append({
|
|
71
|
+
k: r.get(k) for k in
|
|
72
|
+
("id", "type", "name", "content", "proxied", "ttl", "priority", "data")
|
|
73
|
+
if r.get(k) is not None
|
|
74
|
+
})
|
|
75
|
+
pruned_settings = [
|
|
76
|
+
{k: s.get(k) for k in ("id", "value")} for s in settings
|
|
77
|
+
]
|
|
78
|
+
return {
|
|
79
|
+
"zone": {"id": zid, "name": name, "status": zone.get("status")},
|
|
80
|
+
"records": sorted(pruned_records, key=lambda x: (x.get("type", ""), x.get("name", ""))),
|
|
81
|
+
"settings": sorted(pruned_settings, key=lambda x: x.get("id", "")),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def ensure_repo():
|
|
86
|
+
SNAP_DIR.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
if not (SNAP_DIR / ".git").exists():
|
|
88
|
+
subprocess.run(["git", "init", "-q", "-b", "main", str(SNAP_DIR)], check=True)
|
|
89
|
+
subprocess.run(["git", "-C", str(SNAP_DIR), "config", "user.email", "cf-snapshot@local"], check=True)
|
|
90
|
+
subprocess.run(["git", "-C", str(SNAP_DIR), "config", "user.name", "cf-snapshot"], check=True)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main():
|
|
94
|
+
api_key, email = read_creds()
|
|
95
|
+
ensure_repo()
|
|
96
|
+
zones = list_zones(api_key, email)
|
|
97
|
+
written = 0
|
|
98
|
+
for z in zones:
|
|
99
|
+
snap = snapshot_zone(z, api_key, email)
|
|
100
|
+
path = SNAP_DIR / f"{z['name']}.json"
|
|
101
|
+
path.write_text(json.dumps(snap, indent=2, sort_keys=True) + "\n")
|
|
102
|
+
written += 1
|
|
103
|
+
subprocess.run(["git", "-C", str(SNAP_DIR), "add", "-A"], check=True)
|
|
104
|
+
diff = subprocess.run(
|
|
105
|
+
["git", "-C", str(SNAP_DIR), "diff", "--cached", "--quiet"],
|
|
106
|
+
check=False,
|
|
107
|
+
)
|
|
108
|
+
if diff.returncode == 0:
|
|
109
|
+
return 0
|
|
110
|
+
msg = subprocess.run(
|
|
111
|
+
["git", "-C", str(SNAP_DIR), "diff", "--cached", "--stat"],
|
|
112
|
+
check=True, capture_output=True, text=True,
|
|
113
|
+
).stdout.strip()
|
|
114
|
+
subprocess.run(
|
|
115
|
+
["git", "-C", str(SNAP_DIR), "commit", "-q", "-m", f"snapshot: {written} zones"],
|
|
116
|
+
check=True,
|
|
117
|
+
)
|
|
118
|
+
# alert if a meaningful change happened
|
|
119
|
+
subprocess.run(["/usr/local/sbin/notify", "cf-snapshot diff",
|
|
120
|
+
msg[:1000] if msg else "(no diff stat)"], check=False)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
main()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# cloudflare protection layer cron entries
|
|
2
|
+
# - audit log monitor: every 15 min
|
|
3
|
+
# - state snapshot: every 30 min (catches changes audit log misses)
|
|
4
|
+
SHELL=/bin/bash
|
|
5
|
+
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
6
|
+
|
|
7
|
+
*/15 * * * * root /usr/local/sbin/cf-audit-monitor >> /var/log/cf-audit-monitor.log 2>&1
|
|
8
|
+
*/30 * * * * root /usr/local/sbin/cf-snapshot >> /var/log/cf-snapshot.log 2>&1
|
|
9
|
+
* * * * * fleet-guard /usr/local/sbin/fleet-guard execute >> /var/log/fleet-guard/execute.log 2>&1
|
|
10
|
+
17 4 * * * root /usr/local/sbin/cert-expiry-watch >> /var/log/cert-expiry-watch.log 2>&1
|
|
11
|
+
*/30 * * * * root /usr/local/sbin/dns-drift-watch >> /var/log/dns-drift-watch.log 2>&1
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
dns-drift-watch — verifies that public dns resolution for each cloudflare-
|
|
4
|
+
proxied hostname still resolves through cloudflare ip space. detects:
|
|
5
|
+
- attacker repointing a record to a non-cf ip
|
|
6
|
+
- accidental flipping of the proxied flag (orange to grey)
|
|
7
|
+
- nameserver hijack at registrar level
|
|
8
|
+
|
|
9
|
+
works against the latest snapshot in /var/lib/cf-snapshots. only checks A
|
|
10
|
+
records that were proxied=true at snapshot time. queries 1.1.1.1, 8.8.8.8,
|
|
11
|
+
and 9.9.9.9 for redundancy.
|
|
12
|
+
|
|
13
|
+
on drift, creates a fleet-guard hold (kind=dns_drift) so you can approve
|
|
14
|
+
or revert via telegram/imessage.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import socket
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
SNAP_DIR = Path("/var/lib/cf-snapshots")
|
|
24
|
+
GUARD = "/usr/local/sbin/fleet-guard"
|
|
25
|
+
NOTIFY = "/usr/local/sbin/notify"
|
|
26
|
+
RESOLVERS = ["1.1.1.1", "8.8.8.8", "9.9.9.9"]
|
|
27
|
+
|
|
28
|
+
# cloudflare's known v4 ranges. updated by /usr/local/sbin/refresh-cf-firewall.sh
|
|
29
|
+
CF_V4_PATH = Path("/etc/iptables/rules.v4")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cf_ranges():
|
|
33
|
+
"""parse cf v4 ranges from the iptables guard chain. fallback to bundled."""
|
|
34
|
+
if CF_V4_PATH.exists():
|
|
35
|
+
ranges = []
|
|
36
|
+
for line in CF_V4_PATH.read_text().splitlines():
|
|
37
|
+
if "CF-DOCKER-GUARD" in line and "-s " in line and "RETURN" in line:
|
|
38
|
+
parts = line.split()
|
|
39
|
+
for i, p in enumerate(parts):
|
|
40
|
+
if p == "-s" and i + 1 < len(parts):
|
|
41
|
+
ranges.append(parts[i + 1])
|
|
42
|
+
if ranges:
|
|
43
|
+
return ranges
|
|
44
|
+
# fallback: hardcoded list (cf publishes <20 ranges)
|
|
45
|
+
return [
|
|
46
|
+
"173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22",
|
|
47
|
+
"103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18",
|
|
48
|
+
"190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22",
|
|
49
|
+
"198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13",
|
|
50
|
+
"104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def in_range(ip, ranges):
|
|
55
|
+
import ipaddress
|
|
56
|
+
addr = ipaddress.ip_address(ip)
|
|
57
|
+
for r in ranges:
|
|
58
|
+
try:
|
|
59
|
+
if addr in ipaddress.ip_network(r):
|
|
60
|
+
return True
|
|
61
|
+
except ValueError:
|
|
62
|
+
continue
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve(host, resolver):
|
|
67
|
+
try:
|
|
68
|
+
out = subprocess.run(
|
|
69
|
+
["dig", "+short", "+tries=1", "+time=2", "A", host, f"@{resolver}"],
|
|
70
|
+
capture_output=True, text=True, timeout=5,
|
|
71
|
+
)
|
|
72
|
+
ips = [line.strip() for line in out.stdout.splitlines() if line.strip()]
|
|
73
|
+
# filter out cnames (lines that don't look like ipv4)
|
|
74
|
+
return [ip for ip in ips if ip.count(".") == 3 and all(p.isdigit() for p in ip.split("."))]
|
|
75
|
+
except Exception:
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def check_zone(zone_path):
|
|
80
|
+
snap = json.loads(zone_path.read_text())
|
|
81
|
+
zone_name = snap["zone"]["name"]
|
|
82
|
+
drifts = []
|
|
83
|
+
proxied_a_records = [
|
|
84
|
+
r for r in snap.get("records", [])
|
|
85
|
+
if r.get("type") == "A" and r.get("proxied") is True
|
|
86
|
+
]
|
|
87
|
+
cf = cf_ranges()
|
|
88
|
+
for r in proxied_a_records:
|
|
89
|
+
host = r.get("name")
|
|
90
|
+
if not host:
|
|
91
|
+
continue
|
|
92
|
+
all_ips = set()
|
|
93
|
+
for resolver in RESOLVERS:
|
|
94
|
+
all_ips.update(resolve(host, resolver))
|
|
95
|
+
if not all_ips:
|
|
96
|
+
# unresolvable — could be temporary, skip
|
|
97
|
+
continue
|
|
98
|
+
non_cf = [ip for ip in all_ips if not in_range(ip, cf)]
|
|
99
|
+
if non_cf:
|
|
100
|
+
drifts.append({
|
|
101
|
+
"host": host,
|
|
102
|
+
"expected": "cloudflare-proxied",
|
|
103
|
+
"got": sorted(all_ips),
|
|
104
|
+
"non_cf": non_cf,
|
|
105
|
+
"snapshot_content": r.get("content"),
|
|
106
|
+
})
|
|
107
|
+
return zone_name, drifts
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main():
|
|
111
|
+
if not SNAP_DIR.exists():
|
|
112
|
+
sys.exit("no snapshot directory at /var/lib/cf-snapshots")
|
|
113
|
+
|
|
114
|
+
all_drifts = []
|
|
115
|
+
for path in sorted(SNAP_DIR.glob("*.json")):
|
|
116
|
+
zone, drifts = check_zone(path)
|
|
117
|
+
for d in drifts:
|
|
118
|
+
d["zone"] = zone
|
|
119
|
+
all_drifts.append(d)
|
|
120
|
+
|
|
121
|
+
if not all_drifts:
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
# one hold per drift
|
|
125
|
+
for d in all_drifts:
|
|
126
|
+
summary = f"DNS drift: {d['host']} resolves to non-CF IPs {d['non_cf']}"
|
|
127
|
+
try:
|
|
128
|
+
subprocess.run(
|
|
129
|
+
[GUARD, "hold", "dns_drift", summary, "--payload", json.dumps(d)],
|
|
130
|
+
check=False, timeout=15,
|
|
131
|
+
)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
subprocess.run([NOTIFY, "dns-drift-watch hold failed", f"{e}\n{summary}"], check=False)
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
sys.exit(main())
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
fleet-guard — pending-action queue with out-of-band approval.
|
|
4
|
+
|
|
5
|
+
state layout under /var/lib/fleet-guard/:
|
|
6
|
+
pending/<token>.json — awaiting approval. created by `hold`.
|
|
7
|
+
approved/<token>.json — owner approved. picked up by executor.
|
|
8
|
+
processed/<token>.json — executor finished (success or fail).
|
|
9
|
+
|
|
10
|
+
token format: 22-char base32 (random, single-use). 256 bits of entropy.
|
|
11
|
+
|
|
12
|
+
cli verbs (all log to /var/log/fleet-guard/audit.jsonl):
|
|
13
|
+
hold <kind> <summary> [--payload '<json>'] create a pending hold + notify
|
|
14
|
+
list [pending|approved|processed] list tokens with summaries
|
|
15
|
+
show <token> dump one record
|
|
16
|
+
approve <token> [--actor <name>] mark approved
|
|
17
|
+
reject <token> [--actor <name>] mark rejected (deletes record)
|
|
18
|
+
status human summary
|
|
19
|
+
execute run all approved actions
|
|
20
|
+
|
|
21
|
+
approval is also mediated by the daemon (telegram callbacks/messages).
|
|
22
|
+
this cli is the fallback path + ssh-session override.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import base64
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import secrets
|
|
30
|
+
import shutil
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
import time
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
|
|
37
|
+
ROOT = Path("/var/lib/fleet-guard")
|
|
38
|
+
LOG = Path("/var/log/fleet-guard/audit.jsonl")
|
|
39
|
+
NOTIFY = "/usr/local/sbin/notify"
|
|
40
|
+
GUARD_CFG = Path("/etc/fleet/guard.json")
|
|
41
|
+
POLICY_CFG = Path("/etc/fleet/guard.policy.json")
|
|
42
|
+
DEFAULT_TTL = 600
|
|
43
|
+
|
|
44
|
+
PENDING = ROOT / "pending"
|
|
45
|
+
APPROVED = ROOT / "approved"
|
|
46
|
+
PROCESSED = ROOT / "processed"
|
|
47
|
+
|
|
48
|
+
# baseline policy: which audit-log action types create a hold (require
|
|
49
|
+
# approval) vs just notify. cf-audit-monitor consults `policy effective`
|
|
50
|
+
# per-event so admins can override per-zone via /etc/fleet/guard.policy.json.
|
|
51
|
+
DEFAULT_HOLD_ACTIONS = [
|
|
52
|
+
"zone_delete", "zone_create", "zone_settings_change",
|
|
53
|
+
"ns_change", "transfer_in", "transfer_out",
|
|
54
|
+
"member_add", "member_remove",
|
|
55
|
+
"api_token_create", "api_token_delete",
|
|
56
|
+
"ssl_change",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def utcnow():
|
|
61
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def epoch():
|
|
65
|
+
return int(time.time())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def log_event(event):
|
|
69
|
+
LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
entry = {"ts": utcnow(), **event}
|
|
71
|
+
with LOG.open("a") as f:
|
|
72
|
+
f.write(json.dumps(entry, default=str) + "\n")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def gen_token():
|
|
76
|
+
return base64.b32encode(secrets.token_bytes(14)).decode().rstrip("=")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_cfg():
|
|
80
|
+
if not GUARD_CFG.exists():
|
|
81
|
+
return {}
|
|
82
|
+
try:
|
|
83
|
+
return json.loads(GUARD_CFG.read_text())
|
|
84
|
+
except Exception:
|
|
85
|
+
return {}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def write_record(dirpath, token, record):
|
|
89
|
+
dirpath.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
path = dirpath / f"{token}.json"
|
|
91
|
+
tmp = path.with_suffix(".json.tmp")
|
|
92
|
+
tmp.write_text(json.dumps(record, indent=2, sort_keys=True))
|
|
93
|
+
tmp.rename(path)
|
|
94
|
+
os.chmod(path, 0o600)
|
|
95
|
+
return path
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def find_token(token):
|
|
99
|
+
"""search pending/approved/processed for a token"""
|
|
100
|
+
for d in (PENDING, APPROVED, PROCESSED):
|
|
101
|
+
p = d / f"{token}.json"
|
|
102
|
+
if p.exists():
|
|
103
|
+
return d, p
|
|
104
|
+
return None, None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cmd_hold(args):
|
|
108
|
+
token = gen_token()
|
|
109
|
+
cfg = load_cfg()
|
|
110
|
+
ttl = int(cfg.get("tokenTtlSeconds") or DEFAULT_TTL)
|
|
111
|
+
record = {
|
|
112
|
+
"token": token,
|
|
113
|
+
"kind": args.kind,
|
|
114
|
+
"summary": args.summary,
|
|
115
|
+
"payload": json.loads(args.payload) if args.payload else {},
|
|
116
|
+
"created_at": utcnow(),
|
|
117
|
+
"expires_at_epoch": epoch() + ttl,
|
|
118
|
+
"creator": os.environ.get("SUDO_USER") or os.environ.get("USER") or "unknown",
|
|
119
|
+
"creator_uid": os.getuid(),
|
|
120
|
+
}
|
|
121
|
+
write_record(PENDING, token, record)
|
|
122
|
+
log_event({"event": "hold_created", "token": token, "kind": args.kind, "summary": args.summary})
|
|
123
|
+
|
|
124
|
+
# notify owner
|
|
125
|
+
title = f"action held: {args.kind}"
|
|
126
|
+
body = (
|
|
127
|
+
f"{args.summary}\n"
|
|
128
|
+
f"approve: send `/approve {token}` to bot\n"
|
|
129
|
+
f"or: fleet-guard approve {token}\n"
|
|
130
|
+
f"expires in {ttl // 60}m"
|
|
131
|
+
)
|
|
132
|
+
try:
|
|
133
|
+
subprocess.run([NOTIFY, title, body], check=False, timeout=15)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
print(token)
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_list(args):
|
|
141
|
+
target = {"pending": PENDING, "approved": APPROVED, "processed": PROCESSED}.get(args.where, PENDING)
|
|
142
|
+
rows = []
|
|
143
|
+
if target.exists():
|
|
144
|
+
for path in sorted(target.glob("*.json")):
|
|
145
|
+
r = json.loads(path.read_text())
|
|
146
|
+
rows.append((r["token"], r.get("kind", "?"), r.get("summary", "")[:60]))
|
|
147
|
+
for t, k, s in rows:
|
|
148
|
+
print(f"{t} {k:<20} {s}")
|
|
149
|
+
if not rows:
|
|
150
|
+
print(f"(no records in {args.where})")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_show(args):
|
|
155
|
+
_, path = find_token(args.token)
|
|
156
|
+
if not path:
|
|
157
|
+
print(f"token not found: {args.token}", file=sys.stderr)
|
|
158
|
+
return 1
|
|
159
|
+
print(path.read_text())
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _expire_check(record):
|
|
164
|
+
return epoch() > int(record.get("expires_at_epoch", 0))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def cmd_approve(args):
|
|
168
|
+
src, path = find_token(args.token)
|
|
169
|
+
if path is None:
|
|
170
|
+
print(f"token not found: {args.token}", file=sys.stderr)
|
|
171
|
+
return 1
|
|
172
|
+
if src != PENDING:
|
|
173
|
+
print(f"token not in pending state (in {src.name})", file=sys.stderr)
|
|
174
|
+
return 1
|
|
175
|
+
record = json.loads(path.read_text())
|
|
176
|
+
if _expire_check(record):
|
|
177
|
+
log_event({"event": "approve_expired", "token": args.token, "actor": args.actor})
|
|
178
|
+
path.unlink()
|
|
179
|
+
print("token expired", file=sys.stderr)
|
|
180
|
+
return 1
|
|
181
|
+
record["approved_at"] = utcnow()
|
|
182
|
+
record["approver"] = args.actor or os.environ.get("SUDO_USER") or os.environ.get("USER") or "cli"
|
|
183
|
+
write_record(APPROVED, args.token, record)
|
|
184
|
+
path.unlink()
|
|
185
|
+
log_event({"event": "approved", "token": args.token, "kind": record.get("kind"),
|
|
186
|
+
"approver": record["approver"]})
|
|
187
|
+
# acknowledge
|
|
188
|
+
try:
|
|
189
|
+
subprocess.run([NOTIFY, "approval recorded",
|
|
190
|
+
f"{record.get('kind')} approved by {record['approver']}"],
|
|
191
|
+
check=False, timeout=10)
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
print(f"approved {args.token}")
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cmd_reject(args):
|
|
199
|
+
src, path = find_token(args.token)
|
|
200
|
+
if path is None or src != PENDING:
|
|
201
|
+
print(f"token not in pending state", file=sys.stderr)
|
|
202
|
+
return 1
|
|
203
|
+
record = json.loads(path.read_text())
|
|
204
|
+
record["rejected_at"] = utcnow()
|
|
205
|
+
record["rejecter"] = args.actor or os.environ.get("SUDO_USER") or os.environ.get("USER") or "cli"
|
|
206
|
+
write_record(PROCESSED, args.token, {**record, "outcome": "rejected"})
|
|
207
|
+
path.unlink()
|
|
208
|
+
log_event({"event": "rejected", "token": args.token, "actor": record["rejecter"]})
|
|
209
|
+
print(f"rejected {args.token}")
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def cmd_status(args):
|
|
214
|
+
counts = {}
|
|
215
|
+
for name, d in (("pending", PENDING), ("approved", APPROVED), ("processed", PROCESSED)):
|
|
216
|
+
counts[name] = len(list(d.glob("*.json"))) if d.exists() else 0
|
|
217
|
+
print(f"pending={counts['pending']} approved={counts['approved']} processed={counts['processed']}")
|
|
218
|
+
if PENDING.exists():
|
|
219
|
+
for path in sorted(PENDING.glob("*.json")):
|
|
220
|
+
r = json.loads(path.read_text())
|
|
221
|
+
ttl = int(r.get("expires_at_epoch", 0)) - epoch()
|
|
222
|
+
print(f" {r['token']} {r.get('kind')} ttl={ttl}s {r.get('summary', '')[:60]}")
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def load_policy():
|
|
227
|
+
"""returns {'default': {'hold': [...]}, 'zones': {<zone>: {'hold': [...]}}}.
|
|
228
|
+
missing file or invalid json → defaults only."""
|
|
229
|
+
if not POLICY_CFG.exists():
|
|
230
|
+
return {"default": {"hold": list(DEFAULT_HOLD_ACTIONS)}, "zones": {}}
|
|
231
|
+
try:
|
|
232
|
+
raw = json.loads(POLICY_CFG.read_text())
|
|
233
|
+
except Exception:
|
|
234
|
+
return {"default": {"hold": list(DEFAULT_HOLD_ACTIONS)}, "zones": {}}
|
|
235
|
+
raw.setdefault("default", {})
|
|
236
|
+
raw["default"].setdefault("hold", list(DEFAULT_HOLD_ACTIONS))
|
|
237
|
+
raw.setdefault("zones", {})
|
|
238
|
+
return raw
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def save_policy(policy):
|
|
242
|
+
POLICY_CFG.parent.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
tmp = POLICY_CFG.with_suffix(".json.tmp")
|
|
244
|
+
tmp.write_text(json.dumps(policy, indent=2, sort_keys=True))
|
|
245
|
+
tmp.rename(POLICY_CFG)
|
|
246
|
+
os.chmod(POLICY_CFG, 0o640)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def effective_hold_list(policy, zone):
|
|
250
|
+
"""zone-level override wins; falls back to default."""
|
|
251
|
+
if zone:
|
|
252
|
+
z = policy.get("zones", {}).get(zone)
|
|
253
|
+
if z and "hold" in z:
|
|
254
|
+
return z["hold"]
|
|
255
|
+
return policy.get("default", {}).get("hold", DEFAULT_HOLD_ACTIONS)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def cmd_policy(args):
|
|
259
|
+
sub = args.policy_cmd
|
|
260
|
+
policy = load_policy()
|
|
261
|
+
if sub == "show":
|
|
262
|
+
if args.zone:
|
|
263
|
+
hold = effective_hold_list(policy, args.zone)
|
|
264
|
+
print(json.dumps({"zone": args.zone, "hold": hold}, indent=2))
|
|
265
|
+
else:
|
|
266
|
+
print(json.dumps(policy, indent=2, sort_keys=True))
|
|
267
|
+
return 0
|
|
268
|
+
if sub == "set":
|
|
269
|
+
actions = [a.strip() for a in args.actions.split(",") if a.strip()]
|
|
270
|
+
if args.zone == "default":
|
|
271
|
+
policy.setdefault("default", {})["hold"] = actions
|
|
272
|
+
else:
|
|
273
|
+
policy.setdefault("zones", {}).setdefault(args.zone, {})["hold"] = actions
|
|
274
|
+
save_policy(policy)
|
|
275
|
+
log_event({"event": "policy_set", "zone": args.zone, "actions": actions})
|
|
276
|
+
print(f"set {args.zone} hold={actions}")
|
|
277
|
+
return 0
|
|
278
|
+
if sub == "reset":
|
|
279
|
+
if args.zone == "default" or args.zone == "":
|
|
280
|
+
policy["default"] = {"hold": list(DEFAULT_HOLD_ACTIONS)}
|
|
281
|
+
policy["zones"] = {}
|
|
282
|
+
else:
|
|
283
|
+
policy.get("zones", {}).pop(args.zone, None)
|
|
284
|
+
save_policy(policy)
|
|
285
|
+
log_event({"event": "policy_reset", "zone": args.zone or "all"})
|
|
286
|
+
print(f"reset {args.zone or 'all'}")
|
|
287
|
+
return 0
|
|
288
|
+
if sub == "effective":
|
|
289
|
+
# used by cf-audit-monitor. prints "hold" or "notify" then exits 0.
|
|
290
|
+
hold = effective_hold_list(policy, args.zone)
|
|
291
|
+
decision = "hold" if args.action in hold else "notify"
|
|
292
|
+
print(decision)
|
|
293
|
+
return 0
|
|
294
|
+
print(f"unknown policy subcommand: {sub}", file=sys.stderr)
|
|
295
|
+
return 1
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def cmd_execute(args):
|
|
299
|
+
"""invoke executor for each approved record. external script handles each kind."""
|
|
300
|
+
executor = "/usr/local/sbin/fleet-guard-execute"
|
|
301
|
+
if not Path(executor).exists():
|
|
302
|
+
print(f"executor not installed at {executor}", file=sys.stderr)
|
|
303
|
+
return 1
|
|
304
|
+
count = 0
|
|
305
|
+
for path in sorted(APPROVED.glob("*.json")):
|
|
306
|
+
record = json.loads(path.read_text())
|
|
307
|
+
try:
|
|
308
|
+
res = subprocess.run([executor], input=json.dumps(record), capture_output=True,
|
|
309
|
+
text=True, timeout=120)
|
|
310
|
+
ok = res.returncode == 0
|
|
311
|
+
outcome = "executed" if ok else "execute_failed"
|
|
312
|
+
record["executed_at"] = utcnow()
|
|
313
|
+
record["outcome"] = outcome
|
|
314
|
+
record["executor_stdout"] = res.stdout[-2000:]
|
|
315
|
+
record["executor_stderr"] = res.stderr[-2000:]
|
|
316
|
+
write_record(PROCESSED, record["token"], record)
|
|
317
|
+
path.unlink()
|
|
318
|
+
log_event({"event": outcome, "token": record["token"], "kind": record.get("kind")})
|
|
319
|
+
count += 1
|
|
320
|
+
except Exception as e:
|
|
321
|
+
log_event({"event": "execute_error", "token": record["token"], "err": str(e)})
|
|
322
|
+
print(f"processed {count} approvals")
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def main():
|
|
327
|
+
p = argparse.ArgumentParser(prog="fleet-guard")
|
|
328
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
329
|
+
|
|
330
|
+
sp = sub.add_parser("hold")
|
|
331
|
+
sp.add_argument("kind")
|
|
332
|
+
sp.add_argument("summary")
|
|
333
|
+
sp.add_argument("--payload", default="")
|
|
334
|
+
sp.set_defaults(fn=cmd_hold)
|
|
335
|
+
|
|
336
|
+
sp = sub.add_parser("list")
|
|
337
|
+
sp.add_argument("where", nargs="?", default="pending",
|
|
338
|
+
choices=["pending", "approved", "processed"])
|
|
339
|
+
sp.set_defaults(fn=cmd_list)
|
|
340
|
+
|
|
341
|
+
sp = sub.add_parser("show")
|
|
342
|
+
sp.add_argument("token")
|
|
343
|
+
sp.set_defaults(fn=cmd_show)
|
|
344
|
+
|
|
345
|
+
sp = sub.add_parser("approve")
|
|
346
|
+
sp.add_argument("token")
|
|
347
|
+
sp.add_argument("--actor", default="")
|
|
348
|
+
sp.set_defaults(fn=cmd_approve)
|
|
349
|
+
|
|
350
|
+
sp = sub.add_parser("reject")
|
|
351
|
+
sp.add_argument("token")
|
|
352
|
+
sp.add_argument("--actor", default="")
|
|
353
|
+
sp.set_defaults(fn=cmd_reject)
|
|
354
|
+
|
|
355
|
+
sp = sub.add_parser("status")
|
|
356
|
+
sp.set_defaults(fn=cmd_status)
|
|
357
|
+
|
|
358
|
+
sp = sub.add_parser("execute")
|
|
359
|
+
sp.set_defaults(fn=cmd_execute)
|
|
360
|
+
|
|
361
|
+
sp = sub.add_parser("policy", help="view/edit hold-action policy")
|
|
362
|
+
psub = sp.add_subparsers(dest="policy_cmd", required=True)
|
|
363
|
+
pshow = psub.add_parser("show", help="print policy (or effective hold list for zone)")
|
|
364
|
+
pshow.add_argument("zone", nargs="?", default="")
|
|
365
|
+
pset = psub.add_parser("set", help="set hold-action list for default or zone")
|
|
366
|
+
pset.add_argument("zone")
|
|
367
|
+
pset.add_argument("actions", help="comma-separated action types")
|
|
368
|
+
preset = psub.add_parser("reset", help="reset policy: omit zone to wipe all overrides")
|
|
369
|
+
preset.add_argument("zone", nargs="?", default="")
|
|
370
|
+
peff = psub.add_parser("effective", help="print 'hold' or 'notify' for an action+zone")
|
|
371
|
+
peff.add_argument("action")
|
|
372
|
+
peff.add_argument("--zone", default="")
|
|
373
|
+
sp.set_defaults(fn=cmd_policy)
|
|
374
|
+
|
|
375
|
+
args = p.parse_args()
|
|
376
|
+
sys.exit(args.fn(args))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
if __name__ == "__main__":
|
|
380
|
+
main()
|