Skip to content

Commit b34f33f

Browse files
author
STARGA Inc
committed
release: v3.10.4 — install-model security hardening (GHAS #165-171)
Bandit alerts B310 (urlopen unvalidated URL), B603/B607 (subprocess relative path), B404 (subprocess import). All addressed with real defense-in-depth, not blanket suppressions: - URL parse + scheme/host validation before urlopen - shutil.which() resolves absolute ollama path; argv lists, no shell - Whitelisted regex on --model / --name / --keep-alive - System-path blacklist on --dest - Streaming download with explicit timeout (replaces urlretrieve) User-visible behaviour unchanged for happy path.
1 parent 8d6bbc9 commit b34f33f

5 files changed

Lines changed: 89 additions & 17 deletions

File tree

ANATOMY.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
> Re-generate with: `anatomy .`
66
77
**Project:** `mind-mem`
8-
**Files:** 740 | **Est. tokens:** ~1,512,899
9-
**Generated:** 2026-05-09 00:59 UTC
8+
**Files:** 740 | **Est. tokens:** ~1,513,607
9+
**Generated:** 2026-05-09 01:05 UTC
1010

1111
## Token Budget Guide
1212

@@ -56,7 +56,7 @@
5656
| `skills/integrity-scan/` | 1 | ~376 |
5757
| `skills/memory-recall/` | 1 | ~549 |
5858
| `src/` | 1 | ~280 |
59-
| `src/mind_mem/` | 155 | ~537,582 |
59+
| `src/mind_mem/` | 155 | ~538,290 |
6060
| `src/mind_mem/api/` | 5 | ~15,751 |
6161
| `src/mind_mem/mcp/` | 3 | ~3,960 |
6262
| `src/mind_mem/mcp/infra/` | 8 | ~6,924 |
@@ -529,7 +529,7 @@
529529
- `mind_ffi.py` (~5481 tok, huge) — mind-mem FFI bridge — loads compiled MIND .so and exposes scoring functions.
530530
- `mind_filelock.py` (~1844 tok, huge) — mind-mem file locking — cross-platform advisory locks. Zero external deps.
531531
- `mind_kernels.py` (~1706 tok, huge) — # Copyright 2026 STARGA, Inc.
532-
- `mm_cli.py` (~19293 tok, huge) — # Copyright 2026 STARGA, Inc.
532+
- `mm_cli.py` (~20001 tok, huge) — # Copyright 2026 STARGA, Inc.
533533
- `model_audit.py` (~4370 tok, huge) — Model checkpoint audit — scan for remote-code hooks, unsafe pickle, tokenizer injection.
534534
- `model_gate.py` (~2549 tok, huge) — Load-gate registry for ``mm audit-model`` checkpoints.
535535
- `model_provenance.py` (~1751 tok, huge) — Provenance allowlist check for ``mm audit-model`` checkpoints.

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,35 @@
22

33
All notable changes to MIND-Mem are documented in this file.
44

5+
## v3.10.4 — `mm install-model` security hardening (GHAS alerts #165-171)
6+
7+
Released 2026-05-08. Closes 7 Bandit alerts on the new `install-model`
8+
subcommand without changing the user-facing surface.
9+
10+
### Fixed
11+
- **B404** (subprocess import) — annotated; usage is intentional and
12+
safe (absolute path from `shutil.which` + argv list, never `shell=True`).
13+
- **B310** (urlopen with un-validated URL, line 421/433) — added
14+
defense-in-depth URL parse-and-check (must be `https://` and
15+
`huggingface.co` host) on top of the existing repo-name constraint.
16+
Switched from `urlretrieve` to streaming `urlopen` + chunked write
17+
with explicit timeout.
18+
- **B603/B607** (subprocess.run with relative path, line 456/475) —
19+
resolve `ollama` to its absolute path via `shutil.which` once and
20+
reuse for both `ollama create` and the smoke-test `ollama run`.
21+
22+
### Added input validation (defense in depth)
23+
- `--model` must match `[A-Za-z0-9._-]+\.gguf` (no path traversal,
24+
no URL-injection, no shell-meta).
25+
- `--name` must match `[A-Za-z0-9._:/-]+` (Ollama tag charset).
26+
- `--keep-alive` must be `-1` or `<digits><s|m|h|d>?`.
27+
- `--dest` denied if resolved real-path falls under `/etc/`, `/usr/`,
28+
`/bin/`, `/sbin/`, `/lib/`, `/lib64/`, `/var/`, `/sys/`, `/proc/`,
29+
`/dev/`, `/root/`, `/boot/`. Allows `$HOME` symlinked to `/data`
30+
(common on workstations).
31+
32+
No user-visible behaviour change for the happy path.
33+
534
## v3.10.3 — `mm install-model` + GGUF on HuggingFace
635

736
Released 2026-05-08. Closes the public-user setup gap: `pip install

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mind-mem"
3-
version = "3.10.3"
3+
version = "3.10.4"
44
description = "Drop-in memory for Claude Code, OpenClaw, and any MCP-compatible agent."
55
readme = "README.md"
66
license = { text = "Apache-2.0" }

src/mind_mem/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
)
4444
from .storage import get_block_store
4545

46-
__version__ = "3.10.3"
46+
__version__ = "3.10.4"
4747

4848
# Best-effort import-time integrity check. Fails open unless
4949
# MIND_MEM_INTEGRITY=strict, so editable installs and source checkouts

src/mind_mem/mm_cli.py

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -382,12 +382,41 @@ def _cmd_install_model(args: argparse.Namespace) -> int:
382382
present (unless --force on the parent command).
383383
"""
384384
import shutil
385-
import subprocess
385+
import re
386+
import subprocess # noqa: S404 — used with absolute paths from shutil.which + list args, never shell=True
387+
import urllib.parse
386388
import urllib.request
387389

388390
hf_repo = "star-ga/mind-mem-4b"
391+
392+
# Validate args.model: filename only, no path traversal, no URL injection.
393+
if not re.fullmatch(r"[A-Za-z0-9._-]+\.gguf", args.model):
394+
print(json.dumps({"error": f"invalid --model {args.model!r}; must match [A-Za-z0-9._-]+\\.gguf"}, indent=2))
395+
return 1
396+
# Validate args.name: alphanumerics + : _ . - / — Ollama tag charset.
397+
if not re.fullmatch(r"[A-Za-z0-9._:/\-]+", args.name):
398+
print(json.dumps({"error": f"invalid --name {args.name!r}; must match [A-Za-z0-9._:/-]+"}, indent=2))
399+
return 1
400+
# Validate args.keep-alive: -1 | <number><unit> (e.g. 30m, 1h, 24h)
401+
if not re.fullmatch(r"(-1|\d+(s|m|h|d)?)", str(args.keep_alive)):
402+
print(json.dumps({"error": f"invalid --keep-alive {args.keep_alive!r}"}, indent=2))
403+
return 1
404+
389405
gguf_url = f"https://huggingface.co/{hf_repo}/resolve/main/{args.model}"
390-
dest = os.path.expanduser(args.dest)
406+
# Defense in depth: confirm the URL we built is HTTPS + huggingface.co.
407+
parsed = urllib.parse.urlparse(gguf_url)
408+
if parsed.scheme != "https" or parsed.hostname != "huggingface.co":
409+
print(json.dumps({"error": "internal: refusing to fetch from non-HF URL"}, indent=2))
410+
return 1
411+
dest = os.path.realpath(os.path.expanduser(args.dest))
412+
# Refuse writes to canonical system paths. We allow $HOME-symlinked-to-/data
413+
# (common on workstations with a separate SSD for ~/.cache), so we deny by
414+
# blacklist instead of confining by allowlist.
415+
_SYSTEM_PREFIXES = ("/etc/", "/usr/", "/bin/", "/sbin/", "/lib/", "/lib64/",
416+
"/var/", "/sys/", "/proc/", "/dev/", "/root/", "/boot/")
417+
if any(dest.startswith(p) for p in _SYSTEM_PREFIXES):
418+
print(json.dumps({"error": f"refusing to write to system path: {dest}"}, indent=2))
419+
return 1
391420

392421
output: dict[str, Any] = {
393422
"model_file": args.model,
@@ -414,11 +443,14 @@ def _cmd_install_model(args: argparse.Namespace) -> int:
414443
print(json.dumps(output, indent=2))
415444
return 2
416445

417-
# 2. Download GGUF (skip if dest already correct size)
446+
# 2. Download GGUF (skip if dest already correct size).
447+
# URL is constrained above to https://huggingface.co/<known repo>/<validated filename>;
448+
# bandit B310 doesn't see the validation but it's enforced.
418449
os.makedirs(os.path.dirname(dest), exist_ok=True)
419450
expected_size = None
451+
req = urllib.request.Request(gguf_url, method="HEAD")
420452
try:
421-
with urllib.request.urlopen(gguf_url) as resp:
453+
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 — URL validated above
422454
expected_size = int(resp.headers.get("Content-Length") or 0)
423455
except Exception as exc:
424456
output["error"] = f"could not query HF for {args.model}: {exc}"
@@ -430,7 +462,10 @@ def _cmd_install_model(args: argparse.Namespace) -> int:
430462
output["reason"] = "dest already present with matching size"
431463
else:
432464
try:
433-
urllib.request.urlretrieve(gguf_url, dest)
465+
req = urllib.request.Request(gguf_url)
466+
with urllib.request.urlopen(req, timeout=600) as resp, open(dest, "wb") as fh: # noqa: S310 — URL validated above
467+
while chunk := resp.read(8 * 1024 * 1024):
468+
fh.write(chunk)
434469
output["downloaded"] = True
435470
output["bytes"] = os.path.getsize(dest)
436471
except Exception as exc:
@@ -451,10 +486,17 @@ def _cmd_install_model(args: argparse.Namespace) -> int:
451486
fh.write(modelfile_body)
452487
output["modelfile"] = modelfile
453488

454-
# 4. Ollama import
489+
# 4. Ollama import — args.name and modelfile are validated above;
490+
# we resolve `ollama` to its absolute path and pass argv as a list
491+
# (never shell=True) so B603/B607 do not apply.
492+
ollama_bin = shutil.which("ollama")
493+
if not ollama_bin:
494+
output["error"] = "ollama disappeared from PATH between checks"
495+
print(json.dumps(output, indent=2))
496+
return 2
455497
try:
456-
result = subprocess.run(
457-
["ollama", "create", args.name, "-f", modelfile],
498+
result = subprocess.run( # noqa: S603 — argv list, no shell, validated args
499+
[ollama_bin, "create", args.name, "-f", modelfile],
458500
capture_output=True,
459501
text=True,
460502
timeout=180,
@@ -470,10 +512,11 @@ def _cmd_install_model(args: argparse.Namespace) -> int:
470512
print(json.dumps(output, indent=2))
471513
return 6
472514

473-
# 5. Smoke test (warm the model + keep-alive)
515+
# 5. Smoke test (warm the model + keep-alive). Same safety profile
516+
# as step 4: absolute path + argv list + validated args, no shell.
474517
try:
475-
smoke = subprocess.run(
476-
["ollama", "run", args.name, "test"],
518+
smoke = subprocess.run( # noqa: S603 — argv list, no shell, validated args
519+
[ollama_bin, "run", args.name, "test"],
477520
input="hi\n",
478521
capture_output=True,
479522
text=True,

0 commit comments

Comments
 (0)