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,366 @@
|
|
|
1
|
+
"""Scorecards: rules in a TOML file in the portal repo (Python reads TOML without extra packages), each with a level.
|
|
2
|
+
A repo reaches a level when every rule of that level and the levels below passes or is exempt, and at least one rule of
|
|
3
|
+
that level passed. Data the token can't read is "not available", never a fail. Peer rules compare with similar public repos."""
|
|
4
|
+
import re
|
|
5
|
+
import tomllib
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from mergescout.bench.ci import tier
|
|
9
|
+
from stackvitals.ci.bench import _label
|
|
10
|
+
from stackvitals.portal import descriptor
|
|
11
|
+
|
|
12
|
+
LEVELS = ("bronze", "silver", "gold")
|
|
13
|
+
DEFAULT_RULES = """
|
|
14
|
+
# Bronze: someone owns it, changes are reviewed, and the basics are there.
|
|
15
|
+
[[rule]]
|
|
16
|
+
id = "codeowners"
|
|
17
|
+
check = "owners_present"
|
|
18
|
+
level = "bronze"
|
|
19
|
+
|
|
20
|
+
[[rule]]
|
|
21
|
+
id = "reviews-required"
|
|
22
|
+
check = "branch_rule"
|
|
23
|
+
rule = "pull_request"
|
|
24
|
+
level = "bronze"
|
|
25
|
+
|
|
26
|
+
[[rule]]
|
|
27
|
+
id = "readme-and-license"
|
|
28
|
+
check = "files"
|
|
29
|
+
files = ["readme", "license"]
|
|
30
|
+
level = "bronze"
|
|
31
|
+
|
|
32
|
+
# Silver: the owners are real, and CI and reviews do at least as well as half of similar public repos.
|
|
33
|
+
[[rule]]
|
|
34
|
+
id = "owners-valid"
|
|
35
|
+
check = "owners_valid"
|
|
36
|
+
level = "silver"
|
|
37
|
+
|
|
38
|
+
[[rule]]
|
|
39
|
+
id = "ci-first-try"
|
|
40
|
+
check = "ci_first_try"
|
|
41
|
+
peer = 50
|
|
42
|
+
level = "silver"
|
|
43
|
+
|
|
44
|
+
[[rule]]
|
|
45
|
+
id = "first-review"
|
|
46
|
+
check = "first_review"
|
|
47
|
+
peer = 50
|
|
48
|
+
level = "silver"
|
|
49
|
+
|
|
50
|
+
[[rule]]
|
|
51
|
+
id = "contributing"
|
|
52
|
+
check = "files"
|
|
53
|
+
files = ["contributing"]
|
|
54
|
+
level = "silver"
|
|
55
|
+
|
|
56
|
+
# Gold: first reviews faster than three quarters of similar repos, and security basics checked by OpenSSF Scorecard.
|
|
57
|
+
# (No "top quarter" CI rule: in many groups the top quarter is 100% first-try green, so it would ask for perfection.)
|
|
58
|
+
[[rule]]
|
|
59
|
+
id = "first-review-fast"
|
|
60
|
+
check = "first_review"
|
|
61
|
+
peer = 75
|
|
62
|
+
level = "gold"
|
|
63
|
+
|
|
64
|
+
[[rule]]
|
|
65
|
+
id = "security-policy"
|
|
66
|
+
check = "scorecard"
|
|
67
|
+
name = "Security-Policy"
|
|
68
|
+
min = 4
|
|
69
|
+
level = "gold"
|
|
70
|
+
|
|
71
|
+
[[rule]]
|
|
72
|
+
id = "token-permissions"
|
|
73
|
+
check = "scorecard"
|
|
74
|
+
name = "Token-Permissions"
|
|
75
|
+
min = 10
|
|
76
|
+
level = "gold"
|
|
77
|
+
"""
|
|
78
|
+
NA = "not available"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _group(bench: dict | None, metric: str, repo: dict):
|
|
82
|
+
groups = (bench or {}).get("groups") if isinstance(bench, dict) else None
|
|
83
|
+
if not isinstance(groups, dict):
|
|
84
|
+
return None
|
|
85
|
+
lang, size = repo.get("language"), tier(repo.get("stars"))
|
|
86
|
+
for key in (f"{lang}|{size}", f"{lang}|all", f"all|{size}", "all|all"):
|
|
87
|
+
g = groups.get(key)
|
|
88
|
+
if isinstance(g, dict) and isinstance(g.get(metric), dict):
|
|
89
|
+
return key, g
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _peer(rule: dict, value: float, bench: dict | None, metric: str, repo: dict, lower_better: bool, show) -> tuple[str, str]:
|
|
94
|
+
# "peer = 50" means better than half of similar repos: at least the median, or at most it for waits.
|
|
95
|
+
peer = int(rule.get("peer") or 0)
|
|
96
|
+
found = _group(bench, metric, repo) if peer in (25, 50, 75) else None
|
|
97
|
+
if not found:
|
|
98
|
+
if rule.get("min") is None:
|
|
99
|
+
return NA, f"{show(value)}; no benchmark for similar repos yet"
|
|
100
|
+
ok = value >= rule["min"] if not lower_better else value <= rule["min"]
|
|
101
|
+
return ("pass" if ok else "fail"), f"{show(value)}, needs {show(rule['min'])}"
|
|
102
|
+
key, g = found
|
|
103
|
+
need = g[metric].get(f"p{100 - peer if lower_better else peer}")
|
|
104
|
+
if not isinstance(need, (int, float)):
|
|
105
|
+
return NA, f"{show(value)}; the benchmark lacks that percentile"
|
|
106
|
+
ok = value <= need if lower_better else value >= need
|
|
107
|
+
if show(value) == show(need) and value != need:
|
|
108
|
+
# Values that round alike get a decimal, or "94%, needs 94%" would read as a pass.
|
|
109
|
+
show = (lambda v: f"{v:.1%}") if not lower_better else (lambda v: f"{v:.1f} h")
|
|
110
|
+
return ("pass" if ok else "fail"), (f"{show(value)}; better than {peer}% of {_label(key)} needs {show(need)} "
|
|
111
|
+
f"({g.get('repos', '?')} repos)")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _owners_present(repo, rule, bench):
|
|
115
|
+
o = repo.get("owners")
|
|
116
|
+
if not o:
|
|
117
|
+
return NA, "owners not checked yet"
|
|
118
|
+
where = "the repo root" if o.get("file") == "CODEOWNERS" else o.get("file")
|
|
119
|
+
return ("fail", "no CODEOWNERS file") if o.get("status") == "none" else ("pass", f"CODEOWNERS file at {where}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _owners_valid(repo, rule, bench):
|
|
123
|
+
o = repo.get("owners")
|
|
124
|
+
if not o:
|
|
125
|
+
return NA, "owners not checked yet"
|
|
126
|
+
if o.get("status") == "none":
|
|
127
|
+
return NA, "no CODEOWNERS file (the codeowners rule reports it)"
|
|
128
|
+
seen = "owners checked" + (f" ({'; '.join(o['notes'])})" if o.get("notes") else "")
|
|
129
|
+
return ("pass", seen) if o.get("status") == "valid" else ("fail", "; ".join(o.get("reasons") or []) or "no owners")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _branch_rule(repo, rule, bench):
|
|
133
|
+
rules = repo.get("rules")
|
|
134
|
+
if not isinstance(rules, list):
|
|
135
|
+
return NA, "branch rules not readable"
|
|
136
|
+
branch = repo.get("default_branch") or "the default branch"
|
|
137
|
+
if rule["rule"] in rules:
|
|
138
|
+
return "pass", f"{rule['rule']} required on {branch}"
|
|
139
|
+
if repo.get("protected"):
|
|
140
|
+
return NA, (f"classic branch protection is on for {branch}; whether it requires {rule['rule']} needs an admin token "
|
|
141
|
+
"to read")
|
|
142
|
+
return "fail", f"no {rule['rule']} rule on {branch}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _files(repo, rule, bench):
|
|
146
|
+
docs = repo.get("docs")
|
|
147
|
+
if not isinstance(docs, dict):
|
|
148
|
+
return NA, "repo files not readable"
|
|
149
|
+
if any(docs.get(f) is None for f in rule.get("files") or []):
|
|
150
|
+
return NA, "some of these files couldn't be checked"
|
|
151
|
+
missing = [f for f in rule.get("files") or [] if not docs.get(f)]
|
|
152
|
+
shared = set(docs.get("org_defaults") or [])
|
|
153
|
+
present = ", ".join(f + (" (org default from the .github repo)" if f in shared else "") for f in rule.get("files") or [])
|
|
154
|
+
return ("fail", "missing: " + ", ".join(missing)) if missing else ("pass", "present: " + present)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _ci_first_try(repo, rule, bench):
|
|
158
|
+
modules = repo.get("modules") or {}
|
|
159
|
+
text = modules.get("ci_first_try")
|
|
160
|
+
try:
|
|
161
|
+
share = modules.get("ci_first_try_share")
|
|
162
|
+
# The number from app.json when there is one; the badge text ("85%") from older installs.
|
|
163
|
+
value = share if isinstance(share, (int, float)) else float(str(text).rstrip("%")) / 100
|
|
164
|
+
source = "CI Health"
|
|
165
|
+
except (TypeError, ValueError):
|
|
166
|
+
ci = repo.get("ci") or {}
|
|
167
|
+
if not isinstance(ci.get("first_try_share"), (int, float)):
|
|
168
|
+
return NA, "no push CI runs to measure"
|
|
169
|
+
value, source = ci["first_try_share"], f"last {ci.get('pushes')} pushes"
|
|
170
|
+
result, evidence = _peer(rule, value, (bench or {}).get("ci"), "first_try_share", repo, False, lambda v: f"{v:.0%}")
|
|
171
|
+
return result, f"{evidence} ({source})"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _first_review(repo, rule, bench):
|
|
175
|
+
secs, source = ((repo.get("modules") or {}).get("flow") or {}).get("first_review_median_s"), "Team Flow"
|
|
176
|
+
if not isinstance(secs, (int, float)):
|
|
177
|
+
own = repo.get("reviews") or {}
|
|
178
|
+
secs, source = own.get("first_review_median_s"), f"last {own.get('prs')} PRs"
|
|
179
|
+
if not isinstance(secs, (int, float)):
|
|
180
|
+
return NA, "too few recent PRs with a maintainer's review to measure"
|
|
181
|
+
result, evidence = _peer(rule, secs / 3600, (bench or {}).get("flow"), "first_review_hours", repo, True, lambda v: f"{v:.0f} h")
|
|
182
|
+
return result, f"{evidence} ({source})"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _scorecard(repo, rule, bench):
|
|
186
|
+
if not isinstance(repo.get("scorecard"), dict):
|
|
187
|
+
return NA, "OpenSSF has no Scorecard result for this repo"
|
|
188
|
+
score = repo["scorecard"].get(rule.get("name"))
|
|
189
|
+
if not isinstance(score, (int, float)) or score < 0:
|
|
190
|
+
return NA, f"no OpenSSF Scorecard result for {rule.get('name')}"
|
|
191
|
+
ok = score >= rule.get("min", 10)
|
|
192
|
+
when = (repo.get("openssf") or {}).get("date")
|
|
193
|
+
return ("pass" if ok else "fail"), (f"OpenSSF Scorecard {rule.get('name')}: {score}/10, needs {rule.get('min', 10)}"
|
|
194
|
+
+ (f" (results of {str(when)[:10]})" if when else ""))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _main_entity(d: dict) -> dict | None:
|
|
198
|
+
entities = d.get("entities") or []
|
|
199
|
+
return next((e for e in entities if e["kind"] in descriptor.NEEDS), entities[0] if entities else None)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _catalog(repo, rule, bench):
|
|
203
|
+
d = repo.get("descriptor")
|
|
204
|
+
if not isinstance(d, dict):
|
|
205
|
+
return NA, f"{descriptor.FILE} not checked yet"
|
|
206
|
+
if not d.get("present"):
|
|
207
|
+
return "fail", f"no {descriptor.FILE}"
|
|
208
|
+
e = _main_entity(d)
|
|
209
|
+
if not e:
|
|
210
|
+
return "fail", "; ".join(d.get("problems") or []) or f"{descriptor.FILE} declares nothing"
|
|
211
|
+
missing = [f for f in rule.get("fields") or ("owner", "lifecycle") if not e.get(f)]
|
|
212
|
+
if missing:
|
|
213
|
+
return "fail", f"{e['kind']} {e['name']}: no {', '.join(missing)}"
|
|
214
|
+
what = ", ".join(x for x in (e.get("type"), e.get("lifecycle")) if x)
|
|
215
|
+
return "pass", (f"{e['kind']} {e['name']}" + (f" ({what})" if what else "") + (f", owner {e['owner']}" if e.get("owner") else "")
|
|
216
|
+
+ (f", system {e['system']}" if e.get("system") else ""))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _catalog_owner(repo, rule, bench):
|
|
220
|
+
d, o = repo.get("descriptor"), repo.get("owners")
|
|
221
|
+
if not isinstance(d, dict) or not o:
|
|
222
|
+
return NA, f"{descriptor.FILE} or owners not checked yet"
|
|
223
|
+
if not d.get("present"):
|
|
224
|
+
return NA, f"no {descriptor.FILE} (the catalog rule reports it)"
|
|
225
|
+
if o.get("status") == "none":
|
|
226
|
+
return NA, "no CODEOWNERS file (the codeowners rule reports it)"
|
|
227
|
+
owners = list(dict.fromkeys(e.get("owner") for e in d.get("entities") or [] if e["kind"] in descriptor.NEEDS)) or [None]
|
|
228
|
+
results = [descriptor.owner_check(x, o.get("owners") or []) for x in owners]
|
|
229
|
+
bad = [why for result, why in results if result == "fail"]
|
|
230
|
+
return ("fail", "; ".join(bad)) if bad else ("pass", "; ".join(why for _, why in results))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _file(repo, rule, bench):
|
|
234
|
+
path = rule["path"]
|
|
235
|
+
f = (repo.get("files") or {}).get(path)
|
|
236
|
+
if not isinstance(f, dict):
|
|
237
|
+
return NA, f"{path} not checked yet"
|
|
238
|
+
if not f.get("present"):
|
|
239
|
+
return "fail", f"no {path}"
|
|
240
|
+
for key in ("contains", "lacks"):
|
|
241
|
+
if rule.get(key) and f.get(f"{key}:{rule[key]}") is None:
|
|
242
|
+
return NA, f"{path} not read for this pattern yet"
|
|
243
|
+
if rule.get("contains") and not f[f"contains:{rule['contains']}"]:
|
|
244
|
+
return "fail", f"{path} doesn't match {rule['contains']}"
|
|
245
|
+
if rule.get("lacks") and f[f"lacks:{rule['lacks']}"]:
|
|
246
|
+
return "fail", f"{path} matches {rule['lacks']}"
|
|
247
|
+
return "pass", (f"{path} present" + (f", matches {rule['contains']}" if rule.get("contains") else "")
|
|
248
|
+
+ (f", without {rule['lacks']}" if rule.get("lacks") else ""))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
CHECKS = {"owners_present": _owners_present, "owners_valid": _owners_valid, "branch_rule": _branch_rule, "files": _files,
|
|
252
|
+
"ci_first_try": _ci_first_try, "first_review": _first_review, "scorecard": _scorecard, "catalog": _catalog,
|
|
253
|
+
"catalog_owner": _catalog_owner, "file": _file}
|
|
254
|
+
CATALOG_FIELDS = {"type", "lifecycle", "owner", "system", "domain", "description", "tags", "links"}
|
|
255
|
+
# A group with a repeat inside, repeated again ("(\w+\s?)+"): Python's matcher can backtrack for hours on such patterns.
|
|
256
|
+
NESTED_REPEAT = re.compile(r"\((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*\)(?:[+*]|\{\d*,)")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _check_rule(r: dict) -> None:
|
|
260
|
+
if not isinstance(r.get("check"), str) or not isinstance(r.get("level"), str):
|
|
261
|
+
raise ValueError(f"rule {r.get('id')!r}: check and level are words, like check = \"file\" and level = \"bronze\"")
|
|
262
|
+
if not r.get("id") or r.get("check") not in CHECKS or r.get("level") not in LEVELS:
|
|
263
|
+
raise ValueError(f"rule {r.get('id')!r}: check {r.get('check')!r} or level {r.get('level')!r} is not known")
|
|
264
|
+
if r["check"] == "file":
|
|
265
|
+
path = r.get("path")
|
|
266
|
+
if not isinstance(path, str) or not path.strip() or path.startswith("/") or ".." in path.split("/"):
|
|
267
|
+
raise ValueError(f"rule {r['id']!r}: a file rule needs a path inside the repo, like docs/runbook.md")
|
|
268
|
+
for key in ("contains", "lacks"):
|
|
269
|
+
if key in r:
|
|
270
|
+
try:
|
|
271
|
+
re.compile(r[key])
|
|
272
|
+
except (re.error, TypeError) as exc:
|
|
273
|
+
raise ValueError(f"rule {r['id']!r}: {key} isn't a valid pattern ({exc})") from None
|
|
274
|
+
if NESTED_REPEAT.search(r[key]):
|
|
275
|
+
raise ValueError(f"rule {r['id']!r}: {key} repeats a group that repeats, which can take hours on some "
|
|
276
|
+
"files; simplify it") from None
|
|
277
|
+
fields = r.get("fields") or []
|
|
278
|
+
if r["check"] == "catalog" and (not isinstance(fields, list) or not all(isinstance(f, str) for f in fields)
|
|
279
|
+
or not set(fields) <= CATALOG_FIELDS):
|
|
280
|
+
raise ValueError(f"rule {r['id']!r}: fields can be {', '.join(sorted(CATALOG_FIELDS))}")
|
|
281
|
+
if "from" in r:
|
|
282
|
+
# "Enable later": the rule shows what it would say from today, and counts from this date.
|
|
283
|
+
r["from"] = str(r["from"])[:10]
|
|
284
|
+
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", r["from"]):
|
|
285
|
+
raise ValueError(f"rule {r['id']!r}: from must be a date like 2027-01-01")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def load_rules(text: str) -> tuple[list[dict], list[dict]]:
|
|
289
|
+
"""Rules and exemptions from TOML; a rule with an unknown check or level, or a broken file rule, is an error."""
|
|
290
|
+
doc = tomllib.loads(text)
|
|
291
|
+
rules = [r for r in doc.get("rule") or [] if isinstance(r, dict)]
|
|
292
|
+
for r in rules:
|
|
293
|
+
_check_rule(r)
|
|
294
|
+
exemptions = [{**e, "until": str(e.get("until") or "9999-12-31")} for e in doc.get("exemption") or []
|
|
295
|
+
if isinstance(e, dict) and e.get("repo") and e.get("rule")]
|
|
296
|
+
return rules, exemptions
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def file_rules(rules: list[dict]) -> list[dict]:
|
|
300
|
+
"""The files the catalog run must read for the file rules, with their patterns."""
|
|
301
|
+
return [{"path": r["path"], **{k: r[k] for k in ("contains", "lacks") if r.get(k)}} for r in rules if r["check"] == "file"]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def rules_or_default(text: str | None) -> tuple[list[dict], list[dict], list[str]]:
|
|
305
|
+
rules, _ = load_rules(DEFAULT_RULES)
|
|
306
|
+
if not text:
|
|
307
|
+
return rules, [], []
|
|
308
|
+
try:
|
|
309
|
+
own, exemptions = load_rules(text)
|
|
310
|
+
return (own or rules), exemptions, []
|
|
311
|
+
except (ValueError, TypeError, tomllib.TOMLDecodeError) as exc:
|
|
312
|
+
return rules, [], [f"portal.toml couldn't be used ({exc}); the default rules were used."]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def evaluate(repo: dict, rules: list[dict], exemptions: list[dict], now: datetime, bench: dict | None) -> dict:
|
|
316
|
+
today = now.strftime("%Y-%m-%d")
|
|
317
|
+
results = []
|
|
318
|
+
for rule in rules:
|
|
319
|
+
result, evidence = CHECKS[rule["check"]](repo, rule, bench)
|
|
320
|
+
ex = next((e for e in exemptions if e["repo"] == repo["name"] and e["rule"] == rule["id"] and e["until"] >= today), None)
|
|
321
|
+
if ex and result != "pass":
|
|
322
|
+
result, evidence = "exempt", f"exempt until {ex['until']}: {ex.get('reason') or 'no reason given'}"
|
|
323
|
+
if rule.get("from") and today < rule["from"]:
|
|
324
|
+
result, evidence = "preview", f"counts from {rule['from']}; now: {result} ({evidence})"
|
|
325
|
+
results.append({"id": rule["id"], "level": rule["level"], "result": result, "evidence": evidence})
|
|
326
|
+
level = None
|
|
327
|
+
for name in LEVELS:
|
|
328
|
+
mine = [r for r in results if r["level"] == name and r["result"] != "preview"]
|
|
329
|
+
if any(r["result"] == "fail" for r in mine) or not any(r["result"] in ("pass", "exempt") for r in mine):
|
|
330
|
+
break
|
|
331
|
+
level = name
|
|
332
|
+
return {"level": level, "results": results, "not_available": sum(r["result"] == NA for r in results)}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def score_catalog(catalog: dict, rules: list[dict], exemptions: list[dict], now: datetime, bench: dict | None) -> dict:
|
|
336
|
+
"""A level and per-rule results for every checked repo; the counts per level and the rules that fail most often."""
|
|
337
|
+
failed, levels = {}, {name: 0 for name in (*LEVELS, None)}
|
|
338
|
+
for r in catalog["repos"]:
|
|
339
|
+
if r.get("archived") or r.get("fork") or not r.get("checked"):
|
|
340
|
+
continue
|
|
341
|
+
r["score"] = evaluate(r, rules, exemptions, now, bench)
|
|
342
|
+
levels[r["score"]["level"]] += 1
|
|
343
|
+
for res in r["score"]["results"]:
|
|
344
|
+
if res["result"] == "fail":
|
|
345
|
+
failed[res["id"]] = failed.get(res["id"], 0) + 1
|
|
346
|
+
return {"levels": levels, "failed": sorted(failed.items(), key=lambda kv: (-kv[1], kv[0]))}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
RESULTS = ("pass", "fail", "exempt", "preview", "not available")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def pack(catalog: dict) -> dict:
|
|
353
|
+
"""The app's copy: each repo's results as [rule, result, evidence] rows pointing at one list of rules and one of
|
|
354
|
+
evidence lines, which the app expands on load (data.js); scorecards were 72% of the file."""
|
|
355
|
+
rules, said, repos = {}, {}, []
|
|
356
|
+
for r in catalog.get("repos") or []:
|
|
357
|
+
s = r.get("score")
|
|
358
|
+
if s:
|
|
359
|
+
rows = [[rules.setdefault((x["id"], x["level"]), len(rules)),
|
|
360
|
+
RESULTS.index(x["result"]) if x["result"] in RESULTS else x["result"],
|
|
361
|
+
said.setdefault(x["evidence"], len(said))]
|
|
362
|
+
for x in s.get("results") or []]
|
|
363
|
+
r = {**r, "score": {**{k: v for k, v in s.items() if k != "results"}, "rows": rows}}
|
|
364
|
+
repos.append(r)
|
|
365
|
+
return {**catalog, "repos": repos, "packed": 2, "rule_index": [list(k) for k in rules], "result_codes": list(RESULTS),
|
|
366
|
+
"evidence_index": list(said)}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The security probes, named as OpenSSF Scorecard names them where they overlap (so results can be compared), each
|
|
2
|
+
with its risk, what it costs to fix and how; one 0-10 score; SARIF for GitHub's Security tab; and the weekly step."""
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from stackvitals.ci.api import APIError
|
|
8
|
+
from stackvitals.security import repo_checks, workflows
|
|
9
|
+
|
|
10
|
+
RISK = {"critical": 10, "high": 7.5, "medium": 5, "low": 2.5}
|
|
11
|
+
PROBES = {
|
|
12
|
+
"hasDangerousWorkflowUntrustedCheckout": ("critical", "S", "A pull_request_target or workflow_run workflow checks out the pull request's code with secrets in reach.",
|
|
13
|
+
"Use pull_request for untrusted code, or check it out only after a maintainer's label or approval gate."),
|
|
14
|
+
"hasDangerousWorkflowScriptInjection": ("critical", "S", "Text an outsider writes (a title, a body, a branch name) is pasted into a run: script.",
|
|
15
|
+
"Pass it through env: (FOO: ${{ github.event.issue.title }}) and use \"$FOO\" in the script."),
|
|
16
|
+
"topLevelPermissions": ("high", "S", "A workflow's token gets write access it may not need.",
|
|
17
|
+
"Add `permissions: contents: read` at the top and grant writes only on the jobs that need them."),
|
|
18
|
+
"pinsDependencies": ("medium", "M", "An action used by tag, or a script piped from the internet, can change under you (as tj-actions/changed-files did).",
|
|
19
|
+
"Pin each action to its full commit (uses: owner/action@<40-character sha> # v4) and download scripts before running them."),
|
|
20
|
+
"branchProtectionLevel": ("high", "S", "The default branch can be force-pushed or merged into without review or checks.",
|
|
21
|
+
"Add a ruleset on the default branch: block force pushes and deletion, require a review and the CI checks."),
|
|
22
|
+
"securityPolicyPresent": ("medium", "S", "No SECURITY.md tells people how to report a vulnerability privately.",
|
|
23
|
+
"Add SECURITY.md with a contact and a disclosure timeline, and turn on private vulnerability reporting."),
|
|
24
|
+
"releasesAreSigned": ("high", "M", "Recent releases carry no signature or provenance file to check them against.",
|
|
25
|
+
"Sign release assets with Sigstore (actions/attest-build-provenance) or publish .intoto.jsonl provenance."),
|
|
26
|
+
"hasSBOM": ("medium", "S", "No SBOM in the repo or the release assets.", "Attach an SPDX or CycloneDX SBOM to each release (anchore/sbom-action)."),
|
|
27
|
+
"sastToolConfigured": ("medium", "S", "No code scanning (CodeQL, Semgrep, SonarCloud) runs on pull requests.",
|
|
28
|
+
"Turn on CodeQL's default setup in the repo's Security settings, or add a Semgrep workflow."),
|
|
29
|
+
"hasBinaryArtifacts": ("high", "S", "Compiled files are committed, so nobody can review what they do.", "Build them in CI instead and remove them from the repo."),
|
|
30
|
+
"fuzzed": ("medium", "M", "No fuzzing setup was found.", "Add ClusterFuzzLite, Go native fuzz tests, or join OSS-Fuzz."),
|
|
31
|
+
"hasOpenSSFBadge": ("low", "M", "No OpenSSF Best Practices badge.", "Fill in the questionnaire at bestpractices.dev."),
|
|
32
|
+
"packagedWithAutomatedWorkflow": ("medium", "S", "Releases aren't published by a workflow, so their build can't be traced.",
|
|
33
|
+
"Publish from a workflow (with trusted publishing on PyPI and npm)."),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def verdicts(found: list[dict], f: dict) -> list[dict]:
|
|
38
|
+
"""Every probe's result: "fail" with its findings, "pass", or "unknown" (the token couldn't read it)."""
|
|
39
|
+
by = {}
|
|
40
|
+
for x in found:
|
|
41
|
+
if not x.get("guarded"):
|
|
42
|
+
by.setdefault(x["probe"], []).append(x)
|
|
43
|
+
guarded = [x for x in found if x.get("guarded")]
|
|
44
|
+
tiers = f.get("branch") or {}
|
|
45
|
+
state = {
|
|
46
|
+
"branchProtectionLevel": "unknown" if not tiers.get("readable") else ("pass" if tiers.get("level", 0) >= 2 else "fail"),
|
|
47
|
+
"securityPolicyPresent": "pass" if (f.get("policy") or {}).get("file") else "fail",
|
|
48
|
+
"releasesAreSigned": "unknown" if not (f.get("releases") or {}).get("releases") else ("pass" if f["releases"]["signed"] else "fail"),
|
|
49
|
+
"hasSBOM": "pass" if (f.get("tree") or {}).get("sbom_files") or (f.get("releases") or {}).get("sbom") else "fail",
|
|
50
|
+
"sastToolConfigured": "pass" if (f.get("workflows") or {}).get("sast") else "fail",
|
|
51
|
+
"hasBinaryArtifacts": "fail" if (f.get("tree") or {}).get("binaries") else "pass",
|
|
52
|
+
"fuzzed": "pass" if (f.get("tree") or {}).get("fuzzing") or f.get("oss_fuzz") else "fail",
|
|
53
|
+
"hasOpenSSFBadge": "pass" if f.get("badge") else "fail",
|
|
54
|
+
"packagedWithAutomatedWorkflow": "unknown" if not (f.get("releases") or {}).get("releases") else
|
|
55
|
+
("pass" if (f.get("workflows") or {}).get("publish") else "fail"),
|
|
56
|
+
}
|
|
57
|
+
out = []
|
|
58
|
+
for probe, (risk, effort, why, fix) in PROBES.items():
|
|
59
|
+
result = ("fail" if by.get(probe) else "pass") if probe not in state else state[probe]
|
|
60
|
+
out.append({"probe": probe, "risk": risk, "effort": effort, "result": result, "why": why, "fix": fix,
|
|
61
|
+
"findings": by.get(probe, [])[:20],
|
|
62
|
+
"guarded": [x for x in guarded if x["probe"] == probe][:10]})
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def score(results: list[dict]) -> float | None:
|
|
67
|
+
"""0-10: the risk-weighted share of probes passed, among those that could be read."""
|
|
68
|
+
known = [r for r in results if r["result"] != "unknown"]
|
|
69
|
+
total = sum(RISK[r["risk"]] for r in known)
|
|
70
|
+
return round(10 * sum(RISK[r["risk"]] for r in known if r["result"] == "pass") / total, 1) if total else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def sarif(results: list[dict]) -> dict:
|
|
74
|
+
"""The failing workflow findings as SARIF 2.1.0, for github/codeql-action/upload-sarif."""
|
|
75
|
+
rules = [{"id": r["probe"], "shortDescription": {"text": r["why"]}, "help": {"text": r["fix"]},
|
|
76
|
+
"properties": {"security-severity": str(RISK[r["risk"]])}} for r in results if r["findings"]]
|
|
77
|
+
def text(x: dict) -> str:
|
|
78
|
+
return f"{x['detail']} (job {x['job']})" if x.get("job") else x["detail"]
|
|
79
|
+
hits = [{"ruleId": r["probe"], "level": "error" if r["risk"] in ("critical", "high") else "warning",
|
|
80
|
+
"message": {"text": text(x)},
|
|
81
|
+
"locations": [{"physicalLocation": {"artifactLocation": {"uri": x["path"]}}}]}
|
|
82
|
+
for r in results for x in r["findings"] if x.get("path")]
|
|
83
|
+
return {"version": "2.1.0", "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
84
|
+
"runs": [{"tool": {"driver": {"name": "StackVitals", "rules": rules}}, "results": hits}]}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def collect(api, repo: str, branch: str, get_json=None) -> tuple[list[dict], dict]:
|
|
88
|
+
"""(findings, facts) read through the API: workflow files, the tree, protection, policy, releases, fuzzing, badge."""
|
|
89
|
+
owner, name = repo.split("/", 1)
|
|
90
|
+
files = {}
|
|
91
|
+
listing = repo_checks._try(api, f"/repos/{repo}/contents/.github/workflows") or []
|
|
92
|
+
for item in listing if isinstance(listing, list) else []:
|
|
93
|
+
if str(item.get("name", "")).endswith((".yml", ".yaml")):
|
|
94
|
+
try:
|
|
95
|
+
files[item["path"]] = api.file(item["path"], branch).decode("utf-8", "replace")
|
|
96
|
+
except APIError:
|
|
97
|
+
pass
|
|
98
|
+
found = [x for path, text in files.items() for x in workflows.check(path, text)]
|
|
99
|
+
tree = repo_checks._try(api, f"/repos/{repo}/git/trees/{branch}", {"recursive": "1"}) or {}
|
|
100
|
+
paths = [t["path"] for t in tree.get("tree") or [] if t.get("type") == "blob"]
|
|
101
|
+
# Compiled files with no telling name: executable-mode files without an extension, read to check their header.
|
|
102
|
+
sniffed = []
|
|
103
|
+
for e in repo_checks.sniff_candidates(tree.get("tree") or []):
|
|
104
|
+
try:
|
|
105
|
+
import base64
|
|
106
|
+
blob = api._get(f"/repos/{repo}/git/blobs/{e['sha']}")
|
|
107
|
+
if repo_checks.is_executable(base64.b64decode((blob or {}).get("content") or "")[:8]):
|
|
108
|
+
sniffed.append(e["path"])
|
|
109
|
+
except (APIError, ValueError):
|
|
110
|
+
pass
|
|
111
|
+
# Dockerfiles' base images, pinned by digest or not (a few files; vendored and test ones left out).
|
|
112
|
+
for path in [p for p in paths if p.rsplit("/", 1)[-1].startswith("Dockerfile")
|
|
113
|
+
and not any(s in p for s in ("test", "example", "vendor"))][:5]:
|
|
114
|
+
try:
|
|
115
|
+
found += workflows.dockerfile(path, api.file(path, branch).decode("utf-8", "replace"))
|
|
116
|
+
except APIError:
|
|
117
|
+
pass
|
|
118
|
+
rules = repo_checks._try(api, f"/repos/{repo}/rules/branches/{branch}")
|
|
119
|
+
facts = {"workflows": {**workflows.facts(files), "files": len(files)},
|
|
120
|
+
"tree": {**(tf := repo_checks.tree_facts(paths)), "binaries": sorted(set(tf["binaries"]) | set(sniffed))[:50]},
|
|
121
|
+
"branch": repo_checks.branch_tiers(repo_checks._try(api, f"/repos/{repo}/branches/{branch}/protection"),
|
|
122
|
+
rules if isinstance(rules, list) else None,
|
|
123
|
+
repo_checks._try(api, f"/repos/{repo}/branches/{branch}")),
|
|
124
|
+
"policy": repo_checks.policy(api, repo, owner),
|
|
125
|
+
"releases": repo_checks.releases(repo_checks._try(api, f"/repos/{repo}/releases", {"per_page": 10}) or []),
|
|
126
|
+
"oss_fuzz": bool(repo_checks._try(api, f"/repos/google/oss-fuzz/contents/projects/{name.lower()}")),
|
|
127
|
+
"badge": repo_checks.badge(get_json, f"https://github.com/{repo}") if get_json else None}
|
|
128
|
+
return found, facts
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def security_step(ws, env: dict, now: datetime, make_api=None, get_json=None) -> dict:
|
|
132
|
+
"""Weekly: run the probes, write docs/security.json and docs/security.sarif, and keep each week's score."""
|
|
133
|
+
from stackvitals.ci.api import ActionsAPI
|
|
134
|
+
path = Path(ws.path) / "docs" / "security.json"
|
|
135
|
+
try:
|
|
136
|
+
old = json.loads(path.read_text(encoding="utf-8"))
|
|
137
|
+
except (OSError, ValueError):
|
|
138
|
+
old = {}
|
|
139
|
+
if old.get("generated", "")[:10] > (now - timedelta(days=7)).date().isoformat():
|
|
140
|
+
return {"skipped": "read this week"}
|
|
141
|
+
api = (make_api or ActionsAPI)(env.get("GITHUB_TOKEN"), ws.repo)
|
|
142
|
+
info = ws.cache.get("repo_info") or {}
|
|
143
|
+
found, facts = collect(api, ws.repo, info.get("default_branch") or "main", get_json)
|
|
144
|
+
results = verdicts(found, facts)
|
|
145
|
+
level, before = facts["branch"].get("level"), (old.get("facts") or {}).get("branch", {}).get("level")
|
|
146
|
+
history = (old.get("history") or [])[-51:] + [[now.date().isoformat(), score(results)]]
|
|
147
|
+
out = {"schema": 1, "generated": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "score": score(results), "probes": results,
|
|
148
|
+
"facts": facts, "history": history,
|
|
149
|
+
# Protection that got weaker since last week is said out loud; nobody else tracks the change.
|
|
150
|
+
"weakened": before is not None and level is not None and level < before}
|
|
151
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
path.write_text(json.dumps(out, indent=1), encoding="utf-8")
|
|
153
|
+
(path.parent / "security.sarif").write_text(json.dumps(sarif(results), indent=1), encoding="utf-8")
|
|
154
|
+
return {"score": out["score"], "failing": sum(r["result"] == "fail" for r in results)}
|