github.com/google/osv-scalibr@v0.4.1/extractor/filesystem/secrets/mariadb/mariadb.go (about)

     1  // Copyright 2025 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package mariadb returns a list of secret mariadb credentials found in *.cnf and *.ini mariadb files
    16  package mariadb
    17  
    18  import (
    19  	"bufio"
    20  	"context"
    21  	"errors"
    22  	"fmt"
    23  	"io/fs"
    24  	"path/filepath"
    25  	"regexp"
    26  	"strings"
    27  
    28  	"github.com/google/osv-scalibr/extractor/filesystem"
    29  	"github.com/google/osv-scalibr/inventory"
    30  	"github.com/google/osv-scalibr/plugin"
    31  )
    32  
    33  const (
    34  	// Name is the unique name of this extractor.
    35  	Name = "secrets/mariadb"
    36  )
    37  
    38  var (
    39  	keyValuePattern = regexp.MustCompile(`^\s*([^:=\s]+)\s*[:=]\s*(.+)$`)
    40  )
    41  
    42  // Config is the extractor config
    43  type Config struct {
    44  	// FollowInclude directive tells the extractor to follow the include or not
    45  	FollowInclude bool
    46  }
    47  
    48  // DefaultConfig returns the default configuration values for the Extractor.
    49  func DefaultConfig() Config {
    50  	return Config{
    51  		FollowInclude: true,
    52  	}
    53  }
    54  
    55  // Extractor extracts mariadb secret credentials.
    56  type Extractor struct {
    57  	visited       map[string]struct{}
    58  	followInclude bool
    59  }
    60  
    61  // New returns the Extractor with the specified config settings.
    62  func New(cfg Config) filesystem.Extractor {
    63  	return &Extractor{
    64  		visited:       map[string]struct{}{},
    65  		followInclude: cfg.FollowInclude,
    66  	}
    67  }
    68  
    69  // NewDefault returns the Extractor with the default config settings.
    70  func NewDefault() filesystem.Extractor {
    71  	return New(DefaultConfig())
    72  }
    73  
    74  // Name of the extractor.
    75  func (e Extractor) Name() string { return Name }
    76  
    77  // Version of the extractor.
    78  func (e Extractor) Version() int { return 0 }
    79  
    80  // Requirements of the extractor.
    81  func (e Extractor) Requirements() *plugin.Capabilities {
    82  	return &plugin.Capabilities{}
    83  }
    84  
    85  // FileRequired returns true if the file contains mariadb config information
    86  // ref: https://mariadb.com/docs/server/server-management/install-and-upgrade-mariadb/configuring-mariadb/configuring-mariadb-with-option-files
    87  func (e *Extractor) FileRequired(api filesystem.FileAPI) bool {
    88  	path := api.Path()
    89  	return strings.HasSuffix(path, "my.cnf") || strings.HasSuffix(path, "my.ini")
    90  }
    91  
    92  // Extract returns a list of secret mariadb credentials
    93  func (e Extractor) Extract(ctx context.Context, input *filesystem.ScanInput) (inventory.Inventory, error) {
    94  	i := inventory.Inventory{}
    95  	secrets, err := e.includeFile(ctx, input, input.Path)
    96  	if err != nil {
    97  		return i, err
    98  	}
    99  	i.Secrets = secrets
   100  	return i, nil
   101  }
   102  
   103  // include call includeDir or includeFile depending on the prefix
   104  func (e *Extractor) include(ctx context.Context, input *filesystem.ScanInput, line string) ([]*inventory.Secret, error) {
   105  	after, isDir, err := cutIncludePrefix(line)
   106  	if err != nil {
   107  		return nil, fmt.Errorf("error in line %q: %w", line, err)
   108  	}
   109  
   110  	// Remove leading '/' or "C:" since SCALIBR fs paths don't include that.
   111  	// Remove trailing '/' if present
   112  	before, path, _ := strings.Cut(strings.TrimSpace(after), ":")
   113  	if path == "" {
   114  		path = before
   115  	}
   116  	path = strings.Trim(path, "/\\")
   117  
   118  	if isDir {
   119  		sections, err := e.includeDir(ctx, input, path)
   120  		return sections, err
   121  	}
   122  
   123  	return e.includeFile(ctx, input, path)
   124  }
   125  
   126  func cutIncludePrefix(s string) (after string, dir bool, err error) {
   127  	if after, ok := strings.CutPrefix(s, "!includedir"); ok {
   128  		return after, true, nil
   129  	}
   130  	if after, ok := strings.CutPrefix(s, "!include"); ok {
   131  		return after, false, nil
   132  	}
   133  	return "", false, errors.New("unknown include prefix")
   134  }
   135  
   136  // includeFile recursively extract secrets from a config file
   137  func (e *Extractor) includeFile(ctx context.Context, input *filesystem.ScanInput, path string) ([]*inventory.Secret, error) {
   138  	// Prevent circular includes.
   139  	if _, seen := e.visited[path]; seen {
   140  		return nil, nil
   141  	}
   142  	e.visited[path] = struct{}{}
   143  
   144  	f, err := input.FS.Open(path)
   145  	if err != nil {
   146  		return nil, fmt.Errorf("could not open file %w", err)
   147  	}
   148  	defer f.Close()
   149  
   150  	curSection := ""
   151  	sections := map[string]*Credentials{}
   152  	scanner := bufio.NewScanner(f)
   153  	// Note:
   154  	// returning all the config flat instead of handling the files hierarchies
   155  	// because files are opened in no particular order
   156  	res := []*inventory.Secret{}
   157  
   158  	for scanner.Scan() {
   159  		if err := ctx.Err(); err != nil {
   160  			return nil, err
   161  		}
   162  
   163  		line := strings.TrimSpace(scanner.Text())
   164  
   165  		// skip empty lines and comments
   166  		if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
   167  			continue
   168  		}
   169  
   170  		// include a file or a folder
   171  		if strings.HasPrefix(line, "!include") {
   172  			if !e.followInclude {
   173  				continue
   174  			}
   175  			section, err := e.include(ctx, input, line)
   176  			if err != nil {
   177  				return nil, err
   178  			}
   179  			res = append(res, section...)
   180  			continue
   181  		}
   182  
   183  		// new section encountered
   184  		if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
   185  			curSection = strings.Trim(line, "[]")
   186  			if _, ok := sections[curSection]; !ok {
   187  				sections[curSection] = &Credentials{Section: curSection}
   188  			}
   189  			continue
   190  		}
   191  
   192  		// add key value pair to the current section
   193  		matches := keyValuePattern.FindStringSubmatch(line)
   194  		if len(matches) != 3 {
   195  			continue
   196  		}
   197  		key, value := matches[1], matches[2]
   198  
   199  		if curSection == "" {
   200  			return nil, fmt.Errorf("bad format: key-value found outside a section in file %q", path)
   201  		}
   202  
   203  		// If the key is not related to credentials, ignore it silently
   204  		_ = sections[curSection].setField(key, value)
   205  	}
   206  
   207  	if err := scanner.Err(); err != nil {
   208  		return nil, fmt.Errorf("could not extract from file: %w", err)
   209  	}
   210  
   211  	// adding the current file credentials to the ones found in included files
   212  	for _, s := range sections {
   213  		if !isSecret(s) {
   214  			continue
   215  		}
   216  		res = append(res, &inventory.Secret{Secret: *s, Location: path})
   217  	}
   218  
   219  	return res, nil
   220  }
   221  
   222  // includeDir recursively loads .cnf and .ini files from a specified directory.
   223  func (e *Extractor) includeDir(ctx context.Context, input *filesystem.ScanInput, dir string) ([]*inventory.Secret, error) {
   224  	entries, err := fs.ReadDir(input.FS, dir)
   225  	if err != nil {
   226  		return nil, fmt.Errorf("could not read folder %s: %w", dir, err)
   227  	}
   228  
   229  	res := []*inventory.Secret{}
   230  	for _, entry := range entries {
   231  		if err := ctx.Err(); err != nil {
   232  			return nil, err
   233  		}
   234  		if entry.IsDir() {
   235  			continue
   236  		}
   237  		path := filepath.ToSlash(filepath.Join(dir, entry.Name()))
   238  		if !strings.HasSuffix(path, ".cnf") && !strings.HasSuffix(path, ".ini") {
   239  			continue
   240  		}
   241  		sections, err := e.includeFile(ctx, input, path)
   242  		if err != nil {
   243  			return nil, err
   244  		}
   245  		res = append(res, sections...)
   246  	}
   247  	return res, nil
   248  }