Skip to content

Commit 500edff

Browse files
h0tak88rclaude
andcommitted
fix(ai+ci): provider fallback, batch finding report, DB-less cache guard, vet fix
AI provider fallback: - ui_api.go adds aiChat(): tries OpenRouter (UI header key or OPENROUTER_API_KEY) then falls back to the shared brain.ChatWithAI chain (OpenCode -> Z.ai -> Gemini). apiValidateFinding and apiReportFinding now use it, so an OpenCode-only setup no longer fails with "No OpenRouter API key configured". Multi-finding AI report: - New POST /api/findings/report-batch (apiReportFindingsBatch): accepts 1..N selected findings (capped at 25), builds a prompt from each finding's target/type/severity/module/evidence, and uses an attacker-mindset system prompt enforcing the Title/Summary/Steps-to-Reproduce/Impact structure (one report per finding, separated by ---). Routed through aiChat. - scan-detail.js: new "Report selected (AI)" toolbar button reusing the existing finding checkboxes; collects checked rows, POSTs them, and shows the generated report in a copyable modal (Copy / Close / Esc). Programs cache hardening: - programsCacheEnabled() gates the DB-backed cache on DB_HOST. Without a DB the handler now skips cache + background refresh entirely and just does a live fetch, preventing a DB-less deployment from looping expensive upstream rebuilds that can never be persisted. CI fix: - zerodays.go: use net.JoinHostPort instead of fmt.Sprintf("%s:%d", ...) for the MongoDB dial address. Fixes `CGO_ENABLED=1 go vet ./...` failing with "address format %s:%d does not work with IPv6". CI replicated locally: go vet, go build, go test all pass (CGO_ENABLED=1). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent abcb424 commit 500edff

6 files changed

Lines changed: 319 additions & 99 deletions

File tree

