Skip to content

Commit 00836e0

Browse files
h0tak88rclaude
andcommitted
feat: YWH in Programs+pipeline, findings-count fix, Scans-page triggers, pipeline httpx
- programs: native YesWeHack fetch (fetchYWHPrograms via bbscope) wired into the Programs warmer + serveProgramsPayload 'ywh' filter + has_ywh_token. YWH now shows on the Programs page and feeds the root pipeline (which uses the same aggregation). H1/BC/YWH 'has token' now derive from accounts.Count. - findings tag: the standalone global-nuclei scan and the root pipeline never called db.UpdateScanStats, so files_uploaded stayed 0 and the 'N findings' tag never showed. Both now record their match count; 'pipeline' added to indexScanArtifacts (results become viewable) and to the Scans-page finding-type lists (label reads 'findings'). - scans page: add Root Pipeline + Run Nuclei Template buttons to the Scans view header (reusing the existing global modals) so both are reachable there too. - pipeline: add the httpx live-host step (livehosts.FilterLiveHosts) between enumeration and nuclei — scans only live hosts (falls back to all subs), matching the agreed workflow: roots(all platforms) → new-only → enum → httpx → nuclei → Discord. - test: migration test made robust to the shared global db instance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 996fa07 commit 00836e0

9 files changed

Lines changed: 210 additions & 34 deletions

File tree

internal/accounts/accounts_test.go

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,31 +21,63 @@ func TestMigrateEnvAccounts(t *testing.T) {
2121
t.Fatalf("db.EnsureSchema: %v", err)
2222
}
2323

24-
t.Setenv("H1_USERNAME", "0x88")
25-
t.Setenv("H1_TOKEN", "h1-secret")
26-
t.Setenv("BUGCROWD_TOKEN", "bc-secret")
24+
// Use a distinct platform (Intigriti) + unique token so the assertions are
25+
// robust even though db.Init caches one global instance across tests.
26+
t.Setenv("INTIGRITI_TOKEN", "it-secret-xyz")
2727

2828
MigrateEnvAccounts()
2929

