Compare commits

...
Author SHA1 Message Date
Mzack9999 535eb68281 bump rod 2026-11-13 23:11:33 +01:00
Mzack9999 91b57c8650 bump rod 2026-11-13 19:15:08 +01:00
Mzack9999 d95cd26af5 bump rod 2026-11-13 17:23:25 +01:00
Mzack9999 8903942700 bump rod 2026-11-13 16:03:50 +01:00
Mzack9999 aec1420bdb rod module 2026-11-13 14:41:54 +01:00
Dogan Can Bakir b2ea1489fd Merge pull request #8101 from projectdiscovery/feat/run-plan
add -run-plan to run a plan file and resume it
2026-11-11 07:27:33 +03:00
Dogan Can Bakir e6ce143015 Merge remote-tracking branch 'origin/dev' into feat/run-plan
# Conflicts:
#	internal/runner/runner.go
#	pkg/types/types.go
2026-11-11 07:05:20 +03:00
Mzack9999 894f90dd11 reject flags 2026-11-09 20:31:19 +01:00
Dogan Can Bakir a351263c86 add -run-plan to run a plan file and resume it from its done log 2026-11-09 20:18:42 +03:00
36 changed files with 485 additions and 57 deletions

No files matched your search

+1
View File
@@ -167,6 +167,7 @@ TEMPLATES:
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
-validate validate the passed templates to nuclei
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
-run-plan string run only the entries of a -plan file, logging finished ones to <file>.done so running it again resumes
-nss, -no-strict-syntax disable strict syntax check on templates
-td, -template-display displays the templates content
-tl list all templates matching current filters
+1
View File
@@ -165,6 +165,7 @@ TEMPLATES:
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
-validate validate the passed templates to nuclei
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
-run-plan string run only the entries of a -plan file, logging finished ones to <file>.done so running it again resumes
-nss, -no-strict-syntax disable strict syntax check on templates
-td, -template-display displays the templates content
-tl list all templates matching current filters
+1
View File
@@ -165,6 +165,7 @@ TEMPLATES:
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
-validate validate the passed templates to nuclei
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
-run-plan string run only the entries of a -plan file, logging finished ones to <file>.done so running it again resumes
-nss, -no-strict-syntax disable strict syntax check on templates
-td, -template-display displays the templates content
-tl list all templates matching current filters
+1
View File
@@ -182,6 +182,7 @@ TEMPLATES:
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
-validate validate the passed templates to nuclei
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
-run-plan string run only the entries of a -plan file, logging finished ones to <file>.done so running it again resumes
-nss, -no-strict-syntax disable strict syntax check on templates
-td, -template-display displays the templates content
-tl list all templates matching current filters
+1
View File
@@ -165,6 +165,7 @@ TEMPLATES:
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
-validate validate the passed templates to nuclei
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
-run-plan string run only the entries of a -plan file, logging finished ones to <file>.done so running it again resumes
-nss, -no-strict-syntax disable strict syntax check on templates
-td, -template-display displays the templates content
-tl list all templates matching current filters
+1
View File
@@ -165,6 +165,7 @@ TEMPLATES:
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
-validate validate the passed templates to nuclei
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
-run-plan string run only the entries of a -plan file, logging finished ones to <file>.done so running it again resumes
-nss, -no-strict-syntax disable strict syntax check on templates
-td, -template-display displays the templates content
-tl list all templates matching current filters
+1
View File
@@ -165,6 +165,7 @@ TEMPLATES:
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
-validate validate the passed templates to nuclei
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
-run-plan string run only the entries of a -plan file, logging finished ones to <file>.done so running it again resumes
-nss, -no-strict-syntax disable strict syntax check on templates
-td, -template-display displays the templates content
-tl list all templates matching current filters
+1
View File
@@ -165,6 +165,7 @@ TEMPLATES:
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
-validate validate the passed templates to nuclei
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
-run-plan string run only the entries of a -plan file, logging finished ones to <file>.done so running it again resumes
-nss, -no-strict-syntax disable strict syntax check on templates
-td, -template-display displays the templates content
-tl list all templates matching current filters
+1
View File
@@ -165,6 +165,7 @@ TEMPLATES:
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
-validate validate the passed templates to nuclei
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
-run-plan string run only the entries of a -plan file, logging finished ones to <file>.done so running it again resumes
-nss, -no-strict-syntax disable strict syntax check on templates
-td, -template-display displays the templates content
-tl list all templates matching current filters
+1
View File
@@ -314,6 +314,7 @@ on extensive configurability, massive extensibility and ease of use.`)
flagSet.StringSliceVarP(&options.WorkflowURLs, "workflow-url", "wurl", nil, "workflow url or list containing workflow urls to run (comma-separated, file)", goflags.FileCommaSeparatedStringSliceOptions),
flagSet.BoolVar(&options.Validate, "validate", false, "validate the passed templates to nuclei"),
flagSet.StringVar(&options.PlanFile, "plan", "", "write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target"),
flagSet.StringVar(&options.RunPlanFile, "run-plan", "", "run only the entries of a -plan file, logging finished ones to <file>.done so running it again resumes"),
flagSet.BoolVarP(&options.NoStrictSyntax, "no-strict-syntax", "nss", false, "disable strict syntax check on templates"),
flagSet.BoolVarP(&options.TemplateDisplay, "template-display", "td", false, "displays the templates content"),
flagSet.BoolVar(&options.TemplateList, "tl", false, "list all templates matching current filters"),
+2 -6
View File
@@ -1,6 +1,6 @@
module github.com/projectdiscovery/nuclei/v3
go 1.27.3
go 1.27.4
require (
carvel.dev/ytt v0.52.0
@@ -46,7 +46,6 @@ require (
github.com/go-pdf/fpdf v0.9.0
github.com/go-pg/pg/v10 v10.15.0
github.com/go-playground/validator/v10 v10.26.0
github.com/go-rod/rod v0.116.2
github.com/go-sql-driver/mysql v1.10.0
github.com/gobwas/ws v1.4.0
github.com/goccy/go-json v0.10.5
@@ -102,6 +101,7 @@ require (
github.com/projectdiscovery/rdap v0.9.0
github.com/projectdiscovery/retryabledns v1.0.116
github.com/projectdiscovery/retryablehttp-go v1.3.31
github.com/projectdiscovery/rod v0.116.3-0.20261113214723-dd0abeca2a24
github.com/projectdiscovery/sarif v0.1.0
github.com/projectdiscovery/tlsx v1.4.3-0.20261020052910-acb80c2c7c75
github.com/projectdiscovery/uncover v1.2.1
@@ -427,11 +427,7 @@ require (
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/ysmood/fetchup v0.2.3 // indirect
github.com/ysmood/goob v0.4.0 // indirect
github.com/ysmood/got v0.40.0 // indirect
github.com/ysmood/gson v0.7.3 // indirect
github.com/ysmood/leakless v0.9.0 // indirect
github.com/yuin/goldmark v1.7.17 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
+6 -12
View File
@@ -493,8 +493,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
@@ -985,6 +983,8 @@ github.com/projectdiscovery/retryabledns v1.0.116 h1:tK28PJ4tLO7bgM89RpIFjkfTPMS
github.com/projectdiscovery/retryabledns v1.0.116/go.mod h1:r3vH0AKYtJ9fculcqRFrdVYe2ABcHwZeR4C6j3rqCbY=
github.com/projectdiscovery/retryablehttp-go v1.3.31 h1:0hSEZ5+jj6Oo1IL8qrytD36TK/qCxfe543jEaRqSTjs=
github.com/projectdiscovery/retryablehttp-go v1.3.31/go.mod h1:/WEa7YejxTCXEMLCGfGYaC3JsgXgzuqw8rv8cb4buEc=
github.com/projectdiscovery/rod v0.116.3-0.20261113214723-dd0abeca2a24 h1:Hot/bpOreHagjzK10d939dAzEuiDsbScyz/JQ4OTolA=
github.com/projectdiscovery/rod v0.116.3-0.20261113214723-dd0abeca2a24/go.mod h1:FLVLMgXw6OFcOU5nd4Il8cdSWgDfAlDeRZMol3drmGs=
github.com/projectdiscovery/sarif v0.1.0 h1:O541T+a448nSJsmIMnXXSOeDQEzpnCAYvRfe0eG5h74=
github.com/projectdiscovery/sarif v0.1.0/go.mod h1:LBC+reM3bkI3qIIhE0rZaINaYX6VG+En6u2hHa5mA7E=
github.com/projectdiscovery/stringsutil v0.0.2 h1:uzmw3IVLJSMW1kEg8eCStG/cGbYYZAja8BH3LqqJXMA=
@@ -1212,20 +1212,14 @@ github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+Seva
github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
github.com/ysmood/gop v0.4.2 h1:KtW1/RugapWs1iW9ynQdk52BK6TWdP9zC0qDOhW8EDE=
github.com/ysmood/gop v0.4.2/go.mod h1:K4LAB/BdLArknXrVSy+DyXcfb8P85JwAtwYM8zlb0mU=
github.com/ysmood/got v0.43.0 h1:bcHlKg+qnhFz5JxNZ+j4fIfxxB8WWjTh6qdqeS8FfZw=
github.com/ysmood/got v0.43.0/go.mod h1:rsfH2KJ5rcS/5n9pmybIGodoHlT/NTYxjgh3ob7UdSU=
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+18 -5
View File
@@ -558,18 +558,31 @@ func getBoolEnvValue(key string) bool {
// validatePlanOptions rejects options that change the templates or targets
// only once scanning starts, which a plan written beforehand cannot show.
func validatePlanOptions(options *types.Options) error {
if options.PlanFile == "" {
if options.RunPlanFile != "" && options.PlanFile != "" {
return errors.New("-plan cannot be used with -run-plan")
}
if options.RunPlanFile != "" && options.Resume != "" {
return errors.New("-run-plan cannot be used with -resume, the plan's done log resumes it")
}
if options.PlanFile == "" && options.RunPlanFile == "" {
return nil
}
// -automatic-scan builds its own engine, so a plan set on the main engine
// would never be consulted. The other flags change the targets only after
// a plan has been written.
flag := "-plan"
if options.PlanFile == "" {
flag = "-run-plan"
}
switch {
case options.AutomaticScan:
return errors.New("-plan cannot be used with -automatic-scan, which picks templates while scanning")
return fmt.Errorf("%s cannot be used with -automatic-scan, which picks templates while scanning", flag)
case options.Uncover || len(options.UncoverQuery) > 0:
return errors.New("-plan cannot be used with -uncover, which finds targets over the network")
return fmt.Errorf("%s cannot be used with -uncover, which finds targets over the network", flag)
case options.PreflightPortScan:
return errors.New("-plan cannot be used with -preflight-portscan, which drops targets after scanning their ports")
return fmt.Errorf("%s cannot be used with -preflight-portscan, which drops targets after scanning their ports", flag)
case options.DASTServer || options.DASTProxy:
return errors.New("-plan cannot be used with -dast-server or -dast-proxy, which start a server instead of writing a plan")
return fmt.Errorf("%s cannot be used with -dast-server or -dast-proxy, which start a server instead of writing a plan", flag)
}
return nil
}
+15
View File
@@ -23,4 +23,19 @@ func TestPlanRejectsOptionsThatChangeTargetsLater(t *testing.T) {
require.NoError(t, validatePlanOptions(&types.Options{Uncover: true}), "only -plan restricts them")
require.NoError(t, validatePlanOptions(&types.Options{UncoverQuery: goflags.StringSlice{"example"}}))
require.NoError(t, validatePlanOptions(&types.Options{DASTServer: true}))
require.Error(t, validatePlanOptions(&types.Options{RunPlanFile: "p", PlanFile: "q"}))
require.Error(t, validatePlanOptions(&types.Options{RunPlanFile: "p", Resume: "r"}))
require.NoError(t, validatePlanOptions(&types.Options{RunPlanFile: "p"}))
for name, options := range map[string]*types.Options{
"run automatic scan": {RunPlanFile: "p", AutomaticScan: true},
"run uncover": {RunPlanFile: "p", Uncover: true},
"run uncover query": {RunPlanFile: "p", UncoverQuery: goflags.StringSlice{"example"}},
"run preflight": {RunPlanFile: "p", PreflightPortScan: true},
"run dast server": {RunPlanFile: "p", DASTServer: true},
"run dast proxy": {RunPlanFile: "p", DASTProxy: true},
} {
require.Error(t, validatePlanOptions(options), name)
}
}
+215
View File
@@ -0,0 +1,215 @@
package runner
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"strconv"
"sync"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
)
// planRun limits a scan to the entries of a plan file and appends the id of
// each finished entry to the plan's done log, so running the plan again skips
// them. Ids are derived from what an entry runs, not from its position, so the
// log still applies after the plan is written again for changed targets or
// templates.
type planRun struct {
mu sync.Mutex
entries map[uint64]plannedEntry
// done holds finished origin groups by the id of their first entry; an
// entry outside a group is its own group
done map[uint64]struct{}
log *os.File
logErr error
// resumed counts the entries finished before this run
resumed int
}
type plannedEntry struct {
group uint64
seen bool
}
func planDoneLog(planFile string) string {
return planFile + ".done"
}
func openPlanRun(planFile string) (*planRun, error) {
run := &planRun{entries: make(map[uint64]plannedEntry), done: make(map[uint64]struct{})}
if err := run.readPlan(planFile); err != nil {
return nil, err
}
if err := run.readDoneLog(planDoneLog(planFile)); err != nil {
return nil, err
}
log, err := os.OpenFile(planDoneLog(planFile), os.O_CREATE|os.O_RDWR|os.O_APPEND, 0o644)
if err != nil {
return nil, err
}
// an interrupted write leaves a partial line, which the next id must not
// extend
if info, err := log.Stat(); err == nil && info.Size() > 0 {
last := make([]byte, 1)
if _, err := log.ReadAt(last, info.Size()-1); err == nil && last[0] != '\n' {
_, _ = log.WriteString("\n")
}
}
run.log = log
return run, nil
}
func (run *planRun) readPlan(planFile string) error {
file, err := os.Open(planFile)
if err != nil {
return err
}
defer func() { _ = file.Close() }()
reader := bufio.NewReader(file)
for number := 1; ; number++ {
line, readErr := reader.ReadBytes('\n')
if line = bytes.TrimSpace(line); len(line) > 0 {
var entry struct {
ID string `json:"id"`
AlternativeTo string `json:"alternative-to"`
}
if err := json.Unmarshal(line, &entry); err != nil {
return fmt.Errorf("%s:%d: %w", planFile, number, err)
}
// the summary line has no id
if entry.ID != "" {
id, ok := parsePlanID(entry.ID)
if !ok {
return fmt.Errorf("%s:%d: invalid id %q", planFile, number, entry.ID)
}
group := id
if entry.AlternativeTo != "" {
if group, ok = parsePlanID(entry.AlternativeTo); !ok {
return fmt.Errorf("%s:%d: invalid alternative-to %q", planFile, number, entry.AlternativeTo)
}
}
run.entries[id] = plannedEntry{group: group}
}
}
if readErr == io.EOF {
return nil
}
if readErr != nil {
return readErr
}
}
}
func (run *planRun) readDoneLog(path string) error {
file, err := os.Open(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
defer func() { _ = file.Close() }()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
// a partial line from an interrupted write does not parse and is
// run again
id, ok := parsePlanID(scanner.Text())
if !ok {
continue
}
if entry, planned := run.entries[id]; planned {
run.done[entry.group] = struct{}{}
}
}
if err := scanner.Err(); err != nil {
return err
}
for _, entry := range run.entries {
if _, done := run.done[entry.group]; done {
run.resumed++
}
}
return nil
}
// parsePlanID keeps ids as integers, which a plan of millions of entries
// holds in a fraction of the memory of strings.
func parsePlanID(id string) (uint64, bool) {
if len(id) != 16 {
return 0, false
}
value, err := strconv.ParseUint(id, 16, 64)
return value, err == nil
}
func (run *planRun) Skip(id string) bool {
key, ok := parsePlanID(id)
if !ok {
return true
}
run.mu.Lock()
defer run.mu.Unlock()
entry, planned := run.entries[key]
if !planned {
return true
}
if !entry.seen {
entry.seen = true
run.entries[key] = entry
}
_, done := run.done[entry.group]
return done
}
func (run *planRun) Finished(id string) {
key, ok := parsePlanID(id)
if !ok {
return
}
run.mu.Lock()
defer run.mu.Unlock()
entry, planned := run.entries[key]
if !planned {
return
}
run.done[entry.group] = struct{}{}
// written straight to the file: ctrl+c exits without running defers
if _, err := run.log.WriteString(id + "\n"); err != nil && run.logErr == nil {
run.logErr = err
}
}
// unreached counts unfinished entries the scan did not get to: those of a
// host it found unresponsive, and those it does not have because it runs
// with other templates, targets or options than the plan.
func (run *planRun) unreached() int {
run.mu.Lock()
defer run.mu.Unlock()
count := 0
for _, entry := range run.entries {
if _, done := run.done[entry.group]; !done && !entry.seen {
count++
}
}
return count
}
func (run *planRun) close() error {
if err := run.log.Close(); err != nil && run.logErr == nil {
run.logErr = err
}
return run.logErr
}
// finishPlanRun closes the done log and reports the entries left to run.
func (r *Runner) finishPlanRun(run *planRun) {
if err := run.close(); err != nil {
r.Logger.Error().Msgf("Could not write %s: %s", planDoneLog(r.options.RunPlanFile), err)
}
if unreached := run.unreached(); unreached > 0 {
r.Logger.Info().Msgf("%d plan entries did not run; running the plan again retries them. Entries run only when the scan has the templates, targets and options the plan was written with", unreached)
}
}
+52
View File
@@ -0,0 +1,52 @@
package runner
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestPlanRunResumesFromItsDoneLog(t *testing.T) {
planFile := filepath.Join(t.TempDir(), "plan.jsonl")
require.NoError(t, os.WriteFile(planFile, []byte(`{"id":"00000000000000a1","target":"https://a.example/x","templates":["root"]}
{"id":"00000000000000a2","target":"https://a.example/y","templates":["root"],"alternative-to":"00000000000000a1"}
{"id":"00000000000000b1","target":"https://b.example","templates":["generic"]}
{"id":"00000000000000c1","target":"https://c.example","templates":["generic"]}
{"summary":{"entries":3,"alternatives":1}}
`), 0o644))
// the last line was cut off by an interrupted write
require.NoError(t, os.WriteFile(planDoneLog(planFile), []byte("00000000000000b1\n00000000"), 0o644))
run, err := openPlanRun(planFile)
require.NoError(t, err)
require.Len(t, run.entries, 4)
require.Equal(t, 1, run.resumed)
require.True(t, run.Skip("00000000000000b1"), "finished in the previous run")
require.True(t, run.Skip("00000000000000ff"), "not in the plan")
require.False(t, run.Skip("00000000000000a1"))
run.Finished("00000000000000a1")
require.True(t, run.Skip("00000000000000a2"), "its origin is covered")
require.Equal(t, 1, run.unreached(), "c1 never ran")
require.NoError(t, run.close())
log, err := os.ReadFile(planDoneLog(planFile))
require.NoError(t, err)
require.Equal(t, "00000000000000b1\n00000000\n00000000000000a1\n", string(log), "the partial line is not extended")
again, err := openPlanRun(planFile)
require.NoError(t, err)
require.Equal(t, 3, again.resumed, "b1, and a1 with its alternative")
require.True(t, again.Skip("00000000000000a2"), "a finished origin stays covered")
require.False(t, again.Skip("00000000000000c1"))
require.NoError(t, again.close())
}
func TestPlanRunRejectsAMalformedPlan(t *testing.T) {
planFile := filepath.Join(t.TempDir(), "plan.jsonl")
require.NoError(t, os.WriteFile(planFile, []byte("{\"id\":\"00000000000000a1\"}\nnot json\n"), 0o644))
_, err := openPlanRun(planFile)
require.ErrorContains(t, err, "plan.jsonl:2")
}
+9
View File
@@ -855,6 +855,15 @@ func (r *Runner) RunEnumeration() error {
}
}()
}
if r.options.RunPlanFile != "" {
run, err := openPlanRun(r.options.RunPlanFile)
if err != nil {
return errors.Wrap(err, "could not read plan")
}
r.Logger.Info().Msgf("Running %d plan entries from %s, %d already finished", len(run.entries), r.options.RunPlanFile, run.resumed)
executorEngine.SetPlanRun(run)
defer r.finishPlanRun(run)
}
// TODO: remove below functions after v3 or update warning messages
templates.PrintDeprecatedProtocolNameMsgIfApplicable(r.options.Silent, r.options.Verbose)
+1
View File
@@ -51,6 +51,7 @@ type Engine struct {
executerOpts *protocols.ExecutorOptions
Callback func(*output.ResultEvent) // Executed on results
templateExecutionCallback TemplateExecutionCallback
planRun PlanRun
Logger *gologger.Logger
}
+17 -1
View File
@@ -36,9 +36,13 @@ func (e *Engine) executeAllSelfContained(ctx context.Context, alltemplates []*te
if sharedBudget {
defer e.options.ReleaseTemplateThread()
}
defer e.tasksDone(1)
planned, skip := e.claimPlanned(template, &contextargs.MetaInput{})
if skip {
return
}
finished := e.templateExecutionStarted(template, "")
defer func() { finished(ctx.Err()) }()
defer e.tasksDone(1)
var err error
var match bool
ctx := scan.NewScanContext(ctx, contextargs.New(ctx))
@@ -55,6 +59,8 @@ func (e *Engine) executeAllSelfContained(ctx context.Context, alltemplates []*te
}
if err != nil {
e.options.Logger.Warning().Msgf("[%s] Could not execute step (self-contained): %s\n", e.executerOpts.Colorizer.BrightBlue(template.ID), err)
} else if ctx.Context().Err() == nil {
planned()
}
results.CompareAndSwap(false, match)
}(v, usesSharedTemplateBudget)
@@ -330,6 +336,16 @@ func (e *Engine) tasksDone(count int64) {
func (e *Engine) executeTemplateOnInput(ctx context.Context, template *templates.Template, value *contextargs.MetaInput) (matched bool, err error) {
// a pair skipped as a repeat of its origin is done as well
defer e.tasksDone(1)
planned, skip := e.claimPlanned(template, value)
if skip {
return false, nil
}
// an origin a sibling already covered is settled too, so it is recorded
defer func() {
if err == nil && ctx.Err() == nil {
planned()
}
}()
// A template fixed to the target's origin makes the same requests for every
// target that shares one, which a crawled list produces by the hundred.
// Running it once per origin sends exactly the same traffic, minus the
+43 -2
View File
@@ -104,7 +104,7 @@ func planEntry(template *templates.Template, value *contextargs.MetaInput, selec
entry := PlanEntry{
Target: value.Input,
IP: value.CustomIP,
Templates: []string{template.ID},
Templates: planTemplates(template, selection),
Protocol: template.Type().String(),
Requests: template.TotalRequests,
Dynamic: template.Flow != "" || template.IsFuzzableRequest(),
@@ -126,7 +126,6 @@ func planEntry(template *templates.Template, value *contextargs.MetaInput, selec
entry.Dynamic = true
case isCluster(template):
cluster := template.Executer.(*templates.ClusterExecuter)
entry.Templates = slices.Sorted(slices.Values(cluster.TemplateIDs(selection)))
// TotalRequests of a cluster counts only its http and dns requests,
// so an ssl cluster would show none
entry.Requests = cluster.Requests()
@@ -143,6 +142,15 @@ func planEntry(template *templates.Template, value *contextargs.MetaInput, selec
return entry
}
// planTemplates lists the templates an entry runs, sorted so the entry id does
// not depend on clustering order.
func planTemplates(template *templates.Template, selection protocols.TemplateSelection) []string {
if cluster, ok := template.Executer.(*templates.ClusterExecuter); ok {
return slices.Sorted(slices.Values(cluster.TemplateIDs(selection)))
}
return []string{template.ID}
}
func isCluster(template *templates.Template) bool {
_, ok := template.Executer.(*templates.ClusterExecuter)
return ok
@@ -154,3 +162,36 @@ func planEntryID(entry PlanEntry) string {
sum := sha256.Sum256([]byte(entry.Target + "\x00" + entry.IP + "\x00" + strings.Join(entry.Templates, "\x00")))
return hex.EncodeToString(sum[:8])
}
// PlanRun limits a scan to the entries of a plan and records the ones that
// finish, so running the plan again resumes it.
type PlanRun interface {
// Skip reports whether the entry is not in the plan or already finished.
Skip(id string) bool
// Finished records an entry that ran without error.
Finished(id string)
}
// SetPlanRun makes the scan run only the entries run allows.
func (e *Engine) SetPlanRun(run PlanRun) {
e.planRun = run
}
// claimPlanned reports whether the pair is skipped under a plan run, and
// otherwise returns the func that records it finished. Without a plan run
// every pair runs.
func (e *Engine) claimPlanned(template *templates.Template, value *contextargs.MetaInput) (finished func(), skip bool) {
if e.planRun == nil {
return func() {}, false
}
// as in Plan, a self-contained template is not narrowed by any target
var selection protocols.TemplateSelection
if !template.SelfContained {
selection = e.selectionFor(value)
}
id := planEntryID(PlanEntry{Target: value.Input, IP: value.CustomIP, Templates: planTemplates(template, selection)})
if e.planRun.Skip(id) {
return nil, true
}
return func() { e.planRun.Finished(id) }, false
}
+59
View File
@@ -154,3 +154,62 @@ func TestPlanListsOriginAlternatives(t *testing.T) {
})
require.Equal(t, plan, again)
}
// fixedPlanRun is a plan run over fixed ids that records the finished ones.
type fixedPlanRun struct {
mu sync.Mutex
allowed map[string]bool
finished []string
}
func (a *fixedPlanRun) Skip(id string) bool { return !a.allowed[id] }
func (a *fixedPlanRun) Finished(id string) {
a.mu.Lock()
defer a.mu.Unlock()
a.finished = append(a.finished, id)
}
func TestPlanRunRunsOnlyThePlannedPairs(t *testing.T) {
options := &types.Options{BulkSize: 2, TemplateThreads: 2, HeadlessBulkSize: 1, HeadlessTemplateThreads: 1}
engine := New(options)
engine.SetExecuterOptions(&protocols.ExecutorOptions{
Logger: engine.Logger,
Options: options,
ResumeCfg: types.NewResumeCfg(),
ProtocolType: tmpltypes.HTTPProtocol,
Progress: &progressRecorder{},
TargetScope: scopeByInput{"php": {paths: []string{"thinkphp.yaml"}}},
})
var (
mu sync.Mutex
runs []string
)
var templatesList []*templates.Template
for _, path := range []string{"thinkphp.yaml", "generic.yaml"} {
templatesList = append(templatesList, &templates.Template{
ID: path,
Path: path,
TotalRequests: 1,
Executer: &recordingExecuter{path: path, mu: &mu, runs: &runs},
})
}
targets := &fakeTargetProvider{values: []*contextargs.MetaInput{{Input: "php"}, {Input: "plain"}}}
var plan []PlanEntry
engine.Plan(templatesList, targets, true, func(entry PlanEntry) { plan = append(plan, entry) })
require.Len(t, plan, 3)
// the first entry is left out of the plan, as if an agent removed it
run := &fixedPlanRun{allowed: map[string]bool{plan[1].ID: true, plan[2].ID: true}}
engine.SetPlanRun(run)
engine.ExecuteScanWithOpts(context.Background(), templatesList, targets, true)
var want []string
for _, entry := range plan[1:] {
want = append(want, entry.Templates[0]+"@"+entry.Target)
}
require.ElementsMatch(t, want, runs, "only the planned pairs run")
require.ElementsMatch(t, []string{plan[1].ID, plan[2].ID}, run.finished, "the scan derives the ids the plan wrote")
}
@@ -7,10 +7,10 @@ import (
"strings"
"sync"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
"github.com/projectdiscovery/networkpolicy"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/rod"
"github.com/projectdiscovery/rod/lib/proto"
"github.com/projectdiscovery/utils/errkit"
stringsutil "github.com/projectdiscovery/utils/strings"
urlutil "github.com/projectdiscovery/utils/url"
@@ -15,7 +15,7 @@ func TestMemGuardianGoroutineLeak(t *testing.T) {
defer goleak.VerifyNone(t,
goleak.IgnoreAnyContainingPkg("go.opencensus.io/stats/view"),
goleak.IgnoreAnyContainingPkg("github.com/syndtr/goleveldb"),
goleak.IgnoreAnyContainingPkg("github.com/go-rod/rod"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/rod"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/interactsh/pkg/server"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/ratelimit"),
// expirable LRU cache creates a background goroutine for TTL expiration that persists
@@ -104,7 +104,7 @@ func TestMemGuardianReset(t *testing.T) {
defer goleak.VerifyNone(t,
goleak.IgnoreAnyContainingPkg("go.opencensus.io/stats/view"),
goleak.IgnoreAnyContainingPkg("github.com/syndtr/goleveldb"),
goleak.IgnoreAnyContainingPkg("github.com/go-rod/rod"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/rod"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/interactsh/pkg/server"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/ratelimit"),
// expirable LRU cache creates a background goroutine for TTL expiration that persists
+3 -3
View File
@@ -7,10 +7,10 @@ import (
"strings"
"sync"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/launcher/flags"
"github.com/pkg/errors"
"github.com/projectdiscovery/rod"
"github.com/projectdiscovery/rod/lib/launcher"
"github.com/projectdiscovery/rod/lib/launcher/flags"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
+2 -2
View File
@@ -5,8 +5,8 @@ package engine
import (
"encoding/base64"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
"github.com/projectdiscovery/rod"
"github.com/projectdiscovery/rod/lib/proto"
)
// NewHijack create hijack from page.
+2 -2
View File
@@ -5,11 +5,11 @@ import (
"errors"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/utils"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/render"
"github.com/projectdiscovery/rod"
"github.com/projectdiscovery/rod/lib/utils"
)
// Instance is an isolated browser instance opened for doing operations with it.
@@ -5,9 +5,9 @@ import (
"testing"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/cdp"
"github.com/go-rod/rod/lib/proto"
"github.com/projectdiscovery/rod"
"github.com/projectdiscovery/rod/lib/cdp"
"github.com/projectdiscovery/rod/lib/proto"
"github.com/stretchr/testify/require"
)
+2 -2
View File
@@ -11,8 +11,6 @@ import (
"sync"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/generators"
@@ -21,6 +19,8 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/utils"
httputil "github.com/projectdiscovery/nuclei/v3/pkg/protocols/utils/http"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/rod"
"github.com/projectdiscovery/rod/lib/proto"
"github.com/projectdiscovery/utils/errkit"
urlutil "github.com/projectdiscovery/utils/url"
)
@@ -11,10 +11,6 @@ import (
"sync"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/input"
"github.com/go-rod/rod/lib/proto"
"github.com/go-rod/rod/lib/utils"
"github.com/kitabisa/go-ci"
"github.com/pkg/errors"
"github.com/projectdiscovery/gologger"
@@ -24,6 +20,10 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/render"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
filepathutil "github.com/projectdiscovery/nuclei/v3/pkg/utils/filepath"
"github.com/projectdiscovery/rod"
"github.com/projectdiscovery/rod/lib/input"
"github.com/projectdiscovery/rod/lib/proto"
"github.com/projectdiscovery/rod/lib/utils"
contextutil "github.com/projectdiscovery/utils/context"
"github.com/projectdiscovery/utils/errkit"
fileutil "github.com/projectdiscovery/utils/file"
+3 -3
View File
@@ -11,12 +11,12 @@ import (
"testing"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/cdp"
"github.com/go-rod/rod/lib/proto"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/rod"
"github.com/projectdiscovery/rod/lib/cdp"
"github.com/projectdiscovery/rod/lib/proto"
"github.com/stretchr/testify/require"
)
+3 -3
View File
@@ -10,12 +10,12 @@ import (
"testing"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
"github.com/projectdiscovery/networkpolicy"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/rod"
"github.com/projectdiscovery/rod/lib/launcher"
"github.com/projectdiscovery/rod/lib/proto"
"github.com/stretchr/testify/require"
)
+2 -2
View File
@@ -6,10 +6,10 @@ import (
"net/http/httputil"
"strings"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/rod"
"github.com/projectdiscovery/rod/lib/proto"
)
// routingRuleHandler handles proxy rule for actions related to request/response modification
@@ -6,7 +6,7 @@ import (
"runtime"
"testing"
"github.com/go-rod/rod/lib/launcher/flags"
"github.com/projectdiscovery/rod/lib/launcher/flags"
"github.com/stretchr/testify/require"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
+1 -1
View File
@@ -1400,7 +1400,7 @@ func TestExecuteParallelHTTP_GoroutineLeaks(t *testing.T) {
defer goleak.VerifyNone(t,
goleak.IgnoreAnyContainingPkg("go.opencensus.io/stats/view"),
goleak.IgnoreAnyContainingPkg("github.com/syndtr/goleveldb"),
goleak.IgnoreAnyContainingPkg("github.com/go-rod/rod"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/rod"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/interactsh/pkg/server"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/interactsh/pkg/client"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/ratelimit"),
+4
View File
@@ -254,6 +254,9 @@ type Options struct {
// PlanFile is where the work a scan would do is written as JSON lines;
// nuclei exits after writing it, without sending requests.
PlanFile string
// RunPlanFile is a plan whose entries the scan runs, skipping those its
// done log lists as finished.
RunPlanFile string
// StoreEvents is where the scan writes the event of every request, as
// JSON lines, for -replay.
StoreEvents string
@@ -721,6 +724,7 @@ func (options *Options) Copy() *Options {
Silent: options.Silent,
Validate: options.Validate,
PlanFile: options.PlanFile,
RunPlanFile: options.RunPlanFile,
StoreEvents: options.StoreEvents,
ReplayFile: options.ReplayFile,
NoStrictSyntax: options.NoStrictSyntax,
+4 -1
View File
@@ -18,10 +18,13 @@ func TestCopyPreservesPayloadRandomOrder(t *testing.T) {
}
func TestCopyPreservesPlanFile(t *testing.T) {
options := &Options{PlanFile: "plan.jsonl"}
options := &Options{PlanFile: "plan.jsonl", RunPlanFile: "run.jsonl"}
if options.Copy().PlanFile != "plan.jsonl" {
t.Fatal("Copy must preserve PlanFile, or an embedded caller loses plan mode")
}
if options.Copy().RunPlanFile != "run.jsonl" {
t.Fatal("Copy must preserve RunPlanFile, or an embedded caller runs the whole scan")
}
}
func TestToJSONMapRejectsKeysSharingAStringForm(t *testing.T) {