hullproof 0.0.0-stage → 0.5.0

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 (363) hide show
  1. package/LICENSE +222 -0
  2. package/NOTICE +32 -0
  3. package/README.md +313 -2
  4. package/checks/db.firebase-rules-not-open.json +24 -0
  5. package/checks/db.rls-every-table.json +25 -0
  6. package/checks/log.no-credential-in-logs.json +30 -0
  7. package/checks/secrets.no-env-file-committed.json +31 -0
  8. package/checks/secrets.no-secret-in-public-env.json +37 -0
  9. package/checks/storage.firebase-rules-not-open.json +22 -0
  10. package/checks/supply.no-untrusted-event-in-run.json +23 -0
  11. package/checks/web.cors-no-reflected-origin.json +31 -0
  12. package/detectors/ai.json +25 -0
  13. package/detectors/auth.json +27 -0
  14. package/detectors/ci.json +19 -0
  15. package/detectors/containers.json +8 -0
  16. package/detectors/data_stores.json +38 -0
  17. package/detectors/frameworks.json +94 -0
  18. package/detectors/hosting.json +44 -0
  19. package/detectors/iac.json +43 -0
  20. package/detectors/languages.json +48 -0
  21. package/detectors/mobile.json +22 -0
  22. package/detectors/package_managers.json +39 -0
  23. package/detectors/payments.json +19 -0
  24. package/detectors/runtimes.json +17 -0
  25. package/dist/checks/code/admin-role.js +46 -0
  26. package/dist/checks/code/owner-filter.js +91 -0
  27. package/dist/checks/code/shared.js +84 -0
  28. package/dist/checks/code/webhook-verify.js +77 -0
  29. package/dist/checks/context.js +64 -0
  30. package/dist/checks/map/config-checks.js +74 -0
  31. package/dist/checks/map/entry-checks.js +97 -0
  32. package/dist/checks/map/shared.js +44 -0
  33. package/dist/checks/ops.js +307 -0
  34. package/dist/checks/pro-map.js +1 -0
  35. package/dist/checks/run.js +92 -0
  36. package/dist/checks/types.js +1 -0
  37. package/dist/ci/comment.js +44 -0
  38. package/dist/ci/sarif-check.js +157 -0
  39. package/dist/ci/sarif.js +61 -0
  40. package/dist/cli.js +358 -0
  41. package/dist/commands/ci.js +115 -0
  42. package/dist/commands/git.js +58 -0
  43. package/dist/commands/hooks.js +30 -0
  44. package/dist/commands/hunt-pro.js +1 -0
  45. package/dist/commands/hunt.js +86 -0
  46. package/dist/commands/map.js +33 -0
  47. package/dist/commands/mcp.js +17 -0
  48. package/dist/commands/pack.js +48 -0
  49. package/dist/commands/prove-pro.js +1 -0
  50. package/dist/commands/prove.js +173 -0
  51. package/dist/commands/report-gate.js +92 -0
  52. package/dist/commands/scan.js +158 -0
  53. package/dist/confirm/agreement.js +18 -0
  54. package/dist/confirm/chain.js +84 -0
  55. package/dist/confirm/confirm.js +112 -0
  56. package/dist/confirm/honesty.js +75 -0
  57. package/dist/confirm/lead.js +92 -0
  58. package/dist/confirm/merge.js +88 -0
  59. package/dist/confirm/query.js +145 -0
  60. package/dist/confirm/rule.js +86 -0
  61. package/dist/confirm/store.js +42 -0
  62. package/dist/confirm/summary.js +6 -0
  63. package/dist/confirm/synthesis.js +28 -0
  64. package/dist/discover/detectors.js +50 -0
  65. package/dist/discover/discover.js +270 -0
  66. package/dist/discover/extract.js +282 -0
  67. package/dist/discover/git.js +40 -0
  68. package/dist/discover/kit-files.js +47 -0
  69. package/dist/discover/walk.js +110 -0
  70. package/dist/env.js +57 -0
  71. package/dist/gate/gate.js +173 -0
  72. package/dist/hooks/install.js +153 -0
  73. package/dist/hooks/run.js +112 -0
  74. package/dist/map/analyze.js +201 -0
  75. package/dist/map/build.js +253 -0
  76. package/dist/map/candidates.js +49 -0
  77. package/dist/map/cblocks.js +77 -0
  78. package/dist/map/config.js +291 -0
  79. package/dist/map/dotnet.js +242 -0
  80. package/dist/map/entry.js +78 -0
  81. package/dist/map/expo.js +190 -0
  82. package/dist/map/go.js +292 -0
  83. package/dist/map/js-express.js +143 -0
  84. package/dist/map/js-misc.js +295 -0
  85. package/dist/map/js-next.js +292 -0
  86. package/dist/map/laravel.js +389 -0
  87. package/dist/map/lex-more.js +305 -0
  88. package/dist/map/lex.js +425 -0
  89. package/dist/map/light-index.js +191 -0
  90. package/dist/map/light-profiles.js +324 -0
  91. package/dist/map/light.js +574 -0
  92. package/dist/map/local-vocab.js +27 -0
  93. package/dist/map/mobile.js +326 -0
  94. package/dist/map/py-django.js +281 -0
  95. package/dist/map/py.js +391 -0
  96. package/dist/map/rails.js +391 -0
  97. package/dist/map/repo.js +178 -0
  98. package/dist/map/rust.js +312 -0
  99. package/dist/map/spring.js +207 -0
  100. package/dist/map/taint.js +96 -0
  101. package/dist/map/touches.js +355 -0
  102. package/dist/map/vocab.js +88 -0
  103. package/dist/mapping/controls.js +34 -0
  104. package/dist/mapping/dependency-policy.js +44 -0
  105. package/dist/mapping/map.js +44 -0
  106. package/dist/mapping/maps/gitleaks.json +12 -0
  107. package/dist/mapping/maps/osv-scanner.json +7 -0
  108. package/dist/mapping/maps/trivy.json +13 -0
  109. package/dist/mcp/confine.js +59 -0
  110. package/dist/mcp/protocol.js +140 -0
  111. package/dist/mcp/schema.js +43 -0
  112. package/dist/mcp/server.js +37 -0
  113. package/dist/mcp/tools.js +238 -0
  114. package/dist/packs/coverage.js +79 -0
  115. package/dist/packs/manifest.js +36 -0
  116. package/dist/packs/validate.js +271 -0
  117. package/dist/prove/container.js +276 -0
  118. package/dist/prove/exec-main.js +20 -0
  119. package/dist/prove/executor.js +149 -0
  120. package/dist/prove/local.js +131 -0
  121. package/dist/prove/probes.js +65 -0
  122. package/dist/prove/recipe.js +278 -0
  123. package/dist/prove/run.js +136 -0
  124. package/dist/prove/runner.js +1 -0
  125. package/dist/prove/safety.js +126 -0
  126. package/dist/prove/spec.js +357 -0
  127. package/dist/prove/store.js +65 -0
  128. package/dist/report/build.js +297 -0
  129. package/dist/report/entry-points.js +75 -0
  130. package/dist/report/hunt-section.js +71 -0
  131. package/dist/report/kit.js +41 -0
  132. package/dist/report/load.js +35 -0
  133. package/dist/report/messages.js +273 -0
  134. package/dist/report/prove-section.js +65 -0
  135. package/dist/report/report.js +156 -0
  136. package/dist/report/surface.js +21 -0
  137. package/dist/report/test-code.js +51 -0
  138. package/dist/report/tiers.js +44 -0
  139. package/dist/scanners/common.js +162 -0
  140. package/dist/scanners/gitleaks.js +90 -0
  141. package/dist/scanners/index.js +113 -0
  142. package/dist/scanners/osv-scanner.js +77 -0
  143. package/dist/scanners/run-targets.js +34 -0
  144. package/dist/scanners/secret-noise.js +89 -0
  145. package/dist/scanners/semgrep.js +142 -0
  146. package/dist/scanners/trivy.js +65 -0
  147. package/dist/scanners/types.js +1 -0
  148. package/dist/types/check.js +1 -0
  149. package/dist/types/codemap.js +1 -0
  150. package/dist/types/findings.js +5 -0
  151. package/dist/types/hunt.js +1 -0
  152. package/dist/types/index.js +1 -0
  153. package/dist/types/prove.js +1 -0
  154. package/dist/types/results.js +2 -0
  155. package/dist/types/scan.js +1 -0
  156. package/dist/types/stack.js +5 -0
  157. package/dist/util.js +71 -0
  158. package/dist/version.js +2 -0
  159. package/package.json +46 -4
  160. package/packs/android/fixture-result.json +43 -0
  161. package/packs/android/pack.json +56 -0
  162. package/packs/dotnet/fixture-result.json +62 -0
  163. package/packs/dotnet/pack.json +88 -0
  164. package/packs/expo/answer-key.json +51 -0
  165. package/packs/expo/fixture-result.json +62 -0
  166. package/packs/expo/pack.json +79 -0
  167. package/packs/firebase/answer-key.json +27 -0
  168. package/packs/firebase/fixture-result.json +43 -0
  169. package/packs/firebase/pack.json +58 -0
  170. package/packs/go/answer-key.json +101 -0
  171. package/packs/go/fixture-result.json +64 -0
  172. package/packs/go/pack.json +99 -0
  173. package/packs/ios/fixture-result.json +43 -0
  174. package/packs/ios/pack.json +55 -0
  175. package/packs/laravel/answer-key.json +84 -0
  176. package/packs/laravel/fixture-result.json +60 -0
  177. package/packs/laravel/pack.json +91 -0
  178. package/packs/node-ts/answer-key.json +220 -0
  179. package/packs/node-ts/fixture-result.json +114 -0
  180. package/packs/node-ts/pack.json +134 -0
  181. package/packs/python/answer-key.json +195 -0
  182. package/packs/python/fixture-result.json +119 -0
  183. package/packs/python/pack.json +121 -0
  184. package/packs/rails/answer-key.json +84 -0
  185. package/packs/rails/fixture-result.json +60 -0
  186. package/packs/rails/pack.json +78 -0
  187. package/packs/rust/fixture-result.json +43 -0
  188. package/packs/rust/pack.json +69 -0
  189. package/packs/spring/fixture-result.json +63 -0
  190. package/packs/spring/pack.json +103 -0
  191. package/packs/supabase/answer-key.json +52 -0
  192. package/packs/supabase/fixture-result.json +58 -0
  193. package/packs/supabase/pack.json +60 -0
  194. package/rules/gitleaks.toml +256 -0
  195. package/rules/semgrep/api-route-no-auth.yaml +31 -0
  196. package/rules/semgrep/cors-reflect-origin.yaml +48 -0
  197. package/rules/semgrep/eval-or-new-function.yaml +185 -0
  198. package/rules/semgrep/log-session-token.yaml +48 -0
  199. package/rules/semgrep/math-random-tokens.yaml +24 -0
  200. package/rules/semgrep/next-public-secret-name.yaml +32 -0
  201. package/rules/semgrep/postgrest-filter-injection.yaml +78 -0
  202. package/rules/semgrep/raw-sql-concat.yaml +42 -0
  203. package/rules/semgrep/read-by-id-no-owner.yaml +49 -0
  204. package/rules/semgrep/server-action-no-auth.yaml +26 -0
  205. package/rules/semgrep/sql-variants.yaml +99 -0
  206. package/rules/semgrep/ssrf-unguarded-url.yaml +137 -0
  207. package/rules/semgrep/subprocess-injection.yaml +163 -0
  208. package/rules/semgrep/taint-go.yaml +333 -0
  209. package/rules/semgrep/taint-java.yaml +317 -0
  210. package/rules/semgrep/taint-javascript.yaml +412 -0
  211. package/rules/semgrep/taint-php.yaml +361 -0
  212. package/rules/semgrep/taint-python.yaml +414 -0
  213. package/rules/semgrep/tool-endpoint-admin-client.yaml +86 -0
  214. package/rules/semgrep/user-metadata-authz.yaml +22 -0
  215. package/rules/semgrep/verify-result-unused.yaml +62 -0
  216. package/rules/semgrep/webhook-parse-before-verify.yaml +29 -0
  217. package/rules/test-artifact-ids.json +4 -0
  218. package/rules/tiers.json +427 -0
  219. package/schemas/check-results.schema.json +55 -0
  220. package/schemas/check.schema.json +41 -0
  221. package/schemas/codemap.schema.json +216 -0
  222. package/schemas/findings.schema.json +1048 -0
  223. package/schemas/hunt-output.schema.json +46 -0
  224. package/schemas/hunt-result.schema.json +58 -0
  225. package/schemas/results.schema.json +17 -0
  226. package/schemas/scan-hits.schema.json +32 -0
  227. package/schemas/scan-meta.schema.json +25 -0
  228. package/schemas/stack.schema.json +93 -0
  229. package/src/checks/README.md +32 -0
  230. package/src/checks/code/admin-role.ts +49 -0
  231. package/src/checks/code/owner-filter.ts +95 -0
  232. package/src/checks/code/shared.ts +66 -0
  233. package/src/checks/code/webhook-verify.ts +75 -0
  234. package/src/checks/context.ts +72 -0
  235. package/src/checks/map/config-checks.ts +79 -0
  236. package/src/checks/map/entry-checks.ts +116 -0
  237. package/src/checks/map/shared.ts +63 -0
  238. package/src/checks/ops.ts +280 -0
  239. package/src/checks/pro-map.ts +4 -0
  240. package/src/checks/run.ts +80 -0
  241. package/src/checks/types.ts +12 -0
  242. package/src/ci/comment.ts +52 -0
  243. package/src/ci/sarif-check.ts +100 -0
  244. package/src/ci/sarif.ts +72 -0
  245. package/src/cli.ts +273 -0
  246. package/src/commands/ci.ts +104 -0
  247. package/src/commands/git.ts +59 -0
  248. package/src/commands/hooks.ts +27 -0
  249. package/src/commands/hunt-pro.ts +4 -0
  250. package/src/commands/hunt.ts +68 -0
  251. package/src/commands/map.ts +27 -0
  252. package/src/commands/mcp.ts +18 -0
  253. package/src/commands/pack.ts +34 -0
  254. package/src/commands/prove-pro.ts +4 -0
  255. package/src/commands/prove.ts +126 -0
  256. package/src/commands/report-gate.ts +75 -0
  257. package/src/commands/scan.ts +156 -0
  258. package/src/confirm/agreement.ts +23 -0
  259. package/src/confirm/chain.ts +67 -0
  260. package/src/confirm/confirm.ts +107 -0
  261. package/src/confirm/honesty.ts +65 -0
  262. package/src/confirm/lead.ts +76 -0
  263. package/src/confirm/merge.ts +89 -0
  264. package/src/confirm/query.ts +126 -0
  265. package/src/confirm/rule.ts +76 -0
  266. package/src/confirm/store.ts +42 -0
  267. package/src/confirm/summary.ts +9 -0
  268. package/src/confirm/synthesis.ts +33 -0
  269. package/src/discover/detectors.ts +71 -0
  270. package/src/discover/discover.ts +253 -0
  271. package/src/discover/extract.ts +235 -0
  272. package/src/discover/git.ts +31 -0
  273. package/src/discover/kit-files.ts +37 -0
  274. package/src/discover/walk.ts +88 -0
  275. package/src/env.ts +51 -0
  276. package/src/gate/gate.ts +169 -0
  277. package/src/hooks/install.ts +142 -0
  278. package/src/hooks/run.ts +121 -0
  279. package/src/map/analyze.ts +219 -0
  280. package/src/map/build.ts +206 -0
  281. package/src/map/candidates.ts +47 -0
  282. package/src/map/cblocks.ts +55 -0
  283. package/src/map/config.ts +207 -0
  284. package/src/map/dotnet.ts +205 -0
  285. package/src/map/entry.ts +80 -0
  286. package/src/map/expo.ts +171 -0
  287. package/src/map/go.ts +271 -0
  288. package/src/map/js-express.ts +135 -0
  289. package/src/map/js-misc.ts +221 -0
  290. package/src/map/js-next.ts +282 -0
  291. package/src/map/laravel.ts +327 -0
  292. package/src/map/lex-more.ts +152 -0
  293. package/src/map/lex.ts +274 -0
  294. package/src/map/light-index.ts +134 -0
  295. package/src/map/light-profiles.ts +363 -0
  296. package/src/map/light.ts +484 -0
  297. package/src/map/local-vocab.ts +30 -0
  298. package/src/map/mobile.ts +263 -0
  299. package/src/map/py-django.ts +239 -0
  300. package/src/map/py.ts +329 -0
  301. package/src/map/rails.ts +291 -0
  302. package/src/map/repo.ts +150 -0
  303. package/src/map/rust.ts +256 -0
  304. package/src/map/spring.ts +185 -0
  305. package/src/map/taint.ts +86 -0
  306. package/src/map/touches.ts +296 -0
  307. package/src/map/vocab.ts +106 -0
  308. package/src/mapping/controls.ts +47 -0
  309. package/src/mapping/dependency-policy.ts +55 -0
  310. package/src/mapping/map.ts +59 -0
  311. package/src/mapping/maps/gitleaks.json +12 -0
  312. package/src/mapping/maps/osv-scanner.json +7 -0
  313. package/src/mapping/maps/trivy.json +13 -0
  314. package/src/mcp/confine.ts +44 -0
  315. package/src/mcp/protocol.ts +144 -0
  316. package/src/mcp/schema.ts +35 -0
  317. package/src/mcp/server.ts +32 -0
  318. package/src/mcp/tools.ts +235 -0
  319. package/src/packs/coverage.ts +98 -0
  320. package/src/packs/manifest.ts +97 -0
  321. package/src/packs/validate.ts +204 -0
  322. package/src/prove/container.ts +237 -0
  323. package/src/prove/exec-main.ts +16 -0
  324. package/src/prove/executor.ts +130 -0
  325. package/src/prove/local.ts +100 -0
  326. package/src/prove/probes.ts +68 -0
  327. package/src/prove/recipe.ts +215 -0
  328. package/src/prove/run.ts +124 -0
  329. package/src/prove/runner.ts +28 -0
  330. package/src/prove/safety.ts +98 -0
  331. package/src/prove/spec.ts +256 -0
  332. package/src/prove/store.ts +48 -0
  333. package/src/report/build.ts +304 -0
  334. package/src/report/entry-points.ts +78 -0
  335. package/src/report/hunt-section.ts +67 -0
  336. package/src/report/kit.ts +59 -0
  337. package/src/report/load.ts +49 -0
  338. package/src/report/messages.ts +270 -0
  339. package/src/report/prove-section.ts +61 -0
  340. package/src/report/report.ts +160 -0
  341. package/src/report/surface.ts +32 -0
  342. package/src/report/test-code.ts +56 -0
  343. package/src/report/tiers.ts +50 -0
  344. package/src/scanners/common.ts +149 -0
  345. package/src/scanners/gitleaks.ts +89 -0
  346. package/src/scanners/index.ts +103 -0
  347. package/src/scanners/osv-scanner.ts +90 -0
  348. package/src/scanners/run-targets.ts +35 -0
  349. package/src/scanners/secret-noise.ts +80 -0
  350. package/src/scanners/semgrep.ts +139 -0
  351. package/src/scanners/trivy.ts +66 -0
  352. package/src/scanners/types.ts +98 -0
  353. package/src/types/check.ts +52 -0
  354. package/src/types/codemap.ts +142 -0
  355. package/src/types/findings.ts +107 -0
  356. package/src/types/hunt.ts +97 -0
  357. package/src/types/index.ts +10 -0
  358. package/src/types/prove.ts +83 -0
  359. package/src/types/results.ts +15 -0
  360. package/src/types/scan.ts +63 -0
  361. package/src/types/stack.ts +61 -0
  362. package/src/util.ts +59 -0
  363. package/src/version.ts +2 -0
