Skip to content

Commit 214a783

Browse files
h0tak88rclaude
andcommitted
fix(programs): persist last-known-good scope per program (program_scope) +
failed/rate-limited fetches never overwrite Architectural fix for the user's "Ryan-BBP was visible before, now shows null" problem. The warmer used to rebuild the entire payload from scratch every refresh and overwrite the cache file — so a refresh that rate-limited on a program wiped that program's previously-known scope from the dashboard. Now: each program's last-known-good scope is persisted to a new program_scope table (sqlite + postgres), keyed by (platform, handle): - UpsertProgramScope is called ONLY on successful fetches (H1 enrich on ok=true, BC/IT enrich on non-empty summary, force-fetch endpoint on fetched=true). - A failed/rate-limited fetch NEVER touches the DB row → prior data is preserved. - buildProgramsPayload now ends with overlayPersistedProgramScope which fills any program row that came back empty this refresh from the persistent DB. Fresh data always wins where present. Net effect: once Ryan-BBP is successfully fetched even once (via search trigger or a non-rate-limited warmer pass), the dashboard keeps showing those values until a successful fetch updates them — never "—". Bugs from the post-implementation review (3 underlying issues, 10 finding rephrasings): - HIGH: BC force-fetch (apiProgramScopeSummaries) was setting fetched=true unconditionally — guard with summary.ScopeTargets>0 || summary.LatestTarget!="" to match the enrich-path's guard, so 8 BC failure modes don't overwrite good data. - HIGH: overlayPersistedProgramScope's 3-condition gate included LatestTargetUpdatedAt — which fetchITPrograms set from program-level lastUpdatedAt, suppressing overlay for IT even when scope was empty. Dropped to 2-condition gate (ScopeTargets / LatestTarget) so the overlay actually runs. - MEDIUM: IT was persisting the program-level lastUpdatedAt instead of asset-level, causing the watch to fire on non-scope program edits (bounty bumps, etc.). fetchITScopeSummary now extracts per-asset timestamp from common field names (updatedAt/lastUpdatedAt/modifiedAt/addedAt/createdAt) and enrichITScopeCounts unconditionally adopts it (replacing the program-level value). Verified: go build/vet, db tests, focused post-fix review — no regressions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a520d74 commit 214a783

6 files changed

Lines changed: 268 additions & 10 deletions

File tree

internal/api/programs_api.go

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"time"
1616

1717
"github.com/gin-gonic/gin"
18+
"github.com/h0tak88r/AutoAR/internal/db"
1819
"github.com/tidwall/gjson"
1920
)
2021

@@ -297,7 +298,11 @@ func apiProgramScopeSummaries(c *gin.Context) {
297298
case "bc", "bugcrowd":
298299
if bcToken != "" {
299300
summary = fetchBCScopeSummary(item.Handle, item.URL, bcToken)
300-
fetched = true
301+
// fetchBCScopeSummary doesn't return ok — all 8 failure paths
302+
// return a zero-valued summary. Treat any empty result as a
303+
// failed fetch so we don't overwrite persisted good data.
304+
// (Matches the enrichBCScopeCounts guard.)
305+
fetched = summary.ScopeTargets > 0 || summary.LatestTarget != ""
301306
}
302307
}
303308

