mirror of
https://github.com/BishopFox/snowpick.git
synced 2026-11-07 10:26:01 -10:00
603 lines
18 KiB
Go
603 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/tls"
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"golang.org/x/net/proxy"
|
|
)
|
|
|
|
// ScanConfig holds all per-scan options passed through the scan pipeline.
|
|
type ScanConfig struct {
|
|
Client *http.Client
|
|
Widgets []Widget
|
|
TableCandidates []TableCandidate
|
|
SampleCount int
|
|
Concurrency int
|
|
TableConcurrency int
|
|
EnumerateTables bool
|
|
DiscoverWidgetsLimit int
|
|
Fingerprint bool
|
|
Discover bool
|
|
DiscoverLimit int
|
|
TableAPI bool
|
|
}
|
|
|
|
// version is set at build time via -ldflags "-X main.version=v0.2.0".
|
|
var version = "dev"
|
|
|
|
// logf writes informational output to stderr. Replaced with a no-op when -quiet is set.
|
|
var logf = func(format string, a ...any) { fmt.Fprintf(os.Stderr, format, a...) }
|
|
|
|
func main() {
|
|
var (
|
|
target = flag.String("target", "", "single ServiceNow hostname")
|
|
targetsFile = flag.String("targets", "", "file with one hostname per line")
|
|
proxyAddr = flag.String("proxy", "", "optional proxy URL (http://, https://, socks5://, or socks5h://)")
|
|
widgetCSV = flag.String("widgets", "", "comma-separated widget IDs (overrides defaults)")
|
|
widgetsFile = flag.String("widgets-file", "", "JSON file with widget candidates")
|
|
tablesFile = flag.String("tables-file", "", "JSON file with table/field candidates")
|
|
output = flag.String("output", "", "JSON output file (default: stdout)")
|
|
samples = flag.Int("samples", 10, "sample records per exposed finding")
|
|
timeout = flag.Duration("timeout", 15*time.Second, "HTTP timeout per request")
|
|
concurrency = flag.Int("concurrency", 3, "max parallel targets")
|
|
tableConc = flag.Int("table-concurrency", 5, "max parallel table probes per target")
|
|
insecure = flag.Bool("insecure", false, "skip TLS certificate verification")
|
|
tables = flag.Bool("tables", true, "enumerate table/field exposure through widget-simple-list")
|
|
showVersion = flag.Bool("version", false, "print version and exit")
|
|
quiet = flag.Bool("quiet", false, "suppress informational output (errors still print)")
|
|
rate = flag.Duration("rate", 0, "minimum interval between HTTP requests (e.g. 200ms)")
|
|
discoverWidgetsLim = flag.Int("discover-widgets-limit", 50, "max widgets to probe from dynamic discovery")
|
|
doFingerprint = flag.Bool("fingerprint", false, "detect ServiceNow version and release from response signals")
|
|
discover = flag.Bool("discover", false, "discover tables via sys_db_object and enumerate dynamically")
|
|
discoverLimit = flag.Int("discover-limit", 100, "max tables to probe from dynamic discovery")
|
|
tableAPI = flag.Bool("table-api", false, "probe /api/now/table/{table} REST API for data exposure")
|
|
vendor = flag.String("vendor", "", "X-Vendor header value for engagement tracking")
|
|
)
|
|
|
|
flag.Usage = func() {
|
|
fmt.Fprintf(os.Stderr, `snowpick - ServiceNow exposure scanner
|
|
|
|
Usage:
|
|
snowpick -target HOST [options]
|
|
snowpick -targets FILE [options]
|
|
|
|
Targets:
|
|
-target HOST single ServiceNow hostname
|
|
-targets FILE file with one hostname per line
|
|
|
|
Scan options:
|
|
-tables enumerate table/field exposure via widget-simple-list (default: true)
|
|
-table-api probe /api/now/table/ REST API for data exposure
|
|
-fingerprint detect ServiceNow version and release
|
|
-discover discover tables via sys_db_object and enumerate dynamically
|
|
-samples N sample records per exposed finding (default: 10)
|
|
|
|
Discovery limits:
|
|
-discover-limit N max tables from dynamic discovery (default: 100)
|
|
-discover-widgets-limit N
|
|
max widgets from dynamic discovery (default: 50)
|
|
|
|
Custom inputs:
|
|
-widgets LIST comma-separated widget IDs (overrides defaults)
|
|
-widgets-file FILE JSON file with widget candidates
|
|
-tables-file FILE JSON file with table/field candidates
|
|
|
|
Output:
|
|
-output FILE JSON output file (default: stdout)
|
|
-quiet suppress informational output (errors still print)
|
|
|
|
Network:
|
|
-proxy URL proxy URL (http, https, socks5, socks5h)
|
|
-timeout DURATION HTTP timeout per request (default: 15s)
|
|
-rate DURATION minimum interval between requests (e.g. 200ms)
|
|
-concurrency N max parallel targets (default: 3)
|
|
-table-concurrency N max parallel table probes per target (default: 5)
|
|
-insecure skip TLS certificate verification
|
|
|
|
Misc:
|
|
-vendor STRING X-Vendor header for engagement tracking
|
|
-version print version and exit
|
|
|
|
Examples:
|
|
snowpick -target acme.service-now.com
|
|
snowpick -targets hosts.txt -proxy socks5://127.0.0.1:1080 -insecure
|
|
snowpick -target acme.service-now.com -fingerprint -table-api
|
|
snowpick -target acme.service-now.com -output results.json -quiet
|
|
`)
|
|
}
|
|
|
|
flag.Parse()
|
|
|
|
if *showVersion {
|
|
fmt.Println("snowpick " + version)
|
|
os.Exit(0)
|
|
}
|
|
|
|
if *quiet {
|
|
logf = func(string, ...any) {}
|
|
}
|
|
|
|
initColors()
|
|
|
|
if *vendor != "" {
|
|
vendorHeader = *vendor
|
|
}
|
|
|
|
if *concurrency < 1 {
|
|
fmt.Fprintln(os.Stderr, "error: -concurrency must be at least 1")
|
|
os.Exit(1)
|
|
}
|
|
if *tableConc < 1 {
|
|
fmt.Fprintln(os.Stderr, "error: -table-concurrency must be at least 1")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Resolve targets.
|
|
targets := resolveTargets(*target, *targetsFile)
|
|
if len(targets) == 0 {
|
|
fmt.Fprintln(os.Stderr, "snowpick - ServiceNow exposure scanner\n\nerror: no targets specified\n\nRun snowpick -h for usage.")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Resolve widgets.
|
|
widgets := DefaultWidgets
|
|
if *widgetCSV != "" {
|
|
widgets = nil
|
|
for _, id := range strings.Split(*widgetCSV, ",") {
|
|
id = strings.TrimSpace(id)
|
|
if id != "" {
|
|
widgets = append(widgets, Widget{ID: id, Name: abbreviate(id, 8)})
|
|
}
|
|
}
|
|
}
|
|
if *widgetsFile != "" {
|
|
fileWidgets, err := LoadWidgetsFile(*widgetsFile)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: loading widgets file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
widgets = MergeWidgets(widgets, fileWidgets)
|
|
} else {
|
|
widgets = MergeWidgets(widgets, nil)
|
|
}
|
|
|
|
tableCandidates := MergeTableCandidates(DefaultTableCandidates, nil)
|
|
if *tablesFile != "" {
|
|
fileTables, err := LoadTablesFile(*tablesFile)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: loading tables file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
tableCandidates = MergeTableCandidates(tableCandidates, fileTables)
|
|
}
|
|
|
|
client, err := buildClient(*proxyAddr, *timeout, *insecure, *rate)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: building HTTP client: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
proxyLabel := "direct"
|
|
if *proxyAddr != "" {
|
|
proxyLabel = *proxyAddr
|
|
}
|
|
cfg := &ScanConfig{
|
|
Client: client,
|
|
Widgets: widgets,
|
|
TableCandidates: tableCandidates,
|
|
SampleCount: *samples,
|
|
Concurrency: *concurrency,
|
|
TableConcurrency: *tableConc,
|
|
EnumerateTables: *tables,
|
|
DiscoverWidgetsLimit: *discoverWidgetsLim,
|
|
Fingerprint: *doFingerprint,
|
|
Discover: *discover,
|
|
DiscoverLimit: *discoverLimit,
|
|
TableAPI: *tableAPI,
|
|
}
|
|
|
|
logf("%s %s%s | %d targets | %d widgets | proxy %s\n",
|
|
bold("snowpick"), dim(version), colorReset, len(targets), len(cfg.Widgets), proxyLabel)
|
|
|
|
// Wire context with signal handling for clean cancellation.
|
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
defer cancel()
|
|
|
|
scanStart := time.Now()
|
|
|
|
// Scan targets with bounded concurrency.
|
|
results := scanTargets(ctx, targets, cfg)
|
|
|
|
elapsed := time.Since(scanStart).Round(time.Millisecond)
|
|
|
|
// Build and output report.
|
|
report := BuildReport(results, proxyLabel, len(widgets))
|
|
report.ElapsedMS = elapsed.Milliseconds()
|
|
PrintSummary(report, elapsed)
|
|
|
|
if err := WriteJSON(report, *output); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: writing output: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if report.HasFindings() {
|
|
os.Exit(2)
|
|
}
|
|
if report.HasScanErrors() {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func resolveTargets(single, file string) []string {
|
|
var targets []string
|
|
|
|
if single != "" {
|
|
targets = append(targets, normalizeTarget(single))
|
|
}
|
|
|
|
if file != "" {
|
|
f, err := os.Open(file)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: opening targets file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer f.Close()
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line != "" && !strings.HasPrefix(line, "#") {
|
|
targets = append(targets, normalizeTarget(line))
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: reading targets file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
return targets
|
|
}
|
|
|
|
func normalizeTarget(t string) string {
|
|
t = strings.TrimSpace(t)
|
|
t = strings.TrimPrefix(t, "https://")
|
|
t = strings.TrimPrefix(t, "http://")
|
|
t = strings.TrimRight(t, "/")
|
|
return t
|
|
}
|
|
|
|
func buildClient(proxyAddr string, timeout time.Duration, insecure bool, rate time.Duration) (*http.Client, error) {
|
|
transport := &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
|
|
}
|
|
|
|
if proxyAddr != "" {
|
|
proxyURL, err := url.Parse(proxyAddr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing proxy URL: %w", err)
|
|
}
|
|
|
|
switch proxyURL.Scheme {
|
|
case "http", "https":
|
|
transport.Proxy = http.ProxyURL(proxyURL)
|
|
case "socks5", "socks5h":
|
|
dialer, err := buildSocksDialer(proxyURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
transport.DialContext = dialer.DialContext
|
|
default:
|
|
return nil, fmt.Errorf("unsupported proxy scheme %q", proxyURL.Scheme)
|
|
}
|
|
}
|
|
|
|
var rt http.RoundTripper = transport
|
|
if rate > 0 {
|
|
rt = &rateLimitedTransport{base: transport, interval: rate}
|
|
}
|
|
|
|
return &http.Client{
|
|
Transport: rt,
|
|
Timeout: timeout,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// rateLimitedTransport enforces a minimum interval between HTTP requests.
|
|
type rateLimitedTransport struct {
|
|
base http.RoundTripper
|
|
interval time.Duration
|
|
mu sync.Mutex
|
|
last time.Time
|
|
}
|
|
|
|
func (t *rateLimitedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
t.mu.Lock()
|
|
if elapsed := time.Since(t.last); elapsed < t.interval {
|
|
time.Sleep(t.interval - elapsed)
|
|
}
|
|
t.last = time.Now()
|
|
t.mu.Unlock()
|
|
return t.base.RoundTrip(req)
|
|
}
|
|
|
|
func buildSocksDialer(proxyURL *url.URL) (proxy.ContextDialer, error) {
|
|
auth := (*proxy.Auth)(nil)
|
|
if proxyURL.User != nil {
|
|
auth = &proxy.Auth{User: proxyURL.User.Username()}
|
|
if password, ok := proxyURL.User.Password(); ok {
|
|
auth.Password = password
|
|
}
|
|
}
|
|
|
|
dialer, err := proxy.SOCKS5("tcp", proxyURL.Host, auth, proxy.Direct)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating SOCKS5 dialer: %w", err)
|
|
}
|
|
|
|
contextDialer, ok := dialer.(proxy.ContextDialer)
|
|
if !ok {
|
|
return nil, fmt.Errorf("SOCKS5 dialer does not support context")
|
|
}
|
|
|
|
return contextDialer, nil
|
|
}
|
|
|
|
// checkLiveness does an HTTP GET through the proxy to verify the target is live.
|
|
func checkLiveness(ctx context.Context, target string, client *http.Client) (int, error) {
|
|
return checkEndpoint(ctx, target, "/", client)
|
|
}
|
|
|
|
// checkEndpoint does an HTTP GET to a specific path on the target.
|
|
func checkEndpoint(ctx context.Context, target, path string, client *http.Client) (int, error) {
|
|
u := fmt.Sprintf("https://%s%s", target, path)
|
|
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
setHeaders(req)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
resp.Body.Close()
|
|
return resp.StatusCode, nil
|
|
}
|
|
|
|
// progress tracks scan completion for the progress line.
|
|
type progress struct {
|
|
total int
|
|
completed atomic.Int32
|
|
findings atomic.Int32
|
|
}
|
|
|
|
func (p *progress) done(hadFindings bool) {
|
|
p.completed.Add(1)
|
|
if hadFindings {
|
|
p.findings.Add(1)
|
|
}
|
|
}
|
|
|
|
func (p *progress) line() string {
|
|
c := p.completed.Load()
|
|
f := p.findings.Load()
|
|
if f > 0 {
|
|
return fmt.Sprintf("[%s] %d/%d targets complete, %s with findings",
|
|
dim("*"), c, p.total, red(fmt.Sprintf("%d", f)))
|
|
}
|
|
return fmt.Sprintf("[%s] %d/%d targets complete",
|
|
dim("*"), c, p.total)
|
|
}
|
|
|
|
func scanTargets(ctx context.Context, targets []string, cfg *ScanConfig) []TargetResult {
|
|
results := make([]TargetResult, len(targets))
|
|
sem := make(chan struct{}, cfg.Concurrency)
|
|
var wg sync.WaitGroup
|
|
prog := &progress{total: len(targets)}
|
|
|
|
for i, t := range targets {
|
|
wg.Add(1)
|
|
go func(idx int, target string) {
|
|
defer wg.Done()
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
results[idx] = scanTarget(ctx, target, cfg, prog)
|
|
}(i, t)
|
|
}
|
|
wg.Wait()
|
|
return results
|
|
}
|
|
|
|
func scanTarget(ctx context.Context, target string, cfg *ScanConfig, prog *progress) TargetResult {
|
|
result := TargetResult{Target: target}
|
|
client := cfg.Client
|
|
widgets := cfg.Widgets
|
|
|
|
// Helper: collect lines to emit atomically after target completes.
|
|
var lines []string
|
|
emit := func(format string, a ...any) {
|
|
lines = append(lines, fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
// HTTP liveness check through proxy (not a direct TCP dial).
|
|
liveStatus, err := checkLiveness(ctx, target, client)
|
|
if err != nil {
|
|
result.Error = fmt.Sprintf("unreachable: %v", err)
|
|
emit("[%s] %s %s", red("-"), bold(target), dim(result.Error))
|
|
prog.done(false)
|
|
flushLines(lines)
|
|
return result
|
|
}
|
|
result.Reachable = true
|
|
_ = liveStatus
|
|
|
|
// Check stats.do accessibility (bonus signal, single request).
|
|
if statsCode, err := checkEndpoint(ctx, target, "/stats.do", client); err == nil && statsCode == 200 {
|
|
result.StatsDo = true
|
|
}
|
|
|
|
// Bootstrap session.
|
|
session, err := Bootstrap(ctx, target, client)
|
|
if err != nil {
|
|
result.Error = fmt.Sprintf("bootstrap failed: %v", err)
|
|
emit("[%s] %s %s", red("-"), bold(target), dim(result.Error))
|
|
prog.done(false)
|
|
flushLines(lines)
|
|
return result
|
|
}
|
|
result.TokenObtained = true
|
|
|
|
// Version fingerprinting.
|
|
if cfg.Fingerprint {
|
|
result.Fingerprint = FingerprintInstance(ctx, session, client, result.StatsDo)
|
|
}
|
|
|
|
// Dynamic widget discovery via sp_widget table.
|
|
if cfg.DiscoverWidgetsLimit > 0 {
|
|
discovered, _ := DiscoverWidgets(ctx, session, cfg.DiscoverWidgetsLimit, client)
|
|
if len(discovered) > 0 {
|
|
widgets = MergeWidgets(widgets, discovered)
|
|
}
|
|
}
|
|
|
|
// Probe each widget -- only collect results, no per-widget output.
|
|
for _, w := range widgets {
|
|
wr := ProbeWidget(ctx, session, w, client)
|
|
|
|
if wr.Status == StatusExposed {
|
|
samples, err := CollectSamples(ctx, session, w.ID, cfg.SampleCount, client)
|
|
if err == nil {
|
|
wr.Samples = samples
|
|
wr.SampleCount = len(samples)
|
|
wr.Hints = ClassifyWidget(w.Name, samples)
|
|
if len(wr.Hints) == 0 {
|
|
wr.Hints = ReviewRequiredHint(wr.RecordCount)
|
|
}
|
|
}
|
|
}
|
|
if wr.Status == StatusCountOracle && len(wr.Hints) == 0 {
|
|
wr.Hints = ReviewRequiredHint(wr.RecordCount)
|
|
}
|
|
|
|
result.Widgets = append(result.Widgets, *wr)
|
|
}
|
|
|
|
// Dynamic schema discovery via sys_db_object.
|
|
tableCandidates := cfg.TableCandidates
|
|
if cfg.Discover && cfg.EnumerateTables {
|
|
discoveredTables, _ := DiscoverTables(ctx, session, cfg.DiscoverLimit, client)
|
|
if len(discoveredTables) > 0 {
|
|
tableCandidates = MergeTableCandidates(tableCandidates, discoveredTables)
|
|
}
|
|
}
|
|
|
|
if cfg.EnumerateTables {
|
|
result.TableFindings = EnumerateTables(ctx, session, tableCandidates, cfg.SampleCount, cfg.TableConcurrency, client)
|
|
for i, table := range result.TableFindings {
|
|
result.TableFindings[i].Hints = ClassifyTable(table.Table, table.Field, table.Samples)
|
|
if len(result.TableFindings[i].Hints) == 0 && (table.Status == StatusExposed || table.Status == StatusCountOracle) {
|
|
result.TableFindings[i].Hints = ReviewRequiredHint(table.Count)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Table REST API probing.
|
|
if cfg.TableAPI {
|
|
result.TableAPIFindings = EnumerateTableAPI(ctx, session, DefaultTableAPITables, cfg.SampleCount, cfg.TableConcurrency, client)
|
|
}
|
|
|
|
// Build the atomic output block for this target.
|
|
hasFindings := targetHasFindings(&result)
|
|
prog.done(hasFindings)
|
|
|
|
if hasFindings {
|
|
// Emit a finding block: target header + each finding on one line.
|
|
versionTag := ""
|
|
if cfg.Fingerprint && result.Fingerprint != nil && result.Fingerprint.Version != "" {
|
|
versionTag = " " + dim("["+result.Fingerprint.Version+"]")
|
|
}
|
|
statsTag := ""
|
|
if result.StatsDo {
|
|
statsTag = " " + yellow("[stats.do]")
|
|
}
|
|
emit("[%s] %s%s%s", red("!"), bold(target), versionTag, statsTag)
|
|
|
|
for _, w := range result.Widgets {
|
|
switch w.Status {
|
|
case StatusExposed, StatusCountOracle:
|
|
emit(" %s %s %s",
|
|
cyan("|"), w.Name, findingCountSummary(w.Status, w.RecordCount, w.SampleCount, w.Hints))
|
|
}
|
|
}
|
|
for _, t := range result.TableFindings {
|
|
if t.Status != StatusExposed && t.Status != StatusCountOracle {
|
|
continue
|
|
}
|
|
emit(" %s %s.%s %s",
|
|
cyan("|"), t.Table, t.Field, findingCountSummary(t.Status, t.Count, t.SampleCount, t.Hints))
|
|
}
|
|
for _, a := range result.TableAPIFindings {
|
|
if a.Status != StatusExposed && a.Status != StatusCountOracle {
|
|
continue
|
|
}
|
|
emit(" %s %s %s %s",
|
|
cyan("|"), yellow("[API]"), a.Table, findingCountSummary(a.Status, a.RecordCount, a.SampleCount, a.Hints))
|
|
}
|
|
} else {
|
|
// Clean target: single dim line.
|
|
reason := green("clean")
|
|
if result.Error != "" {
|
|
reason = dim(result.Error)
|
|
}
|
|
emit("[%s] %s %s", dim("+"), target, reason)
|
|
}
|
|
|
|
// Progress counter on last line.
|
|
emit(prog.line())
|
|
|
|
flushLines(lines)
|
|
return result
|
|
}
|
|
|
|
// flushLines writes buffered output lines to stderr atomically.
|
|
// Uses a single Write call to minimize interleaving with other goroutines.
|
|
var flushMu sync.Mutex
|
|
|
|
func flushLines(lines []string) {
|
|
if len(lines) == 0 {
|
|
return
|
|
}
|
|
block := strings.Join(lines, "\n") + "\n"
|
|
flushMu.Lock()
|
|
logf("%s", block)
|
|
flushMu.Unlock()
|
|
}
|
|
|
|
// targetHasFindings returns true if a target has any exposed widgets, oracles, or table findings.
|
|
func targetHasFindings(r *TargetResult) bool {
|
|
if len(r.TableFindings) > 0 || len(r.TableAPIFindings) > 0 {
|
|
return true
|
|
}
|
|
for _, w := range r.Widgets {
|
|
if w.Status == StatusExposed || w.Status == StatusCountOracle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|