diff --git a/dashboard/app/api/dependencies/route.ts b/dashboard/app/api/dependencies/route.ts new file mode 100644 index 0000000..7a82abf --- /dev/null +++ b/dashboard/app/api/dependencies/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server' +import { runAdvisoryCheck } from '@/lib/advisory' +import { buildDependencyInventory } from '@/lib/dependency-intelligence.mjs' +import { auth } from '@/lib/auth' +import { parseRepoSlug } from '@/lib/repo-validation.mjs' +import { getJson, getRedis, setJson } from '@/lib/redis' +import { consumeRateLimit, requestIdentity } from '@/lib/rate-limit.mjs' + +type DependencyRecord = { + name: string + ecosystem: string + installedVersion: string + latestVersion: string | null + updateType: 'up_to_date' | 'patch' | 'minor' | 'major' | 'unknown' + vulnerabilityCount: number + highestSeverity: string | null + patchedVersion: string | null + sources: string[] +} + +export const dynamic = 'force-dynamic' + +const CACHE_TTL = 1800 + +export async function GET(req: NextRequest) { + const repo = new URL(req.url).searchParams.get('repo') + const parsedRepo = parseRepoSlug(repo) + if (!parsedRepo) { + return NextResponse.json({ error: 'Invalid repo format. Use owner/name or a GitHub URL' }, { status: 400 }) + } + + const { owner, name } = parsedRepo + const cacheKey = `dependencies:${owner}:${name}` + const session = await auth() + const identity = requestIdentity(req, (session as any)?.user?.email) + const redis = getRedis() + const limit = await consumeRateLimit(redis, identity, 'advisory') + if (!limit.allowed) { + return NextResponse.json({ error: 'rate_limited', message: 'Dependency scan rate limit exceeded. Try again shortly.' }, { status: 429, headers: limit.headers }) + } + + const cached = await getJson(cacheKey) + if (cached) return NextResponse.json({ ...cached, cached: true }) + + try { + const token = (session as any)?.accessToken ?? process.env.GITHUB_TOKEN + const advisory = await runAdvisoryCheck(owner, name, token) + const dependencies = await buildDependencyInventory(advisory) as DependencyRecord[] + const report = { + repo: `${owner}/${name}`, + scannedAt: new Date().toISOString(), + packages: dependencies, + summary: { + total: dependencies.length, + npm: dependencies.filter(d => d.ecosystem === 'npm').length, + vulnerable: dependencies.filter(d => d.vulnerabilityCount > 0).length, + outdated: dependencies.filter(d => d.updateType !== 'up_to_date' && d.updateType !== 'unknown').length, + majorUpdates: dependencies.filter(d => d.updateType === 'major').length, + }, + } + await setJson(cacheKey, report, CACHE_TTL) + return NextResponse.json(report) + } catch (error) { + return NextResponse.json({ error: error instanceof Error ? error.message : 'Dependency scan failed' }, { status: 500 }) + } +} diff --git a/dashboard/app/dependencies/page.tsx b/dashboard/app/dependencies/page.tsx new file mode 100644 index 0000000..75c2166 --- /dev/null +++ b/dashboard/app/dependencies/page.tsx @@ -0,0 +1,126 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' + +const UPDATE_META = { + up_to_date: { label: 'Current', color: 'var(--success)' }, + patch: { label: 'Patch', color: 'var(--warning)' }, + minor: { label: 'Minor', color: 'var(--warning)' }, + major: { label: 'Major', color: 'var(--danger)' }, + unknown: { label: 'Unknown', color: 'var(--text-faint)' }, +} + +type DependencyRecord = { + name: string + ecosystem: string + installedVersion: string + latestVersion: string | null + updateType: keyof typeof UPDATE_META + vulnerabilityCount: number + patchedVersion: string | null + sources: string[] +} +type DependencyResponse = { + repo: string + scannedAt: string + packages: DependencyRecord[] + summary: { total: number; npm: number; vulnerable: number; outdated: number; majorUpdates: number } +} + +export default function DependenciesPage() { + const [repo, setRepo] = useState('') + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + async function scan() { + setLoading(true) + setError('') + try { + const response = await fetch('/api/dependencies?repo=' + encodeURIComponent(repo.trim())) + const json = await response.json() + if (!response.ok) throw new Error(json.message ?? json.error ?? 'Dependency scan failed') + setData(json) + } catch (e) { + setError(e instanceof Error ? e.message : 'Dependency scan failed') + } finally { + setLoading(false) + } + } + + return ( +
+ ← Home +

Dependency Intelligence

+

+ Inventory declared dependencies, correlate known vulnerabilities, and check npm packages for available releases. + Registry freshness is currently available for npm; other ecosystems remain vulnerability-focused. +

+ +
+ setRepo(e.target.value)} onKeyDown={e => e.key === 'Enter' && scan()} + placeholder="owner/name" style={{ flex: 1, padding: '12px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--surface)', color: 'var(--text)' }} /> + +
+ + {error &&
{error}
} + + {data && ( + <> +
+ + + + + +
+ +
+ + + + + + + + + + + + + {data.packages.map(d => { + const meta = UPDATE_META[d.updateType] ?? UPDATE_META.unknown + return ( + + + + + + + + + + + ) + })} + +
PackageEcosystemInstalledLatestUpdateVulnerabilitiesFixSources
{d.name}{d.ecosystem}{d.installedVersion}{d.latestVersion ?? '—'}{meta.label}{d.vulnerabilityCount}{d.patchedVersion ?? '—'}{d.sources.length ? d.sources.join(', ') : '—'}
+
+ + )} +
+ ) +} + +function SummaryCard({ label, value }: { label: string; value: number }) { + return ( +
+
{value}
+
{label}
+
+ ) +} diff --git a/dashboard/app/docs/page.tsx b/dashboard/app/docs/page.tsx index e1c069e..0153007 100644 --- a/dashboard/app/docs/page.tsx +++ b/dashboard/app/docs/page.tsx @@ -48,14 +48,14 @@ function Row({ label, children }: { label: string; children: React.ReactNode }) const DIMS = [ { key: "readme", weight: "20%", title: "README Quality", desc: "Scores length (10 + 5 + 5 pts for 500 / 1500 / 3000 chars), presence of keywords install, usage, license, contributing, feature, example (6 pts each), code blocks (8), images (6), ## headings (4), list items (4), setup / roadmap / sponsor / discord mentions (4 each). Max 100." }, - { key: "activity", weight: "20%", title: "Commit Activity", desc: "Counts commits to the default branch in the last 90 days via the GitHub Commits API. ≥30 = 100 · ≥15 = 75 · ≥5 = 50 · ≥1 = 25 · 0 = 0." }, - { key: "freshness", weight: "15%", title: "Repo Freshness", desc: "Days since last push to the default branch (pushed_at field). ≤7 days = 100 · ≤30 = 80 · ≤90 = 55 · ≤180 = 30 · older = 10." }, - { key: "docs", weight: "15%", title: "Documentation", desc: "Walks the full repo tree (git/trees/HEAD?recursive=1) looking for: LICENSE, CONTRIBUTING.md, CHANGELOG.md, CODE_OF_CONDUCT.md, SECURITY.md, docs/ folder — 16 pts each, max 100." }, - { key: "ci", weight: "10%", title: "CI/CD Setup", desc: "Counts GitHub Actions workflow files via the Actions Workflows API. ≥3 workflows = 100 · ≥1 = 60 · 0 = 0." }, - { key: "issues", weight: "10%", title: "Issue Response", desc: "Fetches up to 50 closed issues and compares against open_issues_count. Score = round(closed / total × 100). No issues at all = 100." }, - { key: "community", weight: "5%", title: "Community Signal", desc: "Math.min(Math.floor(log1p(stars) × 15) + Math.floor(log1p(forks) × 10), 100). Rewards repos with organic momentum." }, - { key: "pr_velocity", weight: "3%", title: "PR Velocity", desc: "Fetches last 20 closed PRs, filters to merged ones, averages (merged_at − created_at). <1 day = 100 · <3 = 85 · <7 = 65 · <14 = 45 · <30 = 25 · else = 10. No merged PRs = 50." }, - { key: "security", weight: "2%", title: "Security", desc: "Walks the repo tree for SECURITY.md (+30), .github/dependabot.yml (+35), and any workflow containing codeql / trivy / snyk (+35). Max 100." }, + { key: "activity", weight: "20%", title: "Commit Activity", desc: "Uses commit count, active-week cadence, and recent 30-day activity over the last 90 days so bursty commit dumps do not score like sustained maintenance." }, + { key: "freshness", weight: "15%", title: "Repo Freshness", desc: "Days since last push to the default branch. ≤7 days = 100 · ≤30 = 80 · ≤90 = 55 · ≤180 = 30 · older = 10." }, + { key: "docs", weight: "15%", title: "Documentation", desc: "Checks LICENSE, CONTRIBUTING.md, CHANGELOG.md, CODE_OF_CONDUCT.md, SECURITY.md, and docs/." }, + { key: "ci", weight: "10%", title: "CI/CD Setup", desc: "Counts GitHub Actions workflow files." }, + { key: "issues", weight: "10%", title: "Issue Maintenance", desc: "Combines stale open-issue ratio with median closed-issue resolution time instead of relying on a closed/open ratio." }, + { key: "community", weight: "5%", title: "Community Signal", desc: "Logarithmic signal from stars and forks." }, + { key: "pr_velocity", weight: "3%", title: "PR Maintenance", desc: "Uses median and 90th-percentile merge time plus stale open PRs instead of a simple average." }, + { key: "security", weight: "2%", title: "Security", desc: "Uses the real advisory/security evidence engine. Scanner coverage is reported separately from the security score." }, ]; export default function DocsPage() { @@ -98,6 +98,9 @@ export default function DocsPage() { {/* ── API Reference ── */}
+ + Returns a dependency inventory with installed versions, npm latest-release checks, vulnerability counts, remediation versions, and source provenance. Results are cached for 30 minutes. + Returns a RepoReport JSON object. Cached in Redis for 15 minutes unless{" "} weights param is present. diff --git a/dashboard/components/Nav.tsx b/dashboard/components/Nav.tsx index 5cd502e..880df2a 100644 --- a/dashboard/components/Nav.tsx +++ b/dashboard/components/Nav.tsx @@ -17,6 +17,7 @@ export default function Nav() { { href: "/badge", label: "Badge" }, { href: "/stats", label: "Stats" }, { href: "/security", label: "Security" }, + { href: "/dependencies", label: "Dependencies" }, { href: "/docs", label: "Docs" }, { href: "/changelog", label: "Changelog" }, { href: "/sponsor", label: "Sponsor" }, diff --git a/dashboard/lib/dependency-intelligence.mjs b/dashboard/lib/dependency-intelligence.mjs new file mode 100644 index 0000000..ffab2e0 --- /dev/null +++ b/dashboard/lib/dependency-intelligence.mjs @@ -0,0 +1,71 @@ +function versionParts(value) { + const match = String(value ?? '').match(/^(?:v|[<>=~^*\s]*)?(\d+)(?:\.(\d+))?(?:\.(\d+))?/) + if (!match) return null + return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)] +} + +export function compareVersions(a, b) { + const av = versionParts(a) + const bv = versionParts(b) + if (!av || !bv) return 0 + for (let i = 0; i < 3; i++) if (av[i] !== bv[i]) return av[i] > bv[i] ? 1 : -1 + return 0 +} + +export function classifyUpdate(installed, latest) { + const a = versionParts(installed) + const b = versionParts(latest ?? '') + if (!a || !b) return 'unknown' + if (a[0] === b[0] && a[1] === b[1] && a[2] === b[2]) return 'up_to_date' + if (a[0] !== b[0]) return 'major' + if (a[1] !== b[1]) return 'minor' + return 'patch' +} + +async function npmLatest(name) { + try { + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, { + signal: AbortSignal.timeout(5000), + headers: { Accept: 'application/json' }, + }) + if (!response.ok) return null + const data = await response.json() + return data.version ?? null + } catch { + return null + } +} + +export async function buildDependencyInventory(report) { + const findings = report.findings ?? [] + const findingMap = new Map() + for (const finding of findings) { + const key = `${finding.ecosystem}:${finding.package}` + const list = findingMap.get(key) ?? [] + list.push(finding) + findingMap.set(key, list) + } + + const packages = report.packages.slice(0, 100) + const npmPackages = packages.filter(p => p.ecosystem === 'npm').slice(0, 30) + const latest = new Map() + const results = await Promise.all(npmPackages.map(async p => [p.name, await npmLatest(p.name)] )) + for (const [name, version] of results) latest.set(name, version) + + return packages.map(pkg => { + const vulns = findingMap.get(`${pkg.ecosystem}:${pkg.name}`) ?? [] + const ranked = [...vulns].sort((a, b) => ({ CRITICAL: 5, HIGH: 4, MODERATE: 3, LOW: 2, UNKNOWN: 1 }[b.severity] ?? 0) - ({ CRITICAL: 5, HIGH: 4, MODERATE: 3, LOW: 2, UNKNOWN: 1 }[a.severity] ?? 0)) + const latestVersion = pkg.ecosystem === 'npm' ? latest.get(pkg.name) ?? null : null + return { + name: pkg.name, + ecosystem: pkg.ecosystem, + installedVersion: pkg.version, + latestVersion, + updateType: classifyUpdate(pkg.version, latestVersion), + vulnerabilityCount: vulns.length, + highestSeverity: ranked[0]?.severity ?? null, + patchedVersion: vulns.find(v => v.patchedVer)?.patchedVer ?? null, + sources: [...new Set(vulns.flatMap(v => v.sources ?? [v.source]))], + } + }) +} diff --git a/dashboard/scripts/dependency-intelligence.test.mjs b/dashboard/scripts/dependency-intelligence.test.mjs new file mode 100644 index 0000000..114203c --- /dev/null +++ b/dashboard/scripts/dependency-intelligence.test.mjs @@ -0,0 +1,17 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { classifyUpdate, compareVersions } from '../lib/dependency-intelligence.mjs' + +test('compares semantic versions', () => { + assert.equal(compareVersions('1.2.3', '1.2.3'), 0) + assert.equal(compareVersions('1.2.3', '1.3.0'), -1) + assert.equal(compareVersions('2.0.0', '1.9.9'), 1) +}) + +test('classifies update levels', () => { + assert.equal(classifyUpdate('1.2.3', '1.2.4'), 'patch') + assert.equal(classifyUpdate('1.2.3', '1.4.0'), 'minor') + assert.equal(classifyUpdate('1.2.3', '2.0.0'), 'major') + assert.equal(classifyUpdate('1.2.3', '1.2.3'), 'up_to_date') + assert.equal(classifyUpdate('latest', null), 'unknown') +})