stackvitals 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 (465) hide show
  1. mergescout/__init__.py +1 -0
  2. mergescout/archive/__init__.py +0 -0
  3. mergescout/archive/__main__.py +30 -0
  4. mergescout/archive/convert.py +83 -0
  5. mergescout/archive/events.py +55 -0
  6. mergescout/archive/manifest.py +45 -0
  7. mergescout/archive/stream.py +83 -0
  8. mergescout/bench/__init__.py +1 -0
  9. mergescout/bench/__main__.py +135 -0
  10. mergescout/bench/ci.py +257 -0
  11. mergescout/bench/deploys.py +115 -0
  12. mergescout/bench/matched.py +22 -0
  13. mergescout/bench/prior.py +42 -0
  14. mergescout/bench/report.py +230 -0
  15. mergescout/bench/shared.py +112 -0
  16. mergescout/bench/stars.py +57 -0
  17. mergescout/cli.py +89 -0
  18. mergescout/config.py +35 -0
  19. mergescout/crawl/__init__.py +0 -0
  20. mergescout/crawl/__main__.py +97 -0
  21. mergescout/crawl/chain.py +34 -0
  22. mergescout/crawl/fetch.py +146 -0
  23. mergescout/crawl/outcomes.py +69 -0
  24. mergescout/crawl/queries.py +250 -0
  25. mergescout/crawl/runner.py +176 -0
  26. mergescout/crawl/seed.py +77 -0
  27. mergescout/crawl/state.py +169 -0
  28. mergescout/crawl/writer.py +87 -0
  29. mergescout/discover/__init__.py +0 -0
  30. mergescout/discover/__main__.py +58 -0
  31. mergescout/discover/sweep.py +286 -0
  32. mergescout/github/__init__.py +0 -0
  33. mergescout/github/graphql.py +202 -0
  34. mergescout/github/identities.py +35 -0
  35. mergescout/github/releases.py +137 -0
  36. mergescout/labels.py +20 -0
  37. mergescout/mcp_server.py +228 -0
  38. mergescout/measure/__init__.py +0 -0
  39. mergescout/measure/__main__.py +75 -0
  40. mergescout/measure/api_cost.py +91 -0
  41. mergescout/measure/count_repos.py +30 -0
  42. mergescout/measure/jsonl.py +14 -0
  43. mergescout/measure/report.py +56 -0
  44. mergescout/measure/sample.py +37 -0
  45. mergescout/owners.py +22 -0
  46. mergescout/setup_check.py +64 -0
  47. mergescout/signals/__init__.py +0 -0
  48. mergescout/signals/__main__.py +96 -0
  49. mergescout/signals/bump_ledger.py +258 -0
  50. mergescout/signals/bumps.py +104 -0
  51. mergescout/signals/check.py +141 -0
  52. mergescout/signals/community_bench.py +149 -0
  53. mergescout/signals/delivery_bench.py +103 -0
  54. mergescout/signals/deps_map.py +261 -0
  55. mergescout/signals/deps_trends.py +117 -0
  56. mergescout/signals/evidence.py +94 -0
  57. mergescout/signals/exploits.py +43 -0
  58. mergescout/signals/flow_bench.py +258 -0
  59. mergescout/signals/identity.py +73 -0
  60. mergescout/signals/issues.py +76 -0
  61. mergescout/signals/labels.py +79 -0
  62. mergescout/signals/osv.py +232 -0
  63. mergescout/signals/owners_watch.py +80 -0
  64. mergescout/signals/repo_stats.py +96 -0
  65. mergescout/signals/rules.py +70 -0
  66. mergescout/signals/silver.py +33 -0
  67. mergescout/signals/top_packages.py +26 -0
  68. mergescout/signals/triage.py +71 -0
  69. mergescout/signals/triage_math.py +120 -0
  70. mergescout/signals/walls.py +91 -0
  71. mergescout/snapshot/__init__.py +0 -0
  72. mergescout/snapshot/__main__.py +215 -0
  73. mergescout/snapshot/build.py +147 -0
  74. mergescout/snapshot/publish.py +70 -0
  75. mergescout/snapshot/schema.py +34 -0
  76. mergescout/snapshot/score.py +30 -0
  77. mergescout/store/__init__.py +0 -0
  78. mergescout/store/__main__.py +46 -0
  79. mergescout/store/sync.py +148 -0
  80. mergescout/timeutil.py +11 -0
  81. mergescout/tool/__init__.py +0 -0
  82. mergescout/tool/action_pack.py +62 -0
  83. mergescout/tool/live.py +201 -0
  84. mergescout/tool/queries.py +293 -0
  85. mergescout/tool/refs.py +31 -0
  86. mergescout/tool/store.py +129 -0
  87. mergescout/tool/tracking.py +83 -0
  88. stackvitals/__init__.py +2 -0
  89. stackvitals/__main__.py +4 -0
  90. stackvitals/ci/__init__.py +1 -0
  91. stackvitals/ci/action.py +574 -0
  92. stackvitals/ci/analyze.py +577 -0
  93. stackvitals/ci/api.py +218 -0
  94. stackvitals/ci/automation.py +212 -0
  95. stackvitals/ci/bench.py +195 -0
  96. stackvitals/ci/burnin.py +102 -0
  97. stackvitals/ci/cli.py +570 -0
  98. stackvitals/ci/collect.py +239 -0
  99. stackvitals/ci/cost.py +107 -0
  100. stackvitals/ci/coverage.py +155 -0
  101. stackvitals/ci/feedback.py +699 -0
  102. stackvitals/ci/fingerprint.py +220 -0
  103. stackvitals/ci/gitlab.py +121 -0
  104. stackvitals/ci/gitlab_action.py +125 -0
  105. stackvitals/ci/impact.py +125 -0
  106. stackvitals/ci/investigate.py +59 -0
  107. stackvitals/ci/kinds.py +52 -0
  108. stackvitals/ci/monitors.py +105 -0
  109. stackvitals/ci/owners.py +116 -0
  110. stackvitals/ci/quarantine_runtime.py +90 -0
  111. stackvitals/ci/report.py +435 -0
  112. stackvitals/ci/retry.py +67 -0
  113. stackvitals/ci/selfheal.py +102 -0
  114. stackvitals/ci/shards.py +120 -0
  115. stackvitals/ci/share.py +59 -0
  116. stackvitals/ci/store.py +93 -0
  117. stackvitals/ci/testreports.py +351 -0
  118. stackvitals/ci/tickets.py +106 -0
  119. stackvitals/ci/track_record.json +11 -0
  120. stackvitals/ci/triage.py +130 -0
  121. stackvitals/ci/triggers.py +147 -0
  122. stackvitals/ci/upload.py +85 -0
  123. stackvitals/ci/validate.py +91 -0
  124. stackvitals/cli.py +111 -0
  125. stackvitals/codeowners.py +69 -0
  126. stackvitals/community/__init__.py +0 -0
  127. stackvitals/community/action.py +140 -0
  128. stackvitals/community/affiliation.py +51 -0
  129. stackvitals/community/analyze.py +507 -0
  130. stackvitals/community/build.py +56 -0
  131. stackvitals/community/chaoss.py +70 -0
  132. stackvitals/community/cli.py +79 -0
  133. stackvitals/community/collect.py +240 -0
  134. stackvitals/community/context.py +54 -0
  135. stackvitals/community/report.py +250 -0
  136. stackvitals/connectors/__init__.py +244 -0
  137. stackvitals/connectors/ai_spend.py +190 -0
  138. stackvitals/connectors/base.py +82 -0
  139. stackvitals/connectors/check.py +686 -0
  140. stackvitals/connectors/copilot.py +104 -0
  141. stackvitals/connectors/discord.py +103 -0
  142. stackvitals/connectors/discourse.py +117 -0
  143. stackvitals/connectors/incident_io.py +106 -0
  144. stackvitals/connectors/jira.py +210 -0
  145. stackvitals/connectors/linear.py +140 -0
  146. stackvitals/connectors/pagerduty.py +96 -0
  147. stackvitals/connectors/slack.py +110 -0
  148. stackvitals/connectors/sonar.py +77 -0
  149. stackvitals/connectors/store.py +120 -0
  150. stackvitals/connectors/tracker.py +108 -0
  151. stackvitals/connectors/writeback.py +105 -0
  152. stackvitals/contribute/__init__.py +1 -0
  153. stackvitals/contribute/brief.py +297 -0
  154. stackvitals/contribute/cli.py +64 -0
  155. stackvitals/core/__init__.py +1 -0
  156. stackvitals/core/actors.py +103 -0
  157. stackvitals/core/agent_ledger.py +120 -0
  158. stackvitals/core/agreements.py +95 -0
  159. stackvitals/core/ai_outcomes.py +276 -0
  160. stackvitals/core/alerts.py +224 -0
  161. stackvitals/core/appdata.py +112 -0
  162. stackvitals/core/bucket.py +190 -0
  163. stackvitals/core/bugs.py +43 -0
  164. stackvitals/core/calendar.py +34 -0
  165. stackvitals/core/capex.py +57 -0
  166. stackvitals/core/change_risk.py +181 -0
  167. stackvitals/core/changes.py +133 -0
  168. stackvitals/core/chat.py +143 -0
  169. stackvitals/core/config.py +373 -0
  170. stackvitals/core/dataflow.py +168 -0
  171. stackvitals/core/decisions.py +275 -0
  172. stackvitals/core/delivery.py +954 -0
  173. stackvitals/core/delivery_peers.py +98 -0
  174. stackvitals/core/delivery_report.py +429 -0
  175. stackvitals/core/deliverycli.py +147 -0
  176. stackvitals/core/devlake_dora.sql +485 -0
  177. stackvitals/core/digest.py +333 -0
  178. stackvitals/core/explaincli.py +107 -0
  179. stackvitals/core/explore.py +296 -0
  180. stackvitals/core/export.py +342 -0
  181. stackvitals/core/exportcli.py +117 -0
  182. stackvitals/core/findings.py +350 -0
  183. stackvitals/core/health.py +94 -0
  184. stackvitals/core/identity.py +82 -0
  185. stackvitals/core/insights.py +365 -0
  186. stackvitals/core/insightscli.py +43 -0
  187. stackvitals/core/installdoc.py +69 -0
  188. stackvitals/core/investment.py +187 -0
  189. stackvitals/core/joins.py +43 -0
  190. stackvitals/core/llmsdoc.py +40 -0
  191. stackvitals/core/merge_queue.py +70 -0
  192. stackvitals/core/metrics.py +109 -0
  193. stackvitals/core/model.py +285 -0
  194. stackvitals/core/projectcli.py +66 -0
  195. stackvitals/core/propose.py +86 -0
  196. stackvitals/core/pulse.py +85 -0
  197. stackvitals/core/recorded.py +145 -0
  198. stackvitals/core/releases.py +39 -0
  199. stackvitals/core/requests.py +528 -0
  200. stackvitals/core/resync.py +35 -0
  201. stackvitals/core/schema.py +205 -0
  202. stackvitals/core/services.py +203 -0
  203. stackvitals/core/settings.py +96 -0
  204. stackvitals/core/sqlcli.py +111 -0
  205. stackvitals/core/stats.py +222 -0
  206. stackvitals/core/store.py +125 -0
  207. stackvitals/core/summary.py +104 -0
  208. stackvitals/core/time.py +29 -0
  209. stackvitals/core/tomledit.py +149 -0
  210. stackvitals/core/uicli.py +138 -0
  211. stackvitals/core/webapp.py +79 -0
  212. stackvitals/core/what_changed.py +76 -0
  213. stackvitals/core/workspace.py +194 -0
  214. stackvitals/deps/__init__.py +1 -0
  215. stackvitals/deps/action.py +299 -0
  216. stackvitals/deps/actions_vulns.py +89 -0
  217. stackvitals/deps/cli.py +77 -0
  218. stackvitals/deps/dossier.py +341 -0
  219. stackvitals/deps/evidence.py +44 -0
  220. stackvitals/deps/fixplan.py +40 -0
  221. stackvitals/deps/guarddog.py +32 -0
  222. stackvitals/deps/hygiene.py +47 -0
  223. stackvitals/deps/imports.py +113 -0
  224. stackvitals/deps/lockfiles.py +198 -0
  225. stackvitals/deps/lookalike.py +80 -0
  226. stackvitals/deps/reach.py +59 -0
  227. stackvitals/deps/release.py +110 -0
  228. stackvitals/deps/runtimes.py +161 -0
  229. stackvitals/deps/supply.py +454 -0
  230. stackvitals/deps/vulns.py +588 -0
  231. stackvitals/editors/__init__.py +40 -0
  232. stackvitals/editors/vscode/extension.js +101 -0
  233. stackvitals/editors/vscode/marks.js +92 -0
  234. stackvitals/editors/vscode/package.json +26 -0
  235. stackvitals/flow/__init__.py +1 -0
  236. stackvitals/flow/action.py +178 -0
  237. stackvitals/flow/analyze.py +418 -0
  238. stackvitals/flow/automate.py +72 -0
  239. stackvitals/flow/build.py +46 -0
  240. stackvitals/flow/cli.py +93 -0
  241. stackvitals/flow/code_hosts.py +146 -0
  242. stackvitals/flow/collect.py +451 -0
  243. stackvitals/flow/gitlab_mr.py +75 -0
  244. stackvitals/flow/knowledge.py +202 -0
  245. stackvitals/flow/peers.py +90 -0
  246. stackvitals/flow/preflight.py +210 -0
  247. stackvitals/flow/report.py +253 -0
  248. stackvitals/flow/rules.py +135 -0
  249. stackvitals/flow/store.py +108 -0
  250. stackvitals/flow/survival.py +84 -0
  251. stackvitals/flow/triage.py +62 -0
  252. stackvitals/mcp_server.py +670 -0
  253. stackvitals/portal/__init__.py +0 -0
  254. stackvitals/portal/action.py +205 -0
  255. stackvitals/portal/batch.py +80 -0
  256. stackvitals/portal/campaigns.py +104 -0
  257. stackvitals/portal/catalog.py +104 -0
  258. stackvitals/portal/checks.py +63 -0
  259. stackvitals/portal/cli.py +172 -0
  260. stackvitals/portal/descriptor.py +105 -0
  261. stackvitals/portal/export.py +43 -0
  262. stackvitals/portal/github.py +48 -0
  263. stackvitals/portal/golden.py +95 -0
  264. stackvitals/portal/history.py +30 -0
  265. stackvitals/portal/inventory.py +264 -0
  266. stackvitals/portal/owners.py +78 -0
  267. stackvitals/portal/pages.py +153 -0
  268. stackvitals/portal/rollup.py +32 -0
  269. stackvitals/portal/scorecard.py +366 -0
  270. stackvitals/security/__init__.py +2 -0
  271. stackvitals/security/probes.py +154 -0
  272. stackvitals/security/repo_checks.py +132 -0
  273. stackvitals/security/workflows.py +111 -0
  274. stackvitals/shell.py +107 -0
  275. stackvitals/site.py +51 -0
  276. stackvitals/web/app/LICENSES.md +428 -0
  277. stackvitals/web/app/boot.js +1 -0
  278. stackvitals/web/app/chunks/AddRepo-KRPZXWCY.js +1 -0
  279. stackvitals/web/app/chunks/SetupRunning-XWFQAKRZ.js +1 -0
  280. stackvitals/web/app/chunks/accuracy-data-OQTDXI7Y.js +1 -0
  281. stackvitals/web/app/chunks/agents-data-BAL6QAOF.js +11 -0
  282. stackvitals/web/app/chunks/chunk-24LFKFZ6.js +1 -0
  283. stackvitals/web/app/chunks/chunk-3G4SQNUC.js +1 -0
  284. stackvitals/web/app/chunks/chunk-3IXWEOIJ.js +154 -0
  285. stackvitals/web/app/chunks/chunk-3ROQFHPV.js +1 -0
  286. stackvitals/web/app/chunks/chunk-45PGAQRO.js +8 -0
  287. stackvitals/web/app/chunks/chunk-4H53OYLG.js +1 -0
  288. stackvitals/web/app/chunks/chunk-4LUI7O5P.js +1 -0
  289. stackvitals/web/app/chunks/chunk-4SQPQ5TB.js +1 -0
  290. stackvitals/web/app/chunks/chunk-4WKQTWUC.js +2 -0
  291. stackvitals/web/app/chunks/chunk-576YHDEK.js +8 -0
  292. stackvitals/web/app/chunks/chunk-6BDBXA6V.js +1 -0
  293. stackvitals/web/app/chunks/chunk-6RLVD2DT.js +1 -0
  294. stackvitals/web/app/chunks/chunk-763NH7PP.js +1 -0
  295. stackvitals/web/app/chunks/chunk-76WAVKUG.js +1 -0
  296. stackvitals/web/app/chunks/chunk-7II2BD5F.js +1 -0
  297. stackvitals/web/app/chunks/chunk-7TJCBOAY.js +1 -0
  298. stackvitals/web/app/chunks/chunk-ASYNLVOP.js +1 -0
  299. stackvitals/web/app/chunks/chunk-CTKDYZMG.js +1 -0
  300. stackvitals/web/app/chunks/chunk-CVB26NMC.js +1 -0
  301. stackvitals/web/app/chunks/chunk-G7NGB5UX.js +1 -0
  302. stackvitals/web/app/chunks/chunk-GPVLJRZD.js +1 -0
  303. stackvitals/web/app/chunks/chunk-GQYFN4PE.js +75 -0
  304. stackvitals/web/app/chunks/chunk-GT4EAKAO.js +2 -0
  305. stackvitals/web/app/chunks/chunk-IYMBFPCV.js +1 -0
  306. stackvitals/web/app/chunks/chunk-LAAGCPU3.js +1 -0
  307. stackvitals/web/app/chunks/chunk-MAPYGK3P.js +1 -0
  308. stackvitals/web/app/chunks/chunk-P5TXDD45.js +1 -0
  309. stackvitals/web/app/chunks/chunk-PABICGBL.js +1 -0
  310. stackvitals/web/app/chunks/chunk-RPXHAG6F.js +1 -0
  311. stackvitals/web/app/chunks/chunk-RQZPSJ2Y.js +1 -0
  312. stackvitals/web/app/chunks/chunk-S2ZBMEVA.js +1 -0
  313. stackvitals/web/app/chunks/chunk-S654BBEP.js +6 -0
  314. stackvitals/web/app/chunks/chunk-T7QVTNXF.js +1 -0
  315. stackvitals/web/app/chunks/chunk-THFUOCPC.js +273 -0
  316. stackvitals/web/app/chunks/chunk-TO4VVRZ7.js +1 -0
  317. stackvitals/web/app/chunks/chunk-VQ5YXO54.js +1 -0
  318. stackvitals/web/app/chunks/chunk-VWKSMFWJ.js +1 -0
  319. stackvitals/web/app/chunks/chunk-Y6LUXDM6.js +1 -0
  320. stackvitals/web/app/chunks/chunk-YTANGYYH.js +1 -0
  321. stackvitals/web/app/chunks/connect-KONYWYTT.js +1 -0
  322. stackvitals/web/app/chunks/connections-data-2DYS4UVW.js +32 -0
  323. stackvitals/web/app/chunks/logos-N5IINUAM.js +1 -0
  324. stackvitals/web/app/chunks/more-KMJMJQD3.js +10 -0
  325. stackvitals/web/app/chunks/more-PC6FVS3P.js +1 -0
  326. stackvitals/web/app/chunks/more-RCIVCUDE.js +2 -0
  327. stackvitals/web/app/chunks/more-ZKKRY6NO.css +1 -0
  328. stackvitals/web/app/chunks/sealbox-35YL4EUH.js +3 -0
  329. stackvitals/web/app/chunks/session-DYYTOE2K.js +1 -0
  330. stackvitals/web/app/chunks/settingsApply-2HRJEZYM.js +1 -0
  331. stackvitals/web/app/chunks/settingsReview-GX2FZQ4M.js +3 -0
  332. stackvitals/web/app/chunks/uPlot.esm-OIWT3AHJ.js +3 -0
  333. stackvitals/web/app/demo/ci/impact.json +1 -0
  334. stackvitals/web/app/demo/ci/quarantine.json +1 -0
  335. stackvitals/web/app/demo/ci/tickets.json +1 -0
  336. stackvitals/web/app/demo/mobile/alerts.json +1 -0
  337. stackvitals/web/app/demo/mobile/app.json +1 -0
  338. stackvitals/web/app/demo/mobile/community.json +1 -0
  339. stackvitals/web/app/demo/mobile/connectors.json +1 -0
  340. stackvitals/web/app/demo/mobile/data.json +1 -0
  341. stackvitals/web/app/demo/mobile/decisions.json +1 -0
  342. stackvitals/web/app/demo/mobile/delivery.json +1 -0
  343. stackvitals/web/app/demo/mobile/deps.json +1 -0
  344. stackvitals/web/app/demo/mobile/flow.json +1 -0
  345. stackvitals/web/app/demo/mobile/health.json +1 -0
  346. stackvitals/web/app/demo/mobile/insights.json +1 -0
  347. stackvitals/web/app/demo/mobile/pulse.json +1 -0
  348. stackvitals/web/app/demo/mobile/security.json +1 -0
  349. stackvitals/web/app/demo/mobile/summary/ci_jobs.jsonl +5 -0
  350. stackvitals/web/app/demo/mobile/summary/devlake_project_pr_metrics.jsonl +14 -0
  351. stackvitals/web/app/demo/mobile/summary/devlake_pull_requests.jsonl +14 -0
  352. stackvitals/web/app/demo/mobile/summary/manifest.json +1 -0
  353. stackvitals/web/app/demo/mobile/summary/prs.jsonl +19 -0
  354. stackvitals/web/app/demo/mobile/summary/tests.jsonl +1 -0
  355. stackvitals/web/app/demo/mobile/supply.json +1 -0
  356. stackvitals/web/app/demo/mobile/vulns.json +1 -0
  357. stackvitals/web/app/demo/payments-api/alerts.json +1 -0
  358. stackvitals/web/app/demo/payments-api/app.json +1 -0
  359. stackvitals/web/app/demo/payments-api/community.json +1 -0
  360. stackvitals/web/app/demo/payments-api/connectors.json +1 -0
  361. stackvitals/web/app/demo/payments-api/data.json +1 -0
  362. stackvitals/web/app/demo/payments-api/decisions.json +1 -0
  363. stackvitals/web/app/demo/payments-api/delivery.json +1 -0
  364. stackvitals/web/app/demo/payments-api/deps.json +1 -0
  365. stackvitals/web/app/demo/payments-api/flow.json +1 -0
  366. stackvitals/web/app/demo/payments-api/health.json +1 -0
  367. stackvitals/web/app/demo/payments-api/insights.json +1 -0
  368. stackvitals/web/app/demo/payments-api/pulse.json +1 -0
  369. stackvitals/web/app/demo/payments-api/security.json +1 -0
  370. stackvitals/web/app/demo/payments-api/summary/ci_jobs.jsonl +5 -0
  371. stackvitals/web/app/demo/payments-api/summary/devlake_project_pr_metrics.jsonl +14 -0
  372. stackvitals/web/app/demo/payments-api/summary/devlake_pull_requests.jsonl +14 -0
  373. stackvitals/web/app/demo/payments-api/summary/manifest.json +1 -0
  374. stackvitals/web/app/demo/payments-api/summary/prs.jsonl +22 -0
  375. stackvitals/web/app/demo/payments-api/summary/tests.jsonl +3 -0
  376. stackvitals/web/app/demo/payments-api/supply.json +1 -0
  377. stackvitals/web/app/demo/payments-api/vulns.json +1 -0
  378. stackvitals/web/app/demo/portal.json +1 -0
  379. stackvitals/web/app/demo/settings.json +1 -0
  380. stackvitals/web/app/demo/web-app/alerts.json +1 -0
  381. stackvitals/web/app/demo/web-app/app.json +1 -0
  382. stackvitals/web/app/demo/web-app/community.json +1 -0
  383. stackvitals/web/app/demo/web-app/connectors.json +1 -0
  384. stackvitals/web/app/demo/web-app/data.json +1 -0
  385. stackvitals/web/app/demo/web-app/decisions.json +1 -0
  386. stackvitals/web/app/demo/web-app/delivery.json +1 -0
  387. stackvitals/web/app/demo/web-app/deps.json +1 -0
  388. stackvitals/web/app/demo/web-app/flow.json +1 -0
  389. stackvitals/web/app/demo/web-app/health.json +1 -0
  390. stackvitals/web/app/demo/web-app/insights.json +1 -0
  391. stackvitals/web/app/demo/web-app/pulse.json +1 -0
  392. stackvitals/web/app/demo/web-app/security.json +1 -0
  393. stackvitals/web/app/demo/web-app/summary/ci_jobs.jsonl +5 -0
  394. stackvitals/web/app/demo/web-app/summary/devlake_project_pr_metrics.jsonl +14 -0
  395. stackvitals/web/app/demo/web-app/summary/devlake_pull_requests.jsonl +14 -0
  396. stackvitals/web/app/demo/web-app/summary/manifest.json +1 -0
  397. stackvitals/web/app/demo/web-app/summary/prs.jsonl +18 -0
  398. stackvitals/web/app/demo/web-app/summary/tests.jsonl +2 -0
  399. stackvitals/web/app/demo/web-app/supply.json +1 -0
  400. stackvitals/web/app/demo/web-app/vulns.json +1 -0
  401. stackvitals/web/app/fonts/plex-mono-400.woff2 +0 -0
  402. stackvitals/web/app/fonts/plex-sans-400.woff2 +0 -0
  403. stackvitals/web/app/fonts/plex-sans-600.woff2 +0 -0
  404. stackvitals/web/app/fonts/plex-sans-condensed-700.woff2 +0 -0
  405. stackvitals/web/app/help/accuracy.json +1 -0
  406. stackvitals/web/app/help/connectors.json +1 -0
  407. stackvitals/web/app/help/getting-started.json +1 -0
  408. stackvitals/web/app/help/index.json +1 -0
  409. stackvitals/web/app/help/modules.json +1 -0
  410. stackvitals/web/app/help/privacy-and-security.json +1 -0
  411. stackvitals/web/app/help/reference.json +1 -0
  412. stackvitals/web/app/main.css +1 -0
  413. stackvitals/web/app/main.js +1 -0
  414. stackvitals/web/app/pages/about.css +1 -0
  415. stackvitals/web/app/pages/about.js +1 -0
  416. stackvitals/web/app/pages/activity.css +1 -0
  417. stackvitals/web/app/pages/activity.js +1 -0
  418. stackvitals/web/app/pages/agents.css +1 -0
  419. stackvitals/web/app/pages/agents.js +2 -0
  420. stackvitals/web/app/pages/ci.css +1 -0
  421. stackvitals/web/app/pages/ci.js +1 -0
  422. stackvitals/web/app/pages/community.css +1 -0
  423. stackvitals/web/app/pages/community.js +1 -0
  424. stackvitals/web/app/pages/connections.css +1 -0
  425. stackvitals/web/app/pages/connections.js +6 -0
  426. stackvitals/web/app/pages/delivery.css +1 -0
  427. stackvitals/web/app/pages/delivery.js +45 -0
  428. stackvitals/web/app/pages/deps.css +1 -0
  429. stackvitals/web/app/pages/deps.js +7 -0
  430. stackvitals/web/app/pages/explore.css +1 -0
  431. stackvitals/web/app/pages/explore.js +7 -0
  432. stackvitals/web/app/pages/health.css +1 -0
  433. stackvitals/web/app/pages/health.js +1 -0
  434. stackvitals/web/app/pages/help.css +1 -0
  435. stackvitals/web/app/pages/help.js +6 -0
  436. stackvitals/web/app/pages/insights.css +1 -0
  437. stackvitals/web/app/pages/insights.js +1 -0
  438. stackvitals/web/app/pages/investment.css +1 -0
  439. stackvitals/web/app/pages/investment.js +3 -0
  440. stackvitals/web/app/pages/overview.css +1 -0
  441. stackvitals/web/app/pages/overview.js +1 -0
  442. stackvitals/web/app/pages/portal.css +1 -0
  443. stackvitals/web/app/pages/portal.js +51 -0
  444. stackvitals/web/app/pages/prs.css +1 -0
  445. stackvitals/web/app/pages/prs.js +4 -0
  446. stackvitals/web/app/pages/settings.css +1 -0
  447. stackvitals/web/app/pages/settings.js +1 -0
  448. stackvitals/web/app/pages/setup.css +1 -0
  449. stackvitals/web/app/pages/setup.js +2 -0
  450. stackvitals/web/app/pages/standing.css +1 -0
  451. stackvitals/web/app/pages/standing.js +1 -0
  452. stackvitals/web/app/pages/welcome-sheet.js +1 -0
  453. stackvitals/web/app/pages/welcome.js +1 -0
  454. stackvitals/web/app/settings-workflow.json +1 -0
  455. stackvitals/web/index.html +34 -0
  456. stackvitals/web/site/index.html +108 -0
  457. stackvitals/web/site/integrations.html +15 -0
  458. stackvitals/web/site/setup.html +15 -0
  459. stackvitals/web/site/site.css +68 -0
  460. stackvitals-0.1.0.dist-info/METADATA +134 -0
  461. stackvitals-0.1.0.dist-info/RECORD +465 -0
  462. stackvitals-0.1.0.dist-info/WHEEL +4 -0
  463. stackvitals-0.1.0.dist-info/entry_points.txt +3 -0
  464. stackvitals-0.1.0.dist-info/licenses/LICENSE +202 -0
  465. stackvitals-0.1.0.dist-info/licenses/NOTICE +16 -0
