diff --git a/audit/internal/app/app.go b/audit/internal/app/app.go index 715e5ad..c681bc3 100644 --- a/audit/internal/app/app.go +++ b/audit/internal/app/app.go @@ -138,6 +138,7 @@ type satRunner interface { ResetNvidiaGPU(index int) (string, error) RunMemoryAcceptancePack(ctx context.Context, baseDir string, sizeMB, passes int, logFunc func(string)) (string, error) RunStorageAcceptancePack(ctx context.Context, baseDir string, extended bool, logFunc func(string)) (string, error) + RunTPMValidationPack(ctx context.Context, baseDir string, logFunc func(string)) (string, error) RunNvidiaConfigCheckPack(ctx context.Context, baseDir string, logFunc func(string)) (string, error) RunPCIeLinkCheckPack(ctx context.Context, baseDir string, logFunc func(string)) (string, error) RunNvidiaPCIeBandwidthPack(ctx context.Context, baseDir string, gpuIndices []int, logFunc func(string)) (string, error) diff --git a/audit/internal/app/app_packs.go b/audit/internal/app/app_packs.go index 0b00ec4..2977fc2 100644 --- a/audit/internal/app/app_packs.go +++ b/audit/internal/app/app_packs.go @@ -258,6 +258,13 @@ func (a *App) RunStorageAcceptancePackResult(baseDir string) (ActionResult, erro return ActionResult{Title: "Storage SAT", Body: satResultBody(path)}, err } +func (a *App) RunTPMValidationPackCtx(ctx context.Context, baseDir string, logFunc func(string)) (string, error) { + if strings.TrimSpace(baseDir) == "" { + baseDir = DefaultSATBaseDir + } + return a.sat.RunTPMValidationPack(ctx, baseDir, logFunc) +} + func (a *App) RunNvidiaConfigCheckPackCtx(ctx context.Context, baseDir string, logFunc func(string)) (string, error) { if strings.TrimSpace(baseDir) == "" { baseDir = DefaultSATBaseDir diff --git a/audit/internal/app/app_test.go b/audit/internal/app/app_test.go index 87345f3..04bab78 100644 --- a/audit/internal/app/app_test.go +++ b/audit/internal/app/app_test.go @@ -283,6 +283,10 @@ func (f fakeSAT) RunStorageAcceptancePack(_ context.Context, baseDir string, _ b return f.runStorageFn(baseDir) } +func (f fakeSAT) RunTPMValidationPack(_ context.Context, _ string, _ func(string)) (string, error) { + return "", nil +} + func (f fakeSAT) RunNvidiaConfigCheckPack(_ context.Context, baseDir string, _ func(string)) (string, error) { return "", nil } diff --git a/audit/internal/app/assets/README.md b/audit/internal/app/assets/README.md index 367f7da..9c1ee42 100644 --- a/audit/internal/app/assets/README.md +++ b/audit/internal/app/assets/README.md @@ -169,7 +169,7 @@ reading here can be normal driver power management, not a fault), speed here, the idle Gen1 reading was power saving — if it stays at Gen1 under load, that's a real link/riser/slot degradation), `pcie-aer-sysfs.txt`, `kernel-aer-nvidia.txt` (AER/NVRM/Xid-filtered dmesg), `lspci-video-vv.txt`, `systemctl-nvidia-units.txt`. AMD systems get `rocm-smi*.txt` here instead. | | `network/` | `ethtool-{info,link,module}.txt` (per-NIC), `mstflint-query.txt` (Mellanox/NVIDIA NICs). | -| `platform/` | `dmidecode-type{0,1,2}.txt` (BIOS/System/Baseboard), `ipmitool-{fru,sdr,sensor,sel,sel-time}.txt` (BMC), `sensors.json`, `lspci-{nn,vmm,vvv}.txt`. | +| `platform/` | `dmidecode-type{0,1,2}.txt` (BIOS/System/Baseboard), `ipmitool-{fru,sdr,sensor,sel,sel-time}.txt` (BMC), `tpm-{properties-fixed,pcr-banks,pcr-values,test-result}.txt` (read-only TPM state), `sensors.json`, `lspci-{nn,vmm,vvv}.txt`. | ## `status/` in detail diff --git a/audit/internal/collector/collector.go b/audit/internal/collector/collector.go index 809d5be..323d00d 100644 --- a/audit/internal/collector/collector.go +++ b/audit/internal/collector/collector.go @@ -52,6 +52,7 @@ func Run(_ runtimeenv.Mode) schema.HardwareIngestRequest { snap.PowerSupplies = enrichPSUsWithTelemetry(snap.PowerSupplies, sensorDoc) snap.Sensors = mergeIPMISensors(buildSensorsFromDoc(sensorDoc), collectIPMISensors()) snap.EventLogs = append(collectIPMISEL(), collectDmesgErrors()...) + snap.PlatformConfig = collectTPMPlatformConfig() finalizeSnapshot(&snap, collectedAt) // remaining collectors added in steps 1.8 – 1.10 diff --git a/audit/internal/collector/tpm.go b/audit/internal/collector/tpm.go new file mode 100644 index 0000000..dfe605f --- /dev/null +++ b/audit/internal/collector/tpm.go @@ -0,0 +1,129 @@ +package collector + +import ( + "encoding/binary" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +var ( + tpmGlob = filepath.Glob + tpmStat = os.Stat + tpmRun = func(name string, args ...string) ([]byte, error) { + return exec.Command(name, args...).Output() + } +) + +// collectTPMPlatformConfig records TPM presence and read-only identity data. +// It never provisions the TPM, changes ownership, writes NV storage, or runs +// TPM2_SelfTest. +func collectTPMPlatformConfig() *json.RawMessage { + config := map[string]any{"TpmPresent": false, "TpmEnabled": false} + devices, _ := tpmGlob("/sys/class/tpm/tpm*") + if len(devices) == 0 { + return rawPlatformConfig(config) + } + + config["TpmPresent"] = true + config["TpmEnabled"] = true + config["TpmDevice"] = filepath.Base(devices[0]) + for _, path := range []string{"/dev/tpmrm0", "/dev/tpm0"} { + if _, err := tpmStat(path); err == nil { + config["TpmInterface"] = path + break + } + } + + out, err := tpmRun("tpm2_getcap", "properties-fixed") + if err != nil { + return rawPlatformConfig(config) + } + properties := parseTPMProperties(string(out)) + if value := tpmPropertyValue(properties, "TPM2_PT_FAMILY_INDICATOR"); value != "" { + config["TpmVersion"] = value + } + if value := tpmManufacturer(properties["TPM2_PT_MANUFACTURER"]); value != "" { + config["TpmManufacturer"] = value + } + firmware1 := tpmPropertyRaw(properties, "TPM2_PT_FIRMWARE_VERSION_1") + firmware2 := tpmPropertyRaw(properties, "TPM2_PT_FIRMWARE_VERSION_2") + if firmware1 != "" || firmware2 != "" { + config["TpmFirmwareVersion"] = strings.Trim(strings.Join([]string{firmware1, firmware2}, "/"), "/") + } + return rawPlatformConfig(config) +} + +type tpmProperty struct { + raw string + value string +} + +func parseTPMProperties(input string) map[string]tpmProperty { + properties := make(map[string]tpmProperty) + current := "" + for _, line := range strings.Split(input, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "TPM2_PT_") && strings.HasSuffix(trimmed, ":") { + current = strings.TrimSuffix(trimmed, ":") + continue + } + if current == "" { + continue + } + property := properties[current] + switch { + case strings.HasPrefix(trimmed, "raw:"): + property.raw = strings.TrimSpace(strings.TrimPrefix(trimmed, "raw:")) + case strings.HasPrefix(trimmed, "value:"): + property.value = strings.Trim(strings.TrimSpace(strings.TrimPrefix(trimmed, "value:")), `"`) + default: + continue + } + properties[current] = property + } + return properties +} + +func tpmPropertyValue(properties map[string]tpmProperty, name string) string { + property := properties[name] + if property.value != "" { + return property.value + } + return property.raw +} + +func tpmPropertyRaw(properties map[string]tpmProperty, name string) string { + return properties[name].raw +} + +func tpmManufacturer(property tpmProperty) string { + if property.value != "" { + return property.value + } + raw := strings.TrimPrefix(property.raw, "0x") + value, err := strconv.ParseUint(raw, 16, 32) + if err != nil { + return "" + } + bytes := make([]byte, 4) + binary.BigEndian.PutUint32(bytes, uint32(value)) + for _, b := range bytes { + if b != 0 && (b < 0x20 || b > 0x7e) { + return property.raw + } + } + return strings.TrimRight(string(bytes), "\x00 ") +} + +func rawPlatformConfig(config map[string]any) *json.RawMessage { + raw, err := json.Marshal(config) + if err != nil { + return nil + } + message := json.RawMessage(raw) + return &message +} diff --git a/audit/internal/collector/tpm_test.go b/audit/internal/collector/tpm_test.go new file mode 100644 index 0000000..e99a6b4 --- /dev/null +++ b/audit/internal/collector/tpm_test.go @@ -0,0 +1,106 @@ +package collector + +import ( + "encoding/json" + "errors" + "os" + "reflect" + "testing" +) + +func TestParseTPMProperties(t *testing.T) { + properties := parseTPMProperties(`TPM2_PT_FAMILY_INDICATOR: + raw: 0x322E3000 + value: "2.0" +TPM2_PT_MANUFACTURER: + raw: 0x49465800 + value: "IFX" +TPM2_PT_FIRMWARE_VERSION_1: + raw: 0x0007003F +TPM2_PT_FIRMWARE_VERSION_2: + raw: 0x00100023 +`) + + if got := tpmPropertyValue(properties, "TPM2_PT_FAMILY_INDICATOR"); got != "2.0" { + t.Fatalf("family indicator = %q, want 2.0", got) + } + if got := tpmManufacturer(properties["TPM2_PT_MANUFACTURER"]); got != "IFX" { + t.Fatalf("manufacturer = %q, want IFX", got) + } + if got := tpmPropertyRaw(properties, "TPM2_PT_FIRMWARE_VERSION_1"); got != "0x0007003F" { + t.Fatalf("firmware version 1 = %q", got) + } +} + +func TestTPMManufacturerDecodesRawVendorID(t *testing.T) { + if got := tpmManufacturer(tpmProperty{raw: "0x49465800"}); got != "IFX" { + t.Fatalf("manufacturer = %q, want IFX", got) + } +} + +func TestCollectTPMPlatformConfig(t *testing.T) { + originalGlob, originalStat, originalRun := tpmGlob, tpmStat, tpmRun + t.Cleanup(func() { + tpmGlob, tpmStat, tpmRun = originalGlob, originalStat, originalRun + }) + + tpmGlob = func(string) ([]string, error) { return []string{"/sys/class/tpm/tpm0"}, nil } + tpmStat = func(path string) (os.FileInfo, error) { + if path == "/dev/tpmrm0" { + return nil, nil + } + return nil, os.ErrNotExist + } + tpmRun = func(name string, args ...string) ([]byte, error) { + if name != "tpm2_getcap" || !reflect.DeepEqual(args, []string{"properties-fixed"}) { + t.Fatalf("unexpected command: %s %v", name, args) + } + return []byte(`TPM2_PT_FAMILY_INDICATOR: + value: "2.0" +TPM2_PT_MANUFACTURER: + raw: 0x49465800 +TPM2_PT_FIRMWARE_VERSION_1: + raw: 0x0007003F +TPM2_PT_FIRMWARE_VERSION_2: + raw: 0x00100023 +`), nil + } + + got := decodeTPMConfig(t, collectTPMPlatformConfig()) + want := map[string]any{ + "TpmPresent": true, + "TpmEnabled": true, + "TpmDevice": "tpm0", + "TpmInterface": "/dev/tpmrm0", + "TpmVersion": "2.0", + "TpmManufacturer": "IFX", + "TpmFirmwareVersion": "0x0007003F/0x00100023", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("config = %#v, want %#v", got, want) + } +} + +func TestCollectTPMPlatformConfigAbsent(t *testing.T) { + originalGlob := tpmGlob + t.Cleanup(func() { tpmGlob = originalGlob }) + tpmGlob = func(string) ([]string, error) { return nil, errors.New("not found") } + + got := decodeTPMConfig(t, collectTPMPlatformConfig()) + want := map[string]any{"TpmPresent": false, "TpmEnabled": false} + if !reflect.DeepEqual(got, want) { + t.Fatalf("config = %#v, want %#v", got, want) + } +} + +func decodeTPMConfig(t *testing.T, raw *json.RawMessage) map[string]any { + t.Helper() + if raw == nil { + t.Fatal("platform config is nil") + } + var config map[string]any + if err := json.Unmarshal(*raw, &config); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + return config +} diff --git a/audit/internal/platform/runtime.go b/audit/internal/platform/runtime.go index 4318ca4..5f0794a 100644 --- a/audit/internal/platform/runtime.go +++ b/audit/internal/platform/runtime.go @@ -17,6 +17,8 @@ var runtimeRequiredTools = []string{ "smartctl", "nvme", "ipmitool", + "tpm2_getcap", + "tpm2_pcrread", "dhclient", "mount", } diff --git a/audit/internal/platform/sat.go b/audit/internal/platform/sat.go index f50b280..9dde5f3 100644 --- a/audit/internal/platform/sat.go +++ b/audit/internal/platform/sat.go @@ -50,6 +50,9 @@ const ( // RAM: memtester 512 MB / 1 pass (extrapolated from validate timing, linear with size). SATEstimatedMemoryStressSec = 140 + // TPM capabilities, PCR values, and existing self-test result queries. + SATEstimatedTPMValidateSec = 5 + // NVIDIA dcgmi diag Level 2 (medium), all GPUs simultaneously. SATEstimatedNvidiaGPUValidateSec = 85 // NVIDIA dcgmi diag Level 3 (targeted stress), all GPUs simultaneously. diff --git a/audit/internal/platform/techdump.go b/audit/internal/platform/techdump.go index b66c95b..334408e 100644 --- a/audit/internal/platform/techdump.go +++ b/audit/internal/platform/techdump.go @@ -29,6 +29,10 @@ var techDumpFixedCommands = []struct { {Name: "ipmitool", Args: []string{"sensor"}, File: "ipmitool-sensor.txt"}, {Name: "ipmitool", Args: []string{"sel", "list"}, File: "ipmitool-sel.txt"}, {Name: "ipmitool", Args: []string{"sel", "time", "get"}, File: "ipmitool-sel-time.txt"}, + {Name: "tpm2_getcap", Args: []string{"properties-fixed"}, File: "tpm-properties-fixed.txt"}, + {Name: "tpm2_getcap", Args: []string{"pcrs"}, File: "tpm-pcr-banks.txt"}, + {Name: "tpm2_pcrread", File: "tpm-pcr-values.txt"}, + {Name: "tpm2_gettestresult", File: "tpm-test-result.txt"}, {Name: "nvme", Args: []string{"list", "-o", "json"}, File: "nvme-list.json"}, {Name: "storcli64", Args: []string{"/call/eall/sall", "show", "all", "J"}, File: "storcli64-drives.json"}, // storcli2 (Tri-Mode controllers, e.g. SAS3808-iMR/9500 series) needs an diff --git a/audit/internal/platform/tpm.go b/audit/internal/platform/tpm.go new file mode 100644 index 0000000..038b0a7 --- /dev/null +++ b/audit/internal/platform/tpm.go @@ -0,0 +1,19 @@ +package platform + +import "context" + +// RunTPMValidationPack verifies TPM 2.0 communication using read-only +// commands. It deliberately excludes SelfTest, provisioning, NV writes, PCR +// changes, key creation, and ownership operations. +func (s *System) RunTPMValidationPack(ctx context.Context, baseDir string, logFunc func(string)) (string, error) { + return runAcceptancePackCtx(ctx, baseDir, "tpm", tpmValidationJobs(), logFunc) +} + +func tpmValidationJobs() []satJob { + return []satJob{ + {name: "01-properties-fixed.log", cmd: []string{"tpm2_getcap", "properties-fixed"}}, + {name: "02-pcr-banks.log", cmd: []string{"tpm2_getcap", "pcrs"}}, + {name: "03-pcr-values.log", cmd: []string{"tpm2_pcrread"}}, + {name: "04-test-result.log", cmd: []string{"tpm2_gettestresult"}}, + } +} diff --git a/audit/internal/platform/tpm_test.go b/audit/internal/platform/tpm_test.go new file mode 100644 index 0000000..4834a5d --- /dev/null +++ b/audit/internal/platform/tpm_test.go @@ -0,0 +1,33 @@ +package platform + +import ( + "reflect" + "strings" + "testing" +) + +func TestTPMValidationJobsAreReadOnly(t *testing.T) { + t.Parallel() + + jobs := tpmValidationJobs() + want := [][]string{ + {"tpm2_getcap", "properties-fixed"}, + {"tpm2_getcap", "pcrs"}, + {"tpm2_pcrread"}, + {"tpm2_gettestresult"}, + } + if len(jobs) != len(want) { + t.Fatalf("jobs=%d want %d", len(jobs), len(want)) + } + for index, job := range jobs { + if !reflect.DeepEqual(job.cmd, want[index]) { + t.Fatalf("jobs[%d].cmd=%v want %v", index, job.cmd, want[index]) + } + joined := strings.ToLower(strings.Join(job.cmd, " ")) + for _, forbidden := range []string{"selftest", "clear", "changeauth", "nvwrite", "pcrextend", "create"} { + if strings.Contains(joined, forbidden) { + t.Fatalf("job %q contains state-changing command %q", joined, forbidden) + } + } + } +} diff --git a/audit/internal/webui/api.go b/audit/internal/webui/api.go index dbf3681..0bf6942 100644 --- a/audit/internal/webui/api.go +++ b/audit/internal/webui/api.go @@ -1542,6 +1542,7 @@ func (h *handler) handleAPISystemTimeSync(w http.ResponseWriter, r *http.Request var standardTools = []string{ "dmidecode", "smartctl", "nvme", "lspci", "ipmitool", + "tpm2_getcap", "tpm2_pcrread", "tpm2_gettestresult", "nvidia-smi", "dcgmi", "nv-hostengine", "memtester", "stress-ng", "nvtop", "mstflint", "saa", } diff --git a/audit/internal/webui/page_validate.go b/audit/internal/webui/page_validate.go index df679fd..0cdda47 100644 --- a/audit/internal/webui/page_validate.go +++ b/audit/internal/webui/page_validate.go @@ -22,6 +22,7 @@ type validateInventory struct { CPU string Memory string Storage string + TPM string NVIDIA string AMD string NvidiaGPUCount int @@ -42,6 +43,7 @@ func validateTotalValidateSec(n int) int { } total := platform.SATEstimatedCPUValidateSec + platform.SATEstimatedMemoryValidateSec + + platform.SATEstimatedTPMValidateSec + platform.SATEstimatedNvidiaInterconnectSec + platform.SATEstimatedNvidiaBandwidthSec if n > 0 { @@ -147,6 +149,12 @@ func renderValidateMode(opts HandlerOptions, stressDefault bool) string { `lsblk; NVMe: nvme id-ctrl, nvme smart-log, nvme device-self-test -s 1; SATA/SAS: smartctl -H -A, smartctl -t short`, `~2 min per device (NVMe short self-test; SATA/SAS short self-test — duration device-dependent).`, )) + + renderSATCard("tpm", "TPM", "runSAT('tpm')", "", renderValidateCardBody( + inv.TPM, + `Checks TPM 2.0 communication and reports its fixed properties, allocated PCR banks, current PCR values, and the result of self-tests already performed by the TPM. It does not start a new self-test or change TPM state.`, + `tpm2_getcap properties-fixed, tpm2_getcap pcrs, tpm2_pcrread, tpm2_gettestresult`, + `Seconds - read-only queries; no ownership, NV, PCR, or key changes.`, + )) + `
@@ -211,7 +219,7 @@ func renderValidateMode(opts HandlerOptions, stressDefault bool) string { let satES = null; ` + satStressModeJS + ` function satLabels() { - return {nvidia:'Validate GPU', 'nvidia-targeted-stress':'NVIDIA Targeted Stress (dcgmi diag targeted_stress)', 'nvidia-targeted-power':'NVIDIA Targeted Power (dcgmi diag targeted_power)', 'nvidia-pulse':'NVIDIA PSU Pulse Test (dcgmi diag pulse_test)', 'nvidia-interconnect':'NVIDIA Interconnect (NCCL all_reduce_perf)', 'nvidia-bandwidth':'NVIDIA Bandwidth (NVBandwidth)', memory:'Validate Memory', storage:'Validate Storage', cpu:'Validate CPU', amd:'Validate AMD GPU', 'amd-mem':'AMD GPU MEM Integrity', 'amd-bandwidth':'AMD GPU MEM Bandwidth'}; + return {nvidia:'Validate GPU', 'nvidia-targeted-stress':'NVIDIA Targeted Stress (dcgmi diag targeted_stress)', 'nvidia-targeted-power':'NVIDIA Targeted Power (dcgmi diag targeted_power)', 'nvidia-pulse':'NVIDIA PSU Pulse Test (dcgmi diag pulse_test)', 'nvidia-interconnect':'NVIDIA Interconnect (NCCL all_reduce_perf)', 'nvidia-bandwidth':'NVIDIA Bandwidth (NVBandwidth)', memory:'Validate Memory', storage:'Validate Storage', tpm:'Validate TPM (read-only)', cpu:'Validate CPU', amd:'Validate AMD GPU', 'amd-mem':'AMD GPU MEM Integrity', 'amd-bandwidth':'AMD GPU MEM Bandwidth'}; } let satNvidiaGPUsPromise = null; function loadSatNvidiaGPUs() { @@ -416,7 +424,7 @@ function runAllSAT() { const status = document.getElementById('sat-all-status'); status.textContent = 'Enqueuing...'; const stressOnlyTargets = ['nvidia-targeted-stress', 'nvidia-targeted-power', 'nvidia-pulse']; - const baseTargets = ['nvidia','nvidia-targeted-stress','nvidia-targeted-power','nvidia-pulse','nvidia-interconnect','nvidia-bandwidth','memory','storage','cpu'].concat(selectedAMDValidateTargets()); + const baseTargets = ['nvidia','nvidia-targeted-stress','nvidia-targeted-power','nvidia-pulse','nvidia-interconnect','nvidia-bandwidth','memory','storage','tpm','cpu'].concat(selectedAMDValidateTargets()); const activeTargets = baseTargets.filter(target => { if (stressOnlyTargets.indexOf(target) >= 0 && !satStressMode()) return false; const btn = document.getElementById('sat-btn-' + target); @@ -498,6 +506,7 @@ func loadValidateInventory(opts HandlerOptions) validateInventory { CPU: unknown, Memory: unknown, Storage: unknown, + TPM: unknown, NVIDIA: unknown, AMD: unknown, } @@ -561,6 +570,7 @@ func loadValidateInventory(opts HandlerOptions) validateInventory { out.CPU = formatValidateDeviceSummary(cpuTotal, cpuCounts, "CPU") out.Memory = formatValidateDeviceSummary(memTotal, memCounts, "module") out.Storage = formatValidateDeviceSummary(storageTotal, storageCounts, "device") + out.TPM = formatValidateTPMSummary(snap.Hardware.PlatformConfig) out.NVIDIA = formatValidateDeviceSummary(nvidiaTotal, nvidiaCounts, "GPU") out.AMD = formatValidateDeviceSummary(amdTotal, amdCounts, "GPU") out.NvidiaGPUCount = nvidiaTotal @@ -568,6 +578,30 @@ func loadValidateInventory(opts HandlerOptions) validateInventory { return out } +func formatValidateTPMSummary(raw *json.RawMessage) string { + if raw == nil { + return "TPM presence was not collected." + } + var config map[string]any + if err := json.Unmarshal(*raw, &config); err != nil { + return "TPM presence was not collected." + } + present, ok := config["TpmPresent"].(bool) + if !ok { + return "TPM presence was not collected." + } + if !present { + return "No TPM detected." + } + parts := []string{"TPM detected"} + for _, key := range []string{"TpmVersion", "TpmManufacturer", "TpmFirmwareVersion", "TpmInterface"} { + if value, ok := config[key].(string); ok && strings.TrimSpace(value) != "" { + parts = append(parts, html.EscapeString(value)) + } + } + return strings.Join(parts, " / ") +} + func renderValidateCardBody(devices, description, commands, settings string) string { return `
` + devices + `
` + `
` + description + `
` + @@ -665,6 +699,12 @@ func renderCheck(opts HandlerOptions) string { `lsblk; NVMe: nvme id-ctrl, nvme smart-log; SATA/SAS: smartctl -H -A`, `Seconds — instantaneous device query, no wear counters incremented.`, )) + + renderSATCard("tpm", "TPM", "runSAT('tpm')", "", renderValidateCardBody( + inv.TPM, + `Checks TPM 2.0 communication and reads fixed properties, allocated PCR banks, current PCR values, and the result of self-tests already performed by the TPM. It never starts a new self-test.`, + `tpm2_getcap properties-fixed, tpm2_getcap pcrs, tpm2_pcrread, tpm2_gettestresult`, + `Seconds - read-only queries; no ownership, NV, PCR, or key changes.`, + )) + renderSATCard("nvidia-config", "GPU Config & NVLink", "runSAT('nvidia-config')", "", renderValidateCardBody( inv.NVIDIA, `Checks GPU configuration and NVLink topology that DCGM diag does not cover: ECC/MIG/power-limit drift from factory default, NVLink-bonded pair link count and error counters, and (informational) NVIDIA Confidential Computing readiness (CPU TEE support + GPU firmware CC capability). Read-only — changes nothing.`, @@ -744,7 +784,7 @@ func renderCheck(opts HandlerOptions) string {