Files
trufflehog/pkg/analyzer/analyzers/privatekey/privatekey.go
John Elliott 761be8877b Add AnalysisError type and wrap all analyzer error paths (#4779)
* 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
2026-04-14 17:18:48 -07:00

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)
}