entrygraph 0.1.6__tar.gz → 0.1.8__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 (172) hide show
  1. {entrygraph-0.1.6 → entrygraph-0.1.8}/PKG-INFO +12 -7
  2. {entrygraph-0.1.6 → entrygraph-0.1.8}/README.md +11 -6
  3. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/_version.py +2 -2
  4. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/api.py +67 -14
  5. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/cli/main.py +1 -1
  6. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/extract/javascript.py +65 -0
  7. entrygraph-0.1.8/tests/fixtures/javascript/commonjs_app/server.js +15 -0
  8. entrygraph-0.1.8/tests/fixtures/python/sanitizer/app.py +12 -0
  9. entrygraph-0.1.8/tests/test_commonjs.py +41 -0
  10. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_extract_javascript.py +26 -0
  11. entrygraph-0.1.8/tests/test_taint_sanitizers.py +57 -0
  12. {entrygraph-0.1.6 → entrygraph-0.1.8}/.github/workflows/ci.yml +0 -0
  13. {entrygraph-0.1.6 → entrygraph-0.1.8}/.github/workflows/release.yml +0 -0
  14. {entrygraph-0.1.6 → entrygraph-0.1.8}/.gitignore +0 -0
  15. {entrygraph-0.1.6 → entrygraph-0.1.8}/LICENSE +0 -0
  16. {entrygraph-0.1.6 → entrygraph-0.1.8}/RELEASING.md +0 -0
  17. {entrygraph-0.1.6 → entrygraph-0.1.8}/pyproject.toml +0 -0
  18. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/__init__.py +0 -0
  19. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/__main__.py +0 -0
  20. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/cli/__init__.py +0 -0
  21. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/cli/render.py +0 -0
  22. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/data/sinks/csharp.toml +0 -0
  23. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/data/sinks/go.toml +0 -0
  24. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/data/sinks/java.toml +0 -0
  25. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/data/sinks/javascript.toml +0 -0
  26. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/data/sinks/lib_javascript.toml +0 -0
  27. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/data/sinks/lib_python.toml +0 -0
  28. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/data/sinks/php.toml +0 -0
  29. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/data/sinks/python.toml +0 -0
  30. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/data/sinks/ruby.toml +0 -0
  31. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/data/sinks/rust.toml +0 -0
  32. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/db/__init__.py +0 -0
  33. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/db/engine.py +0 -0
  34. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/db/meta.py +0 -0
  35. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/db/models.py +0 -0
  36. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/db/queries.py +0 -0
  37. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/detect/__init__.py +0 -0
  38. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/detect/entrypoints/__init__.py +0 -0
  39. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/detect/entrypoints/base.py +0 -0
  40. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/detect/entrypoints/configs.py +0 -0
  41. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/detect/entrypoints/csharp.py +0 -0
  42. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/detect/entrypoints/golang.py +0 -0
  43. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/detect/entrypoints/java.py +0 -0
  44. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/detect/entrypoints/javascript.py +0 -0
  45. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/detect/entrypoints/php.py +0 -0
  46. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/detect/entrypoints/python.py +0 -0
  47. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/detect/entrypoints/ruby.py +0 -0
  48. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/detect/entrypoints/rust.py +0 -0
  49. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/detect/frameworks.py +0 -0
  50. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/detect/manifests.py +0 -0
  51. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/detect/taint.py +0 -0
  52. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/errors.py +0 -0
  53. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/extract/__init__.py +0 -0
  54. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/extract/base.py +0 -0
  55. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/extract/csharp.py +0 -0
  56. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/extract/golang.py +0 -0
  57. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/extract/ir.py +0 -0
  58. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/extract/java.py +0 -0
  59. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/extract/php.py +0 -0
  60. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/extract/python.py +0 -0
  61. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/extract/registry.py +0 -0
  62. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/extract/ruby.py +0 -0
  63. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/extract/rust.py +0 -0
  64. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/fs/__init__.py +0 -0
  65. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/fs/hashing.py +0 -0
  66. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/fs/lang.py +0 -0
  67. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/fs/walker.py +0 -0
  68. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/graph/__init__.py +0 -0
  69. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/graph/adjacency.py +0 -0
  70. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/graph/cte.py +0 -0
  71. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/graph/scoring.py +0 -0
  72. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/kinds.py +0 -0
  73. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/parsing/__init__.py +0 -0
  74. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/parsing/parsers.py +0 -0
  75. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/parsing/queries.py +0 -0
  76. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/pipeline/__init__.py +0 -0
  77. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/pipeline/scanner.py +0 -0
  78. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/pipeline/worker.py +0 -0
  79. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/pipeline/writer.py +0 -0
  80. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/py.typed +0 -0
  81. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/csharp/calls.scm +0 -0
  82. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/csharp/definitions.scm +0 -0
  83. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/csharp/imports.scm +0 -0
  84. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/go/calls.scm +0 -0
  85. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/go/definitions.scm +0 -0
  86. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/go/imports.scm +0 -0
  87. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/java/calls.scm +0 -0
  88. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/java/definitions.scm +0 -0
  89. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/java/imports.scm +0 -0
  90. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/javascript/calls.scm +0 -0
  91. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/javascript/definitions.scm +0 -0
  92. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/javascript/imports.scm +0 -0
  93. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/php/calls.scm +0 -0
  94. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/php/definitions.scm +0 -0
  95. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/php/imports.scm +0 -0
  96. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/python/calls.scm +0 -0
  97. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/python/definitions.scm +0 -0
  98. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/python/imports.scm +0 -0
  99. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/ruby/calls.scm +0 -0
  100. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/ruby/definitions.scm +0 -0
  101. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/ruby/imports.scm +0 -0
  102. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/rust/calls.scm +0 -0
  103. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/rust/definitions.scm +0 -0
  104. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/queries/rust/imports.scm +0 -0
  105. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/resolve/__init__.py +0 -0
  106. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/resolve/externals.py +0 -0
  107. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/resolve/hierarchy.py +0 -0
  108. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/resolve/resolver.py +0 -0
  109. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/resolve/symbol_table.py +0 -0
  110. {entrygraph-0.1.6 → entrygraph-0.1.8}/src/entrygraph/results.py +0 -0
  111. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/conftest.py +0 -0
  112. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/csharp/aspnet_app/Controllers/ReportsController.cs +0 -0
  113. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/csharp/aspnet_app/Program.cs +0 -0
  114. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/csharp/aspnet_app/Services/ReportService.cs +0 -0
  115. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/csharp/aspnet_app/app.csproj +0 -0
  116. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/go/gin_app/go.mod +0 -0
  117. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/go/gin_app/main.go +0 -0
  118. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/go/gin_app/service.go +0 -0
  119. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/java/spring_app/pom.xml +0 -0
  120. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/java/spring_app/src/main/java/com/example/Application.java +0 -0
  121. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/java/spring_app/src/main/java/com/example/ReportRunner.java +0 -0
  122. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/java/spring_app/src/main/java/com/example/ReportService.java +0 -0
  123. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/java/spring_app/src/main/java/com/example/UserController.java +0 -0
  124. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/javascript/express_app/package.json +0 -0
  125. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/javascript/express_app/src/routes.js +0 -0
  126. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/javascript/express_app/src/services.js +0 -0
  127. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/php/laravel_app/app/Http/Controllers/ReportController.php +0 -0
  128. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/php/laravel_app/artisan +0 -0
  129. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/php/laravel_app/composer.json +0 -0
  130. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/php/laravel_app/routes/web.php +0 -0
  131. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/python/flask_app/app/__init__.py +0 -0
  132. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/python/flask_app/app/db.py +0 -0
  133. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/python/flask_app/app/routes.py +0 -0
  134. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/python/flask_app/app/services.py +0 -0
  135. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/python/flask_app/cli.py +0 -0
  136. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/python/flask_app/requirements.txt +0 -0
  137. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/python/taint_source/handler.py +0 -0
  138. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/ruby/sinatra_app/Gemfile +0 -0
  139. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/ruby/sinatra_app/app.rb +0 -0
  140. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/ruby/sinatra_app/services/runner.rb +0 -0
  141. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/rust/axum_app/Cargo.toml +0 -0
  142. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/rust/axum_app/src/handlers.rs +0 -0
  143. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/fixtures/rust/axum_app/src/main.rs +0 -0
  144. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_api.py +0 -0
  145. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_cli.py +0 -0
  146. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_engine_pragmas.py +0 -0
  147. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_entrypoint_expansion.py +0 -0
  148. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_entrypoints.py +0 -0
  149. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_extract_csharp.py +0 -0
  150. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_extract_go.py +0 -0
  151. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_extract_java.py +0 -0
  152. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_extract_php.py +0 -0
  153. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_extract_python.py +0 -0
  154. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_extract_ruby.py +0 -0
  155. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_extract_rust.py +0 -0
  156. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_frameworks.py +0 -0
  157. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_hardening.py +0 -0
  158. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_incremental.py +0 -0
  159. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_indexer.py +0 -0
  160. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_lang.py +0 -0
  161. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_manifests.py +0 -0
  162. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_models.py +0 -0
  163. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_pool.py +0 -0
  164. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_reachability.py +0 -0
  165. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_render.py +0 -0
  166. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_resolver.py +0 -0
  167. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_scoring.py +0 -0
  168. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_sink_catalog.py +0 -0
  169. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_taint.py +0 -0
  170. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_taint_sources.py +0 -0
  171. {entrygraph-0.1.6 → entrygraph-0.1.8}/tests/test_walker.py +0 -0
  172. {entrygraph-0.1.6 → entrygraph-0.1.8}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: entrygraph
