Skip to content

Commit 7d94e85

Browse files
h0tak88rclaude
andcommitted
feat(programs): add Intigriti to the Programs monitor + fix env var name
Previously the Programs page (program list + scope + cache) was HackerOne + Bugcrowd only, and the Intigriti token env var was inconsistent. - Direct Intigriti API client (programs_api.go): fetchITPrograms + fetchITScopeSummary against api.intigriti.com/external/researcher/v1. Built in-house (not bbscope) because bbscope calls log.Fatal on a bad token / HTTP error, which would os.Exit the whole server when the background warmer runs. Returns errors instead; concurrency-bounded scope enrichment with a rate-limit backoff. - Wired Intigriti into apiListPrograms, serveProgramsPayload (platform filter), the cache warmer (buildProgramsPayload), and the cache payload (HasITToken). - UI: Intigriti option in the platform filter, IT badge color/label, program count, and a "set INTIGRITI_TOKEN" hint when no token is configured. - Env var: canonical INTIGRITI_TOKEN, with INTIGRITI_API_KEY accepted as an alias (intigritiToken()). Used by both the Programs page and the on-demand scope fetch (scope_api.go). Replaced the misspelled/dead INTEGRITI_API_KEY in config.go, env.example, and docker-compose, and documented the real platform credentials (H1_USERNAME/H1_TOKEN, BUGCROWD_TOKEN, INTIGRITI_TOKEN, YWH_TOKEN). Caveat: the Intigriti API doesn't return per-target timestamps, so Intigriti rows show scope counts + a representative target but no "updated X ago". CI replicated locally: CGO_ENABLED=1 go vet/build/test pass; node --check on the JS passes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 00935ab commit 7d94e85

9 files changed

Lines changed: 292 additions & 34 deletions

File tree

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -566,9 +566,12 @@ WHOISXMLAPI_API_KEY=...
566566
ZOOMEYE_USERNAME=...
567567
ZOOMEYE_PASSWORD=...
568568
569-
# Bug bounty platforms
570-
H1_API_KEY=... # HackerOne
571-
INTEGRITI_API_KEY=... # Intigriti
569+
# Bug bounty platforms (Programs page + Targets page + `autoar scope`)
570+
H1_USERNAME=... # HackerOne
571+
H1_TOKEN=... # HackerOne API token
572+
BUGCROWD_TOKEN=... # Bugcrowd _crowdcontrol_session_key cookie
573+
INTIGRITI_TOKEN=... # Intigriti researcher API token (INTIGRITI_API_KEY also accepted)
574+
YWH_TOKEN=... # YesWeHack JWT
572575
573576
# AI analysis — only ONE key is needed
574577
# Recommended (free): OpenCode Zen (no credit card required)

docker-compose.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,14 @@ services:
6666
- ZOOMEYE_USERNAME=${ZOOMEYE_USERNAME:-}
6767
- ZOOMEYE_PASSWORD=${ZOOMEYE_PASSWORD:-}
6868
- ZOOMEYEAPI_API_KEY=${ZOOMEYEAPI_API_KEY:-}
69+
# Bug bounty platform credentials (Programs + Targets pages, `autoar scope`)
6970
- H1_API_KEY=${H1_API_KEY:-}
70-
- INTEGRITI_API_KEY=${INTEGRITI_API_KEY:-}
71+
- H1_USERNAME=${H1_USERNAME:-}
72+
- H1_TOKEN=${H1_TOKEN:-}
73+
- BUGCROWD_TOKEN=${BUGCROWD_TOKEN:-}
74+
- INTIGRITI_TOKEN=${INTIGRITI_TOKEN:-}
75+
- INTIGRITI_API_KEY=${INTIGRITI_API_KEY:-}
76+
- YWH_TOKEN=${YWH_TOKEN:-}
7177

7278
# Database Configuration
7379
# Database Type: "postgresql" or "sqlite"

