feat: implement api mode

This commit is contained in:
xyzeva
2024-06-22 12:09:50 +03:00
parent 819e0a01cf
commit da16ec0e7f
10 changed files with 212 additions and 84 deletions

View File

@@ -8,21 +8,22 @@ import (
)
type Settings struct {
Dirlist string
Dnslist string
Debug bool
LogDir string
NoScan bool
Ports string
Dorking bool
Git bool
Whois bool
Threads int
Nuclei bool
Timeout time.Duration
URLs goflags.StringSlice
File string
ApiMode bool
Dirlist string
Dnslist string
Debug bool
LogDir string
NoScan bool
Ports string
Dorking bool
Git bool
Whois bool
Threads int
Nuclei bool
JavaScript bool
Timeout time.Duration
URLs goflags.StringSlice
File string
ApiMode bool
}
const (
@@ -60,6 +61,7 @@ func Parse() *Settings {
flagSet.BoolVar(&settings.Nuclei, "nuclei", false, "Enable scanning using nuclei templates"),
flagSet.BoolVar(&settings.NoScan, "noscan", false, "Do not perform base URL (robots.txt, etc) scanning"),
flagSet.BoolVar(&settings.Whois, "whois", false, "Enable WHOIS lookup"),
flagSet.BoolVar(&settings.JavaScript, "js", false, "Enable JavaScript scans"),
)
flagSet.CreateGroup("runtime", "Runtime",

View File

@@ -22,7 +22,12 @@ const (
bigFile = "directory-list-2.3-big.txt"
)
func Dirlist(size string, url string, timeout time.Duration, threads int, logdir string) {
type DirectoryResult struct {
Url string `json:"url"`
StatusCode int `json:"status_code"`
}
func Dirlist(size string, url string, timeout time.Duration, threads int, logdir string) ([]DirectoryResult, error) {
fmt.Println(styles.Separator.Render("📂 Starting " + styles.Status.Render("directory fuzzing") + "..."))
@@ -31,7 +36,7 @@ func Dirlist(size string, url string, timeout time.Duration, threads int, logdir
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, size+" directory fuzzing"); err != nil {
log.Errorf("Error creating log file: %v", err)
return
return nil, err
}
}
@@ -55,7 +60,7 @@ func Dirlist(size string, url string, timeout time.Duration, threads int, logdir
resp, err := http.Get(list)
if err != nil {
log.Errorf("Error downloading directory list: %s", err)
return
return nil, err
}
defer resp.Body.Close()
var directories []string
@@ -71,6 +76,8 @@ func Dirlist(size string, url string, timeout time.Duration, threads int, logdir
var wg sync.WaitGroup
wg.Add(threads)
results := []DirectoryResult{}
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
@@ -93,9 +100,17 @@ func Dirlist(size string, url string, timeout time.Duration, threads int, logdir
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("%s [%s]\n", strconv.Itoa(resp.StatusCode), directory))
}
result := DirectoryResult{
Url: resp.Request.URL.String(),
StatusCode: resp.StatusCode,
}
results = append(results, result)
}
}
}(thread)
}
wg.Wait()
return results, nil
}

View File

