entrygraph 0.1.5__tar.gz → 0.1.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {entrygraph-0.1.5 → entrygraph-0.1.7}/PKG-INFO +15 -7
- {entrygraph-0.1.5 → entrygraph-0.1.7}/README.md +14 -6
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/_version.py +2 -2
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/api.py +107 -20
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/cli/main.py +14 -3
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/db/meta.py +1 -1
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/db/models.py +4 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/taint.py +15 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/pipeline/scanner.py +4 -5
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/results.py +1 -0
- entrygraph-0.1.7/tests/fixtures/python/sanitizer/app.py +12 -0
- entrygraph-0.1.7/tests/fixtures/python/taint_source/handler.py +12 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_taint.py +20 -0
- entrygraph-0.1.7/tests/test_taint_sanitizers.py +57 -0
- entrygraph-0.1.7/tests/test_taint_sources.py +50 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/.github/workflows/ci.yml +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/.github/workflows/release.yml +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/.gitignore +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/LICENSE +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/RELEASING.md +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/pyproject.toml +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/__init__.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/__main__.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/cli/__init__.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/cli/render.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/csharp.toml +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/go.toml +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/java.toml +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/javascript.toml +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/lib_javascript.toml +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/lib_python.toml +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/php.toml +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/python.toml +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/ruby.toml +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/rust.toml +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/db/__init__.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/db/engine.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/db/queries.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/__init__.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/__init__.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/base.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/configs.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/csharp.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/golang.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/java.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/javascript.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/php.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/python.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/ruby.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/rust.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/frameworks.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/manifests.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/errors.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/__init__.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/base.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/csharp.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/golang.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/ir.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/java.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/javascript.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/php.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/python.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/registry.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/ruby.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/rust.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/fs/__init__.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/fs/hashing.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/fs/lang.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/fs/walker.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/graph/__init__.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/graph/adjacency.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/graph/cte.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/graph/scoring.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/kinds.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/parsing/__init__.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/parsing/parsers.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/parsing/queries.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/pipeline/__init__.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/pipeline/worker.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/pipeline/writer.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/py.typed +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/csharp/calls.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/csharp/definitions.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/csharp/imports.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/go/calls.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/go/definitions.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/go/imports.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/java/calls.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/java/definitions.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/java/imports.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/javascript/calls.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/javascript/definitions.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/javascript/imports.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/php/calls.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/php/definitions.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/php/imports.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/python/calls.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/python/definitions.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/python/imports.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/ruby/calls.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/ruby/definitions.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/ruby/imports.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/rust/calls.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/rust/definitions.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/rust/imports.scm +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/resolve/__init__.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/resolve/externals.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/resolve/hierarchy.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/resolve/resolver.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/resolve/symbol_table.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/conftest.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/csharp/aspnet_app/Controllers/ReportsController.cs +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/csharp/aspnet_app/Program.cs +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/csharp/aspnet_app/Services/ReportService.cs +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/csharp/aspnet_app/app.csproj +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/go/gin_app/go.mod +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/go/gin_app/main.go +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/go/gin_app/service.go +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/java/spring_app/pom.xml +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/java/spring_app/src/main/java/com/example/Application.java +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/java/spring_app/src/main/java/com/example/ReportRunner.java +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/java/spring_app/src/main/java/com/example/ReportService.java +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/java/spring_app/src/main/java/com/example/UserController.java +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/javascript/express_app/package.json +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/javascript/express_app/src/routes.js +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/javascript/express_app/src/services.js +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/php/laravel_app/app/Http/Controllers/ReportController.php +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/php/laravel_app/artisan +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/php/laravel_app/composer.json +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/php/laravel_app/routes/web.php +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/python/flask_app/app/__init__.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/python/flask_app/app/db.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/python/flask_app/app/routes.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/python/flask_app/app/services.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/python/flask_app/cli.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/python/flask_app/requirements.txt +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/ruby/sinatra_app/Gemfile +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/ruby/sinatra_app/app.rb +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/ruby/sinatra_app/services/runner.rb +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/rust/axum_app/Cargo.toml +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/rust/axum_app/src/handlers.rs +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/rust/axum_app/src/main.rs +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_api.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_cli.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_engine_pragmas.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_entrypoint_expansion.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_entrypoints.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_extract_csharp.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_extract_go.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_extract_java.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_extract_javascript.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_extract_php.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_extract_python.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_extract_ruby.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_extract_rust.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_frameworks.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_hardening.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_incremental.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_indexer.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_lang.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_manifests.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_models.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_pool.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_reachability.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_render.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_resolver.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_scoring.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_sink_catalog.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_walker.py +0 -0
- {entrygraph-0.1.5 → entrygraph-0.1.7}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: entrygraph
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: Language-agnostic code graph: query symbols, entrypoints, and source-to-sink call paths from a SQLite index
|
|
5
5
|
Project-URL: Repository, https://github.com/brettbergin/entrygraph
|
|
6
6
|
Author-email: Brett Bergin <brettberginbc@yahoo.com>
|
|
@@ -197,10 +197,16 @@ entrygraph paths --source '*' --sink-category command_exec
|
|
|
197
197
|
|
|
198
198
|
- **CI gate**: exits `0` when a path is found, `1` when none —
|
|
199
199
|
`entrygraph paths --source '*' --sink-category command_exec && echo reachable`.
|
|
200
|
+
- **Catalog sources**: instead of a `--source` glob, use `--source-category` to
|
|
201
|
+
start from every call site of a registered taint source (e.g.
|
|
202
|
+
`--source-category http_input` / `env`) — `entrygraph paths --source-category http_input --sink-category sql`. Combine with `--source` to union both.
|
|
200
203
|
- **Precision/recall dial**: by default only high-confidence edges are traversed.
|
|
201
204
|
Widen with `--include-unresolved` (wildcard `py:*.execute` sinks + dynamic
|
|
202
|
-
calls) or `--include-fuzzy` (speculative class-hierarchy edges)
|
|
203
|
-
|
|
205
|
+
calls) or `--include-fuzzy` (speculative class-hierarchy edges).
|
|
206
|
+
- **Sanitizers**: a registered sanitizer for the sink's category called on a
|
|
207
|
+
path (e.g. `shlex.quote`) *discounts* its risk score — heuristically, since
|
|
208
|
+
there is no dataflow, so it never zeroes the risk or hides the path.
|
|
209
|
+
`--prune-sanitized` opts into dropping those paths entirely.
|
|
204
210
|
- Target an exact sink with `--sink py:subprocess.run` instead of a category.
|
|
205
211
|
|
|
206
212
|
### `stats` & `--json`
|
|
@@ -291,7 +297,7 @@ graph.paths(source="app.routes.*", sink_category="sql",
|
|
|
291
297
|
graph.paths(source="app.routes.*", sink_category="command_exec",
|
|
292
298
|
include_fuzzy=True) # follow speculative class-hierarchy (CHA) edges
|
|
293
299
|
graph.paths(source="app.routes.*", sink_category="command_exec",
|
|
294
|
-
prune_sanitized=True) # drop paths
|
|
300
|
+
prune_sanitized=True) # drop paths where a shlex.quote etc. is called
|
|
295
301
|
|
|
296
302
|
# Incremental re-index (only changed/added/deleted files are reparsed)
|
|
297
303
|
graph.refresh()
|
|
@@ -332,9 +338,11 @@ results are safe to hold and trivial to serialize.
|
|
|
332
338
|
root with `[[sink]]` / `[[source]]` / `[[sanitizer]]` tables (same schema as
|
|
333
339
|
the built-in `data/sinks/*.toml`), or call
|
|
334
340
|
`entrygraph.detect.taint.register_sink(...)` / `register_sanitizer(...)`. A
|
|
335
|
-
`[[sanitizer]]`
|
|
336
|
-
|
|
337
|
-
|
|
341
|
+
`[[sanitizer]]` called on a path discounts its risk score for that category;
|
|
342
|
+
since reachability has no dataflow, the discount is capped (a match never
|
|
343
|
+
zeroes risk or hides a path — use `--prune-sanitized` to drop them explicitly).
|
|
344
|
+
Third-party wrapper libraries that reach a sink internally are covered by
|
|
345
|
+
`data/sinks/lib_*.toml`
|
|
338
346
|
"library summaries" (same schema, with a `library = "..."` tag).
|
|
339
347
|
- **New frameworks / entrypoints** — register a `FrameworkSpec` and an
|
|
340
348
|
`EntrypointRule`; adding a framework is usually a few lines.
|
|
@@ -146,10 +146,16 @@ entrygraph paths --source '*' --sink-category command_exec
|
|
|
146
146
|
|
|
147
147
|
- **CI gate**: exits `0` when a path is found, `1` when none —
|
|
148
148
|
`entrygraph paths --source '*' --sink-category command_exec && echo reachable`.
|
|
149
|
+
- **Catalog sources**: instead of a `--source` glob, use `--source-category` to
|
|
150
|
+
start from every call site of a registered taint source (e.g.
|
|
151
|
+
`--source-category http_input` / `env`) — `entrygraph paths --source-category http_input --sink-category sql`. Combine with `--source` to union both.
|
|
149
152
|
- **Precision/recall dial**: by default only high-confidence edges are traversed.
|
|
150
153
|
Widen with `--include-unresolved` (wildcard `py:*.execute` sinks + dynamic
|
|
151
|
-
calls) or `--include-fuzzy` (speculative class-hierarchy edges)
|
|
152
|
-
|
|
154
|
+
calls) or `--include-fuzzy` (speculative class-hierarchy edges).
|
|
155
|
+
- **Sanitizers**: a registered sanitizer for the sink's category called on a
|
|
156
|
+
path (e.g. `shlex.quote`) *discounts* its risk score — heuristically, since
|
|
157
|
+
there is no dataflow, so it never zeroes the risk or hides the path.
|
|
158
|
+
`--prune-sanitized` opts into dropping those paths entirely.
|
|
153
159
|
- Target an exact sink with `--sink py:subprocess.run` instead of a category.
|
|
154
160
|
|
|
155
161
|
### `stats` & `--json`
|
|
@@ -240,7 +246,7 @@ graph.paths(source="app.routes.*", sink_category="sql",
|
|
|
240
246
|
graph.paths(source="app.routes.*", sink_category="command_exec",
|
|
241
247
|
include_fuzzy=True) # follow speculative class-hierarchy (CHA) edges
|
|
242
248
|
graph.paths(source="app.routes.*", sink_category="command_exec",
|
|
243
|
-
prune_sanitized=True) # drop paths
|
|
249
|
+
prune_sanitized=True) # drop paths where a shlex.quote etc. is called
|
|
244
250
|
|
|
245
251
|
# Incremental re-index (only changed/added/deleted files are reparsed)
|
|
246
252
|
graph.refresh()
|
|
@@ -281,9 +287,11 @@ results are safe to hold and trivial to serialize.
|
|
|
281
287
|
root with `[[sink]]` / `[[source]]` / `[[sanitizer]]` tables (same schema as
|
|
282
288
|
the built-in `data/sinks/*.toml`), or call
|
|
283
289
|
`entrygraph.detect.taint.register_sink(...)` / `register_sanitizer(...)`. A
|
|
284
|
-
`[[sanitizer]]`
|
|
285
|
-
|
|
286
|
-
|
|
290
|
+
`[[sanitizer]]` called on a path discounts its risk score for that category;
|
|
291
|
+
since reachability has no dataflow, the discount is capped (a match never
|
|
292
|
+
zeroes risk or hides a path — use `--prune-sanitized` to drop them explicitly).
|
|
293
|
+
Third-party wrapper libraries that reach a sink internally are covered by
|
|
294
|
+
`data/sinks/lib_*.toml`
|
|
287
295
|
"library summaries" (same schema, with a `library = "..."` tag).
|
|
288
296
|
- **New frameworks / entrypoints** — register a `FrameworkSpec` and an
|
|
289
297
|
`EntrypointRule`; adding a framework is usually a few lines.
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.1.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1,
|
|
21
|
+
__version__ = version = '0.1.7'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 7)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -66,9 +66,13 @@ def _traversal_params(
|
|
|
66
66
|
return floor, include_fuzzy
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
def
|
|
70
|
-
"""True if
|
|
71
|
-
|
|
69
|
+
def _has_sanitizer(path: CallPath) -> bool:
|
|
70
|
+
"""True if a category sanitizer is called on the path (heuristic, no dataflow).
|
|
71
|
+
|
|
72
|
+
Drives ``--prune-sanitized``. Sanitizer matches only discount risk (never zero
|
|
73
|
+
it), so pruning is an explicit opt-in that trades recall for noise reduction
|
|
74
|
+
rather than a silent, certain "this path is safe" claim."""
|
|
75
|
+
return any(e.sanitized_by for e in path.edges)
|
|
72
76
|
|
|
73
77
|
|
|
74
78
|
class CodeGraph:
|
|
@@ -251,7 +255,8 @@ class CodeGraph:
|
|
|
251
255
|
def paths(
|
|
252
256
|
self,
|
|
253
257
|
*,
|
|
254
|
-
source: SourceSpec,
|
|
258
|
+
source: SourceSpec | None = None,
|
|
259
|
+
source_category: str | None = None,
|
|
255
260
|
sink: SourceSpec | None = None,
|
|
256
261
|
sink_category: str | None = None,
|
|
257
262
|
max_depth: int = 25,
|
|
@@ -266,6 +271,11 @@ class CodeGraph:
|
|
|
266
271
|
) -> list[CallPath]:
|
|
267
272
|
"""Enumerate source->sink call paths, risk-ranked (highest first).
|
|
268
273
|
|
|
274
|
+
Sources are an explicit `source` spec (qname/glob/Symbol/Entrypoint) and/or
|
|
275
|
+
a `source_category` resolved from the taint-source catalog (e.g. every call
|
|
276
|
+
site of an `http_input`/`env` source). Sinks work the same via `sink` /
|
|
277
|
+
`sink_category`.
|
|
278
|
+
|
|
269
279
|
By default only EXACT/IMPORT edges are traversed; `include_fuzzy` /
|
|
270
280
|
`include_unresolved` lower the confidence floor (an explicit
|
|
271
281
|
`min_confidence` int overrides the flags). `include_callbacks` also
|
|
@@ -276,7 +286,7 @@ class CodeGraph:
|
|
|
276
286
|
floor, include_cha = _traversal_params(min_confidence, include_fuzzy, include_unresolved)
|
|
277
287
|
kinds = (*edge_kinds, "callback") if include_callbacks else edge_kinds
|
|
278
288
|
with self._session_factory() as session:
|
|
279
|
-
sources = self.
|
|
289
|
+
sources = self._source_ids(session, source, source_category)
|
|
280
290
|
sinks = self._sink_ids(session, sink, sink_category)
|
|
281
291
|
if not sources or not sinks:
|
|
282
292
|
return []
|
|
@@ -290,13 +300,20 @@ class CodeGraph:
|
|
|
290
300
|
edge_map = self._edge_rows(session, raw_paths)
|
|
291
301
|
tainted_sources = self._tainted_source_ids(session, sources)
|
|
292
302
|
excluded_nodes = self._nodes_with_open_frontier(session, all_ids, kinds, floor)
|
|
303
|
+
sibling_calls = self._out_call_qnames(session, all_ids)
|
|
293
304
|
built = [
|
|
294
305
|
self._materialize_path(
|
|
295
|
-
path,
|
|
306
|
+
path,
|
|
307
|
+
symbol_map,
|
|
308
|
+
edge_map,
|
|
309
|
+
registry,
|
|
310
|
+
tainted_sources,
|
|
311
|
+
excluded_nodes,
|
|
312
|
+
sibling_calls,
|
|
296
313
|
)
|
|
297
314
|
for path in raw_paths
|
|
298
315
|
]
|
|
299
|
-
results = [cp for cp in built if not (prune_sanitized and
|
|
316
|
+
results = [cp for cp in built if not (prune_sanitized and _has_sanitizer(cp))]
|
|
300
317
|
results.sort(
|
|
301
318
|
key=lambda p: (-(p.risk_score or 0.0), len(p.symbols), [s.id for s in p.symbols])
|
|
302
319
|
)
|
|
@@ -305,7 +322,8 @@ class CodeGraph:
|
|
|
305
322
|
def reachable(
|
|
306
323
|
self,
|
|
307
324
|
*,
|
|
308
|
-
source: SourceSpec,
|
|
325
|
+
source: SourceSpec | None = None,
|
|
326
|
+
source_category: str | None = None,
|
|
309
327
|
sink: SourceSpec | None = None,
|
|
310
328
|
sink_category: str | None = None,
|
|
311
329
|
max_depth: int = 25,
|
|
@@ -319,7 +337,7 @@ class CodeGraph:
|
|
|
319
337
|
floor, include_cha = _traversal_params(min_confidence, include_fuzzy, include_unresolved)
|
|
320
338
|
kinds = (*edge_kinds, "callback") if include_callbacks else edge_kinds
|
|
321
339
|
with self._session_factory() as session:
|
|
322
|
-
sources = self.
|
|
340
|
+
sources = self._source_ids(session, source, source_category)
|
|
323
341
|
sinks = self._sink_ids(session, sink, sink_category)
|
|
324
342
|
if not sources or not sinks:
|
|
325
343
|
return False
|
|
@@ -362,6 +380,9 @@ class CodeGraph:
|
|
|
362
380
|
sink_edges=count(
|
|
363
381
|
select(func.count(models.Edge.id)).where(models.Edge.sink_id.is_not(None))
|
|
364
382
|
),
|
|
383
|
+
source_edges=count(
|
|
384
|
+
select(func.count(models.Edge.id)).where(models.Edge.source_id.is_not(None))
|
|
385
|
+
),
|
|
365
386
|
)
|
|
366
387
|
|
|
367
388
|
def session(self) -> Session:
|
|
@@ -426,6 +447,22 @@ class CodeGraph:
|
|
|
426
447
|
return {spec.symbol.id}
|
|
427
448
|
return q.symbol_ids_matching(session, str(spec))
|
|
428
449
|
|
|
450
|
+
def _source_ids(self, session: Session, source, source_category: str | None) -> set[int]:
|
|
451
|
+
"""Taint origins: explicit `source` spec plus, for `source_category`, every
|
|
452
|
+
symbol that calls a matching catalog taint-source function."""
|
|
453
|
+
ids = self._spec_to_ids(session, source) if source is not None else set()
|
|
454
|
+
if source_category is not None:
|
|
455
|
+
registry = self._registry(session)
|
|
456
|
+
source_pattern_ids = registry.source_ids_for_category(source_category)
|
|
457
|
+
if source_pattern_ids:
|
|
458
|
+
rows = session.execute(
|
|
459
|
+
select(models.Edge.src_symbol_id).where(
|
|
460
|
+
models.Edge.source_id.in_(source_pattern_ids)
|
|
461
|
+
)
|
|
462
|
+
).scalars()
|
|
463
|
+
ids |= set(rows)
|
|
464
|
+
return ids
|
|
465
|
+
|
|
429
466
|
def _sink_ids(self, session: Session, sink, sink_category: str | None) -> set[int]:
|
|
430
467
|
ids = self._spec_to_ids(session, sink) if sink is not None else set()
|
|
431
468
|
if sink_category is not None:
|
|
@@ -474,18 +511,26 @@ class CodeGraph:
|
|
|
474
511
|
|
|
475
512
|
@staticmethod
|
|
476
513
|
def _tainted_source_ids(session: Session, sources: set[int]) -> set[int]:
|
|
477
|
-
"""Source symbol ids
|
|
514
|
+
"""Source symbol ids known to carry user-controlled data: entrypoints with
|
|
515
|
+
tainted params, plus call sites of catalog taint-source functions."""
|
|
478
516
|
if not sources:
|
|
479
517
|
return set()
|
|
518
|
+
tainted: set[int] = set()
|
|
480
519
|
rows = session.execute(
|
|
481
520
|
select(models.Entrypoint.symbol_id, models.Entrypoint.extra).where(
|
|
482
521
|
models.Entrypoint.symbol_id.in_(sources)
|
|
483
522
|
)
|
|
484
523
|
).all()
|
|
485
|
-
tainted: set[int] = set()
|
|
486
524
|
for sid, extra in rows:
|
|
487
525
|
if extra and json.loads(extra).get("tainted_params"):
|
|
488
526
|
tainted.add(sid)
|
|
527
|
+
source_callers = session.execute(
|
|
528
|
+
select(models.Edge.src_symbol_id).where(
|
|
529
|
+
models.Edge.source_id.is_not(None),
|
|
530
|
+
models.Edge.src_symbol_id.in_(sources),
|
|
531
|
+
)
|
|
532
|
+
).scalars()
|
|
533
|
+
tainted |= set(source_callers)
|
|
489
534
|
return tainted
|
|
490
535
|
|
|
491
536
|
@staticmethod
|
|
@@ -509,8 +554,38 @@ class CodeGraph:
|
|
|
509
554
|
).scalars()
|
|
510
555
|
return set(rows)
|
|
511
556
|
|
|
557
|
+
@staticmethod
|
|
558
|
+
def _out_call_qnames(session: Session, node_ids: set[int]) -> dict[int, list[str]]:
|
|
559
|
+
"""For each node, the callee qnames of its outgoing CALLS edges.
|
|
560
|
+
|
|
561
|
+
Used for sanitizer detection: a sanitizer is usually a *sibling* call of
|
|
562
|
+
an on-path function (`shlex.quote(x)` then `subprocess.run(...)`), not a
|
|
563
|
+
node on the source->sink path itself, so it never appears among the path's
|
|
564
|
+
own hops."""
|
|
565
|
+
if not node_ids:
|
|
566
|
+
return {}
|
|
567
|
+
from entrygraph.kinds import EdgeKind
|
|
568
|
+
|
|
569
|
+
rows = session.execute(
|
|
570
|
+
select(models.Edge.src_symbol_id, models.Edge.dst_qname).where(
|
|
571
|
+
models.Edge.src_symbol_id.in_(node_ids),
|
|
572
|
+
models.Edge.kind == EdgeKind.CALLS,
|
|
573
|
+
)
|
|
574
|
+
).all()
|
|
575
|
+
out: dict[int, list[str]] = {}
|
|
576
|
+
for sid, dst_qname in rows:
|
|
577
|
+
out.setdefault(sid, []).append(dst_qname)
|
|
578
|
+
return out
|
|
579
|
+
|
|
512
580
|
def _materialize_path(
|
|
513
|
-
self,
|
|
581
|
+
self,
|
|
582
|
+
path,
|
|
583
|
+
symbol_map,
|
|
584
|
+
edge_map,
|
|
585
|
+
registry,
|
|
586
|
+
tainted_sources,
|
|
587
|
+
excluded_nodes,
|
|
588
|
+
sibling_calls,
|
|
514
589
|
) -> CallPath:
|
|
515
590
|
symbols = tuple(symbol_map[node] for node, _ in path)
|
|
516
591
|
hops = [hop for _, hop in path[1:]]
|
|
@@ -522,10 +597,11 @@ class CodeGraph:
|
|
|
522
597
|
sink_category = registry.sinks[sink_id].category if sink_id in registry.sinks else None
|
|
523
598
|
terminal_const = is_constant_args(terminal.arg_preview) if terminal else False
|
|
524
599
|
|
|
525
|
-
# sanitizer detection:
|
|
526
|
-
#
|
|
600
|
+
# sanitizer detection: a sanitizer for the sink's category called by any
|
|
601
|
+
# on-path node (its own hops OR a sibling call)
|
|
602
|
+
sibling_qnames = {q for node, _ in path for q in sibling_calls.get(node, ())}
|
|
527
603
|
sanitized_ids, sanitized_effect = self._path_sanitizers(
|
|
528
|
-
symbols, rows, registry, sink_category
|
|
604
|
+
symbols, rows, sibling_qnames, registry, sink_category
|
|
529
605
|
)
|
|
530
606
|
|
|
531
607
|
path_edges: list[PathEdge] = []
|
|
@@ -559,17 +635,28 @@ class CodeGraph:
|
|
|
559
635
|
)
|
|
560
636
|
|
|
561
637
|
@staticmethod
|
|
562
|
-
def _path_sanitizers(symbols, rows, registry, sink_category):
|
|
563
|
-
"""Return (matched sanitizer ids,
|
|
638
|
+
def _path_sanitizers(symbols, rows, sibling_qnames, registry, sink_category):
|
|
639
|
+
"""Return (matched sanitizer ids, effect) for the sink category.
|
|
640
|
+
|
|
641
|
+
Candidates are every callee reachable from an on-path node: the path's own
|
|
642
|
+
hops plus sibling calls (`sibling_qnames`). The effect is capped at
|
|
643
|
+
``"reduces"`` regardless of the catalog's ``effect`` — with no dataflow we
|
|
644
|
+
cannot prove the sanitized value is the one reaching the sink, so a match
|
|
645
|
+
discounts risk but never drives it to zero (which would silently hide a
|
|
646
|
+
real path). ``--prune-sanitized`` is the explicit, heuristic opt-in to
|
|
647
|
+
drop these."""
|
|
564
648
|
if not sink_category:
|
|
565
649
|
return [], None
|
|
566
650
|
matched: list = []
|
|
567
|
-
candidates =
|
|
651
|
+
candidates = (
|
|
652
|
+
[s.qname for s in symbols]
|
|
653
|
+
+ [r.dst_qname for r in rows if r is not None]
|
|
654
|
+
+ list(sibling_qnames)
|
|
655
|
+
)
|
|
568
656
|
for qname in candidates:
|
|
569
657
|
for san in registry.match_sanitizers(qname):
|
|
570
658
|
if san.category == sink_category:
|
|
571
659
|
matched.append(san)
|
|
572
660
|
if not matched:
|
|
573
661
|
return [], None
|
|
574
|
-
|
|
575
|
-
return [s.id for s in matched], effect
|
|
662
|
+
return [s.id for s in matched], "reduces"
|
|
@@ -261,9 +261,12 @@ def _path_tree(index: int, path) -> Tree:
|
|
|
261
261
|
|
|
262
262
|
|
|
263
263
|
def cmd_paths(args) -> int:
|
|
264
|
+
if not args.source and not args.source_category:
|
|
265
|
+
raise EntrygraphError("provide --source and/or --source-category")
|
|
264
266
|
with _open(args) as graph:
|
|
265
267
|
paths = graph.paths(
|
|
266
268
|
source=args.source,
|
|
269
|
+
source_category=args.source_category,
|
|
267
270
|
sink=args.sink,
|
|
268
271
|
sink_category=args.sink_category,
|
|
269
272
|
max_depth=args.max_depth,
|
|
@@ -297,8 +300,11 @@ def cmd_paths(args) -> int:
|
|
|
297
300
|
Panel("[yellow]No source → sink paths found.[/]", border_style="yellow", expand=False)
|
|
298
301
|
)
|
|
299
302
|
return 1
|
|
303
|
+
origin = (
|
|
304
|
+
args.source or (args.source_category and f"category:{args.source_category}") or "source"
|
|
305
|
+
)
|
|
300
306
|
target = args.sink or (args.sink_category and f"category:{args.sink_category}") or "sink"
|
|
301
|
-
con.print(f"[bold]{len(paths)}[/] path(s) [dim]{
|
|
307
|
+
con.print(f"[bold]{len(paths)}[/] path(s) [dim]{origin} → {target}[/]\n")
|
|
302
308
|
for i, path in enumerate(paths, 1):
|
|
303
309
|
con.print(_path_tree(i, path))
|
|
304
310
|
return 0
|
|
@@ -374,7 +380,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
374
380
|
|
|
375
381
|
p = sub.add_parser("paths", help="source -> sink call paths")
|
|
376
382
|
add_db(p)
|
|
377
|
-
p.add_argument("--source",
|
|
383
|
+
p.add_argument("--source", help="qname or glob")
|
|
384
|
+
p.add_argument(
|
|
385
|
+
"--source-category",
|
|
386
|
+
dest="source_category",
|
|
387
|
+
help="named taint-source category (e.g. http_input, env)",
|
|
388
|
+
)
|
|
378
389
|
p.add_argument("--sink", help="qname or glob (e.g. py:subprocess.run)")
|
|
379
390
|
p.add_argument(
|
|
380
391
|
"--sink-category", dest="sink_category", help="named sink category (e.g. command_exec, sql)"
|
|
@@ -404,7 +415,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
404
415
|
"--prune-sanitized",
|
|
405
416
|
dest="prune_sanitized",
|
|
406
417
|
action="store_true",
|
|
407
|
-
help="drop paths
|
|
418
|
+
help="drop paths where a category sanitizer is called (heuristic, no dataflow)",
|
|
408
419
|
)
|
|
409
420
|
p.set_defaults(func=cmd_paths)
|
|
410
421
|
|
|
@@ -127,6 +127,9 @@ class Edge(Base):
|
|
|
127
127
|
confidence: Mapped[int] = mapped_column(Integer, default=0) # kinds.Confidence value
|
|
128
128
|
arg_preview: Mapped[str | None] = mapped_column(Text)
|
|
129
129
|
sink_id: Mapped[str | None] = mapped_column(String(64)) # pre-tagged at index time
|
|
130
|
+
# A call to a taint-source function (request/env/stdin/...) tags its calling
|
|
131
|
+
# edge; the src symbol is then a taint origin. Pre-tagged at index time.
|
|
132
|
+
source_id: Mapped[str | None] = mapped_column(String(64))
|
|
130
133
|
# Edge provenance for edges not resolved by the direct import/scope pass:
|
|
131
134
|
# "cha" (class-hierarchy candidate), "dynamic" (getattr/computed call),
|
|
132
135
|
# "reexport" (chased through a barrel file). NULL for directly-resolved edges.
|
|
@@ -138,6 +141,7 @@ class Edge(Base):
|
|
|
138
141
|
Index("ix_edges_srcfile", "src_file_id"),
|
|
139
142
|
Index("ix_edges_unresolved", "dst_qname", sqlite_where=text("dst_symbol_id IS NULL")),
|
|
140
143
|
Index("ix_edges_sink", "sink_id", sqlite_where=text("sink_id IS NOT NULL")),
|
|
144
|
+
Index("ix_edges_source", "source_id", sqlite_where=text("source_id IS NOT NULL")),
|
|
141
145
|
Index("ix_edges_via", "via", sqlite_where=text("via IS NOT NULL")),
|
|
142
146
|
)
|
|
143
147
|
|
|
@@ -85,6 +85,7 @@ class SinkRegistry:
|
|
|
85
85
|
self.sources = {s.id: s for s in sources}
|
|
86
86
|
self.sanitizers = {s.id: s for s in (sanitizers or [])}
|
|
87
87
|
self._compiled = [(_compile(s.callee), s) for s in sinks]
|
|
88
|
+
self._compiled_sources = [(_compile(s.callee), s) for s in sources]
|
|
88
89
|
self._compiled_sanitizers = [(_compile(s.callee), s) for s in (sanitizers or [])]
|
|
89
90
|
|
|
90
91
|
def match(self, canonical_callee: str, arg_preview: str | None = None) -> str | None:
|
|
@@ -98,6 +99,17 @@ class SinkRegistry:
|
|
|
98
99
|
return sink.id
|
|
99
100
|
return None
|
|
100
101
|
|
|
102
|
+
def match_source(self, canonical_callee: str) -> str | None:
|
|
103
|
+
"""Return the first matching taint-source id for this callee, or None.
|
|
104
|
+
|
|
105
|
+
A call to a source function (e.g. ``flask.request.args.get``, ``os.getenv``)
|
|
106
|
+
marks its calling site as a taint origin. Stamped on edges at index time,
|
|
107
|
+
symmetric to :meth:`match`."""
|
|
108
|
+
for regex, source in self._compiled_sources:
|
|
109
|
+
if regex.match(canonical_callee):
|
|
110
|
+
return source.id
|
|
111
|
+
return None
|
|
112
|
+
|
|
101
113
|
def match_sanitizers(self, canonical_callee: str) -> list[SanitizerPattern]:
|
|
102
114
|
"""Sanitizers whose pattern matches this callee qname."""
|
|
103
115
|
return [s for regex, s in self._compiled_sanitizers if regex.match(canonical_callee)]
|
|
@@ -108,6 +120,9 @@ class SinkRegistry:
|
|
|
108
120
|
def ids_for_category(self, category: str) -> set[str]:
|
|
109
121
|
return {s.id for s in self.sinks.values() if s.category == category}
|
|
110
122
|
|
|
123
|
+
def source_ids_for_category(self, category: str) -> set[str]:
|
|
124
|
+
return {s.id for s in self.sources.values() if s.category == category}
|
|
125
|
+
|
|
111
126
|
def severity_of(self, sink_id: str | None) -> str | None:
|
|
112
127
|
sink = self.sinks.get(sink_id) if sink_id else None
|
|
113
128
|
return sink.severity if sink else None
|
|
@@ -435,11 +435,9 @@ def _write_edges_and_entrypoints(
|
|
|
435
435
|
resolver = FileResolver(x, module_ids[path], table, externals, is_package)
|
|
436
436
|
file_id = file_id_by_path[path]
|
|
437
437
|
for edge in resolver.resolve():
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
else None
|
|
442
|
-
)
|
|
438
|
+
is_call = edge.kind is EdgeKind.CALLS
|
|
439
|
+
sink_id = sink_registry.match(edge.dst_qname, edge.arg_preview) if is_call else None
|
|
440
|
+
source_id = sink_registry.match_source(edge.dst_qname) if is_call else None
|
|
443
441
|
edge_rows.append(
|
|
444
442
|
{
|
|
445
443
|
"id": alloc.take(Edge),
|
|
@@ -452,6 +450,7 @@ def _write_edges_and_entrypoints(
|
|
|
452
450
|
"confidence": int(edge.confidence),
|
|
453
451
|
"arg_preview": edge.arg_preview,
|
|
454
452
|
"sink_id": sink_id,
|
|
453
|
+
"source_id": source_id,
|
|
455
454
|
"via": edge.via,
|
|
456
455
|
}
|
|
457
456
|
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def sanitized(cmd):
|
|
6
|
+
# shlex.quote is a registered command_exec sanitizer, called on the same
|
|
7
|
+
# function as the sink (a sibling call, not a node on the source->sink path).
|
|
8
|
+
subprocess.run(shlex.quote(cmd))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def unsanitized(cmd):
|
|
12
|
+
subprocess.run(cmd)
|
|
@@ -4,6 +4,7 @@ from entrygraph.detect.taint import (
|
|
|
4
4
|
SanitizerPattern,
|
|
5
5
|
SinkPattern,
|
|
6
6
|
SinkRegistry,
|
|
7
|
+
SourcePattern,
|
|
7
8
|
_load_toml,
|
|
8
9
|
builtin_registry,
|
|
9
10
|
)
|
|
@@ -56,6 +57,25 @@ def test_sanitizer_matching_and_category_lookup():
|
|
|
56
57
|
assert reg.sanitizers_for_category("command_exec")[0].id == "san"
|
|
57
58
|
|
|
58
59
|
|
|
60
|
+
def test_match_source_and_source_category():
|
|
61
|
+
reg = SinkRegistry(
|
|
62
|
+
sinks=[],
|
|
63
|
+
sources=[
|
|
64
|
+
SourcePattern(id="env", category="env_input", callee="py:{os.getenv,os.environ.get}"),
|
|
65
|
+
SourcePattern(id="http", category="http_input", callee="py:flask.request*"),
|
|
66
|
+
],
|
|
67
|
+
)
|
|
68
|
+
assert reg.match_source("py:os.getenv") == "env"
|
|
69
|
+
assert reg.match_source("py:flask.request.args.get") == "http"
|
|
70
|
+
assert reg.match_source("py:os.system") is None
|
|
71
|
+
assert reg.source_ids_for_category("env_input") == {"env"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_builtin_registry_matches_env_source():
|
|
75
|
+
reg = builtin_registry()
|
|
76
|
+
assert reg.match_source("py:os.getenv") == "py.env"
|
|
77
|
+
|
|
78
|
+
|
|
59
79
|
def test_builtin_registry_loads_library_summaries_and_sanitizers():
|
|
60
80
|
reg = builtin_registry()
|
|
61
81
|
# library summary: paramiko exec_command mapped to command_exec
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Sanitizer detection over sibling call edges (Phase 1.2).
|
|
2
|
+
|
|
3
|
+
Regression: builtin sanitizers (external qnames like `py:shlex.quote`) could
|
|
4
|
+
never fire — detection only looked at nodes on the source->sink path, but a
|
|
5
|
+
sanitizer is a *sibling* call of an on-path function. And a heuristic match must
|
|
6
|
+
only discount risk, never drive it to zero (which would hide a real path).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from entrygraph import CodeGraph
|
|
16
|
+
|
|
17
|
+
SANITIZER_APP = Path(__file__).parent / "fixtures" / "python" / "sanitizer"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture(scope="module")
|
|
21
|
+
def graph(tmp_path_factory) -> CodeGraph:
|
|
22
|
+
db = tmp_path_factory.mktemp("db") / "graph.db"
|
|
23
|
+
g = CodeGraph.index(SANITIZER_APP, db=db)
|
|
24
|
+
yield g
|
|
25
|
+
g.close()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _path(graph, source):
|
|
29
|
+
paths = graph.paths(source=source, sink_category="command_exec")
|
|
30
|
+
assert paths, f"expected a command_exec path from {source}"
|
|
31
|
+
return paths[0]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_sibling_sanitizer_is_detected(graph):
|
|
35
|
+
# shlex.quote is a sibling call of app.sanitized, not a node on the path
|
|
36
|
+
path = _path(graph, "app.sanitized")
|
|
37
|
+
assert any("py.sanitize.shlex" in e.sanitized_by for e in path.edges)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_unsanitized_path_has_no_sanitizer(graph):
|
|
41
|
+
path = _path(graph, "app.unsanitized")
|
|
42
|
+
assert all(not e.sanitized_by for e in path.edges)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_sanitizer_discounts_but_does_not_zero_risk(graph):
|
|
46
|
+
# the sanitized path must still carry meaningful (nonzero) risk, and be
|
|
47
|
+
# strictly lower-risk than the identical unsanitized path
|
|
48
|
+
sanitized = _path(graph, "app.sanitized")
|
|
49
|
+
unsanitized = _path(graph, "app.unsanitized")
|
|
50
|
+
assert 0.0 < sanitized.risk_score < unsanitized.risk_score
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_prune_sanitized_drops_only_sanitized_paths(graph):
|
|
54
|
+
kept = graph.paths(source="app.*", sink_category="command_exec", prune_sanitized=True)
|
|
55
|
+
sources = {p.symbols[0].qname for p in kept}
|
|
56
|
+
assert "app.unsanitized" in sources
|
|
57
|
+
assert "app.sanitized" not in sources
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""End-to-end taint-source catalog wiring (Phase 1.1).
|
|
2
|
+
|
|
3
|
+
Regression: `[[source]]` catalog entries used to be dead configuration — parsed
|
|
4
|
+
but never matched or stamped, so `paths(source_category=...)` was impossible.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from entrygraph import CodeGraph
|
|
14
|
+
|
|
15
|
+
TAINT_SOURCE_APP = Path(__file__).parent / "fixtures" / "python" / "taint_source"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(scope="module")
|
|
19
|
+
def graph(tmp_path_factory) -> CodeGraph:
|
|
20
|
+
db = tmp_path_factory.mktemp("db") / "graph.db"
|
|
21
|
+
g = CodeGraph.index(TAINT_SOURCE_APP, db=db)
|
|
22
|
+
yield g
|
|
23
|
+
g.close()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_source_edges_stamped(graph):
|
|
27
|
+
# os.getenv call in handler() is stamped as a taint source
|
|
28
|
+
assert graph.stats().source_edges >= 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_paths_from_source_category_to_sink_category(graph):
|
|
32
|
+
# env_input source (os.getenv) reaches a command_exec sink (os.system)
|
|
33
|
+
paths = graph.paths(source_category="env_input", sink_category="command_exec")
|
|
34
|
+
assert paths
|
|
35
|
+
qnames = [s.qname for s in paths[0].symbols]
|
|
36
|
+
assert qnames[0] == "handler.handler"
|
|
37
|
+
assert qnames[-1] == "py:os.system"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_reachable_from_source_category(graph):
|
|
41
|
+
assert graph.reachable(source_category="env_input", sink_category="command_exec")
|
|
42
|
+
# a category with no matching source in this repo yields no reach
|
|
43
|
+
assert not graph.reachable(source_category="http_input", sink_category="command_exec")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_catalog_source_marks_origin_tainted(graph):
|
|
47
|
+
# the source-tainted risk factor should apply (handler calls os.getenv), so
|
|
48
|
+
# the path risk exceeds the untainted-source baseline discount
|
|
49
|
+
path = graph.paths(source_category="env_input", sink_category="command_exec")[0]
|
|
50
|
+
assert path.risk_score and path.risk_score > 0.5
|
|
File without changes
|