fix(security): enforce https on model downloads and record reviewed findings

scripts/generate-model-checksums.py downloads the URLs that come from
remote model listings (the VOSK model-list.json and Hugging Face
responses), and urlopen would follow a file: URL there. _https() now
refuses anything but https before each request. The md5 digest is only
compared against the published listing, not used for security, so it is
marked usedforsecurity=False.

The remaining findings are false positives for this codebase, and each
gets a narrow nosemgrep on its own line with the reason:
- text_injector.py: argv lists without a shell; dictated text sits
  after "--" so it cannot be parsed as an option.
- layout_key_map.py: the KDE layout_memory.xml in the user's own session
  directory, not remote input.
- check_runtime.py: module names come from the literal tuples above.
- sync_flatpak_deps.py, verify_release.py: fixed https://pypi.org
  endpoint.
- web tests: walks this repo's own src/app tree; loopback dev-server
  probe in a test.
This commit is contained in:
2026-11-06 19:29:21 +05:30
parent a9a1900ee4
commit a79f979090
8 changed files with 26 additions and 3 deletions
+16 -3
View File
@@ -64,8 +64,18 @@ class Entry(NamedTuple):
size: int
def _https(url: str) -> str:
"""Refuse anything but https before it reaches urlopen (which also reads file:)."""
if not url.startswith("https://"):
raise SystemExit(f"refusing non-https URL: {url}")
return url
def _fetch_json(url: str):
request = urllib.request.Request(url, headers={"User-Agent": "voculinux-checksum-generator"})
request = urllib.request.Request(
_https(url), headers={"User-Agent": "voculinux-checksum-generator"}
)
# nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- https enforced by _https; fixed upstream endpoints
with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310
return json.load(response)
@@ -92,8 +102,11 @@ def _download_and_hash(url: str, expected_md5: str) -> tuple[str, int]:
a mismatch means the listing and the file disagree, which is worth stopping
for rather than pinning either one.
"""
sha256, md5, size = hashlib.sha256(), hashlib.md5(), 0
request = urllib.request.Request(url, headers={"User-Agent": "voculinux-checksum-generator"})
sha256, md5, size = hashlib.sha256(), hashlib.md5(usedforsecurity=False), 0
request = urllib.request.Request(
_https(url), headers={"User-Agent": "voculinux-checksum-generator"}
)
# nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- https enforced by _https
with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310
for chunk in iter(lambda: response.read(1024 * 1024), b""):
sha256.update(chunk)
+1
View File
@@ -122,6 +122,7 @@ def parse_export(path: Path) -> dict[str, tuple[str, set[str]]]:
def pypi_artifacts(name: str, version: str) -> list[dict]:
url = PYPI_JSON.format(name=name, version=version)
# nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- fixed https://pypi.org endpoint; name and version come from uv.lock
with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310
return json.load(response)["urls"]
+1
View File
@@ -188,6 +188,7 @@ def check_pypi(version: str, stored: dict[str, str]) -> list[str]:
try:
url = f"https://pypi.org/pypi/{PYPI_PROJECT}/{version}/json"
# nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- fixed https://pypi.org host; version is the release being verified
with urllib.request.urlopen(url, timeout=PYPI_TIMEOUT) as response:
payload = json.load(response)
except urllib.error.HTTPError as error:
+2
View File
@@ -103,6 +103,7 @@ def smoke(root: Path) -> None:
site = audit(root, manifest["packages"])
sys.path.insert(0, str(site))
for name in ("numpy", "requests", "psutil", "pyaudio", "cairo", "evdev", "vocalinux"):
# nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import -- names come from the literal tuple above
module = importlib.import_module(name)
if not module.__file__ or not Path(module.__file__).resolve().is_relative_to(
site.resolve()
@@ -118,6 +119,7 @@ def smoke(root: Path) -> None:
"vocalinux.speech_recognition.recognition_manager",
"vocalinux.text_injection.text_injector",
):
# nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import -- names come from the literal tuple above
importlib.import_module(name)
import gi
@@ -3002,6 +3002,7 @@ class TextInjector:
type_timeout = max(5, len(chunk) * self._key_delay_seconds(key_delay) * 4)
try:
subprocess.run(
# nosemgrep: python.lang.security.audit.dangerous-subprocess-use-tainted-env-args.dangerous-subprocess-use-tainted-env-args -- argv list, no shell; text follows `--`
cmd,
check=True,
stderr=subprocess.PIPE,
@@ -3447,6 +3448,7 @@ class TextInjector:
try:
subprocess.run(
# nosemgrep: python.lang.security.audit.dangerous-subprocess-use-tainted-env-args.dangerous-subprocess-use-tainted-env-args -- argv list, no shell; only fixed key codes and a count
cmd,
check=True,
stderr=subprocess.PIPE,
@@ -270,6 +270,7 @@ def _kde_layout_memory_paths() -> list[str]:
def _read_kde_layout_memory(path: str) -> tuple[str, list[str]]:
"""Return ``(SwitchMode, currentLayout tokens)`` from a layout_memory.xml."""
try:
# nosemgrep: python.lang.security.use-defused-xml-parse.use-defused-xml-parse -- local KDE layout_memory.xml from the user's own session, not remote input
tree = ET.parse(path)
except FileNotFoundError:
return "", []
+2
View File
@@ -86,9 +86,11 @@ describe("SEO Assets", () => {
continue;
}
const routePath = `${prefix}/${entry.name}`;
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- walks this repo's own src/app tree; no external input
if (fs.existsSync(path.join(dir, entry.name, "page.tsx"))) {
routes.push(`https://voculinux.com${routePath}/`);
}
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- walks this repo's own src/app tree; no external input
walk(path.join(dir, entry.name), routePath);
}
};
@@ -38,6 +38,7 @@ const CHROME =
async function resolveBaseUrl(): Promise<string | null> {
if (process.env.BASE_URL) return process.env.BASE_URL.replace(/\/$/, "");
try {
// nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request -- loopback dev-server probe in a test; never a shipped request
const res = await fetch("http://127.0.0.1:3456/", { signal: AbortSignal.timeout(2000) });
if (res.ok) return "http://127.0.0.1:3456";
} catch {