Skip to content

Commit 3cc68a8

Browse files
h0tak88rclaude
andcommitted
feat(programs): Discord alerts when a bug-bounty program adds a new in-scope asset
A slow rolling scope-change monitor: when MONITOR_WEBHOOK_URL is set, AutoAR sweeps every program you can see (HackerOne/Bugcrowd/Intigriti) and posts a Discord alert the moment a NEW in-scope asset appears — the highest-value low-competition signal for hunting (e.g. Ryan BBP adding cp-uat-app.ryanplatform.com). - internal/api/program_monitor.go: rolling sweep, one program every PROGRAM_MONITOR_SPACING_MS (default 3s) so polling ~1000+ programs never bursts the platforms' API rate limits (a full pass ~hourly). Reuses the existing per-program scope fetchers, which now also collect ProgramSummary.Assets. - program_assets table (sqlite+pg) + RecordProgramScopeAssets: baselines a program silently on first sight; afterwards only genuinely-new assets are reported. A fetch that returns no assets (429/transient) is skipped and never baselined, so a rate-limit can't trigger a false "all assets are new" flood. - Alerts go to Discord (utils.SendMonitorWebhook) AND the Monitor → Changes feed (change_type=new_program_asset, rendered with label/icon/dot/detail-preview). - Started from boot (app.go); no-op without a webhook or with PROGRAM_MONITOR=off. Config: PROGRAM_MONITOR, PROGRAM_MONITOR_SPACING_MS, PROGRAM_MONITOR_PLATFORMS. Adversarial review (18 agents) → 0 confirmed issues; plus a defensive fix so the sweep goroutine doesn't read the shared stop channel var. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 83d3128 commit 3cc68a8

12 files changed

Lines changed: 446 additions & 1 deletion

File tree

env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ AUTOAR_MODE=api
1414
# webhook URLs work out of the box.
1515
MONITOR_WEBHOOK_URL=
1616

17+
# Bug-bounty scope-change monitor: when MONITOR_WEBHOOK_URL is set, AutoAR slowly
18+
# sweeps every program you can see and posts a Discord alert whenever a NEW in-scope
19+
# asset appears. Set PROGRAM_MONITOR=off to disable. PROGRAM_MONITOR_SPACING_MS is the
20+
# delay between per-program scope checks (default 3000ms — keeps under API rate limits;
21+
# a full sweep of ~1000 programs takes ~an hour). PROGRAM_MONITOR_PLATFORMS limits it to
22+
# a csv subset, e.g. "h1" or "h1,bc" (empty = all of h1,bc,it).
23+
PROGRAM_MONITOR=
24+
PROGRAM_MONITOR_SPACING_MS=
25+
PROGRAM_MONITOR_PLATFORMS=
26+
1727
# ============================================================================
1828
# API SERVER CONFIGURATION
1929
# ============================================================================

