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:
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 "", []
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user