Skip to content

Commit e6b5473

Browse files
h0tak88rclaude
andcommitted
feat(assets): Program Lookup — keyword/domain → bug-bounty program
New Asset Management "Program Lookup": search a company keyword or a domain and find the paying bug-bounty program(s) for it, across your authenticated platform accounts and the public bug-bounties.as93.net aggregator. VDPs are excluded everywhere (paying programs only). - db: bbp_catalog_programs + bbp_catalog_domains (in_scope flag) on both postgres & sqlite; keyword + domain search queries; source clear. - internal/bbcatalog: as93 client with 24h in-memory cache, keeping only rewards containing *bounty (drops recognition/swag-only/none VDPs); Sync() rebuilds the catalog from every enabled account (FetchScope BBPOnly + IncludeOOS → programs + in/out-of-scope domains, in-scope wins on shared roots) plus as93. - scope: exported ScopeElementRoots / In/OutScopeRoots helpers. - api: GET /api/assets/program-lookup (keyword+domain, in/out badge, source tag), POST /api/assets/program-sync (background), GET /api/assets/catalog-status. - ui: "Program Lookup" under Asset Management — search box (auto keyword vs domain), result cards with source + rewards + safe-harbor + Open, green IN-SCOPE / red OUT-OF-SCOPE badge for domain hits, Sync button. - tests: bounty/VDP filter, handle parsing, catalog CRUD + in/out-scope domain search against SQLite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1a92574 commit e6b5473

15 files changed

Lines changed: 1032 additions & 28 deletions

File tree

internal/api/api.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,10 @@ func SetupAPI() *gin.Engine {
586586
apiGroup.POST("/accounts", apiUpsertBBPAccount)
587587
apiGroup.POST("/accounts/:id/toggle", apiToggleBBPAccount)
588588
apiGroup.DELETE("/accounts/:id", apiDeleteBBPAccount)
589+
// Program Lookup (keyword/domain → bug-bounty program) catalog
590+
apiGroup.GET("/assets/program-lookup", apiProgramLookup)
591+
apiGroup.POST("/assets/program-sync", apiProgramSync)
592+
apiGroup.GET("/assets/catalog-status", apiCatalogStatus)
589593
apiGroup.GET("/scope/programs", apiListPrograms)
590594
apiGroup.POST("/scope/program-summaries", apiProgramScopeSummaries)
591595
apiGroup.GET("/scope/watch-status", apiProgramWatchStatus)

internal/api/program_lookup_api.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
"time"
7+
8+
"github.com/gin-gonic/gin"
9+
"github.com/h0tak88r/AutoAR/internal/bbcatalog"
10+
"github.com/h0tak88r/AutoAR/internal/db"
11+
)
12+
13+
// lookupResult is one row in a program-lookup response.
14+
type lookupResult struct {
15+
Source string `json:"source"` // h1, bc, it, ywh, as93
16+
Company string `json:"company"`
17+
Handle string `json:"handle"`
18+
URL string `json:"url"`
19+
Rewards string `json:"rewards"`
20+
SafeHarbor string `json:"safe_harbor"`
21+
OffersBounty bool `json:"offers_bounty"`
22+
MatchType string `json:"match_type"` // "keyword" or "domain"
23+
MatchedDomain string `json:"matched_domain,omitempty"`
24+
InScope *bool `json:"in_scope,omitempty"` // set for domain matches
25+
}
26+
27+
// looksLikeDomain heuristically decides whether a query is a domain vs a keyword.
28+
func looksLikeDomain(q string) bool {
29+
q = strings.TrimSpace(q)
30+
if q == "" || strings.ContainsAny(q, " \t") {
31+
return false
32+
}
33+
q = strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(q, "http://"), "https://"), "/")
34+
return strings.Contains(q, ".") && !strings.Contains(q, " ")
35+
}
36+
37+
// GET /api/assets/program-lookup?q=<keyword-or-domain>&mode=auto|keyword|domain
38+
func apiProgramLookup(c *gin.Context) {
39+
q := strings.TrimSpace(c.Query("q"))
40+
if q == "" {
41+
c.JSON(http.StatusBadRequest, gin.H{"error": "q is required"})
42+
return
43+
}
44+
mode := strings.ToLower(c.DefaultQuery("mode", "auto"))
45+
const limit = 100
46+
47+
var out []lookupResult
48+
seen := map[string]bool{} // dedupe by source|handle|matchType
49+
50+
doKeyword := mode == "keyword" || mode == "auto"
51+
doDomain := mode == "domain" || (mode == "auto" && looksLikeDomain(q))
52+
53+
if doDomain {
54+
// Strip scheme/path so a URL paste still matches.
55+
dq := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(q, "http://"), "https://"), "/")
56+
if i := strings.IndexByte(dq, '/'); i >= 0 {
57+
dq = dq[:i]
58+
}
59+
matches, err := db.SearchCatalogByDomain(dq, limit)
60+
if err == nil {
61+
for _, m := range matches {
62+
k := "d|" + m.Source + "|" + m.Handle
63+
if seen[k] {
64+
continue
65+
}
66+
seen[k] = true
67+
in := m.InScope
68+
out = append(out, lookupResult{
69+
Source: m.Source, Company: m.Company, Handle: m.Handle, URL: m.URL,
70+
Rewards: m.Rewards, SafeHarbor: m.SafeHarbor, OffersBounty: m.OffersBounty,
71+
MatchType: "domain", MatchedDomain: m.MatchedDomain, InScope: &in,
72+
})
73+
}
74+
}
75+
}
76+
77+
if doKeyword {
78+
progs, err := db.SearchCatalogByKeyword(q, limit)
79+
if err == nil {
80+
for _, p := range progs {
81+
k := "k|" + p.Source + "|" + p.Handle
82+
if seen[k] {
83+
continue
84+
}
85+
seen[k] = true
86+
out = append(out, lookupResult{
87+
Source: p.Source, Company: p.Company, Handle: p.Handle, URL: p.URL,
88+
Rewards: p.Rewards, SafeHarbor: p.SafeHarbor, OffersBounty: p.OffersBounty,
89+
MatchType: "keyword",
90+
})
91+
}
92+
}
93+
}
94+
95+
c.JSON(http.StatusOK, gin.H{
96+
"query": q,
97+
"is_domain": looksLikeDomain(q),
98+
"results": out,
99+
"total": len(out),
100+
})
101+
}
102+
103+
// POST /api/assets/program-sync — rebuild the catalog in the background.
104+
func apiProgramSync(c *gin.Context) {
105+
if bbcatalog.SyncAsync() {
106+
c.JSON(http.StatusAccepted, gin.H{"status": "started", "message": "Catalog sync started in the background"})
107+
return
108+
}
109+
c.JSON(http.StatusConflict, gin.H{"status": "running", "message": "A sync is already in progress"})
110+
}
111+
112+
// GET /api/assets/catalog-status — counts + last sync summary.
113+
func apiCatalogStatus(c *gin.Context) {
114+
progs, doms, _ := db.CatalogCounts()
115+
running, last, at := bbcatalog.SyncStatus()
116+
var lastAt string
117+
if !at.IsZero() {
118+
lastAt = at.Format(time.RFC3339)
119+
}
120+
c.JSON(http.StatusOK, gin.H{
121+
"programs": progs,
122+
"domains": doms,
123+
"sync_running": running,
124+
"last_sync_at": lastAt,
125+
"last_sync": last,
126+
})
127+
}

