@chain305/x-security 0.1.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/LICENSE +201 -0
- package/README.md +31 -0
- package/bin/scripts/README.md +110 -0
- package/bin/scripts/writ-refresh.service +24 -0
- package/bin/scripts/writ-refresh.sh +103 -0
- package/bin/scripts/writ-refresh.timer +15 -0
- package/bin/scripts/writ-resolve.sh +223 -0
- package/bin/scripts/writ.logrotate +13 -0
- package/bin/xsecurity.mjs +76021 -0
- package/package.json +25 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# writ-resolve.sh — deploy-time DNS resolver for Writ firewall rules.
|
|
3
|
+
#
|
|
4
|
+
# Reads an iptables-save-format ruleset containing @@WRIT_RESOLVE:<fqdn>@@
|
|
5
|
+
# tokens (produced by `lazy generate --target firewall`) and rewrites
|
|
6
|
+
# each token into one or more concrete `-d <addr>` clauses by resolving the
|
|
7
|
+
# FQDN against the host's system resolver.
|
|
8
|
+
#
|
|
9
|
+
# Generated by Writ — DO NOT EDIT in place. If you need to customize
|
|
10
|
+
# behavior, override via the environment variables documented in the
|
|
11
|
+
# accompanying README.md.
|
|
12
|
+
#
|
|
13
|
+
# Security notes (read these):
|
|
14
|
+
# - We use the system resolver via /etc/resolv.conf. The operator is
|
|
15
|
+
# responsible for ensuring that resolver is trustworthy (DNSSEC,
|
|
16
|
+
# internal-only DNS, etc.). A poisoned resolver can punch holes in the
|
|
17
|
+
# allowlist.
|
|
18
|
+
# - Allowlist mode only: this script emits *additional* ACCEPT lines for
|
|
19
|
+
# resolved addresses. It NEVER emits REJECT and NEVER weakens the
|
|
20
|
+
# default-deny terminator that the generator placed at the tail of the
|
|
21
|
+
# ruleset. Fail-closed is preserved.
|
|
22
|
+
# - On total resolution failure (no FQDN resolves), the script exits
|
|
23
|
+
# non-zero WITHOUT writing output. Combined with the refresh wrapper's
|
|
24
|
+
# hold-previous-rules behavior, this yields fail-closed semantics
|
|
25
|
+
# against the allowlist (no allowed destinations === everything drops).
|
|
26
|
+
# - The log is append-only with ISO-8601 timestamps. Rotation is expected
|
|
27
|
+
# via the provided logrotate.d snippet.
|
|
28
|
+
#
|
|
29
|
+
# Usage:
|
|
30
|
+
# writ-resolve.sh [--rules-file PATH] [--out PATH] [--lenient]
|
|
31
|
+
# ... | writ-resolve.sh > resolved.rules
|
|
32
|
+
#
|
|
33
|
+
# Exit codes:
|
|
34
|
+
# 0 success — all tokens resolved
|
|
35
|
+
# 1 hard failure — at least one FQDN failed to resolve (strict mode)
|
|
36
|
+
# 2 total failure — no FQDN resolved (even in --lenient mode)
|
|
37
|
+
# 3 usage error
|
|
38
|
+
|
|
39
|
+
set -eu
|
|
40
|
+
|
|
41
|
+
LOG_FILE="${WRIT_LOG:-/var/log/writ-resolve.log}"
|
|
42
|
+
LENIENT=0
|
|
43
|
+
RULES_FILE=""
|
|
44
|
+
OUT_FILE=""
|
|
45
|
+
|
|
46
|
+
log() {
|
|
47
|
+
# ISO-8601 UTC timestamp; append-only.
|
|
48
|
+
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
49
|
+
printf '%s %s\n' "$ts" "$*" >> "$LOG_FILE" 2>/dev/null || \
|
|
50
|
+
printf '%s %s\n' "$ts" "$*" >&2
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
usage() {
|
|
54
|
+
cat >&2 <<'EOF'
|
|
55
|
+
writ-resolve.sh [--rules-file PATH] [--out PATH] [--lenient]
|
|
56
|
+
|
|
57
|
+
--rules-file PATH Read template from PATH instead of stdin.
|
|
58
|
+
--out PATH Write resolved ruleset to PATH instead of stdout.
|
|
59
|
+
--lenient Continue if some (but not all) FQDNs fail to resolve.
|
|
60
|
+
Default is strict: any failed resolution => exit 1.
|
|
61
|
+
|
|
62
|
+
Environment:
|
|
63
|
+
WRIT_LOG Override log path (default /var/log/writ-resolve.log)
|
|
64
|
+
EOF
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
while [ $# -gt 0 ]; do
|
|
68
|
+
case "$1" in
|
|
69
|
+
--rules-file) RULES_FILE="$2"; shift 2 ;;
|
|
70
|
+
--out) OUT_FILE="$2"; shift 2 ;;
|
|
71
|
+
--lenient) LENIENT=1; shift ;;
|
|
72
|
+
-h|--help) usage; exit 0 ;;
|
|
73
|
+
*) usage; exit 3 ;;
|
|
74
|
+
esac
|
|
75
|
+
done
|
|
76
|
+
|
|
77
|
+
# Pick a resolver. getent ahosts gives us A + AAAA + canonicalization in one
|
|
78
|
+
# call and respects /etc/nsswitch.conf — preferred. Fall back to `dig +short`
|
|
79
|
+
# if getent is unavailable (some minimal containers strip glibc tooling).
|
|
80
|
+
resolve_fqdn() {
|
|
81
|
+
fqdn="$1"
|
|
82
|
+
if command -v getent >/dev/null 2>&1; then
|
|
83
|
+
# getent ahosts emits lines like: "1.2.3.4 STREAM hostname"
|
|
84
|
+
# We deduplicate addresses since ahosts repeats them per socktype.
|
|
85
|
+
getent ahosts "$fqdn" 2>/dev/null | awk '{print $1}' | sort -u
|
|
86
|
+
elif command -v dig >/dev/null 2>&1; then
|
|
87
|
+
{
|
|
88
|
+
dig +short +time=2 +tries=2 A "$fqdn" 2>/dev/null
|
|
89
|
+
dig +short +time=2 +tries=2 AAAA "$fqdn" 2>/dev/null
|
|
90
|
+
} | grep -E '^[0-9a-fA-F:.]+$' | sort -u
|
|
91
|
+
else
|
|
92
|
+
log "ERROR: neither getent nor dig is available; cannot resolve $fqdn"
|
|
93
|
+
return 1
|
|
94
|
+
fi
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# Read input
|
|
98
|
+
if [ -n "$RULES_FILE" ]; then
|
|
99
|
+
[ -r "$RULES_FILE" ] || { log "ERROR: cannot read $RULES_FILE"; exit 3; }
|
|
100
|
+
INPUT=$(cat "$RULES_FILE")
|
|
101
|
+
else
|
|
102
|
+
INPUT=$(cat)
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
# Pass 1: extract every @@WRIT_RESOLVE:<fqdn>@@ token. Skip comment
|
|
106
|
+
# lines (the generator's header documents the token format with a literal
|
|
107
|
+
# `@@WRIT_RESOLVE:<fqdn>@@` example — we must not try to resolve
|
|
108
|
+
# `<fqdn>` as a real hostname).
|
|
109
|
+
FQDNS=$(printf '%s\n' "$INPUT" \
|
|
110
|
+
| grep -v '^[[:space:]]*#' \
|
|
111
|
+
| grep -oE '@@WRIT_RESOLVE:[^@]+@@' \
|
|
112
|
+
| sed -e 's/^@@WRIT_RESOLVE://' -e 's/@@$//' \
|
|
113
|
+
| sort -u)
|
|
114
|
+
|
|
115
|
+
TOTAL=0
|
|
116
|
+
RESOLVED=0
|
|
117
|
+
FAILED=0
|
|
118
|
+
|
|
119
|
+
# Build a sed script that, for each FQDN, expands the placeholder line into
|
|
120
|
+
# one resolved line per address. We do this by emitting newline-delimited
|
|
121
|
+
# replacement text using a tab-separated address list, then awk does the
|
|
122
|
+
# multi-line expansion.
|
|
123
|
+
|
|
124
|
+
# Stage 1: build an address-map file. Format: <fqdn>\t<addr>
|
|
125
|
+
TMP_MAP=$(mktemp)
|
|
126
|
+
trap 'rm -f "$TMP_MAP"' EXIT
|
|
127
|
+
|
|
128
|
+
if [ -n "$FQDNS" ]; then
|
|
129
|
+
echo "$FQDNS" | while IFS= read -r fqdn; do
|
|
130
|
+
[ -z "$fqdn" ] && continue
|
|
131
|
+
TOTAL=$((TOTAL + 1))
|
|
132
|
+
addrs=$(resolve_fqdn "$fqdn" || true)
|
|
133
|
+
if [ -z "$addrs" ]; then
|
|
134
|
+
log "WARN: failed to resolve $fqdn"
|
|
135
|
+
printf '%s\t__WRIT_UNRESOLVED__\n' "$fqdn" >> "$TMP_MAP"
|
|
136
|
+
else
|
|
137
|
+
log "INFO: resolved $fqdn -> $(echo "$addrs" | tr '\n' ' ')"
|
|
138
|
+
echo "$addrs" | while IFS= read -r addr; do
|
|
139
|
+
[ -z "$addr" ] && continue
|
|
140
|
+
printf '%s\t%s\n' "$fqdn" "$addr" >> "$TMP_MAP"
|
|
141
|
+
done
|
|
142
|
+
fi
|
|
143
|
+
done
|
|
144
|
+
fi
|
|
145
|
+
|
|
146
|
+
# Recompute counts (the while-subshell can't update parent vars portably).
|
|
147
|
+
# Note: `grep -c` on zero matches prints "0" AND exits 1, so we can't pair
|
|
148
|
+
# it with `|| echo 0` (that would emit two lines). Use a wc-based count and
|
|
149
|
+
# always trim to a single integer.
|
|
150
|
+
TOTAL=$(printf '%s\n' "$FQDNS" | grep -v '^$' | wc -l | tr -d ' \n')
|
|
151
|
+
FAILED=$(grep -c '__WRIT_UNRESOLVED__' "$TMP_MAP" 2>/dev/null | head -n 1 | tr -d ' \n')
|
|
152
|
+
[ -z "$FAILED" ] && FAILED=0
|
|
153
|
+
RESOLVED=$((TOTAL - FAILED))
|
|
154
|
+
|
|
155
|
+
# Strict / lenient gating.
|
|
156
|
+
if [ "$TOTAL" -gt 0 ] && [ "$RESOLVED" -eq 0 ]; then
|
|
157
|
+
log "FATAL: zero of $TOTAL FQDNs resolved; refusing to emit ruleset"
|
|
158
|
+
exit 2
|
|
159
|
+
fi
|
|
160
|
+
if [ "$FAILED" -gt 0 ] && [ "$LENIENT" -eq 0 ]; then
|
|
161
|
+
log "FATAL: $FAILED/$TOTAL FQDNs failed (strict mode); refusing to emit ruleset"
|
|
162
|
+
exit 1
|
|
163
|
+
fi
|
|
164
|
+
|
|
165
|
+
# Pass 2: substitute. For every line containing a @@WRIT_RESOLVE:X@@
|
|
166
|
+
# token, emit one copy per resolved address (with `-d <addr>` replacing the
|
|
167
|
+
# token). Lines for unresolved FQDNs are dropped (in lenient mode), which is
|
|
168
|
+
# fail-closed: those destinations remain unreachable.
|
|
169
|
+
OUTPUT=$(printf '%s\n' "$INPUT" | awk -v mapfile="$TMP_MAP" '
|
|
170
|
+
BEGIN {
|
|
171
|
+
while ((getline line < mapfile) > 0) {
|
|
172
|
+
n = split(line, f, "\t")
|
|
173
|
+
if (n < 2) continue
|
|
174
|
+
if (f[2] == "__WRIT_UNRESOLVED__") {
|
|
175
|
+
unresolved[f[1]] = 1
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
# NOTE: do not use `(f[1] in addrs ? ... : ...)` — busybox awk
|
|
179
|
+
# treats the `in` check as a key-creating side-effect under some
|
|
180
|
+
# builds, leading to a phantom empty entry being concatenated on
|
|
181
|
+
# first insert (split() then yields a stray empty element). Track
|
|
182
|
+
# presence via a separate flag instead.
|
|
183
|
+
if (seen[f[1]]) {
|
|
184
|
+
addrs[f[1]] = addrs[f[1]] "\n" f[2]
|
|
185
|
+
} else {
|
|
186
|
+
addrs[f[1]] = f[2]
|
|
187
|
+
seen[f[1]] = 1
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
close(mapfile)
|
|
191
|
+
}
|
|
192
|
+
{
|
|
193
|
+
if (match($0, /@@WRIT_RESOLVE:[^@]+@@/)) {
|
|
194
|
+
token = substr($0, RSTART, RLENGTH)
|
|
195
|
+
fqdn = token
|
|
196
|
+
sub(/^@@WRIT_RESOLVE:/, "", fqdn)
|
|
197
|
+
sub(/@@$/, "", fqdn)
|
|
198
|
+
if (fqdn in unresolved) {
|
|
199
|
+
# Drop the rule — fail-closed.
|
|
200
|
+
next
|
|
201
|
+
}
|
|
202
|
+
if (fqdn in addrs) {
|
|
203
|
+
m = split(addrs[fqdn], a, "\n")
|
|
204
|
+
for (i = 1; i <= m; i++) {
|
|
205
|
+
line = $0
|
|
206
|
+
sub(/@@WRIT_RESOLVE:[^@]+@@/, "-d " a[i], line)
|
|
207
|
+
print line
|
|
208
|
+
}
|
|
209
|
+
next
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
print $0
|
|
213
|
+
}
|
|
214
|
+
')
|
|
215
|
+
|
|
216
|
+
if [ -n "$OUT_FILE" ]; then
|
|
217
|
+
printf '%s\n' "$OUTPUT" > "$OUT_FILE"
|
|
218
|
+
log "INFO: wrote resolved ruleset to $OUT_FILE ($RESOLVED/$TOTAL FQDNs)"
|
|
219
|
+
else
|
|
220
|
+
printf '%s\n' "$OUTPUT"
|
|
221
|
+
fi
|
|
222
|
+
|
|
223
|
+
exit 0
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# /etc/logrotate.d/writ — sample logrotate config for Writ refresh log.
|
|
2
|
+
# Copy to /etc/logrotate.d/writ and adjust paths/retention to taste.
|
|
3
|
+
/var/log/writ-resolve.log {
|
|
4
|
+
weekly
|
|
5
|
+
rotate 8
|
|
6
|
+
compress
|
|
7
|
+
delaycompress
|
|
8
|
+
missingok
|
|
9
|
+
notifempty
|
|
10
|
+
create 0640 root adm
|
|
11
|
+
# Append-only integrity hint: do NOT use copytruncate — it allows
|
|
12
|
+
# in-place rewrites. Rotation + recreate preserves append-only.
|
|
13
|
+
}
|