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