Files
2026-07-13 19:38:35 -05:00

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
}