Skip to content

Commit d7aa1d6

Browse files
committed
feat: Introduce new monitoring daemon, update database modules, and adjust core application logic in main, subdomain monitor, and brain modules.
1 parent eab76d1 commit d7aa1d6

11 files changed

Lines changed: 1045 additions & 162 deletions

File tree

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ Results are automatically uploaded to **Cloudflare R2 storage** and linked direc
3333
| 📱 **Mobile Apps** | APK/IPA analysis with MobSF + MITM traffic interception |
3434
| ⚙️ **Misconfigs** | 100+ service misconfiguration checks |
3535
| 🏴‍☠️ **BB Scope** | Fetch scope from HackerOne, Bugcrowd, Intigriti, YesWeHack, Immunefi |
36+
| 🔄 **Monitoring** | Subdomain + URL change monitoring daemon with Discord alerts & DB history |
37+
| 🤖 **AI Agent CLI** | Full AI hunt loop as a CLI command: `autoar agent "find XSS on example.com"` |
3638
| 📤 **R2 Storage** | Auto-upload every non-empty result file to Cloudflare R2 and print the public URL |
3739

3840
---
@@ -219,6 +221,8 @@ Options:
219221

220222
### Subdomain Monitoring
221223

224+
The monitoring daemon uses a dedicated `last_run_at` DB column (fixes the old timer bug), persists every detected change to `monitor_changes` for history, and sends Discord webhook alerts automatically.
225+
222226
```
223227
autoar monitor subdomains -d <domain> One-time check for subdomain changes
224228
[--check-new] Alert on newly discovered subdomains
@@ -229,6 +233,29 @@ autoar monitor subdomains manage start --all | --id <id> | -d <domain>
229233
autoar monitor subdomains manage stop --all
230234
```
231235

236+
### AI Agent Commands
237+
238+
Autonomous bug hunting directly from the terminal — no Discord required.
239+
240+
```
241+
autoar agent "<request>" [--json]
242+
Run the full AI agent loop (up to 20 iterations) from the CLI.
243+
Example: autoar agent "find XSS vulnerabilities on example.com"
244+
Example: autoar agent "full recon on example.com" --json
245+
246+
autoar explain <result-file> [--json]
247+
Feed any scan result file to the AI for triage and follow-up suggestions.
248+
Example: autoar explain new-results/example.com/nuclei-output.txt
249+
Example: autoar explain new-results/example.com/js-secrets.txt --json
250+
251+
autoar status [--json]
252+
Show runtime metrics and DB scan progress.
253+
Useful for AI agents polling long-running scans:
254+
Example: autoar status --json
255+
Returns: { "active_scans": [ { "target": "...", "current_phase": 4, "total_phases": 12 } ] }
256+
```
257+
258+
232259
### Database & Results
233260

