From 77268e2302c161c79e505509712e27891bdc7a42 Mon Sep 17 00:00:00 2001 From: erangel1 Date: Wed, 13 May 2026 00:55:28 +0200 Subject: [PATCH] edited ci file --- .env | 2 +- cmd/forgebucket/main.go | 8 +- docker-compose.prod.yml | 4 +- docker-compose.yml | 4 +- frontend/src/App.tsx | 2 + frontend/src/api/queries/sbom.ts | 76 ++++++ frontend/src/api/queries/security.ts | 117 ++++++++++ frontend/src/pages/PipelineRunPage.tsx | 89 ++++++- frontend/src/pages/RepoPage.tsx | 1 + frontend/src/pages/RepoSecurityPage.tsx | 298 ++++++++++++++++++++++++ frontend/src/types/api.ts | 47 ++++ internal/api/handlers/sbom.go | 9 +- internal/api/handlers/scanning.go | 3 +- internal/api/handlers/vulnscan.go | 6 +- internal/api/router.go | 4 +- internal/domain/ci/orchestrator.go | 19 +- internal/domain/sbom/generator.go | 24 +- 17 files changed, 684 insertions(+), 29 deletions(-) create mode 100644 frontend/src/api/queries/sbom.ts create mode 100644 frontend/src/api/queries/security.ts create mode 100644 frontend/src/pages/RepoSecurityPage.tsx diff --git a/.env b/.env index cffc9be..1b94e75 100644 --- a/.env +++ b/.env @@ -44,4 +44,4 @@ SePwUlkfdW6u4a0KYMYf3Op6wsXTp0kA2g== # ─── OCI Registry (Phase 4) ─────────────────────────────────────────────────── # Root directory for the OCI Distribution Spec blob and upload storage. -OCI_ROOT=/var/lib/forgebucket/oci +OCI_ROOT=/tmp/forgebucket/oci diff --git a/cmd/forgebucket/main.go b/cmd/forgebucket/main.go index 8c32f25..72fc116 100644 --- a/cmd/forgebucket/main.go +++ b/cmd/forgebucket/main.go @@ -80,7 +80,10 @@ func main() { ciCtx, ciCancel := context.WithCancel(context.Background()) 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) runnerMgr := ci.NewRunnerManager(engine, bus, cfg, 4) @@ -89,9 +92,6 @@ func main() { gitopsCtrl := gitops.NewController(engine, bus, cfg) go gitopsCtrl.Start(ciCtx) - sbomGen := sbom.NewGenerator(engine, bus) - go sbomGen.Start(ciCtx) - go observability.StartNATSWatcher(ciCtx, bus) // Initialise artifact signing key store. diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6f51c0d..36e26ea 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -26,8 +26,8 @@ services: ports: - "8080:8080" volumes: - - repo_data:/var/lib/forgebucket/repos - - oci_data:/var/lib/forgebucket/oci + - repo_data:/tmp/forgebucket/repos + - oci_data:/tmp/forgebucket/oci volumes: postgres_data: diff --git a/docker-compose.yml b/docker-compose.yml index 08221fc..e172d76 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: nats: - image: nats:2-alpine + image: mirror.gcr.io/nats:2-alpine restart: unless-stopped command: ["-js", "-m", "8222"] ports: @@ -16,7 +16,7 @@ services: retries: 10 postgres: - image: postgres:18 + image: mirror.gcr.io/postgres:18 restart: unless-stopped environment: POSTGRES_DB: forgebucket diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 209618c..51a891b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -40,6 +40,7 @@ const RepoPipelinesPage = lazy(() => import('./pages/RepoPipelinesPage')) const EnvironmentsPage = lazy(() => import('./pages/EnvironmentsPage')) const RepoTimelinePage = lazy(() => import('./pages/RepoTimelinePage')) const RepoSecretsPage = lazy(() => import('./pages/RepoSecretsPage')) +const RepoSecurityPage = lazy(() => import('./pages/RepoSecurityPage')) const WorkspacesPage = lazy(() => import('./pages/WorkspacesPage')) const WorkspacePage = lazy(() => import('./pages/WorkspacePage')) const WorkspaceSettingsPage = lazy(() => import('./pages/WorkspaceSettingsPage')) @@ -94,6 +95,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/queries/sbom.ts b/frontend/src/api/queries/sbom.ts new file mode 100644 index 0000000..40857ed --- /dev/null +++ b/frontend/src/api/queries/sbom.ts @@ -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({ + queryKey: ['repos', owner, repo, 'runs', runId, 'sbom'], + queryFn: async () => { + try { + return await api.get( + `/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( + `/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(url, sbomReportSchema, undefined) + }, + onSuccess: (data, { runId }) => { + qc.setQueryData(['repos', owner, repo, 'sbom'], data) + if (runId) { + qc.setQueryData(['repos', owner, repo, 'runs', runId, 'sbom'], data) + } + }, + }) +} diff --git a/frontend/src/api/queries/security.ts b/frontend/src/api/queries/security.ts new file mode 100644 index 0000000..faab0fe --- /dev/null +++ b/frontend/src/api/queries/security.ts @@ -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( + `/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( + `/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( + `/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'] }) + }, + }) +} diff --git a/frontend/src/pages/PipelineRunPage.tsx b/frontend/src/pages/PipelineRunPage.tsx index d7579b5..5ebe11f 100644 --- a/frontend/src/pages/PipelineRunPage.tsx +++ b/frontend/src/pages/PipelineRunPage.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useParams, Link } from 'react-router-dom' import { useRunDetail, useJobLogs, useCancelRun, useRetryJob } from '../api/queries/pipelines' +import { useRunSBOM, getRunSBOMDocumentURL, useGenerateSBOM } from '../api/queries/sbom' import { Skeleton } from '../ui/Skeleton' import { cn } from '../lib/utils' 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` } +// ── 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 ( +
+

