github.com/google/osv-scalibr@v0.4.1/detector/weakcredentials/etcshadow/etcshadow.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 etcshadow implements a detector for weak/guessable passwords stored in /etc/shadow.
    16  package etcshadow
    17  
    18  import (
    19  	"bufio"
    20  	"context"
    21  	"errors"
    22  	"fmt"
    23  	"io/fs"
    24  	"os"
    25  	"sort"
    26  	"strings"
    27  
    28  	"github.com/google/osv-scalibr/detector"
    29  	scalibrfs "github.com/google/osv-scalibr/fs"
    30  	"github.com/google/osv-scalibr/inventory"
    31  	"github.com/google/osv-scalibr/packageindex"
    32  	"github.com/google/osv-scalibr/plugin"
    33  )
    34  
    35  const (
    36  	// Name of the detector.
    37  	Name = "weakcredentials/etcshadow"
    38  )
    39  
    40  // Detector is a SCALIBR Detector for weak/guessable passwords from /etc/shadow.
    41  type Detector struct{}
    42  
    43  // New returns a detector.
    44  func New() detector.Detector {
    45  	return &Detector{}
    46  }
    47  
    48  // Name of the detector.
    49  func (Detector) Name() string { return Name }
    50  
    51  // Version of the detector.
    52  func (Detector) Version() int { return 0 }
    53  
    54  // Requirements of the detector.
    55  func (Detector) Requirements() *plugin.Capabilities { return &plugin.Capabilities{OS: plugin.OSUnix} }
    56  
    57  // RequiredExtractors returns an empty list as there are no dependencies.
    58  func (Detector) RequiredExtractors() []string { return []string{} }
    59  
    60  // DetectedFinding returns generic vulnerability information about what is detected.
    61  func (d Detector) DetectedFinding() inventory.Finding {
    62  	return d.findingForTarget(nil)
    63  }
    64  
    65  func (d Detector) findingForTarget(target *inventory.GenericFindingTargetDetails) inventory.Finding {
    66  	return inventory.Finding{GenericFindings: []*inventory.GenericFinding{{
    67  		Adv: &inventory.GenericFindingAdvisory{
    68  			ID: &inventory.AdvisoryID{
    69  				Publisher: "SCALIBR",
    70  				Reference: "etc-shadow-weakcredentials",
    71  			},
    72  			Title: "Ensure all users have strong passwords configured",
    73  			Description: "The /etc/shadow file contains user account password hashes. " +
    74  				"These passwords must be strong and not easily guessable.",
    75  			Recommendation: "Run the following command to reset password for the reported users:\n" +
    76  				"# change password for USER: sudo passwd USER",
    77  			Sev: inventory.SeverityCritical,
    78  		},
    79  		Target: target,
    80  	}}}
    81  }
    82  
    83  // Scan starts the scan.
    84  func (d Detector) Scan(ctx context.Context, scanRoot *scalibrfs.ScanRoot, px *packageindex.PackageIndex) (inventory.Finding, error) {
    85  	f, err := scanRoot.FS.Open("etc/shadow")
    86  	if err != nil {
    87  		if errors.Is(err, os.ErrNotExist) {
    88  			// File doesn't exist, check not applicable.
    89  			return inventory.Finding{}, nil
    90  		}
    91  		return inventory.Finding{}, err
    92  	}
    93  	defer f.Close()
    94  
    95  	users, err := parseShadowFile(f)
    96  	if err != nil {
    97  		return inventory.Finding{}, err
    98  	}
    99  
   100  	cracker := NewPasswordCracker()
   101  
   102  	// When looking at password hashes we strictly focus on hash strings
   103  	// with the format $ALGO$SALT$HASH. There are many other things we choose
   104  	// not to check for the sake of simplicity (e.g. hash strings preceded
   105  	// by one or two ! characters are for locked logins - password can still be weak
   106  	// and running as user can be done locally with the 'su' command).
   107  	var problemUsers []string
   108  	for user, hash := range users {
   109  		if ctx.Err() != nil {
   110  			return inventory.Finding{}, ctx.Err()
   111  		}
   112  		if _, err := cracker.Crack(ctx, hash); err == nil { // if cracked
   113  			// Report only user name to avoid PII leakage.
   114  			problemUsers = append(problemUsers, user)
   115  		}
   116  	}
   117  
   118  	if len(problemUsers) == 0 {
   119  		return inventory.Finding{}, nil
   120  	}
   121  
   122  	// Sort users to avoid non-determinism in the processing order from users map.
   123  	sort.Strings(problemUsers)
   124  	buf := new(strings.Builder)
   125  	_, _ = fmt.Fprintln(buf, "The following users have weak passwords:")
   126  	for _, u := range problemUsers {
   127  		_, _ = fmt.Fprintln(buf, u)
   128  	}
   129  	problemDescription := buf.String()
   130  	target := &inventory.GenericFindingTargetDetails{Extra: "/etc/shadow: " + problemDescription}
   131  	return d.findingForTarget(target), nil
   132  }
   133  
   134  func parseShadowFile(f fs.File) (map[string]string, error) {
   135  	users := make(map[string]string)
   136  	scanner := bufio.NewScanner(f)
   137  	for scanner.Scan() {
   138  		fields := strings.Split(scanner.Text(), ":")
   139  		if len(fields) >= 2 {
   140  			users[fields[0]] = fields[1]
   141  		}
   142  	}
   143  
   144  	if err := scanner.Err(); err != nil {
   145  		return nil, err
   146  	}
   147  
   148  	return users, nil
   149  }