Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a0df3ec
chore: 移除 cyber-ui submodule,收敛 .gitignore
Jul 7, 2026
bbd9c0e
fix(provider): LLM 协议推断/model 覆盖/能力对齐
Jul 7, 2026
8f801d7
feat(probe): 连接与 LLM 主动探活
Jul 7, 2026
e23a3a3
feat(slashcmd): 统一 slash 命令目录,固定盒子渲染 TUI 面板
Jul 7, 2026
d3e5bab
refactor(agent): 评估器结构化断言、事件 JSON、上下文截断
Jul 7, 2026
f70dfcc
refactor(tools): 扫描采集/spray/gogo/zombie 与输出渲染精简
Jul 7, 2026
93ed515
feat(web): 后端会话/SSE/store/配置热重载/webagent
Jul 7, 2026
3dca5ed
feat(web-ui): agent 控制台前端重构(i18n / 主题 / viewer / 原子组件)
Jul 7, 2026
e2938eb
chore(web): 重建内嵌静态资源
Jul 7, 2026
d90bff8
refactor(web-ui): 从 cyber-ui 子模块消费原子组件(ui/theme)
Jul 7, 2026
64694d7
build(deps): bump chainreactors/sdk to the merged ChainExec fix
Jul 7, 2026
5154efc
fix(ci): 源头修 golangci-lint 报错(errcheck/nilerr/gosec/misspell/unused/S…
Jul 7, 2026
c33f2b2
build(deps): tidy go.sum after sdk bump
Jul 7, 2026
b862bdf
fix(web-chat): REPL/! 命令多行输出用代码围栏渲染,避免塌成一行
Jul 7, 2026
a45e8f5
refactor(web-ui): 消费 cyber-ui 第二刀原子 + terminal 迁子模块
Jul 7, 2026
d7346d6
fix(web-ui): Tailwind 扫描 cyber-ui 子模块源码,补齐子模块内 utility
Jul 7, 2026
8770e7b
chore(web): 重建内嵌静态资源
Jul 7, 2026
dba07db
fix(tools): 恢复扫描工具数据事件桥接
Jul 7, 2026
63f6e0d
feat(truncate): 增加行级裁剪辅助函数
Jul 7, 2026
3dfbc6d
chore(web): 更新 cyber-ui 子模块指针
Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
64 changes: 21 additions & 43 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,53 +23,31 @@ dist/
*.sock.lock
/aiscan
/spray
aiscan_test
aiscan_test_eval
aiscan-test-provider
aiscan-test-ioa*
out/
scan_results.jsonl
pw_driver_bin
node_modules/
community.yaml
aiscan.yaml
config.yaml

# Local data / logs
.aiscan/
.playwright-cli/
tmp/
*.log
*.jsonl
*.png
*.jar
# Local runtime state / operator artifacts
/aiscan-deploy.yaml
/*.log
/.claude/
# operator scan outputs dumped at repo root (screenshots, IP lists, app dumps, findings/reports)
/*.png
/*_ips.txt
/apps_*.json
/*_findings.md
/*_report.md

# leftover from root-build workaround (see memory web-static-build-permission)
web/static.oldroot*
web/static.rootold*
web/static.*
web/static-stale/

# Test payloads / scratch files
webhook.json
xss_payload.json
xss_prod.json
cookies.txt
cookies_sup.txt
login_data.txt
weak_pwd.txt
sqli_payload.txt
ssrf_payload.json
ssti_payload.json
spoof_prod.json
payload.json
cart.json
chat.json
chat_msg.json
checkout.json
cms_body.html
cms_post.json
cms_post.txt
cms_xss.txt
portal.html
portal_full.html
refer/
agent
*.events.jsonl
*.err.log
*.out.log
*.pid
# operator artifacts kept on disk but out of git (2026-07-06 post-reset cleanup)
/monitor/
/scripts/
/scan_report.md
aiscan.yaml
1 change: 1 addition & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
[submodule "web/frontend/cyber-ui"]
path = web/frontend/cyber-ui
url = https://github.com/chainreactors/cyber-ui.git
branch = main
14 changes: 4 additions & 10 deletions cmd/aiscan/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/signal"
"slices"
"strconv"
"strings"
"sync"
Expand All @@ -30,6 +31,8 @@ type webCommand struct {
MaxScans int `long:"max-scans" default:"3" description:"Maximum concurrent scans"`
ScanTimeout int `long:"scan-timeout" default:"600" description:"Maximum scan runtime in seconds"`
IOAToken string `long:"ioa-token" description:"IOA access key (auto-generated if empty)"`
AdminToken string `long:"admin-token" env:"AISCAN_ADMIN_TOKEN" description:"Admin token gating local-agent launch endpoints (empty = open)"`
AgentBinary string `long:"agent-binary" env:"AISCAN_AGENT_BINARY" description:"Path to the aiscan binary used to launch local agents (default: this executable)"`
}

type cliOptions struct {
Expand Down Expand Up @@ -543,7 +546,7 @@ func applyScannerCommandArgs(scannerName string, args []string, option *cfg.Opti
key, value, hasValue := strings.Cut(arg, "=")
matched := false
for _, f := range scannerKnownFlags {
if !containsString(f.names, key) {
if !slices.Contains(f.names, key) {
continue
}
if scannerName == "scan" && key == "--ai" {
Expand Down Expand Up @@ -583,15 +586,6 @@ func flagValue(arg string, hasValue bool, value string, args []string, i *int) (
return args[*i], nil
}

func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}

func truthyFlagValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "1", "t", "true", "y", "yes", "on":
Expand Down
59 changes: 58 additions & 1 deletion cmd/aiscan/web_full.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"fmt"
"io/fs"
"net"
"net/http"
"os"
"path"
Expand Down Expand Up @@ -80,7 +81,26 @@ func runWeb(ctx context.Context, option *cfg.Option, opts webCommand, logger tel
ioaSvc := ioaserver.NewService(ioaserver.NewMemoryStore(), accessKey)
ioaHandler := ioaserver.AuthMiddleware(ioaSvc)(ioaserver.NewHandler(ioaSvc))

handler := web.NewHandler(service, pool, ioaHandler, newSPAFileServer(staticSub))
// Local agents: the hub can spawn `aiscan agent` children on its own host
// (one-click launch/stop from the UI). Each child dials the hub's loopback
// web + IOA endpoints — the IOA access key is embedded into the IOA URL — and
// registers in the pool like any node. The hub holds the only handle to them,
// so they are all killed on shutdown.
localAgents := web.NewLocalAgents(hubLocalURL(opts.Addr), accessKey, opts.AgentBinary, pool)
go func() {
<-ctx.Done()
localAgents.StopAll()
}()

// The local-agent launcher spawns processes on the hub host. When reachable
// off-loopback without an admin token it is wide open; warn loudly rather than
// silently. (Kept back-compat/open so an existing loopback hub works out of
// the box — set --admin-token to gate it.)
if opts.AdminToken == "" && !isLoopbackListen(opts.Addr) {
logger.Warnf("SECURITY: local-agent launch API is UNAUTHENTICATED on non-loopback %s — set --admin-token or AISCAN_ADMIN_TOKEN to gate /api/deploy/local", opts.Addr)
}

handler := web.NewHandler(service, pool, localAgents, opts.AdminToken, ioaHandler, newSPAFileServer(staticSub))

srv := &http.Server{
Addr: opts.Addr,
Expand Down Expand Up @@ -244,3 +264,40 @@ func findWebConfigFile(explicit string) string {
return ""
}

// ---------------------------------------------------------------------------
// Listen-address helpers
// ---------------------------------------------------------------------------

// isLoopbackListen reports whether addr binds only to loopback, so an open
// (token-less) control plane is not actually reachable from the network. A
// wildcard/empty host, a non-loopback IP, or an unparseable address is treated
// as exposed (returns false) so the security warning errs toward firing.
func isLoopbackListen(addr string) bool {
host, _, err := net.SplitHostPort(strings.TrimSpace(addr))
if err != nil {
return false
}
switch host {
case "", "0.0.0.0", "::", "[::]":
return false
}
if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil {
return ip.IsLoopback()
}
return host == "localhost"
}

// hubLocalURL derives the loopback URL a local agent child should dial from the
// server listen address. A wildcard/empty host becomes 127.0.0.1; an
// unparseable address yields "".
func hubLocalURL(addr string) string {
host, port, err := net.SplitHostPort(strings.TrimSpace(addr))
if err != nil || port == "" {
return ""
}
switch host {
case "", "0.0.0.0", "::", "[::]":
host = "127.0.0.1"
}
return "http://" + net.JoinHostPort(host, port)
}
40 changes: 36 additions & 4 deletions core/config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,26 +40,58 @@ func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup) {
option.Provider = selectedProvider
}

// AISCAN_BASE_URL is aiscan's own namespace: an intentional override that wins
// over a base URL set in the config file (CLI --base-url wins via the explicit gate).
if strings.TrimSpace(explicit.BaseURL) == "" {
if v := firstEnv(lookup, "AISCAN_BASE_URL", "AISCAN_BASEURL", "AISCAN_LLM_BASE_URL", "AISCAN_LLM_BASEURL"); v != "" {
option.BaseURL = v
} else if v := providerBaseURLEnv(selectedProvider, lookup); v != "" {
}
}
// Provider-scoped base-URL envs (ANTHROPIC_BASE_URL, OPENAI_BASE_URL, …) are
// commonly injected by the surrounding environment for *other* tools — e.g.
// Claude-Code-style gateways export ANTHROPIC_BASE_URL. Treat them as a fallback
// only: they must not silently override a base URL the user configured for aiscan
// (config file / Settings UI). Apply only when nothing else set one. Mirrors the
// model handling below so a hub-launched agent that inherits the hub's env still
// honors the Settings-saved base URL.
if strings.TrimSpace(option.BaseURL) == "" {
if v := providerBaseURLEnv(selectedProvider, lookup); v != "" {
option.BaseURL = v
}
}

// AISCAN_MODEL / AISCAN_LLM_MODEL are aiscan's *own* namespace: an intentional
// override that still wins over a model set in the config file (CLI --model
// wins over it via the explicit gate).
if strings.TrimSpace(explicit.Model) == "" {
if v := firstEnv(lookup, "AISCAN_MODEL", "AISCAN_LLM_MODEL"); v != "" {
option.Model = v
} else if v := providerModelEnv(selectedProvider, lookup); v != "" {
}
}
// Provider-scoped model envs (ANTHROPIC_MODEL, OPENAI_MODEL, …) are commonly
// injected by the surrounding environment for *other* tools — e.g. Claude-Code
// style gateways export ANTHROPIC_MODEL. Treat them as a fallback only: they
// must not silently override a model the user configured for aiscan (config
// file / Settings UI or --model). Apply only when nothing else set a model.
if strings.TrimSpace(option.Model) == "" {
if v := providerModelEnv(selectedProvider, lookup); v != "" {
option.Model = v
}
}

// AISCAN_API_KEY is aiscan's own namespace: an intentional override that wins
// over a key set in the config file (CLI --api-key wins via the explicit gate).
if strings.TrimSpace(explicit.APIKey) == "" {
if v := providerAPIKeyEnv(selectedProvider, lookup); v != "" {
if v := firstEnv(lookup, "AISCAN_API_KEY", "AISCAN_LLM_API_KEY"); v != "" {
option.APIKey = v
} else if v := firstEnv(lookup, "AISCAN_API_KEY", "AISCAN_LLM_API_KEY"); v != "" {
}
}
// Provider-scoped key envs (ANTHROPIC_API_KEY, OPENAI_API_KEY) are commonly
// present for *other* tools. Treat them as a fallback only so they never override
// a key the user configured for aiscan (config file / Settings UI). Apply only
// when nothing else set one — same rationale as base URL and model above.
if strings.TrimSpace(option.APIKey) == "" {
if v := providerAPIKeyEnv(selectedProvider, lookup); v != "" {
option.APIKey = v
}
}
Expand Down
105 changes: 105 additions & 0 deletions core/config/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,111 @@ llm:
})
}

// A provider-scoped model env (ANTHROPIC_MODEL) is often injected by the
// surrounding environment for another tool (a Claude-Code style gateway). It must
// NOT override a model the user configured for aiscan itself — otherwise editing
// the model in the config file / Settings UI has no effect at runtime. AISCAN_MODEL
// (aiscan's own namespace) keeps overriding; the borrowed provider env only fills
// an empty slot.
func TestResolveRuntimeConfigConfigModelBeatsBorrowedProviderModelEnv(t *testing.T) {
dir := t.TempDir()
writeTestConfig(t, dir, `
llm:
provider: anthropic
base_url: https://kiro.example/v1
api_key: config-key
model: kimi-for-coding
`)
t.Setenv("ANTHROPIC_MODEL", "claude-opus-4-8")

withDefaults(t, func() {
origDir, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origDir)

option := Option{}
if _, err := ResolveRuntimeConfig(&option); err != nil {
t.Fatal(err)
}
if option.Model != "kimi-for-coding" {
t.Errorf("config model should win over borrowed ANTHROPIC_MODEL: got %q, want %q", option.Model, "kimi-for-coding")
}
})

// With no model in the config, the borrowed provider env still fills the gap.
withDefaults(t, func() {
emptyDir := t.TempDir()
writeTestConfig(t, emptyDir, "llm:\n provider: anthropic\n base_url: https://kiro.example/v1\n api_key: config-key\n")
origDir, _ := os.Getwd()
os.Chdir(emptyDir)
defer os.Chdir(origDir)

option := Option{}
if _, err := ResolveRuntimeConfig(&option); err != nil {
t.Fatal(err)
}
if option.Model != "claude-opus-4-8" {
t.Errorf("borrowed ANTHROPIC_MODEL should fill an empty model: got %q, want %q", option.Model, "claude-opus-4-8")
}
})
}

// Same borrowed-env hazard as the model case, but for base_url and api_key: a
// hub-launched agent inherits the hub's env, which on a Claude-Code style gateway
// exports ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY. Those must NOT override the
// base URL / key the user saved via the Settings UI (the config file) — otherwise
// "启动本地 Agent … 模型/密钥沿用当前配置" silently uses the borrowed env instead.
func TestResolveRuntimeConfigConfigBaseURLAndKeyBeatBorrowedProviderEnv(t *testing.T) {
dir := t.TempDir()
writeTestConfig(t, dir, `
llm:
provider: anthropic
base_url: https://kiro.example/v1
api_key: config-key
model: kimi-for-coding
`)
t.Setenv("ANTHROPIC_BASE_URL", "https://borrowed.example/v1")
t.Setenv("ANTHROPIC_API_KEY", "borrowed-key")

withDefaults(t, func() {
origDir, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origDir)

option := Option{}
if _, err := ResolveRuntimeConfig(&option); err != nil {
t.Fatal(err)
}
if option.BaseURL != "https://kiro.example/v1" {
t.Errorf("config base_url should win over borrowed ANTHROPIC_BASE_URL: got %q, want %q", option.BaseURL, "https://kiro.example/v1")
}
if option.APIKey != "config-key" {
t.Errorf("config api_key should win over borrowed ANTHROPIC_API_KEY: got %q, want %q", option.APIKey, "config-key")
}
})

// With no base_url / api_key in the config, the borrowed provider env still
// fills the gap (unchanged fallback behavior).
withDefaults(t, func() {
emptyDir := t.TempDir()
writeTestConfig(t, emptyDir, "llm:\n provider: anthropic\n model: kimi-for-coding\n")
origDir, _ := os.Getwd()
os.Chdir(emptyDir)
defer os.Chdir(origDir)

option := Option{}
if _, err := ResolveRuntimeConfig(&option); err != nil {
t.Fatal(err)
}
if option.BaseURL != "https://borrowed.example/v1" {
t.Errorf("borrowed ANTHROPIC_BASE_URL should fill an empty base_url: got %q, want %q", option.BaseURL, "https://borrowed.example/v1")
}
if option.APIKey != "borrowed-key" {
t.Errorf("borrowed ANTHROPIC_API_KEY should fill an empty api_key: got %q, want %q", option.APIKey, "borrowed-key")
}
})
}

func TestProvidersListOnly(t *testing.T) {
option := Option{}
option.Providers = []LLMProviderEntry{
Expand Down
Loading
Loading