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
-
-
-### App Hosting
-
+
### Container List View
-
+
+
+### App Hosting
+
### Network Topology
-
+
### Traffic Monitoring
-
+
### Monitoring Dashboard
-
+
+
+### Alerts
+
+
+### Audit Logs
+
### Security Scanning
-
+
🌐 **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
+
+ } size="small">Create Rule
+
+
+
+
+ {/* 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
+
+
+
+ }>Run Scan
+
+
+
+
+ {/* 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 && (
+ : }
+ onClick={() => handleInstallTool('nuclei')}
+ disabled={installingTool !== null}
+ sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0 }}
+ >
+ Install Nuclei
+
+ )}
+ {!config.trivyAvailable && (
+ : }
+ onClick={() => handleInstallTool('trivy')}
+ disabled={installingTool !== null}
+ sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0 }}
+ >
+ Install Trivy
+
+ )}
+
+ )}
+
+
+ : }
+ onClick={handleTriggerScan}
+ disabled={scanning}
+ >
+ Run Scan
+
+
+
+
+
+ {/* 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 */}
+
+
+ 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