Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,7 @@ func SetupAPI() *gin.Engine {
// AI finding validation & reporting
apiGroup.POST("/findings/validate", apiValidateFinding)
apiGroup.POST("/findings/report", apiReportFinding)
apiGroup.POST("/findings/report-batch", apiReportFindingsBatch)
// KeyHack templates
apiGroup.GET("/keyhacks", apiListKeyhacks)
apiGroup.GET("/keyhacks/search", apiSearchKeyhacks)
Expand Down
44 changes: 26 additions & 18 deletions internal/api/programs_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,27 +84,35 @@ func apiListPrograms(c *gin.Context) {
sortBy := c.DefaultQuery("sort", "name")
forceRefresh := c.DefaultQuery("refresh", "false") == "true"

// A manual "refresh now" rebuilds in the background (the rebuild makes ~1000
// upstream calls and takes ~a minute — far too long to block the request on).
// We kick it off and still serve the current cache instantly below.
if forceRefresh {
refreshProgramsCacheAsync()
}

// Warm-cache fast path: serve the pre-fetched payload (scope already baked in)
// instantly. If it is stale, refresh in the background and still serve now.
if payload, ok := loadProgramsCache(); ok && len(payload.Programs) > 0 {
stale := time.Since(payload.GeneratedAt) > programsCacheTTL
if stale && !forceRefresh {
// The DB-backed cache is only usable when a DB is configured. Without it we
// skip all cache/background-refresh logic and just do a live fetch (the
// original behavior) — otherwise every request would kick an expensive
// upstream rebuild that can never be persisted.
cacheOn := programsCacheEnabled()

if cacheOn {
// A manual "refresh now" rebuilds in the background (the rebuild makes ~1000
// upstream calls and takes ~a minute — far too long to block the request on).
// We kick it off and still serve the current cache instantly below.
if forceRefresh {
refreshProgramsCacheAsync()
}
serveProgramsPayload(c, payload, platform, sortBy, stale)
return
}

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

// Cold path (cache not built yet): fall back to a live fetch so the first-ever
// load still works, and kick a background build so the next load is instant.
defer refreshProgramsCacheAsync()
}

var allPrograms []ProgramSummary
var mu sync.Mutex
Expand Down
11 changes: 11 additions & 0 deletions internal/api/programs_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"log"
"os"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -48,6 +49,16 @@ var (
programsRefreshing bool
)

// programsCacheEnabled reports whether the DB-backed cache should be used at all.
// Without a DB configured there is nowhere to persist the payload, so the cache
// (and its background refresh) is disabled — the handler then just does a live
// fetch, exactly as it did before this cache existed. This prevents a DB-less
// deployment from triggering an endless loop of expensive upstream rebuilds that
// can never be saved.
func programsCacheEnabled() bool {
return strings.TrimSpace(os.Getenv("DB_HOST")) != ""
}

// loadProgramsCache reads and unmarshals the persisted payload.
// ok is false when the cache is absent or unreadable.
func loadProgramsCache() (programsCachePayload, bool) {
Expand Down
67 changes: 67 additions & 0 deletions internal/api/ui/pages/scan-detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,34 @@
const escAttr = (...args) => window.escAttr(...args);
const copyToClipboard = (...args) => window.copyToClipboard(...args);

// showReportModal renders generated AI report text in a copyable overlay.
function showReportModal(title, text) {
document.getElementById('ai-report-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'ai-report-modal';
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)';
overlay.innerHTML = `
<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)">
<div style="display:flex;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid var(--border,rgba(255,255,255,.1))">
<div style="font-weight:600;font-size:14px;color:var(--text-primary,#fff);flex:1">${esc(title)}</div>
<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>
<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>
</div>
<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>
</div>`;
overlay.querySelector('#ai-report-body').textContent = text;
const close = () => overlay.remove();
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.querySelector('#ai-report-close').addEventListener('click', close);
overlay.querySelector('#ai-report-copy').addEventListener('click', async () => {
try { await copyToClipboard(text); showToast('success', 'Copied', 'Report copied to clipboard'); }
catch (e) { showToast('error', 'Copy failed', e.message || String(e)); }
});
const onKey = (e) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } };
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
}

// ── State for Scan Detail Page ──────────────────────────────────────────
window._scanDetailKnownFiles = new Set();
window._scanDetailRefreshTimer = null;
Expand Down Expand Up @@ -900,6 +928,7 @@
<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)">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<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>
<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>
<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>
</div>
<div style="margin-left:auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap">
Expand Down Expand Up @@ -1197,6 +1226,7 @@
};
const copyTsvBtn = root.querySelector('#recon-copy-selected-tsv');
const exportJsonBtn = root.querySelector('#recon-export-all-json');
const reportAiBtn = root.querySelector('#recon-report-selected-ai');
if (copyTsvBtn) {
copyTsvBtn.addEventListener('click', async () => {
const rows = collectCheckedFindingRows();
Expand All @@ -1210,6 +1240,43 @@
}
});
}
if (reportAiBtn) {
reportAiBtn.addEventListener('click', async () => {
const rows = collectCheckedFindingRows();
if (!rows.length) { showToast('info', 'Nothing selected', 'Check one or more findings, then generate a report.'); return; }
const MAX = 25;
const picked = rows.slice(0, MAX);
if (rows.length > MAX) showToast('info', 'Trimmed', `Reporting the first ${MAX} of ${rows.length} selected findings.`);
const findings = picked.map((r) => {
const info = (r.raw && r.raw.info) || {};
const detailParts = [];
if (r.file) detailParts.push(`file: ${r.file}`);
if (info.line) detailParts.push(`line: ${info.line}`);
if (info.url) detailParts.push(`url: ${info.url}`);
if (info.matched || info.match) detailParts.push(`match: ${info.matched || info.match}`);
if (!detailParts.length && r.raw) {
try { detailParts.push(JSON.stringify(r.raw).slice(0, 400)); } catch (_) { /* ignore */ }
}
return {
target: String(r.target || r.host || ''),
finding_type: String(r.finding || r.title || ''),
severity: String(r.severity || ''),
module: String(r.module || ''),
detail: detailParts.join(' | '),
};
});
const orig = reportAiBtn.textContent;
reportAiBtn.disabled = true; reportAiBtn.textContent = ' Generating…';
try {
const res = await apiPost('/api/findings/report-batch', { findings });
showReportModal(`AI Report — ${picked.length} finding(s)`, String(res.report || '').trim() || '(empty response)');
} catch (e) {
showToast('error', 'Report failed', e.message || String(e));
} finally {
reportAiBtn.disabled = false; reportAiBtn.textContent = orig;
}
});
}
if (exportJsonBtn) {
exportJsonBtn.addEventListener('click', async () => {
const exportedRows = allRows.filter(r => rowMatch(r) && !HIDDEN_KINDS.has(r.kind));
Expand Down
139 changes: 136 additions & 3 deletions internal/api/ui_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,18 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/gin-gonic/gin"
"github.com/h0tak88r/AutoAR/internal/brain"
"github.com/h0tak88r/AutoAR/internal/db"
"github.com/h0tak88r/AutoAR/internal/envloader"
"github.com/h0tak88r/AutoAR/internal/r2storage"
"github.com/h0tak88r/AutoAR/internal/scanner/monitor"
"github.com/h0tak88r/AutoAR/internal/scanner/monitorsuggest"
"github.com/h0tak88r/AutoAR/internal/scanner/nuclei"
"github.com/h0tak88r/AutoAR/internal/scanner/subdomainmonitor"
"github.com/h0tak88r/AutoAR/internal/utils"
"github.com/h0tak88r/AutoAR/internal/version"
"github.com/projectdiscovery/dnsx/libs/dnsx"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/h0tak88r/AutoAR/internal/scanner/nuclei"
)

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

// aiChat routes a system+user prompt to the first available AI provider.
// Preference: an OpenRouter key (UI X-OpenRouter-Key header or OPENROUTER_API_KEY)
// is used first; otherwise (or if OpenRouter errors) it falls back to the shared
// brain provider chain (OpenCode → Z.ai → Gemini). This keeps the dashboard AI
// helpers working when the user only configured the free OpenCode provider.
func aiChat(c *gin.Context, systemPrompt, userPrompt string) (string, error) {
hasOR := strings.TrimSpace(c.GetHeader("X-OpenRouter-Key")) != "" ||
strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")) != ""
if hasOR {
out, err := openRouterChat(c, systemPrompt, userPrompt)
if err == nil {
return out, nil
}
log.Printf("[API] OpenRouter chat failed, falling back to OpenCode/Gemini: %v", err)
}
return brain.ChatWithAI(nil, userPrompt, systemPrompt)
}

// ─────────────────────────────────────────────────────────────────────────────
// POST /api/findings/validate — AI validates a single finding
// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -2214,7 +2233,7 @@ What can an attacker do? Be specific.
## Quick Fix
One-line remediation.`, body.Target, body.FindingType, body.Severity, body.Module)

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

report, err := openRouterChat(c, systemPrompt, userPrompt)
report, err := aiChat(c, systemPrompt, userPrompt)
if err != nil {
log.Printf("[API] report finding error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
Expand All @@ -2291,3 +2310,117 @@ Use EXACTLY this structure (markdown):
"finding": body.FindingType,
})
}

// reportSystemPrompt is the attacker-mindset instruction enforcing the exact
// report structure for the batch finding-report feature.
const reportSystemPrompt = `You are a senior offensive security engineer writing bug bounty reports.

Mindset:
- Think like a real-world attacker, not a beginner.
- Always look for practical, exploitable vulnerabilities.
- Focus on impact, not theory.
- Be concise, direct, and technical.

When reporting a vulnerability:
- ONLY use the following structure.
- Keep it clean, concise, and professional.
- No extra commentary outside the structure.

## Title: <clear, specific vulnerability name>

## Summary
<short explanation of the issue and where it exists>

## Steps to Reproduce
1. <step 1>
2. <step 2>
3. <step 3>

## Impact
<realistic impact, what attacker can achieve>

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.`

// ─────────────────────────────────────────────────────────────────────────────
// POST /api/findings/report-batch — AI writes a report for one or many findings
// ─────────────────────────────────────────────────────────────────────────────

type reportFindingItem struct {
Target string `json:"target"`
FindingType string `json:"finding_type"`
Severity string `json:"severity"`
Module string `json:"module"`
Detail string `json:"detail"`
}

func apiReportFindingsBatch(c *gin.Context) {
var body struct {
Findings []reportFindingItem `json:"findings"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}

// Keep only findings that carry an identifier, and cap the batch so the prompt
// (and cost/latency) stays bounded.
cleaned := make([]reportFindingItem, 0, len(body.Findings))
for _, f := range body.Findings {
if strings.TrimSpace(f.FindingType) == "" && strings.TrimSpace(f.Target) == "" {
continue
}
cleaned = append(cleaned, f)
}
if len(cleaned) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no findings provided"})
return
}
const maxBatch = 25
truncated := false
if len(cleaned) > maxBatch {
cleaned = cleaned[:maxBatch]
truncated = true
}

