Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions dashboard/app/api/dependencies/route.ts
Original file line number Diff line number Diff line change
@@ -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<any>(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 })
}
}
126 changes: 126 additions & 0 deletions dashboard/app/dependencies/page.tsx
Original file line number Diff line number Diff line change
@@ -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<DependencyResponse | null>(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 (
<main style={{ maxWidth: 1100, margin: '0 auto', padding: 'var(--space-12) var(--space-6)' }}>
<Link href="/" style={{ color: 'var(--primary)', textDecoration: 'none', fontSize: 'var(--text-sm)' }}>← Home</Link>
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 'var(--text-2xl)', fontWeight: 800, margin: 'var(--space-4) 0 var(--space-2)' }}>Dependency Intelligence</h1>
<p style={{ color: 'var(--text-muted)', maxWidth: 760, lineHeight: 1.7 }}>
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.
</p>

<div style={{ display: 'flex', gap: 8, margin: 'var(--space-6) 0', maxWidth: 760 }}>
<input value={repo} onChange={e => 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)' }} />
<button onClick={scan} disabled={loading || !repo.trim()}
style={{ padding: '12px 18px', border: 0, borderRadius: 8, background: 'var(--primary)', color: '#fff', fontWeight: 700, cursor: 'pointer' }}>
{loading ? 'Scanning…' : 'Scan'}
</button>
</div>

{error && <div role="alert" style={{ padding: 14, borderRadius: 8, background: 'rgba(255,59,92,.08)', color: 'var(--danger)', marginBottom: 20 }}>{error}</div>}

{data && (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(150px,1fr))', gap: 10, marginBottom: 20 }}>
<SummaryCard label="Packages" value={data.summary.total} />
<SummaryCard label="npm" value={data.summary.npm} />
<SummaryCard label="Vulnerable" value={data.summary.vulnerable} />
<SummaryCard label="Outdated" value={data.summary.outdated} />
<SummaryCard label="Major" value={data.summary.majorUpdates} />
</div>

<div style={{ overflowX: 'auto', border: '1px solid var(--border)', borderRadius: 10 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead><tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left' }}>
<th style={{ padding: 12 }}>Package</th>
<th style={{ padding: 12 }}>Ecosystem</th>
<th style={{ padding: 12 }}>Installed</th>
<th style={{ padding: 12 }}>Latest</th>
<th style={{ padding: 12 }}>Update</th>
<th style={{ padding: 12 }}>Vulnerabilities</th>
<th style={{ padding: 12 }}>Fix</th>
<th style={{ padding: 12 }}>Sources</th>
</tr></thead>
<tbody>
{data.packages.map(d => {
const meta = UPDATE_META[d.updateType] ?? UPDATE_META.unknown
return (
<tr key={d.ecosystem + ':' + d.name} style={{ borderBottom: '1px solid var(--divider)' }}>
<td style={{ padding: 12, fontWeight: 700 }}>{d.name}</td>
<td style={{ padding: 12 }}>{d.ecosystem}</td>
<td style={{ padding: 12, fontFamily: 'monospace' }}>{d.installedVersion}</td>
<td style={{ padding: 12, fontFamily: 'monospace' }}>{d.latestVersion ?? '—'}</td>
<td style={{ padding: 12, color: meta.color, fontWeight: 700 }}>{meta.label}</td>
<td style={{ padding: 12, color: d.vulnerabilityCount ? 'var(--danger)' : 'var(--success)', fontWeight: 700 }}>{d.vulnerabilityCount}</td>
<td style={{ padding: 12, color: 'var(--success)', fontFamily: 'monospace' }}>{d.patchedVersion ?? '—'}</td>
<td style={{ padding: 12, color: 'var(--text-faint)' }}>{d.sources.length ? d.sources.join(', ') : '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</>
)}
</main>
)
}

function SummaryCard({ label, value }: { label: string; value: number }) {
return (
<div style={{ padding: 16, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10 }}>
<div style={{ fontSize: 24, fontWeight: 800 }}>{value}</div>
<div style={{ color: 'var(--text-faint)', fontSize: 12 }}>{label}</div>
</div>
)
}
19 changes: 11 additions & 8 deletions dashboard/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -98,6 +98,9 @@ export default function DocsPage() {

{/* ── API Reference ── */}
<Section title="API Reference">
<Row label="GET /api/dependencies?repo=owner/name">
Returns a dependency inventory with installed versions, npm latest-release checks, vulnerability counts, remediation versions, and source provenance. Results are cached for 30 minutes.
</Row>
<Row label="GET /api/analyze?repo=owner/name">
Returns a <code style={CODE}>RepoReport</code> JSON object. Cached in Redis for 15 minutes unless{" "}
<code style={CODE}>weights</code> param is present.
Expand Down
1 change: 1 addition & 0 deletions dashboard/components/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
71 changes: 71 additions & 0 deletions dashboard/lib/dependency-intelligence.mjs
Original file line number Diff line number Diff line change
@@ -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]))],
}
})
}
17 changes: 17 additions & 0 deletions dashboard/scripts/dependency-intelligence.test.mjs
Original file line number Diff line number Diff line change
@@ -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')
})
Loading