edited ci file
This commit is contained in:
@@ -44,4 +44,4 @@ SePwUlkfdW6u4a0KYMYf3Op6wsXTp0kA2g==
|
|||||||
|
|
||||||
# ─── OCI Registry (Phase 4) ───────────────────────────────────────────────────
|
# ─── OCI Registry (Phase 4) ───────────────────────────────────────────────────
|
||||||
# Root directory for the OCI Distribution Spec blob and upload storage.
|
# Root directory for the OCI Distribution Spec blob and upload storage.
|
||||||
OCI_ROOT=/var/lib/forgebucket/oci
|
OCI_ROOT=/tmp/forgebucket/oci
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ func main() {
|
|||||||
ciCtx, ciCancel := context.WithCancel(context.Background())
|
ciCtx, ciCancel := context.WithCancel(context.Background())
|
||||||
defer ciCancel()
|
defer ciCancel()
|
||||||
|
|
||||||
orchestrator := ci.NewOrchestrator(engine, bus)
|
sbomGen := sbom.NewGenerator(engine, bus)
|
||||||
|
go sbomGen.Start(ciCtx)
|
||||||
|
|
||||||
|
orchestrator := ci.NewOrchestrator(engine, bus, sbomGen)
|
||||||
go orchestrator.Start(ciCtx)
|
go orchestrator.Start(ciCtx)
|
||||||
|
|
||||||
runnerMgr := ci.NewRunnerManager(engine, bus, cfg, 4)
|
runnerMgr := ci.NewRunnerManager(engine, bus, cfg, 4)
|
||||||
@@ -89,9 +92,6 @@ func main() {
|
|||||||
gitopsCtrl := gitops.NewController(engine, bus, cfg)
|
gitopsCtrl := gitops.NewController(engine, bus, cfg)
|
||||||
go gitopsCtrl.Start(ciCtx)
|
go gitopsCtrl.Start(ciCtx)
|
||||||
|
|
||||||
sbomGen := sbom.NewGenerator(engine, bus)
|
|
||||||
go sbomGen.Start(ciCtx)
|
|
||||||
|
|
||||||
go observability.StartNATSWatcher(ciCtx, bus)
|
go observability.StartNATSWatcher(ciCtx, bus)
|
||||||
|
|
||||||
// Initialise artifact signing key store.
|
// Initialise artifact signing key store.
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- repo_data:/var/lib/forgebucket/repos
|
- repo_data:/tmp/forgebucket/repos
|
||||||
- oci_data:/var/lib/forgebucket/oci
|
- oci_data:/tmp/forgebucket/oci
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
nats:
|
nats:
|
||||||
image: nats:2-alpine
|
image: mirror.gcr.io/nats:2-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["-js", "-m", "8222"]
|
command: ["-js", "-m", "8222"]
|
||||||
ports:
|
ports:
|
||||||
@@ -16,7 +16,7 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18
|
image: mirror.gcr.io/postgres:18
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: forgebucket
|
POSTGRES_DB: forgebucket
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const RepoPipelinesPage = lazy(() => import('./pages/RepoPipelinesPage'))
|
|||||||
const EnvironmentsPage = lazy(() => import('./pages/EnvironmentsPage'))
|
const EnvironmentsPage = lazy(() => import('./pages/EnvironmentsPage'))
|
||||||
const RepoTimelinePage = lazy(() => import('./pages/RepoTimelinePage'))
|
const RepoTimelinePage = lazy(() => import('./pages/RepoTimelinePage'))
|
||||||
const RepoSecretsPage = lazy(() => import('./pages/RepoSecretsPage'))
|
const RepoSecretsPage = lazy(() => import('./pages/RepoSecretsPage'))
|
||||||
|
const RepoSecurityPage = lazy(() => import('./pages/RepoSecurityPage'))
|
||||||
const WorkspacesPage = lazy(() => import('./pages/WorkspacesPage'))
|
const WorkspacesPage = lazy(() => import('./pages/WorkspacesPage'))
|
||||||
const WorkspacePage = lazy(() => import('./pages/WorkspacePage'))
|
const WorkspacePage = lazy(() => import('./pages/WorkspacePage'))
|
||||||
const WorkspaceSettingsPage = lazy(() => import('./pages/WorkspaceSettingsPage'))
|
const WorkspaceSettingsPage = lazy(() => import('./pages/WorkspaceSettingsPage'))
|
||||||
@@ -94,6 +95,7 @@ export default function App() {
|
|||||||
<Route path="repos/:owner/:repo/environments" element={<S><EnvironmentsPage /></S>} />
|
<Route path="repos/:owner/:repo/environments" element={<S><EnvironmentsPage /></S>} />
|
||||||
<Route path="repos/:owner/:repo/timeline" element={<S><RepoTimelinePage /></S>} />
|
<Route path="repos/:owner/:repo/timeline" element={<S><RepoTimelinePage /></S>} />
|
||||||
<Route path="repos/:owner/:repo/secrets" element={<S><RepoSecretsPage /></S>} />
|
<Route path="repos/:owner/:repo/secrets" element={<S><RepoSecretsPage /></S>} />
|
||||||
|
<Route path="repos/:owner/:repo/security" element={<S><RepoSecurityPage /></S>} />
|
||||||
<Route path="repos/:owner/:repo/runs/:runId" element={<S><PipelineRunPage /></S>} />
|
<Route path="repos/:owner/:repo/runs/:runId" element={<S><PipelineRunPage /></S>} />
|
||||||
|
|
||||||
<Route path="starred" element={<S><StarredPage /></S>} />
|
<Route path="starred" element={<S><StarredPage /></S>} />
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { api, ApiError } from '../client'
|
||||||
|
import type { SBOMReport } from '../../types/api'
|
||||||
|
|
||||||
|
const sbomReportSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
repoId: z.number(),
|
||||||
|
runId: z.number(),
|
||||||
|
sha: z.string(),
|
||||||
|
format: z.string(),
|
||||||
|
componentCount: z.number(),
|
||||||
|
generatedAt: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** SBOM metadata for a specific pipeline run. */
|
||||||
|
export function useRunSBOM(owner: string, repo: string, runId: number) {
|
||||||
|
return useQuery<SBOMReport | null>({
|
||||||
|
queryKey: ['repos', owner, repo, 'runs', runId, 'sbom'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return await api.get<SBOMReport>(
|
||||||
|
`/api/v1/repos/${owner}/${repo}/runs/${runId}/sbom`,
|
||||||
|
sbomReportSchema,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 404) return null
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: Boolean(owner && repo && runId),
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest SBOM metadata for a repo. */
|
||||||
|
export function useLatestSBOM(owner: string, repo: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['repos', owner, repo, 'sbom'],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<SBOMReport>(
|
||||||
|
`/api/v1/repos/${owner}/${repo}/sbom`,
|
||||||
|
sbomReportSchema,
|
||||||
|
),
|
||||||
|
enabled: Boolean(owner && repo),
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download SBOM document URL for a specific run. */
|
||||||
|
export function getRunSBOMDocumentURL(owner: string, repo: string, runId: number): string {
|
||||||
|
return `/api/v1/repos/${owner}/${repo}/runs/${runId}/sbom/document`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download latest SBOM document URL. */
|
||||||
|
export function getLatestSBOMDocumentURL(owner: string, repo: string): string {
|
||||||
|
return `/api/v1/repos/${owner}/${repo}/sbom/document`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger on-demand SBOM generation. */
|
||||||
|
export function useGenerateSBOM(owner: string, repo: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ ref, runId }: { ref: string; runId?: number }) => {
|
||||||
|
let url = `/api/v1/repos/${owner}/${repo}/sbom/generate?ref=${encodeURIComponent(ref)}`
|
||||||
|
if (runId) url += `&runID=${runId}`
|
||||||
|
return api.post<SBOMReport>(url, sbomReportSchema, undefined)
|
||||||
|
},
|
||||||
|
onSuccess: (data, { runId }) => {
|
||||||
|
qc.setQueryData(['repos', owner, repo, 'sbom'], data)
|
||||||
|
if (runId) {
|
||||||
|
qc.setQueryData(['repos', owner, repo, 'runs', runId, 'sbom'], data)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { api } from '../client'
|
||||||
|
import type { SecretLeak, VulnerabilityFinding } from '../../types/api'
|
||||||
|
|
||||||
|
// ── Zod schemas ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const secretLeakSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
repoId: z.number(),
|
||||||
|
commitSha: z.string(),
|
||||||
|
ref: z.string(),
|
||||||
|
patternName: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
severity: z.string(),
|
||||||
|
matchSample: z.string(),
|
||||||
|
dismissed: z.boolean(),
|
||||||
|
dismissedBy: z.string().optional(),
|
||||||
|
dismissedAt: z.string().nullable().optional(),
|
||||||
|
detectedAt: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const vulnerabilityFindingSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
repoId: z.number(),
|
||||||
|
vulnId: z.string(),
|
||||||
|
purl: z.string(),
|
||||||
|
version: z.string(),
|
||||||
|
summary: z.string(),
|
||||||
|
details: z.string().optional(),
|
||||||
|
cvssScore: z.number(),
|
||||||
|
fixedVersion: z.string(),
|
||||||
|
dismissed: z.boolean(),
|
||||||
|
dismissedBy: z.string().optional(),
|
||||||
|
dismissedAt: z.string().nullable().optional(),
|
||||||
|
detectedAt: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Secret Leak queries ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Active secret leaks for a repo. */
|
||||||
|
export function useSecretLeaks(owner: string, repo: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['repos', owner, repo, 'secrets', 'leaks'],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<SecretLeak[]>(
|
||||||
|
`/api/v1/repos/${owner}/${repo}/secrets/leaks`,
|
||||||
|
z.array(secretLeakSchema),
|
||||||
|
),
|
||||||
|
enabled: Boolean(owner && repo),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dismiss a secret leak. */
|
||||||
|
export function useDismissSecretLeak(owner: string, repo: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (leakId: number) =>
|
||||||
|
api.post(
|
||||||
|
`/api/v1/repos/${owner}/${repo}/secrets/leaks/${leakId}/dismiss`,
|
||||||
|
z.unknown(),
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'secrets', 'leaks'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vulnerability queries ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Active vulnerability findings for a repo. */
|
||||||
|
export function useVulnerabilities(owner: string, repo: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['repos', owner, repo, 'vulnerabilities'],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<VulnerabilityFinding[]>(
|
||||||
|
`/api/v1/repos/${owner}/${repo}/vulnerabilities`,
|
||||||
|
z.array(vulnerabilityFindingSchema),
|
||||||
|
),
|
||||||
|
enabled: Boolean(owner && repo),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger a vulnerability scan. */
|
||||||
|
export function useScanVulnerabilities(owner: string, repo: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.post<VulnerabilityFinding[]>(
|
||||||
|
`/api/v1/repos/${owner}/${repo}/vulnerabilities/scan`,
|
||||||
|
z.array(vulnerabilityFindingSchema),
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'vulnerabilities'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dismiss a vulnerability finding. */
|
||||||
|
export function useDismissVulnerability(owner: string, repo: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (findingId: number) =>
|
||||||
|
api.post(
|
||||||
|
`/api/v1/repos/${owner}/${repo}/vulnerabilities/${findingId}/dismiss`,
|
||||||
|
z.unknown(),
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'vulnerabilities'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { useRunDetail, useJobLogs, useCancelRun, useRetryJob } from '../api/queries/pipelines'
|
import { useRunDetail, useJobLogs, useCancelRun, useRetryJob } from '../api/queries/pipelines'
|
||||||
|
import { useRunSBOM, getRunSBOMDocumentURL, useGenerateSBOM } from '../api/queries/sbom'
|
||||||
import { Skeleton } from '../ui/Skeleton'
|
import { Skeleton } from '../ui/Skeleton'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import type { PipelineJob, PipelineStep, RunStatus } from '../types/api'
|
import type { PipelineJob, PipelineStep, RunStatus } from '../types/api'
|
||||||
@@ -27,6 +28,87 @@ function duration(start: string | null, end: string | null): string {
|
|||||||
return `${m}m ${s % 60}s`
|
return `${m}m ${s % 60}s`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── SBOM section ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SBOMSection({ owner, repo, runId, runStatus, triggerSha }: {
|
||||||
|
owner: string
|
||||||
|
repo: string
|
||||||
|
runId: number
|
||||||
|
runStatus: RunStatus
|
||||||
|
triggerSha: string
|
||||||
|
}) {
|
||||||
|
const { data: sbom, isLoading } = useRunSBOM(owner, repo, runId)
|
||||||
|
const generateSBOM = useGenerateSBOM(owner, repo)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)] mb-3">
|
||||||
|
SBOM
|
||||||
|
</h2>
|
||||||
|
<Skeleton className="h-5 w-64 rounded" />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sbom) {
|
||||||
|
return (
|
||||||
|
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)] mb-1">
|
||||||
|
SBOM — CycloneDX
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-[var(--c-muted)]">
|
||||||
|
<span>{sbom.componentCount} components</span>
|
||||||
|
<span className="font-mono">{sbom.sha.slice(0, 7)}</span>
|
||||||
|
<span>{new Date(sbom.generatedAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={getRunSBOMDocumentURL(owner, repo, runId)}
|
||||||
|
download="bom.json"
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded-lg text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
</svg>
|
||||||
|
Download BOM
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No SBOM yet — show generate option for completed/failed runs
|
||||||
|
if (runStatus === 'succeeded' || runStatus === 'failed') {
|
||||||
|
return (
|
||||||
|
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)] mb-1">
|
||||||
|
SBOM
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[var(--c-muted)]">No SBOM generated for this run.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => generateSBOM.mutate({ ref: triggerSha, runId })}
|
||||||
|
disabled={generateSBOM.isPending}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded-lg text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors disabled:opacity-50 shrink-0"
|
||||||
|
>
|
||||||
|
{generateSBOM.isPending ? 'Generating…' : 'Generate SBOM'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{generateSBOM.isError && (
|
||||||
|
<p className="mt-2 text-xs text-[var(--c-danger)]">{(generateSBOM.error as Error).message}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function shortRef(ref: string): string {
|
function shortRef(ref: string): string {
|
||||||
return ref.replace('refs/heads/', '').replace('refs/tags/', '')
|
return ref.replace('refs/heads/', '').replace('refs/tags/', '')
|
||||||
}
|
}
|
||||||
@@ -172,7 +254,7 @@ function topoColumns(jobs: JobWithSteps[]): JobWithSteps[][] {
|
|||||||
const job = nameToJob.get(name)
|
const job = nameToJob.get(name)
|
||||||
if (!job) return 0
|
if (!job) return 0
|
||||||
let needs: string[] = []
|
let needs: string[] = []
|
||||||
try { needs = JSON.parse(job.needs || '[]') } catch { needs = [] }
|
try { needs = JSON.parse(job.needs || '[]') ?? [] } catch { needs = [] }
|
||||||
const d = needs.length === 0 ? 0 : 1 + Math.max(...needs.map(n => getDepth(n, new Set(visited))))
|
const d = needs.length === 0 ? 0 : 1 + Math.max(...needs.map(n => getDepth(n, new Set(visited))))
|
||||||
depth.set(name, d)
|
depth.set(name, d)
|
||||||
return d
|
return d
|
||||||
@@ -357,6 +439,11 @@ export default function PipelineRunPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* SBOM section */}
|
||||||
|
{!isLoading && run && (
|
||||||
|
<SBOMSection owner={owner} repo={repo} runId={runIdNum} runStatus={run.status as RunStatus} triggerSha={run.triggerSha} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* DAG + log viewer */}
|
{/* DAG + log viewer */}
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ export default function RepoPage() {
|
|||||||
<Link to={`/repos/${owner}/${repoName}/commits`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Commits</Link>
|
<Link to={`/repos/${owner}/${repoName}/commits`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Commits</Link>
|
||||||
<Link to={`/repos/${owner}/${repoName}/branches`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Branches</Link>
|
<Link to={`/repos/${owner}/${repoName}/branches`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Branches</Link>
|
||||||
<Link to={`/repos/${owner}/${repoName}/issues`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Issues</Link>
|
<Link to={`/repos/${owner}/${repoName}/issues`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Issues</Link>
|
||||||
|
<Link to={`/repos/${owner}/${repoName}/security`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Security</Link>
|
||||||
<Link to={`/repos/${owner}/${repoName}/settings`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1 ml-auto">Settings</Link>
|
<Link to={`/repos/${owner}/${repoName}/settings`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1 ml-auto">Settings</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { useSecretLeaks, useDismissSecretLeak } from '../api/queries/security'
|
||||||
|
import { useVulnerabilities, useScanVulnerabilities, useDismissVulnerability } from '../api/queries/security'
|
||||||
|
import { useLatestSBOM, useGenerateSBOM, getLatestSBOMDocumentURL } from '../api/queries/sbom'
|
||||||
|
import { Skeleton } from '../ui/Skeleton'
|
||||||
|
import { cn } from '../lib/utils'
|
||||||
|
|
||||||
|
const SEVERITY_COLORS: Record<string, string> = {
|
||||||
|
critical: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||||
|
high: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
|
||||||
|
medium: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
|
||||||
|
low: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
|
}
|
||||||
|
|
||||||
|
function cvssSeverity(score: number): { label: string; color: string } {
|
||||||
|
if (score >= 9) return { label: 'CRITICAL', color: SEVERITY_COLORS.critical }
|
||||||
|
if (score >= 7) return { label: 'HIGH', color: SEVERITY_COLORS.high }
|
||||||
|
if (score >= 4) return { label: 'MEDIUM', color: SEVERITY_COLORS.medium }
|
||||||
|
return { label: 'LOW', color: SEVERITY_COLORS.low }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SBOM Section ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SBOMSection({ owner, repo }: { owner: string; repo: string }) {
|
||||||
|
const { data: sbom, isLoading } = useLatestSBOM(owner, repo)
|
||||||
|
const generateSBOM = useGenerateSBOM(owner, repo)
|
||||||
|
const [ref, setRef] = useState('main')
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
|
||||||
|
<Skeleton className="h-5 w-48 rounded" />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sbom) {
|
||||||
|
return (
|
||||||
|
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)] mb-1">
|
||||||
|
SBOM — {sbom.format}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-[var(--c-muted)]">
|
||||||
|
<span>{sbom.componentCount} components</span>
|
||||||
|
<span className="font-mono">{sbom.sha.slice(0, 7)}</span>
|
||||||
|
<span>{new Date(sbom.generatedAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={getLatestSBOMDocumentURL(owner, repo)}
|
||||||
|
download="bom.json"
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded-lg text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
</svg>
|
||||||
|
Download BOM
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)] mb-1">
|
||||||
|
SBOM
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[var(--c-muted)]">
|
||||||
|
No SBOM generated yet. Generate one to enable vulnerability scanning.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={ref}
|
||||||
|
onChange={e => setRef(e.target.value)}
|
||||||
|
placeholder="branch or SHA"
|
||||||
|
className="w-36 px-2.5 py-1.5 text-xs border border-[var(--c-border)] rounded-lg bg-[var(--c-surface-muted)] text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none focus:border-[var(--c-brand-focus)] font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => generateSBOM.mutate({ ref })}
|
||||||
|
disabled={generateSBOM.isPending || !ref.trim()}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--c-brand)] hover:bg-[var(--c-brand-hover)] text-white rounded-lg transition-colors disabled:opacity-50 shrink-0"
|
||||||
|
>
|
||||||
|
{generateSBOM.isPending ? 'Generating…' : 'Generate SBOM'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{generateSBOM.isError && (
|
||||||
|
<p className="mt-2 text-xs text-[var(--c-danger)]">{(generateSBOM.error as Error).message}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Secret Leaks Section ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SecretLeaksSection({ owner, repo }: { owner: string; repo: string }) {
|
||||||
|
const { data: leaks, isLoading } = useSecretLeaks(owner, repo)
|
||||||
|
const dismissLeak = useDismissSecretLeak(owner, repo)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg width="14" height="14" fill="none" stroke="var(--c-danger)" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 0 1 21.75 8.25Z" />
|
||||||
|
</svg>
|
||||||
|
<h2 className="text-sm font-semibold text-[var(--c-text)]">Secret Leaks</h2>
|
||||||
|
{!isLoading && leaks && leaks.length > 0 && (
|
||||||
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-[var(--c-danger-tint)] text-[var(--c-danger)]">
|
||||||
|
{leaks.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{[1, 2].map(i => <Skeleton key={i} className="h-12 rounded" />)}
|
||||||
|
</div>
|
||||||
|
) : !leaks?.length ? (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">
|
||||||
|
No secret leaks detected.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-[var(--c-border)]">
|
||||||
|
{leaks.map(leak => (
|
||||||
|
<div key={leak.id} className="flex items-start gap-3 px-4 py-3">
|
||||||
|
<span className={cn(
|
||||||
|
'text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full mt-0.5 shrink-0',
|
||||||
|
SEVERITY_COLORS[leak.severity] ?? SEVERITY_COLORS.medium,
|
||||||
|
)}>
|
||||||
|
{leak.severity}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-[var(--c-text)]">{leak.patternName}</p>
|
||||||
|
<p className="text-xs text-[var(--c-muted)] mt-0.5">{leak.description}</p>
|
||||||
|
<div className="flex items-center gap-2.5 mt-1 text-[10px] text-[var(--c-subtle)] font-mono">
|
||||||
|
<span>{leak.commitSha}</span>
|
||||||
|
<span>{leak.ref.replace('refs/heads/', '')}</span>
|
||||||
|
<span>{new Date(leak.detectedAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
{leak.matchSample && (
|
||||||
|
<code className="inline-block mt-1 px-2 py-0.5 bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded text-[10px] font-mono text-[var(--c-muted)]">
|
||||||
|
{leak.matchSample}
|
||||||
|
</code>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => dismissLeak.mutate(leak.id)}
|
||||||
|
disabled={dismissLeak.isPending}
|
||||||
|
className="shrink-0 px-2.5 py-1 text-[10px] font-medium border border-[var(--c-border)] rounded text-[var(--c-muted)] hover:text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vulnerabilities Section ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function VulnerabilitiesSection({ owner, repo }: { owner: string; repo: string }) {
|
||||||
|
const { data: findings, isLoading } = useVulnerabilities(owner, repo)
|
||||||
|
const scanMut = useScanVulnerabilities(owner, repo)
|
||||||
|
const dismissVuln = useDismissVulnerability(owner, repo)
|
||||||
|
const { data: sbom } = useLatestSBOM(owner, repo)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg width="14" height="14" fill="none" stroke="var(--c-danger)" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126Z M12 15.75h.007v.008H12v-.008Z" />
|
||||||
|
</svg>
|
||||||
|
<h2 className="text-sm font-semibold text-[var(--c-text)]">Vulnerabilities</h2>
|
||||||
|
{!isLoading && findings && findings.length > 0 && (
|
||||||
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-[var(--c-danger-tint)] text-[var(--c-danger)]">
|
||||||
|
{findings.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!sbom && (
|
||||||
|
<span className="text-[10px] text-[var(--c-muted)]">No SBOM available</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => scanMut.mutate()}
|
||||||
|
disabled={scanMut.isPending || !sbom}
|
||||||
|
className="flex items-center gap-1 px-2.5 py-1 text-[10px] font-medium border border-[var(--c-border)] rounded text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||||
|
</svg>
|
||||||
|
{scanMut.isPending ? 'Scanning…' : 'Scan now'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scanMut.isError && (
|
||||||
|
<div className="px-4 py-2 text-xs text-[var(--c-danger)] bg-[var(--c-danger-tint)]/30">
|
||||||
|
Scan failed: {(scanMut.error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scanMut.isSuccess && findings && findings.length === 0 && (
|
||||||
|
<div className="px-4 py-2 text-xs text-[var(--c-success)] bg-[#E3FCEF] dark:bg-green-900/20">
|
||||||
|
Scan complete — no vulnerabilities found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{[1, 2].map(i => <Skeleton key={i} className="h-16 rounded" />)}
|
||||||
|
</div>
|
||||||
|
) : !findings?.length ? (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">
|
||||||
|
{sbom
|
||||||
|
? 'No vulnerability findings. Run a scan to check dependencies.'
|
||||||
|
: 'No SBOM available. Push a commit with a supported manifest (package.json, go.mod, etc.) or trigger a pipeline run to generate one.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-[var(--c-border)]">
|
||||||
|
{findings.map(f => {
|
||||||
|
const sev = cvssSeverity(f.cvssScore)
|
||||||
|
return (
|
||||||
|
<div key={f.id} className="flex items-start gap-3 px-4 py-3">
|
||||||
|
<span className={cn(
|
||||||
|
'text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full mt-0.5 shrink-0',
|
||||||
|
sev.color,
|
||||||
|
)}>
|
||||||
|
{sev.label}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-[var(--c-text)]">{f.vulnId}</span>
|
||||||
|
<span className="text-[10px] text-[var(--c-subtle)] font-mono">CVSS {f.cvssScore.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--c-text)] mt-0.5">{f.summary}</p>
|
||||||
|
<div className="flex items-center gap-2.5 mt-1 text-[10px] text-[var(--c-subtle)] font-mono">
|
||||||
|
<span>{f.purl}</span>
|
||||||
|
<span>v{f.version}</span>
|
||||||
|
{f.fixedVersion && <span>→ fix: {f.fixedVersion}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => dismissVuln.mutate(f.id)}
|
||||||
|
disabled={dismissVuln.isPending}
|
||||||
|
className="shrink-0 px-2.5 py-1 text-[10px] font-medium border border-[var(--c-border)] rounded text-[var(--c-muted)] hover:text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function RepoSecurityPage() {
|
||||||
|
const { owner = '', repo = '' } = useParams()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 md:px-6 py-5 space-y-6">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-[var(--c-muted)] mb-1">
|
||||||
|
<Link to="/repos" className="hover:text-[var(--c-brand)]">Repositories</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link to={`/repos/${owner}/${repo}`} className="hover:text-[var(--c-brand)]">{owner}/{repo}</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-[var(--c-text)]">Security</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-lg font-semibold text-[var(--c-text)]">Security</h1>
|
||||||
|
<p className="text-xs text-[var(--c-muted)] mt-0.5">
|
||||||
|
Secret leak detection and dependency vulnerability scanning.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SBOMSection owner={owner} repo={repo} />
|
||||||
|
<SecretLeaksSection owner={owner} repo={repo} />
|
||||||
|
<VulnerabilitiesSection owner={owner} repo={repo} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -342,3 +342,50 @@ export interface ApiError {
|
|||||||
export interface HealthResponse {
|
export interface HealthResponse {
|
||||||
status: 'ok'
|
status: 'ok'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── SBOM (Phase 4.2) ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SBOMReport {
|
||||||
|
id: number
|
||||||
|
repoId: number
|
||||||
|
runId: number
|
||||||
|
sha: string
|
||||||
|
format: string
|
||||||
|
componentCount: number
|
||||||
|
generatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Secret Scanning (Phase 4.4) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SecretLeak {
|
||||||
|
id: number
|
||||||
|
repoId: number
|
||||||
|
commitSha: string
|
||||||
|
ref: string
|
||||||
|
patternName: string
|
||||||
|
description: string
|
||||||
|
severity: string
|
||||||
|
matchSample: string
|
||||||
|
dismissed: boolean
|
||||||
|
dismissedBy?: string
|
||||||
|
dismissedAt?: string | null
|
||||||
|
detectedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vulnerability Scanning (Phase 4.5) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface VulnerabilityFinding {
|
||||||
|
id: number
|
||||||
|
repoId: number
|
||||||
|
vulnId: string
|
||||||
|
purl: string
|
||||||
|
version: string
|
||||||
|
summary: string
|
||||||
|
details?: string
|
||||||
|
cvssScore: number
|
||||||
|
fixedVersion: string
|
||||||
|
dismissed: boolean
|
||||||
|
dismissedBy?: string
|
||||||
|
dismissedAt?: string | null
|
||||||
|
detectedAt: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ func (h *SBOMHandler) GetLatestDocument(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate triggers on-demand SBOM generation for a repo at a given ref/SHA.
|
// Generate triggers on-demand SBOM generation for a repo at a given ref/SHA.
|
||||||
// POST /api/v1/repos/{owner}/{repo}/sbom/generate?ref=<sha-or-branch>
|
// POST /api/v1/repos/{owner}/{repo}/sbom/generate?ref=<sha-or-branch>[&runID=<id>]
|
||||||
func (h *SBOMHandler) Generate(w http.ResponseWriter, r *http.Request) {
|
func (h *SBOMHandler) Generate(w http.ResponseWriter, r *http.Request) {
|
||||||
repoID, ok := resolveRepoID(h.db, w, r)
|
repoID, ok := resolveRepoID(h.db, w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -126,7 +126,12 @@ func (h *SBOMHandler) Generate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
report, err := h.generator.GenerateOnDemand(repoID, sha)
|
var runID int64
|
||||||
|
if rid := r.URL.Query().Get("runID"); rid != "" {
|
||||||
|
runID, _ = strconv.ParseInt(rid, 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
report, err := h.generator.GenerateOnDemand(repoID, runID, sha)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, "generation failed: "+err.Error(), http.StatusInternalServerError)
|
jsonError(w, "generation failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/api/middleware"
|
||||||
"github.com/forgeo/forgebucket/internal/domain/scanning"
|
"github.com/forgeo/forgebucket/internal/domain/scanning"
|
||||||
"github.com/forgeo/forgebucket/internal/models"
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
)
|
)
|
||||||
@@ -52,7 +53,7 @@ func (h *ScanningHandler) DismissSecrets(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current user from session for audit trail.
|
// Get current user from session for audit trail.
|
||||||
username := r.Context().Value("user").(string)
|
username, _ := r.Context().Value(middleware.ContextKeyUsername).(string)
|
||||||
|
|
||||||
if err := h.scanner.DismissFindings(leakID, username); err != nil {
|
if err := h.scanner.DismissFindings(leakID, username); err != nil {
|
||||||
jsonError(w, err.Error(), http.StatusNotFound)
|
jsonError(w, err.Error(), http.StatusNotFound)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/api/middleware"
|
||||||
"github.com/forgeo/forgebucket/internal/domain/vulnscan"
|
"github.com/forgeo/forgebucket/internal/domain/vulnscan"
|
||||||
"github.com/forgeo/forgebucket/internal/models"
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
)
|
)
|
||||||
@@ -49,6 +50,9 @@ func (h *VulnScanHandler) Scan(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonError(w, "scan failed: "+err.Error(), http.StatusInternalServerError)
|
jsonError(w, "scan failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if findings == nil {
|
||||||
|
findings = []models.VulnerabilityFinding{}
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
jsonOK(w, findings)
|
jsonOK(w, findings)
|
||||||
}
|
}
|
||||||
@@ -68,7 +72,7 @@ func (h *VulnScanHandler) Dismiss(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
username := r.Context().Value("user").(string)
|
username, _ := r.Context().Value(middleware.ContextKeyUsername).(string)
|
||||||
|
|
||||||
if err := h.scanner.DismissFindings(findingID, username); err != nil {
|
if err := h.scanner.DismissFindings(findingID, username); err != nil {
|
||||||
jsonError(w, err.Error(), http.StatusNotFound)
|
jsonError(w, err.Error(), http.StatusNotFound)
|
||||||
|
|||||||
@@ -206,13 +206,13 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
|||||||
})
|
})
|
||||||
r.Get("/artifacts", artifactH.List)
|
r.Get("/artifacts", artifactH.List)
|
||||||
r.With(csrf).Post("/artifacts", artifactH.Upload)
|
r.With(csrf).Post("/artifacts", artifactH.Upload)
|
||||||
|
r.Get("/sbom", sbomH.GetForRun)
|
||||||
|
r.Get("/sbom/document", sbomH.GetDocumentForRun)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
r.Get("/artifacts/{artifactID}/download", artifactH.Download)
|
r.Get("/artifacts/{artifactID}/download", artifactH.Download)
|
||||||
r.Get("/artifacts/{artifactID}/signature", artifactH.GetSignature)
|
r.Get("/artifacts/{artifactID}/signature", artifactH.GetSignature)
|
||||||
r.Get("/artifacts/{artifactID}/verify", artifactH.VerifySignature)
|
r.Get("/artifacts/{artifactID}/verify", artifactH.VerifySignature)
|
||||||
r.Get("/sbom", sbomH.GetForRun)
|
|
||||||
r.Get("/sbom/document", sbomH.GetDocumentForRun)
|
|
||||||
r.Route("/members", func(r chi.Router) {
|
r.Route("/members", func(r chi.Router) {
|
||||||
r.Get("/", memberH.List)
|
r.Get("/", memberH.List)
|
||||||
r.With(csrf).Post("/", memberH.Add)
|
r.With(csrf).Post("/", memberH.Add)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/domain/sbom"
|
||||||
"github.com/forgeo/forgebucket/internal/events"
|
"github.com/forgeo/forgebucket/internal/events"
|
||||||
"github.com/forgeo/forgebucket/internal/models"
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
)
|
)
|
||||||
@@ -17,12 +18,13 @@ import (
|
|||||||
// advances the DAG as jobs complete. It does NOT execute jobs directly —
|
// advances the DAG as jobs complete. It does NOT execute jobs directly —
|
||||||
// that is the RunnerManager's responsibility.
|
// that is the RunnerManager's responsibility.
|
||||||
type Orchestrator struct {
|
type Orchestrator struct {
|
||||||
db *xorm.Engine
|
db *xorm.Engine
|
||||||
bus events.EventBus
|
bus events.EventBus
|
||||||
|
sbomGen *sbom.Generator
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOrchestrator(db *xorm.Engine, bus events.EventBus) *Orchestrator {
|
func NewOrchestrator(db *xorm.Engine, bus events.EventBus, sbomGen *sbom.Generator) *Orchestrator {
|
||||||
return &Orchestrator{db: db, bus: bus}
|
return &Orchestrator{db: db, bus: bus, sbomGen: sbomGen}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start subscribes to relevant NATS subjects and blocks until ctx is cancelled.
|
// Start subscribes to relevant NATS subjects and blocks until ctx is cancelled.
|
||||||
@@ -142,7 +144,11 @@ func (o *Orchestrator) createRun(repo models.Repository, evt events.PushEvent, w
|
|||||||
|
|
||||||
// Create job + step records for every job in the workflow.
|
// Create job + step records for every job in the workflow.
|
||||||
for jobName, wfJob := range wf.Jobs {
|
for jobName, wfJob := range wf.Jobs {
|
||||||
needsJSON, _ := json.Marshal([]string(wfJob.Needs))
|
needs := []string(wfJob.Needs)
|
||||||
|
if needs == nil {
|
||||||
|
needs = []string{}
|
||||||
|
}
|
||||||
|
needsJSON, _ := json.Marshal(needs)
|
||||||
job := &models.PipelineJob{
|
job := &models.PipelineJob{
|
||||||
RunID: run.ID,
|
RunID: run.ID,
|
||||||
Name: jobName,
|
Name: jobName,
|
||||||
@@ -231,6 +237,9 @@ func (o *Orchestrator) advanceDAG(runID, jobID int64, result string) {
|
|||||||
run.FinishedAt = &now
|
run.FinishedAt = &now
|
||||||
o.db.ID(run.ID).Cols("status", "finished_at").Update(&run) //nolint:errcheck
|
o.db.ID(run.ID).Cols("status", "finished_at").Update(&run) //nolint:errcheck
|
||||||
o.bus.Publish(events.SubjectPipelineCompleted, events.PipelineEvent{RunID: run.ID, RepoID: run.RepoID, Status: "succeeded", At: now}) //nolint:errcheck
|
o.bus.Publish(events.SubjectPipelineCompleted, events.PipelineEvent{RunID: run.ID, RepoID: run.RepoID, Status: "succeeded", At: now}) //nolint:errcheck
|
||||||
|
if o.sbomGen != nil {
|
||||||
|
go o.sbomGen.GenerateOnDemand(run.RepoID, run.ID, run.TriggerSHA)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,24 +86,32 @@ func (g *Generator) generateForRun(runID, repoID int64) {
|
|||||||
|
|
||||||
// GenerateOnDemand generates an SBOM for a specific repo + SHA and stores it
|
// GenerateOnDemand generates an SBOM for a specific repo + SHA and stores it
|
||||||
// (or returns the cached one if the SHA was already processed).
|
// (or returns the cached one if the SHA was already processed).
|
||||||
func (g *Generator) GenerateOnDemand(repoID int64, sha string) (*models.SBOMReport, error) {
|
func (g *Generator) GenerateOnDemand(repoID, runID int64, ref string) (*models.SBOMReport, error) {
|
||||||
// Return cached report for this exact SHA if one already exists.
|
|
||||||
var existing models.SBOMReport
|
|
||||||
if found, _ := g.db.Where("repo_id = ? AND sha = ?", repoID, sha).Get(&existing); found {
|
|
||||||
return &existing, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var repo models.Repository
|
var repo models.Repository
|
||||||
if found, _ := g.db.ID(repoID).Get(&repo); !found {
|
if found, _ := g.db.ID(repoID).Get(&repo); !found {
|
||||||
return nil, fmt.Errorf("repo %d not found", repoID)
|
return nil, fmt.Errorf("repo %d not found", repoID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the ref to a full commit SHA — ref can be a branch name, tag, etc.
|
||||||
|
sha, err := gitdomain.RevParse(repo.DiskPath, ref)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rev-parse %s: %w", ref, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return cached report for this exact SHA + runID if one already exists.
|
||||||
|
// Without runID in the cache key, a prior on-demand generation (runID=0)
|
||||||
|
// would shadow subsequent per-run generation requests.
|
||||||
|
var existing models.SBOMReport
|
||||||
|
if found, _ := g.db.Where("repo_id = ? AND sha = ? AND run_id = ?", repoID, sha, runID).Get(&existing); found {
|
||||||
|
return &existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
doc, err := Generate(repo.DiskPath, repo.Name, sha)
|
doc, err := Generate(repo.DiskPath, repo.Name, sha)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
report, err := g.persistAndReturn(repoID, 0, sha, doc)
|
report, err := g.persistAndReturn(repoID, runID, sha, doc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user