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,299 @@
1
+ """The PR check: when a pull request changes a lockfile, each added or changed package's verdict goes to the job summary
2
+ and annotations. Files are read through the API at the base and head commits; no PR code is checked out or run."""
3
+ import codecs
4
+ import json
5
+ import subprocess
6
+ import time
7
+ from datetime import datetime, timezone
8
+
9
+ from stackvitals.ci.api import ActionsAPI, APIError, OutOfBudget
10
+ from stackvitals.ci.cli import home
11
+ from stackvitals.deps.dossier import dossier
12
+ from stackvitals.deps.evidence import load_evidence
13
+ from stackvitals.deps.lockfiles import dependency_paths, foreign_sources, is_lockfile, parse_lockfile
14
+ from stackvitals.deps.lookalike import judge
15
+ from stackvitals.deps import guarddog, release, vulns
16
+ from stackvitals.flow.report import _md
17
+
18
+ MARKER = "<!-- stackvitals:deps -->"
19
+ NOTE = ("Evidence from upgrade PRs across public repos, maintainer history, the package's registry and OSV. Verdicts weigh "
20
+ "what happened, not what will.")
21
+ ORDER = {"investigate": 0, "monitor": 1, "use": 2, None: 3}
22
+ FLAGGED = ("investigate", "monitor")
23
+ # GitHub caps a comment at 65,536 characters: every flagged package is listed, then this many others.
24
+ MAX_FLAGGED, MAX_OTHERS = 100, 30
25
+ MAX_PAGES = 10
26
+
27
+
28
+ def lockfile_changes(filename: str, base: str | None, head: str) -> list[dict]:
29
+ """Packages added or moved to other versions between two versions of a lockfile (removals need no check)."""
30
+ def versions(text):
31
+ out = {}
32
+ for eco, name, version in parse_lockfile(filename, text) if text else []:
33
+ out.setdefault((eco, name), set()).add(version or "")
34
+ return out
35
+ old, new = versions(base), versions(head)
36
+ changes = []
37
+ for (eco, name), vs in sorted(new.items()):
38
+ if (eco, name) not in old:
39
+ changes.append({"ecosystem": eco, "package": name, "change": "added", "from": [], "to": sorted(v for v in vs if v)})
40
+ elif vs != old[(eco, name)]:
41
+ changes.append({"ecosystem": eco, "package": name, "change": "changed", "from": sorted(v for v in old[(eco, name)] if v),
42
+ "to": sorted(v for v in vs if v)})
43
+ return changes
44
+
45
+
46
+ def _annotation(text: str) -> str:
47
+ # Workflow commands treat %, CR and LF specially.
48
+ return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
49
+
50
+
51
+ def _prop(text: str) -> str:
52
+ # Property values (file=, title=) come from the PR, so ":" and "," are escaped too, or a path could add commands.
53
+ return _annotation(text).replace(":", "%3A").replace(",", "%2C")
54
+
55
+
56
+ def _cell(text) -> str:
57
+ # Registry text and package names are untrusted: one line, no table breaks, no @mentions that ping people.
58
+ return _md(" ".join(str(text).split())).replace("@", "@​")
59
+
60
+
61
+ def _text(raw: bytes) -> str:
62
+ # PowerShell's "pip freeze > requirements.txt" writes UTF-16; editors add a UTF-8 BOM.
63
+ if raw.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
64
+ return raw.decode("utf-16")
65
+ return raw.decode("utf-8-sig")
66
+
67
+
68
+ def _vulns(found: dict | None) -> list[str]:
69
+ if not found:
70
+ return []
71
+ if found.get("error"):
72
+ return ["", f"Known vulnerabilities weren't checked this time ({_cell(found['error'])})."]
73
+ added, removed = found.get("added") or [], found.get("removed") or []
74
+ if not added and not removed:
75
+ return ["", f"No known vulnerabilities added or removed in the {found.get('files', 0)} changed lockfile(s) (OSV)."]
76
+ lines = ["", f"**Known vulnerabilities this PR adds: {len(added)}**" + (f"; it removes {len(removed)}." if removed else ".")]
77
+ if added:
78
+ lines += ["", "| Package | Advisory | Severity | Fixed in | Lockfile |", "|---|---|---|---|---|"]
79
+ lines += [f"| {_cell(f['ecosystem'] + ':' + f['package'] + ' ' + f['version'])} | {_cell(f['id'])} | "
80
+ f"{'**malware**' if f['malware'] else f['severity']} | {_cell(f['fixed'] or 'no fix yet')} | {_cell(f['file'])} |"
81
+ for f in added[:MAX_FLAGGED]]
82
+ return lines
83
+
84
+
85
+ def _report(rows: list[tuple[str, dict, dict | None]], has_evidence: bool, unread: list[tuple[str, str]], cut: bool,
86
+ found: dict | None = None) -> str:
87
+ lines = [MARKER, "### Dependency changes in this PR", ""]
88
+ flagged = [r for r in rows if (r[2] or {}).get("verdict") in FLAGGED][:MAX_FLAGGED]
89
+ others = [r for r in rows if (r[2] or {}).get("verdict") not in FLAGGED][:MAX_OTHERS]
90
+ if rows:
91
+ lines += ["| Package | Change | Verdict | Why |", "|---|---|---|---|"]
92
+ for path, c, d in flagged + others:
93
+ change = f"added {', '.join(c['to'])}" if c["change"] == "added" else f"{', '.join(c['from'])} → {', '.join(c['to'])}"
94
+ verdict = (d or {}).get("verdict") or "not enough evidence"
95
+ why = ((d or {}).get("reasons") or [""])[0]
96
+ via = f" (via {' → '.join(c['via'])})" if c.get("via") else ""
97
+ lines.append(f"| {_cell(c['ecosystem'] + ':' + c['package'] + via)} | {_cell(change)} | "
98
+ f"{'**' + verdict + '**' if verdict == 'investigate' else verdict} | {_cell(why)} |")
99
+ hidden = len(rows) - len(flagged) - len(others)
100
+ if hidden:
101
+ lines.append(f"\n…and {hidden} more changed packages{' with nothing to flag' if len(flagged) < MAX_FLAGGED else ''}.")
102
+ lines += _vulns(found)
103
+ if unread:
104
+ lines.append("\nThese lockfiles couldn't be read: " + "; ".join(f"{_cell(p)} ({_cell(why[:80])})" for p, why in unread) + ".")
105
+ if cut:
106
+ lines.append(f"\nOnly the first {MAX_PAGES * 100:,} changed files were checked for lockfiles.")
107
+ note = NOTE if has_evidence else "The dependency evidence isn't available to this run yet, so only the changes are listed."
108
+ return "\n".join(lines) + f"\n\n<sub>{note}</sub>\n"
109
+
110
+
111
+ def accepted_packages(api, now: datetime) -> set[str]:
112
+ """Packages a `/stackvitals accept package:<name>` decision still covers, read from the data branch (a PR's check
113
+ has no checkout of it); nothing when it can't be read."""
114
+ from stackvitals.core.decisions import STATE
115
+ try:
116
+ state = json.loads(api.file(STATE, "stackvitals-data"))
117
+ except Exception: # noqa: BLE001 - the check never fails over the decisions file
118
+ return set()
119
+ day = now.date().isoformat()
120
+ return {v["id"] for v in state.values() if isinstance(v, dict) and v.get("kind") == "package"
121
+ and v.get("verb") == "accept" and (not v.get("until") or v["until"] >= day)}
122
+
123
+
124
+ def apply_policy(d: dict | None, policy: dict | None, name: str, versions: list[str] | None = None,
125
+ accepted: set[str] = frozenset()) -> dict | None:
126
+ """The repo's severity policy on one package's verdict: an advisory rated above max_severity that holds the version
127
+ installed here (or, without one, an open advisory) makes it "investigate", unless a decision accepted the package."""
128
+ from stackvitals.core.config import SEVERITIES
129
+ from stackvitals.deps.dossier import affected_by
130
+ limit = (policy or {}).get("max_severity")
131
+ osv = (d or {}).get("osv") or {}
132
+ if name in accepted:
133
+ return d
134
+ if versions and osv.get("affects") is not None:
135
+ labels = [a["severity"].lower() for a in affected_by(osv, versions, disputed=False)
136
+ if (a.get("severity") or "").lower() in SEVERITIES]
137
+ worst = max(labels, key=SEVERITIES.index, default=None)
138
+ else:
139
+ worst = osv.get("open_severity")
140
+ if not d or not limit or not worst or worst.lower() not in SEVERITIES:
141
+ return d
142
+ if SEVERITIES.index(worst.lower()) <= SEVERITIES.index(limit):
143
+ return d
144
+ what = "an advisory affecting the version installed here" if versions and osv.get("affects") is not None else "an open advisory"
145
+ why = (f"Policy: {what} is rated {worst.lower()}, above this repo's max_severity ({limit}); to accept it "
146
+ f"for a while, comment `/stackvitals accept package:{name} for 30d because <reason>`.")
147
+ return {**d, "verdict": "investigate", "reasons": [why, *(d.get("reasons") or [])]}
148
+
149
+
150
+ def run_deps_action(env: dict, now: datetime | None = None, make_api=ActionsAPI, evidence=..., comments: bool = False,
151
+ policy: dict | None = None, registry=None, outside: bool = True, scan=False,
152
+ deep: bool = False, deny: list[str] | None = None) -> dict | None:
153
+ """One check of a pull request's lockfile changes; other events are skipped."""
154
+ if env.get("GITHUB_EVENT_NAME") != "pull_request":
155
+ return None
156
+ now = now or datetime.now(timezone.utc)
157
+ pr = json.loads(open(env["GITHUB_EVENT_PATH"], encoding="utf-8").read()).get("pull_request") or {}
158
+ repo, token = env["GITHUB_REPOSITORY"], env.get("STACKVITALS_TOKEN") or env.get("GITHUB_TOKEN")
159
+ api = make_api(token, repo, max_requests=80)
160
+ files, cut = [], False
161
+ for page in range(1, MAX_PAGES + 1):
162
+ batch = api._get(f"/repos/{repo}/pulls/{pr['number']}/files", {"per_page": 100, "page": page})
163
+ files += batch
164
+ if len(batch) < 100:
165
+ break
166
+ else:
167
+ cut = True
168
+ # A renamed lockfile is compared with its old path, or every package would look new.
169
+ locks = [(f["filename"], f.get("previous_filename") if f.get("status") == "renamed" else f["filename"]) for f in files
170
+ if is_lockfile(f["filename"]) and f.get("status") != "removed"]
171
+ if evidence is ...:
172
+ evidence = load_evidence(token, home() / "deps")
173
+ accepted = accepted_packages(api, now)
174
+ rows, unread = [], []
175
+ # The packages the evidence covers are the popular names a look-alike would imitate.
176
+ popular = {}
177
+ for key in (evidence or {}).get("map") or {}:
178
+ eco, _, name = key.partition(":")
179
+ popular.setdefault(eco, set()).add(name)
180
+ own = registry is None and outside
181
+ if own:
182
+ import httpx
183
+ registry = httpx.Client(timeout=15, headers={"User-Agent": "stackvitals"})
184
+ checked = deep_checked = 0
185
+ # The job has 20 minutes: GuardDog gets 5 of them in all, and each scan of the PR's lockfiles 4 (base and head).
186
+ deep_until = time.monotonic() + 300
187
+ for path, old_path in locks:
188
+ try:
189
+ head = _text(api.file(path, pr["head"]["sha"]))
190
+ try:
191
+ base = _text(api.file(old_path, pr["base"]["sha"])) if old_path and is_lockfile(old_path) else None
192
+ except APIError as exc:
193
+ if "HTTP 404" not in str(exc):
194
+ raise
195
+ base = None # a lockfile the PR adds
196
+ changes, foreign = lockfile_changes(path, base, head), foreign_sources(path, head)
197
+ except (APIError, OutOfBudget, ValueError) as exc:
198
+ # One unreadable file (conflict markers, a GitHub hiccup) is reported; the rest are still checked.
199
+ unread.append((path, str(exc) or type(exc).__name__))
200
+ continue
201
+ for c in changes:
202
+ d = apply_policy(dossier(c["ecosystem"], c["package"], evidence, now, versions=c["to"]) if evidence else None,
203
+ policy, c["package"], versions=c["to"], accepted=accepted)
204
+ # Only a package the PR adds can be a look-alike slipped in; the registry is asked only for a near name.
205
+ fake = judge(c["ecosystem"], c["package"], popular, registry) if c["change"] == "added" and popular else None
206
+ if fake:
207
+ worse = min(fake["verdict"], (d or {}).get("verdict"), key=lambda v: ORDER.get(v, 3))
208
+ d = {**(d or {}), "verdict": worse, "reasons": fake["reasons"] + list((d or {}).get("reasons") or [])}
209
+ # Signs before any advisory (only hours old, a new install script or dependencies, provenance gone): look first.
210
+ if registry is not None and checked < release.MAX_PACKAGES and c["ecosystem"] in ("npm", "pypi"):
211
+ checked += 1
212
+ signs = release.release_signs(registry, c["ecosystem"], c["package"], c["from"], c["to"], now)
213
+ # Update bots open most PRs within hours of a release, so age alone is a note; another sign makes it a look.
214
+ strong = [s for s in signs if not s.startswith("Released ")]
215
+ if strong:
216
+ worse = min("monitor", (d or {}).get("verdict"), key=lambda v: ORDER.get(v, 3))
217
+ d = {**(d or {}), "verdict": worse, "reasons": signs + list((d or {}).get("reasons") or [])}
218
+ elif signs:
219
+ d = {**(d or {}), "verdict": (d or {}).get("verdict"), "reasons": list((d or {}).get("reasons") or []) + signs}
220
+ # Opt-in: GuardDog reads the new version's code for what malware does.
221
+ left = deep_until - time.monotonic()
222
+ if (deep and outside and deep_checked < guarddog.MAX_PACKAGES and c["ecosystem"] in guarddog.ECOSYSTEMS and c["to"]
223
+ and left > 20):
224
+ deep_checked += 1
225
+ rules = guarddog.scan(c["ecosystem"], c["package"], max(c["to"], key=release._key), timeout=int(min(180, left)))
226
+ if rules:
227
+ worse = min("monitor", (d or {}).get("verdict"), key=lambda v: ORDER.get(v, 3))
228
+ d = {**(d or {}), "verdict": worse, "reasons": [f"GuardDog flagged its code: {', '.join(rules)}."]
229
+ + list((d or {}).get("reasons") or [])}
230
+ host = foreign.get((c["ecosystem"], c["package"]))
231
+ if host:
232
+ # Evidence about the registry package says nothing about a copy from elsewhere.
233
+ d = {**(d or {}), "verdict": "monitor", "reasons": [f"Installed from {host}, not the public registry: the "
234
+ "evidence here is about the registry package of that name."]}
235
+ rows.append((path, c, d))
236
+ # A flagged package another one pulls in names the way it came, so the team knows which dependency to change.
237
+ flagged = {c["package"] for p, c, d in rows if p == path and (d or {}).get("verdict") in FLAGGED}
238
+ for name, chain in dependency_paths(path, head, flagged).items() if flagged else ():
239
+ for p, c, _d in rows:
240
+ if p == path and c["package"] == name and len(chain) > 1:
241
+ c["via"] = chain
242
+ if own:
243
+ registry.close()
244
+ rows.sort(key=lambda r: (ORDER[(r[2] or {}).get("verdict")], r[1]["package"]))
245
+ # Known vulnerabilities the PR adds or removes, in any ecosystem: `scan` is True (download the pinned scanner) or a path.
246
+ found = None
247
+ targets = [(f["filename"], f.get("previous_filename") if f.get("status") == "renamed" else f["filename"]) for f in files
248
+ if vulns.scannable(f["filename"]) and f.get("status") != "removed"]
249
+ if scan and targets:
250
+ try:
251
+ binary = scan if not isinstance(scan, bool) else vulns.ensure_scanner(vulns.tool_dir(env))
252
+ found = vulns.pr_diff(api, targets, pr["base"]["sha"], pr["head"]["sha"], binary, offline=not outside, timeout=240)
253
+ except (vulns.ScannerError, APIError, OutOfBudget, OSError, ValueError, subprocess.TimeoutExpired) as exc:
254
+ found = {"error": str(exc)[:200]}
255
+ if found and found.get("new_packages") and deny and outside:
256
+ import httpx
257
+
258
+ from stackvitals.deps.supply import pr_licences
259
+ # Licences the repo's policy denies, among the package versions this PR brings in.
260
+ with httpx.Client(timeout=20) as c:
261
+ found["licences"] = pr_licences(found["new_packages"], deny, c)
262
+ out = {"lockfiles": len(locks), "changed": len(rows),
263
+ **({"new_vulnerabilities": len(found.get("added") or [])} if found is not None else {}),
264
+ "investigate": sum(1 for r in rows if (r[2] or {}).get("verdict") == "investigate"),
265
+ "monitor": sum(1 for r in rows if (r[2] or {}).get("verdict") == "monitor")}
266
+ if (found or {}).get("licences"):
267
+ out["denied_licences"] = len(found["licences"])
268
+ if (not rows and not unread and not cut and not (found or {}).get("added") and not (found or {}).get("removed")
269
+ and not (found or {}).get("licences")):
270
+ return out
271
+ for path, c, d in rows:
272
+ if d and d["verdict"] in FLAGGED:
273
+ level = "warning" if d["verdict"] == "investigate" else "notice"
274
+ print(f"::{level} file={_prop(path)},title={_prop(c['package'])}: {d['verdict']}::{_annotation(d['reasons'][0])}")
275
+ for f in (found or {}).get("added") or []:
276
+ print(f"::warning file={_prop(f['file'])},title={_prop(f['package'])}: {'malware' if f['malware'] else f['severity']}"
277
+ f"::{_annotation(f['id'] + ' affects ' + f['version'] + (', fixed in ' + f['fixed'] if f['fixed'] else ''))}")
278
+ for f in (found or {}).get("licences") or []:
279
+ print(f"::warning title={_prop(f['package'])}: licence::{_annotation(f['package'] + ' ' + f['version'] + ' is ' + f['licence'] + ', which the licence policy denies')}")
280
+ report = _report(rows, evidence is not None, unread, cut, found)
281
+ if (found or {}).get("licences"):
282
+ report += "\n**Licences the policy denies** (`[deps.licenses] deny`):\n" + "".join(
283
+ f"- {f['package']} {f['version']} ({f['ecosystem']}): {f['licence']}\n" for f in found["licences"])
284
+ if env.get("GITHUB_STEP_SUMMARY"):
285
+ with open(env["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as f:
286
+ f.write(report)
287
+ # Comments are opt-in, and a fork's PR gets a read-only token, so only same-repo PRs are commented on.
288
+ same_repo = ((pr.get("head") or {}).get("repo") or {}).get("full_name") == repo
289
+ if comments and same_repo and (rows or (found or {}).get("added") or (found or {}).get("licences")):
290
+ try:
291
+ old = [c for c in api._get(f"/repos/{repo}/issues/{pr['number']}/comments", {"per_page": 100})
292
+ if MARKER in (c.get("body") or "")]
293
+ if old:
294
+ api.send("PATCH", f"/repos/{repo}/issues/comments/{old[0]['id']}", {"body": report})
295
+ else:
296
+ api.send("POST", f"/repos/{repo}/issues/{pr['number']}/comments", {"body": report})
297
+ except (APIError, OutOfBudget) as exc:
298
+ print(f"Could not post the dependency note ({exc}); posting needs `pull-requests: write`.")
299
+ return out
@@ -0,0 +1,89 @@
1
+ """Known vulnerabilities in the GitHub Actions a repo's workflows use (OSV's "GitHub Actions" ecosystem: tj-actions/
2
+ changed-files and the like), which no lockfile lists. Actions used by tag are checked; ones pinned to a full commit
3
+ can't be matched to a version, and are counted, not guessed."""
4
+ import re
5
+
6
+ import yaml
7
+
8
+ OSV_BATCH = "https://api.osv.dev/v1/querybatch"
9
+ USES = re.compile(r"^([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)(/[^@]*)?@(.+)$")
10
+ SHA = re.compile(r"^[0-9a-f]{40}$")
11
+
12
+
13
+ def uses_in(files: dict[str, str]) -> list[dict]:
14
+ """Every action a workflow uses: {"action", "ref", "file"}; local actions and Docker images left out."""
15
+ out = []
16
+ for path, text in files.items():
17
+ try:
18
+ doc = yaml.safe_load(text)
19
+ except yaml.YAMLError:
20
+ continue
21
+ for job in ((doc or {}).get("jobs") or {}).values() if isinstance(doc, dict) else []:
22
+ refs = [job.get("uses")] if isinstance(job, dict) and job.get("uses") else []
23
+ refs += [s.get("uses") for s in (job.get("steps") or []) if isinstance(s, dict) and s.get("uses")] if isinstance(job, dict) else []
24
+ for ref in refs:
25
+ m = USES.match(str(ref))
26
+ if m:
27
+ out.append({"action": m.group(1), "ref": m.group(3), "file": path})
28
+ return out
29
+
30
+
31
+ def version_of(ref: str) -> str | None:
32
+ """The version a tag names (v4.2.0 and v4 read as 4.2.0 and 4), None for a commit or a branch."""
33
+ if SHA.match(ref):
34
+ return None
35
+ m = re.fullmatch(r"v?(\d+(?:\.\d+){0,2})", ref)
36
+ return m.group(1) if m else None
37
+
38
+
39
+ def _key(v: str) -> list:
40
+ return [int(p) if p.isdigit() else 0 for p in str(v).split(".")[:3]] + [0] * (3 - len(str(v).split(".")[:3]))
41
+
42
+
43
+ def affected(version: str, ranges: list[dict]) -> bool:
44
+ """Whether a tag's version falls in an advisory's ECOSYSTEM ranges. A major-only tag (v4) moves to the newest 4.x,
45
+ so it counts only when no 4.x release is fixed."""
46
+ major_only = "." not in version
47
+ for r in ranges or []:
48
+ if r.get("type") != "ECOSYSTEM":
49
+ continue
50
+ lo, fixed, last = "0", None, None
51
+ for e in r.get("events") or []:
52
+ lo = e.get("introduced", lo)
53
+ fixed = e.get("fixed", fixed)
54
+ last = e.get("last_affected", last)
55
+ v = _key(version)
56
+ if major_only:
57
+ v = [v[0], 10**9, 10**9]
58
+ if _key(lo) <= v and (fixed is None or v < _key(fixed)) and (last is None or v <= _key(last)):
59
+ return True
60
+ return False
61
+
62
+
63
+ def findings(uses: list[dict], post_json, get_json) -> tuple[list[dict], int]:
64
+ """(findings, actions pinned to a commit): one finding per action, tag and advisory. One OSV batch query names the
65
+ advisories of each action; their ranges are read once each and matched here (OSV's API doesn't match versions in
66
+ this ecosystem)."""
67
+ tagged = sorted({(u["action"], u["ref"]) for u in uses if version_of(u["ref"])})
68
+ pinned = sum(1 for u in uses if SHA.match(u["ref"]))
69
+ actions = sorted({a for a, _ref in tagged})
70
+ if not actions:
71
+ return [], pinned
72
+ got = post_json(OSV_BATCH, {"queries": [{"package": {"ecosystem": "GitHub Actions", "name": a}} for a in actions]})
73
+ ids = {a: [v["id"] for v in (res or {}).get("vulns") or []] for a, res in zip(actions, (got or {}).get("results") or [])}
74
+ details = {i: get_json(f"https://api.osv.dev/v1/vulns/{i}") for i in sorted({i for v in ids.values() for i in v})}
75
+ files = {}
76
+ for u in uses:
77
+ files.setdefault((u["action"], u["ref"]), set()).add(u["file"])
78
+ out = []
79
+ for action, ref in tagged:
80
+ for i in ids.get(action, []):
81
+ doc = details.get(i) or {}
82
+ hit = next((a for a in doc.get("affected") or [] if (a.get("package") or {}).get("name", "").lower() == action.lower()), None)
83
+ if hit and affected(version_of(ref), hit.get("ranges")):
84
+ fixed = next((e["fixed"] for r in hit.get("ranges") or [] for e in r.get("events") or [] if e.get("fixed")), None)
85
+ where = sorted(files.get((action, ref), []))
86
+ out.append({"id": i, "aliases": doc.get("aliases") or [], "ecosystem": "GitHub Actions", "package": action,
87
+ "version": ref, "file": where[0] if where else None, "files": where,
88
+ "severity": "unrated", "fixed": fixed, "malware": False, "source": "OSV (GitHub Actions)"})
89
+ return out, pinned
@@ -0,0 +1,77 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ from stackvitals.ci.cli import home, token
8
+ from stackvitals.deps.dossier import dossier
9
+ from stackvitals.deps.evidence import load_evidence
10
+ from stackvitals.deps.lockfiles import read_lockfile
11
+
12
+ MARKS = {"good": "+", "info": ".", "warn": "!", "alarm": "!!"}
13
+ ORDER = {"investigate": 0, "monitor": 1, "use": 2, None: 3}
14
+ FOOTER = ("Evidence from the crawl's upgrade PRs and maintainer history, deps.dev and OSV. "
15
+ "Verdicts weigh what happened, not what will.")
16
+
17
+
18
+ def find(spec: str, ev: dict, now: datetime, ecosystem: str | None = None) -> list[dict]:
19
+ # "pypi:requests" names its ecosystem; a bare name is looked up in npm and PyPI and shown where it is known.
20
+ if ":" in spec:
21
+ eco, name = spec.split(":", 1)
22
+ return [dossier(eco, name, ev, now)]
23
+ found = [dossier(eco, spec, ev, now) for eco in ([ecosystem] if ecosystem else ["pypi", "npm"])]
24
+ known = [d for d in found if d["lines"] and "not in the evidence set" not in d["lines"][0]["text"]]
25
+ return known or found[:1]
26
+
27
+
28
+ def render(d: dict) -> str:
29
+ head = f"{d['ecosystem']}:{d['package']}: {d['verdict'] or 'not enough evidence yet'}"
30
+ lines = [head] + [f" {MARKS[x['signal']]} {x['text']}" for x in d["lines"]]
31
+ return "\n".join(lines)
32
+
33
+
34
+ def main(argv: list[str] | None = None, now: datetime | None = None) -> int:
35
+ if argv and argv[0] == "imports":
36
+ from stackvitals.deps.imports import main as imports_main
37
+ return imports_main(argv[1:])
38
+ parser = argparse.ArgumentParser(prog="stackvitals deps", description="Should we add, keep, upgrade or replace a package?")
39
+ sub = parser.add_subparsers(dest="cmd", required=True)
40
+ chk = sub.add_parser("check", help="One package's evidence and verdict: use, monitor or investigate")
41
+ chk.add_argument("packages", nargs="+", help="names like requests, or pypi:requests / npm:lodash")
42
+ chk.add_argument("--ecosystem", choices=["npm", "pypi"])
43
+ chk.add_argument("--json", action="store_true")
44
+ scan = sub.add_parser("scan", help="Every package in a lockfile, the ones that need a look first")
45
+ scan.add_argument("lockfile", type=Path)
46
+ scan.add_argument("--all", action="store_true", help="also list packages with a use verdict or too little evidence")
47
+ scan.add_argument("--json", action="store_true")
48
+ args = parser.parse_args(argv)
49
+ now = now or datetime.now(timezone.utc)
50
+ ev = load_evidence(token(), home() / "deps")
51
+ if ev is None:
52
+ print("The dependency evidence couldn't be loaded. Set STACKVITALS_DEPS_MAP_URL (and the other "
53
+ "STACKVITALS_DEPS_*_URL files), or a GitHub token that can read them.", file=sys.stderr)
54
+ return 1
55
+ if args.cmd == "check":
56
+ found = [d for spec in args.packages for d in find(spec, ev, now, args.ecosystem)]
57
+ print(json.dumps(found, indent=1) if args.json else "\n\n".join(render(d) for d in found) + f"\n\n{FOOTER}")
58
+ return 0
59
+ try:
60
+ pinned = read_lockfile(args.lockfile)
61
+ except (ValueError, OSError) as exc:
62
+ print(exc, file=sys.stderr)
63
+ return 1
64
+ found = sorted((dossier(eco, name, ev, now) for eco, name in dict.fromkeys((e, n) for e, n, _ in pinned)),
65
+ key=lambda d: (ORDER[d["verdict"]], d["package"]))
66
+ if args.json:
67
+ print(json.dumps(found, indent=1))
68
+ return 0
69
+ count = {v: sum(1 for d in found if d["verdict"] == v) for v in ORDER}
70
+ print(f"{len(found)} packages: {count['investigate']} investigate, {count['monitor']} monitor, {count['use']} use, "
71
+ f"{count[None]} not enough evidence")
72
+ for d in found:
73
+ if args.all or d["verdict"] in ("investigate", "monitor"):
74
+ reason = d["reasons"][0] if d["reasons"] else ""
75
+ print(f" {d['verdict'] or 'not enough evidence':<10} {d['ecosystem']}:{d['package']} {reason}")
76
+ print(f"\n{FOOTER}")
77
+ return 0