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.
- mergescout/__init__.py +1 -0
- mergescout/archive/__init__.py +0 -0
- mergescout/archive/__main__.py +30 -0
- mergescout/archive/convert.py +83 -0
- mergescout/archive/events.py +55 -0
- mergescout/archive/manifest.py +45 -0
- mergescout/archive/stream.py +83 -0
- mergescout/bench/__init__.py +1 -0
- mergescout/bench/__main__.py +135 -0
- mergescout/bench/ci.py +257 -0
- mergescout/bench/deploys.py +115 -0
- mergescout/bench/matched.py +22 -0
- mergescout/bench/prior.py +42 -0
- mergescout/bench/report.py +230 -0
- mergescout/bench/shared.py +112 -0
- mergescout/bench/stars.py +57 -0
- mergescout/cli.py +89 -0
- mergescout/config.py +35 -0
- mergescout/crawl/__init__.py +0 -0
- mergescout/crawl/__main__.py +97 -0
- mergescout/crawl/chain.py +34 -0
- mergescout/crawl/fetch.py +146 -0
- mergescout/crawl/outcomes.py +69 -0
- mergescout/crawl/queries.py +250 -0
- mergescout/crawl/runner.py +176 -0
- mergescout/crawl/seed.py +77 -0
- mergescout/crawl/state.py +169 -0
- mergescout/crawl/writer.py +87 -0
- mergescout/discover/__init__.py +0 -0
- mergescout/discover/__main__.py +58 -0
- mergescout/discover/sweep.py +286 -0
- mergescout/github/__init__.py +0 -0
- mergescout/github/graphql.py +202 -0
- mergescout/github/identities.py +35 -0
- mergescout/github/releases.py +137 -0
- mergescout/labels.py +20 -0
- mergescout/mcp_server.py +228 -0
- mergescout/measure/__init__.py +0 -0
- mergescout/measure/__main__.py +75 -0
- mergescout/measure/api_cost.py +91 -0
- mergescout/measure/count_repos.py +30 -0
- mergescout/measure/jsonl.py +14 -0
- mergescout/measure/report.py +56 -0
- mergescout/measure/sample.py +37 -0
- mergescout/owners.py +22 -0
- mergescout/setup_check.py +64 -0
- mergescout/signals/__init__.py +0 -0
- mergescout/signals/__main__.py +96 -0
- mergescout/signals/bump_ledger.py +258 -0
- mergescout/signals/bumps.py +104 -0
- mergescout/signals/check.py +141 -0
- mergescout/signals/community_bench.py +149 -0
- mergescout/signals/delivery_bench.py +103 -0
- mergescout/signals/deps_map.py +261 -0
- mergescout/signals/deps_trends.py +117 -0
- mergescout/signals/evidence.py +94 -0
- mergescout/signals/exploits.py +43 -0
- mergescout/signals/flow_bench.py +258 -0
- mergescout/signals/identity.py +73 -0
- mergescout/signals/issues.py +76 -0
- mergescout/signals/labels.py +79 -0
- mergescout/signals/osv.py +232 -0
- mergescout/signals/owners_watch.py +80 -0
- mergescout/signals/repo_stats.py +96 -0
- mergescout/signals/rules.py +70 -0
- mergescout/signals/silver.py +33 -0
- mergescout/signals/top_packages.py +26 -0
- mergescout/signals/triage.py +71 -0
- mergescout/signals/triage_math.py +120 -0
- mergescout/signals/walls.py +91 -0
- mergescout/snapshot/__init__.py +0 -0
- mergescout/snapshot/__main__.py +215 -0
- mergescout/snapshot/build.py +147 -0
- mergescout/snapshot/publish.py +70 -0
- mergescout/snapshot/schema.py +34 -0
- mergescout/snapshot/score.py +30 -0
- mergescout/store/__init__.py +0 -0
- mergescout/store/__main__.py +46 -0
- mergescout/store/sync.py +148 -0
- mergescout/timeutil.py +11 -0
- mergescout/tool/__init__.py +0 -0
- mergescout/tool/action_pack.py +62 -0
- mergescout/tool/live.py +201 -0
- mergescout/tool/queries.py +293 -0
- mergescout/tool/refs.py +31 -0
- mergescout/tool/store.py +129 -0
- mergescout/tool/tracking.py +83 -0
- stackvitals/__init__.py +2 -0
- stackvitals/__main__.py +4 -0
- stackvitals/ci/__init__.py +1 -0
- stackvitals/ci/action.py +574 -0
- stackvitals/ci/analyze.py +577 -0
- stackvitals/ci/api.py +218 -0
- stackvitals/ci/automation.py +212 -0
- stackvitals/ci/bench.py +195 -0
- stackvitals/ci/burnin.py +102 -0
- stackvitals/ci/cli.py +570 -0
- stackvitals/ci/collect.py +239 -0
- stackvitals/ci/cost.py +107 -0
- stackvitals/ci/coverage.py +155 -0
- stackvitals/ci/feedback.py +699 -0
- stackvitals/ci/fingerprint.py +220 -0
- stackvitals/ci/gitlab.py +121 -0
- stackvitals/ci/gitlab_action.py +125 -0
- stackvitals/ci/impact.py +125 -0
- stackvitals/ci/investigate.py +59 -0
- stackvitals/ci/kinds.py +52 -0
- stackvitals/ci/monitors.py +105 -0
- stackvitals/ci/owners.py +116 -0
- stackvitals/ci/quarantine_runtime.py +90 -0
- stackvitals/ci/report.py +435 -0
- stackvitals/ci/retry.py +67 -0
- stackvitals/ci/selfheal.py +102 -0
- stackvitals/ci/shards.py +120 -0
- stackvitals/ci/share.py +59 -0
- stackvitals/ci/store.py +93 -0
- stackvitals/ci/testreports.py +351 -0
- stackvitals/ci/tickets.py +106 -0
- stackvitals/ci/track_record.json +11 -0
- stackvitals/ci/triage.py +130 -0
- stackvitals/ci/triggers.py +147 -0
- stackvitals/ci/upload.py +85 -0
- stackvitals/ci/validate.py +91 -0
- stackvitals/cli.py +111 -0
- stackvitals/codeowners.py +69 -0
- stackvitals/community/__init__.py +0 -0
- stackvitals/community/action.py +140 -0
- stackvitals/community/affiliation.py +51 -0
- stackvitals/community/analyze.py +507 -0
- stackvitals/community/build.py +56 -0
- stackvitals/community/chaoss.py +70 -0
- stackvitals/community/cli.py +79 -0
- stackvitals/community/collect.py +240 -0
- stackvitals/community/context.py +54 -0
- stackvitals/community/report.py +250 -0
- stackvitals/connectors/__init__.py +244 -0
- stackvitals/connectors/ai_spend.py +190 -0
- stackvitals/connectors/base.py +82 -0
- stackvitals/connectors/check.py +686 -0
- stackvitals/connectors/copilot.py +104 -0
- stackvitals/connectors/discord.py +103 -0
- stackvitals/connectors/discourse.py +117 -0
- stackvitals/connectors/incident_io.py +106 -0
- stackvitals/connectors/jira.py +210 -0
- stackvitals/connectors/linear.py +140 -0
- stackvitals/connectors/pagerduty.py +96 -0
- stackvitals/connectors/slack.py +110 -0
- stackvitals/connectors/sonar.py +77 -0
- stackvitals/connectors/store.py +120 -0
- stackvitals/connectors/tracker.py +108 -0
- stackvitals/connectors/writeback.py +105 -0
- stackvitals/contribute/__init__.py +1 -0
- stackvitals/contribute/brief.py +297 -0
- stackvitals/contribute/cli.py +64 -0
- stackvitals/core/__init__.py +1 -0
- stackvitals/core/actors.py +103 -0
- stackvitals/core/agent_ledger.py +120 -0
- stackvitals/core/agreements.py +95 -0
- stackvitals/core/ai_outcomes.py +276 -0
- stackvitals/core/alerts.py +224 -0
- stackvitals/core/appdata.py +112 -0
- stackvitals/core/bucket.py +190 -0
- stackvitals/core/bugs.py +43 -0
- stackvitals/core/calendar.py +34 -0
- stackvitals/core/capex.py +57 -0
- stackvitals/core/change_risk.py +181 -0
- stackvitals/core/changes.py +133 -0
- stackvitals/core/chat.py +143 -0
- stackvitals/core/config.py +373 -0
- stackvitals/core/dataflow.py +168 -0
- stackvitals/core/decisions.py +275 -0
- stackvitals/core/delivery.py +954 -0
- stackvitals/core/delivery_peers.py +98 -0
- stackvitals/core/delivery_report.py +429 -0
- stackvitals/core/deliverycli.py +147 -0
- stackvitals/core/devlake_dora.sql +485 -0
- stackvitals/core/digest.py +333 -0
- stackvitals/core/explaincli.py +107 -0
- stackvitals/core/explore.py +296 -0
- stackvitals/core/export.py +342 -0
- stackvitals/core/exportcli.py +117 -0
- stackvitals/core/findings.py +350 -0
- stackvitals/core/health.py +94 -0
- stackvitals/core/identity.py +82 -0
- stackvitals/core/insights.py +365 -0
- stackvitals/core/insightscli.py +43 -0
- stackvitals/core/installdoc.py +69 -0
- stackvitals/core/investment.py +187 -0
- stackvitals/core/joins.py +43 -0
- stackvitals/core/llmsdoc.py +40 -0
- stackvitals/core/merge_queue.py +70 -0
- stackvitals/core/metrics.py +109 -0
- stackvitals/core/model.py +285 -0
- stackvitals/core/projectcli.py +66 -0
- stackvitals/core/propose.py +86 -0
- stackvitals/core/pulse.py +85 -0
- stackvitals/core/recorded.py +145 -0
- stackvitals/core/releases.py +39 -0
- stackvitals/core/requests.py +528 -0
- stackvitals/core/resync.py +35 -0
- stackvitals/core/schema.py +205 -0
- stackvitals/core/services.py +203 -0
- stackvitals/core/settings.py +96 -0
- stackvitals/core/sqlcli.py +111 -0
- stackvitals/core/stats.py +222 -0
- stackvitals/core/store.py +125 -0
- stackvitals/core/summary.py +104 -0
- stackvitals/core/time.py +29 -0
- stackvitals/core/tomledit.py +149 -0
- stackvitals/core/uicli.py +138 -0
- stackvitals/core/webapp.py +79 -0
- stackvitals/core/what_changed.py +76 -0
- stackvitals/core/workspace.py +194 -0
- stackvitals/deps/__init__.py +1 -0
- stackvitals/deps/action.py +299 -0
- stackvitals/deps/actions_vulns.py +89 -0
- stackvitals/deps/cli.py +77 -0
- stackvitals/deps/dossier.py +341 -0
- stackvitals/deps/evidence.py +44 -0
- stackvitals/deps/fixplan.py +40 -0
- stackvitals/deps/guarddog.py +32 -0
- stackvitals/deps/hygiene.py +47 -0
- stackvitals/deps/imports.py +113 -0
- stackvitals/deps/lockfiles.py +198 -0
- stackvitals/deps/lookalike.py +80 -0
- stackvitals/deps/reach.py +59 -0
- stackvitals/deps/release.py +110 -0
- stackvitals/deps/runtimes.py +161 -0
- stackvitals/deps/supply.py +454 -0
- stackvitals/deps/vulns.py +588 -0
- stackvitals/editors/__init__.py +40 -0
- stackvitals/editors/vscode/extension.js +101 -0
- stackvitals/editors/vscode/marks.js +92 -0
- stackvitals/editors/vscode/package.json +26 -0
- stackvitals/flow/__init__.py +1 -0
- stackvitals/flow/action.py +178 -0
- stackvitals/flow/analyze.py +418 -0
- stackvitals/flow/automate.py +72 -0
- stackvitals/flow/build.py +46 -0
- stackvitals/flow/cli.py +93 -0
- stackvitals/flow/code_hosts.py +146 -0
- stackvitals/flow/collect.py +451 -0
- stackvitals/flow/gitlab_mr.py +75 -0
- stackvitals/flow/knowledge.py +202 -0
- stackvitals/flow/peers.py +90 -0
- stackvitals/flow/preflight.py +210 -0
- stackvitals/flow/report.py +253 -0
- stackvitals/flow/rules.py +135 -0
- stackvitals/flow/store.py +108 -0
- stackvitals/flow/survival.py +84 -0
- stackvitals/flow/triage.py +62 -0
- stackvitals/mcp_server.py +670 -0
- stackvitals/portal/__init__.py +0 -0
- stackvitals/portal/action.py +205 -0
- stackvitals/portal/batch.py +80 -0
- stackvitals/portal/campaigns.py +104 -0
- stackvitals/portal/catalog.py +104 -0
- stackvitals/portal/checks.py +63 -0
- stackvitals/portal/cli.py +172 -0
- stackvitals/portal/descriptor.py +105 -0
- stackvitals/portal/export.py +43 -0
- stackvitals/portal/github.py +48 -0
- stackvitals/portal/golden.py +95 -0
- stackvitals/portal/history.py +30 -0
- stackvitals/portal/inventory.py +264 -0
- stackvitals/portal/owners.py +78 -0
- stackvitals/portal/pages.py +153 -0
- stackvitals/portal/rollup.py +32 -0
- stackvitals/portal/scorecard.py +366 -0
- stackvitals/security/__init__.py +2 -0
- stackvitals/security/probes.py +154 -0
- stackvitals/security/repo_checks.py +132 -0
- stackvitals/security/workflows.py +111 -0
- stackvitals/shell.py +107 -0
- stackvitals/site.py +51 -0
- stackvitals/web/app/LICENSES.md +428 -0
- stackvitals/web/app/boot.js +1 -0
- stackvitals/web/app/chunks/AddRepo-KRPZXWCY.js +1 -0
- stackvitals/web/app/chunks/SetupRunning-XWFQAKRZ.js +1 -0
- stackvitals/web/app/chunks/accuracy-data-OQTDXI7Y.js +1 -0
- stackvitals/web/app/chunks/agents-data-BAL6QAOF.js +11 -0
- stackvitals/web/app/chunks/chunk-24LFKFZ6.js +1 -0
- stackvitals/web/app/chunks/chunk-3G4SQNUC.js +1 -0
- stackvitals/web/app/chunks/chunk-3IXWEOIJ.js +154 -0
- stackvitals/web/app/chunks/chunk-3ROQFHPV.js +1 -0
- stackvitals/web/app/chunks/chunk-45PGAQRO.js +8 -0
- stackvitals/web/app/chunks/chunk-4H53OYLG.js +1 -0
- stackvitals/web/app/chunks/chunk-4LUI7O5P.js +1 -0
- stackvitals/web/app/chunks/chunk-4SQPQ5TB.js +1 -0
- stackvitals/web/app/chunks/chunk-4WKQTWUC.js +2 -0
- stackvitals/web/app/chunks/chunk-576YHDEK.js +8 -0
- stackvitals/web/app/chunks/chunk-6BDBXA6V.js +1 -0
- stackvitals/web/app/chunks/chunk-6RLVD2DT.js +1 -0
- stackvitals/web/app/chunks/chunk-763NH7PP.js +1 -0
- stackvitals/web/app/chunks/chunk-76WAVKUG.js +1 -0
- stackvitals/web/app/chunks/chunk-7II2BD5F.js +1 -0
- stackvitals/web/app/chunks/chunk-7TJCBOAY.js +1 -0
- stackvitals/web/app/chunks/chunk-ASYNLVOP.js +1 -0
- stackvitals/web/app/chunks/chunk-CTKDYZMG.js +1 -0
- stackvitals/web/app/chunks/chunk-CVB26NMC.js +1 -0
- stackvitals/web/app/chunks/chunk-G7NGB5UX.js +1 -0
- stackvitals/web/app/chunks/chunk-GPVLJRZD.js +1 -0
- stackvitals/web/app/chunks/chunk-GQYFN4PE.js +75 -0
- stackvitals/web/app/chunks/chunk-GT4EAKAO.js +2 -0
- stackvitals/web/app/chunks/chunk-IYMBFPCV.js +1 -0
- stackvitals/web/app/chunks/chunk-LAAGCPU3.js +1 -0
- stackvitals/web/app/chunks/chunk-MAPYGK3P.js +1 -0
- stackvitals/web/app/chunks/chunk-P5TXDD45.js +1 -0
- stackvitals/web/app/chunks/chunk-PABICGBL.js +1 -0
- stackvitals/web/app/chunks/chunk-RPXHAG6F.js +1 -0
- stackvitals/web/app/chunks/chunk-RQZPSJ2Y.js +1 -0
- stackvitals/web/app/chunks/chunk-S2ZBMEVA.js +1 -0
- stackvitals/web/app/chunks/chunk-S654BBEP.js +6 -0
- stackvitals/web/app/chunks/chunk-T7QVTNXF.js +1 -0
- stackvitals/web/app/chunks/chunk-THFUOCPC.js +273 -0
- stackvitals/web/app/chunks/chunk-TO4VVRZ7.js +1 -0
- stackvitals/web/app/chunks/chunk-VQ5YXO54.js +1 -0
- stackvitals/web/app/chunks/chunk-VWKSMFWJ.js +1 -0
- stackvitals/web/app/chunks/chunk-Y6LUXDM6.js +1 -0
- stackvitals/web/app/chunks/chunk-YTANGYYH.js +1 -0
- stackvitals/web/app/chunks/connect-KONYWYTT.js +1 -0
- stackvitals/web/app/chunks/connections-data-2DYS4UVW.js +32 -0
- stackvitals/web/app/chunks/logos-N5IINUAM.js +1 -0
- stackvitals/web/app/chunks/more-KMJMJQD3.js +10 -0
- stackvitals/web/app/chunks/more-PC6FVS3P.js +1 -0
- stackvitals/web/app/chunks/more-RCIVCUDE.js +2 -0
- stackvitals/web/app/chunks/more-ZKKRY6NO.css +1 -0
- stackvitals/web/app/chunks/sealbox-35YL4EUH.js +3 -0
- stackvitals/web/app/chunks/session-DYYTOE2K.js +1 -0
- stackvitals/web/app/chunks/settingsApply-2HRJEZYM.js +1 -0
- stackvitals/web/app/chunks/settingsReview-GX2FZQ4M.js +3 -0
- stackvitals/web/app/chunks/uPlot.esm-OIWT3AHJ.js +3 -0
- stackvitals/web/app/demo/ci/impact.json +1 -0
- stackvitals/web/app/demo/ci/quarantine.json +1 -0
- stackvitals/web/app/demo/ci/tickets.json +1 -0
- stackvitals/web/app/demo/mobile/alerts.json +1 -0
- stackvitals/web/app/demo/mobile/app.json +1 -0
- stackvitals/web/app/demo/mobile/community.json +1 -0
- stackvitals/web/app/demo/mobile/connectors.json +1 -0
- stackvitals/web/app/demo/mobile/data.json +1 -0
- stackvitals/web/app/demo/mobile/decisions.json +1 -0
- stackvitals/web/app/demo/mobile/delivery.json +1 -0
- stackvitals/web/app/demo/mobile/deps.json +1 -0
- stackvitals/web/app/demo/mobile/flow.json +1 -0
- stackvitals/web/app/demo/mobile/health.json +1 -0
- stackvitals/web/app/demo/mobile/insights.json +1 -0
- stackvitals/web/app/demo/mobile/pulse.json +1 -0
- stackvitals/web/app/demo/mobile/security.json +1 -0
- stackvitals/web/app/demo/mobile/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/mobile/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/mobile/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/mobile/summary/manifest.json +1 -0
- stackvitals/web/app/demo/mobile/summary/prs.jsonl +19 -0
- stackvitals/web/app/demo/mobile/summary/tests.jsonl +1 -0
- stackvitals/web/app/demo/mobile/supply.json +1 -0
- stackvitals/web/app/demo/mobile/vulns.json +1 -0
- stackvitals/web/app/demo/payments-api/alerts.json +1 -0
- stackvitals/web/app/demo/payments-api/app.json +1 -0
- stackvitals/web/app/demo/payments-api/community.json +1 -0
- stackvitals/web/app/demo/payments-api/connectors.json +1 -0
- stackvitals/web/app/demo/payments-api/data.json +1 -0
- stackvitals/web/app/demo/payments-api/decisions.json +1 -0
- stackvitals/web/app/demo/payments-api/delivery.json +1 -0
- stackvitals/web/app/demo/payments-api/deps.json +1 -0
- stackvitals/web/app/demo/payments-api/flow.json +1 -0
- stackvitals/web/app/demo/payments-api/health.json +1 -0
- stackvitals/web/app/demo/payments-api/insights.json +1 -0
- stackvitals/web/app/demo/payments-api/pulse.json +1 -0
- stackvitals/web/app/demo/payments-api/security.json +1 -0
- stackvitals/web/app/demo/payments-api/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/payments-api/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/payments-api/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/payments-api/summary/manifest.json +1 -0
- stackvitals/web/app/demo/payments-api/summary/prs.jsonl +22 -0
- stackvitals/web/app/demo/payments-api/summary/tests.jsonl +3 -0
- stackvitals/web/app/demo/payments-api/supply.json +1 -0
- stackvitals/web/app/demo/payments-api/vulns.json +1 -0
- stackvitals/web/app/demo/portal.json +1 -0
- stackvitals/web/app/demo/settings.json +1 -0
- stackvitals/web/app/demo/web-app/alerts.json +1 -0
- stackvitals/web/app/demo/web-app/app.json +1 -0
- stackvitals/web/app/demo/web-app/community.json +1 -0
- stackvitals/web/app/demo/web-app/connectors.json +1 -0
- stackvitals/web/app/demo/web-app/data.json +1 -0
- stackvitals/web/app/demo/web-app/decisions.json +1 -0
- stackvitals/web/app/demo/web-app/delivery.json +1 -0
- stackvitals/web/app/demo/web-app/deps.json +1 -0
- stackvitals/web/app/demo/web-app/flow.json +1 -0
- stackvitals/web/app/demo/web-app/health.json +1 -0
- stackvitals/web/app/demo/web-app/insights.json +1 -0
- stackvitals/web/app/demo/web-app/pulse.json +1 -0
- stackvitals/web/app/demo/web-app/security.json +1 -0
- stackvitals/web/app/demo/web-app/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/web-app/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/web-app/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/web-app/summary/manifest.json +1 -0
- stackvitals/web/app/demo/web-app/summary/prs.jsonl +18 -0
- stackvitals/web/app/demo/web-app/summary/tests.jsonl +2 -0
- stackvitals/web/app/demo/web-app/supply.json +1 -0
- stackvitals/web/app/demo/web-app/vulns.json +1 -0
- stackvitals/web/app/fonts/plex-mono-400.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-400.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-600.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-condensed-700.woff2 +0 -0
- stackvitals/web/app/help/accuracy.json +1 -0
- stackvitals/web/app/help/connectors.json +1 -0
- stackvitals/web/app/help/getting-started.json +1 -0
- stackvitals/web/app/help/index.json +1 -0
- stackvitals/web/app/help/modules.json +1 -0
- stackvitals/web/app/help/privacy-and-security.json +1 -0
- stackvitals/web/app/help/reference.json +1 -0
- stackvitals/web/app/main.css +1 -0
- stackvitals/web/app/main.js +1 -0
- stackvitals/web/app/pages/about.css +1 -0
- stackvitals/web/app/pages/about.js +1 -0
- stackvitals/web/app/pages/activity.css +1 -0
- stackvitals/web/app/pages/activity.js +1 -0
- stackvitals/web/app/pages/agents.css +1 -0
- stackvitals/web/app/pages/agents.js +2 -0
- stackvitals/web/app/pages/ci.css +1 -0
- stackvitals/web/app/pages/ci.js +1 -0
- stackvitals/web/app/pages/community.css +1 -0
- stackvitals/web/app/pages/community.js +1 -0
- stackvitals/web/app/pages/connections.css +1 -0
- stackvitals/web/app/pages/connections.js +6 -0
- stackvitals/web/app/pages/delivery.css +1 -0
- stackvitals/web/app/pages/delivery.js +45 -0
- stackvitals/web/app/pages/deps.css +1 -0
- stackvitals/web/app/pages/deps.js +7 -0
- stackvitals/web/app/pages/explore.css +1 -0
- stackvitals/web/app/pages/explore.js +7 -0
- stackvitals/web/app/pages/health.css +1 -0
- stackvitals/web/app/pages/health.js +1 -0
- stackvitals/web/app/pages/help.css +1 -0
- stackvitals/web/app/pages/help.js +6 -0
- stackvitals/web/app/pages/insights.css +1 -0
- stackvitals/web/app/pages/insights.js +1 -0
- stackvitals/web/app/pages/investment.css +1 -0
- stackvitals/web/app/pages/investment.js +3 -0
- stackvitals/web/app/pages/overview.css +1 -0
- stackvitals/web/app/pages/overview.js +1 -0
- stackvitals/web/app/pages/portal.css +1 -0
- stackvitals/web/app/pages/portal.js +51 -0
- stackvitals/web/app/pages/prs.css +1 -0
- stackvitals/web/app/pages/prs.js +4 -0
- stackvitals/web/app/pages/settings.css +1 -0
- stackvitals/web/app/pages/settings.js +1 -0
- stackvitals/web/app/pages/setup.css +1 -0
- stackvitals/web/app/pages/setup.js +2 -0
- stackvitals/web/app/pages/standing.css +1 -0
- stackvitals/web/app/pages/standing.js +1 -0
- stackvitals/web/app/pages/welcome-sheet.js +1 -0
- stackvitals/web/app/pages/welcome.js +1 -0
- stackvitals/web/app/settings-workflow.json +1 -0
- stackvitals/web/index.html +34 -0
- stackvitals/web/site/index.html +108 -0
- stackvitals/web/site/integrations.html +15 -0
- stackvitals/web/site/setup.html +15 -0
- stackvitals/web/site/site.css +68 -0
- stackvitals-0.1.0.dist-info/METADATA +134 -0
- stackvitals-0.1.0.dist-info/RECORD +465 -0
- stackvitals-0.1.0.dist-info/WHEEL +4 -0
- stackvitals-0.1.0.dist-info/entry_points.txt +3 -0
- stackvitals-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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())
|