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.`,
+ )) +
`
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 {