var sb strings.Builder
if len(cleaned) == 1 {
sb.WriteString("Write a vulnerability report for this finding.\n\n")
} else {
fmt.Fprintf(&sb, "Write a vulnerability report for the following %d findings.\n\n", len(cleaned))
}
for i, f := range cleaned {
fmt.Fprintf(&sb, "Finding %d:\n", i+1)
if v := strings.TrimSpace(f.Target); v != "" {
fmt.Fprintf(&sb, "- Target: %s\n", v)
}
if v := strings.TrimSpace(f.FindingType); v != "" {
fmt.Fprintf(&sb, "- Vulnerability: %s\n", v)
}
if v := strings.TrimSpace(f.Severity); v != "" {
fmt.Fprintf(&sb, "- Severity: %s\n", v)
}
if v := strings.TrimSpace(f.Module); v != "" {
fmt.Fprintf(&sb, "- Scanner/Module: %s\n", v)
}
if v := strings.TrimSpace(f.Detail); v != "" {
if len(v) > 600 {
v = v[:600]
}
fmt.Fprintf(&sb, "- Evidence: %s\n", v)
}
sb.WriteString("\n")
}

report, err := aiChat(c, reportSystemPrompt, sb.String())
if err != nil {
log.Printf("[API] batch report error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

c.JSON(http.StatusOK, gin.H{
"report": report,
"count": len(cleaned),
"truncated": truncated,
})
}
Loading
Loading