entrygraph 0.1.4__tar.gz → 0.1.6__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 (168) hide show
  1. {entrygraph-0.1.4 → entrygraph-0.1.6}/PKG-INFO +4 -1
  2. {entrygraph-0.1.4 → entrygraph-0.1.6}/README.md +3 -0
  3. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/_version.py +2 -2
  4. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/api.py +40 -6
  5. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/cli/main.py +13 -2
  6. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/db/meta.py +1 -1
  7. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/db/models.py +4 -0
  8. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/detect/entrypoints/python.py +25 -14
  9. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/detect/frameworks.py +10 -0
  10. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/detect/manifests.py +3 -1
  11. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/detect/taint.py +15 -0
  12. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/graph/scoring.py +1 -1
  13. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/pipeline/scanner.py +7 -7
  14. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/resolve/resolver.py +4 -1
  15. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/results.py +1 -0
  16. entrygraph-0.1.6/tests/fixtures/python/taint_source/handler.py +12 -0
  17. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_entrypoint_expansion.py +11 -0
  18. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_frameworks.py +12 -0
  19. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_manifests.py +11 -0
  20. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_resolver.py +32 -0
  21. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_scoring.py +9 -0
  22. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_taint.py +20 -0
  23. entrygraph-0.1.6/tests/test_taint_sources.py +50 -0
  24. {entrygraph-0.1.4 → entrygraph-0.1.6}/.github/workflows/ci.yml +0 -0
  25. {entrygraph-0.1.4 → entrygraph-0.1.6}/.github/workflows/release.yml +0 -0
  26. {entrygraph-0.1.4 → entrygraph-0.1.6}/.gitignore +0 -0
  27. {entrygraph-0.1.4 → entrygraph-0.1.6}/LICENSE +0 -0
  28. {entrygraph-0.1.4 → entrygraph-0.1.6}/RELEASING.md +0 -0
  29. {entrygraph-0.1.4 → entrygraph-0.1.6}/pyproject.toml +0 -0
  30. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/__init__.py +0 -0
  31. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/__main__.py +0 -0
  32. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/cli/__init__.py +0 -0
  33. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/cli/render.py +0 -0
  34. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/data/sinks/csharp.toml +0 -0
  35. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/data/sinks/go.toml +0 -0
  36. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/data/sinks/java.toml +0 -0
  37. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/data/sinks/javascript.toml +0 -0
  38. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/data/sinks/lib_javascript.toml +0 -0
  39. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/data/sinks/lib_python.toml +0 -0
  40. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/data/sinks/php.toml +0 -0
  41. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/data/sinks/python.toml +0 -0
  42. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/data/sinks/ruby.toml +0 -0
  43. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/data/sinks/rust.toml +0 -0
  44. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/db/__init__.py +0 -0
  45. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/db/engine.py +0 -0
  46. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/db/queries.py +0 -0
  47. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/detect/__init__.py +0 -0
  48. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/detect/entrypoints/__init__.py +0 -0
  49. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/detect/entrypoints/base.py +0 -0
  50. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/detect/entrypoints/configs.py +0 -0
  51. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/detect/entrypoints/csharp.py +0 -0
  52. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/detect/entrypoints/golang.py +0 -0
  53. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/detect/entrypoints/java.py +0 -0
  54. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/detect/entrypoints/javascript.py +0 -0
  55. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/detect/entrypoints/php.py +0 -0
  56. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/detect/entrypoints/ruby.py +0 -0
  57. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/detect/entrypoints/rust.py +0 -0
  58. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/errors.py +0 -0
  59. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/extract/__init__.py +0 -0
  60. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/extract/base.py +0 -0
  61. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/extract/csharp.py +0 -0
  62. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/extract/golang.py +0 -0
  63. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/extract/ir.py +0 -0
  64. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/extract/java.py +0 -0
  65. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/extract/javascript.py +0 -0
  66. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/extract/php.py +0 -0
  67. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/extract/python.py +0 -0
  68. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/extract/registry.py +0 -0
  69. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/extract/ruby.py +0 -0
  70. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/extract/rust.py +0 -0
  71. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/fs/__init__.py +0 -0
  72. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/fs/hashing.py +0 -0
  73. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/fs/lang.py +0 -0
  74. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/fs/walker.py +0 -0
  75. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/graph/__init__.py +0 -0
  76. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/graph/adjacency.py +0 -0
  77. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/graph/cte.py +0 -0
  78. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/kinds.py +0 -0
  79. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/parsing/__init__.py +0 -0
  80. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/parsing/parsers.py +0 -0
  81. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/parsing/queries.py +0 -0
  82. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/pipeline/__init__.py +0 -0
  83. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/pipeline/worker.py +0 -0
  84. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/pipeline/writer.py +0 -0
  85. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/py.typed +0 -0
  86. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/csharp/calls.scm +0 -0
  87. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/csharp/definitions.scm +0 -0
  88. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/csharp/imports.scm +0 -0
  89. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/go/calls.scm +0 -0
  90. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/go/definitions.scm +0 -0
  91. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/go/imports.scm +0 -0
  92. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/java/calls.scm +0 -0
  93. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/java/definitions.scm +0 -0
  94. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/java/imports.scm +0 -0
  95. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/javascript/calls.scm +0 -0
  96. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/javascript/definitions.scm +0 -0
  97. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/javascript/imports.scm +0 -0
  98. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/php/calls.scm +0 -0
  99. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/php/definitions.scm +0 -0
  100. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/php/imports.scm +0 -0
  101. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/python/calls.scm +0 -0
  102. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/python/definitions.scm +0 -0
  103. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/python/imports.scm +0 -0
  104. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/ruby/calls.scm +0 -0
  105. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/ruby/definitions.scm +0 -0
  106. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/ruby/imports.scm +0 -0
  107. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/rust/calls.scm +0 -0
  108. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/rust/definitions.scm +0 -0
  109. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/queries/rust/imports.scm +0 -0
  110. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/resolve/__init__.py +0 -0
  111. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/resolve/externals.py +0 -0
  112. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/resolve/hierarchy.py +0 -0
  113. {entrygraph-0.1.4 → entrygraph-0.1.6}/src/entrygraph/resolve/symbol_table.py +0 -0
  114. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/conftest.py +0 -0
  115. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/csharp/aspnet_app/Controllers/ReportsController.cs +0 -0
  116. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/csharp/aspnet_app/Program.cs +0 -0
  117. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/csharp/aspnet_app/Services/ReportService.cs +0 -0
  118. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/csharp/aspnet_app/app.csproj +0 -0
  119. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/go/gin_app/go.mod +0 -0
  120. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/go/gin_app/main.go +0 -0
  121. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/go/gin_app/service.go +0 -0
  122. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/java/spring_app/pom.xml +0 -0
  123. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/java/spring_app/src/main/java/com/example/Application.java +0 -0
  124. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/java/spring_app/src/main/java/com/example/ReportRunner.java +0 -0
  125. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/java/spring_app/src/main/java/com/example/ReportService.java +0 -0
  126. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/java/spring_app/src/main/java/com/example/UserController.java +0 -0
  127. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/javascript/express_app/package.json +0 -0
  128. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/javascript/express_app/src/routes.js +0 -0
  129. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/javascript/express_app/src/services.js +0 -0
  130. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/php/laravel_app/app/Http/Controllers/ReportController.php +0 -0
  131. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/php/laravel_app/artisan +0 -0
  132. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/php/laravel_app/composer.json +0 -0
  133. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/php/laravel_app/routes/web.php +0 -0
  134. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/python/flask_app/app/__init__.py +0 -0
  135. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/python/flask_app/app/db.py +0 -0
  136. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/python/flask_app/app/routes.py +0 -0
  137. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/python/flask_app/app/services.py +0 -0
  138. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/python/flask_app/cli.py +0 -0
  139. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/python/flask_app/requirements.txt +0 -0
  140. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/ruby/sinatra_app/Gemfile +0 -0
  141. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/ruby/sinatra_app/app.rb +0 -0
  142. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/ruby/sinatra_app/services/runner.rb +0 -0
  143. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/rust/axum_app/Cargo.toml +0 -0
  144. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/rust/axum_app/src/handlers.rs +0 -0
  145. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/fixtures/rust/axum_app/src/main.rs +0 -0
  146. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_api.py +0 -0
  147. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_cli.py +0 -0
  148. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_engine_pragmas.py +0 -0
  149. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_entrypoints.py +0 -0
  150. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_extract_csharp.py +0 -0
  151. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_extract_go.py +0 -0
  152. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_extract_java.py +0 -0
  153. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_extract_javascript.py +0 -0
  154. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_extract_php.py +0 -0
  155. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_extract_python.py +0 -0
  156. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_extract_ruby.py +0 -0
  157. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_extract_rust.py +0 -0
  158. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_hardening.py +0 -0
  159. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_incremental.py +0 -0
  160. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_indexer.py +0 -0
  161. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_lang.py +0 -0
  162. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_models.py +0 -0
  163. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_pool.py +0 -0
  164. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_reachability.py +0 -0
  165. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_render.py +0 -0
  166. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_sink_catalog.py +0 -0
  167. {entrygraph-0.1.4 → entrygraph-0.1.6}/tests/test_walker.py +0 -0
  168. {entrygraph-0.1.4 → entrygraph-0.1.6}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: entrygraph
