feat: rework the --batch json output entirely

This commit is contained in:
Stéphane Lesimple
2026-04-08 20:50:54 +02:00
parent 3afbda8430
commit 39dea1245e
8 changed files with 384 additions and 20 deletions

View File

@@ -43,7 +43,8 @@ show_usage() {
so that invoked tools will be prefixed with this (i.e. aarch64-linux-gnu-objdump)
--batch text produce machine readable output, this is the default if --batch is specified alone
--batch short produce only one line with the vulnerabilities separated by spaces
--batch json produce JSON output formatted for Puppet, Ansible, Chef...
--batch json produce comprehensive JSON output with system, CPU, and vulnerability details
--batch json-terse produce a terse JSON array of per-CVE results (legacy format)
--batch nrpe produce machine readable output formatted for NRPE
--batch prometheus produce output for consumption by prometheus-node-exporter

View File

@@ -116,7 +116,7 @@ while [ -n "${1:-}" ]; do
opt_no_color=1
shift
case "$1" in
text | short | nrpe | json | prometheus)
text | short | nrpe | json | json-terse | prometheus)
opt_batch_format="$1"
shift
;;
@@ -124,7 +124,7 @@ while [ -n "${1:-}" ]; do
'') ;; # allow nothing at all
*)
echo "$0: error: unknown batch format '$1'" >&2
echo "$0: error: --batch expects a format from: text, nrpe, json" >&2
echo "$0: error: --batch expects a format from: text, nrpe, json, json-terse" >&2
exit 255
;;
esac

View File

