apisec-code-bolt 0.1.0__py3-none-any.whl

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 (111) hide show
  1. apisec_code_bolt/__init__.py +42 -0
  2. apisec_code_bolt/__main__.py +11 -0
  3. apisec_code_bolt/analysis/__init__.py +96 -0
  4. apisec_code_bolt/analysis/analyzer.py +2309 -0
  5. apisec_code_bolt/analysis/binding_tracker.py +341 -0
  6. apisec_code_bolt/analysis/call_graph.py +1197 -0
  7. apisec_code_bolt/analysis/call_graph_types.py +332 -0
  8. apisec_code_bolt/analysis/call_resolver.py +988 -0
  9. apisec_code_bolt/analysis/capability_tagger.py +322 -0
  10. apisec_code_bolt/analysis/config_scanner.py +197 -0
  11. apisec_code_bolt/analysis/data_flow.py +1883 -0
  12. apisec_code_bolt/analysis/dependency_extractor.py +959 -0
  13. apisec_code_bolt/analysis/flow_analysis.py +1406 -0
  14. apisec_code_bolt/analysis/hof_catalog.py +61 -0
  15. apisec_code_bolt/analysis/integration_detector.py +1399 -0
  16. apisec_code_bolt/analysis/literal_scanner.py +300 -0
  17. apisec_code_bolt/analysis/path_normalizer.py +55 -0
  18. apisec_code_bolt/analysis/read_site_detector.py +310 -0
  19. apisec_code_bolt/analysis/request_patterns.py +162 -0
  20. apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
  21. apisec_code_bolt/analysis/sink_evidence.py +333 -0
  22. apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
  23. apisec_code_bolt/cli/__init__.py +5 -0
  24. apisec_code_bolt/cli/exit_codes.py +17 -0
  25. apisec_code_bolt/cli/main.py +1069 -0
  26. apisec_code_bolt/cloud/__init__.py +1 -0
  27. apisec_code_bolt/cloud/apisec_client.py +118 -0
  28. apisec_code_bolt/cloud/client.py +255 -0
  29. apisec_code_bolt/core/__init__.py +75 -0
  30. apisec_code_bolt/core/config.py +528 -0
  31. apisec_code_bolt/core/credentials.py +65 -0
  32. apisec_code_bolt/core/discovery.py +433 -0
  33. apisec_code_bolt/core/log_format.py +115 -0
  34. apisec_code_bolt/core/manifest.py +1009 -0
  35. apisec_code_bolt/core/repo.py +280 -0
  36. apisec_code_bolt/core/state.py +59 -0
  37. apisec_code_bolt/core/telemetry.py +451 -0
  38. apisec_code_bolt/core/types.py +587 -0
  39. apisec_code_bolt/fingerprinting/__init__.py +1 -0
  40. apisec_code_bolt/frameworks/__init__.py +29 -0
  41. apisec_code_bolt/frameworks/_jwt_common.py +50 -0
  42. apisec_code_bolt/frameworks/auth_helpers.py +437 -0
  43. apisec_code_bolt/frameworks/base.py +608 -0
  44. apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
  45. apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
  46. apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
  47. apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
  48. apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
  49. apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
  50. apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
  51. apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
  52. apisec_code_bolt/frameworks/java/__init__.py +6 -0
  53. apisec_code_bolt/frameworks/java/_annotations.py +167 -0
  54. apisec_code_bolt/frameworks/java/_constraints.py +128 -0
  55. apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
  56. apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
  57. apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
  58. apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
  59. apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
  60. apisec_code_bolt/frameworks/js/__init__.py +8 -0
  61. apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
  62. apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
  63. apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
  64. apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
  65. apisec_code_bolt/frameworks/python/__init__.py +19 -0
  66. apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
  67. apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
  68. apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
  69. apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
  70. apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
  71. apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
  72. apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
  73. apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
  74. apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
  75. apisec_code_bolt/parsing/__init__.py +62 -0
  76. apisec_code_bolt/parsing/base.py +554 -0
  77. apisec_code_bolt/parsing/csharp/__init__.py +5 -0
  78. apisec_code_bolt/parsing/csharp/language_services.py +203 -0
  79. apisec_code_bolt/parsing/csharp/literals.py +72 -0
  80. apisec_code_bolt/parsing/csharp/parser.py +1158 -0
  81. apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
  82. apisec_code_bolt/parsing/js/__init__.py +5 -0
  83. apisec_code_bolt/parsing/js/language_services.py +118 -0
  84. apisec_code_bolt/parsing/js/parser.py +622 -0
  85. apisec_code_bolt/parsing/jvm/__init__.py +7 -0
  86. apisec_code_bolt/parsing/jvm/language_services.py +270 -0
  87. apisec_code_bolt/parsing/jvm/parser.py +774 -0
  88. apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
  89. apisec_code_bolt/parsing/python/__init__.py +150 -0
  90. apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
  91. apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
  92. apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
  93. apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
  94. apisec_code_bolt/parsing/python/expression_utils.py +221 -0
  95. apisec_code_bolt/parsing/python/extraction_types.py +271 -0
  96. apisec_code_bolt/parsing/python/language_services.py +487 -0
  97. apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
  98. apisec_code_bolt/parsing/python/parser.py +719 -0
  99. apisec_code_bolt/parsing/python/path_resolver.py +576 -0
  100. apisec_code_bolt/parsing/python/router_registry.py +806 -0
  101. apisec_code_bolt/parsing/python/type_resolver.py +730 -0
  102. apisec_code_bolt/parsing/python/visitors.py +1544 -0
  103. apisec_code_bolt/parsing/services.py +544 -0
  104. apisec_code_bolt/query/__init__.py +1 -0
  105. apisec_code_bolt/query/ast_cache.py +182 -0
  106. apisec_code_bolt/query/executor.py +283 -0
  107. apisec_code_bolt/query/handlers.py +832 -0
  108. apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
  109. apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
  110. apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
  111. apisec_code_bolt-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,280 @@