internal/api/program_monitor.go

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
package api
2+
3+
import (
4+
"encoding/base64"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"os"
9+
"strconv"
10+
"strings"
11+
"sync"
12+
"time"
13+
14+
"github.com/h0tak88r/AutoAR/internal/db"
15+
"github.com/h0tak88r/AutoAR/internal/logger"
16+
"github.com/h0tak88r/AutoAR/internal/utils"
17+
)
18+
19+
// ─── Bug-bounty scope-change monitor ────────────────────────────────────────────
20+
// A slow rolling sweep over every program that fires a Discord (MONITOR_WEBHOOK_URL)
21+
// alert whenever a NEW in-scope asset appears. It deliberately spaces requests
22+
// (default 3s between programs) so polling ~1000+ programs doesn't trip the
23+
// platforms' API rate limits — a full sweep takes ~an hour, which is fine for
24+
// catching new assets. A fetch that returns no assets (rate-limited / transient)
25+
// is skipped, never baselined, so it can't produce a false "all assets are new" flood.
26+
27+
var (
28+
progMonRunning bool
29+
progMonMu sync.Mutex
30+
progMonStop chan struct{}
31+
)
32+
33+
// StartProgramMonitor launches the scope-change monitor if a webhook is configured
34+
// and it isn't explicitly disabled. Safe to call once at startup.
35+
func StartProgramMonitor() {
36+
if strings.TrimSpace(os.Getenv("MONITOR_WEBHOOK_URL")) == "" {
37+
return // nowhere to send alerts
38+
}
39+
if strings.EqualFold(strings.TrimSpace(os.Getenv("PROGRAM_MONITOR")), "off") {
40+
return
41+
}
42+
progMonMu.Lock()
43+
if progMonRunning {
44+
progMonMu.Unlock()
45+
return
46+
}
47+
progMonRunning = true
48+
progMonStop = make(chan struct{})
49+
stop := progMonStop
50+
progMonMu.Unlock()
51+
52+
// Pass the stop channel by value so the goroutine never races on the package var.
53+
go runProgramMonitorLoop(stop)
54+
}
55+
56+
// StopProgramMonitor signals the monitor to stop.
57+
func StopProgramMonitor() {
58+
progMonMu.Lock()
59+
defer progMonMu.Unlock()
60+
if !progMonRunning {
61+
return
62+
}
63+
close(progMonStop)
64+
progMonRunning = false
65+
}
66+
67+
func programMonitorSpacing() time.Duration {
68+
ms := 3000
69+
if v := strings.TrimSpace(os.Getenv("PROGRAM_MONITOR_SPACING_MS")); v != "" {
70+
if n, err := strconv.Atoi(v); err == nil && n >= 500 {
71+
ms = n
72+
}
73+
}
74+
return time.Duration(ms) * time.Millisecond
75+
}
76+
77+
func runProgramMonitorLoop(stop chan struct{}) {
78+
logger.GetLogger().Infof("[PROGRAM-MONITOR] started (spacing %s) — alerting new in-scope assets to MONITOR_WEBHOOK_URL", programMonitorSpacing())
79+
for {
80+
programs := collectProgramsForMonitor()
81+
if len(programs) == 0 {
82+
if progMonSleep(5*time.Minute, stop) {
83+
return
84+
}
85+
continue
86+
}
87+
logger.GetLogger().Infof("[PROGRAM-MONITOR] sweeping %d program(s)", len(programs))
88+
for _, p := range programs {
89+
select {
90+
case <-stop:
91+
return
92+
default:
93+
}
94+
sweepProgram(p)
95+
if progMonSleep(programMonitorSpacing(), stop) {
96+
return
97+
}
98+
}
99+
// Brief pause between full sweeps.
100+
if progMonSleep(2*time.Minute, stop) {
101+
return
102+
}
103+
}
104+
}
105+
106+
// progMonSleep waits d or returns true if the monitor was asked to stop.
107+
func progMonSleep(d time.Duration, stop chan struct{}) bool {
108+
select {
109+
case <-stop:
110+
return true
111+
case <-time.After(d):
112+
return false
113+
}
114+
}
115+
116+
// collectProgramsForMonitor fetches the program list (without scope — cheap) for every
117+
// configured platform. PROGRAM_MONITOR_PLATFORMS (csv of h1,bc,it) narrows it; empty = all.
118+
func collectProgramsForMonitor() []ProgramSummary {
119+
platforms := strings.ToLower(strings.TrimSpace(os.Getenv("PROGRAM_MONITOR_PLATFORMS")))
120+
want := func(pl string) bool { return platforms == "" || strings.Contains(platforms, pl) }
121+
122+
var all []ProgramSummary
123+
if want("h1") && os.Getenv("H1_USERNAME") != "" && os.Getenv("H1_TOKEN") != "" {
124+
if progs, err := fetchH1Programs(false, false); err == nil {
125+
all = append(all, progs...)
126+
} else {
127+
logger.GetLogger().Infof("[PROGRAM-MONITOR] H1 list fetch failed: %v", err)
128+
}
129+
}
130+
if want("bc") && os.Getenv("BUGCROWD_TOKEN") != "" {
131+
if progs, err := fetchBCPrograms(false, false); err == nil {
132+
all = append(all, progs...)
133+
}
134+
}
135+
if want("it") && intigritiToken() != "" {
136+
if progs, err := fetchITPrograms(false, false); err == nil {
137+
all = append(all, progs...)
138+
}
139+
}
140+
return all
141+
}
142+
143+
// sweepProgram fetches one program's current in-scope assets and alerts on new ones.
144+
func sweepProgram(p ProgramSummary) {
145+
assets := fetchProgramAssets(p)
146+
if len(assets) == 0 {
147+
// Failed / rate-limited / genuinely empty — skip. Never baseline an empty result,
148+
// otherwise the next successful fetch would report every asset as "new".
149+
return
150+
}
151+
key := programScopeCacheKey(p.Platform, p.Handle)
152+
newAssets, firstRun, err := db.RecordProgramScopeAssets(key, assets)
153+
if err != nil {
154+
logger.GetLogger().Infof("[PROGRAM-MONITOR] record failed for %s: %v", key, err)
155+
return
156+
}
157+
if firstRun || len(newAssets) == 0 {
158+
return // baseline run, or nothing new
159+
}
160+
alertNewAssets(p, newAssets)
161+
}
162+
163+
// fetchProgramAssets returns the current in-scope asset identifiers for a program,
164+
// reusing the existing per-platform scope fetchers. Empty on failure.
165+
func fetchProgramAssets(p ProgramSummary) []string {
166+
switch strings.ToLower(p.Platform) {
167+
case "h1", "hackerone":
168+
auth := h1BasicAuth()
169+
if auth == "" {
170+
return nil
171+
}
172+
summary, ok := fetchH1ScopeSummary(p.Handle, auth)
173+
if !ok {
174+
return nil
175+
}
176+
return summary.Assets
177+
case "bc", "bugcrowd":
178+
token := os.Getenv("BUGCROWD_TOKEN")
179+
if token == "" {
180+
return nil
181+
}
182+
return fetchBCScopeSummary(p.Handle, p.URL, token).Assets
183+
case "it", "intigriti":
184+
token := intigritiToken()
185+
if token == "" {
186+
return nil
187+
}
188+
client := &http.Client{Timeout: 20 * time.Second}
189+
return fetchITScopeSummary(client, token, p.ID).Assets
190+
}
191+
return nil
192+
}
193+
194+
func h1BasicAuth() string {
195+
u, t := os.Getenv("H1_USERNAME"), os.Getenv("H1_TOKEN")
196+
if u == "" || t == "" {
197+
return ""
198+
}
199+
return base64.StdEncoding.EncodeToString([]byte(u + ":" + t))
200+
}
201+
202+
// alertNewAssets posts a Discord message and records a dashboard change row.
203+
func alertNewAssets(p ProgramSummary, newAssets []string) {
204+
name := p.Name
205+
if name == "" {
206+
name = p.Handle
207+
}
208+
plural := ""
209+
if len(newAssets) != 1 {
210+
plural = "s"
211+
}
212+
var sb strings.Builder
213+
fmt.Fprintf(&sb, "🆕 **%d new in-scope asset%s** — %s `%s` (%s)\n",
214+
len(newAssets), plural, name, p.Handle, strings.ToUpper(p.Platform))
215+
const maxList = 20
216+
for i, a := range newAssets {
217+
if i >= maxList {
218+
fmt.Fprintf(&sb, " • …and %d more\n", len(newAssets)-maxList)
219+
break
220+
}
221+
fmt.Fprintf(&sb, " • `%s`\n", a)
222+
}
223+
if p.URL != "" {
224+
fmt.Fprintf(&sb, "%s\n", p.URL)
225+
}
226+
utils.SendMonitorWebhook(sb.String())
227+
228+
detail, _ := json.Marshal(map[string]any{
229+
"program": p.Handle,
230+
"platform": p.Platform,
231+
"name": name,
232+
"url": p.URL,
233+
"assets": newAssets,
234+
})
235+
if err := db.InsertMonitorChange(&db.MonitorChange{
236+
TargetType: "program",
237+
Domain: p.Handle,
238+
ChangeType: "new_program_asset",
239+
Detail: string(detail),
240+
Notified: true,
241+
}); err != nil {
242+
logger.GetLogger().Infof("[PROGRAM-MONITOR] failed to persist change for %s: %v", p.Handle, err)
243+
}
244+
logger.GetLogger().Infof("[PROGRAM-MONITOR] %s (%s): %d new asset(s) alerted", p.Handle, p.Platform, len(newAssets))
245+
}

