Files
nuclei-operator/internal/controller/virtualservice_controller.go
Morten Olsen 12d681ada1 feat: implement pod-based scanning architecture
This major refactor moves from synchronous subprocess-based scanning to
asynchronous pod-based scanning using Kubernetes Jobs.

## Architecture Changes
- Scanner jobs are now Kubernetes Jobs with TTLAfterFinished for automatic cleanup
- Jobs have owner references for garbage collection when NucleiScan is deleted
- Configurable concurrency limits, timeouts, and resource requirements

## New Features
- Dual-mode binary: --mode=controller (default) or --mode=scanner
- Annotation-based configuration for Ingress/VirtualService resources
- Operator-level configuration via environment variables
- Startup recovery for orphaned scans after operator restart
- Periodic cleanup of stuck jobs

## New Files
- DESIGN.md: Comprehensive architecture design document
- internal/jobmanager/: Job Manager for creating/monitoring scanner jobs
- internal/scanner/runner.go: Scanner mode implementation
- internal/annotations/: Annotation parsing utilities
- charts/nuclei-operator/templates/scanner-rbac.yaml: Scanner RBAC

## API Changes
- Added ScannerConfig struct for per-scan scanner configuration
- Added JobReference struct for tracking scanner jobs
- Added ScannerConfig field to NucleiScanSpec
- Added JobRef and ScanStartTime fields to NucleiScanStatus

## Supported Annotations
- nuclei.homelab.mortenolsen.pro/enabled
- nuclei.homelab.mortenolsen.pro/templates
- nuclei.homelab.mortenolsen.pro/severity
- nuclei.homelab.mortenolsen.pro/schedule
- nuclei.homelab.mortenolsen.pro/timeout
- nuclei.homelab.mortenolsen.pro/scanner-image

## RBAC Updates
- Added Job and Pod permissions for operator
- Created separate scanner service account with minimal permissions

## Documentation
- Updated README, user-guide, api.md, and Helm chart README
- Added example annotated Ingress resources
2025-12-12 20:55:09 +01:00

259 lines
8.2 KiB
Go