internal/api/ui/index.html

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ <h1 class="auth-title">AutoAR</h1>
7070
<div class="nav-subitems" id="assets-subnav" role="menu">
7171
<div class="nav-subitem" id="nav-targets" data-label="Platforms" role="menuitem">Platforms</div>
7272
<div class="nav-subitem" id="nav-programs" data-label="Programs" role="menuitem">Programs</div>
73+
<div class="nav-subitem" id="nav-program-lookup" data-label="Program Lookup" role="menuitem">Program Lookup</div>
7374
<div class="nav-subitem" id="nav-domains" data-label="Domains" role="menuitem">Domains</div>
7475
<div class="nav-subitem" id="nav-subdomains" data-label="Subdomains" role="menuitem">Subdomains<span class="nav-badge" id="subdomains-badge" style="display:none"></span></div>
7576
</div>
@@ -456,6 +457,29 @@ <h1 class="view-title">Programs</h1>
456457
</div><!-- /view-programs -->
457458

458459

460+
<!-- ══════════════════════════════════════════════════════════════════ -->
461+
<!-- VIEW: Program Lookup -->
462+
<!-- ══════════════════════════════════════════════════════════════════ -->
463+
<div class="view" id="view-program-lookup">
464+
<div class="view-header">
465+
<h1 class="view-title">Program Lookup</h1>
466+
<div class="view-subtitle">Find the bug-bounty program for a keyword or domain — your accounts + public aggregator (paying programs only)</div>
467+
</div>
468+
469+
<div class="filter-bar" style="gap:8px;flex-wrap:wrap;align-items:center;">
470+
<input class="search-input" id="plookup-search" placeholder="Company keyword or domain (e.g. acme, api.acme.com)…" autocomplete="off"
471+
onkeydown="if(event.key==='Enter'){window.programLookupSearch();}" style="flex:1;min-width:260px;" />
472+
<button class="btn btn-primary" onclick="window.programLookupSearch()">Search</button>
473+
<button class="btn btn-ghost" id="plookup-sync-btn" onclick="window.programLookupSync()" title="Rebuild the catalog from your accounts + the as93 aggregator">⟳ Sync catalog</button>
474+
</div>
475+
476+
<div id="plookup-status" style="margin-bottom:12px;font-size:12px;color:rgba(255,255,255,0.5);"></div>
477+
<div id="plookup-results">
478+
<div class="empty-state"><div class="empty-icon">...</div><div class="empty-title">Search a keyword or domain to find its bug-bounty program</div></div>
479+
</div>
480+
</div><!-- /view-program-lookup -->
481+
482+
459483
<!-- ══════════════════════════════════════════════════════════════════ -->
460484
<!-- VIEW: Keyhacks -->
461485
<!-- ══════════════════════════════════════════════════════════════════ -->
@@ -800,6 +824,7 @@ <h2 class="auth-title" style="margin-bottom: 8px;">Import Subdomains</h2>
800824
<script src="/ui/pages/app-config-state.js"></script>
801825
<script src="/ui/pages/targets.js"></script>
802826
<script src="/ui/pages/programs.js"></script>
827+
<script src="/ui/pages/program-lookup.js"></script>
803828
<script src="/ui/pages/keyhacks.js"></script>
804829
<script src="/ui/pages/report-templates.js"></script>
805830
<script src="/ui/pages/monitor.js"></script>