@@ -21,7 +21,7 @@ const (
dnsBigFile = "subdomains-10000.txt"
)
func Dnslist(size string, url string, timeout time.Duration, threads int, logdir string) {
func Dnslist(size string, url string, timeout time.Duration, threads int, logdir string) ([]string, error) {
fmt.Println(styles.Separator.Render("📡 Starting " + styles.Status.Render("DNS fuzzing") + "..."))
@@ -45,7 +45,7 @@ func Dnslist(size string, url string, timeout time.Duration, threads int, logdir
resp, err := http.Get(list)
if err != nil {
log.Errorf("Error downloading DNS list: %s", err)
return
return nil, err
}
defer resp.Body.Close()
var dns []string
@@ -60,7 +60,7 @@ func Dnslist(size string, url string, timeout time.Duration, threads int, logdir
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, size+" subdomain fuzzing"); err != nil {
log.Errorf("Error creating log file: %v", err)
return
return nil, err
}
}
@@ -70,6 +70,8 @@ func Dnslist(size string, url string, timeout time.Duration, threads int, logdir
var wg sync.WaitGroup
wg.Add(threads)
urls := []string{}
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
@@ -80,10 +82,11 @@ func Dnslist(size string, url string, timeout time.Duration, threads int, logdir
}
log.Debugf("Looking up: %s", domain)
_, err := client.Get("http://" + domain + "." + sanitizedURL)
resp, err := client.Get("http://" + domain + "." + sanitizedURL)
if err != nil {
log.Debugf("Error %s: %s", domain, err)
} else {
urls = append(urls, resp.Request.URL.String())
dnslog.Infof("%s %s.%s", styles.Status.Render("[http]"), styles.Highlight.Render(domain), sanitizedURL)
if logdir != "" {
@@ -97,10 +100,11 @@ func Dnslist(size string, url string, timeout time.Duration, threads int, logdir
}
}
_, err = client.Get("https://" + domain + "." + sanitizedURL)
resp, err = client.Get("https://" + domain + "." + sanitizedURL)
if err != nil {
log.Debugf("Error %s: %s", domain, err)
} else {
urls = append(urls, resp.Request.URL.String())
dnslog.Infof("%s %s.%s", styles.Status.Render("[https]"), styles.Highlight.Render(domain), sanitizedURL)
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("[https] %s.%s\n", domain, sanitizedURL))
@@ -110,4 +114,6 @@ func Dnslist(size string, url string, timeout time.Duration, threads int, logdir
}(thread)
}
wg.Wait()
return urls, nil
}

View File

@@ -21,7 +21,12 @@ const (
dorkFile = "dork.txt"
)
func Dork(url string, timeout time.Duration, threads int, logdir string) {
type DorkResult struct {
Url string `json:"url"`
Count int `json:"count"`
}
func Dork(url string, timeout time.Duration, threads int, logdir string) ([]DorkResult, error) {
fmt.Println(styles.Separator.Render("🤓 Starting " + styles.Status.Render("URL Dorking") + "..."))
@@ -30,7 +35,7 @@ func Dork(url string, timeout time.Duration, threads int, logdir string) {
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "URL dorking"); err != nil {
log.Errorf("Error creating log file: %v", err)
return
return nil, err
}
}
@@ -43,7 +48,7 @@ func Dork(url string, timeout time.Duration, threads int, logdir string) {
resp, err := http.Get(dorkURL + dorkFile)
if err != nil {
log.Errorf("Error downloading dork list: %s", err)
return
return nil, err
}
defer resp.Body.Close()
var dorks []string
@@ -56,6 +61,8 @@ func Dork(url string, timeout time.Duration, threads int, logdir string) {
// util.InitProgressBar()
var wg sync.WaitGroup
wg.Add(threads)
dorkResults := []DorkResult{}
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
@@ -71,9 +78,18 @@ func Dork(url string, timeout time.Duration, threads int, logdir string) {
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("%s dork results found for dork [%s]\n", strconv.Itoa(len(results)), dork))
}
result := DorkResult{
Url: dork,
Count: len(results),
}
dorkResults = append(dorkResults, result)
}
}
}(thread)
}
wg.Wait()
return dorkResults, nil
}

View File