@@ -0,0 +1,263 @@
1
+ // Native mobile configuration (Phase 6f): Android (Kotlin and Java apps) and iOS (Swift and Objective C apps). Level L1 reads the
2
+ // configuration files into the code map: AndroidManifest.xml (components, exported flags, permissions, backup, debuggable and cleartext
3
+ // flags), the network security configuration, Info.plist (App Transport Security, URL schemes, usage descriptions) and the entitlements
4
+ // (associated domains). Level L2 turns what other apps and websites can reach into entry points: exported Android components and deep
5
+ // links, iOS URL schemes and universal links. The Kotlin and Swift source is NOT read here: what a handler does with an incoming intent
6
+ // or URL is not analysed. Pattern based, no XML parser; every fact has the file and line it came from.
7
+ import type { AndroidComponent, AndroidConfig, AndroidFilter, AndroidManifest, ConfigSecret, IosConfig, IosPlist, NetworkSecurity, Protection } from '../types/index.ts';
8
+ import type { FileView } from './analyze.ts';
9
+ import { empty, makeEntry, type Extracted } from './entry.ts';
10
+ import { Lines } from './lex.ts';
11
+ import type { Repo } from './repo.ts';
12
+
13
+ export interface MobileFacts { android: AndroidConfig | null; ios: IosConfig | null; secrets: ConfigSecret[] }
14
+
15
+ // Names that say a value is a secret. A key that is public by design (a maps key, a Firebase web key, an analytics id) is not one.
16
+ const SECRET_NAME = /(?:secret|private[_.\- ]?key|passw(?:or)?d|passwd|signing[_.\- ]?key|service[_.\- ]?role|auth[_.\- ]?token|access[_.\- ]?token|refresh[_.\- ]?token)/i;
17
+ const PUBLIC_NAME = /(?:public|publishable|client[_.\- ]?id|maps|geo|analytics|sentry[_.\- ]?dsn)/i;
18
+ const PLACEHOLDER = /^(?:@|\$\(|\$\{|%|<|your[_\- ]|changeme|change[_\- ]me|todo|xxx|null|none|true|false|\s*$)/i;
19
+ export const isSecretValue = (name: string, value: string): boolean => SECRET_NAME.test(name) && !PUBLIC_NAME.test(name) && value.length >= 8 && !PLACEHOLDER.test(value.trim());
20
+
21
+ const blankComments = (t: string): string => t.replace(/<!--[\s\S]*?-->/g, (c) => c.replace(/[^\n]/g, ' '));
22
+ const view = (file: string, text: string): FileView => ({ file, text, masked: text, lang: 'kt', lines: new Lines(text), imports: '' });
23
+ const bool = (v: string | undefined): boolean | null => (v === 'true' ? true : v === 'false' ? false : null);
24
+
25
+ interface Tok { name: string; close: boolean; self: boolean; attrs: Record<string, string>; at: number; end: number }
26
+ function tokens(text: string): Tok[] {
27
+ const out: Tok[] = [];
28
+ for (const m of text.matchAll(/<(\/?)([A-Za-z_][\w.:-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g)) {
29
+ const attrs: Record<string, string> = {};
30
+ for (const a of m[3].matchAll(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[a[1]] = a[2] ?? a[3] ?? '';
31
+ out.push({ name: m[2], close: m[1] === '/', self: m[4] === '/', attrs, at: m.index!, end: m.index! + m[0].length });
32
+ }
33
+ return out;
34
+ }
35
+
36
+ const COMPONENTS = new Set(['activity', 'activity-alias', 'service', 'receiver', 'provider']);
37
+
38
+ export function readManifest(file: string, text: string, gradleTarget: number | null): { manifest: AndroidManifest; secrets: ConfigSecret[]; bounds: Map<AndroidComponent, [number, number]> } {
39
+ const clean = blankComments(text);
40
+ const lines = new Lines(text);
41
+ const toks = tokens(clean);
42
+ const secrets: ConfigSecret[] = [];
43
+ const bounds = new Map<AndroidComponent, [number, number]>();
44
+ const pkg = toks.find((t) => t.name === 'manifest')?.attrs.package ?? null;
45
+ const sdk = toks.find((t) => t.name === 'uses-sdk');
46
+ const targetRaw = sdk?.attrs['android:targetSdkVersion'];
47
+ const target = targetRaw && /^\d+$/.test(targetRaw) ? Number(targetRaw) : gradleTarget;
48
+ const app = toks.find((t) => t.name === 'application' && !t.close);
49
+ const manifest: AndroidManifest = {
50
+ file, package: pkg, line: lines.at(app?.at ?? 0), debuggable: bool(app?.attrs['android:debuggable']), allow_backup: bool(app?.attrs['android:allowBackup']),
51
+ uses_cleartext_traffic: bool(app?.attrs['android:usesCleartextTraffic']), network_security_config: app?.attrs['android:networkSecurityConfig'] ?? null,
52
+ target_sdk: target, permissions: toks.filter((t) => t.name === 'uses-permission' && !t.close).map((t) => t.attrs['android:name']).filter(Boolean).sort(), components: [],
53
+ };
54
+ let cur: AndroidComponent | null = null;
55
+ let curStart = 0;
56
+ let filter: AndroidFilter | null = null;
57
+ let hasFilter = false;
58
+ const finish = (endAt: number): void => {
59
+ if (!cur) return;
60
+ // A component with an intent filter and no explicit flag is exported before Android 12 (target SDK 31).
61
+ if (!cur.exported_explicit) cur.exported = hasFilter && (target === null || target < 31);
62
+ bounds.set(cur, [curStart, endAt]);
63
+ manifest.components.push(cur);
64
+ cur = null;
65
+ };
66
+ for (const t of toks) {
67
+ if (t.name === 'meta-data' && !t.close) {
68
+ const n = t.attrs['android:name'] ?? '';
69
+ const v = t.attrs['android:value'] ?? '';
70
+ if (isSecretValue(n, v)) secrets.push({ file, line: lines.at(t.at), key: n, where: 'android manifest' });
71
+ continue;
72
+ }
73
+ if (COMPONENTS.has(t.name)) {
74
+ if (t.close) { finish(t.end); continue; }
75
+ finish(t.at);
76
+ const nm = t.attrs['android:name'] ?? '';
77
+ const full = nm.startsWith('.') ? `${pkg ?? ''}${nm}` : nm.includes('.') ? nm : `${pkg ?? ''}.${nm}`;
78
+ const perm = t.attrs['android:permission'] ?? t.attrs['android:readPermission'] ?? t.attrs['android:writePermission'] ?? null;
79
+ const ex = t.attrs['android:exported'];
80
+ cur = { kind: t.name === 'activity-alias' ? 'activity' : (t.name as AndroidComponent['kind']), name: full, line: lines.at(t.at), exported: ex === 'true', exported_explicit: ex !== undefined, permission: perm, launcher: false, filters: [] };
81
+ curStart = t.at;
82
+ hasFilter = false;
83
+ if (t.self) finish(t.end);
84
+ continue;
85
+ }
86
+ if (!cur) continue;
87
+ if (t.name === 'intent-filter') { if (t.close) { if (filter) cur.filters.push(filter); filter = null; } else { filter = { actions: [], categories: [], schemes: [], hosts: [], paths: [] }; hasFilter = true; } continue; }
88
+ if (!filter || t.close) continue;
89
+ if (t.name === 'action') filter.actions.push(t.attrs['android:name'] ?? '');
90
+ else if (t.name === 'category') filter.categories.push(t.attrs['android:name'] ?? '');
91
+ else if (t.name === 'data') {
92
+ const a = t.attrs;
93
+ if (a['android:scheme']) filter.schemes.push(a['android:scheme']);
94
+ if (a['android:host']) filter.hosts.push(a['android:host']);
95
+ const p = a['android:path'] ?? a['android:pathPrefix'] ?? a['android:pathPattern'] ?? a['android:pathAdvancedPattern'];
96
+ if (p) filter.paths.push(p);
97
+ }
98
+ }
99
+ finish(clean.length);
100
+ for (const c of manifest.components) c.launcher = c.filters.length > 0 && c.filters.every((f) => f.actions.every((a) => a === 'android.intent.action.MAIN') && f.categories.includes('android.intent.category.LAUNCHER'));
101
+ for (const c of manifest.components) { c.filters.forEach((f) => { f.actions.sort(); f.categories.sort(); f.schemes.sort(); f.hosts.sort(); f.paths.sort(); }); }
102
+ manifest.components.sort((a, b) => a.line - b.line || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
103
+ return { manifest, secrets, bounds };
104
+ }
105
+
106
+ export function readNetworkSecurity(file: string, text: string): NetworkSecurity {
107
+ const clean = blankComments(text);
108
+ const lines = new Lines(text);
109
+ const toks = tokens(clean);
110
+ const base = toks.find((t) => t.name === 'base-config' && !t.close);
111
+ const domains: string[] = [];
112
+ let pins = 0;
113
+ let inDomainConfig: Tok | null = null;
114
+ let userCerts = false;
115
+ let cleartextDomains: string[] = [];
116
+ const pending: string[] = [];
117
+ for (const t of toks) {
118
+ if (t.name === 'domain-config') {
119
+ if (t.close) { if (inDomainConfig?.attrs.cleartextTrafficPermitted === 'true') cleartextDomains = cleartextDomains.concat(pending); pending.length = 0; inDomainConfig = null; } else { inDomainConfig = t; pending.length = 0; }
120
+ } else if (t.name === 'domain' && !t.close && inDomainConfig) { const d = clean.slice(t.end, clean.indexOf('<', t.end)).trim(); if (d) { domains.push(d); pending.push(d); } }
121
+ else if (t.name === 'pin' && !t.close) pins++;
122
+ else if (t.name === 'certificates' && t.attrs.src === 'user') userCerts = true;
123
+ }
124
+ return { file, line: lines.at(base?.at ?? 0), cleartext_base: bool(base?.attrs.cleartextTrafficPermitted), cleartext_domains: cleartextDomains.sort(), trusts_user_certs: userCerts, pinned_domains: pins };
125
+ }
126
+
127
+ // ---- Info.plist and entitlements -------------------------------------------------------------------------------------------------
128
+ type PVal = string | boolean | number | PVal[] | { [k: string]: PVal };
129
+ interface PlistRead { value: { [k: string]: PVal }; keyLines: Map<string, number> }
130
+ function readPlist(text: string): PlistRead | null {
131
+ const lines = new Lines(text);
132
+ const toks = [...blankComments(text).matchAll(/<(\/?)(dict|array|key|string|integer|real|true|false|data|date)\b[^>]*?(\/?)>/g)];
133
+ let i = 0;
134
+ const keyLines = new Map<string, number>();
135
+ const textAfter = (m: RegExpMatchArray): string => text.slice(m.index! + m[0].length, text.indexOf('<', m.index! + m[0].length));
136
+ const value = (): PVal => {
137
+ const t = toks[i++];
138
+ if (!t) return '';
139
+ if (t[2] === 'true') return true;
140
+ if (t[2] === 'false') return false;
141
+ if (t[2] === 'string' || t[2] === 'data' || t[2] === 'date') { const v = t[3] === '/' ? '' : textAfter(t); if (t[3] !== '/') i++; return v; }
142
+ if (t[2] === 'integer' || t[2] === 'real') { const v = Number(textAfter(t)); i++; return v; }
143
+ if (t[2] === 'array') { const a: PVal[] = []; while (i < toks.length && !(toks[i][1] === '/' && toks[i][2] === 'array')) a.push(value()); i++; return a; }
144
+ if (t[2] === 'dict') {
145
+ const d: { [k: string]: PVal } = {};
146
+ while (i < toks.length && !(toks[i][1] === '/' && toks[i][2] === 'dict')) {
147
+ const k = toks[i];
148
+ if (k[2] !== 'key' || k[1] === '/') { i++; continue; }
149
+ const name = textAfter(k);
150
+ keyLines.set(name, lines.at(k.index!));
151
+ i += 2;
152
+ d[name] = value();
153
+ }
154
+ i++;
155
+ return d;
156
+ }
157
+ return '';
158
+ };
159
+ const start = toks.findIndex((t) => t[2] === 'dict' && t[1] !== '/');
160
+ if (start < 0) return null;
161
+ i = start;
162
+ const v = value();
163
+ return typeof v === 'object' && !Array.isArray(v) ? { value: v, keyLines } : null;
164
+ }
165
+
166
+ const strList = (v: PVal | undefined): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []);
167
+
168
+ export function readPlistFile(file: string, text: string): { plist: IosPlist; secrets: ConfigSecret[]; urlSchemes: { scheme: string; line: number }[]; domains: { domain: string; line: number }[] } | null {
169
+ const r = readPlist(text);
170
+ if (!r) return null;
171
+ const d = r.value;
172
+ const ats = typeof d.NSAppTransportSecurity === 'object' && !Array.isArray(d.NSAppTransportSecurity) ? (d.NSAppTransportSecurity as { [k: string]: PVal }) : null;
173
+ const secrets: ConfigSecret[] = [];
174
+ for (const [k, v] of Object.entries(d)) if (typeof v === 'string' && isSecretValue(k, v)) secrets.push({ file, line: r.keyLines.get(k) ?? 1, key: k, where: 'ios plist' });
175
+ const types = Array.isArray(d.CFBundleURLTypes) ? d.CFBundleURLTypes : [];
176
+ const schemes = types.flatMap((t) => (typeof t === 'object' && !Array.isArray(t) ? strList((t as { [k: string]: PVal }).CFBundleURLSchemes) : []));
177
+ const line = r.keyLines.get('CFBundleURLTypes') ?? 1;
178
+ const assoc = strList(d['com.apple.developer.associated-domains']).filter((x) => x.startsWith('applinks:')).map((x) => x.slice('applinks:'.length).replace(/\?.*$/, ''));
179
+ return {
180
+ plist: {
181
+ file, ats_arbitrary_loads: ats && typeof ats.NSAllowsArbitraryLoads === 'boolean' ? (ats.NSAllowsArbitraryLoads as boolean) : null, ats_line: r.keyLines.get('NSAllowsArbitraryLoads') ?? null,
182
+ url_schemes: schemes.slice().sort(), associated_domains: assoc.slice().sort(), usage_descriptions: Object.keys(d).filter((k) => /UsageDescription$/.test(k)).sort(),
183
+ },
184
+ secrets, urlSchemes: schemes.map((scheme) => ({ scheme, line })), domains: assoc.map((domain) => ({ domain, line: r.keyLines.get('com.apple.developer.associated-domains') ?? 1 })),
185
+ };
186
+ }
187
+
188
+ const SKIP = /(?:^|\/)(?:Pods|Carthage|\.build|DerivedData|node_modules|build|intermediates)\//;
189
+
190
+ export function mobileFiles(repo: Repo): { manifests: string[]; plists: string[]; netsec: string[]; strings: string[] } {
191
+ const f = repo.ctx.files.filter((x) => !SKIP.test(x));
192
+ return {
193
+ manifests: f.filter((x) => /(?:^|\/)AndroidManifest\.xml$/.test(x) && !/\/build\//.test(x)),
194
+ netsec: f.filter((x) => /(?:^|\/)res\/xml\/[\w-]*network_security_config\w*\.xml$/.test(x)),
195
+ strings: f.filter((x) => /(?:^|\/)res\/values[\w-]*\/strings\.xml$/.test(x)),
196
+ plists: f.filter((x) => /(?:^|\/)Info\.plist$|\.entitlements$/.test(x)),
197
+ };
198
+ }
199
+
200
+ const gradleTarget = (repo: Repo): number | null => {
201
+ for (const f of repo.ctx.files.filter((x) => /(?:^|\/)build\.gradle(?:\.kts)?$/.test(x))) {
202
+ const m = /\btargetSdk(?:Version)?\s*[= ]\s*(\d+)/.exec(repo.ctx.text(f));
203
+ if (m) return Number(m[1]);
204
+ }
205
+ return null;
206
+ };
207
+
208
+ export function mobileFacts(repo: Repo): MobileFacts {
209
+ const files = mobileFiles(repo);
210
+ const secrets: ConfigSecret[] = [];
211
+ let android: AndroidConfig | null = null;
212
+ if (files.manifests.length || files.netsec.length) {
213
+ const target = gradleTarget(repo);
214
+ android = { manifests: [], network_security: files.netsec.map((f) => readNetworkSecurity(f, repo.ctx.text(f))) };
215
+ for (const f of files.manifests) { const r = readManifest(f, repo.ctx.text(f), target); android.manifests.push(r.manifest); secrets.push(...r.secrets); }
216
+ for (const f of files.strings) {
217
+ const t = repo.ctx.text(f);
218
+ const lines = new Lines(t);
219
+ for (const m of blankComments(t).matchAll(/<string\s+name="([^"]+)"[^>]*>([^<]*)<\/string>/g)) if (isSecretValue(m[1], m[2])) secrets.push({ file: f, line: lines.at(m.index!), key: m[1], where: 'android strings' });
220
+ }
221
+ }
222
+ let ios: IosConfig | null = null;
223
+ const plists = files.plists.map((f) => ({ f, r: readPlistFile(f, repo.ctx.text(f)) })).filter((x) => x.r !== null);
224
+ if (plists.length) { ios = { plists: plists.map((x) => x.r!.plist) }; for (const x of plists) secrets.push(...x.r!.secrets); }
225
+ const sorted = secrets.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) || a.line - b.line || (a.key < b.key ? -1 : 1));
226
+ return { android, ios, secrets: sorted };
227
+ }
228
+
229
+ // ---- entry points -----------------------------------------------------------------------------------------------------------------
230
+ export function extractMobile(repo: Repo): Extracted {
231
+ const out = empty();
232
+ const files = mobileFiles(repo);
233
+ const target = gradleTarget(repo);
234
+ for (const f of files.manifests) {
235
+ const text = repo.ctx.text(f);
236
+ const v = view(f, text);
237
+ const { manifest, bounds } = readManifest(f, text, target);
238
+ for (const c of manifest.components) {
239
+ if (!c.exported || c.launcher) continue;
240
+ const [s, e] = bounds.get(c) ?? [0, 0];
241
+ const prot: Protection[] = c.permission ? [{ kind: 'role_check', scope: 'handler', file: f, line: c.line, via: `android:permission ${c.permission}`, enforced: true, confidence: 'high' }] : [];
242
+ const analysis = { protections: prot, reads: [], data: [], outbound: [], taint: { names: new Set<string>(), validated: new Set<string>(), trusted: new Set<string>() } };
243
+ const links = c.filters.filter((x) => x.actions.includes('android.intent.action.VIEW') && x.categories.includes('android.intent.category.BROWSABLE') && x.schemes.length);
244
+ if (c.kind === 'activity' && links.length) {
245
+ const names = new Set<string>();
246
+ for (const fl of links) for (const sc of fl.schemes) for (const h of fl.hosts.length ? fl.hosts : ['']) for (const p of fl.paths.length ? fl.paths : ['']) names.add(`${sc}://${h}${p}`);
247
+ for (const n of [...names].sort()) out.entries.push(makeEntry(repo, { kind: 'deep_link', framework: 'android', file: f, line: c.line, method: null, path: null, name: n, confidence: 'high', handlerView: v, spec: { start: s, end: e, seeds: [] }, analysis, tags: ['public'] }));
248
+ continue;
249
+ }
250
+ out.entries.push(makeEntry(repo, { kind: 'ipc', framework: 'android', file: f, line: c.line, method: null, path: null, name: c.name, confidence: c.exported_explicit ? 'high' : 'medium', handlerView: v, spec: { start: s, end: e, seeds: [] }, analysis }));
251
+ }
252
+ }
253
+ for (const f of files.plists) {
254
+ const text = repo.ctx.text(f);
255
+ const r = readPlistFile(f, text);
256
+ if (!r) continue;
257
+ const v = view(f, text);
258
+ const analysis = { protections: [] as Protection[], reads: [], data: [], outbound: [], taint: { names: new Set<string>(), validated: new Set<string>(), trusted: new Set<string>() } };
259
+ for (const u of r.urlSchemes) out.entries.push(makeEntry(repo, { kind: 'deep_link', framework: 'ios', file: f, line: u.line, method: null, path: null, name: `${u.scheme}://`, confidence: 'high', handlerView: v, spec: { start: 0, end: 0, seeds: [] }, analysis, tags: ['public'] }));
260
+ for (const d of r.domains) out.entries.push(makeEntry(repo, { kind: 'deep_link', framework: 'ios', file: f, line: d.line, method: null, path: null, name: `https://${d.domain}/`, confidence: 'high', handlerView: v, spec: { start: 0, end: 0, seeds: [] }, analysis, tags: ['public'] }));
261
+ }
262
+ return out;
263
+ }
@@ -0,0 +1,239 @@
1
+ // Django and Django REST framework entry points. Repository level: the URL configuration is read from the root down, so a
2
+ // view's full path includes the prefixes of every include() above it.
3
+ import type { Gate, Protection } from '../types/index.ts';
4
+ import type { FileView, Spec } from './analyze.ts';
5
+ import { empty, joinPath, makeEntry, normPath, type Extracted } from './entry.ts';
6
+ import { closeOf, pyBlock, splitArgs } from './lex.ts';
7
+ import { classDecos, decoratorGroups, pyParams, pyClassMethods, resolvePyModule } from './py.ts';
8
+ import type { Repo } from './repo.ts';
9
+
10
+ interface Pattern { route: string; viewText: string; line: number; includeFile: string | null; routerName: string | null; list: string | null; includeList: string | null }
11
+ interface UrlConf { file: string; view: FileView; patterns: Pattern[]; routers: Map<string, { prefix: string; viewset: string; line: number }[]> }
12
+
13
+ const GENERIC: Record<string, string[]> = {
14
+ ListAPIView: ['GET'], CreateAPIView: ['POST'], RetrieveAPIView: ['GET'], UpdateAPIView: ['PUT', 'PATCH'], DestroyAPIView: ['DELETE'],
15
+ ListCreateAPIView: ['GET', 'POST'], RetrieveUpdateAPIView: ['GET', 'PUT', 'PATCH'], RetrieveDestroyAPIView: ['GET', 'DELETE'],
16
+ RetrieveUpdateDestroyAPIView: ['GET', 'PUT', 'PATCH', 'DELETE'], ModelViewSet: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
17
+ ReadOnlyModelViewSet: ['GET'], ListModelMixin: ['GET'], CreateModelMixin: ['POST'], DestroyModelMixin: ['DELETE'],
18
+ };
19
+
20
+ const routeText = (t: string): string | null => /^\s*[rRuU]?(['"])((?:\\.|(?!\1).)*)\1/.exec(t)?.[2] ?? null;
21
+ const cleanRoute = (r: string): string => normPath(r.replace(/^\^/, '').replace(/\$$/, '').replace(/\(\?P<(\w+)>[^)]*\)/g, ':$1').replace(/\\\//g, '/'));
22
+
23
+ function parseUrlConf(repo: Repo, v: FileView): UrlConf | null {
24
+ if (!/\burlpatterns\b/.test(v.masked)) return null;
25
+ const conf: UrlConf = { file: v.file, view: v, patterns: [], routers: new Map() };
26
+ // Lists of patterns defined in this file (api_urls = [...]): a pattern belongs to the innermost list that holds it.
27
+ const lists: { name: string; from: number; to: number }[] = [];
28
+ for (const l of v.masked.matchAll(/^(\w+)\s*(?::[^=\n]+)?=\s*\[/gm)) { const open = l.index! + l[0].length - 1; const close = closeOf(v.masked, open); if (close > 0) lists.push({ name: l[1], from: open, to: close }); }
29
+ for (const m of v.masked.matchAll(/(?<![\w.])(path|re_path|url)\s*\(/g)) {
30
+ const open = m.index! + m[0].length - 1;
31
+ const args = splitArgs(v.masked, open);
32
+ if (args.length < 2) continue;
33
+ const route = routeText(v.text.slice(args[0].start, args[0].end));
34
+ if (route === null) continue;
35
+ const viewText = v.text.slice(args[1].start, args[1].end);
36
+ const inc = /^include\s*\(\s*([\s\S]*)\)\s*$/.exec(viewText);
37
+ let includeFile: string | null = null;
38
+ let routerName: string | null = null;
39
+ let includeList: string | null = null;
40
+ if (inc) {
41
+ const lit = /^\(?\s*['"]([\w.]+)['"]/.exec(inc[1])?.[1];
42
+ const rn = /^(\w+)\.urls/.exec(inc[1])?.[1];
43
+ const own = /^\(?\s*(\w+)\s*\)?\s*$/.exec(inc[1])?.[1];
44
+ if (own && lists.some((x) => x.name === own)) includeList = own;
45
+ else if (lit) includeFile = resolvePyModule(repo, v.file, lit);
46
+ else if (rn) routerName = rn;
47
+ else { const id = /^\(?\s*(\w+)/.exec(inc[1])?.[1]; if (id) { const imp = [...v.text.matchAll(new RegExp(`from\\s+([\\w.]+)\\s+import\\s+[^\\n]*\\b${id}\\b`, 'g'))][0]; includeFile = resolvePyModule(repo, v.file, imp ? `${imp[1]}.${id}` : id) ?? (imp ? resolvePyModule(repo, v.file, imp[1]) : null); } }
48
+ }
49
+ const holder = lists.filter((x) => m.index! > x.from && m.index! < x.to).sort((a, b) => (a.to - a.from) - (b.to - b.from))[0];
50
+ conf.patterns.push({ route: cleanRoute(route), viewText: inc ? '' : viewText, line: v.lines.at(m.index!), includeFile, routerName, list: holder?.name ?? null, includeList });
51
+ }
52
+ for (const m of v.masked.matchAll(/\b(\w+)\.register\s*\(/g)) {
53
+ const open = m.index! + m[0].length - 1;
54
+ const args = splitArgs(v.masked, open);
55
+ if (args.length < 2) continue;
56
+ const prefix = routeText(v.text.slice(args[0].start, args[0].end));
57
+ const vs = /^[A-Za-z_][\w.]*/.exec(v.text.slice(args[1].start, args[1].end))?.[0];
58
+ if (prefix === null || !vs) continue;
59
+ const list = conf.routers.get(m[1]) ?? [];
60
+ list.push({ prefix: cleanRoute(prefix), viewset: vs.split('.').pop()!, line: v.lines.at(m.index!) });
61
+ conf.routers.set(m[1], list);
62
+ }
63
+ return conf;
64
+ }
65
+
66
+ // The function a url pattern names. A dotted reference (views.add) is followed through the import in the url file, a bare name through
67
+ // "from x import name", and otherwise a def of that name in a views module wins over one in a model, an admin class or a command.
68
+ function pickView(repo: Repo, urls: FileView, expr: string, fnName: string): ReturnType<Repo['defs']>[number] | undefined {
69
+ const defs = repo.defs('py', fnName);
70
+ if (defs.length <= 1) return defs[0];
71
+ const ref = /^([\w.]+)/.exec(expr)?.[1] ?? fnName;
72
+ const parts = ref.split('.');
73
+ let file: string | null = null;
74
+ if (parts.length > 1) {
75
+ const mod = parts[parts.length - 2];
76
+ const imp = new RegExp(`from\\s+([.\\w]+)\\s+import\\s+(?:[^\\n]*,\\s*)?${mod}\\b|import\\s+([.\\w]+)\\s+as\\s+${mod}\\b`).exec(urls.text);
77
+ if (imp) file = imp[2] ? resolvePyModule(repo, urls.file, imp[2]) : (resolvePyModule(repo, urls.file, `${imp[1].replace(/\.$/, '')}.${mod}`) ?? resolvePyModule(repo, urls.file, imp[1]));
78
+ } else {
79
+ const imp = new RegExp(`from\\s+([.\\w]+)\\s+import\\s+[^\\n]*\\b${fnName}\\b`).exec(urls.text);
80
+ if (imp) file = resolvePyModule(repo, urls.file, imp[1]);
81
+ }
82
+ const hit = file ? defs.filter((x) => x.view.file === file) : [];
83
+ if (hit.length) return hit[0];
84
+ const dir = urls.file.includes('/') ? urls.file.slice(0, urls.file.lastIndexOf('/') + 1) : '';
85
+ const rank = (f: string): number => (/(?:^|\/)views?(?:\/|\.py$|_)/.test(f) ? 0 : f.startsWith(dir) ? 1 : 2) + (/(?:admin|models?|management|tests?)(?:\/|\.py$)/.test(f) ? 3 : 0);
86
+ return defs.slice().sort((a, b) => rank(a.view.file) - rank(b.view.file))[0];
87
+ }
88
+
89
+ function findClass(repo: Repo, name: string, near: string): { view: FileView; at: number } | null {
90
+ const files = [near, ...repo.filesOf('py').filter((f) => f !== near)];
91
+ for (const f of files) {
92
+ const view = repo.view(f);
93
+ if (!view) continue;
94
+ const m = new RegExp(`^class\\s+${name}\\b[^\\n]*:`, 'm').exec(view.masked);
95
+ if (m) return { view, at: m.index };
96
+ }
97
+ return null;
98
+ }
99
+
100
+ export function extractDjango(repo: Repo): Extracted {
101
+ const out = empty();
102
+ const confs = new Map<string, UrlConf>();
103
+ for (const f of repo.filesOf('py')) {
104
+ if (!/urls?\.py$|urlconf|routing|routes/.test(f) && !/urlpatterns/.test(repo.ctx.text(f))) continue;
105
+ const v = repo.view(f);
106
+ const c = v ? parseUrlConf(repo, v) : null;
107
+ if (c) confs.set(f, c);
108
+ }
109
+ if (!confs.size) return out;
110
+ const included = new Set<string>();
111
+ for (const c of confs.values()) for (const p of c.patterns) if (p.includeFile) included.add(p.includeFile);
112
+ const roots = [...confs.keys()].filter((f) => !included.has(f)).sort();
113
+ const emitted = new Set<string>();
114
+
115
+ const emitView = (c: UrlConf, prefix: string, p: Pattern, viewExpr: string, route: string, depth: number): void => {
116
+ void depth;
117
+ const full = joinPath(prefix, route);
118
+ const v = c.view;
119
+ if (/admin\.site\.urls/.test(viewExpr)) {
120
+ const e = makeEntry(repo, { kind: 'http_route', framework: 'django-admin', file: v.file, line: p.line, method: 'ANY', path: joinPath(full, '*'), name: 'django admin', confidence: 'medium', handlerView: v, spec: { start: 0, end: 0, seeds: [], decorators: [], returnsJson: false }, tags: ['admin'] });
121
+ e.protections.push({ kind: 'session_read', scope: 'global', file: v.file, line: p.line, via: 'django admin site login', enforced: true, confidence: 'high' }, { kind: 'role_check', scope: 'global', file: v.file, line: p.line, via: 'django admin is_staff', enforced: true, confidence: 'high' });
122
+ out.entries.push(e);
123
+ return;
124
+ }
125
+ const asView = /^([\w.]+?)\.as_view\s*\(/.exec(viewExpr);
126
+ if (asView) {
127
+ const cls = asView[1].split('.').pop()!;
128
+ const found = findClass(repo, cls, v.file);
129
+ if (!found) return;
130
+ emitClass(repo, found.view, found.at, cls, full, out);
131
+ return;
132
+ }
133
+ const fnName = /^([\w.]+)\s*(?:,|$)/.exec(viewExpr.trim())?.[1]?.split('.').pop();
134
+ if (!fnName) return;
135
+ const d = pickView(repo, v, viewExpr.trim(), fnName);
136
+ if (!d) return;
137
+ const b = pyBlock(d.view.masked, d.view.lines, d.start);
138
+ const g = decoratorGroups(d.view).find((x) => x.name === fnName && x.kind === 'def');
139
+ const decoText = g ? g.decos.map((x) => d.view.text.slice(x.start, x.end)).join('\n') : '';
140
+ const apiView = /@api_view\s*\(\s*\[([^\]]*)\]/.exec(decoText)?.[1];
141
+ const reqMethods = /@require_http_methods\s*\(\s*[\[(]([^\])]*)[\])]/.exec(decoText)?.[1];
142
+ const single = /@require_(GET|POST|safe)\b/.exec(decoText)?.[1];
143
+ const ms = apiView || reqMethods ? [...(apiView ?? reqMethods)!.matchAll(/['"](\w+)['"]/g)].map((x) => x[1].toUpperCase()) : single ? (single === 'safe' ? ['GET', 'HEAD'] : [single]) : ['ANY'];
144
+ const deco: NonNullable<Spec['decorators']> = g ? g.decos.map((x) => ({ view: d.view, start: x.start, end: x.end, scope: 'router' as const })) : [];
145
+ for (const method of ms.length ? ms : ['ANY']) {
146
+ out.entries.push(makeEntry(repo, { kind: 'http_route', framework: 'django', file: d.view.file, line: d.view.lines.at(d.start), method, path: full, name: fnName, confidence: 'medium', handlerView: d.view, spec: { start: d.start, end: b.end, seeds: [...pyParams(d.view.masked, d.start), 'request'], decorators: deco, returnsJson: true } }));
147
+ }
148
+ };
149
+
150
+ const walk = (file: string, prefix: string, seen: string[], list: string | null = null): void => {
151
+ const c = confs.get(file);
152
+ if (!c || (list === null && seen.includes(file))) return;
153
+ // The top level is urlpatterns, or every pattern outside a named helper list; a helper list is read where an include() names it.
154
+ const named = new Set(c.patterns.filter((x) => x.includeList).map((x) => x.includeList));
155
+ for (const p of c.patterns) {
156
+ if (list === null ? (p.list !== null && p.list !== 'urlpatterns' && named.has(p.list)) : p.list !== list) continue;
157
+ const joined = joinPath(prefix, p.route);
158
+ if (p.includeList) { if (!seen.includes(`${file}#${p.includeList}`)) walk(file, joined, [...seen, `${file}#${p.includeList}`], p.includeList); continue; }
159
+ if (p.includeFile) { walk(p.includeFile, joined, [...seen, file]); continue; }
160
+ if (p.routerName) {
161
+ for (const r of c.routers.get(p.routerName) ?? []) {
162
+ const found = findClass(repo, r.viewset, file);
163
+ if (!found) continue;
164
+ const base = joinPath(joined, r.prefix);
165
+ emitClass(repo, found.view, found.at, r.viewset, base, out, true);
166
+ }
167
+ continue;
168
+ }
169
+ if (!p.viewText) continue;
170
+ const key = `${file}:${p.line}:${prefix}:${p.route}:${list ?? ''}`;
171
+ if (emitted.has(key)) continue;
172
+ emitted.add(key);
173
+ emitView(c, prefix, p, p.viewText, p.route, seen.length);
174
+ }
175
+ };
176
+ for (const r of roots.length ? roots : [...confs.keys()].sort()) walk(r, '', []);
177
+
178
+ // REST framework settings that apply to every view.
179
+ for (const f of repo.filesOf('py')) {
180
+ if (!/settings/.test(f)) continue;
181
+ const v = repo.view(f);
182
+ const m = v && /REST_FRAMEWORK\s*=\s*\{/.exec(v.masked);
183
+ if (!v || !m) continue;
184
+ const open = v.masked.indexOf('{', m.index);
185
+ const close = closeOf(v.masked, open);
186
+ const body = v.text.slice(open, close);
187
+ const line = v.lines.at(m.index);
188
+ const protections: Protection[] = [];
189
+ if (/DEFAULT_PERMISSION_CLASSES[^\]]*IsAuthenticated(?!OrReadOnly)/.test(body)) protections.push({ kind: 'session_read', scope: 'global', file: f, line, via: 'DEFAULT_PERMISSION_CLASSES IsAuthenticated', enforced: true, confidence: 'high' });
190
+ if (/DEFAULT_PERMISSION_CLASSES[^\]]*IsAdminUser/.test(body)) protections.push({ kind: 'role_check', scope: 'global', file: f, line, via: 'DEFAULT_PERMISSION_CLASSES IsAdminUser', enforced: true, confidence: 'high' });
191
+ if (/DEFAULT_THROTTLE_CLASSES/.test(body)) protections.push({ kind: 'rate_limit', scope: 'global', file: f, line, via: 'DEFAULT_THROTTLE_CLASSES', enforced: true, confidence: 'high' });
192
+ if (protections.length) out.gates.push({ id: `${f}:${line}:REST_FRAMEWORK`, kind: 'guard', framework: 'django', file: f, line, paths: ['*'], methods: [], protections, confidence: 'high' } as Gate);
193
+ }
194
+ return out;
195
+ }
196
+
197
+ function emitClass(repo: Repo, view: FileView, at: number, cls: string, full: string, out: Extracted, viewset = false): void {
198
+ const b = pyBlock(view.masked, view.lines, at);
199
+ const header = view.masked.slice(at, b.bodyStart);
200
+ const bases = (/\(([^)]*)\)/.exec(header)?.[1] ?? '').split(',').map((x) => x.trim().split('.').pop()!).filter(Boolean);
201
+ const decos = classDecos(view, cls);
202
+ const explicit = pyClassMethods(repo, view, cls);
203
+ const implied = bases.flatMap((x) => GENERIC[x] ?? []);
204
+ const seen = new Set<string>();
205
+ const detail = joinPath(full, ':pk');
206
+ for (const cm of explicit) {
207
+ const path = viewset && /^(GET|PUT|PATCH|DELETE)$/.test(cm.method) && !/list/i.test(cm.method) ? full : full;
208
+ out.entries.push(makeEntry(repo, { kind: 'http_route', framework: 'django', file: cm.view.file, line: cm.view.lines.at(cm.start), method: cm.method, path, name: `${cls}.${cm.method.toLowerCase()}`, confidence: 'medium', handlerView: cm.view, spec: { start: cm.start, end: cm.end, seeds: [...pyParams(cm.view.masked, cm.start), 'request'], decorators: decos, returnsJson: true } }));
209
+ seen.add(cm.method);
210
+ }
211
+ // ViewSet actions (list, create, retrieve, update, partial_update, destroy) defined as methods.
212
+ if (viewset) {
213
+ const actions: [string, string, boolean][] = [['list', 'GET', false], ['create', 'POST', false], ['retrieve', 'GET', true], ['update', 'PUT', true], ['partial_update', 'PATCH', true], ['destroy', 'DELETE', true]];
214
+ for (const x of view.masked.slice(b.bodyStart, b.end).matchAll(/^[ \t]+(?:async\s+)?def\s+(list|create|retrieve|update|partial_update|destroy)\s*\(/gm)) {
215
+ const a = actions.find((y) => y[0] === x[1])!;
216
+ const ls = view.masked.lastIndexOf('\n', b.bodyStart + x.index! + 1) + 1;
217
+ const mb = pyBlock(view.masked, view.lines, ls);
218
+ out.entries.push(makeEntry(repo, { kind: 'http_route', framework: 'django', file: view.file, line: view.lines.at(ls), method: a[1], path: a[2] ? detail : full, name: `${cls}.${x[1]}`, confidence: 'medium', handlerView: view, spec: { start: ls, end: mb.end, seeds: [...pyParams(view.masked, ls), 'request'], decorators: decos, returnsJson: true } }));
219
+ seen.add(`${a[1]}:${a[2]}`);
220
+ }
221
+ for (const x of view.masked.slice(b.bodyStart, b.end).matchAll(/@action\s*\(([^)]*)\)\s*\n[ \t]*(?:async\s+)?def\s+(\w+)\s*\(/g)) {
222
+ const ms = /methods\s*=\s*[\[(]([^\])]*)[\])]/.exec(view.text.slice(b.bodyStart + x.index!, b.bodyStart + x.index! + x[0].length))?.[1];
223
+ const methods = ms ? [...ms.matchAll(/['"](\w+)['"]/g)].map((y) => y[1].toUpperCase()) : ['GET'];
224
+ const ls = view.masked.lastIndexOf('\n', b.bodyStart + x.index! + x[0].length - 2) + 1;
225
+ const mb = pyBlock(view.masked, view.lines, ls);
226
+ const det = /detail\s*=\s*True/.test(view.text.slice(b.bodyStart + x.index!, b.bodyStart + x.index! + x[0].length));
227
+ for (const method of methods) out.entries.push(makeEntry(repo, { kind: 'http_route', framework: 'django', file: view.file, line: view.lines.at(ls), method, path: joinPath(det ? detail : full, x[2]), name: `${cls}.${x[2]}`, confidence: 'medium', handlerView: view, spec: { start: ls, end: mb.end, seeds: [...pyParams(view.masked, ls), 'request'], decorators: decos, returnsJson: true } }));
228
+ }
229
+ }
230
+ // Methods implied by a generic base class (ListCreateAPIView, ModelViewSet ...) that the class does not override.
231
+ if (viewset && bases.some((x) => GENERIC[x])) {
232
+ const have = (m: string, d: boolean): boolean => seen.has(`${m}:${d}`);
233
+ const all: [string, boolean][] = bases.includes('ReadOnlyModelViewSet') ? [['GET', false], ['GET', true]] : [['GET', false], ['POST', false], ['GET', true], ['PUT', true], ['PATCH', true], ['DELETE', true]];
234
+ const action = (method: string, det: boolean): string => (method === 'GET' ? (det ? 'retrieve' : 'list') : method === 'POST' ? 'create' : method === 'PUT' ? 'update' : method === 'PATCH' ? 'partial_update' : 'destroy');
235
+ for (const [method, det] of all) if (!have(method, det)) out.entries.push(makeEntry(repo, { kind: 'http_route', framework: 'django', file: view.file, line: view.lines.at(at), method, path: det ? detail : full, name: `${cls}.${action(method, det)}`, confidence: 'medium', handlerView: view, spec: { start: at, end: b.end, seeds: ['request'], decorators: decos, returnsJson: true } }));
236
+ } else if (!viewset) {
237
+ for (const method of implied) if (!seen.has(method)) out.entries.push(makeEntry(repo, { kind: 'http_route', framework: 'django', file: view.file, line: view.lines.at(at), method, path: full, name: `${cls}.${method.toLowerCase()}`, confidence: 'medium', handlerView: view, spec: { start: at, end: b.end, seeds: ['request'], decorators: decos, returnsJson: true } }));
238
+ }
239
+ }