Harden TrueNAS SMART percentage parsing

This commit is contained in:
Richard Courtman
2026-08-29 11:17:14 +01:00
parent d80a6a44b3
commit fb4291131f
3 changed files with 75 additions and 6 deletions
@@ -2643,7 +2643,9 @@ classes, typed `ue` and `sb` arguments from `alert.list` project onto canonical
media-error and available-spare SMART fields after exact disk resolution. The
provider must reject negative or out-of-range values, retain the worst value
when duplicate evidence is present, and never derive a counter from formatted
alert text.
alert text. Available-spare parsing must validate the integer and its 0..100
domain before conversion to the canonical `int` field; oversized, fractional,
or wrapped malformed values are rejected rather than narrowed or truncated.
The same boundary owns TrueNAS `smart_status` normalization. `internal/truenas/client.go`
must parse REST and RPC SMART status separately from native disk state, and
`internal/truenas/disk_health.go` plus `internal/truenas/provider.go` must map
+62 -5
View File
@@ -1298,12 +1298,11 @@ func smartAlertEvidenceFromArgs(class string, args map[string]any) (int64, bool,
return *value, true, 0, false
}
case "smartspareblockcount", "smartspareblockcountalert":
value := readInt64PtrAny(args, "sb")
// TrueNAS publishes the normalized spare-block reserve as a percentage.
// Reject malformed/out-of-range values rather than projecting them onto
// the canonical percentage field.
if value != nil && *value >= 0 && *value <= 100 {
return 0, false, int(*value), true
// Parse directly into the canonical integer width and reject malformed or
// out-of-range values before projecting them onto the percentage field.
if value, ok := readSMARTPercentageAny(args, "sb"); ok {
return 0, false, value, true
}
}
return 0, false, 0, false
@@ -4788,6 +4787,64 @@ func readInt64PtrAny(record map[string]any, keys ...string) *int64 {
return nil
}
func readSMARTPercentageAny(record map[string]any, keys ...string) (int, bool) {
if record == nil {
return 0, false
}
for _, key := range keys {
value, ok := record[key]
if !ok || value == nil {
continue
}
if parsed, ok := parseSMARTPercentageAny(value); ok {
return parsed, true
}
}
return 0, false
}
func parseSMARTPercentageAny(value any) (int, bool) {
var parsed int
switch typed := value.(type) {
case int:
parsed = typed
case int64:
if typed < 0 || typed > 100 {
return 0, false
}
parsed = int(typed)
case float64:
if math.Trunc(typed) != typed || typed < 0 || typed > 100 {
return 0, false
}
parsed = int(typed)
case json.Number:
return parseSMARTPercentageAny(typed.String())
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" {
return 0, false
}
integer, err := strconv.Atoi(trimmed)
if err != nil {
return 0, false
}
parsed = integer
case map[string]any:
if nested, ok := firstAny(typed, "parsed", "value", "rawvalue", "raw"); ok {
return parseSMARTPercentageAny(nested)
}
return 0, false
default:
return 0, false
}
if parsed < 0 || parsed > 100 {
return 0, false
}
return parsed, true
}
func parseInt64Any(value any) (int64, bool) {
switch typed := value.(type) {
case int64:
+10
View File
@@ -374,8 +374,18 @@ func TestSMARTAlertEvidenceFromArgsUsesOnlyTypedNativeFields(t *testing.T) {
name: "spare reserve", class: "SMARTSpareBlockCountAlert",
args: map[string]any{"sb": "8"}, wantSpare: 8, wantSpareSeen: true,
},
{
name: "zero spare reserve", class: "SMARTSpareBlockCount",
args: map[string]any{"sb": json.Number("0")}, wantSpareSeen: true,
},
{
name: "maximum spare reserve", class: "SMARTSpareBlockCountAlert",
args: map[string]any{"sb": map[string]any{"parsed": int64(100)}}, wantSpare: 100, wantSpareSeen: true,
},
{name: "negative counter rejected", class: "SMARTUncorrectedErrors", args: map[string]any{"ue": -1}},
{name: "out of range percentage rejected", class: "SMARTSpareBlockCount", args: map[string]any{"sb": 101}},
{name: "oversized percentage rejected", class: "SMARTSpareBlockCount", args: map[string]any{"sb": "9223372036854775807"}},
{name: "fractional percentage rejected", class: "SMARTSpareBlockCount", args: map[string]any{"sb": 8.5}},
{name: "formatted text is not parsed", class: "SMARTUncorrectedErrors", args: map[string]any{"text": "53 errors"}},
{name: "unrelated alert cannot inject smart data", class: "DiskTemperatureAlert", args: map[string]any{"ue": 53, "sb": 8}},
}