env.example

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,20 @@ ZOOMEYE_USERNAME=your_zoomeye_username_here
170170
ZOOMEYE_PASSWORD=your_zoomeye_password_here
171171
ZOOMEYEAPI_API_KEY=your_zoomeyeapi_key_here
172172

173-
# Bug Bounty Platform APIs
174-
H1_API_KEY=your_hackerone_key_here
175-
INTEGRITI_API_KEY=your_integriti_key_here
173+
# ============================================================================
174+
# BUG BOUNTY PLATFORM CREDENTIALS
175+
# Power the Programs page (program monitor + scope) and the Targets page /
176+
# `autoar scope`. Set the platforms you use; leave the rest blank.
177+
# ============================================================================
178+
# HackerOne — username + API token (https://hackerone.com/settings/api_token)
179+
H1_USERNAME=
180+
H1_TOKEN=
181+
# Bugcrowd — value of the _crowdcontrol_session_key session cookie
182+
BUGCROWD_TOKEN=
183+
# Intigriti — researcher API token. INTIGRITI_API_KEY is accepted as an alias.
184+
INTIGRITI_TOKEN=
185+
# YesWeHack — JWT token (no email/password needed)
186+
YWH_TOKEN=
176187

177188
# ============================================================================
178189
# AI / LLM PROVIDERS

internal/api/programs_api.go

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,21 @@ func apiListPrograms(c *gin.Context) {
148148
}()
149149
}
150150

151+
if platform == "all" || platform == "it" || platform == "intigriti" {
152+
wg.Add(1)
153+
go func() {
154+
defer wg.Done()
155+
progs, err := fetchITPrograms(bbpOnly, includeScope)
156+
if err != nil {
157+
fmt.Fprintf(os.Stderr, "Error fetching Intigriti programs: %v\n", err)
158+
return
159+
}
160+
mu.Lock()
161+
allPrograms = append(allPrograms, progs...)
162+
mu.Unlock()
163+
}()
164+
}
165+
151166
wg.Wait()
152167

153168
sortPrograms(allPrograms, sortBy)
@@ -161,6 +176,7 @@ func apiListPrograms(c *gin.Context) {
161176
"total": len(allPrograms),
162177
"has_h1_token": os.Getenv("H1_USERNAME") != "" && os.Getenv("H1_TOKEN") != "",
163178
"has_bc_token": os.Getenv("BUGCROWD_TOKEN") != "",
179+
"has_it_token": hasIntigritiToken(),
164180
"scope_included": includeScope,
165181
"warm": false,
166182
})
@@ -183,6 +199,10 @@ func serveProgramsPayload(c *gin.Context, payload programsCachePayload, platform
183199
if p.Platform == "bc" {
184200
programs = append(programs, p)
185201
}
202+
case "it", "intigriti":
203+
if p.Platform == "it" {
204+
programs = append(programs, p)
205+
}
186206
}
187207
}
188208

