From 712abbc0e914c2557dd995096a7a8c806100de23 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 24 Jun 2026 11:30:37 -0700 Subject: [PATCH 1/4] feat: integrate utils/cert atomic primitives, remove local replace Use published utils/cert and utils/mitmproxy instead of local replace directives. utils/cert provides atomic certificate generation primitives (NewTemplate, SelfSign/SignWith, Fingerprint, TLS config builder, etc.) that mitmproxy now uses for CA creation and DummyCert key-reuse. Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 6 ++---- go.sum | 4 ++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index aa0be0f..a2320f5 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/chainreactors/sdk v0.3.4-0.20260624031614-b16da9a87441 github.com/chainreactors/spray v1.3.2-0.20260624034433-890630649b2b github.com/chainreactors/utils v0.0.0-20260623065725-737b33d61c6b - github.com/chainreactors/utils/mitmproxy v0.0.0 + github.com/chainreactors/utils/mitmproxy v0.0.0-20260624182357-8d5cad72d8f2 github.com/chainreactors/utils/pty v0.0.0-20260624031611-9aadeae3fb0e github.com/chainreactors/zombie v1.2.3-0.20260624041317-6bf4579de29d github.com/charmbracelet/bubbles v1.0.0 @@ -88,7 +88,7 @@ require ( github.com/censys/censys-sdk-go v0.19.1 // indirect github.com/chainreactors/files v0.0.0-20240716182835-7884ee1e77f0 // indirect github.com/chainreactors/neutron/operators/full v0.0.0-20260615055126-a9bbe4fc3e95 // indirect - github.com/chainreactors/utils/cert v0.0.0 // indirect + github.com/chainreactors/utils/cert v0.0.0-20260624181253-2b3d0b35862f // indirect github.com/chainreactors/words v0.0.0-20260520145736-270600e60fb4 // indirect github.com/charlievieth/fastwalk v1.0.14 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect @@ -308,8 +308,6 @@ require ( ) replace ( - github.com/chainreactors/utils/cert => ../utils/cert - github.com/chainreactors/utils/mitmproxy => ../utils/mitmproxy github.com/reeflective/console => github.com/chainreactors/malice-network/external/console v0.0.0-20260620124902-e62d93c864f7 github.com/reeflective/readline => github.com/chainreactors/malice-network/external/readline v0.0.0-20260620124902-e62d93c864f7 ) diff --git a/go.sum b/go.sum index 81afaa9..1cabf82 100644 --- a/go.sum +++ b/go.sum @@ -206,6 +206,10 @@ github.com/chainreactors/spray v1.3.2-0.20260624034433-890630649b2b/go.mod h1:uh github.com/chainreactors/utils v0.0.0-20240716182459-e85f2b01ee16/go.mod h1:LajXuvESQwP+qCMAvlcoSXppQCjuLlBrnQpu9XQ1HtU= github.com/chainreactors/utils v0.0.0-20260623065725-737b33d61c6b h1:JWciIo6GXn+QqejE8ER0oo/IxqaAAlSaIqsHhZtnoMM= github.com/chainreactors/utils v0.0.0-20260623065725-737b33d61c6b/go.mod h1:LajXuvESQwP+qCMAvlcoSXppQCjuLlBrnQpu9XQ1HtU= +github.com/chainreactors/utils/cert v0.0.0-20260624181253-2b3d0b35862f h1:7KonUAvkZhpmK8RDwDBCuey0qXeoqxJ7uWjoXqzL2z4= +github.com/chainreactors/utils/cert v0.0.0-20260624181253-2b3d0b35862f/go.mod h1:xvvWMcU9Fcht6GR1cc9ceAZ3/Hl2HrkoRzpeyOzx1rQ= +github.com/chainreactors/utils/mitmproxy v0.0.0-20260624182357-8d5cad72d8f2 h1:9sZzzfeYX5gD64TEpCykenqY+fhej6g2s/lCzMDNNIg= +github.com/chainreactors/utils/mitmproxy v0.0.0-20260624182357-8d5cad72d8f2/go.mod h1:2O3/Vw66VnbzhwsHGFJ2Ge98RuSh6XzMMFGZmMmlZ9M= github.com/chainreactors/utils/pty v0.0.0-20260624031611-9aadeae3fb0e h1:KzDf08J9nGhTjyTz1pMR7aMnkbTEeagZllFcZnZuthM= github.com/chainreactors/utils/pty v0.0.0-20260624031611-9aadeae3fb0e/go.mod h1:RW1v+8hFMeO9+TJyQ1iIx9Ea37s+B7BaDf9fyJ0OEC4= github.com/chainreactors/words v0.0.0-20260520145736-270600e60fb4 h1:lvnDYEkatmZFHP5i321qQXK9L4vKRfso/uUfr5tOeC8= From c35e54c6c73ec4afe2747bc0f9a2bcad9ee30725 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Thu, 25 Jun 2026 06:47:13 -0700 Subject: [PATCH 2/4] fix: address code quality issues from post-v0.2.6 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 — panic/crash fixes: - proton: default to "." when -e used without -i, prevent inputs[0] panic - proton: return ctx.Err() in WalkDir to prevent goroutine blocking on jobCh - proton: log WalkDir errors instead of discarding - proton: fix truncate to cut on rune boundaries, not bytes - mitm: remove auto-Clear() in start() that silently destroyed prior captures - mitm: matchStatus returns false for unrecognized patterns instead of true - mitm: reset seq counter in FlowStore.Clear() - mitm: remove dead configureUpstream function and unused imports - build.sh: fix tavily_keys reading from wrong YAML section (websearch→search) - config_test: update stale error message referencing old config.yaml filename - util: handle negative numbers in FormatNumber to prevent infinite recursion Round 2 — code quality improvements: - agent: track ModeIndependent fire goroutines with WaitGroup, wait on Stop/Remove - proton: replace seen map with sync.Map for structural thread safety - agent: allow MaxRetries=0 to mean no retries (change sentinel from ==0 to <0) - evaluator: replace time.Sleep with ctx-aware select in retry loop - loop_tool: support quoted cron expressions in tryCronPrefix - context_window: add gpt-4-1 (128K) and gpt-4-0 (8K) to fix prefix mismatches - tui: replace sync.Once markdown renderer with width-aware cache for resize - web: decouple confirmed status from critical priority in itemToPriority Co-Authored-By: Claude Opus 4.6 (1M context) --- build.sh | 2 +- core/config/config_test.go | 2 +- pkg/agent/context_window.go | 2 ++ pkg/agent/evaluator/evaluator.go | 6 ++++- pkg/agent/loop_scheduler.go | 11 ++++++++- pkg/agent/loop_tool.go | 5 ++++ pkg/agent/types.go | 2 +- pkg/tools/proton/command.go | 24 ++++++++++++------- pkg/tools/proxy/mitm.go | 23 ++---------------- pkg/tui/format.go | 37 ++++++++++++++++------------- pkg/util/format.go | 3 +++ web/frontend/src/lib/scan-result.ts | 2 +- 12 files changed, 67 insertions(+), 52 deletions(-) diff --git a/build.sh b/build.sh index 44ae8b7..ac6e5b6 100755 --- a/build.sh +++ b/build.sh @@ -177,7 +177,7 @@ CFG_IOA_SPACE=$(resolve "$OPT_IOA_SPACE" "$(yaml_val "$CONFIG_FILE" ioa space)") CFG_VERIFY=$(resolve "$OPT_VERIFY" "$(yaml_val "$CONFIG_FILE" scan verify)") CFG_VERIFY_TIMEOUT=$(resolve "$OPT_VERIFY_TIMEOUT" "$(yaml_val "$CONFIG_FILE" scan verify_timeout)") -CFG_TAVILY_KEYS=$(resolve "$OPT_TAVILY_KEYS" "$(yaml_val "$CONFIG_FILE" websearch tavily_keys)") +CFG_TAVILY_KEYS=$(resolve "$OPT_TAVILY_KEYS" "$(yaml_val "$CONFIG_FILE" search tavily_keys)") # build 段仅从 aiscan.yaml 读取(不做 CLI 覆盖) if [ -z "$OSARCH" ]; then diff --git a/core/config/config_test.go b/core/config/config_test.go index 1448542..59459e3 100644 --- a/core/config/config_test.go +++ b/core/config/config_test.go @@ -344,7 +344,7 @@ llm: } if path == "" { - t.Fatal("expected config.yaml to be found") + t.Fatal("expected aiscan.yaml to be found") } if option.Provider != "found-provider" { t.Errorf("Provider: got %q, want %q", option.Provider, "found-provider") diff --git a/pkg/agent/context_window.go b/pkg/agent/context_window.go index c6de381..c2fac92 100644 --- a/pkg/agent/context_window.go +++ b/pkg/agent/context_window.go @@ -34,6 +34,8 @@ var modelContextWindows = []struct { {"gpt-4.1", 1047576}, {"gpt-4o", 128000}, {"gpt-4-turbo", 128000}, + {"gpt-4-1", 128000}, + {"gpt-4-0", 8192}, {"gpt-4", 8192}, {"o4-mini", 200000}, {"o3", 200000}, diff --git a/pkg/agent/evaluator/evaluator.go b/pkg/agent/evaluator/evaluator.go index d12cdc7..b7af74b 100644 --- a/pkg/agent/evaluator/evaluator.go +++ b/pkg/agent/evaluator/evaluator.go @@ -65,7 +65,11 @@ func (e *Evaluator) Evaluate(ctx context.Context, goal, criteria string, message lastErr = err e.cfg.Logger.Warnf("evaluate attempt %d failed: %s", attempt+1, err) if attempt < e.cfg.MaxRetries-1 { - time.Sleep(time.Duration(attempt+1) * time.Second) + select { + case <-time.After(time.Duration(attempt+1) * time.Second): + case <-ctx.Done(): + return nil, ctx.Err() + } } } return nil, fmt.Errorf("evaluate failed after %d attempts: %w", e.cfg.MaxRetries, lastErr) diff --git a/pkg/agent/loop_scheduler.go b/pkg/agent/loop_scheduler.go index f2def9b..baff77a 100644 --- a/pkg/agent/loop_scheduler.go +++ b/pkg/agent/loop_scheduler.go @@ -70,6 +70,7 @@ type LoopScheduler struct { type loopState struct { entry LoopEntry cancel context.CancelFunc + wg sync.WaitGroup fireCount int lastFired time.Time } @@ -198,7 +199,9 @@ func (s *LoopScheduler) fire(ctx context.Context, state *loopState) { } case ModeIndependent: + state.wg.Add(1) go func() { + defer state.wg.Done() if _, err := entry.OnFire(ctx, entry); err != nil { s.log.Warnf("loop=%s fire=%d failed: %s", entry.Name, count, err) } @@ -216,6 +219,7 @@ func (s *LoopScheduler) Remove(name string) error { state.cancel() delete(s.loops, name) s.mu.Unlock() + state.wg.Wait() s.log.Importantf("loop=%s deleted", name) return nil } @@ -245,9 +249,14 @@ func (s *LoopScheduler) Active() int { func (s *LoopScheduler) Stop() { s.mu.Lock() - defer s.mu.Unlock() + states := make([]*loopState, 0, len(s.loops)) for name, state := range s.loops { state.cancel() + states = append(states, state) delete(s.loops, name) } + s.mu.Unlock() + for _, state := range states { + state.wg.Wait() + } } diff --git a/pkg/agent/loop_tool.go b/pkg/agent/loop_tool.go index 0afa237..8751dc9 100644 --- a/pkg/agent/loop_tool.go +++ b/pkg/agent/loop_tool.go @@ -102,6 +102,11 @@ func (c *LoopCommand) create(ctx context.Context, args []string) error { // tryCronPrefix attempts to parse the first 5 args as a cron expression. // Returns the parsed expression, remaining args, and whether it succeeded. func tryCronPrefix(args []string) (*CronExpr, []string, bool) { + if len(args) >= 2 && strings.Contains(args[0], " ") { + if cron, err := ParseCron(args[0]); err == nil { + return cron, args[1:], true + } + } if len(args) < 6 { return nil, nil, false } diff --git a/pkg/agent/types.go b/pkg/agent/types.go index 7f3a180..4bec4ee 100644 --- a/pkg/agent/types.go +++ b/pkg/agent/types.go @@ -232,7 +232,7 @@ func (c Config) init() Config { if c.Logger == nil { c.Logger = telemetry.NopLogger() } - if c.MaxRetries == 0 { + if c.MaxRetries < 0 { c.MaxRetries = DefaultMaxRetries } if c.MaxResultSize <= 0 { diff --git a/pkg/tools/proton/command.go b/pkg/tools/proton/command.go index e71a25f..60c7637 100644 --- a/pkg/tools/proton/command.go +++ b/pkg/tools/proton/command.go @@ -213,10 +213,13 @@ func (c *Command) Execute(ctx context.Context, args []string) error { if len(inputs) == 0 && len(flags.Expressions) == 0 { return fmt.Errorf("proton: target required (-i , -l , -e , or pipe: | proton)") } + if len(inputs) == 0 { + inputs = []string{"."} + } // --- Scan --- sevFilter := buildSeverityFilter(flags.Severity, flags.ExcludeSeverity) - seen := make(map[string]bool) + var seen sync.Map var findingCount int64 var fileOut *os.File @@ -234,10 +237,9 @@ func (c *Command) Execute(ctx context.Context, args []string) error { return } key := uf.TemplateID + "|" + uf.FilePath - if seen[key] { + if _, loaded := seen.LoadOrStore(key, true); loaded { return } - seen[key] = true atomic.AddInt64(&findingCount, 1) writeFinding(commands.Output, uf, flags.JSON, inputs[0]) if fileOut != nil { @@ -382,8 +384,9 @@ func writeFinding(w interface{ Write([]byte) (int, error) }, f file.Finding, jso } func truncate(s string, max int) string { - if len(s) > max { - return s[:max] + "..." + runes := []rune(s) + if len(runes) > max { + return string(runes[:max]) + "..." } return s } @@ -444,8 +447,11 @@ func walkAndScan(ctx context.Context, scanner *file.Scanner, target string, call }() } - _ = filepath.WalkDir(target, func(path string, d fs.DirEntry, err error) error { - if err != nil || ctx.Err() != nil { + if walkErr := filepath.WalkDir(target, func(path string, d fs.DirEntry, err error) error { + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil { return err } if d.IsDir() { @@ -468,7 +474,9 @@ func walkAndScan(ctx context.Context, scanner *file.Scanner, target string, call jobCh <- job{path: path, group: group} } return nil - }) + }); walkErr != nil && ctx.Err() == nil { + fmt.Fprintf(os.Stderr, "proton: walk %s: %v\n", target, walkErr) + } close(jobCh) wg.Wait() } diff --git a/pkg/tools/proxy/mitm.go b/pkg/tools/proxy/mitm.go index 14fb754..978b993 100644 --- a/pkg/tools/proxy/mitm.go +++ b/pkg/tools/proxy/mitm.go @@ -4,14 +4,12 @@ import ( "context" "fmt" "net/http" - "net/url" "strconv" "strings" "sync" "time" "github.com/chainreactors/aiscan/pkg/commands" - "github.com/chainreactors/proxyclient" mitmproxy "github.com/chainreactors/utils/mitmproxy/proxy" goflags "github.com/jessevdk/go-flags" ) @@ -182,7 +180,6 @@ func (s *mitmState) start() error { if err != nil { return fmt.Errorf("create MITM proxy: %w", err) } - s.store.Clear() p.AddAddon(&captureAddon{store: s.store}) listenAddr, _, err := p.StartAsync() if err != nil { @@ -368,6 +365,7 @@ func (s *FlowStore) Clear() { s.mu.Lock() defer s.mu.Unlock() s.flows = s.flows[:0] + s.seq = 0 } func (s *FlowStore) Count() int { @@ -393,7 +391,7 @@ func matchStatus(code int, pattern string) bool { if n, err := strconv.Atoi(p); err == nil { return code == n } - return true + return false } } @@ -499,20 +497,3 @@ func writeHeaders(sb *strings.Builder, h http.Header) { } } } - -// configureUpstream sets the MITM proxy's upstream dialer if a proxy is active. -func configureUpstream(p *mitmproxy.Proxy, proxyURL string) error { - if proxyURL == "" { - return nil - } - u, err := url.Parse(proxyURL) - if err != nil { - return err - } - dial, err := proxyclient.NewClient(u) - if err != nil { - return fmt.Errorf("upstream dialer for %s: %w", u.Scheme, err) - } - p.SetDialer(dial.DialContext) - return nil -} diff --git a/pkg/tui/format.go b/pkg/tui/format.go index e26d57d..05ba405 100644 --- a/pkg/tui/format.go +++ b/pkg/tui/format.go @@ -246,9 +246,10 @@ func summarizeChatMessage(msg agent.ChatMessage) (role string, contentLen int, t // --------------------------------------------------------------------------- var ( - agentMarkdownRenderer *glamour.TermRenderer - agentMarkdownRendererErr error - agentMarkdownRendererOnce sync.Once + agentMarkdownRenderer *glamour.TermRenderer + agentMarkdownRendererErr error + agentMarkdownRendererW int + agentMarkdownRendererMu sync.Mutex ) func renderAgentMarkdown(content string, enabled bool) string { @@ -275,20 +276,22 @@ func renderAgentMarkdown(content string, enabled bool) string { } func getAgentMarkdownRenderer() (*glamour.TermRenderer, error) { - agentMarkdownRendererOnce.Do(func() { - opts := []glamour.TermRendererOption{ - glamour.WithAutoStyle(), - // Auto-detect the richest profile the terminal advertises (truecolor - // -> 256 -> ANSI) instead of pinning 16-color ANSI, so markdown answers - // render with real depth on modern terminals. - glamour.WithColorProfile(termenv.ColorProfile()), - glamour.WithEmoji(), - } - if w := terminalWidth(); w > 0 { - opts = append(opts, glamour.WithWordWrap(w)) - } - agentMarkdownRenderer, agentMarkdownRendererErr = glamour.NewTermRenderer(opts...) - }) + w := terminalWidth() + agentMarkdownRendererMu.Lock() + defer agentMarkdownRendererMu.Unlock() + if agentMarkdownRenderer != nil && w == agentMarkdownRendererW { + return agentMarkdownRenderer, agentMarkdownRendererErr + } + opts := []glamour.TermRendererOption{ + glamour.WithAutoStyle(), + glamour.WithColorProfile(termenv.ColorProfile()), + glamour.WithEmoji(), + } + if w > 0 { + opts = append(opts, glamour.WithWordWrap(w)) + } + agentMarkdownRenderer, agentMarkdownRendererErr = glamour.NewTermRenderer(opts...) + agentMarkdownRendererW = w return agentMarkdownRenderer, agentMarkdownRendererErr } diff --git a/pkg/util/format.go b/pkg/util/format.go index 19db005..62c0249 100644 --- a/pkg/util/format.go +++ b/pkg/util/format.go @@ -17,6 +17,9 @@ func FormatSize(bytes int) string { } func FormatNumber(n int) string { + if n < 0 { + return "-" + FormatNumber(-n) + } if n < 1000 { return fmt.Sprintf("%d", n) } diff --git a/web/frontend/src/lib/scan-result.ts b/web/frontend/src/lib/scan-result.ts index b34b1e7..a7168e7 100644 --- a/web/frontend/src/lib/scan-result.ts +++ b/web/frontend/src/lib/scan-result.ts @@ -682,7 +682,7 @@ function normalizePriority(priority?: string): FindingPriority { function itemToPriority(item: AssetItem): FindingPriority { const p = (item.status || '').toLowerCase() - if (p === 'confirmed' || p === 'critical') return 'critical' + if (p === 'critical') return 'critical' if (p === 'high') return 'high' if (p === 'medium' || p === 'inconclusive') return 'medium' if (p === 'low') return 'low' From c015bf5c7fd31ce7f3eceae7e6be41fd9811cb7b Mon Sep 17 00:00:00 2001 From: M09Ic Date: Thu, 25 Jun 2026 07:26:56 -0700 Subject: [PATCH 3/4] refactor: extract scanner Base struct, unify tool registration, split console.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Type Deps.Logger as telemetry.Logger instead of any for compile-time safety - Extract toolargs.Base with shared Logger/Proxy/WorkDir fields and methods, embed in all 7 scanner tool Commands to eliminate repeated boilerplate - Create per-tool register.go for neutron/spray/zombie/gogo, simplify the central register_command.go to only handle the composite scan command - Split console.go (1331→848 lines) into keybindings.go and banner.go Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/aiscan/imports.go | 6 +- pkg/commands/factory.go | 8 +- pkg/tools/gogo/gogo.go | 32 +- pkg/tools/gogo/register.go | 22 ++ pkg/tools/katana/katana.go | 36 +-- pkg/tools/neutron/neutron.go | 30 +- pkg/tools/neutron/register.go | 22 ++ pkg/tools/proton/command.go | 26 +- pkg/tools/register_command.go | 22 +- pkg/tools/scan/adapter.go | 6 +- pkg/tools/scan/capability.go | 10 +- pkg/tools/scan/capability_katana.go | 4 +- pkg/tools/scan/command.go | 24 +- pkg/tools/scan/options.go | 8 +- pkg/tools/spray/register.go | 22 ++ pkg/tools/spray/spray.go | 30 +- pkg/tools/toolargs/base.go | 20 ++ pkg/tools/zombie/register.go | 22 ++ pkg/tools/zombie/zombie.go | 29 +- pkg/tui/banner.go | 269 ++++++++++++++++ pkg/tui/console.go | 483 ---------------------------- pkg/tui/keybindings.go | 233 ++++++++++++++ 22 files changed, 717 insertions(+), 647 deletions(-) create mode 100644 pkg/tools/gogo/register.go create mode 100644 pkg/tools/neutron/register.go create mode 100644 pkg/tools/spray/register.go create mode 100644 pkg/tools/toolargs/base.go create mode 100644 pkg/tools/zombie/register.go create mode 100644 pkg/tui/banner.go create mode 100644 pkg/tui/keybindings.go diff --git a/cmd/aiscan/imports.go b/cmd/aiscan/imports.go index aed85fe..ac30b23 100644 --- a/cmd/aiscan/imports.go +++ b/cmd/aiscan/imports.go @@ -6,8 +6,12 @@ package main import ( _ "github.com/chainreactors/aiscan/pkg/tools" _ "github.com/chainreactors/aiscan/pkg/tools/arsenal" - _ "github.com/chainreactors/aiscan/pkg/tools/proton" + _ "github.com/chainreactors/aiscan/pkg/tools/gogo" _ "github.com/chainreactors/aiscan/pkg/tools/ioa" + _ "github.com/chainreactors/aiscan/pkg/tools/neutron" + _ "github.com/chainreactors/aiscan/pkg/tools/proton" _ "github.com/chainreactors/aiscan/pkg/tools/proxy" _ "github.com/chainreactors/aiscan/pkg/tools/search" + _ "github.com/chainreactors/aiscan/pkg/tools/spray" + _ "github.com/chainreactors/aiscan/pkg/tools/zombie" ) diff --git a/pkg/commands/factory.go b/pkg/commands/factory.go index 5c8ddca..3b720c2 100644 --- a/pkg/commands/factory.go +++ b/pkg/commands/factory.go @@ -22,17 +22,15 @@ type Deps struct { Provider any ScannerProxy string ScanOpts []any - Logger any + Logger telemetry.Logger NodeName string NodeMeta map[string]any TavilyKeys string // comma-separated Tavily API keys (build-time fallback) } func (d *Deps) GetLogger() telemetry.Logger { - if d != nil { - if logger, ok := d.Logger.(telemetry.Logger); ok && logger != nil { - return logger - } + if d != nil && d.Logger != nil { + return d.Logger } return telemetry.NopLogger() } diff --git a/pkg/tools/gogo/gogo.go b/pkg/tools/gogo/gogo.go index dd41ccf..35f7df7 100644 --- a/pkg/tools/gogo/gogo.go +++ b/pkg/tools/gogo/gogo.go @@ -15,32 +15,26 @@ import ( ) type Command struct { - engine *gogo.Engine - logger telemetry.Logger - proxy string - workDir string + toolargs.Base + engine *gogo.Engine } func New(engine *gogo.Engine) *Command { - return &Command{engine: engine, logger: telemetry.NopLogger()} + c := &Command{engine: engine} + c.InitLogger(nil) + return c } func (c *Command) WithLogger(logger telemetry.Logger) *Command { - if logger != nil { - c.logger = logger - } + c.InitLogger(logger) return c } -func (c *Command) SetWorkDir(dir string) { c.workDir = dir } - func (c *Command) WithProxy(proxy string) *Command { - c.proxy = proxy + c.Proxy = proxy return c } -func (c *Command) SetProxy(proxy string) { c.proxy = proxy } - func (c *Command) Name() string { return "gogo" } func (c *Command) Usage() string { @@ -52,9 +46,9 @@ func (c *Command) Execute(ctx context.Context, args []string) (err error) { args = c.injectProxy(args) if toolargs.BoolFlagEnabled(args, "--debug") { - restoreDebug := telemetry.ActivateDebug(c.logger) + restoreDebug := telemetry.ActivateDebug(c.Logger) defer restoreDebug() - c.logger.Debugf("gogo debug enabled") + c.Logger.Debugf("gogo debug enabled") } var buf bytes.Buffer @@ -86,13 +80,13 @@ func (c *Command) TestInjectProxy(args []string) []string { } func (c *Command) injectProxy(args []string) []string { - if c.proxy == "" { + if c.Proxy == "" { return args } if toolargs.HasFlag(args, "--proxy") { return args } - return append(args, "--proxy", c.proxy) + return append(args, "--proxy", c.Proxy) } // normalizeArgs adapts common agent-generated gogo arguments before handing @@ -139,10 +133,10 @@ func (c *Command) normalizeArgs(args []string) []string { } func (c *Command) resolvePathArg(value string) string { - if c.workDir == "" || value == "" || filepath.IsAbs(value) || strings.HasPrefix(value, "-") { + if c.WorkDir == "" || value == "" || filepath.IsAbs(value) || strings.HasPrefix(value, "-") { return value } - return filepath.Join(c.workDir, value) + return filepath.Join(c.WorkDir, value) } func isGogoValuelessJSONFlag(arg string, args []string, index int) bool { diff --git a/pkg/tools/gogo/register.go b/pkg/tools/gogo/register.go new file mode 100644 index 0000000..921c29e --- /dev/null +++ b/pkg/tools/gogo/register.go @@ -0,0 +1,22 @@ +package gogo + +import ( + "github.com/chainreactors/aiscan/pkg/commands" + "github.com/chainreactors/aiscan/pkg/tools/scan/engine" +) + +func init() { + commands.RegisterFactory(commands.Factory{ + Group: "scanner", + Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { + es, _ := deps.EngineSet.(*engine.Set) + if es == nil || es.Gogo == nil { + return + } + reg.Register( + New(es.Gogo).WithLogger(deps.GetLogger()).WithProxy(deps.ScannerProxy), + "scanner", + ) + }, + }) +} diff --git a/pkg/tools/katana/katana.go b/pkg/tools/katana/katana.go index 63f3f0c..f9d8f07 100644 --- a/pkg/tools/katana/katana.go +++ b/pkg/tools/katana/katana.go @@ -30,29 +30,25 @@ const defaultTimeout = 120 * time.Second const defaultBodyReadSize = 4 * 1024 * 1024 type Command struct { - proxy string - logger telemetry.Logger - workDir string + toolargs.Base } -func New() *Command { return &Command{logger: telemetry.NopLogger()} } +func New() *Command { + c := &Command{} + c.InitLogger(nil) + return c +} func (c *Command) WithLogger(logger telemetry.Logger) *Command { - if logger != nil { - c.logger = logger - } + c.InitLogger(logger) return c } func (c *Command) WithProxy(proxy string) *Command { - c.proxy = proxy + c.Proxy = proxy return c } -func (c *Command) SetProxy(proxy string) { c.proxy = proxy } - -func (c *Command) SetWorkDir(dir string) { c.workDir = dir } - func (c *Command) Name() string { return "katana" } func (c *Command) Usage() string { @@ -115,9 +111,9 @@ func (c *Command) Execute(ctx context.Context, args []string) (err error) { args = c.resolveRelativePaths(args) if toolargs.BoolFlagEnabled(args, "--debug") { - restoreDebug := telemetry.ActivateDebug(c.logger) + restoreDebug := telemetry.ActivateDebug(c.Logger) defer restoreDebug() - c.logger.Debugf("katana debug enabled") + c.Logger.Debugf("katana debug enabled") } options, err := readFlags(args) @@ -131,8 +127,8 @@ func (c *Command) Execute(ctx context.Context, args []string) (err error) { options.DisableUpdateCheck = true // Inject proxy. - if options.Proxy == "" && c.proxy != "" { - options.Proxy = c.proxy + if options.Proxy == "" && c.Proxy != "" { + options.Proxy = c.Proxy } if err := validateOptions(options); err != nil { @@ -197,7 +193,7 @@ func (c *Command) Execute(ctx context.Context, args []string) (err error) { if ctx.Err() != nil { return fmt.Errorf("katana: timed out") } - c.logger.Warnf("katana: crawl %s: %v", u, crawlErr) + c.Logger.Warnf("katana: crawl %s: %v", u, crawlErr) } } @@ -368,7 +364,7 @@ func validateOptions(options *katanatypes.Options) error { } func (c *Command) resolveRelativePaths(args []string) []string { - if c.workDir == "" { + if c.WorkDir == "" { return args } out := make([]string, 0, len(args)) @@ -392,10 +388,10 @@ func (c *Command) resolveRelativePaths(args []string) []string { } func (c *Command) resolvePathArg(value string) string { - if c.workDir == "" || value == "" || filepath.IsAbs(value) || strings.HasPrefix(value, "-") { + if c.WorkDir == "" || value == "" || filepath.IsAbs(value) || strings.HasPrefix(value, "-") { return value } - return filepath.Join(c.workDir, value) + return filepath.Join(c.WorkDir, value) } func isFileFlag(flag string) bool { diff --git a/pkg/tools/neutron/neutron.go b/pkg/tools/neutron/neutron.go index 7b5427b..6e0802c 100644 --- a/pkg/tools/neutron/neutron.go +++ b/pkg/tools/neutron/neutron.go @@ -23,15 +23,11 @@ import ( ) type Command struct { - engine *sdkneutron.Engine - index *association.Index - logger telemetry.Logger - proxy string - workDir string + toolargs.Base + engine *sdkneutron.Engine + index *association.Index } -func (c *Command) SetWorkDir(dir string) { c.workDir = dir } - type neutronFlags struct { Inputs []string `short:"u" long:"target" description:"Target URL, host, or ip:port (can specify multiple)"` Input string `short:"i" long:"input" description:"Target URL, host, or ip:port (alias of --target)"` @@ -84,24 +80,24 @@ type neutronSummary struct { } func New(engine *sdkneutron.Engine, index *association.Index) *Command { - return &Command{engine: engine, index: index, logger: telemetry.NopLogger()} + c := &Command{engine: engine, index: index} + c.InitLogger(nil) + return c } func (c *Command) WithLogger(logger telemetry.Logger) *Command { - if logger != nil { - c.logger = logger - } + c.InitLogger(logger) return c } func (c *Command) WithProxy(proxy string) *Command { - c.proxy = proxy + c.Proxy = proxy scanengine.ApplyNeutronProxy(proxy) return c } func (c *Command) SetProxy(proxy string) { - c.proxy = proxy + c.Base.SetProxy(proxy) scanengine.ApplyNeutronProxy(proxy) } @@ -159,9 +155,9 @@ func (c *Command) Execute(ctx context.Context, args []string) error { return fmt.Errorf("neutron: %w", err) } if flags.Debug { - restoreDebug := telemetry.ActivateDebug(c.logger) + restoreDebug := telemetry.ActivateDebug(c.Logger) defer restoreDebug() - c.logger.Debugf("neutron debug enabled") + c.Logger.Debugf("neutron debug enabled") } if flags.Timeout > 0 { var cancel context.CancelFunc @@ -232,7 +228,7 @@ func (c *Command) Execute(ctx context.Context, args []string) error { return wErr } - c.logger.Infof("neutron action=testing targets=%d templates=%d concurrency=%d rate_limit=%d", len(targets), len(selected), flags.Concurrency, flags.RateLimit) + c.Logger.Infof("neutron action=testing targets=%d templates=%d concurrency=%d rate_limit=%d", len(targets), len(selected), flags.Concurrency, flags.RateLimit) summary := neutronSummary{Targets: len(targets), Templates: len(selected)} var sb strings.Builder jsonOutput := flags.JSON || flags.JSONL @@ -526,7 +522,7 @@ var neutronFileFlags = map[string]bool{ } func (c *Command) resolveRelativePaths(args []string) []string { - return toolargs.ResolveRelativePaths(args, neutronFileFlags, c.workDir) + return toolargs.ResolveRelativePaths(args, neutronFileFlags, c.WorkDir) } func appendNonEmpty(parts []string, values ...string) []string { diff --git a/pkg/tools/neutron/register.go b/pkg/tools/neutron/register.go new file mode 100644 index 0000000..4a24ef3 --- /dev/null +++ b/pkg/tools/neutron/register.go @@ -0,0 +1,22 @@ +package neutron + +import ( + "github.com/chainreactors/aiscan/pkg/commands" + "github.com/chainreactors/aiscan/pkg/tools/scan/engine" +) + +func init() { + commands.RegisterFactory(commands.Factory{ + Group: "scanner", + Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { + es, _ := deps.EngineSet.(*engine.Set) + if es == nil || es.Neutron == nil { + return + } + reg.Register( + New(es.Neutron, es.Index).WithLogger(deps.GetLogger()).WithProxy(deps.ScannerProxy), + "scanner", + ) + }, + }) +} diff --git a/pkg/tools/proton/command.go b/pkg/tools/proton/command.go index 60c7637..b6062d7 100644 --- a/pkg/tools/proton/command.go +++ b/pkg/tools/proton/command.go @@ -25,26 +25,24 @@ import ( ) type Command struct { - logger telemetry.Logger - proxy string - workDir string + toolargs.Base stdinFile string resourceProvider func(string) []byte } func New() *Command { - return &Command{logger: telemetry.NopLogger()} + c := &Command{} + c.InitLogger(nil) + return c } func (c *Command) WithLogger(logger telemetry.Logger) *Command { - if logger != nil { - c.logger = logger - } + c.InitLogger(logger) return c } func (c *Command) WithProxy(proxy string) *Command { - c.proxy = proxy + c.Proxy = proxy return c } @@ -53,8 +51,6 @@ func (c *Command) WithResourceProvider(provider func(string) []byte) *Command { return c } -func (c *Command) SetProxy(proxy string) { c.proxy = proxy } -func (c *Command) SetWorkDir(dir string) { c.workDir = dir } func (c *Command) SetStdinFile(path string) { c.stdinFile = path } func (c *Command) Name() string { return "proton" } @@ -152,9 +148,9 @@ func (c *Command) Execute(ctx context.Context, args []string) error { } if flags.Debug { - restoreDebug := telemetry.ActivateDebug(c.logger) + restoreDebug := telemetry.ActivateDebug(c.Logger) defer restoreDebug() - c.logger.Debugf("proton debug enabled") + c.Logger.Debugf("proton debug enabled") } if flags.Timeout > 0 { var cancel context.CancelFunc @@ -247,12 +243,12 @@ func (c *Command) Execute(ctx context.Context, args []string) error { } } - c.logger.Infof("proton action=scanning targets=%d rules=%d", len(inputs), scanner.Stats.Rules) + c.Logger.Infof("proton action=scanning targets=%d rules=%d", len(inputs), scanner.Stats.Rules) for _, input := range inputs { info, statErr := os.Stat(input) if statErr != nil { - c.logger.Warnf("proton: skip %s: %v", input, statErr) + c.Logger.Warnf("proton: skip %s: %v", input, statErr) continue } if info.IsDir() { @@ -355,7 +351,7 @@ var protonFileFlags = map[string]bool{ } func (c *Command) resolveRelativePaths(args []string) []string { - return toolargs.ResolveRelativePaths(args, protonFileFlags, c.workDir) + return toolargs.ResolveRelativePaths(args, protonFileFlags, c.WorkDir) } // --- output --- diff --git a/pkg/tools/register_command.go b/pkg/tools/register_command.go index 0710e33..5842d4f 100644 --- a/pkg/tools/register_command.go +++ b/pkg/tools/register_command.go @@ -3,12 +3,8 @@ package tools import ( cfg "github.com/chainreactors/aiscan/core/config" "github.com/chainreactors/aiscan/pkg/commands" - gogocmd "github.com/chainreactors/aiscan/pkg/tools/gogo" - neutroncmd "github.com/chainreactors/aiscan/pkg/tools/neutron" "github.com/chainreactors/aiscan/pkg/tools/scan" "github.com/chainreactors/aiscan/pkg/tools/scan/engine" - spraycmd "github.com/chainreactors/aiscan/pkg/tools/spray" - zombiecmd "github.com/chainreactors/aiscan/pkg/tools/zombie" ) func init() { @@ -20,8 +16,6 @@ func init() { if es == nil { return } - logger := deps.GetLogger() - proxy := deps.ScannerProxy var scanOpts []scan.Option for _, o := range deps.ScanOpts { @@ -29,25 +23,13 @@ func init() { scanOpts = append(scanOpts, opt) } } - if proxy != "" { - scanOpts = append(scanOpts, scan.WithProxy(proxy)) + if deps.ScannerProxy != "" { + scanOpts = append(scanOpts, scan.WithProxy(deps.ScannerProxy)) } if es.Gogo != nil && es.Spray != nil { reg.Register(scan.New(es, scanOpts...), "scanner") } - if es.Gogo != nil { - reg.Register(gogocmd.New(es.Gogo).WithLogger(logger).WithProxy(proxy), "scanner") - } - if es.Spray != nil { - reg.Register(spraycmd.New(es.Spray).WithLogger(logger).WithProxy(proxy), "scanner") - } - if es.Zombie != nil { - reg.Register(zombiecmd.New(es.Zombie).WithLogger(logger).WithProxy(proxy), "scanner") - } - if es.Neutron != nil { - reg.Register(neutroncmd.New(es.Neutron, es.Index).WithLogger(logger).WithProxy(proxy), "scanner") - } }, }) } diff --git a/pkg/tools/scan/adapter.go b/pkg/tools/scan/adapter.go index 12ba6cb..70d2342 100644 --- a/pkg/tools/scan/adapter.go +++ b/pkg/tools/scan/adapter.go @@ -23,7 +23,7 @@ func (c *Command) runPortDiscoveryCapability(ctx context.Context, discovery disc if target.Ports != "" { ports = target.Ports } - c.logger.Infof("scan capability=%s target=%s ports=%s", capGogoPortscan, target.Target, ports) + c.Logger.Infof("scan capability=%s target=%s ports=%s", capGogoPortscan, target.Target, ports) resultCh, err := engine.GogoScanStream(ctx, c.engines.Gogo, engine.GogoScanOptions{ Target: target.Target, Ports: ports, @@ -31,7 +31,7 @@ func (c *Command) runPortDiscoveryCapability(ctx context.Context, discovery disc Timeout: discovery.Timeout, VersionLevel: discovery.Version, Exploit: discovery.Exploit, - Proxy: c.proxy, + Proxy: c.Proxy, Debug: discovery.Debug, OnStats: func(stats sdktypes.Stats) { emit(statsEvent(capGogoPortscan, stats)) @@ -120,7 +120,7 @@ func (c *Command) runWeakpassCapability(ctx context.Context, flags flags, creden Top: flags.ZombieTop, Users: credentials.Users, Passwords: credentials.Passwords, - Proxy: c.proxy, + Proxy: c.Proxy, Debug: flags.Debug, OnStats: func(stats sdktypes.Stats) { emit(statsEvent(capZombieWeakpass, stats)) diff --git a/pkg/tools/scan/capability.go b/pkg/tools/scan/capability.go index 82f8315..c188986 100644 --- a/pkg/tools/scan/capability.go +++ b/pkg/tools/scan/capability.go @@ -96,7 +96,7 @@ func (c *Command) buildCapabilities(flags flags, opts scanOptions, profile profi if !profile.Enabled(name) || !hasSpray(c.engines) { return } - sopts.Proxy = c.proxy + sopts.Proxy = c.Proxy sprayBuilt = true capabilities = append(capabilities, sprayCapability(c, flags, opts.Web, name, sources, sopts, c.runSprayCapability)) } @@ -129,7 +129,7 @@ func (c *Command) buildCapabilities(flags flags, opts scanOptions, profile profi wrapRoutes(acceptsTarget(targetWeb), webSources()...), capWorkers(c.engines.Capacity.Spray, flags.SprayThreads), func(ctx context.Context, e event, emit func(event)) { - c.runSprayCapability(ctx, flags, opts.Web, e.Target, capSprayCrawl, engine.SprayCheckOptions{Crawl: true, CrawlDepth: profile.CrawlDepth, Proxy: c.proxy}, emit) + c.runSprayCapability(ctx, flags, opts.Web, e.Target, capSprayCrawl, engine.SprayCheckOptions{Crawl: true, CrawlDepth: profile.CrawlDepth, Proxy: c.Proxy}, emit) }, )) } @@ -168,13 +168,13 @@ func (c *Command) buildCapabilities(flags flags, opts scanOptions, profile profi } if opts.hasDiscoveryOverrides() && !gogoBuilt { - c.logger.Warnf("scan capability=%s option=port status=ignored reason=engine_unavailable", capGogoPortscan) + c.Logger.Warnf("scan capability=%s option=port status=ignored reason=engine_unavailable", capGogoPortscan) } if opts.hasWebOverrides() && !sprayBuilt { - c.logger.Warnf("scan capability=web_probe option=dict,rule,word,default-dict,advance status=ignored reason=engine_unavailable") + c.Logger.Warnf("scan capability=web_probe option=dict,rule,word,default-dict,advance status=ignored reason=engine_unavailable") } if opts.hasWeakpassOverrides() && !weakpassBuilt { - c.logger.Warnf("scan capability=%s option=user,pwd status=ignored reason=engine_unavailable", capZombieWeakpass) + c.Logger.Warnf("scan capability=%s option=user,pwd status=ignored reason=engine_unavailable", capZombieWeakpass) } for _, builder := range extraCapabilityBuilders { diff --git a/pkg/tools/scan/capability_katana.go b/pkg/tools/scan/capability_katana.go index 41226b0..f6497b6 100644 --- a/pkg/tools/scan/capability_katana.go +++ b/pkg/tools/scan/capability_katana.go @@ -122,8 +122,8 @@ func runKatanaCrawl(ctx context.Context, c *Command, e event, depth int, jsMode emit(targetEvent(source, wt.Raw, newWebTarget(wt.Raw, discoveredURL, wt.HostHeader))) }, } - if c.proxy != "" { - options.Proxy = c.proxy + if c.Proxy != "" { + options.Proxy = c.Proxy } gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent) diff --git a/pkg/tools/scan/command.go b/pkg/tools/scan/command.go index 7653780..1381683 100644 --- a/pkg/tools/scan/command.go +++ b/pkg/tools/scan/command.go @@ -19,18 +19,13 @@ import ( ) type Command struct { + toolargs.Base engines *engine.Set parent *agent.Agent deepBrowser DeepBrowserFunc readSkill SkillReader - logger telemetry.Logger - proxy string - workDir string } -func (c *Command) SetWorkDir(dir string) { c.workDir = dir } -func (c *Command) SetProxy(proxy string) { c.proxy = proxy } - type flags struct { Inputs []string `short:"i" long:"input" description:"Input target: URL, IP, IP:port, or CIDR"` ListFile string `short:"l" long:"list" description:"File containing input targets, one per line"` @@ -66,7 +61,8 @@ type flags struct { } func New(engineSet *engine.Set, opts ...Option) *Command { - cmd := &Command{engines: engineSet, logger: telemetry.NopLogger()} + cmd := &Command{engines: engineSet} + cmd.InitLogger(nil) for _, opt := range opts { if opt != nil { opt(cmd) @@ -163,9 +159,9 @@ func (c *Command) execute(ctx context.Context, args []string, stream io.Writer) } if flags.Debug { flags.Trace = true - restoreDebug := telemetry.ActivateDebug(c.logger) + restoreDebug := telemetry.ActivateDebug(c.Logger) defer restoreDebug() - c.logger.Debugf("scan debug enabled") + c.Logger.Debugf("scan debug enabled") } profile, err := profileForMode(flags.Mode) if err != nil { @@ -239,10 +235,10 @@ func (c *Command) execute(ctx context.Context, args []string, stream io.Writer) p.Run(seedsToEvents(seeds)) if c.parent != nil && verifyLevel != "" { - runVerifyPass(ctx, c.parent, c.readSkill, coll, verifyLevel, c.logger) + runVerifyPass(ctx, c.parent, c.readSkill, coll, verifyLevel, c.Logger) } if c.parent != nil && flags.Sniper { - runSniperPass(ctx, c.parent, c.readSkill, coll, c.logger) + runSniperPass(ctx, c.parent, c.readSkill, coll, c.Logger) } coll.Finish() @@ -278,13 +274,13 @@ func (c *Command) execute(ctx context.Context, args []string, stream io.Writer) if flags.OutputFile != "" && !flags.JSON { plainOut := coll.PlainText() if err := writeOutputFile(flags.OutputFile, plainOut); err != nil { - c.logger.Errorf("%s", err.Error()) + c.Logger.Errorf("%s", err.Error()) } } if flags.AssetReportFile != "" { assetOut := coll.AssetReport() if err := writeOutputFile(flags.AssetReportFile, assetOut); err != nil { - c.logger.Errorf("%s", err.Error()) + c.Logger.Errorf("%s", err.Error()) } } return out, coll.StructuredResult(), nil @@ -298,7 +294,7 @@ var scanFileFlags = map[string]bool{ } func (c *Command) resolveRelativePaths(args []string) []string { - return toolargs.ResolveRelativePaths(args, scanFileFlags, c.workDir) + return toolargs.ResolveRelativePaths(args, scanFileFlags, c.WorkDir) } func writeOutputFile(path, content string) error { diff --git a/pkg/tools/scan/options.go b/pkg/tools/scan/options.go index 2cbf023..aec72bd 100644 --- a/pkg/tools/scan/options.go +++ b/pkg/tools/scan/options.go @@ -16,15 +16,11 @@ func WithParent(a *agent.Agent) Option { } func WithProxy(proxy string) Option { - return func(c *Command) { c.proxy = proxy } + return func(c *Command) { c.Proxy = proxy } } func WithLogger(logger telemetry.Logger) Option { - return func(c *Command) { - if logger != nil { - c.logger = logger - } - } + return func(c *Command) { c.InitLogger(logger) } } func (c *Command) Configure(opts ...Option) { diff --git a/pkg/tools/spray/register.go b/pkg/tools/spray/register.go new file mode 100644 index 0000000..1a6ba59 --- /dev/null +++ b/pkg/tools/spray/register.go @@ -0,0 +1,22 @@ +package spray + +import ( + "github.com/chainreactors/aiscan/pkg/commands" + "github.com/chainreactors/aiscan/pkg/tools/scan/engine" +) + +func init() { + commands.RegisterFactory(commands.Factory{ + Group: "scanner", + Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { + es, _ := deps.EngineSet.(*engine.Set) + if es == nil || es.Spray == nil { + return + } + reg.Register( + New(es.Spray).WithLogger(deps.GetLogger()).WithProxy(deps.ScannerProxy), + "scanner", + ) + }, + }) +} diff --git a/pkg/tools/spray/spray.go b/pkg/tools/spray/spray.go index 4bbdd51..4a3cbbe 100644 --- a/pkg/tools/spray/spray.go +++ b/pkg/tools/spray/spray.go @@ -14,32 +14,26 @@ import ( ) type Command struct { - engine *spray.Engine - logger telemetry.Logger - proxy string - workDir string + toolargs.Base + engine *spray.Engine } func New(engine *spray.Engine) *Command { - return &Command{engine: engine, logger: telemetry.NopLogger()} + c := &Command{engine: engine} + c.InitLogger(nil) + return c } func (c *Command) WithLogger(logger telemetry.Logger) *Command { - if logger != nil { - c.logger = logger - } + c.InitLogger(logger) return c } -func (c *Command) SetWorkDir(dir string) { c.workDir = dir } - func (c *Command) WithProxy(proxy string) *Command { - c.proxy = proxy + c.Proxy = proxy return c } -func (c *Command) SetProxy(proxy string) { c.proxy = proxy } - func (c *Command) Name() string { return "spray" } func (c *Command) Usage() string { @@ -51,9 +45,9 @@ func (c *Command) Execute(ctx context.Context, args []string) (err error) { var buf bytes.Buffer debug := toolargs.BoolFlagEnabled(args, "--debug") if debug { - restoreDebug := telemetry.ActivateDebug(c.logger) + restoreDebug := telemetry.ActivateDebug(c.Logger) defer restoreDebug() - c.logger.Debugf("spray debug enabled") + c.Logger.Debugf("spray debug enabled") } if c.engine != nil { c.engine.InstallResourceProvider() @@ -103,13 +97,13 @@ func (c *Command) TestInjectProxy(args []string) []string { } func (c *Command) injectProxy(args []string) []string { - if c.proxy == "" { + if c.Proxy == "" { return args } if toolargs.HasFlag(args, "--proxy") { return args } - return append(args, "--proxy", c.proxy) + return append(args, "--proxy", c.Proxy) } func withDefaultNoBar(args []string) []string { @@ -145,5 +139,5 @@ var sprayFileFlags = map[string]bool{ } func (c *Command) resolveRelativePaths(args []string) []string { - return toolargs.ResolveRelativePaths(args, sprayFileFlags, c.workDir) + return toolargs.ResolveRelativePaths(args, sprayFileFlags, c.WorkDir) } diff --git a/pkg/tools/toolargs/base.go b/pkg/tools/toolargs/base.go new file mode 100644 index 0000000..d27ada6 --- /dev/null +++ b/pkg/tools/toolargs/base.go @@ -0,0 +1,20 @@ +package toolargs + +import "github.com/chainreactors/aiscan/pkg/telemetry" + +type Base struct { + Logger telemetry.Logger + Proxy string + WorkDir string +} + +func (b *Base) SetWorkDir(dir string) { b.WorkDir = dir } +func (b *Base) SetProxy(proxy string) { b.Proxy = proxy } + +func (b *Base) InitLogger(logger telemetry.Logger) { + if logger != nil { + b.Logger = logger + } else { + b.Logger = telemetry.NopLogger() + } +} diff --git a/pkg/tools/zombie/register.go b/pkg/tools/zombie/register.go new file mode 100644 index 0000000..86be9cc --- /dev/null +++ b/pkg/tools/zombie/register.go @@ -0,0 +1,22 @@ +package zombie + +import ( + "github.com/chainreactors/aiscan/pkg/commands" + "github.com/chainreactors/aiscan/pkg/tools/scan/engine" +) + +func init() { + commands.RegisterFactory(commands.Factory{ + Group: "scanner", + Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { + es, _ := deps.EngineSet.(*engine.Set) + if es == nil || es.Zombie == nil { + return + } + reg.Register( + New(es.Zombie).WithLogger(deps.GetLogger()).WithProxy(deps.ScannerProxy), + "scanner", + ) + }, + }) +} diff --git a/pkg/tools/zombie/zombie.go b/pkg/tools/zombie/zombie.go index 5e90816..187a6ba 100644 --- a/pkg/tools/zombie/zombie.go +++ b/pkg/tools/zombie/zombie.go @@ -13,35 +13,26 @@ import ( ) type Command struct { - engine *sdkzombie.Engine - logger telemetry.Logger - proxy string - workDir string + toolargs.Base + engine *sdkzombie.Engine } func New(engine *sdkzombie.Engine) *Command { - return &Command{engine: engine, logger: telemetry.NopLogger()} + c := &Command{engine: engine} + c.InitLogger(nil) + return c } func (c *Command) WithLogger(logger telemetry.Logger) *Command { - if logger != nil { - c.logger = logger - } + c.InitLogger(logger) return c } -func (c *Command) SetWorkDir(dir string) { c.workDir = dir } - func (c *Command) WithProxy(proxy string) *Command { - c.proxy = proxy + c.Proxy = proxy return c } -// SetProxy stores the proxy URL. Note: the zombie library's RunOptions no -// longer exposes ProxyDial, so proxy is not applied at runtime until upstream -// re-adds support. -func (c *Command) SetProxy(proxy string) { c.proxy = proxy } - func (c *Command) Name() string { return "zombie" } func (c *Command) Usage() string { @@ -52,9 +43,9 @@ func (c *Command) Execute(ctx context.Context, args []string) error { args = c.resolveRelativePaths(args) var buf bytes.Buffer if toolargs.BoolFlagEnabled(args, "--debug") { - restoreDebug := telemetry.ActivateDebug(c.logger) + restoreDebug := telemetry.ActivateDebug(c.Logger) defer restoreDebug() - c.logger.Debugf("zombie debug enabled") + c.Logger.Debugf("zombie debug enabled") } runOpts := zombiecore.RunOptions{Output: &buf} if err := zombiecore.RunWithArgs(ctx, args, runOpts); err != nil { @@ -76,5 +67,5 @@ var zombieFileFlags = map[string]bool{ } func (c *Command) resolveRelativePaths(args []string) []string { - return toolargs.ResolveRelativePaths(args, zombieFileFlags, c.workDir) + return toolargs.ResolveRelativePaths(args, zombieFileFlags, c.WorkDir) } diff --git a/pkg/tui/banner.go b/pkg/tui/banner.go new file mode 100644 index 0000000..6c327cb --- /dev/null +++ b/pkg/tui/banner.go @@ -0,0 +1,269 @@ +package tui + +import ( + "fmt" + "io" + "os" + "strings" + + cfg "github.com/chainreactors/aiscan/core/config" + outputpkg "github.com/chainreactors/aiscan/core/output" + "golang.org/x/term" +) + +// renderBanner prints a compact welcome block to stderr: title/version, +// resolved model, the session mode, and a short next-step hint. It uses fixed +// ANSI tokens so redirected or recorded sessions do not receive terminal +// background probes. stderr-TTY-only and skipped in quiet mode so redirected +// logs stay clean. Printed once into the scrollback (PTY-forward safe). +func (r *AgentConsole) renderBanner() { + if r.output == nil || r.output.VerbosityLevel() < 0 || r.output.Stderr() == nil { + return + } + if !r.output.tty { + return + } + fmt.Fprint(r.output.Stderr(), r.bannerOutput()) +} + +func (r *AgentConsole) bannerOutput() string { + colorEnabled := r.output != nil && r.output.color.Enabled + provider, model := r.providerModel() + modelText := "not configured - run `aiscan --init`" + modelStyle := ansiWarn + switch { + case provider != "" && model != "": + modelText = provider + " / " + model + modelStyle = ansiAccent + case provider != "": + modelText = provider + modelStyle = ansiAccent + } + + width := r.bannerWidth() + header := ansiTitle("aiscan", colorEnabled) + " " + ansiDim("v"+cfg.Version, colorEnabled) + + var lines []string + lines = append(lines, header) + lines = append(lines, bannerKV("model", modelStyle(modelText, colorEnabled), colorEnabled)) + lines = append(lines, bannerKV("mode", ansiDim(r.sessionSummary(), colorEnabled), colorEnabled)) + lines = append(lines, bannerKV("help", renderInlineCommands([]string{"/help", "/status", "/exit"}, colorEnabled), colorEnabled)) + lines = append(lines, bannerKV("keys", ansiDim("Esc", colorEnabled)+" "+ansiDim("interrupt", colorEnabled)+ansiDim(" ", colorEnabled)+ansiDim("Ctrl+O", colorEnabled)+" "+ansiDim("verbosity", colorEnabled), colorEnabled)) + + box := renderFixedBox(strings.Join(lines, "\n"), width, colorEnabled) + intent := ansiDim("输入目标或任务即可;例如:扫描 192.168.1.10 的 Web 风险", colorEnabled) + + var b strings.Builder + fmt.Fprintln(&b) + fmt.Fprintln(&b, box) + fmt.Fprintln(&b, " "+intent) + if notice := strings.TrimSpace(r.startupNotice); notice != "" { + fmt.Fprintln(&b, " "+ansiWarn("⚠ "+notice, colorEnabled)) + } + fmt.Fprintln(&b) + return b.String() +} + +func (r *AgentConsole) bannerWidth() int { + const ( + minWidth = 44 + defaultWidth = 64 + maxWidth = 78 + ) + width := defaultWidth + if r != nil && r.terminal != nil && r.terminal.Control != nil { + if columns, _ := r.terminal.Control.Size(); columns > 0 { + width = columns - 4 + } + } else if r != nil && r.output != nil && r.output.Stderr() != nil { + if columns := writerTerminalWidth(r.output.Stderr()); columns > 0 { + width = columns - 4 + } + } + if width < minWidth { + return minWidth + } + if width > maxWidth { + return maxWidth + } + return width +} + +func writerTerminalWidth(w io.Writer) int { + file, ok := w.(*os.File) + if !ok { + return 0 + } + width, _, err := term.GetSize(int(file.Fd())) + if err != nil || width <= 0 { + return 0 + } + return width +} + +func bannerKV(label, value string, colorEnabled bool) string { + return ansiDim(fmt.Sprintf("%-9s", label), colorEnabled) + value +} + +func renderFixedBox(body string, width int, colorEnabled bool) string { + const minInnerWidth = 16 + innerWidth := width - 4 + if innerWidth < minInnerWidth { + innerWidth = minInnerWidth + } + lines := strings.Split(body, "\n") + for _, line := range lines { + if n := visibleRuneLen(line); n > innerWidth { + innerWidth = n + } + } + + border := func(s string) string { return ansiDim(s, colorEnabled) } + var b strings.Builder + fmt.Fprintf(&b, "%s\n", border("╭"+strings.Repeat("─", innerWidth+2)+"╮")) + for _, line := range lines { + padding := innerWidth - visibleRuneLen(line) + if padding < 0 { + padding = 0 + } + fmt.Fprintf(&b, "%s %s%s %s\n", + border("│"), + line, + strings.Repeat(" ", padding), + border("│")) + } + fmt.Fprint(&b, border("╰"+strings.Repeat("─", innerWidth+2)+"╯")) + return b.String() +} + +func visibleRuneLen(s string) int { + return len([]rune(outputpkg.StripANSI(s))) +} + +func ansiWrap(s, code string, enabled bool) string { + return outputpkg.NewColor(enabled).Wrap(s, code) +} + +func ansiTitle(s string, enabled bool) string { + return ansiWrap(s, outputpkg.ANSIBold+outputpkg.ANSICyan, enabled) +} + +func ansiAccent(s string, enabled bool) string { + return ansiWrap(s, outputpkg.ANSICyan, enabled) +} + +func ansiWarn(s string, enabled bool) string { + return ansiWrap(s, outputpkg.ANSIYellow, enabled) +} + +func ansiDim(s string, enabled bool) string { + return ansiWrap(s, outputpkg.ANSIDim, enabled) +} + +func renderInlineCommands(commands []string, colorEnabled bool) string { + parts := make([]string, 0, len(commands)) + for _, command := range commands { + parts = append(parts, ansiAccent(command, colorEnabled)) + } + return strings.Join(parts, ansiDim(" ", colorEnabled)) +} + +func (r *AgentConsole) sessionSummary() string { + var parts []string + if r != nil && r.output != nil { + switch r.output.mode { + case ModeStatic: + parts = append(parts, "static") + case ModeForwarded: + parts = append(parts, "forwarded") + default: + parts = append(parts, "pty") + } + if r.output.stream.enabled { + parts = append(parts, "stream") + } else if r.output.Markdown() { + parts = append(parts, "pretty") + } else { + parts = append(parts, "plain") + } + } + if r != nil && r.option != nil { + if space := strings.TrimSpace(r.option.Space); space != "" { + parts = append(parts, "space "+space) + } + } + if len(parts) == 0 { + return "pty" + } + return strings.Join(parts, " · ") +} + +func (r *AgentConsole) providerModel() (string, string) { + if r.appInfo.Commands == nil { + return "", "" + } + pc := r.appInfo.ProviderConfig + return pc.Provider, pc.Model +} + +func (r *AgentConsole) renderHelp() string { + colorEnabled := r.output != nil && r.output.color.Enabled + cmds := r.allCommands() + rows := make([]helpRow, 0, len(cmds)+3) + for _, c := range cmds { + if c.Hidden { + continue + } + rows = append(rows, helpRow{Command: c.Name, Detail: c.Description}) + } + rows = append(rows, helpRow{}) + rows = append(rows, helpRow{Command: "普通文本", Detail: "直接发送自然语言任务"}) + rows = append(rows, helpRow{Command: "! <命令>", Detail: "直接执行 bash/伪命令(跳过 LLM)"}) + return r.renderPanel("commands", renderHelpRows(rows, colorEnabled), colorEnabled) +} + +func (r *AgentConsole) renderStatus() string { + colorEnabled := r.output != nil && r.output.color.Enabled + info := CollectStatus(r.replSession(), r.sessionSummary(), agentConsoleHistoryPath()) + rows := []helpRow{ + {Command: "model", Detail: info.Provider + " / " + info.Model}, + {Command: "render", Detail: info.Mode}, + {Command: "task", Detail: info.Task}, + {Command: "ioa", Detail: info.IOA}, + {Command: "history", Detail: info.History}, + } + if info.Skills != "" { + rows = append(rows, helpRow{Command: "skills", Detail: info.Skills}) + } + return r.renderPanel("status", renderHelpRows(rows, colorEnabled), colorEnabled) +} + +type helpRow struct { + Command string + Detail string +} + +const helpRowCommandWidth = 18 + +func renderHelpRows(rows []helpRow, colorEnabled bool) string { + var b strings.Builder + for _, row := range rows { + if row.Command == "" && row.Detail == "" { + b.WriteByte('\n') + continue + } + command := ansiAccent(fmt.Sprintf("%-*s", helpRowCommandWidth, row.Command), colorEnabled) + detail := ansiDim(row.Detail, colorEnabled) + fmt.Fprintf(&b, "%s%s\n", command, detail) + } + return strings.TrimRight(b.String(), "\n") +} + +func (r *AgentConsole) renderPanel(title, body string, colorEnabled bool) string { + title = strings.TrimSpace(title) + if title == "" { + title = "aiscan" + } + header := ansiTitle(title, colorEnabled) + return "\n" + renderFixedBox(header+"\n"+body, r.bannerWidth(), colorEnabled) + "\n\n" +} diff --git a/pkg/tui/console.go b/pkg/tui/console.go index 51b2515..5618617 100644 --- a/pkg/tui/console.go +++ b/pkg/tui/console.go @@ -8,7 +8,6 @@ import ( "io" "os" "path/filepath" - "sort" "strings" "sync" "sync/atomic" @@ -21,10 +20,8 @@ import ( "github.com/chainreactors/aiscan/pkg/agent" ioaclient "github.com/chainreactors/ioa/client" "github.com/reeflective/console" - "github.com/reeflective/readline/inputrc" rlterm "github.com/reeflective/readline/terminal" "github.com/spf13/cobra" - "golang.org/x/term" ) const agentPromptCommandName = "__prompt" @@ -134,225 +131,6 @@ func NewAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInf return repl } -func configureAgentReadline(c *console.Console) { - if c == nil { - return - } - shell := c.Shell() - cfg := shell.Config - _ = cfg.Set("autocomplete", true) - _ = cfg.Set("usage-hint-always", false) - _ = cfg.Set("history-autosuggest", true) - _ = cfg.Set("show-all-if-ambiguous", true) - _ = cfg.Set("show-all-if-unmodified", true) - _ = cfg.Set("menu-complete-display-prefix", true) - _ = cfg.Set("page-completions", false) - _ = cfg.Set("completion-query-items", 1000) - _ = cfg.Set("bell-style", "none") - _ = cfg.Set("enable-bracketed-paste", false) - // Bind Tab to menu-complete so arrow keys navigate the dropdown. - for _, keymap := range []string{"emacs", "emacs-standard", "vi-insert"} { - _ = cfg.Bind(keymap, `\t`, "menu-complete", false) - _ = cfg.Bind(keymap, inputrc.Unescape(`\e[Z`), "menu-complete-backward", false) - } -} - -func (r *AgentConsole) configureInterruptKey() { - if r == nil || r.console == nil || r.console.Shell() == nil { - return - } - shell := r.console.Shell() - shell.Keymap.Register(map[string]func(){ - agentConsoleInterruptCommandName: func() { - r.handleEscapeInterruptKey() - }, - }) - escape := inputrc.Unescape(`\e`) - for _, keymap := range []string{"emacs", "emacs-standard"} { - _ = shell.Config.Bind(keymap, escape, agentConsoleInterruptCommandName, false) - } -} - -func (r *AgentConsole) configureCtrlCKey() { - if r == nil || r.console == nil || r.console.Shell() == nil { - return - } - shell := r.console.Shell() - shell.Keymap.Register(map[string]func(){ - agentConsoleCtrlCCommandName: func() { - r.handleCtrlC() - }, - }) - ctrlC := inputrc.Unescape(`\C-c`) - for _, keymap := range []string{"emacs", "emacs-standard"} { - _ = shell.Config.Bind(keymap, ctrlC, agentConsoleCtrlCCommandName, false) - } -} - -func (r *AgentConsole) handleCtrlC() { - if r.InterruptCurrentRun() { - return - } - if r.pendingExit.Load() { - os.Exit(0) - } - r.pendingExit.Store(true) - fmt.Fprintf(r.stderr, " Press Ctrl+C again to exit\n") - go func() { - time.Sleep(3 * time.Second) - r.pendingExit.Store(false) - }() - shell := r.console.Shell() - shell.Display.AcceptLine() - shell.History.Accept(false, false, errors.New(os.Interrupt.String())) -} - -func (r *AgentConsole) configureVerbosityToggleKey() { - if r == nil || r.console == nil || r.console.Shell() == nil { - return - } - shell := r.console.Shell() - shell.Keymap.Register(map[string]func(){ - agentConsoleToggleVerbosityCommandName: func() { - r.handleToggleVerbosity() - }, - }) - ctrlO := inputrc.Unescape(`\C-o`) - for _, keymap := range []string{"emacs", "emacs-standard"} { - _ = shell.Config.Bind(keymap, ctrlO, agentConsoleToggleVerbosityCommandName, false) - } -} - -func (r *AgentConsole) handleToggleVerbosity() { - out := r.ensureOutput() - if out == nil { - return - } - current := out.VerbosityLevel() - next := (current + 1) % 3 - out.SetVerbosity(next) - label := out.VerbosityLabel() - if out.color.Enabled { - fmt.Fprintf(r.stderr, "\n%s %s\n", - out.dim("verbosity:"), - out.color.Wrap(label, outputpkg.ANSICyan)) - } else { - fmt.Fprintf(r.stderr, "\nverbosity: %s\n", label) - } -} - -func (r *AgentConsole) handleEscapeInterruptKey() { - if r == nil || r.console == nil || r.console.Shell() == nil { - return - } - shell := r.console.Shell() - pending := string(shell.Keys.Read()) - if pending == "" { - pending = readPendingTerminalBytes(agentConsoleEscapeSequenceWait) - } - keymap := string(shell.Keymap.Main()) - if feed, ok := agentConsoleEscapeSequenceFeed(shell.Config.Binds[keymap], pending); ok { - shell.Keys.Feed(true, []rune(feed)...) - return - } - if pending != "" { - shell.Keys.Feed(true, []rune(pending)...) - return - } - r.InterruptCurrentRun() -} - -func agentConsoleEscapeSequenceFeed(binds map[string]inputrc.Bind, pending string) (string, bool) { - if len(binds) == 0 || pending == "" { - return "", false - } - sequence := inputrc.Unescape(`\e`) + pending - matches := make([]string, 0, 4) - for seq := range binds { - readlineSeq := agentConsoleReadlineSequence(seq) - if len(readlineSeq) <= 1 || !strings.HasPrefix(readlineSeq, inputrc.Unescape(`\e`)) { - continue - } - if strings.HasPrefix(sequence, readlineSeq) { - matches = append(matches, seq) - } - } - sort.Slice(matches, func(i, j int) bool { - left := agentConsoleReadlineSequence(matches[i]) - right := agentConsoleReadlineSequence(matches[j]) - if len(left) == len(right) { - return left < right - } - return len(left) > len(right) - }) - for _, seq := range matches { - bind := binds[seq] - replacement, ok := agentConsoleEquivalentNonEscapeBind(binds, bind) - if !ok { - continue - } - return replacement + sequence[len(agentConsoleReadlineSequence(seq)):], true - } - return "", false -} - -func agentConsoleEquivalentNonEscapeBind(binds map[string]inputrc.Bind, target inputrc.Bind) (string, bool) { - if target.Action == "" { - return "", false - } - candidates := make([]string, 0, 4) - for seq, bind := range binds { - if bind.Action != target.Action || bind.Macro != target.Macro || strings.HasPrefix(agentConsoleReadlineSequence(seq), inputrc.Unescape(`\e`)) { - continue - } - candidates = append(candidates, seq) - } - sort.Slice(candidates, func(i, j int) bool { - if len(candidates[i]) == len(candidates[j]) { - return candidates[i] < candidates[j] - } - return len(candidates[i]) < len(candidates[j]) - }) - if len(candidates) == 0 { - return agentConsoleFallbackNonEscapeBind(target) - } - return candidates[0], true -} - -func agentConsoleFallbackNonEscapeBind(target inputrc.Bind) (string, bool) { - switch target.Action { - case "previous-history", "history-search-backward": - return inputrc.Unescape(`\C-p`), true - case "next-history", "history-search-forward": - return inputrc.Unescape(`\C-n`), true - case "backward-char", "vi-backward-char": - return inputrc.Unescape(`\C-b`), true - case "forward-char", "vi-forward-char": - return inputrc.Unescape(`\C-f`), true - case "beginning-of-line": - return inputrc.Unescape(`\C-a`), true - case "end-of-line": - return inputrc.Unescape(`\C-e`), true - default: - return "", false - } -} - -func agentConsoleReadlineSequence(seq string) string { - if seq == "" { - return "" - } - converted := make([]rune, 0, len(seq)) - for _, r := range seq { - if inputrc.IsMeta(r) { - converted = append(converted, inputrc.Esc, inputrc.Demeta(r)) - continue - } - converted = append(converted, r) - } - return string(converted) -} - func (r *AgentConsole) Start() error { r.renderBanner() defer r.stopController() @@ -514,201 +292,6 @@ func (r *AgentConsole) executeArgs(ctx context.Context, args []string) error { return root.Execute() } -// renderBanner prints a compact welcome block to stderr: title/version, -// resolved model, the session mode, and a short next-step hint. It uses fixed -// ANSI tokens so redirected or recorded sessions do not receive terminal -// background probes. stderr-TTY-only and skipped in quiet mode so redirected -// logs stay clean. Printed once into the scrollback (PTY-forward safe). -func (r *AgentConsole) renderBanner() { - if r.output == nil || r.output.VerbosityLevel() < 0 || r.output.Stderr() == nil { - return - } - if !r.output.tty { - return - } - fmt.Fprint(r.output.Stderr(), r.bannerOutput()) -} - -func (r *AgentConsole) bannerOutput() string { - colorEnabled := r.output != nil && r.output.color.Enabled - provider, model := r.providerModel() - modelText := "not configured - run `aiscan --init`" - modelStyle := ansiWarn - switch { - case provider != "" && model != "": - modelText = provider + " / " + model - modelStyle = ansiAccent - case provider != "": - modelText = provider - modelStyle = ansiAccent - } - - width := r.bannerWidth() - header := ansiTitle("aiscan", colorEnabled) + " " + ansiDim("v"+cfg.Version, colorEnabled) - - var lines []string - lines = append(lines, header) - lines = append(lines, bannerKV("model", modelStyle(modelText, colorEnabled), colorEnabled)) - lines = append(lines, bannerKV("mode", ansiDim(r.sessionSummary(), colorEnabled), colorEnabled)) - lines = append(lines, bannerKV("help", renderInlineCommands([]string{"/help", "/status", "/exit"}, colorEnabled), colorEnabled)) - lines = append(lines, bannerKV("keys", ansiDim("Esc", colorEnabled)+" "+ansiDim("interrupt", colorEnabled)+ansiDim(" ", colorEnabled)+ansiDim("Ctrl+O", colorEnabled)+" "+ansiDim("verbosity", colorEnabled), colorEnabled)) - - box := renderFixedBox(strings.Join(lines, "\n"), width, colorEnabled) - intent := ansiDim("输入目标或任务即可;例如:扫描 192.168.1.10 的 Web 风险", colorEnabled) - - var b strings.Builder - fmt.Fprintln(&b) - fmt.Fprintln(&b, box) - fmt.Fprintln(&b, " "+intent) - if notice := strings.TrimSpace(r.startupNotice); notice != "" { - fmt.Fprintln(&b, " "+ansiWarn("⚠ "+notice, colorEnabled)) - } - fmt.Fprintln(&b) - return b.String() -} - -func (r *AgentConsole) bannerWidth() int { - const ( - minWidth = 44 - defaultWidth = 64 - maxWidth = 78 - ) - width := defaultWidth - if r != nil && r.terminal != nil && r.terminal.Control != nil { - if columns, _ := r.terminal.Control.Size(); columns > 0 { - width = columns - 4 - } - } else if r != nil && r.output != nil && r.output.Stderr() != nil { - if columns := writerTerminalWidth(r.output.Stderr()); columns > 0 { - width = columns - 4 - } - } - if width < minWidth { - return minWidth - } - if width > maxWidth { - return maxWidth - } - return width -} - -func writerTerminalWidth(w io.Writer) int { - file, ok := w.(*os.File) - if !ok { - return 0 - } - width, _, err := term.GetSize(int(file.Fd())) - if err != nil || width <= 0 { - return 0 - } - return width -} - -func bannerKV(label, value string, colorEnabled bool) string { - return ansiDim(fmt.Sprintf("%-9s", label), colorEnabled) + value -} - -func renderFixedBox(body string, width int, colorEnabled bool) string { - const minInnerWidth = 16 - innerWidth := width - 4 - if innerWidth < minInnerWidth { - innerWidth = minInnerWidth - } - lines := strings.Split(body, "\n") - for _, line := range lines { - if n := visibleRuneLen(line); n > innerWidth { - innerWidth = n - } - } - - border := func(s string) string { return ansiDim(s, colorEnabled) } - var b strings.Builder - fmt.Fprintf(&b, "%s\n", border("╭"+strings.Repeat("─", innerWidth+2)+"╮")) - for _, line := range lines { - padding := innerWidth - visibleRuneLen(line) - if padding < 0 { - padding = 0 - } - fmt.Fprintf(&b, "%s %s%s %s\n", - border("│"), - line, - strings.Repeat(" ", padding), - border("│")) - } - fmt.Fprint(&b, border("╰"+strings.Repeat("─", innerWidth+2)+"╯")) - return b.String() -} - -func visibleRuneLen(s string) int { - return len([]rune(outputpkg.StripANSI(s))) -} - -func ansiWrap(s, code string, enabled bool) string { - return outputpkg.NewColor(enabled).Wrap(s, code) -} - -func ansiTitle(s string, enabled bool) string { - return ansiWrap(s, outputpkg.ANSIBold+outputpkg.ANSICyan, enabled) -} - -func ansiAccent(s string, enabled bool) string { - return ansiWrap(s, outputpkg.ANSICyan, enabled) -} - -func ansiWarn(s string, enabled bool) string { - return ansiWrap(s, outputpkg.ANSIYellow, enabled) -} - -func ansiDim(s string, enabled bool) string { - return ansiWrap(s, outputpkg.ANSIDim, enabled) -} - -func renderInlineCommands(commands []string, colorEnabled bool) string { - parts := make([]string, 0, len(commands)) - for _, command := range commands { - parts = append(parts, ansiAccent(command, colorEnabled)) - } - return strings.Join(parts, ansiDim(" ", colorEnabled)) -} - -func (r *AgentConsole) sessionSummary() string { - var parts []string - if r != nil && r.output != nil { - switch r.output.mode { - case ModeStatic: - parts = append(parts, "static") - case ModeForwarded: - parts = append(parts, "forwarded") - default: - parts = append(parts, "pty") - } - if r.output.stream.enabled { - parts = append(parts, "stream") - } else if r.output.Markdown() { - parts = append(parts, "pretty") - } else { - parts = append(parts, "plain") - } - } - if r != nil && r.option != nil { - if space := strings.TrimSpace(r.option.Space); space != "" { - parts = append(parts, "space "+space) - } - } - if len(parts) == 0 { - return "pty" - } - return strings.Join(parts, " · ") -} - -func (r *AgentConsole) providerModel() (string, string) { - if r.appInfo.Commands == nil { - return "", "" - } - pc := r.appInfo.ProviderConfig - return pc.Provider, pc.Model -} - func (r *AgentConsole) replSession() *Session { s := &Session{ Ctx: r.ctx, @@ -985,72 +568,6 @@ func wrapCommand(cmd Command, s *Session) *cobra.Command { return cc } -// --------------------------------------------------------------------------- -// Rendering: help, status, panels -// --------------------------------------------------------------------------- - -func (r *AgentConsole) renderHelp() string { - colorEnabled := r.output != nil && r.output.color.Enabled - cmds := r.allCommands() - rows := make([]helpRow, 0, len(cmds)+3) - for _, c := range cmds { - if c.Hidden { - continue - } - rows = append(rows, helpRow{Command: c.Name, Detail: c.Description}) - } - rows = append(rows, helpRow{}) - rows = append(rows, helpRow{Command: "普通文本", Detail: "直接发送自然语言任务"}) - rows = append(rows, helpRow{Command: "! <命令>", Detail: "直接执行 bash/伪命令(跳过 LLM)"}) - return r.renderPanel("commands", renderHelpRows(rows, colorEnabled), colorEnabled) -} - -func (r *AgentConsole) renderStatus() string { - colorEnabled := r.output != nil && r.output.color.Enabled - info := CollectStatus(r.replSession(), r.sessionSummary(), agentConsoleHistoryPath()) - rows := []helpRow{ - {Command: "model", Detail: info.Provider + " / " + info.Model}, - {Command: "render", Detail: info.Mode}, - {Command: "task", Detail: info.Task}, - {Command: "ioa", Detail: info.IOA}, - {Command: "history", Detail: info.History}, - } - if info.Skills != "" { - rows = append(rows, helpRow{Command: "skills", Detail: info.Skills}) - } - return r.renderPanel("status", renderHelpRows(rows, colorEnabled), colorEnabled) -} - -type helpRow struct { - Command string - Detail string -} - -const helpRowCommandWidth = 18 - -func renderHelpRows(rows []helpRow, colorEnabled bool) string { - var b strings.Builder - for _, row := range rows { - if row.Command == "" && row.Detail == "" { - b.WriteByte('\n') - continue - } - command := ansiAccent(fmt.Sprintf("%-*s", helpRowCommandWidth, row.Command), colorEnabled) - detail := ansiDim(row.Detail, colorEnabled) - fmt.Fprintf(&b, "%s%s\n", command, detail) - } - return strings.TrimRight(b.String(), "\n") -} - -func (r *AgentConsole) renderPanel(title, body string, colorEnabled bool) string { - title = strings.TrimSpace(title) - if title == "" { - title = "aiscan" - } - header := ansiTitle(title, colorEnabled) - return "\n" + renderFixedBox(header+"\n"+body, r.bannerWidth(), colorEnabled) + "\n\n" -} - func (r *AgentConsole) ensureOutput() *AgentOutput { if r.output == nil { r.output = NewAgentOutput(r.option) diff --git a/pkg/tui/keybindings.go b/pkg/tui/keybindings.go new file mode 100644 index 0000000..6108ea0 --- /dev/null +++ b/pkg/tui/keybindings.go @@ -0,0 +1,233 @@ +package tui + +import ( + "errors" + "fmt" + "os" + "sort" + "strings" + "time" + + outputpkg "github.com/chainreactors/aiscan/core/output" + "github.com/reeflective/console" + "github.com/reeflective/readline/inputrc" +) + +func configureAgentReadline(c *console.Console) { + if c == nil { + return + } + shell := c.Shell() + cfg := shell.Config + _ = cfg.Set("autocomplete", true) + _ = cfg.Set("usage-hint-always", false) + _ = cfg.Set("history-autosuggest", true) + _ = cfg.Set("show-all-if-ambiguous", true) + _ = cfg.Set("show-all-if-unmodified", true) + _ = cfg.Set("menu-complete-display-prefix", true) + _ = cfg.Set("page-completions", false) + _ = cfg.Set("completion-query-items", 1000) + _ = cfg.Set("bell-style", "none") + _ = cfg.Set("enable-bracketed-paste", false) + // Bind Tab to menu-complete so arrow keys navigate the dropdown. + for _, keymap := range []string{"emacs", "emacs-standard", "vi-insert"} { + _ = cfg.Bind(keymap, `\t`, "menu-complete", false) + _ = cfg.Bind(keymap, inputrc.Unescape(`\e[Z`), "menu-complete-backward", false) + } +} + +func (r *AgentConsole) configureInterruptKey() { + if r == nil || r.console == nil || r.console.Shell() == nil { + return + } + shell := r.console.Shell() + shell.Keymap.Register(map[string]func(){ + agentConsoleInterruptCommandName: func() { + r.handleEscapeInterruptKey() + }, + }) + escape := inputrc.Unescape(`\e`) + for _, keymap := range []string{"emacs", "emacs-standard"} { + _ = shell.Config.Bind(keymap, escape, agentConsoleInterruptCommandName, false) + } +} + +func (r *AgentConsole) configureCtrlCKey() { + if r == nil || r.console == nil || r.console.Shell() == nil { + return + } + shell := r.console.Shell() + shell.Keymap.Register(map[string]func(){ + agentConsoleCtrlCCommandName: func() { + r.handleCtrlC() + }, + }) + ctrlC := inputrc.Unescape(`\C-c`) + for _, keymap := range []string{"emacs", "emacs-standard"} { + _ = shell.Config.Bind(keymap, ctrlC, agentConsoleCtrlCCommandName, false) + } +} + +func (r *AgentConsole) handleCtrlC() { + if r.InterruptCurrentRun() { + return + } + if r.pendingExit.Load() { + os.Exit(0) + } + r.pendingExit.Store(true) + fmt.Fprintf(r.stderr, " Press Ctrl+C again to exit\n") + go func() { + time.Sleep(3 * time.Second) + r.pendingExit.Store(false) + }() + shell := r.console.Shell() + shell.Display.AcceptLine() + shell.History.Accept(false, false, errors.New(os.Interrupt.String())) +} + +func (r *AgentConsole) configureVerbosityToggleKey() { + if r == nil || r.console == nil || r.console.Shell() == nil { + return + } + shell := r.console.Shell() + shell.Keymap.Register(map[string]func(){ + agentConsoleToggleVerbosityCommandName: func() { + r.handleToggleVerbosity() + }, + }) + ctrlO := inputrc.Unescape(`\C-o`) + for _, keymap := range []string{"emacs", "emacs-standard"} { + _ = shell.Config.Bind(keymap, ctrlO, agentConsoleToggleVerbosityCommandName, false) + } +} + +func (r *AgentConsole) handleToggleVerbosity() { + out := r.ensureOutput() + if out == nil { + return + } + current := out.VerbosityLevel() + next := (current + 1) % 3 + out.SetVerbosity(next) + label := out.VerbosityLabel() + if out.color.Enabled { + fmt.Fprintf(r.stderr, "\n%s %s\n", + out.dim("verbosity:"), + out.color.Wrap(label, outputpkg.ANSICyan)) + } else { + fmt.Fprintf(r.stderr, "\nverbosity: %s\n", label) + } +} + +func (r *AgentConsole) handleEscapeInterruptKey() { + if r == nil || r.console == nil || r.console.Shell() == nil { + return + } + shell := r.console.Shell() + pending := string(shell.Keys.Read()) + if pending == "" { + pending = readPendingTerminalBytes(agentConsoleEscapeSequenceWait) + } + keymap := string(shell.Keymap.Main()) + if feed, ok := agentConsoleEscapeSequenceFeed(shell.Config.Binds[keymap], pending); ok { + shell.Keys.Feed(true, []rune(feed)...) + return + } + if pending != "" { + shell.Keys.Feed(true, []rune(pending)...) + return + } + r.InterruptCurrentRun() +} + +func agentConsoleEscapeSequenceFeed(binds map[string]inputrc.Bind, pending string) (string, bool) { + if len(binds) == 0 || pending == "" { + return "", false + } + sequence := inputrc.Unescape(`\e`) + pending + matches := make([]string, 0, 4) + for seq := range binds { + readlineSeq := agentConsoleReadlineSequence(seq) + if len(readlineSeq) <= 1 || !strings.HasPrefix(readlineSeq, inputrc.Unescape(`\e`)) { + continue + } + if strings.HasPrefix(sequence, readlineSeq) { + matches = append(matches, seq) + } + } + sort.Slice(matches, func(i, j int) bool { + left := agentConsoleReadlineSequence(matches[i]) + right := agentConsoleReadlineSequence(matches[j]) + if len(left) == len(right) { + return left < right + } + return len(left) > len(right) + }) + for _, seq := range matches { + bind := binds[seq] + replacement, ok := agentConsoleEquivalentNonEscapeBind(binds, bind) + if !ok { + continue + } + return replacement + sequence[len(agentConsoleReadlineSequence(seq)):], true + } + return "", false +} + +func agentConsoleEquivalentNonEscapeBind(binds map[string]inputrc.Bind, target inputrc.Bind) (string, bool) { + if target.Action == "" { + return "", false + } + candidates := make([]string, 0, 4) + for seq, bind := range binds { + if bind.Action != target.Action || bind.Macro != target.Macro || strings.HasPrefix(agentConsoleReadlineSequence(seq), inputrc.Unescape(`\e`)) { + continue + } + candidates = append(candidates, seq) + } + sort.Slice(candidates, func(i, j int) bool { + if len(candidates[i]) == len(candidates[j]) { + return candidates[i] < candidates[j] + } + return len(candidates[i]) < len(candidates[j]) + }) + if len(candidates) == 0 { + return agentConsoleFallbackNonEscapeBind(target) + } + return candidates[0], true +} + +func agentConsoleFallbackNonEscapeBind(target inputrc.Bind) (string, bool) { + switch target.Action { + case "previous-history", "history-search-backward": + return inputrc.Unescape(`\C-p`), true + case "next-history", "history-search-forward": + return inputrc.Unescape(`\C-n`), true + case "backward-char", "vi-backward-char": + return inputrc.Unescape(`\C-b`), true + case "forward-char", "vi-forward-char": + return inputrc.Unescape(`\C-f`), true + case "beginning-of-line": + return inputrc.Unescape(`\C-a`), true + case "end-of-line": + return inputrc.Unescape(`\C-e`), true + default: + return "", false + } +} + +func agentConsoleReadlineSequence(seq string) string { + if seq == "" { + return "" + } + converted := make([]rune, 0, len(seq)) + for _, r := range seq { + if inputrc.IsMeta(r) { + converted = append(converted, inputrc.Esc, inputrc.Demeta(r)) + continue + } + converted = append(converted, r) + } + return string(converted) +} From 0b2af2e674ae745b0512abb34dcfda473d8bfc40 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Thu, 25 Jun 2026 08:44:04 -0700 Subject: [PATCH 4/4] chore: update v0.2.7 changelog, fix MITM lint and flaky test - Add MITM traffic capture section to v0.2.7 changelog - Fix 3 errcheck lint issues in mitm.go (Shutdown return, type assertions) - Widen SSH banner test timeouts to prevent flaky failures under load - Update README/README_CN taglines Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 2 +- README_CN.md | 2 +- docs/changelog.md | 37 ++++++++++++++++++++++++++---- pkg/tools/proxy/mitm.go | 10 +++++--- pkg/tools/proxy/mitm_bench_test.go | 4 ++-- 5 files changed, 44 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a999b3d..8bb636f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

aiscan logo

aiscan

-

Agentic Security Scanner — AI-driven reconnaissance meets deterministic scanning

+

AI-driven single-binary pentest agent with a built-in multi-engine arsenal, ready to go

Preview — APIs and features may change between releases

diff --git a/README_CN.md b/README_CN.md index f8b4645..baea679 100644 --- a/README_CN.md +++ b/README_CN.md @@ -1,7 +1,7 @@

aiscan logo

aiscan

-

Agentic Security Scanner — AI 驱动的侦察与确定性扫描融合

+

AI 驱动的面向实战的单文件渗透 agent,内置多引擎武器库开箱即用

Preview — 本项目处于早期预览阶段,API 和功能可能随版本变更

diff --git a/docs/changelog.md b/docs/changelog.md index 944caaa..aaa62d2 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,8 +1,8 @@ # Changelog -## v0.2.7 — Proton 敏感信息扫描 + /loop 循环任务 + TUI 交互增强 + 多 Provider 配置 +## v0.2.7 — MITM 流量捕获 + Proton 敏感信息扫描 + /loop 循环任务 + TUI 交互增强 -新增 Proton 敏感信息扫描器(SDK 引擎 + 197 条内嵌规则 + 双向管道);`/loop` 循环任务调度;TUI 交互全面增强(verbosity 切换、中断控制、文件补全、实时 token 用量);并发工具执行 OOM 防护;多 Provider 列表配置格式;FOFA key-only 认证支持。 +MITM 透明流量拦截(`proxy mitm` 子命令族);Proton 敏感信息扫描器(SDK 引擎 + 197 条内嵌规则 + 双向管道);`/loop` 循环任务调度;TUI 交互全面增强(verbosity 切换、中断控制、文件补全、实时 token 用量);多 Provider 列表配置格式;FOFA key-only 认证支持。 ### New Features @@ -67,6 +67,36 @@ bash(command="loop */5 * * * * check scan progress") bash(command="loop list") ``` +**MITM 流量捕获** + +透明 HTTP/HTTPS 流量拦截,集成 utils/mitmproxy 到 proxy 命令组。扫描引擎(gogo/spray/zombie/neutron)自动路由到本地 MITM 代理;若已有外部代理(trojan/vless/clash)则作为上游透传。 + +- `proxy mitm start [--addr]`:启动本地 MITM 代理,自动切换扫描引擎代理 +- `proxy mitm stop`:停止 MITM 并恢复之前的代理设置 +- `proxy mitm status`:查看状态和 flow 计数 +- `proxy mitm flows [--host/--status/--type/--last]`:按条件查询捕获的 HTTP 流 +- `proxy mitm flow `:查看单个 flow 详情 +- `proxy mitm clear`:清空 flow 存储 +- `proxy mitm analyze [--host/--last]`:结构化输出供 AI 分析 + +```bash +# 启动 MITM 拦截 +proxy mitm start --addr 127.0.0.1:8888 + +# 正常执行扫描(流量自动经过 MITM) +scan -i target + +# 查看捕获的流量 +proxy mitm flows --last 20 +proxy mitm flow 42 + +# AI 分析捕获的请求 +proxy mitm analyze --host target.com + +# 停止并恢复 +proxy mitm stop +``` + ### Improvements **TUI 交互增强** @@ -112,8 +142,6 @@ llm: - **Resources 统一**:4 套独立 config map(gogo/spray/zombie/proton)合并为单一 `configs map[string]map[string][]byte` + `Config(engine, name)` 方法 - **toolargs 共享工具包**:提取 `ResolveRelativePaths` 和 `NormalizeFlags` 到 `toolargs/`,6 个工具共用(proton/neutron/scan/spray/zombie/katana) -- **Deps.GetLogger()**:消除 5 个 register.go 中重复的 logger 提取模式 -- 净减 180 行代码 ### Bug Fixes @@ -127,6 +155,7 @@ llm: ### Dependencies - **spray [v1.3.1](https://github.com/chainreactors/spray/releases/tag/v1.3.1)**:mask 表达式支持所有请求字段、`--keys` 插件内嵌 156 条 proton 模板、extract severity 分级 + 上下文捕获、修复 crawl-only 提前 drain 和 OutputCh panic +- **utils/cert**:集成 utils/cert 原子化证书原语(CA 生成、子证书签发、随机 Subject、PEM 工具函数),移除本地 replace - bump SDK、zombie、logs、utils/pty 修复上游 data race ### Breaking Changes diff --git a/pkg/tools/proxy/mitm.go b/pkg/tools/proxy/mitm.go index 978b993..3c52103 100644 --- a/pkg/tools/proxy/mitm.go +++ b/pkg/tools/proxy/mitm.go @@ -193,7 +193,7 @@ func (s *mitmState) start() error { func (s *mitmState) stop() { if s.server != nil { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - s.server.Shutdown(ctx) + _ = s.server.Shutdown(ctx) cancel() s.server = nil } @@ -222,7 +222,9 @@ func (a *captureAddon) Requestheaders(f *mitmproxy.Flow) { func (a *captureAddon) Response(f *mitmproxy.Flow) { var dur time.Duration if start, ok := a.pending.LoadAndDelete(f.Id.String()); ok { - dur = time.Since(start.(time.Time)) + if t, ok := start.(time.Time); ok { + dur = time.Since(t) + } } flow := Flow{ Timestamp: f.StartTime, @@ -250,7 +252,9 @@ func (a *captureAddon) Response(f *mitmproxy.Flow) { func (a *captureAddon) RequestError(f *mitmproxy.Flow, err error) { var dur time.Duration if start, ok := a.pending.LoadAndDelete(f.Id.String()); ok { - dur = time.Since(start.(time.Time)) + if t, ok := start.(time.Time); ok { + dur = time.Since(t) + } } a.store.Add(Flow{ Timestamp: f.StartTime, diff --git a/pkg/tools/proxy/mitm_bench_test.go b/pkg/tools/proxy/mitm_bench_test.go index 69d1944..491cf12 100644 --- a/pkg/tools/proxy/mitm_bench_test.go +++ b/pkg/tools/proxy/mitm_bench_test.go @@ -206,7 +206,7 @@ func TestMITMCapture_ServerFirst_Fallback(t *testing.T) { t.Fatal(err) } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() conn, err := dial(ctx, "tcp", tcpServer.Addr().String()) if err != nil { @@ -215,7 +215,7 @@ func TestMITMCapture_ServerFirst_Fallback(t *testing.T) { defer conn.Close() buf := make([]byte, 64) - conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + conn.SetReadDeadline(time.Now().Add(8 * time.Second)) n, err := io.ReadAtLeast(conn, buf, 3) if err != nil { t.Fatalf("expected SSH banner data, got error: %v", err)