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.
Files changed (170) hide show
  1. {entrygraph-0.1.5 → entrygraph-0.1.7}/PKG-INFO +15 -7
  2. {entrygraph-0.1.5 → entrygraph-0.1.7}/README.md +14 -6
  3. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/_version.py +2 -2
  4. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/api.py +107 -20
  5. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/cli/main.py +14 -3
  6. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/db/meta.py +1 -1
  7. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/db/models.py +4 -0
  8. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/taint.py +15 -0
  9. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/pipeline/scanner.py +4 -5
  10. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/results.py +1 -0
  11. entrygraph-0.1.7/tests/fixtures/python/sanitizer/app.py +12 -0
  12. entrygraph-0.1.7/tests/fixtures/python/taint_source/handler.py +12 -0
  13. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_taint.py +20 -0
  14. entrygraph-0.1.7/tests/test_taint_sanitizers.py +57 -0
  15. entrygraph-0.1.7/tests/test_taint_sources.py +50 -0
  16. {entrygraph-0.1.5 → entrygraph-0.1.7}/.github/workflows/ci.yml +0 -0
  17. {entrygraph-0.1.5 → entrygraph-0.1.7}/.github/workflows/release.yml +0 -0
  18. {entrygraph-0.1.5 → entrygraph-0.1.7}/.gitignore +0 -0
  19. {entrygraph-0.1.5 → entrygraph-0.1.7}/LICENSE +0 -0
  20. {entrygraph-0.1.5 → entrygraph-0.1.7}/RELEASING.md +0 -0
  21. {entrygraph-0.1.5 → entrygraph-0.1.7}/pyproject.toml +0 -0
  22. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/__init__.py +0 -0
  23. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/__main__.py +0 -0
  24. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/cli/__init__.py +0 -0
  25. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/cli/render.py +0 -0
  26. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/csharp.toml +0 -0
  27. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/go.toml +0 -0
  28. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/java.toml +0 -0
  29. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/javascript.toml +0 -0
  30. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/lib_javascript.toml +0 -0
  31. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/lib_python.toml +0 -0
  32. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/php.toml +0 -0
  33. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/python.toml +0 -0
  34. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/ruby.toml +0 -0
  35. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/data/sinks/rust.toml +0 -0
  36. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/db/__init__.py +0 -0
  37. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/db/engine.py +0 -0
  38. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/db/queries.py +0 -0
  39. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/__init__.py +0 -0
  40. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/__init__.py +0 -0
  41. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/base.py +0 -0
  42. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/configs.py +0 -0
  43. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/csharp.py +0 -0
  44. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/golang.py +0 -0
  45. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/java.py +0 -0
  46. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/javascript.py +0 -0
  47. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/php.py +0 -0
  48. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/python.py +0 -0
  49. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/ruby.py +0 -0
  50. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/entrypoints/rust.py +0 -0
  51. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/frameworks.py +0 -0
  52. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/detect/manifests.py +0 -0
  53. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/errors.py +0 -0
  54. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/__init__.py +0 -0
  55. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/base.py +0 -0
  56. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/csharp.py +0 -0
  57. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/golang.py +0 -0
  58. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/ir.py +0 -0
  59. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/java.py +0 -0
  60. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/javascript.py +0 -0
  61. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/php.py +0 -0
  62. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/python.py +0 -0
  63. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/registry.py +0 -0
  64. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/ruby.py +0 -0
  65. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/extract/rust.py +0 -0
  66. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/fs/__init__.py +0 -0
  67. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/fs/hashing.py +0 -0
  68. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/fs/lang.py +0 -0
  69. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/fs/walker.py +0 -0
  70. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/graph/__init__.py +0 -0
  71. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/graph/adjacency.py +0 -0
  72. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/graph/cte.py +0 -0
  73. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/graph/scoring.py +0 -0
  74. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/kinds.py +0 -0
  75. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/parsing/__init__.py +0 -0
  76. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/parsing/parsers.py +0 -0
  77. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/parsing/queries.py +0 -0
  78. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/pipeline/__init__.py +0 -0
  79. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/pipeline/worker.py +0 -0
  80. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/pipeline/writer.py +0 -0
  81. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/py.typed +0 -0
  82. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/csharp/calls.scm +0 -0
  83. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/csharp/definitions.scm +0 -0
  84. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/csharp/imports.scm +0 -0
  85. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/go/calls.scm +0 -0
  86. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/go/definitions.scm +0 -0
  87. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/go/imports.scm +0 -0
  88. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/java/calls.scm +0 -0
  89. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/java/definitions.scm +0 -0
  90. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/java/imports.scm +0 -0
  91. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/javascript/calls.scm +0 -0
  92. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/javascript/definitions.scm +0 -0
  93. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/javascript/imports.scm +0 -0
  94. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/php/calls.scm +0 -0
  95. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/php/definitions.scm +0 -0
  96. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/php/imports.scm +0 -0
  97. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/python/calls.scm +0 -0
  98. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/python/definitions.scm +0 -0
  99. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/python/imports.scm +0 -0
  100. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/ruby/calls.scm +0 -0
  101. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/ruby/definitions.scm +0 -0
  102. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/ruby/imports.scm +0 -0
  103. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/rust/calls.scm +0 -0
  104. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/rust/definitions.scm +0 -0
  105. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/queries/rust/imports.scm +0 -0
  106. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/resolve/__init__.py +0 -0
  107. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/resolve/externals.py +0 -0
  108. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/resolve/hierarchy.py +0 -0
  109. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/resolve/resolver.py +0 -0
  110. {entrygraph-0.1.5 → entrygraph-0.1.7}/src/entrygraph/resolve/symbol_table.py +0 -0
  111. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/conftest.py +0 -0
  112. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/csharp/aspnet_app/Controllers/ReportsController.cs +0 -0
  113. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/csharp/aspnet_app/Program.cs +0 -0
  114. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/csharp/aspnet_app/Services/ReportService.cs +0 -0
  115. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/csharp/aspnet_app/app.csproj +0 -0
  116. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/go/gin_app/go.mod +0 -0
  117. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/go/gin_app/main.go +0 -0
  118. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/go/gin_app/service.go +0 -0
  119. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/java/spring_app/pom.xml +0 -0
  120. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/java/spring_app/src/main/java/com/example/Application.java +0 -0
  121. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/java/spring_app/src/main/java/com/example/ReportRunner.java +0 -0
  122. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/java/spring_app/src/main/java/com/example/ReportService.java +0 -0
  123. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/java/spring_app/src/main/java/com/example/UserController.java +0 -0
  124. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/javascript/express_app/package.json +0 -0
  125. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/javascript/express_app/src/routes.js +0 -0
  126. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/javascript/express_app/src/services.js +0 -0
  127. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/php/laravel_app/app/Http/Controllers/ReportController.php +0 -0
  128. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/php/laravel_app/artisan +0 -0
  129. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/php/laravel_app/composer.json +0 -0
  130. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/php/laravel_app/routes/web.php +0 -0
  131. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/python/flask_app/app/__init__.py +0 -0
  132. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/python/flask_app/app/db.py +0 -0
  133. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/python/flask_app/app/routes.py +0 -0
  134. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/python/flask_app/app/services.py +0 -0
  135. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/python/flask_app/cli.py +0 -0
  136. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/python/flask_app/requirements.txt +0 -0
  137. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/ruby/sinatra_app/Gemfile +0 -0
  138. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/ruby/sinatra_app/app.rb +0 -0
  139. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/ruby/sinatra_app/services/runner.rb +0 -0
  140. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/rust/axum_app/Cargo.toml +0 -0
  141. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/rust/axum_app/src/handlers.rs +0 -0
  142. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/fixtures/rust/axum_app/src/main.rs +0 -0
  143. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_api.py +0 -0
  144. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_cli.py +0 -0
  145. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_engine_pragmas.py +0 -0
  146. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_entrypoint_expansion.py +0 -0
  147. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_entrypoints.py +0 -0
  148. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_extract_csharp.py +0 -0
  149. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_extract_go.py +0 -0
  150. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_extract_java.py +0 -0
  151. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_extract_javascript.py +0 -0
  152. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_extract_php.py +0 -0
  153. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_extract_python.py +0 -0
  154. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_extract_ruby.py +0 -0
  155. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_extract_rust.py +0 -0
  156. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_frameworks.py +0 -0
  157. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_hardening.py +0 -0
  158. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_incremental.py +0 -0
  159. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_indexer.py +0 -0
  160. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_lang.py +0 -0
  161. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_manifests.py +0 -0
  162. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_models.py +0 -0
  163. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_pool.py +0 -0
  164. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_reachability.py +0 -0
  165. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_render.py +0 -0
  166. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_resolver.py +0 -0
  167. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_scoring.py +0 -0
  168. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_sink_catalog.py +0 -0
  169. {entrygraph-0.1.5 → entrygraph-0.1.7}/tests/test_walker.py +0 -0
  170. {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.5
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); drop
203
- neutralized paths with `--prune-sanitized`.
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 neutralized by a shlex.quote etc.
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]]` with `effect = "neutralizes"` prunes a path for its category;
336
- `effect = "reduces"` only discounts the risk score. Third-party wrapper
337
- libraries that reach a sink internally are covered by `data/sinks/lib_*.toml`
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); drop
152
- neutralized paths with `--prune-sanitized`.
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 neutralized by a shlex.quote etc.
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]]` with `effect = "neutralizes"` prunes a path for its category;
285
- `effect = "reduces"` only discounts the risk score. Third-party wrapper
286
- libraries that reach a sink internally are covered by `data/sinks/lib_*.toml`
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.5'
22
- __version_tuple__ = version_tuple = (0, 1, 5)
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 _fully_sanitized(path: CallPath) -> bool:
70
- """True if the terminal hop was neutralized by a sanitizer (risk driven to 0)."""
71
- return path.risk_score == 0.0
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._spec_to_ids(session, source)
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, symbol_map, edge_map, registry, tainted_sources, excluded_nodes
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 _fully_sanitized(cp))]
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._spec_to_ids(session, source)
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 that are entrypoints with known user-controlled params."""
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, path, symbol_map, edge_map, registry, tainted_sources, excluded_nodes
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: any on-path symbol OR sibling call matching a
526
- # sanitizer for the sink's category
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, strongest effect) for the sink category."""
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 = [s.qname for s in symbols] + [r.dst_qname for r in rows if r is not None]
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
- effect = "neutralizes" if any(s.effect == "neutralizes" for s in matched) else "reduces"
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]{args.source} → {target}[/]\n")
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", required=True, help="qname or glob")
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 neutralized by a registered sanitizer",
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
 
@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session
15
15
  from entrygraph.db.models import Base, Meta
16
16
  from entrygraph.errors import SchemaMismatchError
17
17
 
18
- SCHEMA_VERSION = 2
18
+ SCHEMA_VERSION = 3
19
19
 
20
20
 
21
21
  def create_schema(engine: Engine) -> None:
@@ -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
- sink_id = (
439
- sink_registry.match(edge.dst_qname, edge.arg_preview)
440
- if edge.kind is EdgeKind.CALLS
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
  )
@@ -136,3 +136,4 @@ class GraphStats:
136
136
  resolved_edges: int
137
137
  entrypoints: int
138
138
  sink_edges: int
139
+ source_edges: int
@@ -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)
@@ -0,0 +1,12 @@
1
+ import os
2
+
3
+
4
+ def handler():
5
+ # os.getenv is a registered taint source (category "env_input").
6
+ cmd = os.getenv("CMD")
7
+ run(cmd)
8
+
9
+
10
+ def run(cmd):
11
+ # os.system is a registered command_exec sink.
12
+ os.system(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