internal/api/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,7 @@ func SetupAPI() *gin.Engine {
572572
// AI finding validation & reporting
573573
apiGroup.POST("/findings/validate", apiValidateFinding)
574574
apiGroup.POST("/findings/report", apiReportFinding)
575+
apiGroup.POST("/findings/report-batch", apiReportFindingsBatch)
575576
// KeyHack templates
576577
apiGroup.GET("/keyhacks", apiListKeyhacks)
577578
apiGroup.GET("/keyhacks/search", apiSearchKeyhacks)

internal/api/programs_api.go

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -84,27 +84,35 @@ func apiListPrograms(c *gin.Context) {
8484
sortBy := c.DefaultQuery("sort", "name")
8585
forceRefresh := c.DefaultQuery("refresh", "false") == "true"
8686

87-
// A manual "refresh now" rebuilds in the background (the rebuild makes ~1000
88-
// upstream calls and takes ~a minute — far too long to block the request on).
89-
// We kick it off and still serve the current cache instantly below.
90-
if forceRefresh {
91-
refreshProgramsCacheAsync()
92-
}
93-
94-
// Warm-cache fast path: serve the pre-fetched payload (scope already baked in)
95-
// instantly. If it is stale, refresh in the background and still serve now.
96-
if payload, ok := loadProgramsCache(); ok && len(payload.Programs) > 0 {
97-
stale := time.Since(payload.GeneratedAt) > programsCacheTTL
98-
if stale && !forceRefresh {
87+
// The DB-backed cache is only usable when a DB is configured. Without it we
88+
// skip all cache/background-refresh logic and just do a live fetch (the
89+
// original behavior) — otherwise every request would kick an expensive
90+
// upstream rebuild that can never be persisted.
91+
cacheOn := programsCacheEnabled()
92+
93+
if cacheOn {
94+
// A manual "refresh now" rebuilds in the background (the rebuild makes ~1000
95+
// upstream calls and takes ~a minute — far too long to block the request on).
96+
// We kick it off and still serve the current cache instantly below.
97+
if forceRefresh {
9998
refreshProgramsCacheAsync()
10099
}
101-
serveProgramsPayload(c, payload, platform, sortBy, stale)
102-
return
103-
}
104100

105-
// Cold path (cache not built yet): fall back to a live fetch so the first-ever
106-
// load still works, and kick a background build so the next load is instant.
107-
defer refreshProgramsCacheAsync()
101+
// Warm-cache fast path: serve the pre-fetched payload (scope already baked in)
102+
// instantly. If it is stale, refresh in the background and still serve now.
103+
if payload, ok := loadProgramsCache(); ok && len(payload.Programs) > 0 {
104+
stale := time.Since(payload.GeneratedAt) > programsCacheTTL
105+
if stale && !forceRefresh {
106+
refreshProgramsCacheAsync()
107+
}
108+
serveProgramsPayload(c, payload, platform, sortBy, stale)
109+
return
110+
}
111+
112+
// Cold path (cache not built yet): fall back to a live fetch so the first-ever
113+
// load still works, and kick a background build so the next load is instant.
114+
defer refreshProgramsCacheAsync()
115+
}
108116

109117
var allPrograms []ProgramSummary
110118
var mu sync.Mutex

internal/api/programs_cache.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"log"
66
"os"
7+
"strings"
78
"sync"
89
"time"
910

@@ -48,6 +49,16 @@ var (
4849
programsRefreshing bool
4950
)
5051

52+
// programsCacheEnabled reports whether the DB-backed cache should be used at all.
53+
// Without a DB configured there is nowhere to persist the payload, so the cache
54+
// (and its background refresh) is disabled — the handler then just does a live
55+
// fetch, exactly as it did before this cache existed. This prevents a DB-less
56+
// deployment from triggering an endless loop of expensive upstream rebuilds that
57+
// can never be saved.
58+
func programsCacheEnabled() bool {
59+
return strings.TrimSpace(os.Getenv("DB_HOST")) != ""
60+
}
61+
5162
// loadProgramsCache reads and unmarshals the persisted payload.
5263
// ok is false when the cache is absent or unreadable.
5364
func loadProgramsCache() (programsCachePayload, bool) {

internal/api/ui/pages/scan-detail.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,34 @@
1414
const escAttr = (...args) => window.escAttr(...args);
1515
const copyToClipboard = (...args) => window.copyToClipboard(...args);
1616

17+
// showReportModal renders generated AI report text in a copyable overlay.
18+
function showReportModal(title, text) {
19+
document.getElementById('ai-report-modal')?.remove();
20+
const overlay = document.createElement('div');
21+
overlay.id = 'ai-report-modal';
22+
overlay.style.cssText = 'position:fixed;inset:0;z-index:10000;background:rgba(2,6,23,.72);display:flex;align-items:center;justify-content:center;padding:24px;backdrop-filter:blur(2px)';
23+
overlay.innerHTML = `
24+
<div style="background:var(--bg-card,#0b1220);border:1px solid var(--border,rgba(255,255,255,.12));border-radius:12px;max-width:860px;width:100%;max-height:86vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.5)">
25+
<div style="display:flex;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid var(--border,rgba(255,255,255,.1))">
26+
<div style="font-weight:600;font-size:14px;color:var(--text-primary,#fff);flex:1">${esc(title)}</div>
27+
<button type="button" id="ai-report-copy" style="padding:6px 12px;background:rgba(52,211,153,.12);border:1px solid rgba(52,211,153,.4);border-radius:6px;color:#34d399;font-size:12px;cursor:pointer">Copy</button>
28+
<button type="button" id="ai-report-close" style="padding:6px 12px;background:rgba(255,255,255,.06);border:1px solid var(--border,rgba(255,255,255,.15));border-radius:6px;color:var(--text-secondary,#cbd5e1);font-size:12px;cursor:pointer">Close</button>
29+
</div>
30+
<pre id="ai-report-body" style="margin:0;padding:18px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;line-height:1.55;color:var(--text-primary,#e2e8f0)"></pre>
31+
</div>`;
32+
overlay.querySelector('#ai-report-body').textContent = text;
33+
const close = () => overlay.remove();
34+
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
35+
overlay.querySelector('#ai-report-close').addEventListener('click', close);
36+
overlay.querySelector('#ai-report-copy').addEventListener('click', async () => {
37+
try { await copyToClipboard(text); showToast('success', 'Copied', 'Report copied to clipboard'); }
38+
catch (e) { showToast('error', 'Copy failed', e.message || String(e)); }
39+
});
40+
const onKey = (e) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } };
41+
document.addEventListener('keydown', onKey);
42+
document.body.appendChild(overlay);
43+
}
44+
1745
// ── State for Scan Detail Page ──────────────────────────────────────────
1846
window._scanDetailKnownFiles = new Set();
1947
window._scanDetailRefreshTimer = null;
@@ -900,6 +928,7 @@
900928
<div id="recon-quick-tools" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 10px;border-bottom:1px solid var(--border);background:rgba(2,6,23,.38)">
901929
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
902930
<button type="button" id="recon-copy-selected-tsv" title="Copy checked rows from the current page" style="padding:6px 10px;background:rgba(34,211,238,.1);border:1px solid rgba(34,211,238,.35);border-radius:6px;color:var(--accent-cyan);font-size:11px;cursor:pointer;white-space:nowrap"> Copy selected</button>
931+
<button type="button" id="recon-report-selected-ai" title="Generate an AI vulnerability report for the checked rows" style="padding:6px 10px;background:rgba(52,211,153,.1);border:1px solid rgba(52,211,153,.4);border-radius:6px;color:#34d399;font-size:11px;cursor:pointer;white-space:nowrap"> Report selected (AI)</button>
903932
<button type="button" id="recon-export-all-json" title="Export all findings in the current view as Markdown" style="padding:6px 10px;background:rgba(167,139,250,.08);border:1px solid rgba(167,139,250,.35);border-radius:6px;color:#c4b5fd;font-size:11px;cursor:pointer;white-space:nowrap"> Export Markdown</button>
904933
</div>
905934
<div style="margin-left:auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap">
@@ -1197,6 +1226,7 @@
11971226
};
11981227
const copyTsvBtn = root.querySelector('#recon-copy-selected-tsv');
11991228
const exportJsonBtn = root.querySelector('#recon-export-all-json');
1229+
const reportAiBtn = root.querySelector('#recon-report-selected-ai');
12001230
if (copyTsvBtn) {
12011231
copyTsvBtn.addEventListener('click', async () => {
12021232
const rows = collectCheckedFindingRows();
@@ -1210,6 +1240,43 @@
12101240
}
12111241
});
12121242
}
1243+
if (reportAiBtn) {
1244+
reportAiBtn.addEventListener('click', async () => {
1245+
const rows = collectCheckedFindingRows();
1246+
if (!rows.length) { showToast('info', 'Nothing selected', 'Check one or more findings, then generate a report.'); return; }
1247+
const MAX = 25;
1248+
const picked = rows.slice(0, MAX);
1249+
if (rows.length > MAX) showToast('info', 'Trimmed', `Reporting the first ${MAX} of ${rows.length} selected findings.`);
1250+
const findings = picked.map((r) => {
1251+
const info = (r.raw && r.raw.info) || {};
1252+
const detailParts = [];
1253+
if (r.file) detailParts.push(`file: ${r.file}`);
1254+
if (info.line) detailParts.push(`line: ${info.line}`);
1255+
if (info.url) detailParts.push(`url: ${info.url}`);
1256+
if (info.matched || info.match) detailParts.push(`match: ${info.matched || info.match}`);
1257+
if (!detailParts.length && r.raw) {
1258+
try { detailParts.push(JSON.stringify(r.raw).slice(0, 400)); } catch (_) { /* ignore */ }
1259+
}
1260+
return {
1261+
target: String(r.target || r.host || ''),
1262+
finding_type: String(r.finding || r.title || ''),
1263+
severity: String(r.severity || ''),
1264+
module: String(r.module || ''),
1265+
detail: detailParts.join(' | '),
1266+
};
1267+
});
1268+
const orig = reportAiBtn.textContent;
1269+
reportAiBtn.disabled = true; reportAiBtn.textContent = ' Generating…';
1270+
try {
1271+
const res = await apiPost('/api/findings/report-batch', { findings });
1272+
showReportModal(`AI Report — ${picked.length} finding(s)`, String(res.report || '').trim() || '(empty response)');
1273+
} catch (e) {
1274+
showToast('error', 'Report failed', e.message || String(e));
1275+
} finally {
1276+
reportAiBtn.disabled = false; reportAiBtn.textContent = orig;
1277+
}
1278+
});
1279+
}
12131280
if (exportJsonBtn) {
12141281
exportJsonBtn.addEventListener('click', async () => {
12151282
const exportedRows = allRows.filter(r => rowMatch(r) && !HIDDEN_KINDS.has(r.kind));

internal/api/ui_api.go

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,18 @@ import (
2222
"github.com/aws/aws-sdk-go-v2/service/s3"
2323
"github.com/aws/aws-sdk-go-v2/service/s3/types"
2424
"github.com/gin-gonic/gin"
25+
"github.com/h0tak88r/AutoAR/internal/brain"
2526
"github.com/h0tak88r/AutoAR/internal/db"
2627
"github.com/h0tak88r/AutoAR/internal/envloader"
2728
"github.com/h0tak88r/AutoAR/internal/r2storage"
2829
"github.com/h0tak88r/AutoAR/internal/scanner/monitor"
2930
"github.com/h0tak88r/AutoAR/internal/scanner/monitorsuggest"
31+
"github.com/h0tak88r/AutoAR/internal/scanner/nuclei"
3032
"github.com/h0tak88r/AutoAR/internal/scanner/subdomainmonitor"
3133
"github.com/h0tak88r/AutoAR/internal/utils"
3234
"github.com/h0tak88r/AutoAR/internal/version"
3335
"github.com/projectdiscovery/dnsx/libs/dnsx"
3436
"github.com/projectdiscovery/nuclei/v3/pkg/output"
35-
"github.com/h0tak88r/AutoAR/internal/scanner/nuclei"
3637
)
3738

3839
// ─────────────────────────────────────────────────────────────────────────────
@@ -2170,6 +2171,24 @@ func openRouterChat(c *gin.Context, systemPrompt, userPrompt string) (string, er
21702171
return result.Choices[0].Message.Content, nil
21712172
}
21722173

2174+
// aiChat routes a system+user prompt to the first available AI provider.
2175+
// Preference: an OpenRouter key (UI X-OpenRouter-Key header or OPENROUTER_API_KEY)
2176+
// is used first; otherwise (or if OpenRouter errors) it falls back to the shared
2177+
// brain provider chain (OpenCode → Z.ai → Gemini). This keeps the dashboard AI
2178+
// helpers working when the user only configured the free OpenCode provider.
2179+
func aiChat(c *gin.Context, systemPrompt, userPrompt string) (string, error) {
2180+
hasOR := strings.TrimSpace(c.GetHeader("X-OpenRouter-Key")) != "" ||
2181+
strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")) != ""
2182+
if hasOR {
2183+
out, err := openRouterChat(c, systemPrompt, userPrompt)
2184+
if err == nil {
2185+
return out, nil
2186+
}
2187+
log.Printf("[API] OpenRouter chat failed, falling back to OpenCode/Gemini: %v", err)
2188+
}
2189+
return brain.ChatWithAI(nil, userPrompt, systemPrompt)
2190+
}
2191+
21732192
// ─────────────────────────────────────────────────────────────────────────────
21742193
// POST /api/findings/validate — AI validates a single finding
21752194
// ─────────────────────────────────────────────────────────────────────────────
@@ -2214,7 +2233,7 @@ What can an attacker do? Be specific.
22142233
## Quick Fix
22152234
One-line remediation.`, body.Target, body.FindingType, body.Severity, body.Module)
22162235

2217-
analysis, err := openRouterChat(c, systemPrompt, userPrompt)
2236+
analysis, err := aiChat(c, systemPrompt, userPrompt)
22182237
if err != nil {
22192238
log.Printf("[API] validate finding error: %v", err)
22202239
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -2278,7 +2297,7 @@ Use EXACTLY this structure (markdown):
22782297
## Impact
22792298
[1-2 sentences: what an attacker can do]`, body.Target, body.FindingType, body.Severity, body.Module)
22802299

2281-
report, err := openRouterChat(c, systemPrompt, userPrompt)
2300+
report, err := aiChat(c, systemPrompt, userPrompt)
22822301
if err != nil {
22832302
log.Printf("[API] report finding error: %v", err)
22842303
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -2291,3 +2310,117 @@ Use EXACTLY this structure (markdown):
22912310
"finding": body.FindingType,
22922311
})
22932312
}
2313+
2314+
// reportSystemPrompt is the attacker-mindset instruction enforcing the exact
2315+
// report structure for the batch finding-report feature.
2316+
const reportSystemPrompt = `You are a senior offensive security engineer writing bug bounty reports.
2317+
2318+
Mindset:
2319+
- Think like a real-world attacker, not a beginner.
2320+
- Always look for practical, exploitable vulnerabilities.
2321+
- Focus on impact, not theory.
2322+
- Be concise, direct, and technical.
2323+
2324+
When reporting a vulnerability:
2325+
- ONLY use the following structure.
2326+
- Keep it clean, concise, and professional.
2327+
- No extra commentary outside the structure.
2328+
2329+
## Title: <clear, specific vulnerability name>
2330+
2331+
## Summary
2332+
<short explanation of the issue and where it exists>
2333+
2334+
## Steps to Reproduce
2335+
1. <step 1>
2336+
2. <step 2>
2337+
3. <step 3>
2338+
2339+
## Impact
2340+
<realistic impact, what attacker can achieve>
2341+
2342+
If multiple findings are provided, output one report per finding using EXACTLY this structure, separated by a line containing only '---'. If several findings are clearly the same vulnerability class on related assets, you may merge them into a single report and list all affected targets in the Summary.`
2343+
2344+
// ─────────────────────────────────────────────────────────────────────────────
2345+
// POST /api/findings/report-batch — AI writes a report for one or many findings
2346+
// ─────────────────────────────────────────────────────────────────────────────
2347+
2348+
type reportFindingItem struct {
2349+
Target string `json:"target"`
2350+
FindingType string `json:"finding_type"`
2351+
Severity string `json:"severity"`
2352+
Module string `json:"module"`
2353+
Detail string `json:"detail"`
2354+
}
2355+
2356+
func apiReportFindingsBatch(c *gin.Context) {
2357+
var body struct {
2358+
Findings []reportFindingItem `json:"findings"`
2359+
}
2360+
if err := c.ShouldBindJSON(&body); err != nil {
2361+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
2362+
return
2363+
}
2364+
2365+
// Keep only findings that carry an identifier, and cap the batch so the prompt
2366+
// (and cost/latency) stays bounded.
2367+
cleaned := make([]reportFindingItem, 0, len(body.Findings))
2368+
for _, f := range body.Findings {
2369+
if strings.TrimSpace(f.FindingType) == "" && strings.TrimSpace(f.Target) == "" {
2370+
continue
2371+
}
2372+
cleaned = append(cleaned, f)
2373+
}
2374+
if len(cleaned) == 0 {
2375+
c.JSON(http.StatusBadRequest, gin.H{"error": "no findings provided"})
2376+
return
2377+
}
2378+
const maxBatch = 25
2379+
truncated := false
2380+
if len(cleaned) > maxBatch {
2381+
cleaned = cleaned[:maxBatch]
2382+
truncated = true
2383+
}
2384+
2385+
var sb strings.Builder
2386+
if len(cleaned) == 1 {
2387+
sb.WriteString("Write a vulnerability report for this finding.\n\n")
2388+
} else {
2389+
fmt.Fprintf(&sb, "Write a vulnerability report for the following %d findings.\n\n", len(cleaned))
2390+
}
2391+
for i, f := range cleaned {
2392+
fmt.Fprintf(&sb, "Finding %d:\n", i+1)
2393+
if v := strings.TrimSpace(f.Target); v != "" {
2394+
fmt.Fprintf(&sb, "- Target: %s\n", v)
2395+
}
2396+
if v := strings.TrimSpace(f.FindingType); v != "" {
2397+
fmt.Fprintf(&sb, "- Vulnerability: %s\n", v)
2398+
}
2399+
if v := strings.TrimSpace(f.Severity); v != "" {
2400+
fmt.Fprintf(&sb, "- Severity: %s\n", v)
2401+
}
2402+
if v := strings.TrimSpace(f.Module); v != "" {
2403+
fmt.Fprintf(&sb, "- Scanner/Module: %s\n", v)
2404+
}
2405+
if v := strings.TrimSpace(f.Detail); v != "" {
2406+
if len(v) > 600 {
2407+
v = v[:600]
2408+
}
2409+
fmt.Fprintf(&sb, "- Evidence: %s\n", v)
2410+
}
2411+
sb.WriteString("\n")
2412+
}
2413+
2414+
report, err := aiChat(c, reportSystemPrompt, sb.String())
2415+
if err != nil {
2416+
log.Printf("[API] batch report error: %v", err)
2417+
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
2418+
return
2419+
}
2420+
2421+
c.JSON(http.StatusOK, gin.H{
2422+
"report": report,
2423+
"count": len(cleaned),
2424+
"truncated": truncated,
2425+
})
2426+
}

0 commit comments

Comments
 (0)