@@ -0,0 +1,588 @@
1
+ """Known vulnerabilities in every package of every lockfile, in every ecosystem OSV covers (npm, PyPI, Go, Rust, Maven,
2
+ RubyGems, NuGet, Composer and more), from Google's OSV-Scanner run on the repo's lockfiles. The binary is pinned and
3
+ checked against its published SHA-256; the lockfiles are read through the API, so the repo's code is never checked out.
4
+ With outside lookups off, the scanner uses OSV's downloaded databases, so no package name leaves the runner."""
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import platform
9
+ import re
10
+ import stat
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ from datetime import datetime, timedelta
15
+ from pathlib import Path
16
+
17
+ from stackvitals.core.time import iso
18
+
19
+ SCANNER = "2.7.0"
20
+ RELEASES = f"https://github.com/google/osv-scanner/releases/download/v{SCANNER}"
21
+ SHA256 = {
22
+ "osv-scanner_linux_amd64": "160c6253e2922e9e0a6a9771c5c4c40a4b3c6090b34a3cdd5e80c96cc2e4218b",
23
+ "osv-scanner_linux_arm64": "4061ef4c021ed3c47f203b818f337e351336cc5cd67c0274fc36bd1dfc728dd7",
24
+ "osv-scanner_darwin_amd64": "e82c5df7ab0a42dff29a1cd0660711447b56b4c0483ff66ad8634df10fd81a45",
25
+ "osv-scanner_darwin_arm64": "d4926ef8da134332543be276dff157e4c170f99ad91fc48e89448a8816bdf70b",
26
+ "osv-scanner_windows_amd64.exe": "9964ed64ec9a387391eb4cc006ad5c531154821aef29b793e02c448de4686438",
27
+ "osv-scanner_windows_arm64.exe": "3e630381c1f1fd3c9c4d447e6ff2ef6c737083371722773db8337eead0f97962",
28
+ }
29
+ # Every lockfile and manifest the scanner reads (osv-scalibr's extractors), by file name.
30
+ LOCKFILES = re.compile(
31
+ r"(^|/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lock|deno\.lock"
32
+ r"|requirements[^/]*\.txt|poetry\.lock|uv\.lock|Pipfile\.lock|pdm\.lock|pylock(\.[^/]+)?\.toml"
33
+ r"|go\.mod|Cargo\.lock|Gemfile\.lock|gems\.locked|composer\.lock|packages\.lock\.json|paket\.lock"
34
+ r"|(buildscript-)?gradle\.lockfile|verification-metadata\.xml|pom\.xml|mix\.lock|pubspec\.lock"
35
+ r"|Package\.resolved|renv\.lock|conan\.lock|cabal\.project\.freeze|stack\.yaml\.lock"
36
+ # .NET project files (a side-by-side found a remote-code-execution advisory we missed for want of these), and SBOMs.
37
+ r"|[^/]+\.(csproj|fsproj|vbproj)|packages\.config|Directory\.Packages\.props"
38
+ r"|[^/]+\.spdx(\.json)?|[^/]+\.cdx\.(json|xml)|bom\.(json|xml))$")
39
+ # Lockfiles in these folders belong to vendored code, tests or fixtures, not to what the project ships.
40
+ SKIP = re.compile(r"(^|/)(node_modules|vendor|third_party|testdata|test-data|fixtures?|__fixtures__|\.git)/")
41
+ MAX_FILES, MAX_BYTES = 200, 5_000_000
42
+ RESCAN = timedelta(hours=6) # new advisories for unchanged lockfiles
43
+ LEVELS = ((9.0, "critical"), (7.0, "high"), (4.0, "moderate"), (0.1, "low"))
44
+
45
+
46
+ class ScannerError(RuntimeError):
47
+ pass
48
+
49
+
50
+ def asset() -> str | None:
51
+ system = {"linux": "linux", "darwin": "darwin", "win32": "windows"}.get(sys.platform)
52
+ machine = platform.machine().lower()
53
+ arch = "arm64" if machine in ("arm64", "aarch64") else "amd64" if machine in ("x86_64", "amd64") else None
54
+ if not system or not arch:
55
+ return None
56
+ return f"osv-scanner_{system}_{arch}" + (".exe" if system == "windows" else "")
57
+
58
+
59
+ def ensure_scanner(cache_dir: Path, client=None) -> Path:
60
+ """The pinned scanner, downloaded once and kept; refused unless it matches the published SHA-256."""
61
+ name = asset()
62
+ if not name:
63
+ raise ScannerError(f"no OSV-Scanner build for {sys.platform} {platform.machine()}")
64
+ path = Path(cache_dir) / f"osv-scanner-{SCANNER}" / name
65
+ if path.exists() and _sha256(path.read_bytes()) == SHA256[name]:
66
+ return path
67
+ own = client is None
68
+ if own:
69
+ import httpx
70
+ client = httpx.Client(timeout=120, follow_redirects=True, headers={"User-Agent": "stackvitals"})
71
+ try:
72
+ body = client.get(f"{RELEASES}/{name}").raise_for_status().content
73
+ except Exception as exc: # noqa: BLE001 - any download trouble is said, never fatal
74
+ raise ScannerError(f"OSV-Scanner download failed ({type(exc).__name__})") from exc
75
+ finally:
76
+ if own:
77
+ client.close()
78
+ if _sha256(body) != SHA256[name]:
79
+ raise ScannerError("OSV-Scanner download didn't match its published SHA-256; not run")
80
+ path.parent.mkdir(parents=True, exist_ok=True)
81
+ path.write_bytes(body)
82
+ path.chmod(path.stat().st_mode | stat.S_IXUSR)
83
+ return path
84
+
85
+
86
+ def tool_dir(env: dict) -> Path:
87
+ # The runner's tool cache, which the Action keeps between runs; a temp folder elsewhere.
88
+ return Path(env.get("RUNNER_TOOL_CACHE") or tempfile.gettempdir()) / "stackvitals"
89
+
90
+
91
+ def _sha256(data: bytes) -> str:
92
+ return hashlib.sha256(data).hexdigest()
93
+
94
+
95
+ def scannable(path: str) -> bool:
96
+ return bool(LOCKFILES.search(path)) and not SKIP.search(path)
97
+
98
+
99
+ def lockfile_blobs(api, ref: str) -> list[dict]:
100
+ """The repo's lockfiles at a commit, from one tree read: path, blob sha and size."""
101
+ tree = api._get(f"/repos/{api.repo}/git/trees/{ref}", {"recursive": "1"})
102
+ return [{"path": t["path"], "sha": t["sha"], "size": t.get("size") or 0} for t in tree.get("tree") or []
103
+ if t.get("type") == "blob" and scannable(t["path"])][:MAX_FILES]
104
+
105
+
106
+ DOT = "_dot"
107
+
108
+
109
+ def _plain(path: str) -> str:
110
+ # The scanner skips folders named with a leading dot (.devcontainer), so they get a plain name while scanning.
111
+ return "/".join(DOT + s if s.startswith(".") else s for s in path.split("/"))
112
+
113
+
114
+ def _real(path: str) -> str:
115
+ return "/".join(s[len(DOT):] if s.startswith(DOT + ".") else s for s in path.split("/"))
116
+
117
+
118
+ def fetch(api, blobs: list[dict], ref: str, dest: Path) -> list[str]:
119
+ """Write each lockfile under `dest` at its own path; very large ones are skipped and named."""
120
+ skipped = []
121
+ for b in blobs:
122
+ if b["size"] > MAX_BYTES:
123
+ skipped.append(b["path"])
124
+ continue
125
+ target = dest / _plain(b["path"])
126
+ target.parent.mkdir(parents=True, exist_ok=True)
127
+ target.write_bytes(api.file(b["path"], ref))
128
+ return skipped
129
+
130
+
131
+ def run_scanner(binary: Path, folder: Path, offline: bool, timeout: int = 600, all_packages: bool = False) -> dict:
132
+ """The scanner's JSON report. Exit 1 means it found vulnerabilities; anything else non-zero is trouble."""
133
+ # No call analysis: it needs the source, which isn't fetched, and with Go installed (as on GitHub's runners) its
134
+ # failure on a lone go.mod failed the whole scan. Reachability comes from govulncheck run in the repo's own workflow.
135
+ args = [str(binary), "scan", "source", "-r", "--format", "json", "--no-call-analysis=go",
136
+ *(["--all-packages"] if all_packages else [])]
137
+ if offline:
138
+ # OSV's own databases, downloaded whole: the package names never leave the runner; no resolving either.
139
+ args += ["--offline-vulnerabilities", "--download-offline-databases", "--no-resolve"]
140
+ # Downloaded databases are kept beside the scanner, so a cached tool folder reuses them.
141
+ run = subprocess.run([*args, str(folder)], capture_output=True, timeout=timeout,
142
+ env={**os.environ, "NO_COLOR": "1", "OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY": str(Path(binary).parent / "db")})
143
+ if run.returncode not in (0, 1) or not run.stdout.strip():
144
+ tail = run.stderr.decode("utf-8", "replace").strip().splitlines()[-1:] or ["no output"]
145
+ raise ScannerError(f"OSV-Scanner exit {run.returncode}: {tail[0][:200]}")
146
+ return json.loads(run.stdout.decode("utf-8"))
147
+
148
+
149
+ def _key(version: str) -> list:
150
+ return [(0, int(p)) if p.isdigit() else (1, p) for p in re.split(r"[.\-+_]", str(version)) if p]
151
+
152
+
153
+ def _fixed(vulns: list[dict], ids: set[str], ecosystem: str, name: str, version: str) -> str | None:
154
+ # The lowest version that fixes every advisory of the group and is newer than the one installed.
155
+ best = None
156
+ for v in vulns:
157
+ if v.get("id") not in ids:
158
+ continue
159
+ for a in v.get("affected") or []:
160
+ pkg = a.get("package") or {}
161
+ if pkg.get("name") != name or pkg.get("ecosystem") != ecosystem:
162
+ continue
163
+ for r in a.get("ranges") or []:
164
+ for e in r.get("events") or []:
165
+ f = e.get("fixed")
166
+ if f and _key(f) > _key(version) and (best is None or _key(f) < _key(best)):
167
+ best = f
168
+ return best
169
+
170
+
171
+ def _level(group: dict, vulns: list[dict], ids: set[str]) -> str:
172
+ try:
173
+ score = float(group.get("max_severity") or 0)
174
+ except ValueError:
175
+ score = 0
176
+ for floor, name in LEVELS:
177
+ if score >= floor:
178
+ return name
179
+ labels = [str((v.get("database_specific") or {}).get("severity") or "").lower() for v in vulns if v.get("id") in ids]
180
+ for name in ("critical", "high", "moderate", "medium", "low"):
181
+ if name in labels:
182
+ return "moderate" if name == "medium" else name
183
+ return "unrated"
184
+
185
+
186
+ def findings(doc: dict, root: Path) -> list[dict]:
187
+ """One row per advisory group per package per lockfile, with the version that fixes it."""
188
+ out, base = [], str(Path(root).resolve()).replace("\\", "/").rstrip("/") + "/"
189
+ for result in doc.get("results") or []:
190
+ source = str((result.get("source") or {}).get("path") or "").replace("\\", "/")
191
+ file = source[len(base):] if source.startswith(base) else source.rsplit("/", 1)[-1]
192
+ for p in result.get("packages") or []:
193
+ pkg, vulns = p.get("package") or {}, p.get("vulnerabilities") or []
194
+ for g in p.get("groups") or []:
195
+ ids = set(g.get("ids") or [])
196
+ main = sorted(ids, key=lambda i: (not i.startswith("MAL-"), not i.startswith("GHSA-"), i))[0] if ids else "?"
197
+ out.append({"id": main, "aliases": sorted(set(g.get("aliases") or []) - {main}), "file": file,
198
+ "ecosystem": pkg.get("ecosystem"), "package": pkg.get("name"), "version": pkg.get("version"),
199
+ "severity": _level(g, vulns, ids), "malware": any(i.startswith("MAL-") for i in ids),
200
+ "fixed": _fixed(vulns, ids, pkg.get("ecosystem"), pkg.get("name"), pkg.get("version") or ""),
201
+ "summary": next((v.get("summary") for v in vulns if v.get("id") in ids and v.get("summary")), "")})
202
+ return out
203
+
204
+
205
+ def dev_only(root: Path) -> set[tuple[str, str]]:
206
+ """(lockfile, package) pairs a lockfile marks as development-only: npm's "dev": true, pnpm's dev: true, and
207
+ Pipfile.lock's develop section (when not also in default). Other formats don't say, so nothing is guessed."""
208
+ out = set()
209
+ for path in Path(root).rglob("*"):
210
+ rel = path.relative_to(root).as_posix()
211
+ try:
212
+ if path.name in ("package-lock.json", "npm-shrinkwrap.json"):
213
+ doc = json.loads(path.read_text(encoding="utf-8"))
214
+ for key, meta in (doc.get("packages") or {}).items():
215
+ if key and isinstance(meta, dict) and meta.get("dev"):
216
+ out.add((rel, key.rsplit("node_modules/", 1)[-1].lower()))
217
+ elif path.name == "Pipfile.lock":
218
+ doc = json.loads(path.read_text(encoding="utf-8"))
219
+ runtime = {k.lower() for k in doc.get("default") or {}}
220
+ out |= {(rel, k.lower()) for k in doc.get("develop") or {} if k.lower() not in runtime}
221
+ elif path.name == "pnpm-lock.yaml":
222
+ import yaml
223
+ doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
224
+ for key, meta in (doc.get("packages") or {}).items():
225
+ if isinstance(meta, dict) and meta.get("dev") is True:
226
+ name = str(key).lstrip("/")
227
+ out.add((rel, name.rsplit("@", 1)[0].lower() if not name.startswith("@") or name.count("@") > 1 else name.lower()))
228
+ except (OSError, ValueError, AttributeError):
229
+ continue
230
+ return out
231
+
232
+
233
+ def scan(api, ref: str, binary: Path, offline: bool, blobs: list[dict] | None = None) -> dict:
234
+ """Fetch the lockfiles at `ref` into a scratch folder, scan them, and return what was found."""
235
+ blobs = lockfile_blobs(api, ref) if blobs is None else blobs
236
+ if not blobs:
237
+ return {"files": [], "skipped": [], "findings": [], "packages": []}
238
+ with tempfile.TemporaryDirectory(prefix="stackvitals-osv-") as tmp:
239
+ skipped = fetch(api, blobs, ref, Path(tmp))
240
+ doc = run_scanner(binary, Path(tmp), offline, all_packages=True)
241
+ dev = dev_only(Path(tmp))
242
+ # A package only development uses (test tools, linters) is marked, so it can wait behind what ships.
243
+ rows = [{**f, "file": _real(f["file"]), **({"dev": True} if (f["file"], str(f.get("package") or "").lower()) in dev else {})}
244
+ for f in findings(doc, Path(tmp))]
245
+ return {"files": [b["path"] for b in blobs if b["path"] not in skipped], "skipped": skipped,
246
+ "findings": rows, "packages": [[_real(p[0]), *p[1:]] for p in packages_of(doc, Path(tmp))]}
247
+
248
+
249
+ def pr_diff(api, paths: list[tuple[str, str | None]], base: str, head: str, binary: Path, offline: bool,
250
+ timeout: int = 600) -> dict:
251
+ """Known vulnerabilities a pull request adds or removes in the lockfiles it changes: each lockfile is scanned at
252
+ the base and head commits (a renamed one at its old path) and the advisories compared per package."""
253
+ def found(ref: str, wanted: list[tuple[str, str]]) -> set:
254
+ # wanted: (path at this commit, path at head), so a renamed file's rows line up.
255
+ with tempfile.TemporaryDirectory(prefix="stackvitals-osv-") as tmp:
256
+ names = {}
257
+ for i, (at, as_head) in enumerate(wanted):
258
+ try:
259
+ body = api.file(at, ref)
260
+ except Exception as exc: # noqa: BLE001 - a lockfile the PR adds has no base version
261
+ if "404" in str(exc):
262
+ continue
263
+ raise
264
+ # One folder per file keeps two lockfiles of the same name apart.
265
+ target = Path(tmp) / str(i) / Path(at).name
266
+ target.parent.mkdir(parents=True, exist_ok=True)
267
+ target.write_bytes(body)
268
+ names[f"{i}/{Path(at).name}"] = as_head
269
+ if not names:
270
+ return set(), set()
271
+ doc = run_scanner(binary, Path(tmp), offline, timeout=timeout, all_packages=True)
272
+ rows = findings(doc, Path(tmp))
273
+ pkgs = {(eco, name, version) for _f, eco, name, version in packages_of(doc, Path(tmp))}
274
+ return {(names.get(r["file"], r["file"]), r["ecosystem"], r["package"], r["id"], r["version"], r["severity"],
275
+ r["fixed"] or "", r["malware"]) for r in rows}, pkgs
276
+ after, pkgs_after = found(head, [(p, p) for p, _old in paths])
277
+ before, pkgs_before = found(base, [(old, p) for p, old in paths if old])
278
+ key = lambda r: r[:4] # noqa: E731 - the same advisory on the same package counts as already there
279
+ had, has = {key(r) for r in before}, {key(r) for r in after}
280
+ row = lambda r: {"file": r[0], "ecosystem": r[1], "package": r[2], "id": r[3], "version": r[4], "severity": r[5], # noqa: E731
281
+ "fixed": r[6] or None, "malware": r[7]}
282
+ order = lambda f: (not f["malware"], ["critical", "high", "moderate", "low", "unrated"].index(f["severity"]), f["package"]) # noqa: E731
283
+ return {"added": sorted((row(r) for r in after if key(r) not in had), key=order),
284
+ "removed": sorted((row(r) for r in before if key(r) not in has), key=order), "files": len(paths),
285
+ # Package versions the PR brings in, for its licence check.
286
+ "new_packages": sorted(pkgs_after - pkgs_before)[:200]}
287
+
288
+
289
+ # GitHub's ecosystem names, as OSV and the scanner write them.
290
+ GHSA_ECOSYSTEMS = {"npm": "npm", "pip": "PyPI", "rubygems": "RubyGems", "nuget": "NuGet", "maven": "Maven",
291
+ "composer": "Packagist", "go": "Go", "rust": "crates.io", "pub": "Pub", "swift": "SwiftURL",
292
+ "erlang": "Hex"}
293
+ MALWARE_PAGES = 5
294
+ CONDITION = re.compile(r"^\s*(>=|<=|>|<|=)\s*(\S+)\s*$")
295
+
296
+
297
+ def in_range(version: str, spec: str) -> bool:
298
+ """GitHub's vulnerable_version_range: conditions joined by commas, all of which must hold (">= 0", "= 1.2.3")."""
299
+ for part in str(spec or "").split(","):
300
+ m = CONDITION.match(part)
301
+ if not m:
302
+ return False
303
+ op, bound = m.groups()
304
+ a, b = _key(version), _key(bound)
305
+ if not {"=": a == b, ">=": a >= b, "<=": a <= b, ">": a > b, "<": a < b}[op]:
306
+ return False
307
+ return True
308
+
309
+
310
+ def malware_feed(api, since: str) -> list[dict]:
311
+ """GitHub's malware advisories published since `since`, newest first: usually hours before OSV lists them."""
312
+ params = {"type": "malware", "per_page": 100, "sort": "published", "direction": "desc", "published": f">={since[:10]}"}
313
+ client = getattr(api, "client", None)
314
+ if client is None:
315
+ return [a for a in api._get("/advisories", params) if (a.get("published_at") or "") >= since]
316
+ from stackvitals.ci.api import API, APIError
317
+ # This endpoint pages by cursor (the Link header's next), not by page number; a worm can publish hundreds a day.
318
+ out, url = [], f"{API}/advisories"
319
+ for _ in range(MALWARE_PAGES):
320
+ api.requests += 1
321
+ resp = client.get(url, headers=api.headers, params=params)
322
+ if resp.status_code >= 400:
323
+ raise APIError(f"GET /advisories: HTTP {resp.status_code}")
324
+ batch = resp.json()
325
+ out += [a for a in batch if (a.get("published_at") or "") >= since]
326
+ url = (resp.links.get("next") or {}).get("url")
327
+ if not url or len(batch) < 100 or (batch[-1].get("published_at") or "") < since:
328
+ break
329
+ params = None # the next link carries them
330
+ return out
331
+
332
+
333
+ def malware_matches(advisories: list[dict], packages: list[list[str]], now: datetime) -> list[dict]:
334
+ """Rows for every package of the repo that a GitHub malware advisory names, in the scanner's shape."""
335
+ by_name = {}
336
+ for file, eco, name, version in packages:
337
+ by_name.setdefault((eco, name.lower()), []).append((file, name, version))
338
+ rows = []
339
+ for a in advisories:
340
+ for v in a.get("vulnerabilities") or []:
341
+ pkg = v.get("package") or {}
342
+ eco = GHSA_ECOSYSTEMS.get(str(pkg.get("ecosystem") or "").lower())
343
+ for file, name, version in by_name.get((eco, str(pkg.get("name") or "").lower()), []):
344
+ if in_range(version, v.get("vulnerable_version_range")):
345
+ rows.append({"id": a["ghsa_id"], "aliases": [], "file": file, "ecosystem": eco, "package": name,
346
+ "version": version, "severity": "critical", "malware": True, "fixed": None,
347
+ "summary": (a.get("summary") or "Malicious package")[:200],
348
+ "source": f"GitHub malware advisory, published {str(a.get('published_at'))[:16]}Z"})
349
+ return rows
350
+
351
+
352
+ def packages_of(doc: dict, root: Path) -> list[list[str]]:
353
+ # Every package the scanner read (with --all-packages), as [file, ecosystem, name, version].
354
+ base = str(Path(root).resolve()).replace("\\", "/").rstrip("/") + "/"
355
+ out = set()
356
+ for result in doc.get("results") or []:
357
+ source = str((result.get("source") or {}).get("path") or "").replace("\\", "/")
358
+ file = source[len(base):] if source.startswith(base) else source.rsplit("/", 1)[-1]
359
+ for p in result.get("packages") or []:
360
+ pkg = p.get("package") or {}
361
+ if pkg.get("name") and pkg.get("version"):
362
+ out.add((file, pkg.get("ecosystem") or "", pkg["name"], pkg["version"]))
363
+ return [list(x) for x in sorted(out)]
364
+
365
+
366
+ def ident(f: dict) -> str:
367
+ return f"{f['file']}|{f['ecosystem']}|{f['package']}|{f['version']}|{f['id']}"
368
+
369
+
370
+ def merge(previous: list[dict], found: list[dict], now: datetime) -> tuple[list[dict], list[dict], list[dict]]:
371
+ """Today's open findings with the day each was first seen, the new ones, and those gone since the last scan."""
372
+ seen = {ident(f): f.get("first_seen") for f in previous}
373
+ today = iso(now)[:10]
374
+ current = [{**f, "first_seen": seen.get(ident(f)) or today} for f in found]
375
+ new = [f for f in current if ident(f) not in seen]
376
+ gone_ids = {ident(f) for f in current}
377
+ gone = [{**f, "closed": today} for f in previous if ident(f) not in gone_ids]
378
+ return current, new, gone
379
+
380
+
381
+ def digest(blobs: list[dict]) -> str:
382
+ return hashlib.sha256("\n".join(f"{b['path']} {b['sha']}" for b in blobs).encode()).hexdigest()[:16]
383
+
384
+
385
+ def _same_package(rows: list[dict], previous: list[dict]) -> list[dict]:
386
+ # GitHub's malware advisory and OSV's MAL- record name one bad version twice: keep one row, from its first sighting.
387
+ first = {}
388
+ for f in previous:
389
+ if f.get("malware"):
390
+ k = (f["file"], f["ecosystem"], f["package"].lower(), f["version"])
391
+ first[k] = min(first.get(k, f["first_seen"]), f["first_seen"])
392
+ out, seen = [], set()
393
+ for f in sorted(rows, key=lambda f: not f.get("source")):
394
+ k = (f["file"], f["ecosystem"], f["package"].lower(), f["version"])
395
+ if f.get("malware") and k in seen:
396
+ continue
397
+ seen.add(k)
398
+ out.append({**f, **({"first_seen": first[k]} if f.get("malware") and k in first else {})})
399
+ return out
400
+
401
+
402
+ def ignores(toml_text: str | None, now: datetime) -> dict[str, dict]:
403
+ """The advisories a repo's osv-scanner.toml ignores ([[IgnoredVulns]] id, ignoreUntil, reason), still in force today;
404
+ the same file OSV-Scanner itself reads, so a team that uses it already gets the same answers here."""
405
+ import tomllib
406
+ try:
407
+ doc = tomllib.loads(toml_text or "")
408
+ except tomllib.TOMLDecodeError:
409
+ return {}
410
+ out = {}
411
+ for item in doc.get("IgnoredVulns") or []:
412
+ if not isinstance(item, dict) or not item.get("id"):
413
+ continue
414
+ until = item.get("ignoreUntil")
415
+ until = until.date().isoformat() if hasattr(until, "date") else str(until)[:10] if until else None
416
+ if until and until < now.date().isoformat():
417
+ continue
418
+ out[str(item["id"])] = {"until": until, "reason": str(item.get("reason") or "")[:300]}
419
+ return out
420
+
421
+
422
+ SARIF_LEVEL = {"critical": "error", "high": "error", "moderate": "warning", "low": "note", "unrated": "note"}
423
+ SARIF_SCORE = {"critical": "9.5", "high": "7.5", "moderate": "5.0", "low": "2.0", "unrated": "0.0"}
424
+
425
+
426
+ def sarif(findings: list[dict]) -> dict:
427
+ """Open findings (ignored ones left out) as SARIF 2.1.0 for GitHub's Security tab: one rule per advisory, located in
428
+ the lockfile, with the fixing version as the help."""
429
+ live = [f for f in findings if not f.get("ignored")]
430
+ rules = {f["id"]: {"id": f["id"], "shortDescription": {"text": f"{f.get('package')} {f.get('version')}: {f['id']}"},
431
+ "helpUri": f"https://osv.dev/vulnerability/{f['id']}",
432
+ "help": {"text": f"Upgrade {f.get('package')} to {f['fixed']}." if f.get("fixed") else "No fixed version yet."},
433
+ "properties": {"security-severity": "10.0" if f.get("malware") else SARIF_SCORE.get(f.get("severity") or "unrated", "0.0")}}
434
+ for f in live}
435
+ results = [{"ruleId": f["id"], "level": "error" if f.get("malware") else SARIF_LEVEL.get(f.get("severity") or "unrated", "note"),
436
+ "message": {"text": f"{'Malware' if f.get('malware') else 'Known vulnerability'} in {f.get('package')} {f.get('version')} ({f.get('ecosystem')})"},
437
+ "locations": [{"physicalLocation": {"artifactLocation": {"uri": f.get("file") or "."}}}]} for f in live]
438
+ return {"version": "2.1.0", "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
439
+ "runs": [{"tool": {"driver": {"name": "StackVitals (OSV-Scanner)", "rules": list(rules.values())}}, "results": results}]}
440
+
441
+
442
+ def apply_reach(findings: list[dict], reach: dict | None) -> None:
443
+ """Puts govulncheck's verdict on each Go finding it covers (by the advisory's id or an alias): "called" with the
444
+ call path, "imported" or only "required"; so the advisories your code really calls come first."""
445
+ by_id = {}
446
+ for r in (reach or {}).get("findings") or []:
447
+ for i in [r["id"], *(r.get("aliases") or [])]:
448
+ by_id[i] = r
449
+ for f in findings:
450
+ hit = next((by_id[i] for i in [f.get("id"), *(f.get("aliases") or [])] if i in by_id), None)
451
+ if hit and f.get("ecosystem") == "Go":
452
+ f["reach"] = {"level": hit["reach"], "path": hit.get("path") or []}
453
+
454
+
455
+ def apply_ignores(findings: list[dict], ignored: dict[str, dict]) -> None:
456
+ """Marks findings the repo chose to ignore (by the advisory's id or any alias); they stay listed, with the reason."""
457
+ for f in findings:
458
+ hit = next((ignored[i] for i in [f.get("id"), *(f.get("aliases") or [])] if i in ignored), None)
459
+ if hit:
460
+ f["ignored"] = hit
461
+ else:
462
+ f.pop("ignored", None)
463
+
464
+
465
+ def _action_findings(api, ref: str, offline: bool, notes: list) -> list[dict]:
466
+ """Known vulnerabilities in the GitHub Actions the workflows use by tag; nothing when outside lookups are off."""
467
+ if offline:
468
+ return []
469
+ import httpx
470
+
471
+ from stackvitals.deps import actions_vulns
472
+ try:
473
+ listing = api._get(f"/repos/{api.repo}/contents/.github/workflows", {"ref": ref}) or []
474
+ files = {i["path"]: api.file(i["path"], ref).decode("utf-8", "replace") for i in listing
475
+ if isinstance(i, dict) and str(i.get("name", "")).endswith((".yml", ".yaml"))}
476
+ with httpx.Client(timeout=30) as c:
477
+ found, _pinned = actions_vulns.findings(actions_vulns.uses_in(files), lambda u, b: c.post(u, json=b).json(),
478
+ lambda u: c.get(u).json())
479
+ return found
480
+ except Exception as exc: # noqa: BLE001 - the lockfile findings still count
481
+ notes.append(f"Vulnerabilities: the workflows' actions weren't checked ({type(exc).__name__}).")
482
+ return []
483
+
484
+
485
+ def vulns_step(ws, env: dict, now: datetime, make_api=None, scanner=None) -> dict | None:
486
+ """Scan the default branch's lockfiles when they change, and every 6 hours for new advisories; on every run, check
487
+ GitHub's newest malware advisories against every package the last scan read. Keep each finding's first-seen day,
488
+ publish docs/vulns.json and name new ones in the job summary. Trouble is said, never fatal."""
489
+ from stackvitals.core.config import parse
490
+ from stackvitals.core.store import published, write_if_changed
491
+ cfg = ws.cache.get("settings") or parse("")[0]
492
+ path = ws.path / "deps" / "vulns.json"
493
+ try:
494
+ state = json.loads(path.read_text(encoding="utf-8"))
495
+ except (OSError, ValueError):
496
+ state = {}
497
+ if make_api is None:
498
+ from stackvitals.ci.api import ActionsAPI as make_api
499
+ api = make_api(env.get("STACKVITALS_TOKEN") or env.get("GITHUB_TOKEN"), ws.repo, max_requests=MAX_FILES + 20)
500
+ offline = not cfg["deps"].get("outside_lookups", True)
501
+ try:
502
+ ref = api._get(f"/repos/{ws.repo}").get("default_branch") or "main"
503
+ blobs = lockfile_blobs(api, ref)
504
+ mark = digest(blobs)
505
+ fresh = state.get("last") and state["last"] > iso(now - RESCAN)
506
+ full = mark != state.get("digest") or not fresh or env.get("GITHUB_EVENT_NAME") == "workflow_dispatch"
507
+ result = scan(api, ref, scanner or ensure_scanner(tool_dir(env)), offline, blobs) if full else None
508
+ except ScannerError as exc:
509
+ ws.notes.append(f"Vulnerabilities: {exc}.")
510
+ return {"error": str(exc)}
511
+ except Exception as exc: # noqa: BLE001 - GitHub trouble mid-scan keeps the last result
512
+ ws.notes.append(f"Vulnerabilities: not scanned this run ({type(exc).__name__}: {str(exc)[:120]}).")
513
+ return {"error": str(exc)}
514
+ packages = result["packages"] if result else state.get("packages") or []
515
+ # The fast lane: malware GitHub published since the last full scan (an hour of overlap), matched by name and version.
516
+ malware = []
517
+ if packages:
518
+ try:
519
+ since = min(state.get("last") or iso(now - timedelta(days=3)), iso(now - timedelta(hours=1)))
520
+ since = iso(datetime.fromisoformat(since.replace("Z", "+00:00")) - timedelta(hours=1))
521
+ malware = malware_matches(malware_feed(api, since), packages, now)
522
+ except Exception as exc: # noqa: BLE001 - the scan's own results still count
523
+ ws.notes.append(f"Vulnerabilities: GitHub's malware advisories weren't read ({type(exc).__name__}).")
524
+ previous = state.get("open") or []
525
+ if result is None:
526
+ known = {ident(f) for f in previous}
527
+ if not [m for m in malware if ident(m) not in known]:
528
+ return None
529
+ base = [{k: v for k, v in f.items() if k != "first_seen"} for f in previous]
530
+ else:
531
+ # A malware match from GitHub's feed stays open while the package is still in a lockfile, even after the
532
+ # feed's window has moved past it and before OSV lists it.
533
+ present = {(eco, name.lower(), version) for _file, eco, name, version in packages}
534
+ held = [{k: v for k, v in f.items() if k != "first_seen"} for f in previous
535
+ if f.get("malware") and str(f.get("source") or "").startswith("GitHub malware")
536
+ and (f.get("ecosystem"), str(f.get("package") or "").lower(), f.get("version")) in present]
537
+ base = result["findings"] + held + _action_findings(api, ref, offline, ws.notes)
538
+ rows = _same_package(base + malware, previous)
539
+ current, new, gone = merge(previous, rows, now)
540
+ current = _same_package(current, previous)
541
+ closed = (gone + (state.get("closed") or []))[:200]
542
+ # Advisories the repo ignores in osv-scanner.toml stay listed, marked with their reason, until their date.
543
+ try:
544
+ toml_text = api.file("osv-scanner.toml", ref).decode("utf-8", "replace")
545
+ except Exception: # noqa: BLE001 - no file (or no access) means nothing is ignored
546
+ toml_text = None
547
+ apply_ignores(current, ignores(toml_text, now))
548
+ from stackvitals.deps.fixplan import plan
549
+ for f in current:
550
+ # The smallest upgrade that fixes it, how big a jump that is, and the command.
551
+ if (fix := plan(f)):
552
+ f["fix_plan"] = fix
553
+ if env.get("STACKVITALS_GOVULNCHECK"):
554
+ from stackvitals.deps.supply import _reachability
555
+ # Go advisories say whether the code calls the vulnerable function (govulncheck run in the workflow).
556
+ apply_reach(current, _reachability(env["STACKVITALS_GOVULNCHECK"]))
557
+ new = [f for f in new if not f.get("ignored")]
558
+ files = result["files"] if result else state.get("files") or []
559
+ skipped = result["skipped"] if result else state.get("skipped") or []
560
+ # Whether the repo's own code imports each npm, PyPI or crates.io package (the imports-json input, `stackvitals deps imports`).
561
+ imports_doc = None
562
+ if env.get("STACKVITALS_IMPORTS"):
563
+ try:
564
+ imports_doc = json.loads(Path(env["STACKVITALS_IMPORTS"]).read_text(encoding="utf-8"))
565
+ except (OSError, ValueError):
566
+ ws.notes.append(f"Vulnerabilities: {env['STACKVITALS_IMPORTS']} couldn't be read; run `stackvitals deps imports --out` before StackVitals.")
567
+ if isinstance(imports_doc, dict):
568
+ from stackvitals.deps.imports import imported
569
+ for f in current:
570
+ f["imported"] = imported(imports_doc, f.get("ecosystem"), f.get("package") or "")
571
+ out = {"generated": iso(now), "ref": ref, "scanner": f"OSV-Scanner {SCANNER}", "offline": offline,
572
+ "files": files, "skipped": skipped, "open": current, "closed": closed}
573
+ path.parent.mkdir(parents=True, exist_ok=True)
574
+ keep = {"last": iso(now), "digest": mark} if result else {"last": state.get("last"), "digest": state.get("digest")}
575
+ path.write_text(json.dumps({**out, **keep, "packages": packages}, indent=1, sort_keys=True) + "\n",
576
+ encoding="utf-8", newline="\n")
577
+ docs = ws.path / "docs"
578
+ docs.mkdir(parents=True, exist_ok=True)
579
+ write_if_changed(docs / "vulns.json", published(out))
580
+ # The same findings for GitHub's Security tab (github/codeql-action/upload-sarif reads it from the data branch).
581
+ write_if_changed(docs / "vulns.sarif", sarif(current))
582
+ if new:
583
+ worst = sorted(new, key=lambda f: (not f["malware"], ["critical", "high", "moderate", "low", "unrated"].index(f["severity"])))
584
+ ws.notes.append(f"Vulnerabilities: {len(new)} new since the last scan, worst {worst[0]['package']} "
585
+ f"{worst[0]['version']} ({worst[0]['id']}, {'malware' if worst[0]['malware'] else worst[0]['severity']}"
586
+ f"{', fixed in ' + worst[0]['fixed'] if worst[0]['fixed'] else ''}).")
587
+ return {"files": len(files), "open": len(current), "new": len(new), "closed": len(gone),
588
+ "malware": sum(f["malware"] for f in current), "scanned": result is not None}
@@ -0,0 +1,40 @@
1
+ """Editor add-ons: `stackvitals editor vscode` installs the flaky-test marker into VS Code (or Cursor, VSCodium)."""
2
+ import argparse
3
+ import json
4
+ import shutil
5
+ import sys
6
+ from importlib.resources import files
7
+ from pathlib import Path
8
+
9
+ HOMES = {"vscode": ".vscode", "cursor": ".cursor", "vscodium": ".vscode-oss"}
10
+
11
+
12
+ def install(target: Path) -> Path:
13
+ source = files("stackvitals.editors") / "vscode"
14
+ manifest = json.loads((source / "package.json").read_text(encoding="utf-8"))
15
+ prefix = f"{manifest['publisher']}.{manifest['name']}-"
16
+ target.mkdir(parents=True, exist_ok=True)
17
+ # Only our own older copies are replaced; nothing else in the folder is touched.
18
+ for old in target.glob(prefix + "*"):
19
+ if old.is_dir():
20
+ shutil.rmtree(old)
21
+ dest = target / (prefix + manifest["version"])
22
+ dest.mkdir()
23
+ for name in ("package.json", "extension.js", "marks.js"):
24
+ (dest / name).write_text((source / name).read_text(encoding="utf-8"), encoding="utf-8")
25
+ return dest
26
+
27
+
28
+ def main(argv: list[str] | None = None) -> int:
29
+ ap = argparse.ArgumentParser(prog="stackvitals editor", description=__doc__)
30
+ ap.add_argument("editor", choices=sorted(HOMES), help="which editor to install the marker into")
31
+ ap.add_argument("--dir", type=Path, help="the editor's extensions folder, if not the usual one")
32
+ args = ap.parse_args(argv)
33
+ dest = install(args.dir or Path.home() / HOMES[args.editor] / "extensions")
34
+ print(f"Installed into {dest}. Reload the editor; flaky tests are marked in test files of repos with a "
35
+ "stackvitals-data branch (run \"StackVitals: Refresh flaky tests\" to fetch the newest).")
36
+ return 0
37
+
38
+
39
+ if __name__ == "__main__":
40
+ sys.exit(main())