package installer
import (
"bytes"
"context"
"crypto/md5"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/charmbracelet/glamour"
"github.com/olekukonko/tablewriter"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v3/pkg/external/customtemplates"
"github.com/projectdiscovery/utils/errkit"
fileutil "github.com/projectdiscovery/utils/file"
stringsutil "github.com/projectdiscovery/utils/strings"
updateutils "github.com/projectdiscovery/utils/update"
)
const (
checkSumFilePerm = 0644
)
var (
HideProgressBar = true
HideUpdateChangesTable = false
HideReleaseNotes = true
)
type templateUpdateResults struct {
additions []string
deletions []string
modifications []string
totalCount int
}
func (t *templateUpdateResults) String() string {
var buff bytes.Buffer
data := [][]string{
{
strconv.Itoa(t.totalCount),
strconv.Itoa(len(t.additions)),
strconv.Itoa(len(t.modifications)),
strconv.Itoa(len(t.deletions)),
},
}
table := tablewriter.NewWriter(&buff)
table.Header([]string{"Total", "Added", "Modified", "Removed"})
for _, v := range data {
_ = table.Append(v)
}
_ = table.Render()
defer func() {
_ = table.Close()
}()
return buff.String()
}
type TemplateManager struct {
CustomTemplates *customtemplates.CustomTemplatesManager
DisablePublicTemplates bool
}
func (t *TemplateManager) FreshInstallIfNotExists() error {
if fileutil.FolderExists(config.DefaultConfig.TemplatesDirectory) {
return nil
}
gologger.Info().Msgf("nuclei-templates are not installed, installing...")
if err := t.installTemplatesAt(config.DefaultConfig.TemplatesDirectory); err != nil {
return errkit.Wrapf(err, "failed to install templates at %s", config.DefaultConfig.TemplatesDirectory)
}
if t.CustomTemplates != nil {
t.CustomTemplates.Download(context.TODO())
}
return nil
}
func (t *TemplateManager) UpdateIfOutdated() error {
if !fileutil.FolderExists(config.DefaultConfig.TemplatesDirectory) {
return t.FreshInstallIfNotExists()
}
needsUpdate := config.DefaultConfig.NeedsTemplateUpdate()
if !needsUpdate && config.DefaultConfig.LatestNucleiTemplatesVersion == "" && config.DefaultConfig.TemplateVersion != "" {
ghrd, err := updateutils.NewghReleaseDownloader(config.OfficialNucleiTemplatesRepoName)
if err == nil {
latestVersion := ghrd.Latest.GetTagName()
if config.IsOutdatedVersion(config.DefaultConfig.TemplateVersion, latestVersion) {
needsUpdate = true
gologger.Debug().Msgf("PDTM API unavailable, verified update needed via GitHub API: %s -> %s", config.DefaultConfig.TemplateVersion, latestVersion)
}
}
}
if needsUpdate {
return t.updateTemplatesAt(config.DefaultConfig.TemplatesDirectory)
}
return nil
}
func (t *TemplateManager) installTemplatesAt(dir string) error {
if !fileutil.FolderExists(dir) {
if err := fileutil.CreateFolder(dir); err != nil {
return errkit.Wrapf(err, "failed to create directory at %s", dir)
}
}
if t.DisablePublicTemplates {
gologger.Info().Msgf("Skipping installation of public nuclei-templates")
return nil
}
ghrd, err := updateutils.NewghReleaseDownloader(config.OfficialNucleiTemplatesRepoName)
if err != nil {
return errkit.Wrapf(err, "failed to install templates at %s", dir)
}
if err := t.writeTemplatesToDisk(ghrd, dir); err != nil {
return errkit.Wrapf(err, "failed to write templates to disk at %s", dir)
}
gologger.Info().Msgf("Successfully installed nuclei-templates at %s", dir)
return nil
}
func (t *TemplateManager) updateTemplatesAt(dir string) error {
if t.DisablePublicTemplates {
gologger.Info().Msgf("Skipping update of public nuclei-templates")
return nil
}
oldchecksums, err := t.getChecksumFromDir(dir)
if err != nil {
oldchecksums = make(map[string]string)
}
ghrd, err := updateutils.NewghReleaseDownloader(config.OfficialNucleiTemplatesRepoName)
if err != nil {
return errkit.Wrapf(err, "failed to install templates at %s", dir)
}
latestVersion := ghrd.Latest.GetTagName()
currentVersion := config.DefaultConfig.TemplateVersion
if config.IsOutdatedVersion(currentVersion, latestVersion) {
gologger.Info().Msgf("Your current nuclei-templates %s are outdated. Latest is %s\n", currentVersion, latestVersion)
} else {
gologger.Debug().Msgf("Updating nuclei-templates from %s to %s (forced update)\n", currentVersion, latestVersion)
}
if err := t.writeTemplatesToDisk(ghrd, dir); err != nil {
return err
}
newchecksums, err := t.getChecksumFromDir(dir)
if err != nil {
return errkit.Wrapf(err, "failed to get checksums from %s after update", dir)
}
results := t.summarizeChanges(oldchecksums, newchecksums)
for _, deletion := range results.deletions {
if err := os.Remove(deletion); err != nil && !os.IsNotExist(err) {
gologger.Warning().Msgf("failed to remove deleted template %s: %s", deletion, err)
}
}
if results.totalCount > 0 {
gologger.Info().Msgf("Successfully updated nuclei-templates (%v) to %s. GoodLuck!", ghrd.Latest.GetTagName(), dir)
if !HideUpdateChangesTable {
gologger.Print().Msgf("\nNuclei Templates %s Changelog\n", ghrd.Latest.GetTagName())
gologger.DefaultLogger.Print().Msg(results.String())
}
} else {
gologger.Info().Msgf("Successfully updated nuclei-templates (%v) to %s. GoodLuck!", ghrd.Latest.GetTagName(), dir)
}
return nil
}
func (t *TemplateManager) summarizeChanges(old, new map[string]string) *templateUpdateResults {
results := &templateUpdateResults{}
for k, v := range new {
if oldv, ok := old[k]; ok {
if oldv != v {
results.modifications = append(results.modifications, k)
}
} else {
results.additions = append(results.additions, k)
}
}
for k := range old {
if _, ok := new[k]; !ok {
results.deletions = append(results.deletions, k)
}
}
results.totalCount = len(results.additions) + len(results.deletions) + len(results.modifications)
return results
}
func (t *TemplateManager) getAbsoluteFilePath(templateDir, uri string, f fs.FileInfo) string {
if f.Name() == config.NucleiIgnoreFileName {
return config.DefaultConfig.GetIgnoreFilePath()
}
if !strings.EqualFold(f.Name(), config.NewTemplateAdditionsFileName) {
if strings.TrimSpace(f.Name()) == "" || strings.HasPrefix(f.Name(), ".") || strings.EqualFold(f.Name(), "README.md") {
return ""
}
}
index := strings.Index(uri, "/")
if index == -1 {
gologger.Warning().Msgf("failed to get directory name from uri: %s", uri)
return filepath.Join(templateDir, uri)
}
rootDirectory := uri[:index+1]
relPath := strings.TrimPrefix(uri, rootDirectory)
if stringsutil.HasPrefixAny(relPath, ".github", ".git") {
return ""
}
newPath := filepath.Clean(filepath.Join(templateDir, relPath))
if !strings.HasPrefix(newPath, templateDir) {
return ""
}
if newPath == templateDir || newPath == templateDir+string(os.PathSeparator) {
return ""
}
if relPath != "" && f.IsDir() {
if err := fileutil.CreateFolder(newPath); err != nil {
gologger.Warning().Msgf("uri %v: got %s while installing templates", uri, err)
}
return ""
}
return newPath
}
func (t *TemplateManager) writeTemplatesToDisk(ghrd *updateutils.GHReleaseDownloader, dir string) error {
localTemplatesIndex, err := config.GetNucleiTemplatesIndex()
if err != nil {
gologger.Warning().Msgf("failed to get local nuclei-templates index: %s", err)
if localTemplatesIndex == nil {
localTemplatesIndex = map[string]string{}
}
}
callbackFunc := func(uri string, f fs.FileInfo, r io.Reader) error {
writePath := t.getAbsoluteFilePath(dir, uri, f)
if writePath == "" {
return nil
}
bin, err := io.ReadAll(r)
if err != nil {
return errkit.Wrapf(err, "failed to read file %s", uri)
}
id, _ := config.GetTemplateIDFromReader(bytes.NewReader(bin), uri)
if id != "" {
if oldPath, ok := localTemplatesIndex[id]; ok {
if oldPath != writePath {
if err := os.WriteFile(writePath, bin, f.Mode()); err != nil {
return errkit.Wrapf(err, "failed to write file %s", uri)
}
if err := os.Remove(oldPath); err != nil {
gologger.Warning().Msgf("failed to remove old template %s: %s", oldPath, err)
}
return nil
}
}
}
return os.WriteFile(writePath, bin, f.Mode())
}
err = ghrd.DownloadSourceWithCallback(!HideProgressBar, callbackFunc)
if err != nil {
return errkit.Wrap(err, "failed to download templates")
}
if err := config.DefaultConfig.WriteTemplatesConfig(); err != nil {
return errkit.Wrap(err, "failed to write templates config")
}
if err := config.DefaultConfig.UpdateNucleiIgnoreHash(); err != nil {
return errkit.Wrap(err, "failed to update nuclei ignore hash")
}
if err := config.DefaultConfig.SetTemplatesVersion(ghrd.Latest.GetTagName()); err != nil {
return errkit.Wrap(err, "failed to update templates version")
}
PurgeEmptyDirectories(dir)
_ = os.Remove(config.DefaultConfig.GetTemplateIndexFilePath())
index, err := config.GetNucleiTemplatesIndex()
if err != nil {
return errkit.Wrap(err, "failed to get nuclei templates index")
}
if err = config.DefaultConfig.WriteTemplatesIndex(index); err != nil {
return errkit.Wrap(err, "failed to write nuclei templates index")
}
if !HideReleaseNotes {
output := ghrd.Latest.GetBody()
r, err := glamour.NewTermRenderer(glamour.WithAutoStyle())
if err != nil {
gologger.Error().Msgf("markdown rendering not supported: %v", err)
}
if rendered, err := r.Render(output); err == nil {
output = rendered
} else {
gologger.Error().Msg(err.Error())
}
gologger.Print().Msgf("\n%v\n\n", output)
}
return t.writeChecksumFileInDir(dir)
}
func (t *TemplateManager) getChecksumFromDir(dir string) (map[string]string, error) {
checksumFilePath := config.DefaultConfig.GetChecksumFilePath()
if fileutil.FileExists(checksumFilePath) {
checksums, err := os.ReadFile(checksumFilePath)
if err == nil {
allChecksums := make(map[string]string)
for _, v := range strings.Split(string(checksums), ";") {
v = strings.TrimSpace(v)
tmparr := strings.Split(v, ",")
if len(tmparr) != 2 {
continue
}
allChecksums[tmparr[0]] = tmparr[1]
}
return allChecksums, nil
}
}
return t.calculateChecksumMap(dir)
}
func (t *TemplateManager) writeChecksumFileInDir(dir string) error {
checksumMap, err := t.calculateChecksumMap(dir)
if err != nil {
return err
}
var buff bytes.Buffer
for k, v := range checksumMap {
buff.WriteString(k)
buff.WriteString(",")
buff.WriteString(v)
buff.WriteString(";")
}
return os.WriteFile(config.DefaultConfig.GetChecksumFilePath(), buff.Bytes(), checkSumFilePerm)
}
func (t *TemplateManager) calculateChecksumMap(dir string) (map[string]string, error) {
checksumMap := map[string]string{}
getChecksum := func(filepath string) (string, error) {
bin, err := os.ReadFile(filepath)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", md5.Sum(bin)), nil
}
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if stringsutil.HasPrefixAny(path, config.DefaultConfig.GetAllCustomTemplateDirs()...) {
return nil
}
if !d.IsDir() {
checksum, err := getChecksum(path)
if err != nil {
return err
}
checksumMap[path] = checksum
}
return nil
})
return checksumMap, errkit.Wrap(err, "failed to calculate checksums of templates")
}