Skip to content

CodeQL Alert Dismissal Registry

Purpose

This document is the single source of truth for all CodeQL alerts that have been dismissed as false positives in this repository. It covers every alert type we encounter, not just one query.

Policy: dismiss only after verifying that the code is genuinely safe, log the dismissal here, and note which alert type it falls under.


Alert types

1. py/path-injection -- Uncontrolled data used in path expression

Why it fires: CodeQL flags every os.walk, os.path.isdir, os.path.isfile, and open call that receives a value derived from a FastAPI query parameter -- even when the value has been sanitised via os.path.normpath + str.startswith in a separate function.

Why it is a false positive here: CodeQL's taint-tracking state machine requires the normpath + startswith guard to appear inline in the same function as the filesystem call. Our architecture performs sanitisation in shared helpers (resolve_corpus_path_param, resolved_corpus_root_str, safe_relpath_under_corpus_root, normpath_if_under_root). CodeQL cannot model cross-function sanitisation for this pattern, so every new file that touches the filesystem with a request-derived path triggers the same false positive.

When alerts persist after safe_relpath_under_corpus_root: re-verify the path string with normpath_if_under_root(path, root_s) immediately before each open / os.path.isfile / FileResponse sink, or build the target from safe_resolve_directory(corpus_root) plus constant path segments with an inline os.path.normpath + str.startswith(safe_prefix) guard in the same function. CodeQL does not always propagate sanitiser state out of helpers.

Inline pragma (same Type 1): if sinks still alert after the above, add the same # codeql[py/path-injection] -- … line used elsewhere under src/podcast_scraper/server/routes/ (see corpus_binary.py), documenting the sanitizer chain. Prefer fixing taint flow first; use the pragma when CodeQL cannot close the query.

Code-side patterns added for viewer routes (same Type 1): GET/PUT /feeds re-resolves the corpus root with safe_resolve_directory and checks feeds.spec with normpath_if_under_root before any filesystem access. GET …/jobs/…/log uses jobs_log_path.resolve_pipeline_job_log_path (same function as isfile): safe_resolve_directory, then normpath_if_under_root on the safe_relpath_under_corpus_root output; routes/jobs maps JobLogPathError to HTTPException. If CodeQL still flags isfile on the resolved path, jobs_log_path carries # codeql[py/path-injection] on the line immediately above that sink (single-line comment; Type 1). viewer_operator_extras_source (Docker mode) uses safe_fixed_file_under_root for viewer_operator.yaml before isfile. If CodeQL still flags isfile, use a single-line # codeql[py/path-injection] -- … immediately above the sink (splitting the pragma across two # lines can fail to suppress; Type 1). routes/jobs uses the same pragma for FileResponse / to_thread(assert_operator_pipeline_extras, …) / log-tail verified_under where taint does not cross from jobs_log_path / operator_paths helpers. publish_calendar_date_for_artifact_listing uses normpath_if_under_root before metadata isfile. operator_config routes call _verified_operator_config_path so paths are either under the resolved corpus root or exactly the server operator_config_fixed_path. Generic helpers atomic_write_text and load_feeds_spec_file use pragmas documenting that callers only pass corpus-anchored or packaged paths.

perf_cache mtime tokens (same Type 1): the operator-viewer caching arc added os.path.getmtime / Path.resolve calls that stat a request-derived corpus path purely to compute a cache-invalidation token (never to read request content). The paths are already validated — safe_resolve_directory + startswith(safe_prefix) (corpus_theme_clusters / corpus_topic_clusters), safe_relpath_under_corpus_root (corpus_enrichments single-envelope), _resolve_corpus / resolve_corpus_path_param which raises on escape (corpus_enrichments list, corpus_persons top, app_enrichment _corpus_signals), and perf_cache.corpus_mtime stats root / <constant> for a caller-validated root. Each sink carries a single-line # codeql[py/path-injection] -- … immediately above it.

CI unit tests: check_test_policy keeps FastAPI out of tests/unit/ even though .[dev] includes it. Modules imported by unit tests must not import FastAPI at import time. pipeline_jobs and operator_paths use typing.Any for the app handle; operator_config_security raises OperatorYamlUnsafeError (stdlib) and routes translate to HTTPException.

2. actions/artifact-poisoning/critical -- artifact download in same workflow_call chain

Why it fires: CodeQL flags actions/download-artifact steps in workflows triggered by workflow_dispatch because, in theory, an attacker could replace the artifact between upload and download if the upload happened in a less-privileged workflow.

Why it is a false positive here: the artifact is uploaded and downloaded within the same workflow_call chain (github.run_id is identical). The triggering workflow (drill-exercise / drill-infra-destroy) requires workflow_dispatch with a confirmation input and is restricted to repo admins. No external user can inject content into the artifact between upload (infra-apply/plan) and download (infra-destroy).

3. Snyk Container -- base-image transitive CVE not reachable (formerly type 2)

Why it fires: the Snyk container scan inspects the final podcast-scraper:snyk-scan image's Debian package list and uploads each high/critical package CVE as a Code Scanning alert. These are not vulnerabilities in our Python code or pip dependencies -- they are in OS packages from the python:3.12-slim (Debian 13) / python:3.12-slim-bookworm (Debian 12) base images.

Why it is a false positive here: apply both gates before dismissing:

  1. Upstream Debian fix status. Either Debian explicitly has no fix (apt-get upgrade cannot help) or the version flagged is already the latest in Debian's apt index. The Dockerfiles run apt-get update && apt-get upgrade -y early so any backported fix lands automatically on the next rebuild.
  2. Reachability from our deployment topology. The vulnerable code path must not be invoked by anything the pipeline runs: Whisper, spaCy, transformers, sentence-transformers, FAISS, ffmpeg, supervisor, FastAPI / uvicorn, httpx / requests, etc. We don't expose DTLS handshake parsing to untrusted peers; we don't process ICC color profiles or other image-rendering flows; and so on.

