release: dependency scanner + CDN release delivery #566
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// 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))
|
||||
}
|
||||
@@ -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:"-"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// 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(",")
|
||||
}
|
||||
@@ -178,6 +178,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
|
||||
loadOtherFrom(cfg)
|
||||
loadUpdateCheckerFrom(cfg)
|
||||
loadNtfyFrom(cfg)
|
||||
loadCDNFrom(cfg)
|
||||
loadLoginNotificationFrom(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// 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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// 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, "^~>=<!")
|
||||
v = strings.TrimSpace(v)
|
||||
// If it has " || " or " - " (ranges), take the first version
|
||||
if idx := strings.Index(v, " "); idx > 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.enable_licensing_help"}}</p>
|
||||
</div>
|
||||
|
||||
{{if and (ne .StreamConfig.Platform "joomla") (ne .StreamConfig.Platform "both") (ne .StreamConfig.Platform "")}}
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox">
|
||||
<input name="require_key" type="checkbox" {{if .StreamConfig.RequireKey}}checked{{end}}>
|
||||
@@ -26,6 +27,7 @@
|
||||
</div>
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.require_key_help"}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.feed_visibility"}}</label>
|
||||
|
||||
@@ -86,6 +86,12 @@
|
||||
<span data-tooltip-content="{{ctx.Locale.Tr "repo.release.download_count" (ctx.Locale.PrettyNumber .DownloadCount)}}">
|
||||
{{svg "octicon-info"}}
|
||||
</span>
|
||||
{{if $.CDNEnabled}}
|
||||
<label class="tw-flex tw-items-center tw-gap-1 tw-ml-2" data-tooltip-content="{{ctx.Locale.Tr "repo.release.cdn_public_tooltip"}}">
|
||||
<input type="checkbox" name="attachment-cdn-{{.UUID}}" {{if .CDNPublic}}checked{{end}} {{if $.ReleaseHasStream}}disabled{{end}}>
|
||||
<span class="tw-text-text-light tw-text-xs">{{ctx.Locale.Tr "repo.release.cdn_public"}}</span>
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
<a class="ui mini compact red button" data-global-click="onReleaseEditAttachmentDelete" data-id="{{.ID}}" data-uuid="{{.UUID}}">
|
||||
{{ctx.Locale.Tr "remove"}}
|
||||
|
||||
@@ -31,13 +31,15 @@
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.update_platform_help"}}</p>
|
||||
</div>
|
||||
|
||||
{{if and .RepoUpdateConfig (ne .RepoUpdateConfig.Platform "joomla") (ne .RepoUpdateConfig.Platform "both") (ne .RepoUpdateConfig.Platform "")}}
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox">
|
||||
<input name="require_update_key" type="checkbox" {{if and .RepoUpdateConfig .RepoUpdateConfig.RequireKey}}checked{{end}}>
|
||||
<input name="require_update_key" type="checkbox" {{if .RepoUpdateConfig.RequireKey}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.require_update_key"}}</label>
|
||||
</div>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.require_update_key_help"}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.download_gating"}}</label>
|
||||
|
||||
Reference in New Issue
Block a user