@@ -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+
699918func firstGJSONString (result gjson.Result , paths ... string ) string {
700919 for _ , path := range paths {
701920 value := strings .TrimSpace (result .Get (path ).Str )
0 commit comments