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,198 @@
|
|
|
1
|
+
"""Packages and versions from the lockfiles projects commit: npm's package-lock, and pip, uv, Poetry and Pipenv files."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import tomllib
|
|
5
|
+
from collections import deque
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from mergescout.signals.bumps import _norm
|
|
10
|
+
|
|
11
|
+
SUPPORTED = "package-lock.json, npm-shrinkwrap.json, requirements*.txt, uv.lock, poetry.lock, Pipfile.lock"
|
|
12
|
+
REGISTRY_HOSTS = {"npm": ("registry.npmjs.org", "registry.yarnpkg.com"), "pypi": ("pypi.org", "files.pythonhosted.org")}
|
|
13
|
+
REQUIREMENT = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(?:==\s*([^\s,;]+))?")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_lockfile(path: Path) -> list[tuple[str, str, str | None]]:
|
|
17
|
+
"""(ecosystem, name, version) for every package the file pins, sorted; a file it can't read is a clear error."""
|
|
18
|
+
path = Path(path)
|
|
19
|
+
return parse_lockfile(path.name, path.read_text(encoding="utf-8"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_lockfile(filename: str) -> bool:
|
|
23
|
+
name = filename.rsplit("/", 1)[-1].lower()
|
|
24
|
+
return name in ("package-lock.json", "npm-shrinkwrap.json", "uv.lock", "poetry.lock", "pipfile.lock") or (
|
|
25
|
+
name.startswith("requirements") and name.endswith(".txt"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_lockfile(filename: str, text: str) -> list[tuple[str, str, str | None]]:
|
|
29
|
+
name = filename.rsplit("/", 1)[-1].lower()
|
|
30
|
+
if name in ("package-lock.json", "npm-shrinkwrap.json"):
|
|
31
|
+
found = _npm(json.loads(text))
|
|
32
|
+
elif name.endswith(".txt"):
|
|
33
|
+
found = _requirements(text)
|
|
34
|
+
elif name in ("uv.lock", "poetry.lock"):
|
|
35
|
+
found = {("pypi", _norm("pypi", p["name"]), p.get("version")) for p in tomllib.loads(text).get("package") or []
|
|
36
|
+
if p.get("name") and not _local((p.get("source") or {}))}
|
|
37
|
+
elif name == "pipfile.lock":
|
|
38
|
+
doc = json.loads(text)
|
|
39
|
+
found = {("pypi", _norm("pypi", n), (v.get("version") or "").lstrip("=") or None)
|
|
40
|
+
for section in ("default", "develop") for n, v in (doc.get(section) or {}).items()}
|
|
41
|
+
else:
|
|
42
|
+
raise ValueError(f"{filename} isn't a lockfile this reads yet; it reads {SUPPORTED}.")
|
|
43
|
+
return sorted(found, key=lambda t: (t[0], t[1], t[2] or ""))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _local(source: dict) -> bool:
|
|
47
|
+
# uv lists the project itself and path dependencies; they aren't registry packages.
|
|
48
|
+
return any(k in source for k in ("editable", "virtual", "directory", "path"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _npm(doc: dict) -> set:
|
|
52
|
+
if isinstance(doc.get("packages"), dict):
|
|
53
|
+
# v2/v3: "node_modules/a/node_modules/b" is package b, nested under a; an alias ("react": "npm:other@1") names the real one.
|
|
54
|
+
return {("npm", _npm_name(key, v), v.get("version")) for key, v in doc["packages"].items()
|
|
55
|
+
if key and "node_modules/" in key and isinstance(v, dict) and not v.get("link")}
|
|
56
|
+
found = set()
|
|
57
|
+
|
|
58
|
+
def walk(deps: dict) -> None:
|
|
59
|
+
for n, v in (deps or {}).items():
|
|
60
|
+
found.add(("npm", *_v1_alias(n, (v or {}).get("version"))))
|
|
61
|
+
walk((v or {}).get("dependencies"))
|
|
62
|
+
walk(doc.get("dependencies"))
|
|
63
|
+
return found
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _v1_alias(name: str, version) -> tuple[str, str | None]:
|
|
67
|
+
# v1 writes an alias as "version": "npm:real-name@1.2.3".
|
|
68
|
+
if isinstance(version, str) and version.startswith("npm:") and "@" in version[5:]:
|
|
69
|
+
real, _, ver = version[4:].rpartition("@")
|
|
70
|
+
return real, ver
|
|
71
|
+
return name, version
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _npm_name(key: str, entry: dict) -> str:
|
|
75
|
+
name = entry.get("name")
|
|
76
|
+
return name if isinstance(name, str) and name else key.rsplit("node_modules/", 1)[1]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _host(url: str) -> str:
|
|
80
|
+
parsed = urlparse(url.removeprefix("git+"))
|
|
81
|
+
return parsed.hostname or (url.split(":", 1)[0] if ":" in url else "a local path")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def foreign_sources(filename: str, text: str) -> dict[tuple[str, str], str]:
|
|
85
|
+
"""Packages installed from somewhere other than the public registry (a git repo, a tarball URL, another index), with
|
|
86
|
+
where from: evidence about the registry package of that name doesn't describe them."""
|
|
87
|
+
name, out = filename.rsplit("/", 1)[-1].lower(), {}
|
|
88
|
+
if name in ("package-lock.json", "npm-shrinkwrap.json"):
|
|
89
|
+
doc = json.loads(text)
|
|
90
|
+
if isinstance(doc.get("packages"), dict):
|
|
91
|
+
entries = [(_npm_name(k, v), v) for k, v in doc["packages"].items()
|
|
92
|
+
if k and "node_modules/" in k and isinstance(v, dict) and not v.get("link")]
|
|
93
|
+
else:
|
|
94
|
+
entries = []
|
|
95
|
+
|
|
96
|
+
def walk(deps):
|
|
97
|
+
for n, v in (deps or {}).items():
|
|
98
|
+
entries.append((_v1_alias(n, (v or {}).get("version"))[0], v or {}))
|
|
99
|
+
walk((v or {}).get("dependencies"))
|
|
100
|
+
walk(doc.get("dependencies"))
|
|
101
|
+
for pkg, v in entries:
|
|
102
|
+
url = v.get("resolved")
|
|
103
|
+
if isinstance(url, str) and url and _host(url) not in REGISTRY_HOSTS["npm"]:
|
|
104
|
+
out[("npm", pkg)] = _host(url)
|
|
105
|
+
elif name in ("uv.lock", "poetry.lock"):
|
|
106
|
+
for p in tomllib.loads(text).get("package") or []:
|
|
107
|
+
src = p.get("source") or {}
|
|
108
|
+
if not p.get("name") or _local(src):
|
|
109
|
+
continue
|
|
110
|
+
url = src.get("git") or src.get("url") or src.get("registry") or ""
|
|
111
|
+
if isinstance(url, str) and url and (src.get("type") or "git") != "directory" and _host(url) not in REGISTRY_HOSTS["pypi"]:
|
|
112
|
+
out[("pypi", _norm("pypi", p["name"]))] = _host(url)
|
|
113
|
+
elif name == "pipfile.lock":
|
|
114
|
+
doc = json.loads(text)
|
|
115
|
+
for section in ("default", "develop"):
|
|
116
|
+
for n, v in (doc.get(section) or {}).items():
|
|
117
|
+
if isinstance(v, dict) and isinstance(v.get("git") or v.get("file"), str):
|
|
118
|
+
out[("pypi", _norm("pypi", n))] = _host(v.get("git") or v.get("file"))
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _requirements(text: str) -> set:
|
|
123
|
+
found = set()
|
|
124
|
+
for line in text.splitlines():
|
|
125
|
+
line = line.split("#", 1)[0].strip()
|
|
126
|
+
# Options (-e, -r, --hash) and URLs aren't registry packages.
|
|
127
|
+
if not line or line.startswith("-") or "://" in line:
|
|
128
|
+
continue
|
|
129
|
+
m = REQUIREMENT.match(line)
|
|
130
|
+
if m:
|
|
131
|
+
found.add(("pypi", _norm("pypi", m.group(1)), m.group(2)))
|
|
132
|
+
return found
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def dependency_paths(filename: str, text: str, targets: set[str]) -> dict[str, list[str]]:
|
|
136
|
+
"""The shortest chain from a direct dependency to each target package (["express", "qs", "side-channel"]), for
|
|
137
|
+
lockfiles that record the graph and which packages are direct (npm v2/v3, uv); none for the rest (poetry.lock
|
|
138
|
+
doesn't say which are direct, so a guessed chain could name the wrong one) or a file that can't be read."""
|
|
139
|
+
name = filename.rsplit("/", 1)[-1].lower()
|
|
140
|
+
npm = name in ("package-lock.json", "npm-shrinkwrap.json")
|
|
141
|
+
try:
|
|
142
|
+
if npm:
|
|
143
|
+
roots, children = _npm_graph(json.loads(text))
|
|
144
|
+
elif name == "uv.lock":
|
|
145
|
+
roots, children = _uv_graph(tomllib.loads(text))
|
|
146
|
+
else:
|
|
147
|
+
return {}
|
|
148
|
+
except (ValueError, TypeError, AttributeError):
|
|
149
|
+
return {}
|
|
150
|
+
want = set(targets) if npm else {_norm("pypi", t) for t in targets}
|
|
151
|
+
found, queue, seen = {}, deque((r, [label]) for r, label in roots), set()
|
|
152
|
+
while queue and len(found) < len(want):
|
|
153
|
+
node, path = queue.popleft()
|
|
154
|
+
if node in seen:
|
|
155
|
+
continue
|
|
156
|
+
seen.add(node)
|
|
157
|
+
if path[-1] in want and path[-1] not in found:
|
|
158
|
+
found[path[-1]] = path
|
|
159
|
+
queue.extend((child, path + [label]) for child, label in children.get(node, ()))
|
|
160
|
+
return found
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _npm_graph(doc: dict) -> tuple[list, dict]:
|
|
164
|
+
pkgs = doc.get("packages")
|
|
165
|
+
if not isinstance(pkgs, dict):
|
|
166
|
+
return [], {}
|
|
167
|
+
|
|
168
|
+
def deps(entry: dict) -> list[str]:
|
|
169
|
+
return [n for key in ("dependencies", "optionalDependencies", "devDependencies", "peerDependencies")
|
|
170
|
+
for n in (entry.get(key) or {})]
|
|
171
|
+
|
|
172
|
+
def resolve(parent: str, child: str) -> str | None:
|
|
173
|
+
# Node looks in the parent's own node_modules, then each folder above it, then the top.
|
|
174
|
+
here = parent
|
|
175
|
+
while True:
|
|
176
|
+
key = f"{here}/node_modules/{child}" if here else f"node_modules/{child}"
|
|
177
|
+
if key in pkgs:
|
|
178
|
+
return key
|
|
179
|
+
if not here:
|
|
180
|
+
return None
|
|
181
|
+
here = here.rsplit("/node_modules/", 1)[0] if "/node_modules/" in here else ""
|
|
182
|
+
children = {key: [(k, _npm_name(k, pkgs[k])) for c in deps(v) if (k := resolve(key, c))]
|
|
183
|
+
for key, v in pkgs.items() if isinstance(v, dict)}
|
|
184
|
+
return children.get("", []), children
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _uv_graph(doc: dict) -> tuple[list, dict]:
|
|
188
|
+
packages = [p for p in doc.get("package") or [] if p.get("name")]
|
|
189
|
+
edges = {_norm("pypi", p["name"]): [_norm("pypi", d["name"]) for key in ("dependencies",)
|
|
190
|
+
for d in p.get(key) or [] if d.get("name")]
|
|
191
|
+
+ [_norm("pypi", d["name"]) for group in ("optional-dependencies", "dev-dependencies")
|
|
192
|
+
for ds in (p.get(group) or {}).values() for d in ds if d.get("name")]
|
|
193
|
+
for p in packages}
|
|
194
|
+
# The project's own entries (a local source) name its direct dependencies.
|
|
195
|
+
local = [_norm("pypi", p["name"]) for p in packages if _local(p.get("source") or {})]
|
|
196
|
+
roots = [d for proj in local for d in edges.get(proj, [])]
|
|
197
|
+
children = {n: [(d, d) for d in ds if d in edges] for n, ds in edges.items()}
|
|
198
|
+
return [(r, r) for r in roots if r in edges], children
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Names that imitate a popular package (typosquats): one edit away, or the same letters with other separators. The
|
|
2
|
+
popular names are the ones the dependency evidence already covers (the crawl's most-used and most-upgraded packages)."""
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
MIN_LENGTH = 5 # shorter popular names have too many real neighbours ("chalk", "chai")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _norm(eco: str, name: str) -> str:
|
|
9
|
+
name = name.lower()
|
|
10
|
+
return re.sub(r"[-_.]+", "-", name) if eco == "pypi" else name
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _one_edit(a: str, b: str) -> bool:
|
|
14
|
+
"""True when b is a with one letter added, dropped, changed, or two neighbours swapped."""
|
|
15
|
+
if a == b or abs(len(a) - len(b)) > 1:
|
|
16
|
+
return False
|
|
17
|
+
if len(a) == len(b):
|
|
18
|
+
diff = [i for i in range(len(a)) if a[i] != b[i]]
|
|
19
|
+
return len(diff) == 1 or (len(diff) == 2 and diff[1] == diff[0] + 1 and a[diff[0]] == b[diff[1]] and a[diff[1]] == b[diff[0]])
|
|
20
|
+
short, long_ = (a, b) if len(a) < len(b) else (b, a)
|
|
21
|
+
i = 0
|
|
22
|
+
while i < len(short) and short[i] == long_[i]:
|
|
23
|
+
i += 1
|
|
24
|
+
return short[i:] == long_[i + 1:]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def lookalike(eco: str, name: str, popular: dict[str, set[str]]) -> str | None:
|
|
28
|
+
"""The popular package `name` imitates, or None (a popular name itself, a scoped one, or nothing close)."""
|
|
29
|
+
names = {_norm(eco, p): p for p in popular.get(eco) or ()}
|
|
30
|
+
mine = _norm(eco, name)
|
|
31
|
+
if not names or mine in names or mine.startswith("@"):
|
|
32
|
+
return None
|
|
33
|
+
bare = re.sub(r"[-_.]", "", mine)
|
|
34
|
+
for norm, original in sorted(names.items()):
|
|
35
|
+
if len(norm) < MIN_LENGTH or norm.startswith("@"):
|
|
36
|
+
continue
|
|
37
|
+
if _one_edit(norm, mine) or (bare == re.sub(r"[-_.]", "", norm) and bare != mine):
|
|
38
|
+
return original
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# On OpenSSF's malicious look-alikes the weekly downloads were 1 to 75; legitimate look-alikes of popular names had
|
|
43
|
+
# 823,598 to 192 million (bench/accuracy/deps). Under this, a look-alike is flagged.
|
|
44
|
+
LOW_DOWNLOADS = 1000
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
NPM_STATS, PYPI_STATS = "https://api.npmjs.org", "https://pypistats.org"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def weekly_downloads(eco: str, name: str, client) -> int | None:
|
|
51
|
+
"""Last week's downloads from the registry's free stats (npm's API, pypistats for PyPI); None when unknown."""
|
|
52
|
+
import httpx
|
|
53
|
+
url = (f"{NPM_STATS}/downloads/point/last-week/{name}" if eco == "npm"
|
|
54
|
+
else f"{PYPI_STATS}/api/packages/{_norm('pypi', name)}/recent" if eco == "pypi" else None)
|
|
55
|
+
if not url or client is None:
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
resp = client.get(url)
|
|
59
|
+
body = resp.json() if resp.status_code == 200 else {}
|
|
60
|
+
except (httpx.HTTPError, ValueError):
|
|
61
|
+
return None
|
|
62
|
+
value = body.get("downloads") if eco == "npm" else (body.get("data") or {}).get("last_week")
|
|
63
|
+
return value if isinstance(value, int) else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def judge(eco: str, name: str, popular: dict[str, set[str]], client) -> dict | None:
|
|
67
|
+
"""A verdict for a package a PR adds whose name imitates a popular one and that few people use, else None."""
|
|
68
|
+
target = lookalike(eco, name, popular)
|
|
69
|
+
if not target:
|
|
70
|
+
return None
|
|
71
|
+
downloads = weekly_downloads(eco, name, client)
|
|
72
|
+
if downloads is not None and downloads >= LOW_DOWNLOADS:
|
|
73
|
+
return None
|
|
74
|
+
if downloads is None:
|
|
75
|
+
# Without the download count the name alone false-alarms on real neighbours (preact and react): a note, not an alarm.
|
|
76
|
+
return {"verdict": "monitor", "reasons": [f"Its name is one edit from the popular {target}, and its downloads "
|
|
77
|
+
f"couldn't be checked. Check you meant {target}."]}
|
|
78
|
+
return {"verdict": "investigate", "reasons": [f"Its name is one edit from the popular {target} and it has "
|
|
79
|
+
f"{downloads:,} downloads last week: a common way to slip in a malicious "
|
|
80
|
+
f"package. Check you meant {target}."]}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Reachability from govulncheck (Go's own free scanner, run in the team's workflow): for each advisory, whether the code
|
|
2
|
+
only requires the module, imports the vulnerable package, or calls the vulnerable function, with the call path."""
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
LEVELS = ("required", "imported", "called")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _short(frame: dict) -> str:
|
|
9
|
+
# "example.com/app/web" + "render" reads as "web.render"; the program's entry stays "main".
|
|
10
|
+
fn = frame.get("function") or ""
|
|
11
|
+
pkg = (frame.get("package") or "").rsplit("/", 1)[-1]
|
|
12
|
+
if frame.get("receiver"):
|
|
13
|
+
fn = f"{frame['receiver'].lstrip('*')}.{fn}"
|
|
14
|
+
return fn if fn == "main" or not pkg else f"{pkg}.{fn}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def read_govulncheck(text: str) -> dict:
|
|
18
|
+
"""govulncheck -format json output: each advisory's deepest reach, its fixing version, aliases and call path."""
|
|
19
|
+
osv, found, scanner = {}, {}, None
|
|
20
|
+
for line in _objects(text):
|
|
21
|
+
if isinstance(line.get("config"), dict):
|
|
22
|
+
c = line["config"]
|
|
23
|
+
scanner = f"{c.get('scanner_name', 'govulncheck')} {c.get('scanner_version', '')}".strip()
|
|
24
|
+
elif isinstance(line.get("osv"), dict) and line["osv"].get("id"):
|
|
25
|
+
osv[line["osv"]["id"]] = line["osv"]
|
|
26
|
+
elif isinstance(line.get("finding"), dict) and line["finding"].get("osv"):
|
|
27
|
+
f = line["finding"]
|
|
28
|
+
trace = [t for t in f.get("trace") or [] if isinstance(t, dict)]
|
|
29
|
+
top = trace[0] if trace else {}
|
|
30
|
+
reach = "called" if top.get("function") else "imported" if top.get("package") else "required"
|
|
31
|
+
mine = found.setdefault(f["osv"], {"id": f["osv"], "reach": reach, "fixed": f.get("fixed_version"),
|
|
32
|
+
"module": top.get("module"), "version": top.get("version"), "path": []})
|
|
33
|
+
if LEVELS.index(reach) >= LEVELS.index(mine["reach"]):
|
|
34
|
+
mine["reach"] = reach
|
|
35
|
+
if reach == "called":
|
|
36
|
+
mine["path"] = [_short(t) for t in reversed(trace) if t.get("function")]
|
|
37
|
+
findings = []
|
|
38
|
+
for i, f in sorted(found.items()):
|
|
39
|
+
o = osv.get(i) or {}
|
|
40
|
+
findings.append({**f, "aliases": list(o.get("aliases") or []), "summary": str(o.get("summary") or "")[:200]})
|
|
41
|
+
return {"scanner": scanner, "findings": findings,
|
|
42
|
+
"counts": {level: sum(1 for f in findings if f["reach"] == level) for level in LEVELS}}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _objects(text: str):
|
|
46
|
+
# govulncheck writes indented JSON objects one after another; read them whole, skipping anything else.
|
|
47
|
+
decoder, at = json.JSONDecoder(), 0
|
|
48
|
+
while at < len(text):
|
|
49
|
+
start = text.find("{", at)
|
|
50
|
+
if start < 0:
|
|
51
|
+
return
|
|
52
|
+
try:
|
|
53
|
+
obj, end = decoder.raw_decode(text, start)
|
|
54
|
+
except ValueError:
|
|
55
|
+
at = start + 1
|
|
56
|
+
continue
|
|
57
|
+
at = end
|
|
58
|
+
if isinstance(obj, dict):
|
|
59
|
+
yield obj
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Signs a new release may be compromised, before any advisory exists: it is only hours old, it adds an install script,
|
|
2
|
+
it pulls in new dependencies, or the signed record of where it was built is gone. Compared with the version it
|
|
3
|
+
replaces, from the registries' own metadata. A sign is a reason to look first, never a block."""
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
NPM = "https://registry.npmjs.org"
|
|
8
|
+
PYPI = "https://pypi.org"
|
|
9
|
+
DEPS_DEV = "https://api.deps.dev/v3/systems"
|
|
10
|
+
HOOKS = ("preinstall", "install", "postinstall")
|
|
11
|
+
# 2025-26 compromised releases stayed up from 40 minutes to a few hours; a release younger than this gets a sign.
|
|
12
|
+
COOLDOWN = timedelta(hours=48)
|
|
13
|
+
MAX_PACKAGES = 30
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _key(version: str) -> list:
|
|
17
|
+
import re
|
|
18
|
+
return [(0, int(p)) if p.isdigit() else (1, p) for p in re.split(r"[.\-+_]", str(version)) if p]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get(client, url: str) -> dict | None:
|
|
22
|
+
try:
|
|
23
|
+
r = client.get(url)
|
|
24
|
+
except Exception: # noqa: BLE001 - a registry hiccup means no sign, never a failed check
|
|
25
|
+
return None
|
|
26
|
+
return r.json() if r.status_code == 200 else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _age(published: str | None, now: datetime) -> str | None:
|
|
30
|
+
if not published:
|
|
31
|
+
return None
|
|
32
|
+
when = datetime.fromisoformat(published.replace("Z", "+00:00"))
|
|
33
|
+
hours = (now - when).total_seconds() / 3600
|
|
34
|
+
if 0 <= hours < COOLDOWN.total_seconds() / 3600:
|
|
35
|
+
n = max(1, round(hours))
|
|
36
|
+
return (f"Released {n} hour{'' if n == 1 else 's'} ago: compromised releases are usually found and pulled within hours, "
|
|
37
|
+
"so a few days' wait is safer.")
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def npm_signs(client, name: str, old: str | None, new: str, now: datetime) -> list[str]:
|
|
42
|
+
path = quote(name, safe="@")
|
|
43
|
+
after = _get(client, f"{NPM}/{path}/{quote(new)}")
|
|
44
|
+
if not after:
|
|
45
|
+
return []
|
|
46
|
+
signs = []
|
|
47
|
+
meta = _get(client, f"{DEPS_DEV}/npm/packages/{quote(name, safe='')}/versions/{quote(new, safe='')}") or {}
|
|
48
|
+
if age := _age(meta.get("publishedAt"), now):
|
|
49
|
+
signs.append(age)
|
|
50
|
+
before = _get(client, f"{NPM}/{path}/{quote(old)}") if old else None
|
|
51
|
+
hooks = [h for h in HOOKS if (after.get("scripts") or {}).get(h)]
|
|
52
|
+
if before is not None:
|
|
53
|
+
added = [h for h in hooks if not (before.get("scripts") or {}).get(h)]
|
|
54
|
+
if added:
|
|
55
|
+
signs.append(f"Adds an install script ({', '.join(added)}) that {old} didn't have: it runs when the package installs.")
|
|
56
|
+
new_deps = sorted(set(after.get("dependencies") or {}) - set(before.get("dependencies") or {}))
|
|
57
|
+
if new_deps:
|
|
58
|
+
signs.append(f"New dependencies since {old}: {', '.join(new_deps[:5])}{' and more' if len(new_deps) > 5 else ''}.")
|
|
59
|
+
if (before.get("dist") or {}).get("attestations") and not (after.get("dist") or {}).get("attestations"):
|
|
60
|
+
signs.append(f"{old} carried build provenance (a signed record of where it was built); {new} doesn't.")
|
|
61
|
+
return signs
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _pypi_release(client, name: str, version: str) -> dict | None:
|
|
65
|
+
return _get(client, f"{PYPI}/pypi/{quote(name)}/{quote(version)}/json")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _pypi_provenance(client, name: str, version: str, doc: dict) -> bool | None:
|
|
69
|
+
files = doc.get("urls") or []
|
|
70
|
+
if not files:
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
r = client.get(f"{PYPI}/integrity/{quote(name)}/{quote(version)}/{quote(files[0]['filename'])}/provenance")
|
|
74
|
+
except Exception: # noqa: BLE001
|
|
75
|
+
return None
|
|
76
|
+
return r.status_code == 200
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def pypi_signs(client, name: str, old: str | None, new: str, now: datetime) -> list[str]:
|
|
80
|
+
after = _pypi_release(client, name, new)
|
|
81
|
+
if not after:
|
|
82
|
+
return []
|
|
83
|
+
signs = []
|
|
84
|
+
times = sorted(u.get("upload_time_iso_8601") for u in after.get("urls") or [] if u.get("upload_time_iso_8601"))
|
|
85
|
+
if age := _age(times[0] if times else None, now):
|
|
86
|
+
signs.append(age)
|
|
87
|
+
before = _pypi_release(client, name, old) if old else None
|
|
88
|
+
if before is not None:
|
|
89
|
+
def names(doc):
|
|
90
|
+
import re
|
|
91
|
+
return {re.split(r"[\s<>=!~;\[(]", r, 1)[0].lower() for r in (doc.get("info") or {}).get("requires_dist") or []
|
|
92
|
+
if "extra ==" not in r}
|
|
93
|
+
new_deps = sorted(names(after) - names(before))
|
|
94
|
+
if new_deps:
|
|
95
|
+
signs.append(f"New dependencies since {old}: {', '.join(new_deps[:5])}{' and more' if len(new_deps) > 5 else ''}.")
|
|
96
|
+
if _pypi_provenance(client, name, old, before) and _pypi_provenance(client, name, new, after) is False:
|
|
97
|
+
signs.append(f"{old} carried build provenance (a signed record of where it was built); {new} doesn't.")
|
|
98
|
+
return signs
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def release_signs(client, ecosystem: str, name: str, old_versions: list[str], new_versions: list[str],
|
|
102
|
+
now: datetime) -> list[str]:
|
|
103
|
+
"""Signs for the newest version a change installs, against the newest version it replaces."""
|
|
104
|
+
if not new_versions or ecosystem not in ("npm", "pypi"):
|
|
105
|
+
return []
|
|
106
|
+
new = max(new_versions, key=_key)
|
|
107
|
+
old = max(old_versions, key=_key) if old_versions else None
|
|
108
|
+
if old and _key(old) >= _key(new):
|
|
109
|
+
return [] # a downgrade or the same version: nothing new arrived
|
|
110
|
+
return (npm_signs if ecosystem == "npm" else pypi_signs)(client, name, old, new, now)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""The language runtimes a repo builds and runs on (Node.js, Python, PHP, Ruby, Go), read from its version files,
|
|
2
|
+
Dockerfiles and workflow setup steps, checked against endoflife.date: a release line out of support gets no security
|
|
3
|
+
fixes, and a pinned patch older than the newest one misses the fixes since. Runtime flaws (PHP's in October 2026) are
|
|
4
|
+
not package advisories, so lockfile scans never see them."""
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
EOL_API = "https://endoflife.date/api"
|
|
9
|
+
PRODUCTS = {"node": "nodejs", "python": "python", "php": "php", "ruby": "ruby", "go": "go", "debian": "debian", "ubuntu": "ubuntu",
|
|
10
|
+
"alpine": "alpine-linux"}
|
|
11
|
+
FILES = re.compile(r"(^|/)(\.nvmrc|\.node-version|\.python-version|\.php-version|\.ruby-version|\.tool-versions|runtime\.txt"
|
|
12
|
+
r"|go\.mod|package\.json|composer\.json|Dockerfile[^/]*|\.github/workflows/[^/]+\.ya?ml)$")
|
|
13
|
+
SKIP = re.compile(r"(^|/)(node_modules|vendor|third_party|testdata|fixtures?|examples?)/")
|
|
14
|
+
MAX_FILES = 40
|
|
15
|
+
VERSION = r"v?(\d+(?:\.\d+){0,2})"
|
|
16
|
+
SETUP = {"node": re.compile(rf"node-version:\s*['\"]?{VERSION}"), "python": re.compile(rf"python-version:\s*['\"]?{VERSION}"),
|
|
17
|
+
"php": re.compile(rf"php-version:\s*['\"]?{VERSION}"), "ruby": re.compile(rf"ruby-version:\s*['\"]?{VERSION}"),
|
|
18
|
+
"go": re.compile(rf"go-version:\s*['\"]?{VERSION}")}
|
|
19
|
+
MATRIX_KEYS = {"php": "php", "php-version": "php", "php-versions": "php", "python": "python", "python-version": "python",
|
|
20
|
+
"python-versions": "python", "node": "node", "node-version": "node", "node-versions": "node", "ruby": "ruby",
|
|
21
|
+
"ruby-version": "ruby", "go": "go", "go-version": "go"}
|
|
22
|
+
MATRIX = re.compile(r"(?mi)^\s*(" + "|".join(re.escape(k) for k in MATRIX_KEYS) + r")\s*:\s*(?:\[([^\]\n]*)\]|\n((?:[ \t]*-[ \t]*['\"]?[\d.]+['\"]?[ \t]*\n)+))")
|
|
23
|
+
IMAGE = re.compile(rf"(?mi)^\s*FROM\s+(?:[\w./-]+/)?(node|python|php|ruby|golang)[:@]{VERSION}")
|
|
24
|
+
# Base operating system images, by version or codename (endoflife.date's debian, ubuntu and alpine).
|
|
25
|
+
DEBIAN = {"stretch": "9", "buster": "10", "bullseye": "11", "bookworm": "12", "trixie": "13"}
|
|
26
|
+
UBUNTU = {"bionic": "18.04", "focal": "20.04", "jammy": "22.04", "noble": "24.04", "oracular": "24.10", "plucky": "25.04"}
|
|
27
|
+
CODENAMES = {**DEBIAN, **UBUNTU}
|
|
28
|
+
OS_IMAGE = re.compile(r"(?mi)^\s*FROM\s+(?:--platform=\S+\s+)?(?:[\w./-]+/)?(debian|ubuntu|alpine)[:]([\w.]+)")
|
|
29
|
+
SUFFIX = re.compile(r"(?mi)^\s*FROM\s+(?:--platform=\S+\s+)?(?:[\w./-]+/)?(?:node|python|php|ruby|golang)[:][\w.]*?-(?:slim-)?("
|
|
30
|
+
+ "|".join(CODENAMES) + r")\b")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def wanted(path: str) -> bool:
|
|
34
|
+
return bool(FILES.search(path)) and not SKIP.search(path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def detect(files: dict[str, str]) -> list[dict]:
|
|
38
|
+
"""Every runtime version a repo names, with where: [{"runtime", "version", "file"}]; versions that aren't a number
|
|
39
|
+
("lts/*", ">=18") are left out rather than guessed."""
|
|
40
|
+
found = []
|
|
41
|
+
|
|
42
|
+
def add(runtime, version, file, use="runs"):
|
|
43
|
+
# "runs": what the project runs on; "tests": a test matrix, where old lines are often kept on purpose.
|
|
44
|
+
if version and re.fullmatch(r"\d+(\.\d+){0,2}", version):
|
|
45
|
+
found.append({"runtime": runtime, "version": version, "file": file, "use": use})
|
|
46
|
+
for path, text in files.items():
|
|
47
|
+
name = path.rsplit("/", 1)[-1]
|
|
48
|
+
first = text.strip().splitlines()[0].strip() if text.strip() else ""
|
|
49
|
+
if name in (".nvmrc", ".node-version"):
|
|
50
|
+
add("node", first.lstrip("v"), path)
|
|
51
|
+
elif name == ".python-version":
|
|
52
|
+
add("python", first, path)
|
|
53
|
+
elif name == ".php-version":
|
|
54
|
+
add("php", first, path)
|
|
55
|
+
elif name == ".ruby-version":
|
|
56
|
+
add("ruby", first.removeprefix("ruby-"), path)
|
|
57
|
+
elif name == "runtime.txt":
|
|
58
|
+
m = re.match(rf"(python|php|ruby)-{VERSION}", first)
|
|
59
|
+
if m:
|
|
60
|
+
add(m.group(1), m.group(2), path)
|
|
61
|
+
elif name == ".tool-versions":
|
|
62
|
+
for line in text.splitlines():
|
|
63
|
+
parts = line.split()
|
|
64
|
+
if len(parts) >= 2 and parts[0] in ("nodejs", "python", "php", "ruby", "golang"):
|
|
65
|
+
add({"nodejs": "node", "golang": "go"}.get(parts[0], parts[0]), parts[1], path)
|
|
66
|
+
elif name == "go.mod":
|
|
67
|
+
m = re.search(r"(?m)^go\s+(\d+\.\d+(?:\.\d+)?)", text)
|
|
68
|
+
if m:
|
|
69
|
+
add("go", m.group(1), path)
|
|
70
|
+
elif name.startswith("Dockerfile"):
|
|
71
|
+
for runtime, version in IMAGE.findall(text):
|
|
72
|
+
add({"golang": "go"}.get(runtime.lower(), runtime.lower()), version, path)
|
|
73
|
+
# The operating system under the image: debian:bookworm, ubuntu:22.04, alpine:3.18, or a language image's
|
|
74
|
+
# suffix (python:3.11-slim-bookworm). An end-of-life base gets no security fixes for its packages.
|
|
75
|
+
for os_name, tag in OS_IMAGE.findall(text):
|
|
76
|
+
version = CODENAMES.get(tag.split("-")[0].lower(), tag.split("-")[0])
|
|
77
|
+
add(os_name.lower(), version, path)
|
|
78
|
+
for codename in SUFFIX.findall(text):
|
|
79
|
+
add("debian" if codename.lower() in DEBIAN else "ubuntu", CODENAMES[codename.lower()], path)
|
|
80
|
+
elif path.startswith(".github/workflows/"):
|
|
81
|
+
for runtime, pattern in SETUP.items():
|
|
82
|
+
for version in pattern.findall(text):
|
|
83
|
+
add(runtime, version, path)
|
|
84
|
+
# A matrix names the versions: "php: [8.2, 8.3]" or a "- '3.12'" list under "python:".
|
|
85
|
+
for key, inline, block in MATRIX.findall(text):
|
|
86
|
+
runtime = MATRIX_KEYS[key.lower()]
|
|
87
|
+
for version in re.findall(r"['\"]?(\d+(?:\.\d+){0,2})['\"]?", inline or block):
|
|
88
|
+
add(runtime, version, path, "tests")
|
|
89
|
+
elif name == "package.json":
|
|
90
|
+
try:
|
|
91
|
+
engine = str(((json.loads(text) or {}).get("engines") or {}).get("node") or "")
|
|
92
|
+
except ValueError:
|
|
93
|
+
engine = ""
|
|
94
|
+
m = re.fullmatch(rf"\s*[\^~]?{VERSION}(\.x)?\s*", engine)
|
|
95
|
+
if m:
|
|
96
|
+
add("node", m.group(1), path)
|
|
97
|
+
seen, out = set(), []
|
|
98
|
+
for f in found:
|
|
99
|
+
k = (f["runtime"], f["version"], f["file"], f["use"])
|
|
100
|
+
if k not in seen:
|
|
101
|
+
seen.add(k)
|
|
102
|
+
out.append(f)
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _cycle(product: str, version: str, cycles: list[dict]) -> dict | None:
|
|
107
|
+
# The release line a version belongs to: "8.3" for PHP 8.3.4, "20" for Node 20.11, "3.11" for Python 3.11.2.
|
|
108
|
+
parts = version.split(".")
|
|
109
|
+
for n in (2, 1):
|
|
110
|
+
name = ".".join(parts[:n])
|
|
111
|
+
hit = next((c for c in cycles if str(c.get("cycle")) == name), None)
|
|
112
|
+
if hit:
|
|
113
|
+
return hit
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def judge(found: list[dict], cycles: dict[str, list[dict]], today: str) -> list[dict]:
|
|
118
|
+
"""For each runtime version found: its release line, whether that line is out of support (and since when), and
|
|
119
|
+
the newest patch when the repo pins an older one."""
|
|
120
|
+
out = []
|
|
121
|
+
for f in found:
|
|
122
|
+
product = PRODUCTS[f["runtime"]]
|
|
123
|
+
c = _cycle(product, f["version"], cycles.get(product) or [])
|
|
124
|
+
if not c:
|
|
125
|
+
out.append({**f, "status": "unknown"})
|
|
126
|
+
continue
|
|
127
|
+
eol = c.get("eol")
|
|
128
|
+
ended = eol is True or (isinstance(eol, str) and eol <= today)
|
|
129
|
+
latest = str(c.get("latest") or "")
|
|
130
|
+
pinned = f["version"].count(".") >= 2 # a full version; "8.3" or "20" installs the newest patch
|
|
131
|
+
behind = pinned and latest and _key(f["version"]) < _key(latest)
|
|
132
|
+
out.append({**f, "cycle": str(c.get("cycle")), "eol": eol if isinstance(eol, str) else None, "latest": latest or None,
|
|
133
|
+
"latest_date": c.get("latestReleaseDate"),
|
|
134
|
+
"status": "end of life" if ended else "behind the newest patch" if behind else "supported"})
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _key(version: str) -> list:
|
|
139
|
+
return [int(p) if p.isdigit() else 0 for p in version.split(".")]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def runtimes(api, ref: str, client) -> list[dict]:
|
|
143
|
+
"""Read the files that name runtimes at `ref` (one tree read, at most 40 files) and judge each version."""
|
|
144
|
+
tree = api._get(f"/repos/{api.repo}/git/trees/{ref}", {"recursive": "1"})
|
|
145
|
+
paths = [t["path"] for t in tree.get("tree") or [] if t.get("type") == "blob" and wanted(t["path"])][:MAX_FILES]
|
|
146
|
+
files = {}
|
|
147
|
+
for p in paths:
|
|
148
|
+
try:
|
|
149
|
+
files[p] = api.file(p, ref).decode("utf-8", "replace")
|
|
150
|
+
except Exception: # noqa: BLE001 - one unreadable file is skipped
|
|
151
|
+
continue
|
|
152
|
+
found = detect(files)
|
|
153
|
+
cycles = {}
|
|
154
|
+
for product in sorted({PRODUCTS[f["runtime"]] for f in found}):
|
|
155
|
+
try:
|
|
156
|
+
r = client.get(f"{EOL_API}/{product}.json")
|
|
157
|
+
cycles[product] = r.json() if r.status_code == 200 else []
|
|
158
|
+
except Exception: # noqa: BLE001
|
|
159
|
+
cycles[product] = []
|
|
160
|
+
from datetime import date
|
|
161
|
+
return judge(found, cycles, date.today().isoformat())
|