234261
```

cmd/autoar/main.go

Lines changed: 403 additions & 95 deletions
Large diffs are not rendered by default.

internal/modules/brain/brain.go

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const (
5151
DefaultModel = "google/gemini-2.0-flash-001"
5252
OpenRouterEndpoint = "https://openrouter.ai/api/v1/chat/completions"
5353
GeminiDirectEndpoint = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"
54-
MaxAgentIterations = 8
54+
MaxAgentIterations = 20 // increased from 8 — complex hunts need subdomains+live+ports+nuclei+js+gf+validations
5555
)
5656

5757
// ─── Agent Loop types ─────────────────────────────────────────────────────────
@@ -103,12 +103,8 @@ You control the AutoAR security tool via JSON actions.
103103
| autoar zerodays scan -d DOMAIN | CVE/zero-day scan on live hosts (outputs JSON) |
104104
| autoar lite run -d DOMAIN | Fast recon workflow (subdomains+live+tech+nuclei) |
105105
| autoar domain run -d DOMAIN | Full deep-recon workflow |
106-
107-
## JSON Log Schemas (abbreviated)
108-
- **ports**: {"host": "sub.example.com", "port": 8080, "proto": "tcp", "state": "open"}
109-
- **nuclei**: {"template-id": "...", "host": "...", "severity": "high|medium|low|info", "matched-at": "..."}
110-
- **livehosts**: {"url": "https://sub.example.com", "status": 200, "title": "...", "tech": [...]}
111-
- **dns**: {"host": "...", "cname": "...", "takeover": true/false, "provider": "..."}
106+
| autoar status --json | Poll active scan progress: {"active_scans":[{"target":"","scan_type":"","status":"","current_phase":0,"total_phases":0,"phase_name":""}]} |
107+
| autoar explain PATH | Feed any result file to AI for triage: returns markdown analysis |
112108
113109
## Rules
114110
1. Each turn respond ONLY with a single valid JSON object matching one of the action types.
@@ -118,7 +114,7 @@ You control the AutoAR security tool via JSON actions.
118114
5. Use "report" + "notify": false for informational summaries.
119115
6. Use "done" when the request is fully handled.
120116
7. Never invent scan results. Only act on what you actually received.
121-
8. Max iterations: 8. Use them wisely.`
117+
8. Max iterations: 20. For long scans, use 'autoar status --json' to poll progress between iterations instead of waiting blindly.`
122118

123119
// RunAgentLoop runs the natural language AI agent loop.
124120
// userRequest: the user's NL message (e.g. "scan example.com for open ports")
@@ -161,11 +157,32 @@ func RunAgentLoop(userRequest string, progressFn func(string)) (*AgentResult, er
161157
// Append assistant reply to history
162158
history = append(history, Message{Role: "assistant", Content: aiReply})
163159

164-
// Parse action
165-
action, err := parseAgentAction(aiReply)
166-
if err != nil {
167-
log.Printf("[AGENT] Failed to parse action: %v — stopping", err)
168-
break
160+
// Parse action — retry up to 2 times on bad JSON so one hallucination
161+
// doesn't kill the whole session.
162+
var action *AgentAction
163+
for attempt := 0; attempt < 2; attempt++ {
164+
action, err = parseAgentAction(aiReply)
165+
if err == nil {
166+
break
167+
}
168+
if attempt == 0 {
169+
log.Printf("[AGENT] Bad JSON (attempt %d): %v — asking AI to retry", attempt+1, err)
170+
history = append(history, Message{
171+
Role: "user",
172+
Content: "Your last response was not valid JSON. Please respond with EXACTLY one JSON action object and nothing else. Example: {\"action\":\"run_command\",\"command\":\"autoar subdomains get -d example.com\",\"reason\":\"start recon\"}",
173+
})
174+
aiReply, err = ChatWithAI(history, "", "")
175+
if err != nil {
176+
log.Printf("[AGENT] AI retry call failed: %v — skipping iteration", err)
177+
break
178+
}
179+
history = append(history, Message{Role: "assistant", Content: aiReply})
180+
}
181+
}
182+
if action == nil {
183+
log.Printf("[AGENT] Could not parse action after retries — skipping iteration")
184+
history = append(history, Message{Role: "user", Content: "Skipping this turn due to invalid response. Please continue with a valid JSON action."})
185+
continue
169186
}
170187

171188
switch action.Action {
@@ -197,10 +214,8 @@ func RunAgentLoop(userRequest string, progressFn func(string)) (*AgentResult, er
197214
}
198215
}
199216

200-
// Truncate very long output so we don't blow context
201-
if len(toolResult) > 8000 {
202-
toolResult = toolResult[:8000] + "\n...(truncated)"
203-
}
217+
// Smart truncation: keep head (startup info) + tail (where results live)
218+
toolResult = smartTruncate(toolResult, 8000)
204219

205220
feedback := fmt.Sprintf("Command output:\n```\n%s\n```", toolResult)
206221
history = append(history, Message{Role: "user", Content: feedback})
@@ -237,6 +252,24 @@ func RunAgentLoop(userRequest string, progressFn func(string)) (*AgentResult, er
237252
return result, nil
238253
}
239254

255+
// smartTruncate keeps the beginning (startup/info) and the end (results) of long output.
256+
// This is better than naive head-only truncation because scan results are at the end.
257+
func smartTruncate(s string, maxLen int) string {
258+
if len(s) <= maxLen {
259+
return s
260+
}
261+
headLen := 2000
262+
tailLen := maxLen - headLen - 50 // 50 chars for the separator line
263+
if tailLen < 500 {
264+
// maxLen is very small, just do a simple truncation
265+
return s[:maxLen] + "...(truncated)"
266+
}
267+
head := s[:headLen]
268+
tail := s[len(s)-tailLen:]
269+
skipped := len(s) - headLen - tailLen
270+
return head + fmt.Sprintf("\n\n...[%d bytes omitted]...\n\n", skipped) + tail
271+
}
272+
240273
// parseAgentAction extracts the JSON AgentAction from a possibly-markdown-wrapped AI reply.
241274
func parseAgentAction(reply string) (*AgentAction, error) {
242275
clean := strings.TrimSpace(reply)

internal/modules/db/db.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,46 @@ func GetSubdomainMonitorTargetByID(id int) (*SubdomainMonitorTarget, error) {
284284
return dbInstance.GetSubdomainMonitorTargetByID(id)
285285
}
286286

287+
// UpdateSubdomainMonitorLastRun updates last_run_at for a subdomain monitor target (fixes timer bug)
288+
func UpdateSubdomainMonitorLastRun(id int) error {
289+
if dbInstance == nil {
290+
if err := Init(); err != nil {
291+
return err
292+
}
293+
}
294+
return dbInstance.UpdateSubdomainMonitorLastRun(id)
295+
}
296+
297+
// UpdateMonitorTargetLastRun updates last_hash and last_run_at for a URL monitor target
298+
func UpdateMonitorTargetLastRun(id int, hash string, changed bool) error {
299+
if dbInstance == nil {
300+
if err := Init(); err != nil {
301+
return err
302+
}
303+
}
304+
return dbInstance.UpdateMonitorTargetLastRun(id, hash, changed)
305+
}
306+
307+
// InsertMonitorChange records a detected change in the monitor_changes table
308+
func InsertMonitorChange(change *MonitorChange) error {
309+
if dbInstance == nil {
310+
if err := Init(); err != nil {
311+
return err
312+
}
313+
}
314+
return dbInstance.InsertMonitorChange(change)
315+
}
316+
317+
// ListMonitorChanges lists recent monitor changes, optionally filtered by domain
318+
func ListMonitorChanges(domain string, limit int) ([]MonitorChange, error) {
319+
if dbInstance == nil {
320+
if err := Init(); err != nil {
321+
return nil, err
322+
}
323+
}
324+
return dbInstance.ListMonitorChanges(domain, limit)
325+
}
326+
287327
// CreateScan creates a new scan record
288328
func CreateScan(scan *ScanRecord) error {
289329
if dbInstance == nil {

internal/modules/db/postgres.go

Lines changed: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -238,16 +238,28 @@ func (p *PostgresDB) InitSchema() error {
238238
strategy TEXT NOT NULL,
239239
pattern TEXT,
240240
is_running BOOLEAN DEFAULT FALSE,
241+
last_hash TEXT,
242+
last_run_at TIMESTAMP,
243+
change_count INTEGER DEFAULT 0,
241244
created_at TIMESTAMP DEFAULT NOW(),
242245
updated_at TIMESTAMP DEFAULT NOW()
243246
);
244247
245-
-- Ensure is_running column exists (for backward compatibility)
248+
-- Ensure new columns exist (for backward compatibility)
246249
DO $$
247250
BEGIN
248251
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='updates_targets' AND column_name='is_running') THEN
249252
ALTER TABLE updates_targets ADD COLUMN is_running BOOLEAN DEFAULT FALSE;
250253
END IF;
254+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='updates_targets' AND column_name='last_hash') THEN
255+
ALTER TABLE updates_targets ADD COLUMN last_hash TEXT;
256+
END IF;
257+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='updates_targets' AND column_name='last_run_at') THEN
258+
ALTER TABLE updates_targets ADD COLUMN last_run_at TIMESTAMP;
259+
END IF;
260+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='updates_targets' AND column_name='change_count') THEN
261+
ALTER TABLE updates_targets ADD COLUMN change_count INTEGER DEFAULT 0;
262+
END IF;
251263
END $$;
252264
253265
-- Create subdomain_monitor_targets table for subdomain monitoring
@@ -258,10 +270,34 @@ func (p *PostgresDB) InitSchema() error {
258270
threads INTEGER DEFAULT 100,
259271
check_new BOOLEAN DEFAULT TRUE,
260272
is_running BOOLEAN DEFAULT FALSE,
273+
last_run_at TIMESTAMP,
261274
created_at TIMESTAMP DEFAULT NOW(),
262275
updated_at TIMESTAMP DEFAULT NOW()
263276
);
264277
278+
-- Ensure last_run_at exists (for backward compatibility)
279+
DO $$
280+
BEGIN
281+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='subdomain_monitor_targets' AND column_name='last_run_at') THEN
282+
ALTER TABLE subdomain_monitor_targets ADD COLUMN last_run_at TIMESTAMP;
283+
END IF;
284+
END $$;
285+
286+
-- Create monitor_changes table for change history
287+
CREATE TABLE IF NOT EXISTS monitor_changes (
288+
id SERIAL PRIMARY KEY,
289+
target_type VARCHAR(20) NOT NULL,
290+
target_id INTEGER NOT NULL,
291+
domain TEXT NOT NULL,
292+
change_type VARCHAR(50) NOT NULL,
293+
detail TEXT,
294+
detected_at TIMESTAMP DEFAULT NOW(),
295+
notified BOOLEAN DEFAULT FALSE
296+
);
297+
CREATE INDEX IF NOT EXISTS idx_monitor_changes_domain ON monitor_changes(domain);
298+
CREATE INDEX IF NOT EXISTS idx_monitor_changes_detected_at ON monitor_changes(detected_at);
299+
CREATE INDEX IF NOT EXISTS idx_monitor_changes_change_type ON monitor_changes(change_type);
300+
265301
-- Create scans table for scan progress tracking
266302
CREATE TABLE IF NOT EXISTS scans (
267303
id SERIAL PRIMARY KEY,
@@ -828,7 +864,7 @@ func (p *PostgresDB) GetMonitorTargetByID(id int) (*MonitorTarget, error) {
828864
// ListSubdomainMonitorTargets returns all subdomain monitoring targets
829865
func (p *PostgresDB) ListSubdomainMonitorTargets() ([]SubdomainMonitorTarget, error) {
830866
rows, err := p.pool.Query(p.ctx, `
831-
SELECT id, domain, interval_seconds, threads, check_new, is_running, created_at, updated_at
867+
SELECT id, domain, interval_seconds, threads, check_new, is_running, last_run_at, created_at, updated_at
832868
FROM subdomain_monitor_targets
833869
ORDER BY domain;
834870
`)
@@ -840,7 +876,7 @@ func (p *PostgresDB) ListSubdomainMonitorTargets() ([]SubdomainMonitorTarget, er
840876
var targets []SubdomainMonitorTarget
841877
for rows.Next() {
842878
var t SubdomainMonitorTarget
843-
if err := rows.Scan(&t.ID, &t.Domain, &t.Interval, &t.Threads, &t.CheckNew, &t.IsRunning, &t.CreatedAt, &t.UpdatedAt); err != nil {
879+
if err := rows.Scan(&t.ID, &t.Domain, &t.Interval, &t.Threads, &t.CheckNew, &t.IsRunning, &t.LastRunAt, &t.CreatedAt, &t.UpdatedAt); err != nil {
844880
return nil, fmt.Errorf("failed to scan subdomain monitor target: %v", err)
845881
}
846882
targets = append(targets, t)
@@ -904,10 +940,10 @@ func (p *PostgresDB) SetSubdomainMonitorRunningStatus(id int, isRunning bool) er
904940
func (p *PostgresDB) GetSubdomainMonitorTargetByID(id int) (*SubdomainMonitorTarget, error) {
905941
var t SubdomainMonitorTarget
906942
err := p.pool.QueryRow(p.ctx, `
907-
SELECT id, domain, interval_seconds, threads, check_new, is_running, created_at, updated_at
943+
SELECT id, domain, interval_seconds, threads, check_new, is_running, last_run_at, created_at, updated_at
908944
FROM subdomain_monitor_targets
909945
WHERE id = $1;
910-
`, id).Scan(&t.ID, &t.Domain, &t.Interval, &t.Threads, &t.CheckNew, &t.IsRunning, &t.CreatedAt, &t.UpdatedAt)
946+
`, id).Scan(&t.ID, &t.Domain, &t.Interval, &t.Threads, &t.CheckNew, &t.IsRunning, &t.LastRunAt, &t.CreatedAt, &t.UpdatedAt)
911947