@@ -307,6 +312,13 @@ func apiProgramScopeSummaries(c *gin.Context) {
307312
// hide the program's real scope until the TTL expires.
308313
if fetched {
309314
setCachedProgramScope(cacheKey, summary)
315+
// Persist last-known-good in the DB — so a search-driven refresh
316+
// for a program that the warmer keeps rate-limiting still wins.
317+
_ = db.UpsertProgramScope(db.PersistedProgramScope{
318+
Platform: summary.Platform, Handle: summary.Handle,
319+
ScopeTargets: summary.ScopeTargets, LatestTarget: summary.LatestTarget,
320+
LatestTargetUpdatedAt: summary.LatestTargetUpdatedAt, LatestTargetBrief: summary.LatestTargetBrief,
321+
})
310322
// User-initiated force-fetch (Programs page search) → also feed the
311323
// scope-update watch so a genuinely-newer-than-watermark program
312324
// alerts Discord immediately, instead of waiting for the next
@@ -494,6 +506,13 @@ func enrichH1ScopeCounts(programs []ProgramSummary, auth string) {
494506
p.LatestTarget = summary.LatestTarget
495507
p.LatestTargetUpdatedAt = summary.LatestTargetUpdatedAt
496508
p.LatestTargetBrief = summary.LatestTargetBrief
509+
// Persist last-known-good — failed/rate-limited fetches don't
510+
// reach here, so a transient 429 never overwrites real data.
511+
_ = db.UpsertProgramScope(db.PersistedProgramScope{
512+
Platform: p.Platform, Handle: p.Handle,
513+
ScopeTargets: p.ScopeTargets, LatestTarget: p.LatestTarget,
514+
LatestTargetUpdatedAt: p.LatestTargetUpdatedAt, LatestTargetBrief: p.LatestTargetBrief,
515+
})
497516
}
498517
}(&programs[i])
499518
}
@@ -679,10 +698,19 @@ func enrichBCScopeCounts(programs []ProgramSummary, token string) {
679698
time.Sleep(500 * time.Millisecond) // extra spacing for BC
680699

681700
summary := fetchBCScopeSummary(p.Handle, p.URL, token)
682-
p.ScopeTargets = summary.ScopeTargets
683-
p.LatestTarget = summary.LatestTarget
684-
p.LatestTargetUpdatedAt = summary.LatestTargetUpdatedAt
685-
p.LatestTargetBrief = summary.LatestTargetBrief
701+
// Only persist (and overwrite the in-memory row) when fetch actually
702+
// returned scope — empty/transient failures leave prior data intact.
703+
if summary.ScopeTargets > 0 || summary.LatestTarget != "" {
704+
p.ScopeTargets = summary.ScopeTargets
705+
p.LatestTarget = summary.LatestTarget
706+
p.LatestTargetUpdatedAt = summary.LatestTargetUpdatedAt
707+
p.LatestTargetBrief = summary.LatestTargetBrief
708+
_ = db.UpsertProgramScope(db.PersistedProgramScope{
709+
Platform: p.Platform, Handle: p.Handle,
710+
ScopeTargets: p.ScopeTargets, LatestTarget: p.LatestTarget,
711+
LatestTargetUpdatedAt: p.LatestTargetUpdatedAt, LatestTargetBrief: p.LatestTargetBrief,
712+
})
713+
}
686714
}(&programs[i])
687715
}
688716
wg.Wait()
@@ -916,10 +944,26 @@ func enrichITScopeCounts(programs []ProgramSummary, token string) {
916944
defer func() { <-sem }()
917945

918946
summary := fetchITScopeSummary(client, token, p.ID)
919-
p.ScopeTargets = summary.ScopeTargets
920-
if p.LatestTarget == "" {
921-
p.LatestTarget = summary.LatestTarget
922-
p.LatestTargetBrief = summary.LatestTargetBrief
947+
// Only persist on a real scope payload — IT often rate-limits and the
948+
// fetcher swallows that as an empty summary; leaving the in-memory row
949+
// alone preserves whatever was already there (e.g. from the merged DB).
950+
if summary.ScopeTargets > 0 || summary.LatestTarget != "" {
951+
p.ScopeTargets = summary.ScopeTargets
952+
if p.LatestTarget == "" {
953+
p.LatestTarget = summary.LatestTarget
954+
p.LatestTargetBrief = summary.LatestTargetBrief
955+
}
956+
// Overwrite LatestTargetUpdatedAt with the asset-level timestamp from
957+
// fetchITScopeSummary (may be empty if IT didn't expose it). DROP the
958+
// program-level lastUpdatedAt that fetchITPrograms set — otherwise the
959+
// scope-update watch fires on every program edit (bounty bump, etc.)
960+
// not just on real scope changes.
961+
p.LatestTargetUpdatedAt = summary.LatestTargetUpdatedAt
962+
_ = db.UpsertProgramScope(db.PersistedProgramScope{
963+
Platform: p.Platform, Handle: p.Handle,
964+
ScopeTargets: p.ScopeTargets, LatestTarget: p.LatestTarget,
965+
LatestTargetUpdatedAt: p.LatestTargetUpdatedAt, LatestTargetBrief: p.LatestTargetBrief,
966+
})
923967
}
924968
}(&programs[i])
925969
}
@@ -967,9 +1011,16 @@ func fetchITScopeSummary(client *http.Client, token, programID string) ProgramSu
9671011
if target != "" {
9681012
summary.Assets = append(summary.Assets, target)
9691013
}
970-
if target != "" && summary.LatestTarget == "" {
1014+
// Per-asset timestamp (try common IT field names). Use the LATEST one
1015+
// across all assets so the watch fires only when scope genuinely changes
1016+
// — NOT on unrelated program edits (bounty bump, description change, …)
1017+
// which would otherwise look like scope updates if we used the program
1018+
// list's program-level lastUpdatedAt.
1019+
updatedAt := firstGJSONString(v, "updatedAt", "lastUpdatedAt", "modifiedAt", "addedAt", "createdAt")
1020+
if target != "" && (summary.LatestTarget == "" || isNewerProgramTime(updatedAt, summary.LatestTargetUpdatedAt)) {
9711021
summary.LatestTarget = target
9721022
summary.LatestTargetBrief = firstGJSONString(v, "description", "type.value")
1023+
summary.LatestTargetUpdatedAt = updatedAt
9731024
}
9741025
return true
9751026
})