+ SBOM +

+ +
+ ) + } + + if (sbom) { + return ( +
+
+
+

+ SBOM — CycloneDX +

+
+ {sbom.componentCount} components + {sbom.sha.slice(0, 7)} + {new Date(sbom.generatedAt).toLocaleString()} +
+
+ + + + + Download BOM + +
+
+ ) + } + + // No SBOM yet — show generate option for completed/failed runs + if (runStatus === 'succeeded' || runStatus === 'failed') { + return ( +
+
+
+

+ SBOM +

+

No SBOM generated for this run.

+
+ +
+ {generateSBOM.isError && ( +

{(generateSBOM.error as Error).message}

+ )} +
+ ) + } + + return null +} + function shortRef(ref: string): string { return ref.replace('refs/heads/', '').replace('refs/tags/', '') } @@ -172,7 +254,7 @@ function topoColumns(jobs: JobWithSteps[]): JobWithSteps[][] { const job = nameToJob.get(name) if (!job) return 0 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)))) depth.set(name, d) return d @@ -357,6 +439,11 @@ export default function PipelineRunPage() { ) : null} + {/* SBOM section */} + {!isLoading && run && ( + + )} + {/* DAG + log viewer */}
diff --git a/frontend/src/pages/RepoPage.tsx b/frontend/src/pages/RepoPage.tsx index 5c1b28c..5cc552f 100644 --- a/frontend/src/pages/RepoPage.tsx +++ b/frontend/src/pages/RepoPage.tsx @@ -192,6 +192,7 @@ export default function RepoPage() { Commits Branches Issues + Security Settings
diff --git a/frontend/src/pages/RepoSecurityPage.tsx b/frontend/src/pages/RepoSecurityPage.tsx new file mode 100644 index 0000000..fe19727 --- /dev/null +++ b/frontend/src/pages/RepoSecurityPage.tsx @@ -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 = { + 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 ( +
+ +
+ ) + } + + if (sbom) { + return ( +
+
+
+

+ SBOM — {sbom.format} +

+
+ {sbom.componentCount} components + {sbom.sha.slice(0, 7)} + {new Date(sbom.generatedAt).toLocaleString()} +
+
+ + + + + Download BOM + +
+
+ ) + } + + return ( +
+
+
+

+ SBOM +

+

+ No SBOM generated yet. Generate one to enable vulnerability scanning. +

+
+
+ 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" + /> + +
+
+ {generateSBOM.isError && ( +

{(generateSBOM.error as Error).message}

+ )} +
+ ) +} + +// ── Secret Leaks Section ────────────────────────────────────────────────────── + +function SecretLeaksSection({ owner, repo }: { owner: string; repo: string }) { + const { data: leaks, isLoading } = useSecretLeaks(owner, repo) + const dismissLeak = useDismissSecretLeak(owner, repo) + + return ( +
+
+
+ + + +