912948
if err == pgx.ErrNoRows {
913949
return nil, fmt.Errorf("subdomain monitor target not found with id: %d", id)
@@ -918,6 +954,88 @@ func (p *PostgresDB) GetSubdomainMonitorTargetByID(id int) (*SubdomainMonitorTar
918954
return &t, nil
919955
}
920956

957+
// UpdateSubdomainMonitorLastRun updates last_run_at to now for a subdomain monitor target
958+
func (p *PostgresDB) UpdateSubdomainMonitorLastRun(id int) error {
959+
_, err := p.pool.Exec(p.ctx, `
960+
UPDATE subdomain_monitor_targets
961+
SET last_run_at = NOW()
962+
WHERE id = $1;
963+
`, id)
964+
if err != nil {
965+
return fmt.Errorf("failed to update subdomain monitor last_run_at: %v", err)
966+
}
967+
return nil
968+
}
969+
970+
// UpdateMonitorTargetLastRun updates last_hash, last_run_at, and optionally increments change_count
971+
func (p *PostgresDB) UpdateMonitorTargetLastRun(id int, hash string, changed bool) error {
972+
_, err := p.pool.Exec(p.ctx, `
973+
UPDATE updates_targets
974+
SET last_hash = $1,
975+
last_run_at = NOW(),
976+
change_count = CASE WHEN $2 THEN change_count + 1 ELSE change_count END
977+
WHERE id = $3;
978+
`, hash, changed, id)
979+
if err != nil {
980+
return fmt.Errorf("failed to update monitor target last run: %v", err)
981+
}
982+
return nil
983+
}
984+
985+
// InsertMonitorChange records a detected change in the monitor_changes table
986+
func (p *PostgresDB) InsertMonitorChange(change *MonitorChange) error {
987+
_, err := p.pool.Exec(p.ctx, `
988+
INSERT INTO monitor_changes (target_type, target_id, domain, change_type, detail, notified)
989+
VALUES ($1, $2, $3, $4, $5, $6);
990+
`, change.TargetType, change.TargetID, change.Domain, change.ChangeType, change.Detail, change.Notified)
991+
if err != nil {
992+
return fmt.Errorf("failed to insert monitor change: %v", err)
993+
}
994+
return nil
995+
}
996+
997+
// ListMonitorChanges lists recent monitor changes, optionally filtered by domain
998+
func (p *PostgresDB) ListMonitorChanges(domain string, limit int) ([]MonitorChange, error) {
999+
if limit <= 0 {
1000+
limit = 100
1001+
}
1002+
var rows interface{ Close() }
1003+
var err error
1004+
var query string
1005+
var args []interface{}
1006+
1007+
if domain != "" {
1008+
query = `SELECT id, target_type, target_id, domain, change_type, COALESCE(detail,'') as detail, detected_at, notified
1009+
FROM monitor_changes WHERE domain = $1 ORDER BY detected_at DESC LIMIT $2;`
1010+
args = []interface{}{domain, limit}
1011+
} else {
1012+
query = `SELECT id, target_type, target_id, domain, change_type, COALESCE(detail,'') as detail, detected_at, notified
1013+
FROM monitor_changes ORDER BY detected_at DESC LIMIT $1;`
1014+
args = []interface{}{limit}
1015+
}
1016+
1017+
pgRows, pgErr := p.pool.Query(p.ctx, query, args...)
1018+
if pgErr != nil {
1019+
return nil, fmt.Errorf("failed to query monitor changes: %v", pgErr)
1020+
}
1021+
rows = pgRows
1022+
defer pgRows.Close()
1023+
1024+
var changes []MonitorChange
1025+
for pgRows.Next() {
1026+
var c MonitorChange
1027+
if err = pgRows.Scan(&c.ID, &c.TargetType, &c.TargetID, &c.Domain, &c.ChangeType, &c.Detail, &c.DetectedAt, &c.Notified); err != nil {
1028+
return nil, fmt.Errorf("failed to scan monitor change: %v", err)
1029+
}
1030+
changes = append(changes, c)
1031+
}
1032+
_ = rows
1033+
if pgRows.Err() != nil {
1034+
return nil, fmt.Errorf("failed to iterate monitor changes: %v", pgRows.Err())
1035+
}
1036+
return changes, nil
1037+
}
1038+
9211039
// CreateScan creates a new scan record
9221040
func (p *PostgresDB) CreateScan(scan *ScanRecord) error {
9231041
completedPhasesJSON := "[]"

0 commit comments

Comments
 (0)