fix(security): remove pre-existing bandit HIGH findings and enable the HIGH gate#2350
Conversation
…e HIGH gate Bandit ran warning-only, so 18 pre-existing HIGH findings (16x B602 subprocess shell=True, 1x B605 os.system, 1x B324 weak MD5) sat unblocked in core paths. This fixes them and makes new HIGH findings blocking, so a shell-injection or weak-hash regression fails lint instead of shipping. Sixteen findings were fixed by removing the risk: shell=True string commands were converted to shell=False args-lists (util.py and cli.py port-kill helpers now run `netstat`/`lsof` bare and filter in Python instead of piping through the shell; lemonade_client's os.system taskkill, logger's chcp, and ui/build's npm calls run via args-lists / `cmd /c`; the mailto opener uses os.startfile). Port inputs are int-validated first. The MD5 in context7_cache is a cache key, fixed with usedforsecurity=False. Two findings are genuinely unavoidable and kept with a justified inline `# nosec B602` plus a matching entry in .security-suppressions.json: - shell_tools.py sandboxed executor (Windows-only, whitelist-validated commands, shell needed for cmd.exe built-ins/pipes) - mcp stdio transport legacy from_command() shell-string API (trusted SDK config, documented contract) The gate lives in util/check_security_gates.py (new_bandit_highs keyed by (normalized path, test_id), never line number) and is wired into util/lint.py check_bandit(); .bandit-baseline.json is an empty allowlist so any HIGH fails.
|
Verdict: Approve with suggestions — safe to merge; one easy efficiency fix worth applying. This PR closes 18 pre-existing bandit HIGH findings (16 by converting The one thing worth fixing before merge: the lint step runs bandit over 🔍 Technical details🟢 MinorBandit scans (This drops the now-unused Note (not blocking): Strengths
|
os.startfile is the correct shell-free way to open the user-built mailto URL (its '&'-separated query params would be mis-parsed by cmd /c start), but the attr is Windows-only so pylint flags no-member on the Linux CI. The call is already guarded by the platform check; disable the false positive inline.
|
🟡
Fix: drop 🔍 Technical details
Replace the two-call pattern with: from check_security_gates import (
format_finding,
load_baseline,
new_bandit_highs,
run_bandit,
)
try:
report = run_bandit(SRC_DIR)
except (RuntimeError, ValueError) as exc:
print(f"[ERROR] Could not run Bandit: {exc}")
return CheckResult("Security Check (Bandit)", False, False, 1, str(exc))
baseline = load_baseline(".bandit-baseline.json")
new_highs = new_bandit_highs(report.get("results", []), baseline)
passed = not new_highsThis removes the duplicate scan while keeping both the HIGH gate and the MEDIUM/LOW informational count. |
check_bandit() ran bandit twice per lint invocation -- once for the MEDIUM/LOW count and again inside check_bandit_high_gate() -- doubling the most expensive lint step. Drive both the HIGH gate and the informational count off the single report already in hand.
|
Good catch on the double scan — fixed in |
|
🟡 The new In practice this only bites contributors who haven't installed dev dependencies, so CI is unaffected — but it's a meaningful UX regression. 🔍 Technical details
except FileNotFoundError:
return 1, f"Command not found: {cmd[0]}"
except (RuntimeError, ValueError) as exc:Minimal fix — add except (RuntimeError, ValueError, FileNotFoundError) as exc:
print(f"[ERROR] Could not run Bandit: {exc}")
return CheckResult("Security Check (Bandit)", False, False, 1, str(exc))Or handle it in |
run_bandit() used a bare subprocess.run, so a missing bandit binary raised FileNotFoundError -- which check_bandit() didn't catch, crashing lint.py for contributors without dev deps (the old run_command() path returned a warning). Catch it in run_bandit() and re-raise as an actionable RuntimeError so callers keep handling a single exception type. Also thread lint.py's EXCLUDE_DIRS through run_bandit() so the exclude list can't drift from DEFAULT_EXCLUDE.
|
Fixed in |
Bandit ran warning-only, so 18 pre-existing HIGH-severity findings sat unblocked in core paths (CLI, subprocess helpers, MCP stdio transport, Lemonade launch). Before: a new
shell=Trueinjection site or weak-hash regression would pass lint and ship. After: all 18 are fixed (16 by removing the risk, 2 kept as justified suppressions) and any new HIGH finding now failsutil/lint.py --bandit.How each finding was handled
Fixed by removing the risk (16):
shell=Truestring commands converted toshell=Falseargs-lists.util.py/cli.pyport-kill helpers: runnetstat/lsofbare and filter output in Python instead of piping through the shell;taskkill/killtake args-lists. Port inputs areint()-validated first.lemonade_client.py:os.system(taskkill …)→subprocess.run([...], shell=False)(B605).logger.py:chcp(a cmd.exe builtin) →cmd /c chcp.ui/build.py:npmcalls (.cmdshims) →cmd /c npm …on Windows, bare on other platforms.cli.pymailto opener:start "" <url>→os.startfile()(ShellExecute, no shell parsing) — this one mattered because the URL is user-built.context7_cache.py: MD5 is a cache key, fixed withusedforsecurity=False(B324).Kept as justified suppressions (2) — inline
# nosec B602+ a matching entry in.security-suppressions.json:agents/tools/shell_tools.py— sandboxed shell executor; every command (and pipeline segment) is whitelist-validated before running, andshell=Trueis Windows-only so cmd.exe can resolve built-ins/pipes the tool exists to run.mcp/client/transports/stdio.py— legacyfrom_command()accepts a full shell command string (documented contract), needs shell parsing; command is trusted SDK config, and modernfrom_config()already runsshell=False.The gate
util/check_security_gates.pyaddsnew_bandit_highs(results, baseline), keyed by(normalized path, test_id)— never line number, so cosmetic line shifts don't trip it. Wired intoutil/lint.py'scheck_bandit();.bandit-baseline.jsonships as an empty allowlist so any HIGH fails.Test plan
python -m bandit -r src/gaia -ll -f json→ 0 HIGH findingspython util/lint.py --bandit→ PASSsubprocess.run(f"echo {x}", shell=True)probe makes the gate exit 1; passes again once removedpytest tests/unit/test_check_security_gates.py→ 18 passedpytest tests/unit/test_webui_build.py tests/unit/mcp/client/ tests/unit/test_shell_guardrails.py→ passnetstatdetection +taskkillargs-list finds and kills it and frees the portblack/isortclean on all changed files