Skip to content

Commit 4306cc2

Browse files
committed
adding cloud results upload
1 parent 941f787 commit 4306cc2

File tree

6 files changed

+464
-2
lines changed

6 files changed

+464
-2
lines changed

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,14 @@ OUTPUT:
138138
-v, -verbose display verbose output
139139
-version display project version
140140

141+
PDCP:
142+
-pd, -dashboard upload or view output in the PDCP UI dashboard
143+
-pdu, -dashboard-upload string upload tlsx output file (JSONL format) to the PDCP UI dashboard
144+
-auth string PDCP API key for authentication
145+
-tid, -team-id string upload asset results to a specified team ID
146+
-aid, -asset-id string upload new assets to an existing asset ID
147+
-aname, -asset-name string asset group name
148+
141149
DEBUG:
142150
-health-check, -hc run diagnostic check up
143151
```
@@ -421,6 +429,43 @@ echo example.com | tlsx -json -silent | jq .
421429
}
422430
```
423431

432+
### PDCP Dashboard Integration
433+
434+
**tlsx** supports uploading scan results to the ProjectDiscovery Cloud Platform (PDCP) dashboard for visualization and analysis.
435+
436+
#### Uploading Results in Real-Time
437+
438+
Enable dashboard upload to automatically upload results as they are discovered:
439+
440+
```console
441+
$ tlsx -u example.com -pd -json
442+
```
443+
444+
Results will be automatically uploaded to PDCP and you'll receive a dashboard URL to view them.
445+
446+
#### Uploading an Existing File
447+
448+
Upload a previously saved JSONL output file to PDCP:
449+
450+
```console
451+
$ tlsx -pdu results.jsonl -tid your-team-id -aname "My Scan"
452+
```
453+
454+
#### Configuration Options
455+
456+
- `-pd, --dashboard`: Enable real-time upload to PDCP dashboard
457+
- `-pdu, --dashboard-upload <file>`: Upload a specific JSONL file to PDCP
458+
- `-auth <key>`: PDCP API key (can also be set via environment or credentials handler)
459+
- `-tid, --team-id <id>`: Specify team ID for uploads
460+
- `-aid, --asset-id <id>`: Upload to an existing asset ID
461+
- `-aname, --asset-name <name>`: Set a custom name for the asset group
462+
463+
Example with all options:
464+
465+
```console
466+
$ tlsx -u example.com -pd -json -tid team123 -aname "Production Scan"
467+
```
468+
424469
## Configuration
425470

426471
### Scan Mode

cmd/tlsx/main.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,15 @@ func readFlags(args ...string) error {
149149
flagSet.BoolVar(&options.Version, "version", false, "display project version"),
150150
)
151151

152+
flagSet.CreateGroup("pdcp", "PDCP",
153+
flagSet.BoolVarP(&options.Dashboard, "dashboard", "pd", false, "upload or view output in the PDCP UI dashboard"),
154+
flagSet.StringVarP(&options.DashboardUpload, "dashboard-upload", "pdu", "", "upload tlsx output file (JSONL format) to the PDCP UI dashboard"),
155+
flagSet.StringVarP(&options.PDCPAPIKey, "auth", "", "", "PDCP API key for authentication"),
156+
flagSet.StringVarP(&options.PDCPTeamID, "team-id", "tid", "", "upload asset results to a specified team ID"),
157+
flagSet.StringVarP(&options.PDCPAssetID, "asset-id", "aid", "", "upload new assets to an existing asset ID"),
158+
flagSet.StringVarP(&options.PDCPAssetName, "asset-name", "aname", "", "asset group name"),
159+
)
160+
152161
flagSet.CreateGroup("debug", "Debug",
153162
flagSet.BoolVarP(&options.HealthCheck, "hc", "health-check", false, "run diagnostic check up"),
154163
)

internal/pdcp/utils.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package pdcp
2+
3+
import (
4+
pdcpauth "github.com/projectdiscovery/utils/auth/pdcp"
5+
urlutil "github.com/projectdiscovery/utils/url"
6+
)
7+
8+
func getAssetsDashBoardURL(id, teamID string) string {
9+
ux, _ := urlutil.Parse(pdcpauth.DashBoardURL)
10+
ux.Path = "/assets/" + id
11+
if ux.Params == nil {
12+
ux.Params = urlutil.NewOrderedParams()
13+
}
14+
if teamID != "" {
15+
ux.Params.Add("team_id", teamID)
16+
} else {
17+
ux.Params.Add("team_id", NoneTeamID)
18+
}
19+
ux.Update()
20+
return ux.String()
21+
}
22+
23+
// {"asset_id":"cqdtekhte9oc73e9hrvg","message":"Successfully uploaded asset","upload_status":"success","uploaded_at":"2024-07-20 15:27:16.148527329 +0000 UTC m=+1078.215945902"}
24+
type uploadResponse struct {
25+
ID string `json:"asset_id"`
26+
Message string `json:"message"`
27+
}

