Path: blob/dev/pkg/protocols/common/hosterrorscache/hosterrorscache.go
2072 views
package hosterrorscache12import (3"errors"4"net"5"net/url"6"regexp"7"strings"8"sync"9"sync/atomic"1011"github.com/projectdiscovery/gcache"12"github.com/projectdiscovery/gologger"13"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"14"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"15"github.com/projectdiscovery/nuclei/v3/pkg/types/nucleierr"16"github.com/projectdiscovery/utils/errkit"17stringsutil "github.com/projectdiscovery/utils/strings"18)1920// CacheInterface defines the signature of the hosterrorscache so that21// users of Nuclei as embedded lib may implement their own cache22type CacheInterface interface {23SetVerbose(verbose bool) // log verbosely24Close() // close the cache25Check(protoType string, ctx *contextargs.Context) bool // return true if the host should be skipped26Remove(ctx *contextargs.Context) // remove a host from the cache27MarkFailed(protoType string, ctx *contextargs.Context, err error) // record a failure (and cause) for the host28MarkFailedOrRemove(protoType string, ctx *contextargs.Context, err error) // record a failure (and cause) for the host or remove it29}3031var (32_ CacheInterface = (*Cache)(nil)33)3435// Cache is a cache for host based errors. It allows skipping36// certain hosts based on an error threshold.37//38// It uses an LRU cache internally for skipping unresponsive hosts39// that remain so for a duration.40type Cache struct {41MaxHostError int42verbose bool43failedTargets gcache.Cache[string, *cacheItem]44TrackError []string45}4647type cacheItem struct {48sync.Once49errors atomic.Int3250isPermanentErr bool51cause error // optional cause52mu sync.Mutex53}5455const DefaultMaxHostsCount = 100005657// New returns a new host max errors cache58func New(maxHostError, maxHostsCount int, trackError []string) *Cache {59gc := gcache.New[string, *cacheItem](maxHostsCount).ARC().Build()6061return &Cache{62failedTargets: gc,63MaxHostError: maxHostError,64TrackError: trackError,65}66}6768// SetVerbose sets the cache to log at verbose level69func (c *Cache) SetVerbose(verbose bool) {70c.verbose = verbose71}7273// Close closes the host errors cache74func (c *Cache) Close() {75if config.DefaultConfig.IsDebugArgEnabled(config.DebugArgHostErrorStats) {76items := c.failedTargets.GetALL(false)77for k, val := range items {78gologger.Info().Label("MaxHostErrorStats").Msgf("Host: %s, Errors: %d", k, val.errors.Load())79}80}81c.failedTargets.Purge()82}8384// NormalizeCacheValue processes the input value and returns a normalized cache85// value.86func (c *Cache) NormalizeCacheValue(value string) string {87var normalizedValue = value8889u, err := url.ParseRequestURI(value)90if err != nil || u.Host == "" {91if strings.Contains(value, ":") {92return normalizedValue93}94u, err2 := url.ParseRequestURI("https://" + value)95if err2 != nil {96return normalizedValue97}9899normalizedValue = u.Host100} else {101port := u.Port()102if port == "" {103switch u.Scheme {104case "https":105normalizedValue = net.JoinHostPort(u.Host, "443")106case "http":107normalizedValue = net.JoinHostPort(u.Host, "80")108}109} else {110normalizedValue = u.Host111}112}113114return normalizedValue115}116117// ErrUnresponsiveHost is returned when a host is unresponsive118// var ErrUnresponsiveHost = errors.New("skipping as host is unresponsive")119120// Check returns true if a host should be skipped as it has been121// unresponsive for a certain number of times.122//123// The value can be many formats -124// - URL: https?:// type125// - Host:port type126// - host type127func (c *Cache) Check(protoType string, ctx *contextargs.Context) bool {128finalValue := c.GetKeyFromContext(ctx, nil)129130cache, err := c.failedTargets.GetIFPresent(finalValue)131if err != nil {132return false133}134135cache.mu.Lock()136defer cache.mu.Unlock()137138if cache.isPermanentErr {139// skipping permanent errors is expected so verbose instead of info140gologger.Verbose().Msgf("Skipped %s from target list as found unresponsive permanently: %s", finalValue, cache.cause)141return true142}143144if cache.errors.Load() >= int32(c.MaxHostError) {145cache.Do(func() {146gologger.Info().Msgf("Skipped %s from target list as found unresponsive %d times", finalValue, cache.errors.Load())147})148return true149}150151return false152}153154// Remove removes a host from the cache155func (c *Cache) Remove(ctx *contextargs.Context) {156key := c.GetKeyFromContext(ctx, nil)157_ = c.failedTargets.Remove(key) // remove even the cache is not present158}159160// MarkFailed marks a host as failed previously161//162// Deprecated: Use MarkFailedOrRemove instead.163func (c *Cache) MarkFailed(protoType string, ctx *contextargs.Context, err error) {164if err == nil {165return166}167168c.MarkFailedOrRemove(protoType, ctx, err)169}170171// MarkFailedOrRemove marks a host as failed previously or removes it172func (c *Cache) MarkFailedOrRemove(protoType string, ctx *contextargs.Context, err error) {173if err != nil && !c.checkError(protoType, err) {174return175}176177if err == nil {178// Remove the host from cache179//180// NOTE(dwisiswant0): The decision was made to completely remove the181// cached entry for the host instead of simply decrementing the error182// count (using `(atomic.Int32).Swap` to update the value to `N-1`).183// This approach was chosen because the error handling logic operates184// concurrently, and decrementing the count could lead to UB (unexpected185// behavior) even when the error is `nil`.186//187// To clarify, consider the following scenario where the error188// encountered does NOT belong to the permanent network error category189// (`errkit.ErrKindNetworkPermanent`):190//191// 1. Iteration 1: A timeout error occurs, and the error count for the192// host is incremented.193// 2. Iteration 2: Another timeout error is encountered, leading to194// another increment in the host's error count.195// 3. Iteration 3: A third timeout error happens, which increments the196// error count further. At this point, the host is flagged as197// unresponsive.198// 4. Iteration 4: The host becomes reachable (no error or a transient199// issue resolved). Instead of performing a no-op and leaving the200// host in the cache, the host entry is removed entirely to reset its201// state.202// 5. Iteration 5: A subsequent timeout error occurs after the host was203// removed and re-added to the cache. The error count is reset and204// starts from 1 again.205//206// This removal strategy ensures the cache is updated dynamically to207// reflect the current state of the host without persisting stale or208// irrelevant error counts that could interfere with future error209// handling and tracking logic.210c.Remove(ctx)211212return213}214215cacheKey := c.GetKeyFromContext(ctx, err)216cache, cacheErr := c.failedTargets.GetIFPresent(cacheKey)217if errors.Is(cacheErr, gcache.KeyNotFoundError) {218cache = &cacheItem{errors: atomic.Int32{}}219}220221cache.mu.Lock()222defer cache.mu.Unlock()223224if errkit.IsKind(err, errkit.ErrKindNetworkPermanent) {225cache.isPermanentErr = true226}227228cache.cause = err229cache.errors.Add(1)230231_ = c.failedTargets.Set(cacheKey, cache)232}233234// GetKeyFromContext returns the key for the cache from the context235func (c *Cache) GetKeyFromContext(ctx *contextargs.Context, err error) string {236// Note:237// ideally any changes made to remote addr in template like {{Hostname}}:81 etc238// should be reflected in contextargs but it is not yet reflected in some cases239// and needs refactor of ScanContext + ContextArgs to achieve that240// i.e why we use real address from error if present241var address string242243// 1. the address carried inside the error (if the transport sets it)244if err != nil {245if v := errkit.GetAttrValue(err, "address"); v.Any() != nil {246address = v.String()247}248}249250if address == "" {251address = ctx.MetaInput.Address()252}253254finalValue := c.NormalizeCacheValue(address)255return finalValue256}257258var reCheckError = regexp.MustCompile(`(no address found for host|could not resolve host|connection refused|connection reset by peer|could not connect to any address found for host|timeout awaiting response headers)`)259260// checkError checks if an error represents a type that should be261// added to the host skipping table.262// it first parses error and extracts the cause and checks for blacklisted263// or common errors that should be skipped264func (c *Cache) checkError(protoType string, err error) bool {265if err == nil {266return false267}268if protoType != "http" {269return false270}271kind := errkit.GetErrorKind(err, nucleierr.ErrTemplateLogic)272switch kind {273case nucleierr.ErrTemplateLogic:274// these are errors that are not related to the target275// and are due to template logic276return false277case errkit.ErrKindNetworkTemporary:278// these should not be counted as host errors279return false280case errkit.ErrKindNetworkPermanent:281// these should be counted as host errors282return true283case errkit.ErrKindDeadline:284// these should not be counted as host errors285return false286default:287// parse error for further processing288errX := errkit.FromError(err)289tmp := errX.Cause()290cause := tmp.Error()291if stringsutil.ContainsAll(cause, "ReadStatusLine:", "read: connection reset by peer") {292// this is a FP and should not be counted as a host error293// because server closes connection when it reads corrupted bytes which we send via rawhttp294return false295}296if strings.HasPrefix(cause, "ReadStatusLine:") {297// error is present in last part when using rawhttp298// this will be fixed once errkit is used everywhere299lastIndex := strings.LastIndex(cause, ":")300if lastIndex == -1 {301lastIndex = 0302}303if lastIndex >= len(cause)-1 {304lastIndex = 0305}306cause = cause[lastIndex+1:]307}308for _, msg := range c.TrackError {309if strings.Contains(cause, msg) {310return true311}312}313return reCheckError.MatchString(cause)314}315}316317318