Add Circle CI source (#997)
Lint / lint (push) Waiting to run
Release / Release (push) Waiting to run
Scan for secrets / test (push) Waiting to run
Snifftest / Run Snifftest (push) Waiting to run
Test / test (push) Waiting to run
Test / test-detectors (push) Waiting to run
Lint / lint (push) Waiting to run
Release / Release (push) Waiting to run
Scan for secrets / test (push) Waiting to run
Snifftest / Run Snifftest (push) Waiting to run
Test / test (push) Waiting to run
Test / test-detectors (push) Waiting to run
* Add Circle CI source * remove SHA1 line * remove trim
This commit is contained in:
@@ -96,7 +96,7 @@ trufflehog s3 --bucket=<bucket name> --only-verified
|
|||||||
TruffleHog v3 is a complete rewrite in Go with many new powerful features.
|
TruffleHog v3 is a complete rewrite in Go with many new powerful features.
|
||||||
|
|
||||||
- We've **added over 700 credential detectors that support active verification against their respective APIs**.
|
- We've **added over 700 credential detectors that support active verification against their respective APIs**.
|
||||||
- We've also added native **support for scanning GitHub, GitLab, filesystems, and S3**.
|
- We've also added native **support for scanning GitHub, GitLab, filesystems, S3, and Circle CI**.
|
||||||
- **Instantly verify private keys** against millions of github users and **billions** of TLS certificates using our [Driftwood](https://trufflesecurity.com/blog/driftwood) technology.
|
- **Instantly verify private keys** against millions of github users and **billions** of TLS certificates using our [Driftwood](https://trufflesecurity.com/blog/driftwood) technology.
|
||||||
|
|
||||||
|
|
||||||
@@ -160,6 +160,7 @@ TruffleHog has a sub-command for each source of data that you may want to scan:
|
|||||||
- S3
|
- S3
|
||||||
- filesystem
|
- filesystem
|
||||||
- syslog
|
- syslog
|
||||||
|
- circleci
|
||||||
- file and stdin (coming soon)
|
- file and stdin (coming soon)
|
||||||
|
|
||||||
Each subcommand can have options that you can see with the `--help` flag provided to the sub command:
|
Each subcommand can have options that you can see with the `--help` flag provided to the sub command:
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ var (
|
|||||||
syslogTLSCert = syslogScan.Flag("cert", "Path to TLS cert.").String()
|
syslogTLSCert = syslogScan.Flag("cert", "Path to TLS cert.").String()
|
||||||
syslogTLSKey = syslogScan.Flag("key", "Path to TLS key.").String()
|
syslogTLSKey = syslogScan.Flag("key", "Path to TLS key.").String()
|
||||||
syslogFormat = syslogScan.Flag("format", "Log format. Can be rfc3164 or rfc5424").String()
|
syslogFormat = syslogScan.Flag("format", "Log format. Can be rfc3164 or rfc5424").String()
|
||||||
|
|
||||||
|
circleCiScan = cli.Command("circleci", "Scan CircleCI")
|
||||||
|
circleCiScanToken = circleCiScan.Flag("token", "CircleCI token. Can also be provided with environment variable").Envar("CIRCLECI_TOKEN").Required().String()
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -293,6 +296,10 @@ func run(state overseer.State) {
|
|||||||
if err = e.ScanSyslog(ctx, sources.NewConfig(syslog)); err != nil {
|
if err = e.ScanSyslog(ctx, sources.NewConfig(syslog)); err != nil {
|
||||||
logrus.WithError(err).Fatal("Failed to scan syslog.")
|
logrus.WithError(err).Fatal("Failed to scan syslog.")
|
||||||
}
|
}
|
||||||
|
case circleCiScan.FullCommand():
|
||||||
|
if err = e.ScanCircleCI(ctx, *circleCiScanToken); err != nil {
|
||||||
|
logrus.WithError(err).Fatal("Failed to scan CircleCI.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// asynchronously wait for scanning to finish and cleanup
|
// asynchronously wait for scanning to finish and cleanup
|
||||||
go e.Finish(ctx)
|
go e.Finish(ctx)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/go-errors/errors"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
"google.golang.org/protobuf/types/known/anypb"
|
||||||
|
|
||||||
|
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
|
||||||
|
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
|
||||||
|
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/sourcespb"
|
||||||
|
"github.com/trufflesecurity/trufflehog/v3/pkg/sources/circleci"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScanS3 scans CircleCI logs.
|
||||||
|
func (e *Engine) ScanCircleCI(ctx context.Context, token string) error {
|
||||||
|
connection := &sourcespb.CircleCI{
|
||||||
|
Credential: &sourcespb.CircleCI_Token{
|
||||||
|
Token: token,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var conn anypb.Any
|
||||||
|
err := anypb.MarshalFrom(&conn, connection, proto.MarshalOptions{})
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Error("failed to marshal Circle CI connection")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
circleSource := circleci.Source{}
|
||||||
|
err = circleSource.Init(ctx, "trufflehog - Circle CI", 0, int64(sourcespb.SourceType_SOURCE_TYPE_CIRCLECI), true, &conn, runtime.NumCPU())
|
||||||
|
if err != nil {
|
||||||
|
return errors.WrapPrefix(err, "failed to init Circle CI source", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.sourcesWg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer common.RecoverWithExit(ctx)
|
||||||
|
defer e.sourcesWg.Done()
|
||||||
|
err := circleSource.Chunks(ctx, e.ChunksChan())
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Error("error scanning Circle CI")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+2
-2
@@ -42,7 +42,7 @@ func (e *Engine) ScanS3(ctx context.Context, c sources.Config) error {
|
|||||||
var conn anypb.Any
|
var conn anypb.Any
|
||||||
err := anypb.MarshalFrom(&conn, connection, proto.MarshalOptions{})
|
err := anypb.MarshalFrom(&conn, connection, proto.MarshalOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.WithError(err).Error("failed to marshal github connection")
|
logrus.WithError(err).Error("failed to marshal S3 connection")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ func (e *Engine) ScanS3(ctx context.Context, c sources.Config) error {
|
|||||||
defer e.sourcesWg.Done()
|
defer e.sourcesWg.Done()
|
||||||
err := s3Source.Chunks(ctx, e.ChunksChan())
|
err := s3Source.Chunks(ctx, e.ChunksChan())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.WithError(err).Error("error scanning s3")
|
logrus.WithError(err).Error("error scanning S3")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package circleci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-errors/errors"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
"google.golang.org/protobuf/types/known/anypb"
|
||||||
|
|
||||||
|
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
|
||||||
|
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
|
||||||
|
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/source_metadatapb"
|
||||||
|
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/sourcespb"
|
||||||
|
"github.com/trufflesecurity/trufflehog/v3/pkg/sources"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
baseURL = "https://circleci.com/api/v1.1/"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Source struct {
|
||||||
|
name string
|
||||||
|
sourceId int64
|
||||||
|
jobId int64
|
||||||
|
verify bool
|
||||||
|
sources.Progress
|
||||||
|
token string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the Source satisfies the interface at compile time
|
||||||
|
var _ sources.Source = (*Source)(nil)
|
||||||
|
|
||||||
|
// Type returns the type of source.
|
||||||
|
// It is used for matching source types in configuration and job input.
|
||||||
|
func (s *Source) Type() sourcespb.SourceType {
|
||||||
|
return sourcespb.SourceType_SOURCE_TYPE_CIRCLECI
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) SourceID() int64 {
|
||||||
|
return s.sourceId
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) JobID() int64 {
|
||||||
|
return s.jobId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init returns an initialized Filesystem source.
|
||||||
|
func (s *Source) Init(aCtx context.Context, name string, jobId, sourceId int64, verify bool, connection *anypb.Any, _ int) error {
|
||||||
|
s.name = name
|
||||||
|
s.sourceId = sourceId
|
||||||
|
s.jobId = jobId
|
||||||
|
s.verify = verify
|
||||||
|
s.client = common.RetryableHttpClientTimeout(3)
|
||||||
|
|
||||||
|
var conn sourcespb.CircleCI
|
||||||
|
if err := anypb.UnmarshalTo(connection, &conn, proto.UnmarshalOptions{}); err != nil {
|
||||||
|
return errors.WrapPrefix(err, "error unmarshalling connection", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch conn.Credential.(type) {
|
||||||
|
case *sourcespb.CircleCI_Token:
|
||||||
|
s.token = conn.GetToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunks emits chunks of bytes over a channel.
|
||||||
|
func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk) error {
|
||||||
|
projects, err := s.projects(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, proj := range projects {
|
||||||
|
builds, err := s.buildsForProject(ctx, proj)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bld := range builds {
|
||||||
|
buildSteps, err := s.stepsForBuild(ctx, proj, bld)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, step := range buildSteps {
|
||||||
|
for _, action := range step.Actions {
|
||||||
|
err = s.chunkAction(ctx, proj, bld, action, step.Name, chunksChan)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type project struct {
|
||||||
|
VCS string `json:"vcs_type"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
RepoName string `json:"reponame"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) projects(ctx context.Context) ([]project, error) {
|
||||||
|
reqURL := fmt.Sprintf("%sprojects", baseURL)
|
||||||
|
req, err := http.NewRequest("GET", reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Circle-Token", s.token)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
res, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
if res.StatusCode > 399 && res.StatusCode < 500 {
|
||||||
|
return nil, fmt.Errorf("invalid credentials, status %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var projects []project
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&projects); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type build struct {
|
||||||
|
BuildNum int `json:"build_num"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) buildsForProject(ctx context.Context, proj project) ([]build, error) {
|
||||||
|
reqURL := fmt.Sprintf("%sproject/%s/%s/%s", baseURL, proj.VCS, proj.Username, proj.RepoName)
|
||||||
|
req, err := http.NewRequest("GET", reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Circle-Token", s.token)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
res, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
var builds []build
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&builds); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return builds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type action struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
OutputURL string `json:"output_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type buildStep struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Actions []action `json:"actions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) stepsForBuild(ctx context.Context, proj project, bld build) ([]buildStep, error) {
|
||||||
|
reqURL := fmt.Sprintf("%sproject/%s/%s/%s/%d", baseURL, proj.VCS, proj.Username, proj.RepoName, bld.BuildNum)
|
||||||
|
req, err := http.NewRequest("GET", reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Circle-Token", s.token)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
res, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
type buildRes struct {
|
||||||
|
Steps []buildStep `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var bldRes buildRes
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&bldRes); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return bldRes.Steps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Source) chunkAction(ctx context.Context, proj project, bld build, act action, stepName string, chunksChan chan *sources.Chunk) error {
|
||||||
|
req, err := http.NewRequest("GET", act.OutputURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
logOutput, err := ioutil.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
linkURL := fmt.Sprintf("https://app.circleci.com/pipelines/%s/%s/%s/%d", proj.VCS, proj.Username, proj.RepoName, bld.BuildNum)
|
||||||
|
|
||||||
|
chunk := &sources.Chunk{
|
||||||
|
SourceType: s.Type(),
|
||||||
|
SourceName: s.name,
|
||||||
|
SourceID: s.SourceID(),
|
||||||
|
Data: removeCircleSha1Line(logOutput),
|
||||||
|
SourceMetadata: &source_metadatapb.MetaData{
|
||||||
|
Data: &source_metadatapb.MetaData_Circleci{
|
||||||
|
Circleci: &source_metadatapb.CircleCI{
|
||||||
|
VcsType: proj.VCS,
|
||||||
|
Username: proj.Username,
|
||||||
|
Repository: proj.RepoName,
|
||||||
|
BuildNumber: int64(bld.BuildNum),
|
||||||
|
BuildStep: stepName,
|
||||||
|
Link: linkURL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Verify: s.verify,
|
||||||
|
}
|
||||||
|
|
||||||
|
chunksChan <- chunk
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeCircleSha1Line(input []byte) []byte {
|
||||||
|
// Split the input slice into a slice of lines
|
||||||
|
lines := bytes.Split(input, []byte("\n"))
|
||||||
|
|
||||||
|
// Iterate over the lines and add the ones that don't contain "CIRCLE_SHA1=" to the result slice
|
||||||
|
result := make([][]byte, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
if !bytes.Contains(line, []byte("CIRCLE_SHA1=")) {
|
||||||
|
result = append(result, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join the lines in the result slice and return the resulting slice
|
||||||
|
return bytes.Join(result, []byte("\n"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package circleci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kylelemons/godebug/pretty"
|
||||||
|
"google.golang.org/protobuf/types/known/anypb"
|
||||||
|
|
||||||
|
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
|
||||||
|
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
|
||||||
|
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/source_metadatapb"
|
||||||
|
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/sourcespb"
|
||||||
|
"github.com/trufflesecurity/trufflehog/v3/pkg/sources"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSource_Scan(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
secret, err := common.GetTestSecret(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(fmt.Errorf("failed to access secret: %v", err))
|
||||||
|
}
|
||||||
|
token := secret.MustGetField("CIRCLECI_TOKEN")
|
||||||
|
|
||||||
|
type init struct {
|
||||||
|
name string
|
||||||
|
verify bool
|
||||||
|
connection *sourcespb.CircleCI
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
init init
|
||||||
|
wantSourceMetadata *source_metadatapb.MetaData
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "get a chunk",
|
||||||
|
init: init{
|
||||||
|
name: "example repo",
|
||||||
|
connection: &sourcespb.CircleCI{
|
||||||
|
Credential: &sourcespb.CircleCI_Token{
|
||||||
|
Token: token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
verify: true,
|
||||||
|
},
|
||||||
|
wantSourceMetadata: &source_metadatapb.MetaData{
|
||||||
|
Data: &source_metadatapb.MetaData_Circleci{
|
||||||
|
Circleci: &source_metadatapb.CircleCI{
|
||||||
|
VcsType: "github",
|
||||||
|
Username: "dustin-decker",
|
||||||
|
Repository: "circle-ci",
|
||||||
|
BuildNumber: 2,
|
||||||
|
BuildStep: "Spin up environment",
|
||||||
|
Link: "https://app.circleci.com/pipelines/github/dustin-decker/circle-ci/2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s := Source{}
|
||||||
|
|
||||||
|
conn, err := anypb.New(tt.init.connection)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Init(ctx, tt.init.name, 0, 0, tt.init.verify, conn, 5)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("Source.Init() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chunksCh := make(chan *sources.Chunk, 1)
|
||||||
|
go func() {
|
||||||
|
err = s.Chunks(ctx, chunksCh)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("Source.Chunks() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
gotChunk := <-chunksCh
|
||||||
|
if diff := pretty.Compare(gotChunk.SourceMetadata, tt.wantSourceMetadata); diff != "" {
|
||||||
|
t.Errorf("Source.Chunks() %s diff: (-got +want)\n%s", tt.name, diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user