Files
Vyntral 3a4c230aa7 feat: v2.0 full rewrite — event-driven pipeline, AI + Nuclei + proxy
Complete architectural overhaul. Replaces the v0.1 monolithic scanner
with an event-driven pipeline of auto-registered modules.

Foundation (internal/):
- eventbus: typed pub/sub, 20 event types, race-safe, drop counter
- module: registry with phase-based selection
- store: thread-safe host store with per-host locks + deep-copy reads
- pipeline: coordinator with phase barriers + panic recovery
- config: 5 scan profiles + 3 AI tiers + YAML loader + auto-discovery

Modules (26 auto-registered across 6 phases):
- Discovery: passive (26 sources), bruteforce, recursive, AXFR, GitHub
  dorks, CT streaming, permutation, reverse DNS, vhost, ASN, supply
  chain (npm + PyPI)
- Enrichment: HTTP probe + tech fingerprint + TLS appliance ID, ports
- Analysis: security checks, takeover (110+ sigs), cloud, JavaScript,
  GraphQL, JWT, headers (OWASP), HTTP smuggling, AI cascade, Nuclei
- Reporting: TXT/JSON/CSV writer + AI scan brief

AI layer (internal/ai/ + internal/modules/ai/):
- Three profiles: lean (16 GB), balanced (32 GB MoE), heavy (64 GB)
- Six event-driven handlers: CVE, JS file, HTTP response, secret
  filter, multi-agent vuln enrichment, anomaly + executive report
- Content-hash cache dedups Ollama calls across hosts
- Auto-pull of missing models via /api/pull with streaming progress
- End-of-scan AI SCAN BRIEF in terminal with top chains + next actions

Nuclei compat layer (internal/nucleitpl/):
- Executes ~13k community templates (HTTP subset)
- Auto-download of nuclei-templates ZIP to ~/.god-eye/nuclei-templates
- Scope filter rejects off-host templates (eliminates OSINT FPs)

Operations:
- Interactive wizard (internal/wizard/) — zero-flag launch
- LivePrinter (internal/tui/) — colorized event stream
- Diff engine + scheduler (internal/diff, internal/scheduler) for
  continuous ASM monitoring with webhook alerts
- Proxy support (internal/proxyconf/): http / https / socks5 / socks5h
  + basic auth

Fixes #1 — native SOCKS5 / Tor compatibility via --proxy flag.

185 unit tests across 15 packages, all race-detector clean.
2026-04-18 16:48:41 +02:00

193 lines
6.0 KiB
Go

