diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 83b3b8ee04..894487b449 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -428,6 +428,7 @@ func prepareMigrationTasks() []*migration { newMigration(348, "Add issue priority definitions table", v1_27.AddIssuePriorityDefTable), newMigration(349, "Add security scanning tables", v1_27.AddSecurityScanningTables), newMigration(350, "Add issue type definitions table", v1_27.AddIssueTypeDefTable), + newMigration(351, "Add CDN public flag to attachments", v1_27.AddAttachmentCDNPublic), } return preparedMigrations } diff --git a/models/migrations/v1_27/v352.go b/models/migrations/v1_27/v352.go new file mode 100644 index 0000000000..57209388e0 --- /dev/null +++ b/models/migrations/v1_27/v352.go @@ -0,0 +1,14 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package v1_27 + +import "xorm.io/xorm" + +// AddAttachmentCDNPublic adds the cdn_public column to the attachment table. +func AddAttachmentCDNPublic(x *xorm.Engine) error { + type Attachment struct { + CDNPublic bool `xorm:"NOT NULL DEFAULT false 'cdn_public'"` + } + return x.Sync(new(Attachment)) +} diff --git a/models/repo/attachment.go b/models/repo/attachment.go index b2821a9d7e..5e6b24f0b6 100644 --- a/models/repo/attachment.go +++ b/models/repo/attachment.go @@ -31,6 +31,7 @@ type Attachment struct { Name string DownloadCount int64 `xorm:"DEFAULT 0"` Size int64 `xorm:"DEFAULT 0"` + CDNPublic bool `xorm:"NOT NULL DEFAULT false 'cdn_public'"` CreatedUnix timeutil.TimeStamp `xorm:"created"` CustomDownloadURL string `xorm:"-"` } diff --git a/modules/setting/cdn.go b/modules/setting/cdn.go new file mode 100644 index 0000000000..6dcbc05a9f --- /dev/null +++ b/modules/setting/cdn.go @@ -0,0 +1,34 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package setting + +import "time" + +// CDN holds configuration for the built-in CDN asset delivery system. +var CDN = struct { + Enabled bool + Domain string // e.g. "cdn.mokoconsulting.tech" + CacheTTL time.Duration // Cache-Control max-age for CDN responses + AllowedOrigins []string // CORS origins allowed to fetch CDN assets + AllowedIPs []string // IP/CIDR allowlist (empty = allow all) + AllowedDomains []string // Referrer domain allowlist (empty = allow all) + MaxFileSize int64 // max file size to serve (bytes) +}{ + Enabled: false, + Domain: "", + CacheTTL: 24 * time.Hour, + MaxFileSize: 100 * 1024 * 1024, // 100MB +} + +func loadCDNFrom(cfg ConfigProvider) { + sec := cfg.Section("cdn") + CDN.Enabled = sec.Key("ENABLED").MustBool(false) + CDN.Domain = sec.Key("DOMAIN").String() + CDN.CacheTTL = sec.Key("CACHE_TTL").MustDuration(CDN.CacheTTL) + CDN.MaxFileSize = sec.Key("MAX_FILE_SIZE").MustInt64(CDN.MaxFileSize) + + CDN.AllowedOrigins = sec.Key("ALLOWED_ORIGINS").Strings(",") + CDN.AllowedIPs = sec.Key("ALLOWED_IPS").Strings(",") + CDN.AllowedDomains = sec.Key("ALLOWED_DOMAINS").Strings(",") +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index b097b7e95c..11d8d37ce4 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -178,6 +178,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error { loadOtherFrom(cfg) loadUpdateCheckerFrom(cfg) loadNtfyFrom(cfg) + loadCDNFrom(cfg) loadLoginNotificationFrom(cfg) return nil } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 9e5aef5955..7d77e6716c 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2734,7 +2734,7 @@ "repo.settings.support_url_help": "Shown when downloads are gated. Can point to your wiki, product page, or external support site.", "repo.settings.custom_fields": "Custom Fields", "repo.settings.manifest": "Manifest", - "repo.settings.manifest_desc": "Project identity, governance, and build settings from the moko-platform manifest. These are accessible via API for Actions workflows and the moko-platform CLI.", + "repo.settings.manifest_desc": "Project identity, governance, and build settings from the mokoplatform manifest. These are accessible via API for Actions workflows and the mokoplatform CLI.", "repo.settings.manifest_identity": "Identity", "repo.settings.manifest_name": "Project Name", "repo.settings.manifest_org": "Organization", @@ -2831,6 +2831,8 @@ "repo.release.message": "Describe this release", "repo.release.prerelease_desc": "Mark as Pre-Release", "repo.release.prerelease_helper": "Mark this release unsuitable for production use.", + "repo.release.cdn_public": "CDN", + "repo.release.cdn_public_tooltip": "Make this asset available via the CDN. Disabled when the release is assigned to an update stream.", "repo.release.cancel": "Cancel", "repo.release.publish": "Publish Release", "repo.release.save_draft": "Save Draft", diff --git a/routers/web/repo/cdn.go b/routers/web/repo/cdn.go new file mode 100644 index 0000000000..904e99ffb9 --- /dev/null +++ b/routers/web/repo/cdn.go @@ -0,0 +1,278 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package repo + +import ( + "fmt" + "net" + "net/http" + "strings" + + licenses_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" + repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/httplib" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage" +) + +// CDNHandler serves release assets via the CDN hostname. +// URL format: /:owner/:repo/releases/:tag/:filename +// Only assets with cdn_public=true are served. +func CDNHandler(w http.ResponseWriter, req *http.Request) { + if !setting.CDN.Enabled { + http.NotFound(w, req) + return + } + + if !cdnCheckIPAllowed(req) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if !cdnCheckReferrerAllowed(req) { + http.Error(w, "Forbidden: referrer not allowed", http.StatusForbidden) + return + } + + // Parse: /:owner/:repo/releases/:tag/:filename + urlPath := strings.TrimPrefix(req.URL.Path, "/") + parts := strings.SplitN(urlPath, "/", 6) + + // Minimum: owner/repo/releases/tag/filename = 5 parts + if len(parts) < 5 || parts[2] != "releases" { + http.Error(w, "Not Found: expected /:owner/:repo/releases/:tag/:filename", http.StatusNotFound) + return + } + + ownerName := parts[0] + repoName := parts[1] + tagName := parts[3] + fileName := parts[4] + // Allow filenames with slashes (parts[5] if present) + if len(parts) == 6 { + fileName = parts[4] + "/" + parts[5] + } + + // Load repository + repo, err := repo_model.GetRepositoryByOwnerAndName(req.Context(), ownerName, repoName) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + http.NotFound(w, req) + } else { + log.Error("CDN: GetRepositoryByOwnerAndName %s/%s: %v", ownerName, repoName, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + return + } + + // Look up the release by tag + release, err := repo_model.GetRelease(req.Context(), repo.ID, tagName) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + http.NotFound(w, req) + } else { + log.Error("CDN: GetRelease %s/%s tag=%s: %v", ownerName, repoName, tagName, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + return + } + + // Don't serve draft releases via CDN + if release.IsDraft { + http.NotFound(w, req) + return + } + + // If the release is assigned to an update stream, CDN is disabled - + // the update server handles distribution for streamed releases. + if stream := licenses_model.GetReleaseStream(req.Context(), release.ID); stream != "" { + http.Error(w, "Forbidden: release is served via update stream", http.StatusForbidden) + return + } + + // Find the specific attachment by filename + attach, err := repo_model.GetAttachmentByReleaseIDFileName(req.Context(), release.ID, fileName) + if err != nil || attach == nil { + http.NotFound(w, req) + return + } + + // Only serve assets marked as CDN public + if !attach.CDNPublic { + http.Error(w, "Forbidden: asset is not CDN-enabled", http.StatusForbidden) + return + } + + // Check file size limit + if setting.CDN.MaxFileSize > 0 && attach.Size > setting.CDN.MaxFileSize { + http.Error(w, "File too large for CDN delivery", http.StatusRequestEntityTooLarge) + return + } + + // CORS headers + if len(setting.CDN.AllowedOrigins) > 0 { + origin := req.Header.Get("Origin") + for _, allowed := range setting.CDN.AllowedOrigins { + if allowed == "*" || allowed == origin { + w.Header().Set("Access-Control-Allow-Origin", allowed) + break + } + } + } else { + w.Header().Set("Access-Control-Allow-Origin", "*") + } + w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS") + w.Header().Set("Access-Control-Max-Age", "86400") + + if req.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + // ETag based on attachment UUID (immutable for same content) + etag := `"` + attach.UUID + `"` + w.Header().Set("Etag", etag) + + // 304 Not Modified check + if inm := req.Header.Get("If-None-Match"); inm != "" { + for item := range strings.SplitSeq(inm, ",") { + item = strings.TrimPrefix(strings.TrimSpace(item), "W/") + if item == etag { + w.WriteHeader(http.StatusNotModified) + return + } + } + } + + // Last-Modified + lastModified := attach.CreatedUnix.AsTimePtr() + if lastModified != nil { + w.Header().Set("Last-Modified", lastModified.UTC().Format(http.TimeFormat)) + } + + // CDN cache headers + cacheTTL := int(setting.CDN.CacheTTL.Seconds()) + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, no-transform", cacheTTL)) + + // Increment download count + if err := attach.IncreaseDownloadCount(req.Context()); err != nil { + log.Error("CDN: IncreaseDownloadCount: %v", err) + } + + // Try direct storage URL (S3/object storage) + if setting.Attachment.Storage.ServeDirect() { + u, err := storage.Attachments.ServeDirectURL(attach.RelativePath(), attach.Name, req.Method, nil) + if u != nil && err == nil { + http.Redirect(w, req, u.String(), http.StatusTemporaryRedirect) + return + } + } + + // Serve from local storage + fr, err := storage.Attachments.Open(attach.RelativePath()) + if err != nil { + log.Error("CDN: storage.Open %s: %v", attach.RelativePath(), err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + defer fr.Close() + + httplib.ServeUserContentByFile(req, w, fr, httplib.ServeHeaderOptions{ + Filename: attach.Name, + CacheIsPublic: true, + CacheDuration: setting.CDN.CacheTTL, + }) +} + +// cdnCheckIPAllowed checks if the request IP is in the configured allowlist. +func cdnCheckIPAllowed(req *http.Request) bool { + if len(setting.CDN.AllowedIPs) == 0 { + return true + } + + remoteIP := cdnGetRemoteIP(req) + if remoteIP == nil { + return false + } + + for _, cidr := range setting.CDN.AllowedIPs { + cidr = strings.TrimSpace(cidr) + if cidr == "" { + continue + } + + if !strings.Contains(cidr, "/") { + if remoteIP.Equal(net.ParseIP(cidr)) { + return true + } + continue + } + + _, network, err := net.ParseCIDR(cidr) + if err != nil { + log.Warn("CDN: invalid CIDR in AllowedIPs: %s", cidr) + continue + } + if network.Contains(remoteIP) { + return true + } + } + return false +} + +// cdnCheckReferrerAllowed checks if the request referrer domain is allowed. +func cdnCheckReferrerAllowed(req *http.Request) bool { + if len(setting.CDN.AllowedDomains) == 0 { + return true + } + + referer := req.Header.Get("Referer") + if referer == "" { + return true // direct requests always allowed + } + + for _, domain := range setting.CDN.AllowedDomains { + domain = strings.TrimSpace(strings.ToLower(domain)) + if domain == "" { + continue + } + if domain == "*" { + return true + } + refLower := strings.ToLower(referer) + if strings.Contains(refLower, "://"+domain+"/") || strings.Contains(refLower, "://"+domain+":") || + strings.HasSuffix(refLower, "://"+domain) { + return true + } + if strings.HasPrefix(domain, "*.") { + baseDomain := domain[2:] + if strings.Contains(refLower, "."+baseDomain+"/") || strings.Contains(refLower, "."+baseDomain+":") || + strings.HasSuffix(refLower, "."+baseDomain) { + return true + } + } + } + return false +} + +// cdnGetRemoteIP extracts the client IP, checking proxy headers. +func cdnGetRemoteIP(req *http.Request) net.IP { + if xff := req.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.SplitN(xff, ",", 2) + if ip := net.ParseIP(strings.TrimSpace(parts[0])); ip != nil { + return ip + } + } + if xri := req.Header.Get("X-Real-IP"); xri != "" { + if ip := net.ParseIP(strings.TrimSpace(xri)); ip != nil { + return ip + } + } + host, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + return net.ParseIP(req.RemoteAddr) + } + return net.ParseIP(host) +} diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index 6f7484112a..595b237d72 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -596,7 +596,10 @@ func EditRelease(ctx *context.Context) { ctx.Data["content"] = rel.Note ctx.Data["prerelease"] = rel.IsPrerelease ctx.Data["IsDraft"] = rel.IsDraft - ctx.Data["ReleaseStream"] = licenses_model.GetReleaseStream(ctx, rel.ID) + releaseStream := licenses_model.GetReleaseStream(ctx, rel.ID) + ctx.Data["ReleaseStream"] = releaseStream + ctx.Data["ReleaseHasStream"] = releaseStream != "" + ctx.Data["CDNEnabled"] = setting.CDN.Enabled rel.Repo = ctx.Repo.Repository if err := rel.LoadAttributes(ctx); err != nil { @@ -683,6 +686,28 @@ func EditReleasePost(ctx *context.Context) { } else { _ = licenses_model.DeleteReleaseStream(ctx, rel.ID) } + + // Update per-asset CDN visibility flags. + if setting.CDN.Enabled { + const cdnPrefix = "attachment-cdn-" + cdnUUIDs := make(map[string]bool) + for k := range ctx.Req.Form { + if strings.HasPrefix(k, cdnPrefix) { + cdnUUIDs[k[len(cdnPrefix):]] = true + } + } + // Load all attachments for this release to update cdn_public + if err := repo_model.GetReleaseAttachments(ctx, rel); err == nil { + for _, attach := range rel.Attachments { + wantCDN := cdnUUIDs[attach.UUID] + if attach.CDNPublic != wantCDN { + attach.CDNPublic = wantCDN + _ = repo_model.UpdateAttachmentByUUID(ctx, attach, "cdn_public") + } + } + } + } + ctx.Redirect(ctx.Repo.RepoLink + "/releases") } diff --git a/routers/web/web.go b/routers/web/web.go index 491bc86372..55b933672d 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -260,6 +260,20 @@ func Routes() *web.Router { // GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route routes.BeforeRouting(chi_middleware.GetHead) + // CDN hostname handler - intercepts requests on the CDN domain before any + // session/auth middleware runs, serving only CDN-public release assets. + if setting.CDN.Enabled && setting.CDN.Domain != "" { + routes.BeforeRouting(func(resp http.ResponseWriter, req *http.Request) { + host := req.Host + if idx := strings.Index(host, ":"); idx >= 0 { + host = host[:idx] + } + if strings.EqualFold(host, setting.CDN.Domain) { + repo.CDNHandler(resp, req) + } + }) + } + routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler routes.Methods("GET, HEAD", "/assets/site-manifest.json", misc.SiteManifest) routes.Methods("GET, HEAD, OPTIONS", "/assets/*", routing.MarkLogLevelTrace, public.AssetsCors(), public.FileHandlerFunc()) diff --git a/services/security/dependency_scanner.go b/services/security/dependency_scanner.go new file mode 100644 index 0000000000..b5773abbfd --- /dev/null +++ b/services/security/dependency_scanner.go @@ -0,0 +1,541 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package security + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" +) + +// ────────────────────────────────────────────────────────────────────── +// Dependency manifest parsers +// ────────────────────────────────────────────────────────────────────── + +// dependency represents a single package with version. +type dependency struct { + Name string + Version string + Ecosystem string // "Go", "npm", "PyPI", "Packagist" + FilePath string // which manifest file it came from +} + +// manifestParser extracts dependencies from a file's contents. +type manifestParser struct { + FileName string + Ecosystem string + Parse func(content string, filePath string) []dependency +} + +var manifestParsers = []manifestParser{ + {"go.mod", "Go", parseGoMod}, + {"package.json", "npm", parsePackageJSON}, + {"composer.json", "Packagist", parseComposerJSON}, + {"requirements.txt", "PyPI", parseRequirementsTxt}, +} + +// parseGoMod extracts dependencies from go.mod. +func parseGoMod(content, filePath string) []dependency { + var deps []dependency + inRequire := false + + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + + if line == ")" { + inRequire = false + continue + } + if strings.HasPrefix(line, "require (") || strings.HasPrefix(line, "require(") { + inRequire = true + continue + } + + if inRequire { + // Lines like: github.com/foo/bar v1.2.3 + parts := strings.Fields(line) + if len(parts) >= 2 && !strings.HasPrefix(parts[0], "//") { + deps = append(deps, dependency{ + Name: parts[0], + Version: parts[1], + Ecosystem: "Go", + FilePath: filePath, + }) + } + continue + } + + // Single-line require: require github.com/foo/bar v1.2.3 + if strings.HasPrefix(line, "require ") && !strings.Contains(line, "(") { + parts := strings.Fields(line) + if len(parts) >= 3 { + deps = append(deps, dependency{ + Name: parts[1], + Version: parts[2], + Ecosystem: "Go", + FilePath: filePath, + }) + } + } + } + return deps +} + +// parsePackageJSON extracts dependencies from package.json. +func parsePackageJSON(content, filePath string) []dependency { + var pkg struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + } + if err := json.Unmarshal([]byte(content), &pkg); err != nil { + return nil + } + + var deps []dependency + for name, version := range pkg.Dependencies { + deps = append(deps, dependency{ + Name: name, + Version: cleanSemver(version), + Ecosystem: "npm", + FilePath: filePath, + }) + } + for name, version := range pkg.DevDependencies { + deps = append(deps, dependency{ + Name: name, + Version: cleanSemver(version), + Ecosystem: "npm", + FilePath: filePath, + }) + } + return deps +} + +// parseComposerJSON extracts dependencies from composer.json. +func parseComposerJSON(content, filePath string) []dependency { + var pkg struct { + Require map[string]string `json:"require"` + RequireDev map[string]string `json:"require-dev"` + } + if err := json.Unmarshal([]byte(content), &pkg); err != nil { + return nil + } + + var deps []dependency + for name, version := range pkg.Require { + if name == "php" || strings.HasPrefix(name, "ext-") { + continue // skip platform requirements + } + deps = append(deps, dependency{ + Name: name, + Version: cleanSemver(version), + Ecosystem: "Packagist", + FilePath: filePath, + }) + } + for name, version := range pkg.RequireDev { + if name == "php" || strings.HasPrefix(name, "ext-") { + continue + } + deps = append(deps, dependency{ + Name: name, + Version: cleanSemver(version), + Ecosystem: "Packagist", + FilePath: filePath, + }) + } + return deps +} + +// parseRequirementsTxt extracts dependencies from requirements.txt. +func parseRequirementsTxt(content, filePath string) []dependency { + var deps []dependency + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") { + continue + } + + // Handle: package==1.0.0, package>=1.0.0, package~=1.0.0 + for _, sep := range []string{"==", ">=", "~=", "<=", "!="} { + if idx := strings.Index(line, sep); idx > 0 { + name := strings.TrimSpace(line[:idx]) + version := strings.TrimSpace(line[idx+len(sep):]) + // Strip any trailing constraints like ",<2.0" + if ci := strings.Index(version, ","); ci > 0 { + version = version[:ci] + } + deps = append(deps, dependency{ + Name: name, + Version: version, + Ecosystem: "PyPI", + FilePath: filePath, + }) + break + } + } + } + return deps +} + +// cleanSemver strips npm/composer range prefixes (^, ~, >=) to get a plain version. +func cleanSemver(v string) string { + v = strings.TrimSpace(v) + v = strings.TrimLeft(v, "^~>= 0 { + v = v[:idx] + } + return v +} + +// ────────────────────────────────────────────────────────────────────── +// OSV.dev API client +// ────────────────────────────────────────────────────────────────────── + +const osvBatchURL = "https://api.osv.dev/v1/querybatch" +const osvMaxBatch = 1000 // OSV batch limit + +var osvClient = &http.Client{Timeout: 30 * time.Second} + +// osvQuery is a single query in a batch request. +type osvQuery struct { + Package *osvPackage `json:"package"` + Version string `json:"version"` +} + +type osvPackage struct { + Name string `json:"name"` + Ecosystem string `json:"ecosystem"` +} + +// osvBatchRequest is the batch query body. +type osvBatchRequest struct { + Queries []osvQuery `json:"queries"` +} + +// osvBatchResponse is the batch response. +type osvBatchResponse struct { + Results []osvResult `json:"results"` +} + +type osvResult struct { + Vulns []osvVuln `json:"vulns"` +} + +type osvVuln struct { + ID string `json:"id"` + Summary string `json:"summary"` + Details string `json:"details"` + Severity []osvSeverity `json:"severity"` + Aliases []string `json:"aliases"` +} + +type osvSeverity struct { + Type string `json:"type"` // "CVSS_V3", "CVSS_V2" + Score string `json:"score"` // CVSS vector string +} + +// queryOSV sends a batch of dependencies to OSV.dev and returns vulnerabilities. +func queryOSV(deps []dependency) (*osvBatchResponse, error) { + queries := make([]osvQuery, 0, len(deps)) + for _, d := range deps { + if d.Version == "" || d.Version == "*" || d.Version == "latest" { + continue // can't query without a concrete version + } + queries = append(queries, osvQuery{ + Package: &osvPackage{Name: d.Name, Ecosystem: d.Ecosystem}, + Version: d.Version, + }) + } + + if len(queries) == 0 { + return &osvBatchResponse{}, nil + } + + body, err := json.Marshal(osvBatchRequest{Queries: queries}) + if err != nil { + return nil, fmt.Errorf("marshal OSV request: %w", err) + } + + resp, err := osvClient.Post(osvBatchURL, "application/json", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("OSV API request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("OSV API returned %d: %s", resp.StatusCode, string(respBody)) + } + + var result osvBatchResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode OSV response: %w", err) + } + return &result, nil +} + +// ────────────────────────────────────────────────────────────────────── +// Severity mapping +// ────────────────────────────────────────────────────────────────────── + +// mapCVSSSeverity converts a CVSS v3 base score to an AlertSeverity. +func mapCVSSSeverity(vulnSeverities []osvSeverity) security_model.AlertSeverity { + for _, s := range vulnSeverities { + if s.Type == "CVSS_V3" { + score := extractCVSSBaseScore(s.Score) + switch { + case score >= 9.0: + return security_model.SeverityCritical + case score >= 7.0: + return security_model.SeverityHigh + case score >= 4.0: + return security_model.SeverityMedium + case score > 0: + return security_model.SeverityLow + } + } + } + + // No CVSS score available - default to medium + return security_model.SeverityMedium +} + +// extractCVSSBaseScore parses the base score from a CVSS v3 vector string. +// Vector format: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H +// We compute a simplified score from the vector metrics. +func extractCVSSBaseScore(vector string) float64 { + if vector == "" { + return 0 + } + + // CVSS v3 vectors encode severity in metrics. Use a simplified + // lookup based on the most impactful metrics. + parts := make(map[string]string) + for _, segment := range strings.Split(vector, "/") { + kv := strings.SplitN(segment, ":", 2) + if len(kv) == 2 { + parts[kv[0]] = kv[1] + } + } + + // Simplified scoring based on key CVSS v3 metrics + var score float64 + + // Attack Vector (AV) + switch parts["AV"] { + case "N": // Network + score += 3.0 + case "A": // Adjacent + score += 2.0 + case "L": // Local + score += 1.0 + case "P": // Physical + score += 0.5 + } + + // Attack Complexity (AC) + switch parts["AC"] { + case "L": // Low + score += 1.5 + case "H": // High + score += 0.5 + } + + // Privileges Required (PR) + switch parts["PR"] { + case "N": // None + score += 1.5 + case "L": // Low + score += 1.0 + case "H": // High + score += 0.5 + } + + // Impact metrics (C/I/A) + for _, metric := range []string{"C", "I", "A"} { + switch parts[metric] { + case "H": + score += 1.2 + case "L": + score += 0.5 + } + } + + // Cap at 10.0 + if score > 10.0 { + score = 10.0 + } + return score +} + +// ────────────────────────────────────────────────────────────────────── +// DependencyScanner +// ────────────────────────────────────────────────────────────────────── + +// DependencyScanner checks project dependencies against known vulnerabilities. +type DependencyScanner struct{} + +// NewDependencyScanner creates a new dependency vulnerability scanner. +func NewDependencyScanner() *DependencyScanner { + return &DependencyScanner{} +} + +func (s *DependencyScanner) Type() security_model.ScannerType { + return security_model.ScannerDependency +} + +func (s *DependencyScanner) ScanCommit(commit *git.Commit) ([]Finding, error) { + return s.ScanTree(commit) +} + +func (s *DependencyScanner) ScanTree(commit *git.Commit) ([]Finding, error) { + if commit == nil { + return nil, nil + } + + // Step 1: Find and parse manifest files + entries, err := commit.ListEntriesRecursiveFast() + if err != nil { + return nil, fmt.Errorf("ListEntriesRecursiveFast: %w", err) + } + + var allDeps []dependency + for _, entry := range entries { + if !entry.IsRegular() { + continue + } + + path := entry.Name() + baseName := path + if idx := strings.LastIndex(path, "/"); idx >= 0 { + baseName = path[idx+1:] + } + + // Skip vendored/nested files + lower := strings.ToLower(path) + if strings.Contains(lower, "vendor/") || strings.Contains(lower, "node_modules/") || + strings.Contains(lower, "testdata/") { + continue + } + + for _, parser := range manifestParsers { + if baseName == parser.FileName { + reader, err := entry.Blob().DataAsync() + if err != nil { + log.Trace("DependencyScanner: skip %s: %v", path, err) + continue + } + content, err := io.ReadAll(io.LimitReader(reader, 5*1024*1024)) // 5MB limit + reader.Close() + if err != nil { + continue + } + + deps := parser.Parse(string(content), path) + allDeps = append(allDeps, deps...) + break + } + } + } + + if len(allDeps) == 0 { + return nil, nil + } + + log.Info("DependencyScanner: found %d dependencies across manifest files", len(allDeps)) + + // Step 2: Query OSV in batches + var findings []Finding + for i := 0; i < len(allDeps); i += osvMaxBatch { + end := i + osvMaxBatch + if end > len(allDeps) { + end = len(allDeps) + } + batch := allDeps[i:end] + + resp, err := queryOSV(batch) + if err != nil { + log.Error("DependencyScanner: OSV query failed: %v", err) + continue + } + + // Step 3: Map results to findings + // OSV batch response indices correspond 1:1 with the query indices. + // But we may have skipped deps with empty versions, so build the + // queryable subset to align indices. + queryable := make([]dependency, 0, len(batch)) + for _, d := range batch { + if d.Version != "" && d.Version != "*" && d.Version != "latest" { + queryable = append(queryable, d) + } + } + + for j, result := range resp.Results { + if j >= len(queryable) { + break + } + dep := queryable[j] + + for _, vuln := range result.Vulns { + severity := mapCVSSSeverity(vuln.Severity) + + // Build CVE alias for rule ID (prefer CVE over GHSA) + ruleID := vuln.ID + for _, alias := range vuln.Aliases { + if strings.HasPrefix(alias, "CVE-") { + ruleID = alias + break + } + } + + title := fmt.Sprintf("%s in %s@%s", ruleID, dep.Name, dep.Version) + + description := vuln.Summary + if description == "" { + description = vuln.Details + } + // Truncate long descriptions + if len(description) > 500 { + description = description[:497] + "..." + } + + // Metadata JSON + meta, _ := json.Marshal(map[string]string{ + "vuln_id": vuln.ID, + "ecosystem": dep.Ecosystem, + "package": dep.Name, + "version": dep.Version, + }) + + fingerprint := fmt.Sprintf("%x", sha256.Sum256([]byte(vuln.ID+":"+dep.Name+":"+dep.Version))) + + findings = append(findings, Finding{ + Scanner: security_model.ScannerDependency, + Severity: severity, + RuleID: ruleID, + Title: title, + Description: description, + FilePath: dep.FilePath, + CommitSHA: commit.ID.String(), + Fingerprint: fingerprint[:32], + Metadata: string(meta), + }) + } + } + } + + return findings, nil +} diff --git a/services/security/orchestrator.go b/services/security/orchestrator.go index a44c9c1b73..f4108d9a89 100644 --- a/services/security/orchestrator.go +++ b/services/security/orchestrator.go @@ -32,8 +32,10 @@ func ScanOnPush(ctx context.Context, repo *repo_model.Repository, commit *git.Co if cfg.SecretScanner { scanners = append(scanners, NewSecretScanner()) } + if cfg.DependScanner { + scanners = append(scanners, NewDependencyScanner()) + } // Future scanners added here: - // if cfg.DependScanner { scanners = append(scanners, NewDependencyScanner()) } // if cfg.CodeScanner { scanners = append(scanners, NewCodeScanner()) } if len(scanners) == 0 { diff --git a/templates/org/settings/update_streams.tmpl b/templates/org/settings/update_streams.tmpl index 5da64c43e1..920fffcc79 100644 --- a/templates/org/settings/update_streams.tmpl +++ b/templates/org/settings/update_streams.tmpl @@ -19,6 +19,7 @@

