github.com/projectdiscovery/nuclei/v2@v2.9.15/internal/installer/template.go (about)

     1  package installer
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"crypto/md5"
     7  	"fmt"
     8  	"io"
     9  	"io/fs"
    10  	"os"
    11  	"path/filepath"
    12  	"strconv"
    13  	"strings"
    14  
    15  	"github.com/charmbracelet/glamour"
    16  	"github.com/olekukonko/tablewriter"
    17  	"github.com/projectdiscovery/gologger"
    18  	"github.com/projectdiscovery/nuclei/v2/pkg/catalog/config"
    19  	"github.com/projectdiscovery/nuclei/v2/pkg/external/customtemplates"
    20  	errorutil "github.com/projectdiscovery/utils/errors"
    21  	fileutil "github.com/projectdiscovery/utils/file"
    22  	stringsutil "github.com/projectdiscovery/utils/strings"
    23  	updateutils "github.com/projectdiscovery/utils/update"
    24  )
    25  
    26  const (
    27  	checkSumFilePerm = 0644
    28  )
    29  
    30  var (
    31  	HideProgressBar        = true
    32  	HideUpdateChangesTable = false
    33  	HideReleaseNotes       = true
    34  )
    35  
    36  // TemplateUpdateResults contains the results of template update
    37  type templateUpdateResults struct {
    38  	additions     []string
    39  	deletions     []string
    40  	modifications []string
    41  	totalCount    int
    42  }
    43  
    44  // String returns markdown table of template update results
    45  func (t *templateUpdateResults) String() string {
    46  	var buff bytes.Buffer
    47  	data := [][]string{
    48  		{strconv.Itoa(t.totalCount), strconv.Itoa(len(t.additions)), strconv.Itoa(len(t.deletions))},
    49  	}
    50  	table := tablewriter.NewWriter(&buff)
    51  	table.SetHeader([]string{"Total", "Added", "Removed"})
    52  	for _, v := range data {
    53  		table.Append(v)
    54  	}
    55  	table.Render()
    56  	return buff.String()
    57  }
    58  
    59  // TemplateManager is a manager for templates.
    60  // It downloads / updates / installs templates.
    61  type TemplateManager struct {
    62  	CustomTemplates        *customtemplates.CustomTemplatesManager // optional if given tries to download custom templates
    63  	DisablePublicTemplates bool                                    // if true,
    64  	// public templates are not downloaded from the GitHub nuclei-templates repository
    65  }
    66  
    67  // FreshInstallIfNotExists installs templates if they are not already installed
    68  // if templates directory already exists, it does nothing
    69  func (t *TemplateManager) FreshInstallIfNotExists() error {
    70  	if fileutil.FolderExists(config.DefaultConfig.TemplatesDirectory) {
    71  		return nil
    72  	}
    73  	gologger.Info().Msgf("nuclei-templates are not installed, installing...")
    74  	if err := t.installTemplatesAt(config.DefaultConfig.TemplatesDirectory); err != nil {
    75  		return errorutil.NewWithErr(err).Msgf("failed to install templates at %s", config.DefaultConfig.TemplatesDirectory)
    76  	}
    77  	if t.CustomTemplates != nil {
    78  		t.CustomTemplates.Download(context.TODO())
    79  	}
    80  	return nil
    81  }
    82  
    83  // UpdateIfOutdated updates templates if they are outdated
    84  func (t *TemplateManager) UpdateIfOutdated() error {
    85  	// if the templates folder does not exist, it's a fresh installation and do not update
    86  	if !fileutil.FolderExists(config.DefaultConfig.TemplatesDirectory) {
    87  		return t.FreshInstallIfNotExists()
    88  	}
    89  	if config.DefaultConfig.NeedsTemplateUpdate() {
    90  		return t.updateTemplatesAt(config.DefaultConfig.TemplatesDirectory)
    91  	}
    92  	return nil
    93  }
    94  
    95  // installTemplatesAt installs templates at given directory
    96  func (t *TemplateManager) installTemplatesAt(dir string) error {
    97  	if !fileutil.FolderExists(dir) {
    98  		if err := fileutil.CreateFolder(dir); err != nil {
    99  			return errorutil.NewWithErr(err).Msgf("failed to create directory at %s", dir)
   100  		}
   101  	}
   102  	if t.DisablePublicTemplates {
   103  		gologger.Info().Msgf("Skipping installation of public nuclei-templates")
   104  		return nil
   105  	}
   106  	ghrd, err := updateutils.NewghReleaseDownloader(config.OfficialNucleiTemplatesRepoName)
   107  	if err != nil {
   108  		return errorutil.NewWithErr(err).Msgf("failed to install templates at %s", dir)
   109  	}
   110  
   111  	// write templates to disk
   112  	if err := t.writeTemplatesToDisk(ghrd, dir); err != nil {
   113  		return errorutil.NewWithErr(err).Msgf("failed to write templates to disk at %s", dir)
   114  	}
   115  	gologger.Info().Msgf("Successfully installed nuclei-templates at %s", dir)
   116  	return nil
   117  }
   118  
   119  // updateTemplatesAt updates templates at given directory
   120  func (t *TemplateManager) updateTemplatesAt(dir string) error {
   121  	if t.DisablePublicTemplates {
   122  		gologger.Info().Msgf("Skipping update of public nuclei-templates")
   123  		return nil
   124  	}
   125  	// firstly, read checksums from .checksum file these are used to generate stats
   126  	oldchecksums, err := t.getChecksumFromDir(dir)
   127  	if err != nil {
   128  		// if something went wrong, overwrite all files
   129  		oldchecksums = make(map[string]string)
   130  	}
   131  
   132  	ghrd, err := updateutils.NewghReleaseDownloader(config.OfficialNucleiTemplatesRepoName)
   133  	if err != nil {
   134  		return errorutil.NewWithErr(err).Msgf("failed to install templates at %s", dir)
   135  	}
   136  
   137  	gologger.Info().Msgf("Your current nuclei-templates %s are outdated. Latest is %s\n", config.DefaultConfig.TemplateVersion, ghrd.Latest.GetTagName())
   138  
   139  	// write templates to disk
   140  	if err := t.writeTemplatesToDisk(ghrd, dir); err != nil {
   141  		return err
   142  	}
   143  
   144  	// get checksums from new templates
   145  	newchecksums, err := t.getChecksumFromDir(dir)
   146  	if err != nil {
   147  		// unlikely this case will happen
   148  		return errorutil.NewWithErr(err).Msgf("failed to get checksums from %s after update", dir)
   149  	}
   150  
   151  	// summarize all changes
   152  	results := t.summarizeChanges(oldchecksums, newchecksums)
   153  
   154  	// print summary
   155  	if results.totalCount > 0 {
   156  		gologger.Info().Msgf("Successfully updated nuclei-templates (%v) to %s. GoodLuck!", ghrd.Latest.GetTagName(), dir)
   157  		if !HideUpdateChangesTable {
   158  			// print summary table
   159  			gologger.Print().Msgf("\nNuclei Templates %s Changelog\n", ghrd.Latest.GetTagName())
   160  			gologger.DefaultLogger.Print().Msg(results.String())
   161  		}
   162  	} else {
   163  		gologger.Info().Msgf("Successfully updated nuclei-templates (%v) to %s. GoodLuck!", ghrd.Latest.GetTagName(), dir)
   164  	}
   165  	return nil
   166  }
   167  
   168  // summarizeChanges summarizes changes between old and new checksums
   169  func (t *TemplateManager) summarizeChanges(old, new map[string]string) *templateUpdateResults {
   170  	results := &templateUpdateResults{}
   171  	for k, v := range new {
   172  		if oldv, ok := old[k]; ok {
   173  			if oldv != v {
   174  				results.modifications = append(results.modifications, k)
   175  			}
   176  		} else {
   177  			results.additions = append(results.additions, k)
   178  		}
   179  	}
   180  	for k := range old {
   181  		if _, ok := new[k]; !ok {
   182  			results.deletions = append(results.deletions, k)
   183  		}
   184  	}
   185  	results.totalCount = len(results.additions) + len(results.deletions) + len(results.modifications)
   186  	return results
   187  }
   188  
   189  // getAbsoluteFilePath returns an absolute path where a file should be written based on given uri(i.e., files in zip)
   190  // if a returned path is empty, it means that file should not be written and skipped
   191  func (t *TemplateManager) getAbsoluteFilePath(templateDir, uri string, f fs.FileInfo) string {
   192  	// overwrite .nuclei-ignore every time nuclei-templates are downloaded
   193  	if f.Name() == config.NucleiIgnoreFileName {
   194  		return config.DefaultConfig.GetIgnoreFilePath()
   195  	}
   196  	// skip all meta files
   197  	if !strings.EqualFold(f.Name(), config.NewTemplateAdditionsFileName) {
   198  		if strings.TrimSpace(f.Name()) == "" || strings.HasPrefix(f.Name(), ".") || strings.EqualFold(f.Name(), "README.md") {
   199  			return ""
   200  		}
   201  	}
   202  
   203  	// get root or leftmost directory name from path
   204  	// this is in format `projectdiscovery-nuclei-templates-commithash`
   205  
   206  	index := strings.Index(uri, "/")
   207  	if index == -1 {
   208  		// zip files does not have directory at all , in this case log error but continue
   209  		gologger.Warning().Msgf("failed to get directory name from uri: %s", uri)
   210  		return filepath.Join(templateDir, uri)
   211  	}
   212  	// separator is also included in rootDir
   213  	rootDirectory := uri[:index+1]
   214  	relPath := strings.TrimPrefix(uri, rootDirectory)
   215  
   216  	// if it is a github meta directory skip it
   217  	if stringsutil.HasPrefixAny(relPath, ".github", ".git") {
   218  		return ""
   219  	}
   220  
   221  	newPath := filepath.Clean(filepath.Join(templateDir, relPath))
   222  
   223  	if !strings.HasPrefix(newPath, templateDir) {
   224  		// we don't allow LFI
   225  		return ""
   226  	}
   227  
   228  	if newPath == templateDir || newPath == templateDir+string(os.PathSeparator) {
   229  		// skip writing the folder itself since it already exists
   230  		return ""
   231  	}
   232  
   233  	if relPath != "" && f.IsDir() {
   234  		// if uri is a directory, create it
   235  		if err := fileutil.CreateFolder(newPath); err != nil {
   236  			gologger.Warning().Msgf("uri %v: got %s while installing templates", uri, err)
   237  		}
   238  		return ""
   239  	}
   240  	return newPath
   241  }
   242  
   243  // writeChecksumFileInDir is actual method responsible for writing all templates to directory
   244  func (t *TemplateManager) writeTemplatesToDisk(ghrd *updateutils.GHReleaseDownloader, dir string) error {
   245  	localTemplatesIndex, err := config.GetNucleiTemplatesIndex()
   246  	if err != nil {
   247  		gologger.Warning().Msgf("failed to get local nuclei-templates index: %s", err)
   248  		if localTemplatesIndex == nil {
   249  			localTemplatesIndex = map[string]string{} // no-op
   250  		}
   251  	}
   252  
   253  	callbackFunc := func(uri string, f fs.FileInfo, r io.Reader) error {
   254  		writePath := t.getAbsoluteFilePath(dir, uri, f)
   255  		if writePath == "" {
   256  			// skip writing file
   257  			return nil
   258  		}
   259  
   260  		bin, err := io.ReadAll(r)
   261  		if err != nil {
   262  			// if error occurs, iteration also stops
   263  			return errorutil.NewWithErr(err).Msgf("failed to read file %s", uri)
   264  		}
   265  		// TODO: It might be better to just download index file from nuclei templates repo
   266  		// instead of creating it from scratch
   267  		id, _ := config.GetTemplateIDFromReader(bytes.NewReader(bin), uri)
   268  		if id != "" {
   269  			// based on template id, check if we are updating a path of official nuclei template
   270  			if oldPath, ok := localTemplatesIndex[id]; ok {
   271  				if oldPath != writePath {
   272  					// write new template at a new path and delete old template
   273  					if err := os.WriteFile(writePath, bin, f.Mode()); err != nil {
   274  						return errorutil.NewWithErr(err).Msgf("failed to write file %s", uri)
   275  					}
   276  					// after successful write, remove old template
   277  					if err := os.Remove(oldPath); err != nil {
   278  						gologger.Warning().Msgf("failed to remove old template %s: %s", oldPath, err)
   279  					}
   280  					return nil
   281  				}
   282  			}
   283  		}
   284  		// no change in template Path of official templates
   285  		return os.WriteFile(writePath, bin, f.Mode())
   286  	}
   287  	err = ghrd.DownloadSourceWithCallback(!HideProgressBar, callbackFunc)
   288  	if err != nil {
   289  		return errorutil.NewWithErr(err).Msgf("failed to download templates")
   290  	}
   291  
   292  	if err := config.DefaultConfig.WriteTemplatesConfig(); err != nil {
   293  		return errorutil.NewWithErr(err).Msgf("failed to write templates config")
   294  	}
   295  	// update ignore hash after writing new templates
   296  	if err := config.DefaultConfig.UpdateNucleiIgnoreHash(); err != nil {
   297  		return errorutil.NewWithErr(err).Msgf("failed to update nuclei ignore hash")
   298  	}
   299  
   300  	// update templates version in config file
   301  	if err := config.DefaultConfig.SetTemplatesVersion(ghrd.Latest.GetTagName()); err != nil {
   302  		return errorutil.NewWithErr(err).Msgf("failed to update templates version")
   303  	}
   304  
   305  	PurgeEmptyDirectories(dir)
   306  
   307  	// generate index of all templates
   308  	_ = os.Remove(config.DefaultConfig.GetTemplateIndexFilePath())
   309  
   310  	index, err := config.GetNucleiTemplatesIndex()
   311  	if err != nil {
   312  		return errorutil.NewWithErr(err).Msgf("failed to get nuclei templates index")
   313  	}
   314  
   315  	if err = config.DefaultConfig.WriteTemplatesIndex(index); err != nil {
   316  		return errorutil.NewWithErr(err).Msgf("failed to write nuclei templates index")
   317  	}
   318  
   319  	if !HideReleaseNotes {
   320  		output := ghrd.Latest.GetBody()
   321  		// adjust colors for both dark / light terminal themes
   322  		r, err := glamour.NewTermRenderer(glamour.WithAutoStyle())
   323  		if err != nil {
   324  			gologger.Error().Msgf("markdown rendering not supported: %v", err)
   325  		}
   326  		if rendered, err := r.Render(output); err == nil {
   327  			output = rendered
   328  		} else {
   329  			gologger.Error().Msg(err.Error())
   330  		}
   331  		gologger.Print().Msgf("\n%v\n\n", output)
   332  	}
   333  
   334  	// after installation, create and write checksums to .checksum file
   335  	return t.writeChecksumFileInDir(dir)
   336  }
   337  
   338  // getChecksumFromDir returns a map containing checksums (md5 hash) of all yaml files (with .yaml extension)
   339  // if .checksum file does not exist, checksums are calculated and returned
   340  func (t *TemplateManager) getChecksumFromDir(dir string) (map[string]string, error) {
   341  	checksumFilePath := config.DefaultConfig.GetChecksumFilePath()
   342  	if fileutil.FileExists(checksumFilePath) {
   343  		checksums, err := os.ReadFile(checksumFilePath)
   344  		if err == nil {
   345  			allChecksums := make(map[string]string)
   346  			for _, v := range strings.Split(string(checksums), "\n") {
   347  				v = strings.TrimSpace(v)
   348  				tmparr := strings.Split(v, ",")
   349  				if len(tmparr) != 2 {
   350  					continue
   351  				}
   352  				allChecksums[tmparr[0]] = tmparr[1]
   353  			}
   354  			return allChecksums, nil
   355  		}
   356  	}
   357  	return t.calculateChecksumMap(dir)
   358  }
   359  
   360  // writeChecksumFileInDir creates checksums of all yaml files in given directory
   361  // and writes them to a file named .checksum
   362  func (t *TemplateManager) writeChecksumFileInDir(dir string) error {
   363  	checksumMap, err := t.calculateChecksumMap(dir)
   364  	if err != nil {
   365  		return err
   366  	}
   367  	var buff bytes.Buffer
   368  	for k, v := range checksumMap {
   369  		buff.WriteString(k + "," + v)
   370  	}
   371  	return os.WriteFile(config.DefaultConfig.GetChecksumFilePath(), buff.Bytes(), checkSumFilePerm)
   372  }
   373  
   374  // getChecksumMap returns a map containing checksums (md5 hash) of all yaml files (with .yaml extension)
   375  func (t *TemplateManager) calculateChecksumMap(dir string) (map[string]string, error) {
   376  	// getchecksumMap walks given directory `dir` and returns a map containing
   377  	// checksums (md5 hash) of all yaml files (with .yaml extension) and the
   378  	// format is map[filePath]checksum
   379  	checksumMap := map[string]string{}
   380  
   381  	getChecksum := func(filepath string) (string, error) {
   382  		// return md5 hash of the file
   383  		bin, err := os.ReadFile(filepath)
   384  		if err != nil {
   385  			return "", err
   386  		}
   387  		return fmt.Sprintf("%x", md5.Sum(bin)), nil
   388  	}
   389  
   390  	err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
   391  		if err != nil {
   392  			return err
   393  		}
   394  		// skip checksums of custom templates i.e github and s3
   395  		if stringsutil.HasPrefixAny(path, config.DefaultConfig.GetAllCustomTemplateDirs()...) {
   396  			return nil
   397  		}
   398  
   399  		// current implementations calculates checksums of all files (including .yaml,.txt,.md,.json etc)
   400  		if !d.IsDir() {
   401  			checksum, err := getChecksum(path)
   402  			if err != nil {
   403  				return err
   404  			}
   405  			checksumMap[path] = checksum
   406  		}
   407  		return nil
   408  	})
   409  	return checksumMap, errorutil.WrapfWithNil(err, "failed to calculate checksums of templates")
   410  }