Skip to main content
Glama
xordej

Inventory Guard MCP Server

by xordej
README.md
# Inventory Guard (`invguard`)

**A self-hosted vulnerability watchdog for the hardware and software you actually own.**

You tell it what's in your rack. It watches the public CVE firehose — NVD, CVE List V5, OSV, CISA KEV, EPSS, Cisco PSIRT, MSRC, CSAF vendors — and tells you when something that matters to *your* boxes shows up. Nothing else.

It runs in one container, on one machine, behind loopback. It never scans your network. It never can.

> **Version 1.7.2** · Python 3.12 / FastAPI / SQLite · Docker Compose · MCP server included
>
> **Note on language:** the web UI, CLI output and error messages are in **Ukrainian**. The codebase, API and this document are in English. Internationalization is on the roadmap, not in the box.

---

## Table of contents

- [The design stance](#the-design-stance)
- [What it does](#what-it-does)
- [Architecture](#architecture)
- [How a scan actually runs](#how-a-scan-actually-runs)
- [Data sources](#data-sources)
- [The matching pipeline](#the-matching-pipeline)
- [Finding lifecycle](#finding-lifecycle)
- [Egress guard](#egress-guard)
- [Installation](#installation)
- [First run](#first-run)
- [Configuration](#configuration)
- [MCP integration](#mcp-integration)
- [REST API](#rest-api)
- [CLI](#cli)
- [Backup, restore and updates](#backup-restore-and-updates)
- [Troubleshooting](#troubleshooting)
- [Development](#development)
- [Project layout](#project-layout)

---

## The design stance

Most vulnerability tooling starts by scanning your network. Inventory Guard starts by *refusing* to.

The inventory is maintained **by hand** — web forms, CSV import, or an AI agent acting on your explicit instruction. There is no discovery, no SNMP, no SSH polling, no agents on endpoints. This isn't a missing feature; it's the product. A security tool that opens connections into your infrastructure is a security tool that can be turned against it.

Three invariants follow from that, and they're enforced in code and in tests:

| Invariant | Enforcement |
|---|---|
| The app never initiates connections into your internal network | `httpguard` rejects literal IPs and all private/link-local ranges, on the **resolved** address, redirects included |
| Only public identifiers leave the machine | Outbound URL and body are scanned; anything matching `name`, `role`, `notes`, `host_label` or an IP address is refused before the socket opens |
| A feed failure degrades, never cascades | Each connector is wrapped; a failure lands in `feeds_failed` with a human-readable reason, the run continues on cache, and the report marks that source as stale |

What actually goes over the wire: CPE strings, PURLs, version numbers, package names. Public classifiers. Nothing that describes your topology.

---

## What it does

- **Manual inventory** of devices (vendor / model / OS family / version → CPE 2.3) and software packages (name / ecosystem / version → PURL), with cascading autocomplete backed by the official NIST CPE dictionary and ecosystem registries.
- **Incremental feed sync** — full CVE corpus on first run, cursor-based deltas after that.
- **Version-range matching** across CPE ranges and ecosystem version schemes, with source priority `vendor > NVD > OSV` and provenance recorded per match.
- **Scheduled, triggered and manual scans** — weekly by default, 60-second debounce after inventory edits, or on demand.
- **Markdown reports** stored in SQLite with a one-way file mirror, plus a diff of new/closed findings per run.
- **Alerts** on KEV membership, CVSS ≥ 7.0 or EPSS ≥ 0.5 (all configurable), deduplicated per finding.
- **Findings workflow** — `open → acknowledged → fixed | false_positive`, append-only history.
- **MCP server** so Claude Desktop or any other agent can read the report, run a scan and triage findings.
- **CSV/JSON export** and a full database dump endpoint.

---

## Architecture

One process, one SQLite file, three ways in.

```mermaid
graph TB
    subgraph clients["Clients"]
        UI["Web UI<br/><i>vanilla ES modules</i>"]
        CURL["REST clients<br/><i>scripts, CI</i>"]
        AGENT["AI agents<br/><i>Claude Desktop, IDEs</i>"]
    end

    subgraph app["invguard container - 127.0.0.1:8480"]
        MW["Bearer-token middleware<br/><i>everything except /health and /</i>"]
        ROUTERS["FastAPI routers<br/><i>thin: zero logic</i>"]
        MCP["FastMCP server<br/><i>/mcp + stdio shim</i>"]

        subgraph domain["Domain"]
            INV["inventory<br/>service, importer, catalog"]
            SCAN["compliance<br/>scanner, reporter, alerts, scheduler"]
            MATCH["matching<br/>engine, versions"]
            KB["kb<br/>worker, store, sources"]
            FEEDS["feeds<br/>connector registry"]
        end

        GUARD["httpguard<br/><i>the only door outward</i>"]
        DB[("SQLite WAL<br/>invguard.db<br/><i>0600, self-migrating</i>")]
    end

    EXT["Public feeds<br/><i>NVD, CVE V5, OSV, KEV, EPSS,<br/>Cisco, MSRC, CSAF</i>"]
    FS["/reports<br/><i>one-way markdown mirror</i>"]

    UI --> MW
    CURL --> MW
    AGENT --> MCP
    MW --> ROUTERS
    ROUTERS --> INV
    ROUTERS --> SCAN
    ROUTERS --> MATCH
    ROUTERS --> KB
    MCP --> INV
    MCP --> SCAN
    MCP --> MATCH
    SCAN --> FEEDS
    KB --> GUARD
    FEEDS --> GUARD
    GUARD --> EXT
    INV --> DB
    SCAN --> DB
    MATCH --> DB
    KB --> DB
    FEEDS --> DB
    SCAN --> FS
```

**Why these choices, briefly.** SQLite because this is a single-user local service and one file is a trivial backup — access goes through a repository layer so a PostgreSQL move wouldn't touch the domain. Vanilla JS because a security tool shouldn't ship a build chain and a `node_modules` supply chain with it. One uvicorn worker because a shared DB connection and a shared scheduler beat horizontal scaling for a workload measured in dozens of inventory items. No Celery/Redis for background jobs — the queue lives in the same SQLite database (`kb_jobs`) with an asyncio worker in the app lifespan, which survives restarts without two extra containers.

---

## How a scan actually runs

Three triggers, one pipeline, exactly one run at a time.

```mermaid
flowchart TD
    T1["cron<br/><i>every N hours, default 168</i>"] --> LOCK
    T2["inventory changed<br/><i>60s debounce</i>"] --> LOCK
    T3["manual / scan_now<br/><i>UI, REST, agent</i>"] --> LOCK

    LOCK{{"asyncio.Lock + flock<br/><i>single-flight across processes</i>"}}
    LOCK --> RUN["INSERT scan_runs<br/><i>trigger, scope</i>"]

    RUN --> FACTS["Phase 1 - FACT feeds, sequential<br/>cvelist_v5, cisco_openvuln, msrc,<br/>csaf, nvd, osv"]
    FACTS --> STORE[("advisories + affected<br/><i>cached corpus</i>")]
    STORE --> ENGINE["matching.engine.sync()<br/><i>exactly once</i>"]
    ENGINE --> DIFF["diff vs previous run<br/><i>new, closed, total_open</i>"]
    DIFF --> ENRICH["Phase 2 - ENRICHMENT feeds<br/>cisa_kev, epss, premium<br/><i>only for advisories that actually matched</i>"]
    ENRICH --> FINISH["UPDATE scan_runs<br/><i>feeds_ok / feeds_failed snapshot</i>"]
    FINISH --> REPORT["reporter.generate()<br/><i>markdown to DB + atomic file mirror</i>"]
    REPORT --> ALERTS["alerts.process()<br/><i>KEV, or CVSS 7.0+, or EPSS 0.5+</i>"]

    FACTS -. "connector raises" .-> FAIL["feeds_failed[]<br/><i>run continues on cache,<br/>report marks the stale source</i>"]
    FAIL -.-> ENGINE
```

Two ordering decisions worth calling out. **Facts before enrichment**: KEV and EPSS are asked only about advisories that already matched something you own — no point pulling exploit probability for CVEs irrelevant to your rack. **Matching runs exactly once**, between the phases; enrichment doesn't change `affected`, so a second sync would only zero out the "new findings" counter.

Passing `target_ids` to `scan_now` narrows the run to specific inventory items — seconds instead of minutes, useful right after adding one device.

---

## Data sources

| Connector | Source | Auth | Role |
|---|---|---|---|
| `cvelist_v5` | CVE List V5 (MITRE bulk releases) | none | Core corpus, incremental by release cursor |
| `nvd` | NVD API 2.0 | optional free key | CPE configurations with version ranges — the backbone of hardware matching |
| `osv` | OSV.dev | none | Software packages by PURL (PyPI, npm, Go, Maven, deb, rpm) |
| `cisa_kev` | CISA Known Exploited Vulnerabilities | none | "Exploited in the wild" flag — always alerts |
| `epss` | FIRST.org EPSS | none | Exploitation probability 0–1, drives report ordering |
| `cisco_openvuln` | Cisco PSIRT openVuln | OAuth2 (free registration) | Queries by exact IOS/IOS-XE/NX-OS/ASA version — more precise than CPE matching, returns first-fixed |
| `msrc` | Microsoft MSRC | none | Windows: matches against `FixedBuild` per remediation |
| `csaf` | Any CSAF 2.0 provider | none | One parser covers Dell, VMware, Juniper, Fortinet, Siemens… configure provider URLs in settings |
| `vulners` | Vulners *(premium)* | API key | Vendor bulletin aggregation, exploit markers |
| `vulncheck` | VulnCheck *(premium)* | API key | Extended KEV, exploit intelligence |

**Without an NVD API key** you're rate-limited to roughly 5 requests per 30 seconds and the first corpus build takes a long while. The key is free and takes two minutes: <https://nvd.nist.gov/developers/request-an-api-key>. Roughly a tenfold speedup.

**Premium connectors are dark by default.** They don't appear in a run, don't register their egress domains, and can't open a socket until you enter every required key. Deleting the key disables them instantly, no restart. Data from them only *enriches* records (`enrichment_json`) — a match found solely by a premium source is tagged `matched_by='<source>'` and badged in the UI, so every decision stays auditable.

MikroTik RouterOS and Supermicro BMC/BIOS have no vendor API. They're covered through NVD CPE + CVE List V5, and the report says so honestly: `matched_by='nvd_cpe'`.

---

## The matching pipeline

```mermaid
flowchart LR
    subgraph inv["Your inventory"]
        D["Device<br/><i>cisco / c9300 / ios_xe / 17.6.4</i>"]
        P["Package<br/><i>pypi / requests / 2.28.1</i>"]
    end

    D --> CPE["CPE 2.3<br/><i>cpe:2.3:o:cisco:ios_xe:17.6.4</i>"]
    P --> PURL["PURL<br/><i>pkg:pypi/requests@2.28.1</i>"]

    AFF[("affected<br/>cpe_or_purl + version_range_json")]

    CPE --> CMP{"versions.compare()<br/><i>range boundaries, epochs,<br/>release suffixes,<br/>Including / Excluding</i>"}
    PURL --> CMP
    AFF --> CMP

    CMP -->|hit| M["match<br/><i>matched_by = vendor / nvd_cpe / osv / csaf</i>"]
    CMP -->|no hit| SKIP["ignored"]

    M --> PRIO{"conflicting sources?"}
    PRIO -->|"vendor beats nvd beats osv"| KEEP["keep the most authoritative<br/><i>the vendor knows its own fixed versions</i>"]
```

Version comparison is the part that quietly decides whether the whole product is useful. Boundary conditions (`versionEndExcluding` vs `versionEndIncluding`), RPM epochs, release suffixes and vendor formats like `15.2(2)E` each have dedicated tests — a false "no vulnerabilities" is the worst possible output of a tool like this.

---

## Finding lifecycle

```mermaid
stateDiagram-v2
    [*] --> open : matcher finds it
    open --> acknowledged : you take it on
    open --> fixed : version bumped
    open --> false_positive : comment required
    acknowledged --> fixed
    acknowledged --> false_positive : comment required
    fixed --> open : reappears in a later run
    false_positive --> [*]
    fixed --> [*]

    note right of open
        Every transition is appended to
        match_history. Nothing is ever
        overwritten.
    end note
```

Deleting a device closes its open findings with a "device decommissioned" marker rather than dropping the rows.

---

## Egress guard

Every outbound request — including each hop of a redirect chain — passes three independent checks before a socket is opened.

```mermaid
flowchart TD
    REQ["Outbound request<br/><i>from a connector or the KB worker</i>"] --> C1{"1. Host on the allowlist?<br/><i>core domains + egress_domains<br/>of ENABLED connectors only</i>"}
    C1 -->|no| BLOCK["GuardError<br/><i>reason surfaces in the run report</i>"]
    C1 -->|yes| C2{"2. Literal IP, or resolves to<br/>private / loopback / link-local?"}
    C2 -->|yes| BLOCK
    C2 -->|no| C3{"3. Do URL or body contain<br/>name / role / notes / host_label<br/>or any IP address?"}
    C3 -->|yes| BLOCK
    C3 -->|no| SEND["Send"]
    SEND --> REDIR{"redirect?"}
    REDIR -->|yes| C1
    REDIR -->|no| DONE["Response"]
```

Check 3 has a subtlety that bit us in 1.0.1: a device with the role `dell` or the name `cisco` was blocking every legitimate NVD request for that vendor. The guard now maintains a set of *public tokens* — CPE components, PURL components, vendor and OS-family dictionary entries — that are legal to send outward.

The single documented exception to the private-address rule is the alert webhook, and it's restricted to loopback: alerts are not permitted to leave the machine.

---

## Installation

### Requirements

- **Docker** with Compose v2 (`docker compose version`), or the legacy `docker-compose`
- **`bash`** and **`curl`** on the host — the install scripts need both. Most distributions ship them; Alpine and some minimal container images don't (`apk add bash curl`)
- ~2 GB free disk for the CVE corpus, 1 GB RAM available to the container
- Outbound HTTPS to the feed domains

On **native Linux**, your user must be able to talk to the Docker socket. If `docker info` fails with *permission denied*:

```bash
sudo usermod -aG docker "$USER"    # then log out and back in, or: newgrp docker
```

`install.sh` checks this up front and prints that command rather than failing later with something unrelated.

### Quick start

```bash
git clone https://github.com/<you>/invguard.git
cd invguard
bash install.sh
```

`install.sh` checks Docker and `curl`, creates `data/`, `reports/`, `upd/`, `backups/`, writes `.env`, builds and starts the container, waits for health, and **prints your access token**. It's idempotent — re-running it never loses data.

### Why there's a generated `.env`

This is the one piece of setup that genuinely differs between platforms, so it's worth thirty seconds of your attention.

The container writes to `./data` and `./reports` through bind mounts. On **Docker Desktop** (macOS, Windows) the file-sharing layer rewrites ownership, so whatever UID runs inside the container appears as *you* on the host. On **native Linux** there is no such translation — UIDs are literal. A container running as UID 10001 simply cannot write into a `data/` directory that your user created with mode 700, and the failure is ugly: SQLite can't create the database, the app dies at startup, no token is ever generated.

So `install.sh` writes your host UID/GID into `.env`, and `compose.yaml` interpolates them:

```
INVGUARD_UID=1000
INVGUARD_GID=1000
```

```yaml
user: "${INVGUARD_UID:-10001}:${INVGUARD_GID:-10001}"
```

Consequences worth knowing:

- `.env` is **machine-specific** and gitignored. Cloning the repo elsewhere means running `install.sh` (or copying `.env.example` and filling in `id -u` / `id -g`) — bringing someone else's `.env` along will break the mount.
- Running `install.sh` **as root** deliberately does *not* run the container as root: it keeps UID 10001 and `chown`s the volumes to match.
- If you ever start the stack by hand without `.env` present, it falls back to UID 10001 and you're back to the Linux permission problem. `docker compose up` alone is only safe once `.env` exists.

### Manual

```bash
docker compose up -d --build
docker compose ps                        # invguard: Up (healthy)
cat data/.token                          # your token
curl http://127.0.0.1:8480/health
```

Then open <http://127.0.0.1:8480> and paste the token when prompted. The browser stores it locally; you won't be asked again.

### A word on exposure

The port binds to `127.0.0.1` only. Publishing it to a LAN is a deliberate act, and if you do it, put TLS in front — your own reverse proxy or Tailscale. Don't just flip the compose mapping to `0.0.0.0` and call it a day: the token is the only authentication layer, and it was designed for a loopback threat model.

---

## First run

```mermaid
sequenceDiagram
    actor You
    participant UI as Web UI
    participant KB as KB worker
    participant Scan as Scanner
    participant Feeds as Public feeds

    You->>UI: paste token
    You->>UI: settings - add NVD API key
    You->>UI: "+ Add vendor" (e.g. Cisco)
    UI->>KB: enqueue job (202)
    KB->>Feeds: page through the NIST CPE dictionary
    KB-->>UI: progress: vendors, models, OS, versions
    You->>UI: add device - autocomplete fills the CPE
    UI->>Scan: inventory changed (60s debounce)
    Scan->>Feeds: full corpus sync - minutes, once
    Scan-->>UI: report + findings
```

Recommended order:

1. **⚙ → NVD API key.** Do this first; everything downstream is faster.
2. **⚙ → "+ Add vendor"** for each vendor in your rack. This pulls that vendor's slice of the official CPE dictionary into a local knowledge base, so autocomplete only ever offers identifiers that will actually match. Runs in the background with a progress bar; a giant vendor is capped at 120k entries.
3. **Inventory → + Add** devices and packages. Vendor → model → OS → version cascades; the CPE is assembled for you and validated before saving. Or import CSV — you get a per-row preview with reasons for every rejected line before anything is written, and partial success ("added N, rejected M").
4. **Report → "Scan now".** The first run downloads the full CVE corpus — hundreds of MB, expect minutes. Later runs are incremental.
5. **`bash scripts/setup-mcp.sh`** if you want an agent connected.

The suggestion order in autocomplete is deliberately precision-first: registry version → exact OS build → CPE marketing label. For Windows 10 you'll be offered `10.0.19045.5011` before `22h2`, because the build number is what actually determines whether a patch is installed.

---

## Configuration

### Environment (compose.yaml)

| Variable | Default | Meaning |
|---|---|---|
| `INVGUARD_DATA_DIR` | `/data` | SQLite, token file, CVE mirror |
| `INVGUARD_REPORTS_DIR` | `/reports` | Markdown report mirror |
| `INVGUARD_TOKEN_FILE` | `<data_dir>/.token` | Where the plaintext token is written once |
| `INVGUARD_UID` / `INVGUARD_GID` | host `id -u` / `id -g` | Read by Compose, not by the app — see [`.env`](#why-theres-a-generated-env) |

**There is no port environment variable.** The port inside the container is fixed at 8480 by the Dockerfile `CMD` and the healthcheck. To change the port you reach the service on, edit the mapping in `compose.yaml` — the first number is the host side:

```yaml
ports:
  - "127.0.0.1:9000:8480"     # http://127.0.0.1:9000
```

Keep the `127.0.0.1:` prefix unless you've read [A word on exposure](#a-word-on-exposure).

### Settings (⚙ in the UI, stored in `kv`)

| Setting | Default | Notes |
|---|---|---|
| `scan_interval_hours` | `168` | 1 hour – 90 days. Counted from the *last* run of any kind |
| `alert_cvss_threshold` | `7.0` | |
| `alert_epss_threshold` | `0.5` | KEV always alerts regardless |
| `alert_webhook_url` | *(empty)* | Loopback hosts only |
| `nvd_api_key` | *(empty)* | Masked on read — last 4 characters only |
| `cisco_client_id` / `cisco_client_secret` | *(empty)* | Enables the openVuln connector |
| `csaf_provider_urls` | *(empty)* | Comma-separated `https://…/provider-metadata.json` |
| `vulners_api_key` / `vulncheck_api_key` | *(empty)* | Enables premium connectors |
| `premium_daily_limit` | `500` | Request budget per premium connector per day |

**Secrets entered in the UI take priority over the environment**, are stored in the `kv` table, are masked on read and never appear in logs.

---

## MCP integration

The same domain, exposed as agent tools. Two transports: **streamable HTTP** mounted at `/mcp` behind the same bearer token, and a **stdio shim** for desktop clients.

```bash
bash scripts/setup-mcp.sh          # writes the Claude Desktop config, keeps existing servers
```

The script backs up your config and always writes it if Docker and the container are found — problems are reported as warnings rather than aborting halfway (a lesson from 1.7.1, where a fragile handshake check meant the config was sometimes never written at all).

Manual config, if you'd rather:

```json
{
  "mcpServers": {
    "invguard": {
      "command": "/usr/local/bin/docker",
      "args": ["exec", "-i", "invguard", "python", "-m", "invguard.mcp_stdio"]
    }
  }
}
```

Use the absolute path to your `docker` binary (`command -v docker`) — desktop clients don't inherit your shell `PATH`. Config locations: `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS), `%APPDATA%\Claude\` (Windows), `~/.config/Claude/` (Linux). Fully quit and relaunch the client afterwards — not just close the window.

**Tools:** `inventory_add_device`, `inventory_add_package`, `inventory_import`, `inventory_list`, `scan_now`, `compliance_status`, `get_report`, `query_cve`, `ack_match`, `resolve_match`.

**Resource:** `report://latest` — the agent can read the latest report without calling a tool.

Verify readiness from inside the container:

```bash
docker exec -i invguard python -m invguard.cli mcp-check --verbose
```

---

## REST API

Everything the UI does, the API does. All routes except `/health` and `/` require `Authorization: Bearer <token>`.

```bash
TOKEN=$(cat data/.token)
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8480/api/summary
```

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/health`, `/api/version`, `/api/ping` | Liveness (`/health` is public) |
| `GET` `POST` `PUT` `DELETE` | `/api/devices[/{id}]` | Device CRUD |
| `GET` `POST` `PUT` `DELETE` | `/api/packages[/{id}]` | Package CRUD |
| `POST` | `/api/import/{devices\|packages}` | CSV import (`dry_run` for preview) |
| `GET` | `/api/import/{kind}/template` | CSV template |
| `GET` | `/api/catalog` | Vendor / OS-family dictionary |
| `POST` | `/api/scan` | Run now (optional `target_ids`) |
| `GET` | `/api/scan/runs` | Run history with feed snapshots |
| `GET` | `/api/summary`, `/api/matches[/{id}]` | Findings, filtered |
| `PUT` | `/api/matches/{id}/status` | Triage transition |
| `GET` | `/api/reports/latest`, `/api/reports/new/{run_id}` | Markdown |
| `POST` | `/api/reports/rebuild` | Rebuild the file mirror from the DB |
| `POST` `GET` | `/api/feeds/refresh`, `/api/feeds/status` | Feed sync and state |
| `GET` `PUT` | `/api/settings` | Thresholds, keys, schedule |
| `GET` `POST` `DELETE` | `/api/kb/vendors`, `/api/kb/jobs`, `/api/kb/suggest/*` | Knowledge base and autocomplete |
| `GET` | `/api/export/{inventory\|matches\|reports\|database}` | CSV / JSON / ZIP |

Domain errors come back as `400` with a human-readable `error` field (in Ukrainian). A missing or bad token is `401`.

---

## CLI

```bash
docker compose exec invguard python -m invguard.cli <command>
```

| Command | What it does |
|---|---|
| `status` | Schema version, DB size, inventory counts, corpus size, findings by status, last run, per-feed freshness |
| `backup [--output PATH]` | Consistent snapshot via `VACUUM INTO` — safe on a live database |
| `restore SOURCE [--force]` | Restore a snapshot; the current DB is renamed to `.db.old`, migrations run on connect |
| `rotate-token [--show]` | New token, old one invalid immediately, no restart |
| `rebuild-reports` | Regenerate the `reports/` file mirror from the database |
| `mcp-check [--verbose]` | MCP self-diagnosis: import, tool count, stdout cleanliness, installed SDK version |

The token's hash lives in the database, so a restored backup keeps working with the same token.

---

## Backup, restore and updates

**Backup is a copy of `data/`.** For a consistent snapshot of a running system use `invguard backup` rather than `cp` — `VACUUM INTO` won't catch the database mid-write.

**Updating from git:**

```bash
git pull
docker compose up -d --build
```

Migrations run automatically on connect, keyed by `PRAGMA user_version` (currently v9). The migration list is append-only — a newer binary upgrades an older database; the reverse is not supported, so take a snapshot first.

**Updating from a zip** (for air-gapped or non-git deployments) — drop the archive in `upd/` and run `bash update.sh`. It snapshots the database, backs up the current code, swaps it, rebuilds, and **rolls back automatically** if the build fails or health doesn't come up. `data/`, `reports/`, `upd/` and `backups/` are never touched.

After any update, hard-refresh the browser once (`Ctrl+F5` / `Cmd+Shift+R`) and fully restart your MCP client — the MCP process lives inside the container and went down with it.

---

## Troubleshooting

### Install and startup

**`docker: command not found` / "Docker не відповідає"**
Docker isn't installed or the daemon isn't running. On Linux: `sudo systemctl start docker`. On macOS/Windows: launch Docker Desktop and wait for it to go green.

**"compose.yaml не знайдено"**
Run the script from the repository root, not from a subdirectory.

**The service doesn't come up within 60 seconds**
`install.sh` now dumps the last 30 log lines automatically and names the three usual causes — you shouldn't have to go looking. To fetch more:

```bash
docker compose logs invguard --tail 100
```

Usual suspects: the volume isn't writable by the UID in `.env` (`unable to open database file`); port 8480 is taken (`ss -tlnp | grep 8480`, or `lsof -i :8480` — note `ss` isn't installed on every minimal system, `netstat -tlnp` is the older fallback); or a build failure that scrolled past. To move the port, edit the mapping in `compose.yaml`.

**"каталог data/ належить UID N, а контейнер працюватиме як M"**
`install.sh` caught an ownership mismatch before starting anything — it prints the exact `chown` to run. The usual cause is a first run under `sudo` followed by a run without it, or a `data/` restored from an archive that carried foreign ownership:

```bash
sudo chown -R "$(id -u):$(id -g)" data reports
```

**"unable to open database file" in the container log**
Same root cause, reached the hard way — the mount isn't writable by the UID in `.env`. Check that `.env` matches `id -u` / `id -g`, fix ownership as above, then `docker compose up -d`.

**Rootless Docker**
Under rootless Docker your UID is remapped inside the container, and `INVGUARD_UID` from `.env` may not be what the container actually sees. If the volumes turn out unwritable, the simplest fix is to let the daemon's own mapping own them: remove the `user:` line from `compose.yaml` and `chown -R 10001:10001 data reports` from inside the namespace (`podman unshare` has an equivalent). This is the one configuration where the `.env` mechanism doesn't help.

**SELinux (Fedora, RHEL, CentOS): `Permission denied` on the volumes**
`install.sh` detects `Enforcing` mode and warns you, but it won't edit your compose file. Add `:z` to both bind mounts in `compose.yaml`:

```yaml
volumes:
  - ./data:/data:z
  - ./reports:/reports:z
```

`:z` relabels the content as shared. Use `:Z` (capital) only if this is the sole container touching those directories.

### Access

**Every request returns 401**
The token in your browser is stale — most often after `rotate-token` or a fresh `data/`. Read the current one with `cat data/.token` and re-enter it via the "Токен" button.

**`data/.token` doesn't exist**
The container never completed its first start. Check the logs; the token is generated during lifespan startup, right after the database opens.

**Lost the token**

```bash
docker compose exec invguard python -m invguard.cli rotate-token --show
```

### Feeds and scanning

**The first scan takes forever**
Expected. The first run pulls the entire CVE corpus — hundreds of thousands of records. Add an NVD API key before you start (⚙ → NVD) for roughly a tenfold speedup. Subsequent runs are incremental and take seconds to minutes.

**A source shows up in `feeds_failed`**
By design the run continues on cached data and the report marks that source as stale. Check the reason on the **Прогони** (Runs) page:

- `HTTP 403/429` from NVD — you're rate-limited. Add an API key.
- "заблоковано httpguard" — the connector tried to reach a domain outside the allowlist, or the target resolved to a private address. For CSAF providers, confirm the URL is an `https://…/provider-metadata.json` and that its ROLIE feeds live on public hosts.
- Cisco `401` — OAuth2 credentials are wrong or the API subscription lapsed. Re-check client ID and secret at apiconsole.cisco.com.

**No findings at all, and that feels wrong**
Check that your inventory items actually have a CPE (the column is visible in the inventory table). A device with an empty or hand-typed invalid CPE matches nothing. Use "+ Add vendor" first so autocomplete offers dictionary-backed identifiers — everything it suggests is guaranteed to match.

**Findings for a version you already patched**
Update the version field on the device; the next run closes the finding automatically. If the source's range is genuinely wrong, mark the finding `false_positive` with a comment — it stays in the history and won't re-alert.

**A scan seems stuck**
Only one run executes at a time (an in-process lock plus a `flock` file, so the MCP shim and the web process can't collide). A run triggered while another is in flight waits its turn. If the container was killed mid-run, the kernel releases the `flock` automatically — just restart.

### MCP

**The server doesn't appear in Claude Desktop**
Run the diagnostic first — it reports facts, not guesses:

```bash
docker exec -i invguard python -m invguard.cli mcp-check --verbose
```

- *`No module named 'mcp.server.fastmcp'`* — an old MCP SDK got baked into a cached layer. Since 1.7.2 the build fails loudly on this instead of shipping a silently broken feature: `docker compose build --no-cache && docker compose up -d`.
- *"MCP: готово"* but the client still shows nothing — the config path or the `docker` path is wrong. Use the absolute binary path; desktop apps don't inherit your shell environment. Then fully quit the client (⌘Q, not just closing the window).
- *Something writes to stdout during import* — `mcp-check` catches this. stdout carries the JSON-RPC protocol; any stray `print` breaks the handshake.

**Reading the client's MCP log**
The path is OS-specific — `setup-mcp.sh` prints the right one for your system in its closing hints:

| OS | Log |
|---|---|
| macOS | `~/Library/Logs/Claude/mcp-server-invguard.log` |
| Linux | `${XDG_CONFIG_HOME:-~/.config}/Claude/logs/mcp-server-invguard.log` |
| Windows | `%APPDATA%\Claude\logs\mcp-server-invguard.log` |

More detail, in Ukrainian: [`docs/MCP_TROUBLESHOOTING_UA.md`](docs/MCP_TROUBLESHOOTING_UA.md).

### UI

**The interface looks like the old version after an update**
Hard-refresh once: `Ctrl+F5` / `Cmd+Shift+R`. JS modules are served with a `?v=<version>` query and the HTML is `no-store`, but a browser cache can still hold the previous assets for a moment.

**Autocomplete offers nothing**
The knowledge base for that vendor hasn't been built yet. ⚙ → "+ Add vendor", then wait for the background job. Progress and any errors show up in the same modal.

### Data

**The database is much larger than expected**
The CVE corpus dominates. `invguard status` shows the split. `VACUUM` happens implicitly during `backup`, so restoring a backup gives you a compacted file.

**Restoring while the service is running**
Don't. `docker compose down`, restore, then `up`. `restore` without `--force` refuses to overwrite an existing database precisely to make this hard to get wrong.

---

## Development

```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest                                        # 151 test functions across 22 files
INVGUARD_DATA_DIR=./data uvicorn invguard.main:app --port 8480
```

```bash
bash tests/dryrun_install.sh    # install.sh on mocked docker/curl — no Docker needed
```

That harness exists because every installation bug we shipped was platform-specific, and almost all of them were invisible on macOS: Docker Desktop's ownership rewriting hides the entire class of "the container can't write to the bind mount". The mock deliberately emulates **native Linux UID semantics** and covers a missing `curl`, a Docker socket without permission, a stopped daemon, inherited root ownership, `NO_COLOR`, and a re-run for idempotency. `pytest` invokes it too.

The Python suite runs entirely on recorded feed fixtures — no network, no live API keys. It includes an end-to-end smoke test, a real MCP handshake, guard tests proving that a disabled connector cannot open a connection and that a request leaking a hostname or IP is rejected by the transport, and a version-comparison edge-case matrix.

House rules, learned the hard way:

- Routers and MCP tools stay **thin** — zero logic, calls into the domain only. Two bridges, one core.
- Every fix ships with a regression test that fails without it.
- `innerHTML` is permitted **only** via `DOMPurify.sanitize(marked.parse(...))`, and only for completed markdown reports. Everything else is `textContent`.
- Connectors never write to the database. They return normalized records; `store.py` applies them atomically. That's what makes them pure functions of `(client, cursor)` and testable without a database.
- User-facing errors are written in plain Ukrainian, at the point where the domain knows what actually went wrong.

---

## Project layout

```
invguard/
├── main.py              FastAPI app, lifespan, token middleware
├── cli.py               maintenance commands
├── mcp_server.py        FastMCP tools (thin, like the routers)
├── mcp_stdio.py         stdio shim for desktop clients
├── core/
│   ├── config.py        pydantic-settings; secrets live in kv, not here
│   ├── db.py            SQLite WAL 0600, self-migrating by user_version
│   ├── auth.py          bearer token (sha256 in DB), rotation
│   └── httpguard.py     allowlist, private-range block, leak filter
├── inventory/           CRUD, CSV import with partial success, CPE catalog
├── feeds/               connector contract + registry
│   └── premium/         optional, dark until keyed
├── matching/            engine (diff vs previous run) + version schemes
├── compliance/          scanner, reporter, alerts, scheduler
├── kb/                  knowledge base: worker, sources, autocomplete store
└── routers/             REST, one module per resource

web/                     index.html + ES modules + vendored libraries
tests/                   pytest suite on recorded fixtures
docs/                    architecture, operations, MCP diagnostics (Ukrainian)
scripts/setup-mcp.sh     Claude Desktop wiring
install.sh, update.sh    first install; zip-based update with rollback
compose.yaml             hardened single service
.env.example             template for the UID/GID file install.sh generates
tests/dryrun_install.sh  install.sh on mocked docker/curl (native-Linux semantics)
```

Runtime state (`data/`, `reports/`, `backups/`, `upd/`) is created at install time and gitignored. It never lives in the repository.

---

## Container hardening

Not decorative — this is the deployment contract:

```yaml
ports:         ["127.0.0.1:8480:8480"]   # loopback only
read_only:     true                      # immutable rootfs, /tmp is tmpfs
cap_drop:      [ALL]
security_opt:  [no-new-privileges:true]
mem_limit:     1g
memswap_limit: 1g                        # swap off - fail predictably, don't thrash
user:          "${INVGUARD_UID:-10001}:..."  # non-root, host UID from .env
```

The process runs a single uvicorn worker. Large corpora (NVD, CVE List V5) are streamed, never loaded into memory whole.