// Package supplychain enumerates npm and PyPI packages that reference the
// target domain in their source, then flags packages as potential supply
// chain assets. Useful for discovering internal-only tools published by
// mistake to public registries and for finding branded utility packages
// that could reveal internal endpoints/secrets.
//
// This is a discovery-oriented check. Actually downloading + scanning
// package contents for secrets is a Fase 2 follow-up; here we just surface
// the packages and the URLs they point at.
package supplychain
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"god-eye/internal/eventbus"
"god-eye/internal/module"
"god-eye/internal/sources"
"god-eye/internal/store"
)
const ModuleName = "vuln.supply-chain"
type scModule struct{}
func Register() { module.Register(&scModule{}) }
func (*scModule) Name() string { return ModuleName }
func (*scModule) Phase() module.Phase { return module.PhaseDiscovery }
func (*scModule) Consumes() []eventbus.EventType { return nil }
func (*scModule) Produces() []eventbus.EventType {
return []eventbus.EventType{eventbus.EventSubdomainDiscovered, eventbus.EventAPIFinding}
}
func (*scModule) DefaultEnabled() bool { return true }
func (*scModule) Run(mctx module.Context) error {
target := mctx.Target
if target == "" {
return nil
}
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); checkNPM(mctx, target) }()
go func() { defer wg.Done(); checkPyPI(mctx, target) }()
wg.Wait()
return nil
}
// checkNPM uses npm's registry search API. Packages matching "<target>"
// or "<target-suffix>" are surfaced.
func checkNPM(mctx module.Context, target string) {
q := extractBrand(target)
if q == "" {
return
}
url := fmt.Sprintf("https://registry.npmjs.org/-/v1/search?text=%s&size=100", q)
body, err := fetchJSON(mctx.Ctx, url, 15*time.Second)
if err != nil {
return
}
var parsed struct {
Objects []struct {
Package struct {
Name string `json:"name"`
Links map[string]string `json:"links"`
Description string `json:"description"`
} `json:"package"`
} `json:"objects"`
}
_ = json.Unmarshal(body, &parsed)
for _, obj := range parsed.Objects {
pkg := obj.Package
text := pkg.Name + " " + pkg.Description
for _, link := range pkg.Links {
text += " " + link
}
if !strings.Contains(strings.ToLower(text), target) {
continue
}
// Emit an APIFinding for discovery context.
mctx.Bus.Publish(mctx.Ctx, eventbus.APIFinding{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: target},
Kind: "supply-chain:npm",
URL: "https://www.npmjs.com/package/" + pkg.Name,
Issue: "npm package references target: " + pkg.Name + " — " + pkg.Description,
Severity: eventbus.SeverityInfo,
})
// If the description or links contain subdomains of the target,
// also feed them into discovery.
for _, sub := range sources.ExtractSubdomains(text, target) {
_ = mctx.Store.Upsert(mctx.Ctx, sub, func(h *store.Host) {
store.AddDiscoveryMethod(h, "supply-chain:npm:"+pkg.Name)
})
mctx.Bus.Publish(mctx.Ctx, eventbus.SubdomainDiscovered{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: sub},
Subdomain: sub,
Method: "supply-chain:npm:" + pkg.Name,
})
}
}
}
func checkPyPI(mctx module.Context, target string) {
// PyPI no longer supports XML-RPC search; use the simple index
// (all packages) scanning is too expensive. Instead query a few
// likely branded package prefixes via the JSON index.
q := extractBrand(target)
if q == "" {
return
}
// Try exact-name lookups for common variants.
candidates := []string{q, q + "-cli", q + "-sdk", q + "-api", q + "-client"}
for _, name := range candidates {
url := "https://pypi.org/pypi/" + name + "/json"
body, err := fetchJSON(mctx.Ctx, url, 10*time.Second)
if err != nil || len(body) < 50 {
continue
}
var parsed struct {
Info struct {
Name string `json:"name"`
Summary string `json:"summary"`
HomePage string `json:"home_page"`
ProjectURL string `json:"project_url"`
ProjectURLs map[string]string `json:"project_urls"`
} `json:"info"`
}
_ = json.Unmarshal(body, &parsed)
info := parsed.Info
if info.Name == "" {
continue
}
text := info.Name + " " + info.Summary + " " + info.HomePage + " " + info.ProjectURL
for _, u := range info.ProjectURLs {
text += " " + u
}
if !strings.Contains(strings.ToLower(text), target) {
continue
}
mctx.Bus.Publish(mctx.Ctx, eventbus.APIFinding{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: target},
Kind: "supply-chain:pypi",
URL: "https://pypi.org/project/" + info.Name + "/",
Issue: "PyPI package references target: " + info.Name + " — " + info.Summary,
Severity: eventbus.SeverityInfo,
})
for _, sub := range sources.ExtractSubdomains(text, target) {
_ = mctx.Store.Upsert(mctx.Ctx, sub, func(h *store.Host) {
store.AddDiscoveryMethod(h, "supply-chain:pypi:"+info.Name)
})
mctx.Bus.Publish(mctx.Ctx, eventbus.SubdomainDiscovered{
EventMeta: eventbus.EventMeta{At: time.Now(), Source: ModuleName, Target: sub},
Subdomain: sub,
Method: "supply-chain:pypi:" + info.Name,
})
}
}
}
// extractBrand returns the "brand" (second-to-last label) from example.com →
// "example". Used as the package-search query term.
func extractBrand(domain string) string {
labels := strings.Split(strings.TrimSuffix(domain, "."), ".")
if len(labels) < 2 {
return ""
}
return strings.ToLower(labels[len(labels)-2])
}
func fetchJSON(ctx context.Context, url string, timeout time.Duration) ([]byte, error) {
c := &http.Client{Timeout: timeout}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "god-eye-v2")
resp, err := c.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
}