3
- Version: 0.1.4
3
+ Version: 0.1.6
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,6 +197,9 @@ 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
205
  calls) or `--include-fuzzy` (speculative class-hierarchy edges); drop
@@ -146,6 +146,9 @@ 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
154
  calls) or `--include-fuzzy` (speculative class-hierarchy edges); drop
@@ -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.4'
22
- __version_tuple__ = version_tuple = (0, 1, 4)
21
+ __version__ = version = '0.1.6'
22
+ __version_tuple__ = version_tuple = (0, 1, 6)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -251,7 +251,8 @@ class CodeGraph:
251
251
  def paths(
252
252
  self,
253
253
  *,
254
- source: SourceSpec,
254
+ source: SourceSpec | None = None,
255
+ source_category: str | None = None,
255
256
  sink: SourceSpec | None = None,
256
257
  sink_category: str | None = None,
257
258
  max_depth: int = 25,
@@ -266,6 +267,11 @@ class CodeGraph:
266
267
  ) -> list[CallPath]:
267
268
  """Enumerate source->sink call paths, risk-ranked (highest first).
268
269
 
270
+ Sources are an explicit `source` spec (qname/glob/Symbol/Entrypoint) and/or
271
+ a `source_category` resolved from the taint-source catalog (e.g. every call
272
+ site of an `http_input`/`env` source). Sinks work the same via `sink` /
273
+ `sink_category`.
274
+
269
275
  By default only EXACT/IMPORT edges are traversed; `include_fuzzy` /
270
276
  `include_unresolved` lower the confidence floor (an explicit
271
277
  `min_confidence` int overrides the flags). `include_callbacks` also
@@ -276,7 +282,7 @@ class CodeGraph:
276
282
  floor, include_cha = _traversal_params(min_confidence, include_fuzzy, include_unresolved)
277
283
  kinds = (*edge_kinds, "callback") if include_callbacks else edge_kinds
278
284
  with self._session_factory() as session:
279
- sources = self._spec_to_ids(session, source)
285
+ sources = self._source_ids(session, source, source_category)
280
286
  sinks = self._sink_ids(session, sink, sink_category)
281
287
  if not sources or not sinks:
282
288
  return []
@@ -305,7 +311,8 @@ class CodeGraph:
305
311
  def reachable(
306
312
  self,
307
313
  *,
308
- source: SourceSpec,
314
+ source: SourceSpec | None = None,
315
+ source_category: str | None = None,
309
316
  sink: SourceSpec | None = None,
310
317
  sink_category: str | None = None,
311
318
  max_depth: int = 25,
@@ -319,7 +326,7 @@ class CodeGraph:
319
326
  floor, include_cha = _traversal_params(min_confidence, include_fuzzy, include_unresolved)
320
327
  kinds = (*edge_kinds, "callback") if include_callbacks else edge_kinds
321
328
  with self._session_factory() as session:
322
- sources = self._spec_to_ids(session, source)
329
+ sources = self._source_ids(session, source, source_category)
323
330
  sinks = self._sink_ids(session, sink, sink_category)
324
331
  if not sources or not sinks:
325
332
  return False
@@ -362,6 +369,9 @@ class CodeGraph:
362
369
  sink_edges=count(
363
370
  select(func.count(models.Edge.id)).where(models.Edge.sink_id.is_not(None))
364
371
  ),
372
+ source_edges=count(
373
+ select(func.count(models.Edge.id)).where(models.Edge.source_id.is_not(None))
374
+ ),
365
375
  )
366
376
 
367
377
  def session(self) -> Session:
@@ -426,6 +436,22 @@ class CodeGraph:
426
436
  return {spec.symbol.id}
427
437
  return q.symbol_ids_matching(session, str(spec))
428
438
 
439
+ def _source_ids(self, session: Session, source, source_category: str | None) -> set[int]:
440
+ """Taint origins: explicit `source` spec plus, for `source_category`, every
441
+ symbol that calls a matching catalog taint-source function."""
442
+ ids = self._spec_to_ids(session, source) if source is not None else set()
443
+ if source_category is not None:
444
+ registry = self._registry(session)
445
+ source_pattern_ids = registry.source_ids_for_category(source_category)
446
+ if source_pattern_ids:
447
+ rows = session.execute(
448
+ select(models.Edge.src_symbol_id).where(
449
+ models.Edge.source_id.in_(source_pattern_ids)
450
+ )
451
+ ).scalars()
452
+ ids |= set(rows)
453
+ return ids
454
+
429
455
  def _sink_ids(self, session: Session, sink, sink_category: str | None) -> set[int]:
430
456
  ids = self._spec_to_ids(session, sink) if sink is not None else set()
431
457
  if sink_category is not None:
@@ -474,18 +500,26 @@ class CodeGraph:
474
500
 
475
501
  @staticmethod
476
502
  def _tainted_source_ids(session: Session, sources: set[int]) -> set[int]:
477
- """Source symbol ids that are entrypoints with known user-controlled params."""
503
+ """Source symbol ids known to carry user-controlled data: entrypoints with
504
+ tainted params, plus call sites of catalog taint-source functions."""
478
505
  if not sources:
479
506
  return set()
507
+ tainted: set[int] = set()
480
508
  rows = session.execute(
481
509
  select(models.Entrypoint.symbol_id, models.Entrypoint.extra).where(
482
510
  models.Entrypoint.symbol_id.in_(sources)
483
511
  )
484
512
  ).all()
485
- tainted: set[int] = set()
486
513
  for sid, extra in rows:
487
514
  if extra and json.loads(extra).get("tainted_params"):
488
515
  tainted.add(sid)
516
+ source_callers = session.execute(
517
+ select(models.Edge.src_symbol_id).where(
518
+ models.Edge.source_id.is_not(None),
519
+ models.Edge.src_symbol_id.in_(sources),
520
+ )
521
+ ).scalars()
522
+ tainted |= set(source_callers)
489
523
  return tainted
490
524
 
491
525
  @staticmethod
@@ -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)"
@@ -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
 
@@ -7,6 +7,7 @@ import re
7
7
 
8
8
  from entrygraph.detect.entrypoints.base import (
9
9
  EntrypointRule,
10
+ Matcher,
10
11
  first_string_arg,
11
12
  identifier_args,
12
13
  methods_kwarg,
@@ -157,20 +158,30 @@ def _django_urls(x: FileExtraction) -> list[EntrypointHint]:
157
158
  return hints
158
159
 
159
160
 
160
- def _click_commands(x: FileExtraction) -> list[EntrypointHint]:
161
- hints = []
162
- for symbol, decorator in _decorated(x):
163
- if _CLICK_CMD.match(decorator):
164
- hints.append(
165
- EntrypointHint(
166
- rule_id="python.click.command",
167
- kind=EntrypointKind.CLI_COMMAND,
168
- handler_qualified_name=symbol.qualified_name,
169
- name=first_string_arg(decorator) or symbol.name,
170
- framework="click",
161
+ def _click_like(framework: str, rule_id: str) -> Matcher:
162
+ """click and typer share decorator shape (`@app.command`); only the
163
+ reported framework/rule_id differ, so bind them per rule."""
164
+
165
+ def _match(x: FileExtraction) -> list[EntrypointHint]:
166
+ hints = []
167
+ for symbol, decorator in _decorated(x):
168
+ if _CLICK_CMD.match(decorator):
169
+ hints.append(
170
+ EntrypointHint(
171
+ rule_id=rule_id,
172
+ kind=EntrypointKind.CLI_COMMAND,
173
+ handler_qualified_name=symbol.qualified_name,
174
+ name=first_string_arg(decorator) or symbol.name,
175
+ framework=framework,
176
+ )
171
177
  )
172
- )
173
- return hints
178
+ return hints
179
+
180
+ return _match
181
+
182
+
183
+ _click_commands = _click_like("click", "python.click.command")
184
+ _typer_commands = _click_like("typer", "python.typer.command")
174
185
 
175
186
 
176
187
  def _celery_tasks(x: FileExtraction) -> list[EntrypointHint]:
@@ -363,7 +374,7 @@ register(
363
374
  )
364
375
  register(
365
376
  EntrypointRule(
366
- "python.typer.command", "python", "typer", EntrypointKind.CLI_COMMAND, _click_commands
377
+ "python.typer.command", "python", "typer", EntrypointKind.CLI_COMMAND, _typer_commands
367
378
  )
368
379
  )
369
380
  register(
@@ -104,6 +104,16 @@ FRAMEWORKS: list[FrameworkSpec] = [
104
104
  ),
105
105
  ),
106
106
  # JS/TS, Go, Java, Ruby specs land with their extractors (M8)
107
+ FrameworkSpec(
108
+ "aws-lambda-js",
109
+ "javascript",
110
+ (
111
+ FrameworkSignal("file_presence", "serverless.y*ml", 0.5),
112
+ FrameworkSignal("file_presence", "template.y*ml", 0.3),
113
+ FrameworkSignal("manifest_dep", "@types/aws-lambda", 0.6),
114
+ FrameworkSignal("manifest_dep", "aws-lambda", 0.5),
115
+ ),
116
+ ),
107
117
  FrameworkSpec(
108
118
  "express",
109
119
  "javascript",
@@ -223,9 +223,11 @@ def parse_manifests(root: str | Path) -> ManifestDeps:
223
223
  if not manifest.is_file():
224
224
  continue
225
225
  if any(
226
- part in ("node_modules", "vendor", ".venv", "venv", "target", "packages")
226
+ part in ("node_modules", "vendor", ".venv", "venv", "target")
227
227
  for part in manifest.parts
228
228
  ):
229
+ # note: "packages" is intentionally NOT excluded — it is the
230
+ # standard JS workspace layout (packages/<name>/package.json).
229
231
  continue
230
232
  try:
231
233
  text = manifest.read_text(encoding="utf-8", errors="replace")
@@ -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
@@ -33,7 +33,7 @@ _LENGTH_DECAY = 0.97
33
33
  _CONST_TOKEN = re.compile(
34
34
  r"""^(
35
35
  \s | , | = | \( | \) | \[ | \] | \{ | \} | : |
36
- '[^']*' | "[^"]*" | `[^`]*` |
36
+ '[^']*' | "[^"]*" | `(?:[^`$]|\$(?!\{))*` | # backtick: no ${...} interpolation
37
37
  \d[\d_.eExXaAbBcCdDfF]* |