internal/pdcp/writer.go

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
package pdcp
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"regexp"
12+
"sync/atomic"
13+
"time"
14+
15+
"github.com/projectdiscovery/gologger"
16+
"github.com/projectdiscovery/tlsx/pkg/tlsx/clients"
17+
"github.com/projectdiscovery/retryablehttp-go"
18+
pdcpauth "github.com/projectdiscovery/utils/auth/pdcp"
19+
"github.com/projectdiscovery/utils/conversion"
20+
"github.com/projectdiscovery/utils/env"
21+
errkit "github.com/projectdiscovery/utils/errkit"
22+
unitutils "github.com/projectdiscovery/utils/unit"
23+
updateutils "github.com/projectdiscovery/utils/update"
24+
urlutil "github.com/projectdiscovery/utils/url"
25+
)
26+
27+
const (
28+
uploadEndpoint = "/v1/assets"
29+
appendEndpoint = "/v1/assets/%s/contents"
30+
flushTimer = time.Minute
31+
MaxChunkSize = 4 * unitutils.Mega // 4 MB
32+
xidRe = `^[a-z0-9]{20}$`
33+
teamIDHeader = "X-Team-Id"
34+
NoneTeamID = "none"
35+
)
36+
37+
var (
38+
xidRegex = regexp.MustCompile(xidRe)
39+
// EnableeUpload if set to true enables the upload feature
40+
HideAutoSaveMsg = env.GetEnvOrDefault("DISABLE_CLOUD_UPLOAD_WRN", false)
41+
EnableCloudUpload = env.GetEnvOrDefault("ENABLE_CLOUD_UPLOAD", false)
42+
)
43+
44+
// UploadWriter is a writer that uploads its output to pdcp
45+
// server to enable web dashboard and more
46+
type UploadWriter struct {
47+
creds *pdcpauth.PDCPCredentials
48+
uploadURL *url.URL
49+
client *retryablehttp.Client
50+
done chan struct{}
51+
data chan *clients.Response
52+
assetGroupID string
53+
assetGroupName string
54+
counter atomic.Int32
55+
closed atomic.Bool
56+
TeamID string
57+
}
58+
59+
// NewUploadWriterCallback creates a new upload writer callback
60+
// which when enabled periodically uploads the results to pdcp assets dashboard
61+
func NewUploadWriterCallback(ctx context.Context, creds *pdcpauth.PDCPCredentials) (*UploadWriter, error) {
62+
if creds == nil {
63+
return nil, fmt.Errorf("no credentials provided")
64+
}
65+
u := &UploadWriter{
66+
creds: creds,
67+
done: make(chan struct{}, 1),
68+
data: make(chan *clients.Response, 8), // default buffer size
69+
TeamID: "",
70+
}
71+
var err error
72+
tmp, err := urlutil.Parse(creds.Server)
73+
if err != nil {
74+
return nil, errkit.Wrap(err, "could not parse server url")
75+
}
76+
tmp.Path = uploadEndpoint
77+
tmp.Update()
78+
u.uploadURL = tmp.URL
79+
80+
// create http client
81+
opts := retryablehttp.DefaultOptionsSingle
82+
opts.NoAdjustTimeout = true
83+
opts.Timeout = time.Duration(3) * time.Minute
84+
u.client = retryablehttp.NewClient(opts)
85+
// start auto commit
86+
// upload every 1 minute or when buffer is full
87+
go u.autoCommit(ctx)
88+
return u, nil
89+
}
90+
91+
// GetWriterCallback returns the writer callback
92+
func (u *UploadWriter) GetWriterCallback() func(*clients.Response) {
93+
return func(resp *clients.Response) {
94+
u.data <- resp
95+
}
96+
}
97+
98+
// SetAssetID sets the scan id for the upload writer
99+
func (u *UploadWriter) SetAssetID(id string) error {
100+
if !xidRegex.MatchString(id) {
101+
return fmt.Errorf("invalid asset id provided")
102+
}
103+
u.assetGroupID = id
104+
return nil
105+
}
106+
107+
// SetAssetGroupName sets the scan name for the upload writer
108+
func (u *UploadWriter) SetAssetGroupName(name string) {
109+
u.assetGroupName = name
110+
}
111+
112+
// SetTeamID sets the team id for the upload writer
113+
func (u *UploadWriter) SetTeamID(id string) {
114+
u.TeamID = id
115+
}
116+
117+
func (u *UploadWriter) autoCommit(ctx context.Context) {
118+
// wait for context to be done
119+
defer func() {
120+
u.done <- struct{}{}
121+
close(u.done)
122+
// if no scanid is generated no results were uploaded
123+
if u.assetGroupID == "" {
124+
gologger.Verbose().Msgf("UI dashboard setup skipped, no results found to upload")
125+
} else {
126+
gologger.Info().Msgf("Found %v results, View found results in dashboard : %v", u.counter.Load(), getAssetsDashBoardURL(u.assetGroupID, u.TeamID))
127+
}
128+
}()
129+
// temporary buffer to store the results
130+
buff := &bytes.Buffer{}
131+
ticker := time.NewTicker(flushTimer)
132+
133+
for {
134+
select {
135+
case <-ctx.Done():
136+
// flush before exit
137+
if buff.Len() > 0 {
138+
if err := u.uploadChunk(buff); err != nil {
139+
gologger.Error().Msgf("Failed to upload scan results on cloud: %v", err)
140+
}
141+
}
142+
return
143+
case <-ticker.C:
144+
// flush the buffer
145+
if buff.Len() > 0 {
146+
if err := u.uploadChunk(buff); err != nil {
147+
gologger.Error().Msgf("Failed to upload scan results on cloud: %v", err)
148+
}
149+
}
150+
case res, ok := <-u.data:
151+
if !ok {
152+
if buff.Len() > 0 {
153+
if err := u.uploadChunk(buff); err != nil {
154+
gologger.Error().Msgf("Failed to upload scan results on cloud: %v", err)
155+
}
156+
}
157+
return
158+
}
159+
160+
lineBytes, err := json.Marshal(res)
161+
if err != nil {
162+
gologger.Error().Msgf("Failed to marshal result: %v", err)
163+
continue
164+
}
165+
u.counter.Add(1)
166+
line := conversion.String(lineBytes)
167+
if buff.Len()+len(line) > MaxChunkSize {
168+
// flush existing buffer
169+
if err := u.uploadChunk(buff); err != nil {
170+
gologger.Error().Msgf("Failed to upload asset results on cloud: %v", err)
171+
}
172+
} else {
173+
buff.WriteString(line)
174+
buff.WriteString("\n")
175+
}
176+
}
177+
}
178+
}
179+
180+
// uploadChunk uploads a chunk of data to the server
181+
func (u *UploadWriter) uploadChunk(buff *bytes.Buffer) error {
182+
if err := u.upload(buff.Bytes()); err != nil {
183+
return errkit.Wrap(err, "could not upload chunk")
184+
}
185+
// if successful, reset the buffer
186+
buff.Reset()
187+
// log in verbose mode
188+
gologger.Warning().Msgf("Uploaded results chunk, you can view assets at %v", getAssetsDashBoardURL(u.assetGroupID, u.TeamID))
189+
return nil
190+
}
191+
192+
func (u *UploadWriter) upload(data []byte) error {
193+
req, err := u.getRequest(data)
194+
if err != nil {
195+
return errkit.Wrap(err, "could not create upload request")
196+
}
197+
resp, err := u.client.Do(req)
198+
if err != nil {
199+
return errkit.Wrap(err, "could not upload results")
200+
}
201+
defer func() {
202+
_ = resp.Body.Close()
203+
}()
204+
bin, err := io.ReadAll(resp.Body)
205+
if err != nil {
206+
return errkit.Wrap(err, "could not get id from response")
207+
}
208+
if resp.StatusCode != http.StatusOK {
209+
return fmt.Errorf("could not upload results got status code %v on %v", resp.StatusCode, resp.Request.URL.String())
210+
}
211+
var uploadResp uploadResponse
212+
if err := json.Unmarshal(bin, &uploadResp); err != nil {
213+
return errkit.Wrapf(err, "could not unmarshal response got %v", string(bin))
214+
}
215+
if uploadResp.ID != "" && u.assetGroupID == "" {
216+
u.assetGroupID = uploadResp.ID
217+
}
218+
return nil
219+
}
220+
221+
// getRequest returns a new request for upload
222+
// if scanID is not provided create new scan by uploading the data
223+
// if scanID is provided append the data to existing scan
224+
func (u *UploadWriter) getRequest(bin []byte) (*retryablehttp.Request, error) {
225+
var method, url string
226+
227+
if u.assetGroupID == "" {
228+
u.uploadURL.Path = uploadEndpoint
229+
method = http.MethodPost
230+
url = u.uploadURL.String()
231+
} else {
232+
u.uploadURL.Path = fmt.Sprintf(appendEndpoint, u.assetGroupID)
233+
method = http.MethodPatch
234+
url = u.uploadURL.String()
235+
}
236+
req, err := retryablehttp.NewRequest(method, url, bytes.NewReader(bin))
237+
if err != nil {
238+
return nil, errkit.Wrap(err, "could not create cloud upload request")
239+
}
240+
// add pdtm meta params - version will be set by updateutils
241+
req.Params.Merge(updateutils.GetpdtmParams("tlsx"))
242+
// if it is upload endpoint also include name if it exists
243+
if u.assetGroupName != "" && req.Path == uploadEndpoint {
244+
req.Params.Add("name", u.assetGroupName)
245+
}
246+
req.Update()
247+
248+
req.Header.Set(pdcpauth.ApiKeyHeaderName, u.creds.APIKey)
249+
if u.TeamID != "" {
250+
req.Header.Set(teamIDHeader, u.TeamID)
251+
}
252+
req.Header.Set("Content-Type", "application/octet-stream")
253+
req.Header.Set("Accept", "application/json")
254+
return req, nil
255+
}
256+
257+
// Close closes the upload writer
258+
func (u *UploadWriter) Close() {
259+
if !u.closed.Load() {
260+
// protect to avoid channel closed twice error
261+
close(u.data)
262+
u.closed.Store(true)
263+
}
264+
<-u.done
265+
}
266+

0 commit comments

Comments
 (0)