30-
// h1 env creds imported as a "default" DB account.
31-
rows, err := db.ListBBPAccounts("h1")
32-
if err != nil {
33-
t.Fatalf("ListBBPAccounts(h1): %v", err)
34-
}
35-
if len(rows) != 1 || rows[0].Label != "default" || rows[0].Username != "0x88" || rows[0].Token != "h1-secret" {
36-
t.Fatalf("h1 not imported correctly: %+v", rows)
30+
// The env Intigriti token is imported as a "default" DB account.
31+
def := findAccount(t, "it", "default")
32+
if def.Token != "it-secret-xyz" {
33+
t.Fatalf("it/default not imported correctly: %+v", def)
3734
}
3835

39-
// After migration, For(h1) serves exactly the DB account — the env account is
40-
// no longer injected (would otherwise duplicate the same credential).
41-
acc := For("h1")
42-
if len(acc) != 1 || acc[0].Source != "db" || acc[0].Token != "h1-secret" {
43-
t.Fatalf("For(h1) after migration = %+v, want one db account", acc)
36+
// After migration, For(it) no longer injects an env-source account (DB is
37+
// authoritative) and does include the migrated credential.
38+
foundMigrated := false
39+
for _, a := range For("it") {
40+
if a.Source == "env" {
41+
t.Fatalf("env account still injected after migration: %+v", a)
42+
}
43+
if a.Token == "it-secret-xyz" {
44+
foundMigrated = true
45+
}
46+
}
47+
if !foundMigrated {
48+
t.Fatalf("migrated it account missing from For(it)")
4449
}
4550

46-
// Idempotent: a second run must not create duplicates.
51+
// Idempotent: a second run must not create a duplicate "default".
4752
MigrateEnvAccounts()
48-
if rows2, _ := db.ListBBPAccounts("h1"); len(rows2) != 1 {
49-
t.Fatalf("migration not idempotent: %d h1 rows", len(rows2))
53+
if n := countAccounts(t, "it", "default"); n != 1 {
54+
t.Fatalf("migration not idempotent: %d it/default accounts", n)
55+
}
56+
}
57+
58+
func findAccount(t *testing.T, platform, label string) db.BBPAccount {
59+
t.Helper()
60+
rows, err := db.ListBBPAccounts(platform)
61+
if err != nil {
62+
t.Fatalf("ListBBPAccounts(%s): %v", platform, err)
63+
}
64+
for _, r := range rows {
65+
if r.Label == label {
66+
return r
67+
}
68+
}
69+
t.Fatalf("no %s/%s account found in %+v", platform, label, rows)
70+
return db.BBPAccount{}
71+
}
72+
73+
func countAccounts(t *testing.T, platform, label string) int {
74+
t.Helper()
75+
rows, _ := db.ListBBPAccounts(platform)
76+
n := 0
77+
for _, r := range rows {
78+
if r.Label == label {
79+
n++
80+
}
5081
}
82+
return n
5183
}

internal/api/api.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1577,7 +1577,7 @@ func indexScanArtifacts(scanID, scanType, target string) {
15771577
"urls": true, "js": true, "jsscan": true, "reflection": true,
15781578
"nuclei": true, "tech": true, "ports": true, "gf": true,
15791579
"backup": true, "aem": true, "depconfusion": true, "wp_confusion": true,
1580-
"zerodays": true,
1580+
"zerodays": true, "pipeline": true,
15811581
}
15821582

15831583
if domainRootTypes[scanType] && target != "" {

internal/api/pipeline_api.go

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/gin-gonic/gin"
1212
"github.com/h0tak88r/AutoAR/internal/db"
13+
"github.com/h0tak88r/AutoAR/internal/scanner/livehosts"
1314
"github.com/h0tak88r/AutoAR/internal/scanner/nuclei"
1415
scopemod "github.com/h0tak88r/AutoAR/internal/scanner/scope"
1516
"github.com/h0tak88r/AutoAR/internal/scanner/subdomains"
@@ -114,31 +115,45 @@ func runRootPipeline(scanID, template string, newOnly bool, threads, maxRoots in
114115
defer wg.Done()
115116
defer func() { <-sem }()
116117
subs, err := subdomains.EnumerateSubdomains(root, threads)
117-
mu.Lock()
118-
defer mu.Unlock()
119-
done++
120118
if err != nil {
119+
mu.Lock()
120+
done++
121121
stdLog(scanID, "[WARN] enum %s failed: %v", root, err)
122+
mu.Unlock()
122123
return
123124
}
124125
if len(subs) > 0 {
125126
if ierr := db.BatchInsertSubdomains(root, subs, false); ierr != nil {
126127
stdLog(scanID, "[WARN] store %s subs failed: %v", root, ierr)
127128
}
128-
allSubs = append(allSubs, subs...)
129129
}
130-
stdLog(scanID, "[OK] %s → %d subs (%d/%d roots)", root, len(subs), done, len(targetRoots))
130+
// httpx: probe for live hosts (marks live in the DB) and scan only those.
131+
// Falls back to the raw subdomains if the probe finds nothing (nuclei does
132+
// its own probing anyway, so no host is lost).
133+
targets := subs
134+
liveN := 0
135+
if res, herr := livehosts.FilterLiveHosts(root, threads, true); herr == nil && res != nil && res.LiveSubsFile != "" {
136+
if live, rerr := utils.ReadLines(res.LiveSubsFile); rerr == nil && len(live) > 0 {
137+
targets = live
138+
liveN = len(live)
139+
}
140+
}
141+
mu.Lock()
142+
done++
143+
allSubs = append(allSubs, targets...)
144+
stdLog(scanID, "[OK] %s → %d subs, %d live (%d/%d roots)", root, len(subs), liveN, done, len(targetRoots))
145+
mu.Unlock()
131146
}(r)
132147
}
133148
wg.Wait()
134149

135150
allSubs = uniqueStrings(allSubs)
136-
stdLog(scanID, "[INFO] enumeration complete: %d new subdomain(s)", len(allSubs))
151+
stdLog(scanID, "[INFO] enumeration + httpx complete: %d live host(s) to scan", len(allSubs))
137152
if len(allSubs) == 0 {
138-
utils.SendMonitorWebhook(" Root Pipeline: enumeration found no subdomains for the new roots.")
153+
utils.SendMonitorWebhook(" Root Pipeline: no live hosts found for the new roots.")
139154
return nil
140155
}
141-
utils.SendMonitorWebhook(fmt.Sprintf(" Root Pipeline: enumerated **%d** subdomains — running nuclei…", len(allSubs)))
156+
utils.SendMonitorWebhook(fmt.Sprintf(" Root Pipeline: %d live host(s) — running nuclei…", len(allSubs)))
142157

143158
// Write the discovered subs to a temp target file for nuclei.
144159
tmpFile, err := os.CreateTemp("", "root-pipeline-targets-*.txt")
@@ -179,6 +194,9 @@ func runRootPipeline(scanID, template string, newOnly bool, threads, maxRoots in
179194
return fmt.Errorf("nuclei scan failed: %w", err)
180195
}
181196

197+
// Record the finding count so the Scans page shows the "N findings" tag.
198+
_ = db.UpdateScanStats(scanID, matches, 0)
199+
182200
stdLog(scanID, "[OK] Root Pipeline complete — %d roots, %d subs, %d matches", len(targetRoots), len(allSubs), matches)
183201
utils.SendMonitorWebhook(fmt.Sprintf(" **Root Pipeline complete**\nTemplate: `%s`\nNew roots: %d\nSubdomains: %d\nMatches: %d",
184202
tplName, len(targetRoots), len(allSubs), matches))

internal/api/programs_api.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,10 @@ func serveProgramsPayload(c *gin.Context, payload programsCachePayload, platform
243243
if p.Platform == "it" {
244244
programs = append(programs, p)
245245
}
246+
case "ywh", "yeswehack":
247+
if p.Platform == "ywh" {
248+
programs = append(programs, p)
249+
}
246250
case "ha", "hackadvisor", "external":
247251
if p.Platform == "ha" {
248252
programs = append(programs, p)
@@ -258,6 +262,7 @@ func serveProgramsPayload(c *gin.Context, payload programsCachePayload, platform
258262
"has_h1_token": payload.HasH1Token,
259263
"has_bc_token": payload.HasBCToken,
260264
"has_it_token": payload.HasITToken,
265+
"has_ywh_token": payload.HasYWHToken,
261266
"has_ha_token": payload.HasHAToken,
262267
"scope_included": true,
263268
"warm": true,

internal/api/programs_cache.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"sync"
99
"time"
1010

11+
"github.com/h0tak88r/AutoAR/internal/accounts"
1112
"github.com/h0tak88r/AutoAR/internal/db"
1213
)
1314

@@ -42,6 +43,7 @@ type programsCachePayload struct {
4243
HasH1Token bool `json:"has_h1_token"`
4344
HasBCToken bool `json:"has_bc_token"`
4445
HasITToken bool `json:"has_it_token"`
46+
HasYWHToken bool `json:"has_ywh_token"`
4547
HasHAToken bool `json:"has_ha_token"`
4648
GeneratedAt time.Time `json:"generated_at"`
4749
}
@@ -133,6 +135,21 @@ func buildProgramsPayload() programsCachePayload {
133135
mu.Unlock()
134136
}()
135137

138+
// YesWeHack — native fetch across every configured YWH account (bbscope). No-op
139+
// unless a YWH account/token is configured.
140+
wg.Add(1)
141+
go func() {
142+
defer wg.Done()
143+
progs, err := fetchYWHPrograms(true, true)
144+
if err != nil {
145+
log.Printf("[PROGRAMS] YesWeHack fetch failed: %v", err)
146+
return
147+
}
148+
mu.Lock()
149+
all = append(all, progs...)
150+
mu.Unlock()
151+
}()
152+
136153
// HackAdvisor — external targets aggregator (Immunefi, Standoff365, BI.ZONE,
137154
// YesWeHack, self-hosted, …). No-op unless HACKADVISOR_TOKEN is set. The list
138155
// already carries scope_count + scope_updated_at, so no per-program enrichment.
@@ -160,9 +177,10 @@ func buildProgramsPayload() programsCachePayload {
160177

161178
return programsCachePayload{
162179
Programs: all,
163-
HasH1Token: os.Getenv("H1_USERNAME") != "" && os.Getenv("H1_TOKEN") != "",
164-
HasBCToken: os.Getenv("BUGCROWD_TOKEN") != "",
180+
HasH1Token: accounts.Count("h1") > 0,
181+
HasBCToken: accounts.Count("bc") > 0,
165182
HasITToken: hasIntigritiToken(),
183+
HasYWHToken: accounts.Count("ywh") > 0,
166184
HasHAToken: hasHackAdvisorToken(),
167185
GeneratedAt: time.Now().UTC(),
168186
}

internal/api/programs_ywh.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package api
2+
3+
import (
4+
"strings"
5+
6+
"github.com/h0tak88r/AutoAR/internal/accounts"
7+
scopemod "github.com/h0tak88r/AutoAR/internal/scanner/scope"
8+
"github.com/sw33tLie/bbscope/pkg/scope"
9+
)
10+
11+
// fetchYWHPrograms fetches YesWeHack programs (with in-scope assets) across every
12+
// configured YWH account and merges them, so YesWeHack shows up on the Programs
13+
// page and feeds the root pipeline just like HackerOne/Bugcrowd/Intigriti. Uses
14+
// bbscope's YesWeHack client via scopemod.FetchScope. includeScope is accepted for
15+
// signature parity with the other fetchers — bbscope always returns scope.
16+
func fetchYWHPrograms(bbpOnly, includeScope bool) ([]ProgramSummary, error) {
17+
_ = includeScope
18+
accts := accounts.For("ywh")
19+
if len(accts) == 0 {
20+
return nil, nil
21+
}
22+
var merged []ProgramSummary
23+
idx := map[string]int{}
24+
var firstErr error
25+
for _, a := range accts {
26+
if a.Token == "" && (a.Email == "" || a.Password == "") {
27+
continue
28+
}
29+
programs, err := scopemod.FetchScope(scopemod.Options{
30+
Platform: scopemod.PlatformYesWeHack,
31+
Token: a.Token,
32+
Email: a.Email,
33+
Password: a.Password,
34+
Categories: "all",
35+
BBPOnly: bbpOnly,
36+
IncludeOOS: true,
37+
})
38+
if err != nil {
39+
if firstErr == nil {
40+
firstErr = err
41+
}
42+
continue
43+
}
44+
var summaries []ProgramSummary
45+
for _, pd := range programs {
46+
if s, ok := ywhProgramDataToSummary(pd); ok {
47+
summaries = append(summaries, s)
48+
}
49+
}
50+
merged = mergeProgramsByHandle(merged, idx, summaries, a.Label)
51+
}
52+
if len(merged) == 0 && firstErr != nil {
53+
return nil, firstErr
54+
}
55+
return merged, nil
56+
}
57+
58+
// ywhProgramDataToSummary converts a bbscope ProgramData (URL + scope) into a
59+
// ProgramSummary. bbscope carries no program name, so the URL slug is the handle.
60+
func ywhProgramDataToSummary(pd scope.ProgramData) (ProgramSummary, bool) {
61+
handle := ywhHandleFromURL(pd.Url)
62+
s := ProgramSummary{
63+
Platform: "ywh",
64+
Handle: handle,
65+
Name: handle,
66+
URL: pd.Url,
67+
OffersBounties: true, // BBPOnly filter already applied upstream
68+
}
69+
for _, e := range pd.InScope {
70+
t := strings.TrimSpace(e.Target)
71+
if t == "" {
72+
continue
73+
}
74+
s.ScopeTargets++
75+
s.Assets = append(s.Assets, t)
76+
if s.LatestTarget == "" {
77+
s.LatestTarget = t
78+
s.LatestTargetBrief = strings.TrimSpace(e.Description)
79+
}
80+
}
81+
return s, pd.Url != "" || s.ScopeTargets > 0
82+
}
83+
84+
func ywhHandleFromURL(u string) string {
85+
u = strings.TrimRight(strings.TrimSpace(u), "/")
86+
if i := strings.LastIndex(u, "/"); i >= 0 && i < len(u)-1 {
87+
return u[i+1:]
88+
}
89+
return u
90+
}

internal/api/ui/index.html

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,9 +243,19 @@ <h1 class="view-title">Overview</h1>
243243
<!-- ══════════════════════════════════════════════════════════════════ -->
244244
<div class="view" id="view-scans">
245245

246-
<div class="view-header">
247-
<h1 class="view-title">Scans</h1>
248-
<div class="view-subtitle">Active and recent scan history</div>
246+
<div class="view-header" style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px">
247+
<div>
248+
<h1 class="view-title">Scans</h1>
249+
<div class="view-subtitle">Active and recent scan history</div>
250+
</div>
251+
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
252+
<button id="scans-root-pipeline-btn" class="btn btn-primary" style="font-size:12px;padding:7px 16px;gap:6px;display:flex;align-items:center;background:var(--accent-emerald-dim);color:var(--accent-emerald);border-color:rgba(16,185,129,0.3)" onclick="promptRootPipeline()">
253+
Root Pipeline
254+
</button>
255+
<button id="scans-run-nuclei-btn" class="btn btn-primary" style="font-size:12px;padding:7px 16px;gap:6px;display:flex;align-items:center;background:var(--accent-purple-dim);color:var(--accent-purple);border-color:rgba(168,85,247,0.3)" onclick="promptRunGlobalNuclei()">
256+
Run Nuclei Template
257+
</button>
258+
</div>
249259
</div>
250260

251261
<div id="scans-container">

internal/api/ui/pages/scans-page.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@
149149
<span style="font-size:11px;color:var(--text-muted)">Phase ${currentPhase}${totalPhases > 0 ? '/' + totalPhases : ''} · ${pct}%</span>
150150
<div style="display:flex;gap:10px;align-items:center">
151151
${(() => {
152-
const isFindingType = ['reflection', 'dns_cf1016', 'dns-cf1016', 'dns', 'dns-takeover', 'dns-dangling-ip', 'nuclei', 'nuclei-full', 'nuclei-cves', 'nuclei-panels', 'nuclei-vulnerabilities', 'nuclei-default-logins', 'misconfig', 's3', 'github', 'github_org', 'github_scan', 'zerodays', 'gf', 'ffuf', 'sqlmap', 'backup', 'mcp-discovery'].includes(scanType);
152+
const isFindingType = ['reflection', 'dns_cf1016', 'dns-cf1016', 'dns', 'dns-takeover', 'dns-dangling-ip', 'nuclei', 'nuclei-full', 'nuclei-cves', 'nuclei-panels', 'nuclei-vulnerabilities', 'nuclei-default-logins', 'misconfig', 's3', 'github', 'github_org', 'github_scan', 'zerodays', 'pipeline', 'gf', 'ffuf', 'sqlmap', 'backup', 'mcp-discovery'].includes(scanType);
153153
const label = isFindingType ? (filesUploaded === 1 ? 'finding' : 'findings') : (filesUploaded === 1 ? 'file' : 'files');
154154
const icon = isFindingType ? '' : '';
155155
return filesUploaded > 0 ? `<span style="font-size:10px;color:var(--text-muted)">${icon} ${filesUploaded} ${label}</span>` : '';
@@ -218,7 +218,7 @@
218218
const elapsed = completedAt ? elapsedBetween(startedAt, completedAt) : elapsedStr(startedAt);
219219
const scanID = s.scan_id || s.ScanID || '';
220220
const filesUploaded = s.files_uploaded || s.FilesUploaded || 0;
221-
const isFindingType = ['reflection', 'dns_cf1016', 'dns-cf1016', 'dns', 'dns-takeover', 'dns-dangling-ip', 'nuclei', 'nuclei-full', 'nuclei-cves', 'nuclei-panels', 'nuclei-vulnerabilities', 'nuclei-default-logins', 'misconfig', 's3', 'github', 'github_org', 'github_scan', 'zerodays', 'jwt', 'gf', 'ffuf', 'sqlmap', 'backup', 'mcp-discovery'].includes(scanType);
221+
const isFindingType = ['reflection', 'dns_cf1016', 'dns-cf1016', 'dns', 'dns-takeover', 'dns-dangling-ip', 'nuclei', 'nuclei-full', 'nuclei-cves', 'nuclei-panels', 'nuclei-vulnerabilities', 'nuclei-default-logins', 'misconfig', 's3', 'github', 'github_org', 'github_scan', 'zerodays', 'jwt', 'pipeline', 'gf', 'ffuf', 'sqlmap', 'backup', 'mcp-discovery'].includes(scanType);
222222
const label = isFindingType ? 'findings' : 'files';
223223
const icon = isFindingType ? '' : '';
224224
const badgeHtml = filesUploaded > 0 ? `<span class="badge badge-running" style="font-size:10px;padding:2px 6px;margin-bottom:4px;display:inline-block;background:rgba(6,182,212,0.15);border:1px solid rgba(6,182,212,0.3);color:var(--accent-cyan);cursor:help" title="${filesUploaded} ${label} identified">${icon} ${filesUploaded} ${label}</span><br/>` : '';

internal/api/ui_api.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,9 @@ func apiRunGlobalNuclei(c *gin.Context) {
735735
return fmt.Errorf("nuclei SDK scan failed: %w", err)
736736
}
737737

738+
// Record the finding count so the Scans page shows the "N findings" tag.
739+
_ = db.UpdateScanStats(scanID, matches, 0)
740+
738741
stdLog(scanID, "[OK] Global Nuclei scan completed. Matches: %d", matches)
739742
utils.SendWebhookLogAsync(fmt.Sprintf(" **Global Nuclei Scan Completed**\nTemplate: `%s`\nTargets: %d\nMatches: %d", templateNameForLog, totalSubs, matches))
740743

0 commit comments

Comments
 (0)