internal/api/ui/pages/app-config-state.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ window.AppConfigState = {
33
POLL_INTERVAL: 15000,
44
POLL_FAST_SCANS: 3500,
55
POLL_FAST_ANY: 7000,
6-
VIEWS: ['overview', 'scans', 'domains', 'subdomains', 'targets', 'programs', 'keyhacks', 'monitor', 'r2', 'settings', 'report-templates', 'apkauditor', 'ipaauditor', 'adbauditor', 'securitylab'],
6+
VIEWS: ['overview', 'scans', 'domains', 'subdomains', 'targets', 'programs', 'program-lookup', 'keyhacks', 'monitor', 'r2', 'settings', 'report-templates', 'apkauditor', 'ipaauditor', 'adbauditor', 'securitylab'],
77
state: {
88
view: 'overview',
99
config: null,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
overview: 'Overview', scans: 'Scans', domains: 'Domains', subdomains: 'Subdomains',
55
targets: 'Bug Bounty Targets',
66
programs: 'Programs',
7+
'program-lookup': 'Program Lookup',
78
keyhacks: 'Keyhacks',
89
monitor: 'Monitor', r2: 'R2 Storage', settings: 'Settings',
910
'report-templates': 'Report Templates',

internal/api/ui/pages/polling.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
// backend warmer rebuild, then re-renders the table once it's done.
5151
window.ProgramsPage.refreshNow();
5252
break;
53+
case 'program-lookup': window.loadProgramLookup(); break;
5354
case 'monitor': window.loadMonitor(); break;
5455
case 'keyhacks': window.loadKeyhacks(); break;
5556
case 'report-templates': window.renderReportTemplates(); break;
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
(() => {
2+
const SOURCE_META = {
3+
h1: { name: 'HackerOne', color: '#2ecc71' },
4+
bc: { name: 'Bugcrowd', color: '#e67e22' },
5+
it: { name: 'Intigriti', color: '#9b59b6' },
6+
ywh: { name: 'YesWeHack', color: '#3498db' },
7+
as93: { name: 'External', color: '#95a5a6' },
8+
};
9+
10+
const esc = (s) =>
11+
window.esc ? window.esc(s) : String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
12+
13+
async function refreshStatus() {
14+
try {
15+
const s = await window.apiFetch('/api/assets/catalog-status');
16+
const el = document.getElementById('plookup-status');
17+
if (el) {
18+
const last = s.last_sync_at ? new Date(s.last_sync_at).toLocaleString() : 'never';
19+
el.innerHTML =
20+
`Catalog: <b>${s.programs || 0}</b> programs · <b>${s.domains || 0}</b> scoped domains · last sync: ${esc(last)}` +
21+
(s.sync_running ? ' · <span style="color:#f1c40f">syncing…</span>' : '');
22+
}
23+
return s;
24+
} catch (e) {
25+
return null;
26+
}
27+
}
28+
29+
async function loadProgramLookup() {
30+
await refreshStatus();
31+
}
32+
33+
async function programLookupSearch() {
34+
const q = (document.getElementById('plookup-search')?.value || '').trim();
35+
const box = document.getElementById('plookup-results');
36+
if (!q || !box) return;
37+
box.innerHTML = '<div class="empty-state"><div class="empty-title">Searching…</div></div>';
38+
try {
39+
const data = await window.apiFetch('/api/assets/program-lookup?q=' + encodeURIComponent(q));
40+
renderResults(data);
41+
} catch (e) {
42+
box.innerHTML = `<div class="empty-state"><div class="empty-title" style="color:var(--accent-red)">Search failed: ${esc(e.message)}</div></div>`;
43+
}
44+
}
45+
46+
function renderResults(data) {
47+
const box = document.getElementById('plookup-results');
48+
const results = data.results || [];
49+
if (!results.length) {
50+
box.innerHTML =
51+
`<div class="empty-state"><div class="empty-icon">∅</div><div class="empty-title">No paying programs found for “${esc(data.query)}”</div>` +
52+
`<div style="font-size:12px;color:var(--text-muted);margin-top:6px">Try a different keyword, or Sync the catalog if you haven't yet.</div></div>`;
53+
return;
54+
}
55+
const rows = results
56+
.map((r) => {
57+
const meta = SOURCE_META[r.source] || { name: r.source, color: '#888' };
58+
let scopeBadge = '';
59+
if (r.match_type === 'domain') {
60+
scopeBadge = r.in_scope
61+
? `<span style="flex-shrink:0;background:rgba(46,204,113,.15);color:#2ecc71;border:1px solid #2ecc7155;font-size:10px;font-weight:700;padding:2px 8px;border-radius:6px">IN-SCOPE</span>`
62+
: `<span style="flex-shrink:0;background:rgba(231,76,60,.15);color:#e74c3c;border:1px solid #e74c3c55;font-size:10px;font-weight:700;padding:2px 8px;border-radius:6px">OUT-OF-SCOPE</span>`;
63+
}
64+
const sub = [
65+
r.match_type === 'domain' ? 'matched: ' + esc(r.matched_domain) : '',
66+
r.rewards ? esc(r.rewards) : '',
67+
r.safe_harbor ? 'safe-harbor: ' + esc(r.safe_harbor) : '',
68+
].filter(Boolean).join(' · ');
69+
return `
70+
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid var(--border);border-radius:10px;margin-bottom:8px;background:rgba(255,255,255,.02)">
71+
<span style="flex-shrink:0;font-size:10px;font-weight:800;color:${meta.color};border:1px solid ${meta.color}66;border-radius:6px;padding:3px 8px">${esc(meta.name)}</span>
72+
<div style="flex:1;min-width:0">
73+
<div style="font-weight:600;color:var(--text-primary);font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(r.company)}</div>
74+
<div style="font-size:11px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${sub}</div>
75+
</div>
76+
${scopeBadge}
77+
<a href="${esc(r.url)}" target="_blank" rel="noopener" class="btn btn-sm" style="flex-shrink:0">Open</a>
78+
</div>`;
79+
})
80+
.join('');
81+
box.innerHTML =
82+
`<div style="font-size:12px;color:var(--text-muted);margin-bottom:10px">${results.length} paying program(s) for “${esc(data.query)}${data.is_domain ? ' · domain search' : ''}</div>` +
83+
rows;
84+
}
85+
86+
async function programLookupSync() {
87+
const btn = document.getElementById('plookup-sync-btn');
88+
if (btn) { btn.disabled = true; btn.textContent = '⟳ Syncing…'; }
89+
try {
90+
await window.apiPost('/api/assets/program-sync', {});
91+
window.showToast('info', 'Sync started', 'Rebuilding the program catalog in the background…');
92+
const poll = setInterval(async () => {
93+
const s = await refreshStatus();
94+
if (s && !s.sync_running) {
95+
clearInterval(poll);
96+
if (btn) { btn.disabled = false; btn.textContent = '⟳ Sync catalog'; }
97+
window.showToast('success', 'Catalog synced', `${s.programs || 0} programs · ${s.domains || 0} domains`);
98+
}
99+
}, 3000);
100+
} catch (e) {
101+
if (btn) { btn.disabled = false; btn.textContent = '⟳ Sync catalog'; }
102+
window.showToast('error', 'Sync failed', e.message);
103+
}
104+
}
105+
106+
window.loadProgramLookup = loadProgramLookup;
107+
window.programLookupSearch = programLookupSearch;
108+
window.programLookupSync = programLookupSync;
109+
})();

0 commit comments

Comments
 (0)