This commit is contained in:
Morten Olsen
2025-12-12 11:10:01 +01:00
commit 277fc459d5
64 changed files with 8625 additions and 0 deletions

239
internal/scanner/scanner.go Normal file
View File

@@ -0,0 +1,239 @@
/*
Copyright 2025.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package scanner
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
nucleiv1alpha1 "github.com/mortenolsen/nuclei-operator/api/v1alpha1"
)
// Scanner defines the interface for executing Nuclei scans
type Scanner interface {
// Scan executes a Nuclei scan against the given targets and returns the results
Scan(ctx context.Context, targets []string, options ScanOptions) (*ScanResult, error)
}
// ScanOptions contains configuration options for a scan
type ScanOptions struct {
// Templates specifies which Nuclei templates to use (paths or tags)
Templates []string
// Severity filters results by minimum severity level
Severity []string
// Timeout is the maximum duration for the scan
Timeout time.Duration
}
// ScanResult contains the results of a completed scan
type ScanResult struct {
// Findings contains all vulnerabilities/issues discovered
Findings []nucleiv1alpha1.Finding
// Summary provides aggregated statistics
Summary nucleiv1alpha1.ScanSummary
// Duration is how long the scan took
Duration time.Duration
}
// NucleiScanner implements the Scanner interface using the Nuclei binary
type NucleiScanner struct {
nucleiBinaryPath string
templatesPath string
}
// Config holds configuration for the NucleiScanner
type Config struct {
// NucleiBinaryPath is the path to the nuclei binary (default: "nuclei")
NucleiBinaryPath string
// TemplatesPath is the path to nuclei templates (default: use nuclei's default)
TemplatesPath string
// DefaultTimeout is the default scan timeout (default: 30m)
DefaultTimeout time.Duration
}
// DefaultConfig returns a Config with default values
func DefaultConfig() Config {
return Config{
NucleiBinaryPath: getEnvOrDefault("NUCLEI_BINARY_PATH", "nuclei"),
TemplatesPath: getEnvOrDefault("NUCLEI_TEMPLATES_PATH", ""),
DefaultTimeout: getEnvDurationOrDefault("NUCLEI_TIMEOUT", 30*time.Minute),
}
}
// NewNucleiScanner creates a new NucleiScanner with the given configuration
func NewNucleiScanner(config Config) *NucleiScanner {
return &NucleiScanner{
nucleiBinaryPath: config.NucleiBinaryPath,
templatesPath: config.TemplatesPath,
}
}
// NewNucleiScannerWithDefaults creates a new NucleiScanner with default configuration
func NewNucleiScannerWithDefaults() *NucleiScanner {
return NewNucleiScanner(DefaultConfig())
}
// Scan executes a Nuclei scan against the given targets
func (s *NucleiScanner) Scan(ctx context.Context, targets []string, options ScanOptions) (*ScanResult, error) {
if len(targets) == 0 {
return nil, fmt.Errorf("no targets provided for scan")
}
startTime := time.Now()
// Create a temporary directory for this scan
tmpDir, err := os.MkdirTemp("", "nuclei-scan-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
// Write targets to a file
targetsFile := filepath.Join(tmpDir, "targets.txt")
if err := os.WriteFile(targetsFile, []byte(strings.Join(targets, "\n")), 0600); err != nil {
return nil, fmt.Errorf("failed to write targets file: %w", err)
}
// Build the nuclei command arguments
args := s.buildArgs(targetsFile, options)
// Set timeout from options or use default
timeout := options.Timeout
if timeout == 0 {
timeout = 30 * time.Minute
}
// Create context with timeout
scanCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Execute nuclei
cmd := exec.CommandContext(scanCtx, s.nucleiBinaryPath, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
duration := time.Since(startTime)
// Check for context cancellation
if scanCtx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("scan timed out after %v", timeout)
}
if scanCtx.Err() == context.Canceled {
return nil, fmt.Errorf("scan was cancelled")
}
// Nuclei returns exit code 0 even when it finds vulnerabilities
// Non-zero exit codes indicate actual errors
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
// Exit code 1 can mean "no results found" which is not an error
if exitErr.ExitCode() != 1 {
return nil, fmt.Errorf("nuclei execution failed: %w, stderr: %s", err, stderr.String())
}
} else {
return nil, fmt.Errorf("failed to execute nuclei: %w", err)
}
}
// Parse the JSONL output
findings, err := ParseJSONLOutput(stdout.Bytes())
if err != nil {
return nil, fmt.Errorf("failed to parse nuclei output: %w", err)
}
// Calculate summary
summary := calculateSummary(findings, len(targets), duration)
return &ScanResult{
Findings: findings,
Summary: summary,
Duration: duration,
}, nil
}
// buildArgs constructs the command line arguments for nuclei
func (s *NucleiScanner) buildArgs(targetsFile string, options ScanOptions) []string {
args := []string{
"-l", targetsFile,
"-jsonl",
"-silent",
"-no-color",
}
// Add templates path if configured
if s.templatesPath != "" {
args = append(args, "-t", s.templatesPath)
}
// Add specific templates if provided
if len(options.Templates) > 0 {
for _, t := range options.Templates {
args = append(args, "-t", t)
}
}
// Add severity filter if provided
if len(options.Severity) > 0 {
args = append(args, "-severity", strings.Join(options.Severity, ","))
}
return args
}
// calculateSummary generates a ScanSummary from the findings
func calculateSummary(findings []nucleiv1alpha1.Finding, targetsCount int, duration time.Duration) nucleiv1alpha1.ScanSummary {
severityCounts := make(map[string]int)
for _, f := range findings {
severity := strings.ToLower(f.Severity)
severityCounts[severity]++
}
return nucleiv1alpha1.ScanSummary{
TotalFindings: len(findings),
FindingsBySeverity: severityCounts,
TargetsScanned: targetsCount,
DurationSeconds: int64(duration.Seconds()),
}
}
// getEnvOrDefault returns the environment variable value or a default
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getEnvDurationOrDefault returns the environment variable as a duration or a default
func getEnvDurationOrDefault(key string, defaultValue time.Duration) time.Duration {
if value := os.Getenv(key); value != "" {
if d, err := time.ParseDuration(value); err == nil {
return d
}
}
return defaultValue
}