@@ -20,7 +20,7 @@ const (
gitFile = "git.txt"
)
func Git(url string, timeout time.Duration, threads int, logdir string) {
func Git(url string, timeout time.Duration, threads int, logdir string) ([]string, error) {
fmt.Println(styles.Separator.Render("🌿 Starting " + styles.Status.Render("git repository scanning") + "..."))
@@ -29,7 +29,7 @@ func Git(url string, timeout time.Duration, threads int, logdir string) {
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "git directory fuzzing"); err != nil {
log.Errorf("Error creating log file: %v", err)
return
return nil, err
}
}
@@ -42,7 +42,7 @@ func Git(url string, timeout time.Duration, threads int, logdir string) {
resp, err := http.Get(gitURL + gitFile)
if err != nil {
log.Errorf("Error downloading git list: %s", err)
return
return nil, err
}
defer resp.Body.Close()
var gitUrls []string
@@ -59,6 +59,8 @@ func Git(url string, timeout time.Duration, threads int, logdir string) {
var wg sync.WaitGroup
wg.Add(threads)
foundUrls := []string{}
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
@@ -74,15 +76,19 @@ func Git(url string, timeout time.Duration, threads int, logdir string) {
log.Debugf("Error %s: %s", repourl, err)
}
if resp.StatusCode != 404 {
if resp.StatusCode == 200 && !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") {
// log url, directory, and status code
gitlog.Infof("%s git found at [%s]", styles.Status.Render(strconv.Itoa(resp.StatusCode)), styles.Highlight.Render(repourl))
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("%s git found at [%s]\n", strconv.Itoa(resp.StatusCode), repourl))
}
foundUrls = append(foundUrls, resp.Request.URL.String())
}
}
}(thread)
}
wg.Wait()
return foundUrls, nil
}

View File

