From eb078579e910816aca41f1bf6a3af270bca8e728 Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Tue, 7 Jul 2026 04:38:13 -0700 Subject: [PATCH 1/2] fix(gogo): populate pkg.ChainExec when injecting neutron templates engine.NeutronScan drives the package-global pkg.ChainExec, which is only ever assigned by pkg.LoadTemplates. applyInjectedNeutron injects templates by setting pkg.TemplateMap directly and bypasses LoadTemplates, so pkg.ChainExec stays nil. Any exploit scan (RunnerOption.Exploit != "none") then calls ChainExec.Execute on a nil receiver, panicking inside engine.NeutronScan -> executeTemplates. The panic is caught only by the ants scan pool ("worker exits from panic"), which silently drops that host's result. Build the ChainExecutor alongside TemplateMap (same registration gogo's own pkg.LoadTemplates performs) so the injected-template path keeps the two package globals consistent. Co-Authored-By: Claude Opus 4.8 (1M context) --- gogo/gogo.go | 19 +++++++++++++++ gogo/gogo_chainexec_test.go | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 gogo/gogo_chainexec_test.go diff --git a/gogo/gogo.go b/gogo/gogo.go index 08f98ca..f79dadb 100644 --- a/gogo/gogo.go +++ b/gogo/gogo.go @@ -11,6 +11,7 @@ import ( "github.com/chainreactors/gogo/v2/engine" "github.com/chainreactors/gogo/v2/pkg" "github.com/chainreactors/logs" + neutrontemplates "github.com/chainreactors/neutron/templates" sdkfingers "github.com/chainreactors/sdk/fingers" "github.com/chainreactors/sdk/neutron" "github.com/chainreactors/sdk/pkg/types" @@ -84,6 +85,23 @@ func buildTemplateMap(templates []*types.Template) map[string][]*types.Template return templateMap } +// buildChainExecutor 构建 gogo 漏洞扫描所需的 chain 执行器。 +// +// gogo 的 engine.NeutronScan 依赖包级全局 pkg.ChainExec 来解析模板链,而该全局 +// 原本只由 pkg.LoadTemplates 赋值。SDK 注入模板时走的是 buildTemplateMap 直接填 +// pkg.TemplateMap 的旁路,绕过了 LoadTemplates,因此必须在这里同步构建 ChainExec, +// 否则开启 exploit 扫描(Exploit != "none")时 ChainExec.Execute 会打在 nil 上导致 +// ants worker panic。语义与 pkg.LoadTemplates 中的 chainExec 构建保持一致。 +func buildChainExecutor(templates []*types.Template) *neutrontemplates.ChainExecutor { + chainExec := neutrontemplates.NewChainExecutor(neutrontemplates.ChainConfig{}) + for _, template := range templates { + if template.Id != "" { + chainExec.Add(template.Id, template.Chains) + } + } + return chainExec +} + // Init 初始化引擎(加载指纹库等) func (e *Engine) Init() error { e.mu.Lock() @@ -166,6 +184,7 @@ func (e *Engine) applyInjectedNeutron() bool { } templateMap := buildTemplateMap(templates) pkg.TemplateMap = templateMap + pkg.ChainExec = buildChainExecutor(templates) templateCount := 0 for _, values := range templateMap { templateCount += len(values) diff --git a/gogo/gogo_chainexec_test.go b/gogo/gogo_chainexec_test.go new file mode 100644 index 0000000..832a79e --- /dev/null +++ b/gogo/gogo_chainexec_test.go @@ -0,0 +1,46 @@ +package gogo + +import ( + "testing" + + neutrontemplates "github.com/chainreactors/neutron/templates" + "github.com/chainreactors/sdk/pkg/types" +) + +// Regression for the nil-pointer panic in engine.NeutronScan: when the SDK +// injects templates it populates pkg.TemplateMap directly (buildTemplateMap), +// bypassing pkg.LoadTemplates which is the only other place that builds +// pkg.ChainExec. buildChainExecutor must rebuild that executor so exploit +// scans don't call ChainExec.Execute on a nil receiver. +func TestBuildChainExecutorRegistersIDsAndChains(t *testing.T) { + tmpls := []*types.Template{ + {Id: "entry", Chains: []string{"leaf"}}, + {Id: "leaf"}, + {Id: ""}, // no id: must be skipped without panicking + } + + ce := buildChainExecutor(tmpls) + if ce == nil { + t.Fatal("buildChainExecutor returned nil executor") + } + if !ce.Has("entry") || !ce.Has("leaf") { + t.Fatalf("registered ids = entry:%v leaf:%v, want both true", ce.Has("entry"), ce.Has("leaf")) + } + + // "leaf" is a chain target of "entry", so only "entry" is an entrypoint. + eps := ce.Entrypoints() + if len(eps) != 1 || eps[0] != "entry" { + t.Fatalf("entrypoints = %v, want [entry]", eps) + } + + // Executing from the entrypoint must walk entry -> leaf without panic + // (the panic being fixed is exactly this call on a nil receiver). + var visited []string + ce.Execute(eps, func(id string, _ map[string]interface{}) *neutrontemplates.ChainResult { + visited = append(visited, id) + return &neutrontemplates.ChainResult{} + }) + if len(visited) != 2 { + t.Fatalf("chain walk visited %v, want entry+leaf", visited) + } +} From a08d850e15956a3fb247d36a6f9f0ec569100282 Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Tue, 7 Jul 2026 05:01:41 -0700 Subject: [PATCH 2/2] test: repair two stale tests pre-existing on master Both fail on master independently of the ChainExec fix; bundled here so this PR's CI can go green (requested by maintainer). - neutron/config_templates_test.go: OperatorResult (= parsers.NeutronResult) now embeds parsers.Result, so Matched is a promoted field that cannot be set in a composite literal. Assign it after construction. (Was: "unknown field Matched in struct literal" -> neutron [build failed].) - proton/engine_test.go TestEngine_ConfigFilter_IDs: the file extractor filters out dummy values like "test", so `password = test` yielded 0 findings. Use a realistic value ("test123", as the sibling Execute test already does); keep "PRIVATE KEY" in the input to assert the excluded private-key-detect rule does not fire. Not touched: examples/cases/spray_crawl_finger.TestSprayCrawlAndDeepFinger fast-failed (~0.9s, not a timeout) once in CI but passes 15/15 locally and shows no data race; it is flaky under parallel-package load, not stale. Left as-is rather than hack an assertion I cannot reproduce failing. Co-Authored-By: Claude Opus 4.8 (1M context) --- neutron/config_templates_test.go | 6 +++++- proton/engine_test.go | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/neutron/config_templates_test.go b/neutron/config_templates_test.go index c680f73..052b07d 100644 --- a/neutron/config_templates_test.go +++ b/neutron/config_templates_test.go @@ -67,10 +67,14 @@ func TestExecuteTaskAndResultHelpers(t *testing.T) { t.Fatal("expected explicit empty templates to fail validation") } + // OperatorResult (parsers.NeutronResult) embeds parsers.Result, so Matched is + // a promoted field and can't be set in a composite literal — assign it directly. + matchedResult := &types.OperatorResult{} + matchedResult.Matched = true matched := &ExecuteResult{ success: true, template: &types.Template{Id: "demo"}, - data: &NeutronResult{Result: &types.OperatorResult{Matched: true}}, + data: &NeutronResult{Result: matchedResult}, } if !matched.Success() || !matched.Matched() || matched.Template().Id != "demo" || matched.Data() != matched.Result() { t.Fatalf("unexpected matched result helpers: %+v", matched) diff --git a/proton/engine_test.go b/proton/engine_test.go index d59b817..9ae5af3 100644 --- a/proton/engine_test.go +++ b/proton/engine_test.go @@ -457,7 +457,10 @@ func TestEngine_ConfigFilter_IDs(t *testing.T) { }) eng := mustEngine(t, NewConfig().WithTemplatePaths(tmplDir).WithIDs("password-extract")) - findings := eng.ScanData([]byte("PRIVATE KEY\npassword = test\n"), "all.txt") + // The file extractor drops dummy values like "test"; use a realistic value so + // only the ID filter (not the FP filter) decides the outcome. "PRIVATE KEY" stays + // in the input to assert the excluded private-key-detect rule does not fire. + findings := eng.ScanData([]byte("PRIVATE KEY\npassword = test123\n"), "all.txt") if len(findings) != 1 { t.Fatalf("expected 1 finding, got %d", len(findings)) }