internal/api/programs_cache.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,13 @@ func buildProgramsPayload() programsCachePayload {
134134

135135
wg.Wait()
136136

137+
// Overlay last-known-good scope from the program_scope DB table. This is the
138+
// key persistence guarantee: a program previously fetched successfully keeps
139+
// its real values on the dashboard even when this refresh's enrichment came
140+
// back empty (e.g. rate-limited). The enrich functions ONLY upsert on success,
141+
// so anything in the DB is real data — never an empty value masquerading.
142+
overlayPersistedProgramScope(all)
143+
137144
return programsCachePayload{
138145
Programs: all,
139146
HasH1Token: os.Getenv("H1_USERNAME") != "" && os.Getenv("H1_TOKEN") != "",
@@ -143,6 +150,42 @@ func buildProgramsPayload() programsCachePayload {
143150
}
144151
}
145152

153+
// overlayPersistedProgramScope fills any program row whose fresh enrichment left
154+
// scope empty (ScopeTargets==0 AND no LatestTarget) from the program_scope DB
155+
// table. Rows where this refresh DID get fresh scope are left alone — the latest
156+
// successful fetch always wins. Mutates in place.
157+
func overlayPersistedProgramScope(programs []ProgramSummary) {
158+
persisted, err := db.LoadProgramScopes()
159+
if err != nil || len(persisted) == 0 {
160+
return
161+
}
162+
restored := 0
163+
for i := range programs {
164+
p := &programs[i]
165+
// Only treat scope as "fresh this refresh" when we actually got a target
166+
// or a count. LatestTargetUpdatedAt can be populated from the program-list
167+
// API (e.g. IT's program-level lastUpdatedAt) even when scope enrich
168+
// returned nothing — checking it here would suppress the overlay and
169+
// leave the dashboard showing "—".
170+
if p.ScopeTargets > 0 || p.LatestTarget != "" {
171+
continue // this refresh enriched scope — keep the fresh value
172+
}
173+
key := strings.ToLower(p.Platform) + ":" + p.Handle
174+
s, ok := persisted[key]
175+
if !ok || (s.ScopeTargets == 0 && s.LatestTarget == "") {
176+
continue
177+
}
178+
p.ScopeTargets = s.ScopeTargets
179+
p.LatestTarget = s.LatestTarget
180+
p.LatestTargetUpdatedAt = s.LatestTargetUpdatedAt
181+
p.LatestTargetBrief = s.LatestTargetBrief
182+
restored++
183+
}
184+
if restored > 0 {
185+
log.Printf("[PROGRAMS] preserved last-known-good scope for %d program(s) where this refresh came back empty", restored)
186+
}
187+
}
188+
146189
// refreshProgramsCache rebuilds and persists the cache. It is single-flight:
147190
// if a refresh is already running, it returns false immediately instead of
148191
// launching a second concurrent fetch. Returns true when it persisted a build.

internal/db/db.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,26 @@ func DeleteMonitorChangesByType(changeType string) (int64, error) {
192192
return dbInstance.DeleteMonitorChangesByType(changeType)
193193
}
194194

195+
// UpsertProgramScope persists last-known-good scope for one program.
196+
func UpsertProgramScope(s PersistedProgramScope) error {
197+
if dbInstance == nil {
198+
if err := Init(); err != nil {
199+
return err
200+
}
201+
}
202+
return dbInstance.UpsertProgramScope(s)
203+
}
204+
205+
// LoadProgramScopes returns every persisted scope row keyed by platform:handle.
206+
func LoadProgramScopes() (map[string]PersistedProgramScope, error) {
207+
if dbInstance == nil {
208+
if err := Init(); err != nil {
209+
return nil, err
210+
}
211+
}
212+
return dbInstance.LoadProgramScopes()
213+
}
214+
195215
// InsertKeyhackTemplate inserts or updates a KeyHack template
196216
func InsertKeyhackTemplate(keyname, commandTemplate, method, url, header, body, notes, description string) error {
197217
if dbInstance == nil {

internal/db/postgres.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,20 @@ func (p *PostgresDB) InitSchema() error {
246246
);
247247
CREATE INDEX IF NOT EXISTS idx_program_assets_key ON program_assets(program_key);
248248
249+
-- Persistent last-known-good scope per program. Catalogue payload overlays
250+
-- these onto the freshly-fetched program list — a rate-limited refresh never
251+
-- wipes a program's previously-known scope.
252+
CREATE TABLE IF NOT EXISTS program_scope (
253+
platform VARCHAR(16) NOT NULL,
254+
handle VARCHAR(255) NOT NULL,
255+
scope_targets INTEGER DEFAULT 0,
256+
latest_target VARCHAR(1024) DEFAULT '',
257+
latest_target_updated_at VARCHAR(64) DEFAULT '',
258+
latest_target_brief VARCHAR(2048) DEFAULT '',
259+
fetched_at TIMESTAMP DEFAULT NOW(),
260+
PRIMARY KEY (platform, handle)
261+
);
262+
249263
-- Create keyhack_templates table with proper constraints
250264
CREATE TABLE IF NOT EXISTS keyhack_templates (
251265
id SERIAL PRIMARY KEY,
@@ -727,6 +741,52 @@ func (p *PostgresDB) DeleteProgramScopeAssetsByKey(programKey string) (int64, er
727741
return tag.RowsAffected(), nil
728742
}
729743

744+
// UpsertProgramScope writes the last-known-good scope for one program. Only
745+
// called on a SUCCESSFUL fetch — failed/rate-limited fetches don't touch the
746+
// row, so prior data is preserved.
747+
func (p *PostgresDB) UpsertProgramScope(s PersistedProgramScope) error {
748+
platform := strings.ToLower(strings.TrimSpace(s.Platform))
749+
handle := strings.TrimSpace(s.Handle)
750+
if platform == "" || handle == "" {
751+
return nil
752+
}
753+
_, err := p.pool.Exec(p.ctx, `
754+
INSERT INTO program_scope (platform, handle, scope_targets, latest_target, latest_target_updated_at, latest_target_brief, fetched_at)
755+
VALUES ($1, $2, $3, $4, $5, $6, NOW())
756+
ON CONFLICT (platform, handle) DO UPDATE SET
757+
scope_targets = EXCLUDED.scope_targets,
758+
latest_target = EXCLUDED.latest_target,
759+
latest_target_updated_at = EXCLUDED.latest_target_updated_at,
760+
latest_target_brief = EXCLUDED.latest_target_brief,
761+
fetched_at = EXCLUDED.fetched_at;
762+
`, platform, handle, s.ScopeTargets, s.LatestTarget, s.LatestTargetUpdatedAt, s.LatestTargetBrief)
763+
if err != nil {
764+
return fmt.Errorf("failed to upsert program_scope: %v", err)
765+
}
766+
return nil
767+
}
768+
769+
// LoadProgramScopes returns every persisted scope row keyed by "<platform>:<handle>".
770+
func (p *PostgresDB) LoadProgramScopes() (map[string]PersistedProgramScope, error) {
771+
rows, err := p.pool.Query(p.ctx, `
772+
SELECT platform, handle, scope_targets, latest_target, latest_target_updated_at, latest_target_brief, fetched_at
773+
FROM program_scope;
774+
`)
775+
if err != nil {
776+
return nil, fmt.Errorf("failed to query program_scope: %v", err)
777+
}
778+
defer rows.Close()
779+
out := make(map[string]PersistedProgramScope)
780+
for rows.Next() {
781+
var s PersistedProgramScope
782+
if err := rows.Scan(&s.Platform, &s.Handle, &s.ScopeTargets, &s.LatestTarget, &s.LatestTargetUpdatedAt, &s.LatestTargetBrief, &s.FetchedAt); err != nil {
783+
return nil, fmt.Errorf("failed to scan program_scope: %v", err)
784+
}
785+
out[strings.ToLower(s.Platform)+":"+s.Handle] = s
786+
}
787+
return out, rows.Err()
788+
}
789+
730790
// TruncateProgramScopeAssets wipes every program_assets row.
731791
func (p *PostgresDB) TruncateProgramScopeAssets() (int64, error) {
732792
tag, err := p.pool.Exec(p.ctx, `DELETE FROM program_assets;`)

internal/db/sqlite.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,20 @@ func (s *SQLiteDB) InitSchema() error {
138138
);
139139
CREATE INDEX IF NOT EXISTS idx_program_assets_key ON program_assets(program_key);
140140
141+
-- Persistent last-known-good scope per program. The catalogue payload overlays
142+
-- these on top of the freshly-fetched program list, so a rate-limited refresh
143+
-- never wipes a program's previously-known scope from the dashboard.
144+
CREATE TABLE IF NOT EXISTS program_scope (
145+
platform TEXT NOT NULL,
146+
handle TEXT NOT NULL,
147+
scope_targets INTEGER DEFAULT 0,
148+
latest_target TEXT DEFAULT '',
149+
latest_target_updated_at TEXT DEFAULT '',
150+
latest_target_brief TEXT DEFAULT '',
151+
fetched_at TIMESTAMP DEFAULT (datetime('now')),
152+
PRIMARY KEY (platform, handle)
153+
);
154+
141155
-- Create keyhack_templates table
142156
CREATE TABLE IF NOT EXISTS keyhack_templates (
143157
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -605,6 +619,52 @@ func (s *SQLiteDB) DeleteProgramScopeAssetsByKey(programKey string) (int64, erro
605619
return n, nil
606620
}
607621

622+
// UpsertProgramScope writes the last-known-good scope for one program. Called
623+
// only on a SUCCESSFUL fetch — failed fetches don't touch the table, so prior
624+
// data is preserved.
625+
func (s *SQLiteDB) UpsertProgramScope(p PersistedProgramScope) error {
626+
platform := strings.ToLower(strings.TrimSpace(p.Platform))
627+
handle := strings.TrimSpace(p.Handle)
628+
if platform == "" || handle == "" {
629+
return nil
630+
}
631+
_, err := s.db.Exec(`
632+
INSERT INTO program_scope (platform, handle, scope_targets, latest_target, latest_target_updated_at, latest_target_brief, fetched_at)
633+
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
634+
ON CONFLICT (platform, handle) DO UPDATE SET
635+
scope_targets = excluded.scope_targets,
636+
latest_target = excluded.latest_target,
637+
latest_target_updated_at = excluded.latest_target_updated_at,
638+
latest_target_brief = excluded.latest_target_brief,
639+
fetched_at = excluded.fetched_at;
640+
`, platform, handle, p.ScopeTargets, p.LatestTarget, p.LatestTargetUpdatedAt, p.LatestTargetBrief)
641+
if err != nil {
642+
return fmt.Errorf("failed to upsert program_scope: %v", err)
643+
}
644+
return nil
645+
}
646+
647+
// LoadProgramScopes returns every persisted scope row keyed by "<platform>:<handle>".
648+
func (s *SQLiteDB) LoadProgramScopes() (map[string]PersistedProgramScope, error) {
649+
rows, err := s.db.Query(`
650+
SELECT platform, handle, scope_targets, latest_target, latest_target_updated_at, latest_target_brief, fetched_at
651+
FROM program_scope;
652+
`)
653+
if err != nil {
654+
return nil, fmt.Errorf("failed to query program_scope: %v", err)
655+
}
656+
defer rows.Close()
657+
out := make(map[string]PersistedProgramScope)
658+
for rows.Next() {
659+
var p PersistedProgramScope
660+
if err := rows.Scan(&p.Platform, &p.Handle, &p.ScopeTargets, &p.LatestTarget, &p.LatestTargetUpdatedAt, &p.LatestTargetBrief, &p.FetchedAt); err != nil {
661+
return nil, fmt.Errorf("failed to scan program_scope: %v", err)
662+
}
663+
out[strings.ToLower(p.Platform)+":"+p.Handle] = p
664+
}
665+
return out, rows.Err()
666+
}
667+
608668
// TruncateProgramScopeAssets wipes every program_assets row.
609669
func (s *SQLiteDB) TruncateProgramScopeAssets() (int64, error) {
610670
res, err := s.db.Exec(`DELETE FROM program_assets;`)

internal/db/types.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,15 @@ type DB interface {
132132
// Used to scrub the historical flood of false "new_program_asset" entries.
133133
DeleteMonitorChangesByType(changeType string) (int64, error)
134134

135+
// UpsertProgramScope persists the last-known-good scope summary for a single
136+
// program. Only called on SUCCESSFUL fetches — a failed/rate-limited fetch must
137+
// NOT call this, otherwise we'd overwrite good data with empty values.
138+
UpsertProgramScope(s PersistedProgramScope) error
139+
// LoadProgramScopes returns every persisted scope row keyed by
140+
// "<lower(platform)>:<handle>" so the warmer can overlay it onto a fresh
141+
// catalogue payload (preserving prior scope when a refresh's enrichment fails).
142+
LoadProgramScopes() (map[string]PersistedProgramScope, error)
143+
135144
// DNS Takeover Providers
136145
ListVulnerableDNSProviders() (map[string]string, error)
137146
AddVulnerableDNSProvider(name, fingerprint string) error
@@ -279,6 +288,21 @@ type JSEndpoint struct {
279288
SourceJS string // the JS URL it was found in (best-effort)
280289
}
281290

291+
// PersistedProgramScope is the last-known-good scope summary for a single
292+
// bug-bounty program. UpsertProgramScope writes one of these PER successful
293+
// fetch; a failed/rate-limited fetch must never call upsert. The serving layer
294+
// overlays this on top of the freshly-built catalogue so the dashboard keeps
295+
// showing real values even when this refresh's enrichment came back empty.
296+
type PersistedProgramScope struct {
297+
Platform string // "h1" | "bc" | "it"
298+
Handle string // platform handle (e.g. "ryan-bbp")
299+
ScopeTargets int
300+
LatestTarget string
301+
LatestTargetUpdatedAt string
302+
LatestTargetBrief string
303+
FetchedAt time.Time
304+
}
305+
282306
// MonitorChange records a detected change for history/querying
283307
type MonitorChange struct {
284308
ID int

0 commit comments

Comments
 (0)