Secret Leaks

+ {!isLoading && leaks && leaks.length > 0 && ( + + {leaks.length} + + )} +
+
+ + {isLoading ? ( +
+ {[1, 2].map(i => )} +
+ ) : !leaks?.length ? ( +
+ No secret leaks detected. +
+ ) : ( +
+ {leaks.map(leak => ( +
+ + {leak.severity} + +
+

{leak.patternName}

+

{leak.description}

+
+ {leak.commitSha} + {leak.ref.replace('refs/heads/', '')} + {new Date(leak.detectedAt).toLocaleDateString()} +
+ {leak.matchSample && ( + + {leak.matchSample} + + )} +
+ +
+ ))} +
+ )} +
+ ) +} + +// ── 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 ( +
+
+
+ + + +

Vulnerabilities

+ {!isLoading && findings && findings.length > 0 && ( + + {findings.length} + + )} +
+
+ {!sbom && ( + No SBOM available + )} + +
+
+ + {scanMut.isError && ( +
+ Scan failed: {(scanMut.error as Error).message} +
+ )} + + {scanMut.isSuccess && findings && findings.length === 0 && ( +
+ Scan complete — no vulnerabilities found. +
+ )} + + {isLoading ? ( +
+ {[1, 2].map(i => )} +
+ ) : !findings?.length ? ( +
+ {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.'} +
+ ) : ( +
+ {findings.map(f => { + const sev = cvssSeverity(f.cvssScore) + return ( +
+ + {sev.label} + +
+
+ {f.vulnId} + CVSS {f.cvssScore.toFixed(1)} +
+

{f.summary}

+
+ {f.purl} + v{f.version} + {f.fixedVersion && → fix: {f.fixedVersion}} +
+
+ +
+ ) + })} +
+ )} +
+ ) +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default function RepoSecurityPage() { + const { owner = '', repo = '' } = useParams() + + return ( +
+ {/* Breadcrumb */} +
+
+ Repositories + / + {owner}/{repo} + / + Security +
+

Security

+

+ Secret leak detection and dependency vulnerability scanning. +

+
+ + + + +
+ ) +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index a136d93..12bd184 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -342,3 +342,50 @@ export interface ApiError { export interface HealthResponse { 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 +} diff --git a/internal/api/handlers/sbom.go b/internal/api/handlers/sbom.go index 77f9eab..60eb73a 100644 --- a/internal/api/handlers/sbom.go +++ b/internal/api/handlers/sbom.go @@ -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. -// POST /api/v1/repos/{owner}/{repo}/sbom/generate?ref= +// POST /api/v1/repos/{owner}/{repo}/sbom/generate?ref=[&runID=] func (h *SBOMHandler) Generate(w http.ResponseWriter, r *http.Request) { repoID, ok := resolveRepoID(h.db, w, r) if !ok { @@ -126,7 +126,12 @@ func (h *SBOMHandler) Generate(w http.ResponseWriter, r *http.Request) { 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 { jsonError(w, "generation failed: "+err.Error(), http.StatusInternalServerError) return diff --git a/internal/api/handlers/scanning.go b/internal/api/handlers/scanning.go index a585942..f353daf 100644 --- a/internal/api/handlers/scanning.go +++ b/internal/api/handlers/scanning.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" "xorm.io/xorm" + "github.com/forgeo/forgebucket/internal/api/middleware" "github.com/forgeo/forgebucket/internal/domain/scanning" "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. - username := r.Context().Value("user").(string) + username, _ := r.Context().Value(middleware.ContextKeyUsername).(string) if err := h.scanner.DismissFindings(leakID, username); err != nil { jsonError(w, err.Error(), http.StatusNotFound) diff --git a/internal/api/handlers/vulnscan.go b/internal/api/handlers/vulnscan.go index a1e0e7f..576e5ce 100644 --- a/internal/api/handlers/vulnscan.go +++ b/internal/api/handlers/vulnscan.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" "xorm.io/xorm" + "github.com/forgeo/forgebucket/internal/api/middleware" "github.com/forgeo/forgebucket/internal/domain/vulnscan" "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) return } + if findings == nil { + findings = []models.VulnerabilityFinding{} + } w.WriteHeader(http.StatusCreated) jsonOK(w, findings) } @@ -68,7 +72,7 @@ func (h *VulnScanHandler) Dismiss(w http.ResponseWriter, r *http.Request) { return } - username := r.Context().Value("user").(string) + username, _ := r.Context().Value(middleware.ContextKeyUsername).(string) if err := h.scanner.DismissFindings(findingID, username); err != nil { jsonError(w, err.Error(), http.StatusNotFound) diff --git a/internal/api/router.go b/internal/api/router.go index bdc255d..b3227ce 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -206,13 +206,13 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even }) r.Get("/artifacts", artifactH.List) 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}/signature", artifactH.GetSignature) 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.Get("/", memberH.List) r.With(csrf).Post("/", memberH.Add) diff --git a/internal/domain/ci/orchestrator.go b/internal/domain/ci/orchestrator.go index 24505c3..5984f32 100644 --- a/internal/domain/ci/orchestrator.go +++ b/internal/domain/ci/orchestrator.go @@ -9,6 +9,7 @@ import ( "xorm.io/xorm" + "github.com/forgeo/forgebucket/internal/domain/sbom" "github.com/forgeo/forgebucket/internal/events" "github.com/forgeo/forgebucket/internal/models" ) @@ -17,12 +18,13 @@ import ( // advances the DAG as jobs complete. It does NOT execute jobs directly — // that is the RunnerManager's responsibility. type Orchestrator struct { - db *xorm.Engine - bus events.EventBus + db *xorm.Engine + bus events.EventBus + sbomGen *sbom.Generator } -func NewOrchestrator(db *xorm.Engine, bus events.EventBus) *Orchestrator { - return &Orchestrator{db: db, bus: bus} +func NewOrchestrator(db *xorm.Engine, bus events.EventBus, sbomGen *sbom.Generator) *Orchestrator { + return &Orchestrator{db: db, bus: bus, sbomGen: sbomGen} } // 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. 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{ RunID: run.ID, Name: jobName, @@ -231,6 +237,9 @@ func (o *Orchestrator) advanceDAG(runID, jobID int64, result string) { run.FinishedAt = &now 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 + if o.sbomGen != nil { + go o.sbomGen.GenerateOnDemand(run.RepoID, run.ID, run.TriggerSHA) + } return } diff --git a/internal/domain/sbom/generator.go b/internal/domain/sbom/generator.go index 43f1bd3..9204237 100644 --- a/internal/domain/sbom/generator.go +++ b/internal/domain/sbom/generator.go @@ -86,24 +86,32 @@ func (g *Generator) generateForRun(runID, repoID int64) { // GenerateOnDemand generates an SBOM for a specific repo + SHA and stores it // (or returns the cached one if the SHA was already processed). -func (g *Generator) GenerateOnDemand(repoID int64, sha 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 - } - +func (g *Generator) GenerateOnDemand(repoID, runID int64, ref string) (*models.SBOMReport, error) { var repo models.Repository if found, _ := g.db.ID(repoID).Get(&repo); !found { 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) if err != nil { return nil, err } - report, err := g.persistAndReturn(repoID, 0, sha, doc) + report, err := g.persistAndReturn(repoID, runID, sha, doc) if err != nil { return nil, err }