/*
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 controller
import (
"context"
"fmt"
"reflect"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
logf "sigs.k8s.io/controller-runtime/pkg/log"
istionetworkingv1beta1 "istio.io/client-go/pkg/apis/networking/v1beta1"
nucleiv1alpha1 "github.com/mortenolsen/nuclei-operator/api/v1alpha1"
"github.com/mortenolsen/nuclei-operator/internal/annotations"
)
// VirtualServiceReconciler reconciles VirtualService objects and creates NucleiScan resources
type VirtualServiceReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=networking.istio.io,resources=virtualservices,verbs=get;list;watch
// +kubebuilder:rbac:groups=networking.istio.io,resources=virtualservices/status,verbs=get
// +kubebuilder:rbac:groups=nuclei.homelab.mortenolsen.pro,resources=nucleiscans,verbs=get;list;watch;create;update;patch;delete
// Reconcile handles VirtualService events and creates/updates corresponding NucleiScan resources
func (r *VirtualServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := logf.FromContext(ctx)
// Fetch the VirtualService resource
virtualService := &istionetworkingv1beta1.VirtualService{}
if err := r.Get(ctx, req.NamespacedName, virtualService); err != nil {
if apierrors.IsNotFound(err) {
// VirtualService was deleted - NucleiScan will be garbage collected via ownerReference
log.Info("VirtualService not found, likely deleted")
return ctrl.Result{}, nil
}
log.Error(err, "Failed to get VirtualService")
return ctrl.Result{}, err
}
// Parse annotations to get scan configuration
scanConfig := annotations.ParseAnnotations(virtualService.Annotations)
// Define the NucleiScan name based on the VirtualService name
nucleiScanName := fmt.Sprintf("%s-scan", virtualService.Name)
// Check if a NucleiScan already exists for this VirtualService
existingScan := &nucleiv1alpha1.NucleiScan{}
err := r.Get(ctx, client.ObjectKey{
Namespace: virtualService.Namespace,
Name: nucleiScanName,
}, existingScan)
if err != nil && !apierrors.IsNotFound(err) {
log.Error(err, "Failed to get existing NucleiScan")
return ctrl.Result{}, err
}
// Check if scanning is disabled via annotations
if !scanConfig.IsEnabled() {
// Scanning disabled - delete existing NucleiScan if it exists
if err == nil {
log.Info("Scanning disabled via annotation, deleting existing NucleiScan", "nucleiScan", nucleiScanName)
if err := r.Delete(ctx, existingScan); err != nil && !apierrors.IsNotFound(err) {
log.Error(err, "Failed to delete NucleiScan")
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
// Extract target URLs from the VirtualService
targets := extractURLsFromVirtualService(virtualService)
if len(targets) == 0 {
log.Info("No targets extracted from VirtualService, skipping NucleiScan creation")
return ctrl.Result{}, nil
}
if apierrors.IsNotFound(err) {
// Create a new NucleiScan
spec := nucleiv1alpha1.NucleiScanSpec{
SourceRef: nucleiv1alpha1.SourceReference{
APIVersion: "networking.istio.io/v1beta1",
Kind: "VirtualService",
Name: virtualService.Name,
Namespace: virtualService.Namespace,
UID: string(virtualService.UID),
},
Targets: targets,
}
// Apply annotation configuration to the spec
scanConfig.ApplyToNucleiScanSpec(&spec)
nucleiScan := &nucleiv1alpha1.NucleiScan{
ObjectMeta: metav1.ObjectMeta{
Name: nucleiScanName,
Namespace: virtualService.Namespace,
},
Spec: spec,
}
// Set owner reference for garbage collection
if err := controllerutil.SetControllerReference(virtualService, nucleiScan, r.Scheme); err != nil {
log.Error(err, "Failed to set owner reference on NucleiScan")
return ctrl.Result{}, err
}
if err := r.Create(ctx, nucleiScan); err != nil {
log.Error(err, "Failed to create NucleiScan")
return ctrl.Result{}, err
}
log.Info("Created NucleiScan for VirtualService", "nucleiScan", nucleiScanName, "targets", targets)
return ctrl.Result{}, nil
}
// NucleiScan exists - check if it needs to be updated
needsUpdate := false
// Check if targets changed
if !reflect.DeepEqual(existingScan.Spec.Targets, targets) {
existingScan.Spec.Targets = targets
needsUpdate = true
}
// Also update the SourceRef UID in case it changed (e.g., VirtualService was recreated)
if existingScan.Spec.SourceRef.UID != string(virtualService.UID) {
existingScan.Spec.SourceRef.UID = string(virtualService.UID)
needsUpdate = true
}
// Apply annotation configuration
scanConfig.ApplyToNucleiScanSpec(&existingScan.Spec)
if needsUpdate {
if err := r.Update(ctx, existingScan); err != nil {
log.Error(err, "Failed to update NucleiScan")
return ctrl.Result{}, err
}
log.Info("Updated NucleiScan for VirtualService", "nucleiScan", nucleiScanName, "targets", targets)
}
return ctrl.Result{}, nil
}
// extractURLsFromVirtualService extracts target URLs from a VirtualService resource
func extractURLsFromVirtualService(vs *istionetworkingv1beta1.VirtualService) []string {
var urls []string
// Check if VirtualService has gateways defined (indicates external traffic)
// If no gateways or only "mesh" gateway, it's internal service-to-service
hasExternalGateway := false
for _, gw := range vs.Spec.Gateways {
if gw != "mesh" {
hasExternalGateway = true
break
}
}
// If no external gateway, skip this VirtualService
if !hasExternalGateway && len(vs.Spec.Gateways) > 0 {
return urls
}
// Extract URLs from hosts
for _, host := range vs.Spec.Hosts {
// Skip wildcard hosts and internal service names (no dots or starts with *)
if strings.HasPrefix(host, "*") {
continue
}
// Skip internal Kubernetes service names (typically don't contain dots or are short names)
// External hosts typically have FQDNs like "myapp.example.com"
if !strings.Contains(host, ".") {
continue
}
// Skip Kubernetes internal service FQDNs (*.svc.cluster.local)
if strings.Contains(host, ".svc.cluster.local") || strings.Contains(host, ".svc.") {
continue
}
// Default to HTTPS for external hosts (security scanning)
scheme := "https"
// Extract paths from HTTP routes if defined
pathsFound := false
if vs.Spec.Http != nil {
for _, httpRoute := range vs.Spec.Http {
if httpRoute.Match != nil {
for _, match := range httpRoute.Match {
if match.Uri != nil {
if match.Uri.GetPrefix() != "" {
url := fmt.Sprintf("%s://%s%s", scheme, host, match.Uri.GetPrefix())
urls = append(urls, url)
pathsFound = true
} else if match.Uri.GetExact() != "" {
url := fmt.Sprintf("%s://%s%s", scheme, host, match.Uri.GetExact())
urls = append(urls, url)
pathsFound = true
} else if match.Uri.GetRegex() != "" {
// For regex patterns, just use the base URL
// We can't enumerate all possible matches
url := fmt.Sprintf("%s://%s", scheme, host)
urls = append(urls, url)
pathsFound = true
}
}
}
}
}
}
// If no specific paths found, add base URL
if !pathsFound {
url := fmt.Sprintf("%s://%s", scheme, host)
urls = append(urls, url)
}
}
// Deduplicate URLs
return deduplicateStrings(urls)
}
// SetupWithManager sets up the controller with the Manager
func (r *VirtualServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&istionetworkingv1beta1.VirtualService{}).
Owns(&nucleiv1alpha1.NucleiScan{}).
Named("virtualservice").
Complete(r)
}