feat: comprehensive audit + Tier 3 wiring (security/correctness/features)

Multi-agent audit + fix pass covering bugs, security, dead config wiring,
and missing features. All quality gates green: build/vet/test -race/govulncheck/
golangci-lint. Frontend tests 47/47.

SECURITY & CORRECTNESS

- Scanner pipeline fail-closed: cache.go scan timeout now 503 (was goto servePkg);
  scanner.go all-scanners-fail saves ScanStatusError; CheckVulnerabilities blocks
  on missing/error result instead of allowing.
- Scanner argv corrections: govulncheck source-mode + go.mod discovery;
  npm-audit generates lockfile via npm install --package-lock-only --ignore-scripts;
  pip-audit per-extension dispatch (wheel direct / sdist extract+pyproject).
- GHSA version-range filtering implemented (was always-include); url.QueryEscape
  on package name.
- pypi SSRF closed via host allowlist on original_url; url.QueryEscape on
  rewriteURL output.
- Path traversal: cache temp file uses os.CreateTemp; smb keyToPath sanitized;
  filesystem keyToPath returns error + filepath.Abs prefix-verify.
- Goroutine leaks plugged: cache.cleanupWorker stop channel; auth.ValidationCache
  Stop() with sync.Once; ws.unregister buffered with non-blocking send.
- WebSocket double-close panic fixed via sync.Once Client.closeSend().
- Race conditions: gormstore.registryCache sync.RWMutex (was concurrent map
  panic); auth.LastUsedAt no longer mutated under RLock; analytics strict
  lock-ordering invariant (statsMu before downloadsMu).
- Auth validator fails CLOSED on transport/unknown-status errors (was returning
  true,err 'allow cache fallback').
- Credential cache hash full SHA256 (was 8-byte truncate; eliminates 2^32
  collision risk).
- filesystem.fs.used incremented after successful rename (no quota inflation
  race).
- gormstore aggregation events deleted in same tx as insert (no double-counting).
- gormstore.SavePackage uses Updates(map) so zero-value security flags
  (RequiresAuth=false) can be cleared.
- partition_manager validates partition name regex before DROP TABLE.
- vcs/git: version regex validation rejects --option-injection; checkout uses
  --detach -- separator; removed TrimPrefix(repo,'v') that corrupted vault/
  vitess/vim-go.
- WebSocket CheckOrigin allowlist (was return true; CSWSH closed); same-origin
  default + ServerConfig.AllowedOrigins.
- fiber CVE GO-2026-4543 patched (v2.52.10 -> v2.52.12).

FUNCTIONAL FEATURES

- API key DB persistence: APIKeyModel + migration 202604280002, full CRUD,
  async LastUsedAt updates with WaitGroup-tracked goroutines drained on Close.
- Admin bootstrap via GOHOARDER_BOOTSTRAP_ADMIN_KEY env var; idempotent
  (skipped when non-revoked admin already exists).
