diff --git a/docs/development/component-development.mdx b/docs/development/component-development.mdx index 57058a0a..148b1d53 100644 --- a/docs/development/component-development.mdx +++ b/docs/development/component-development.mdx @@ -615,14 +615,49 @@ Does your Docker image have a shell (/bin/sh)? ├─ YES → Use Shell Wrapper Pattern │ entrypoint: 'sh', command: ['-c', 'tool "$@"', '--'] │ -└─ NO (Distroless) → Does your tool have a -stream flag? - ├─ YES → Use Direct Binary + Stream - │ entrypoint: 'tool', command: ['-stream', ...] - │ - └─ NO → Rely on SDK stdin handling - Note: May have buffering issues +└─ NO (Distroless) → Use Default Entrypoint Pattern + Omit entrypoint, pass args via command: [] + The image's built-in ENTRYPOINT handles execution. ``` +### Distroless Images (Default Entrypoint Pattern) + +Many ProjectDiscovery images (subfinder, dnsx, naabu, amass, notify) are **distroless** and do not contain `/bin/sh`. For these images, omit the `entrypoint` field entirely and let Docker use the image's default entrypoint: + +```typescript +// ✅ CORRECT - Distroless image (no shell available) +runner: { + kind: 'docker', + image: 'ghcr.io/shipsecai/subfinder:latest', + // No entrypoint — uses image default (/usr/local/bin/subfinder) + command: [], + network: 'bridge', +} +``` + +```typescript +// ❌ WRONG - Distroless image with shell wrapper (exit code 127) +runner: { + kind: 'docker', + image: 'ghcr.io/shipsecai/subfinder:latest', + entrypoint: 'sh', // sh does not exist in distroless images! + command: ['-c', 'subfinder "$@"', '--'], +} +``` + +In the `execute()` function, append tool arguments directly to `command`: +```typescript +const runnerConfig: DockerRunnerConfig = { + ...baseRunner, + command: [...(baseRunner.command ?? []), ...toolArgs], +}; +``` + + + Distroless Go binaries (like ProjectDiscovery tools) handle PTY signals correctly. + Verified with `docker run --rm -t image args...` — output streams and exits cleanly. + + --- ## File System Access diff --git a/scratch/opencode-mcp-test/run_test.sh b/scratch/opencode-mcp-test/run_test.sh index ecd4f040..0e814477 100755 --- a/scratch/opencode-mcp-test/run_test.sh +++ b/scratch/opencode-mcp-test/run_test.sh @@ -21,7 +21,7 @@ docker run --rm \ --network host \ -v "$DIR:/workspace" \ -e OPENROUTER_API_KEY="$OPENROUTER_API_KEY" \ - ghcr.io/shipsecai/opencode:1.1.53 \ + ghcr.io/shipsecai/opencode:latest \ run --log-level INFO "$(cat prompt.txt)" # Kill MCP server diff --git a/worker/src/components/ai/__tests__/opencode.test.ts b/worker/src/components/ai/__tests__/opencode.test.ts index 3fb16d85..c1479839 100644 --- a/worker/src/components/ai/__tests__/opencode.test.ts +++ b/worker/src/components/ai/__tests__/opencode.test.ts @@ -93,7 +93,7 @@ describe('shipsec.opencode.agent', () => { expect(runSpy).toHaveBeenCalled(); const runnerCall = runSpy.mock.calls[0][0]; - expect(runnerCall.image).toBe('ghcr.io/shipsecai/opencode:1.1.53'); + expect(runnerCall.image).toBe('ghcr.io/shipsecai/opencode:latest'); expect(runnerCall.network).toBe('host'); expect(runnerCall.env.OPENAI_API_KEY).toBe('sk-test'); }); diff --git a/worker/src/components/ai/opencode.ts b/worker/src/components/ai/opencode.ts index 40d8e724..e83fd0eb 100644 --- a/worker/src/components/ai/opencode.ts +++ b/worker/src/components/ai/opencode.ts @@ -99,7 +99,7 @@ const definition = defineComponent({ category: 'ai', runner: { kind: 'docker', - image: 'ghcr.io/shipsecai/opencode:1.1.53', + image: 'ghcr.io/shipsecai/opencode:latest', entrypoint: 'opencode', // We will override this in execution network: 'host' as const, // Required to access localhost gateway command: ['help'], diff --git a/worker/src/components/security/__tests__/amass.test.ts b/worker/src/components/security/__tests__/amass.test.ts index ac070fd9..f039acb3 100644 --- a/worker/src/components/security/__tests__/amass.test.ts +++ b/worker/src/components/security/__tests__/amass.test.ts @@ -113,7 +113,7 @@ describe.skip('amass component', () => { expect(component.runner.kind).toBe('docker'); if (component.runner.kind === 'docker') { - expect(component.runner.image).toBe('ghcr.io/shipsecai/amass:v5.0.1'); + expect(component.runner.image).toBe('ghcr.io/shipsecai/amass:latest'); expect(component.runner.entrypoint).toBe('sh'); expect(component.runner.command).toBeInstanceOf(Array); } diff --git a/worker/src/components/security/__tests__/dnsx.test.ts b/worker/src/components/security/__tests__/dnsx.test.ts index bff6b81c..cfffe6ac 100644 --- a/worker/src/components/security/__tests__/dnsx.test.ts +++ b/worker/src/components/security/__tests__/dnsx.test.ts @@ -197,7 +197,7 @@ describe.skip('dnsx component', () => { expect(component.runner.kind).toBe('docker'); if (component.runner.kind === 'docker') { - expect(component.runner.image).toBe('ghcr.io/shipsecai/dnsx:v1.2.2'); + expect(component.runner.image).toBe('ghcr.io/shipsecai/dnsx:latest'); expect(component.runner.entrypoint).toBe('sh'); } }); diff --git a/worker/src/components/security/__tests__/naabu.test.ts b/worker/src/components/security/__tests__/naabu.test.ts index 9f300910..734784c0 100644 --- a/worker/src/components/security/__tests__/naabu.test.ts +++ b/worker/src/components/security/__tests__/naabu.test.ts @@ -105,9 +105,10 @@ describe('naabu component', () => { expect(component.runner.kind).toBe('docker'); if (component.runner.kind === 'docker') { - expect(component.runner.image).toBe('ghcr.io/shipsecai/naabu:v2.3.7'); - expect(component.runner.entrypoint).toBe('sh'); - expect(component.runner.command).toBeInstanceOf(Array); + expect(component.runner.image).toBe('ghcr.io/shipsecai/naabu:latest'); + // Distroless image — no entrypoint override, uses image default + expect(component.runner.entrypoint).toBeUndefined(); + expect(component.runner.command).toEqual([]); } }); }); diff --git a/worker/src/components/security/__tests__/subfinder.test.ts b/worker/src/components/security/__tests__/subfinder.test.ts index 359c4df9..1fe38b49 100644 --- a/worker/src/components/security/__tests__/subfinder.test.ts +++ b/worker/src/components/security/__tests__/subfinder.test.ts @@ -166,7 +166,7 @@ describe.skip('subfinder component', () => { expect(component.runner.kind).toBe('docker'); if (component.runner.kind === 'docker') { - expect(component.runner.image).toBe('ghcr.io/shipsecai/subfinder:v2.12.0'); + expect(component.runner.image).toBe('ghcr.io/shipsecai/subfinder:latest'); } }); }); diff --git a/worker/src/components/security/__tests__/trufflehog.test.ts b/worker/src/components/security/__tests__/trufflehog.test.ts index 3bde4ee9..be7a5a35 100644 --- a/worker/src/components/security/__tests__/trufflehog.test.ts +++ b/worker/src/components/security/__tests__/trufflehog.test.ts @@ -29,7 +29,7 @@ describe('trufflehog component', () => { expect(component.runner.kind).toBe('docker'); if (component.runner.kind === 'docker') { - expect(component.runner.image).toBe('ghcr.io/shipsecai/trufflehog:v3.93.1'); + expect(component.runner.image).toBe('ghcr.io/shipsecai/trufflehog:latest'); } }); diff --git a/worker/src/components/security/amass.ts b/worker/src/components/security/amass.ts index f16cc32a..25bc9212 100644 --- a/worker/src/components/security/amass.ts +++ b/worker/src/components/security/amass.ts @@ -19,7 +19,7 @@ import { } from '@shipsec/component-sdk'; import { IsolatedContainerVolume } from '../../utils/isolated-volume'; -const AMASS_IMAGE = 'ghcr.io/shipsecai/amass:v5.0.1'; +const AMASS_IMAGE = 'ghcr.io/shipsecai/amass:latest'; const AMASS_TIMEOUT_SECONDS = (() => { const raw = process.env.AMASS_TIMEOUT_SECONDS; const parsed = raw ? Number.parseInt(raw, 10) : NaN; @@ -470,19 +470,14 @@ const definition = (defineComponent as any)({ runner: { kind: 'docker', image: AMASS_IMAGE, - // IMPORTANT: Use shell wrapper for PTY compatibility - // Running CLI tools directly as entrypoint can cause them to hang with PTY (pseudo-terminal) - // The shell wrapper ensures proper TTY signal handling and clean exit - // See docs/component-development.md "Docker Entrypoint Pattern" for details - entrypoint: 'sh', + // The amass image is distroless (no shell available). + // Use the image's default entrypoint directly and pass args via command. network: 'bridge', timeoutSeconds: AMASS_TIMEOUT_SECONDS, env: { HOME: '/tmp', }, - // Shell wrapper pattern: sh -c 'amass "$@"' -- [args...] - // This allows dynamic args to be appended and properly passed to amass - command: ['-c', 'amass "$@"', '--'], + command: [], }, inputs: inputSchema, outputs: outputSchema, @@ -643,9 +638,7 @@ const definition = (defineComponent as any)({ network: baseRunner.network, timeoutSeconds: baseRunner.timeoutSeconds ?? AMASS_TIMEOUT_SECONDS, env: { ...(baseRunner.env ?? {}) }, - // Preserve the shell wrapper from baseRunner (sh -c 'amass "$@"' --) - entrypoint: baseRunner.entrypoint, - // Append amass arguments to shell wrapper command + // Pass amass CLI args directly (image default entrypoint is amass) command: [...(baseRunner.command ?? []), ...amassArgs], volumes: [volume.getVolumeConfig(CONTAINER_INPUT_DIR, true)], }; diff --git a/worker/src/components/security/dnsx.ts b/worker/src/components/security/dnsx.ts index 6872ab92..55eb41d9 100644 --- a/worker/src/components/security/dnsx.ts +++ b/worker/src/components/security/dnsx.ts @@ -35,7 +35,7 @@ const recordTypeEnum = z.enum([ const outputModeEnum = z.enum(['silent', 'json']); -const DNSX_IMAGE = 'ghcr.io/shipsecai/dnsx:v1.2.2'; +const DNSX_IMAGE = 'ghcr.io/shipsecai/dnsx:latest'; const DNSX_TIMEOUT_SECONDS = 180; const INPUT_MOUNT_NAME = 'inputs'; const CONTAINER_INPUT_DIR = `/${INPUT_MOUNT_NAME}`; @@ -483,19 +483,16 @@ const definition = defineComponent({ runner: { kind: 'docker', image: DNSX_IMAGE, - // IMPORTANT: Use shell wrapper for PTY compatibility - // Running CLI tools directly as entrypoint can cause them to hang with PTY (pseudo-terminal) - // The shell wrapper ensures proper TTY signal handling and clean exit - // See docs/component-development.md "Docker Entrypoint Pattern" for details - entrypoint: 'sh', + // The dnsx image is distroless (no shell available). + // Use the image's default entrypoint directly and pass args via command. network: 'bridge', timeoutSeconds: DNSX_TIMEOUT_SECONDS, env: { - HOME: '/root', + // Image runs as nonroot — /root is not writable. + // Use /tmp so dnsx can create its config dir. + HOME: '/tmp', }, - // Shell wrapper pattern: sh -c 'dnsx "$@"' -- [args...] - // This allows dynamic args to be appended and properly passed to dnsx - command: ['-c', 'dnsx "$@"', '--'], + command: [], }, inputs: inputSchema, outputs: outputSchema, @@ -655,11 +652,7 @@ const definition = defineComponent({ network: baseRunner.network, timeoutSeconds: baseRunner.timeoutSeconds ?? DNSX_TIMEOUT_SECONDS, env: { ...(baseRunner.env ?? {}) }, - // Preserve the shell wrapper from baseRunner (sh -c 'dnsx "$@"' --) - // This is critical for PTY compatibility - do not override with 'dnsx' - entrypoint: baseRunner.entrypoint, - // Append dnsx arguments to shell wrapper command - // Resulting command: ['sh', '-c', 'dnsx "$@"', '--', ...dnsxArgs] + // Pass dnsx CLI args directly (image default entrypoint is dnsx) command: [...(baseRunner.command ?? []), ...dnsxArgs], volumes: [volume.getVolumeConfig(CONTAINER_INPUT_DIR, true)], }; diff --git a/worker/src/components/security/httpx.ts b/worker/src/components/security/httpx.ts index 30624340..ee95318e 100644 --- a/worker/src/components/security/httpx.ts +++ b/worker/src/components/security/httpx.ts @@ -210,7 +210,7 @@ const definition = defineComponent({ category: 'security', runner: { kind: 'docker', - image: 'ghcr.io/shipsecai/httpx:v1.7.4', + image: 'ghcr.io/shipsecai/httpx:latest', entrypoint: 'httpx', network: 'bridge', timeoutSeconds: dockerTimeoutSeconds, diff --git a/worker/src/components/security/naabu.ts b/worker/src/components/security/naabu.ts index b06be5f2..1d287cee 100644 --- a/worker/src/components/security/naabu.ts +++ b/worker/src/components/security/naabu.ts @@ -11,7 +11,15 @@ import { generateFindingHash, analyticsResultSchema, type AnalyticsResult, + type DockerRunnerConfig, + ContainerError, } from '@shipsec/component-sdk'; +import { IsolatedContainerVolume } from '../../utils/isolated-volume'; + +const NAABU_IMAGE = 'ghcr.io/shipsecai/naabu:latest'; +const INPUT_MOUNT_NAME = 'inputs'; +const CONTAINER_INPUT_DIR = `/${INPUT_MOUNT_NAME}`; +const TARGETS_FILE_NAME = 'targets.txt'; const inputSchema = inputs({ targets: port( @@ -181,107 +189,86 @@ const dockerTimeoutSeconds = (() => { return parsed; })(); -const definition = defineComponent({ - id: 'shipsec.naabu.scan', - label: 'Naabu Port Scan', - category: 'security', - runner: { - kind: 'docker', - image: 'ghcr.io/shipsecai/naabu:v2.3.7', - entrypoint: 'sh', - network: 'bridge', - timeoutSeconds: dockerTimeoutSeconds, - command: [ - '-c', - String.raw`set -eo pipefail +interface BuildNaabuArgsOptions { + targetFile: string; + ports?: string; + topPorts?: number; + excludePorts?: string; + rate?: number; + retries?: number; + enablePing: boolean; + interface?: string; +} -INPUT=$(cat) +/** + * Build Naabu CLI arguments in TypeScript. + * Follows the Dynamic Args Pattern from component-development.mdx + */ +const buildNaabuArgs = (options: BuildNaabuArgsOptions): string[] => { + const args: string[] = []; -TARGETS_SECTION=$(printf "%s" "$INPUT" | tr -d '\n' | sed -n 's/.*"targets":[[:space:]]*\[\([^]]*\)\].*/\1/p') + // Target list file + args.push('-list', options.targetFile); -if [ -z "$TARGETS_SECTION" ]; then - exit 0 -fi + // JSON output for structured parsing + args.push('-json'); -TARGETS=$(printf "%s" "$TARGETS_SECTION" | tr ',' '\n' | sed 's/"//g; s/^[[:space:]]*//; s/[[:space:]]*$//' | sed '/^$/d') + // Silent mode for clean output + args.push('-silent'); -if [ -z "$TARGETS" ]; then - exit 0 -fi + // Stream mode to prevent output buffering (critical for PTY) + args.push('-stream'); -extract_string() { - key="$1" - printf "%s" "$INPUT" | tr -d '\n' | grep -o "\"$key\":[[:space:]]*\"[^\"]*\"" | head -n1 | sed "s/.*\"$key\":[[:space:]]*\"\([^\"]*\)\".*/\1/" -} + // Port configuration + if (options.ports) { + args.push('-p', options.ports); + } + if (typeof options.topPorts === 'number' && options.topPorts >= 1) { + args.push('-top-ports', String(options.topPorts)); + } + if (options.excludePorts) { + args.push('-exclude-ports', options.excludePorts); + } -extract_number() { - key="$1" - printf "%s" "$INPUT" | tr -d '\n' | grep -o "\"$key\":[[:space:]]*[0-9][0-9]*" | head -n1 | sed 's/[^0-9]//g' -} + // Rate and retries + if (typeof options.rate === 'number' && options.rate >= 1) { + args.push('-rate', String(options.rate)); + } + if (typeof options.retries === 'number') { + args.push('-retries', String(options.retries)); + } -extract_bool() { - key="$1" - default="$2" - value=$(printf "%s" "$INPUT" | tr -d '\n' | grep -o "\"$key\":[[:space:]]*\\(true\\|false\\)" | head -n1 | sed 's/.*://; s/[[:space:]]//g') - if [ -z "$value" ]; then - echo "$default" - elif [ "$value" = "true" ]; then - echo "true" - else - echo "false" - fi -} + // Ping probes + if (options.enablePing) { + args.push('-ping'); + } -PORTS=$(extract_string "ports" | tr -d ' ') -EXCLUDE_PORTS=$(extract_string "excludePorts" | tr -d ' ') -INTERFACE=$(extract_string "interface") -TOP_PORTS=$(extract_number "topPorts") -RATE=$(extract_number "rate") -RETRIES=$(extract_number "retries") -ENABLE_PING=$(extract_bool "enablePing" "false") - -LIST_FILE=$(mktemp) -trap 'rm -f "$LIST_FILE"' EXIT - -printf "%s\n" "$TARGETS" > "$LIST_FILE" - -CMD="naabu -list $LIST_FILE -json -silent" - -if [ -n "$PORTS" ]; then - CMD="$CMD -p $PORTS" -fi -if [ -n "$TOP_PORTS" ]; then - CMD="$CMD -top-ports $TOP_PORTS" -fi -if [ -n "$EXCLUDE_PORTS" ]; then - CMD="$CMD -exclude-ports $EXCLUDE_PORTS" -fi -if [ -n "$RATE" ]; then - CMD="$CMD -rate $RATE" -fi -if [ -n "$RETRIES" ]; then - CMD="$CMD -retries $RETRIES" -fi -if [ "$ENABLE_PING" = "true" ]; then - CMD="$CMD -ping" -fi -if [ -n "$INTERFACE" ]; then - CMD="$CMD -interface $INTERFACE" -fi - -# CRITICAL: Enable stream mode to prevent output buffering -# ProjectDiscovery tools buffer output by default, causing containers to appear hung -# -stream flag: Disables buffering + forces immediate output flush -# Without this, naabu buffers up to 8KB before flushing, causing timeout failures -# See docs/component-development.md "Output Buffering" section for details -CMD="$CMD -stream" - -eval "$CMD" -`, - ], + // Network interface + if (options.interface) { + args.push('-interface', options.interface); + } + + return args; +}; + +const definition = defineComponent({ + id: 'shipsec.naabu.scan', + label: 'Naabu Port Scan', + category: 'security', + runner: { + kind: 'docker', + image: NAABU_IMAGE, + // The naabu image is distroless (no shell available). + // Use the image's default entrypoint directly and pass args via command. + network: 'bridge', + timeoutSeconds: dockerTimeoutSeconds, env: { - HOME: '/root', + // Image runs as nonroot — /root is not writable. + // Use /tmp so naabu can create its config dir. + HOME: '/tmp', }, + command: [], + stdinJson: false, }, inputs: inputSchema, outputs: outputSchema, @@ -316,92 +303,137 @@ eval "$CMD" description: 'Fast TCP port scanner (Naabu).', }, async execute({ inputs, params }, context) { - const trimmedPorts = params.ports?.trim(); - const trimmedExclude = params.excludePorts?.trim(); - const trimmedInterface = params.interface?.trim(); + const parsedParams = parameterSchema.parse(params); + const trimmedPorts = parsedParams.ports?.trim(); + const trimmedExclude = parsedParams.excludePorts?.trim(); + const trimmedInterface = parsedParams.interface?.trim(); - const runnerParams = { - ...params, - targets: inputs.targets, + const effectiveOptions = { ports: trimmedPorts && trimmedPorts.length > 0 ? trimmedPorts : undefined, + topPorts: parsedParams.topPorts, excludePorts: trimmedExclude && trimmedExclude.length > 0 ? trimmedExclude : undefined, + rate: parsedParams.rate, + retries: parsedParams.retries ?? 1, + enablePing: parsedParams.enablePing ?? false, interface: trimmedInterface && trimmedInterface.length > 0 ? trimmedInterface : undefined, }; context.logger.info( - `[Naabu] Scanning ${runnerParams.targets.length} target(s) with options: ports=${runnerParams.ports ?? 'default'}, topPorts=${runnerParams.topPorts ?? 'default'}, excludePorts=${runnerParams.excludePorts ?? 'none'}, rate=${runnerParams.rate ?? 'auto'}, retries=${runnerParams.retries}, enablePing=${runnerParams.enablePing ?? false}`, + `[Naabu] Scanning ${inputs.targets.length} target(s) with options: ports=${effectiveOptions.ports ?? 'default'}, topPorts=${effectiveOptions.topPorts ?? 'default'}, rate=${effectiveOptions.rate ?? 'auto'}, retries=${effectiveOptions.retries}`, ); context.emitProgress({ message: 'Launching Naabu port scan…', level: 'info', - data: { targets: runnerParams.targets.slice(0, 5) }, + data: { targets: inputs.targets.slice(0, 5) }, }); - const result = await runComponentWithRunner( - this.runner, - async () => ({}) as Output, - runnerParams, - context, - ); + const tenantId = (context as any).tenantId ?? 'default-tenant'; + const volume = new IsolatedContainerVolume(tenantId, context.runId); + const baseRunner = definition.runner; - if (typeof result === 'string') { - const findings = parseNaabuOutput(result); - - // Build analytics-ready results with scanner metadata - const analyticsResults: AnalyticsResult[] = findings.map((finding) => ({ - scanner: 'naabu', - finding_hash: generateFindingHash('open-port', finding.host, String(finding.port)), - severity: 'info' as const, - asset_key: `${finding.host}:${finding.port}`, - host: finding.host, - port: finding.port, - protocol: finding.protocol, - ip: finding.ip, - })); - - const output: Output = { - findings, - results: analyticsResults, - rawOutput: result, - targetCount: runnerParams.targets.length, - openPortCount: findings.length, - options: { - ports: runnerParams.ports ?? null, - topPorts: runnerParams.topPorts ?? null, - excludePorts: runnerParams.excludePorts ?? null, - rate: runnerParams.rate ?? null, - retries: runnerParams.retries ?? 1, - enablePing: runnerParams.enablePing ?? false, - interface: runnerParams.interface ?? null, - }, - }; - return outputSchema.parse(output); + if (baseRunner.kind !== 'docker') { + throw new ContainerError('Naabu runner is expected to be docker-based.', { + details: { expectedKind: 'docker', actualKind: baseRunner.kind }, + }); } - if (result && typeof result === 'object') { - const parsed = outputSchema.safeParse(result); - if (parsed.success) { - return parsed.data; + let rawOutput: string; + try { + // Write targets to input file + const inputFiles: Record = { + [TARGETS_FILE_NAME]: inputs.targets.join('\n'), + }; + + const volumeName = await volume.initialize(inputFiles); + context.logger.info(`[Naabu] Created isolated volume: ${volumeName}`); + + // Build naabu CLI arguments in TypeScript + const naabuArgs = buildNaabuArgs({ + targetFile: `${CONTAINER_INPUT_DIR}/${TARGETS_FILE_NAME}`, + ...effectiveOptions, + }); + + const runnerConfig: DockerRunnerConfig = { + kind: 'docker', + image: baseRunner.image, + network: baseRunner.network, + timeoutSeconds: baseRunner.timeoutSeconds ?? dockerTimeoutSeconds, + env: { ...(baseRunner.env ?? {}) }, + stdinJson: false, + // Pass naabu CLI args directly (image default entrypoint is naabu) + command: [...(baseRunner.command ?? []), ...naabuArgs], + volumes: [volume.getVolumeConfig(CONTAINER_INPUT_DIR, true)], + }; + + try { + const result = await runComponentWithRunner( + runnerConfig, + async () => ({}) as Output, + {}, + context, + ); + rawOutput = typeof result === 'string' ? result : ''; + } catch (error) { + // Naabu can exit non-zero when some probes fail, + // but may still have produced valid output. Preserve partial results. + if (error instanceof ContainerError) { + const details = (error as any).details as Record | undefined; + const capturedStdout = details?.stdout; + if (typeof capturedStdout === 'string' && capturedStdout.trim().length > 0) { + context.logger.warn( + `[Naabu] Container exited non-zero but produced output. Preserving partial results.`, + ); + rawOutput = capturedStdout; + } else { + throw error; + } + } else { + throw error; + } } + } finally { + await volume.cleanup(); + context.logger.info('[Naabu] Cleaned up isolated volume'); } - return { - findings: [], - results: [], - rawOutput: typeof result === 'string' ? result : '', - targetCount: runnerParams.targets.length, - openPortCount: 0, + // Parse naabu JSON output + const findings = parseNaabuOutput(rawOutput); + + // Build analytics-ready results + const analyticsResults: AnalyticsResult[] = findings.map((finding) => ({ + scanner: 'naabu', + finding_hash: generateFindingHash('open-port', finding.host, String(finding.port)), + severity: 'info' as const, + asset_key: `${finding.host}:${finding.port}`, + host: finding.host, + port: finding.port, + protocol: finding.protocol, + ip: finding.ip, + })); + + context.logger.info( + `[Naabu] Found ${findings.length} open ports across ${inputs.targets.length} targets`, + ); + + const output: Output = { + findings, + results: analyticsResults, + rawOutput, + targetCount: inputs.targets.length, + openPortCount: findings.length, options: { - ports: runnerParams.ports ?? null, - topPorts: runnerParams.topPorts ?? null, - excludePorts: runnerParams.excludePorts ?? null, - rate: runnerParams.rate ?? null, - retries: runnerParams.retries ?? 1, - enablePing: runnerParams.enablePing ?? false, - interface: runnerParams.interface ?? null, + ports: effectiveOptions.ports ?? null, + topPorts: effectiveOptions.topPorts ?? null, + excludePorts: effectiveOptions.excludePorts ?? null, + rate: effectiveOptions.rate ?? null, + retries: effectiveOptions.retries, + enablePing: effectiveOptions.enablePing, + interface: effectiveOptions.interface ?? null, }, }; + + return outputSchema.parse(output); }, }); diff --git a/worker/src/components/security/notify.ts b/worker/src/components/security/notify.ts index 917c3d96..69f3953a 100644 --- a/worker/src/components/security/notify.ts +++ b/worker/src/components/security/notify.ts @@ -4,13 +4,23 @@ import { ComponentRetryPolicy, runComponentWithRunner, ConfigurationError, + ContainerError, defineComponent, inputs, outputs, parameters, port, param, + type DockerRunnerConfig, } from '@shipsec/component-sdk'; +import { IsolatedContainerVolume } from '../../utils/isolated-volume'; + +const NOTIFY_IMAGE = 'ghcr.io/shipsecai/notify:latest'; +const INPUT_MOUNT_NAME = 'inputs'; +const CONTAINER_INPUT_DIR = `/${INPUT_MOUNT_NAME}`; +const MESSAGES_FILE_NAME = 'messages.txt'; +const PROVIDER_CONFIG_FILE_NAME = 'provider-config.yaml'; +const NOTIFY_CONFIG_FILE_NAME = 'notify-config.yaml'; const inputSchema = inputs({ messages: port( @@ -207,91 +217,100 @@ const dockerTimeoutSeconds = (() => { return parsed; })(); +interface BuildNotifyArgsOptions { + messagesFile: string; + providerConfigFile: string; + notifyConfigFile?: string; + providerIds?: string[]; + recipientIds?: string[]; + messageFormat?: string; + bulk: boolean; + silent: boolean; + verbose: boolean; + charLimit?: number; + delaySeconds?: number; + rateLimit?: number; + proxy?: string; +} + +/** + * Build Notify CLI arguments in TypeScript. + * Follows the Dynamic Args Pattern from component-development.mdx + */ +const buildNotifyArgs = (options: BuildNotifyArgsOptions): string[] => { + const args: string[] = []; + + // Input file (messages) — uses -i flag instead of stdin piping + args.push('-i', options.messagesFile); + + // Provider config (required) + args.push('-provider-config', options.providerConfigFile); + + // Optional notify config + if (options.notifyConfigFile) { + args.push('-config', options.notifyConfigFile); + } + + // Boolean flags + if (options.bulk) { + args.push('-bulk'); + } + + // Verbose and silent are mutually exclusive — verbose takes precedence + if (options.verbose) { + args.push('-verbose'); + } else if (options.silent) { + args.push('-silent'); + } + + // Numeric options + if (options.charLimit != null) { + args.push('-char-limit', String(options.charLimit)); + } + if (options.delaySeconds != null) { + args.push('-delay', String(options.delaySeconds)); + } + if (options.rateLimit != null) { + args.push('-rate-limit', String(options.rateLimit)); + } + + // String options + if (options.proxy) { + args.push('-proxy', options.proxy); + } + if (options.messageFormat) { + args.push('-msg-format', options.messageFormat); + } + + // Provider and recipient filtering + if (options.providerIds && options.providerIds.length > 0) { + args.push('-provider', options.providerIds.join(',')); + } + if (options.recipientIds && options.recipientIds.length > 0) { + args.push('-id', options.recipientIds.join(',')); + } + + return args; +}; + const definition = defineComponent({ id: 'shipsec.notify.dispatch', label: 'ProjectDiscovery Notify', category: 'security', runner: { kind: 'docker', - image: 'ghcr.io/shipsecai/notify:v1.0.7', - entrypoint: 'sh', + image: NOTIFY_IMAGE, + // The notify image is distroless (no shell available). + // Use the image's default entrypoint directly and pass args via command. network: 'bridge', timeoutSeconds: dockerTimeoutSeconds, env: { - HOME: '/root', + // Image runs as nonroot — /root is not writable. + // Use /tmp so notify can create its config dir. + HOME: '/tmp', }, - command: [ - '-c', - String.raw`set -euo pipefail - -INPUT=$(cat) - -# Extract fields from JSON using jq if available, fallback to sed -if command -v jq >/dev/null 2>&1; then - MESSAGES=$(printf "%s" "$INPUT" | jq -r '.messages // ""') - PROVIDER_CONFIG=$(printf "%s" "$INPUT" | jq -r '.providerConfig // ""') - NOTIFY_CONFIG=$(printf "%s" "$INPUT" | jq -r '.notifyConfig // ""') -else - MESSAGES=$(printf "%s" "$INPUT" | sed -n 's/.*"messages":"\([^"]*\)".*/\1/p') - PROVIDER_CONFIG=$(printf "%s" "$INPUT" | sed -n 's/.*"providerConfig":"\([^"]*\)".*/\1/p') - NOTIFY_CONFIG=$(printf "%s" "$INPUT" | sed -n 's/.*"notifyConfig":"\([^"]*\)".*/\1/p') -fi - -# Validate required fields -if [ -z "$PROVIDER_CONFIG" ]; then - echo "Provider configuration is required" >&2 - exit 1 -fi - -# Create temporary files for configs and messages -PROVIDER_CONFIG_FILE=$(mktemp) -MESSAGE_FILE=$(mktemp) -NOTIFY_CONFIG_FILE="" - -if [ -n "$NOTIFY_CONFIG" ]; then - NOTIFY_CONFIG_FILE=$(mktemp) -fi - -trap 'rm -f "$PROVIDER_CONFIG_FILE" "$MESSAGE_FILE" "$NOTIFY_CONFIG_FILE"' EXIT - -# Write provider config to temp file -printf "%s" "$PROVIDER_CONFIG" | base64 -d > "$PROVIDER_CONFIG_FILE" - -# Write notify config to temp file if provided -if [ -n "$NOTIFY_CONFIG" ]; then - printf "%s" "$NOTIFY_CONFIG" | base64 -d > "$NOTIFY_CONFIG_FILE" -fi - -# Write messages to temp file -printf "%s" "$MESSAGES" | base64 -d > "$MESSAGE_FILE" - -# Build command from args array -if command -v jq >/dev/null 2>&1; then - ARGS=$(printf "%s" "$INPUT" | jq -r '.args[]' 2>/dev/null || echo "") -else - ARGS_JSON=$(printf "%s" "$INPUT" | sed -n 's/.*"args":\[\([^]]*\)\].*/\1/p') - ARGS=$(printf "%s" "$ARGS_JSON" | tr ',' '\n' | sed 's/^"//; s/"$//' | grep -v '^$') -fi - -# Build command with provider config -set -- notify -provider-config "$PROVIDER_CONFIG_FILE" - -# Add notify config if provided -if [ -n "$NOTIFY_CONFIG_FILE" ]; then - set -- "$@" -config "$NOTIFY_CONFIG_FILE" -fi - -# Add arguments from TypeScript -while IFS= read -r arg; do - [ -n "$arg" ] && set -- "$@" "$arg" -done << EOF -$ARGS -EOF - -# Execute notify -cat "$MESSAGE_FILE" | "$@" -`, - ], + command: [], + stdinJson: false, }, inputs: inputSchema, outputs: outputSchema, @@ -338,7 +357,8 @@ cat "$MESSAGE_FILE" | "$@" } const { messages, recipientIds, providerConfig, notifyConfig } = inputs; - const { providerIds } = params; + const parsedParams = parameterSchema.parse(params); + const { providerIds } = parsedParams; context.logger.info( `[Notify] Sending ${messages.length} message(s) via ${providerIds && providerIds.length > 0 ? providerIds.join(', ') : 'all configured providers'}`, @@ -347,67 +367,76 @@ cat "$MESSAGE_FILE" | "$@" `Sending ${messages.length} notification${messages.length > 1 ? 's' : ''}`, ); - // Build notify command arguments (all logic in TypeScript!) - // Note: Config file paths will be added by bash script using temp files - const args: string[] = []; + const tenantId = (context as any).tenantId ?? 'default-tenant'; + const volume = new IsolatedContainerVolume(tenantId, context.runId); + const baseRunner = definition.runner; - // Boolean flags - if (params.bulk ?? true) { - args.push('-bulk'); + if (baseRunner.kind !== 'docker') { + throw new ContainerError('Notify runner is expected to be docker-based.', { + details: { expectedKind: 'docker', actualKind: baseRunner.kind }, + }); } - // Verbose and silent are mutually exclusive - verbose takes precedence - if (params.verbose ?? false) { - args.push('-verbose'); - } else if (params.silent ?? true) { - args.push('-silent'); - } - - // Numeric options - if (params.charLimit != null) { - args.push('-char-limit', String(params.charLimit)); - } - if (params.delaySeconds != null) { - args.push('-delay', String(params.delaySeconds)); - } - if (params.rateLimit != null) { - args.push('-rate-limit', String(params.rateLimit)); - } - - // String options - if (params.proxy) { - args.push('-proxy', params.proxy); - } - if (params.messageFormat) { - args.push('-msg-format', params.messageFormat); - } + let rawOutput: string; + try { + // Prepare input files for the volume + const inputFiles: Record = { + [MESSAGES_FILE_NAME]: messages.join('\n'), + [PROVIDER_CONFIG_FILE_NAME]: providerConfig, + }; + + // Add notify config file if provided + if (notifyConfig && notifyConfig.trim().length > 0) { + inputFiles[NOTIFY_CONFIG_FILE_NAME] = notifyConfig; + } + + const volumeName = await volume.initialize(inputFiles); + context.logger.info(`[Notify] Created isolated volume: ${volumeName}`); + + // Build notify CLI arguments in TypeScript + const notifyArgs = buildNotifyArgs({ + messagesFile: `${CONTAINER_INPUT_DIR}/${MESSAGES_FILE_NAME}`, + providerConfigFile: `${CONTAINER_INPUT_DIR}/${PROVIDER_CONFIG_FILE_NAME}`, + notifyConfigFile: + notifyConfig && notifyConfig.trim().length > 0 + ? `${CONTAINER_INPUT_DIR}/${NOTIFY_CONFIG_FILE_NAME}` + : undefined, + providerIds, + recipientIds, + messageFormat: parsedParams.messageFormat, + bulk: parsedParams.bulk ?? true, + silent: parsedParams.silent ?? true, + verbose: parsedParams.verbose ?? false, + charLimit: parsedParams.charLimit, + delaySeconds: parsedParams.delaySeconds, + rateLimit: parsedParams.rateLimit, + proxy: parsedParams.proxy, + }); + + const runnerConfig: DockerRunnerConfig = { + kind: 'docker', + image: baseRunner.image, + network: baseRunner.network, + timeoutSeconds: baseRunner.timeoutSeconds ?? dockerTimeoutSeconds, + env: { ...(baseRunner.env ?? {}) }, + stdinJson: false, + // Pass notify CLI args directly (image default entrypoint is notify) + command: [...(baseRunner.command ?? []), ...notifyArgs], + volumes: [volume.getVolumeConfig(CONTAINER_INPUT_DIR, true)], + }; + + const result = await runComponentWithRunner, string>( + runnerConfig, + async () => '', + {}, + context, + ); - // Provider and recipient filtering - if (providerIds && providerIds.length > 0) { - args.push('-provider', providerIds.join(',')); + rawOutput = typeof result === 'string' ? result.trim() : ''; + } finally { + await volume.cleanup(); + context.logger.info('[Notify] Cleaned up isolated volume'); } - if (recipientIds && recipientIds.length > 0) { - args.push('-id', recipientIds.join(',')); - } - - // Build docker payload (minimal, just data for bash) - const dockerPayload = { - messages: Buffer.from(messages.join('\n'), 'utf8').toString('base64'), - providerConfig: Buffer.from(providerConfig, 'utf8').toString('base64'), - notifyConfig: notifyConfig ? Buffer.from(notifyConfig, 'utf8').toString('base64') : '', - args, // TypeScript-built command arguments! - }; - - // Execute notify via Docker - const rawResult = await runComponentWithRunner( - this.runner, - async () => '', - dockerPayload, - context, - ); - - // Return raw output - const rawOutput = typeof rawResult === 'string' ? rawResult.trim() : ''; context.logger.info(`[Notify] Notifications sent successfully`); diff --git a/worker/src/components/security/prowler-scan.ts b/worker/src/components/security/prowler-scan.ts index f41309ed..78077771 100644 --- a/worker/src/components/security/prowler-scan.ts +++ b/worker/src/components/security/prowler-scan.ts @@ -407,7 +407,7 @@ const definition = defineComponent({ retryPolicy: prowlerRetryPolicy, runner: { kind: 'docker', - image: 'ghcr.io/shipsecai/prowler:5.14.2', + image: 'ghcr.io/shipsecai/prowler:latest', platform: 'linux/amd64', command: [], // Placeholder - actual command built dynamically in execute() }, @@ -575,7 +575,7 @@ const definition = defineComponent({ // Prepare a one-off runner with dynamic command and volume const dockerRunner: DockerRunnerConfig = { kind: 'docker', - image: 'ghcr.io/shipsecai/prowler:5.14.2', + image: 'ghcr.io/shipsecai/prowler:latest', platform: 'linux/amd64', network: 'bridge', timeoutSeconds: 900, diff --git a/worker/src/components/security/subfinder.ts b/worker/src/components/security/subfinder.ts index 6e094757..e58c26aa 100644 --- a/worker/src/components/security/subfinder.ts +++ b/worker/src/components/security/subfinder.ts @@ -17,7 +17,7 @@ import { } from '@shipsec/component-sdk'; import { IsolatedContainerVolume } from '../../utils/isolated-volume'; -const SUBFINDER_IMAGE = 'ghcr.io/shipsecai/subfinder:v2.12.0'; +const SUBFINDER_IMAGE = 'ghcr.io/shipsecai/subfinder:latest'; const SUBFINDER_TIMEOUT_SECONDS = 1800; // 30 minutes const INPUT_MOUNT_NAME = 'inputs'; const CONTAINER_INPUT_DIR = `/${INPUT_MOUNT_NAME}`; @@ -271,19 +271,16 @@ const definition = defineComponent({ runner: { kind: 'docker', image: SUBFINDER_IMAGE, - // IMPORTANT: Use shell wrapper for PTY compatibility - // Running CLI tools directly as entrypoint can cause them to hang with PTY (pseudo-terminal) - // The shell wrapper ensures proper TTY signal handling and clean exit - // See docs/component-development.md "Docker Entrypoint Pattern" for details - entrypoint: 'sh', + // The subfinder image is distroless (no shell available). + // Use the image's default entrypoint directly and pass args via command. network: 'bridge', timeoutSeconds: SUBFINDER_TIMEOUT_SECONDS, env: { - HOME: '/root', + // Image runs as nonroot — /root is not writable. + // Use /tmp so subfinder can create its config dir. + HOME: '/tmp', }, - // Shell wrapper pattern: sh -c 'subfinder "$@"' -- [args...] - // This allows dynamic args to be appended and properly passed to subfinder - command: ['-c', 'subfinder "$@"', '--'], + command: [], }, inputs: inputSchema, outputs: outputSchema, @@ -434,9 +431,7 @@ const definition = defineComponent({ network: baseRunner.network, timeoutSeconds: baseRunner.timeoutSeconds ?? SUBFINDER_TIMEOUT_SECONDS, env: { ...(baseRunner.env ?? {}) }, - // Preserve the shell wrapper from baseRunner (sh -c 'subfinder "$@"' --) - entrypoint: baseRunner.entrypoint, - // Append subfinder arguments to shell wrapper command + // Pass subfinder CLI args directly (image default entrypoint is subfinder) command: [...(baseRunner.command ?? []), ...subfinderArgs], volumes: [volume.getVolumeConfig(CONTAINER_INPUT_DIR, true)], }; diff --git a/worker/src/components/security/trufflehog.ts b/worker/src/components/security/trufflehog.ts index e9fa67c9..06a768bb 100644 --- a/worker/src/components/security/trufflehog.ts +++ b/worker/src/components/security/trufflehog.ts @@ -313,7 +313,7 @@ const definition = defineComponent({ category: 'security', runner: { kind: 'docker', - image: 'ghcr.io/shipsecai/trufflehog:v3.93.1', + image: 'ghcr.io/shipsecai/trufflehog:latest', entrypoint: 'trufflehog', network: 'bridge', command: [], // Will be built dynamically in execute