* Add AnalysisError type and AnalysisErrorInfo interface Introduce a shared error type that provides structured metadata (analyzer type, operation, service, resource) for analysis failures. This allows the scanner to extract context from errors without depending on concrete types. * Wrap errors in simple API analyzers with AnalysisError Batch A: Airbrake, Anthropic, Asana, DigitalOcean, DockerHub, ElevenLabs, Fastly, Groq, HuggingFace, Mailchimp, Mailgun, Mux, Netlify, Ngrok, Notion, OpenAI, Opsgenie, Posthog, Postman, Sendgrid, Sourcegraph. Wraps credential validation errors with operation "validate_credentials" and AnalyzePermissions errors with operation "analyze_permissions". * Wrap errors in remaining analyzers with AnalysisError (Batches B-E) Batch B (OAuth/multi-credential): airtableoauth, airtablepat, datadog, dropbox, figma, launchdarkly, plaid Batch C (Complex): bitbucket, databricks, github, gitlab, jira, monday, planetscale, shopify, slack, square, stripe, twilio Batch D (Database): mysql, postgres (service: Database) Batch E (PrivateKey): privatekey (service: crypto) * Use Type().String() and constants for NewAnalysisError calls Address PR feedback: replace hardcoded analyzer type strings with a.Type().String() and replace raw operation/service strings with package-level constants (OperationValidateCredentials, OperationAnalyzePermissions, ServiceAPI, ServiceConfig, etc.). * Omit empty resource parenthetical from AnalysisError messages Conditionally include "(resource: ...)" only when non-empty, avoiding cluttered messages like "... (resource: ): ..." that appear for the majority of analyzers that don't set a resource. * Wrap no-data error path in GitHub analyzer with AnalysisError
331 lines
9.5 KiB
Go
331 lines
9.5 KiB
Go
//go:generate generate_permissions permissions.yaml permissions.go privatekey
|
|
|
|
package privatekey
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/jedib0t/go-pretty/v6/table"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/privatekey"
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
var _ analyzers.Analyzer = (*Analyzer)(nil)
|
|
|
|
type Analyzer struct {
|
|
Cfg *config.Config
|
|
}
|
|
|
|
func (Analyzer) Type() analyzers.AnalyzerType { return analyzers.AnalyzerTypePrivateKey }
|
|
|
|
func (a Analyzer) Analyze(ctx context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
|
|
// token will be already normalized by the time it reaches here
|
|
token, ok := credInfo["token"]
|
|
if !ok {
|
|
return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationValidateCredentials, analyzers.ServiceConfig, "", errors.New("token not found in credInfo"))
|
|
}
|
|
|
|
info, err := AnalyzePermissions(ctx, a.Cfg, token)
|
|
if err != nil {
|
|
return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationAnalyzePermissions, analyzers.ServiceCrypto, "", err)
|
|
}
|
|
|
|
return secretInfoToAnalyzerResult(info), nil
|
|
}
|
|
|
|
type SecretInfo struct {
|
|
TLSCertificateResult *privatekey.DriftwoodResult
|
|
GithubUsername *string
|
|
GitlabUsername *string
|
|
}
|
|
|
|
func AnalyzePermissions(ctx context.Context, cfg *config.Config, token string) (*SecretInfo, error) {
|
|
|
|
var (
|
|
wg sync.WaitGroup
|
|
parsedKey any
|
|
err error
|
|
analyzerErrors = privatekey.NewVerificationErrors(3)
|
|
info = &SecretInfo{}
|
|
)
|
|
|
|
parsedKey, err = ssh.ParseRawPrivateKey([]byte(token))
|
|
if err != nil && strings.Contains(err.Error(), "private key is passphrase protected") {
|
|
// key is password protected
|
|
parsedKey, _, err = privatekey.Crack([]byte(token))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fingerprint, err := privatekey.FingerprintPEMKey(parsedKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Look up certificate information.
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
data, err := analyzeFingerprint(ctx, fingerprint)
|
|
if err != nil {
|
|
analyzerErrors.Add(err)
|
|
} else {
|
|
info.TLSCertificateResult = data
|
|
}
|
|
}()
|
|
|
|
// Test SSH key against github.com
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
user, err := analyzeGithubUser(ctx, parsedKey)
|
|
if err != nil {
|
|
analyzerErrors.Add(err)
|
|
} else if user != nil {
|
|
info.GithubUsername = user
|
|
}
|
|
}()
|
|
|
|
// Test SSH key against gitlab.com
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
user, err := analyzeGitlabUser(ctx, parsedKey)
|
|
if err != nil {
|
|
analyzerErrors.Add(err)
|
|
} else if user != nil {
|
|
info.GitlabUsername = user
|
|
}
|
|
}()
|
|
wg.Wait()
|
|
|
|
if len(analyzerErrors.Errors) == 3 {
|
|
return nil, fmt.Errorf("analyzer failures: %s", strings.Join(analyzerErrors.Errors, ", "))
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
func AnalyzeAndPrintPermissions(cfg *config.Config, key string) {
|
|
if cfg.LoggingEnabled {
|
|
color.Red("[x] Logging is not supported for this analyzer.")
|
|
return
|
|
}
|
|
|
|
token := privatekey.Normalize(key)
|
|
if len(token) < 64 {
|
|
color.Red("[x] Error: Invalid Private Key")
|
|
return
|
|
}
|
|
|
|
// key entered through command line may have spaces instead of newlines, replace them
|
|
token = replaceSpacesWithNewlines(token)
|
|
|
|
info, err := AnalyzePermissions(context.Background(), cfg, token)
|
|
if err != nil {
|
|
color.Red("[x] Error: %s", err.Error())
|
|
return
|
|
}
|
|
|
|
color.Green("[!] Valid Private Key\n\n")
|
|
|
|
if info.GithubUsername == nil && info.GitlabUsername == nil && info.TLSCertificateResult == nil {
|
|
color.Yellow("[i] Insufficient information returned from fingerprint analysis. No permissions found.")
|
|
return
|
|
}
|
|
|
|
if info.GithubUsername != nil {
|
|
color.Yellow("[i] GitHub Details:")
|
|
printUserInfo(*info.GithubUsername)
|
|
}
|
|
if info.GitlabUsername != nil {
|
|
color.Yellow("[i] GitLab Details:")
|
|
printUserInfo(*info.GitlabUsername)
|
|
}
|
|
if info.TLSCertificateResult != nil {
|
|
printTLSCertificateResult(info.TLSCertificateResult)
|
|
}
|
|
|
|
}
|
|
|
|
func printUserInfo(username string) {
|
|
color.Yellow("[i] Username: %s", username)
|
|
color.Yellow("[i] Permissions: %s\n\n", color.GreenString("Clone/Push"))
|
|
}
|
|
|
|
func printTLSCertificateResult(result *privatekey.DriftwoodResult) {
|
|
color.Yellow("[i] TLS Certificate Details:")
|
|
fmt.Print("\n")
|
|
t := table.NewWriter()
|
|
t.SetOutputMirror(os.Stdout)
|
|
t.AppendHeader(
|
|
table.Row{"Subject Key ID", "Subject Name", "Subject Organization", "Permissions", "Expiration Date", "Domains"})
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
for _, certificateResult := range result.CertificateResults {
|
|
t.AppendRow([]interface{}{
|
|
green(certificateResult.SubjectKeyID),
|
|
green(certificateResult.SubjectName),
|
|
green(strings.Join(certificateResult.SubjectOrganization, ", ")),
|
|
green(strings.Join(append(certificateResult.KeyUsages, certificateResult.ExtendedKeyUsages...), ", ")),
|
|
green(certificateResult.ExpirationTimestamp.Format(time.RFC3339)),
|
|
green(strings.Join(certificateResult.Domains, ", ")),
|
|
})
|
|
}
|
|
t.Render()
|
|
}
|
|
|
|
func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult {
|
|
if info == nil {
|
|
return nil
|
|
}
|
|
|
|
result := analyzers.AnalyzerResult{
|
|
AnalyzerType: analyzers.AnalyzerTypePrivateKey,
|
|
Metadata: nil,
|
|
Bindings: []analyzers.Binding{},
|
|
UnboundedResources: []analyzers.Resource{},
|
|
}
|
|
|
|
if info.TLSCertificateResult != nil {
|
|
bounded, unbounded := bakeTLSResources(info.TLSCertificateResult)
|
|
result.Bindings = append(result.Bindings, bounded...)
|
|
result.UnboundedResources = append(result.UnboundedResources, unbounded...)
|
|
}
|
|
|
|
if info.GithubUsername != nil {
|
|
result.Bindings = append(result.Bindings, bakeGithubResources(info.GithubUsername)...)
|
|
}
|
|
|
|
if info.GitlabUsername != nil {
|
|
result.Bindings = append(result.Bindings, bakeGitlabResources(info.GitlabUsername)...)
|
|
}
|
|
|
|
return &result
|
|
}
|
|
|
|
func bakeGithubResources(username *string) []analyzers.Binding {
|
|
resource := &analyzers.Resource{
|
|
Name: *username,
|
|
FullyQualifiedName: fmt.Sprintf("github.com/user/%s", *username),
|
|
Type: "user", // always user ???
|
|
}
|
|
|
|
permissions := []analyzers.Permission{
|
|
{Value: PermissionStrings[Clone], Parent: nil},
|
|
{Value: PermissionStrings[Push], Parent: nil},
|
|
}
|
|
|
|
return analyzers.BindAllPermissions(*resource, permissions...)
|
|
}
|
|
|
|
func bakeGitlabResources(username *string) []analyzers.Binding {
|
|
resource := &analyzers.Resource{
|
|
Name: *username,
|
|
FullyQualifiedName: fmt.Sprintf("gitlab.com/user/%s", *username),
|
|
Type: "user", // always user ???
|
|
}
|
|
|
|
permissions := []analyzers.Permission{
|
|
{Value: PermissionStrings[Clone], Parent: nil},
|
|
{Value: PermissionStrings[Push], Parent: nil},
|
|
}
|
|
|
|
return analyzers.BindAllPermissions(*resource, permissions...)
|
|
}
|
|
|
|
func bakeTLSResources(result *privatekey.DriftwoodResult) ([]analyzers.Binding, []analyzers.Resource) {
|
|
|
|
unboundedResources := make([]analyzers.Resource, 0, len(result.CertificateResults))
|
|
boundedResources := make([]analyzers.Binding, 0, len(result.CertificateResults))
|
|
|
|
// iterate result.CertificateResults
|
|
for _, cert := range result.CertificateResults {
|
|
if cert.SubjectName == "" && cert.SubjectKeyID == "" {
|
|
continue
|
|
}
|
|
resource := &analyzers.Resource{
|
|
Name: cert.SubjectName,
|
|
FullyQualifiedName: fmt.Sprintf("%s/%s", cert.SubjectKeyID, cert.SubjectName),
|
|
Type: "certificate",
|
|
}
|
|
certPermissions := append(cert.KeyUsages, cert.ExtendedKeyUsages...)
|
|
permissions := make([]analyzers.Permission, 0, len(certPermissions))
|
|
for _, perm := range certPermissions {
|
|
perm, ok := StringToPermission[perm]
|
|
if !ok {
|
|
continue
|
|
}
|
|
permissions = append(permissions, analyzers.Permission{
|
|
Value: PermissionStrings[perm],
|
|
Parent: nil,
|
|
})
|
|
}
|
|
|
|
if len(permissions) > 0 {
|
|
// bind all permissions with resources
|
|
boundedResources = append(boundedResources, analyzers.BindAllPermissions(*resource, permissions...)...)
|
|
} else {
|
|
unboundedResources = append(unboundedResources, *resource)
|
|
}
|
|
|
|
}
|
|
|
|
return boundedResources, unboundedResources
|
|
}
|
|
|
|
func analyzeFingerprint(ctx context.Context, fingerprint string) (*privatekey.DriftwoodResult, error) {
|
|
|
|
result, err := privatekey.LookupFingerprint(ctx, fingerprint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(result.CertificateResults) == 0 {
|
|
return nil, nil
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func analyzeGithubUser(ctx context.Context, parsedKey any) (*string, error) {
|
|
return privatekey.VerifyGitHubUser(ctx, parsedKey)
|
|
}
|
|
|
|
func analyzeGitlabUser(ctx context.Context, parsedKey any) (*string, error) {
|
|
return privatekey.VerifyGitLabUser(ctx, parsedKey)
|
|
}
|
|
|
|
// replaceSpacesWithNewlines extracts the base64 part, replaces spaces with newlines if needed, and reconstructs the key.
|
|
func replaceSpacesWithNewlines(privateKey string) string {
|
|
// Regex pattern to extract the key content
|
|
re := regexp.MustCompile(`(?i)(-----\s*BEGIN[ A-Z0-9_-]*PRIVATE KEY\s*-----)\s*([\s\S]*?)\s*(-----\s*END[ A-Z0-9_-]*PRIVATE KEY\s*-----)`)
|
|
|
|
// Find matches
|
|
matches := re.FindStringSubmatch(privateKey)
|
|
if len(matches) != 4 {
|
|
// no need to process
|
|
return privateKey
|
|
}
|
|
|
|
header := matches[1] // BEGIN line
|
|
base64Part := matches[2] // Base64 content
|
|
footer := matches[3] // END line
|
|
|
|
// Replace spaces with newlines
|
|
formattedBase64 := strings.ReplaceAll(base64Part, " ", "\n")
|
|
|
|
// Reconstruct the private key
|
|
return fmt.Sprintf("%s\n%s\n%s", header, formattedBase64, footer)
|
|
}
|