Repository navigation
Expand file tree
/
Copy pathposture-diff
More file actions
executable file
·248 lines (227 loc) · 10.1 KB
/
Copy pathposture-diff
File metadata and controls
executable file
·248 lines (227 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
#!/bin/sh
# This file is part of KASLD - https://github.com/bcoles/kasld
#
# posture-diff — compare two `kasld -j` snapshots and report whether the
# KASLR security POSTURE regressed. A regression gate for config-drift
# monitoring / CI: save a baseline snapshot, compare a later run, fail if the
# target got easier to attack.
#
# Only the boot-STABLE, security-relevant posture is compared — guaranteed
# residual entropy, KASLR posture state, unpatched CVE-class leaks, and which
# defenses are off. The per-boot VOLATILE values (the resolved base address,
# slide, direct-map base) are never read: KASLR re-randomizes them every boot,
# so comparing them would flag a healthy reboot as a change. Comparing posture,
# not addresses, is the whole point.
#
# The raw component leak-set is deliberately NOT diffed: timing side channels
# are nondeterministic, so a run-to-run difference there is noise, not drift.
# A leak that actually weakens the target shows up as an entropy drop, a newly
# unpatched CVE, or a relaxed defense — all of which ARE compared.
#
# Usage: posture-diff BASELINE.json CURRENT.json
# Snapshots are `kasld -j` output (live, or replayed from an extra/collect
# bundle via extra/prepare-bundle <bundle> DIR && KASLD_SYSROOT=DIR kasld -j)
# -- both taken the same way, which the tool checks: a replay reaches only the
# components a captured tree can answer, so a live run and a replay of one
# host measure different vantages and are not comparable. A pair describing
# different architectures is refused on the same grounds.
#
# Exit: 0 = no regression 1 = regression (findings on stdout) 2 = error
#
# Dependencies: jq. No root required (it reads two files).
#
# NOTE: like kasld itself, a "no regression" result is not a security
# assurance — the posture reflects what this vantage could measure, and the
# residual entropy is an upper bound on retained protection, not a guarantee.
# ---
# <bcoles@gmail.com>
set -u
prog=$(basename "$0")
usage() {
cat <<EOF
Usage: $prog BASELINE.json CURRENT.json
Compare two 'kasld -j' snapshots; exit non-zero if the KASLR posture regressed.
BASELINE.json the reference snapshot (e.g. last known-good)
CURRENT.json the snapshot to check against it
Exit: 0 no regression, 1 regression, 2 error.
EOF
}
case "${1:-}" in
-h | --help)
usage
exit 0
;;
esac
[ $# -eq 2 ] || {
usage >&2
exit 2
}
base=$1
cur=$2
command -v jq >/dev/null 2>&1 || {
echo "$prog: jq not found (apt install jq)" >&2
exit 2
}
for f in "$base" "$cur"; do
[ -f "$f" ] || {
echo "$prog: no such file: $f" >&2
exit 2
}
jq -e . "$f" >/dev/null 2>&1 || {
echo "$prog: not valid JSON: $f" >&2
exit 2
}
# Reject anything that is not a kasld `-j` snapshot, so a wrong or truncated
# file fails clearly rather than coalescing to zeros and reporting a false
# "entropy dropped to 0" regression. Both blocks are always present in a
# current `kasld -j`.
jq -e '((.kaslr | type) == "object") and ((.hardening | type) == "object")' \
"$f" >/dev/null 2>&1 || {
echo "$prog: $f is not a kasld -j snapshot (missing .kaslr / .hardening)" >&2
exit 2
}
done
# The document format this tool reads, as the MAJOR a consumer dispatches on.
# A MINOR bump is additive, so a higher one is read as it stands; a MAJOR bump
# means a key was renamed or a value's meaning changed, and a posture verdict
# computed from fields that no longer mean what the queries below assume is not
# a wrong number, it is an answer to a different question. Refused for the same
# reason a mismatched fact source is.
#
# Which side is stale depends on the direction, and the message has to say
# which: a snapshot NEWER than this tool means the tool is behind the kasld that
# wrote it, while an older one means the snapshot predates the format this tool
# reads. Telling someone to update the wrong half of the pair is worse than
# saying nothing.
SCHEMA_MAJOR=1
schema_major() {
jq -r 'if (.schema_version | type) == "string"
then (.schema_version | split(".") | .[0])
else "unstated" end' "$1"
}
for f in "$base" "$cur"; do
v=$(schema_major "$f") || exit 2
case "$v" in
unstated)
# A snapshot from before the field existed. Warned about rather than
# refused, exactly as an absent .replay is: refusing would reject every
# capture taken before this release, and the fields read below were the
# same then.
echo "$prog: warning: $f states no schema_version string, so its format" \
"cannot be checked; it predates the field" >&2
;;
"$SCHEMA_MAJOR") ;;
*[!0-9]*)
echo "$prog: $f states an unreadable schema_version" >&2
exit 2
;;
*)
echo "$prog: $f is schema $v.x; this tool reads $SCHEMA_MAJOR.x" >&2
if [ "$v" -gt "$SCHEMA_MAJOR" ]; then
echo "$prog: the snapshot is newer than this tool; use the posture-diff" \
"shipped with the kasld that wrote it" >&2
else
echo "$prog: the snapshot predates this tool's format; recapture with" \
"the current kasld, or read it with the posture-diff of its own" \
"release" >&2
fi
exit 2
;;
esac
done
# Both snapshots have to have been taken the same way. A replay reads a
# captured tree and skips every live probe, so it reaches a different set of
# leaks than a live run of the same host does: the residual entropy and the
# CVE-class leak set then differ by vantage rather than by posture. The
# difference usually runs the flattering way -- fewer techniques ran, so fewer
# leaked -- which this gate would pass silently. Refused instead: no field of
# either document can separate the two effects afterwards.
#
# A snapshot predating the field states nothing about how it was taken, and the
# pairing cannot be checked at all. Said once rather than assumed safe.
fact_source() {
jq -r 'if (.replay | type) == "boolean" then
(if .replay then "replay" else "live" end)
else "unstated" end' "$1"
}
b_src=$(fact_source "$base") || exit 2
c_src=$(fact_source "$cur") || exit 2
if [ "$b_src" != "$c_src" ] &&
[ "$b_src" != unstated ] && [ "$c_src" != unstated ]; then
echo "$prog: snapshots were not taken the same way:" \
"baseline is a $b_src run, current is a $c_src run" >&2
echo "$prog: a replay runs only the components a captured tree can answer," \
"so the two measure different vantages, not different postures" >&2
exit 2
fi
if [ "$b_src" = unstated ] || [ "$c_src" = unstated ]; then
echo "$prog: warning: a snapshot states no fact source (.replay), so live" \
"and replayed captures cannot be told apart; recapture to check it" >&2
fi
# Both snapshots have to describe the same architecture. Every quantity this
# gate compares is measured on a per-arch scale -- the KASLR grid, the slot
# count, the residual entropy in bits -- so an x86_64 baseline against an
# aarch64 current subtracts two different scales and reports the difference as
# drift. A snapshot that identifies no architecture states nothing to check.
arch_of() { jq -r '.arch // "unknown"' "$1"; }
b_arch=$(arch_of "$base") || exit 2
c_arch=$(arch_of "$cur") || exit 2
if [ "$b_arch" != "$c_arch" ] &&
[ "$b_arch" != unknown ] && [ "$c_arch" != unknown ]; then
echo "$prog: snapshots describe different architectures:" \
"baseline is $b_arch, current is $c_arch" >&2
echo "$prog: residual entropy and the KASLR grid are measured per" \
"architecture, so the two postures are not comparable" >&2
exit 2
fi
# The boot-stable posture: everything a regression gate should compare, and
# nothing that re-randomizes per boot. Missing blocks (an older kasld -j, KASLR
# off) coalesce to safe neutral values.
posture='{
kaslr: (.hardening.kaslr_posture.state // "active"),
guar_bits: (.kaslr.inferred.entropy_bits // 0),
pguar_bits: (.kaslr.inferred_physical.entropy_bits // 0),
unpatched: ([(.hardening.patched_vulnerabilities.possibly_unpatched // [])[].component] | sort),
defenses_off: ([(.hardening.active_defenses // [])[] | select(.active == false) | .gate] | sort),
confirmed: ([(.hardening.confirmed_mitigations // [])[].gate] | sort | unique)
}'
b_posture=$(jq "$posture" "$base") || exit 2
c_posture=$(jq "$posture" "$cur") || exit 2
# Emit one line per regression; empty output means the posture held or improved.
findings=$(jq -rn --argjson b "$b_posture" --argjson c "$c_posture" '
# KASLR posture worse-ness: active is best; a disabled / failed kernel is
# worse. unsupported is arch-fixed (cannot change on the same host).
# A state outside this set has NO rank. Scoring it as the best would let a
# move into an unrecognised state read as no regression -- the one direction
# a gate must not get wrong -- so an unrankable CHANGE is reported instead,
# while an unrankable state that stayed put is not a change at all.
def rank: {"active": 2, "randomization_failed": 1, "disabled": 1, "unsupported": 0}[.];
( if $c.guar_bits < $b.guar_bits
then "guaranteed entropy dropped: \($b.guar_bits) -> \($c.guar_bits) bits (virtual)"
else empty end ),
( if $c.pguar_bits < $b.pguar_bits
then "guaranteed entropy dropped: \($b.pguar_bits) -> \($c.pguar_bits) bits (physical)"
else empty end ),
( if ($b.kaslr | rank) == null or ($c.kaslr | rank) == null
then ( if $c.kaslr != $b.kaslr
then "KASLR posture state changed and cannot be ranked: \($b.kaslr) -> \($c.kaslr)"
else empty end )
elif ($c.kaslr | rank) < ($b.kaslr | rank)
then "KASLR posture worsened: \($b.kaslr) -> \($c.kaslr)"
else empty end ),
( ($c.unpatched - $b.unpatched)[] | "new CVE-class leak succeeded: \(.)" ),
( ($c.defenses_off - $b.defenses_off)[] | "defense turned off: \(.)" ),
# A control confirmed to defeat a leak at baseline no longer does — a
# non-sysctl defense (KPTI, an MDS hardware fix, a hardening CONFIG) has
# weakened. These come from deterministic checks, so a drop is a real change,
# not run-to-run noise.
( ($b.confirmed - $c.confirmed)[] | "confirmed mitigation no longer blocking a leak: \(.)" )
') || exit 2
if [ -n "$findings" ]; then
echo "REGRESSION: posture is weaker than baseline"
# Indent each finding for readability.
printf '%s\n' "$findings" | sed 's/^/ - /'
exit 1
fi
echo "OK: no posture regression"
exit 0