diff --git a/docs/plans/2026-03-08-codebase-audit-design.md b/docs/plans/2026-03-08-codebase-audit-design.md new file mode 100644 index 00000000..2867f10a --- /dev/null +++ b/docs/plans/2026-03-08-codebase-audit-design.md @@ -0,0 +1,180 @@ +# Codebase Audit & Stub Completion Design + +## Goal + +Complete all stubbed/incomplete code in the workflow engine, implement DockerSandbox for secure container execution, and build realistic test scenarios for plugins with zero coverage. + +## Audit Summary + +### Stubs Found in Core Engine (3 items) + +| File | Step Type | Issue | +|------|-----------|-------| +| `module/pipeline_step_scan_sast.go` | step.scan_sast | Returns `ErrNotImplemented` | +| `module/pipeline_step_scan_container.go` | step.scan_container | Returns `ErrNotImplemented` | +| `module/pipeline_step_scan_deps.go` | step.scan_deps | Returns `ErrNotImplemented` | + +**Root cause**: Blocked on `sandbox.DockerSandbox` which doesn't exist. These steps have scanning logic inline in the core engine — wrong architecture. Scanning should be delegated to plugins via a provider interface. + +### External Plugins — All Fully Implemented + +Deep audits confirmed all plugin code is production-ready: +- **workflow-plugin-supply-chain**: 4 steps + 6 modules (Trivy, Grype, Snyk, ECR, GCP) +- **workflow-plugin-data-protection**: 3 steps + 4 modules (regex, GCP DLP, AWS Macie, Presidio) +- **workflow-plugin-github**: 3 steps + 1 module (41 tests, real GitHub API client) +- All other plugins: bento, authz, payments, waf, security, sandbox — fully implemented + +### Missing: DockerSandbox + +`workflow-plugin-sandbox` provides WASM execution + goroutine guards but no Docker container isolation. A `sandbox.docker` module is needed for secure container execution (used by CI/CD steps, scan steps, etc.). + +### Scenario Coverage Gaps + +8 plugins with zero scenario coverage: authz, payments, github, waf, security, sandbox, supply-chain, data-protection. + +## Architecture + +### Part 1A: Security Scanner Provider Interface + +The core engine defines a `SecurityScannerProvider` interface. Scan steps delegate to whichever plugin registers as provider. + +```go +// In module/scan_provider.go +type SecurityScannerProvider interface { + // ScanSAST runs static analysis on source code. + ScanSAST(ctx context.Context, opts SASTScanOpts) (*ScanResult, error) + // ScanContainer scans a container image for vulnerabilities. + ScanContainer(ctx context.Context, opts ContainerScanOpts) (*ScanResult, error) + // ScanDeps scans dependencies for known vulnerabilities. + ScanDeps(ctx context.Context, opts DepsScanOpts) (*ScanResult, error) +} + +type ScanResult struct { + Passed bool + Findings []ScanFinding + Summary map[string]int // severity -> count + OutputFormat string // sarif, json, table + RawOutput string +} + +type ScanFinding struct { + ID string // CVE-2024-1234 + Severity string // critical, high, medium, low, info + Title string + Description string + Package string + Version string + FixVersion string + Location string // file path or image layer +} +``` + +The 3 scan steps become thin wrappers: +1. Look up `SecurityScannerProvider` from service registry +2. Call the appropriate method +3. Evaluate severity gate (fail_on_severity) +4. Return structured results + +### Part 1B: DockerSandbox Module + +Add `sandbox.docker` to the existing `workflow-plugin-sandbox`. Provides secure container execution: + +```yaml +modules: + - name: docker-sandbox + type: sandbox.docker + config: + maxCPU: "1.0" # CPU limit + maxMemory: "512m" # Memory limit + networkMode: "none" # No network by default + readOnlyRootfs: true # Immutable filesystem + noPrivileged: true # Never allow --privileged + allowedImages: # Whitelist of allowed images + - "semgrep/semgrep:*" + - "aquasec/trivy:*" + - "anchore/grype:*" + timeout: "5m" # Max execution time +``` + +Interface: +```go +type DockerSandbox interface { + Run(ctx context.Context, opts DockerRunOpts) (*DockerRunResult, error) +} + +type DockerRunOpts struct { + Image string + Command []string + Env map[string]string + Mounts []Mount // Read-only bind mounts + WorkDir string + NetworkMode string // "none", "bridge" (not "host") +} +``` + +Uses Docker Engine API client (`github.com/docker/docker/client`), NOT `os/exec` with `docker run` (which can be shell-injected). + +### Part 1C: Security Scanner Plugin + +Create `workflow-plugin-security-scanner` (public, Apache-2.0) that: +1. Implements `SecurityScannerProvider` interface +2. Provides `security.scanner` module type +3. Supports backends: semgrep (SAST), trivy (container + deps), grype (deps) +4. Optionally uses DockerSandbox for isolated execution when available +5. Falls back to direct CLI execution when DockerSandbox isn't configured +6. Includes `mock` mode for testing without real tools + +### Part 2: Public Scenarios (workflow-scenarios) + +| # | Scenario | Plugin | Tests | Verification | +|---|----------|--------|-------|-------------| +| 46 | github-cicd | workflow-plugin-github | Webhook HMAC validation, action trigger/status, check runs | Verify payload parsing, signature validation, event filtering | +| 47 | authz-rbac | workflow-plugin-authz | Casbin policy CRUD, role enforcement, deny access | Verify policy creates, role assigns, access denied returns 403 | +| 48 | payment-processing | workflow-plugin-payments | Charge, capture, refund, subscription lifecycle | Verify amounts, status transitions, webhook handling | +| 49 | security-scanning | Core + scanner plugin | SAST, container scan, dependency scan | Verify findings count, severity filtering, pass/fail gate | + +### Part 3: Private Scenarios (workflow-scenarios-private) + +| # | Scenario | Plugin | Tests | Verification | +|---|----------|--------|-------|-------------| +| 01 | waf-protection | workflow-plugin-waf | Input sanitization, IP check, WAF evaluate | Verify blocked requests return 403, clean requests pass | +| 02 | mfa-encryption | workflow-plugin-security | TOTP enroll/verify, AES encrypt/decrypt | Verify TOTP codes validate, encrypted != plaintext, decrypt == original | +| 03 | wasm-sandbox | workflow-plugin-sandbox | WASM exec, goroutine guards | Verify WASM output, resource limits enforced | +| 04 | data-protection | workflow-plugin-data-protection | PII detect, data mask, classify | Verify PII found in test data, masked values differ, classifications correct | +| 05 | supply-chain | workflow-plugin-supply-chain | Signature verify, vuln scan, SBOM | Verify signature validation, finding counts, SBOM component counts | + +### Scenario Design Principles + +Every test script uses `jq` for JSON validation: +```bash +# Good: verify specific field values +RESULT=$(curl -s "$BASE_URL/api/scan" -d '{"target":"test-image:v1"}') +PASSED=$(echo "$RESULT" | jq -r '.passed') +SEVERITY=$(echo "$RESULT" | jq -r '.summary.critical') +[ "$PASSED" = "false" ] && [ "$SEVERITY" -gt 0 ] && echo "PASS: scan detected critical vulns" || echo "FAIL: expected critical findings" + +# Bad: just check HTTP status +curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/api/scan" | grep -q "200" && echo "PASS" +``` + +Tests must verify: +1. **Data transforms** — output values match expected transformations +2. **State changes** — persistence confirmed by reading back after writing +3. **Enforcement** — denied/blocked requests fail with proper error codes (403, 400) +4. **Error paths** — invalid inputs return descriptive error messages + +## Implementation Order + +1. **Part 1A**: SecurityScannerProvider interface in core engine +2. **Part 1B**: DockerSandbox module in workflow-plugin-sandbox +3. **Part 1C**: Security scanner plugin implementing the provider +4. **Part 1 wiring**: Update core scan steps to delegate to provider +5. **Part 2**: Public scenarios 46-49 +6. **Part 3**: Private scenarios repo + scenarios 01-05 + +## Out of Scope + +- Real cloud API integration tests (need credentials) +- `cache.modular` interface gap (needs modular framework changes) +- Phase 5 architecture refactoring (separate effort) +- Documentation updates (separate effort) diff --git a/docs/plans/2026-03-08-codebase-audit.md b/docs/plans/2026-03-08-codebase-audit.md new file mode 100644 index 00000000..7c0473a8 --- /dev/null +++ b/docs/plans/2026-03-08-codebase-audit.md @@ -0,0 +1,511 @@ +# Codebase Audit Implementation Plan + +**Goal:** Fix all stubbed code in the workflow engine, implement DockerSandbox, and build test scenarios for all untested plugins. + +**Architecture:** Provider interface for security scanning, Docker sandbox module in existing sandbox plugin, new security scanner plugin, 4 public scenarios, 5 private scenarios. + +**Tech Stack:** Go, Docker Engine API, YAML configs, bash test scripts with jq + +--- + +### Task 1: SecurityScannerProvider Interface (Core Engine) + +**Files:** +- Create: `module/scan_provider.go` +- Modify: `module/pipeline_step_scan_sast.go` +- Modify: `module/pipeline_step_scan_container.go` +- Modify: `module/pipeline_step_scan_deps.go` + +**Step 1: Create the provider interface** + +Create `module/scan_provider.go` with: +- `SecurityScannerProvider` interface (ScanSAST, ScanContainer, ScanDeps methods) +- `ScanResult` struct (Passed, Findings, Summary, OutputFormat, RawOutput) +- `ScanFinding` struct (ID, Severity, Title, Description, Package, Version, FixVersion, Location) +- `SASTScanOpts` (Scanner, SourcePath, Rules, FailOnSeverity, OutputFormat) +- `ContainerScanOpts` (Scanner, TargetImage, SeverityThreshold, IgnoreUnfixed, OutputFormat) +- `DepsScanOpts` (Scanner, SourcePath, FailOnSeverity, OutputFormat) +- `severityRank()` helper mapping severity string to int for comparison +- `SeverityAtOrAbove()` exported helper for threshold checks + +**Step 2: Rewrite scan_sast to delegate to provider** + +Rewrite `ScanSASTStep.Execute()`: +1. Look up `SecurityScannerProvider` from app service registry via `security-scanner` key +2. Build `SASTScanOpts` from step config +3. Call `provider.ScanSAST(ctx, opts)` +4. Evaluate severity gate: if any finding severity >= fail_on_severity, set `response_status: 400` +5. Return structured output: `passed`, `findings`, `summary`, `scanner` +6. If no provider registered, return clear error: "no security scanner provider configured — load a scanner plugin" + +**Step 3: Rewrite scan_container to delegate to provider** + +Same pattern: look up provider, call `provider.ScanContainer()`, evaluate severity gate. + +**Step 4: Rewrite scan_deps to delegate to provider** + +Same pattern: look up provider, call `provider.ScanDeps()`, evaluate severity gate. + +**Step 5: Add tests** + +Create `module/scan_provider_test.go`: +- Test with mock provider implementing the interface +- Test scan_sast delegates correctly, severity gating works +- Test scan_container delegates correctly +- Test scan_deps delegates correctly +- Test error when no provider registered + +**Step 6: Commit** + +```bash +git add module/scan_provider.go module/pipeline_step_scan_sast.go module/pipeline_step_scan_container.go module/pipeline_step_scan_deps.go module/scan_provider_test.go +git commit -m "feat: SecurityScannerProvider interface — scan steps delegate to plugins" +``` + +--- + +### Task 2: DockerSandbox Module (workflow-plugin-sandbox) + +**Files:** +- Create: `workflow-plugin-sandbox/internal/module_docker.go` +- Create: `workflow-plugin-sandbox/internal/module_docker_test.go` +- Modify: `workflow-plugin-sandbox/internal/plugin.go` + +**Step 1: Define DockerSandbox types** + +In `module_docker.go`: +- `DockerSandboxModule` struct with config fields: maxCPU, maxMemory, networkMode, readOnlyRootfs, allowedImages, timeout +- `DockerRunOpts` struct: Image, Command, Env, Mounts, WorkDir, NetworkMode +- `DockerRunResult` struct: ExitCode, Stdout, Stderr, Duration +- `Mount` struct: Source, Target, ReadOnly + +**Step 2: Implement module lifecycle** + +- `Init()`: validate config, register self as `sandbox-docker:` in service registry +- `Start()`: create Docker client (`client.NewClientWithOpts(client.FromEnv)`) +- `Stop()`: close client +- `Run()`: implementation that: + 1. Validates image against allowedImages whitelist (glob match) + 2. Creates container with security constraints: + - CPU/memory limits via `container.Resources` + - NetworkMode from config (never "host") + - ReadonlyRootfs: true by default + - No --privileged ever + - SecurityOpt: ["no-new-privileges"] + 3. Starts container with context timeout + 4. Waits for completion, captures stdout/stderr via attach + 5. Removes container on completion + 6. Returns result + +**Step 3: Add mock mode** + +When `mock: true` in config: +- `Run()` returns a synthetic result based on image name +- No actual Docker operations — for testing without Docker daemon + +**Step 4: Register in plugin** + +Add `sandbox.docker` to the plugin's `ModuleTypes()` and `CreateModule()`. + +**Step 5: Add tests** + +Test mock mode returns expected results. Test config validation (reject host network, require allowed images). + +**Step 6: Commit** + +--- + +### Task 3: Security Scanner Plugin (New Public Plugin) + +**Files:** +- Create: `workflow-plugin-security-scanner/` (new repo) +- Key files: `cmd/workflow-plugin-security-scanner/main.go`, `internal/plugin.go`, `internal/module_scanner.go`, `internal/scanner_semgrep.go`, `internal/scanner_trivy.go`, `internal/scanner_grype.go`, `internal/scanner_mock.go` + +**Step 1: Create repository structure** + +``` +workflow-plugin-security-scanner/ +├── cmd/workflow-plugin-security-scanner/main.go +├── internal/ +│ ├── plugin.go # Plugin provider +│ ├── module_scanner.go # SecurityScannerModule +│ ├── scanner_semgrep.go # SAST via semgrep CLI +│ ├── scanner_trivy.go # Container + deps via trivy CLI +│ ├── scanner_grype.go # Deps via grype CLI +│ ├── scanner_mock.go # Mock scanner for testing +│ └── scanner_test.go # Tests +├── go.mod +├── go.sum +└── Makefile +``` + +**Step 2: Implement SecurityScannerModule** + +Module type: `security.scanner` +Config: +```yaml +modules: + - name: scanner + type: security.scanner + config: + sast_backend: semgrep # or: mock + container_backend: trivy # or: mock + deps_backend: grype # or: trivy, mock + docker_sandbox: "" # optional: name of sandbox.docker module +``` + +On `Init()`: register as `security-scanner:` service (matching what core scan steps look up). + +**Step 3: Implement semgrep backend** + +`ScanSAST()`: runs `semgrep scan --json --config= `, parses JSON output into ScanResult. + +**Step 4: Implement trivy backend** + +`ScanContainer()`: runs `trivy image --format json `, parses findings. +`ScanDeps()`: runs `trivy fs --format json `, parses findings. + +**Step 5: Implement grype backend** + +`ScanDeps()`: runs `grype -o json`, parses findings. + +**Step 6: Implement mock backend** + +Returns synthetic findings for testing. Configurable via mock_findings in config. + +**Step 7: Add tests with mock backend** + +Test all three scan methods with mock backend. Verify output format matches ScanResult. + +**Step 8: Commit and tag** + +--- + +### Task 4: Public Scenario 46 — GitHub CI/CD + +**Files:** +- Create: `workflow-scenarios/scenarios/46-github-cicd/` + - `scenario.yaml`, `config/app.yaml`, `k8s/deployment.yaml`, `k8s/service.yaml`, `test/run.sh` + +**Step 1: Write scenario config** + +```yaml +# scenario.yaml +name: github-cicd +description: GitHub CI/CD integration — webhook events, action triggers, status checks +version: "1.0" +plugins: + - workflow-plugin-github +``` + +**Step 2: Write workflow config (app.yaml)** + +Pipelines: +- `POST /api/webhooks/github` — receive webhook, validate signature, parse event, store in state +- `POST /api/actions/trigger` — trigger workflow dispatch (mock responds with run ID) +- `GET /api/actions/status/{run_id}` — check workflow run status +- `POST /api/checks/create` — create check run on commit + +Use mock GitHub API (step.set to simulate responses since this runs without real GitHub). + +**Step 3: Write test script** + +Test cases with jq validation: +1. POST webhook with valid HMAC signature → 200, verify event type parsed +2. POST webhook with invalid signature → 401/403 +3. POST webhook with filtered event (not in allowed list) → 200 with status:ignored +4. POST trigger action → verify run_id in response +5. GET action status → verify status and conclusion fields +6. POST create check → verify check_run_id in response +7. POST with missing required fields → proper error message + +**Step 4: Commit** + +--- + +### Task 5: Public Scenario 47 — Authz RBAC + +**Files:** +- Create: `workflow-scenarios/scenarios/47-authz-rbac/` + +**Step 1: Write workflow config** + +Modules: `authz.casbin` with SQLite adapter (in-memory for tests). + +Pipelines: +- `POST /api/policies` — add Casbin policy (step.authz_add_policy) +- `DELETE /api/policies` — remove policy (step.authz_remove_policy) +- `POST /api/roles/assign` — assign role to user (step.authz_role_assign) +- `POST /api/check` — check authorization (step.authz_check_casbin) +- `GET /api/protected/admin` — admin-only endpoint (step.authz_check_casbin → 403 if not admin) +- `GET /api/protected/viewer` — viewer endpoint (any role) + +**Step 2: Write test script** + +1. Check access before any policies → denied (403) +2. Add admin policy for user alice → 200 +3. Assign admin role to alice → 200 +4. Check alice admin access → allowed +5. Check bob admin access → denied (403) +6. Add viewer policy for bob → 200 +7. Check bob viewer access → allowed +8. Remove alice admin policy → 200 +9. Re-check alice admin access → denied (403) + +**Step 3: Commit** + +--- + +### Task 6: Public Scenario 48 — Payment Processing + +**Files:** +- Create: `workflow-scenarios/scenarios/48-payment-processing/` + +**Step 1: Write workflow config** + +Modules: `payments.provider` with mock provider. + +Pipelines: +- `POST /api/customers` — ensure customer (step.payment_customer_ensure) +- `POST /api/charges` — create charge (step.payment_charge) +- `POST /api/captures/{charge_id}` — capture charge (step.payment_capture) +- `POST /api/refunds/{charge_id}` — refund charge (step.payment_refund) +- `POST /api/subscriptions` — create subscription (step.payment_subscription_create) +- `DELETE /api/subscriptions/{sub_id}` — cancel subscription (step.payment_subscription_cancel) +- `POST /api/checkout` — create checkout session (step.payment_checkout_create) + +**Step 2: Write test script** + +1. Ensure customer → verify customer_id returned +2. Create charge (amount: 5000, currency: usd) → verify charge_id, status=pending +3. Capture charge → verify status=captured +4. Refund charge → verify status=refunded, refund_id returned +5. Create subscription → verify subscription_id, status=active +6. Cancel subscription → verify status=canceled +7. Create checkout → verify checkout_url returned +8. Charge with invalid amount → proper error + +**Step 3: Commit** + +--- + +### Task 7: Public Scenario 49 — Security Scanning + +**Files:** +- Create: `workflow-scenarios/scenarios/49-security-scanning/` + +**Step 1: Write workflow config** + +Modules: `security.scanner` with mock backend. + +Pipelines: +- `POST /api/scan/sast` — run SAST scan (step.scan_sast) +- `POST /api/scan/container` — run container scan (step.scan_container) +- `POST /api/scan/deps` — run dependency scan (step.scan_deps) + +**Step 2: Write test script** + +1. SAST scan with clean source → passed=true, findings=[] +2. SAST scan with vulnerable source → passed=false, findings array not empty +3. Container scan → verify summary has severity counts +4. Deps scan with fail_on_severity=critical → passes when no criticals +5. Deps scan with fail_on_severity=low → fails when low+ findings exist + +**Step 3: Commit** + +--- + +### Task 8: Create Private Scenarios Repository + +**Files:** +- Create: `workflow-scenarios-private/` (new repo) + +**Step 1: Create repo structure** + +Mirror workflow-scenarios structure: +``` +workflow-scenarios-private/ +├── scenarios/ +│ ├── 01-waf-protection/ +│ ├── 02-mfa-encryption/ +│ ├── 03-wasm-sandbox/ +│ ├── 04-data-protection/ +│ └── 05-supply-chain/ +├── scripts/ +│ ├── deploy.sh +│ └── test.sh +├── Makefile +├── scenarios.json +└── README.md +``` + +**Step 2: Create base infrastructure** + +Copy deploy.sh, test.sh, Makefile from workflow-scenarios (adapted for private plugin images). + +**Step 3: Commit** + +--- + +### Task 9: Private Scenario 01 — WAF Protection + +**Files:** +- Create: `workflow-scenarios-private/scenarios/01-waf-protection/` + +**Step 1: Write workflow config** + +Modules: `security.waf` (Coraza local mode). + +Pipelines: +- `POST /api/sanitize` — input sanitization (step.input_sanitize) +- `POST /api/check-ip` — IP reputation check (step.ip_check) +- `POST /api/evaluate` — full WAF evaluation (step.waf_evaluate) +- `POST /api/submit` — form submission protected by WAF +- `GET /api/data` — data endpoint protected by WAF + +**Step 2: Write test script** + +1. Submit clean input → 200, sanitized output matches input +2. Submit XSS payload (``) → blocked (403) +3. Submit SQL injection (`' OR 1=1 --`) → blocked (403) +4. Check known-bad IP → blocked +5. Check clean IP → allowed +6. WAF evaluate with clean request → passed=true +7. WAF evaluate with malicious headers → blocked with rule ID + +**Step 3: Commit** + +--- + +### Task 10: Private Scenario 02 — MFA & Encryption + +**Files:** +- Create: `workflow-scenarios-private/scenarios/02-mfa-encryption/` + +**Step 1: Write workflow config** + +Modules: `security.mfa` (TOTP), `security.encryption` (local AES-256-GCM). + +Pipelines: +- `POST /api/mfa/enroll` — generate TOTP secret (step.mfa_enroll) +- `POST /api/mfa/verify` — verify TOTP code (step.mfa_verify) +- `POST /api/encrypt` — encrypt a value (step.encrypt_value) +- `POST /api/decrypt` — decrypt a value (step.decrypt_value) + +**Step 2: Write test script** + +1. Enroll MFA → verify secret_url and secret returned (otpauth:// format) +2. Verify with invalid code → rejected +3. Encrypt plaintext → ciphertext differs from plaintext, not empty +4. Decrypt ciphertext → matches original plaintext +5. Decrypt with wrong key/invalid ciphertext → error + +**Step 3: Commit** + +--- + +### Task 11: Private Scenario 03 — WASM Sandbox + +**Files:** +- Create: `workflow-scenarios-private/scenarios/03-wasm-sandbox/` + +**Step 1: Write workflow config** + +Modules: `sandbox.wasm`. + +Pipelines: +- `POST /api/exec/wasm` — execute WASM module (step.wasm_exec) +- `POST /api/exec/guarded` — guarded goroutine execution (step.goroutine_guard) + +**Step 2: Write test script** + +1. Execute simple WASM → verify output matches expected +2. Execute with resource limits → completes within limits +3. Goroutine guard with safe function → succeeds +4. Goroutine guard with timeout → properly terminated + +**Step 3: Commit** + +--- + +### Task 12: Private Scenario 04 — Data Protection + +**Files:** +- Create: `workflow-scenarios-private/scenarios/04-data-protection/` + +**Step 1: Write workflow config** + +Modules: `data.pii` (local regex mode). + +Pipelines: +- `POST /api/detect` — PII detection (step.pii_detect) +- `POST /api/mask` — data masking (step.data_mask) +- `POST /api/classify` — data classification (step.data_classify) + +**Step 2: Write test script** + +1. Detect PII in email → finds email type, count=1 +2. Detect PII in SSN → finds ssn type +3. Detect PII in credit card → finds credit_card type, validates Luhn +4. Detect clean data → count=0 +5. Mask with redact strategy → field shows [REDACTED] +6. Mask with partial strategy → shows last 4 chars +7. Mask with hash strategy → SHA-256 hash returned +8. Classify → fields with PII marked "restricted", clean fields "internal" + +**Step 3: Commit** + +--- + +### Task 13: Private Scenario 05 — Supply Chain + +**Files:** +- Create: `workflow-scenarios-private/scenarios/05-supply-chain/` + +**Step 1: Write workflow config** + +Modules: `security.plugin-verifier`, scanner with mock mode. + +Pipelines: +- `POST /api/verify` — verify plugin signature (step.verify_signature) +- `POST /api/scan` — vulnerability scan (step.vuln_scan) +- `POST /api/sbom/generate` — SBOM generation (step.sbom_generate) +- `POST /api/sbom/check` — SBOM policy check (step.sbom_check) + +**Step 2: Write test script** + +Since these need real CLI tools (trivy, grype, cosign), tests use mock mode: +1. Verify valid signature → verified=true +2. Verify tampered file → verified=false in enforce mode → pipeline stops +3. Vuln scan with mock findings → passed=false, findings count matches +4. Vuln scan clean → passed=true +5. SBOM check with denied package → violations found + +**Step 3: Commit** + +--- + +### Task 14: Wire Everything Together + +**Step 1: Update workflow engine to register SecurityScannerProvider lookup** + +In `engine.go`, ensure scan steps can find the provider from the service registry. + +**Step 2: Update workflow-scenarios Makefile and scenarios.json** + +Add entries for scenarios 46-49. + +**Step 3: Run all tests** + +```bash +# Core engine (workflow repo) +go test ./... + +# Sandbox plugin (workflow-plugin-sandbox repo) +go test ./... + +# Security scanner plugin (workflow-plugin-security-scanner repo) +go test ./... +``` + +**Step 4: Final commit and push all repos** diff --git a/module/pipeline_step_scan_container.go b/module/pipeline_step_scan_container.go index 013bc3c3..531567c2 100644 --- a/module/pipeline_step_scan_container.go +++ b/module/pipeline_step_scan_container.go @@ -10,37 +10,34 @@ import ( // ScanContainerStep runs a container vulnerability scanner (e.g., Trivy) // against a target image and evaluates findings against a severity gate. -// -// NOTE: This step is not yet implemented. Docker-based execution requires -// sandbox.DockerSandbox, which is not yet available. Calls to Execute will -// always return ErrNotImplemented. +// Execution is delegated to a SecurityScannerProvider registered under +// the "security-scanner" service. type ScanContainerStep struct { name string scanner string - image string targetImage string severityThreshold string ignoreUnfixed bool outputFormat string + app modular.Application } // NewScanContainerStepFactory returns a StepFactory that creates ScanContainerStep instances. func NewScanContainerStepFactory() StepFactory { - return func(name string, config map[string]any, _ modular.Application) (PipelineStep, error) { + return func(name string, config map[string]any, app modular.Application) (PipelineStep, error) { scanner, _ := config["scanner"].(string) if scanner == "" { scanner = "trivy" } - image, _ := config["image"].(string) - if image == "" { - image = "aquasec/trivy:latest" - } - targetImage, _ := config["target_image"].(string) if targetImage == "" { // Fall back to "image" config key for the scan target (as in the YAML spec) - targetImage = image + targetImage, _ = config["image"].(string) + } + targetImage = strings.TrimSpace(targetImage) + if targetImage == "" { + return nil, fmt.Errorf("scan_container step %q: target image is required; set 'target_image' or 'image' in config", name) } severityThreshold, _ := config["severity_threshold"].(string) @@ -48,6 +45,10 @@ func NewScanContainerStepFactory() StepFactory { severityThreshold = "HIGH" } + if err := validateSeverity(severityThreshold); err != nil { + return nil, fmt.Errorf("scan_container step %q: %w", name, err) + } + ignoreUnfixed, _ := config["ignore_unfixed"].(bool) outputFormat, _ := config["output_format"].(string) @@ -58,11 +59,11 @@ func NewScanContainerStepFactory() StepFactory { return &ScanContainerStep{ name: name, scanner: scanner, - image: "aquasec/trivy:latest", // scanner image is always Trivy targetImage: targetImage, severityThreshold: severityThreshold, ignoreUnfixed: ignoreUnfixed, outputFormat: outputFormat, + app: app, }, nil } } @@ -70,13 +71,42 @@ func NewScanContainerStepFactory() StepFactory { // Name returns the step name. func (s *ScanContainerStep) Name() string { return s.name } -// Execute runs the container scanner and returns findings as a ScanResult. -// -// NOTE: This step is not yet implemented. Execution via sandbox.DockerSandbox -// is required but the sandbox package is not yet available. This method always -// returns ErrNotImplemented to prevent silent no-ops in CI/CD pipelines. -func (s *ScanContainerStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - return nil, fmt.Errorf("scan_container step %q: %w", s.name, ErrNotImplemented) +// Execute runs the container scanner via the SecurityScannerProvider and evaluates +// the severity gate. Returns an error if the gate fails or no provider is configured. +func (s *ScanContainerStep) Execute(ctx context.Context, _ *PipelineContext) (*StepResult, error) { + if s.app == nil { + return nil, fmt.Errorf("scan_container step %q: no application context", s.name) + } + var provider SecurityScannerProvider + if err := s.app.GetService("security-scanner", &provider); err != nil { + return nil, fmt.Errorf("scan_container step %q: no security scanner provider configured — load a scanner plugin", s.name) + } + + result, err := provider.ScanContainer(ctx, ContainerScanOpts{ + Scanner: s.scanner, + TargetImage: s.targetImage, + SeverityThreshold: s.severityThreshold, + IgnoreUnfixed: s.ignoreUnfixed, + OutputFormat: s.outputFormat, + }) + if err != nil { + return nil, fmt.Errorf("scan_container step %q: %w", s.name, err) + } + + passed := result.EvaluateGate(s.severityThreshold) + + output := map[string]any{ + "passed": passed, + "findings": result.Findings, + "summary": result.Summary, + "scanner": result.Scanner, + } + + if !passed { + return nil, fmt.Errorf("scan_container step %q: severity gate failed (threshold: %s)", s.name, s.severityThreshold) + } + + return &StepResult{Output: output}, nil } // validateSeverity checks that a severity string is valid. diff --git a/module/pipeline_step_scan_deps.go b/module/pipeline_step_scan_deps.go index 2f65f62a..c8805b80 100644 --- a/module/pipeline_step_scan_deps.go +++ b/module/pipeline_step_scan_deps.go @@ -9,32 +9,25 @@ import ( // ScanDepsStep runs a dependency vulnerability scanner (e.g., Grype) // against a source path and evaluates findings against a severity gate. -// -// NOTE: This step is not yet implemented. Docker-based execution requires -// sandbox.DockerSandbox, which is not yet available. Calls to Execute will -// always return ErrNotImplemented. +// Execution is delegated to a SecurityScannerProvider registered under +// the "security-scanner" service. type ScanDepsStep struct { name string scanner string - image string sourcePath string failOnSeverity string outputFormat string + app modular.Application } // NewScanDepsStepFactory returns a StepFactory that creates ScanDepsStep instances. func NewScanDepsStepFactory() StepFactory { - return func(name string, config map[string]any, _ modular.Application) (PipelineStep, error) { + return func(name string, config map[string]any, app modular.Application) (PipelineStep, error) { scanner, _ := config["scanner"].(string) if scanner == "" { scanner = "grype" } - image, _ := config["image"].(string) - if image == "" { - image = "anchore/grype:latest" - } - sourcePath, _ := config["source_path"].(string) if sourcePath == "" { sourcePath = "/workspace" @@ -57,10 +50,10 @@ func NewScanDepsStepFactory() StepFactory { return &ScanDepsStep{ name: name, scanner: scanner, - image: image, sourcePath: sourcePath, failOnSeverity: failOnSeverity, outputFormat: outputFormat, + app: app, }, nil } } @@ -68,11 +61,39 @@ func NewScanDepsStepFactory() StepFactory { // Name returns the step name. func (s *ScanDepsStep) Name() string { return s.name } -// Execute runs the dependency scanner and returns findings as a ScanResult. -// -// NOTE: This step is not yet implemented. Execution via sandbox.DockerSandbox -// is required but the sandbox package is not yet available. This method always -// returns ErrNotImplemented to prevent silent no-ops in CI/CD pipelines. -func (s *ScanDepsStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - return nil, fmt.Errorf("scan_deps step %q: %w", s.name, ErrNotImplemented) +// Execute runs the dependency scanner via the SecurityScannerProvider and evaluates +// the severity gate. Returns an error if the gate fails or no provider is configured. +func (s *ScanDepsStep) Execute(ctx context.Context, _ *PipelineContext) (*StepResult, error) { + if s.app == nil { + return nil, fmt.Errorf("scan_deps step %q: no application context", s.name) + } + var provider SecurityScannerProvider + if err := s.app.GetService("security-scanner", &provider); err != nil { + return nil, fmt.Errorf("scan_deps step %q: no security scanner provider configured — load a scanner plugin", s.name) + } + + result, err := provider.ScanDeps(ctx, DepsScanOpts{ + Scanner: s.scanner, + SourcePath: s.sourcePath, + FailOnSeverity: s.failOnSeverity, + OutputFormat: s.outputFormat, + }) + if err != nil { + return nil, fmt.Errorf("scan_deps step %q: %w", s.name, err) + } + + passed := result.EvaluateGate(s.failOnSeverity) + + output := map[string]any{ + "passed": passed, + "findings": result.Findings, + "summary": result.Summary, + "scanner": result.Scanner, + } + + if !passed { + return nil, fmt.Errorf("scan_deps step %q: severity gate failed (threshold: %s)", s.name, s.failOnSeverity) + } + + return &StepResult{Output: output}, nil } diff --git a/module/pipeline_step_scan_sast.go b/module/pipeline_step_scan_sast.go index e98a553e..01855a1c 100644 --- a/module/pipeline_step_scan_sast.go +++ b/module/pipeline_step_scan_sast.go @@ -8,34 +8,26 @@ import ( ) // ScanSASTStep runs a SAST (Static Application Security Testing) scanner -// inside a Docker container and evaluates findings against a severity gate. -// -// NOTE: This step is not yet implemented. Docker-based execution requires -// sandbox.DockerSandbox, which is not yet available. Calls to Execute will -// always return ErrNotImplemented. +// and evaluates findings against a severity gate. Execution is delegated to +// a SecurityScannerProvider registered under the "security-scanner" service. type ScanSASTStep struct { name string scanner string - image string sourcePath string rules []string failOnSeverity string outputFormat string + app modular.Application } // NewScanSASTStepFactory returns a StepFactory that creates ScanSASTStep instances. func NewScanSASTStepFactory() StepFactory { - return func(name string, config map[string]any, _ modular.Application) (PipelineStep, error) { + return func(name string, config map[string]any, app modular.Application) (PipelineStep, error) { scanner, _ := config["scanner"].(string) if scanner == "" { return nil, fmt.Errorf("scan_sast step %q: 'scanner' is required", name) } - image, _ := config["image"].(string) - if image == "" { - image = "semgrep/semgrep:latest" - } - sourcePath, _ := config["source_path"].(string) if sourcePath == "" { sourcePath = "/workspace" @@ -52,7 +44,11 @@ func NewScanSASTStepFactory() StepFactory { failOnSeverity, _ := config["fail_on_severity"].(string) if failOnSeverity == "" { - failOnSeverity = "error" + failOnSeverity = "high" + } + + if err := validateSeverity(failOnSeverity); err != nil { + return nil, fmt.Errorf("scan_sast step %q: %w", name, err) } outputFormat, _ := config["output_format"].(string) @@ -63,11 +59,11 @@ func NewScanSASTStepFactory() StepFactory { return &ScanSASTStep{ name: name, scanner: scanner, - image: image, sourcePath: sourcePath, rules: rules, failOnSeverity: failOnSeverity, outputFormat: outputFormat, + app: app, }, nil } } @@ -75,11 +71,40 @@ func NewScanSASTStepFactory() StepFactory { // Name returns the step name. func (s *ScanSASTStep) Name() string { return s.name } -// Execute runs the SAST scanner and returns findings as a ScanResult. -// -// NOTE: This step is not yet implemented. Execution via sandbox.DockerSandbox -// is required but the sandbox package is not yet available. This method always -// returns ErrNotImplemented to prevent silent no-ops in CI/CD pipelines. -func (s *ScanSASTStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - return nil, fmt.Errorf("scan_sast step %q: %w", s.name, ErrNotImplemented) +// Execute runs the SAST scanner via the SecurityScannerProvider and evaluates +// the severity gate. Returns an error if the gate fails or no provider is configured. +func (s *ScanSASTStep) Execute(ctx context.Context, _ *PipelineContext) (*StepResult, error) { + if s.app == nil { + return nil, fmt.Errorf("scan_sast step %q: no application context", s.name) + } + var provider SecurityScannerProvider + if err := s.app.GetService("security-scanner", &provider); err != nil { + return nil, fmt.Errorf("scan_sast step %q: no security scanner provider configured — load a scanner plugin", s.name) + } + + result, err := provider.ScanSAST(ctx, SASTScanOpts{ + Scanner: s.scanner, + SourcePath: s.sourcePath, + Rules: s.rules, + FailOnSeverity: s.failOnSeverity, + OutputFormat: s.outputFormat, + }) + if err != nil { + return nil, fmt.Errorf("scan_sast step %q: %w", s.name, err) + } + + passed := result.EvaluateGate(s.failOnSeverity) + + output := map[string]any{ + "passed": passed, + "findings": result.Findings, + "summary": result.Summary, + "scanner": result.Scanner, + } + + if !passed { + return nil, fmt.Errorf("scan_sast step %q: severity gate failed (threshold: %s)", s.name, s.failOnSeverity) + } + + return &StepResult{Output: output}, nil } diff --git a/module/pipeline_step_scan_test.go b/module/pipeline_step_scan_test.go index d47fd970..8ef660fa 100644 --- a/module/pipeline_step_scan_test.go +++ b/module/pipeline_step_scan_test.go @@ -2,13 +2,18 @@ package module import ( "context" - "errors" + "strings" "testing" ) -func TestScanSASTStep_ExecuteReturnsErrNotImplemented(t *testing.T) { +// newNoProviderApp returns a mock app with no services registered. +func newNoProviderApp() *scanMockApp { + return &scanMockApp{services: map[string]any{}} +} + +func TestScanSASTStep_NoProvider(t *testing.T) { factory := NewScanSASTStepFactory() - step, err := factory("sast-step", map[string]any{"scanner": "semgrep"}, nil) + step, err := factory("sast-step", map[string]any{"scanner": "semgrep"}, newNoProviderApp()) if err != nil { t.Fatalf("factory returned error: %v", err) } @@ -17,14 +22,14 @@ func TestScanSASTStep_ExecuteReturnsErrNotImplemented(t *testing.T) { if execErr == nil { t.Fatal("expected Execute to return an error, got nil") } - if !errors.Is(execErr, ErrNotImplemented) { - t.Errorf("expected errors.Is(err, ErrNotImplemented), got: %v", execErr) + if !strings.Contains(execErr.Error(), "no security scanner provider configured") { + t.Errorf("unexpected error: %v", execErr) } } -func TestScanContainerStep_ExecuteReturnsErrNotImplemented(t *testing.T) { +func TestScanContainerStep_NoProvider(t *testing.T) { factory := NewScanContainerStepFactory() - step, err := factory("container-step", map[string]any{}, nil) + step, err := factory("container-step", map[string]any{"target_image": "myapp:latest"}, newNoProviderApp()) if err != nil { t.Fatalf("factory returned error: %v", err) } @@ -33,14 +38,25 @@ func TestScanContainerStep_ExecuteReturnsErrNotImplemented(t *testing.T) { if execErr == nil { t.Fatal("expected Execute to return an error, got nil") } - if !errors.Is(execErr, ErrNotImplemented) { - t.Errorf("expected errors.Is(err, ErrNotImplemented), got: %v", execErr) + if !strings.Contains(execErr.Error(), "no security scanner provider configured") { + t.Errorf("unexpected error: %v", execErr) + } +} + +func TestScanContainerStep_MissingTargetImage(t *testing.T) { + factory := NewScanContainerStepFactory() + _, err := factory("container-step", map[string]any{}, nil) + if err == nil { + t.Fatal("expected error for missing target_image, got nil") + } + if !strings.Contains(err.Error(), "target image is required") { + t.Errorf("unexpected error: %v", err) } } -func TestScanDepsStep_ExecuteReturnsErrNotImplemented(t *testing.T) { +func TestScanDepsStep_NoProvider(t *testing.T) { factory := NewScanDepsStepFactory() - step, err := factory("deps-step", map[string]any{}, nil) + step, err := factory("deps-step", map[string]any{}, newNoProviderApp()) if err != nil { t.Fatalf("factory returned error: %v", err) } @@ -49,7 +65,7 @@ func TestScanDepsStep_ExecuteReturnsErrNotImplemented(t *testing.T) { if execErr == nil { t.Fatal("expected Execute to return an error, got nil") } - if !errors.Is(execErr, ErrNotImplemented) { - t.Errorf("expected errors.Is(err, ErrNotImplemented), got: %v", execErr) + if !strings.Contains(execErr.Error(), "no security scanner provider configured") { + t.Errorf("unexpected error: %v", execErr) } } diff --git a/module/scan_provider.go b/module/scan_provider.go new file mode 100644 index 00000000..837b59f0 --- /dev/null +++ b/module/scan_provider.go @@ -0,0 +1,41 @@ +package module + +import "context" + +// SecurityScannerProvider is implemented by plugins that provide security scanning. +type SecurityScannerProvider interface { + ScanSAST(ctx context.Context, opts SASTScanOpts) (*ScanResult, error) + ScanContainer(ctx context.Context, opts ContainerScanOpts) (*ScanResult, error) + ScanDeps(ctx context.Context, opts DepsScanOpts) (*ScanResult, error) +} + +// SASTScanOpts configures a SAST scan. +type SASTScanOpts struct { + Scanner string + SourcePath string + Rules []string + FailOnSeverity string + OutputFormat string +} + +// ContainerScanOpts configures a container vulnerability scan. +type ContainerScanOpts struct { + Scanner string + TargetImage string + SeverityThreshold string + IgnoreUnfixed bool + OutputFormat string +} + +// DepsScanOpts configures a dependency vulnerability scan. +type DepsScanOpts struct { + Scanner string + SourcePath string + FailOnSeverity string + OutputFormat string +} + +// SeverityRank returns a numeric rank for severity comparison (higher = more severe). +func SeverityRank(severity string) int { + return severityRank(severity) +} diff --git a/module/scan_provider_test.go b/module/scan_provider_test.go new file mode 100644 index 00000000..c73e11d3 --- /dev/null +++ b/module/scan_provider_test.go @@ -0,0 +1,338 @@ +package module + +import ( + "context" + "fmt" + "reflect" + "strings" + "testing" + "time" + + "github.com/CrisisTextLine/modular" +) + +// mockSecurityScanner is a test implementation of SecurityScannerProvider. +type mockSecurityScanner struct { + SASTResult *ScanResult + SASTErr error + ContainerResult *ScanResult + ContainerErr error + DepsResult *ScanResult + DepsErr error + + SASTCallOpts SASTScanOpts + ContainerCallOpts ContainerScanOpts + DepsCallOpts DepsScanOpts +} + +func (m *mockSecurityScanner) ScanSAST(_ context.Context, opts SASTScanOpts) (*ScanResult, error) { + m.SASTCallOpts = opts + return m.SASTResult, m.SASTErr +} + +func (m *mockSecurityScanner) ScanContainer(_ context.Context, opts ContainerScanOpts) (*ScanResult, error) { + m.ContainerCallOpts = opts + return m.ContainerResult, m.ContainerErr +} + +func (m *mockSecurityScanner) ScanDeps(_ context.Context, opts DepsScanOpts) (*ScanResult, error) { + m.DepsCallOpts = opts + return m.DepsResult, m.DepsErr +} + +// scanMockApp is a minimal modular.Application for scan step tests. +type scanMockApp struct { + services map[string]any +} + +func (a *scanMockApp) GetService(name string, target any) error { + svc, ok := a.services[name] + if !ok { + return fmt.Errorf("service %q not found", name) + } + rv := reflect.ValueOf(target) + if rv.Kind() != reflect.Ptr || rv.IsNil() { + return fmt.Errorf("target must be a non-nil pointer") + } + rv.Elem().Set(reflect.ValueOf(svc)) + return nil +} + +func (a *scanMockApp) RegisterService(name string, svc any) error { a.services[name] = svc; return nil } +func (a *scanMockApp) RegisterConfigSection(string, modular.ConfigProvider) {} +func (a *scanMockApp) GetConfigSection(string) (modular.ConfigProvider, error) { + return nil, nil +} +func (a *scanMockApp) ConfigSections() map[string]modular.ConfigProvider { return nil } +func (a *scanMockApp) Logger() modular.Logger { return nil } +func (a *scanMockApp) SetLogger(modular.Logger) {} +func (a *scanMockApp) ConfigProvider() modular.ConfigProvider { return nil } +func (a *scanMockApp) SvcRegistry() modular.ServiceRegistry { return a.services } +func (a *scanMockApp) RegisterModule(modular.Module) {} +func (a *scanMockApp) Init() error { return nil } +func (a *scanMockApp) Start() error { return nil } +func (a *scanMockApp) Stop() error { return nil } +func (a *scanMockApp) Run() error { return nil } +func (a *scanMockApp) IsVerboseConfig() bool { return false } +func (a *scanMockApp) SetVerboseConfig(bool) {} +func (a *scanMockApp) Context() context.Context { return context.Background() } +func (a *scanMockApp) GetServicesByModule(string) []string { return nil } +func (a *scanMockApp) GetServiceEntry(string) (*modular.ServiceRegistryEntry, bool) { + return nil, false +} +func (a *scanMockApp) GetServicesByInterface(_ reflect.Type) []*modular.ServiceRegistryEntry { + return nil +} +func (a *scanMockApp) GetModule(string) modular.Module { return nil } +func (a *scanMockApp) GetAllModules() map[string]modular.Module { return nil } +func (a *scanMockApp) StartTime() time.Time { return time.Time{} } +func (a *scanMockApp) OnConfigLoaded(func(modular.Application) error) {} + +func newScanApp(provider SecurityScannerProvider) *scanMockApp { + app := &scanMockApp{services: map[string]any{}} + app.services["security-scanner"] = provider + return app +} + +// TestScanSASTStep_Success verifies that Execute returns output when the scan passes. +func TestScanSASTStep_Success(t *testing.T) { + mock := &mockSecurityScanner{ + SASTResult: &ScanResult{ + Scanner: "semgrep", + Findings: []Finding{}, + }, + } + app := newScanApp(mock) + + factory := NewScanSASTStepFactory() + step, err := factory("sast-step", map[string]any{ + "scanner": "semgrep", + "source_path": "/src", + "fail_on_severity": "high", + "output_format": "sarif", + "rules": []any{"p/ci"}, + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + result, err := step.Execute(context.Background(), &PipelineContext{}) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if passed, _ := result.Output["passed"].(bool); !passed { + t.Error("expected passed=true in output") + } + if mock.SASTCallOpts.Scanner != "semgrep" { + t.Errorf("expected scanner=semgrep, got %q", mock.SASTCallOpts.Scanner) + } + if mock.SASTCallOpts.SourcePath != "/src" { + t.Errorf("expected source_path=/src, got %q", mock.SASTCallOpts.SourcePath) + } +} + +// TestScanSASTStep_GateFails verifies that Execute returns an error when the gate fails. +func TestScanSASTStep_GateFails(t *testing.T) { + mock := &mockSecurityScanner{ + SASTResult: &ScanResult{ + Scanner: "semgrep", + Findings: []Finding{ + {RuleID: "sql-injection", Severity: "high", Message: "SQL Injection"}, + }, + }, + } + app := newScanApp(mock) + + factory := NewScanSASTStepFactory() + step, err := factory("sast-step", map[string]any{ + "scanner": "semgrep", + "fail_on_severity": "high", + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + _, execErr := step.Execute(context.Background(), &PipelineContext{}) + if execErr == nil { + t.Fatal("expected error when gate fails") + } + if !strings.Contains(execErr.Error(), "severity gate failed") { + t.Errorf("expected severity gate error, got: %v", execErr) + } +} + +// TestScanSASTStep_ProviderError verifies that Execute propagates provider errors. +func TestScanSASTStep_ProviderError(t *testing.T) { + mock := &mockSecurityScanner{SASTErr: fmt.Errorf("scanner unavailable")} + app := newScanApp(mock) + + factory := NewScanSASTStepFactory() + step, err := factory("sast-step", map[string]any{"scanner": "semgrep"}, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + _, execErr := step.Execute(context.Background(), &PipelineContext{}) + if execErr == nil { + t.Fatal("expected error from provider") + } + if !strings.Contains(execErr.Error(), "scanner unavailable") { + t.Errorf("expected provider error, got: %v", execErr) + } +} + +// TestScanContainerStep_Success verifies container scan success. +func TestScanContainerStep_Success(t *testing.T) { + mock := &mockSecurityScanner{ + ContainerResult: &ScanResult{ + Scanner: "trivy", + Findings: []Finding{{RuleID: "CVE-low", Severity: "low"}}, + }, + } + app := newScanApp(mock) + + factory := NewScanContainerStepFactory() + step, err := factory("container-step", map[string]any{ + "scanner": "trivy", + "target_image": "myapp:latest", + "severity_threshold": "high", + "ignore_unfixed": true, + "output_format": "json", + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + result, err := step.Execute(context.Background(), &PipelineContext{}) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if mock.ContainerCallOpts.TargetImage != "myapp:latest" { + t.Errorf("expected target_image=myapp:latest, got %q", mock.ContainerCallOpts.TargetImage) + } + if !mock.ContainerCallOpts.IgnoreUnfixed { + t.Error("expected ignore_unfixed=true") + } +} + +// TestScanContainerStep_GateFails verifies container scan gate failure. +func TestScanContainerStep_GateFails(t *testing.T) { + mock := &mockSecurityScanner{ + ContainerResult: &ScanResult{ + Scanner: "trivy", + Findings: []Finding{ + {RuleID: "CVE-2024-1234", Severity: "critical"}, + }, + }, + } + app := newScanApp(mock) + + factory := NewScanContainerStepFactory() + step, err := factory("container-step", map[string]any{ + "target_image": "vulnerable:latest", + "severity_threshold": "high", + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + _, execErr := step.Execute(context.Background(), &PipelineContext{}) + if execErr == nil { + t.Fatal("expected error when gate fails") + } + if !strings.Contains(execErr.Error(), "severity gate failed") { + t.Errorf("expected severity gate error, got: %v", execErr) + } +} + +// TestScanDepsStep_Success verifies dependency scan success. +func TestScanDepsStep_Success(t *testing.T) { + mock := &mockSecurityScanner{ + DepsResult: &ScanResult{ + Scanner: "grype", + Findings: []Finding{{RuleID: "GHSA-medium", Severity: "medium"}}, + }, + } + app := newScanApp(mock) + + factory := NewScanDepsStepFactory() + step, err := factory("deps-step", map[string]any{ + "scanner": "grype", + "source_path": "/code", + "fail_on_severity": "high", + "output_format": "table", + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + result, err := step.Execute(context.Background(), &PipelineContext{}) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if mock.DepsCallOpts.SourcePath != "/code" { + t.Fatalf("expected source_path=/code, got %q", mock.DepsCallOpts.SourcePath) + } +} + +// TestScanDepsStep_GateFails verifies dependency scan gate failure. +func TestScanDepsStep_GateFails(t *testing.T) { + mock := &mockSecurityScanner{ + DepsResult: &ScanResult{ + Scanner: "grype", + Findings: []Finding{ + {RuleID: "GHSA-1234", Severity: "high"}, + }, + }, + } + app := newScanApp(mock) + + factory := NewScanDepsStepFactory() + step, err := factory("deps-step", map[string]any{ + "fail_on_severity": "high", + }, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + _, execErr := step.Execute(context.Background(), &PipelineContext{}) + if execErr == nil { + t.Fatal("expected error when gate fails") + } + if !strings.Contains(execErr.Error(), "severity gate failed") { + t.Errorf("expected severity gate error, got: %v", execErr) + } +} + +// TestSeverityRank verifies severity ranking. +func TestSeverityRank(t *testing.T) { + cases := []struct { + severity string + rank int + }{ + {"critical", 5}, + {"CRITICAL", 5}, + {"high", 4}, + {"HIGH", 4}, + {"medium", 3}, + {"low", 2}, + {"info", 1}, + {"unknown", 0}, + {"", 0}, + } + for _, tc := range cases { + got := SeverityRank(tc.severity) + if got != tc.rank { + t.Errorf("SeverityRank(%q) = %d, want %d", tc.severity, got, tc.rank) + } + } +} diff --git a/plugin/external/adapter.go b/plugin/external/adapter.go index 74731f8b..54d7514e 100644 --- a/plugin/external/adapter.go +++ b/plugin/external/adapter.go @@ -139,7 +139,11 @@ func (a *ExternalPluginAdapter) ModuleFactories() map[string]plugin.ModuleFactor if createErr != nil || createResp.Error != "" { return nil } - return NewRemoteModule(name, createResp.HandleId, a.client.client) + remote := NewRemoteModule(name, createResp.HandleId, a.client.client) + if tn == "security.scanner" { + return NewSecurityScannerRemoteModule(remote) + } + return remote } } return factories diff --git a/plugin/external/security_scanner_adapter.go b/plugin/external/security_scanner_adapter.go new file mode 100644 index 00000000..c8058b3b --- /dev/null +++ b/plugin/external/security_scanner_adapter.go @@ -0,0 +1,112 @@ +package external + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/workflow/module" +) + +// SecurityScannerRemoteModule wraps a RemoteModule for security.scanner type modules. +// On Init it registers a RemoteSecurityScannerProvider in the service registry so +// that scan steps can call app.GetService("security-scanner", &provider). +type SecurityScannerRemoteModule struct { + *RemoteModule +} + +// NewSecurityScannerRemoteModule wraps a RemoteModule as a security scanner module. +func NewSecurityScannerRemoteModule(remote *RemoteModule) *SecurityScannerRemoteModule { + return &SecurityScannerRemoteModule{RemoteModule: remote} +} + +// Init calls the remote Init and registers the scanner provider in the service registry. +func (m *SecurityScannerRemoteModule) Init(app modular.Application) error { + if err := m.RemoteModule.Init(app); err != nil { + return err + } + provider := &remoteSecurityScannerProvider{module: m.RemoteModule} + return app.RegisterService("security-scanner", provider) +} + +// remoteSecurityScannerProvider implements module.SecurityScannerProvider by +// calling InvokeService on the remote module. +type remoteSecurityScannerProvider struct { + module *RemoteModule +} + +func (p *remoteSecurityScannerProvider) ScanSAST(ctx context.Context, opts module.SASTScanOpts) (*module.ScanResult, error) { + args := map[string]any{ + "scanner": opts.Scanner, + "source_path": opts.SourcePath, + "rules": opts.Rules, + "fail_on_severity": opts.FailOnSeverity, + "output_format": opts.OutputFormat, + } + // TODO: add context-aware InvokeService — the context (deadline/cancellation) is + // not propagated to the remote plugin because InvokeService does not accept a ctx parameter. + result, err := p.module.InvokeService("ScanSAST", args) + if err != nil { + return nil, fmt.Errorf("remote ScanSAST: %w", err) + } + return decodeScanResult(result) +} + +func (p *remoteSecurityScannerProvider) ScanContainer(ctx context.Context, opts module.ContainerScanOpts) (*module.ScanResult, error) { + args := map[string]any{ + "scanner": opts.Scanner, + "target_image": opts.TargetImage, + "severity_threshold": opts.SeverityThreshold, + "ignore_unfixed": opts.IgnoreUnfixed, + "output_format": opts.OutputFormat, + } + result, err := p.module.InvokeService("ScanContainer", args) + if err != nil { + return nil, fmt.Errorf("remote ScanContainer: %w", err) + } + return decodeScanResult(result) +} + +func (p *remoteSecurityScannerProvider) ScanDeps(ctx context.Context, opts module.DepsScanOpts) (*module.ScanResult, error) { + args := map[string]any{ + "scanner": opts.Scanner, + "source_path": opts.SourcePath, + "fail_on_severity": opts.FailOnSeverity, + "output_format": opts.OutputFormat, + } + result, err := p.module.InvokeService("ScanDeps", args) + if err != nil { + return nil, fmt.Errorf("remote ScanDeps: %w", err) + } + return decodeScanResult(result) +} + +// decodeScanResult converts a map[string]any from InvokeService to a *module.ScanResult. +// The map is encoded via JSON round-trip for simplicity. +func decodeScanResult(data map[string]any) (*module.ScanResult, error) { + raw, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("marshal scan result: %w", err) + } + // Use an intermediate struct matching the JSON fields. + var wire struct { + Scanner string `json:"scanner"` + PassedGate bool `json:"passed_gate"` + Findings []module.Finding `json:"findings"` + Summary module.ScanSummary `json:"summary"` + } + if err := json.Unmarshal(raw, &wire); err != nil { + return nil, fmt.Errorf("decode scan result: %w", err) + } + sr := &module.ScanResult{ + Scanner: wire.Scanner, + PassedGate: wire.PassedGate, + Findings: wire.Findings, + Summary: wire.Summary, + } + return sr, nil +} + +// Ensure SecurityScannerRemoteModule satisfies modular.Module at compile time. +var _ modular.Module = (*SecurityScannerRemoteModule)(nil) diff --git a/plugins/all/all.go b/plugins/all/all.go index c65fa129..d5453a32 100644 --- a/plugins/all/all.go +++ b/plugins/all/all.go @@ -47,6 +47,7 @@ import ( pluginpolicy "github.com/GoCodeAlone/workflow/plugins/policy" pluginscheduler "github.com/GoCodeAlone/workflow/plugins/scheduler" pluginsecrets "github.com/GoCodeAlone/workflow/plugins/secrets" + pluginscanner "github.com/GoCodeAlone/workflow/plugins/scanner" pluginsm "github.com/GoCodeAlone/workflow/plugins/statemachine" pluginstorage "github.com/GoCodeAlone/workflow/plugins/storage" plugintimeline "github.com/GoCodeAlone/workflow/plugins/timeline" @@ -90,6 +91,7 @@ func DefaultPlugins() []plugin.EnginePlugin { pluginpolicy.New(), plugink8s.New(), pluginmarketplace.New(), + pluginscanner.New(), pluginactors.New(), } } diff --git a/plugins/scanner/module.go b/plugins/scanner/module.go new file mode 100644 index 00000000..9a658cc4 --- /dev/null +++ b/plugins/scanner/module.go @@ -0,0 +1,196 @@ +package scanner + +import ( + "context" + "fmt" + "strings" + + "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/workflow/module" +) + +// ScannerModule implements SecurityScannerProvider and registers itself +// in the service registry so that scan steps can find it. +type ScannerModule struct { + name string + mode string // "mock" or "cli" + mockFindings map[string][]module.Finding + semgrepBinary string + trivyBinary string + grypeBinary string +} + +// NewScannerModule creates a ScannerModule from config. +func NewScannerModule(name string, cfg map[string]any) (*ScannerModule, error) { + m := &ScannerModule{ + name: name, + mode: "mock", + semgrepBinary: "semgrep", + trivyBinary: "trivy", + grypeBinary: "grype", + mockFindings: make(map[string][]module.Finding), + } + + if v, ok := cfg["mode"].(string); ok && v != "" { + if v != "mock" && v != "cli" { + return nil, fmt.Errorf("security.scanner %q: invalid mode %q (expected \"mock\" or \"cli\")", name, v) + } + m.mode = v + } + if v, ok := cfg["semgrepBinary"].(string); ok && v != "" { + m.semgrepBinary = v + } + if v, ok := cfg["trivyBinary"].(string); ok && v != "" { + m.trivyBinary = v + } + if v, ok := cfg["grypeBinary"].(string); ok && v != "" { + m.grypeBinary = v + } + + if mockCfg, ok := cfg["mockFindings"].(map[string]any); ok { + validScanTypes := map[string]bool{"sast": true, "container": true, "deps": true} + for scanType, findingsRaw := range mockCfg { + if !validScanTypes[scanType] { + return nil, fmt.Errorf("security.scanner %q: unknown mockFindings scan type %q (expected sast, container, or deps)", name, scanType) + } + findings, err := parseMockFindings(findingsRaw) + if err != nil { + return nil, fmt.Errorf("security.scanner %q: invalid mockFindings.%s: %w", name, scanType, err) + } + m.mockFindings[scanType] = findings + } + } + + return m, nil +} + +// Name returns the module name. +func (m *ScannerModule) Name() string { return m.name } + +// Init registers the module as a SecurityScannerProvider in the service registry. +// Only one security.scanner module may be loaded at a time; this is intentional — +// the engine uses a single provider under the "security-scanner" service key. +func (m *ScannerModule) Init(app modular.Application) error { + return app.RegisterService("security-scanner", m) +} + +// Start is a no-op. +func (m *ScannerModule) Start(_ context.Context) error { return nil } + +// Stop is a no-op. +func (m *ScannerModule) Stop(_ context.Context) error { return nil } + +// ScanSAST performs a SAST scan. In mock mode, returns preconfigured findings. +func (m *ScannerModule) ScanSAST(_ context.Context, opts module.SASTScanOpts) (*module.ScanResult, error) { + scanner := opts.Scanner + if scanner == "" { + scanner = "semgrep" + } + + if m.mode == "mock" { + return m.mockScan("sast", scanner, opts.FailOnSeverity), nil + } + + return nil, fmt.Errorf("security.scanner %q: CLI mode not yet implemented for SAST (scanner: %s)", m.name, scanner) +} + +// ScanContainer performs a container image scan. In mock mode, returns preconfigured findings. +func (m *ScannerModule) ScanContainer(_ context.Context, opts module.ContainerScanOpts) (*module.ScanResult, error) { + scanner := opts.Scanner + if scanner == "" { + scanner = "trivy" + } + + if m.mode == "mock" { + return m.mockScan("container", scanner, opts.SeverityThreshold), nil + } + + return nil, fmt.Errorf("security.scanner %q: CLI mode not yet implemented for container scan (scanner: %s)", m.name, scanner) +} + +// ScanDeps performs a dependency vulnerability scan. In mock mode, returns preconfigured findings. +func (m *ScannerModule) ScanDeps(_ context.Context, opts module.DepsScanOpts) (*module.ScanResult, error) { + scanner := opts.Scanner + if scanner == "" { + scanner = "grype" + } + + if m.mode == "mock" { + return m.mockScan("deps", scanner, opts.FailOnSeverity), nil + } + + return nil, fmt.Errorf("security.scanner %q: CLI mode not yet implemented for deps scan (scanner: %s)", m.name, scanner) +} + +// mockScan returns a ScanResult from preconfigured mock findings. +func (m *ScannerModule) mockScan(scanType, scanner, threshold string) *module.ScanResult { + result := module.NewScanResult(scanner) + + if findings, ok := m.mockFindings[scanType]; ok { + for _, f := range findings { + result.AddFinding(f) + } + } else { + // Default mock: return a few sample findings + result.AddFinding(module.Finding{ + RuleID: "MOCK-001", + Severity: "medium", + Message: fmt.Sprintf("Mock %s finding from %s scanner", scanType, scanner), + Location: "/src/main.go", + Line: 42, + }) + result.AddFinding(module.Finding{ + RuleID: "MOCK-002", + Severity: "low", + Message: fmt.Sprintf("Mock informational %s finding", scanType), + Location: "/src/util.go", + Line: 15, + }) + } + + result.ComputeSummary() + if threshold != "" { + result.EvaluateGate(threshold) + } else { + result.PassedGate = true + } + + return result +} + +// parseMockFindings converts raw config into a slice of Finding. +func parseMockFindings(raw any) ([]module.Finding, error) { + items, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("expected array of findings") + } + + var findings []module.Finding + for i, item := range items { + m, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("expected finding object at index %d, got %T", i, item) + } + f := module.Finding{ + RuleID: getString(m, "rule_id"), + Severity: strings.ToLower(getString(m, "severity")), + Message: getString(m, "message"), + Location: getString(m, "location"), + } + switch v := m["line"].(type) { + case float64: + f.Line = int(v) + case int: + f.Line = v + } + findings = append(findings, f) + } + return findings, nil +} + +func getString(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} diff --git a/plugins/scanner/module_test.go b/plugins/scanner/module_test.go new file mode 100644 index 00000000..4f794a2a --- /dev/null +++ b/plugins/scanner/module_test.go @@ -0,0 +1,311 @@ +package scanner + +import ( + "context" + "testing" + + "github.com/GoCodeAlone/workflow/module" +) + +func TestNewScannerModule_Defaults(t *testing.T) { + m, err := NewScannerModule("test", map[string]any{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.mode != "mock" { + t.Errorf("expected mode=mock, got %q", m.mode) + } + if m.semgrepBinary != "semgrep" { + t.Errorf("expected semgrepBinary=semgrep, got %q", m.semgrepBinary) + } + if m.trivyBinary != "trivy" { + t.Errorf("expected trivyBinary=trivy, got %q", m.trivyBinary) + } + if m.grypeBinary != "grype" { + t.Errorf("expected grypeBinary=grype, got %q", m.grypeBinary) + } +} + +func TestNewScannerModule_CustomConfig(t *testing.T) { + m, err := NewScannerModule("test", map[string]any{ + "mode": "cli", + "semgrepBinary": "/usr/local/bin/semgrep", + "trivyBinary": "/usr/local/bin/trivy", + "grypeBinary": "/usr/local/bin/grype", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.mode != "cli" { + t.Errorf("expected mode=cli, got %q", m.mode) + } +} + +func TestScannerModule_MockSAST_DefaultFindings(t *testing.T) { + m, _ := NewScannerModule("test", map[string]any{}) + result, err := m.ScanSAST(context.Background(), module.SASTScanOpts{ + Scanner: "semgrep", + SourcePath: "/workspace", + FailOnSeverity: "high", + }) + if err != nil { + t.Fatalf("ScanSAST failed: %v", err) + } + if result.Scanner != "semgrep" { + t.Errorf("expected scanner=semgrep, got %q", result.Scanner) + } + if len(result.Findings) != 2 { + t.Errorf("expected 2 default findings, got %d", len(result.Findings)) + } + if !result.PassedGate { + t.Error("expected gate to pass with default findings (medium/low) and high threshold") + } +} + +func TestScannerModule_MockSAST_CustomFindings(t *testing.T) { + m, err := NewScannerModule("test", map[string]any{ + "mockFindings": map[string]any{ + "sast": []any{ + map[string]any{ + "rule_id": "SEC-001", + "severity": "critical", + "message": "SQL injection detected", + "location": "/src/db.go", + "line": float64(55), + }, + map[string]any{ + "rule_id": "SEC-002", + "severity": "high", + "message": "Hardcoded credential", + "location": "/src/auth.go", + "line": float64(12), + }, + }, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result, err := m.ScanSAST(context.Background(), module.SASTScanOpts{ + Scanner: "semgrep", + FailOnSeverity: "high", + }) + if err != nil { + t.Fatalf("ScanSAST failed: %v", err) + } + if len(result.Findings) != 2 { + t.Fatalf("expected 2 findings, got %d", len(result.Findings)) + } + if result.PassedGate { + t.Error("expected gate to FAIL with critical finding and high threshold") + } + if result.Summary.Critical != 1 { + t.Errorf("expected 1 critical, got %d", result.Summary.Critical) + } + if result.Summary.High != 1 { + t.Errorf("expected 1 high, got %d", result.Summary.High) + } +} + +func TestScannerModule_MockContainer(t *testing.T) { + m, _ := NewScannerModule("test", map[string]any{}) + result, err := m.ScanContainer(context.Background(), module.ContainerScanOpts{ + Scanner: "trivy", + TargetImage: "myapp:latest", + SeverityThreshold: "critical", + }) + if err != nil { + t.Fatalf("ScanContainer failed: %v", err) + } + if result.Scanner != "trivy" { + t.Errorf("expected scanner=trivy, got %q", result.Scanner) + } + if !result.PassedGate { + t.Error("expected gate to pass with default findings (medium/low) and critical threshold") + } +} + +func TestScannerModule_MockDeps(t *testing.T) { + m, _ := NewScannerModule("test", map[string]any{}) + result, err := m.ScanDeps(context.Background(), module.DepsScanOpts{ + Scanner: "grype", + SourcePath: "/workspace", + FailOnSeverity: "medium", + }) + if err != nil { + t.Fatalf("ScanDeps failed: %v", err) + } + if result.Scanner != "grype" { + t.Errorf("expected scanner=grype, got %q", result.Scanner) + } + if result.PassedGate { + t.Error("expected gate to FAIL with medium finding and medium threshold") + } +} + +func TestScannerModule_MockContainer_CustomFindings(t *testing.T) { + m, err := NewScannerModule("test", map[string]any{ + "mockFindings": map[string]any{ + "container": []any{ + map[string]any{ + "rule_id": "CVE-2024-1234", + "severity": "critical", + "message": "Remote code execution in libfoo", + "location": "layer:sha256:abc123", + }, + }, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result, err := m.ScanContainer(context.Background(), module.ContainerScanOpts{ + Scanner: "trivy", + TargetImage: "myapp:latest", + SeverityThreshold: "high", + }) + if err != nil { + t.Fatalf("ScanContainer failed: %v", err) + } + if result.PassedGate { + t.Error("expected gate to fail with critical finding and high threshold") + } + if result.Summary.Critical != 1 { + t.Errorf("expected 1 critical, got %d", result.Summary.Critical) + } +} + +func TestScannerModule_DefaultScanner(t *testing.T) { + m, _ := NewScannerModule("test", map[string]any{}) + + // SAST defaults to semgrep + result, _ := m.ScanSAST(context.Background(), module.SASTScanOpts{ + FailOnSeverity: "critical", + }) + if result.Scanner != "semgrep" { + t.Errorf("expected SAST default scanner=semgrep, got %q", result.Scanner) + } + + // Container defaults to trivy + result, _ = m.ScanContainer(context.Background(), module.ContainerScanOpts{ + SeverityThreshold: "critical", + }) + if result.Scanner != "trivy" { + t.Errorf("expected container default scanner=trivy, got %q", result.Scanner) + } + + // Deps defaults to grype + result, _ = m.ScanDeps(context.Background(), module.DepsScanOpts{ + FailOnSeverity: "critical", + }) + if result.Scanner != "grype" { + t.Errorf("expected deps default scanner=grype, got %q", result.Scanner) + } +} + +func TestScannerModule_CLIModeErrors(t *testing.T) { + m, _ := NewScannerModule("test", map[string]any{ + "mode": "cli", + }) + + _, err := m.ScanSAST(context.Background(), module.SASTScanOpts{Scanner: "semgrep"}) + if err == nil { + t.Error("expected error for CLI mode SAST") + } + + _, err = m.ScanContainer(context.Background(), module.ContainerScanOpts{Scanner: "trivy"}) + if err == nil { + t.Error("expected error for CLI mode container") + } + + _, err = m.ScanDeps(context.Background(), module.DepsScanOpts{Scanner: "grype"}) + if err == nil { + t.Error("expected error for CLI mode deps") + } +} + +func TestScannerModule_Name(t *testing.T) { + m, _ := NewScannerModule("my-scanner", map[string]any{}) + if m.Name() != "my-scanner" { + t.Errorf("expected name=my-scanner, got %q", m.Name()) + } +} + +func TestScannerModule_Lifecycle(t *testing.T) { + m, _ := NewScannerModule("test", map[string]any{}) + if err := m.Start(context.Background()); err != nil { + t.Errorf("Start failed: %v", err) + } + if err := m.Stop(context.Background()); err != nil { + t.Errorf("Stop failed: %v", err) + } +} + +func TestNewScannerModule_InvalidMode(t *testing.T) { + _, err := NewScannerModule("test", map[string]any{ + "mode": "invalid", + }) + if err == nil { + t.Fatal("expected error for invalid mode, got nil") + } +} + +func TestNewScannerModule_UnknownMockFindingsScanType(t *testing.T) { + _, err := NewScannerModule("test", map[string]any{ + "mockFindings": map[string]any{ + "containers": []any{}, // typo: should be "container" + }, + }) + if err == nil { + t.Fatal("expected error for unknown scan type, got nil") + } +} + +func TestParseMockFindings_MalformedItem(t *testing.T) { + _, err := parseMockFindings([]any{ + "not a map", // should be map[string]any + }) + if err == nil { + t.Fatal("expected error for malformed finding item, got nil") + } +} + +func TestParseMockFindings_IntLine(t *testing.T) { + findings, err := parseMockFindings([]any{ + map[string]any{ + "rule_id": "TEST-001", + "severity": "high", + "message": "test", + "location": "/src/main.go", + "line": 55, // int, not float64 + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(findings) != 1 { + t.Fatalf("expected 1 finding, got %d", len(findings)) + } + if findings[0].Line != 55 { + t.Errorf("expected line=55, got %d", findings[0].Line) + } +} + +func TestPlugin_New(t *testing.T) { + p := New() + if p.PluginName != "scanner" { + t.Errorf("expected plugin name=scanner, got %q", p.PluginName) + } + + factories := p.ModuleFactories() + if _, ok := factories["security.scanner"]; !ok { + t.Error("expected security.scanner module factory") + } + + caps := p.Capabilities() + if len(caps) != 1 || caps[0].Name != "security-scanner" { + t.Errorf("unexpected capabilities: %v", caps) + } +} diff --git a/plugins/scanner/plugin.go b/plugins/scanner/plugin.go new file mode 100644 index 00000000..9000fc63 --- /dev/null +++ b/plugins/scanner/plugin.go @@ -0,0 +1,88 @@ +// Package scanner provides a built-in engine plugin that registers +// the security.scanner module type, implementing SecurityScannerProvider. +// It supports mock mode for testing. CLI mode (shelling out to semgrep, +// trivy, grype) is not yet implemented. +package scanner + +import ( + "log/slog" + + "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/workflow/capability" + "github.com/GoCodeAlone/workflow/plugin" + "github.com/GoCodeAlone/workflow/schema" +) + +// Plugin registers the security.scanner module type. +type Plugin struct { + plugin.BaseEnginePlugin +} + +// New creates a new scanner plugin. +func New() *Plugin { + return &Plugin{ + BaseEnginePlugin: plugin.BaseEnginePlugin{ + BaseNativePlugin: plugin.BaseNativePlugin{ + PluginName: "scanner", + PluginVersion: "1.0.0", + PluginDescription: "Security scanner provider: SAST, container, and dependency scanning with mock mode for testing", + }, + Manifest: plugin.PluginManifest{ + Name: "scanner", + Version: "1.0.0", + Author: "GoCodeAlone", + Description: "Security scanner provider with pluggable backends", + Tier: plugin.TierCore, + ModuleTypes: []string{"security.scanner"}, + Capabilities: []plugin.CapabilityDecl{ + {Name: "security-scanner", Role: "provider", Priority: 50}, + }, + }, + }, + } +} + +// Capabilities returns the plugin's capability contracts. +func (p *Plugin) Capabilities() []capability.Contract { + return []capability.Contract{ + { + Name: "security-scanner", + Description: "Security scanning: SAST, container image, and dependency vulnerability scanning", + }, + } +} + +// ModuleFactories returns the security.scanner module factory. +func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { + return map[string]plugin.ModuleFactory{ + "security.scanner": func(name string, cfg map[string]any) modular.Module { + mod, err := NewScannerModule(name, cfg) + if err != nil { + slog.Error("security.scanner: failed to create module", "name", name, "error", err) + return nil + } + return mod + }, + } +} + +// ModuleSchemas returns schemas for the security.scanner module. +func (p *Plugin) ModuleSchemas() []*schema.ModuleSchema { + return []*schema.ModuleSchema{ + scannerModuleSchema(), + } +} + +func scannerModuleSchema() *schema.ModuleSchema { + return &schema.ModuleSchema{ + Type: "security.scanner", + Description: "Security scanner provider supporting mock, semgrep, trivy, and grype backends", + ConfigFields: []schema.ConfigFieldDef{ + {Key: "mode", Type: schema.FieldTypeSelect, Description: "Scanner mode: 'mock' for testing or 'cli' for real tools", DefaultValue: "mock", Options: []string{"mock", "cli"}}, + {Key: "semgrepBinary", Type: schema.FieldTypeString, Description: "Path to semgrep binary", DefaultValue: "semgrep"}, + {Key: "trivyBinary", Type: schema.FieldTypeString, Description: "Path to trivy binary", DefaultValue: "trivy"}, + {Key: "grypeBinary", Type: schema.FieldTypeString, Description: "Path to grype binary", DefaultValue: "grype"}, + {Key: "mockFindings", Type: schema.FieldTypeJSON, Description: "Mock findings to return (keyed by scan type: sast, container, deps)"}, + }, + } +}