From d913695303bfd23ac980698aae0594f67a330f29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Wed, 27 May 2026 11:45:51 +0800 Subject: [PATCH] Add files via upload --- internal/handler/project.go | 175 ++++++++++++++++++---- internal/handler/project_context.go | 2 +- internal/handler/webshell_context.go | 2 +- internal/project/fact_recording_prompt.go | 100 +++++++++++++ internal/project/scope_block.go | 99 ++++++++++++ internal/project/scope_block_test.go | 40 +++++ internal/project/stats.go | 21 +++ 7 files changed, 408 insertions(+), 31 deletions(-) create mode 100644 internal/project/fact_recording_prompt.go create mode 100644 internal/project/scope_block.go create mode 100644 internal/project/scope_block_test.go create mode 100644 internal/project/stats.go diff --git a/internal/handler/project.go b/internal/handler/project.go index 228efb59..4d0a5a60 100644 --- a/internal/handler/project.go +++ b/internal/handler/project.go @@ -6,6 +6,7 @@ import ( "strings" "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/project" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -29,12 +30,13 @@ type createProjectRequest struct { Status string `json:"status"` } +// updateProjectRequest 部分更新:字段省略表示不修改;传 null 或 "" 可清空字符串字段。 type updateProjectRequest struct { - Name string `json:"name"` - Description string `json:"description"` - ScopeJSON string `json:"scope_json"` - Status string `json:"status"` - Pinned *bool `json:"pinned"` + Name *string `json:"name"` + Description *string `json:"description"` + ScopeJSON *string `json:"scope_json"` + Status *string `json:"status"` + Pinned *bool `json:"pinned"` } // CreateProject POST /api/projects @@ -75,6 +77,46 @@ func (h *ProjectHandler) ListProjects(c *gin.Context) { c.JSON(http.StatusOK, list) } +// GetProjectStats GET /api/projects/:id/stats +func (h *ProjectHandler) GetProjectStats(c *gin.Context) { + stats, err := project.GetProjectStats(h.db, c.Param("id")) + if err != nil { + if strings.Contains(err.Error(), "不存在") { + c.JSON(http.StatusNotFound, gin.H{"error": "项目不存在"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, stats) +} + +// ListProjectConversations GET /api/projects/:id/conversations +func (h *ProjectHandler) ListProjectConversations(c *gin.Context) { + projectID := c.Param("id") + if _, err := h.db.GetProject(projectID); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "项目不存在"}) + return + } + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100")) + offset, _ := strconv.Atoi(c.Query("offset")) + list, err := h.db.ListConversationsByProjectID(projectID, limit, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if list == nil { + list = []*database.Conversation{} + } + total, _ := h.db.CountConversationsByProjectID(projectID) + c.JSON(http.StatusOK, gin.H{ + "conversations": list, + "total": total, + "limit": limit, + "offset": offset, + }) +} + // GetProject GET /api/projects/:id func (h *ProjectHandler) GetProject(c *gin.Context) { p, err := h.db.GetProject(c.Param("id")) @@ -98,17 +140,21 @@ func (h *ProjectHandler) UpdateProject(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if s := strings.TrimSpace(req.Name); s != "" { - p.Name = s + if req.Name != nil { + if s := strings.TrimSpace(*req.Name); s != "" { + p.Name = s + } } - if req.Description != "" || c.Request.ContentLength > 0 { - p.Description = req.Description + if req.Description != nil { + p.Description = *req.Description } - if req.ScopeJSON != "" || c.GetHeader("Content-Type") != "" { - p.ScopeJSON = req.ScopeJSON + if req.ScopeJSON != nil { + p.ScopeJSON = *req.ScopeJSON } - if s := strings.TrimSpace(req.Status); s != "" { - p.Status = s + if req.Status != nil { + if s := strings.TrimSpace(*req.Status); s != "" { + p.Status = s + } } if req.Pinned != nil { p.Pinned = *req.Pinned @@ -139,6 +185,18 @@ type upsertFactRequest struct { RelatedVulnerabilityID string `json:"related_vulnerability_id"` } +// updateFactRequest 部分更新事实;指针字段省略=不修改,body 传 "" 可清空(仍走 merge 逻辑见 Upsert)。 +type updateFactRequest struct { + FactKey *string `json:"fact_key"` + Category *string `json:"category"` + Summary *string `json:"summary"` + Body *string `json:"body"` + Confidence *string `json:"confidence"` + Pinned *bool `json:"pinned"` + RelatedVulnerabilityID *string `json:"related_vulnerability_id"` + ClearBody bool `json:"clear_body"` +} + // ListFacts GET /api/projects/:id/facts (fact_key 查询参数可获取单条详情) func (h *ProjectHandler) ListFacts(c *gin.Context) { projectID := c.Param("id") @@ -154,9 +212,13 @@ func (h *ProjectHandler) ListFacts(c *gin.Context) { limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100")) offset, _ := strconv.Atoi(c.Query("offset")) filter := database.ProjectFactListFilter{ - Category: c.Query("category"), - Confidence: c.Query("confidence"), - Search: c.Query("search"), + Category: c.Query("category"), + Confidence: c.Query("confidence"), + Search: c.Query("search"), + RelatedVulnerabilityID: c.Query("related_vulnerability_id"), + } + if c.Query("exclude_deprecated") == "1" || c.Query("exclude_deprecated") == "true" { + filter.ExcludeDeprecated = true } list, err := h.db.ListProjectFacts(projectID, filter, limit, offset) if err != nil { @@ -166,6 +228,53 @@ func (h *ProjectHandler) ListFacts(c *gin.Context) { if list == nil { list = []*database.ProjectFact{} } + if sparseOnly := c.Query("sparse_only"); sparseOnly == "1" || sparseOnly == "true" { + filtered := make([]*database.ProjectFact, 0, len(list)) + for _, f := range list { + if project.IsSparseFactBody(f.Category, f.FactKey, f.Body) { + filtered = append(filtered, f) + } + } + list = filtered + } + c.JSON(http.StatusOK, list) +} + +// GetFactPreviousVersion GET /api/projects/:id/facts/:factId/previous-version +func (h *ProjectHandler) GetFactPreviousVersion(c *gin.Context) { + existing, err := h.db.GetProjectFact(c.Param("factId")) + if err != nil || existing.ProjectID != c.Param("id") { + c.JSON(http.StatusNotFound, gin.H{"error": "事实不存在"}) + return + } + if strings.TrimSpace(existing.SupersedesFactID) == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "无上一版本"}) + return + } + v, err := h.db.GetProjectFactVersion(existing.SupersedesFactID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, v) +} + +// ListFactVersions GET /api/projects/:id/facts/:factId/versions +func (h *ProjectHandler) ListFactVersions(c *gin.Context) { + existing, err := h.db.GetProjectFact(c.Param("factId")) + if err != nil || existing.ProjectID != c.Param("id") { + c.JSON(http.StatusNotFound, gin.H{"error": "事实不存在"}) + return + } + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) + list, err := h.db.ListProjectFactVersions(existing.ID, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if list == nil { + list = []*database.ProjectFactVersion{} + } c.JSON(http.StatusOK, list) } @@ -201,28 +310,36 @@ func (h *ProjectHandler) UpdateFact(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "事实不存在"}) return } - var req upsertFactRequest + var req updateFactRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if k := strings.TrimSpace(req.FactKey); k != "" { - existing.FactKey = k + if req.FactKey != nil { + if k := strings.TrimSpace(*req.FactKey); k != "" { + existing.FactKey = k + } } - if req.Category != "" { - existing.Category = req.Category + if req.Category != nil && strings.TrimSpace(*req.Category) != "" { + existing.Category = *req.Category } - if req.Summary != "" { - existing.Summary = req.Summary + if req.Summary != nil && strings.TrimSpace(*req.Summary) != "" { + existing.Summary = *req.Summary } - if strings.TrimSpace(req.Body) != "" { - existing.Body = req.Body + if req.ClearBody { + existing.Body = "" + } else if req.Body != nil { + existing.Body = *req.Body } - if req.Confidence != "" { - existing.Confidence = req.Confidence + if req.Confidence != nil && strings.TrimSpace(*req.Confidence) != "" { + existing.Confidence = *req.Confidence + } + if req.Pinned != nil { + existing.Pinned = *req.Pinned + } + if req.RelatedVulnerabilityID != nil { + existing.RelatedVulnerabilityID = *req.RelatedVulnerabilityID } - existing.Pinned = req.Pinned - existing.RelatedVulnerabilityID = req.RelatedVulnerabilityID updated, err := h.db.UpsertProjectFact(existing) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/internal/handler/project_context.go b/internal/handler/project_context.go index 66e0f943..4bfd2433 100644 --- a/internal/handler/project_context.go +++ b/internal/handler/project_context.go @@ -23,7 +23,7 @@ func (h *AgentHandler) projectBlackboardBlock(conversationID string) string { if err != nil || projectID == "" { return "" } - block, err := project.BuildFactIndexBlock(h.db, projectID, h.config.Project) + block, err := project.BuildProjectBlackboardBlock(h.db, projectID, h.config.Project) if err != nil { h.logger.Warn("构建项目黑板索引失败", zap.String("conversationId", conversationID), zap.Error(err)) return "" diff --git a/internal/handler/webshell_context.go b/internal/handler/webshell_context.go index 06419cc9..6a29c908 100644 --- a/internal/handler/webshell_context.go +++ b/internal/handler/webshell_context.go @@ -65,7 +65,7 @@ func BuildWebshellAssistantContext(conn *database.WebShellConnection, skillHint, b.WriteString(conn.ID) b.WriteString("\"):") b.WriteString(webshellAssistantToolList) - b.WriteString("。") + b.WriteString("。边渗透边记录:每确认新认知即 upsert_project_fact,每验证漏洞即 record_vulnerability,勿等会话结束。") b.WriteString(skillHint) b.WriteString("\n\n用户请求:") b.WriteString(userMsg) diff --git a/internal/project/fact_recording_prompt.go b/internal/project/fact_recording_prompt.go new file mode 100644 index 00000000..1e02e650 --- /dev/null +++ b/internal/project/fact_recording_prompt.go @@ -0,0 +1,100 @@ +package project + +import ( + "strings" + + "cyberstrike-ai/internal/mcp/builtin" +) + +// 边渗透边记录:统一节奏文案(agents/*.md 须与 FactRecordingIncrementalRhythmMarkdown 保持一致)。 +const ( + factRhythmCore = "勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。" + factRhythmCoordinatorSuffix = "委派/子任务返回新认知或漏洞时,由协调者及时写入,勿假定子代理已记。" + factRhythmSubAgentSuffix = "若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。" +) + +// FactRecordingIncrementalRhythmMarkdown 返回边渗透边记录节奏(Markdown,供 agents/*.md 与文档对齐)。 +func FactRecordingIncrementalRhythmMarkdown(coordinator, subAgent bool) string { + var b strings.Builder + b.WriteString("- **边渗透边记录(强制节奏)**:") + b.WriteString(factRhythmCore) + if coordinator { + b.WriteString(factRhythmCoordinatorSuffix) + } + if subAgent { + b.WriteString(factRhythmSubAgentSuffix) + } + return b.String() +} + +func factRecordingIncrementalRhythmBuiltin(coordinator, subAgent bool) string { + var b strings.Builder + b.WriteString("- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 ") + b.WriteString(builtin.ToolUpsertProjectFact) + b.WriteString("(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 ") + b.WriteString(builtin.ToolRecordVulnerability) + b.WriteString(";与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。") + if coordinator { + b.WriteString(factRhythmCoordinatorSuffix) + } + if subAgent { + b.WriteString(factRhythmSubAgentSuffix) + } + return b.String() +} + +// FactRecordingBlackboardSection 项目黑板与漏洞记录的完整系统提示块(单/多 Agent 主代理共用)。 +// coordinatorDelegate 为 true 时追加「协调者代子代理落库」说明(Deep / plan_execute / supervisor)。 +func FactRecordingBlackboardSection(coordinatorDelegate bool) string { + var b strings.Builder + b.WriteString("## 项目黑板(事实)与漏洞记录(分离)\n\n") + b.WriteString("当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 fact_key + 摘要)。**摘要不足时必须调用 ") + b.WriteString(builtin.ToolGetProjectFact) + b.WriteString("(fact_key) 获取 body,禁止凭摘要臆造细节。**\n\n") + b.WriteString(factRecordingIncrementalRhythmBuiltin(coordinatorDelegate, false)) + b.WriteString("\n\n") + b.WriteString("- **环境/目标/认证等认知**(非正式漏洞条目):使用 ") + b.WriteString(builtin.ToolUpsertProjectFact) + b.WriteString(",fact_key 建议 `category/slug`(如 target/primary_domain),同 key 覆盖更新;body 记端口/版本/凭据特征与证据来源。\n") + b.WriteString("- **发现与利用上下文**(审计复现):fact_key 建议 finding/、chain/、exploit/、poc/ 前缀;**body 必填**完整攻击链(入口 → 步骤 → 原始请求/响应或命令 → 现象 → 关联 related_vulnerability_id),**禁止仅写结论**;summary 写「什么 + 在哪 + 如何验证」一行要点。\n") + b.WriteString("- **可交付漏洞**:使用 ") + b.WriteString(builtin.ToolRecordVulnerability) + b.WriteString(",含标题、严重程度、类型、目标、证明(POC)、影响、修复建议。记前可先 ") + b.WriteString(builtin.ToolListVulnerabilities) + b.WriteString(" 查重,详情用 ") + b.WriteString(builtin.ToolGetVulnerability) + b.WriteString("(id)(默认仅当前项目/会话)。\n") + b.WriteString("- 同一发现可能需**各记一次**(事实记**完整攻击链与 exploit 细节**供复现,漏洞记正式 findings)。误报用 ") + b.WriteString(builtin.ToolDeprecateProjectFact) + b.WriteString(" 或漏洞状态 false_positive。\n") + b.WriteString("- 事实多时用 ") + b.WriteString(builtin.ToolListProjectFacts) + b.WriteString(" / ") + b.WriteString(builtin.ToolSearchProjectFacts) + b.WriteString(" 检索。\n\n") + b.WriteString(FactRecordingGuidanceBlock()) + b.WriteString("\n\n严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。") + return b.String() +} + +// FactRecordingSubAgentSection 子代理边渗透边记录(无工具时输出待落库条目)。 +func FactRecordingSubAgentSection() string { + return "## 边渗透边记录\n\n" + factRecordingIncrementalRhythmBuiltin(false, true) + "\n" +} + +// FactRecordingBlackboardSectionMarkdown 与 FactRecordingBlackboardSection 等价的 Markdown(工具名为字面量,供 agents/*.md)。 +func FactRecordingBlackboardSectionMarkdown(coordinatorDelegate bool) string { + var b strings.Builder + b.WriteString("## 项目黑板(事实)与漏洞记录(分离)\n\n") + b.WriteString("当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 `fact_key` + 摘要)。**摘要不足时必须调用 `get_project_fact(fact_key)` 获取 body,禁止凭摘要臆造细节。**\n\n") + b.WriteString(FactRecordingIncrementalRhythmMarkdown(coordinatorDelegate, false)) + b.WriteString("\n\n") + b.WriteString("- **环境/目标/认证等认知**(非正式漏洞):使用 **`upsert_project_fact`**,`fact_key` 建议 `category/slug`(如 `target/primary_domain`),同 key 覆盖更新;body 记端口/版本/凭据特征与证据来源。\n") + b.WriteString("- **发现与利用上下文**(审计复现):`fact_key` 建议 `finding/`、`chain/`、`exploit/`、`poc/` 前缀;**body 必填**完整攻击链(入口 → 步骤 → 原始请求/响应或命令 → 现象 → 关联 `related_vulnerability_id`),**禁止仅写结论**;summary 写「什么 + 在哪 + 如何验证」一行要点。\n") + b.WriteString("- **可交付漏洞**:使用 **`record_vulnerability`**(标题、描述、严重程度、类型、目标、证明 POC、影响、修复建议)。严重程度 critical / high / medium / low / info。\n") + b.WriteString("- 同一发现可能需**各记一次**(事实记可复现攻击链,漏洞记正式 findings)。误报用 **`deprecate_project_fact`** 或漏洞状态 false_positive。\n") + b.WriteString("- 事实多时用 **`list_project_facts`** / **`search_project_facts`** 检索。\n\n") + b.WriteString(FactRecordingGuidanceBlock()) + b.WriteString("\n\n严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。") + return b.String() +} diff --git a/internal/project/scope_block.go b/internal/project/scope_block.go new file mode 100644 index 00000000..e52cf1ea --- /dev/null +++ b/internal/project/scope_block.go @@ -0,0 +1,99 @@ +package project + +import ( + "encoding/json" + "fmt" + "strings" + + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/database" +) + +// projectScopePayload 解析 projects.scope_json(约定字段,可扩展)。 +type projectScopePayload struct { + Targets []string `json:"targets"` + Exclude []string `json:"exclude"` + Notes string `json:"notes"` +} + +// BuildScopeBlock 将项目 scope_json 格式化为 Agent 可读的授权范围块。 +func BuildScopeBlock(proj *database.Project) string { + if proj == nil { + return "" + } + raw := strings.TrimSpace(proj.ScopeJSON) + if raw == "" { + return "" + } + + var payload projectScopePayload + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return fmt.Sprintf("## 项目测试范围(project: %s)\n(scope_json 非合法 JSON,请人工核对配置)\n```\n%s\n```\n"+ + "仅对明确授权目标执行测试;超出范围须停止并说明。\n", proj.Name, truncateRunes(raw, 800)) + } + + var b strings.Builder + b.WriteString(fmt.Sprintf("## 项目测试范围(project: %s, id: %s)\n", proj.Name, proj.ID)) + b.WriteString("以下为授权边界,**必须遵守**:仅测试列出的 targets,避开 exclude,不得擅自扩大范围。\n") + + if len(payload.Targets) > 0 { + b.WriteString("\n**允许测试(targets)**:\n") + for _, t := range payload.Targets { + t = strings.TrimSpace(t) + if t != "" { + b.WriteString("- " + t + "\n") + } + } + } + if len(payload.Exclude) > 0 { + b.WriteString("\n**明确排除(exclude)**:\n") + for _, t := range payload.Exclude { + t = strings.TrimSpace(t) + if t != "" { + b.WriteString("- " + t + "\n") + } + } + } + if n := strings.TrimSpace(payload.Notes); n != "" { + b.WriteString("\n**说明(notes)**:\n" + n + "\n") + } + if len(payload.Targets) == 0 && len(payload.Exclude) == 0 && strings.TrimSpace(payload.Notes) == "" { + b.WriteString("\n(scope_json 已配置但未识别 targets/exclude/notes 字段,原始内容供参考)\n```json\n") + b.WriteString(truncateRunes(raw, 1200)) + b.WriteString("\n```\n") + } + b.WriteString("\n若目标不在 targets 内或命中 exclude,不得主动扫描/利用;需用户明确扩大授权后再继续。\n") + return b.String() +} + +func truncateRunes(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) + "…" +} + +// BuildProjectBlackboardBlock 组合测试范围 + 事实黑板索引。 +func BuildProjectBlackboardBlock(db *database.DB, projectID string, cfg config.ProjectConfig) (string, error) { + projectID = strings.TrimSpace(projectID) + if projectID == "" { + return "", nil + } + proj, err := db.GetProject(projectID) + if err != nil { + return "", err + } + parts := []string{} + if scope := strings.TrimSpace(BuildScopeBlock(proj)); scope != "" { + parts = append(parts, scope) + } + index, err := BuildFactIndexBlock(db, projectID, cfg) + if err != nil { + return "", err + } + if strings.TrimSpace(index) != "" { + parts = append(parts, index) + } + return strings.Join(parts, "\n\n"), nil +} diff --git a/internal/project/scope_block_test.go b/internal/project/scope_block_test.go new file mode 100644 index 00000000..11a5a264 --- /dev/null +++ b/internal/project/scope_block_test.go @@ -0,0 +1,40 @@ +package project + +import ( + "strings" + "testing" + + "cyberstrike-ai/internal/database" +) + +func TestBuildScopeBlock_targetsExcludeNotes(t *testing.T) { + proj := &database.Project{ + ID: "p1", + Name: "Acme", + ScopeJSON: `{"targets":["https://app.example.com"],"exclude":["*.cdn.example.com"],"notes":"仅 Web 层"}`, + } + block := BuildScopeBlock(proj) + if !strings.Contains(block, "https://app.example.com") { + t.Fatalf("missing target: %s", block) + } + if !strings.Contains(block, "cdn.example.com") { + t.Fatalf("missing exclude: %s", block) + } + if !strings.Contains(block, "仅 Web 层") { + t.Fatalf("missing notes: %s", block) + } +} + +func TestBuildScopeBlock_empty(t *testing.T) { + if BuildScopeBlock(&database.Project{Name: "X"}) != "" { + t.Fatal("expected empty") + } +} + +func TestBuildScopeBlock_invalidJSON(t *testing.T) { + proj := &database.Project{Name: "X", ScopeJSON: `{not json`} + block := BuildScopeBlock(proj) + if !strings.Contains(block, "非合法 JSON") { + t.Fatalf("unexpected: %s", block) + } +} diff --git a/internal/project/stats.go b/internal/project/stats.go new file mode 100644 index 00000000..b6e1d1b3 --- /dev/null +++ b/internal/project/stats.go @@ -0,0 +1,21 @@ +package project + +import "cyberstrike-ai/internal/database" + +// GetProjectStats 聚合项目统计(含待补全事实数)。 +func GetProjectStats(db *database.DB, projectID string) (*database.ProjectStats, error) { + stats, err := db.GetProjectStatsCounts(projectID) + if err != nil { + return nil, err + } + rows, err := db.ListProjectFactsForSparseCheck(projectID) + if err != nil { + return nil, err + } + for _, r := range rows { + if IsSparseFactBody(r.Category, r.FactKey, r.Body) { + stats.SparseFactCount++ + } + } + return stats, nil +}