{{ctx.Locale.Tr "org.settings.enable_licensing_help"}}

+ {{if and (ne .StreamConfig.Platform "joomla") (ne .StreamConfig.Platform "both") (ne .StreamConfig.Platform "")}}
@@ -26,6 +27,7 @@

{{ctx.Locale.Tr "org.settings.require_key_help"}}

+ {{end}}
diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl index d23e103c3d..035f0d524e 100644 --- a/templates/repo/release/new.tmpl +++ b/templates/repo/release/new.tmpl @@ -86,6 +86,12 @@ {{svg "octicon-info"}} + {{if $.CDNEnabled}} + + {{end}}
{{ctx.Locale.Tr "remove"}} diff --git a/templates/repo/settings/licensing.tmpl b/templates/repo/settings/licensing.tmpl index f85d6a5173..7dc52faf5c 100644 --- a/templates/repo/settings/licensing.tmpl +++ b/templates/repo/settings/licensing.tmpl @@ -31,13 +31,15 @@

{{ctx.Locale.Tr "repo.settings.update_platform_help"}}

+ {{if and .RepoUpdateConfig (ne .RepoUpdateConfig.Platform "joomla") (ne .RepoUpdateConfig.Platform "both") (ne .RepoUpdateConfig.Platform "")}}
- +

{{ctx.Locale.Tr "repo.settings.require_update_key_help"}}

+ {{end}}