@@ -193,6 +213,7 @@ func serveProgramsPayload(c *gin.Context, payload programsCachePayload, platform
193213
"total": len(programs),
194214
"has_h1_token": payload.HasH1Token,
195215
"has_bc_token": payload.HasBCToken,
216+
"has_it_token": payload.HasITToken,
196217
"scope_included": true,
197218
"warm": true,
198219
"stale": stale,
@@ -696,6 +717,204 @@ func fetchBCScopeSummary(handle, programURL, token string) ProgramSummary {
696717
return summary
697718
}
698719

720+
// ─────────────────────────────────────────────────────────────────────────────
721+
// Intigriti program fetching
722+
//
723+
// Implemented directly against the Intigriti researcher API (instead of bbscope)
724+
// because bbscope calls log.Fatal on a bad token / HTTP error, which would
725+
// os.Exit the whole server when the background warmer runs. This client returns
726+
// errors instead.
727+
// ─────────────────────────────────────────────────────────────────────────────
728+
729+
const intigritiAPIBase = "https://api.intigriti.com/external/researcher/v1"
730+
731+
// intigritiToken returns the configured Intigriti token. INTIGRITI_TOKEN is the
732+
// canonical name; INTIGRITI_API_KEY is accepted as an alias.
733+
func intigritiToken() string {
734+
if t := strings.TrimSpace(os.Getenv("INTIGRITI_TOKEN")); t != "" {
735+
return t
736+
}
737+
return strings.TrimSpace(os.Getenv("INTIGRITI_API_KEY"))
738+
}
739+
740+
func hasIntigritiToken() bool { return intigritiToken() != "" }
741+
742+
func fetchITPrograms(bbpOnly, includeScope bool) ([]ProgramSummary, error) {
743+
token := intigritiToken()
744+
if token == "" {
745+
// No token — return empty gracefully (mirrors Bugcrowd behavior).
746+
return nil, nil
747+
}
748+
749+
client := &http.Client{Timeout: 30 * time.Second}
750+
var allPrograms []ProgramSummary
751+
offset, total := 0, 0
752+
753+
for {
754+
listURL := fmt.Sprintf("%s/programs?statusId=3&limit=500&offset=%d", intigritiAPIBase, offset)
755+
req, err := http.NewRequest("GET", listURL, nil)
756+
if err != nil {
757+
return allPrograms, err
758+
}
759+
req.Header.Set("Authorization", "Bearer "+token)
760+
req.Header.Set("Accept", "application/json")
761+
762+
resp, err := client.Do(req)
763+
if err != nil {
764+
return allPrograms, err
765+
}
766+
body, _ := io.ReadAll(resp.Body)
767+
resp.Body.Close()
768+
769+
if resp.StatusCode == http.StatusUnauthorized {
770+
return allPrograms, fmt.Errorf("Intigriti API: invalid token (401)")
771+
}
772+
if resp.StatusCode != http.StatusOK {
773+
return allPrograms, fmt.Errorf("Intigriti API returned %d: %s", resp.StatusCode, string(body[:min(300, len(body))]))
774+
}
775+
776+
bodyStr := string(body)
777+
if offset == 0 {
778+
total = int(gjson.Get(bodyStr, "maxCount").Int())
779+
}
780+
records := gjson.Get(bodyStr, "records").Array()
781+
if len(records) == 0 {
782+
break
783+
}
784+
785+
for _, rec := range records {
786+
maxBounty := rec.Get("maxBounty.value").Int()
787+
if bbpOnly && maxBounty == 0 {
788+
continue
789+
}
790+
id := rec.Get("id").String()
791+
if id == "" {
792+
continue
793+
}
794+
// webLinks.detail looks like ".../programs/<company>/<handle>?..."; the
795+
// path after '=' (or the raw value) is the researcher-facing path.
796+
detail := rec.Get("webLinks.detail").String()
797+
programPath := detail
798+
if i := strings.Index(detail, "="); i >= 0 && i+1 < len(detail) {
799+
programPath = detail[i+1:]
800+
}
801+
handle := programPath
802+
if i := strings.LastIndex(strings.TrimRight(programPath, "/"), "/"); i >= 0 {
803+
handle = strings.TrimRight(programPath, "/")[i+1:]
804+
}
805+
name := strings.TrimSpace(rec.Get("name").Str)
806+
if name == "" {
807+
name = handle
808+
}
809+
url := "https://app.intigriti.com/researcher" + programPath
810+
if programPath == "" {
811+
url = "https://app.intigriti.com/"
812+
}
813+
// confidentialityLevel: 4 = Public, else private-ish.
814+
state := "public_mode"
815+
if rec.Get("confidentialityLevel.id").Int() != 4 {
816+
state = "soft_launched"
817+
}
818+
819+
allPrograms = append(allPrograms, ProgramSummary{
820+
ID: id,
821+
Platform: "it",
822+
Handle: handle,
823+
Name: name,
824+
URL: url,
825+
State: state,
826+
SubmissionState: "open",
827+
OffersBounties: maxBounty > 0,
828+
Currency: "EUR",
829+
LatestTargetUpdatedAt: firstGJSONString(rec, "lastUpdatedAt", "updatedAt", "lastActivityAt", "lastSolved"),
830+
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
831+
})
832+
}
833+
834+
offset += len(records)
835+
if total == 0 || offset >= total {
836+
break
837+
}
838+
}
839+
840+
if includeScope {
841+
enrichITScopeCounts(allPrograms, token)
842+
}
843+
return allPrograms, nil
844+
}
845+
846+
func enrichITScopeCounts(programs []ProgramSummary, token string) {
847+
sem := make(chan struct{}, 8)
848+
var wg sync.WaitGroup
849+
client := &http.Client{Timeout: 20 * time.Second}
850+
851+
for i := range programs {
852+
wg.Add(1)
853+
sem <- struct{}{}
854+
go func(p *ProgramSummary) {
855+
defer wg.Done()
856+
defer func() { <-sem }()
857+
858+
summary := fetchITScopeSummary(client, token, p.ID)
859+
p.ScopeTargets = summary.ScopeTargets
860+
if p.LatestTarget == "" {
861+
p.LatestTarget = summary.LatestTarget
862+
p.LatestTargetBrief = summary.LatestTargetBrief
863+
}
864+
}(&programs[i])
865+
}
866+
wg.Wait()
867+
}
868+
869+
// fetchITScopeSummary fetches one program's scope by ID. programID is stored in
870+
// ProgramSummary.ID by fetchITPrograms.
871+
func fetchITScopeSummary(client *http.Client, token, programID string) ProgramSummary {
872+
summary := ProgramSummary{Platform: "it"}
873+
if programID == "" {
874+
return summary
875+
}
876+
877+
for attempt := 0; attempt < 2; attempt++ {
878+
req, err := http.NewRequest("GET", intigritiAPIBase+"/programs/"+programID, nil)
879+
if err != nil {
880+
return summary
881+
}
882+
req.Header.Set("Authorization", "Bearer "+token)
883+
req.Header.Set("Accept", "application/json")
884+
885+
resp, err := client.Do(req)
886+
if err != nil {
887+
return summary
888+
}
889+
body, _ := io.ReadAll(resp.Body)
890+
resp.Body.Close()
891+
if resp.StatusCode != http.StatusOK {
892+
return summary
893+
}
894+
bodyStr := string(body)
895+
// Intigriti rate-limits with a "Request blocked" body — back off once.
896+
if strings.Contains(bodyStr, "Request blocked") && attempt == 0 {
897+
time.Sleep(2 * time.Second)
898+
continue
899+
}
900+
901+
gjson.Get(bodyStr, "domains.content").ForEach(func(_, v gjson.Result) bool {
902+
if v.Get("tier.id").Int() == 5 { // tier 5 = out of scope
903+
return true
904+
}
905+
summary.ScopeTargets++
906+
target := strings.TrimSpace(v.Get("endpoint").Str)
907+
if target != "" && summary.LatestTarget == "" {
908+
summary.LatestTarget = target
909+
summary.LatestTargetBrief = firstGJSONString(v, "description", "type.value")
910+
}
911+
return true
912+
})
913+
return summary
914+
}
915+
return summary
916+
}
917+
699918
func firstGJSONString(result gjson.Result, paths ...string) string {
700919
for _, path := range paths {
701920
value := strings.TrimSpace(result.Get(path).Str)

internal/api/programs_cache.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type programsCachePayload struct {
4141
Programs []ProgramSummary `json:"programs"`
4242
HasH1Token bool `json:"has_h1_token"`
4343
HasBCToken bool `json:"has_bc_token"`
44+
HasITToken bool `json:"has_it_token"`
4445
GeneratedAt time.Time `json:"generated_at"`
4546
}
4647

@@ -118,12 +119,26 @@ func buildProgramsPayload() programsCachePayload {
118119
mu.Unlock()
119120
}()
120121

122+
wg.Add(1)
123+
go func() {
124+
defer wg.Done()
125+
progs, err := fetchITPrograms(true, true)
126+
if err != nil {
127+
log.Printf("[PROGRAMS] Intigriti fetch failed: %v", err)
128+
return
129+
}
130+
mu.Lock()
131+
all = append(all, progs...)
132+
mu.Unlock()
133+
}()
134+
121135
wg.Wait()
122136

123137
return programsCachePayload{
124138
Programs: all,
125139
HasH1Token: os.Getenv("H1_USERNAME") != "" && os.Getenv("H1_TOKEN") != "",
126140
HasBCToken: os.Getenv("BUGCROWD_TOKEN") != "",
141+
HasITToken: hasIntigritiToken(),
127142
GeneratedAt: time.Now().UTC(),
128143
}
129144
}

internal/api/scope_api.go

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ var platforms = []platformMeta{
4949
Name: "Intigriti",
5050
Logo: "",
5151
AuthFields: []string{"token"},
52-
EnvKeys: []string{"INTIGRITI_TOKEN"},
52+
EnvKeys: []string{"INTIGRITI_TOKEN", "INTIGRITI_API_KEY"},
5353
Description: "Fetch in-scope root domains from all accessible Intigriti programs.",
5454
},
5555
{
@@ -84,17 +84,17 @@ func apiScopePlatforms(c *gin.Context) {
8484

8585
// scopeFetchRequest is the body for POST /api/scope/fetch
8686
type scopeFetchRequest struct {
87-
Platform string `json:"platform"`
88-
Username string `json:"username,omitempty"`
89-
Token string `json:"token,omitempty"`
90-
Email string `json:"email,omitempty"`
91-
Password string `json:"password,omitempty"`
92-
BBPOnly bool `json:"bbp_only"`
93-
PvtOnly bool `json:"pvt_only"`
94-
PublicOnly bool `json:"public_only"`
95-
ActiveOnly bool `json:"active_only"`
96-
IncludeOOS bool `json:"include_oos"`
97-
ExtractRoots *bool `json:"extract_roots,omitempty"` // default true if omitted
87+
Platform string `json:"platform"`
88+
Username string `json:"username,omitempty"`
89+
Token string `json:"token,omitempty"`
90+
Email string `json:"email,omitempty"`
91+
Password string `json:"password,omitempty"`
92+
BBPOnly bool `json:"bbp_only"`
93+
PvtOnly bool `json:"pvt_only"`
94+
PublicOnly bool `json:"public_only"`
95+
ActiveOnly bool `json:"active_only"`
96+
IncludeOOS bool `json:"include_oos"`
97+
ExtractRoots *bool `json:"extract_roots,omitempty"` // default true if omitted
9898
}
9999

100100
// POST /api/scope/fetch — fetch programs + extract root domains from a bug bounty platform
@@ -137,7 +137,7 @@ func apiFetchScope(c *gin.Context) {
137137
case scopemod.PlatformBugcrowd:
138138
token = os.Getenv("BUGCROWD_TOKEN")
139139
case scopemod.PlatformIntigriti:
140-
token = os.Getenv("INTIGRITI_TOKEN")
140+
token = intigritiToken() // INTIGRITI_TOKEN, or INTIGRITI_API_KEY alias
141141
case scopemod.PlatformYesWeHack:
142142
// YWH supports a JWT token directly (no email/password needed)
143143
token = os.Getenv("YWH_TOKEN")

internal/api/ui/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ <h1 class="view-title">Programs</h1>
424424
<option value="all">All Platforms</option>
425425
<option value="h1">HackerOne</option>
426426
<option value="bc">Bugcrowd</option>
427+
<option value="it">Intigriti</option>
427428
</select>
428429
<input class="search-input" id="programs-search" placeholder="Search programs…" autocomplete="off" oninput="window.ProgramsPage.renderPrograms()" />
429430
</div>

0 commit comments

Comments
 (0)