When both gates pass, dismiss as won't fix with a comment that names (a) the Debian fix status and (b) the reachability reasoning. Re-evaluate when Snyk re-scans on each PR -- if the same CVE re-surfaces after a Debian backport ships, apt-get upgrade should pick it up automatically; otherwise re-dismiss and append a new row below.

Sanitiser chain (reference):

All user-supplied corpus paths flow through one of:

Entry point Module Guards
resolve_corpus_path_param pathutil.py normpath + startswith(anchor) -- raises CorpusPathRequestError on escape
resolved_corpus_root_str pathutil.py normpath + startswith(anchor) -- falls back to anchor on escape
normpath_if_under_root path_validation.py normpath + startswith(root) -- returns None on escape
safe_relpath_under_corpus_root path_validation.py normpath + startswith + no .. -- returns None on escape
safe_resolve_directory path_validation.py realpath + rejects .. -- use before joins from a Path root

Why it fires: CodeQL flags Response.set_cookie(...) whose value is derived from request input (e.g. the OAuth state/grant/platform query params flow into the platform session's state cookie).

Why it is a false positive here: the cookie value is never the raw input — it is app_sessions.sign(payload), i.e. base64url(json).base64url(hmac_sha256). The base64url alphabet is [A-Za-z0-9_-] plus a . separator: no CR/LF, ;, or control characters can reach the Set-Cookie header, so header injection / response splitting is impossible. User-influenced fields live inside the JSON payload that gets base64-encoded, not in the header syntax.


Dismissed alerts

Alerts are dismissed via GitHub API as false positive. Each row records the alert type (number from the list above), the alert number, file, line, date, and a short comment.

Type Alert File Line Dismissed Comment
1 #44 server/routes/artifacts.py 106 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #45 server/routes/artifacts.py 112 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #50 server/routes/artifacts.py 37 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #51 server/routes/artifacts.py 67 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #52 search/cli_handlers.py 58 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #53 search/cli_handlers.py 59 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #54 search/cli_handlers.py 66 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #55 search/cli_handlers.py 68 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #56 search/cli_handlers.py 81 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #57 search/cli_handlers.py 82 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #58 search/corpus_search.py 120 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #59 gi/explore.py 68 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #60 gi/explore.py 72 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #61 gi/explore.py 73 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #62 gi/explore.py 74 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #63 gi/explore.py 130 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #64 gi/explore.py 148 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #65 gi/explore.py 151 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #66 gi/explore.py 162 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #67 gi/explore.py 163 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #68 gi/explore.py 304 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #69 search/faiss_store.py 164 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #70 search/faiss_store.py 166 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #71 search/faiss_store.py 169 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #72 search/faiss_store.py 188 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #73 search/faiss_store.py 188 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #74 search/faiss_store.py 190 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #75 search/faiss_store.py 192 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #76 server/routes/index_stats.py 64 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #77 server/routes/index_stats.py 49 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #78 server/pathutil.py 53 2026-04-06 normpath+startswith via resolve_corpus_path_param
1 #129 server/routes/corpus_binary.py 60 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #130 server/routes/corpus_binary.py 66 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #131 server/corpus_catalog.py 33 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #132 server/corpus_catalog.py 160 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #133 server/corpus_catalog.py 293 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #134 server/corpus_catalog.py 294 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #136 server/corpus_catalog.py 331 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #139 server/routes/corpus_metrics.py 83 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #140 server/routes/corpus_metrics.py 160 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #141 server/routes/corpus_metrics.py 164 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #142 server/routes/corpus_metrics.py 191 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #143 server/routes/corpus_metrics.py 195 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #144 server/routes/corpus_metrics.py 222 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #146 server/routes/corpus_metrics.py 228 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #147 server/routes/corpus_library.py 220 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #149 server/routes/corpus_library.py 276 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #150 server/routes/index_rebuild.py 117 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #151 search/index_source_mtime.py 34 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #152 search/index_source_mtime.py 81 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #154 server/index_staleness.py 59 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #155 utils/path_validation.py 180 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #156 utils/path_validation.py 196 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #158 server/pathutil.py 74 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #159 server/corpus_catalog.py 328 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #160 server/corpus_catalog.py 358 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #161 server/corpus_catalog.py 359 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #162 server/routes/corpus_library.py 215 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #163 server/routes/corpus_library.py 271 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #164 server/index_staleness.py 55 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #165 server/pathutil.py 63 2026-04-10 normpath+startswith via resolve_corpus_path_param
1 #206 server/cil_queries.py 59 2026-04-14 anchor_s from server output_dir; root_path only in startswith filters
1 #207 server/cil_queries.py 63 2026-04-14 anchor_s from server output_dir; root_path only in startswith filters
1 #166 server/routes/index_stats.py 89 2026-04-17 normpath+startswith via resolve_corpus_path_param
1 #208 server/corpus_catalog.py 268 2026-04-17 normpath+startswith via safe_resolve_directory
1 #209 server/corpus_catalog.py 345 2026-04-17 normpath+startswith via safe_resolve_directory
1 #224 server/routes/corpus_text_file.py 68 2026-04-17 normpath_if_under_root inline before isfile
1 #225 server/routes/corpus_text_file.py 82 2026-04-17 normpath_if_under_root inline before isfile
1 #226 server/routes/corpus_topic_clusters.py 65 2026-04-17 safe_resolve_directory + normpath+startswith inline
1 #227 server/routes/corpus_topic_clusters.py 76 2026-04-17 safe_resolve_directory + normpath+startswith inline
1 #228 server/routes/corpus_text_file.py 144 2026-04-17 normpath_if_under_root inline before FileResponse
1 #230 search/topic_clusters.py 108 2026-04-17 safe_resolve_directory + normpath+startswith inline
1 #231 search/topic_clusters.py 104 2026-04-17 safe_resolve_directory + normpath+startswith inline
1 #233 server/cil_queries.py 155 2026-04-18 os.path.isdir(anchor_s) in iter_cil_bridge_bundles; anchor/root normpath + prefix under server anchor (PR #588; same chain as #206/#207)
1 #234 server/cil_queries.py 159 2026-04-18 os.walk(anchor_s) in iter_cil_bridge_bundles; same guards as #233
1 #235 server/cil_queries.py 187 2026-04-18 _posix_relpath_under_corpus Path.resolve; inputs from bridge paths already under root_prefix (node-episodes / bridge scan)
1 #236 server/routes/corpus_library.py 171 2026-04-18 corpus_node_episodes root.resolve() after _resolve_corpus_root → resolve_corpus_path_param
1 #237 server/cil_digest_topics.py 82 2026-04-18 _read_json_object open; callers pass joined after normpath+startswith under corpus root or safe_relpath_under_corpus_root bridge path (PR #602)
1 #238 server/cil_digest_topics.py 102 2026-04-18 corpus_root.resolve() + normpath join + startswith(safe_prefix) before cluster JSON access (PR #602)
1 #239 server/cil_digest_topics.py 108 2026-04-18 os.path.isfile(joined) same chain as #238 (PR #602)
1 #240 server/cil_digest_topics.py 175 2026-04-18 safe_relpath_under_corpus_root before isfile / _read_json_object on bridge (PR #602)
1 #241 server/cil_digest_topics.py 219 2026-04-18 same as #240 in row_matches_library_topic_cluster_filter (PR #602)
1 #244–#297 atomic_write.py, feeds_spec.py, corpus_catalog.py, corpus_text_file.py, routes/feeds.py, routes/jobs.py, routes/operator_config.py various 2026-04-21 PR #649 py/path-injection batch on refs/pull/649/merge; Type 1 false positives (resolve_corpus_path_param / normpath_if_under_root / safe_relpath_under_corpus_root / _verified_operator_config_path / trusted callers); dismissed via gh api …/code-scanning/alerts/{n}
1 #304 server/operator_paths.py 50 2026-04-24 Type 1: candidate_s from safe_fixed_file_under_root before isfile; CodeQL cross-function taint gap; dismissed gh api (PR #666)
1 #305 server/jobs_log_path.py 74 2026-04-24 Type 1: log_path from normpath_if_under_root after safe_relpath_under_corpus_root before isfile; dismissed gh api (PR #666)
1 #306 server/routes/corpus_library.py 395 2026-04-25 Type 1: root sanitized via _resolve_corpus_root → resolve_corpus_path_param (normpath+startswith anchor); .resolve() on the already-anchored path. Dismissed gh api (PR #675)
1 #307 server/routes/corpus_library.py 401 2026-04-25 Type 1: target = os.path.normpath(os.path.join(root_s, bridge_relative_path)) followed by inline target.startswith(root_s + os.sep) prefix-guard before open(). Dismissed gh api (PR #675)
1 #308 server/routes/operator_config.py 136 2026-04-28 Type 1: corpus_root from resolve_corpus_path_param (normpath + startswith anchor) immediately before corpus_root.mkdir(parents=True, exist_ok=True) on GET handler — auto-create restricted to subdirs under the configured corpus root (#693 first-run UX). Dismissed gh api (PR #702)
1 #309 server/routes/operator_config.py 195 2026-04-28 Type 1: same sanitizer chain as #308, mirror on PUT handler. Dismissed gh api (PR #702)
1 #311 server/routes/scheduled_jobs.py 48 2026-05-02 Type 1: corpus from _resolve_corpus_root → resolve_corpus_path_param (normpath+startswith anchor); .resolve() on already-anchored Path before os.path.normpath. Same shape as routes/jobs.py and routes/corpus_library.py #306. Dismissed gh api (PR #707, #708)
1 #535 server/routes/enrichment.py 401 2026-08-23 Type 1: RFC-118 GET /api/enrichment/stats — corpus from _corpus_and_operator → _resolve_corpus_root → resolve_corpus_path_param (normpath+startswith anchor) before compute_enrichment_staleness reads envelopes/run-summary under it; .resolve() on the already-anchored path. Same chain as #306/#311. Dismissed gh api (PR #1815)
1 #536 server/app_catalog_cache.py 43 2026-08-23 Type 1: _key() = str(Path(root).resolve()) — a cache-key STRING derivation, no filesystem access with the tainted value. Every route call site passes root via _resolve_corpus_root → resolve_corpus_path_param (normpath+startswith anchor) first. Same chain as #306/#311/#535. Dismissed gh api (PR #1806)
2 #319 .github/workflows/drill-infra-destroy.yml 85 2026-05-12 Type 2: tfstate artifact download in same workflow_call chain (same run_id); only repo admins can trigger; no external input controls artifact content
2 #320 .github/workflows/drill-infra-destroy.yml 103 2026-05-12 Type 2: tfstate artifact download in same workflow_call chain (same run_id); only repo admins can trigger; no external input controls artifact content
3 #298 docker/pipeline (lcms2/liblcms2-2@2.16-2) — 2026-05-02 Type 3: SNYK-DEBIAN13-LCMS2-16104015 (CVE-2026-41254 incorrect-behavior-order). Transitive system dep via ffmpeg / image libs. Pipeline processes audio + text only; no PIL/Pillow image color-management invocation in src/ (grep -r "from PIL" empty). Latest in Debian 13 trixie apt index; apt-get upgrade would auto-pull a backport once published. Dismissed gh api (won't fix; not reachable).
3 #312 docker/pipeline (gnutls28/libgnutls30t64@3.8.9-3+deb13u2) — 2026-05-02 Type 3: SNYK-DEBIAN13-GNUTLS28-16344314 (CVE-2026-33845 DTLS handshake integer underflow). Snyk explicitly: "no fixed version for Debian:13 gnutls28". Pipeline uses HTTP/HTTPS via httpx + requests (OpenSSL TLS), not gnutls's DTLS path; we do not accept inbound DTLS handshakes. Dismissed gh api (won't fix; not reachable).
1 #327 server/pathutil.py 116 2026-05-24 Type 1: corpus_s inline normpath + startswith(safe_prefix) under anchor_str before manifest join (read_manifest_produced_by_under_anchor; PR #815)
1 #328 server/pathutil.py 125 2026-05-24 Type 1: manifest_s inline normpath + startswith(safe_prefix) before os.path.isfile (same function; PR #815)
1 #329 server/pathutil.py 129 2026-05-24 Type 1: manifest_s same inline sanitizer chain before read_text (PR #815)
1 #330 server/pathutil.py 115 2026-05-24 Type 1: post-refactor corpus_s inline normpath + startswith (PR #815)
1 #331 server/pathutil.py 123 2026-05-24 Type 1: manifest_s inline sanitizer before os.path.isfile (PR #815)
1 #332 server/pathutil.py 126 2026-05-24 Type 1: manifest_s inline sanitizer before read_text (PR #815)
1 #342 search/backends/lancedb_backend.py 131 2026-06-01 Type 1: meta_path via normpath_if_under_root after safe_resolve_directory + safe_relpath_under_corpus_root (constant index_meta.json) before open in read_index_meta; corpus root confined at the route by resolve_corpus_path_param (raises on escape). Same shape as jobs_log_path:74. Dismissed gh api (PR #865)
1 #338 search/backends/lancedb_backend.py 135 2026-06-01 Type 1: same sanitizer chain, os.path.isfile(meta_path) sink in read_index_meta (PR #865)
1 #341 search/hybrid_search.py 172 2026-06-01 Type 1: index_dir_str via normpath_if_under_root after safe_resolve_directory + safe_relpath_under_corpus_root (constant search/lance_index) before os.path.isdir in hybrid_candidates; corpus root confined at the route by resolve_corpus_path_param. Dismissed gh api (PR #865)
1 #343 kg/corpus.py 40 2026-06-05 Type 1: new /api/relational/* routes confine the corpus path via resolve_corpus_path_param (raises on anchor escape) before get_corpus_graph → CorpusGraph.build → scan_kg_artifact_paths; reads only files under <corpus>. Same class as #865. Dismissed gh api (PR #890)
1 #344 kg/corpus.py 44 2026-06-05 Type 1: same sanitizer chain, load_kg_artifacts read under <corpus> (PR #890)
1 #345 kg/corpus.py 45 2026-06-05 Type 1: same sanitizer chain, load_kg_artifacts read under <corpus> (PR #890)
1 #346 kg/corpus.py 46 2026-06-05 Type 1: same sanitizer chain, load_kg_artifacts read under <corpus> (PR #890)
1 #347 search/corpus_graph.py 277 2026-06-05 Type 1: get_corpus_graph cache/build on the route-confined corpus root (resolve_corpus_path_param raises on escape); reads only <corpus> artifacts. Same class as #865. Dismissed gh api (PR #890)
1 #348 search/query_log.py 40 2026-06-05 Type 1: search/query-activity routes confine the corpus root via resolve_corpus_path_param before append_query_event → mkdir under <corpus>/search/. Same class as #343. Dismissed gh api (PR #896)
1 #349 search/query_log.py 41 2026-06-05 Type 1: same sanitizer chain, append_query_event opens <corpus>/search/query_log.jsonl for append (PR #896)
1 #350 search/query_log.py 81 2026-06-05 Type 1: same sanitizer chain, read_query_activity path.exists() on route-confined corpus root (PR #896)
1 #351 search/query_log.py 83 2026-06-05 Type 1: same sanitizer chain, read_query_activity reads <corpus>/search/query_log.jsonl (PR #896)
1 #352 server/routes/corpus_media.py 67 2026-06-06 Type 1: target from safe_relpath_under_corpus_root after media/ prefix guard + resolve_corpus_path_param; CodeQL cross-function taint gap before inline normpath_if_under_root. Dismissed gh api (PR #898)
1 #353 server/routes/corpus_media.py 73 2026-06-06 Type 1: root_s from safe_resolve_directory(root) after resolve_corpus_path_param anchor guard. Dismissed gh api (PR #898)
1 #354 server/routes/corpus_media.py 79 2026-06-06 Type 1: verified from normpath_if_under_root(target, root_s) immediately before os.path.isfile. Same shape as corpus_text_file #224. Dismissed gh api (PR #898)
1 #355 server/routes/corpus_media.py 85 2026-06-06 Type 1: verified from normpath_if_under_root(target, root_s) immediately before FileResponse. Same shape as corpus_text_file #228. Dismissed gh api (PR #898)
1 #357 server/routes/corpus_media.py 53 2026-06-06 Type 1: _safe_media_target_str sink — safe_relpath_under_corpus_root(base, norm) after the media/ prefix + suffix-allowlist guards; route confines root via resolve_corpus_path_param. Traversal tests pass. Dismissed gh api (PR #901)
1 #358 server/routes/corpus_media.py 115 2026-06-06 Type 1: stem-extension resolve (_resolve_existing_media) — each candidate re-verified by normpath_if_under_root + realpath containment before isfile/FileResponse. Same class as #355. Dismissed gh api (PR #901)
1 #360 search/backends/lancedb_backend.py 332 2026-06-11 Type 1: meta_path via normpath_if_under_root after safe_resolve_directory + safe_relpath_under_corpus_root (constant index_meta.json) before os.path.isfile in stored_schema_version — identical shape to read_index_meta #338/#342, corpus root route-confined by resolve_corpus_path_param. Dismissed gh api (PR #969)
1 #361 search/backends/lancedb_backend.py 335 2026-06-11 Type 1: same sanitizer chain, open(meta_path) sink in stored_schema_version (PR #969)
1 #362 search/backends/lancedb_backend.py 359 2026-06-11 Type 1: same sanitizer chain, schema-version helper filesystem sink on the route-confined corpus root (PR #969)
1 #363 search/backends/lancedb_backend.py 341 2026-06-11 Type 1: same sanitizer chain, stored_schema_version sink re-numbered after the fix line-shift; meta_path via normpath_if_under_root. Same shape as #338/#342 (PR #969)
1 #369 server/routes/corpus_digest.py 278 2026-06-15 Type 1: corpus path sanitised upstream (resolve_corpus_path_param/safe_resolve_directory); index sub-path from constant suffix. FAISS→LanceDB refactor (#995, PR #1010)
1 #370 gi/explore.py 131 2026-06-15 Type 1: default_vector_index_dir builds output_dir/search (constant suffix) on a validated root. #995, PR #1010
1 #371 search/index_pool.py 45 2026-06-15 Type 1: getmtime on a validated index_dir (resolve_corpus_path_param/safe_resolve_directory upstream). #995, PR #1010
1 #372 search/index_pool.py 69 2026-06-15 Type 1: get_lance_backend resolves a validated index_dir; cross-fn sanitiser. #995, PR #1010
1 #374 search/lance_index_stats.py 38 2026-06-15 Type 1: _dir_size os.walk on a validated lance_dir under the sanitised corpus root. #995, PR #1010
1 #375 search/lance_index_stats.py 41 2026-06-15 Type 1: os.path.getsize under a validated lance_dir. #995, PR #1010
1 #376 search/lance_index_stats.py 50 2026-06-15 Type 1: read_lance_index_stats is_dir on a validated lance_dir. #995, PR #1010
1 #377 search/lance_index_stats.py 82 2026-06-15 Type 1: LanceDB open on a validated lance_dir; sanitised upstream. #995, PR #1010
1 #382 server/routes/index_stats.py 145 2026-06-20 Type 1: GET /index/timeseries builds search/lance_index (constant suffix) on a corpus path sanitised by resolve_corpus_path_param (normpath + startswith-anchor). Mirrors #166. PR #1038
1 #383 search/lance_index_stats.py 102 2026-06-20 Type 1: read_lance_doc_type_by_month is_dir/LanceDB open on a validated lance_dir; sanitised upstream. Mirrors #376/#377. PR #1038
1 #384 search/corpus_graph.py 414 2026-06-23 Type 1: re-fire of #347 — get_corpus_graph cache/build on the route-confined corpus root (resolve_corpus_path_param raises on anchor escape); reads only <corpus> artifacts. The reconcile_hosts (#1056) cache-key addition shifted the sink line 277→414, so CodeQL re-attributed the already-dismissed false positive to the PR. Sanitiser chain unchanged. Dismissed gh api (PR #1059)
1 #388 server/routes/corpus_enrichments.py 61 2026-06-28 Type 1: _read_envelope is_file() sink on a path built from safe_relpath_under_corpus_root after corpus root sanitised by resolve_corpus_path_param. Cross-fn taint gap. Dismissed gh api (PR #1127)
1 #389 server/routes/corpus_enrichments.py 66 2026-06-28 Type 1: same chain, read_text sink in _read_envelope (PR #1127)
1 #390 server/routes/corpus_enrichments.py 91 2026-06-28 Type 1: list_corpus_enrichments is_dir() on root / "enrichments" (constant suffix) on the sanitised root. Same shape as #131-#136 corpus_catalog. Dismissed gh api (PR #1127)
1 #391 server/routes/corpus_enrichments.py 94 2026-06-28 Type 1: same chain, glob("*.json") sink (PR #1127)
1 #392 server/routes/corpus_library.py 83 2026-06-28 Type 1: re-fire of dismissed corpus_library batch (#147 / #149 / #162 / #163 / #236 / #306 / #307) — same resolve_corpus_path_param chain, line drifted post-merge. Dismissed gh api (PR #1127)
1 #393 server/routes/enrichment.py 155 2026-06-28 Type 1: corpus_root from resolve_corpus_path_param before status-file is_file() / read_text chain (RFC-088 chunk 1 surface). Same shape as #244-#297 batch. Dismissed gh api (PR #1127)
1 #394 server/routes/enrichment_config.py 130 2026-06-28 Type 1: _read_operator_yaml(corpus_root) is_file() sink — corpus_root from resolve_corpus_path_param (normpath + startswith anchor), path built as corpus_root / "viewer_operator.yaml" (constant suffix). RFC-088 v2 config surface (GET /api/enrichment/config). Dismissed gh api (PR #1127)
1 #395 server/routes/enrichment_config.py 133 2026-06-28 Type 1: same chain, read_text sink on the same path (PR #1127)
1 #396 server/jobs.py 136 2026-06-28 Type 1: re-fire of dismissed server/jobs.py batch (#244-#297 / #305) — same jobs_log_path.resolve_pipeline_job_log_path (safe_resolve_directory + safe_relpath_under_corpus_root + normpath_if_under_root) sanitiser chain; line drifted post-merge. Dismissed gh api (PR #1127)
1 #397 server/jobs.py 141 2026-06-28 Type 1: same chain (PR #1127)
1 #398 server/jobs.py 212 2026-06-28 Type 1: same chain (PR #1127)
1 #399 server/jobs.py 225 2026-06-28 Type 1: same chain (PR #1127)
1 #400 server/jobs.py 226 2026-06-28 Type 1: same chain (PR #1127)
1 #401 server/jobs.py 586 2026-06-28 Type 1: same chain (PR #1127)
1 #414 server/routes/corpus_theme_clusters.py 75 2026-07-05 Type 1: joined prefix-checked (joined.startswith(safe_prefix) under root_s) immediately before os.path.isfile / open(); CodeQL misses the barrier. Dismissed gh api (PR #1141)
1 #415 server/routes/corpus_theme_clusters.py 80 2026-07-05 Type 1: same safe_prefix guard before open() (PR #1141)
ck #408 server/routes/app_auth.py 108 2026-07-05 py/cookie-injection FP: cookie value is app_sessions.sign({...}, secret) — an HMAC-signed opaque token; the grant input is signed inside, never reflected raw; set httponly + samesite=lax + secure. Not injectable. Dismissed gh api (PR #1141)
1 #409 server/app_user_store.py 31 2026-07-05 Type 1: user_id is user_id_for() = u_ + sha256[:24] (opaque hex, cannot contain / or ..) and arrives only via the HMAC-signed session; additionally guarded by _is_safe_user_id early not-found return before the sink. CodeQL misses the barrier (helper). Dismissed gh api (PR #1141)
1 #410 server/app_user_store.py 99 2026-07-05 Type 1: same — get_user guarded by _is_safe_user_id (PR #1141)
1 #411 server/app_user_store.py 102 2026-07-05 Type 1: same — get_user read guarded (PR #1141)
1 #412 server/app_user_store.py 208 2026-07-05 Type 1: same — delete_user rmtree guarded by _is_safe_user_id (PR #1141)
1 #413 server/app_user_store.py 210 2026-07-05 Type 1: same — delete_user guarded (PR #1141)
1 #418 server/feed_signals.py 57 2026-07-11 Type 1: _read_kg_artifact — root route-confined (corpus-path chain); relpath rejected up-front if absolute or containing .. (Path(relpath).parts), then realpath-under-root guard (target.startswith(root_real + os.sep)) before read_text. Same class as #307/#414; CodeQL misses the inline+realpath barrier. Dismissed gh api (PR #1172)
1 #419 server/feed_signals.py 119 2026-07-11 Type 1: _read_enrichment_data — enricher_id is a literal constant at every caller (topic_theme_clusters / temporal_velocity / grounding_rate) and validated to a bare [A-Za-z0-9_-] token before os.path.join; path is <root>/enrichments/<token>.json (constant suffix) on the route-confined corpus root. Same class as #390. Dismissed gh api (PR #1172)
1 #420 utils/usage_status.py 48 2026-07-16 Type 1: _discover_event_files globs under root, which is validated by resolve_corpus_path_param at the /api/usage route (normpath+startswith anchor); run_id is a substring filter, not a path component. Same class as the corpus routes. Dismissed via gh api (PR #1190)
1 #421 utils/usage_status.py 72 2026-07-16 Type 1: usage_rollup_snapshot — source is the route-validated corpus root (resolve_corpus_path_param). Dismissed via gh api (PR #1190)
1 #422 utils/usage_status.py 74 2026-07-16 Type 1: same as #421 — route-validated corpus root. Dismissed via gh api (PR #1190)
1 #429 server/routes/corpus_library.py 494 2026-07-23 Type 1: metadata_abs_path is joined onto the route-validated corpus root (resolve_corpus_path_param) — same normpath+startswith chain as the other corpus routes. Dismissed via gh api (PR #1274)
1 #430 server/routes/health.py 40 2026-07-23 Type 1: candidate is corpus_dir / 'enrichments' / 'topic_similarity.json' where corpus_dir is route-validated (_corpus_dir_for_health → resolve_corpus_path_param); the suffix is a constant. Same class as #419. Dismissed via gh api (PR #1274)
1 #431 search/operators.py 186 2026-07-23 Type 1: path is corpus_root / _TOPIC_CONSENSUS_REL where corpus_root is route-validated (/api/search → resolve_corpus_path_param) and the suffix is a constant. Dismissed via gh api (PR #1274)
1 #432 search/operators.py 189 2026-07-23 Type 1: same as #431 — path.read_text() on the validated corpus-root-anchored path. Dismissed via gh api (PR #1274)
1 #364 search/hybrid_search.py 190 2026-07-23 Type 1: index_dir_str passes through safe_relpath_under_corpus_root + normpath_if_under_root before os.path.isdir. Pre-existing on main (never dismissed); dismissed here since inline # codeql[] pragma is docs-only per registry policy. Dismissed via gh api (PR #1274)
1 #387 search/topic_clusters.py 104 2026-07-23 Type 1: joined built with inline normpath + startswith(safe_prefix) guard in the same function before os.path.isfile. Pre-existing on main; dismissed here since inline # codeql[] pragma is docs-only. Dismissed via gh api (PR #1274)
1 #452 server/routes/corpus_digest.py 258 2026-07-25 Type 1: root via _resolve_corpus_root → resolve_corpus_path_param (normpath+startswith anchor; raises on escape); perf_cache lambda sink, cross-fn taint gap. Same class as #306/#392. Dismissed via gh api (PR #1333)
1 #453 server/routes/corpus_digest.py 311 2026-07-25 Type 1: topic-band perf_cache key on the route-confined root (resolve_corpus_path_param anchor guard). Same class as #452. Dismissed via gh api (PR #1333)
1 #454 server/routes/corpus_library.py 261 2026-07-25 Type 1: catalog_feeds perf_cache on root via _resolve_corpus_root; re-fire of #306/#392, sink line drifted by the perf-cache change. Dismissed via gh api (PR #1333)
1 #455 search/lance_index_stats.py 69 2026-07-25 Type 1: lance_dir is server-derived (index_dir/'lance_index' from the route-confined corpus root), not raw user input; perf_cache key/lambda sink. Same class as #342/#360. Dismissed via gh api (PR #1333)
4 #456 server/routes/app_auth.py 151 2026-07-25 Type 4 (py/cookie-injection) FP: the STATE_COOKIE value is app_sessions.sign(payload) = base64url(json).base64url(hmac) — base64url charset only, no CRLF/;/control chars → no header injection; user fields (grant/platform/state) are JSON-encoded inside the signed token (#1310). Dismissed via gh api (PR #1333)

Still open (not yet dismissed)

None.


How to dismiss new alerts

Step 1 -- Classify

Determine whether the alert matches a known type above.

  • Known type, same sanitiser chain: proceed to dismiss (agent: no user approval needed for type 1 if the sanitiser chain is verified).
  • New type not listed here: stop. Explain the taint flow to the user, propose a code fix if possible, and get explicit approval before dismissing. Then add the new type to the "Alert types" section above.

Step 2 -- Dismiss via API

List open alerts for a PR (required when Security tab shows PR-only CodeQL): the default GET …/code-scanning/alerts?state=open is for the default branch; PR findings often appear only on refs/pull/<N>/merge.

gh api 'repos/chipi/podcast_scraper/code-scanning/alerts?state=open&ref=refs/pull/<N>/merge&per_page=100' \
  -q '.[] | "\(.number) \(.rule.id) \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"'

Dismiss one alert by number (same alert id repo-wide; comment should cite Type and doc):

gh api repos/chipi/podcast_scraper/code-scanning/alerts/ALERT_NUMBER \
  -X PATCH \
  -f state=dismissed \
  -f dismissed_reason="false positive" \
  -f dismissed_comment="Type N: <short reason>"

Step 3 -- Log

Add a row to the "Dismissed alerts" table and, if applicable, remove the matching row from "Still open." | 1 | #487 | server/routes/corpus_rollback.py | 71 | 2026-08-09 | _assert_under_root: resolve+startswith guard (rollback DELETE) | | 1 | #488 | server/routes/corpus_rollback.py | 84 | 2026-08-09 | run_id sanitized by _require_safe_run_id; root anchor-guarded | | 1 | #489 | server/routes/corpus_rollback.py | 86 | 2026-08-09 | run_id sanitized; root anchor-guarded | | 1 | #490 | server/routes/corpus_rollback.py | 103 | 2026-08-09 | run_dir from on-disk metadata index under root | | 1 | #491 | server/routes/corpus_rollback.py | 110 | 2026-08-09 | run_dir from on-disk metadata index; numeric idx prefix | | 1 | #492 | server/routes/corpus_rollback.py | 132 | 2026-08-09 | dst under /.trash; src realpath re-checked under root | | 1 | #493 | server/routes/corpus_rollback.py | 133 | 2026-08-09 | shutil.move; src realpath re-checked under root immediately above | | 1 | #494 | server/routes/corpus_rollback.py | 133 | 2026-08-09 | shutil.move; src realpath re-checked under root immediately above | | 1 | #495 | server/routes/corpus_rollback.py | 157 | 2026-08-09 | manifest is /; root anchor-guarded | | 1 | #496 | server/routes/corpus_rollback.py | 162 | 2026-08-09 | manifest is /; root anchor-guarded | | 1 | #497 | server/routes/corpus_rollback.py | 166 | 2026-08-09 | manifest read; / path | | 1 | #498 | server/routes/corpus_topic_clusters.py | 154 | 2026-08-09 | root via resolve_corpus_path_param; normpath+realpath | | 1 | #499 | utils/filesystem.py | 117 | 2026-08-09 | validate_and_normalize_output_dir: this IS the validator (resolve + validate_path_is_safe) | | 1 | #500 | server/routes/jobs.py | 59 | 2026-08-09 | corpus is resolved anchor; feeds.spec is a constant filename | | 1 | #501 | server/jobs.py | 263 | 2026-08-09 | corpus_root is resolved anchor; feeds.spec constant filename | | 1 | #502 | server/jobs.py | 264 | 2026-08-09 | corpus_root resolved anchor; spec.resolve() constant filename | | 1 | #503 | workflow/run_index.py | 287 | 2026-08-09 | output_dir server default / resolve_corpus_path_param; constant glob | | 1 | #504 | server/routes/corpus_rollback.py | 86 | 2026-08-09 | run_id sanitized; root anchor-guarded (re-dismiss after pragma line-shift) | | 1 | #505 | server/routes/corpus_rollback.py | 137 | 2026-08-09 | dst under root/.trash; src realpath re-checked (re-dismiss after line-shift) | | 1 | #506 | server/routes/corpus_rollback.py | 165 | 2026-08-09 | manifest is root/constant; anchor-guarded (re-dismiss after line-shift) | | 1 | #539 | server/routes/jobs.py | 399 | 2026-08-25 | corpus from _resolve_corpus_root (anchor-guarded); normpath of resolved anchor (#1785 running) | | 1 | #540 | server/routes/jobs.py | 455 | 2026-08-25 | same anchor-guarded corpus; normpath of resolved anchor (#1785 stop) | | 1 | #541 | server/routes/jobs.py | 474 | 2026-08-25 | same anchor-guarded corpus; normpath of resolved anchor (#1785 resume) | | 1 | #542 | server/queue_sweeper.py | 69 | 2026-08-25 | drain_is_paused: corpus_root anchor-guarded at every caller; path is <root>/<constant> (PAUSE_FLAG_RELPATH) | | 1 | #549 | workflow/run_index.py | 291 | 2026-09-01 | Re-issue of dismissed #503 (same sink, moved 288 -> 291 by the PR #1898 refactor). _scan_corpus_metadata_index globs constant patterns under a caller-supplied root; the only request-derived caller is routes/corpus_rollback, which builds the root via resolve_corpus_path_param (normpath + resolve() + startswith(anchor + os.sep), raises 400 on escape). The other caller passes cfg.output_dir (operator config, not a request). | | 1 | #561 | server/routes/app_profile.py | 107 | 2026-09-11 | serve_avatar returns 404 unless _is_safe_user_id (re.fullmatch(r'u_[0-9a-f]{24}')); path is <data_dir>/users/<24hex>/ + fixed avatar.* glob — cannot traverse. Auth-gated. (PR #2034; second taint path on the same line as #562.) | | 1 | #562 | server/routes/app_profile.py | 107 | 2026-09-11 | serve_avatar: same _is_safe_user_id fullmatch guard + fixed avatar.* glob. Auth-gated. (PR #2034.) | | 1 | #563 | server/routes/app_relational.py | 118 | 2026-09-11 | Serves person_image_path(root, person_id.strip()); person_id → _safe_name (re.sub(r'[^a-z0-9._-]','_') strips every separator) + fixed f'{stem}.{ext}' glob under enrichments/person_images/; no separator survives so the path cannot traverse. Auth-gated. (PR #2034; same sanitizer as #564.) | | 1 | #564 | enrichment/enrichers/person_web.py | 432 | 2026-09-11 | person_image_path: person_id → _safe_name (separator-stripping char allow-list) + fixed f'{stem}.{ext}' glob under the images dir; the joined path is always a direct child of the dir. (PR #2034.) | | 1 | (PR #2126, fill on dismissal) | perf_cache.py | 154 | 2026-09-19 | corpus_mtime: read-only os.path.getmtime(root / name) where name comes from a constant tuple of four filenames. Every server caller anchor-guards the root first — app_corpus_access via safe_resolve_directory(root) (line 68), likewise app_catalog_cache, app_cache_warm, cil_queries. Same shape as dismissed #500/#501. An inline pragma is already present on the line above and does not clear the PR check. | | 1 | (PR #2126, fill on dismissal) | kg/entity_clusters.py | 500 | 2026-09-19 | cached_entity_id_map: the flagged line is key = (str(root.resolve()), bool(same_show_required)) — a cache key, not a filesystem sink. .resolve() normalises; nothing is opened, globbed or stat'd on this line. | | 1 | (PR #2126, fill on dismissal) | search/corpus_graph.py | 563 | 2026-09-19 | get_corpus_graph: the flagged line is _root = str(Path(corpus_dir).resolve()), again a cache key rather than a sink. Same no-filesystem-access argument as the entity_clusters entry above. | | 1 | #596 | search/storylines.py | 58 | 2026-09-27 | Re-issue caused by a RENAME, not by new code (PR #2127): theme_clusters.py → storylines.py. CodeQL treats a renamed file as new, so sinks already dismissed under the old names re-fire with fresh ids. Path is safe_resolve_directory(corpus_root) + the constant STORYLINES_REL, with an inline os.path.normpath + startswith(safe_prefix) guard in the same function immediately above the sink. No request-derived component reaches the path. | | 1 | #598 | server/routes/corpus_storylines.py | 121 | 2026-09-27 | Same rename re-issue (corpus_theme_clusters.py → corpus_storylines.py); direct successor to dismissed #414/#415. joined = normpath(join(root_s, *parts)) with parts constant, guarded by joined != root_s and not joined.startswith(safe_prefix) → 404, immediately above os.path.isfile. | | 1 | #599 | server/routes/corpus_storylines.py | 152 | 2026-09-27 | Same rename re-issue; successor to dismissed #531. os.path.getmtime(joined) only stats a path already normpath'd and startswith(safe_prefix)-guarded earlier in the same function; the value feeds a perf_cache token. | | 1 | #600 | search/storylines.py | 55 | 2026-09-27 | Same rename re-issue as #596 — the os.path.isfile(joined) sink a few lines above the os.stat one. Same constant-segment + inline-guard argument. |

Note for the next rename (2026-09-27). Moving a file that contains dismissed py/path-injection sinks WILL turn the PR's CodeQL check red with "new alerts in code changed by this pull request", even though no logic changed and the inline # codeql[...] pragmas moved with the code. The pragmas are documentation; they do not suppress. Re-dismiss the new ids against this table and name which old ids they succeed. gh api caps dismissed_comment at 280 characters, so keep the API comment short and put the reasoning here.


2026-09-28 — PR #2181 (branch fix/quality-arc-2026-09-28)

Alert 608 — py/path-injection — src/podcast_scraper/server/jobs.py (_write_job_worklist)

Type 1. The sink writes a reprocess work-list file. Two independent taints reached it and both are closed:

  • run_id arrives from the request layer. It is no longer interpolated: the filename stem is rebuilt from a parsed uuid.UUID, and a value that does not parse is replaced with a fresh uuid4 rather than trusted. A separator, .. or an absolute path cannot survive that.
  • corpus_root arrives from the path query parameter and is anchor-guarded by _resolve_corpus_root. The guard is repeated inline in the same function as the sink, per this document's Type-1 note that CodeQL does not propagate sanitiser state out of helpers: safe_resolve_directory → join only constant segments (.viewer, jobs) plus the UUID stem → os.path.normpath → startswith(root + os.sep) → write.

main already carries three alerts of this same class in this same file. Dismissed after the inline guard cleared one of the two original sinks and could not clear the second.

Containment is tested, not asserted: test_a_run_id_that_is_not_a_uuid_cannot_shape_the_path parametrises ../../../../etc/passwd, /etc/passwd, .., a/b and a non-UUID string, and requires each to land inside the jobs dir with a UUID stem.

Alert 607 — py/weak-sensitive-data-hashing — src/podcast_scraper/utils/filesystem.py:243

Not a secret and not this PR's code. The hashed value is an RSS feed URL, hashed to build a deterministic output directory name (output/rss_<host>_<digest>). The call already declares hashlib.sha1(..., usedforsecurity=False) and the line is commented "Deterministic hash for directory naming (not security sensitive)".

utils/filesystem.py is absent from this PR's diff (git diff --name-only origin/main..HEAD); the alert surfaced against the merge ref rather than being introduced here.