@@ -1,4 +1,245 @@
# vim: set ts=4 sw=4 sts=4 et:
# --- JSON helper functions ---
# Escape a string for use in a JSON value (handles backslashes, double quotes, newlines, tabs)
# Args: $1=string
# Prints: escaped string (without surrounding quotes)
_json_escape() {
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' | tr '\n' ' '
}
# Convert a shell capability value to a JSON token
# Args: $1=value (1=true, 0=false, -1/empty=null, other string=quoted string)
# Prints: JSON token
_json_cap() {
case "${1:-}" in
1) printf 'true' ;;
0) printf 'false' ;;
-1|'') printf 'null' ;;
*) printf '"%s"' "$(_json_escape "$1")" ;;
esac
}
# Emit a JSON string value or null
# Args: $1=string (empty=null)
# Prints: JSON token ("escaped string" or null)
_json_str() {
if [ -n "${1:-}" ]; then
printf '"%s"' "$(_json_escape "$1")"
else
printf 'null'
fi
}
# Emit a JSON number value or null
# Args: $1=number (empty=null)
# Prints: JSON token
_json_num() {
if [ -n "${1:-}" ]; then
printf '%s' "$1"
else
printf 'null'
fi
}
# Emit a JSON boolean value or null
# Args: $1=value (1/0/empty)
# Prints: JSON token
_json_bool() {
case "${1:-}" in
1) printf 'true' ;;
0) printf 'false' ;;
*) printf 'null' ;;
esac
}
# --- JSON section builders (comprehensive format) ---
# Build the "meta" section of the comprehensive JSON output
# Sets: g_json_meta
# shellcheck disable=SC2034
_build_json_meta() {
local timestamp mode
timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo "unknown")
if [ "$opt_live" = 1 ]; then
mode="live"
else
mode="offline"
fi
local run_as_root
if [ "$(id -u)" -eq 0 ]; then
run_as_root='true'
else
run_as_root='false'
fi
g_json_meta=$(printf '{"script_version":%s,"format_version":1,"timestamp":%s,"os":%s,"mode":"%s","run_as_root":%s,"reduced_accuracy":%s,"paranoid":%s,"sysfs_only":%s,"no_hw":%s,"extra":%s}' \
"$(_json_str "$VERSION")" \
"$(_json_str "$timestamp")" \
"$(_json_str "$g_os")" \
"$mode" \
"$run_as_root" \
"$(_json_bool "${g_bad_accuracy:-0}")" \
"$(_json_bool "$opt_paranoid")" \
"$(_json_bool "$opt_sysfs_only")" \
"$(_json_bool "$opt_no_hw")" \
"$(_json_bool "$opt_extra")")
}
# Build the "system" section of the comprehensive JSON output
# Sets: g_json_system
# shellcheck disable=SC2034
_build_json_system() {
local kernel_release kernel_version kernel_arch smt_val
if [ "$opt_live" = 1 ]; then
kernel_release=$(uname -r)
kernel_version=$(uname -v)
kernel_arch=$(uname -m)
else
kernel_release=''
kernel_version=''
kernel_arch=''
fi
# SMT detection
is_cpu_smt_enabled
smt_val=$?
case $smt_val in
0) smt_val='true' ;;
1) smt_val='false' ;;
*) smt_val='null' ;;
esac
g_json_system=$(printf '{"kernel_release":%s,"kernel_version":%s,"kernel_arch":%s,"kernel_image":%s,"kernel_config":%s,"kernel_version_string":%s,"kernel_cmdline":%s,"cpu_count":%s,"smt_enabled":%s,"hypervisor_host":%s,"hypervisor_host_reason":%s}' \
"$(_json_str "$kernel_release")" \
"$(_json_str "$kernel_version")" \
"$(_json_str "$kernel_arch")" \
"$(_json_str "${opt_kernel:-}")" \
"$(_json_str "${opt_config:-}")" \
"$(_json_str "${g_kernel_version:-}")" \
"$(_json_str "${g_kernel_cmdline:-}")" \
"$(_json_num "${g_max_core_id:+$((g_max_core_id + 1))}")" \
"$smt_val" \
"$(_json_bool "${g_has_vmm:-}")" \
"$(_json_str "${g_has_vmm_reason:-}")")
}
# Build the "cpu" section of the comprehensive JSON output
# Sets: g_json_cpu
# shellcheck disable=SC2034
_build_json_cpu() {
local cpuid_hex ucode_hex codename caps
if [ -n "${cpu_cpuid:-}" ]; then
cpuid_hex=$(printf '0x%08x' "$cpu_cpuid")
else
cpuid_hex=''
fi
if [ -n "${cpu_ucode:-}" ]; then
ucode_hex=$(printf '0x%x' "$cpu_ucode")
else
ucode_hex=''
fi
codename=''
if is_intel; then
codename=$(get_intel_codename 2>/dev/null || true)
fi
# Build capabilities sub-object
caps=$(printf '{"spec_ctrl":%s,"ibrs":%s,"ibpb":%s,"ibpb_ret":%s,"stibp":%s,"ssbd":%s,"l1d_flush":%s,"md_clear":%s,"arch_capabilities":%s,"rdcl_no":%s,"ibrs_all":%s,"rsba":%s,"l1dflush_no":%s,"ssb_no":%s,"mds_no":%s,"taa_no":%s,"pschange_msc_no":%s,"tsx_ctrl_msr":%s,"tsx_ctrl_rtm_disable":%s,"tsx_ctrl_cpuid_clear":%s,"gds_ctrl":%s,"gds_no":%s,"gds_mitg_dis":%s,"gds_mitg_lock":%s,"rfds_no":%s,"rfds_clear":%s,"its_no":%s,"sbdr_ssdp_no":%s,"fbsdp_no":%s,"psdp_no":%s,"fb_clear":%s,"rtm":%s,"tsx_force_abort":%s,"tsx_force_abort_rtm_disable":%s,"tsx_force_abort_cpuid_clear":%s,"sgx":%s,"srbds":%s,"srbds_on":%s,"amd_ssb_no":%s,"hygon_ssb_no":%s,"ipred":%s,"rrsba":%s,"bhi":%s,"tsa_sq_no":%s,"tsa_l1_no":%s,"verw_clear":%s,"autoibrs":%s,"sbpb":%s,"avx2":%s,"avx512":%s}' \
"$(_json_cap "${cap_spec_ctrl:-}")" \
"$(_json_cap "${cap_ibrs:-}")" \
"$(_json_cap "${cap_ibpb:-}")" \
"$(_json_cap "${cap_ibpb_ret:-}")" \
"$(_json_cap "${cap_stibp:-}")" \
"$(_json_cap "${cap_ssbd:-}")" \
"$(_json_cap "${cap_l1df:-}")" \
"$(_json_cap "${cap_md_clear:-}")" \
"$(_json_cap "${cap_arch_capabilities:-}")" \
"$(_json_cap "${cap_rdcl_no:-}")" \
"$(_json_cap "${cap_ibrs_all:-}")" \
"$(_json_cap "${cap_rsba:-}")" \
"$(_json_cap "${cap_l1dflush_no:-}")" \
"$(_json_cap "${cap_ssb_no:-}")" \
"$(_json_cap "${cap_mds_no:-}")" \
"$(_json_cap "${cap_taa_no:-}")" \
"$(_json_cap "${cap_pschange_msc_no:-}")" \
"$(_json_cap "${cap_tsx_ctrl_msr:-}")" \
"$(_json_cap "${cap_tsx_ctrl_rtm_disable:-}")" \
"$(_json_cap "${cap_tsx_ctrl_cpuid_clear:-}")" \
"$(_json_cap "${cap_gds_ctrl:-}")" \
"$(_json_cap "${cap_gds_no:-}")" \
"$(_json_cap "${cap_gds_mitg_dis:-}")" \
"$(_json_cap "${cap_gds_mitg_lock:-}")" \
"$(_json_cap "${cap_rfds_no:-}")" \
"$(_json_cap "${cap_rfds_clear:-}")" \
"$(_json_cap "${cap_its_no:-}")" \
"$(_json_cap "${cap_sbdr_ssdp_no:-}")" \
"$(_json_cap "${cap_fbsdp_no:-}")" \
"$(_json_cap "${cap_psdp_no:-}")" \
"$(_json_cap "${cap_fb_clear:-}")" \
"$(_json_cap "${cap_rtm:-}")" \
"$(_json_cap "${cap_tsx_force_abort:-}")" \
"$(_json_cap "${cap_tsx_force_abort_rtm_disable:-}")" \
"$(_json_cap "${cap_tsx_force_abort_cpuid_clear:-}")" \
"$(_json_cap "${cap_sgx:-}")" \
"$(_json_cap "${cap_srbds:-}")" \
"$(_json_cap "${cap_srbds_on:-}")" \
"$(_json_cap "${cap_amd_ssb_no:-}")" \
"$(_json_cap "${cap_hygon_ssb_no:-}")" \
"$(_json_cap "${cap_ipred:-}")" \
"$(_json_cap "${cap_rrsba:-}")" \
"$(_json_cap "${cap_bhi:-}")" \
"$(_json_cap "${cap_tsa_sq_no:-}")" \
"$(_json_cap "${cap_tsa_l1_no:-}")" \
"$(_json_cap "${cap_verw_clear:-}")" \
"$(_json_cap "${cap_autoibrs:-}")" \
"$(_json_cap "${cap_sbpb:-}")" \
"$(_json_cap "${cap_avx2:-}")" \
"$(_json_cap "${cap_avx512:-}")")
g_json_cpu=$(printf '{"vendor":%s,"friendly_name":%s,"family":%s,"model":%s,"stepping":%s,"cpuid":%s,"platform_id":%s,"hybrid":%s,"codename":%s,"arm_part_list":%s,"arm_arch_list":%s,"capabilities":%s}' \
"$(_json_str "${cpu_vendor:-}")" \
"$(_json_str "${cpu_friendly_name:-}")" \
"$(_json_num "${cpu_family:-}")" \
"$(_json_num "${cpu_model:-}")" \
"$(_json_num "${cpu_stepping:-}")" \
"$(_json_str "$cpuid_hex")" \
"$(_json_num "${cpu_platformid:-}")" \
"$(_json_bool "${cpu_hybrid:-}")" \
"$(_json_str "$codename")" \
"$(_json_str "${cpu_part_list:-}")" \
"$(_json_str "${cpu_arch_list:-}")" \
"$caps")
}
# Build the "cpu_microcode" section of the comprehensive JSON output
# Sets: g_json_cpu_microcode
# shellcheck disable=SC2034
_build_json_cpu_microcode() {
local ucode_uptodate ucode_hex latest_hex blacklisted
if [ -n "${cpu_ucode:-}" ]; then
ucode_hex=$(printf '0x%x' "$cpu_ucode")
else
ucode_hex=''
fi
is_latest_known_ucode
case $? in
0) ucode_uptodate='true' ;;
1) ucode_uptodate='false' ;;
*) ucode_uptodate='null' ;;
esac
if is_ucode_blacklisted; then
blacklisted='true'
else
blacklisted='false'
fi
latest_hex="${ret_is_latest_known_ucode_version:-}"
g_json_cpu_microcode=$(printf '{"installed_version":%s,"latest_version":%s,"microcode_up_to_date":%s,"is_blacklisted":%s,"message":%s,"db_source":%s,"db_info":%s}' \
"$(_json_str "$ucode_hex")" \
"$(_json_str "$latest_hex")" \
"$ucode_uptodate" \
"$blacklisted" \
"$(_json_str "${ret_is_latest_known_ucode_latest:-}")" \
"$(_json_str "${g_mcedb_source:-}")" \
"$(_json_str "${g_mcedb_info:-}")")
}
# --- Format-specific batch emitters ---
# Emit a single CVE result as plain text
@@ -16,28 +257,62 @@ _emit_short() {
g_short_output="${g_short_output}$1 "
}
# Append a CVE result as a JSON object to the batch output buffer
# Append a CVE result as a terse JSON object to the batch output buffer
# Args: $1=cve $2=aka $3=status(UNK|VULN|OK) $4=description
# Sets: g_json_output
# Callers: pvulnstatus
_emit_json() {
_emit_json_terse() {
local is_vuln esc_name esc_infos
case "$3" in
UNK) is_vuln="null" ;;
VULN) is_vuln="true" ;;
OK) is_vuln="false" ;;
*)
echo "$0: error: unknown status '$3' passed to _emit_json()" >&2
echo "$0: error: unknown status '$3' passed to _emit_json_terse()" >&2
exit 255
;;
esac
# escape backslashes and double quotes for valid JSON strings
esc_name=$(printf '%s' "$2" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')
esc_infos=$(printf '%s' "$4" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')
esc_name=$(_json_escape "$2")
esc_infos=$(_json_escape "$4")
[ -z "$g_json_output" ] && g_json_output='['
g_json_output="${g_json_output}{\"NAME\":\"$esc_name\",\"CVE\":\"$1\",\"VULNERABLE\":$is_vuln,\"INFOS\":\"$esc_infos\"},"
}
# Append a CVE result as a comprehensive JSON object to the batch output buffer
# Args: $1=cve $2=aka $3=status(UNK|VULN|OK) $4=description
# Sets: g_json_vulns
# Callers: pvulnstatus
_emit_json_full() {
local is_vuln esc_name esc_infos aliases cpu_affected sysfs_status sysfs_msg
case "$3" in
UNK) is_vuln="null" ;;
VULN) is_vuln="true" ;;
OK) is_vuln="false" ;;
*)
echo "$0: error: unknown status '$3' passed to _emit_json_full()" >&2
exit 255
;;
esac
esc_name=$(_json_escape "$2")
esc_infos=$(_json_escape "$4")
aliases=$(_cve_registry_field "$1" 4)
# CPU affection status (cached, cheap)
if is_cpu_affected "$1" 2>/dev/null; then
cpu_affected='true'
else
cpu_affected='false'
fi
# sysfs status: use the value captured by this CVE's check function, then clear it
# so it doesn't leak into the next CVE that might not call sys_interface_check
sysfs_status="${g_json_cve_sysfs_status:-}"
sysfs_msg="${g_json_cve_sysfs_msg:-}"
: "${g_json_vulns:=}"
g_json_vulns="${g_json_vulns}{\"cve\":\"$1\",\"name\":\"$esc_name\",\"aliases\":$(_json_str "$aliases"),\"cpu_affected\":$cpu_affected,\"status\":\"$3\",\"vulnerable\":$is_vuln,\"info\":\"$esc_infos\",\"sysfs_status\":$(_json_str "$sysfs_status"),\"sysfs_message\":$(_json_str "$sysfs_msg")},"
}
# Append vulnerable CVE IDs to the NRPE output buffer
# Args: $1=cve $2=aka $3=status $4=description
# Sets: g_nrpe_vuln
@@ -85,7 +360,8 @@ pvulnstatus() {
case "$opt_batch_format" in
text) _emit_text "$1" "$aka" "$2" "$3" ;;
short) _emit_short "$1" "$aka" "$2" "$3" ;;
json) _emit_json "$1" "$aka" "$2" "$3" ;;
json) _emit_json_full "$1" "$aka" "$2" "$3" ;;
json-terse) _emit_json_terse "$1" "$aka" "$2" "$3" ;;
nrpe) _emit_nrpe "$1" "$aka" "$2" "$3" ;;
prometheus) _emit_prometheus "$1" "$aka" "$2" "$3" ;;
*)
@@ -93,6 +369,9 @@ pvulnstatus() {
exit 255
;;
esac
# reset per-CVE sysfs globals so they don't leak into the next CVE
g_json_cve_sysfs_status=''
g_json_cve_sysfs_msg=''
fi
_record_result "$1" "$2"

View File

@@ -33,11 +33,12 @@ read_inteldb() {
}
# Check whether the CPU is running the latest known microcode version
# Sets: ret_is_latest_known_ucode_latest
# Sets: ret_is_latest_known_ucode_latest, ret_is_latest_known_ucode_version
# Returns: 0=latest, 1=outdated, 2=unknown
is_latest_known_ucode() {
local brand_prefix tuple pfmask ucode ucode_date
parse_cpu_details
ret_is_latest_known_ucode_version=''
if [ "$cpu_cpuid" = 0 ]; then
ret_is_latest_known_ucode_latest="couldn't get your cpuid"
return 2
@@ -64,6 +65,8 @@ is_latest_known_ucode() {
ucode_date=$(echo "$tuple" | cut -d, -f5 | sed -E 's=(....)(..)(..)=\1/\2/\3=')
pr_debug "is_latest_known_ucode: with cpuid $cpu_cpuid has ucode $cpu_ucode, last known is $ucode from $ucode_date"
ret_is_latest_known_ucode_latest=$(printf "latest version is 0x%x dated $ucode_date according to $g_mcedb_info" "$ucode")
# shellcheck disable=SC2034
ret_is_latest_known_ucode_version=$(printf "0x%x" "$ucode")
if [ "$cpu_ucode" -ge "$ucode" ]; then
return 0
else

View File

@@ -312,9 +312,14 @@ sys_interface_check() {
g_mockme=$(printf "%b\n%b" "$g_mockme" "SMC_MOCK_SYSFS_$(basename "$file")='$ret_sys_interface_check_fullmsg'")
fi
if [ "$mode" = silent ]; then
# capture sysfs message for JSON even in silent mode
# shellcheck disable=SC2034
g_json_cve_sysfs_msg="$ret_sys_interface_check_fullmsg"
return 0
elif [ "$mode" = quiet ]; then
pr_info "* Information from the /sys interface: $ret_sys_interface_check_fullmsg"
# shellcheck disable=SC2034
g_json_cve_sysfs_msg="$ret_sys_interface_check_fullmsg"
return 0
fi
pr_info_nol "* Mitigated according to the /sys interface: "
@@ -334,6 +339,11 @@ sys_interface_check() {
ret_sys_interface_check_status=UNK
pstatus yellow UNKNOWN "$ret_sys_interface_check_fullmsg"
fi
# capture for JSON full output (read by _emit_json_full via pvulnstatus)
# shellcheck disable=SC2034
g_json_cve_sysfs_status="$ret_sys_interface_check_status"
# shellcheck disable=SC2034
g_json_cve_sysfs_msg="$ret_sys_interface_check_fullmsg"
pr_debug "sys_interface_check: $file=$msg (re=$regex)"
return 0
}