@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.
@@ -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
+ }