3
- Version: 0.1.6
3
+ Version: 0.1.8
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>
@@ -202,8 +202,11 @@ entrygraph paths --source '*' --sink-category command_exec
202
202
  `--source-category http_input` / `env`) — `entrygraph paths --source-category http_input --sink-category sql`. Combine with `--source` to union both.
203
203
  - **Precision/recall dial**: by default only high-confidence edges are traversed.
204
204
  Widen with `--include-unresolved` (wildcard `py:*.execute` sinks + dynamic
205
- calls) or `--include-fuzzy` (speculative class-hierarchy edges); drop
206
- 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.
207
210
  - Target an exact sink with `--sink py:subprocess.run` instead of a category.
208
211
 
209
212
  ### `stats` & `--json`
@@ -294,7 +297,7 @@ graph.paths(source="app.routes.*", sink_category="sql",
294
297
  graph.paths(source="app.routes.*", sink_category="command_exec",
295
298
  include_fuzzy=True) # follow speculative class-hierarchy (CHA) edges
296
299
  graph.paths(source="app.routes.*", sink_category="command_exec",
297
- prune_sanitized=True) # drop paths neutralized by a shlex.quote etc.
300
+ prune_sanitized=True) # drop paths where a shlex.quote etc. is called
298
301
 
299
302
  # Incremental re-index (only changed/added/deleted files are reparsed)