@@ -2,10 +2,10 @@ package js
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"slices"
"strings"
"time"
@@ -16,15 +16,24 @@ import (
urlutil "github.com/projectdiscovery/utils/url"
)
func JavascriptScan(url string, timeout time.Duration, threads int, logdir string) {
type JavascriptScanResult struct {
SupabaseResults []supabaseScanResult `json:"supabase_results"`
FoundEnvironmentVars map[string]string `json:"environment_variables"`
}
func JavascriptScan(url string, timeout time.Duration, threads int, logdir string) (*JavascriptScanResult, error) {
jslog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "🚧 JavaScript",
}).With("url", url)
baseUrl, err := urlutil.Parse(url)
if err != nil {
return
return nil, err
}
resp, err := http.Get(url)
if err != nil {
fmt.Println(err)
return
return nil, err
}
defer resp.Body.Close()
@@ -37,13 +46,13 @@ func JavascriptScan(url string, timeout time.Duration, threads int, logdir strin
doc, err := htmlquery.Parse(strings.NewReader(html))
if err != nil {
return
return nil, err
}
var scripts []string
nodes, err := htmlquery.QueryAll(doc, "//script/@src")
if err != nil {
return
return nil, err
}
for _, node := range nodes {
var src = htmlquery.InnerText(node)
@@ -61,9 +70,10 @@ func JavascriptScan(url string, timeout time.Duration, threads int, logdir strin
for _, script := range scripts {
if strings.Contains(script, "/_buildManifest.js") {
jslog.Infof("Detected Next.JS pages router! Getting all scripts from %s", script)
nextScripts, err := frameworks.GetPagesRouterScripts(script)
if err != nil {
return
return nil, err
}
for _, nextScript := range nextScripts {
@@ -75,10 +85,11 @@ func JavascriptScan(url string, timeout time.Duration, threads int, logdir strin
}
}
log.Debugf("Got all scripts: %s, now running scans on them", scripts)
jslog.Infof("Got %d scripts, now running scans on them", len(scripts))
var supabaseResults []supabaseScanResult
for _, script := range scripts {
log.Debugf("Scanning %s", script)
jslog.Infof("Scanning %s", script)
resp, err := http.Get(script)
if err != nil {
fmt.Println(err)
@@ -92,19 +103,22 @@ func JavascriptScan(url string, timeout time.Duration, threads int, logdir strin
}
content := string(bodyBytes)
supabaseResults, err := ScanSupabase(content)
jslog.Infof("Running supabase scanner on %s", script)
scriptSupabaseResults, err := ScanSupabase(content, script)
if err != nil {
log.Debugf("Error while scanning supabase: %s", err)
jslog.Errorf("Error while scanning supabase: %s", err)
}
if supabaseResults != nil {
marshalled, err := json.Marshal(supabaseResults)
if err != nil {
continue
}
log.Debugf("Supabase results: %s", marshalled)
if scriptSupabaseResults != nil {
supabaseResults = append(supabaseResults, scriptSupabaseResults...)
}
}
result := JavascriptScanResult{
SupabaseResults: supabaseResults,
FoundEnvironmentVars: map[string]string{},
}
return &result, nil
}

View File

@@ -10,6 +10,7 @@ import (
"io"
"math"
"net/http"
"os"
"regexp"
"slices"
"strconv"
@@ -92,7 +93,10 @@ func GetSupabaseJsonResponse(projectId string, path string, apikey string, auth
return data.(map[string]interface{}), nil
}
func ScanSupabase(jsContent string) ([]supabaseScanResult, error) {
func ScanSupabase(jsContent string, jsUrl string) ([]supabaseScanResult, error) {
supabaselog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "🚧 JavaScript > Supabase ⚡️",
}).With("url", jsUrl)
jwtRegex, err := regexp.Compile("[\"|'|`](ey[A-Za-z0-9_-]{2,}(?:\\.[A-Za-z0-9_-]{2,}){2})[\"|'|`]")
@@ -118,15 +122,15 @@ func ScanSupabase(jsContent string) ([]supabaseScanResult, error) {
decoded, err := base64.RawStdEncoding.DecodeString(body)
if err != nil {
log.Debugf("Failed to decode JWT %s: %s", body, err)
supabaselog.Debugf("Failed to decode JWT %s: %s", body, err)
continue
}
log.Debugf("JWT body: %s", decoded)
supabaselog.Debugf("JWT body: %s", decoded)
var supabaseJwt *supabaseJwtBody
err = json.Unmarshal([]byte(decoded), &supabaseJwt)
if err != nil {
log.Debugf("Failed to json parse JWT %s: %s", jwt, err)
supabaselog.Debugf("Failed to json parse JWT %s: %s", jwt, err)
continue
}
@@ -134,22 +138,22 @@ func ScanSupabase(jsContent string) ([]supabaseScanResult, error) {
continue
}
log.Debugf("Found valid supabase project %s with role %s", *supabaseJwt.ProjectId, *supabaseJwt.Role)
supabaselog.Infof("Found valid supabase project %s with role %s", *supabaseJwt.ProjectId, *supabaseJwt.Role)
client := http.Client{}
req, err := http.NewRequest("POST", "https://"+*supabaseJwt.ProjectId+".supabase.co/auth/v1/signup", bytes.NewBufferString(`{"email":"automated`+strconv.Itoa(int(time.Now().Unix()))+`@sif.sh","password":"automatedacct"}`))
if err != nil {
log.Debugf("1 %s", err)
supabaselog.Errorf("Error while creating HTTP req for creating user: %s", err)
continue
}
req.Header.Set("apikey", jwt)
resp, err := client.Do(req)
if err != nil {
log.Debugf("2 %s", err)
supabaselog.Errorf("Error while sending request to create user: %s", err)
continue
}
log.Debugf("%d", resp.StatusCode)
var auth string
if resp.StatusCode == http.StatusOK {
body, err := io.ReadAll(resp.Body)
@@ -165,7 +169,7 @@ func ScanSupabase(jsContent string) ([]supabaseScanResult, error) {
}
auth = data["access_token"].(string)
log.Debugf("Created account with JWT %s", auth)
supabaselog.Infof("Created account with JWT %s", auth)
}
var collections = []supabaseCollection{}
@@ -199,11 +203,13 @@ func ScanSupabase(jsContent string) ([]supabaseScanResult, error) {
}
samples := sampleObj["array"].([]interface{})
for _, sample := range samples {
log.Debugf("%s", sample)
marshalled, err := json.Marshal(samples)
if err != nil {
supabaselog.Errorf("Failed to marshal sample data for %s: %s", k, err)
}
supabaselog.Infof("Got sample (1000 entries) for collection %s: %s", k, string(marshalled))
limitedSample := samples[0:int(math.Min(float64(len(samples)), 10))]
collection := supabaseCollection{

View File

@@ -30,7 +30,7 @@ import (
"github.com/projectdiscovery/ratelimit"
)
func Nuclei(url string, timeout time.Duration, threads int, logdir string) {
func Nuclei(url string, timeout time.Duration, threads int, logdir string) ([]output.ResultEvent, error) {
fmt.Println(styles.Separator.Render("⚛️ Starting " + styles.Status.Render("nuclei template scanning") + "..."))
sanitizedURL := strings.Split(url, "://")[1]
@@ -50,12 +50,14 @@ func Nuclei(url string, timeout time.Duration, threads int, logdir string) {
config.DefaultConfig.SetTemplatesDir(pwd)
catalog := disk.NewCatalog(pwd)
results := []output.ResultEvent{}
// Custom output
outputWriter := testutils.NewMockOutputWriter()
outputWriter.WriteCallback = func(event *output.ResultEvent) {
if event.Matched != "" {
nucleilog.Infof(format.FormatLine(event))
results = append(results, *event)
// TODO: metasploit
}
}
@@ -70,7 +72,7 @@ func Nuclei(url string, timeout time.Duration, threads int, logdir string) {
interactOpts := interactsh.DefaultOptions(outputWriter, reportingClient, progressClient)
interactClient, err := interactsh.New(interactOpts)
if err != nil {
log.Fatalf("Could not create interact client: %s\n", err)
return nil, err
}
defer interactClient.Close()
@@ -92,13 +94,13 @@ func Nuclei(url string, timeout time.Duration, threads int, logdir string) {
workflowLoader, err := parsers.NewLoader(&executorOpts)
if err != nil {
nucleilog.Fatalf("Could not create workflow loader: %s\n", err)
return nil, err
}
executorOpts.WorkflowLoader = workflowLoader
store, err := loader.New(loader.NewConfig(options, catalog, executorOpts))
if err != nil {
nucleilog.Fatalf("Could not create loader client: %s\n", err)
return nil, err
}
store.Load()
@@ -107,4 +109,6 @@ func Nuclei(url string, timeout time.Duration, threads int, logdir string) {
_ = engine.Execute(store.Templates(), input)
engine.WorkPool().Wait()
return results, nil
}

View File

@@ -18,14 +18,14 @@ import (
const commonPorts = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/ports/top-ports.txt"
func Ports(scope string, url string, timeout time.Duration, threads int, logdir string) {
fmt.Println(styles.Separator.Render("🚪 Starting " + styles.Status.Render("port scanning") + "..."))
func Ports(scope string, url string, timeout time.Duration, threads int, logdir string) ([]string, error) {
log.Printf(styles.Separator.Render("🚪 Starting " + styles.Status.Render("port scanning") + "..."))
sanitizedURL := strings.Split(url, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, scope+" port scanning"); err != nil {
log.Errorf("Error creating log file: %v", err)
return
return nil, err
}
}
@@ -41,7 +41,7 @@ func Ports(scope string, url string, timeout time.Duration, threads int, logdir
resp, err := http.Get(commonPorts)
if err != nil {
log.Errorf("Error downloading ports list: %s", err)
return
return nil, err
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
@@ -89,4 +89,6 @@ func Ports(scope string, url string, timeout time.Duration, threads int, logdir
} else {
portlog.Error("Found no open ports")
}
return openPorts, nil
}

87
sif.go
View File

@@ -2,6 +2,7 @@ package sif
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
@@ -12,8 +13,7 @@ import (
"github.com/dropalldatabases/sif/pkg/config"
"github.com/dropalldatabases/sif/pkg/logger"
"github.com/dropalldatabases/sif/pkg/scan"
"github.com/dropalldatabases/sif/pkg/scan/js"
"github.com/dropalldatabases/sif/pkg/utils"
jsscan "github.com/dropalldatabases/sif/pkg/scan/js"
)
// App is a client instance. It is first initialised using New and then ran
@@ -24,6 +24,16 @@ type App struct {
logFiles []string
}
type UrlResult struct {
Url string `json:"url"`
Results []ModuleResult
}
type ModuleResult struct {
Id string `json:"id"`
Data interface{} `json:"data"`
}
// New creates a new App struct by parsing the configuration options,
// figuring out the targets from list or file, etc.
//
@@ -68,6 +78,10 @@ func (app *App) Run() error {
log.SetLevel(log.DebugLevel)
}
if app.settings.ApiMode {
log.SetLevel(5)
}
if app.settings.LogDir != "" {
if err := logger.Init(app.settings.LogDir); err != nil {
return err
@@ -81,6 +95,8 @@ func (app *App) Run() error {
log.Infof("📡Starting scan on %s...", url)
moduleResults := []ModuleResult{}
if app.settings.LogDir != "" {
if err := logger.CreateFile(&app.logFiles, url, app.settings.LogDir); err != nil {
return err
@@ -92,15 +108,30 @@ func (app *App) Run() error {
}
if app.settings.Dirlist != "none" {
scan.Dirlist(app.settings.Dirlist, url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
result, err := scan.Dirlist(app.settings.Dirlist, url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
if err != nil {
log.Errorf("Error while running directory scan: %s", err)
} else {
moduleResults = append(moduleResults, ModuleResult{"dirlist", result})
}
}
if app.settings.Dnslist != "none" {
scan.Dnslist(app.settings.Dnslist, url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
result, err := scan.Dnslist(app.settings.Dnslist, url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
if err != nil {
log.Errorf("Error while running dns scan: %s", err)
} else {
moduleResults = append(moduleResults, ModuleResult{"dnslist", result})
}
}
if app.settings.Ports != "none" {
scan.Ports(app.settings.Ports, url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
result, err := scan.Ports(app.settings.Ports, url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
if err != nil {
log.Errorf("Error while running port scan: %s", err)
} else {
moduleResults = append(moduleResults, ModuleResult{"portscan", result})
}
}
if app.settings.Whois {
@@ -109,26 +140,52 @@ func (app *App) Run() error {
// func Git(url string, timeout time.Duration, threads int, logdir string)
if app.settings.Git {
scan.Git(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
result, err := scan.Git(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
if err != nil {
log.Errorf("Error while running Git module: %s", err)
} else {
moduleResults = append(moduleResults, ModuleResult{"git", result})
}
}
if app.settings.Nuclei {
scan.Nuclei(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
result, err := scan.Nuclei(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
if err != nil {
log.Errorf("Error while running Nuclei module: %s", err)
} else {
moduleResults = append(moduleResults, ModuleResult{"nuclei", result})
}
}
js.JavascriptScan(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
if app.settings.JavaScript {
result, err := jsscan.JavascriptScan(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
if err != nil {
log.Errorf("Error while running JS module: %s", err)
} else {
moduleResults = append(moduleResults, ModuleResult{"js", result})
}
}
if app.settings.ApiMode {
utils.ReturnApiOutput()
}
result := UrlResult{
Url: url,
Results: moduleResults,
}
// TODO: WHOIS
marshalled, err := json.Marshal(result)
if err != nil {
log.Fatalf("failed to marshal result: %s", err)
}
fmt.Println(string(marshalled))
}
}
if app.settings.LogDir != "" {
fmt.Println(styles.Box.Render(fmt.Sprintf("🌿 All scans completed!\n📂 Output saved to files: %s\n", strings.Join(app.logFiles, ", "))))
} else {
fmt.Println(styles.Box.Render(fmt.Sprintf("🌿 All scans completed!\n")))
if !app.settings.ApiMode {
if app.settings.LogDir != "" {
fmt.Println(styles.Box.Render(fmt.Sprintf("🌿 All scans completed!\n📂 Output saved to files: %s\n", strings.Join(app.logFiles, ", "))))
} else {
fmt.Println(styles.Box.Render(fmt.Sprintf("🌿 All scans completed!\n")))
}
}
return nil