diff --git a/CHANGELOG.md b/CHANGELOG.md index f01d101..23170a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Penetration testing system** — Built-in security scanner with 7 modules that scan container endpoints and dependencies: + - **Built-in modules**: `ports` (open port detection), `headers` (HTTP security header audit), `tls` (weak protocol/cipher/cert checks), `web` (exposed .env/.git/debug endpoints), `dns` (dangling CNAMEs, missing SPF/DMARC/DKIM) + - **External tool modules**: `nuclei` (template-based vulnerability scanning) and `trivy` (container filesystem CVE scanning via rootfs inspection) — auto-installable from the UI + - 8 gRPC/REST endpoints (`/v1/pentest/*`): trigger scans, list findings with severity/category/status filters, suppress findings, view scan history, install tools + - Async job queue with 5 concurrent workers, SHA-256 fingerprint-based finding deduplication, scheduled scans (default 24h), 90-day retention + - Proto definitions (`proto/containarium/v1/pentest.proto`), server implementation, and web UI (Security > Pentest tab) +- **Demo page: Pentest tab** — New demo tab showcasing the pentest findings view with grouped-by-container layout and mock data. + +### Changed +- **Pentest findings grouped by container** — The Security > Pentest tab now groups findings by container name instead of showing a flat list. Each group has a collapsible header showing the container name and finding count, sorted by most findings first. Container names are extracted from target strings (e.g., `voicegpt-container (usr/bin/docker)` → `voicegpt-container`, `10.0.3.136:8080 (pes-container)` → `pes-container`). + ## [v0.12.0] - 2026-03-15 ### Added diff --git a/README.md b/README.md index 48f8774..ecbdcb2 100644 --- a/README.md +++ b/README.md @@ -8,25 +8,31 @@ Built with LXC, SSH jump hosts, and cloud-native automation. ✅ Just fast, cheap, isolated Linux environments ### Container Management -![Container Dashboard](docs/screenshots/dashboard-1.png) - -### App Hosting -![Apps Dashboard](docs/screenshots/dashboard-2.png) +![Container Dashboard](docs/screenshots/dashboard-container.png) ### Container List View -![Container List](docs/screenshots/dashboard-3.png) +![Container List](docs/screenshots/dashboard-container-listview.png) + +### App Hosting +![Apps Dashboard](docs/screenshots/dashboard-app.png) ### Network Topology -![Network Topology](docs/screenshots/dashboard-4.png) +![Network Topology](docs/screenshots/dashboard-network.png) ### Traffic Monitoring -![Traffic Monitor](docs/screenshots/dashboard-5.png) +![Traffic Monitor](docs/screenshots/dashboard-traffic.png) ### Monitoring Dashboard -![Monitoring Dashboard](docs/screenshots/dashboard-6.png) +![Monitoring Dashboard](docs/screenshots/dashboard-monitoring.png) + +### Alerts +![Alerts](docs/screenshots/dashboard-alert.png) + +### Audit Logs +![Audit Logs](docs/screenshots/dashboard-audit.png) ### Security Scanning -![Security Scanning](docs/screenshots/dashboard-7.png) +![Security Scanning](docs/screenshots/dashboard-security.png) 🌐 **Live Demo:** [https://containarium.kafeido.app/webui/demo](https://containarium.kafeido.app/webui/demo) diff --git a/api/swagger/containarium.swagger.json b/api/swagger/containarium.swagger.json index 8710e70..18a5b34 100644 --- a/api/swagger/containarium.swagger.json +++ b/api/swagger/containarium.swagger.json @@ -29,6 +29,9 @@ { "name": "EventService" }, + { + "name": "PentestService" + }, { "name": "SecurityService" } @@ -1996,6 +1999,302 @@ ] } }, + "/v1/pentest/config": { + "get": { + "summary": "Get pentest configuration", + "description": "Returns the current penetration test configuration including enabled modules and external tool availability.", + "operationId": "PentestService_GetPentestConfig", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/GetPentestConfigResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "tags": [ + "Pentest" + ] + } + }, + "/v1/pentest/findings": { + "get": { + "summary": "List pentest findings", + "description": "Returns security findings from penetration test scans with optional filtering by severity, category, and status.", + "operationId": "PentestService_ListPentestFindings", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/ListPentestFindingsResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "severity", + "description": "Filter by severity (optional)", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "category", + "description": "Filter by category/module (optional)", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "status", + "description": "Filter by status: \"open\", \"resolved\", \"suppressed\" (optional)", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "limit", + "description": "Maximum number of findings to return (default: 50)", + "in": "query", + "required": false, + "type": "integer", + "format": "int32" + }, + { + "name": "offset", + "description": "Offset for pagination", + "in": "query", + "required": false, + "type": "integer", + "format": "int32" + } + ], + "tags": [ + "Pentest" + ] + } + }, + "/v1/pentest/findings/summary": { + "get": { + "summary": "Get pentest finding summary", + "description": "Returns aggregate statistics of security findings including counts by severity and category.", + "operationId": "PentestService_GetPentestFindingSummary", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/GetPentestFindingSummaryResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "tags": [ + "Pentest" + ] + } + }, + "/v1/pentest/findings/{findingId}/suppress": { + "post": { + "summary": "Suppress a pentest finding", + "description": "Marks a finding as suppressed with a reason. Suppressed findings are excluded from open counts and alerts.", + "operationId": "PentestService_SuppressPentestFinding", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/SuppressPentestFindingResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "findingId", + "description": "Finding ID to suppress", + "in": "path", + "required": true, + "type": "string", + "format": "int64" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SuppressPentestFindingBody" + } + } + ], + "tags": [ + "Pentest" + ] + } + }, + "/v1/pentest/scan": { + "post": { + "summary": "Trigger penetration test scan", + "description": "Triggers an on-demand penetration test scan across all registered endpoints and containers.", + "operationId": "PentestService_TriggerPentestScan", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/TriggerPentestScanResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/TriggerPentestScanRequest" + } + } + ], + "tags": [ + "Pentest" + ] + } + }, + "/v1/pentest/scans": { + "get": { + "summary": "List pentest scan runs", + "description": "Returns recent penetration test scan runs with their status and finding counts.", + "operationId": "PentestService_ListPentestScanRuns", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/ListPentestScanRunsResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "limit", + "description": "Maximum number of scan runs to return (default: 20)", + "in": "query", + "required": false, + "type": "integer", + "format": "int32" + }, + { + "name": "offset", + "description": "Offset for pagination", + "in": "query", + "required": false, + "type": "integer", + "format": "int32" + } + ], + "tags": [ + "Pentest" + ] + } + }, + "/v1/pentest/scans/{id}": { + "get": { + "summary": "Get pentest scan run details", + "description": "Returns details of a specific penetration test scan run including finding counts.", + "operationId": "PentestService_GetPentestScanRun", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/GetPentestScanRunResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "id", + "description": "Scan run ID", + "in": "path", + "required": true, + "type": "string" + } + ], + "tags": [ + "Pentest" + ] + } + }, + "/v1/pentest/tools/install": { + "post": { + "summary": "Install pentest tool", + "description": "Downloads and installs an external pentest tool (nuclei or trivy) from GitHub releases.", + "operationId": "PentestService_InstallPentestTool", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/InstallPentestToolResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/Status" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/InstallPentestToolRequest" + } + } + ], + "tags": [ + "Pentest" + ] + } + }, "/v1/security/clamav-reports": { "get": { "summary": "List ClamAV scan reports", @@ -3850,6 +4149,30 @@ } } }, + "GetPentestConfigResponse": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/PentestConfig" + } + } + }, + "GetPentestFindingSummaryResponse": { + "type": "object", + "properties": { + "summary": { + "$ref": "#/definitions/PentestFindingSummary" + } + } + }, + "GetPentestScanRunResponse": { + "type": "object", + "properties": { + "scanRun": { + "$ref": "#/definitions/PentestScanRun" + } + } + }, "GetRoutesResponse": { "type": "object", "properties": { @@ -3987,6 +4310,26 @@ }, "title": "HistoricalConnection represents a persisted connection record" }, + "InstallPentestToolRequest": { + "type": "object", + "properties": { + "toolName": { + "type": "string", + "title": "Tool name: \"nuclei\" or \"trivy\"" + } + } + }, + "InstallPentestToolResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + } + }, "InstallStackBody": { "type": "object", "properties": { @@ -4164,6 +4507,38 @@ } } }, + "ListPentestFindingsResponse": { + "type": "object", + "properties": { + "findings": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/PentestFinding" + } + }, + "totalCount": { + "type": "integer", + "format": "int32" + } + } + }, + "ListPentestScanRunsResponse": { + "type": "object", + "properties": { + "scanRuns": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/PentestScanRun" + } + }, + "totalCount": { + "type": "integer", + "format": "int32" + } + } + }, "ListWebhookDeliveriesResponse": { "type": "object", "properties": { @@ -4385,6 +4760,230 @@ }, "title": "PassthroughRoute represents a direct TCP/UDP port forwarding rule" }, + "PentestConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "title": "Whether pentest scanning is enabled" + }, + "interval": { + "type": "string", + "title": "Scan interval (e.g., \"24h\")" + }, + "modules": { + "type": "string", + "title": "Comma-separated list of enabled modules" + }, + "nucleiAvailable": { + "type": "boolean", + "title": "Whether Nuclei is available" + }, + "trivyAvailable": { + "type": "boolean", + "title": "Whether Trivy is available" + } + }, + "title": "PentestConfig returns the current pentest configuration" + }, + "PentestFinding": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "int64", + "title": "Database ID" + }, + "fingerprint": { + "type": "string", + "title": "SHA-256 fingerprint for deduplication (category|target|title)" + }, + "category": { + "type": "string", + "title": "Scanner module that found this (e.g., \"headers\", \"tls\", \"nuclei\")" + }, + "severity": { + "type": "string", + "title": "Severity: \"critical\", \"high\", \"medium\", \"low\", \"info\"" + }, + "title": { + "type": "string", + "title": "Short title of the finding" + }, + "description": { + "type": "string", + "title": "Detailed description" + }, + "target": { + "type": "string", + "title": "Target that was scanned (URL, IP:port, domain)" + }, + "evidence": { + "type": "string", + "title": "Evidence or raw output supporting the finding" + }, + "cveIds": { + "type": "string", + "title": "Comma-separated CVE IDs if applicable" + }, + "remediation": { + "type": "string", + "title": "Remediation guidance" + }, + "status": { + "type": "string", + "title": "Current status: \"open\", \"resolved\", \"suppressed\"" + }, + "firstScanRunId": { + "type": "string", + "title": "Scan run ID that first found this" + }, + "lastScanRunId": { + "type": "string", + "title": "Scan run ID that last saw this" + }, + "firstSeenAt": { + "type": "string", + "title": "When first seen (ISO 8601)" + }, + "lastSeenAt": { + "type": "string", + "title": "When last seen (ISO 8601)" + }, + "resolvedAt": { + "type": "string", + "title": "When resolved (ISO 8601, empty if still open)" + }, + "suppressed": { + "type": "boolean", + "title": "Whether the finding is suppressed" + }, + "suppressedReason": { + "type": "string", + "title": "Reason for suppression" + } + }, + "title": "PentestFinding represents a single security finding" + }, + "PentestFindingSummary": { + "type": "object", + "properties": { + "totalFindings": { + "type": "integer", + "format": "int32" + }, + "openFindings": { + "type": "integer", + "format": "int32" + }, + "resolvedFindings": { + "type": "integer", + "format": "int32" + }, + "suppressedFindings": { + "type": "integer", + "format": "int32" + }, + "criticalCount": { + "type": "integer", + "format": "int32" + }, + "highCount": { + "type": "integer", + "format": "int32" + }, + "mediumCount": { + "type": "integer", + "format": "int32" + }, + "lowCount": { + "type": "integer", + "format": "int32" + }, + "infoCount": { + "type": "integer", + "format": "int32" + }, + "byCategory": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + }, + "title": "Per-category breakdown" + } + }, + "title": "PentestFindingSummary provides aggregate counts" + }, + "PentestScanRun": { + "type": "object", + "properties": { + "id": { + "type": "string", + "title": "Unique scan run ID (UUID)" + }, + "trigger": { + "type": "string", + "title": "How the scan was triggered: \"scheduled\", \"manual\", \"startup\"" + }, + "status": { + "type": "string", + "title": "Scan status: \"running\", \"completed\", \"failed\"" + }, + "modules": { + "type": "string", + "title": "Comma-separated list of modules that were run" + }, + "targetsCount": { + "type": "integer", + "format": "int32", + "title": "Number of targets scanned" + }, + "criticalCount": { + "type": "integer", + "format": "int32", + "title": "Finding counts by severity" + }, + "highCount": { + "type": "integer", + "format": "int32" + }, + "mediumCount": { + "type": "integer", + "format": "int32" + }, + "lowCount": { + "type": "integer", + "format": "int32" + }, + "infoCount": { + "type": "integer", + "format": "int32" + }, + "errorMessage": { + "type": "string", + "title": "Error message if status is \"failed\"" + }, + "startedAt": { + "type": "string", + "title": "When the scan started (ISO 8601)" + }, + "completedAt": { + "type": "string", + "title": "When the scan completed (ISO 8601)" + }, + "duration": { + "type": "string", + "title": "Duration of the scan" + }, + "completedCount": { + "type": "integer", + "format": "int32", + "title": "Number of targets that finished processing" + } + }, + "title": "PentestScanRun represents a single penetration test scan execution" + }, "Protocol": { "type": "string", "enum": [ @@ -4732,6 +5331,23 @@ }, "title": "StopContainerResponse is the response from stopping a container" }, + "SuppressPentestFindingBody": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "title": "Reason for suppression" + } + } + }, + "SuppressPentestFindingResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, "SystemInfo": { "type": "object", "properties": { @@ -4941,6 +5557,28 @@ } } }, + "TriggerPentestScanRequest": { + "type": "object", + "properties": { + "modules": { + "type": "string", + "title": "Optional: comma-separated list of modules to run (empty = all enabled)" + } + } + }, + "TriggerPentestScanResponse": { + "type": "object", + "properties": { + "scanRunId": { + "type": "string", + "title": "Scan run ID" + }, + "message": { + "type": "string", + "title": "Human-readable message" + } + } + }, "UpdateAlertRuleBody": { "type": "object", "properties": { diff --git a/docs/screenshots/dashboard-6.png b/docs/screenshots/dashboard-6.png deleted file mode 100644 index cb8efbc..0000000 Binary files a/docs/screenshots/dashboard-6.png and /dev/null differ diff --git a/docs/screenshots/dashboard-alert.png b/docs/screenshots/dashboard-alert.png new file mode 100644 index 0000000..5b8ff35 Binary files /dev/null and b/docs/screenshots/dashboard-alert.png differ diff --git a/docs/screenshots/dashboard-2.png b/docs/screenshots/dashboard-app.png similarity index 100% rename from docs/screenshots/dashboard-2.png rename to docs/screenshots/dashboard-app.png diff --git a/docs/screenshots/dashboard-audit.png b/docs/screenshots/dashboard-audit.png new file mode 100644 index 0000000..98b3c13 Binary files /dev/null and b/docs/screenshots/dashboard-audit.png differ diff --git a/docs/screenshots/dashboard-3.png b/docs/screenshots/dashboard-container-listview.png similarity index 100% rename from docs/screenshots/dashboard-3.png rename to docs/screenshots/dashboard-container-listview.png diff --git a/docs/screenshots/dashboard-1.png b/docs/screenshots/dashboard-container.png similarity index 100% rename from docs/screenshots/dashboard-1.png rename to docs/screenshots/dashboard-container.png diff --git a/docs/screenshots/dashboard-monitoring.png b/docs/screenshots/dashboard-monitoring.png new file mode 100644 index 0000000..6cd19a4 Binary files /dev/null and b/docs/screenshots/dashboard-monitoring.png differ diff --git a/docs/screenshots/dashboard-4.png b/docs/screenshots/dashboard-network.png similarity index 100% rename from docs/screenshots/dashboard-4.png rename to docs/screenshots/dashboard-network.png diff --git a/docs/screenshots/dashboard-security.png b/docs/screenshots/dashboard-security.png new file mode 100644 index 0000000..9331d31 Binary files /dev/null and b/docs/screenshots/dashboard-security.png differ diff --git a/docs/screenshots/dashboard-5.png b/docs/screenshots/dashboard-traffic.png similarity index 100% rename from docs/screenshots/dashboard-5.png rename to docs/screenshots/dashboard-traffic.png diff --git a/internal/cmd/service.go b/internal/cmd/service.go index 16d132f..21d954d 100644 --- a/internal/cmd/service.go +++ b/internal/cmd/service.go @@ -34,7 +34,7 @@ NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=false -ReadWritePaths=/var/lib/incus /etc/containarium /etc /home /var/lock /run/lock +ReadWritePaths=/var/lib/incus /etc/containarium /etc /home /var/lock /run/lock /opt/containarium StandardOutput=journal StandardError=journal diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 663e4fc..b833f52 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -307,6 +307,11 @@ func (gs *GatewayServer) Start(ctx context.Context) error { return fmt.Errorf("failed to register security service gateway: %w", err) } + // Register PentestService gateway handler + if err := pb.RegisterPentestServiceHandlerFromEndpoint(ctx, mux, gs.grpcAddress, opts); err != nil { + return fmt.Errorf("failed to register pentest service gateway: %w", err) + } + // Create HTTP handler with authentication middleware, then audit middleware. // Audit wraps the inner handler so auth runs first (sets username in context), // then audit captures the response on the way out. diff --git a/internal/pentest/external_nuclei.go b/internal/pentest/external_nuclei.go new file mode 100644 index 0000000..f545abb --- /dev/null +++ b/internal/pentest/external_nuclei.go @@ -0,0 +1,112 @@ +package pentest + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "log" + "os/exec" + "strings" +) + +// NucleiModule runs Nuclei vulnerability scanner if available +type NucleiModule struct { + available bool +} + +func NewNucleiModule() *NucleiModule { + _, err := exec.LookPath("nuclei") + return &NucleiModule{available: err == nil} +} + +func (m *NucleiModule) Name() string { return "nuclei" } + +// Available returns whether nuclei is installed +func (m *NucleiModule) Available() bool { return m.available } + +// nucleiResult represents a single finding from Nuclei JSON output +type nucleiResult struct { + TemplateID string `json:"template-id"` + Info struct { + Name string `json:"name"` + Description string `json:"description"` + Severity string `json:"severity"` + Tags []string `json:"tags"` + Reference []string `json:"reference"` + CVE string `json:"cve-id,omitempty"` + } `json:"info"` + MatcherName string `json:"matcher-name"` + MatchedAt string `json:"matched-at"` + Host string `json:"host"` + CURLCommand string `json:"curl-command"` +} + +func (m *NucleiModule) Scan(ctx context.Context, target ScanTarget) ([]Finding, error) { + if !m.available { + return nil, nil + } + if target.FullDomain == "" { + return nil, nil + } + + url := target.URL() + + cmd := exec.CommandContext(ctx, "nuclei", + "-u", url, + "-jsonl", + "-severity", "critical,high,medium", + "-nc", // no color + "-silent", // suppress banner + ) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("nuclei: failed to create pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("nuclei: failed to start: %w", err) + } + + var findings []Finding + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + var result nucleiResult + if err := json.Unmarshal([]byte(line), &result); err != nil { + log.Printf("Pentest nuclei: failed to parse JSON line: %v", err) + continue + } + + severity := strings.ToLower(result.Info.Severity) + if severity == "" { + severity = "medium" + } + + title := result.Info.Name + if result.MatcherName != "" { + title += " (" + result.MatcherName + ")" + } + + f := NewFinding("nuclei", severity, title, result.MatchedAt) + f.Description = result.Info.Description + f.Evidence = fmt.Sprintf("Template: %s, Matched at: %s", result.TemplateID, result.MatchedAt) + if result.Info.CVE != "" { + f.CVEIDs = result.Info.CVE + } + if len(result.Info.Reference) > 0 { + f.Remediation = "References: " + strings.Join(result.Info.Reference, ", ") + } + findings = append(findings, f) + } + + // Wait for command to finish (ignore exit code — nuclei returns non-zero for findings) + cmd.Wait() + + return findings, nil +} diff --git a/internal/pentest/external_trivy.go b/internal/pentest/external_trivy.go new file mode 100644 index 0000000..255156b --- /dev/null +++ b/internal/pentest/external_trivy.go @@ -0,0 +1,126 @@ +package pentest + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os/exec" + "strings" + + "github.com/footprintai/containarium/internal/incus" +) + +// TrivyModule runs Trivy vulnerability scanner on container filesystems +type TrivyModule struct { + available bool + incusClient *incus.Client + storagePool string +} + +func NewTrivyModule(incusClient *incus.Client) *TrivyModule { + _, err := exec.LookPath("trivy") + return &TrivyModule{ + available: err == nil, + incusClient: incusClient, + storagePool: "default", + } +} + +func (m *TrivyModule) Name() string { return "trivy" } + +// Available returns whether trivy is installed +func (m *TrivyModule) Available() bool { return m.available } + +// trivyOutput represents Trivy JSON output +type trivyOutput struct { + Results []trivyResultEntry `json:"Results"` +} + +type trivyResultEntry struct { + Target string `json:"Target"` + Vulnerabilities []trivyVulnerability `json:"Vulnerabilities"` +} + +type trivyVulnerability struct { + VulnerabilityID string `json:"VulnerabilityID"` + PkgName string `json:"PkgName"` + InstalledVersion string `json:"InstalledVersion"` + FixedVersion string `json:"FixedVersion"` + Severity string `json:"Severity"` + Title string `json:"Title"` + Description string `json:"Description"` + PrimaryURL string `json:"PrimaryURL"` +} + +func (m *TrivyModule) Scan(ctx context.Context, target ScanTarget) ([]Finding, error) { + if !m.available || m.incusClient == nil { + return nil, nil + } + if target.ContainerName == "" || target.TargetType != "container" { + return nil, nil + } + + // Build the rootfs path (same pattern as ClamAV scanner) + rootfsPath := fmt.Sprintf("/var/lib/incus/storage-pools/%s/containers/%s/rootfs", + m.storagePool, target.ContainerName) + + cmd := exec.CommandContext(ctx, "trivy", + "rootfs", + "--format", "json", + "--severity", "CRITICAL,HIGH", + "--skip-dirs", "/proc,/sys,/dev", + "--no-progress", + rootfsPath, + ) + + output, err := cmd.Output() + if err != nil { + // Trivy may return non-zero on findings + if exitErr, ok := err.(*exec.ExitError); ok { + if len(output) == 0 { + output = exitErr.Stderr + log.Printf("Pentest trivy: error scanning %s: %s", target.ContainerName, string(output)) + return nil, nil + } + } else { + return nil, fmt.Errorf("trivy: failed to run: %w", err) + } + } + + var trivyOut trivyOutput + if err := json.Unmarshal(output, &trivyOut); err != nil { + return nil, fmt.Errorf("trivy: failed to parse output: %w", err) + } + + var findings []Finding + for _, result := range trivyOut.Results { + for _, vuln := range result.Vulnerabilities { + severity := strings.ToLower(vuln.Severity) + if severity == "" { + severity = "high" + } + + title := vuln.Title + if title == "" { + title = fmt.Sprintf("%s in %s", vuln.VulnerabilityID, vuln.PkgName) + } + + targetStr := fmt.Sprintf("%s (%s)", target.ContainerName, result.Target) + f := NewFinding("trivy", severity, title, targetStr) + f.Description = vuln.Description + f.CVEIDs = vuln.VulnerabilityID + f.Evidence = fmt.Sprintf("Package: %s, Installed: %s, Fixed: %s", + vuln.PkgName, vuln.InstalledVersion, vuln.FixedVersion) + if vuln.FixedVersion != "" { + f.Remediation = fmt.Sprintf("Upgrade %s from %s to %s", + vuln.PkgName, vuln.InstalledVersion, vuln.FixedVersion) + } else { + f.Remediation = "No fix available yet. Monitor for updates." + } + findings = append(findings, f) + } + } + + return findings, nil +} diff --git a/internal/pentest/installer.go b/internal/pentest/installer.go new file mode 100644 index 0000000..6834ff0 --- /dev/null +++ b/internal/pentest/installer.go @@ -0,0 +1,280 @@ +package pentest + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" +) + +// toolBinDir is the directory where pentest tools are installed. +// We use /opt/containarium/bin instead of /usr/local/bin because systemd +// ProtectSystem=strict makes /usr read-only. +const toolBinDir = "/opt/containarium/bin" + +func init() { + // Ensure toolBinDir is on PATH so exec.LookPath and exec.Command find + // tools installed there. + path := os.Getenv("PATH") + if !strings.Contains(path, toolBinDir) { + os.Setenv("PATH", toolBinDir+":"+path) + } +} + +// Installer handles downloading and installing external pentest tools. +type Installer struct { + mu sync.Mutex +} + +// NewInstaller creates a new Installer. +func NewInstaller() *Installer { + return &Installer{} +} + +// githubRelease is a subset of the GitHub releases API response. +type githubRelease struct { + TagName string `json:"tag_name"` +} + +// resolveLatestVersion fetches the latest release tag from a GitHub repo. +func resolveLatestVersion(owner, repo string) (string, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) + resp, err := http.Get(url) + if err != nil { + return "", fmt.Errorf("failed to fetch latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + var release githubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", fmt.Errorf("failed to decode release: %w", err) + } + if release.TagName == "" { + return "", fmt.Errorf("no tag found in latest release") + } + return release.TagName, nil +} + +// InstallNuclei downloads and installs the latest Nuclei binary. +func (i *Installer) InstallNuclei() error { + i.mu.Lock() + defer i.mu.Unlock() + + if _, err := exec.LookPath("nuclei"); err == nil { + return fmt.Errorf("nuclei is already installed") + } + + tag, err := resolveLatestVersion("projectdiscovery", "nuclei") + if err != nil { + return fmt.Errorf("failed to resolve nuclei version: %w", err) + } + + version := strings.TrimPrefix(tag, "v") + arch := runtime.GOARCH + if arch == "amd64" { + arch = "amd64" + } + + // nuclei releases use format: nuclei_{version}_linux_amd64.zip + filename := fmt.Sprintf("nuclei_%s_linux_%s.zip", version, arch) + downloadURL := fmt.Sprintf("https://github.com/projectdiscovery/nuclei/releases/download/%s/%s", tag, filename) + + log.Printf("Installer: downloading Nuclei %s from %s", tag, downloadURL) + + destPath := filepath.Join(toolBinDir, "nuclei") + if err := i.installFromZip(downloadURL, "nuclei", destPath); err != nil { + return fmt.Errorf("failed to install nuclei: %w", err) + } + + // Validate + out, err := exec.Command("nuclei", "--version").CombinedOutput() + if err != nil { + return fmt.Errorf("nuclei validation failed: %w (%s)", err, string(out)) + } + log.Printf("Installer: Nuclei installed: %s", strings.TrimSpace(string(out))) + + // Update templates (needed for CVE detection) + log.Printf("Installer: updating Nuclei templates...") + if out, err := exec.Command("nuclei", "-update-templates").CombinedOutput(); err != nil { + log.Printf("Installer: nuclei template update warning: %v (%s)", err, strings.TrimSpace(string(out))) + } else { + log.Printf("Installer: Nuclei templates updated") + } + + return nil +} + +// InstallTrivy downloads and installs the latest Trivy binary. +func (i *Installer) InstallTrivy() error { + i.mu.Lock() + defer i.mu.Unlock() + + if _, err := exec.LookPath("trivy"); err == nil { + return fmt.Errorf("trivy is already installed") + } + + tag, err := resolveLatestVersion("aquasecurity", "trivy") + if err != nil { + return fmt.Errorf("failed to resolve trivy version: %w", err) + } + + version := strings.TrimPrefix(tag, "v") + arch := runtime.GOARCH + osName := "Linux" + if arch == "amd64" { + arch = "64bit" + } else if arch == "arm64" { + arch = "ARM64" + } + + // trivy releases use format: trivy_{version}_Linux-64bit.tar.gz + filename := fmt.Sprintf("trivy_%s_%s-%s.tar.gz", version, osName, arch) + downloadURL := fmt.Sprintf("https://github.com/aquasecurity/trivy/releases/download/%s/%s", tag, filename) + + log.Printf("Installer: downloading Trivy %s from %s", tag, downloadURL) + + destPath := filepath.Join(toolBinDir, "trivy") + if err := i.installFromTarGz(downloadURL, "trivy", destPath); err != nil { + return fmt.Errorf("failed to install trivy: %w", err) + } + + // Validate + out, err := exec.Command("trivy", "--version").CombinedOutput() + if err != nil { + return fmt.Errorf("trivy validation failed: %w (%s)", err, string(out)) + } + log.Printf("Installer: Trivy installed: %s", strings.TrimSpace(string(out))) + + return nil +} + +// installFromZip downloads a zip archive and extracts a named binary to destPath. +func (i *Installer) installFromZip(downloadURL, binaryName, destPath string) error { + tmpDir, err := os.MkdirTemp("", "pentest-install-*") + if err != nil { + return fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + zipPath := filepath.Join(tmpDir, "archive.zip") + if err := downloadFile(downloadURL, zipPath); err != nil { + return err + } + + r, err := zip.OpenReader(zipPath) + if err != nil { + return fmt.Errorf("failed to open zip: %w", err) + } + defer r.Close() + + for _, f := range r.File { + if filepath.Base(f.Name) == binaryName && !f.FileInfo().IsDir() { + rc, err := f.Open() + if err != nil { + return fmt.Errorf("failed to open %s in zip: %w", binaryName, err) + } + defer rc.Close() + + return writeBinary(rc, destPath) + } + } + + return fmt.Errorf("%s binary not found in zip archive", binaryName) +} + +// installFromTarGz downloads a tar.gz archive and extracts a named binary to destPath. +func (i *Installer) installFromTarGz(downloadURL, binaryName, destPath string) error { + tmpDir, err := os.MkdirTemp("", "pentest-install-*") + if err != nil { + return fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + tgzPath := filepath.Join(tmpDir, "archive.tar.gz") + if err := downloadFile(downloadURL, tgzPath); err != nil { + return err + } + + f, err := os.Open(tgzPath) + if err != nil { + return fmt.Errorf("failed to open tar.gz: %w", err) + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to read tar: %w", err) + } + if filepath.Base(hdr.Name) == binaryName && hdr.Typeflag == tar.TypeReg { + return writeBinary(tr, destPath) + } + } + + return fmt.Errorf("%s binary not found in tar.gz archive", binaryName) +} + +// downloadFile downloads a URL to a local file path. +func downloadFile(url, destPath string) error { + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("download failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download returned status %d", resp.StatusCode) + } + + out, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", destPath, err) + } + defer out.Close() + + if _, err := io.Copy(out, resp.Body); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + return nil +} + +// writeBinary writes binary content from reader to destPath with executable permissions. +func writeBinary(src io.Reader, destPath string) error { + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", filepath.Dir(destPath), err) + } + out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return fmt.Errorf("failed to create binary at %s: %w", destPath, err) + } + defer out.Close() + + if _, err := io.Copy(out, src); err != nil { + return fmt.Errorf("failed to write binary: %w", err) + } + return nil +} diff --git a/internal/pentest/manager.go b/internal/pentest/manager.go new file mode 100644 index 0000000..10c1c49 --- /dev/null +++ b/internal/pentest/manager.go @@ -0,0 +1,471 @@ +package pentest + +import ( + "context" + "fmt" + "log" + "os/exec" + "strings" + "sync" + "time" + + "github.com/footprintai/containarium/internal/app" + "github.com/footprintai/containarium/internal/incus" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" +) + +const maxConcurrentWorkers = 5 + +// ManagerConfig holds configuration for the pentest manager +type ManagerConfig struct { + Interval time.Duration // scan interval (default 24h) + EnabledModules []string // module names to enable (empty = all) +} + +// Manager orchestrates periodic pentest scans using a PostgreSQL job queue +type Manager struct { + mu sync.RWMutex + store *Store + incusClient *incus.Client + routeStore *app.RouteStore + collector *TargetCollector + modules []Module + config ManagerConfig + metricsRecorder *MetricsRecorder + cancel context.CancelFunc +} + +// NewManager creates a new pentest manager +func NewManager( + store *Store, + incusClient *incus.Client, + routeStore *app.RouteStore, + meterProvider *sdkmetric.MeterProvider, + config ManagerConfig, +) *Manager { + if config.Interval == 0 { + config.Interval = 24 * time.Hour + } + + collector := NewTargetCollector(routeStore, incusClient) + + // Build module list + allModules := []Module{ + NewHeadersModule(), + NewTLSModule(), + NewPortsModule(), + NewWebModule(), + NewDNSModule(), + } + + // Add external tools if available + nuclei := NewNucleiModule() + if nuclei.Available() { + allModules = append(allModules, nuclei) + log.Printf("Pentest: Nuclei scanner available") + } + + trivy := NewTrivyModule(incusClient) + if trivy.Available() { + allModules = append(allModules, trivy) + log.Printf("Pentest: Trivy scanner available") + } + + // Filter modules if specific ones are configured + var modules []Module + if len(config.EnabledModules) > 0 { + enabled := make(map[string]bool) + for _, name := range config.EnabledModules { + enabled[name] = true + } + for _, m := range allModules { + if enabled[m.Name()] { + modules = append(modules, m) + } + } + } else { + modules = allModules + } + + m := &Manager{ + store: store, + incusClient: incusClient, + routeStore: routeStore, + collector: collector, + modules: modules, + config: config, + } + + // Setup metrics if provider available + if meterProvider != nil { + mr, err := NewMetricsRecorder(store, meterProvider) + if err != nil { + log.Printf("Pentest: failed to create metrics recorder: %v", err) + } else { + m.metricsRecorder = mr + } + } + + return m +} + +// Start begins the worker pool and periodic scan enqueue loop +func (m *Manager) Start(ctx context.Context) { + ctx, m.cancel = context.WithCancel(ctx) + + // Start long-running worker goroutines + m.startWorkers(ctx, maxConcurrentWorkers) + + // Start periodic scan cycle loop + go m.scanCycleLoop(ctx) + + m.mu.RLock() + moduleNames := make([]string, len(m.modules)) + for i, mod := range m.modules { + moduleNames[i] = mod.Name() + } + m.mu.RUnlock() + log.Printf("Pentest manager started (interval: %v, modules: %s, workers: %d)", + m.config.Interval, strings.Join(moduleNames, ","), maxConcurrentWorkers) +} + +// Stop stops the worker pool and scan loop +func (m *Manager) Stop() { + if m.cancel != nil { + m.cancel() + } + log.Printf("Pentest manager stopped") +} + +// scanCycleLoop runs periodic scan enqueue cycles +func (m *Manager) scanCycleLoop(ctx context.Context) { + // Startup delay + timer := time.NewTimer(5 * time.Minute) + defer timer.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + m.runScanCycle(ctx) + timer.Reset(m.config.Interval) + } + } +} + +// runScanCycle creates a scan run and enqueues jobs for all targets (non-blocking) +func (m *Manager) runScanCycle(ctx context.Context) { + scanRunID, targetsCount, err := m.enqueueScan(ctx, "scheduled") + if err != nil { + log.Printf("Pentest scan cycle: failed to enqueue: %v", err) + return + } + + // Cleanup old data (90 day retention) + if err := m.store.Cleanup(ctx, 90); err != nil { + log.Printf("Pentest scan cycle: cleanup failed: %v", err) + } + if err := m.store.CleanupOldJobs(ctx, 90); err != nil { + log.Printf("Pentest scan cycle: job cleanup failed: %v", err) + } + + log.Printf("Pentest scan cycle: %d jobs enqueued for scan run %s", targetsCount, scanRunID) +} + +// enqueueScan creates a scan run record, collects targets, and enqueues a job per target. +// Returns the scan run ID and number of targets enqueued. +func (m *Manager) enqueueScan(ctx context.Context, trigger string) (string, int, error) { + m.mu.RLock() + moduleNames := make([]string, len(m.modules)) + for i, mod := range m.modules { + moduleNames[i] = mod.Name() + } + m.mu.RUnlock() + + scanRunID, err := m.store.CreateScanRun(ctx, trigger, strings.Join(moduleNames, ",")) + if err != nil { + return "", 0, fmt.Errorf("failed to create scan run: %w", err) + } + + log.Printf("Pentest scan %s started (trigger: %s, modules: %s)", scanRunID, trigger, strings.Join(moduleNames, ",")) + + targets := m.collector.Collect(ctx) + if len(targets) == 0 { + log.Printf("Pentest scan %s: no targets found", scanRunID) + now := time.Now() + m.store.UpdateScanRun(ctx, &ScanRun{ + ID: scanRunID, + Status: "completed", + TargetsCount: 0, + CompletedAt: &now, + }) + return scanRunID, 0, nil + } + + // Update scan run with target count + m.store.UpdateScanRun(ctx, &ScanRun{ + ID: scanRunID, + Status: "running", + TargetsCount: len(targets), + }) + + // Enqueue one job per target + for _, target := range targets { + if _, err := m.store.EnqueueScanJob(ctx, scanRunID, target); err != nil { + log.Printf("Pentest scan %s: failed to enqueue target %s: %v", scanRunID, target.FullDomain, err) + } + } + + log.Printf("Pentest scan %s: %d targets enqueued", scanRunID, len(targets)) + return scanRunID, len(targets), nil +} + +// RunScan enqueues a scan (non-blocking). Workers process jobs asynchronously. +func (m *Manager) RunScan(ctx context.Context, trigger string) (string, error) { + scanRunID, _, err := m.enqueueScan(ctx, trigger) + return scanRunID, err +} + +// startWorkers spawns count goroutines that poll the job queue +func (m *Manager) startWorkers(ctx context.Context, count int) { + for i := 0; i < count; i++ { + go m.worker(ctx, i) + } +} + +// worker is a long-running goroutine that claims and processes pentest scan jobs +func (m *Manager) worker(ctx context.Context, id int) { + log.Printf("Pentest worker %d started", id) + for { + select { + case <-ctx.Done(): + log.Printf("Pentest worker %d stopped", id) + return + default: + } + + job, err := m.store.ClaimNextJob(ctx) + if err != nil { + log.Printf("Pentest worker %d: claim error: %v", id, err) + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + } + continue + } + + if job == nil { + // No jobs available, wait before polling again + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + } + continue + } + + log.Printf("Pentest worker %d: processing job %d (scan_run=%s, target=%s)", + id, job.ID, job.ScanRunID, job.targetLabel()) + + if err := m.scanTarget(ctx, job); err != nil { + log.Printf("Pentest worker %d: job %d failed: %v", id, job.ID, err) + if failErr := m.store.FailJob(ctx, job.ID, err.Error()); failErr != nil { + log.Printf("Pentest worker %d: failed to mark job %d as failed: %v", id, job.ID, failErr) + } + } else { + if err := m.store.CompleteJob(ctx, job.ID); err != nil { + log.Printf("Pentest worker %d: failed to mark job %d as completed: %v", id, job.ID, err) + } + log.Printf("Pentest worker %d: job %d completed", id, job.ID) + } + + // Check if the scan run is fully done + m.tryFinalizeScanRun(ctx, job.ScanRunID) + } +} + +// scanTarget runs all enabled modules against a single target and saves findings +func (m *Manager) scanTarget(ctx context.Context, job *PentestScanJob) error { + target := job.ToScanTarget() + var allFindings []Finding + + m.mu.RLock() + modules := make([]Module, len(m.modules)) + copy(modules, m.modules) + m.mu.RUnlock() + + for _, module := range modules { + findings, err := module.Scan(ctx, target) + if err != nil { + log.Printf("Pentest scan %s: module %s error on %s: %v", + job.ScanRunID, module.Name(), job.targetLabel(), err) + continue + } + allFindings = append(allFindings, findings...) + } + + // Deduplicate by fingerprint + seen := make(map[string]bool) + var deduped []Finding + for _, f := range allFindings { + if !seen[f.Fingerprint] { + seen[f.Fingerprint] = true + deduped = append(deduped, f) + } + } + + if len(deduped) > 0 { + if err := m.store.SaveFindings(ctx, job.ScanRunID, deduped); err != nil { + return fmt.Errorf("failed to save findings: %w", err) + } + } + + return nil +} + +// tryFinalizeScanRun checks if all jobs for a scan run are done, and if so, finalizes it +func (m *Manager) tryFinalizeScanRun(ctx context.Context, scanRunID string) { + pending, err := m.store.CountPendingJobs(ctx, scanRunID) + if err != nil { + log.Printf("Pentest finalize: failed to count pending jobs for %s: %v", scanRunID, err) + return + } + if pending > 0 { + return + } + + m.finalizeScanRun(ctx, scanRunID) +} + +// finalizeScanRun counts findings, updates the scan run status, marks resolved findings, and records metrics +func (m *Manager) finalizeScanRun(ctx context.Context, scanRunID string) { + run, err := m.store.GetScanRun(ctx, scanRunID) + if err != nil { + log.Printf("Pentest finalize: failed to get scan run %s: %v", scanRunID, err) + return + } + + // Already finalized (another worker may have beaten us) + if run.Status == "completed" || run.Status == "failed" { + return + } + + // Count findings by severity from pentest_findings where last_scan_run_id = scanRunID + bySeverity, err := m.store.CountFindingsBySeverity(ctx, scanRunID) + if err != nil { + log.Printf("Pentest finalize: failed to count findings for %s: %v", scanRunID, err) + } + + // Collect fingerprints seen in this scan run + seenFingerprints, err := m.store.GetFingerprintsForScanRun(ctx, scanRunID) + if err != nil { + log.Printf("Pentest finalize: failed to get fingerprints for %s: %v", scanRunID, err) + } + + // Mark resolved findings + if err := m.store.MarkResolved(ctx, scanRunID, seenFingerprints); err != nil { + log.Printf("Pentest finalize: failed to mark resolved for %s: %v", scanRunID, err) + } + + // Update scan run to completed + now := time.Now() + m.store.UpdateScanRun(ctx, &ScanRun{ + ID: scanRunID, + Status: "completed", + TargetsCount: run.TargetsCount, + CriticalCount: bySeverity["critical"], + HighCount: bySeverity["high"], + MediumCount: bySeverity["medium"], + LowCount: bySeverity["low"], + InfoCount: bySeverity["info"], + CompletedAt: &now, + }) + + // Record metrics + duration := now.Sub(run.StartedAt) + if m.metricsRecorder != nil { + m.metricsRecorder.RecordScanResults(ctx, duration, run.TargetsCount) + } + + totalFindings := bySeverity["critical"] + bySeverity["high"] + bySeverity["medium"] + bySeverity["low"] + bySeverity["info"] + log.Printf("Pentest scan %s completed: %d targets, %d findings (critical=%d, high=%d, medium=%d, low=%d, info=%d) in %s", + scanRunID, run.TargetsCount, totalFindings, + bySeverity["critical"], bySeverity["high"], bySeverity["medium"], bySeverity["low"], bySeverity["info"], + duration.Truncate(time.Second)) +} + +// GetModules returns the list of enabled modules +func (m *Manager) GetModules() []Module { + m.mu.RLock() + defer m.mu.RUnlock() + result := make([]Module, len(m.modules)) + copy(result, m.modules) + return result +} + +// NucleiAvailable returns whether Nuclei is installed +func (m *Manager) NucleiAvailable() bool { + m.mu.RLock() + defer m.mu.RUnlock() + for _, mod := range m.modules { + if mod.Name() == "nuclei" { + return true + } + } + return false +} + +// TrivyAvailable returns whether Trivy is installed +func (m *Manager) TrivyAvailable() bool { + m.mu.RLock() + defer m.mu.RUnlock() + for _, mod := range m.modules { + if mod.Name() == "trivy" { + return true + } + } + return false +} + +// RefreshExternalModules re-checks LookPath for external tools +// and appends newly available modules. +func (m *Manager) RefreshExternalModules() { + m.mu.Lock() + defer m.mu.Unlock() + + hasModule := func(name string) bool { + for _, mod := range m.modules { + if mod.Name() == name { + return true + } + } + return false + } + + if !hasModule("nuclei") { + if _, err := exec.LookPath("nuclei"); err == nil { + nuclei := NewNucleiModule() + if nuclei.Available() { + m.modules = append(m.modules, nuclei) + log.Printf("Pentest: Nuclei scanner now available (hot-reload)") + } + } + } + + if !hasModule("trivy") { + if _, err := exec.LookPath("trivy"); err == nil { + trivy := NewTrivyModule(m.incusClient) + if trivy.Available() { + m.modules = append(m.modules, trivy) + log.Printf("Pentest: Trivy scanner now available (hot-reload)") + } + } + } +} + +// Interval returns the configured scan interval +func (m *Manager) Interval() time.Duration { + return m.config.Interval +} diff --git a/internal/pentest/metrics.go b/internal/pentest/metrics.go new file mode 100644 index 0000000..50f65d6 --- /dev/null +++ b/internal/pentest/metrics.go @@ -0,0 +1,105 @@ +package pentest + +import ( + "context" + "log" + "time" + + "go.opentelemetry.io/otel/attribute" + otelmetric "go.opentelemetry.io/otel/metric" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" +) + +// MetricsRecorder records pentest findings as OTel gauge metrics +type MetricsRecorder struct { + store *Store + provider *sdkmetric.MeterProvider + + findingsOpen otelmetric.Int64Gauge + findingsTotal otelmetric.Int64Gauge + lastTimestamp otelmetric.Int64Gauge + durationSeconds otelmetric.Float64Gauge + targetsCount otelmetric.Int64Gauge +} + +// NewMetricsRecorder creates a new metrics recorder using an existing MeterProvider +func NewMetricsRecorder(store *Store, provider *sdkmetric.MeterProvider) (*MetricsRecorder, error) { + mr := &MetricsRecorder{ + store: store, + provider: provider, + } + if err := mr.initInstruments(); err != nil { + return nil, err + } + return mr, nil +} + +func (mr *MetricsRecorder) initInstruments() error { + meter := mr.provider.Meter("containarium.pentest") + var err error + + mr.findingsOpen, err = meter.Int64Gauge("pentest.findings.open", + otelmetric.WithDescription("Number of open pentest findings")) + if err != nil { + return err + } + + mr.findingsTotal, err = meter.Int64Gauge("pentest.findings.total", + otelmetric.WithDescription("Total number of pentest findings")) + if err != nil { + return err + } + + mr.lastTimestamp, err = meter.Int64Gauge("pentest.scan.last_timestamp", + otelmetric.WithDescription("Unix epoch of last completed scan"), + otelmetric.WithUnit("s")) + if err != nil { + return err + } + + mr.durationSeconds, err = meter.Float64Gauge("pentest.scan.duration_seconds", + otelmetric.WithDescription("Duration of last scan in seconds"), + otelmetric.WithUnit("s")) + if err != nil { + return err + } + + mr.targetsCount, err = meter.Int64Gauge("pentest.scan.targets_count", + otelmetric.WithDescription("Number of targets scanned")) + if err != nil { + return err + } + + return nil +} + +// RecordScanResults records metrics after a scan completes +func (mr *MetricsRecorder) RecordScanResults(ctx context.Context, duration time.Duration, numTargets int) { + // Record scan metadata + mr.lastTimestamp.Record(ctx, time.Now().Unix()) + mr.durationSeconds.Record(ctx, duration.Seconds()) + mr.targetsCount.Record(ctx, int64(numTargets)) + + // Query open finding counts from the store + bySeverity, byCategory, total, err := mr.store.GetOpenFindingCounts(ctx) + if err != nil { + log.Printf("Pentest metrics: failed to get finding counts: %v", err) + return + } + + // Record open findings by severity + for _, sev := range []string{"critical", "high", "medium", "low", "info"} { + count := bySeverity[sev] + attrs := otelmetric.WithAttributes(attribute.String("severity", sev)) + mr.findingsOpen.Record(ctx, count, attrs) + } + + // Record open findings by category + for cat, count := range byCategory { + attrs := otelmetric.WithAttributes(attribute.String("category", cat)) + mr.findingsOpen.Record(ctx, count, attrs) + } + + // Record total + mr.findingsTotal.Record(ctx, total) +} diff --git a/internal/pentest/module_dns.go b/internal/pentest/module_dns.go new file mode 100644 index 0000000..fe22985 --- /dev/null +++ b/internal/pentest/module_dns.go @@ -0,0 +1,103 @@ +package pentest + +import ( + "context" + "fmt" + "net" + "strings" + "time" +) + +// DNSModule checks for DNS-related security issues +type DNSModule struct{} + +func NewDNSModule() *DNSModule { return &DNSModule{} } + +func (m *DNSModule) Name() string { return "dns" } + +func (m *DNSModule) Scan(ctx context.Context, target ScanTarget) ([]Finding, error) { + if target.FullDomain == "" { + return nil, nil + } + + var findings []Finding + domain := target.FullDomain + + // Check for dangling CNAME (subdomain takeover) + cnames, err := net.LookupCNAME(domain) + if err == nil && cnames != "" && cnames != domain+"." { + // Check if the CNAME target resolves + _, err := net.LookupHost(strings.TrimSuffix(cnames, ".")) + if err != nil { + f := NewFinding("dns", "high", "Dangling CNAME (potential subdomain takeover)", domain) + f.Description = fmt.Sprintf("The domain %s has a CNAME pointing to %s which does not resolve.", domain, cnames) + f.Evidence = fmt.Sprintf("CNAME: %s -> %s (NXDOMAIN)", domain, cnames) + f.Remediation = "Remove the dangling CNAME record or point it to a valid target." + findings = append(findings, f) + } + } + + // Check SPF, DMARC, DKIM for the base domain + // Extract base domain (e.g., "app.containarium.dev" -> "containarium.dev") + parts := strings.Split(domain, ".") + var baseDomain string + if len(parts) >= 2 { + baseDomain = strings.Join(parts[len(parts)-2:], ".") + } else { + baseDomain = domain + } + + resolver := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{Timeout: 5 * time.Second} + return d.DialContext(ctx, "udp", "8.8.8.8:53") + }, + } + + // Check SPF + txts, err := resolver.LookupTXT(ctx, baseDomain) + if err == nil { + hasSPF := false + for _, txt := range txts { + if strings.HasPrefix(txt, "v=spf1") { + hasSPF = true + break + } + } + if !hasSPF { + f := NewFinding("dns", "medium", "Missing SPF record", baseDomain) + f.Description = "No SPF record found for the domain. This allows email spoofing." + f.Evidence = fmt.Sprintf("No TXT record starting with 'v=spf1' for %s", baseDomain) + f.Remediation = "Add an SPF TXT record to specify authorized mail servers." + findings = append(findings, f) + } + } + + // Check DMARC + dmarcTxts, err := resolver.LookupTXT(ctx, "_dmarc."+baseDomain) + if err != nil || len(dmarcTxts) == 0 { + f := NewFinding("dns", "medium", "Missing DMARC record", baseDomain) + f.Description = "No DMARC record found for the domain. This weakens email authentication." + f.Evidence = fmt.Sprintf("No TXT record at _dmarc.%s", baseDomain) + f.Remediation = "Add a DMARC TXT record at _dmarc." + baseDomain + findings = append(findings, f) + } else { + hasDMARC := false + for _, txt := range dmarcTxts { + if strings.HasPrefix(txt, "v=DMARC1") { + hasDMARC = true + break + } + } + if !hasDMARC { + f := NewFinding("dns", "medium", "Invalid DMARC record", baseDomain) + f.Description = "DMARC record exists but does not start with 'v=DMARC1'." + f.Evidence = fmt.Sprintf("TXT at _dmarc.%s: %s", baseDomain, strings.Join(dmarcTxts, "; ")) + f.Remediation = "Fix the DMARC record to start with 'v=DMARC1'." + findings = append(findings, f) + } + } + + return findings, nil +} diff --git a/internal/pentest/module_headers.go b/internal/pentest/module_headers.go new file mode 100644 index 0000000..e7f3736 --- /dev/null +++ b/internal/pentest/module_headers.go @@ -0,0 +1,112 @@ +package pentest + +import ( + "context" + "fmt" + "net/http" + "time" +) + +// HeadersModule checks for missing or misconfigured security headers +type HeadersModule struct{} + +func NewHeadersModule() *HeadersModule { return &HeadersModule{} } + +func (m *HeadersModule) Name() string { return "headers" } + +func (m *HeadersModule) Scan(ctx context.Context, target ScanTarget) ([]Finding, error) { + if target.FullDomain == "" { + return nil, nil // skip non-route targets + } + + url := target.URL() + client := &http.Client{Timeout: 10 * time.Second} + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("headers: failed to create request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, nil // target unreachable, not a finding + } + defer resp.Body.Close() + + var findings []Finding + + checks := []struct { + header string + severity string + title string + remediation string + }{ + { + header: "Strict-Transport-Security", + severity: "medium", + title: "Missing HSTS header", + remediation: "Add 'Strict-Transport-Security: max-age=31536000; includeSubDomains' header.", + }, + { + header: "Content-Security-Policy", + severity: "medium", + title: "Missing Content-Security-Policy header", + remediation: "Add a Content-Security-Policy header to prevent XSS and data injection attacks.", + }, + { + header: "X-Frame-Options", + severity: "medium", + title: "Missing X-Frame-Options header", + remediation: "Add 'X-Frame-Options: DENY' or 'SAMEORIGIN' to prevent clickjacking.", + }, + { + header: "X-Content-Type-Options", + severity: "low", + title: "Missing X-Content-Type-Options header", + remediation: "Add 'X-Content-Type-Options: nosniff' to prevent MIME-type sniffing.", + }, + { + header: "Referrer-Policy", + severity: "low", + title: "Missing Referrer-Policy header", + remediation: "Add 'Referrer-Policy: strict-origin-when-cross-origin' or stricter.", + }, + { + header: "Permissions-Policy", + severity: "low", + title: "Missing Permissions-Policy header", + remediation: "Add a Permissions-Policy header to control browser feature access.", + }, + } + + for _, check := range checks { + if resp.Header.Get(check.header) == "" { + f := NewFinding("headers", check.severity, check.title, url) + f.Description = fmt.Sprintf("The %s header is missing from the response.", check.header) + f.Evidence = fmt.Sprintf("GET %s -> HTTP %d, header '%s' not present", url, resp.StatusCode, check.header) + f.Remediation = check.remediation + findings = append(findings, f) + } + } + + // Check for server version disclosure + server := resp.Header.Get("Server") + if server != "" { + f := NewFinding("headers", "low", "Server version disclosure", url) + f.Description = "The Server header reveals software version information." + f.Evidence = fmt.Sprintf("Server: %s", server) + f.Remediation = "Remove or genericize the Server header to prevent information leakage." + findings = append(findings, f) + } + + xPoweredBy := resp.Header.Get("X-Powered-By") + if xPoweredBy != "" { + f := NewFinding("headers", "low", "X-Powered-By disclosure", url) + f.Description = "The X-Powered-By header reveals technology stack information." + f.Evidence = fmt.Sprintf("X-Powered-By: %s", xPoweredBy) + f.Remediation = "Remove the X-Powered-By header to prevent information leakage." + findings = append(findings, f) + } + + return findings, nil +} diff --git a/internal/pentest/module_headers_test.go b/internal/pentest/module_headers_test.go new file mode 100644 index 0000000..dfd3176 --- /dev/null +++ b/internal/pentest/module_headers_test.go @@ -0,0 +1,141 @@ +package pentest + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHeadersModule_NoSecurityHeaders(t *testing.T) { + // Server with no security headers + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + module := NewHeadersModule() + assert.Equal(t, "headers", module.Name()) + + // Direct test with a non-TLS server for simplicity + srvHTTP := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srvHTTP.Close() + + // Test finding generation logic manually + findings, err := scanHeadersForURL(context.Background(), srvHTTP.URL) + require.NoError(t, err) + + // Should find missing HSTS, CSP, X-Frame-Options, etc. + assert.True(t, len(findings) >= 4, "expected at least 4 missing header findings, got %d", len(findings)) + + // Verify finding structure + for _, f := range findings { + assert.Equal(t, "headers", f.Category) + assert.NotEmpty(t, f.Fingerprint) + assert.NotEmpty(t, f.Title) + assert.NotEmpty(t, f.Evidence) + assert.NotEmpty(t, f.Remediation) + } +} + +func TestHeadersModule_AllSecurityHeaders(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Strict-Transport-Security", "max-age=31536000") + w.Header().Set("Content-Security-Policy", "default-src 'self'") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "strict-origin") + w.Header().Set("Permissions-Policy", "camera=()") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + findings, err := scanHeadersForURL(context.Background(), srv.URL) + require.NoError(t, err) + assert.Empty(t, findings, "expected no findings when all security headers are present") +} + +func TestHeadersModule_ServerDisclosure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Strict-Transport-Security", "max-age=31536000") + w.Header().Set("Content-Security-Policy", "default-src 'self'") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "strict-origin") + w.Header().Set("Permissions-Policy", "camera=()") + w.Header().Set("Server", "Apache/2.4.51") + w.Header().Set("X-Powered-By", "PHP/8.1") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + findings, err := scanHeadersForURL(context.Background(), srv.URL) + require.NoError(t, err) + assert.Len(t, findings, 2, "expected Server and X-Powered-By disclosure findings") +} + +func TestHeadersModule_SkipsNonRouteTargets(t *testing.T) { + module := NewHeadersModule() + findings, err := module.Scan(context.Background(), ScanTarget{ + IP: "10.100.0.5", + Port: 3000, + }) + require.NoError(t, err) + assert.Nil(t, findings, "should skip non-route targets") +} + +// scanHeadersForURL is a test helper that runs the headers check logic against a URL +func scanHeadersForURL(ctx context.Context, url string) ([]Finding, error) { + client := &http.Client{} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var findings []Finding + checks := []struct { + header string + severity string + title string + remediation string + }{ + {"Strict-Transport-Security", "medium", "Missing HSTS header", "Add HSTS header."}, + {"Content-Security-Policy", "medium", "Missing Content-Security-Policy header", "Add CSP header."}, + {"X-Frame-Options", "medium", "Missing X-Frame-Options header", "Add X-Frame-Options."}, + {"X-Content-Type-Options", "low", "Missing X-Content-Type-Options header", "Add nosniff."}, + {"Referrer-Policy", "low", "Missing Referrer-Policy header", "Add Referrer-Policy."}, + {"Permissions-Policy", "low", "Missing Permissions-Policy header", "Add Permissions-Policy."}, + } + + for _, check := range checks { + if resp.Header.Get(check.header) == "" { + f := NewFinding("headers", check.severity, check.title, url) + f.Evidence = check.header + " not present" + f.Remediation = check.remediation + findings = append(findings, f) + } + } + + if server := resp.Header.Get("Server"); server != "" { + f := NewFinding("headers", "low", "Server version disclosure", url) + f.Evidence = "Server: " + server + findings = append(findings, f) + } + if xpb := resp.Header.Get("X-Powered-By"); xpb != "" { + f := NewFinding("headers", "low", "X-Powered-By disclosure", url) + f.Evidence = "X-Powered-By: " + xpb + findings = append(findings, f) + } + + return findings, nil +} diff --git a/internal/pentest/module_ports.go b/internal/pentest/module_ports.go new file mode 100644 index 0000000..e862eca --- /dev/null +++ b/internal/pentest/module_ports.go @@ -0,0 +1,74 @@ +package pentest + +import ( + "context" + "fmt" + "net" + "time" +) + +// PortsModule scans for unexpectedly open ports on container IPs +type PortsModule struct{} + +func NewPortsModule() *PortsModule { return &PortsModule{} } + +func (m *PortsModule) Name() string { return "ports" } + +// commonPorts are ports that should not typically be exposed from user containers +var commonPorts = []struct { + port int + service string + severity string +}{ + {22, "SSH", "medium"}, + {23, "Telnet", "high"}, + {3306, "MySQL", "high"}, + {5432, "PostgreSQL", "high"}, + {6379, "Redis", "high"}, + {27017, "MongoDB", "high"}, + {9200, "Elasticsearch", "high"}, + {11211, "Memcached", "high"}, + {2375, "Docker API (unencrypted)", "critical"}, + {2376, "Docker API", "high"}, + {5900, "VNC", "high"}, + {8080, "HTTP Alt", "medium"}, + {8443, "HTTPS Alt", "medium"}, + {9090, "Prometheus", "medium"}, + {15672, "RabbitMQ Management", "medium"}, +} + +func (m *PortsModule) Scan(ctx context.Context, target ScanTarget) ([]Finding, error) { + if target.IP == "" { + return nil, nil + } + + var findings []Finding + + for _, p := range commonPorts { + // Skip the declared target port + if p.port == target.Port { + continue + } + + addr := net.JoinHostPort(target.IP, fmt.Sprintf("%d", p.port)) + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + continue // port closed or filtered + } + conn.Close() + + targetStr := addr + if target.ContainerName != "" { + targetStr = fmt.Sprintf("%s (%s)", addr, target.ContainerName) + } + + f := NewFinding("ports", p.severity, + fmt.Sprintf("Undeclared open port: %d (%s)", p.port, p.service), targetStr) + f.Description = fmt.Sprintf("Port %d (%s) is open on %s but not declared as an exposed service.", p.port, p.service, target.IP) + f.Evidence = fmt.Sprintf("TCP connect to %s succeeded", addr) + f.Remediation = fmt.Sprintf("If %s is not needed, close port %d. If intentional, declare it as an exposed route.", p.service, p.port) + findings = append(findings, f) + } + + return findings, nil +} diff --git a/internal/pentest/module_tls.go b/internal/pentest/module_tls.go new file mode 100644 index 0000000..09e489e --- /dev/null +++ b/internal/pentest/module_tls.go @@ -0,0 +1,100 @@ +package pentest + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "strings" + "time" +) + +// TLSModule checks TLS configuration for security issues +type TLSModule struct{} + +func NewTLSModule() *TLSModule { return &TLSModule{} } + +func (m *TLSModule) Name() string { return "tls" } + +func (m *TLSModule) Scan(ctx context.Context, target ScanTarget) ([]Finding, error) { + if target.FullDomain == "" { + return nil, nil // skip non-route targets + } + + host := target.FullDomain + addr := fmt.Sprintf("%s:443", host) + + dialer := &net.Dialer{Timeout: 10 * time.Second} + conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{ + InsecureSkipVerify: true, // We inspect the cert ourselves + }) + if err != nil { + return nil, nil // TLS not available or unreachable + } + defer conn.Close() + + state := conn.ConnectionState() + var findings []Finding + targetStr := fmt.Sprintf("https://%s", host) + + // Check TLS version + switch state.Version { + case tls.VersionTLS10: + f := NewFinding("tls", "critical", "TLS 1.0 in use", targetStr) + f.Description = "The server supports TLS 1.0, which has known vulnerabilities." + f.Evidence = "Negotiated protocol: TLS 1.0" + f.Remediation = "Disable TLS 1.0 and require TLS 1.2 or higher." + findings = append(findings, f) + case tls.VersionTLS11: + f := NewFinding("tls", "high", "TLS 1.1 in use", targetStr) + f.Description = "The server supports TLS 1.1, which is deprecated." + f.Evidence = "Negotiated protocol: TLS 1.1" + f.Remediation = "Disable TLS 1.1 and require TLS 1.2 or higher." + findings = append(findings, f) + } + + // Check cipher suites for known weak ones + cipherName := tls.CipherSuiteName(state.CipherSuite) + weakPrefixes := []string{"TLS_RSA_", "TLS_ECDHE_RSA_WITH_RC4", "TLS_ECDHE_RSA_WITH_3DES"} + for _, prefix := range weakPrefixes { + if strings.HasPrefix(cipherName, prefix) { + f := NewFinding("tls", "high", "Weak cipher suite", targetStr) + f.Description = fmt.Sprintf("The server negotiated a weak cipher suite: %s", cipherName) + f.Evidence = fmt.Sprintf("Cipher: %s", cipherName) + f.Remediation = "Configure the server to only use strong cipher suites (AEAD-based)." + findings = append(findings, f) + break + } + } + + // Check certificate expiry + if len(state.PeerCertificates) > 0 { + cert := state.PeerCertificates[0] + daysUntilExpiry := time.Until(cert.NotAfter).Hours() / 24 + + if daysUntilExpiry < 0 { + f := NewFinding("tls", "critical", "TLS certificate expired", targetStr) + f.Description = "The TLS certificate has expired." + f.Evidence = fmt.Sprintf("Certificate expired at: %s", cert.NotAfter.Format(time.RFC3339)) + f.Remediation = "Renew the TLS certificate immediately." + findings = append(findings, f) + } else if daysUntilExpiry < 30 { + f := NewFinding("tls", "medium", "TLS certificate expiring soon", targetStr) + f.Description = fmt.Sprintf("The TLS certificate expires in %.0f days.", daysUntilExpiry) + f.Evidence = fmt.Sprintf("Certificate expires at: %s (%.0f days)", cert.NotAfter.Format(time.RFC3339), daysUntilExpiry) + f.Remediation = "Renew the TLS certificate before it expires." + findings = append(findings, f) + } + + // Check self-signed + if cert.Issuer.String() == cert.Subject.String() { + f := NewFinding("tls", "medium", "Self-signed TLS certificate", targetStr) + f.Description = "The TLS certificate is self-signed and will not be trusted by browsers." + f.Evidence = fmt.Sprintf("Issuer: %s, Subject: %s", cert.Issuer, cert.Subject) + f.Remediation = "Use a certificate from a trusted Certificate Authority." + findings = append(findings, f) + } + } + + return findings, nil +} diff --git a/internal/pentest/module_web.go b/internal/pentest/module_web.go new file mode 100644 index 0000000..3e421cd --- /dev/null +++ b/internal/pentest/module_web.go @@ -0,0 +1,178 @@ +package pentest + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// WebModule checks for common web application misconfigurations and exposures +type WebModule struct{} + +func NewWebModule() *WebModule { return &WebModule{} } + +func (m *WebModule) Name() string { return "web" } + +type webCheck struct { + path string + title string + severity string + description string + remediation string + // matchFunc returns true if the response indicates a finding + matchFunc func(statusCode int, body string, headers http.Header) bool +} + +var webChecks = []webCheck{ + { + path: "/.env", + title: "Exposed .env file", + severity: "critical", + description: "The .env file is publicly accessible and may contain secrets, API keys, and database credentials.", + remediation: "Block access to .env files in your web server configuration.", + matchFunc: func(status int, body string, _ http.Header) bool { + return status == 200 && (strings.Contains(body, "DB_") || strings.Contains(body, "API_KEY") || + strings.Contains(body, "SECRET") || strings.Contains(body, "PASSWORD")) + }, + }, + { + path: "/.git/HEAD", + title: "Exposed .git directory", + severity: "high", + description: "The .git directory is accessible, potentially exposing source code and commit history.", + remediation: "Block access to .git directories in your web server configuration.", + matchFunc: func(status int, body string, _ http.Header) bool { + return status == 200 && strings.Contains(body, "ref:") + }, + }, + { + path: "/debug/pprof/", + title: "Exposed Go pprof endpoint", + severity: "high", + description: "Go pprof debug endpoint is publicly accessible, leaking runtime profiling data.", + remediation: "Remove or restrict access to /debug/pprof/ endpoints in production.", + matchFunc: func(status int, body string, _ http.Header) bool { + return status == 200 && strings.Contains(body, "pprof") + }, + }, + { + path: "/actuator/", + title: "Exposed Spring Boot Actuator", + severity: "high", + description: "Spring Boot Actuator endpoints are accessible, potentially exposing sensitive runtime info.", + remediation: "Restrict access to actuator endpoints or disable them in production.", + matchFunc: func(status int, body string, _ http.Header) bool { + return status == 200 && (strings.Contains(body, "actuator") || strings.Contains(body, "health")) + }, + }, + { + path: "/_next/static/chunks/app/layout.js.map", + title: "Exposed Next.js source maps", + severity: "medium", + description: "Next.js source maps are accessible, allowing attackers to read the original source code.", + remediation: "Disable source maps in production by setting productionBrowserSourceMaps: false in next.config.js.", + matchFunc: func(status int, body string, headers http.Header) bool { + ct := headers.Get("Content-Type") + return status == 200 && (strings.Contains(ct, "json") || strings.Contains(body, "mappings")) + }, + }, + { + path: "/_next/data/", + title: "Exposed Next.js data directory", + severity: "medium", + description: "Next.js data directory listing is accessible.", + remediation: "Ensure directory listing is disabled and Next.js data routes are properly configured.", + matchFunc: func(status int, body string, _ http.Header) bool { + return status == 200 && strings.Contains(body, "Index of") + }, + }, + { + path: "/server-status", + title: "Exposed Apache server-status", + severity: "medium", + description: "Apache server-status page is publicly accessible, revealing server internals.", + remediation: "Restrict /server-status to localhost or remove the mod_status module.", + matchFunc: func(status int, body string, _ http.Header) bool { + return status == 200 && strings.Contains(body, "Apache Server Status") + }, + }, + { + path: "/wp-login.php", + title: "WordPress login page exposed", + severity: "info", + description: "WordPress login page is publicly accessible.", + remediation: "Consider restricting access to wp-login.php or using a security plugin.", + matchFunc: func(status int, body string, _ http.Header) bool { + return status == 200 && strings.Contains(body, "wp-login") + }, + }, +} + +func (m *WebModule) Scan(ctx context.Context, target ScanTarget) ([]Finding, error) { + if target.FullDomain == "" { + return nil, nil // skip non-route targets + } + + baseURL := target.URL() + client := &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return http.ErrUseLastResponse + } + return nil + }, + } + + var findings []Finding + + for _, check := range webChecks { + checkURL := baseURL + check.path + req, err := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, nil) + if err != nil { + continue + } + req.Header.Set("User-Agent", "Containarium-PentestScanner/1.0") + + resp, err := client.Do(req) + if err != nil { + continue + } + + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + body := string(bodyBytes) + + if check.matchFunc(resp.StatusCode, body, resp.Header) { + f := NewFinding("web", check.severity, check.title, checkURL) + f.Description = check.description + f.Evidence = fmt.Sprintf("GET %s -> HTTP %d", checkURL, resp.StatusCode) + f.Remediation = check.remediation + findings = append(findings, f) + } + } + + // Check for directory listing on root + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/", nil) + if err == nil { + req.Header.Set("User-Agent", "Containarium-PentestScanner/1.0") + resp, err := client.Do(req) + if err == nil { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + body := string(bodyBytes) + if resp.StatusCode == 200 && strings.Contains(body, "Index of /") { + f := NewFinding("web", "medium", "Directory listing enabled", baseURL+"/") + f.Description = "Directory listing is enabled, potentially exposing file structure." + f.Evidence = fmt.Sprintf("GET %s/ -> HTTP 200 with 'Index of /'", baseURL) + f.Remediation = "Disable directory listing in your web server configuration." + findings = append(findings, f) + } + } + } + + return findings, nil +} diff --git a/internal/pentest/module_web_test.go b/internal/pentest/module_web_test.go new file mode 100644 index 0000000..f6e29b9 --- /dev/null +++ b/internal/pentest/module_web_test.go @@ -0,0 +1,158 @@ +package pentest + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWebModule_ExposedEnvFile(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/.env" { + w.WriteHeader(http.StatusOK) + w.Write([]byte("DB_HOST=localhost\nDB_PASSWORD=secret\nAPI_KEY=abc123")) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + // Use scanWebForURL helper to test against the httptest server + findings := scanWebForURL(t, srv.URL) + + var found bool + for _, f := range findings { + if f.Title == "Exposed .env file" { + found = true + assert.Equal(t, "critical", f.Severity) + assert.Equal(t, "web", f.Category) + } + } + assert.True(t, found, "expected to find 'Exposed .env file' finding") +} + +func TestWebModule_ExposedGitDir(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/.git/HEAD" { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ref: refs/heads/main")) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + findings := scanWebForURL(t, srv.URL) + + var found bool + for _, f := range findings { + if f.Title == "Exposed .git directory" { + found = true + assert.Equal(t, "high", f.Severity) + } + } + assert.True(t, found, "expected to find 'Exposed .git directory' finding") +} + +func TestWebModule_NoExposures(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + findings := scanWebForURL(t, srv.URL) + assert.Empty(t, findings, "expected no findings when nothing is exposed") +} + +func TestWebModule_SkipsNonRouteTargets(t *testing.T) { + module := NewWebModule() + findings, err := module.Scan(context.Background(), ScanTarget{ + IP: "10.100.0.5", + Port: 3000, + }) + require.NoError(t, err) + assert.Nil(t, findings) +} + +func TestWebModule_DirectoryListing(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + w.WriteHeader(http.StatusOK) + w.Write([]byte("Index of /")) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + findings := scanWebForURL(t, srv.URL) + + var found bool + for _, f := range findings { + if f.Title == "Directory listing enabled" { + found = true + assert.Equal(t, "medium", f.Severity) + } + } + assert.True(t, found, "expected to find 'Directory listing enabled' finding") +} + +// scanWebForURL runs the web checks against a test server URL +func scanWebForURL(t *testing.T, baseURL string) []Finding { + t.Helper() + client := &http.Client{} + var findings []Finding + + for _, check := range webChecks { + checkURL := baseURL + check.path + req, err := http.NewRequest(http.MethodGet, checkURL, nil) + require.NoError(t, err) + req.Header.Set("User-Agent", "test") + + resp, err := client.Do(req) + require.NoError(t, err) + + bodyBytes := make([]byte, 4096) + n, _ := resp.Body.Read(bodyBytes) + resp.Body.Close() + body := string(bodyBytes[:n]) + + if check.matchFunc(resp.StatusCode, body, resp.Header) { + f := NewFinding("web", check.severity, check.title, checkURL) + findings = append(findings, f) + } + } + + // Check directory listing + req, err := http.NewRequest(http.MethodGet, baseURL+"/", nil) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + bodyBytes := make([]byte, 4096) + n, _ := resp.Body.Read(bodyBytes) + resp.Body.Close() + body := string(bodyBytes[:n]) + if resp.StatusCode == 200 && len(body) > 0 && contains(body, "Index of /") { + f := NewFinding("web", "medium", "Directory listing enabled", baseURL+"/") + findings = append(findings, f) + } + + return findings +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/pentest/scanner.go b/internal/pentest/scanner.go new file mode 100644 index 0000000..18d61fa --- /dev/null +++ b/internal/pentest/scanner.go @@ -0,0 +1,74 @@ +package pentest + +import ( + "context" + "crypto/sha256" + "fmt" +) + +// ScanTarget represents a target to be scanned +type ScanTarget struct { + FullDomain string // e.g., "myapp.containarium.dev" + IP string // e.g., "10.100.0.5" + Port int // e.g., 3000 + Protocol string // "http" or "grpc" + ContainerName string // e.g., "user1-container" + TargetType string // "route" or "container" +} + +// URL returns the HTTP(S) URL for this target +func (t ScanTarget) URL() string { + if t.FullDomain != "" { + return fmt.Sprintf("https://%s", t.FullDomain) + } + return fmt.Sprintf("http://%s:%d", t.IP, t.Port) +} + +// HostPort returns "host:port" for TCP connections +func (t ScanTarget) HostPort() string { + if t.FullDomain != "" { + return fmt.Sprintf("%s:443", t.FullDomain) + } + return fmt.Sprintf("%s:%d", t.IP, t.Port) +} + +// Finding represents a single security finding from a scanner module +type Finding struct { + Fingerprint string // SHA-256 of category|target|title + Category string // module name (e.g., "headers", "tls", "nuclei") + Severity string // "critical", "high", "medium", "low", "info" + Title string + Description string + Target string // URL, IP:port, or domain + Evidence string + CVEIDs string // comma-separated + Remediation string +} + +// NewFinding creates a finding with an auto-generated fingerprint +func NewFinding(category, severity, title, target string) Finding { + fp := Fingerprint(category, target, title) + return Finding{ + Fingerprint: fp, + Category: category, + Severity: severity, + Title: title, + Target: target, + } +} + +// Fingerprint generates a SHA-256 fingerprint for deduplication +func Fingerprint(category, target, title string) string { + data := fmt.Sprintf("%s|%s|%s", category, target, title) + hash := sha256.Sum256([]byte(data)) + return fmt.Sprintf("%x", hash) +} + +// Module is the interface that all scanner modules implement +type Module interface { + // Name returns the module identifier (e.g., "headers", "tls") + Name() string + + // Scan runs the module against a target and returns findings + Scan(ctx context.Context, target ScanTarget) ([]Finding, error) +} diff --git a/internal/pentest/scanner_test.go b/internal/pentest/scanner_test.go new file mode 100644 index 0000000..1132f8a --- /dev/null +++ b/internal/pentest/scanner_test.go @@ -0,0 +1,55 @@ +package pentest + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFingerprint_Deterministic(t *testing.T) { + fp1 := Fingerprint("headers", "https://example.com", "Missing HSTS header") + fp2 := Fingerprint("headers", "https://example.com", "Missing HSTS header") + assert.Equal(t, fp1, fp2, "same input should produce same fingerprint") +} + +func TestFingerprint_DifferentInputs(t *testing.T) { + fp1 := Fingerprint("headers", "https://example.com", "Missing HSTS header") + fp2 := Fingerprint("headers", "https://other.com", "Missing HSTS header") + fp3 := Fingerprint("tls", "https://example.com", "Missing HSTS header") + + assert.NotEqual(t, fp1, fp2, "different targets should have different fingerprints") + assert.NotEqual(t, fp1, fp3, "different categories should have different fingerprints") +} + +func TestNewFinding(t *testing.T) { + f := NewFinding("web", "critical", "Exposed .env file", "https://example.com/.env") + assert.Equal(t, "web", f.Category) + assert.Equal(t, "critical", f.Severity) + assert.Equal(t, "Exposed .env file", f.Title) + assert.Equal(t, "https://example.com/.env", f.Target) + assert.NotEmpty(t, f.Fingerprint) +} + +func TestScanTarget_URL(t *testing.T) { + t.Run("with domain", func(t *testing.T) { + target := ScanTarget{FullDomain: "app.example.com"} + assert.Equal(t, "https://app.example.com", target.URL()) + }) + + t.Run("without domain", func(t *testing.T) { + target := ScanTarget{IP: "10.100.0.5", Port: 3000} + assert.Equal(t, "http://10.100.0.5:3000", target.URL()) + }) +} + +func TestScanTarget_HostPort(t *testing.T) { + t.Run("with domain", func(t *testing.T) { + target := ScanTarget{FullDomain: "app.example.com"} + assert.Equal(t, "app.example.com:443", target.HostPort()) + }) + + t.Run("without domain", func(t *testing.T) { + target := ScanTarget{IP: "10.100.0.5", Port: 3000} + assert.Equal(t, "10.100.0.5:3000", target.HostPort()) + }) +} diff --git a/internal/pentest/store.go b/internal/pentest/store.go new file mode 100644 index 0000000..42a8f40 --- /dev/null +++ b/internal/pentest/store.go @@ -0,0 +1,767 @@ +package pentest + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store handles persistent storage of pentest scan runs and findings +type Store struct { + pool *pgxpool.Pool +} + +// NewStore creates a new pentest store connected to PostgreSQL +func NewStore(ctx context.Context, pool *pgxpool.Pool) (*Store, error) { + store := &Store{pool: pool} + if err := store.initSchema(ctx); err != nil { + return nil, fmt.Errorf("failed to initialize pentest schema: %w", err) + } + return store, nil +} + +func (s *Store) initSchema(ctx context.Context) error { + schema := ` + CREATE TABLE IF NOT EXISTS pentest_scan_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + trigger TEXT NOT NULL DEFAULT 'manual', + status TEXT NOT NULL DEFAULT 'running', + modules TEXT NOT NULL DEFAULT '', + targets_count INTEGER NOT NULL DEFAULT 0, + critical_count INTEGER NOT NULL DEFAULT 0, + high_count INTEGER NOT NULL DEFAULT 0, + medium_count INTEGER NOT NULL DEFAULT 0, + low_count INTEGER NOT NULL DEFAULT 0, + info_count INTEGER NOT NULL DEFAULT 0, + error_message TEXT NOT NULL DEFAULT '', + started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + completed_at TIMESTAMP WITH TIME ZONE + ); + CREATE INDEX IF NOT EXISTS idx_pentest_scan_runs_started + ON pentest_scan_runs(started_at DESC); + CREATE INDEX IF NOT EXISTS idx_pentest_scan_runs_status + ON pentest_scan_runs(status); + + CREATE TABLE IF NOT EXISTS pentest_findings ( + id BIGSERIAL PRIMARY KEY, + fingerprint TEXT NOT NULL UNIQUE, + category TEXT NOT NULL, + severity TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + target TEXT NOT NULL, + evidence TEXT NOT NULL DEFAULT '', + cve_ids TEXT NOT NULL DEFAULT '', + remediation TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'open', + first_scan_run_id UUID REFERENCES pentest_scan_runs(id) ON DELETE SET NULL, + last_scan_run_id UUID REFERENCES pentest_scan_runs(id) ON DELETE SET NULL, + first_seen_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMP WITH TIME ZONE, + suppressed BOOLEAN NOT NULL DEFAULT false, + suppressed_reason TEXT NOT NULL DEFAULT '' + ); + CREATE INDEX IF NOT EXISTS idx_pentest_findings_severity + ON pentest_findings(severity); + CREATE INDEX IF NOT EXISTS idx_pentest_findings_category + ON pentest_findings(category); + CREATE INDEX IF NOT EXISTS idx_pentest_findings_status + ON pentest_findings(status); + CREATE INDEX IF NOT EXISTS idx_pentest_findings_fingerprint + ON pentest_findings(fingerprint); + + CREATE TABLE IF NOT EXISTS pentest_scan_jobs ( + id BIGSERIAL PRIMARY KEY, + scan_run_id UUID REFERENCES pentest_scan_runs(id) ON DELETE CASCADE, + target_domain TEXT NOT NULL DEFAULT '', + target_ip TEXT NOT NULL DEFAULT '', + target_port INTEGER NOT NULL DEFAULT 0, + target_protocol TEXT NOT NULL DEFAULT '', + container_name TEXT NOT NULL DEFAULT '', + target_type TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + retry_count INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 2, + error_message TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + started_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE + ); + CREATE INDEX IF NOT EXISTS idx_pentest_scan_jobs_status + ON pentest_scan_jobs(status); + CREATE INDEX IF NOT EXISTS idx_pentest_scan_jobs_scan_run_id + ON pentest_scan_jobs(scan_run_id); + CREATE INDEX IF NOT EXISTS idx_pentest_scan_jobs_created_at + ON pentest_scan_jobs(created_at DESC); + ` + _, err := s.pool.Exec(ctx, schema) + return err +} + +// ScanRun represents a pentest scan execution +type ScanRun struct { + ID string + Trigger string + Status string + Modules string + TargetsCount int + CriticalCount int + HighCount int + MediumCount int + LowCount int + InfoCount int + ErrorMessage string + StartedAt time.Time + CompletedAt *time.Time +} + +// FindingRecord represents a stored finding +type FindingRecord struct { + ID int64 + Fingerprint string + Category string + Severity string + Title string + Description string + Target string + Evidence string + CVEIDs string + Remediation string + Status string + FirstScanRunID *string + LastScanRunID *string + FirstSeenAt time.Time + LastSeenAt time.Time + ResolvedAt *time.Time + Suppressed bool + SuppressedReason string +} + +// CreateScanRun inserts a new scan run and returns its UUID +func (s *Store) CreateScanRun(ctx context.Context, trigger, modules string) (string, error) { + var id string + err := s.pool.QueryRow(ctx, + `INSERT INTO pentest_scan_runs (trigger, modules) VALUES ($1, $2) RETURNING id`, + trigger, modules, + ).Scan(&id) + if err != nil { + return "", fmt.Errorf("failed to create scan run: %w", err) + } + return id, nil +} + +// UpdateScanRun updates a scan run's status and counts +func (s *Store) UpdateScanRun(ctx context.Context, run *ScanRun) error { + _, err := s.pool.Exec(ctx, ` + UPDATE pentest_scan_runs + SET status = $2, targets_count = $3, + critical_count = $4, high_count = $5, medium_count = $6, + low_count = $7, info_count = $8, error_message = $9, + completed_at = $10 + WHERE id = $1 + `, run.ID, run.Status, run.TargetsCount, + run.CriticalCount, run.HighCount, run.MediumCount, + run.LowCount, run.InfoCount, run.ErrorMessage, + run.CompletedAt) + return err +} + +// ListScanRuns returns recent scan runs +func (s *Store) ListScanRuns(ctx context.Context, limit, offset int) ([]ScanRun, int32, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + var totalCount int32 + err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM pentest_scan_runs`).Scan(&totalCount) + if err != nil { + return nil, 0, fmt.Errorf("failed to count scan runs: %w", err) + } + + rows, err := s.pool.Query(ctx, ` + SELECT id, trigger, status, modules, targets_count, + critical_count, high_count, medium_count, low_count, info_count, + error_message, started_at, completed_at + FROM pentest_scan_runs + ORDER BY started_at DESC + LIMIT $1 OFFSET $2 + `, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to list scan runs: %w", err) + } + defer rows.Close() + + var runs []ScanRun + for rows.Next() { + var run ScanRun + if err := rows.Scan( + &run.ID, &run.Trigger, &run.Status, &run.Modules, &run.TargetsCount, + &run.CriticalCount, &run.HighCount, &run.MediumCount, &run.LowCount, &run.InfoCount, + &run.ErrorMessage, &run.StartedAt, &run.CompletedAt, + ); err != nil { + return nil, 0, fmt.Errorf("failed to scan row: %w", err) + } + runs = append(runs, run) + } + return runs, totalCount, rows.Err() +} + +// GetScanRun returns a specific scan run by ID +func (s *Store) GetScanRun(ctx context.Context, id string) (*ScanRun, error) { + run := &ScanRun{} + err := s.pool.QueryRow(ctx, ` + SELECT id, trigger, status, modules, targets_count, + critical_count, high_count, medium_count, low_count, info_count, + error_message, started_at, completed_at + FROM pentest_scan_runs + WHERE id = $1 + `, id).Scan( + &run.ID, &run.Trigger, &run.Status, &run.Modules, &run.TargetsCount, + &run.CriticalCount, &run.HighCount, &run.MediumCount, &run.LowCount, &run.InfoCount, + &run.ErrorMessage, &run.StartedAt, &run.CompletedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to get scan run: %w", err) + } + return run, nil +} + +// SaveFindings batch-upserts findings by fingerprint +func (s *Store) SaveFindings(ctx context.Context, scanRunID string, findings []Finding) error { + for _, f := range findings { + _, err := s.pool.Exec(ctx, ` + INSERT INTO pentest_findings ( + fingerprint, category, severity, title, description, + target, evidence, cve_ids, remediation, status, + first_scan_run_id, last_scan_run_id, + first_seen_at, last_seen_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'open', $10, $10, NOW(), NOW()) + ON CONFLICT (fingerprint) DO UPDATE SET + last_scan_run_id = $10, + last_seen_at = NOW(), + severity = EXCLUDED.severity, + description = EXCLUDED.description, + evidence = EXCLUDED.evidence, + cve_ids = EXCLUDED.cve_ids, + remediation = EXCLUDED.remediation, + status = CASE + WHEN pentest_findings.suppressed = true THEN 'suppressed' + ELSE 'open' + END + `, f.Fingerprint, f.Category, f.Severity, f.Title, f.Description, + f.Target, f.Evidence, f.CVEIDs, f.Remediation, scanRunID) + if err != nil { + return fmt.Errorf("failed to save finding %s: %w", f.Fingerprint, err) + } + } + return nil +} + +// FindingListParams holds filter parameters for listing findings +type FindingListParams struct { + Severity string + Category string + Status string + Limit int + Offset int +} + +// ListFindings returns findings with optional filters +func (s *Store) ListFindings(ctx context.Context, params FindingListParams) ([]FindingRecord, int32, error) { + if params.Limit <= 0 { + params.Limit = 50 + } + if params.Limit > 1000 { + params.Limit = 1000 + } + + baseQuery := `SELECT id, fingerprint, category, severity, title, description, + target, evidence, cve_ids, remediation, status, + first_scan_run_id, last_scan_run_id, + first_seen_at, last_seen_at, resolved_at, suppressed, suppressed_reason + FROM pentest_findings WHERE 1=1` + countQuery := `SELECT COUNT(*) FROM pentest_findings WHERE 1=1` + + var args []interface{} + argIdx := 1 + + if params.Severity != "" { + baseQuery += fmt.Sprintf(" AND severity = $%d", argIdx) + countQuery += fmt.Sprintf(" AND severity = $%d", argIdx) + args = append(args, params.Severity) + argIdx++ + } + if params.Category != "" { + baseQuery += fmt.Sprintf(" AND category = $%d", argIdx) + countQuery += fmt.Sprintf(" AND category = $%d", argIdx) + args = append(args, params.Category) + argIdx++ + } + if params.Status != "" { + baseQuery += fmt.Sprintf(" AND status = $%d", argIdx) + countQuery += fmt.Sprintf(" AND status = $%d", argIdx) + args = append(args, params.Status) + argIdx++ + } + + var totalCount int32 + err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&totalCount) + if err != nil { + return nil, 0, fmt.Errorf("failed to count findings: %w", err) + } + + baseQuery += fmt.Sprintf(" ORDER BY last_seen_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1) + args = append(args, params.Limit, params.Offset) + + rows, err := s.pool.Query(ctx, baseQuery, args...) + if err != nil { + return nil, 0, fmt.Errorf("failed to list findings: %w", err) + } + defer rows.Close() + + var findings []FindingRecord + for rows.Next() { + var f FindingRecord + if err := rows.Scan( + &f.ID, &f.Fingerprint, &f.Category, &f.Severity, &f.Title, &f.Description, + &f.Target, &f.Evidence, &f.CVEIDs, &f.Remediation, &f.Status, + &f.FirstScanRunID, &f.LastScanRunID, + &f.FirstSeenAt, &f.LastSeenAt, &f.ResolvedAt, &f.Suppressed, &f.SuppressedReason, + ); err != nil { + return nil, 0, fmt.Errorf("failed to scan finding row: %w", err) + } + findings = append(findings, f) + } + return findings, totalCount, rows.Err() +} + +// GetFindingSummary returns aggregate finding statistics +func (s *Store) GetFindingSummary(ctx context.Context) (*FindingSummary, error) { + summary := &FindingSummary{ + ByCategory: make(map[string]int32), + } + + // Overall counts by status + err := s.pool.QueryRow(ctx, ` + SELECT + COUNT(*), + COUNT(*) FILTER (WHERE status = 'open'), + COUNT(*) FILTER (WHERE status = 'resolved'), + COUNT(*) FILTER (WHERE suppressed = true) + FROM pentest_findings + `).Scan(&summary.TotalFindings, &summary.OpenFindings, &summary.ResolvedFindings, &summary.SuppressedFindings) + if err != nil { + return nil, fmt.Errorf("failed to get finding summary: %w", err) + } + + // Counts by severity (open only) + rows, err := s.pool.Query(ctx, ` + SELECT severity, COUNT(*) + FROM pentest_findings + WHERE status = 'open' + GROUP BY severity + `) + if err != nil { + return nil, fmt.Errorf("failed to get severity counts: %w", err) + } + defer rows.Close() + + for rows.Next() { + var sev string + var count int32 + if err := rows.Scan(&sev, &count); err != nil { + return nil, err + } + switch sev { + case "critical": + summary.CriticalCount = count + case "high": + summary.HighCount = count + case "medium": + summary.MediumCount = count + case "low": + summary.LowCount = count + case "info": + summary.InfoCount = count + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + // Counts by category (open only) + catRows, err := s.pool.Query(ctx, ` + SELECT category, COUNT(*) + FROM pentest_findings + WHERE status = 'open' + GROUP BY category + `) + if err != nil { + return nil, fmt.Errorf("failed to get category counts: %w", err) + } + defer catRows.Close() + + for catRows.Next() { + var cat string + var count int32 + if err := catRows.Scan(&cat, &count); err != nil { + return nil, err + } + summary.ByCategory[cat] = count + } + + return summary, catRows.Err() +} + +// FindingSummary holds aggregate finding statistics +type FindingSummary struct { + TotalFindings int32 + OpenFindings int32 + ResolvedFindings int32 + SuppressedFindings int32 + CriticalCount int32 + HighCount int32 + MediumCount int32 + LowCount int32 + InfoCount int32 + ByCategory map[string]int32 +} + +// SuppressFinding marks a finding as suppressed +func (s *Store) SuppressFinding(ctx context.Context, findingID int64, reason string) error { + result, err := s.pool.Exec(ctx, ` + UPDATE pentest_findings + SET suppressed = true, suppressed_reason = $2, status = 'suppressed' + WHERE id = $1 + `, findingID, reason) + if err != nil { + return fmt.Errorf("failed to suppress finding: %w", err) + } + if result.RowsAffected() == 0 { + return fmt.Errorf("finding %d not found", findingID) + } + return nil +} + +// MarkResolved marks findings as resolved if they were not seen in the given scan run. +// Only affects findings that are currently open and were last seen before this scan. +func (s *Store) MarkResolved(ctx context.Context, scanRunID string, seenFingerprints []string) error { + if len(seenFingerprints) == 0 { + // No findings seen — mark all open findings as resolved + _, err := s.pool.Exec(ctx, ` + UPDATE pentest_findings + SET status = 'resolved', resolved_at = NOW() + WHERE status = 'open' + `) + return err + } + + // Build placeholders for the fingerprints + query := ` + UPDATE pentest_findings + SET status = 'resolved', resolved_at = NOW() + WHERE status = 'open' AND fingerprint NOT IN (` + args := make([]interface{}, len(seenFingerprints)) + for i, fp := range seenFingerprints { + if i > 0 { + query += ", " + } + query += fmt.Sprintf("$%d", i+1) + args[i] = fp + } + query += ")" + + _, err := s.pool.Exec(ctx, query, args...) + return err +} + +// Cleanup removes old scan runs and resolved findings beyond the retention period +func (s *Store) Cleanup(ctx context.Context, retentionDays int) error { + cutoff := time.Now().AddDate(0, 0, -retentionDays) + + // Delete old scan runs + _, err := s.pool.Exec(ctx, `DELETE FROM pentest_scan_runs WHERE started_at < $1`, cutoff) + if err != nil { + return fmt.Errorf("failed to cleanup scan runs: %w", err) + } + + // Delete old resolved findings + _, err = s.pool.Exec(ctx, `DELETE FROM pentest_findings WHERE status = 'resolved' AND resolved_at < $1`, cutoff) + if err != nil { + return fmt.Errorf("failed to cleanup findings: %w", err) + } + + return nil +} + +// GetOpenFindingCounts returns counts of open findings by severity and category +// Used by the metrics recorder +func (s *Store) GetOpenFindingCounts(ctx context.Context) (bySeverity map[string]int64, byCategory map[string]int64, total int64, err error) { + bySeverity = make(map[string]int64) + byCategory = make(map[string]int64) + + rows, err := s.pool.Query(ctx, ` + SELECT severity, category, COUNT(*) + FROM pentest_findings + WHERE status = 'open' + GROUP BY severity, category + `) + if err != nil { + return nil, nil, 0, fmt.Errorf("failed to get open finding counts: %w", err) + } + defer rows.Close() + + for rows.Next() { + var sev, cat string + var count int64 + if err := rows.Scan(&sev, &cat, &count); err != nil { + return nil, nil, 0, err + } + bySeverity[sev] += count + byCategory[cat] += count + total += count + } + return bySeverity, byCategory, total, rows.Err() +} + +// PentestScanJob represents a queued pentest scan job for a single target +type PentestScanJob struct { + ID int64 + ScanRunID string + TargetDomain string + TargetIP string + TargetPort int + TargetProtocol string + ContainerName string + TargetType string + Status string // pending | running | completed | failed + RetryCount int + MaxRetries int + ErrorMessage string + CreatedAt time.Time + StartedAt *time.Time + CompletedAt *time.Time +} + +// targetLabel returns a human-readable label for logging +func (j *PentestScanJob) targetLabel() string { + if j.TargetDomain != "" { + return j.TargetDomain + } + if j.TargetIP != "" { + return fmt.Sprintf("%s:%d", j.TargetIP, j.TargetPort) + } + return j.ContainerName +} + +// ToScanTarget converts a job back to a ScanTarget for module scanning +func (j *PentestScanJob) ToScanTarget() ScanTarget { + return ScanTarget{ + FullDomain: j.TargetDomain, + IP: j.TargetIP, + Port: j.TargetPort, + Protocol: j.TargetProtocol, + ContainerName: j.ContainerName, + TargetType: j.TargetType, + } +} + +// EnqueueScanJob inserts a new pending scan job for a target and returns its ID +func (s *Store) EnqueueScanJob(ctx context.Context, scanRunID string, target ScanTarget) (int64, error) { + var id int64 + err := s.pool.QueryRow(ctx, + `INSERT INTO pentest_scan_jobs (scan_run_id, target_domain, target_ip, target_port, target_protocol, container_name, target_type) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`, + scanRunID, target.FullDomain, target.IP, target.Port, target.Protocol, target.ContainerName, target.TargetType, + ).Scan(&id) + if err != nil { + return 0, fmt.Errorf("failed to enqueue pentest scan job: %w", err) + } + return id, nil +} + +// ClaimNextJob atomically claims the oldest pending pentest job for processing. +// Returns nil if no jobs are available. +func (s *Store) ClaimNextJob(ctx context.Context) (*PentestScanJob, error) { + row := s.pool.QueryRow(ctx, ` + UPDATE pentest_scan_jobs + SET status = 'running', started_at = NOW() + WHERE id = ( + SELECT id FROM pentest_scan_jobs + WHERE status = 'pending' + ORDER BY created_at + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING id, scan_run_id, target_domain, target_ip, target_port, target_protocol, + container_name, target_type, status, retry_count, max_retries, + COALESCE(error_message, ''), created_at, started_at, completed_at + `) + + job := &PentestScanJob{} + err := row.Scan( + &job.ID, &job.ScanRunID, &job.TargetDomain, &job.TargetIP, &job.TargetPort, &job.TargetProtocol, + &job.ContainerName, &job.TargetType, &job.Status, &job.RetryCount, &job.MaxRetries, + &job.ErrorMessage, &job.CreatedAt, &job.StartedAt, &job.CompletedAt, + ) + if err != nil { + if err.Error() == "no rows in result set" { + return nil, nil + } + return nil, fmt.Errorf("failed to claim pentest scan job: %w", err) + } + return job, nil +} + +// CompleteJob marks a pentest scan job as completed +func (s *Store) CompleteJob(ctx context.Context, jobID int64) error { + _, err := s.pool.Exec(ctx, + `UPDATE pentest_scan_jobs SET status = 'completed', completed_at = NOW() WHERE id = $1`, + jobID, + ) + return err +} + +// FailJob increments retry_count. If retries remain, re-queues as pending; otherwise marks as failed. +func (s *Store) FailJob(ctx context.Context, jobID int64, errMsg string) error { + _, err := s.pool.Exec(ctx, ` + UPDATE pentest_scan_jobs + SET retry_count = retry_count + 1, + error_message = $2, + status = CASE WHEN retry_count + 1 < max_retries THEN 'pending' ELSE 'failed' END, + started_at = CASE WHEN retry_count + 1 < max_retries THEN NULL ELSE started_at END, + completed_at = CASE WHEN retry_count + 1 >= max_retries THEN NOW() ELSE NULL END + WHERE id = $1 + `, jobID, errMsg) + return err +} + +// ListScanJobs returns scan jobs for a given scan run, ordered by creation time +func (s *Store) ListScanJobs(ctx context.Context, scanRunID string, limit int) ([]PentestScanJob, error) { + if limit <= 0 { + limit = 100 + } + + rows, err := s.pool.Query(ctx, ` + SELECT id, scan_run_id, target_domain, target_ip, target_port, target_protocol, + container_name, target_type, status, retry_count, max_retries, + COALESCE(error_message, ''), created_at, started_at, completed_at + FROM pentest_scan_jobs + WHERE scan_run_id = $1 + ORDER BY created_at DESC + LIMIT $2 + `, scanRunID, limit) + if err != nil { + return nil, fmt.Errorf("failed to list pentest scan jobs: %w", err) + } + defer rows.Close() + + var jobs []PentestScanJob + for rows.Next() { + var job PentestScanJob + if err := rows.Scan( + &job.ID, &job.ScanRunID, &job.TargetDomain, &job.TargetIP, &job.TargetPort, &job.TargetProtocol, + &job.ContainerName, &job.TargetType, &job.Status, &job.RetryCount, &job.MaxRetries, + &job.ErrorMessage, &job.CreatedAt, &job.StartedAt, &job.CompletedAt, + ); err != nil { + return nil, fmt.Errorf("failed to scan pentest job row: %w", err) + } + jobs = append(jobs, job) + } + return jobs, rows.Err() +} + +// CountPendingJobs returns the number of pending or running jobs for a scan run +func (s *Store) CountPendingJobs(ctx context.Context, scanRunID string) (int, error) { + var count int + err := s.pool.QueryRow(ctx, + `SELECT COUNT(*) FROM pentest_scan_jobs WHERE scan_run_id = $1 AND status IN ('pending', 'running')`, + scanRunID, + ).Scan(&count) + if err != nil { + return 0, fmt.Errorf("failed to count pending pentest jobs: %w", err) + } + return count, nil +} + +// CountFinishedJobs returns the number of completed or failed jobs for a scan run +func (s *Store) CountFinishedJobs(ctx context.Context, scanRunID string) (int, error) { + var count int + err := s.pool.QueryRow(ctx, + `SELECT COUNT(*) FROM pentest_scan_jobs WHERE scan_run_id = $1 AND status IN ('completed', 'failed')`, + scanRunID, + ).Scan(&count) + if err != nil { + return 0, fmt.Errorf("failed to count finished pentest jobs: %w", err) + } + return count, nil +} + +// CountFindingsBySeverity returns a map of severity -> count for findings in a scan run +func (s *Store) CountFindingsBySeverity(ctx context.Context, scanRunID string) (map[string]int, error) { + rows, err := s.pool.Query(ctx, ` + SELECT severity, COUNT(*) + FROM pentest_findings + WHERE last_scan_run_id = $1 + GROUP BY severity + `, scanRunID) + if err != nil { + return nil, fmt.Errorf("failed to count findings by severity: %w", err) + } + defer rows.Close() + + counts := map[string]int{"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0} + for rows.Next() { + var sev string + var count int + if err := rows.Scan(&sev, &count); err != nil { + return nil, err + } + counts[sev] = count + } + return counts, rows.Err() +} + +// GetFingerprintsForScanRun returns all fingerprints of findings associated with a scan run +func (s *Store) GetFingerprintsForScanRun(ctx context.Context, scanRunID string) ([]string, error) { + rows, err := s.pool.Query(ctx, ` + SELECT fingerprint + FROM pentest_findings + WHERE last_scan_run_id = $1 + `, scanRunID) + if err != nil { + return nil, fmt.Errorf("failed to get fingerprints for scan run: %w", err) + } + defer rows.Close() + + var fingerprints []string + for rows.Next() { + var fp string + if err := rows.Scan(&fp); err != nil { + return nil, err + } + fingerprints = append(fingerprints, fp) + } + return fingerprints, rows.Err() +} + +// CleanupOldJobs deletes completed/failed pentest jobs older than retentionDays +func (s *Store) CleanupOldJobs(ctx context.Context, retentionDays int) error { + cutoff := time.Now().AddDate(0, 0, -retentionDays) + _, err := s.pool.Exec(ctx, + `DELETE FROM pentest_scan_jobs WHERE status IN ('completed', 'failed') AND created_at < $1`, + cutoff, + ) + return err +} + +// Close closes the underlying connection pool +func (s *Store) Close() { + s.pool.Close() +} diff --git a/internal/pentest/targets.go b/internal/pentest/targets.go new file mode 100644 index 0000000..4a2f15d --- /dev/null +++ b/internal/pentest/targets.go @@ -0,0 +1,79 @@ +package pentest + +import ( + "context" + "log" + + "github.com/footprintai/containarium/internal/app" + "github.com/footprintai/containarium/internal/incus" +) + +// TargetCollector gathers scan targets from routes and containers +type TargetCollector struct { + routeStore *app.RouteStore + incusClient *incus.Client +} + +// NewTargetCollector creates a new target collector +func NewTargetCollector(routeStore *app.RouteStore, incusClient *incus.Client) *TargetCollector { + return &TargetCollector{ + routeStore: routeStore, + incusClient: incusClient, + } +} + +// Collect gathers all scan targets from active routes and running containers +func (tc *TargetCollector) Collect(ctx context.Context) []ScanTarget { + seen := make(map[string]bool) // dedup by "ip:port" or "domain" + var targets []ScanTarget + + // Collect from routes + if tc.routeStore != nil { + routes, err := tc.routeStore.List(ctx, true) // active only + if err != nil { + log.Printf("Pentest target collector: failed to list routes: %v", err) + } else { + for _, r := range routes { + key := r.FullDomain + if seen[key] { + continue + } + seen[key] = true + targets = append(targets, ScanTarget{ + FullDomain: r.FullDomain, + IP: r.TargetIP, + Port: r.TargetPort, + Protocol: r.Protocol, + ContainerName: r.ContainerName, + TargetType: "route", + }) + } + } + } + + // Collect from running containers (for port scanning / Trivy) + if tc.incusClient != nil { + containers, err := tc.incusClient.ListContainers() + if err != nil { + log.Printf("Pentest target collector: failed to list containers: %v", err) + } else { + for _, c := range containers { + if c.State != "Running" || c.Role.IsCoreRole() { + continue + } + key := c.IPAddress + if key == "" || seen[key] { + continue + } + seen[key] = true + targets = append(targets, ScanTarget{ + IP: c.IPAddress, + ContainerName: c.Name, + TargetType: "container", + }) + } + } + } + + return targets +} diff --git a/internal/server/alert_rules.go b/internal/server/alert_rules.go index 5ac6307..d73ac0f 100644 --- a/internal/server/alert_rules.go +++ b/internal/server/alert_rules.go @@ -98,4 +98,47 @@ const DefaultAlertRules = `groups: annotations: summary: "No running containers" description: "There are no running user containers for more than 5 minutes." + + - name: pentest_alerts + interval: 60s + rules: + - alert: PentestCriticalFindings + expr: pentest_findings_open{severity="critical"} > 0 + for: 5m + labels: + severity: critical + source: default + annotations: + summary: "Critical pentest findings detected" + description: "There are {{ $value }} critical security findings from automated penetration testing." + + - alert: PentestHighFindings + expr: pentest_findings_open{severity="high"} > 3 + for: 10m + labels: + severity: warning + source: default + annotations: + summary: "Multiple high-severity pentest findings" + description: "There are {{ $value }} high-severity security findings from automated penetration testing." + + - alert: PentestScanStale + expr: time() - pentest_scan_last_timestamp > 172800 + for: 5m + labels: + severity: warning + source: default + annotations: + summary: "Pentest scan is stale" + description: "No penetration test scan has completed in the last 48 hours." + + - alert: PentestHighCPU + expr: avg_over_time(system_cpu_load_1m[10m]) > system_cpu_count * 0.9 + for: 10m + labels: + severity: warning + source: default + annotations: + summary: "Sustained high CPU during pentest" + description: "System CPU load has been above 90% for 10 minutes, possibly due to an active pentest scan or attack." ` diff --git a/internal/server/dual_server.go b/internal/server/dual_server.go index 8cbfc04..d37b2dc 100644 --- a/internal/server/dual_server.go +++ b/internal/server/dual_server.go @@ -25,9 +25,11 @@ import ( "github.com/footprintai/containarium/internal/metrics" "github.com/footprintai/containarium/internal/mtls" "github.com/footprintai/containarium/internal/network" + "github.com/footprintai/containarium/internal/pentest" "github.com/footprintai/containarium/internal/security" "github.com/footprintai/containarium/internal/traffic" pb "github.com/footprintai/containarium/pkg/pb/containarium/v1" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" "google.golang.org/grpc" "google.golang.org/grpc/reflection" ) @@ -103,6 +105,8 @@ type DualServer struct { alertStore *alert.Store alertManager *alert.Manager alertDeliveryStore *alert.DeliveryStore + pentestManager *pentest.Manager + pentestStore *pentest.Store } // NewDualServer creates a new dual server instance @@ -551,6 +555,42 @@ skipAppHosting: } } + // Setup pentest manager + var pentestManager *pentest.Manager + var pentestStore *pentest.Store + if postgresConnString != "" { + pentestPool, poolErr := connectToPostgres(postgresConnString, 5, 3*time.Second) + if poolErr != nil { + log.Printf("Warning: Failed to connect to PostgreSQL for pentest store: %v", poolErr) + } else { + pentestStore, err = pentest.NewStore(context.Background(), pentestPool) + if err != nil { + log.Printf("Warning: Failed to create pentest store: %v", err) + pentestPool.Close() + } else { + pentestIncusClient, incusErr := incus.New() + if incusErr != nil { + log.Printf("Warning: Failed to create incus client for pentest: %v", incusErr) + } else { + var meterProvider *sdkmetric.MeterProvider + if metricsCollector != nil { + meterProvider = metricsCollector.MeterProvider() + } + pentestManager = pentest.NewManager( + pentestStore, + pentestIncusClient, + routeStore, + meterProvider, + pentest.ManagerConfig{}, + ) + pentestServer := NewPentestServer(pentestStore, pentestManager) + pb.RegisterPentestServiceServer(grpcServer, pentestServer) + log.Printf("Pentest service enabled") + } + } + } + } + // Setup audit logging store and event subscriber var auditStore *audit.Store var auditEventSubscriber *audit.EventSubscriber @@ -733,6 +773,8 @@ skipAppHosting: alertStore: alertStore, alertManager: alertManager, alertDeliveryStore: containerServer.alertDeliveryStore, + pentestManager: pentestManager, + pentestStore: pentestStore, }, nil } @@ -756,6 +798,12 @@ func (ds *DualServer) Start(ctx context.Context) error { log.Printf("Security scanner started") } + // Start pentest manager if available + if ds.pentestManager != nil { + ds.pentestManager.Start(ctx) + log.Printf("Pentest manager started") + } + // Start audit event subscriber if available if ds.auditEventSubscriber != nil { ds.auditEventSubscriber.Start(ctx) @@ -881,6 +929,12 @@ func (ds *DualServer) Start(ctx context.Context) error { if ds.securityScanner != nil { ds.securityScanner.Stop() } + if ds.pentestManager != nil { + ds.pentestManager.Stop() + } + if ds.pentestStore != nil { + ds.pentestStore.Close() + } if ds.sshCollector != nil { ds.sshCollector.Stop() } diff --git a/internal/server/pentest_server.go b/internal/server/pentest_server.go new file mode 100644 index 0000000..4344ad0 --- /dev/null +++ b/internal/server/pentest_server.go @@ -0,0 +1,271 @@ +package server + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/footprintai/containarium/internal/pentest" + pb "github.com/footprintai/containarium/pkg/pb/containarium/v1" +) + +// PentestServer implements the PentestService gRPC service +type PentestServer struct { + pb.UnimplementedPentestServiceServer + store *pentest.Store + manager *pentest.Manager + installer *pentest.Installer +} + +// NewPentestServer creates a new pentest server +func NewPentestServer(store *pentest.Store, manager *pentest.Manager) *PentestServer { + return &PentestServer{ + store: store, + manager: manager, + installer: pentest.NewInstaller(), + } +} + +// TriggerPentestScan triggers an on-demand scan (non-blocking). +// Jobs are enqueued and processed asynchronously by workers. +func (s *PentestServer) TriggerPentestScan(ctx context.Context, req *pb.TriggerPentestScanRequest) (*pb.TriggerPentestScanResponse, error) { + if s.manager == nil { + return nil, fmt.Errorf("pentest scanner is not available") + } + + scanRunID, err := s.manager.RunScan(ctx, "manual") + if err != nil { + return nil, fmt.Errorf("failed to trigger scan: %w", err) + } + + return &pb.TriggerPentestScanResponse{ + ScanRunId: scanRunID, + Message: "Pentest scan enqueued — workers processing asynchronously", + }, nil +} + +// ListPentestScanRuns returns recent scan runs +func (s *PentestServer) ListPentestScanRuns(ctx context.Context, req *pb.ListPentestScanRunsRequest) (*pb.ListPentestScanRunsResponse, error) { + runs, totalCount, err := s.store.ListScanRuns(ctx, int(req.Limit), int(req.Offset)) + if err != nil { + return nil, fmt.Errorf("failed to list scan runs: %w", err) + } + + var pbRuns []*pb.PentestScanRun + for _, run := range runs { + pbRun := scanRunToProto(&run) + if run.Status == "running" { + if count, err := s.store.CountFinishedJobs(ctx, run.ID); err == nil { + pbRun.CompletedCount = int32(count) + } + } else { + pbRun.CompletedCount = int32(run.TargetsCount) + } + pbRuns = append(pbRuns, pbRun) + } + + return &pb.ListPentestScanRunsResponse{ + ScanRuns: pbRuns, + TotalCount: totalCount, + }, nil +} + +// GetPentestScanRun returns a specific scan run +func (s *PentestServer) GetPentestScanRun(ctx context.Context, req *pb.GetPentestScanRunRequest) (*pb.GetPentestScanRunResponse, error) { + run, err := s.store.GetScanRun(ctx, req.Id) + if err != nil { + return nil, fmt.Errorf("scan run not found: %w", err) + } + + return &pb.GetPentestScanRunResponse{ + ScanRun: scanRunToProto(run), + }, nil +} + +// ListPentestFindings returns findings with optional filtering +func (s *PentestServer) ListPentestFindings(ctx context.Context, req *pb.ListPentestFindingsRequest) (*pb.ListPentestFindingsResponse, error) { + params := pentest.FindingListParams{ + Severity: req.Severity, + Category: req.Category, + Status: req.Status, + Limit: int(req.Limit), + Offset: int(req.Offset), + } + + findings, totalCount, err := s.store.ListFindings(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to list findings: %w", err) + } + + var pbFindings []*pb.PentestFinding + for _, f := range findings { + pbFindings = append(pbFindings, findingToProto(&f)) + } + + return &pb.ListPentestFindingsResponse{ + Findings: pbFindings, + TotalCount: totalCount, + }, nil +} + +// GetPentestFindingSummary returns aggregate finding statistics +func (s *PentestServer) GetPentestFindingSummary(ctx context.Context, req *pb.GetPentestFindingSummaryRequest) (*pb.GetPentestFindingSummaryResponse, error) { + summary, err := s.store.GetFindingSummary(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get finding summary: %w", err) + } + + return &pb.GetPentestFindingSummaryResponse{ + Summary: &pb.PentestFindingSummary{ + TotalFindings: summary.TotalFindings, + OpenFindings: summary.OpenFindings, + ResolvedFindings: summary.ResolvedFindings, + SuppressedFindings: summary.SuppressedFindings, + CriticalCount: summary.CriticalCount, + HighCount: summary.HighCount, + MediumCount: summary.MediumCount, + LowCount: summary.LowCount, + InfoCount: summary.InfoCount, + ByCategory: summary.ByCategory, + }, + }, nil +} + +// SuppressPentestFinding suppresses a finding +func (s *PentestServer) SuppressPentestFinding(ctx context.Context, req *pb.SuppressPentestFindingRequest) (*pb.SuppressPentestFindingResponse, error) { + if err := s.store.SuppressFinding(ctx, req.FindingId, req.Reason); err != nil { + return nil, fmt.Errorf("failed to suppress finding: %w", err) + } + + return &pb.SuppressPentestFindingResponse{ + Message: fmt.Sprintf("Finding %d suppressed", req.FindingId), + }, nil +} + +// GetPentestConfig returns the current configuration +func (s *PentestServer) GetPentestConfig(ctx context.Context, req *pb.GetPentestConfigRequest) (*pb.GetPentestConfigResponse, error) { + config := &pb.PentestConfig{ + Enabled: s.manager != nil, + } + + if s.manager != nil { + config.Interval = s.manager.Interval().String() + modules := s.manager.GetModules() + names := make([]string, len(modules)) + for i, m := range modules { + names[i] = m.Name() + } + config.Modules = strings.Join(names, ",") + config.NucleiAvailable = s.manager.NucleiAvailable() + config.TrivyAvailable = s.manager.TrivyAvailable() + } + + return &pb.GetPentestConfigResponse{ + Config: config, + }, nil +} + +// InstallPentestTool downloads and installs an external pentest tool +func (s *PentestServer) InstallPentestTool(ctx context.Context, req *pb.InstallPentestToolRequest) (*pb.InstallPentestToolResponse, error) { + toolName := strings.ToLower(strings.TrimSpace(req.ToolName)) + if toolName != "nuclei" && toolName != "trivy" { + return &pb.InstallPentestToolResponse{ + Success: false, + Message: fmt.Sprintf("unsupported tool: %q (supported: nuclei, trivy)", req.ToolName), + }, nil + } + + // Check if already available + if toolName == "nuclei" && s.manager != nil && s.manager.NucleiAvailable() { + return &pb.InstallPentestToolResponse{ + Success: true, + Message: "Nuclei is already installed and active", + }, nil + } + if toolName == "trivy" && s.manager != nil && s.manager.TrivyAvailable() { + return &pb.InstallPentestToolResponse{ + Success: true, + Message: "Trivy is already installed and active", + }, nil + } + + var err error + switch toolName { + case "nuclei": + err = s.installer.InstallNuclei() + case "trivy": + err = s.installer.InstallTrivy() + } + + if err != nil { + return &pb.InstallPentestToolResponse{ + Success: false, + Message: fmt.Sprintf("installation failed: %v", err), + }, nil + } + + // Hot-reload: refresh modules in the running manager + if s.manager != nil { + s.manager.RefreshExternalModules() + } + + return &pb.InstallPentestToolResponse{ + Success: true, + Message: fmt.Sprintf("%s installed successfully", toolName), + }, nil +} + +// scanRunToProto converts a store ScanRun to a proto ScanRun +func scanRunToProto(run *pentest.ScanRun) *pb.PentestScanRun { + pbRun := &pb.PentestScanRun{ + Id: run.ID, + Trigger: run.Trigger, + Status: run.Status, + Modules: run.Modules, + TargetsCount: int32(run.TargetsCount), + CriticalCount: int32(run.CriticalCount), + HighCount: int32(run.HighCount), + MediumCount: int32(run.MediumCount), + LowCount: int32(run.LowCount), + InfoCount: int32(run.InfoCount), + ErrorMessage: run.ErrorMessage, + StartedAt: run.StartedAt.Format(time.RFC3339), + } + if run.CompletedAt != nil { + pbRun.CompletedAt = run.CompletedAt.Format(time.RFC3339) + pbRun.Duration = run.CompletedAt.Sub(run.StartedAt).Truncate(time.Second).String() + } + return pbRun +} + +// findingToProto converts a store FindingRecord to a proto PentestFinding +func findingToProto(f *pentest.FindingRecord) *pb.PentestFinding { + pbF := &pb.PentestFinding{ + Id: f.ID, + Fingerprint: f.Fingerprint, + Category: f.Category, + Severity: f.Severity, + Title: f.Title, + Description: f.Description, + Target: f.Target, + Evidence: f.Evidence, + CveIds: f.CVEIDs, + Remediation: f.Remediation, + Status: f.Status, + FirstSeenAt: f.FirstSeenAt.Format(time.RFC3339), + LastSeenAt: f.LastSeenAt.Format(time.RFC3339), + Suppressed: f.Suppressed, + SuppressedReason: f.SuppressedReason, + } + if f.FirstScanRunID != nil { + pbF.FirstScanRunId = *f.FirstScanRunID + } + if f.LastScanRunID != nil { + pbF.LastScanRunId = *f.LastScanRunID + } + if f.ResolvedAt != nil { + pbF.ResolvedAt = f.ResolvedAt.Format(time.RFC3339) + } + return pbF +} diff --git a/pkg/pb/containarium/v1/pentest.pb.go b/pkg/pb/containarium/v1/pentest.pb.go new file mode 100644 index 0000000..025940e --- /dev/null +++ b/pkg/pb/containarium/v1/pentest.pb.go @@ -0,0 +1,1597 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: containarium/v1/pentest.proto + +package containariumv1 + +import ( + _ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options" + _ "google.golang.org/genproto/googleapis/api/annotations" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// PentestScanRun represents a single penetration test scan execution +type PentestScanRun struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Unique scan run ID (UUID) + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // How the scan was triggered: "scheduled", "manual", "startup" + Trigger string `protobuf:"bytes,2,opt,name=trigger,proto3" json:"trigger,omitempty"` + // Scan status: "running", "completed", "failed" + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + // Comma-separated list of modules that were run + Modules string `protobuf:"bytes,4,opt,name=modules,proto3" json:"modules,omitempty"` + // Number of targets scanned + TargetsCount int32 `protobuf:"varint,5,opt,name=targets_count,json=targetsCount,proto3" json:"targets_count,omitempty"` + // Finding counts by severity + CriticalCount int32 `protobuf:"varint,6,opt,name=critical_count,json=criticalCount,proto3" json:"critical_count,omitempty"` + HighCount int32 `protobuf:"varint,7,opt,name=high_count,json=highCount,proto3" json:"high_count,omitempty"` + MediumCount int32 `protobuf:"varint,8,opt,name=medium_count,json=mediumCount,proto3" json:"medium_count,omitempty"` + LowCount int32 `protobuf:"varint,9,opt,name=low_count,json=lowCount,proto3" json:"low_count,omitempty"` + InfoCount int32 `protobuf:"varint,10,opt,name=info_count,json=infoCount,proto3" json:"info_count,omitempty"` + // Error message if status is "failed" + ErrorMessage string `protobuf:"bytes,11,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + // When the scan started (ISO 8601) + StartedAt string `protobuf:"bytes,12,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"` + // When the scan completed (ISO 8601) + CompletedAt string `protobuf:"bytes,13,opt,name=completed_at,json=completedAt,proto3" json:"completed_at,omitempty"` + // Duration of the scan + Duration string `protobuf:"bytes,14,opt,name=duration,proto3" json:"duration,omitempty"` + // Number of targets that finished processing + CompletedCount int32 `protobuf:"varint,15,opt,name=completed_count,json=completedCount,proto3" json:"completed_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PentestScanRun) Reset() { + *x = PentestScanRun{} + mi := &file_containarium_v1_pentest_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PentestScanRun) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PentestScanRun) ProtoMessage() {} + +func (x *PentestScanRun) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PentestScanRun.ProtoReflect.Descriptor instead. +func (*PentestScanRun) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{0} +} + +func (x *PentestScanRun) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *PentestScanRun) GetTrigger() string { + if x != nil { + return x.Trigger + } + return "" +} + +func (x *PentestScanRun) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *PentestScanRun) GetModules() string { + if x != nil { + return x.Modules + } + return "" +} + +func (x *PentestScanRun) GetTargetsCount() int32 { + if x != nil { + return x.TargetsCount + } + return 0 +} + +func (x *PentestScanRun) GetCriticalCount() int32 { + if x != nil { + return x.CriticalCount + } + return 0 +} + +func (x *PentestScanRun) GetHighCount() int32 { + if x != nil { + return x.HighCount + } + return 0 +} + +func (x *PentestScanRun) GetMediumCount() int32 { + if x != nil { + return x.MediumCount + } + return 0 +} + +func (x *PentestScanRun) GetLowCount() int32 { + if x != nil { + return x.LowCount + } + return 0 +} + +func (x *PentestScanRun) GetInfoCount() int32 { + if x != nil { + return x.InfoCount + } + return 0 +} + +func (x *PentestScanRun) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +func (x *PentestScanRun) GetStartedAt() string { + if x != nil { + return x.StartedAt + } + return "" +} + +func (x *PentestScanRun) GetCompletedAt() string { + if x != nil { + return x.CompletedAt + } + return "" +} + +func (x *PentestScanRun) GetDuration() string { + if x != nil { + return x.Duration + } + return "" +} + +func (x *PentestScanRun) GetCompletedCount() int32 { + if x != nil { + return x.CompletedCount + } + return 0 +} + +// PentestFinding represents a single security finding +type PentestFinding struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Database ID + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + // SHA-256 fingerprint for deduplication (category|target|title) + Fingerprint string `protobuf:"bytes,2,opt,name=fingerprint,proto3" json:"fingerprint,omitempty"` + // Scanner module that found this (e.g., "headers", "tls", "nuclei") + Category string `protobuf:"bytes,3,opt,name=category,proto3" json:"category,omitempty"` + // Severity: "critical", "high", "medium", "low", "info" + Severity string `protobuf:"bytes,4,opt,name=severity,proto3" json:"severity,omitempty"` + // Short title of the finding + Title string `protobuf:"bytes,5,opt,name=title,proto3" json:"title,omitempty"` + // Detailed description + Description string `protobuf:"bytes,6,opt,name=description,proto3" json:"description,omitempty"` + // Target that was scanned (URL, IP:port, domain) + Target string `protobuf:"bytes,7,opt,name=target,proto3" json:"target,omitempty"` + // Evidence or raw output supporting the finding + Evidence string `protobuf:"bytes,8,opt,name=evidence,proto3" json:"evidence,omitempty"` + // Comma-separated CVE IDs if applicable + CveIds string `protobuf:"bytes,9,opt,name=cve_ids,json=cveIds,proto3" json:"cve_ids,omitempty"` + // Remediation guidance + Remediation string `protobuf:"bytes,10,opt,name=remediation,proto3" json:"remediation,omitempty"` + // Current status: "open", "resolved", "suppressed" + Status string `protobuf:"bytes,11,opt,name=status,proto3" json:"status,omitempty"` + // Scan run ID that first found this + FirstScanRunId string `protobuf:"bytes,12,opt,name=first_scan_run_id,json=firstScanRunId,proto3" json:"first_scan_run_id,omitempty"` + // Scan run ID that last saw this + LastScanRunId string `protobuf:"bytes,13,opt,name=last_scan_run_id,json=lastScanRunId,proto3" json:"last_scan_run_id,omitempty"` + // When first seen (ISO 8601) + FirstSeenAt string `protobuf:"bytes,14,opt,name=first_seen_at,json=firstSeenAt,proto3" json:"first_seen_at,omitempty"` + // When last seen (ISO 8601) + LastSeenAt string `protobuf:"bytes,15,opt,name=last_seen_at,json=lastSeenAt,proto3" json:"last_seen_at,omitempty"` + // When resolved (ISO 8601, empty if still open) + ResolvedAt string `protobuf:"bytes,16,opt,name=resolved_at,json=resolvedAt,proto3" json:"resolved_at,omitempty"` + // Whether the finding is suppressed + Suppressed bool `protobuf:"varint,17,opt,name=suppressed,proto3" json:"suppressed,omitempty"` + // Reason for suppression + SuppressedReason string `protobuf:"bytes,18,opt,name=suppressed_reason,json=suppressedReason,proto3" json:"suppressed_reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PentestFinding) Reset() { + *x = PentestFinding{} + mi := &file_containarium_v1_pentest_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PentestFinding) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PentestFinding) ProtoMessage() {} + +func (x *PentestFinding) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PentestFinding.ProtoReflect.Descriptor instead. +func (*PentestFinding) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{1} +} + +func (x *PentestFinding) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *PentestFinding) GetFingerprint() string { + if x != nil { + return x.Fingerprint + } + return "" +} + +func (x *PentestFinding) GetCategory() string { + if x != nil { + return x.Category + } + return "" +} + +func (x *PentestFinding) GetSeverity() string { + if x != nil { + return x.Severity + } + return "" +} + +func (x *PentestFinding) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *PentestFinding) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *PentestFinding) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *PentestFinding) GetEvidence() string { + if x != nil { + return x.Evidence + } + return "" +} + +func (x *PentestFinding) GetCveIds() string { + if x != nil { + return x.CveIds + } + return "" +} + +func (x *PentestFinding) GetRemediation() string { + if x != nil { + return x.Remediation + } + return "" +} + +func (x *PentestFinding) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *PentestFinding) GetFirstScanRunId() string { + if x != nil { + return x.FirstScanRunId + } + return "" +} + +func (x *PentestFinding) GetLastScanRunId() string { + if x != nil { + return x.LastScanRunId + } + return "" +} + +func (x *PentestFinding) GetFirstSeenAt() string { + if x != nil { + return x.FirstSeenAt + } + return "" +} + +func (x *PentestFinding) GetLastSeenAt() string { + if x != nil { + return x.LastSeenAt + } + return "" +} + +func (x *PentestFinding) GetResolvedAt() string { + if x != nil { + return x.ResolvedAt + } + return "" +} + +func (x *PentestFinding) GetSuppressed() bool { + if x != nil { + return x.Suppressed + } + return false +} + +func (x *PentestFinding) GetSuppressedReason() string { + if x != nil { + return x.SuppressedReason + } + return "" +} + +// PentestFindingSummary provides aggregate counts +type PentestFindingSummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + TotalFindings int32 `protobuf:"varint,1,opt,name=total_findings,json=totalFindings,proto3" json:"total_findings,omitempty"` + OpenFindings int32 `protobuf:"varint,2,opt,name=open_findings,json=openFindings,proto3" json:"open_findings,omitempty"` + ResolvedFindings int32 `protobuf:"varint,3,opt,name=resolved_findings,json=resolvedFindings,proto3" json:"resolved_findings,omitempty"` + SuppressedFindings int32 `protobuf:"varint,4,opt,name=suppressed_findings,json=suppressedFindings,proto3" json:"suppressed_findings,omitempty"` + CriticalCount int32 `protobuf:"varint,5,opt,name=critical_count,json=criticalCount,proto3" json:"critical_count,omitempty"` + HighCount int32 `protobuf:"varint,6,opt,name=high_count,json=highCount,proto3" json:"high_count,omitempty"` + MediumCount int32 `protobuf:"varint,7,opt,name=medium_count,json=mediumCount,proto3" json:"medium_count,omitempty"` + LowCount int32 `protobuf:"varint,8,opt,name=low_count,json=lowCount,proto3" json:"low_count,omitempty"` + InfoCount int32 `protobuf:"varint,9,opt,name=info_count,json=infoCount,proto3" json:"info_count,omitempty"` + // Per-category breakdown + ByCategory map[string]int32 `protobuf:"bytes,10,rep,name=by_category,json=byCategory,proto3" json:"by_category,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PentestFindingSummary) Reset() { + *x = PentestFindingSummary{} + mi := &file_containarium_v1_pentest_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PentestFindingSummary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PentestFindingSummary) ProtoMessage() {} + +func (x *PentestFindingSummary) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PentestFindingSummary.ProtoReflect.Descriptor instead. +func (*PentestFindingSummary) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{2} +} + +func (x *PentestFindingSummary) GetTotalFindings() int32 { + if x != nil { + return x.TotalFindings + } + return 0 +} + +func (x *PentestFindingSummary) GetOpenFindings() int32 { + if x != nil { + return x.OpenFindings + } + return 0 +} + +func (x *PentestFindingSummary) GetResolvedFindings() int32 { + if x != nil { + return x.ResolvedFindings + } + return 0 +} + +func (x *PentestFindingSummary) GetSuppressedFindings() int32 { + if x != nil { + return x.SuppressedFindings + } + return 0 +} + +func (x *PentestFindingSummary) GetCriticalCount() int32 { + if x != nil { + return x.CriticalCount + } + return 0 +} + +func (x *PentestFindingSummary) GetHighCount() int32 { + if x != nil { + return x.HighCount + } + return 0 +} + +func (x *PentestFindingSummary) GetMediumCount() int32 { + if x != nil { + return x.MediumCount + } + return 0 +} + +func (x *PentestFindingSummary) GetLowCount() int32 { + if x != nil { + return x.LowCount + } + return 0 +} + +func (x *PentestFindingSummary) GetInfoCount() int32 { + if x != nil { + return x.InfoCount + } + return 0 +} + +func (x *PentestFindingSummary) GetByCategory() map[string]int32 { + if x != nil { + return x.ByCategory + } + return nil +} + +// PentestConfig returns the current pentest configuration +type PentestConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Whether pentest scanning is enabled + Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` + // Scan interval (e.g., "24h") + Interval string `protobuf:"bytes,2,opt,name=interval,proto3" json:"interval,omitempty"` + // Comma-separated list of enabled modules + Modules string `protobuf:"bytes,3,opt,name=modules,proto3" json:"modules,omitempty"` + // Whether Nuclei is available + NucleiAvailable bool `protobuf:"varint,4,opt,name=nuclei_available,json=nucleiAvailable,proto3" json:"nuclei_available,omitempty"` + // Whether Trivy is available + TrivyAvailable bool `protobuf:"varint,5,opt,name=trivy_available,json=trivyAvailable,proto3" json:"trivy_available,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PentestConfig) Reset() { + *x = PentestConfig{} + mi := &file_containarium_v1_pentest_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PentestConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PentestConfig) ProtoMessage() {} + +func (x *PentestConfig) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PentestConfig.ProtoReflect.Descriptor instead. +func (*PentestConfig) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{3} +} + +func (x *PentestConfig) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *PentestConfig) GetInterval() string { + if x != nil { + return x.Interval + } + return "" +} + +func (x *PentestConfig) GetModules() string { + if x != nil { + return x.Modules + } + return "" +} + +func (x *PentestConfig) GetNucleiAvailable() bool { + if x != nil { + return x.NucleiAvailable + } + return false +} + +func (x *PentestConfig) GetTrivyAvailable() bool { + if x != nil { + return x.TrivyAvailable + } + return false +} + +type TriggerPentestScanRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Optional: comma-separated list of modules to run (empty = all enabled) + Modules string `protobuf:"bytes,1,opt,name=modules,proto3" json:"modules,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TriggerPentestScanRequest) Reset() { + *x = TriggerPentestScanRequest{} + mi := &file_containarium_v1_pentest_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TriggerPentestScanRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TriggerPentestScanRequest) ProtoMessage() {} + +func (x *TriggerPentestScanRequest) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TriggerPentestScanRequest.ProtoReflect.Descriptor instead. +func (*TriggerPentestScanRequest) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{4} +} + +func (x *TriggerPentestScanRequest) GetModules() string { + if x != nil { + return x.Modules + } + return "" +} + +type TriggerPentestScanResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Scan run ID + ScanRunId string `protobuf:"bytes,1,opt,name=scan_run_id,json=scanRunId,proto3" json:"scan_run_id,omitempty"` + // Human-readable message + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TriggerPentestScanResponse) Reset() { + *x = TriggerPentestScanResponse{} + mi := &file_containarium_v1_pentest_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TriggerPentestScanResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TriggerPentestScanResponse) ProtoMessage() {} + +func (x *TriggerPentestScanResponse) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TriggerPentestScanResponse.ProtoReflect.Descriptor instead. +func (*TriggerPentestScanResponse) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{5} +} + +func (x *TriggerPentestScanResponse) GetScanRunId() string { + if x != nil { + return x.ScanRunId + } + return "" +} + +func (x *TriggerPentestScanResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type ListPentestScanRunsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Maximum number of scan runs to return (default: 20) + Limit int32 `protobuf:"varint,1,opt,name=limit,proto3" json:"limit,omitempty"` + // Offset for pagination + Offset int32 `protobuf:"varint,2,opt,name=offset,proto3" json:"offset,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListPentestScanRunsRequest) Reset() { + *x = ListPentestScanRunsRequest{} + mi := &file_containarium_v1_pentest_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListPentestScanRunsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListPentestScanRunsRequest) ProtoMessage() {} + +func (x *ListPentestScanRunsRequest) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListPentestScanRunsRequest.ProtoReflect.Descriptor instead. +func (*ListPentestScanRunsRequest) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{6} +} + +func (x *ListPentestScanRunsRequest) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListPentestScanRunsRequest) GetOffset() int32 { + if x != nil { + return x.Offset + } + return 0 +} + +type ListPentestScanRunsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ScanRuns []*PentestScanRun `protobuf:"bytes,1,rep,name=scan_runs,json=scanRuns,proto3" json:"scan_runs,omitempty"` + TotalCount int32 `protobuf:"varint,2,opt,name=total_count,json=totalCount,proto3" json:"total_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListPentestScanRunsResponse) Reset() { + *x = ListPentestScanRunsResponse{} + mi := &file_containarium_v1_pentest_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListPentestScanRunsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListPentestScanRunsResponse) ProtoMessage() {} + +func (x *ListPentestScanRunsResponse) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListPentestScanRunsResponse.ProtoReflect.Descriptor instead. +func (*ListPentestScanRunsResponse) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{7} +} + +func (x *ListPentestScanRunsResponse) GetScanRuns() []*PentestScanRun { + if x != nil { + return x.ScanRuns + } + return nil +} + +func (x *ListPentestScanRunsResponse) GetTotalCount() int32 { + if x != nil { + return x.TotalCount + } + return 0 +} + +type GetPentestScanRunRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Scan run ID + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetPentestScanRunRequest) Reset() { + *x = GetPentestScanRunRequest{} + mi := &file_containarium_v1_pentest_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetPentestScanRunRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetPentestScanRunRequest) ProtoMessage() {} + +func (x *GetPentestScanRunRequest) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetPentestScanRunRequest.ProtoReflect.Descriptor instead. +func (*GetPentestScanRunRequest) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{8} +} + +func (x *GetPentestScanRunRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type GetPentestScanRunResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ScanRun *PentestScanRun `protobuf:"bytes,1,opt,name=scan_run,json=scanRun,proto3" json:"scan_run,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetPentestScanRunResponse) Reset() { + *x = GetPentestScanRunResponse{} + mi := &file_containarium_v1_pentest_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetPentestScanRunResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetPentestScanRunResponse) ProtoMessage() {} + +func (x *GetPentestScanRunResponse) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetPentestScanRunResponse.ProtoReflect.Descriptor instead. +func (*GetPentestScanRunResponse) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{9} +} + +func (x *GetPentestScanRunResponse) GetScanRun() *PentestScanRun { + if x != nil { + return x.ScanRun + } + return nil +} + +type ListPentestFindingsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Filter by severity (optional) + Severity string `protobuf:"bytes,1,opt,name=severity,proto3" json:"severity,omitempty"` + // Filter by category/module (optional) + Category string `protobuf:"bytes,2,opt,name=category,proto3" json:"category,omitempty"` + // Filter by status: "open", "resolved", "suppressed" (optional) + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + // Maximum number of findings to return (default: 50) + Limit int32 `protobuf:"varint,4,opt,name=limit,proto3" json:"limit,omitempty"` + // Offset for pagination + Offset int32 `protobuf:"varint,5,opt,name=offset,proto3" json:"offset,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListPentestFindingsRequest) Reset() { + *x = ListPentestFindingsRequest{} + mi := &file_containarium_v1_pentest_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListPentestFindingsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListPentestFindingsRequest) ProtoMessage() {} + +func (x *ListPentestFindingsRequest) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListPentestFindingsRequest.ProtoReflect.Descriptor instead. +func (*ListPentestFindingsRequest) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{10} +} + +func (x *ListPentestFindingsRequest) GetSeverity() string { + if x != nil { + return x.Severity + } + return "" +} + +func (x *ListPentestFindingsRequest) GetCategory() string { + if x != nil { + return x.Category + } + return "" +} + +func (x *ListPentestFindingsRequest) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ListPentestFindingsRequest) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListPentestFindingsRequest) GetOffset() int32 { + if x != nil { + return x.Offset + } + return 0 +} + +type ListPentestFindingsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Findings []*PentestFinding `protobuf:"bytes,1,rep,name=findings,proto3" json:"findings,omitempty"` + TotalCount int32 `protobuf:"varint,2,opt,name=total_count,json=totalCount,proto3" json:"total_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListPentestFindingsResponse) Reset() { + *x = ListPentestFindingsResponse{} + mi := &file_containarium_v1_pentest_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListPentestFindingsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListPentestFindingsResponse) ProtoMessage() {} + +func (x *ListPentestFindingsResponse) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListPentestFindingsResponse.ProtoReflect.Descriptor instead. +func (*ListPentestFindingsResponse) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{11} +} + +func (x *ListPentestFindingsResponse) GetFindings() []*PentestFinding { + if x != nil { + return x.Findings + } + return nil +} + +func (x *ListPentestFindingsResponse) GetTotalCount() int32 { + if x != nil { + return x.TotalCount + } + return 0 +} + +type GetPentestFindingSummaryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetPentestFindingSummaryRequest) Reset() { + *x = GetPentestFindingSummaryRequest{} + mi := &file_containarium_v1_pentest_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetPentestFindingSummaryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetPentestFindingSummaryRequest) ProtoMessage() {} + +func (x *GetPentestFindingSummaryRequest) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetPentestFindingSummaryRequest.ProtoReflect.Descriptor instead. +func (*GetPentestFindingSummaryRequest) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{12} +} + +type GetPentestFindingSummaryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Summary *PentestFindingSummary `protobuf:"bytes,1,opt,name=summary,proto3" json:"summary,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetPentestFindingSummaryResponse) Reset() { + *x = GetPentestFindingSummaryResponse{} + mi := &file_containarium_v1_pentest_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetPentestFindingSummaryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetPentestFindingSummaryResponse) ProtoMessage() {} + +func (x *GetPentestFindingSummaryResponse) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetPentestFindingSummaryResponse.ProtoReflect.Descriptor instead. +func (*GetPentestFindingSummaryResponse) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{13} +} + +func (x *GetPentestFindingSummaryResponse) GetSummary() *PentestFindingSummary { + if x != nil { + return x.Summary + } + return nil +} + +type SuppressPentestFindingRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Finding ID to suppress + FindingId int64 `protobuf:"varint,1,opt,name=finding_id,json=findingId,proto3" json:"finding_id,omitempty"` + // Reason for suppression + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SuppressPentestFindingRequest) Reset() { + *x = SuppressPentestFindingRequest{} + mi := &file_containarium_v1_pentest_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SuppressPentestFindingRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SuppressPentestFindingRequest) ProtoMessage() {} + +func (x *SuppressPentestFindingRequest) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SuppressPentestFindingRequest.ProtoReflect.Descriptor instead. +func (*SuppressPentestFindingRequest) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{14} +} + +func (x *SuppressPentestFindingRequest) GetFindingId() int64 { + if x != nil { + return x.FindingId + } + return 0 +} + +func (x *SuppressPentestFindingRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type SuppressPentestFindingResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SuppressPentestFindingResponse) Reset() { + *x = SuppressPentestFindingResponse{} + mi := &file_containarium_v1_pentest_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SuppressPentestFindingResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SuppressPentestFindingResponse) ProtoMessage() {} + +func (x *SuppressPentestFindingResponse) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SuppressPentestFindingResponse.ProtoReflect.Descriptor instead. +func (*SuppressPentestFindingResponse) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{15} +} + +func (x *SuppressPentestFindingResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type GetPentestConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetPentestConfigRequest) Reset() { + *x = GetPentestConfigRequest{} + mi := &file_containarium_v1_pentest_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetPentestConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetPentestConfigRequest) ProtoMessage() {} + +func (x *GetPentestConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetPentestConfigRequest.ProtoReflect.Descriptor instead. +func (*GetPentestConfigRequest) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{16} +} + +type GetPentestConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Config *PentestConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetPentestConfigResponse) Reset() { + *x = GetPentestConfigResponse{} + mi := &file_containarium_v1_pentest_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetPentestConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetPentestConfigResponse) ProtoMessage() {} + +func (x *GetPentestConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetPentestConfigResponse.ProtoReflect.Descriptor instead. +func (*GetPentestConfigResponse) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{17} +} + +func (x *GetPentestConfigResponse) GetConfig() *PentestConfig { + if x != nil { + return x.Config + } + return nil +} + +type InstallPentestToolRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Tool name: "nuclei" or "trivy" + ToolName string `protobuf:"bytes,1,opt,name=tool_name,json=toolName,proto3" json:"tool_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstallPentestToolRequest) Reset() { + *x = InstallPentestToolRequest{} + mi := &file_containarium_v1_pentest_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstallPentestToolRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstallPentestToolRequest) ProtoMessage() {} + +func (x *InstallPentestToolRequest) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstallPentestToolRequest.ProtoReflect.Descriptor instead. +func (*InstallPentestToolRequest) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{18} +} + +func (x *InstallPentestToolRequest) GetToolName() string { + if x != nil { + return x.ToolName + } + return "" +} + +type InstallPentestToolResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstallPentestToolResponse) Reset() { + *x = InstallPentestToolResponse{} + mi := &file_containarium_v1_pentest_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstallPentestToolResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstallPentestToolResponse) ProtoMessage() {} + +func (x *InstallPentestToolResponse) ProtoReflect() protoreflect.Message { + mi := &file_containarium_v1_pentest_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstallPentestToolResponse.ProtoReflect.Descriptor instead. +func (*InstallPentestToolResponse) Descriptor() ([]byte, []int) { + return file_containarium_v1_pentest_proto_rawDescGZIP(), []int{19} +} + +func (x *InstallPentestToolResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *InstallPentestToolResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +var File_containarium_v1_pentest_proto protoreflect.FileDescriptor + +const file_containarium_v1_pentest_proto_rawDesc = "" + + "\n" + + "\x1dcontainarium/v1/pentest.proto\x12\x0fcontainarium.v1\x1a\x1cgoogle/api/annotations.proto\x1a.protoc-gen-openapiv2/options/annotations.proto\"\xe2\x03\n" + + "\x0ePentestScanRun\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n" + + "\atrigger\x18\x02 \x01(\tR\atrigger\x12\x16\n" + + "\x06status\x18\x03 \x01(\tR\x06status\x12\x18\n" + + "\amodules\x18\x04 \x01(\tR\amodules\x12#\n" + + "\rtargets_count\x18\x05 \x01(\x05R\ftargetsCount\x12%\n" + + "\x0ecritical_count\x18\x06 \x01(\x05R\rcriticalCount\x12\x1d\n" + + "\n" + + "high_count\x18\a \x01(\x05R\thighCount\x12!\n" + + "\fmedium_count\x18\b \x01(\x05R\vmediumCount\x12\x1b\n" + + "\tlow_count\x18\t \x01(\x05R\blowCount\x12\x1d\n" + + "\n" + + "info_count\x18\n" + + " \x01(\x05R\tinfoCount\x12#\n" + + "\rerror_message\x18\v \x01(\tR\ferrorMessage\x12\x1d\n" + + "\n" + + "started_at\x18\f \x01(\tR\tstartedAt\x12!\n" + + "\fcompleted_at\x18\r \x01(\tR\vcompletedAt\x12\x1a\n" + + "\bduration\x18\x0e \x01(\tR\bduration\x12'\n" + + "\x0fcompleted_count\x18\x0f \x01(\x05R\x0ecompletedCount\"\xc1\x04\n" + + "\x0ePentestFinding\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\x12 \n" + + "\vfingerprint\x18\x02 \x01(\tR\vfingerprint\x12\x1a\n" + + "\bcategory\x18\x03 \x01(\tR\bcategory\x12\x1a\n" + + "\bseverity\x18\x04 \x01(\tR\bseverity\x12\x14\n" + + "\x05title\x18\x05 \x01(\tR\x05title\x12 \n" + + "\vdescription\x18\x06 \x01(\tR\vdescription\x12\x16\n" + + "\x06target\x18\a \x01(\tR\x06target\x12\x1a\n" + + "\bevidence\x18\b \x01(\tR\bevidence\x12\x17\n" + + "\acve_ids\x18\t \x01(\tR\x06cveIds\x12 \n" + + "\vremediation\x18\n" + + " \x01(\tR\vremediation\x12\x16\n" + + "\x06status\x18\v \x01(\tR\x06status\x12)\n" + + "\x11first_scan_run_id\x18\f \x01(\tR\x0efirstScanRunId\x12'\n" + + "\x10last_scan_run_id\x18\r \x01(\tR\rlastScanRunId\x12\"\n" + + "\rfirst_seen_at\x18\x0e \x01(\tR\vfirstSeenAt\x12 \n" + + "\flast_seen_at\x18\x0f \x01(\tR\n" + + "lastSeenAt\x12\x1f\n" + + "\vresolved_at\x18\x10 \x01(\tR\n" + + "resolvedAt\x12\x1e\n" + + "\n" + + "suppressed\x18\x11 \x01(\bR\n" + + "suppressed\x12+\n" + + "\x11suppressed_reason\x18\x12 \x01(\tR\x10suppressedReason\"\xfe\x03\n" + + "\x15PentestFindingSummary\x12%\n" + + "\x0etotal_findings\x18\x01 \x01(\x05R\rtotalFindings\x12#\n" + + "\ropen_findings\x18\x02 \x01(\x05R\fopenFindings\x12+\n" + + "\x11resolved_findings\x18\x03 \x01(\x05R\x10resolvedFindings\x12/\n" + + "\x13suppressed_findings\x18\x04 \x01(\x05R\x12suppressedFindings\x12%\n" + + "\x0ecritical_count\x18\x05 \x01(\x05R\rcriticalCount\x12\x1d\n" + + "\n" + + "high_count\x18\x06 \x01(\x05R\thighCount\x12!\n" + + "\fmedium_count\x18\a \x01(\x05R\vmediumCount\x12\x1b\n" + + "\tlow_count\x18\b \x01(\x05R\blowCount\x12\x1d\n" + + "\n" + + "info_count\x18\t \x01(\x05R\tinfoCount\x12W\n" + + "\vby_category\x18\n" + + " \x03(\v26.containarium.v1.PentestFindingSummary.ByCategoryEntryR\n" + + "byCategory\x1a=\n" + + "\x0fByCategoryEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\x05R\x05value:\x028\x01\"\xb3\x01\n" + + "\rPentestConfig\x12\x18\n" + + "\aenabled\x18\x01 \x01(\bR\aenabled\x12\x1a\n" + + "\binterval\x18\x02 \x01(\tR\binterval\x12\x18\n" + + "\amodules\x18\x03 \x01(\tR\amodules\x12)\n" + + "\x10nuclei_available\x18\x04 \x01(\bR\x0fnucleiAvailable\x12'\n" + + "\x0ftrivy_available\x18\x05 \x01(\bR\x0etrivyAvailable\"5\n" + + "\x19TriggerPentestScanRequest\x12\x18\n" + + "\amodules\x18\x01 \x01(\tR\amodules\"V\n" + + "\x1aTriggerPentestScanResponse\x12\x1e\n" + + "\vscan_run_id\x18\x01 \x01(\tR\tscanRunId\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"J\n" + + "\x1aListPentestScanRunsRequest\x12\x14\n" + + "\x05limit\x18\x01 \x01(\x05R\x05limit\x12\x16\n" + + "\x06offset\x18\x02 \x01(\x05R\x06offset\"|\n" + + "\x1bListPentestScanRunsResponse\x12<\n" + + "\tscan_runs\x18\x01 \x03(\v2\x1f.containarium.v1.PentestScanRunR\bscanRuns\x12\x1f\n" + + "\vtotal_count\x18\x02 \x01(\x05R\n" + + "totalCount\"*\n" + + "\x18GetPentestScanRunRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"W\n" + + "\x19GetPentestScanRunResponse\x12:\n" + + "\bscan_run\x18\x01 \x01(\v2\x1f.containarium.v1.PentestScanRunR\ascanRun\"\x9a\x01\n" + + "\x1aListPentestFindingsRequest\x12\x1a\n" + + "\bseverity\x18\x01 \x01(\tR\bseverity\x12\x1a\n" + + "\bcategory\x18\x02 \x01(\tR\bcategory\x12\x16\n" + + "\x06status\x18\x03 \x01(\tR\x06status\x12\x14\n" + + "\x05limit\x18\x04 \x01(\x05R\x05limit\x12\x16\n" + + "\x06offset\x18\x05 \x01(\x05R\x06offset\"{\n" + + "\x1bListPentestFindingsResponse\x12;\n" + + "\bfindings\x18\x01 \x03(\v2\x1f.containarium.v1.PentestFindingR\bfindings\x12\x1f\n" + + "\vtotal_count\x18\x02 \x01(\x05R\n" + + "totalCount\"!\n" + + "\x1fGetPentestFindingSummaryRequest\"d\n" + + " GetPentestFindingSummaryResponse\x12@\n" + + "\asummary\x18\x01 \x01(\v2&.containarium.v1.PentestFindingSummaryR\asummary\"V\n" + + "\x1dSuppressPentestFindingRequest\x12\x1d\n" + + "\n" + + "finding_id\x18\x01 \x01(\x03R\tfindingId\x12\x16\n" + + "\x06reason\x18\x02 \x01(\tR\x06reason\":\n" + + "\x1eSuppressPentestFindingResponse\x12\x18\n" + + "\amessage\x18\x01 \x01(\tR\amessage\"\x19\n" + + "\x17GetPentestConfigRequest\"R\n" + + "\x18GetPentestConfigResponse\x126\n" + + "\x06config\x18\x01 \x01(\v2\x1e.containarium.v1.PentestConfigR\x06config\"8\n" + + "\x19InstallPentestToolRequest\x12\x1b\n" + + "\ttool_name\x18\x01 \x01(\tR\btoolName\"P\n" + + "\x1aInstallPentestToolResponse\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage2\x87\x12\n" + + "\x0ePentestService\x12\x94\x02\n" + + "\x12TriggerPentestScan\x12*.containarium.v1.TriggerPentestScanRequest\x1a+.containarium.v1.TriggerPentestScanResponse\"\xa4\x01\x92A\x85\x01\n" + + "\aPentest\x12\x1dTrigger penetration test scan\x1a[Triggers an on-demand penetration test scan across all registered endpoints and containers.\x82\xd3\xe4\x93\x02\x15:\x01*\"\x10/v1/pentest/scan\x12\x81\x02\n" + + "\x13ListPentestScanRuns\x12+.containarium.v1.ListPentestScanRunsRequest\x1a,.containarium.v1.ListPentestScanRunsResponse\"\x8e\x01\x92Ar\n" + + "\aPentest\x12\x16List pentest scan runs\x1aOReturns recent penetration test scan runs with their status and finding counts.\x82\xd3\xe4\x93\x02\x13\x12\x11/v1/pentest/scans\x12\x88\x02\n" + + "\x11GetPentestScanRun\x12).containarium.v1.GetPentestScanRunRequest\x1a*.containarium.v1.GetPentestScanRunResponse\"\x9b\x01\x92Az\n" + + "\aPentest\x12\x1cGet pentest scan run details\x1aQReturns details of a specific penetration test scan run including finding counts.\x82\xd3\xe4\x93\x02\x18\x12\x16/v1/pentest/scans/{id}\x12\xa5\x02\n" + + "\x13ListPentestFindings\x12+.containarium.v1.ListPentestFindingsRequest\x1a,.containarium.v1.ListPentestFindingsResponse\"\xb2\x01\x92A\x92\x01\n" + + "\aPentest\x12\x15List pentest findings\x1apReturns security findings from penetration test scans with optional filtering by severity, category, and status.\x82\xd3\xe4\x93\x02\x16\x12\x14/v1/pentest/findings\x12\xae\x02\n" + + "\x18GetPentestFindingSummary\x120.containarium.v1.GetPentestFindingSummaryRequest\x1a1.containarium.v1.GetPentestFindingSummaryResponse\"\xac\x01\x92A\x84\x01\n" + + "\aPentest\x12\x1bGet pentest finding summary\x1a\\Returns aggregate statistics of security findings including counts by severity and category.\x82\xd3\xe4\x93\x02\x1e\x12\x1c/v1/pentest/findings/summary\x12\xc6\x02\n" + + "\x16SuppressPentestFinding\x12..containarium.v1.SuppressPentestFindingRequest\x1a/.containarium.v1.SuppressPentestFindingResponse\"\xca\x01\x92A\x91\x01\n" + + "\aPentest\x12\x1aSuppress a pentest finding\x1ajMarks a finding as suppressed with a reason. Suppressed findings are excluded from open counts and alerts.\x82\xd3\xe4\x93\x02/:\x01*\"*/v1/pentest/findings/{finding_id}/suppress\x12\x9a\x02\n" + + "\x10GetPentestConfig\x12(.containarium.v1.GetPentestConfigRequest\x1a).containarium.v1.GetPentestConfigResponse\"\xb0\x01\x92A\x92\x01\n" + + "\aPentest\x12\x19Get pentest configuration\x1alReturns the current penetration test configuration including enabled modules and external tool availability.\x82\xd3\xe4\x93\x02\x14\x12\x12/v1/pentest/config\x12\x8f\x02\n" + + "\x12InstallPentestTool\x12*.containarium.v1.InstallPentestToolRequest\x1a+.containarium.v1.InstallPentestToolResponse\"\x9f\x01\x92Ax\n" + + "\aPentest\x12\x14Install pentest tool\x1aWDownloads and installs an external pentest tool (nuclei or trivy) from GitHub releases.\x82\xd3\xe4\x93\x02\x1e:\x01*\"\x19/v1/pentest/tools/installBKZIgithub.com/footprintai/containarium/pkg/pb/containarium/v1;containariumv1b\x06proto3" + +var ( + file_containarium_v1_pentest_proto_rawDescOnce sync.Once + file_containarium_v1_pentest_proto_rawDescData []byte +) + +func file_containarium_v1_pentest_proto_rawDescGZIP() []byte { + file_containarium_v1_pentest_proto_rawDescOnce.Do(func() { + file_containarium_v1_pentest_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_containarium_v1_pentest_proto_rawDesc), len(file_containarium_v1_pentest_proto_rawDesc))) + }) + return file_containarium_v1_pentest_proto_rawDescData +} + +var file_containarium_v1_pentest_proto_msgTypes = make([]protoimpl.MessageInfo, 21) +var file_containarium_v1_pentest_proto_goTypes = []any{ + (*PentestScanRun)(nil), // 0: containarium.v1.PentestScanRun + (*PentestFinding)(nil), // 1: containarium.v1.PentestFinding + (*PentestFindingSummary)(nil), // 2: containarium.v1.PentestFindingSummary + (*PentestConfig)(nil), // 3: containarium.v1.PentestConfig + (*TriggerPentestScanRequest)(nil), // 4: containarium.v1.TriggerPentestScanRequest + (*TriggerPentestScanResponse)(nil), // 5: containarium.v1.TriggerPentestScanResponse + (*ListPentestScanRunsRequest)(nil), // 6: containarium.v1.ListPentestScanRunsRequest + (*ListPentestScanRunsResponse)(nil), // 7: containarium.v1.ListPentestScanRunsResponse + (*GetPentestScanRunRequest)(nil), // 8: containarium.v1.GetPentestScanRunRequest + (*GetPentestScanRunResponse)(nil), // 9: containarium.v1.GetPentestScanRunResponse + (*ListPentestFindingsRequest)(nil), // 10: containarium.v1.ListPentestFindingsRequest + (*ListPentestFindingsResponse)(nil), // 11: containarium.v1.ListPentestFindingsResponse + (*GetPentestFindingSummaryRequest)(nil), // 12: containarium.v1.GetPentestFindingSummaryRequest + (*GetPentestFindingSummaryResponse)(nil), // 13: containarium.v1.GetPentestFindingSummaryResponse + (*SuppressPentestFindingRequest)(nil), // 14: containarium.v1.SuppressPentestFindingRequest + (*SuppressPentestFindingResponse)(nil), // 15: containarium.v1.SuppressPentestFindingResponse + (*GetPentestConfigRequest)(nil), // 16: containarium.v1.GetPentestConfigRequest + (*GetPentestConfigResponse)(nil), // 17: containarium.v1.GetPentestConfigResponse + (*InstallPentestToolRequest)(nil), // 18: containarium.v1.InstallPentestToolRequest + (*InstallPentestToolResponse)(nil), // 19: containarium.v1.InstallPentestToolResponse + nil, // 20: containarium.v1.PentestFindingSummary.ByCategoryEntry +} +var file_containarium_v1_pentest_proto_depIdxs = []int32{ + 20, // 0: containarium.v1.PentestFindingSummary.by_category:type_name -> containarium.v1.PentestFindingSummary.ByCategoryEntry + 0, // 1: containarium.v1.ListPentestScanRunsResponse.scan_runs:type_name -> containarium.v1.PentestScanRun + 0, // 2: containarium.v1.GetPentestScanRunResponse.scan_run:type_name -> containarium.v1.PentestScanRun + 1, // 3: containarium.v1.ListPentestFindingsResponse.findings:type_name -> containarium.v1.PentestFinding + 2, // 4: containarium.v1.GetPentestFindingSummaryResponse.summary:type_name -> containarium.v1.PentestFindingSummary + 3, // 5: containarium.v1.GetPentestConfigResponse.config:type_name -> containarium.v1.PentestConfig + 4, // 6: containarium.v1.PentestService.TriggerPentestScan:input_type -> containarium.v1.TriggerPentestScanRequest + 6, // 7: containarium.v1.PentestService.ListPentestScanRuns:input_type -> containarium.v1.ListPentestScanRunsRequest + 8, // 8: containarium.v1.PentestService.GetPentestScanRun:input_type -> containarium.v1.GetPentestScanRunRequest + 10, // 9: containarium.v1.PentestService.ListPentestFindings:input_type -> containarium.v1.ListPentestFindingsRequest + 12, // 10: containarium.v1.PentestService.GetPentestFindingSummary:input_type -> containarium.v1.GetPentestFindingSummaryRequest + 14, // 11: containarium.v1.PentestService.SuppressPentestFinding:input_type -> containarium.v1.SuppressPentestFindingRequest + 16, // 12: containarium.v1.PentestService.GetPentestConfig:input_type -> containarium.v1.GetPentestConfigRequest + 18, // 13: containarium.v1.PentestService.InstallPentestTool:input_type -> containarium.v1.InstallPentestToolRequest + 5, // 14: containarium.v1.PentestService.TriggerPentestScan:output_type -> containarium.v1.TriggerPentestScanResponse + 7, // 15: containarium.v1.PentestService.ListPentestScanRuns:output_type -> containarium.v1.ListPentestScanRunsResponse + 9, // 16: containarium.v1.PentestService.GetPentestScanRun:output_type -> containarium.v1.GetPentestScanRunResponse + 11, // 17: containarium.v1.PentestService.ListPentestFindings:output_type -> containarium.v1.ListPentestFindingsResponse + 13, // 18: containarium.v1.PentestService.GetPentestFindingSummary:output_type -> containarium.v1.GetPentestFindingSummaryResponse + 15, // 19: containarium.v1.PentestService.SuppressPentestFinding:output_type -> containarium.v1.SuppressPentestFindingResponse + 17, // 20: containarium.v1.PentestService.GetPentestConfig:output_type -> containarium.v1.GetPentestConfigResponse + 19, // 21: containarium.v1.PentestService.InstallPentestTool:output_type -> containarium.v1.InstallPentestToolResponse + 14, // [14:22] is the sub-list for method output_type + 6, // [6:14] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_containarium_v1_pentest_proto_init() } +func file_containarium_v1_pentest_proto_init() { + if File_containarium_v1_pentest_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_containarium_v1_pentest_proto_rawDesc), len(file_containarium_v1_pentest_proto_rawDesc)), + NumEnums: 0, + NumMessages: 21, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_containarium_v1_pentest_proto_goTypes, + DependencyIndexes: file_containarium_v1_pentest_proto_depIdxs, + MessageInfos: file_containarium_v1_pentest_proto_msgTypes, + }.Build() + File_containarium_v1_pentest_proto = out.File + file_containarium_v1_pentest_proto_goTypes = nil + file_containarium_v1_pentest_proto_depIdxs = nil +} diff --git a/pkg/pb/containarium/v1/pentest.pb.gw.go b/pkg/pb/containarium/v1/pentest.pb.gw.go new file mode 100644 index 0000000..f0c5393 --- /dev/null +++ b/pkg/pb/containarium/v1/pentest.pb.gw.go @@ -0,0 +1,653 @@ +// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT. +// source: containarium/v1/pentest.proto + +/* +Package containariumv1 is a reverse proxy. + +It translates gRPC into RESTful JSON APIs. +*/ +package containariumv1 + +import ( + "context" + "errors" + "io" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/grpc-ecosystem/grpc-gateway/v2/utilities" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +// Suppress "imported and not used" errors +var ( + _ codes.Code + _ io.Reader + _ status.Status + _ = errors.New + _ = runtime.String + _ = utilities.NewDoubleArray + _ = metadata.Join +) + +func request_PentestService_TriggerPentestScan_0(ctx context.Context, marshaler runtime.Marshaler, client PentestServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq TriggerPentestScanRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.TriggerPentestScan(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_PentestService_TriggerPentestScan_0(ctx context.Context, marshaler runtime.Marshaler, server PentestServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq TriggerPentestScanRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.TriggerPentestScan(ctx, &protoReq) + return msg, metadata, err +} + +var filter_PentestService_ListPentestScanRuns_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} + +func request_PentestService_ListPentestScanRuns_0(ctx context.Context, marshaler runtime.Marshaler, client PentestServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListPentestScanRunsRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_PentestService_ListPentestScanRuns_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ListPentestScanRuns(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_PentestService_ListPentestScanRuns_0(ctx context.Context, marshaler runtime.Marshaler, server PentestServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListPentestScanRunsRequest + metadata runtime.ServerMetadata + ) + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_PentestService_ListPentestScanRuns_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListPentestScanRuns(ctx, &protoReq) + return msg, metadata, err +} + +func request_PentestService_GetPentestScanRun_0(ctx context.Context, marshaler runtime.Marshaler, client PentestServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetPentestScanRunRequest + metadata runtime.ServerMetadata + err error + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id") + } + protoReq.Id, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err) + } + msg, err := client.GetPentestScanRun(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_PentestService_GetPentestScanRun_0(ctx context.Context, marshaler runtime.Marshaler, server PentestServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetPentestScanRunRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id") + } + protoReq.Id, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err) + } + msg, err := server.GetPentestScanRun(ctx, &protoReq) + return msg, metadata, err +} + +var filter_PentestService_ListPentestFindings_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} + +func request_PentestService_ListPentestFindings_0(ctx context.Context, marshaler runtime.Marshaler, client PentestServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListPentestFindingsRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_PentestService_ListPentestFindings_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ListPentestFindings(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_PentestService_ListPentestFindings_0(ctx context.Context, marshaler runtime.Marshaler, server PentestServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListPentestFindingsRequest + metadata runtime.ServerMetadata + ) + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_PentestService_ListPentestFindings_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListPentestFindings(ctx, &protoReq) + return msg, metadata, err +} + +func request_PentestService_GetPentestFindingSummary_0(ctx context.Context, marshaler runtime.Marshaler, client PentestServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetPentestFindingSummaryRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.GetPentestFindingSummary(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_PentestService_GetPentestFindingSummary_0(ctx context.Context, marshaler runtime.Marshaler, server PentestServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetPentestFindingSummaryRequest + metadata runtime.ServerMetadata + ) + msg, err := server.GetPentestFindingSummary(ctx, &protoReq) + return msg, metadata, err +} + +func request_PentestService_SuppressPentestFinding_0(ctx context.Context, marshaler runtime.Marshaler, client PentestServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SuppressPentestFindingRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["finding_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "finding_id") + } + protoReq.FindingId, err = runtime.Int64(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "finding_id", err) + } + msg, err := client.SuppressPentestFinding(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_PentestService_SuppressPentestFinding_0(ctx context.Context, marshaler runtime.Marshaler, server PentestServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SuppressPentestFindingRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + val, ok := pathParams["finding_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "finding_id") + } + protoReq.FindingId, err = runtime.Int64(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "finding_id", err) + } + msg, err := server.SuppressPentestFinding(ctx, &protoReq) + return msg, metadata, err +} + +func request_PentestService_GetPentestConfig_0(ctx context.Context, marshaler runtime.Marshaler, client PentestServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetPentestConfigRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.GetPentestConfig(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_PentestService_GetPentestConfig_0(ctx context.Context, marshaler runtime.Marshaler, server PentestServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetPentestConfigRequest + metadata runtime.ServerMetadata + ) + msg, err := server.GetPentestConfig(ctx, &protoReq) + return msg, metadata, err +} + +func request_PentestService_InstallPentestTool_0(ctx context.Context, marshaler runtime.Marshaler, client PentestServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq InstallPentestToolRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.InstallPentestTool(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_PentestService_InstallPentestTool_0(ctx context.Context, marshaler runtime.Marshaler, server PentestServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq InstallPentestToolRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.InstallPentestTool(ctx, &protoReq) + return msg, metadata, err +} + +// RegisterPentestServiceHandlerServer registers the http handlers for service PentestService to "mux". +// UnaryRPC :call PentestServiceServer directly. +// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. +// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterPentestServiceHandlerFromEndpoint instead. +// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call. +func RegisterPentestServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server PentestServiceServer) error { + mux.Handle(http.MethodPost, pattern_PentestService_TriggerPentestScan_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/containarium.v1.PentestService/TriggerPentestScan", runtime.WithHTTPPathPattern("/v1/pentest/scan")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_PentestService_TriggerPentestScan_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PentestService_TriggerPentestScan_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_PentestService_ListPentestScanRuns_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/containarium.v1.PentestService/ListPentestScanRuns", runtime.WithHTTPPathPattern("/v1/pentest/scans")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_PentestService_ListPentestScanRuns_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PentestService_ListPentestScanRuns_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_PentestService_GetPentestScanRun_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/containarium.v1.PentestService/GetPentestScanRun", runtime.WithHTTPPathPattern("/v1/pentest/scans/{id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_PentestService_GetPentestScanRun_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PentestService_GetPentestScanRun_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_PentestService_ListPentestFindings_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/containarium.v1.PentestService/ListPentestFindings", runtime.WithHTTPPathPattern("/v1/pentest/findings")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_PentestService_ListPentestFindings_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PentestService_ListPentestFindings_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_PentestService_GetPentestFindingSummary_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/containarium.v1.PentestService/GetPentestFindingSummary", runtime.WithHTTPPathPattern("/v1/pentest/findings/summary")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_PentestService_GetPentestFindingSummary_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PentestService_GetPentestFindingSummary_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_PentestService_SuppressPentestFinding_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/containarium.v1.PentestService/SuppressPentestFinding", runtime.WithHTTPPathPattern("/v1/pentest/findings/{finding_id}/suppress")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_PentestService_SuppressPentestFinding_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PentestService_SuppressPentestFinding_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_PentestService_GetPentestConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/containarium.v1.PentestService/GetPentestConfig", runtime.WithHTTPPathPattern("/v1/pentest/config")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_PentestService_GetPentestConfig_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PentestService_GetPentestConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_PentestService_InstallPentestTool_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/containarium.v1.PentestService/InstallPentestTool", runtime.WithHTTPPathPattern("/v1/pentest/tools/install")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_PentestService_InstallPentestTool_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PentestService_InstallPentestTool_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + return nil +} + +// RegisterPentestServiceHandlerFromEndpoint is same as RegisterPentestServiceHandler but +// automatically dials to "endpoint" and closes the connection when "ctx" gets done. +func RegisterPentestServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) { + conn, err := grpc.NewClient(endpoint, opts...) + if err != nil { + return err + } + defer func() { + if err != nil { + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + return + } + go func() { + <-ctx.Done() + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + }() + }() + return RegisterPentestServiceHandler(ctx, mux, conn) +} + +// RegisterPentestServiceHandler registers the http handlers for service PentestService to "mux". +// The handlers forward requests to the grpc endpoint over "conn". +func RegisterPentestServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error { + return RegisterPentestServiceHandlerClient(ctx, mux, NewPentestServiceClient(conn)) +} + +// RegisterPentestServiceHandlerClient registers the http handlers for service PentestService +// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "PentestServiceClient". +// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "PentestServiceClient" +// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in +// "PentestServiceClient" to call the correct interceptors. This client ignores the HTTP middlewares. +func RegisterPentestServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client PentestServiceClient) error { + mux.Handle(http.MethodPost, pattern_PentestService_TriggerPentestScan_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/containarium.v1.PentestService/TriggerPentestScan", runtime.WithHTTPPathPattern("/v1/pentest/scan")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_PentestService_TriggerPentestScan_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PentestService_TriggerPentestScan_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_PentestService_ListPentestScanRuns_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/containarium.v1.PentestService/ListPentestScanRuns", runtime.WithHTTPPathPattern("/v1/pentest/scans")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_PentestService_ListPentestScanRuns_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PentestService_ListPentestScanRuns_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_PentestService_GetPentestScanRun_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/containarium.v1.PentestService/GetPentestScanRun", runtime.WithHTTPPathPattern("/v1/pentest/scans/{id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_PentestService_GetPentestScanRun_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PentestService_GetPentestScanRun_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_PentestService_ListPentestFindings_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/containarium.v1.PentestService/ListPentestFindings", runtime.WithHTTPPathPattern("/v1/pentest/findings")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_PentestService_ListPentestFindings_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PentestService_ListPentestFindings_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_PentestService_GetPentestFindingSummary_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/containarium.v1.PentestService/GetPentestFindingSummary", runtime.WithHTTPPathPattern("/v1/pentest/findings/summary")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_PentestService_GetPentestFindingSummary_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PentestService_GetPentestFindingSummary_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_PentestService_SuppressPentestFinding_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/containarium.v1.PentestService/SuppressPentestFinding", runtime.WithHTTPPathPattern("/v1/pentest/findings/{finding_id}/suppress")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_PentestService_SuppressPentestFinding_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PentestService_SuppressPentestFinding_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_PentestService_GetPentestConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/containarium.v1.PentestService/GetPentestConfig", runtime.WithHTTPPathPattern("/v1/pentest/config")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_PentestService_GetPentestConfig_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PentestService_GetPentestConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_PentestService_InstallPentestTool_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/containarium.v1.PentestService/InstallPentestTool", runtime.WithHTTPPathPattern("/v1/pentest/tools/install")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_PentestService_InstallPentestTool_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_PentestService_InstallPentestTool_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + return nil +} + +var ( + pattern_PentestService_TriggerPentestScan_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "pentest", "scan"}, "")) + pattern_PentestService_ListPentestScanRuns_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "pentest", "scans"}, "")) + pattern_PentestService_GetPentestScanRun_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"v1", "pentest", "scans", "id"}, "")) + pattern_PentestService_ListPentestFindings_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "pentest", "findings"}, "")) + pattern_PentestService_GetPentestFindingSummary_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"v1", "pentest", "findings", "summary"}, "")) + pattern_PentestService_SuppressPentestFinding_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4}, []string{"v1", "pentest", "findings", "finding_id", "suppress"}, "")) + pattern_PentestService_GetPentestConfig_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "pentest", "config"}, "")) + pattern_PentestService_InstallPentestTool_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"v1", "pentest", "tools", "install"}, "")) +) + +var ( + forward_PentestService_TriggerPentestScan_0 = runtime.ForwardResponseMessage + forward_PentestService_ListPentestScanRuns_0 = runtime.ForwardResponseMessage + forward_PentestService_GetPentestScanRun_0 = runtime.ForwardResponseMessage + forward_PentestService_ListPentestFindings_0 = runtime.ForwardResponseMessage + forward_PentestService_GetPentestFindingSummary_0 = runtime.ForwardResponseMessage + forward_PentestService_SuppressPentestFinding_0 = runtime.ForwardResponseMessage + forward_PentestService_GetPentestConfig_0 = runtime.ForwardResponseMessage + forward_PentestService_InstallPentestTool_0 = runtime.ForwardResponseMessage +) diff --git a/pkg/pb/containarium/v1/pentest_grpc.pb.go b/pkg/pb/containarium/v1/pentest_grpc.pb.go new file mode 100644 index 0000000..da4d624 --- /dev/null +++ b/pkg/pb/containarium/v1/pentest_grpc.pb.go @@ -0,0 +1,407 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc (unknown) +// source: containarium/v1/pentest.proto + +package containariumv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + PentestService_TriggerPentestScan_FullMethodName = "/containarium.v1.PentestService/TriggerPentestScan" + PentestService_ListPentestScanRuns_FullMethodName = "/containarium.v1.PentestService/ListPentestScanRuns" + PentestService_GetPentestScanRun_FullMethodName = "/containarium.v1.PentestService/GetPentestScanRun" + PentestService_ListPentestFindings_FullMethodName = "/containarium.v1.PentestService/ListPentestFindings" + PentestService_GetPentestFindingSummary_FullMethodName = "/containarium.v1.PentestService/GetPentestFindingSummary" + PentestService_SuppressPentestFinding_FullMethodName = "/containarium.v1.PentestService/SuppressPentestFinding" + PentestService_GetPentestConfig_FullMethodName = "/containarium.v1.PentestService/GetPentestConfig" + PentestService_InstallPentestTool_FullMethodName = "/containarium.v1.PentestService/InstallPentestTool" +) + +// PentestServiceClient is the client API for PentestService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// PentestService provides automated penetration testing and vulnerability scanning +type PentestServiceClient interface { + // TriggerPentestScan triggers an on-demand penetration test scan + TriggerPentestScan(ctx context.Context, in *TriggerPentestScanRequest, opts ...grpc.CallOption) (*TriggerPentestScanResponse, error) + // ListPentestScanRuns returns recent scan runs + ListPentestScanRuns(ctx context.Context, in *ListPentestScanRunsRequest, opts ...grpc.CallOption) (*ListPentestScanRunsResponse, error) + // GetPentestScanRun returns details of a specific scan run + GetPentestScanRun(ctx context.Context, in *GetPentestScanRunRequest, opts ...grpc.CallOption) (*GetPentestScanRunResponse, error) + // ListPentestFindings returns security findings with optional filtering + ListPentestFindings(ctx context.Context, in *ListPentestFindingsRequest, opts ...grpc.CallOption) (*ListPentestFindingsResponse, error) + // GetPentestFindingSummary returns aggregate finding statistics + GetPentestFindingSummary(ctx context.Context, in *GetPentestFindingSummaryRequest, opts ...grpc.CallOption) (*GetPentestFindingSummaryResponse, error) + // SuppressPentestFinding marks a finding as suppressed (acknowledged false positive or accepted risk) + SuppressPentestFinding(ctx context.Context, in *SuppressPentestFindingRequest, opts ...grpc.CallOption) (*SuppressPentestFindingResponse, error) + // GetPentestConfig returns the current pentest configuration + GetPentestConfig(ctx context.Context, in *GetPentestConfigRequest, opts ...grpc.CallOption) (*GetPentestConfigResponse, error) + // InstallPentestTool downloads and installs an external pentest tool (nuclei or trivy) + InstallPentestTool(ctx context.Context, in *InstallPentestToolRequest, opts ...grpc.CallOption) (*InstallPentestToolResponse, error) +} + +type pentestServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewPentestServiceClient(cc grpc.ClientConnInterface) PentestServiceClient { + return &pentestServiceClient{cc} +} + +func (c *pentestServiceClient) TriggerPentestScan(ctx context.Context, in *TriggerPentestScanRequest, opts ...grpc.CallOption) (*TriggerPentestScanResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(TriggerPentestScanResponse) + err := c.cc.Invoke(ctx, PentestService_TriggerPentestScan_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *pentestServiceClient) ListPentestScanRuns(ctx context.Context, in *ListPentestScanRunsRequest, opts ...grpc.CallOption) (*ListPentestScanRunsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListPentestScanRunsResponse) + err := c.cc.Invoke(ctx, PentestService_ListPentestScanRuns_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *pentestServiceClient) GetPentestScanRun(ctx context.Context, in *GetPentestScanRunRequest, opts ...grpc.CallOption) (*GetPentestScanRunResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetPentestScanRunResponse) + err := c.cc.Invoke(ctx, PentestService_GetPentestScanRun_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *pentestServiceClient) ListPentestFindings(ctx context.Context, in *ListPentestFindingsRequest, opts ...grpc.CallOption) (*ListPentestFindingsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListPentestFindingsResponse) + err := c.cc.Invoke(ctx, PentestService_ListPentestFindings_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *pentestServiceClient) GetPentestFindingSummary(ctx context.Context, in *GetPentestFindingSummaryRequest, opts ...grpc.CallOption) (*GetPentestFindingSummaryResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetPentestFindingSummaryResponse) + err := c.cc.Invoke(ctx, PentestService_GetPentestFindingSummary_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *pentestServiceClient) SuppressPentestFinding(ctx context.Context, in *SuppressPentestFindingRequest, opts ...grpc.CallOption) (*SuppressPentestFindingResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SuppressPentestFindingResponse) + err := c.cc.Invoke(ctx, PentestService_SuppressPentestFinding_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *pentestServiceClient) GetPentestConfig(ctx context.Context, in *GetPentestConfigRequest, opts ...grpc.CallOption) (*GetPentestConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetPentestConfigResponse) + err := c.cc.Invoke(ctx, PentestService_GetPentestConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *pentestServiceClient) InstallPentestTool(ctx context.Context, in *InstallPentestToolRequest, opts ...grpc.CallOption) (*InstallPentestToolResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(InstallPentestToolResponse) + err := c.cc.Invoke(ctx, PentestService_InstallPentestTool_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// PentestServiceServer is the server API for PentestService service. +// All implementations must embed UnimplementedPentestServiceServer +// for forward compatibility. +// +// PentestService provides automated penetration testing and vulnerability scanning +type PentestServiceServer interface { + // TriggerPentestScan triggers an on-demand penetration test scan + TriggerPentestScan(context.Context, *TriggerPentestScanRequest) (*TriggerPentestScanResponse, error) + // ListPentestScanRuns returns recent scan runs + ListPentestScanRuns(context.Context, *ListPentestScanRunsRequest) (*ListPentestScanRunsResponse, error) + // GetPentestScanRun returns details of a specific scan run + GetPentestScanRun(context.Context, *GetPentestScanRunRequest) (*GetPentestScanRunResponse, error) + // ListPentestFindings returns security findings with optional filtering + ListPentestFindings(context.Context, *ListPentestFindingsRequest) (*ListPentestFindingsResponse, error) + // GetPentestFindingSummary returns aggregate finding statistics + GetPentestFindingSummary(context.Context, *GetPentestFindingSummaryRequest) (*GetPentestFindingSummaryResponse, error) + // SuppressPentestFinding marks a finding as suppressed (acknowledged false positive or accepted risk) + SuppressPentestFinding(context.Context, *SuppressPentestFindingRequest) (*SuppressPentestFindingResponse, error) + // GetPentestConfig returns the current pentest configuration + GetPentestConfig(context.Context, *GetPentestConfigRequest) (*GetPentestConfigResponse, error) + // InstallPentestTool downloads and installs an external pentest tool (nuclei or trivy) + InstallPentestTool(context.Context, *InstallPentestToolRequest) (*InstallPentestToolResponse, error) + mustEmbedUnimplementedPentestServiceServer() +} + +// UnimplementedPentestServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedPentestServiceServer struct{} + +func (UnimplementedPentestServiceServer) TriggerPentestScan(context.Context, *TriggerPentestScanRequest) (*TriggerPentestScanResponse, error) { + return nil, status.Error(codes.Unimplemented, "method TriggerPentestScan not implemented") +} +func (UnimplementedPentestServiceServer) ListPentestScanRuns(context.Context, *ListPentestScanRunsRequest) (*ListPentestScanRunsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListPentestScanRuns not implemented") +} +func (UnimplementedPentestServiceServer) GetPentestScanRun(context.Context, *GetPentestScanRunRequest) (*GetPentestScanRunResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetPentestScanRun not implemented") +} +func (UnimplementedPentestServiceServer) ListPentestFindings(context.Context, *ListPentestFindingsRequest) (*ListPentestFindingsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListPentestFindings not implemented") +} +func (UnimplementedPentestServiceServer) GetPentestFindingSummary(context.Context, *GetPentestFindingSummaryRequest) (*GetPentestFindingSummaryResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetPentestFindingSummary not implemented") +} +func (UnimplementedPentestServiceServer) SuppressPentestFinding(context.Context, *SuppressPentestFindingRequest) (*SuppressPentestFindingResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SuppressPentestFinding not implemented") +} +func (UnimplementedPentestServiceServer) GetPentestConfig(context.Context, *GetPentestConfigRequest) (*GetPentestConfigResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetPentestConfig not implemented") +} +func (UnimplementedPentestServiceServer) InstallPentestTool(context.Context, *InstallPentestToolRequest) (*InstallPentestToolResponse, error) { + return nil, status.Error(codes.Unimplemented, "method InstallPentestTool not implemented") +} +func (UnimplementedPentestServiceServer) mustEmbedUnimplementedPentestServiceServer() {} +func (UnimplementedPentestServiceServer) testEmbeddedByValue() {} + +// UnsafePentestServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to PentestServiceServer will +// result in compilation errors. +type UnsafePentestServiceServer interface { + mustEmbedUnimplementedPentestServiceServer() +} + +func RegisterPentestServiceServer(s grpc.ServiceRegistrar, srv PentestServiceServer) { + // If the following call panics, it indicates UnimplementedPentestServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&PentestService_ServiceDesc, srv) +} + +func _PentestService_TriggerPentestScan_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(TriggerPentestScanRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PentestServiceServer).TriggerPentestScan(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PentestService_TriggerPentestScan_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PentestServiceServer).TriggerPentestScan(ctx, req.(*TriggerPentestScanRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _PentestService_ListPentestScanRuns_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListPentestScanRunsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PentestServiceServer).ListPentestScanRuns(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PentestService_ListPentestScanRuns_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PentestServiceServer).ListPentestScanRuns(ctx, req.(*ListPentestScanRunsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _PentestService_GetPentestScanRun_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetPentestScanRunRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PentestServiceServer).GetPentestScanRun(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PentestService_GetPentestScanRun_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PentestServiceServer).GetPentestScanRun(ctx, req.(*GetPentestScanRunRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _PentestService_ListPentestFindings_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListPentestFindingsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PentestServiceServer).ListPentestFindings(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PentestService_ListPentestFindings_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PentestServiceServer).ListPentestFindings(ctx, req.(*ListPentestFindingsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _PentestService_GetPentestFindingSummary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetPentestFindingSummaryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PentestServiceServer).GetPentestFindingSummary(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PentestService_GetPentestFindingSummary_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PentestServiceServer).GetPentestFindingSummary(ctx, req.(*GetPentestFindingSummaryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _PentestService_SuppressPentestFinding_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SuppressPentestFindingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PentestServiceServer).SuppressPentestFinding(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PentestService_SuppressPentestFinding_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PentestServiceServer).SuppressPentestFinding(ctx, req.(*SuppressPentestFindingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _PentestService_GetPentestConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetPentestConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PentestServiceServer).GetPentestConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PentestService_GetPentestConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PentestServiceServer).GetPentestConfig(ctx, req.(*GetPentestConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _PentestService_InstallPentestTool_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(InstallPentestToolRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PentestServiceServer).InstallPentestTool(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PentestService_InstallPentestTool_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PentestServiceServer).InstallPentestTool(ctx, req.(*InstallPentestToolRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// PentestService_ServiceDesc is the grpc.ServiceDesc for PentestService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var PentestService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "containarium.v1.PentestService", + HandlerType: (*PentestServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "TriggerPentestScan", + Handler: _PentestService_TriggerPentestScan_Handler, + }, + { + MethodName: "ListPentestScanRuns", + Handler: _PentestService_ListPentestScanRuns_Handler, + }, + { + MethodName: "GetPentestScanRun", + Handler: _PentestService_GetPentestScanRun_Handler, + }, + { + MethodName: "ListPentestFindings", + Handler: _PentestService_ListPentestFindings_Handler, + }, + { + MethodName: "GetPentestFindingSummary", + Handler: _PentestService_GetPentestFindingSummary_Handler, + }, + { + MethodName: "SuppressPentestFinding", + Handler: _PentestService_SuppressPentestFinding_Handler, + }, + { + MethodName: "GetPentestConfig", + Handler: _PentestService_GetPentestConfig_Handler, + }, + { + MethodName: "InstallPentestTool", + Handler: _PentestService_InstallPentestTool_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "containarium/v1/pentest.proto", +} diff --git a/proto/containarium/v1/pentest.proto b/proto/containarium/v1/pentest.proto new file mode 100644 index 0000000..6ae8e92 --- /dev/null +++ b/proto/containarium/v1/pentest.proto @@ -0,0 +1,338 @@ +syntax = "proto3"; + +package containarium.v1; + +import "google/api/annotations.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; + +option go_package = "github.com/footprintai/containarium/pkg/pb/containarium/v1;containariumv1"; + +// ============= Data Models ============= + +// PentestScanRun represents a single penetration test scan execution +message PentestScanRun { + // Unique scan run ID (UUID) + string id = 1; + + // How the scan was triggered: "scheduled", "manual", "startup" + string trigger = 2; + + // Scan status: "running", "completed", "failed" + string status = 3; + + // Comma-separated list of modules that were run + string modules = 4; + + // Number of targets scanned + int32 targets_count = 5; + + // Finding counts by severity + int32 critical_count = 6; + int32 high_count = 7; + int32 medium_count = 8; + int32 low_count = 9; + int32 info_count = 10; + + // Error message if status is "failed" + string error_message = 11; + + // When the scan started (ISO 8601) + string started_at = 12; + + // When the scan completed (ISO 8601) + string completed_at = 13; + + // Duration of the scan + string duration = 14; + + // Number of targets that finished processing + int32 completed_count = 15; +} + +// PentestFinding represents a single security finding +message PentestFinding { + // Database ID + int64 id = 1; + + // SHA-256 fingerprint for deduplication (category|target|title) + string fingerprint = 2; + + // Scanner module that found this (e.g., "headers", "tls", "nuclei") + string category = 3; + + // Severity: "critical", "high", "medium", "low", "info" + string severity = 4; + + // Short title of the finding + string title = 5; + + // Detailed description + string description = 6; + + // Target that was scanned (URL, IP:port, domain) + string target = 7; + + // Evidence or raw output supporting the finding + string evidence = 8; + + // Comma-separated CVE IDs if applicable + string cve_ids = 9; + + // Remediation guidance + string remediation = 10; + + // Current status: "open", "resolved", "suppressed" + string status = 11; + + // Scan run ID that first found this + string first_scan_run_id = 12; + + // Scan run ID that last saw this + string last_scan_run_id = 13; + + // When first seen (ISO 8601) + string first_seen_at = 14; + + // When last seen (ISO 8601) + string last_seen_at = 15; + + // When resolved (ISO 8601, empty if still open) + string resolved_at = 16; + + // Whether the finding is suppressed + bool suppressed = 17; + + // Reason for suppression + string suppressed_reason = 18; +} + +// PentestFindingSummary provides aggregate counts +message PentestFindingSummary { + int32 total_findings = 1; + int32 open_findings = 2; + int32 resolved_findings = 3; + int32 suppressed_findings = 4; + int32 critical_count = 5; + int32 high_count = 6; + int32 medium_count = 7; + int32 low_count = 8; + int32 info_count = 9; + + // Per-category breakdown + map by_category = 10; +} + +// PentestConfig returns the current pentest configuration +message PentestConfig { + // Whether pentest scanning is enabled + bool enabled = 1; + + // Scan interval (e.g., "24h") + string interval = 2; + + // Comma-separated list of enabled modules + string modules = 3; + + // Whether Nuclei is available + bool nuclei_available = 4; + + // Whether Trivy is available + bool trivy_available = 5; +} + +// ============= Request/Response Messages ============= + +message TriggerPentestScanRequest { + // Optional: comma-separated list of modules to run (empty = all enabled) + string modules = 1; +} + +message TriggerPentestScanResponse { + // Scan run ID + string scan_run_id = 1; + + // Human-readable message + string message = 2; +} + +message ListPentestScanRunsRequest { + // Maximum number of scan runs to return (default: 20) + int32 limit = 1; + + // Offset for pagination + int32 offset = 2; +} + +message ListPentestScanRunsResponse { + repeated PentestScanRun scan_runs = 1; + int32 total_count = 2; +} + +message GetPentestScanRunRequest { + // Scan run ID + string id = 1; +} + +message GetPentestScanRunResponse { + PentestScanRun scan_run = 1; +} + +message ListPentestFindingsRequest { + // Filter by severity (optional) + string severity = 1; + + // Filter by category/module (optional) + string category = 2; + + // Filter by status: "open", "resolved", "suppressed" (optional) + string status = 3; + + // Maximum number of findings to return (default: 50) + int32 limit = 4; + + // Offset for pagination + int32 offset = 5; +} + +message ListPentestFindingsResponse { + repeated PentestFinding findings = 1; + int32 total_count = 2; +} + +message GetPentestFindingSummaryRequest {} + +message GetPentestFindingSummaryResponse { + PentestFindingSummary summary = 1; +} + +message SuppressPentestFindingRequest { + // Finding ID to suppress + int64 finding_id = 1; + + // Reason for suppression + string reason = 2; +} + +message SuppressPentestFindingResponse { + string message = 1; +} + +message GetPentestConfigRequest {} + +message GetPentestConfigResponse { + PentestConfig config = 1; +} + +message InstallPentestToolRequest { + // Tool name: "nuclei" or "trivy" + string tool_name = 1; +} + +message InstallPentestToolResponse { + bool success = 1; + string message = 2; +} + +// ============= Service Definition ============= + +// PentestService provides automated penetration testing and vulnerability scanning +service PentestService { + // TriggerPentestScan triggers an on-demand penetration test scan + rpc TriggerPentestScan(TriggerPentestScanRequest) returns (TriggerPentestScanResponse) { + option (google.api.http) = { + post: "/v1/pentest/scan" + body: "*" + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Trigger penetration test scan"; + description: "Triggers an on-demand penetration test scan across all registered endpoints and containers."; + tags: "Pentest"; + }; + } + + // ListPentestScanRuns returns recent scan runs + rpc ListPentestScanRuns(ListPentestScanRunsRequest) returns (ListPentestScanRunsResponse) { + option (google.api.http) = { + get: "/v1/pentest/scans" + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "List pentest scan runs"; + description: "Returns recent penetration test scan runs with their status and finding counts."; + tags: "Pentest"; + }; + } + + // GetPentestScanRun returns details of a specific scan run + rpc GetPentestScanRun(GetPentestScanRunRequest) returns (GetPentestScanRunResponse) { + option (google.api.http) = { + get: "/v1/pentest/scans/{id}" + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get pentest scan run details"; + description: "Returns details of a specific penetration test scan run including finding counts."; + tags: "Pentest"; + }; + } + + // ListPentestFindings returns security findings with optional filtering + rpc ListPentestFindings(ListPentestFindingsRequest) returns (ListPentestFindingsResponse) { + option (google.api.http) = { + get: "/v1/pentest/findings" + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "List pentest findings"; + description: "Returns security findings from penetration test scans with optional filtering by severity, category, and status."; + tags: "Pentest"; + }; + } + + // GetPentestFindingSummary returns aggregate finding statistics + rpc GetPentestFindingSummary(GetPentestFindingSummaryRequest) returns (GetPentestFindingSummaryResponse) { + option (google.api.http) = { + get: "/v1/pentest/findings/summary" + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get pentest finding summary"; + description: "Returns aggregate statistics of security findings including counts by severity and category."; + tags: "Pentest"; + }; + } + + // SuppressPentestFinding marks a finding as suppressed (acknowledged false positive or accepted risk) + rpc SuppressPentestFinding(SuppressPentestFindingRequest) returns (SuppressPentestFindingResponse) { + option (google.api.http) = { + post: "/v1/pentest/findings/{finding_id}/suppress" + body: "*" + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Suppress a pentest finding"; + description: "Marks a finding as suppressed with a reason. Suppressed findings are excluded from open counts and alerts."; + tags: "Pentest"; + }; + } + + // GetPentestConfig returns the current pentest configuration + rpc GetPentestConfig(GetPentestConfigRequest) returns (GetPentestConfigResponse) { + option (google.api.http) = { + get: "/v1/pentest/config" + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get pentest configuration"; + description: "Returns the current penetration test configuration including enabled modules and external tool availability."; + tags: "Pentest"; + }; + } + + // InstallPentestTool downloads and installs an external pentest tool (nuclei or trivy) + rpc InstallPentestTool(InstallPentestToolRequest) returns (InstallPentestToolResponse) { + option (google.api.http) = { + post: "/v1/pentest/tools/install" + body: "*" + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Install pentest tool"; + description: "Downloads and installs an external pentest tool (nuclei or trivy) from GitHub releases."; + tags: "Pentest"; + }; + } +} diff --git a/test/integration/storage_test.go b/test/integration/storage_test.go index 2a6e4d4..d22768e 100644 --- a/test/integration/storage_test.go +++ b/test/integration/storage_test.go @@ -254,11 +254,11 @@ func testPrepareRebootData(t *testing.T, ctx context.Context, grpcClient *client t.Logf("✓ Test container created and state saved") t.Logf(" Container: %s", info.Name) t.Logf(" Test data hash: %s", testDataHash) - t.Logf("") - t.Logf("=" + strings.Repeat("=", 70)) - t.Logf("REBOOT TEST PREPARATION COMPLETE") - t.Logf("=" + strings.Repeat("=", 70)) - t.Logf("") + t.Log("") + t.Log("=" + strings.Repeat("=", 70)) + t.Log("REBOOT TEST PREPARATION COMPLETE") + t.Log("=" + strings.Repeat("=", 70)) + t.Log("") t.Logf("Next steps:") t.Logf(" 1. Write test data to container:") t.Logf(" sudo incus exec %s -- bash -c 'echo \"%s\" > /home/%s/test-data.txt'", @@ -271,8 +271,8 @@ func testPrepareRebootData(t *testing.T, ctx context.Context, grpcClient *client t.Logf("") t.Logf(" 3. After reboot, run this test again:") t.Logf(" make test-integration") - t.Logf("") - t.Logf("=" + strings.Repeat("=", 70)) + t.Log("") + t.Log("=" + strings.Repeat("=", 70)) } // testDataPersistenceAfterReboot verifies data survived the reboot @@ -310,11 +310,11 @@ func testDataPersistenceAfterReboot(t *testing.T, stateFile string) { t.Log("✓ Test cleanup complete") }() - t.Logf("") - t.Logf("=" + strings.Repeat("=", 70)) - t.Logf("REBOOT PERSISTENCE TEST PASSED") - t.Logf("=" + strings.Repeat("=", 70)) - t.Logf("") + t.Log("") + t.Log("=" + strings.Repeat("=", 70)) + t.Log("REBOOT PERSISTENCE TEST PASSED") + t.Log("=" + strings.Repeat("=", 70)) + t.Log("") t.Logf("To complete verification:") t.Logf(" 1. Verify data integrity:") t.Logf(" sudo incus exec %s -- cat /home/%s/test-data.txt", diff --git a/web-ui/app/demo/page.tsx b/web-ui/app/demo/page.tsx index 38bf119..35bdea1 100644 --- a/web-ui/app/demo/page.tsx +++ b/web-ui/app/demo/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { Box, Typography, @@ -19,22 +19,41 @@ import { Stack, LinearProgress, IconButton, + Collapse, + FormControl, + InputLabel, + Select, + MenuItem, } from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; import DnsIcon from '@mui/icons-material/Dns'; import AppsIcon from '@mui/icons-material/Apps'; import HubIcon from '@mui/icons-material/Hub'; import TimelineIcon from '@mui/icons-material/Timeline'; import ShieldIcon from '@mui/icons-material/Shield'; import MonitorHeartIcon from '@mui/icons-material/MonitorHeart'; +import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive'; +import BugReportIcon from '@mui/icons-material/BugReport'; +import HistoryIcon from '@mui/icons-material/History'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import RefreshIcon from '@mui/icons-material/Refresh'; import DownloadIcon from '@mui/icons-material/Download'; import ScannerIcon from '@mui/icons-material/Scanner'; import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import ErrorIcon from '@mui/icons-material/Error'; +import AddIcon from '@mui/icons-material/Add'; +import LockIcon from '@mui/icons-material/Lock'; +import SendIcon from '@mui/icons-material/Send'; import Tooltip from '@mui/material/Tooltip'; import CircularProgress from '@mui/material/CircularProgress'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Snackbar from '@mui/material/Snackbar'; import AppBar from '@/src/components/layout/AppBar'; import ContainerTopology from '@/src/components/containers/ContainerTopology'; import LabelEditorDialog from '@/src/components/containers/LabelEditorDialog'; @@ -43,7 +62,8 @@ import NetworkTopologyView from '@/src/components/network/NetworkTopologyView'; import TrafficView, { RouteTrafficStats } from '@/src/components/traffic/TrafficView'; import { Container, ContainerMetricsWithRate, SystemInfo } from '@/src/types/container'; import { App, NetworkTopology, ProxyRoute, NetworkNode, PassthroughRoute, DNSRecord } from '@/src/types/app'; -import { ClamavContainerSummary, ScanStatusResponse, ScanJob } from '@/src/types/security'; +import { ClamavContainerSummary, ScanStatusResponse, ScanJob, PentestFinding } from '@/src/types/security'; +import { AuditLogEntry } from '@/src/types/audit'; // Mock system info for system resources card const mockSystemInfo: SystemInfo = { @@ -807,7 +827,7 @@ function DemoSecurityView() { }); return ( - + {/* Header */} @@ -893,6 +913,537 @@ function DemoSecurityView() { ); } +// ============================================ +// Demo Audit View (self-contained, no API calls) +// ============================================ + +const mockAuditLogs: AuditLogEntry[] = [ + { id: 1, timestamp: '2026-03-15T10:30:15Z', username: 'alice', action: 'ssh_login', resourceType: 'container', resourceId: 'alice-container', detail: 'SSH session established via sshpiper', sourceIp: '203.0.113.42', statusCode: 0 }, + { id: 2, timestamp: '2026-03-15T10:25:00Z', username: 'admin', action: 'api_post', resourceType: 'api', resourceId: 'POST /v1/pentest/scan', detail: 'Manual pentest scan triggered', sourceIp: '10.0.100.1', statusCode: 200 }, + { id: 3, timestamp: '2026-03-15T10:20:30Z', username: '', action: 'EVENT_TYPE_APP_DEPLOYED', resourceType: 'app', resourceId: 'ml-dashboard', detail: 'App deployed: ml-dashboard (alice-container)', sourceIp: '', statusCode: 0 }, + { id: 4, timestamp: '2026-03-15T10:15:00Z', username: 'bob', action: 'terminal_access', resourceType: 'container', resourceId: 'bob-container', detail: 'Web terminal session opened', sourceIp: '198.51.100.5', statusCode: 0 }, + { id: 5, timestamp: '2026-03-15T10:10:45Z', username: 'admin', action: 'api_put', resourceType: 'api', resourceId: 'PUT /v1/system/alerting/config', detail: 'Webhook URL updated', sourceIp: '10.0.100.1', statusCode: 200 }, + { id: 6, timestamp: '2026-03-15T09:55:00Z', username: '', action: 'EVENT_TYPE_CONTAINER_CREATED', resourceType: 'container', resourceId: 'frank-container', detail: 'Container created: frank (8 CPU, 16GB RAM, RTX 3090)', sourceIp: '', statusCode: 0 }, + { id: 7, timestamp: '2026-03-15T09:30:00Z', username: 'admin', action: 'api_delete', resourceType: 'api', resourceId: 'DELETE /v1/alerts/rules/old-rule-1', detail: 'Alert rule deleted', sourceIp: '10.0.100.1', statusCode: 200 }, + { id: 8, timestamp: '2026-03-15T09:15:20Z', username: 'charlie', action: 'ssh_login', resourceType: 'container', resourceId: 'charlie-container', detail: 'SSH session established via sshpiper', sourceIp: '192.0.2.88', statusCode: 0 }, + { id: 9, timestamp: '2026-03-15T08:45:00Z', username: '', action: 'EVENT_TYPE_ROUTE_ADDED', resourceType: 'route', resourceId: 'charlie-training-monitor.containarium.dev', detail: 'Route added: charlie-training-monitor → 10.0.100.18:5000', sourceIp: '', statusCode: 0 }, + { id: 10, timestamp: '2026-03-15T08:00:00Z', username: 'admin', action: 'api_get', resourceType: 'api', resourceId: 'GET /v1/containers', detail: 'Listed containers', sourceIp: '10.0.100.1', statusCode: 200 }, + { id: 11, timestamp: '2026-03-14T23:00:00Z', username: '', action: 'EVENT_TYPE_CONTAINER_STOPPED', resourceType: 'container', resourceId: 'david-container', detail: 'Container stopped by user', sourceIp: '', statusCode: 0 }, + { id: 12, timestamp: '2026-03-14T22:30:00Z', username: 'emma', action: 'ssh_login', resourceType: 'container', resourceId: 'emma-container', detail: 'SSH session established via sshpiper', sourceIp: '198.51.100.12', statusCode: 0 }, +]; + +const DEMO_METHOD_STYLES: Record = { + api_get: { label: 'GET', bg: '#e8f5e9', color: '#2e7d32' }, + api_post: { label: 'POST', bg: '#e3f2fd', color: '#1565c0' }, + api_put: { label: 'PUT', bg: '#fff3e0', color: '#e65100' }, + api_delete: { label: 'DELETE', bg: '#ffebee', color: '#c62828' }, +}; + +function DemoActionChip({ action }: { action: string }) { + if (action === 'ssh_login') return ; + if (action === 'terminal_access') return ; + const ms = DEMO_METHOD_STYLES[action]; + if (ms) return ; + if (action.startsWith('EVENT_TYPE_')) { + const label = action.replace('EVENT_TYPE_', '').split('_').map(w => w.charAt(0) + w.slice(1).toLowerCase()).join(' '); + return ; + } + return ; +} + +function DemoAuditView() { + return ( + + + Audit Logs + + + + {/* Filters */} + + + + + Action + + + + Resource Type + + + + + + {/* Table */} + + + + + Timestamp + Username + Action + Resource + Detail + Source IP + Status + + + + {mockAuditLogs.map(entry => ( + + {formatDate(entry.timestamp)} + {entry.username || '-'} + + + {entry.resourceType === 'api' ? ( + + {entry.resourceId.replace(/^(GET|POST|PUT|DELETE|PATCH)\s+/, '')} + + ) : ( + <> + {entry.resourceType}/ + {entry.resourceId} + + )} + + {entry.detail || '-'} + {entry.sourceIp || '-'} + {entry.statusCode > 0 ? entry.statusCode : '-'} + + ))} + +
+
+
+ ); +} + +// ============================================ +// Demo Alerts View (self-contained, no API calls) +// ============================================ + +const mockDefaultRules = [ + { name: 'HighMemoryUsage', expr: 'system_memory_used_bytes / system_memory_total_bytes * 100 > 90', duration: '5m', severity: 'critical', description: 'System memory usage exceeds 90% for 5 minutes' }, + { name: 'HighDiskUsage', expr: 'system_disk_used_bytes / system_disk_total_bytes * 100 > 85', duration: '5m', severity: 'warning', description: 'System disk usage exceeds 85%' }, + { name: 'DiskAlmostFull', expr: 'system_disk_used_bytes / system_disk_total_bytes * 100 > 95', duration: '1m', severity: 'critical', description: 'System disk usage exceeds 95% — immediate action required' }, + { name: 'HighCPULoad', expr: 'system_cpu_load_5m / system_cpu_cores * 100 > 80', duration: '10m', severity: 'warning', description: 'CPU load average exceeds 80% of available cores' }, + { name: 'MetricsCollectionDown', expr: 'up == 0', duration: '5m', severity: 'critical', description: 'Metrics scrape target is down' }, + { name: 'ContainerHighMemory', expr: 'container_memory_usage_bytes / container_memory_limit_bytes * 100 > 90', duration: '5m', severity: 'warning', description: 'Container memory usage exceeds 90% of its limit' }, + { name: 'ContainerHighCPU', expr: 'container_cpu_usage_percent > 90', duration: '10m', severity: 'warning', description: 'Container CPU usage exceeds 90%' }, + { name: 'ContainerStopped', expr: 'container_state{state="Stopped"} == 1', duration: '15m', severity: 'info', description: 'Container has been in Stopped state for 15 minutes' }, + { name: 'NoRunningContainers', expr: 'count(container_state{state="Running"}) == 0', duration: '5m', severity: 'critical', description: 'No running containers detected' }, +]; + +const mockCustomRules = [ + { id: 'cr-1', name: 'GPUTempHigh', expr: 'gpu_temperature_celsius > 85', duration: '3m', severity: 'warning', description: 'GPU temperature exceeds 85°C', enabled: true, createdAt: '2026-03-10T10:00:00Z' }, + { id: 'cr-2', name: 'TrainingJobStalled', expr: 'rate(training_steps_total[10m]) == 0', duration: '15m', severity: 'critical', description: 'No training progress for 15 minutes', enabled: true, createdAt: '2026-03-12T14:30:00Z' }, +]; + +const mockDeliveries = [ + { id: 'd1', timestamp: '2026-03-15T09:30:15Z', alertName: 'HighCPULoad', source: 'vmalert', success: true, httpStatus: 200, durationMs: 125, errorMessage: '' }, + { id: 'd2', timestamp: '2026-03-15T08:15:00Z', alertName: 'Test Alert', source: 'test', success: true, httpStatus: 200, durationMs: 89, errorMessage: '' }, + { id: 'd3', timestamp: '2026-03-14T22:10:45Z', alertName: 'ContainerHighMemory', source: 'vmalert', success: false, httpStatus: 502, durationMs: 5032, errorMessage: 'upstream connect error or disconnect/reset before headers' }, + { id: 'd4', timestamp: '2026-03-14T18:00:00Z', alertName: 'HighDiskUsage', source: 'vmalert', success: true, httpStatus: 200, durationMs: 98, errorMessage: '' }, +]; + +function DemoAlertSeverityChip({ severity }: { severity: string }) { + const colorMap: Record = { critical: 'error', warning: 'warning', info: 'info' }; + return ; +} + +function DemoAlertsView() { + const [ruleTab, setRuleTab] = useState(0); + + return ( + + {/* Header */} + + Alerts + + + + + + + {/* Status Cards */} + + + + vmalert + + + healthy + + + + + + Alertmanager + + + healthy + + + + + + Total Rules + {mockDefaultRules.length + mockCustomRules.length} + + + + + Custom Rules + {mockCustomRules.length} + + + + + Webhook Target + https://hooks.slack.com/services/T.../B.../xxx + + + + + {/* Rule Tabs */} + setRuleTab(v)} sx={{ mb: 2 }}> + + + + + + {/* Default Rules */} + {ruleTab === 0 && ( + + + + + Name + Expression + Duration + Severity + Status + + + + {mockDefaultRules.map((rule) => ( + + + + + + {rule.name} + {rule.description} + + + + + + {rule.expr} + + + {rule.duration} + + + + ))} + +
+
+ )} + + {/* Custom Rules */} + {ruleTab === 1 && ( + + + + + Name + Expression + Duration + Severity + Enabled + Created + + + + {mockCustomRules.map((rule) => ( + + + {rule.name} + {rule.description} + + + + {rule.expr} + + + {rule.duration} + + + {formatDate(rule.createdAt)} + + ))} + +
+
+ )} + + {/* Delivery History */} + {ruleTab === 2 && ( + + + + + Time + Alert + Source + Status + HTTP Code + Duration + Error + + + + {mockDeliveries.map((d) => ( + + {formatDate(d.timestamp)} + {d.alertName} + + : undefined} /> + + + {d.success ? : } + + {d.httpStatus} + {d.durationMs}ms + + {d.errorMessage && {d.errorMessage}} + + + ))} + +
+
+ )} +
+ ); +} + +// ============================================ +// Demo Pentest View (grouped findings by container) +// ============================================ + +const mockPentestFindings: PentestFinding[] = [ + { id: 1, fingerprint: 'f1', category: 'trivy', severity: 'critical', title: 'crypto/tls: Unexpected session resumption in crypto/tls', description: 'Go stdlib vulnerability allowing TLS session resumption bypass.', target: 'alice-container (usr/bin/docker)', evidence: 'CVE-2024-45238 detected in go1.21.5', cveIds: 'CVE-2024-45238', remediation: 'Upgrade Go to 1.22.2 or later.', status: 'open', firstScanRunId: 'run-1', lastScanRunId: 'run-2', firstSeenAt: '2026-03-10T04:00:00Z', lastSeenAt: '2026-03-15T04:00:00Z', resolvedAt: '', suppressed: false, suppressedReason: '' }, + { id: 2, fingerprint: 'f2', category: 'trivy', severity: 'high', title: 'cryptography: Subgroup Attack Due to Missing Validation', description: 'Python cryptography package vulnerable to subgroup attacks on SECT curves.', target: 'alice-container (Python)', evidence: 'cryptography==41.0.7', cveIds: 'CVE-2024-26130', remediation: 'Upgrade cryptography to >= 42.0.0.', status: 'open', firstScanRunId: 'run-1', lastScanRunId: 'run-2', firstSeenAt: '2026-03-10T04:00:00Z', lastSeenAt: '2026-03-15T04:00:00Z', resolvedAt: '', suppressed: false, suppressedReason: '' }, + { id: 3, fingerprint: 'f3', category: 'trivy', severity: 'critical', title: 'crypto/tls: Unexpected session resumption in crypto/tls', description: '', target: 'alice-container (usr/libexec/docker/cli-plugins/docker-compose)', evidence: 'CVE-2024-45238', cveIds: 'CVE-2024-45238', remediation: 'Upgrade Go runtime.', status: 'open', firstScanRunId: 'run-1', lastScanRunId: 'run-2', firstSeenAt: '2026-03-10T04:00:00Z', lastSeenAt: '2026-03-15T04:00:00Z', resolvedAt: '', suppressed: false, suppressedReason: '' }, + { id: 4, fingerprint: 'f4', category: 'trivy', severity: 'high', title: 'net/http: HTTP/2 CONTINUATION flood in net/http', description: '', target: 'alice-container (usr/libexec/docker/cli-plugins/docker-compose)', evidence: 'CVE-2024-24791', cveIds: 'CVE-2024-24791', remediation: 'Upgrade Go runtime.', status: 'open', firstScanRunId: 'run-1', lastScanRunId: 'run-2', firstSeenAt: '2026-03-10T04:00:00Z', lastSeenAt: '2026-03-15T04:00:00Z', resolvedAt: '', suppressed: false, suppressedReason: '' }, + { id: 5, fingerprint: 'f5', category: 'trivy', severity: 'medium', title: 'archive/zip: Incorrect handling of certain ZIP files', description: '', target: 'alice-container (usr/libexec/docker/cli-plugins/docker-compose)', evidence: 'CVE-2024-24789', cveIds: 'CVE-2024-24789', remediation: 'Upgrade Go runtime.', status: 'open', firstScanRunId: 'run-1', lastScanRunId: 'run-2', firstSeenAt: '2026-03-10T04:00:00Z', lastSeenAt: '2026-03-15T04:00:00Z', resolvedAt: '', suppressed: false, suppressedReason: '' }, + { id: 6, fingerprint: 'f6', category: 'ports', severity: 'medium', title: 'Undeclared open port: 8080 (HTTP Alt)', description: 'Container is listening on port 8080 which is not declared in configuration.', target: '10.0.100.12:8080 (alice-container)', evidence: 'TCP port 8080 OPEN', cveIds: '', remediation: 'Declare port in container config or close unused ports.', status: 'open', firstScanRunId: 'run-2', lastScanRunId: 'run-2', firstSeenAt: '2026-03-15T04:00:00Z', lastSeenAt: '2026-03-15T04:00:00Z', resolvedAt: '', suppressed: false, suppressedReason: '' }, + { id: 7, fingerprint: 'f7', category: 'ports', severity: 'high', title: 'Undeclared open port: 5432 (PostgreSQL)', description: 'Database port exposed without declaration.', target: '10.0.100.18:5432 (charlie-container)', evidence: 'TCP port 5432 OPEN', cveIds: '', remediation: 'Restrict database access or declare port.', status: 'open', firstScanRunId: 'run-2', lastScanRunId: 'run-2', firstSeenAt: '2026-03-15T04:00:00Z', lastSeenAt: '2026-03-15T04:00:00Z', resolvedAt: '', suppressed: false, suppressedReason: '' }, + { id: 8, fingerprint: 'f8', category: 'ports', severity: 'medium', title: 'Undeclared open port: 22 (SSH)', description: 'SSH port exposed.', target: '10.0.100.18:22 (charlie-container)', evidence: 'TCP port 22 OPEN', cveIds: '', remediation: 'Consider restricting SSH access.', status: 'open', firstScanRunId: 'run-2', lastScanRunId: 'run-2', firstSeenAt: '2026-03-15T04:00:00Z', lastSeenAt: '2026-03-15T04:00:00Z', resolvedAt: '', suppressed: false, suppressedReason: '' }, + { id: 9, fingerprint: 'f9', category: 'trivy', severity: 'low', title: 'golang.org/x/net: Excessive memory usage in net/http', description: '', target: 'bob-container (usr/bin/containerd)', evidence: 'CVE-2023-44487', cveIds: 'CVE-2023-44487', remediation: 'Upgrade Go module.', status: 'open', firstScanRunId: 'run-1', lastScanRunId: 'run-2', firstSeenAt: '2026-03-10T04:00:00Z', lastSeenAt: '2026-03-15T04:00:00Z', resolvedAt: '', suppressed: false, suppressedReason: '' }, +]; + +function DemoSeverityChip({ severity }: { severity: string }) { + const colorMap: Record = { + critical: 'error', high: 'warning', medium: 'info', low: 'default', info: 'default', + }; + return ; +} + +function DemoPentestFindingRow({ finding }: { finding: PentestFinding }) { + const [expanded, setExpanded] = useState(false); + return ( + <> + setExpanded(!expanded)}> + + + {expanded ? : } + + + + + {finding.title} + + + {finding.target} + + + + {formatDate(finding.lastSeenAt)} + + + + + + + + + + + + {finding.description && ( + + Description + {finding.description} + + )} + {finding.evidence && ( + + Evidence + {finding.evidence} + + )} + {finding.remediation && ( + + Remediation + {finding.remediation} + + )} + {finding.cveIds && ( + + CVE IDs + {finding.cveIds} + + )} + + + + + + + ); +} + +function DemoPentestView() { + const [collapsedTargets, setCollapsedTargets] = useState>(new Set()); + + const groupedFindings = useMemo(() => { + const groups = new Map(); + for (const f of mockPentestFindings) { + const ipMatch = f.target.match(/^\d+\.\d+\.\d+\.\d+:\d+\s+\((.+)\)$/); + const nameMatch = f.target.match(/^(.+?)\s+\(/); + const containerName = ipMatch ? ipMatch[1] : nameMatch ? nameMatch[1] : f.target; + const list = groups.get(containerName) || []; + list.push(f); + groups.set(containerName, list); + } + return [...groups.entries()].sort((a, b) => b[1].length - a[1].length); + }, []); + + const toggleTargetGroup = (target: string) => { + setCollapsedTargets((prev) => { + const next = new Set(prev); + if (next.has(target)) { next.delete(target); } else { next.add(target); } + return next; + }); + }; + + return ( + + + + Penetration Test Findings + + Modules: ports,trivy | Interval: 6h | Nuclei: active | Trivy: active + + + + + + + + + {/* Summary Cards */} + + + + + + + + + + {/* Findings Table — grouped by container */} + + + + + Severity + Module + Title + Target + Status + Last Seen + Actions + + + + {groupedFindings.map(([target, targetFindings]) => { + const isCollapsed = collapsedTargets.has(target); + return ( + + toggleTargetGroup(target)}> + + + {isCollapsed ? : } + {target} + + + + + {!isCollapsed && targetFindings.map((finding) => ( + + ))} + + ); + })} + +
+
+
+ ); +} + +// ============================================ +// Demo Combined Security View (sub-tabs: Malware Scan + Pentest) +// ============================================ + +function DemoCombinedSecurityView() { + const [securityTab, setSecurityTab] = useState(0); + + return ( + + setSecurityTab(v)} + sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }} + > + } iconPosition="start" label="Malware Scan" /> + } iconPosition="start" label="Pentest" /> + + + {securityTab === 0 && } + {securityTab === 1 && } + + ); +} + // ============================================ // Tab Panel // ============================================ @@ -947,6 +1498,8 @@ export default function DemoPage() { } iconPosition="start" label="Network" /> } iconPosition="start" label="Traffic" /> } iconPosition="start" label="Monitoring" /> + } iconPosition="start" label="Alerts" /> + } iconPosition="start" label="Audit" /> } iconPosition="start" label="Security" />
@@ -1038,9 +1591,19 @@ export default function DemoPage() { - {/* Security View */} + {/* Alerts View */} - + + + + {/* Audit View */} + + + + + {/* Security View (with sub-tabs: Malware Scan + Pentest) */} + + {/* Label Editor Dialog */} diff --git a/web-ui/src/components/security/PentestView.tsx b/web-ui/src/components/security/PentestView.tsx new file mode 100644 index 0000000..5e15fd5 --- /dev/null +++ b/web-ui/src/components/security/PentestView.tsx @@ -0,0 +1,617 @@ +'use client'; + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + Box, + Typography, + CircularProgress, + Alert, + IconButton, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Chip, + Button, + Stack, + Collapse, + MenuItem, + Select, + FormControl, + InputLabel, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + LinearProgress, + TablePagination, +} from '@mui/material'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import DownloadIcon from '@mui/icons-material/Download'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import Tooltip from '@mui/material/Tooltip'; +import Snackbar from '@mui/material/Snackbar'; +import { Server } from '@/src/types/server'; +import { PentestFinding, PentestFindingSummary, PentestScanRun, PentestConfig } from '@/src/types/security'; +import { getClient } from '@/src/lib/api/client'; + +interface PentestViewProps { + server: Server; +} + +function formatDate(iso: string): string { + if (!iso) return '-'; + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} + +function SeverityChip({ severity }: { severity: string }) { + const colorMap: Record = { + critical: 'error', + high: 'warning', + medium: 'info', + low: 'default', + info: 'default', + }; + return ( + + ); +} + +function StatusChip({ status }: { status: string }) { + switch (status) { + case 'open': + return ; + case 'resolved': + return ; + case 'suppressed': + return ; + default: + return ; + } +} + +function SummaryCard({ title, value, color }: { title: string; value: number; color: string }) { + return ( + + + {value} + + + {title} + + + ); +} + +function FindingRow({ finding, onSuppress }: { finding: PentestFinding; onSuppress: (id: number) => void }) { + const [expanded, setExpanded] = useState(false); + + return ( + <> + setExpanded(!expanded)}> + + + {expanded ? : } + + + + + + + + {finding.title} + + + + {finding.target} + + + + {formatDate(finding.lastSeenAt)} + + {finding.status === 'open' && ( + + { e.stopPropagation(); onSuppress(finding.id); }}> + + + + )} + + + + + + + + {finding.description && ( + + Description + {finding.description} + + )} + {finding.evidence && ( + + Evidence + + {finding.evidence} + + + )} + {finding.remediation && ( + + Remediation + {finding.remediation} + + )} + {finding.cveIds && ( + + CVE IDs + {finding.cveIds} + + )} + + First seen: {formatDate(finding.firstSeenAt)} + Last seen: {formatDate(finding.lastSeenAt)} + {finding.resolvedAt && Resolved: {formatDate(finding.resolvedAt)}} + + + + + + + + ); +} + +export default function PentestView({ server }: PentestViewProps) { + const [summary, setSummary] = useState(null); + const [findings, setFindings] = useState([]); + const [findingsTotalCount, setFindingsTotalCount] = useState(0); + const [scanRuns, setScanRuns] = useState([]); + const [config, setConfig] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [scanning, setScanning] = useState(false); + const [snackMessage, setSnackMessage] = useState(null); + + // Filters + const [severityFilter, setSeverityFilter] = useState(''); + const [categoryFilter, setCategoryFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState('open'); + + // Pagination + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + + // Suppress dialog + const [suppressId, setSuppressId] = useState(null); + const [suppressReason, setSuppressReason] = useState(''); + + // Show scan history + const [showScanHistory, setShowScanHistory] = useState(false); + + // Tool install state + const [installingTool, setInstallingTool] = useState(null); + + // Collapsed target groups + const [collapsedTargets, setCollapsedTargets] = useState>(new Set()); + + const groupedFindings = useMemo(() => { + const groups = new Map(); + for (const f of findings) { + // Extract container name: "IP:port (name)" → name, "name (details)" → name + const ipMatch = f.target.match(/^\d+\.\d+\.\d+\.\d+:\d+\s+\((.+)\)$/); + const nameMatch = f.target.match(/^(.+?)\s+\(/); + const containerName = ipMatch ? ipMatch[1] : nameMatch ? nameMatch[1] : f.target; + const list = groups.get(containerName) || []; + list.push(f); + groups.set(containerName, list); + } + return [...groups.entries()].sort((a, b) => b[1].length - a[1].length); + }, [findings]); + + const toggleTargetGroup = (target: string) => { + setCollapsedTargets((prev) => { + const next = new Set(prev); + if (next.has(target)) { + next.delete(target); + } else { + next.add(target); + } + return next; + }); + }; + + const loadData = useCallback(async () => { + try { + const client = getClient(server); + const [summaryResp, findingsResp, runsResp, configResp] = await Promise.all([ + client.getPentestFindingSummary(), + client.listPentestFindings({ severity: severityFilter || undefined, category: categoryFilter || undefined, status: statusFilter || undefined, limit: rowsPerPage, offset: page * rowsPerPage }), + client.listPentestScanRuns(10), + client.getPentestConfig(), + ]); + setSummary(summaryResp.summary); + setFindings(findingsResp.findings); + setFindingsTotalCount(findingsResp.totalCount); + setScanRuns(runsResp.scanRuns); + setConfig(configResp.config); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load pentest data'); + } finally { + setIsLoading(false); + } + }, [server, severityFilter, categoryFilter, statusFilter, page, rowsPerPage]); + + useEffect(() => { + loadData(); + const interval = setInterval(loadData, 60000); + return () => clearInterval(interval); + }, [loadData]); + + const handleTriggerScan = async () => { + setScanning(true); + try { + const client = getClient(server); + const result = await client.triggerPentestScan(); + setSnackMessage(result.message || `Scan started: ${result.scanRunId}`); + // Poll for completion + const pollInterval = setInterval(async () => { + try { + const runsResp = await client.listPentestScanRuns(5); + setScanRuns(runsResp.scanRuns); + const latest = runsResp.scanRuns[0]; + if (latest && latest.id === result.scanRunId && latest.status !== 'running') { + clearInterval(pollInterval); + setScanning(false); + loadData(); + } + } catch { + // ignore polling errors + } + }, 5000); + // Safety: stop polling after 10 minutes + setTimeout(() => { clearInterval(pollInterval); setScanning(false); }, 600000); + } catch (err) { + setSnackMessage(err instanceof Error ? err.message : 'Scan trigger failed'); + setScanning(false); + } + }; + + const handleSuppress = async () => { + if (suppressId === null) return; + try { + const client = getClient(server); + await client.suppressPentestFinding(suppressId, suppressReason); + setSnackMessage('Finding suppressed'); + setSuppressId(null); + setSuppressReason(''); + loadData(); + } catch (err) { + setSnackMessage(err instanceof Error ? err.message : 'Failed to suppress finding'); + } + }; + + const handleInstallTool = async (toolName: string) => { + setInstallingTool(toolName); + try { + const client = getClient(server); + const result = await client.installPentestTool(toolName); + setSnackMessage(result.message || `${toolName} installed`); + if (result.success) { + loadData(); // Refresh config to update button visibility + } + } catch (err) { + setSnackMessage(err instanceof Error ? err.message : `Failed to install ${toolName}`); + } finally { + setInstallingTool(null); + } + }; + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ( + + + }> + {error} + + + ); + } + + const categories = summary?.byCategory ? Object.keys(summary.byCategory) : []; + + return ( + + {/* Header */} + + + Penetration Test Findings + {config && ( + + + Modules: {config.modules} | Interval: {config.interval} + {config.nucleiAvailable && ' | Nuclei: active'} + {config.trivyAvailable && ' | Trivy: active'} + + {!config.nucleiAvailable && ( + + )} + {!config.trivyAvailable && ( + + )} + + )} + + + + + + + + {/* Scan Progress */} + {scanRuns.length > 0 && scanRuns[0].status === 'running' && ( + + + + Scanning... {scanRuns[0].completedCount}/{scanRuns[0].targetsCount} targets + + + 0 ? (scanRuns[0].completedCount / scanRuns[0].targetsCount) * 100 : 0} + /> + + )} + + {/* Summary Cards */} + {summary && ( + + + + + + + + + + )} + + {/* Filters */} + + + + Severity + + + + Category + + + + Status + + + + {findingsTotalCount} findings + + + + + {/* Findings Table */} + + + + + Severity + Module + Title + Target + Status + Last Seen + Actions + + + + {findings.length === 0 ? ( + + + + {statusFilter === 'open' ? 'No open findings. Run a scan to check for vulnerabilities.' : 'No findings match the current filters.'} + + + + ) : ( + groupedFindings.map(([target, targetFindings]) => { + const isCollapsed = collapsedTargets.has(target); + return ( + + toggleTargetGroup(target)} + > + + + {isCollapsed ? : } + + {target} + + + + + + {!isCollapsed && targetFindings.map((finding) => ( + setSuppressId(id)} /> + ))} + + ); + }) + )} + +
+
+ + {/* Pagination */} + {findingsTotalCount > 0 && ( + setPage(newPage)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(e) => { setRowsPerPage(parseInt(e.target.value, 10)); setPage(0); }} + rowsPerPageOptions={[10, 25, 50, 100]} + sx={{ mb: 2 }} + /> + )} + + {/* Scan History Toggle */} + + + + + + + + Started + Trigger + Status + Targets + Critical + High + Medium + Duration + + + + {scanRuns.map((run) => ( + + {formatDate(run.startedAt)} + + + + + {run.targetsCount} + {run.criticalCount > 0 ? {run.criticalCount} : '-'} + {run.highCount > 0 ? {run.highCount} : '-'} + {run.mediumCount > 0 ? run.mediumCount : '-'} + {run.duration || '-'} + + ))} + +
+
+
+ + {/* Suppress Dialog */} + setSuppressId(null)}> + Suppress Finding + + + Suppressed findings are excluded from open counts and alerts. + + setSuppressReason(e.target.value)} + placeholder="e.g., Accepted risk, false positive, handled externally" + /> + + + + + + + + setSnackMessage(null)} + message={snackMessage} + /> +
+ ); +} diff --git a/web-ui/src/components/security/SecurityView.tsx b/web-ui/src/components/security/SecurityView.tsx index 930c756..d80b659 100644 --- a/web-ui/src/components/security/SecurityView.tsx +++ b/web-ui/src/components/security/SecurityView.tsx @@ -20,12 +20,15 @@ import { Stack, Collapse, LinearProgress, + Tabs, + Tab, } from '@mui/material'; import RefreshIcon from '@mui/icons-material/Refresh'; import DownloadIcon from '@mui/icons-material/Download'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import ShieldIcon from '@mui/icons-material/Shield'; +import BugReportIcon from '@mui/icons-material/BugReport'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import ScannerIcon from '@mui/icons-material/Scanner'; import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; @@ -37,6 +40,7 @@ import { Server } from '@/src/types/server'; import { ClamavContainerSummary, ClamavReport, ScanStatusResponse } from '@/src/types/security'; import { useSecurity } from '@/src/lib/hooks/useSecurity'; import { getClient } from '@/src/lib/api/client'; +import PentestView from './PentestView'; interface SecurityViewProps { server: Server; @@ -239,6 +243,27 @@ function ContainerRow({ container, server, onScan, scanStatus }: { container: Cl } export default function SecurityView({ server }: SecurityViewProps) { + const [securityTab, setSecurityTab] = useState(0); + + return ( + + {/* Sub-tabs */} + setSecurityTab(v)} + sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }} + > + } iconPosition="start" label="Malware Scan" /> + } iconPosition="start" label="Pentest" /> + + + {securityTab === 0 && } + {securityTab === 1 && } + + ); +} + +function ClamavView({ server }: SecurityViewProps) { const { summary, isLoading, error, refresh } = useSecurity(server); // Scan state @@ -333,7 +358,7 @@ export default function SecurityView({ server }: SecurityViewProps) { if (isLoading) { return ( - + ); @@ -341,26 +366,21 @@ export default function SecurityView({ server }: SecurityViewProps) { if (error) { return ( - - - - - }> - {error instanceof Error ? error.message : 'Failed to fetch security data'} - - + + + + }> + {error instanceof Error ? error.message : 'Failed to fetch security data'} + ); } return ( - + {/* Header */} - - - Security Scanning - + ClamAV Malware Scanning