300
303
  graph.refresh()
@@ -335,9 +338,11 @@ results are safe to hold and trivial to serialize.
335
338
  root with `[[sink]]` / `[[source]]` / `[[sanitizer]]` tables (same schema as
336
339
  the built-in `data/sinks/*.toml`), or call
337
340
  `entrygraph.detect.taint.register_sink(...)` / `register_sanitizer(...)`. A
338
- `[[sanitizer]]` with `effect = "neutralizes"` prunes a path for its category;
339
- `effect = "reduces"` only discounts the risk score. Third-party wrapper
340
- 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`
341
346
  "library summaries" (same schema, with a `library = "..."` tag).
342
347
  - **New frameworks / entrypoints** — register a `FrameworkSpec` and an
343
348
  `EntrypointRule`; adding a framework is usually a few lines.
@@ -151,8 +151,11 @@ entrygraph paths --source '*' --sink-category command_exec
151
151
  `--source-category http_input` / `env`) — `entrygraph paths --source-category http_input --sink-category sql`. Combine with `--source` to union both.
152
152
  - **Precision/recall dial**: by default only high-confidence edges are traversed.
153
153
  Widen with `--include-unresolved` (wildcard `py:*.execute` sinks + dynamic
154
- calls) or `--include-fuzzy` (speculative class-hierarchy edges); drop
155
- 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.
156
159
  - Target an exact sink with `--sink py:subprocess.run` instead of a category.
157
160
 
158
161
  ### `stats` & `--json`
@@ -243,7 +246,7 @@ graph.paths(source="app.routes.*", sink_category="sql",
243
246
  graph.paths(source="app.routes.*", sink_category="command_exec",
244
247
  include_fuzzy=True) # follow speculative class-hierarchy (CHA) edges
245
248
  graph.paths(source="app.routes.*", sink_category="command_exec",
246
- prune_sanitized=True) # drop paths neutralized by a shlex.quote etc.
249
+ prune_sanitized=True) # drop paths where a shlex.quote etc. is called
247
250
 
248
251
  # Incremental re-index (only changed/added/deleted files are reparsed)
249
252
  graph.refresh()
@@ -284,9 +287,11 @@ results are safe to hold and trivial to serialize.
284
287
  root with `[[sink]]` / `[[source]]` / `[[sanitizer]]` tables (same schema as
285
288
  the built-in `data/sinks/*.toml`), or call
286
289
  `entrygraph.detect.taint.register_sink(...)` / `register_sanitizer(...)`. A
287
- `[[sanitizer]]` with `effect = "neutralizes"` prunes a path for its category;
288
- `effect = "reduces"` only discounts the risk score. Third-party wrapper
289
- 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`
290
295
  "library summaries" (same schema, with a `library = "..."` tag).
291
296
  - **New frameworks / entrypoints** — register a `FrameworkSpec` and an
292
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.6'
22
- __version_tuple__ = version_tuple = (0, 1, 6)
21
+ __version__ = version = '0.1.8'
22
+ __version_tuple__ = version_tuple = (0, 1, 8)
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:
@@ -296,13 +300,20 @@ class CodeGraph:
296
300
  edge_map = self._edge_rows(session, raw_paths)
297
301
  tainted_sources = self._tainted_source_ids(session, sources)
298
302
  excluded_nodes = self._nodes_with_open_frontier(session, all_ids, kinds, floor)
303
+ sibling_calls = self._out_call_qnames(session, all_ids)
299
304
  built = [
300
305
  self._materialize_path(
301
- 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,
302
313
  )
303
314
  for path in raw_paths
304
315
  ]
305
- 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))]
306
317
  results.sort(
307
318
  key=lambda p: (-(p.risk_score or 0.0), len(p.symbols), [s.id for s in p.symbols])
308
319
  )
@@ -543,8 +554,38 @@ class CodeGraph:
543
554
  ).scalars()
544
555
  return set(rows)
545
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
+
546
580
  def _materialize_path(
547
- 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,
548
589
  ) -> CallPath:
549
590
  symbols = tuple(symbol_map[node] for node, _ in path)
550
591
  hops = [hop for _, hop in path[1:]]
@@ -556,10 +597,11 @@ class CodeGraph:
556
597
  sink_category = registry.sinks[sink_id].category if sink_id in registry.sinks else None
557
598
  terminal_const = is_constant_args(terminal.arg_preview) if terminal else False
558
599
 
559
- # sanitizer detection: any on-path symbol OR sibling call matching a
560
- # 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, ())}
561
603
  sanitized_ids, sanitized_effect = self._path_sanitizers(
562
- symbols, rows, registry, sink_category
604
+ symbols, rows, sibling_qnames, registry, sink_category
563
605
  )
564
606
 
565
607
  path_edges: list[PathEdge] = []
@@ -593,17 +635,28 @@ class CodeGraph:
593
635
  )
594
636
 
595
637
  @staticmethod
596
- def _path_sanitizers(symbols, rows, registry, sink_category):
597
- """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."""
598
648
  if not sink_category:
599
649
  return [], None
600
650
  matched: list = []
601
- 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
+ )
602
656
  for qname in candidates:
603
657
  for san in registry.match_sanitizers(qname):
604
658
  if san.category == sink_category:
605
659
  matched.append(san)
606
660
  if not matched:
607
661
  return [], None
608
- effect = "neutralizes" if any(s.effect == "neutralizes" for s in matched) else "reduces"
609
- return [s.id for s in matched], effect
662
+ return [s.id for s in matched], "reduces"
@@ -415,7 +415,7 @@ def build_parser() -> argparse.ArgumentParser:
415
415
  "--prune-sanitized",
416
416
  dest="prune_sanitized",
417
417
  action="store_true",
418
- help="drop paths neutralized by a registered sanitizer",
418
+ help="drop paths where a category sanitizer is called (heuristic, no dataflow)",
419
419
  )
420
420
  p.set_defaults(func=cmd_paths)
421
421
 
@@ -238,12 +238,77 @@ class JavaScriptExtractor:
238
238
  )
239
239
  for node in caps.get("export.from", []):
240
240
  self._reexport(node, ctx, out)
241
+ for node in caps.get("require", []):
242
+ self._commonjs_require(node, ctx, out)
241
243
 
242
244
  for imp in out.imports:
243
245
  out.framework_signals.append(
244
246
  ("import", imp.module.split(".")[0] if "." in imp.module else imp.module)
245
247
  )
246
248
 
249
+ def _commonjs_require(self, call, ctx, out) -> None:
250
+ """CommonJS `require('m')` bindings -> RawImport, mirroring ESM handling.
251
+
252
+ ``const cp = require('m')`` -> whole module bound to ``cp``
253
+ ``const { x, y: z } = require('m')`` -> named imports ``x`` and ``y`` (as ``z``)
254
+ bare / chained / reassigned -> module-only (side-effect) import,
255
+ which still yields the framework signal even when no alias can be bound.
256
+ """
257
+ args = call.child_by_field_name("arguments")
258
+ spec = None
259
+ if args is not None:
260
+ for child in args.named_children:
261
+ if child.type == "string":
262
+ spec = node_text(child).strip("'\"`")
263
+ break
264
+ if not spec:
265
+ return # dynamic require (variable/template arg) — target unknowable
266
+ module = self._resolve_module(spec, ctx)
267
+ span = span_of(call)
268
+
269
+ parent = call.parent
270
+ name_node = None
271
+ if parent is not None and parent.type == "variable_declarator":
272
+ name_node = parent.child_by_field_name("name")
273
+ elif parent is not None and parent.type == "assignment_expression":
274
+ name_node = parent.child_by_field_name("left")
275
+
276
+ if name_node is not None and name_node.type == "identifier":
277
+ # Bind the alias to the module itself (same as an ESM default import),
278
+ # so `const cp = require('child_process'); cp.exec()` canonicalizes to
279
+ # child_process.exec.
280
+ out.imports.append(
281
+ RawImport(module=module, imported_name=None, alias=node_text(name_node), span=span)
282
+ )
283
+ return
284
+ if name_node is not None and name_node.type == "object_pattern":
285
+ for child in name_node.named_children:
286
+ if child.type == "shorthand_property_identifier_pattern": # { exec }
287
+ nm = node_text(child)
288
+ out.imports.append(
289
+ RawImport(module=module, imported_name=nm, alias=nm, span=span)
290
+ )
291
+ elif child.type == "pair_pattern": # { exec: run }
292
+ key = child.child_by_field_name("key")
293
+ value = child.child_by_field_name("value")
294
+ if key is None:
295
+ continue
296
+ alias = (
297
+ node_text(value)
298
+ if value is not None and value.type == "identifier"
299
+ else node_text(key)
300
+ )
301
+ out.imports.append(
302
+ RawImport(
303
+ module=module, imported_name=node_text(key), alias=alias, span=span
304
+ )
305
+ )
306
+ return
307
+ # bare `require('m')`, `require('m').foo`, `module.exports = require('m')`, etc.
308
+ out.imports.append(
309
+ RawImport(module=module, imported_name=None, alias=module.split(".")[-1], span=span)
310
+ )
311
+
247
312
  def _reexport(self, node, ctx, out) -> None:
248
313
  """`export { X as Y } from "./m"` / `export * from "./m"` -> RawReexport."""
249
314
  source = node.child_by_field_name("source")
@@ -0,0 +1,15 @@
1
+ const cp = require('child_process');
2
+ const { exec } = require('child_process');
3
+ const express = require('express');
4
+
5
+ function runDefault(input) {
6
+ cp.execSync(input);
7
+ }
8
+
9
+ function runNamed(input) {
10
+ exec(input);
11
+ }
12
+
13
+ const app = express();
14
+ app.get('/a', (req, res) => runDefault(req.query.cmd));
15
+ app.get('/b', (req, res) => runNamed(req.query.cmd));
@@ -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,41 @@
1
+ """End-to-end CommonJS `require()` support (Phase 1.3).
2
+
3
+ Regression: the JS extractor dropped the `require()` captures entirely, so a
4
+ pure-CommonJS app produced no imports — no framework detection and no
5
+ canonicalization of `cp.execSync` to a `child_process` sink.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ from entrygraph import CodeGraph
15
+
16
+ COMMONJS_APP = Path(__file__).parent / "fixtures" / "javascript" / "commonjs_app"
17
+
18
+
19
+ @pytest.fixture(scope="module")
20
+ def graph(tmp_path_factory) -> CodeGraph:
21
+ db = tmp_path_factory.mktemp("db") / "graph.db"
22
+ g = CodeGraph.index(COMMONJS_APP, db=db)
23
+ yield g
24
+ g.close()
25
+
26
+
27
+ def test_commonjs_require_enables_framework_detection(graph):
28
+ names = {f.name for f in graph.detect().frameworks}
29
+ assert "express" in names
30
+
31
+
32
+ def test_commonjs_require_canonicalizes_command_sinks(graph):
33
+ # both the default-bound (cp.execSync) and destructured (exec) forms must
34
+ # resolve to child_process command_exec sinks
35
+ assert graph.stats().sink_edges >= 2
36
+ sinks = {
37
+ row["dst_qname"]
38
+ for row in graph.sql("SELECT dst_qname FROM edges WHERE sink_id IS NOT NULL")
39
+ }
40
+ assert "js:child_process.execSync" in sinks
41
+ assert "js:child_process.exec" in sinks
@@ -72,6 +72,32 @@ def test_relative_import_resolution():
72
72
  assert any(i.module == "db.conn" for i in x.imports)
73
73
 
74
74
 
75
+ def test_commonjs_require_bindings():
76
+ x = extract(
77
+ "const cp = require('child_process');\n"
78
+ "const { exec, spawn: launch } = require('child_process');\n"
79
+ "const express = require('express');\n"
80
+ "require('./side-effect');\n"
81
+ "const rel = require('../db/conn');\n",
82
+ path="src/routes/users.js",
83
+ )
84
+ imports = {(i.module, i.imported_name, i.alias) for i in x.imports}
85
+ assert ("child_process", None, "cp") in imports # default -> module binding
86
+ assert ("child_process", "exec", "exec") in imports # destructured
87
+ assert ("child_process", "spawn", "launch") in imports # destructured + rename
88
+ assert ("express", None, "express") in imports
89
+ assert ("db.conn", None, "rel") in imports # relative resolved to dotted, bound to `rel`
90
+ # side-effect require still registers the module (framework signal source)
91
+ assert any(m == "routes.side-effect" and n is None for m, n, _ in imports)
92
+
93
+
94
+ def test_commonjs_require_emits_framework_signal():
95
+ # regression: CommonJS `require('express')` used to yield no import at all,
96
+ # so express was undetectable in pure-CJS apps.
97
+ x = extract("const express = require('express');\n")
98
+ assert ("import", "express") in x.framework_signals
99
+
100
+
75
101
  def test_calls_with_receivers():
76
102
  x = extract(
77
103
  "import cp from 'child_process';\nfunction run() { cp.execSync('ls'); helper(); }\n"
@@ -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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes