Skip to main content
Glama
DeveshB-1

hostproof-mcp

by DeveshB-1
README.md
# hostproof

SOC 2 and ISO 27001 evidence from self-hosted Linux servers and Kubernetes clusters, pushed into Sprinto monitors through Sprinto's Upload Evidence API. It ships as a CLI and as an MCP server, so an agent can run the checks and file the evidence too.

## Why this exists

Sprinto's cloud integrations cover AWS, GCP and Azure accounts. They can't see inside a bare-metal box in a colo, an on-prem RHEL fleet, or a k3s cluster behind a VPN. For those, Sprinto's docs point to the [Upload Evidence API](https://docs.sprinto.com/api-reference/upload-evidence-api): you collect the proof yourself and POST it to a monitor.

hostproof is the "collect it yourself" part. It runs read-only checks where Sprinto can't reach, maps each one to the controls an auditor asks about, packs the raw output into a zip with a sha256 manifest, and uploads one bundle per monitor.

Sprinto already has an [official MCP server](https://docs.sprinto.com/sprinto-ai/sprinto-mcp). It works on records already inside a Sprinto workspace (controls, risks, policies, evidence). hostproof's MCP server sits on the host and produces new evidence, so the two work side by side.

## What it checks

```
$ hostproof checks
linux.ssh_root_login         SOC2 CC6.1       ISO A.8.2,A.8.5   SSH root login is disabled
linux.ssh_password_auth      SOC2 CC6.1       ISO A.8.5         SSH password authentication is disabled
linux.firewall               SOC2 CC6.6       ISO A.8.20        Host firewall is active
linux.disk_encryption        SOC2 CC6.1,CC6.7 ISO A.8.24        Data filesystems are encrypted at rest
linux.time_sync              SOC2 CC7.2       ISO A.8.17        Clock is synchronized to NTP
linux.audit_logging          SOC2 CC7.2       ISO A.8.15        Audit logging is enabled and persistent
linux.password_aging         SOC2 CC6.1       ISO A.5.17        Local password maximum age is enforced
linux.uid0_accounts          SOC2 CC6.2,CC6.3 ISO A.8.2         No extra UID 0 accounts
linux.auto_updates           SOC2 CC7.1       ISO A.8.8         Automatic security updates are enabled
k8s.privileged_containers    SOC2 CC6.8       ISO A.8.9         No privileged or host-namespace workload containers
k8s.run_as_non_root          SOC2 CC6.8       ISO A.8.9         Workload containers run as non-root
k8s.network_policies         SOC2 CC6.6       ISO A.8.22        Namespaces default-deny ingress traffic
k8s.cluster_admin_bindings   SOC2 CC6.3       ISO A.8.2         cluster-admin is not granted to people or app service accounts
k8s.image_pinning            SOC2 CC8.1       ISO A.8.32        Workload images are pinned to a tag or digest
k8s.resource_limits          SOC2 A1.1        ISO A.8.6         Workload containers set resource limits
```

ISO IDs are Annex A of ISO/IEC 27001:2022. The mappings are my suggestions; the auditor has the final say.

A few details that are easy to get wrong:

- **sshd uses the first value it reads**, and `Include` expands in place. On most distros the drop-in directory is included at the top of `sshd_config`, so a drop-in beats the main file. hostproof follows the same order and reports when a value is the OpenSSH default rather than set explicitly.
- **No root needed.** If `sshd -T` fails without root, hostproof parses the config files instead. Firewall and audit checks read service state through `systemctl`.
- **Pod specs contain secrets.** Env values in pod JSON often hold passwords. Kubernetes evidence stores only the findings (pod and container names), never the raw spec, and Secrets are never listed. A test plants `DB_PASSWORD=hunter2...` in a fixture pod and checks it never reaches the bundle.

## Quickstart

```bash
uv sync                        # or: pip install -e '.[mcp]'
uv run hostproof run           # Linux checks on this machine
uv run hostproof run --k8s --kubectl "kubectl --context prod"
uv run hostproof run --bundle evidence.zip
uv run hostproof verify evidence.zip
```

Pushing to Sprinto:

```bash
export SPRINTO_API_TOKEN=...   # your Sprinto Developer API key
uv run hostproof monitors      # pk, type and name of each monitor
cp examples/monitors.json monitors.json   # map monitor pks to check ids
uv run hostproof upload --monitors monitors.json --dry-run
uv run hostproof upload --monitors monitors.json --region in   # us | eu | in | au
```

MCP server (Claude Desktop, Claude Code, Cursor):

```json
{
  "mcpServers": {
    "hostproof": {
      "command": "uv",
      "args": ["--directory", "/path/to/hostproof", "run", "hostproof-mcp"],
      "env": { "SPRINTO_API_TOKEN": "...", "SPRINTO_REGION": "us", "HOSTPROOF_KUBECTL": "kubectl" }
    }
  }
}
```

Tools: `list_checks`, `run_compliance_checks`, `write_evidence_bundle`, `list_sprinto_monitors`, `upload_evidence`. `upload_evidence` returns a preview and sends nothing unless it's called again with `confirm=true`, so the agent has to show a person what's about to land in the audit trail.

## Sprinto API details used

From Sprinto's public docs:

- `POST {base}/api/v1/uploadEvidence`, `multipart/form-data`, header `api-token`, fields `monitor_pk` and `files` (repeatable, up to 10). Success is `201 {"success": true, "error": ""}`; a bad token is `401` with `"api-token not found"`.
- Monitor lookup: GraphQL `query { monitors { nodes { pk name monitorType } } }` on `{base}/dev-api/graphql`.
- Rate limit: 10 requests per minute per key and per IP, and going over blocks access for 10 minutes. The client waits 6.5 s between calls, which is why the two-monitor upload below takes 6.6 s.

The evidence bundle is a `.zip`, one of the formats Sprinto lists for evidence files.

## Demo run

These are real runs on my Arch Linux laptop (not a hardened server, so it fails several checks). Output is pasted as it came out, trimmed only where marked `...`.

```
$ hostproof run
WARN  linux.ssh_root_login     CC6.1       A.8.2,A.8.5   PermitRootLogin prohibit-password (OpenSSH default): root can still log in with a key
FAIL  linux.ssh_password_auth  CC6.1       A.8.5         PasswordAuthentication yes (OpenSSH default): password logins are accepted
PASS  linux.firewall           CC6.6       A.8.20        host firewall service active: firewalld
FAIL  linux.disk_encryption    CC6.1,CC6.7 A.8.24        unencrypted mounted filesystems: nvme1n1p2 (ext4) on /
PASS  linux.time_sync          CC7.2       A.8.17        system clock is NTP-synchronized
WARN  linux.audit_logging      CC7.2       A.8.15        auditd is not running; only persistent journald logs exist
FAIL  linux.password_aging     CC6.1       A.5.17        PASS_MAX_DAYS 99999 (policy: <= 90)
PASS  linux.uid0_accounts      CC6.2,CC6.3 A.8.2         root is the only UID 0 account
FAIL  linux.auto_updates       CC7.1       A.8.8         no dnf-automatic or unattended-upgrades unit is enabled

4 fail, 3 pass, 2 warn
```

Kubernetes checks against the cluster fixture in `tests/fixtures/k8s` (kubectl `-o json` output for 4 namespaces, 4 pods, 2 NetworkPolicies and 4 ClusterRoleBindings; see "What's mocked"):

```
$ hostproof run --k8s-fixture tests/fixtures/k8s --only k8s.privileged_containers k8s.run_as_non_root k8s.network_policies k8s.cluster_admin_bindings k8s.image_pinning k8s.resource_limits
FAIL  k8s.privileged_containers   CC6.8       A.8.9         2 privileged or host-namespace findings: web/debug-shell:shell, web/debug-shell (hostNetwork/hostPID)
FAIL  k8s.run_as_non_root         CC6.8       A.8.9         2 container(s) may run as root (no runAsNonRoot / non-zero runAsUser): web/frontend-5c7d8b-qwert:nginx, web/debug-shell:shell
FAIL  k8s.network_policies        CC6.6       A.8.22        namespaces without a default-deny ingress NetworkPolicy: default, web
FAIL  k8s.cluster_admin_bindings  CC6.3       A.8.2         non-system subjects bound to cluster-admin: User/alice@acme.example via alice-admin, ServiceAccount/deployer via ci-deployer
WARN  k8s.image_pinning           CC8.1       A.8.32        2 container(s) on :latest or untagged images: web/frontend-5c7d8b-qwert:nginx (nginx:latest), web/debug-shell:shell (busybox)
WARN  k8s.resource_limits         A1.1        A.8.6         1 container(s) without CPU/memory limits: web/frontend-5c7d8b-qwert:nginx

4 fail, 2 warn
```

The bundle, and the tamper check:

```
$ hostproof run --bundle out/evidence.zip    # same table as above
$ hostproof verify out/evidence.zip
manifest OK
$ unzip -l out/evidence.zip
     1273  summary.md
    18366  results.json
     3913  raw/linux.ssh_root_login.txt
     ...   (one raw/<check>.txt per check)
     1273  manifest.json
```

Upload over real HTTP to the local mock of Sprinto's endpoints (`python -m tests.mock_sprinto 18765`):

```
$ hostproof upload --monitors out/monitors.json --dry-run
[dry-run] POST /api/v1/uploadEvidence monitor_pk=7c9e6679-7425-40de-944b-e07fc1f90ae7 files=hostproof-demo-host-2026-10-25.zip (6711 bytes): linux.ssh_root_login=warn, linux.ssh_password_auth=fail
[dry-run] POST /api/v1/uploadEvidence monitor_pk=16fd2706-8baf-433b-82eb-8c7fada847da files=hostproof-demo-host-2026-10-25.zip (1876 bytes): linux.disk_encryption=fail

$ time SPRINTO_API_TOKEN=test-token hostproof upload --monitors out/monitors.json --base-url http://127.0.0.1:18765
uploaded hostproof-demo-host-2026-10-25.zip to monitor 7c9e6679-7425-40de-944b-e07fc1f90ae7: linux.ssh_root_login=warn, linux.ssh_password_auth=fail
uploaded hostproof-demo-host-2026-10-25.zip to monitor 16fd2706-8baf-433b-82eb-8c7fada847da: linux.disk_encryption=fail
real	0m6.657s

$ SPRINTO_API_TOKEN=bad hostproof upload --monitors out/monitors.json --base-url http://127.0.0.1:18765 --only linux.disk_encryption
error: upload to monitor 16fd2706-8baf-433b-82eb-8c7fada847da failed (HTTP 401): api-token not found
```

The MCP flow, driven by the MCP Python client over stdio (`examples/mcp_demo.py`):

```
tools: list_checks, run_compliance_checks, write_evidence_bundle, list_sprinto_monitors, upload_evidence
monitors: ['SSH hardening on servers', 'Encryption at rest']
preview (sent=False):
   linux.ssh_root_login=warn: PermitRootLogin prohibit-password (OpenSSH default): root can still log in with a key
   linux.ssh_password_auth=fail: PasswordAuthentication yes (OpenSSH default): password logins are accepted
confirmed: sent=True response={'success': True, 'error': ''}
```

## Tests

```
$ uv run pytest -q
.........................                                                [100%]
25 passed in 3.85s
```

They cover hardened and weak hosts through a fixture executor, sshd `Include` ordering, the Kubernetes checks, secret redaction, bundle determinism and tamper detection, multipart bytes surviving the upload intact, 400/401 handling, the 10-file cap, rate-limit spacing, and a real stdio session with the MCP server.

## What's mocked

- **Sprinto.** I don't have a Sprinto workspace or API token, so uploads went to `tests/mock_sprinto.py`, a local server written from the public docs (header name, field names, the 10-file cap, response shapes). The real call path is the same code with `--region` instead of `--base-url`. Two things I couldn't confirm from the docs: whether the GraphQL endpoint takes the same `api-token` header as the upload endpoint, and whether `/api/v1/uploadEvidence` lives on the `eu.`, `in.` and `au.` hosts too (the docs show it only on `app.sprinto.com`).
- **Kubernetes.** I tried to start k3s in rootless podman for a live cluster, but this machine doesn't delegate the cpuset cgroup to user sessions, so it exited at startup. The K8s checks ran against hand-written kubectl JSON fixtures instead. Point `--kubectl` at a real cluster and the same code runs.
- **Linux.** Not mocked. The demo above is this laptop.

## Layout

```
hostproof/checks/linux.py   9 host checks
hostproof/checks/k8s.py     6 cluster checks
hostproof/runner.py         run checks, build and verify the zip bundle
hostproof/sprinto.py        Sprinto client (upload, monitor lookup, rate limit)
hostproof/cli.py            hostproof run | upload | monitors | verify | checks
hostproof/mcp_server.py     MCP tools
tests/mock_sprinto.py       local stand-in for Sprinto's evidence endpoints
```

MIT licensed.