1
+ """Git repository info detection for registration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import logging
7
+ import re
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.parse import unquote, urlparse
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def _run_git(project_root: Path, *args: str) -> str | None:
17
+ """Run a git command and return stdout, or None on failure."""
18
+ try:
19
+ result = subprocess.run(
20
+ ["git", *args],
21
+ cwd=project_root,
22
+ capture_output=True,
23
+ text=True,
24
+ timeout=5,
25
+ )
26
+ if result.returncode == 0 and result.stdout:
27
+ return result.stdout.strip()
28
+ return None
29
+ except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
30
+ return None
31
+
32
+
33
+ def _infer_scm_provider(repo_url: str) -> str:
34
+ """Infer SCM provider from URL."""
35
+ url_lower = repo_url.lower()
36
+ if "github.com" in url_lower:
37
+ return "github"
38
+ if "gitlab.com" in url_lower or "gitlab." in url_lower:
39
+ return "gitlab"
40
+ if "bitbucket.org" in url_lower or "bitbucket." in url_lower:
41
+ return "bitbucket"
42
+ if "azure" in url_lower or "dev.azure.com" in url_lower or "visualstudio.com" in url_lower:
43
+ return "azure"
44
+ return "git"
45
+
46
+
47
+ def _sha256_hex(data: bytes) -> str:
48
+ return hashlib.sha256(data).hexdigest()
49
+
50
+
51
+ _GIT_AT_HOST_PATH = re.compile(
52
+ r"^git@(?P<host>[^:/]+)[:/](?P<path>.+)$",
53
+ re.IGNORECASE,
54
+ )
55
+
56
+
57
+ def _strip_git_suffix(segment: str) -> str:
58
+ s = segment.strip().rstrip("/")
59
+ if s.lower().endswith(".git"):
60
+ return s[:-4]
61
+ return s
62
+
63
+
64
+ def _parse_github_host_path(host: str, path: str) -> str | None:
65
+ if "github.com" not in host.lower():
66
+ return None
67
+ segments = [p for p in path.split("/") if p]
68
+ if len(segments) < 2:
69
+ return None
70
+ org, repo = segments[0], _strip_git_suffix(segments[1])
71
+ if not org or not repo:
72
+ return None
73
+ return f"github:{org.lower()}/{repo.lower()}"
74
+
75
+
76
+ def _parse_gitlab_host_path(host: str, path: str) -> str | None:
77
+ if "gitlab" not in host.lower():
78
+ return None
79
+ segments = [p for p in path.split("/") if p]
80
+ if len(segments) < 2:
81
+ return None
82
+ slug = "/".join(_strip_git_suffix(s).lower() for s in segments)
83
+ return f"gitlab:{slug}"
84
+
85
+
86
+ def _parse_bitbucket_host_path(host: str, path: str) -> str | None:
87
+ if "bitbucket" not in host.lower():
88
+ return None
89
+ segments = [p for p in path.split("/") if p]
90
+ if len(segments) < 2:
91
+ return None
92
+ ws, repo = segments[0], _strip_git_suffix(segments[1])
93
+ if not ws or not repo:
94
+ return None
95
+ return f"bitbucket:{ws.lower()}/{repo.lower()}"
96
+
97
+
98
+ def _parse_azure_https(host: str, path: str) -> str | None:
99
+ path = path.strip("/")
100
+ if not path:
101
+ return None
102
+ parts = [p for p in path.split("/") if p]
103
+ hl = host.lower()
104
+ if "dev.azure.com" in hl:
105
+ # dev.azure.com/Org/Project/_git/Repo
106
+ try:
107
+ idx = parts.index("_git")
108
+ except ValueError:
109
+ return None
110
+ if idx < 2 or idx + 1 >= len(parts):
111
+ return None
112
+ org, project = parts[0], parts[1]
113
+ repo = _strip_git_suffix(parts[idx + 1])
114
+ return f"azure:{org.lower()}/{project.lower()}/{repo.lower()}"
115
+ if "visualstudio.com" in hl:
116
+ try:
117
+ idx = parts.index("_git")
118
+ except ValueError:
119
+ return None
120
+ if idx < 1 or idx + 1 >= len(parts):
121
+ return None
122
+ namespace = "/".join(parts[:idx]).lower()
123
+ repo = _strip_git_suffix(parts[idx + 1])
124
+ return f"azure:{namespace}/{repo.lower()}"
125
+ return None
126
+
127
+
128
+ def _parse_azure_ssh(remote: str) -> str | None:
129
+ m = re.match(
130
+ r"^git@ssh\.dev\.azure\.com:v3/(?P<org>[^/]+)/(?P<project>[^/]+)/(?P<repo>.+)$",
131
+ remote.strip(),
132
+ re.IGNORECASE,
133
+ )
134
+ if not m:
135
+ return None
136
+ org, project, repo = m.group("org"), m.group("project"), _strip_git_suffix(m.group("repo"))
137
+ return f"azure:{org.lower()}/{project.lower()}/{repo.lower()}"
138
+
139
+
140
+ def _parse_scp_style(remote: str) -> str | None:
141
+ m = _GIT_AT_HOST_PATH.match(remote.strip())
142
+ if not m:
143
+ return None
144
+ host = m.group("host").lower()
145
+ raw_path = m.group("path").strip()
146
+ path = raw_path.replace("\\", "/")
147
+ if path.startswith("/"):
148
+ path = path.lstrip("/")
149
+ path = unquote(path)
150
+ segments = [p for p in path.split("/") if p]
151
+ if not segments:
152
+ return None
153
+
154
+ if "github.com" in host:
155
+ if len(segments) < 2:
156
+ return None
157
+ org, repo = segments[0], _strip_git_suffix(segments[1])
158
+ return f"github:{org.lower()}/{repo.lower()}"
159
+
160
+ if "gitlab" in host:
161
+ slug = "/".join(_strip_git_suffix(s).lower() for s in segments)
162
+ return f"gitlab:{slug}"
163
+
164
+ if "bitbucket" in host:
165
+ if len(segments) < 2:
166
+ return None
167
+ ws, repo = segments[0], _strip_git_suffix(segments[1])
168
+ return f"bitbucket:{ws.lower()}/{repo.lower()}"
169
+
170
+ if (
171
+ ("ssh.dev.azure.com" in host or "vs-ssh.visualstudio.com" in host)
172
+ and len(segments) >= 4
173
+ and segments[0].lower() == "v3"
174
+ ):
175
+ org, project, repo = segments[1], segments[2], _strip_git_suffix(segments[3])
176
+ return f"azure:{org.lower()}/{project.lower()}/{repo.lower()}"
177
+
178
+ return None
179
+
180
+
181
+ def _parse_remote_to_canonical(remote_url: str) -> str | None:
182
+ u = remote_url.strip()
183
+ if not u:
184
+ return None
185
+
186
+ azure_ssh = _parse_azure_ssh(u)
187
+ if azure_ssh:
188
+ return azure_ssh
189
+
190
+ scp = _parse_scp_style(u)
191
+ if scp:
192
+ return scp
193
+
194
+ parsed = urlparse(u)
195
+ scheme = (parsed.scheme or "").lower()
196
+ if scheme not in {"http", "https", "ssh"}:
197
+ return None
198
+ host = (parsed.hostname or "").lower()
199
+ path = unquote((parsed.path or "").strip("/"))
200
+ if not host or not path:
201
+ return None
202
+
203
+ for fn in (
204
+ _parse_github_host_path,
205
+ _parse_gitlab_host_path,
206
+ _parse_bitbucket_host_path,
207
+ _parse_azure_https,
208
+ ):
209
+ hit = fn(host, path)
210
+ if hit:
211
+ return hit
212
+ return None
213
+
214
+
215
+ def derive_canonical_repo_id(remote_url: str, project_root: Path) -> str:
216
+ """
217
+ Stable repository id for applicationsservice surface idempotency.
218
+
219
+ Preferred: ``{provider}:{namespace/repo}`` (lowercase, no ``.git`` suffix), e.g. ``github:apisec/api``.
220
+ If the remote cannot be parsed but ``remote_url`` is non-empty: ``git:url-<sha256(normalized_url)>``.
221
+ If there is no remote URL: ``local:<sha256(absolute_project_root)>``.
222
+ """
223
+ root = project_root.resolve()
224
+ ru = (remote_url or "").strip()
225
+ parsed = _parse_remote_to_canonical(ru) if ru else None
226
+ if parsed:
227
+ return parsed
228
+ if ru:
229
+ norm = ru.lower().rstrip("/")
230
+ if norm.endswith(".git"):
231
+ norm = norm[:-4]
232
+ digest = _sha256_hex(norm.encode("utf-8"))
233
+ return f"git:url-{digest}"
234
+ return f"local:{_sha256_hex(str(root).encode('utf-8'))}"
235
+
236
+
237
+ def detect_repo_info(project_root: Path) -> dict[str, Any]:
238
+ """
239
+ Detect git repository info from the project root.
240
+
241
+ Falls back to sensible defaults when git is not available or not a repo.
242
+ Returns a dict with keys: repo_name, repo_url, branch, commit_sha, scm_provider, canonical_repo_id.
243
+ """
244
+ root = project_root.resolve()
245
+ repo_name = root.name
246
+ repo_url = f"local://{root}"
247
+ branch = "main"
248
+ commit_sha = _sha256_hex(str(root).encode("utf-8"))[:12]
249
+ scm_provider = "local"
250
+
251
+ remote_url = _run_git(root, "config", "--get", "remote.origin.url")
252
+ if remote_url:
253
+ repo_url = remote_url
254
+ # Extract repo name from URL (e.g., git@github.com:org/repo.git -> repo)
255
+ if "/" in repo_url:
256
+ name_part = repo_url.rstrip("/").rsplit("/", 1)[-1]
257
+ if name_part.endswith(".git"):
258
+ name_part = name_part[:-4]
259
+ if name_part:
260
+ repo_name = name_part
261
+ scm_provider = _infer_scm_provider(repo_url)
262
+
263
+ branch_out = _run_git(root, "rev-parse", "--abbrev-ref", "HEAD")
264
+ if branch_out:
265
+ branch = branch_out
266
+
267
+ sha_out = _run_git(root, "rev-parse", "HEAD")
268
+ if sha_out:
269
+ commit_sha = sha_out
270
+
271
+ canonical_repo_id = derive_canonical_repo_id(repo_url, root)
272
+
273
+ return {
274
+ "repo_name": repo_name,
275
+ "repo_url": repo_url,
276
+ "branch": branch,
277
+ "commit_sha": commit_sha,
278
+ "scm_provider": scm_provider,
279
+ "canonical_repo_id": canonical_repo_id,
280
+ }
@@ -0,0 +1,59 @@
1
+ """Repository state file management for Nth-run routing.
2
+
3
+ Manages the `apisec-code-bolt/state.yaml` file in the repository root.
4
+ This file contains only the applicationId — written once after first registration.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import yaml
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ STATE_DIR_NAME = "apisec-code-bolt"
19
+ STATE_FILE_NAME = "state.yaml"
20
+
21
+
22
+ @dataclass
23
+ class RepoState:
24
+ """Minimal state persisted in the repository."""
25
+
26
+ application_id: str
27
+
28
+
29
+ def state_file_path(project_root: Path) -> Path:
30
+ """Return the path to the state file for the given project root."""
31
+ return project_root / STATE_DIR_NAME / STATE_FILE_NAME
32
+
33
+
34
+ def read_state(project_root: Path) -> RepoState | None:
35
+ """Read state from the repository. Returns None if no state file exists."""
36
+ path = state_file_path(project_root)
37
+ if not path.exists():
38
+ return None
39
+ try:
40
+ with open(path) as f:
41
+ data: dict[str, Any] = yaml.safe_load(f) or {}
42
+ app_id = data.get("applicationId", "")
43
+ if not app_id:
44
+ return None
45
+ return RepoState(application_id=app_id)
46
+ except Exception:
47
+ logger.warning("Failed to read state file at %s", path, exc_info=True)
48
+ return None
49
+
50
+
51
+ def write_state(project_root: Path, state: RepoState) -> Path:
52
+ """Write state to the repository. Creates directory if needed."""
53
+ path = state_file_path(project_root)
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+ data = {"applicationId": state.application_id}
56
+ with open(path, "w") as f:
57
+ yaml.safe_dump(data, f, default_flow_style=False)
58
+ logger.info("State file written: %s", path)
59
+ return path