Skip to content

Code scanning: model dw/security.py's validators, fix the two real findings - #58

Merged
dkackman merged 1 commit into
masterfrom
codeql-sanitizer-model
Sep 9, 2026
Merged

Code scanning: model dw/security.py's validators, fix the two real findings#58
dkackman merged 1 commit into
masterfrom
codeql-sanitizer-model

Conversation

@dkackman

@dkackman dkackman commented Sep 9, 2026

Copy link
Copy Markdown
Owner

The 27 open code scanning alerts were 26 copies of one false positive and one real finding.

Why there were 26

Every flagged filesystem access already reaches the disk through validate_path, which resolves with os.path.realpath and then raises unless the result is the base directory or a descendant of it — exactly the normalize-then-check shape py/path-injection looks for. But that query recognizes the check only as a local barrier guard. A validator that lives in another module and returns the safe path rather than guarding a branch is invisible to it, so every route touching a file gets flagged. Dismissing the alerts one by one would not stop the next one.

What this does

Teaches the query about the validators, in a local query pack (.github/codeql/dw-security/) that models them as sanitizers, with the built-in query it replaces filtered out. Loading a pack needs advanced setup, so scanning moves off GitHub's default setup to .github/workflows/codeql.yml — same three languages (actions, javascript-typescript, python), same weekly schedule.

It is deliberately not a blanket suppression:

  • validate_path(path, base) is a barrier only when base is not None; with None it is modeled as PathNormalization, so the path stays reportable until something checks it.
  • Code that reaches the disk without a validator still flags at high severity. That signal is what the 26-alert wall was hiding, and CLAUDE.md now says so.

The trade is that dw/security.py becomes trusted by the query rather than checked by it — documented at the top of DwPathSanitizers.qll, where it points at tests/test_security.py as the place containment is actually established.

The two genuine findings, fixed rather than modeled

  • resolve_workflow_reference called os.path.isfile on the submitted path before any containment check, so a workflow_path could probe for files anywhere on disk. Containment now comes first, and is re-applied to the path that is returned rather than trusted from source_for_path's answer about it.
  • /api/validate returned str(e) of an unexpected validator exception to the client (py/stack-trace-exposure). It now logs the detail and reports the category, as the handler 15 lines above it already did. The test that pinned the old behaviour now pins the new contract: generic message out, "boom" only in the log.

Verification

Run locally with the CodeQL CLI 2.26.4 against a database built from this tree:

built-in py/path-injection modeled dw/path-injection
results 39 0

py/stack-trace-exposure: 1 before → 0 after. Full test suite: 3293 passed, 5 skipped. black --check clean.

Caveat worth knowing

Advanced setup means we own the workflow file — GitHub's automatic query-suite and CLI updates stop being invisible, and github/codeql-action needs Dependabot to stay current. That is the cost of custom queries; there is no way to load a pack under default setup.

Default setup is already disabled

It had to be: GitHub rejects SARIF from an advanced configuration while default setup is enabled (Code Scanning could not process the submitted SARIF file: CodeQL analyses from advanced configurations cannot be processed when the default setup is enabled), which is what the first red checks on this PR were. Turned off via PATCH /code-scanning/default-setup; re-enabling is one API call if this is ever reverted.

With it off, all three languages upload cleanly, and the python job's log shows the pack doing its job in CI, not just locally:

Recording pack reference dw/security-queries at .../.github/codeql/dw-security
Compiled .../DwPathInjection.ql
Evaluation done; writing results to dw/security-queries/DwPathInjection.bqrs
Interpreting code scanning results for python

After merge: the 27 existing alerts close as fixed once master is analyzed.

🤖 Generated with Claude Code

…ndings

The 27 open code scanning alerts were 26 copies of one false positive and
one real finding. Every flagged filesystem access already reached the disk
through validate_path, which resolves with realpath and then raises unless
the result is contained - the normalize-then-check shape py/path-injection
looks for. But that query recognizes the check only as a *local* barrier
guard, so a validator in another module that returns the safe path instead
of guarding a branch is invisible to it, and every route touching a file
gets flagged. Dismissing the alerts would not stop the next one.

So teach the query about the validators instead, in a local query pack that
models them as sanitizers, and filter out the built-in query it replaces.
Loading a pack needs advanced setup, so scanning moves from GitHub's default
setup to .github/workflows/codeql.yml - same three languages, same weekly
schedule, so nothing that was scanned stops being scanned.

This is not a blanket suppression: a path validated without a base directory
is modeled as normalization only and stays reportable, and code that reaches
the disk without a validator still flags at high severity. That signal is
what the 26-alert wall was hiding.

The two genuine findings are fixed rather than modeled:

- resolve_workflow_reference called os.path.isfile on the submitted path
  before any containment check, which let a workflow_path probe for files
  anywhere on disk. The containment now comes first, and is re-applied to
  the path that is returned rather than trusted from source_for_path's
  answer about it.
- /api/validate returned the string of an unexpected validator exception to
  the client. It now logs the detail and reports the category, as the
  handler above it already did. The test that pinned the old behaviour now
  pins the new contract: generic message out, "boom" only in the log.

Verified with the CodeQL CLI against a database built from this tree: the
modeled query reports 0 results where the built-in one reports 39, and
py/stack-trace-exposure goes from 1 to 0. Full suite 3293 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread dw/server/app.py
confined = validate_path(candidate, source.root, allow_create=False)
except SecurityError:
confined = None
if confined is not None and os.path.isfile(confined):
@dkackman
dkackman merged commit 13876cd into master Sep 9, 2026
12 of 15 checks passed
@dkackman
dkackman deleted the codeql-sanitizer-model branch September 9, 2026 03:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants