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