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 }