38
38
  True|False|None|null|true|false|nil |
39
39
  [A-Za-z_]\w*\s*= # kwarg name before '='
@@ -305,12 +305,13 @@ def _write_files(
305
305
  for path, fid in session.execute(select(File.path, File.id).where(File.repo_id == repo.id)):
306
306
  file_id_by_path[path] = fid
307
307
 
308
- reindexed = {w.path for w in diff.to_index} | {
308
+ to_index_paths = {w.path for w in diff.to_index}
309
+ reindexed = to_index_paths | {
309
310
  w.path for w in walked if w.skip_reason and w.path not in file_id_by_path
310
311
  }
311
312
  new_rows = []
312
313
  for wf in walked:
313
- if wf.path in file_id_by_path and wf.path not in {w.path for w in diff.to_index}:
314
+ if wf.path in file_id_by_path and wf.path not in to_index_paths:
314
315
  continue # unchanged; row already present
315
316
  if wf.path not in reindexed and not wf.skip_reason:
316
317
  continue
@@ -434,11 +435,9 @@ def _write_edges_and_entrypoints(
434
435
  resolver = FileResolver(x, module_ids[path], table, externals, is_package)
435
436
  file_id = file_id_by_path[path]
436
437
  for edge in resolver.resolve():
437
- sink_id = (
438
- sink_registry.match(edge.dst_qname, edge.arg_preview)
439
- if edge.kind is EdgeKind.CALLS
440
- else None
441
- )
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
442
441
  edge_rows.append(
443
442
  {
444
443
  "id": alloc.take(Edge),
@@ -451,6 +450,7 @@ def _write_edges_and_entrypoints(
451
450
  "confidence": int(edge.confidence),
452
451
  "arg_preview": edge.arg_preview,
453
452
  "sink_id": sink_id,
453
+ "source_id": source_id,
454
454
  "via": edge.via,
455
455
  }
456
456
  )
@@ -71,6 +71,7 @@ class FileResolver:
71
71
  self.module_symbol_id = module_symbol_id
72
72
  self.table = table
73
73
  self.externals = externals
74
+ self.is_package = is_package
74
75
  self.prefix = LANG_PREFIX.get(extraction.language, extraction.language)
75
76
  self.import_map, self.wildcard_modules = build_import_map(extraction, is_package)
76
77
 
@@ -122,7 +123,9 @@ class FileResolver:
122
123
  seen: set[str] = set()
123
124
  for imp in self.x.imports:
124
125
  module = (
125
- expand_relative(imp, self.x.module_path, False) if imp.is_relative else imp.module
126
+ expand_relative(imp, self.x.module_path, self.is_package)
127
+ if imp.is_relative
128
+ else imp.module
126
129
  )
127
130
  if not module or module in seen:
128
131
  continue
@@ -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 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)
@@ -160,6 +160,17 @@ def test_lambda_js_handler_rule():
160
160
  assert hints and hints[0].kind is EntrypointKind.LAMBDA_HANDLER
161
161
 
162
162
 
163
+ def test_typer_command_reports_typer_not_click():
164
+ # typer reuses click's decorator shape; the hint must carry framework/rule_id
165
+ # for typer, not be mislabeled as click.
166
+ sym = _sym("serve", "cli.serve")
167
+ sym.decorators = ["@app.command()"]
168
+ hints = _run("python.typer.command", _extraction([sym]), frameworks=frozenset({"typer"}))
169
+ assert hints
170
+ assert hints[0].framework == "typer"
171
+ assert hints[0].rule_id == "python.typer.command"
172
+
173
+
163
174
  def test_sidekiq_worker_rule():
164
175
  from entrygraph.detect.entrypoints import rules_for
165
176
 
@@ -33,6 +33,18 @@ def test_language_gating():
33
33
  assert not any(d.name == "rails" for d in detected)
34
34
 
35
35
 
36
+ def test_aws_lambda_js_detected_from_serverless_config():
37
+ # the javascript.aws-lambda.handler entrypoint rule is gated on this spec;
38
+ # regression: the spec was missing, so the rule could never fire.
39
+ detected = detect_frameworks(
40
+ ManifestDeps(javascript={"@types/aws-lambda"}),
41
+ import_signals=set(),
42
+ file_paths=["serverless.yml"],
43
+ languages_present={"javascript"},
44
+ )
45
+ assert any(d.name == "aws-lambda-js" for d in detected)
46
+
47
+
36
48
  def test_typescript_counts_for_js_frameworks():
37
49
  manifests = ManifestDeps(javascript={"express"})
38
50
  detected = detect_frameworks(
@@ -78,6 +78,17 @@ def test_parse_manifests_fixture():
78
78
  assert "requirements.txt" in deps.sources
79
79
 
80
80
 
81
+ def test_parse_manifests_reads_js_workspace_packages(tmp_path: Path):
82
+ # `packages/<name>/package.json` is the standard JS workspace layout and must
83
+ # not be excluded (regression: "packages" was in the skip list).
84
+ pkg = tmp_path / "packages" / "api"
85
+ pkg.mkdir(parents=True)
86
+ (pkg / "package.json").write_text('{"dependencies": {"express": "^4"}}')
87
+ deps = parse_manifests(tmp_path)
88
+ assert "express" in deps.javascript
89
+ assert "packages/api/package.json" in deps.sources
90
+
91
+
81
92
  # ---------------- C1: C#, PHP, Rust manifests ----------------
82
93
  from entrygraph.detect.manifests import ( # noqa: E402
83
94
  parse_cargo_toml,
@@ -57,6 +57,38 @@ def test_import_based_project_resolution():
57
57
  assert call.confidence is Confidence.IMPORT
58
58
 
59
59
 
60
+ def test_relative_import_from_package_init_resolves_submodule():
61
+ # `from .sub import thing` in the package's own __init__.py (module_path
62
+ # "app", is_package=True) must resolve to the project module "app.sub".
63
+ # Regression: is_package was hardcoded False, dropping "app" and yielding a
64
+ # bogus ".sub" external placeholder.
65
+ table = make_table()
66
+ table.add_module("app.sub", 20)
67
+ x = FileExtraction(
68
+ path="app/__init__.py",
69
+ language="python",
70
+ module_path="app",
71
+ parse_ok=True,
72
+ error_count=0,
73
+ imports=[
74
+ RawImport(
75
+ module="sub",
76
+ imported_name="thing",
77
+ alias="thing",
78
+ span=SPAN,
79
+ is_relative=True,
80
+ relative_level=1,
81
+ )
82
+ ],
83
+ )
84
+ externals = ExternalRegistry(iter(range(100, 200)).__next__)
85
+ resolver = FileResolver(x, 1, table, externals, is_package=True)
86
+ edges = resolver.resolve()
87
+ imp = next(e for e in edges if e.kind is EdgeKind.IMPORTS)
88
+ assert imp.dst_qname == "app.sub"
89
+ assert imp.dst_symbol_id == 20
90
+
91
+
60
92
  def test_external_import_creates_placeholder():
61
93
  table = make_table()
62
94
  imports = [RawImport(module="subprocess", imported_name=None, alias="sub", span=SPAN)]
@@ -17,6 +17,15 @@ def test_is_constant_args():
17
17
  assert not is_constant_args("(a, b, c, d, e, f, ...")
18
18
 
19
19
 
20
+ def test_js_template_literal_interpolation_is_not_constant():
21
+ # a plain backtick template with no interpolation is a literal
22
+ assert is_constant_args("(`ls -la`)")
23
+ # `${...}` carries a variable into the sink -> the archetypal JS command
24
+ # injection must NOT be scored as constant/safe
25
+ assert not is_constant_args("(`ls ${req.query.dir}`)")
26
+ assert not is_constant_args("(`${userInput}`)")
27
+
28
+
20
29
  def test_score_path_ordering_by_severity():
21
30
  common = {
22
31
  "hop_confidences": [int(Confidence.EXACT)],
@@ -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,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
File without changes
File without changes
File without changes