Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 6 additions & 238 deletions cmd/nuclei/main.go

Large diffs are not rendered by default.

266 changes: 266 additions & 0 deletions internal/runner/flags.go

Large diffs are not rendered by default.

36 changes: 27 additions & 9 deletions internal/runner/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package runner

import (
"bufio"
"bytes"
"fmt"
"io/fs"
"os"
Expand Down Expand Up @@ -298,21 +299,39 @@ func validateDASTOptions(options *types.Options) error {
return nil
}

// LoadReportingOptionsFromBytes parses YAML reporting-config bytes into a
// *reporting.Options with env-var expansion, matching the CLI's -report-config.
func LoadReportingOptionsFromBytes(data []byte) (*reporting.Options, error) {
reportingOptions := &reporting.Options{}
if err := yaml.DecodeAndValidate(bytes.NewReader(data), reportingOptions); err != nil {
return nil, errors.Wrap(err, "could not parse reporting config file")
}
Walk(reportingOptions, expandEndVars)
return reportingOptions, nil
}

func createReportingOptions(options *types.Options) (*reporting.Options, error) {
var reportingOptions = &reporting.Options{}
if options.ReportingConfig != "" {
file, err := os.Open(options.ReportingConfig)
data, err := os.ReadFile(options.ReportingConfig)
if err != nil {
return nil, errors.Wrap(err, "could not open reporting config file")
}
defer func() {
_ = file.Close()
}()

if err := yaml.DecodeAndValidate(file, reportingOptions); err != nil {
return nil, errors.Wrap(err, "could not parse reporting config file")
reportingOptions, err = LoadReportingOptionsFromBytes(data)
if err != nil {
return nil, err
}
Walk(reportingOptions, expandEndVars)
}
ApplyExporterOptionsFromTypes(reportingOptions, options)
return reportingOptions, nil
}

// ApplyExporterOptionsFromTypes wires exporter fields from *types.Options
// (markdown/sarif/json/jsonl/pdf-export, omit-raw, sort-mode) onto an existing
// *reporting.Options. No-op when no exporter fields are set.
func ApplyExporterOptionsFromTypes(reportingOptions *reporting.Options, options *types.Options) {
if reportingOptions == nil {
return
}
if options.MarkdownExportDirectory != "" {
reportingOptions.MarkdownExporter = &markdown.Options{
Expand Down Expand Up @@ -357,7 +376,6 @@ func createReportingOptions(options *types.Options) (*reporting.Options, error)

reportingOptions.OmitRaw = options.OmitRawRequests
reportingOptions.ExecutionId = options.ExecutionId
return reportingOptions, nil
}

// configureOutput configures the output logging levels to be displayed on the screen
Expand Down
48 changes: 25 additions & 23 deletions internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/automaticscan"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/globalmatchers"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/honeypotdetector"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/hosterrorscache"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolinit"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/honeypotdetector"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/uncover"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/utils/excludematchers"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/headless/engine"
Expand Down Expand Up @@ -287,7 +287,9 @@ func New(options *types.Options) (*Runner, error) {
runner.honeypotDetector = hpDetector
}
// setup a proxy writer to automatically upload results to PDCP
runner.output = runner.setupPDCPUpload(outputWriter)
wrapped, pdcpMsg := SetupPDCPUpload(context.Background(), runner.Logger, runner.options, outputWriter)
runner.output = wrapped
runner.pdcpUploadErrMsg = pdcpMsg
if options.HTTPStats {
runner.httpStats = outputstats.NewTracker()
runner.output = output.NewMultiWriter(runner.output, output.NewTrackerWriter(runner.httpStats))
Expand Down Expand Up @@ -466,42 +468,42 @@ func (r *Runner) Close() {
events.Close()
}

// setupPDCPUpload sets up the PDCP upload writer
// by creating a new writer and returning it
func (r *Runner) setupPDCPUpload(writer output.Writer) output.Writer {
// SetupPDCPUpload wraps writer with the PDCP upload writer when cloud upload
// is enabled (implicitly enabled when opts.ScanID is set). On disable or
// failure it returns the original writer plus a user-facing status message.
// ctx controls the upload writer lifetime; pass context.Background() to
// outlive any per-scan context.
func SetupPDCPUpload(ctx context.Context, logger *gologger.Logger, opts *types.Options, writer output.Writer) (output.Writer, string) {
// if scanid is given implicitly consider that scan upload is enabled
if r.options.ScanID != "" {
r.options.EnableCloudUpload = true
if opts.ScanID != "" {
opts.EnableCloudUpload = true
}
if !r.options.EnableCloudUpload && !EnableCloudUpload {
r.pdcpUploadErrMsg = "Scan results upload to cloud is disabled."
return writer
if !opts.EnableCloudUpload && !EnableCloudUpload {
return writer, "Scan results upload to cloud is disabled."
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
h := &pdcpauth.PDCPCredHandler{}
creds, err := h.GetCreds()
if err != nil {
if err != pdcpauth.ErrNoCreds && !HideAutoSaveMsg {
r.Logger.Verbose().Msgf("Could not get credentials for cloud upload: %s\n", err)
logger.Verbose().Msgf("Could not get credentials for cloud upload: %s\n", err)
}
r.pdcpUploadErrMsg = fmt.Sprintf("To view results on Cloud Dashboard, configure API key from %v", pdcpauth.DashBoardURL)
return writer
return writer, fmt.Sprintf("To view results on Cloud Dashboard, configure API key from %v", pdcpauth.DashBoardURL)
}
uploadWriter, err := pdcp.NewUploadWriter(context.Background(), r.Logger, creds)
uploadWriter, err := pdcp.NewUploadWriter(ctx, logger, creds)
if err != nil {
r.pdcpUploadErrMsg = fmt.Sprintf("PDCP (%v) Auto-Save Failed: %s\n", pdcpauth.DashBoardURL, err)
return writer
return writer, fmt.Sprintf("PDCP (%v) Auto-Save Failed: %s\n", pdcpauth.DashBoardURL, err)
}
if r.options.ScanID != "" {
if opts.ScanID != "" {
// ignore and use empty scan id if invalid
_ = uploadWriter.SetScanID(r.options.ScanID)
_ = uploadWriter.SetScanID(opts.ScanID)
}
Comment thread
ShubhamRasal marked this conversation as resolved.
Outdated
if r.options.ScanName != "" {
uploadWriter.SetScanName(r.options.ScanName)
if opts.ScanName != "" {
uploadWriter.SetScanName(opts.ScanName)
}
if r.options.TeamID != "" {
uploadWriter.SetTeamID(r.options.TeamID)
if opts.TeamID != "" {
uploadWriter.SetTeamID(opts.TeamID)
}
return output.NewMultiWriter(writer, uploadWriter)
return output.NewMultiWriter(writer, uploadWriter), ""
}

// RunEnumeration sets up the input layer for giving input nuclei.
Expand Down
92 changes: 92 additions & 0 deletions lib/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/projectdiscovery/goflags"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/internal/runner"
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
"github.com/projectdiscovery/utils/errkit"

Expand Down Expand Up @@ -556,6 +557,97 @@ func WithOptions(opts *pkgtypes.Options) NucleiSDKOptions {
}
}

// WithPDCPUpload uploads findings to the PDCP dashboard, matching the CLI's
// `-dashboard -scan-id -team-id`. Credentials come from PDCP_API_KEY or
// ~/.config/nuclei/.pdcp/credentials.yaml; missing creds log a warning and
// scans continue. A non-empty scanID implicitly enables upload.
func WithPDCPUpload(scanID, teamID string) NucleiSDKOptions {
return func(e *NucleiEngine) error {
e.opts.EnableCloudUpload = true
if scanID != "" {
e.opts.ScanID = scanID
}
if teamID != "" {
e.opts.TeamID = teamID
}
return nil
}
}

// WithConfigFile loads a nuclei -config style YAML into the engine options.
// Only fields the YAML explicitly sets are written; other fields keep prior
// values. Apply With* AFTER WithConfigFile to override YAML values.
//
// Limitation: YAML keys set to the goflags default value are indistinguishable
// from "unset" by the diff and are silently dropped. Use the explicit With*
// option when this matters.
//
// If the YAML sets `report-config: <path>`, that file is also loaded into the
// reporting options unless WithReportingConfig* was already used.
func WithConfigFile(path string) NucleiSDKOptions {
return func(e *NucleiEngine) error {
if err := overlayConfigFromFile(e.opts, path); err != nil {
return errkit.Wrap(err, "could not merge nuclei config file")
}
return loadImplicitReportingConfig(e)
}
}

// WithConfigBytes is WithConfigFile from memory. Same merge semantics and the
// same flag-default silent-drop limitation apply.
//
// Spills bytes to a 0600 temp file (goflags has no in-memory merge API). If
// the YAML carries secrets, the disk-spill window is short but non-zero.
func WithConfigBytes(data []byte) NucleiSDKOptions {
return func(e *NucleiEngine) error {
// goflags has no in-memory merge; tmp-file fallback.
tmp, err := os.CreateTemp("", "nuclei-sdk-config-*.yaml")
if err != nil {
return errkit.Wrap(err, "could not create temp file for nuclei config bytes")
}
tmpPath := tmp.Name()
defer func() { _ = os.Remove(tmpPath) }()
// CreateTemp defaults to 0600 on Unix but not Windows; enforce explicitly.
if err := os.Chmod(tmpPath, 0o600); err != nil {
_ = tmp.Close()
return errkit.Wrap(err, "could not restrict permissions on temp file for nuclei config bytes")
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return errkit.Wrap(err, "could not write nuclei config bytes to temp file")
}
if err := tmp.Close(); err != nil {
return errkit.Wrap(err, "could not close temp file for nuclei config bytes")
}
if err := overlayConfigFromFile(e.opts, tmpPath); err != nil {
return errkit.Wrap(err, "could not merge nuclei config bytes")
}
return loadImplicitReportingConfig(e)
}
}

// WithReportingConfigFile loads a nuclei -report-config style YAML file
// (Jira/Linear/GitHub/etc. tracker configuration) into the engine's
// reporting options. Equivalent to -report-config <path> on the CLI.
func WithReportingConfigFile(path string) NucleiSDKOptions {
return func(e *NucleiEngine) error {
return loadReportingConfigFromPath(e, path)
}
}

// WithReportingConfigBytes is WithReportingConfigFile from memory. Passing
// nil/empty produces an empty reporting.Options (no-op).
func WithReportingConfigBytes(data []byte) NucleiSDKOptions {
return func(e *NucleiEngine) error {
ropts, err := runner.LoadReportingOptionsFromBytes(data)
if err != nil {
return errkit.Wrap(err, "could not parse reporting config bytes")
}
e.reportingOpts = ropts
return nil
}
}
Comment thread
ShubhamRasal marked this conversation as resolved.

// WithTemporaryDirectory allows setting a parent directory for SDK-managed temporary files.
// A temporary directory will be created inside the provided directory and cleaned up on engine close.
// If not set, a temporary directory will be automatically created in the system temp location.
Expand Down
86 changes: 86 additions & 0 deletions lib/config_load.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package nuclei

import (
"os"
"reflect"

"github.com/projectdiscovery/goflags"
"github.com/projectdiscovery/nuclei/v3/internal/runner"
pkgtypes "github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/utils/errkit"
)

// loadReportingConfigFromPath reads + parses a -report-config YAML at path
// and stores the result on e.
func loadReportingConfigFromPath(e *NucleiEngine, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return errkit.Wrap(err, "could not open reporting config file")
}
ropts, err := runner.LoadReportingOptionsFromBytes(data)
if err != nil {
return errkit.Wrap(err, "could not parse reporting config file")
}
e.reportingOpts = ropts
return nil
}

// loadImplicitReportingConfig loads the reporting YAML pointed at by
// opts.ReportingConfig, matching the CLI's `report-config:` behaviour. Skipped
// when an explicit WithReportingConfig* already set reportingOpts.
func loadImplicitReportingConfig(e *NucleiEngine) error {
if e.opts.ReportingConfig == "" || e.reportingOpts != nil {
return nil
}
return loadReportingConfigFromPath(e, e.opts.ReportingConfig)
}

// newConfigFlagSet binds the shared flag inventory to opts. Used to build the
// baseline and overlay structs for the reflection-based YAML diff.
func newConfigFlagSet(opts *pkgtypes.Options) *goflags.FlagSet {
fs := goflags.NewFlagSet()
fs.CaseSensitive = true
runner.BindOptionFlags(fs, opts)
return fs
}

// overlayConfigFromFile applies only YAML-set fields from path into dst.
//
// goflags writes flag defaults into the bound pointer at registration, so
// binding directly to dst would clobber existing values. We diff a baseline
// (flag-defaults only) against an overlay (flag-defaults + YAML); fields that
// differ are the ones the YAML touched and get copied into dst.
func overlayConfigFromFile(dst *pkgtypes.Options, path string) error {
baseline := &pkgtypes.Options{}
_ = newConfigFlagSet(baseline)

overlay := &pkgtypes.Options{}
fs := newConfigFlagSet(overlay)
if err := fs.MergeConfigFile(path); err != nil {
return err
}

applyOverlay(dst, baseline, overlay)
return nil
}

// applyOverlay copies any field from overlay into dst where overlay differs
// from baseline. Unexported / non-settable fields are skipped.
func applyOverlay(dst, baseline, overlay *pkgtypes.Options) {
dstV := reflect.ValueOf(dst).Elem()
baseV := reflect.ValueOf(baseline).Elem()
overV := reflect.ValueOf(overlay).Elem()

for i := 0; i < dstV.NumField(); i++ {
df := dstV.Field(i)
if !df.CanSet() {
continue
}
bf := baseV.Field(i)
of := overV.Field(i)
if reflect.DeepEqual(bf.Interface(), of.Interface()) {
continue
}
df.Set(of)
}
}
Loading
Loading