- Auth middleware factory in pkg/app: RequireAuth, RequireRole, RequirePermission,
  OptionalAuth. Mounted on proxy + read APIs when Auth.Enabled=true.
  DELETE /api/packages/* requires admin role. /health public for k8s probes.
- NFS storage backend: pkg/storage/nfs wraps filesystem with /proc/mounts
  detection (Linux), post-write fsync, read-after-write health probe.
- WebSocket real-time pipeline: pkg/events Broadcaster interface; cache + scanner
  managers emit EventPackageCached / EventPackageDownloaded / EventScanComplete;
  app.go runs 30s EventStatsUpdate ticker. Frontend has full WS client (reconnect
  with exponential backoff 1s->30s, 25s heartbeat, subscriber pattern), Pinia
  realtime store, Dashboard live indicator + activity feed + reactive stats
  override.
- TLS termination: fiber.ListenTLS when Server.TLS.Enabled.
- Pre-warming: Prewarming.Enabled flag wired (was hardcoded false); Interval,
  MaxConcurrent, TopPackages plumbed.
- NetworkConfig wired (timeouts, retry, rate-limit, circuit-breaker).
- AuthConfig.BcryptCost wired.
- Security.Scanners.Static.MaxPackageSize plumbed to cache.io.LimitReader.
- Handlers.{Go,NPM,PyPI}.Enabled gates route mounting.
- Graceful shutdown: authManager.Close drains async writes.

LINT/HYGIENE

- 80 golangci-lint issues resolved: errcheck (Close/Write/RemoveAll wrapped),
  gofmt, gosec G104/G306, govet shadow renames + fieldalignment reorders,
  staticcheck ST1000 pkg comments + QF1003 tagged switches + QF1008 embedded
  field selectors + QF1001 De Morgan's law.

BEHAVIOR CHANGES

- Scanner now fail-closed: deployments without scanner binaries
  (trivy/govulncheck/npm-audit/pip-audit/grype) BLOCK packages instead of
  serving unscanned. Add binaries or plan a future allow-on-scan-error flag.
- WS origin defaults to same-origin; cross-origin dashboards must set
  server.allowed_origins.
- Validator no longer fails open; private packages won't serve from cache when
  validation can't be performed.
- download_events retention dropped from 24h to ~5min (deleted in agg tx).
  Migration 202604280001 purges pre-upgrade events.
- DELETE /api/packages/* requires admin role when auth enabled.

NEW PACKAGES

- pkg/events  - Broadcaster interface
- pkg/storage/nfs  - NFS-aware filesystem wrapper

DEFERRED (out of scope this pass)

- Static scanner implementation (config defines AllowedLicenses/MaxPackageSize/
  BlockSuspicious but pkg/scanner/static/ does not exist).
- AuthConfig.AuditLog wiring (no audit log infra yet).
- CacheConfig.TTLOverrides passthrough (would touch cache.Config beyond
  integrator scope).
This commit is contained in:
2026-04-29 00:57:56 +01:00
parent 3c8b3eb46c
commit e39d6a0f0d
80 changed files with 6383 additions and 661 deletions
+198 -11
View File
@@ -1,3 +1,5 @@
// Package ghsa implements a vulnerability scanner backed by the GitHub
// Security Advisory Database.
package ghsa
import (
@@ -6,6 +8,8 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -105,7 +109,7 @@ func (s *Scanner) Health(ctx context.Context) error {
if err != nil {
return fmt.Errorf("github advisory database not accessible: %w", err)
}
defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical
defer func() { _ = resp.Body.Close() }() // #nosec G104 -- Cleanup, error not critical
// Accept any 2xx or 403 (rate limit) as healthy
// Rate limits are expected without a GitHub token and shouldn't fail health checks
@@ -136,9 +140,13 @@ func (s *Scanner) mapRegistryToEcosystem(registry string) string {
// queryAdvisories queries GitHub Advisory Database for a package
func (s *Scanner) queryAdvisories(ctx context.Context, ecosystem, packageName string) ([]GHSAAdvisory, error) {
url := fmt.Sprintf("https://api.github.com/advisories?ecosystem=%s&affects=%s&per_page=100", ecosystem, packageName)
endpoint := fmt.Sprintf(
"https://api.github.com/advisories?ecosystem=%s&affects=%s&per_page=100",
url.QueryEscape(ecosystem),
url.QueryEscape(packageName),
)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
@@ -152,7 +160,7 @@ func (s *Scanner) queryAdvisories(ctx context.Context, ecosystem, packageName st
if err != nil {
return nil, fmt.Errorf("failed to query advisories: %w", err)
}
defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical
defer func() { _ = resp.Body.Close() }() // #nosec G104 -- Cleanup, error not critical
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
@@ -167,15 +175,194 @@ func (s *Scanner) queryAdvisories(ctx context.Context, ecosystem, packageName st
return advisories, nil
}
// filterAffectedAdvisories filters advisories that affect the given version
// filterAffectedAdvisories filters advisories that affect the given version.
// Each advisory may have multiple GHSAVulnerability entries; if any of them
// applies to our installed version (per its vulnerable_version_range), the
// advisory is considered affecting.
//
// Fail-closed: if the version or any range cannot be parsed, the advisory is
// included. We err on the side of reporting a possible vulnerability rather
// than silently dropping it.
func (s *Scanner) filterAffectedAdvisories(advisories []GHSAAdvisory, version string) []GHSAAdvisory {
// Check if this version is affected
// GitHub API already filters by package, but we need to check version ranges
// For now, we'll include all advisories that match the package
// A more sophisticated implementation would parse version ranges
affected := append([]GHSAAdvisory(nil), advisories...)
if version == "" {
// Without a target version, conservatively include everything.
return append([]GHSAAdvisory(nil), advisories...)
}
return affected
out := make([]GHSAAdvisory, 0, len(advisories))
for _, adv := range advisories {
if advisoryAffectsVersion(adv, version) {
out = append(out, adv)
}
}
return out
}
// advisoryAffectsVersion reports whether the given installed version falls
// within any of the advisory's vulnerable version ranges.
func advisoryAffectsVersion(adv GHSAAdvisory, version string) bool {
// If the advisory carries no per-vuln range info, conservatively include.
if len(adv.Vulnerabilities) == 0 {
return true
}
for _, v := range adv.Vulnerabilities {
rangeExpr := strings.TrimSpace(v.VulnerableVersions)
if rangeExpr == "" {
// No range — assume affected.
return true
}
matched, ok := versionInRange(version, rangeExpr)
if !ok {
// Parse error → fail-closed: include.
log.Debug().
Str("ghsa_id", adv.GHSAID).
Str("range", rangeExpr).
Str("version", version).
Msg("Could not parse GHSA vulnerable_version_range, including advisory")
return true
}
if matched {
return true
}
}
return false
}
// versionInRange reports whether version satisfies expr. Returns (matched, ok)
// where ok=false signals a parse error. Supported forms (comma separated AND):
//
// "= X"
// "< X"
// "<= X"
// "> X"
// ">= X"
// ">= X, < Y"
//
// All clauses must be satisfied for the range to match.
func versionInRange(version, expr string) (bool, bool) {
clauses := strings.Split(expr, ",")
for _, c := range clauses {
c = strings.TrimSpace(c)
if c == "" {
continue
}
op, bound, ok := splitOpAndVersion(c)
if !ok {
return false, false
}
cmp, ok := compareVersions(version, bound)
if !ok {
return false, false
}
switch op {
case "=", "==":
if cmp != 0 {
return false, true
}
case "<":
if cmp >= 0 {
return false, true
}
case "<=":
if cmp > 0 {
return false, true
}
case ">":
if cmp <= 0 {
return false, true
}
case ">=":
if cmp < 0 {
return false, true
}
default:
return false, false
}
}
return true, true
}
// splitOpAndVersion parses "<op> <version>" pairs (e.g. ">= 1.2.3").
func splitOpAndVersion(clause string) (op, ver string, ok bool) {
clause = strings.TrimSpace(clause)
// Longer operators first to avoid prefix shadowing.
for _, candidate := range []string{">=", "<=", "==", "=", ">", "<"} {
if strings.HasPrefix(clause, candidate) {
rest := strings.TrimSpace(strings.TrimPrefix(clause, candidate))
if rest == "" {
return "", "", false
}
return candidate, rest, true
}
}
return "", "", false
}
// compareVersions compares two dot-separated version strings.
// Returns (cmp, ok). Numeric segments are compared numerically.
// A pre-release suffix (anything after '-' or '+') is treated as lower-priority
// than the same version without one, matching common semver intuition for
// the cases we expect from the GitHub Advisory Database.
func compareVersions(a, b string) (int, bool) {
aBase, aPre := splitPreRelease(a)
bBase, bPre := splitPreRelease(b)
aParts := strings.Split(aBase, ".")
bParts := strings.Split(bBase, ".")
n := len(aParts)
if len(bParts) > n {
n = len(bParts)
}
for i := 0; i < n; i++ {
var av, bv int
var err error
if i < len(aParts) {
av, err = strconv.Atoi(aParts[i])
if err != nil {
return 0, false
}
}
if i < len(bParts) {
bv, err = strconv.Atoi(bParts[i])
if err != nil {
return 0, false
}
}
if av != bv {
if av < bv {
return -1, true
}
return 1, true
}
}
// Bases equal; compare pre-release. No pre-release > has pre-release.
switch {
case aPre == "" && bPre == "":
return 0, true
case aPre == "" && bPre != "":
return 1, true
case aPre != "" && bPre == "":
return -1, true
default:
return strings.Compare(aPre, bPre), true
}
}
// splitPreRelease separates "1.2.3-rc1" into ("1.2.3", "rc1"). Build metadata
// after '+' is stripped (per semver).
func splitPreRelease(v string) (base, pre string) {
v = strings.TrimSpace(v)
v = strings.TrimPrefix(v, "v")
if i := strings.Index(v, "+"); i >= 0 {
v = v[:i]
}
if i := strings.Index(v, "-"); i >= 0 {
return v[:i], v[i+1:]
}
return v, ""
}
// emptyResult returns an empty scan result
+160
View File
@@ -0,0 +1,160 @@
package ghsa
import (
"testing"
)
func TestVersionInRange(t *testing.T) {
// matched: whether the version satisfies the range expression.
// ok: whether the parser/comparator could evaluate the inputs.
cases := []struct {
name string
version string
expr string
matched bool
ok bool
}{
{"single LT match", "1.2.3", "< 2.0.0", true, true},
{"single LT no match", "2.5.0", "< 2.0.0", false, true},
{"single GTE match", "2.5.0", ">= 2.0.0", true, true},
{"single GTE no match", "1.0.0", ">= 2.0.0", false, true},
{"range hit", "1.5.0", ">= 1.0.0, < 2.0.0", true, true},
{"range below", "0.9.0", ">= 1.0.0, < 2.0.0", false, true},
{"range above", "2.0.0", ">= 1.0.0, < 2.0.0", false, true},
{"range upper bound exclusive", "2.0.0", ">= 1.0.0, < 2.0.0", false, true},
{"range lower bound inclusive", "1.0.0", ">= 1.0.0, < 2.0.0", true, true},
{"equality match", "1.2.3", "= 1.2.3", true, true},
{"equality miss", "1.2.4", "= 1.2.3", false, true},
{"with v prefix on bound", "1.2.3", ">= v1.0.0", true, true},
{"shorter version coerces", "1.0", ">= 1.0.0", true, true},
{"pre-release lower than release", "1.0.0-rc1", ">= 1.0.0", false, true},
{"pre-release greater than older", "1.0.0-rc1", ">= 0.9.0", true, true},
{"malformed operator", "1.0.0", "~ 1.0.0", false, false},
{"malformed version", "abc", ">= 1.0.0", false, false},
{"empty bound after op", "1.0.0", ">=", false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
matched, ok := versionInRange(tc.version, tc.expr)
if matched != tc.matched || ok != tc.ok {
t.Fatalf("versionInRange(%q, %q) = (%v, %v), want (%v, %v)",
tc.version, tc.expr, matched, ok, tc.matched, tc.ok)
}
})
}
}
func TestAdvisoryAffectsVersion(t *testing.T) {
cases := []struct {
name string
version string
adv GHSAAdvisory
want bool
}{
{
name: "advisory with no vulnerabilities is conservatively included",
adv: GHSAAdvisory{GHSAID: "GHSA-xxxx", Vulnerabilities: nil},
version: "1.0.0",
want: true,
},
{
name: "matching range marks advisory as affecting",
adv: GHSAAdvisory{
GHSAID: "GHSA-aaaa",
Vulnerabilities: []GHSAVulnerability{
{VulnerableVersions: ">= 1.0.0, < 2.0.0"},
},
},
version: "1.5.0",
want: true,
},
{
name: "non-matching range excludes advisory",
adv: GHSAAdvisory{
GHSAID: "GHSA-bbbb",
Vulnerabilities: []GHSAVulnerability{
{VulnerableVersions: ">= 2.0.0"},
},
},
version: "1.0.0",
want: false,
},
{
name: "any matching range across multiple vulns is affecting",
adv: GHSAAdvisory{
GHSAID: "GHSA-cccc",
Vulnerabilities: []GHSAVulnerability{
{VulnerableVersions: "< 0.5.0"},
{VulnerableVersions: ">= 1.0.0, < 1.2.0"},
},
},
version: "1.1.0",
want: true,
},
{
name: "empty range falls back to affecting (fail-closed)",
adv: GHSAAdvisory{
GHSAID: "GHSA-dddd",
Vulnerabilities: []GHSAVulnerability{{VulnerableVersions: ""}},
},
version: "1.0.0",
want: true,
},
{
name: "unparseable range falls back to affecting (fail-closed)",
adv: GHSAAdvisory{
GHSAID: "GHSA-eeee",
Vulnerabilities: []GHSAVulnerability{{VulnerableVersions: "~> 1.0"}},
},
version: "1.0.0",
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := advisoryAffectsVersion(tc.adv, tc.version)
if got != tc.want {
t.Fatalf("advisoryAffectsVersion(%q) = %v, want %v",
tc.version, got, tc.want)
}
})
}
}
func TestFilterAffectedAdvisoriesEmptyVersion(t *testing.T) {
// Without a target version we can't compare ranges, so all advisories
// are conservatively included.
s := &Scanner{}
in := []GHSAAdvisory{
{GHSAID: "A", Vulnerabilities: []GHSAVulnerability{{VulnerableVersions: ">= 2.0.0"}}},
{GHSAID: "B"},
}
out := s.filterAffectedAdvisories(in, "")
if len(out) != len(in) {
t.Fatalf("expected all advisories with empty version, got %d/%d", len(out), len(in))
}
}
func TestFilterAffectedAdvisoriesFiltersByRange(t *testing.T) {
s := &Scanner{}
in := []GHSAAdvisory{
{ // matches
GHSAID: "MATCH",
Vulnerabilities: []GHSAVulnerability{
{VulnerableVersions: ">= 1.0.0, < 2.0.0"},
},
},
{ // does not match
GHSAID: "MISS",
Vulnerabilities: []GHSAVulnerability{
{VulnerableVersions: ">= 3.0.0"},
},
},
}
out := s.filterAffectedAdvisories(in, "1.5.0")
if len(out) != 1 || out[0].GHSAID != "MATCH" {
t.Fatalf("expected only MATCH, got %+v", out)
}
}
+76 -5
View File
@@ -1,3 +1,5 @@
// Package govulncheck wraps the `govulncheck` CLI to scan Go modules for
// known vulnerabilities.
package govulncheck
import (
@@ -6,6 +8,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
@@ -66,15 +69,30 @@ func (s *Scanner) Scan(ctx context.Context, registry, packageName, version strin
if err != nil {
return nil, fmt.Errorf("failed to create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
defer func() { _ = os.RemoveAll(tmpDir) }()
// Extract the .zip file
if err := s.extractZip(filePath, tmpDir); err != nil {
return nil, fmt.Errorf("failed to extract zip: %w", err)
if extractErr := s.extractZip(filePath, tmpDir); extractErr != nil {
return nil, fmt.Errorf("failed to extract zip: %w", extractErr)
}
// Run govulncheck
cmd := exec.CommandContext(ctx, "govulncheck", "-json", "-mode=binary", tmpDir) // #nosec G204 -- govulncheck command with temp directory
// Locate the Go module root (directory containing go.mod). Go modules
// in the proxy zip layout are nested under <module>@<version>/.
moduleDir, err := findGoModDir(tmpDir)
if err != nil {
log.Warn().
Err(err).
Str("package", packageName).
Str("version", version).
Msg("Could not locate go.mod in extracted module, skipping govulncheck")
return s.skippedResult(registry, packageName, version, "no go.mod in extracted module"), nil
}
// Run govulncheck in source mode against the module's package set.
// -mode=binary requires a compiled binary which we do not have; the
// default (source) mode wants a Go source tree with a go.mod.
cmd := exec.CommandContext(ctx, "govulncheck", "-json", "./...") // #nosec G204 -- fixed args, cwd is controlled temp dir
cmd.Dir = moduleDir
output, _ := cmd.CombinedOutput()
// govulncheck returns non-zero when vulnerabilities are found
@@ -128,6 +146,59 @@ func (s *Scanner) extractZip(zipPath, destDir string) error {
return cmd.Run()
}
// findGoModDir walks the directory tree under root looking for a directory
// that contains a go.mod file. The Go module proxy ships zips with layout
// "<module>@<version>/...", so the module root is typically one or two
// levels below the extraction directory. Returns an error if none is found.
func findGoModDir(root string) (string, error) {
// Quick check: does the root itself contain go.mod?
if _, err := os.Stat(filepath.Join(root, "go.mod")); err == nil {
return root, nil
}
var found string
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil // keep searching
}
if d.IsDir() {
return nil
}
if d.Name() == "go.mod" {
found = filepath.Dir(path)
return filepath.SkipAll
}
return nil
})
if err != nil {
return "", err
}
if found == "" {
return "", fmt.Errorf("go.mod not found under %s", root)
}
return found, nil
}
// skippedResult returns a clean ScanResult marked as skipped with an
// explanation. Using clean (not error) here because the package is simply
// not a Go module we can analyse — not a scanner failure.
func (s *Scanner) skippedResult(registry, packageName, version, reason string) *metadata.ScanResult {
return &metadata.ScanResult{
ID: uuid.New().String(),
Registry: registry,
PackageName: packageName,
PackageVersion: version,
Scanner: ScannerName,
ScannedAt: time.Now(),
Status: metadata.ScanStatusClean,
VulnerabilityCount: 0,
Vulnerabilities: []metadata.Vulnerability{},
Details: map[string]interface{}{
"skipped": reason,
},
}
}
// convertResult converts govulncheck findings to our ScanResult format
func (s *Scanner) convertResult(vulns []GovulncheckVuln, registry, packageName, version string) *metadata.ScanResult {
vulnerabilities := make([]metadata.Vulnerability, 0)
+2
View File
@@ -1,3 +1,5 @@
// Package grype wraps the Anchore `grype` CLI to scan packages for
// known vulnerabilities.
package grype
import (
+45 -8
View File
@@ -1,3 +1,5 @@
// Package npmaudit wraps the `npm audit` CLI to surface vulnerability
// findings for npm packages.
package npmaudit
import (
@@ -66,7 +68,7 @@ func (s *Scanner) Scan(ctx context.Context, registry, packageName, version strin
if err != nil {
return nil, fmt.Errorf("failed to create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
defer func() { _ = os.RemoveAll(tmpDir) }()
// Extract the .tgz file
if err := s.extractTgz(filePath, tmpDir); err != nil {
@@ -80,8 +82,36 @@ func (s *Scanner) Scan(ctx context.Context, registry, packageName, version strin
packageDir = tmpDir
}
// Run npm audit
cmd := exec.CommandContext(ctx, "npm", "audit", "--json", "--package-lock-only")
// npm tarballs ship only package.json — there is no lockfile. We must
// generate one before `npm audit` can resolve the dependency tree.
// NOTE: this performs network egress (npm registry lookups for
// transitive deps). Acceptable here because the scanner runs server-
// side and the operator already trusts upstream resolution to cache
// the package; we use --ignore-scripts to avoid running install hooks.
log.Info().
Str("scanner", ScannerName).
Str("package", packageName).
Msg("Generating package-lock.json for npm audit (network egress)")
installCmd := exec.CommandContext(ctx, "npm", "install",
"--package-lock-only",
"--omit=dev",
"--ignore-scripts",
"--no-audit",
)
installCmd.Dir = packageDir
if installOut, err := installCmd.CombinedOutput(); err != nil {
log.Warn().
Err(err).
Str("package", packageName).
Str("output", string(installOut)).
Msg("npm install --package-lock-only failed; returning scan-error")
return s.scanErrorResult(registry, packageName, version,
fmt.Sprintf("npm install --package-lock-only failed: %v", err)), nil
}
// Run npm audit against the freshly generated lockfile.
cmd := exec.CommandContext(ctx, "npm", "audit", "--json")
cmd.Dir = packageDir
output, _ := cmd.CombinedOutput() // npm audit returns non-zero when vulns found
@@ -90,8 +120,9 @@ func (s *Scanner) Scan(ctx context.Context, registry, packageName, version strin
if len(output) > 0 {
if err := json.Unmarshal(output, &auditResult); err != nil {
log.Warn().Err(err).Msg("Failed to parse npm audit output")
// Return clean result on parse error
return s.emptyResult(registry, packageName, version), nil
// Parse failure means we couldn't determine vulnerability state — fail closed.
return s.scanErrorResult(registry, packageName, version,
fmt.Sprintf("failed to parse npm audit output: %v", err)), nil
}
}
@@ -123,7 +154,11 @@ func (s *Scanner) extractTgz(tgzPath, destDir string) error {
}
// emptyResult returns an empty scan result
func (s *Scanner) emptyResult(registry, packageName, version string) *metadata.ScanResult {
// scanErrorResult returns a result marked as scan-error so the manager merge
// and CheckVulnerabilities can fail closed. Use this when the scan could not
// complete and we therefore have no signal about vulnerabilities.
func (s *Scanner) scanErrorResult(registry, packageName, version, reason string) *metadata.ScanResult {
return &metadata.ScanResult{
ID: uuid.New().String(),
Registry: registry,
@@ -131,10 +166,12 @@ func (s *Scanner) emptyResult(registry, packageName, version string) *metadata.S
PackageVersion: version,
Scanner: ScannerName,
ScannedAt: time.Now(),
Status: metadata.ScanStatusClean,
Status: metadata.ScanStatusError,
VulnerabilityCount: 0,
Vulnerabilities: []metadata.Vulnerability{},
Details: map[string]interface{}{},
Details: map[string]interface{}{
"error": reason,
},
}
}
+3 -2
View File
@@ -1,3 +1,4 @@
// Package osv implements a vulnerability scanner backed by the OSV.dev API.
package osv
import (
@@ -158,7 +159,7 @@ func (s *Scanner) Scan(ctx context.Context, registry, packageName, version strin
if err != nil {
return nil, fmt.Errorf("OSV API request failed: %w", err)
}
defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical
defer func() { _ = resp.Body.Close() }() // #nosec G104 -- Cleanup, error not critical
// Read response
body, err := io.ReadAll(resp.Body)
@@ -372,7 +373,7 @@ func (s *Scanner) Health(ctx context.Context) error {
if err != nil {
return fmt.Errorf("OSV API not reachable: %w", err)
}
defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical
defer func() { _ = resp.Body.Close() }() // #nosec G104 -- Cleanup, error not critical
log.Debug().Int("status", resp.StatusCode).Msg("OSV health check passed")
return nil
+106 -9
View File
@@ -1,3 +1,5 @@
// Package pipaudit wraps the `pip-audit` CLI to scan Python wheels and
// source distributions for known vulnerabilities.
package pipaudit
import (
@@ -7,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/lukaszraczylo/gohoarder/pkg/config"
@@ -66,7 +69,7 @@ func (s *Scanner) Scan(ctx context.Context, registry, packageName, version strin
if err != nil {
return nil, fmt.Errorf("failed to create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
defer func() { _ = os.RemoveAll(tmpDir) }()
// Copy the wheel/tar.gz file to temp directory
tmpFile := filepath.Join(tmpDir, filepath.Base(filePath))
@@ -74,16 +77,30 @@ func (s *Scanner) Scan(ctx context.Context, registry, packageName, version strin
return nil, fmt.Errorf("failed to copy file: %w", err)
}
// Run pip-audit on the package file
cmd := exec.CommandContext(ctx, "pip-audit", "-r", tmpFile, "--format", "json") // #nosec G204 -- pip-audit command with temp file
output, _ := cmd.CombinedOutput() // pip-audit returns non-zero when vulns found
// Build the appropriate pip-audit invocation based on artifact type.
// `-r` expects requirements.txt — passing a wheel/tarball there is wrong.
// Wheels can be scanned directly via positional arg. Source distributions
// (tarballs) need to be extracted; if they contain a pyproject.toml we
// can scan that, otherwise we fail closed.
cmd, prepErr := s.buildAuditCmd(ctx, tmpDir, tmpFile)
if prepErr != nil {
log.Warn().
Err(prepErr).
Str("package", packageName).
Str("version", version).
Msg("pip-audit could not prepare input artifact, returning scan-error")
return s.scanErrorResult(registry, packageName, version, prepErr.Error()), nil
}
output, _ := cmd.CombinedOutput() // pip-audit returns non-zero when vulns found
// Parse pip-audit output
var auditResult PipAuditResult
if len(output) > 0 {
if err := json.Unmarshal(output, &auditResult); err != nil {
log.Warn().Err(err).Msg("Failed to parse pip-audit output")
return s.emptyResult(registry, packageName, version), nil
// Parse failure → no signal → fail closed.
return s.scanErrorResult(registry, packageName, version,
fmt.Sprintf("failed to parse pip-audit output: %v", err)), nil
}
}
@@ -117,8 +134,84 @@ func (s *Scanner) copyFile(src, dst string) error {
return os.WriteFile(dst, input, 0600)
}
// emptyResult returns an empty scan result
func (s *Scanner) emptyResult(registry, packageName, version string) *metadata.ScanResult {
// buildAuditCmd constructs the right pip-audit command for the input artifact.
//
// - .whl -> pip-audit <wheel> --format json
// - .tar.gz / .tgz / .zip (sdist) -> extract; if pyproject.toml exists
// run `pip-audit --pyproject <pyproject> --format json`; otherwise error.
//
// extractDir is used as a workspace for sdist extraction.
func (s *Scanner) buildAuditCmd(ctx context.Context, extractDir, artifact string) (*exec.Cmd, error) {
lower := strings.ToLower(artifact)
switch {
case strings.HasSuffix(lower, ".whl"):
// pip-audit can scan a wheel directly via positional argument.
return exec.CommandContext(ctx, "pip-audit", artifact, "--format", "json"), nil // #nosec G204 -- artifact path is in controlled tmp dir
case strings.HasSuffix(lower, ".tar.gz"),
strings.HasSuffix(lower, ".tgz"),
strings.HasSuffix(lower, ".zip"):
// Source distributions must be unpacked first.
sdistDir := filepath.Join(extractDir, "sdist")
if err := os.MkdirAll(sdistDir, 0o750); err != nil {
return nil, fmt.Errorf("create sdist dir: %w", err)
}
if err := s.extractSdist(artifact, sdistDir); err != nil {
return nil, fmt.Errorf("extract sdist: %w", err)
}
pyproject, err := findPyProject(sdistDir)
if err != nil {
return nil, fmt.Errorf("no pyproject.toml in sdist: %w", err)
}
return exec.CommandContext(ctx, "pip-audit", "--pyproject", pyproject, "--format", "json"), nil // #nosec G204 -- pyproject path under controlled tmp dir
default:
return nil, fmt.Errorf("unsupported pip artifact extension: %s", filepath.Base(artifact))
}
}
// extractSdist unpacks a Python source distribution into destDir.
func (s *Scanner) extractSdist(archive, destDir string) error {
lower := strings.ToLower(archive)
switch {
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"):
return exec.Command("tar", "-xzf", archive, "-C", destDir).Run()
case strings.HasSuffix(lower, ".zip"):
return exec.Command("unzip", "-q", archive, "-d", destDir).Run()
default:
return fmt.Errorf("unknown archive type: %s", archive)
}
}
// findPyProject returns the path to a pyproject.toml within root, walking
// one level deep (sdists typically extract to <pkg>-<ver>/).
func findPyProject(root string) (string, error) {
var found string
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
return nil
}
if d.Name() == "pyproject.toml" {
found = path
return filepath.SkipAll
}
return nil
})
if err != nil {
return "", err
}
if found == "" {
return "", fmt.Errorf("pyproject.toml not found under %s", root)
}
return found, nil
}
// scanErrorResult returns a result marked scan-error so manager merge and
// CheckVulnerabilities can fail closed when this scanner could not run.
func (s *Scanner) scanErrorResult(registry, packageName, version, reason string) *metadata.ScanResult {
return &metadata.ScanResult{
ID: uuid.New().String(),
Registry: registry,
@@ -126,13 +219,17 @@ func (s *Scanner) emptyResult(registry, packageName, version string) *metadata.S
PackageVersion: version,
Scanner: ScannerName,
ScannedAt: time.Now(),
Status: metadata.ScanStatusClean,
Status: metadata.ScanStatusError,
VulnerabilityCount: 0,
Vulnerabilities: []metadata.Vulnerability{},
Details: map[string]interface{}{},
Details: map[string]interface{}{
"error": reason,
},
}
}
// emptyResult returns an empty scan result
// convertResult converts pip-audit output to our ScanResult format
func (s *Scanner) convertResult(auditResult *PipAuditResult, registry, packageName, version string) *metadata.ScanResult {
vulnerabilities := make([]metadata.Vulnerability, 0)
+6 -2
View File
@@ -2,6 +2,7 @@ package scanner
import (
"context"
"sync"
"time"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
@@ -15,6 +16,7 @@ type RescanWorker struct {
storage storage.StorageBackend
manager *Manager
stopCh chan struct{}
stopOnce sync.Once
interval time.Duration
}
@@ -64,9 +66,11 @@ func (w *RescanWorker) Start(ctx context.Context) {
}
}
// Stop stops the rescan worker
// Stop stops the rescan worker. Safe to call multiple times.
func (w *RescanWorker) Stop() {
close(w.stopCh)
w.stopOnce.Do(func() {
close(w.stopCh)
})
}
// rescanPackages re-scans packages that need updating
+117 -10
View File
@@ -1,11 +1,18 @@
// Package scanner orchestrates pluggable vulnerability scanners and
// records their results against cached packages.
package scanner
import (
"context"
stderrors "errors"
"fmt"
"strings"
"sync"
"time"
"github.com/lukaszraczylo/gohoarder/pkg/config"
hoardererrors "github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/lukaszraczylo/gohoarder/pkg/events"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/lukaszraczylo/gohoarder/pkg/scanner/ghsa"
"github.com/lukaszraczylo/gohoarder/pkg/scanner/govulncheck"
@@ -14,6 +21,8 @@ import (
"github.com/lukaszraczylo/gohoarder/pkg/scanner/osv"
"github.com/lukaszraczylo/gohoarder/pkg/scanner/pipaudit"
"github.com/lukaszraczylo/gohoarder/pkg/scanner/trivy"
"github.com/lukaszraczylo/gohoarder/pkg/uuid"
"github.com/lukaszraczylo/gohoarder/pkg/websocket"
"github.com/rs/zerolog/log"
)
@@ -37,11 +46,34 @@ type DatabaseUpdater interface {
// Manager manages multiple security scanners
type Manager struct {
metadataStore metadata.MetadataStore
config config.SecurityConfig
broadcaster events.Broadcaster
scanners []Scanner
config config.SecurityConfig
bcMu sync.RWMutex
enabled bool
}
// SetBroadcaster wires an events.Broadcaster onto the scanner so scan
// lifecycle events are published. Pass nil to disable broadcasting.
// Safe for concurrent use.
func (m *Manager) SetBroadcaster(b events.Broadcaster) {
m.bcMu.Lock()
m.broadcaster = b
m.bcMu.Unlock()
}
// emit publishes an event via the configured broadcaster, if any.
// Non-blocking: the underlying transport handles overflow by dropping.
func (m *Manager) emit(eventType string, payload map[string]interface{}) {
m.bcMu.RLock()
b := m.broadcaster
m.bcMu.RUnlock()
if b == nil {
return
}
b.BroadcastEvent(eventType, payload)
}
// New creates a new scanner manager with configured scanners
func New(cfg config.SecurityConfig, metadataStore metadata.MetadataStore) (*Manager, error) {
manager := &Manager{
@@ -178,11 +210,42 @@ func (m *Manager) ScanPackage(ctx context.Context, registry, packageName, versio
Msg("Scan completed")
}
// If no scanners succeeded, return
// If no scanners succeeded, persist a synthetic error result so callers
// fail closed (no scan == blocked) rather than silently leaving
// SecurityScanned=false which would allow the package to be served.
if len(scanResults) == 0 {
log.Warn().
Str("package", packageName).
Msg("All scanners failed, no results to save")
Msg("All scanners failed, saving scan-error result for fail-closed enforcement")
errResult := &metadata.ScanResult{
ID: uuid.New().String(),
Registry: registry,
PackageName: packageName,
PackageVersion: version,
Scanner: "all",
ScannedAt: time.Now(),
Status: metadata.ScanStatusError,
VulnerabilityCount: 0,
Vulnerabilities: []metadata.Vulnerability{},
Details: map[string]interface{}{
"error": "all configured scanners failed for this package",
},
}
if err := m.metadataStore.SaveScanResult(ctx, errResult); err != nil {
log.Error().
Err(err).
Str("package", packageName).
Msg("Failed to save scan-error result")
return err
}
m.emit(string(websocket.EventScanComplete), map[string]interface{}{
"registry": registry,
"name": packageName,
"version": version,
"status": string(errResult.Status),
"vulnerability_count": errResult.VulnerabilityCount,
})
return nil
}
@@ -206,6 +269,14 @@ func (m *Manager) ScanPackage(ctx context.Context, registry, packageName, versio
Strs("scanners", scannerNames).
Msg("Consolidated scan results saved")
m.emit(string(websocket.EventScanComplete), map[string]interface{}{
"registry": registry,
"name": packageName,
"version": version,
"status": string(mergedResult.Status),
"vulnerability_count": mergedResult.VulnerabilityCount,
})
return nil
}
@@ -285,11 +356,21 @@ func (m *Manager) mergeResults(results []*metadata.ScanResult, scannerNames []st
}
}
// Update status to worst case
if result.Status == metadata.ScanStatusVulnerable {
// Update status to worst case.
// Order: vulnerable > error > pending > clean. Vulnerable wins because
// the cache layer must surface the actual vulns. Otherwise, if any
// scanner errored, propagate so CheckVulnerabilities can fail closed.
switch result.Status {
case metadata.ScanStatusVulnerable:
merged.Status = metadata.ScanStatusVulnerable
} else if result.Status == metadata.ScanStatusPending && merged.Status != metadata.ScanStatusVulnerable {
merged.Status = metadata.ScanStatusPending
case metadata.ScanStatusError:
if merged.Status != metadata.ScanStatusVulnerable {
merged.Status = metadata.ScanStatusError
}
case metadata.ScanStatusPending:
if merged.Status != metadata.ScanStatusVulnerable && merged.Status != metadata.ScanStatusError {
merged.Status = metadata.ScanStatusPending
}
}
}
@@ -359,11 +440,37 @@ func (m *Manager) CheckVulnerabilities(ctx context.Context, registry, packageNam
}
}
// Get latest scan result
// Get latest scan result.
// SECURITY: Fail closed when no scan exists or backend errors.
// Previously this returned (false, "", nil) which allowed unscanned
// packages through — a fail-open bypass. Cache layer is expected to
// wait for the initial scan via SecurityScanned flag; once that flag
// is set, GetScanResult MUST return a record. A missing record at this
// point indicates either a cleared/lost scan or a transient error;
// either way we block.
result, err := m.metadataStore.GetScanResult(ctx, registry, packageName, version)
if err != nil {
// No scan result found - allow download (will be scanned after)
return false, "", nil
var hErr *hoardererrors.Error
if stderrors.As(err, &hErr) && hErr.Code == hoardererrors.ErrCodeNotFound {
return true, "no scan available - fail closed", nil
}
// Real backend error (DB transient, etc.) — also block.
log.Warn().
Err(err).
Str("package", packageName).
Msg("Failed to retrieve scan result, failing closed")
return true, "scan lookup failed - fail closed", nil
}
if result == nil {
// File-backed metadata store returns (nil, nil) on not-found.
return true, "no scan available - fail closed", nil
}
// If the scan itself errored (all scanners failed for this package),
// block. A scan-error record means we don't actually know whether the
// package is safe.
if result.Status == metadata.ScanStatusError {
return true, "scan failed - fail closed", nil
}
// Build set of bypassed CVEs for fast lookup
+256
View File
@@ -0,0 +1,256 @@
package scanner
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/lukaszraczylo/gohoarder/pkg/config"
hoardererrors "github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/lukaszraczylo/gohoarder/pkg/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeBroadcaster records BroadcastEvent calls for assertions.
type fakeBroadcaster struct {
events []fakeBroadcastEvent
mu sync.Mutex
}
type fakeBroadcastEvent struct {
Payload any
Type string
}
func (f *fakeBroadcaster) BroadcastEvent(eventType string, payload any) {
f.mu.Lock()
defer f.mu.Unlock()
f.events = append(f.events, fakeBroadcastEvent{Type: eventType, Payload: payload})
}
func (f *fakeBroadcaster) snapshot() []fakeBroadcastEvent {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]fakeBroadcastEvent, len(f.events))
copy(out, f.events)
return out
}
// stubScanner is a minimal Scanner implementation for tests.
type stubScanner struct {
result *metadata.ScanResult
err error
name string
}
func (s *stubScanner) Name() string { return s.name }
func (s *stubScanner) Scan(_ context.Context, registry, packageName, version, _ string) (*metadata.ScanResult, error) {
if s.err != nil {
return nil, s.err
}
r := *s.result
r.Registry = registry
r.PackageName = packageName
r.PackageVersion = version
return &r, nil
}
func (s *stubScanner) Health(context.Context) error { return nil }
// stubMetadataStore is a minimal MetadataStore — only SaveScanResult is exercised.
type stubMetadataStore struct {
saveErr error
savedResults []*metadata.ScanResult
mu sync.Mutex
}
func (m *stubMetadataStore) SaveScanResult(_ context.Context, r *metadata.ScanResult) error {
if m.saveErr != nil {
return m.saveErr
}
m.mu.Lock()
m.savedResults = append(m.savedResults, r)
m.mu.Unlock()
return nil
}
// All other MetadataStore methods are unused by ScanPackage — stub to satisfy interface.
func (m *stubMetadataStore) SavePackage(context.Context, *metadata.Package) error { return nil }
func (m *stubMetadataStore) GetPackage(context.Context, string, string, string) (*metadata.Package, error) {
return nil, hoardererrors.NotFound("not implemented")
}
func (m *stubMetadataStore) DeletePackage(context.Context, string, string, string) error { return nil }
func (m *stubMetadataStore) ListPackages(context.Context, *metadata.ListOptions) ([]*metadata.Package, error) {
return nil, nil
}
func (m *stubMetadataStore) UpdateDownloadCount(context.Context, string, string, string) error {
return nil
}
func (m *stubMetadataStore) GetStats(context.Context, string) (*metadata.Stats, error) {
return nil, nil
}
func (m *stubMetadataStore) GetScanResult(context.Context, string, string, string) (*metadata.ScanResult, error) {
return nil, nil
}
func (m *stubMetadataStore) Count(context.Context) (int, error) { return 0, nil }
func (m *stubMetadataStore) Health(context.Context) error { return nil }
func (m *stubMetadataStore) Close() error { return nil }
func (m *stubMetadataStore) SaveCVEBypass(context.Context, *metadata.CVEBypass) error {
return nil
}
func (m *stubMetadataStore) GetActiveCVEBypasses(context.Context) ([]*metadata.CVEBypass, error) {
return nil, nil
}
func (m *stubMetadataStore) ListCVEBypasses(context.Context, *metadata.BypassListOptions) ([]*metadata.CVEBypass, error) {
return nil, nil
}
func (m *stubMetadataStore) DeleteCVEBypass(context.Context, string) error { return nil }
func (m *stubMetadataStore) CleanupExpiredBypasses(context.Context) (int, error) {
return 0, nil
}
func (m *stubMetadataStore) GetTimeSeriesStats(context.Context, string, string) (*metadata.TimeSeriesStats, error) {
return nil, nil
}
func (m *stubMetadataStore) AggregateDownloadData(context.Context) error { return nil }
func (m *stubMetadataStore) SaveAPIKey(context.Context, *metadata.APIKey) error {
return metadata.ErrNotImplemented
}
func (m *stubMetadataStore) GetAPIKey(context.Context, string) (*metadata.APIKey, error) {
return nil, metadata.ErrNotImplemented
}
func (m *stubMetadataStore) ListAPIKeys(context.Context) ([]*metadata.APIKey, error) {
return nil, metadata.ErrNotImplemented
}
func (m *stubMetadataStore) DeleteAPIKey(context.Context, string) error {
return metadata.ErrNotImplemented
}
func (m *stubMetadataStore) UpdateAPIKeyLastUsed(context.Context, string, time.Time) error {
return nil
}
func newTestManager(t *testing.T, store metadata.MetadataStore) *Manager {
t.Helper()
// Enabled=true but all built-in scanners disabled — we'll register
// our own stub via RegisterScanner.
cfg := config.SecurityConfig{Enabled: true}
mgr, err := New(cfg, store)
require.NoError(t, err)
return mgr
}
// TestBroadcaster_ScanCompleteSuccess verifies EventScanComplete fires
// after a successful scan with the expected payload shape.
func TestBroadcaster_ScanCompleteSuccess(t *testing.T) {
store := &stubMetadataStore{}
mgr := newTestManager(t, store)
result := &metadata.ScanResult{
ID: "r1",
Scanner: "stub",
ScannedAt: time.Now(),
Status: metadata.ScanStatusClean,
VulnerabilityCount: 0,
Vulnerabilities: []metadata.Vulnerability{},
}
mgr.RegisterScanner(&stubScanner{name: "stub", result: result})
bc := &fakeBroadcaster{}
mgr.SetBroadcaster(bc)
err := mgr.ScanPackage(context.Background(), "npm", "react", "18.2.0", "/tmp/dummy")
require.NoError(t, err)
events := bc.snapshot()
require.Len(t, events, 1)
assert.Equal(t, string(websocket.EventScanComplete), events[0].Type)
payload, ok := events[0].Payload.(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "npm", payload["registry"])
assert.Equal(t, "react", payload["name"])
assert.Equal(t, "18.2.0", payload["version"])
assert.Equal(t, string(metadata.ScanStatusClean), payload["status"])
assert.Equal(t, 0, payload["vulnerability_count"])
}
// TestBroadcaster_ScanCompleteAllScannersFailed verifies that the
// synthetic-error path still fires EventScanComplete with status=error.
func TestBroadcaster_ScanCompleteAllScannersFailed(t *testing.T) {
store := &stubMetadataStore{}
mgr := newTestManager(t, store)
mgr.RegisterScanner(&stubScanner{name: "broken", err: errors.New("scanner exploded")})
bc := &fakeBroadcaster{}
mgr.SetBroadcaster(bc)
err := mgr.ScanPackage(context.Background(), "pypi", "requests", "2.31.0", "/tmp/dummy")
require.NoError(t, err)
events := bc.snapshot()
require.Len(t, events, 1)
assert.Equal(t, string(websocket.EventScanComplete), events[0].Type)
payload := events[0].Payload.(map[string]interface{})
assert.Equal(t, "pypi", payload["registry"])
assert.Equal(t, "requests", payload["name"])
assert.Equal(t, "2.31.0", payload["version"])
assert.Equal(t, string(metadata.ScanStatusError), payload["status"])
}
// TestBroadcaster_NoEmitOnSaveError verifies no event is emitted when
// the metadata store fails to persist the scan result.
func TestBroadcaster_NoEmitOnSaveError(t *testing.T) {
store := &stubMetadataStore{saveErr: errors.New("db down")}
mgr := newTestManager(t, store)
result := &metadata.ScanResult{
ID: "r2",
Scanner: "stub",
ScannedAt: time.Now(),
Status: metadata.ScanStatusClean,
Vulnerabilities: []metadata.Vulnerability{},
}
mgr.RegisterScanner(&stubScanner{name: "stub", result: result})
bc := &fakeBroadcaster{}
mgr.SetBroadcaster(bc)
err := mgr.ScanPackage(context.Background(), "npm", "x", "1", "/tmp/dummy")
require.Error(t, err)
assert.Empty(t, bc.snapshot(), "no event should fire when SaveScanResult fails")
}
// TestBroadcaster_DisabledNoEmit verifies disabled scanner manager
// silently no-ops and emits nothing.
func TestBroadcaster_DisabledNoEmit(t *testing.T) {
store := &stubMetadataStore{}
cfg := config.SecurityConfig{Enabled: false}
mgr, err := New(cfg, store)
require.NoError(t, err)
bc := &fakeBroadcaster{}
mgr.SetBroadcaster(bc)
err = mgr.ScanPackage(context.Background(), "npm", "x", "1", "/tmp/dummy")
require.NoError(t, err)
assert.Empty(t, bc.snapshot())
}
// TestBroadcaster_NilBroadcasterSafe ensures nil broadcaster is safe.
func TestBroadcaster_NilBroadcasterSafe(t *testing.T) {
store := &stubMetadataStore{}
mgr := newTestManager(t, store)
mgr.RegisterScanner(&stubScanner{name: "stub", result: &metadata.ScanResult{
ID: "r", Scanner: "stub", ScannedAt: time.Now(), Status: metadata.ScanStatusClean,
}})
// No SetBroadcaster.
err := mgr.ScanPackage(context.Background(), "npm", "x", "1", "/tmp/dummy")
require.NoError(t, err)
}
+2
View File
@@ -1,3 +1,5 @@
// Package trivy wraps the Aqua Security `trivy` CLI to scan packages for
// known vulnerabilities and license issues.
package trivy
import (