internal/api/programs_api.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type ProgramSummary struct {
4242
LatestTargetBrief string `json:"latest_target_brief"` // brief context for the latest target
4343
UpdatedAt string `json:"updated_at"` // when we last fetched this
4444
Stats ProgramStats `json:"stats"`
45+
Assets []string `json:"-"` // all in-scope asset identifiers (internal, for scope-change monitoring)
4546
}
4647

4748
// ProgramStats holds user-specific stats from H1.
@@ -526,6 +527,9 @@ func fetchH1ScopeSummary(handle, auth string) (ProgramSummary, bool) {
526527
if attrs.Get("eligible_for_submission").Bool() {
527528
summary.ScopeTargets++
528529
target := attrs.Get("asset_identifier").Str
530+
if target != "" {
531+
summary.Assets = append(summary.Assets, target)
532+
}
529533
updatedAt := firstGJSONString(attrs, "updated_at", "created_at", "last_updated_at")
530534
if target != "" && (summary.LatestTarget == "" || isNewerProgramTime(updatedAt, summary.LatestTargetUpdatedAt)) {
531535
summary.LatestTarget = target
@@ -719,6 +723,9 @@ func fetchBCScopeSummary(handle, programURL, token string) ProgramSummary {
719723
scope.Get("targets").ForEach(func(_, t gjson.Result) bool {
720724
summary.ScopeTargets++
721725
target := firstGJSONString(t, "uri", "name", "target")
726+
if target != "" {
727+
summary.Assets = append(summary.Assets, target)
728+
}
722729
updatedAt := firstGJSONString(t, "updatedAt", "updated_at", "lastUpdatedAt", "createdAt", "created_at")
723730
if target != "" && (summary.LatestTarget == "" || isNewerProgramTime(updatedAt, summary.LatestTargetUpdatedAt)) {
724731
summary.LatestTarget = target
@@ -920,6 +927,9 @@ func fetchITScopeSummary(client *http.Client, token, programID string) ProgramSu
920927
}
921928
summary.ScopeTargets++
922929
target := strings.TrimSpace(v.Get("endpoint").Str)
930+
if target != "" {
931+
summary.Assets = append(summary.Assets, target)
932+
}
923933
if target != "" && summary.LatestTarget == "" {
924934
summary.LatestTarget = target
925935
summary.LatestTargetBrief = firstGJSONString(v, "description", "type.value")

internal/api/ui/pages/monitor.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,12 @@
284284
const src = o.source_js ? ` (in ${String(o.source_js).slice(0, 80)})` : '';
285285
return `${String(o.endpoint).slice(0, 120)}${src}`;
286286
}
287+
if (Array.isArray(o.assets)) {
288+
const head = o.assets.slice(0, 5).join(', ');
289+
const more = o.assets.length > 5 ? ` +${o.assets.length - 5} more` : '';
290+
const prog = o.name || o.program || '';
291+
return `${prog ? prog + ': ' : ''}${head}${more}`;
292+
}
287293
} catch (e) { /* use raw */ }
288294
return detail;
289295
}
@@ -363,7 +369,7 @@
363369
const detailPreview = formatMonitorDetailPreview(detail);
364370
const iconMap = {
365371
new_subdomain: '', became_live: '', became_dead: '',
366-
content_changed: '', status_changed: '', new_js_endpoint: '',
372+
content_changed: '', status_changed: '', new_js_endpoint: '', new_program_asset: '',
367373
};
368374
return `<div class="change-item">
369375
<div class="change-dot ${ctype}"></div>

internal/api/ui/pages/overview.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
content_changed: '',
6969
status_changed: '',
7070
new_js_endpoint: '',
71+
new_program_asset: '',
7172
};
7273
const preview = String(detail || '').slice(0, 200);
7374
return `<div class="change-item">

internal/api/ui/pages/ui-helpers.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
content_changed: 'Content Changed',
7373
status_changed: 'Status Changed',
7474
new_js_endpoint: 'New JS Endpoint',
75+
new_program_asset: 'New Program Asset',
7576
};
7677
return map[t] || t;
7778
}

internal/api/ui/styles.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,7 @@ input, select, textarea { font-family: inherit; }
879879
.change-dot.content_changed { color: var(--accent-amber); background: var(--accent-amber); }
880880
.change-dot.status_changed { color: var(--accent-purple); background: var(--accent-purple); }
881881
.change-dot.new_js_endpoint { color: var(--accent-pink, #ec4899); background: var(--accent-pink, #ec4899); }
882+
.change-dot.new_program_asset { color: var(--accent-emerald); background: var(--accent-emerald); }
882883

883884
.change-body { flex: 1; min-width: 0; }
884885

internal/app/app.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ func StartAPI() error {
7373
// the persisted state is truthful and monitoring continues after a Docker restart.
7474
resumeMonitorsOnStartup()
7575

76+
// Bug-bounty scope-change monitor: slow rolling sweep of all programs that alerts
77+
// to MONITOR_WEBHOOK_URL (Discord) whenever a new in-scope asset appears. No-op when
78+
// no webhook is configured or PROGRAM_MONITOR=off.
79+
api.StartProgramMonitor()
80+
7681
// Pre-warm and keep the Programs catalogue cache fresh in the background so the
7782
// Programs page loads instantly instead of fetching ~1000 upstream calls per visit.
7883
if os.Getenv("DB_HOST") != "" {

internal/db/db.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,26 @@ func InsertJSEndpoints(domain string, endpoints []JSEndpoint) error {
142142
return dbInstance.InsertJSEndpoints(domain, endpoints)
143143
}
144144

145+
// ListProgramScopeAssets returns the stored in-scope assets for a program.
146+
func ListProgramScopeAssets(programKey string) ([]string, error) {
147+
if dbInstance == nil {
148+
if err := Init(); err != nil {
149+
return nil, err
150+
}
151+
}
152+
return dbInstance.ListProgramScopeAssets(programKey)
153+
}
154+
155+
// RecordProgramScopeAssets diffs+stores a program's assets, returning newly-seen ones.
156+
func RecordProgramScopeAssets(programKey string, assets []string) ([]string, bool, error) {
157+
if dbInstance == nil {
158+
if err := Init(); err != nil {
159+
return nil, false, err
160+
}
161+
}
162+
return dbInstance.RecordProgramScopeAssets(programKey, assets)
163+
}
164+
145165
// InsertKeyhackTemplate inserts or updates a KeyHack template
146166
func InsertKeyhackTemplate(keyname, commandTemplate, method, url, header, body, notes, description string) error {
147167
if dbInstance == nil {

0 commit comments

Comments
 (0)