diff --git a/frontend/src/pages/__tests__/ActionCenterPage.test.tsx b/frontend/src/pages/__tests__/ActionCenterPage.test.tsx
index 9a151f67..edc3f30d 100644
--- a/frontend/src/pages/__tests__/ActionCenterPage.test.tsx
+++ b/frontend/src/pages/__tests__/ActionCenterPage.test.tsx
@@ -148,11 +148,14 @@ describe('ActionCenterPage', () => {
]),
);
- it('renders page heading', () => {
+ it('omits the redundant page heading supplied by the app top bar', () => {
setupStore();
renderPage();
- expect(screen.getByRole('heading', { level: 2, name: /Action Center/i })).toBeInTheDocument();
+ expect(
+ screen.queryByRole('heading', { level: 2, name: /^Action Center$/ }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByPlaceholderText(/Filter by title, node, or run ID/i)).toBeInTheDocument();
});
it('renders loading skeletons when isLoading is true and no data', () => {
diff --git a/frontend/src/pages/__tests__/ArtifactLibrary.test.tsx b/frontend/src/pages/__tests__/ArtifactLibrary.test.tsx
index 156c45eb..5e80d0cf 100644
--- a/frontend/src/pages/__tests__/ArtifactLibrary.test.tsx
+++ b/frontend/src/pages/__tests__/ArtifactLibrary.test.tsx
@@ -198,10 +198,13 @@ describe('ArtifactLibrary', () => {
expect(screen.getByPlaceholderText('Filter by name...')).toBeInTheDocument();
});
- it('renders page heading', () => {
+ it('omits the redundant page heading supplied by the app top bar', () => {
renderPage();
- expect(screen.getByRole('heading', { level: 2, name: /Artifacts/i })).toBeInTheDocument();
+ expect(
+ screen.queryByRole('heading', { level: 2, name: /^Artifacts$/ }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Filter by name...')).toBeInTheDocument();
});
it('renders loading skeletons when isLoading is true', () => {
diff --git a/frontend/src/pages/__tests__/DashboardPage.test.tsx b/frontend/src/pages/__tests__/DashboardPage.test.tsx
index 843bf1f5..4f940f6b 100644
--- a/frontend/src/pages/__tests__/DashboardPage.test.tsx
+++ b/frontend/src/pages/__tests__/DashboardPage.test.tsx
@@ -131,10 +131,13 @@ describe('DashboardPage', () => {
// Loading
describe('loading state', () => {
- it('renders heading during loading', () => {
+ it('omits the redundant page heading supplied by the app top bar during loading', () => {
setup({ isLoading: true });
renderPage();
- expect(screen.getByRole('heading', { name: 'Dashboard', level: 2 })).toBeInTheDocument();
+ expect(
+ screen.queryByRole('heading', { name: 'Dashboard', level: 2 }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText('Workflows')).toBeInTheDocument();
});
it('renders skeleton placeholders when isLoading', () => {
diff --git a/frontend/src/pages/__tests__/FindingsPage.test.tsx b/frontend/src/pages/__tests__/FindingsPage.test.tsx
index 6fbcd63e..7dbd84c7 100644
--- a/frontend/src/pages/__tests__/FindingsPage.test.tsx
+++ b/frontend/src/pages/__tests__/FindingsPage.test.tsx
@@ -4,6 +4,7 @@ import { createSelectMock } from '@/test/mocks/radix-select';
import { createAuthStoreMock } from '@/test/mocks/auth-store';
import { renderWithProviders } from '@/test/render-with-providers';
import type { FindingsResponse, FindingItem } from '@/services/api/findings';
+import type { FindingsQueryParams } from '@/services/api';
// --- Mock select components (passthrough for test rendering) ---
mock.module('@/components/ui/select', createSelectMock);
@@ -18,14 +19,18 @@ const mockQueryState: {
isLoading: false,
error: null,
};
+const mockFindingsQueryParams: FindingsQueryParams[] = [];
mock.module('@/hooks/queries/useFindingsQueries', () => ({
- useFindingsQuery: () => ({
- data: mockQueryState.data,
- isLoading: mockQueryState.isLoading,
- error: mockQueryState.error,
- refetch: mock(),
- }),
+ useFindingsQuery: (params: FindingsQueryParams) => {
+ mockFindingsQueryParams.push(params);
+ return {
+ data: mockQueryState.data,
+ isLoading: mockQueryState.isLoading,
+ error: mockQueryState.error,
+ refetch: mock(),
+ };
+ },
}));
// --- Auth store ---
@@ -77,9 +82,11 @@ const setupStore = (overrides: MockQueryOverrides = {}) => {
mockQueryState.data = overrides.data ?? undefined;
mockQueryState.isLoading = overrides.isLoading ?? false;
mockQueryState.error = overrides.error ?? null;
+ mockFindingsQueryParams.length = 0;
};
-const renderPage = () => renderWithProviders(
);
+const renderPage = (initialPath = '/findings') =>
+ renderWithProviders(
, { initialEntries: [initialPath] });
// --- Tests ---
describe('FindingsPage', () => {
@@ -92,10 +99,12 @@ describe('FindingsPage', () => {
cleanup();
});
- it('renders page heading', () => {
+ it('omits the redundant page heading supplied by the app top bar', () => {
setupStore();
renderPage();
- expect(screen.getByRole('heading', { level: 2, name: /Findings/i })).toBeInTheDocument();
+
+ expect(screen.queryByRole('heading', { level: 2, name: /^Findings$/ })).not.toBeInTheDocument();
+ expect(screen.getByPlaceholderText(/Search findings by name/i)).toBeInTheDocument();
});
it('renders loading skeletons when isLoading is true and no data', () => {
@@ -182,4 +191,14 @@ describe('FindingsPage', () => {
expect(screen.getByRole('columnheader', { name: 'Workflow' })).toBeInTheDocument();
expect(screen.getByText('Run ID')).toBeInTheDocument();
});
+
+ it('uses the backend maximum page size in Kanban view', () => {
+ setupStore({ data: POPULATED_RESPONSE });
+ renderPage('/findings?view=kanban');
+
+ const latestParams = mockFindingsQueryParams[mockFindingsQueryParams.length - 1];
+ expect(screen.getByRole('region', { name: /Kanban board/i })).toBeInTheDocument();
+ expect(latestParams?.page).toBe(1);
+ expect(latestParams?.pageSize).toBe(100);
+ });
});
diff --git a/frontend/src/pages/__tests__/IntegrationsManager.test.tsx b/frontend/src/pages/__tests__/IntegrationsManager.test.tsx
index a14f1545..5ffd7f8e 100644
--- a/frontend/src/pages/__tests__/IntegrationsManager.test.tsx
+++ b/frontend/src/pages/__tests__/IntegrationsManager.test.tsx
@@ -202,10 +202,15 @@ describe('IntegrationsManager', () => {
expect(screen.getByText('Available providers')).toBeInTheDocument();
});
- it('renders page heading', () => {
+ it('omits the redundant page heading supplied by the app top bar', () => {
renderPage();
- expect(screen.getByRole('heading', { level: 2, name: /^Connections$/ })).toBeInTheDocument();
+ expect(
+ screen.queryByRole('heading', { level: 2, name: /^Connections$/ }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('heading', { level: 2, name: /Active connections/i }),
+ ).toBeInTheDocument();
});
it('renders section headings', () => {
diff --git a/frontend/src/pages/__tests__/McpLibraryPage.test.tsx b/frontend/src/pages/__tests__/McpLibraryPage.test.tsx
index 0fe8cd5a..274781a3 100644
--- a/frontend/src/pages/__tests__/McpLibraryPage.test.tsx
+++ b/frontend/src/pages/__tests__/McpLibraryPage.test.tsx
@@ -255,10 +255,13 @@ describe('McpLibraryPage', () => {
expect(screen.getByText('Add Server')).toBeInTheDocument();
});
- it('renders page heading', () => {
+ it('omits the redundant page heading supplied by the app top bar', () => {
renderPage();
- expect(screen.getByRole('heading', { level: 2, name: /MCP Library/i })).toBeInTheDocument();
+ expect(
+ screen.queryByRole('heading', { level: 2, name: /^MCP Library$/ }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Filter by server name')).toBeInTheDocument();
});
it('renders loading skeletons when isLoading is true and no servers', () => {
diff --git a/frontend/src/pages/__tests__/SchedulesPage.test.tsx b/frontend/src/pages/__tests__/SchedulesPage.test.tsx
index d9c21e62..43910cc9 100644
--- a/frontend/src/pages/__tests__/SchedulesPage.test.tsx
+++ b/frontend/src/pages/__tests__/SchedulesPage.test.tsx
@@ -166,11 +166,14 @@ describe('SchedulesPage', () => {
cleanup();
});
- it('renders page heading', () => {
+ it('omits the redundant page heading supplied by the app top bar', () => {
setupStore();
renderPage();
- expect(screen.getByRole('heading', { level: 2, name: /Schedules/i })).toBeInTheDocument();
+ expect(
+ screen.queryByRole('heading', { level: 2, name: /^Schedules$/ }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /New schedule/i })).toBeInTheDocument();
});
it('renders loading skeletons when isLoading is true and no data', () => {
diff --git a/frontend/src/pages/__tests__/TemplateLibraryPage.test.tsx b/frontend/src/pages/__tests__/TemplateLibraryPage.test.tsx
index cfe0a4d4..05ba3d10 100644
--- a/frontend/src/pages/__tests__/TemplateLibraryPage.test.tsx
+++ b/frontend/src/pages/__tests__/TemplateLibraryPage.test.tsx
@@ -246,10 +246,13 @@ describe('TemplateLibraryPage', () => {
expect(screen.getByPlaceholderText('Filter by template name')).toBeInTheDocument();
});
- it('renders page heading', () => {
+ it('omits the redundant page heading supplied by the app top bar', () => {
renderPage();
- expect(screen.getByRole('heading', { level: 2, name: /Templates/i })).toBeInTheDocument();
+ expect(
+ screen.queryByRole('heading', { level: 2, name: /^Templates$/ }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Filter by template name')).toBeInTheDocument();
});
it('renders loading skeletons when isLoading is true', () => {
diff --git a/frontend/src/pages/__tests__/WebhooksPage.test.tsx b/frontend/src/pages/__tests__/WebhooksPage.test.tsx
index 4433223f..f3a5237a 100644
--- a/frontend/src/pages/__tests__/WebhooksPage.test.tsx
+++ b/frontend/src/pages/__tests__/WebhooksPage.test.tsx
@@ -197,11 +197,12 @@ describe('WebhooksPage', () => {
]),
);
- it('renders page heading', () => {
+ it('omits the redundant page heading supplied by the app top bar', () => {
setupStore();
renderPage();
- expect(screen.getByRole('heading', { level: 2, name: /Webhooks/i })).toBeInTheDocument();
+ expect(screen.queryByRole('heading', { level: 2, name: /^Webhooks$/ })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /New webhook/i })).toBeInTheDocument();
});
it('renders loading skeletons when isLoading is true and no data', () => {
diff --git a/frontend/src/pages/api-keys-manager/ApiKeysTable.tsx b/frontend/src/pages/api-keys-manager/ApiKeysTable.tsx
index fc4100da..f6ace262 100644
--- a/frontend/src/pages/api-keys-manager/ApiKeysTable.tsx
+++ b/frontend/src/pages/api-keys-manager/ApiKeysTable.tsx
@@ -134,7 +134,6 @@ export function ApiKeysTable({
return (
<>
Stored secrets
diff --git a/packages/component-sdk/src/__tests__/har-builder.test.ts b/packages/component-sdk/src/__tests__/har-builder.test.ts
new file mode 100644
index 00000000..55f4c320
--- /dev/null
+++ b/packages/component-sdk/src/__tests__/har-builder.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'bun:test';
+
+import { buildHarResponse } from '../http/har-builder';
+import type { HttpResponseLike } from '../http/types';
+
+describe('buildHarResponse', () => {
+ it('does not wait for clone stream cancellation after truncating response capture', async () => {
+ const encoder = new TextEncoder();
+ let readCount = 0;
+
+ const body = new ReadableStream({
+ pull(controller) {
+ readCount += 1;
+ controller.enqueue(encoder.encode(`${readCount}`.repeat(20)));
+ },
+ cancel: () => new Promise(() => {}),
+ });
+
+ const response = {
+ status: 200,
+ statusText: 'OK',
+ headers: new Headers({ 'content-type': 'text/plain' }),
+ text: async () => '',
+ clone: () => response,
+ body,
+ } as unknown as HttpResponseLike;
+
+ const result = await Promise.race([
+ buildHarResponse(response, { maxResponseBodySize: 10 }),
+ new Promise<'timed out'>((resolve) => setTimeout(() => resolve('timed out'), 50)),
+ ]);
+
+ expect(result).not.toBe('timed out');
+ if (result !== 'timed out') {
+ expect(result.content.text).toBe('1111111111');
+ expect(result.content.size).toBeGreaterThan(10);
+ }
+ });
+});
diff --git a/packages/component-sdk/src/__tests__/http-instrumentation.test.ts b/packages/component-sdk/src/__tests__/http-instrumentation.test.ts
index d558343a..19f45240 100644
--- a/packages/component-sdk/src/__tests__/http-instrumentation.test.ts
+++ b/packages/component-sdk/src/__tests__/http-instrumentation.test.ts
@@ -42,6 +42,43 @@ describe('HTTP instrumentation', () => {
}
});
+ it('preserves response body when HAR capture truncates a large response', async () => {
+ const recorded: TraceEvent[] = [];
+ const trace: ITraceService = {
+ record: (event) => {
+ recorded.push(event);
+ },
+ };
+
+ const context = createExecutionContext({
+ runId: 'run-http-large',
+ componentRef: 'test.http',
+ trace,
+ });
+
+ const largeBody = 'a'.repeat(60 * 1024);
+ const originalFetch = globalThis.fetch;
+ const mockFetch = Object.assign(
+ async () =>
+ new Response(largeBody, {
+ status: 200,
+ headers: { 'Content-Type': 'text/plain' },
+ }),
+ { preconnect: () => {} },
+ ) as typeof fetch;
+ globalThis.fetch = mockFetch;
+
+ try {
+ const response = await context.http.fetch('https://example.com/large');
+ await expect(response.text()).resolves.toBe(largeBody);
+
+ const responseEvent = recorded.find((event) => event.type === 'HTTP_RESPONSE_RECEIVED');
+ expect(responseEvent?.data).toBeDefined();
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
it('emits HTTP_REQUEST_ERROR when fetch fails', async () => {
const recorded: TraceEvent[] = [];
const trace: ITraceService = {
diff --git a/packages/component-sdk/src/__tests__/runner-docker-pull.test.ts b/packages/component-sdk/src/__tests__/runner-docker-pull.test.ts
new file mode 100644
index 00000000..b91482c4
--- /dev/null
+++ b/packages/component-sdk/src/__tests__/runner-docker-pull.test.ts
@@ -0,0 +1,89 @@
+import { EventEmitter } from 'node:events';
+import { describe, expect, it, mock, vi } from 'bun:test';
+import { createExecutionContext } from '../context';
+
+const spawnCalls: string[][] = [];
+
+const dockerSpawn = vi.fn((_: string, args: string[]) => {
+ spawnCalls.push(args);
+
+ const proc = new EventEmitter() as EventEmitter & {
+ stdout: EventEmitter;
+ stderr: EventEmitter;
+ stdin: { write: ReturnType; end: ReturnType };
+ kill: ReturnType;
+ };
+
+ proc.stdout = new EventEmitter();
+ proc.stderr = new EventEmitter();
+ proc.stdin = {
+ write: vi.fn(),
+ end: vi.fn(),
+ };
+ proc.kill = vi.fn();
+
+ queueMicrotask(() => {
+ if (args[0] === 'image' && args[1] === 'inspect') {
+ proc.emit('close', 1);
+ return;
+ }
+
+ if (args[0] === 'pull') {
+ proc.stderr.emit('data', Buffer.from('Pulling fs layer\n'));
+ proc.emit('close', 0);
+ return;
+ }
+
+ if (args[0] === 'run') {
+ proc.stdout.emit('data', Buffer.from('{"ok":true}'));
+ proc.emit('close', 0);
+ return;
+ }
+
+ proc.emit('close', 0);
+ });
+
+ return proc;
+});
+
+mock.module('child_process', () => ({
+ spawn: dockerSpawn,
+}));
+
+const { runComponentWithRunner, stripAnsiCodes } = await import('../runner');
+
+describe('Docker image preparation', () => {
+ it('strips private-mode terminal control sequences from fallback output', () => {
+ expect(stripAnsiCodes('\x1B[?9001h\x1B[?1004h\x1B[?25lapi.example.com')).toBe(
+ 'api.example.com',
+ );
+ });
+
+ it('pulls a missing image before running the container without polluting output', async () => {
+ spawnCalls.length = 0;
+
+ const context = createExecutionContext({
+ runId: 'docker-pull-run',
+ componentRef: 'docker.pull',
+ });
+
+ const result = await runComponentWithRunner(
+ {
+ kind: 'docker',
+ image: 'example/scanner:latest',
+ command: ['scan'],
+ timeoutSeconds: 30,
+ },
+ async () => ({}),
+ {},
+ context,
+ );
+
+ expect(result).toEqual({ ok: true });
+ expect(spawnCalls.map((args) => args.slice(0, 3))).toEqual([
+ ['image', 'inspect', 'example/scanner:latest'],
+ ['pull', 'example/scanner:latest'],
+ ['run', '--rm', '-i'],
+ ]);
+ });
+});
diff --git a/packages/component-sdk/src/http/har-builder.ts b/packages/component-sdk/src/http/har-builder.ts
index 4074ebb2..85376e17 100644
--- a/packages/component-sdk/src/http/har-builder.ts
+++ b/packages/component-sdk/src/http/har-builder.ts
@@ -66,9 +66,7 @@ export function headersToHar(headers: HttpHeaders | Record): Har
export function maskHeaders(headers: HarHeader[], sensitive: string[]): HarHeader[] {
const sensitiveSet = normalizeSensitiveHeaders(sensitive);
return headers.map((header) =>
- sensitiveSet.has(header.name.toLowerCase())
- ? { ...header, value: '***' }
- : header,
+ sensitiveSet.has(header.name.toLowerCase()) ? { ...header, value: '***' } : header,
);
}
@@ -87,9 +85,7 @@ export function maskQueryParams(
): HarQueryString[] {
const sensitiveSet = new Set(sensitive.map((param) => param.toLowerCase()));
return queryParams.map((param) =>
- sensitiveSet.has(param.name.toLowerCase())
- ? { ...param, value: '***' }
- : param,
+ sensitiveSet.has(param.name.toLowerCase()) ? { ...param, value: '***' } : param,
);
}
@@ -113,10 +109,7 @@ export function maskUrlQueryParams(url: string, sensitive: string[]): string {
}
}
-export function truncateBody(
- body: string,
- maxSize: number,
-): { text: string; truncated: boolean } {
+export function truncateBody(body: string, maxSize: number): { text: string; truncated: boolean } {
if (body.length <= maxSize) {
return { text: body, truncated: false };
}
@@ -202,13 +195,14 @@ export async function buildHarResponse(
chunks.push(chunk.slice(0, remaining));
bodyText += chunk.slice(0, remaining);
truncated = true;
- // Cancel the stream to avoid reading remaining data
- await reader.cancel();
+ // Do not await clone cancellation: Node's fetch tee can wait until
+ // the original response body is consumed, delaying callers.
+ void reader.cancel().catch(() => undefined);
break;
}
} else {
truncated = true;
- await reader.cancel();
+ void reader.cancel().catch(() => undefined);
break;
}
}
diff --git a/packages/component-sdk/src/runner.ts b/packages/component-sdk/src/runner.ts
index a26cf8d0..4b950e1c 100644
--- a/packages/component-sdk/src/runner.ts
+++ b/packages/component-sdk/src/runner.ts
@@ -12,16 +12,17 @@ import { ContainerError, TimeoutError, ValidationError, ConfigurationError } fro
* Docker containers and PTY output often contain color/control codes
* that pollute structured output (JSON parsing, line splitting).
*/
-function stripAnsiCodes(text: string): string {
- // Covers SGR (colors), cursor movement, and other CSI sequences
- return text.replace(/\x1B(?:[@-Z\\-_]|\[[0-9;]*[ -/]*[@-~])/g, '');
+export function stripAnsiCodes(text: string): string {
+ return text
+ .replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, '')
+ .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
}
// Standard output file path inside the container
const CONTAINER_OUTPUT_PATH = '/sentris-output';
const OUTPUT_FILENAME = 'result.json';
-type PtySpawn = typeof import('node-pty')['spawn'];
+type PtySpawn = (typeof import('node-pty'))['spawn'];
let cachedPtySpawn: PtySpawn | null = null;
let cachedDockerPath: string | null = null;
@@ -47,13 +48,13 @@ export async function resolveDockerPath(context?: ExecutionContext): Promise {
cachedPtySpawn = mod.spawn;
return cachedPtySpawn;
} catch (error) {
- console.warn('[Docker][PTY] node-pty module not available:', error instanceof Error ? error.message : error);
+ console.warn(
+ '[Docker][PTY] node-pty module not available:',
+ error instanceof Error ? error.message : error,
+ );
return null;
}
}
+async function runDockerSetupCommand(
+ args: string[],
+ context: ExecutionContext,
+ timeoutSeconds: number,
+): Promise<{ stdout: string; stderr: string }> {
+ const dockerPath = await resolveDockerPath(context);
+
+ return new Promise((resolve, reject) => {
+ let stdout = '';
+ let stderr = '';
+
+ const proc = spawn(dockerPath, args, {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ env: process.env,
+ });
+
+ const timeout = setTimeout(() => {
+ proc.kill();
+ reject(
+ new TimeoutError(
+ `Docker setup command timed out after ${timeoutSeconds}s`,
+ timeoutSeconds * 1000,
+ { details: { dockerArgs: formatArgs(args) } },
+ ),
+ );
+ }, timeoutSeconds * 1000);
+
+ proc.stdout.on('data', (data) => {
+ stdout += data.toString();
+ });
+
+ proc.stderr.on('data', (data) => {
+ stderr += data.toString();
+ });
+
+ proc.on('error', (error) => {
+ clearTimeout(timeout);
+ reject(
+ new ContainerError(`Failed to run Docker setup command: ${error.message}`, {
+ cause: error,
+ details: { dockerArgs: formatArgs(args) },
+ }),
+ );
+ });
+
+ proc.on('close', (code) => {
+ clearTimeout(timeout);
+ if (code !== 0) {
+ reject(
+ new ContainerError(`Docker setup command failed with exit code ${code}`, {
+ details: { exitCode: code, stdout, stderr, dockerArgs: formatArgs(args) },
+ }),
+ );
+ return;
+ }
+
+ resolve({ stdout, stderr });
+ });
+ });
+}
+
+async function ensureDockerImageAvailable(
+ runner: DockerRunnerConfig,
+ context: ExecutionContext,
+): Promise {
+ const setupTimeoutSeconds = Math.max(300, runner.timeoutSeconds ?? 300);
+ const inspectArgs = ['image', 'inspect', runner.image];
+
+ try {
+ await runDockerSetupCommand(inspectArgs, context, setupTimeoutSeconds);
+ return;
+ } catch {
+ context.emitProgress(`Pulling Docker image: ${runner.image}`);
+ }
+
+ const pullArgs = ['pull'];
+ if (runner.platform && runner.platform.trim().length > 0) {
+ pullArgs.push('--platform', runner.platform);
+ }
+ pullArgs.push(runner.image);
+
+ try {
+ await runDockerSetupCommand(pullArgs, context, setupTimeoutSeconds);
+ } catch (error) {
+ throw new ContainerError(`Failed to pull Docker image: ${runner.image}`, {
+ cause: error instanceof Error ? error : undefined,
+ details: { image: runner.image },
+ });
+ }
+}
+
export async function runComponentInline(
execute: (params: I, context: ExecutionContext) => Promise,
params: I,
@@ -123,7 +218,18 @@ async function runComponentInDocker(
params: I,
context: ExecutionContext,
): Promise {
- const { image, command, entrypoint, env = {}, network = 'none', platform, containerName, volumes, timeoutSeconds = 300, detached } = runner;
+ const {
+ image,
+ command,
+ entrypoint,
+ env = {},
+ network = 'none',
+ platform,
+ containerName,
+ volumes,
+ timeoutSeconds = 300,
+ detached,
+ } = runner;
const memoryLimit = runner.memoryLimit ?? '512m';
const cpuLimit = runner.cpuLimit ?? '1';
const pidsLimit = runner.pidsLimit ?? 256;
@@ -139,19 +245,27 @@ async function runComponentInDocker(
try {
// Write inputs to file instead of passing via env or stdin
await writeFile(hostInputPath, JSON.stringify(params));
+ await ensureDockerImageAvailable(runner, context);
const dockerArgs = [
'run',
'--rm',
'-i',
- '--network', network,
- '--memory', memoryLimit,
- '--cpus', cpuLimit,
- '--pids-limit', String(pidsLimit),
- '--label', `sentris.runId=${context.runId}`,
- '--label', `sentris.nodeRef=${context.componentRef}`,
+ '--network',
+ network,
+ '--memory',
+ memoryLimit,
+ '--cpus',
+ cpuLimit,
+ '--pids-limit',
+ String(pidsLimit),
+ '--label',
+ `sentris.runId=${context.runId}`,
+ '--label',
+ `sentris.nodeRef=${context.componentRef}`,
// Mount the directory containing both input and output
- '-v', `${outputDir}:${CONTAINER_OUTPUT_PATH}`,
+ '-v',
+ `${outputDir}:${CONTAINER_OUTPUT_PATH}`,
];
if (containerName) {
@@ -190,43 +304,56 @@ async function runComponentInDocker(
dockerArgs.push(image, ...command);
-
const useTerminal = Boolean(context.terminalCollector);
let capturedStdout = '';
if (runner.detached) {
// For detached mode, we use -d instead of -i and return the container ID
- const detachedArgs = dockerArgs.map(arg => arg === '-i' ? '-d' : arg);
+ const detachedArgs = dockerArgs.map((arg) => (arg === '-i' ? '-d' : arg));
if (!detachedArgs.includes('-d')) {
detachedArgs.splice(1, 0, '-d');
}
// In detached mode, keep --rm only when explicitly requested
- const persistentArgs = runner.autoRemove ? detachedArgs : detachedArgs.filter(arg => arg !== '--rm');
-
- capturedStdout = await runDockerWithStandardIO(persistentArgs, params, context, timeoutSeconds, runner.stdinJson, true);
+ const persistentArgs = runner.autoRemove
+ ? detachedArgs
+ : detachedArgs.filter((arg) => arg !== '--rm');
+
+ capturedStdout = await runDockerWithStandardIO(
+ persistentArgs,
+ params,
+ context,
+ timeoutSeconds,
+ runner.stdinJson,
+ true,
+ );
// In detached mode, we return the container ID as part of a specialized output
return {
containerId: capturedStdout.trim(),
status: 'running',
- endpoint: env.ENDPOINT || `http://localhost:${env.PORT || 8080}`
+ endpoint: env.ENDPOINT || `http://localhost:${env.PORT || 8080}`,
} as unknown as O;
}
if (useTerminal) {
// Remove -i flag for PTY mode (stdin not needed with TTY)
- const argsWithoutStdin = dockerArgs.filter(arg => arg !== '-i');
+ const argsWithoutStdin = dockerArgs.filter((arg) => arg !== '-i');
if (!argsWithoutStdin.includes('-t')) {
argsWithoutStdin.splice(2, 0, '-t');
}
// NEVER write JSON to stdin in PTY mode - it pollutes the terminal output
capturedStdout = await runDockerWithPty(argsWithoutStdin, params, context, timeoutSeconds);
} else {
- capturedStdout = await runDockerWithStandardIO(dockerArgs, params, context, timeoutSeconds, runner.stdinJson);
+ capturedStdout = await runDockerWithStandardIO(
+ dockerArgs,
+ params,
+ context,
+ timeoutSeconds,
+ runner.stdinJson,
+ );
}
-
// Read output from file (with stdout fallback for legacy components)
return await readOutputFromFile(hostOutputPath, capturedStdout, context);
} finally {
@@ -240,7 +367,7 @@ async function runComponentInDocker(
/**
* Read component output from the mounted output file.
* If file doesn't exist, falls back to stdout parsing for backwards compatibility.
- *
+ *
* @param filePath Path to the output file
* @param stdout Captured stdout as fallback for legacy components
* @param context Execution context for logging
@@ -248,7 +375,7 @@ async function runComponentInDocker(
async function readOutputFromFile(
filePath: string,
stdout: string,
- context: ExecutionContext
+ context: ExecutionContext,
): Promise {
// First, try to read from the output file (preferred method)
try {
@@ -276,7 +403,9 @@ async function readOutputFromFile(
// Strip ANSI escape codes before parsing — Docker/PTY output often contains
// color codes that break JSON parsing and pollute line-based output.
const cleanStdout = stripAnsiCodes(stdout);
- context.logger.info(`[Docker] No output file found, using stdout fallback (${cleanStdout.length} bytes)`);
+ context.logger.info(
+ `[Docker] No output file found, using stdout fallback (${cleanStdout.length} bytes)`,
+ );
// Try to parse stdout as JSON
try {
@@ -314,9 +443,15 @@ async function runDockerWithStandardIO(
const timeout = setTimeout(() => {
proc.kill();
- reject(new TimeoutError(`Docker container timed out after ${timeoutSeconds}s`, timeoutSeconds * 1000, {
- details: { dockerArgs: formatArgs(dockerArgs) },
- }));
+ reject(
+ new TimeoutError(
+ `Docker container timed out after ${timeoutSeconds}s`,
+ timeoutSeconds * 1000,
+ {
+ details: { dockerArgs: formatArgs(dockerArgs) },
+ },
+ ),
+ );
}, timeoutSeconds * 1000);
const proc = spawn(dockerPath, dockerArgs, {
@@ -324,8 +459,6 @@ async function runDockerWithStandardIO(
env: process.env,
});
-
-
let stdout = '';
let stderr = '';
@@ -355,7 +488,7 @@ async function runDockerWithStandardIO(
const chunk = data.toString();
stderr += chunk;
const isProgress = isDockerProgressMessage(chunk);
- const level = isProgress ? 'info' as const : 'error' as const;
+ const level = isProgress ? ('info' as const) : ('error' as const);
const logEntry = {
runId: context.runId,
nodeRef: context.componentRef,
@@ -383,10 +516,12 @@ async function runDockerWithStandardIO(
proc.on('error', (error) => {
clearTimeout(timeout);
context.logger.error(`[Docker] Failed to start: ${error.message}`);
- reject(new ContainerError(`Failed to start Docker container: ${error.message}`, {
- cause: error,
- details: { dockerArgs: formatArgs(dockerArgs) },
- }));
+ reject(
+ new ContainerError(`Failed to start Docker container: ${error.message}`, {
+ cause: error,
+ details: { dockerArgs: formatArgs(dockerArgs) },
+ }),
+ );
});
proc.on('close', (code) => {
@@ -403,9 +538,11 @@ async function runDockerWithStandardIO(
data: { exitCode: code, stderr: stderr.slice(0, 500) },
});
- reject(new ContainerError(`Docker container failed with exit code ${code}: ${stderr}`, {
- details: { exitCode: code, stderr, stdout, dockerArgs: formatArgs(dockerArgs) },
- }));
+ reject(
+ new ContainerError(`Docker container failed with exit code ${code}: ${stderr}`, {
+ details: { exitCode: code, stderr, stdout, dockerArgs: formatArgs(dockerArgs) },
+ }),
+ );
return;
}
@@ -425,10 +562,12 @@ async function runDockerWithStandardIO(
} catch (e) {
clearTimeout(timeout);
proc.kill();
- reject(new ValidationError(`Failed to write input to Docker container: ${e}`, {
- cause: e as Error,
- details: { inputType: typeof params },
- }));
+ reject(
+ new ValidationError(`Failed to write input to Docker container: ${e}`, {
+ cause: e as Error,
+ details: { inputType: typeof params },
+ }),
+ );
}
} else {
// Close stdin immediately if stdinJson is false
@@ -452,7 +591,7 @@ async function runDockerWithPty(
if (!spawnPty) {
context.logger.warn('[Docker][PTY] node-pty unavailable; falling back to standard IO');
// Remove -t flag before falling back to standard IO (stdin is not a TTY)
- const argsWithoutTty = dockerArgs.filter(arg => arg !== '-t');
+ const argsWithoutTty = dockerArgs.filter((arg) => arg !== '-t');
return runDockerWithStandardIO(argsWithoutTty, params, context, timeoutSeconds);
}
@@ -477,13 +616,16 @@ async function runDockerWithPty(
dockerPath,
pathEnv: process.env.PATH,
cwd: process.cwd(),
- error: error instanceof Error ? {
- message: error.message,
- stack: error.stack,
- name: error.name,
- // @ts-ignore
- code: error.code,
- } : String(error)
+ error:
+ error instanceof Error
+ ? {
+ message: error.message,
+ stack: error.stack,
+ name: error.name,
+ // @ts-ignore
+ code: error.code,
+ }
+ : String(error),
};
context.logger.warn(
@@ -500,13 +642,17 @@ async function runDockerWithPty(
return;
}
-
-
const timeout = setTimeout(() => {
ptyProcess.kill();
- reject(new TimeoutError(`Docker container timed out after ${timeoutSeconds}s`, timeoutSeconds * 1000, {
- details: { dockerArgs: formatArgs(dockerArgs) },
- }));
+ reject(
+ new TimeoutError(
+ `Docker container timed out after ${timeoutSeconds}s`,
+ timeoutSeconds * 1000,
+ {
+ details: { dockerArgs: formatArgs(dockerArgs) },
+ },
+ ),
+ );
}, timeoutSeconds * 1000);
// NEVER write JSON to stdin in PTY mode - it pollutes the terminal output
@@ -529,16 +675,15 @@ async function runDockerWithPty(
data: { exitCode },
});
- reject(new ContainerError(
- `Docker PTY execution failed with exit code ${exitCode}`,
- {
+ reject(
+ new ContainerError(`Docker PTY execution failed with exit code ${exitCode}`, {
details: {
exitCode,
stdout,
dockerArgs: formatArgs(dockerArgs),
},
- },
- ));
+ }),
+ );
return;
}
diff --git a/scripts/__tests__/template-library-live-audit-utils.test.ts b/scripts/__tests__/template-library-live-audit-utils.test.ts
new file mode 100644
index 00000000..22c97456
--- /dev/null
+++ b/scripts/__tests__/template-library-live-audit-utils.test.ts
@@ -0,0 +1,150 @@
+import { describe, expect, it } from 'bun:test';
+import {
+ getNodeIoWarningSignals,
+ renderTemplateAuditMarkdown,
+ summarizeNodeIoNode,
+ waitForNodeIoEvidence,
+} from '../template-library-live-audit-utils';
+
+describe('template library live audit helpers', () => {
+ it('waits for node I/O ingestion to reach the expected node count', async () => {
+ const sleeps: number[] = [];
+ const responses = [
+ { runId: 'run-1', nodes: [] },
+ { runId: 'run-1', nodes: [{ nodeRef: 'trigger_1' }] },
+ {
+ runId: 'run-1',
+ nodes: [{ nodeRef: 'trigger_1' }, { nodeRef: 'osv_query' }],
+ },
+ ];
+
+ const result = await waitForNodeIoEvidence({
+ runId: 'run-1',
+ expectedNodeCount: 2,
+ timeoutMs: 1000,
+ pollIntervalMs: 25,
+ sleep: async (ms) => {
+ sleeps.push(ms);
+ },
+ fetchNodeIo: async () => responses.shift() ?? { runId: 'run-1', nodes: [] },
+ });
+
+ expect(result.nodes).toHaveLength(2);
+ expect(sleeps).toEqual([25, 25]);
+ });
+
+ it('summarizes output keys when node outputs are a JSON string', () => {
+ const summary = summarizeNodeIoNode({
+ nodeRef: 'query_nvd_candidates',
+ componentId: 'sentris.nvd.cve.query',
+ status: 'completed',
+ durationMs: 1250,
+ outputs: JSON.stringify({
+ ok: true,
+ status: 200,
+ vulnerabilities: [],
+ }),
+ outputsSpilled: true,
+ outputsTruncated: false,
+ inputsSpilled: false,
+ inputsTruncated: false,
+ });
+
+ expect(summary).toEqual({
+ nodeRef: 'query_nvd_candidates',
+ componentId: 'sentris.nvd.cve.query',
+ status: 'completed',
+ durationMs: 1250,
+ errorMessage: null,
+ inputKeys: [],
+ outputKeys: ['ok', 'status', 'vulnerabilities'],
+ warnings: [],
+ inputsSpilled: false,
+ inputsTruncated: false,
+ outputsSpilled: true,
+ outputsTruncated: false,
+ });
+ });
+
+ it('extracts warning signals from node outputs', () => {
+ const summary = summarizeNodeIoNode({
+ nodeRef: 'query_nvd_candidates',
+ componentId: 'sentris.nvd.cve.query',
+ status: 'completed',
+ outputs: {
+ ok: false,
+ status: 503,
+ statusText: 'Service Unavailable',
+ warnings: ['NVD CVE query unavailable: Service Unavailable'],
+ },
+ });
+
+ expect(summary.warnings).toEqual(['NVD CVE query unavailable: Service Unavailable']);
+ expect(getNodeIoWarningSignals([summary])).toEqual([
+ 'query_nvd_candidates: NVD CVE query unavailable: Service Unavailable',
+ ]);
+ });
+
+ it('keeps truncation flags when serialized node outputs cannot be parsed', () => {
+ const summary = summarizeNodeIoNode({
+ nodeRef: 'rank_cve_candidates',
+ status: 'completed',
+ outputs: '{"report":',
+ outputsSpilled: true,
+ outputsTruncated: true,
+ });
+
+ expect(summary.outputKeys).toEqual([]);
+ expect(summary.outputsSpilled).toBe(true);
+ expect(summary.outputsTruncated).toBe(true);
+ });
+
+ it('renders node I/O evidence in the audit markdown report', () => {
+ const markdown = renderTemplateAuditMarkdown({
+ apiBase: 'http://127.0.0.1:3211/api/v1',
+ outputRoot: 'C:/tmp/audit',
+ generatedAt: '2026-06-21T04:00:00.000Z',
+ results: [
+ {
+ templateId: 'tpl-1',
+ templateName: 'Exposed Service CVE Mapper',
+ seedFile: 'exposed-service-cve-mapper.json',
+ category: 'cve-research',
+ components: ['sentris.nvd.cve.query'],
+ requiredSecrets: [],
+ runtimeInputs: [],
+ classification: 'live-run',
+ createOk: true,
+ runAttempted: true,
+ terminalStatus: 'COMPLETED',
+ artifactsCount: 1,
+ recommendation: 'keep',
+ rationale: 'Live execution completed and produced at least one artifact.',
+ nodeIo: [
+ {
+ nodeRef: 'query_nvd_candidates',
+ componentId: 'sentris.nvd.cve.query',
+ status: 'completed',
+ durationMs: 1250,
+ errorMessage: null,
+ inputKeys: ['keywordSearch'],
+ outputKeys: ['ok', 'status', 'vulnerabilities'],
+ warnings: [],
+ inputsSpilled: false,
+ inputsTruncated: false,
+ outputsSpilled: true,
+ outputsTruncated: false,
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(markdown).toContain('## Node I/O Evidence');
+ expect(markdown).toContain('### Exposed Service CVE Mapper');
+ expect(markdown).toContain('query_nvd_candidates');
+ expect(markdown).toContain('sentris.nvd.cve.query');
+ expect(markdown).toContain('ok, status, vulnerabilities');
+ expect(markdown).toContain('outputs spilled');
+ });
+});
diff --git a/scripts/template-library-live-audit-utils.ts b/scripts/template-library-live-audit-utils.ts
new file mode 100644
index 00000000..952e0047
--- /dev/null
+++ b/scripts/template-library-live-audit-utils.ts
@@ -0,0 +1,287 @@
+export interface NodeIoEvidenceResponse {
+ runId?: string;
+ nodes?: Record[];
+ [key: string]: unknown;
+}
+
+export interface NodeIoNodeSummary {
+ nodeRef: string;
+ componentId?: string;
+ status?: string;
+ durationMs?: number | null;
+ errorMessage?: string | null;
+ inputKeys: string[];
+ outputKeys: string[];
+ warnings: string[];
+ inputsSpilled: boolean;
+ inputsTruncated: boolean;
+ outputsSpilled: boolean;
+ outputsTruncated: boolean;
+}
+
+export interface TemplateAuditMarkdownResult {
+ templateId: string;
+ templateName: string;
+ seedFile: string | null;
+ category: string | null;
+ components: string[];
+ requiredSecrets: string[];
+ runtimeInputs: unknown[];
+ classification: string;
+ createOk: boolean;
+ createError?: string;
+ runAttempted: boolean;
+ runStartOk?: boolean;
+ runStartError?: string;
+ terminalStatus?: string;
+ statusError?: string;
+ artifactsCount?: number;
+ nodeIo?: NodeIoNodeSummary[];
+ recommendation: string;
+ rationale: string;
+}
+
+export interface RenderTemplateAuditMarkdownOptions {
+ apiBase: string;
+ outputRoot: string;
+ generatedAt: string;
+ results: TemplateAuditMarkdownResult[];
+}
+
+export interface WaitForNodeIoEvidenceOptions {
+ runId: string;
+ expectedNodeCount?: number;
+ timeoutMs: number;
+ pollIntervalMs: number;
+ fetchNodeIo: () => Promise;
+ sleep?: (ms: number) => Promise;
+}
+
+function defaultSleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function hasEnoughNodeEvidence(
+ response: NodeIoEvidenceResponse,
+ expectedNodeCount: number,
+): boolean {
+ return Array.isArray(response.nodes) && response.nodes.length >= expectedNodeCount;
+}
+
+function parseRecord(value: unknown): Record | null {
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ return value as Record;
+ }
+
+ if (typeof value !== 'string') return null;
+
+ try {
+ const parsed = JSON.parse(value);
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
+ ? (parsed as Record)
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+function getBoolean(value: unknown): boolean {
+ return value === true;
+}
+
+function getStringArray(value: unknown): string[] {
+ if (!Array.isArray(value)) return [];
+ return value.map(String).map((item) => item.trim()).filter(Boolean);
+}
+
+function addWarnings(target: Set, value: unknown): void {
+ for (const warning of getStringArray(value)) {
+ target.add(warning);
+ }
+}
+
+function collectNodeWarnings(outputs: Record | null): string[] {
+ if (!outputs) return [];
+
+ const warnings = new Set();
+ addWarnings(warnings, outputs.warnings);
+
+ const report = parseRecord(outputs.report);
+ addWarnings(warnings, report?.warnings);
+
+ const summary = parseRecord(outputs.summary);
+ addWarnings(warnings, summary?.warnings);
+
+ return Array.from(warnings);
+}
+
+export function summarizeNodeIoNode(node: Record): NodeIoNodeSummary {
+ const inputs = parseRecord(node.inputs);
+ const outputs = parseRecord(node.outputs);
+
+ return {
+ nodeRef: String(node.nodeRef ?? ''),
+ componentId: typeof node.componentId === 'string' ? node.componentId : undefined,
+ status: typeof node.status === 'string' ? node.status : undefined,
+ durationMs: typeof node.durationMs === 'number' ? node.durationMs : null,
+ errorMessage: typeof node.errorMessage === 'string' ? node.errorMessage : null,
+ inputKeys: inputs ? Object.keys(inputs) : [],
+ outputKeys: outputs ? Object.keys(outputs) : [],
+ warnings: collectNodeWarnings(outputs),
+ inputsSpilled: getBoolean(node.inputsSpilled),
+ inputsTruncated: getBoolean(node.inputsTruncated),
+ outputsSpilled: getBoolean(node.outputsSpilled),
+ outputsTruncated: getBoolean(node.outputsTruncated),
+ };
+}
+
+export function getNodeIoWarningSignals(nodes: NodeIoNodeSummary[]): string[] {
+ const signals = new Set();
+ for (const node of nodes) {
+ const nodeLabel = node.nodeRef || node.componentId || 'node';
+ for (const warning of node.warnings) {
+ signals.add(`${nodeLabel}: ${warning}`);
+ }
+ }
+ return Array.from(signals);
+}
+
+function escapeMarkdownTable(value: unknown): string {
+ return String(value ?? '').replace(/\|/g, '\\|');
+}
+
+function renderNodeFlags(node: NodeIoNodeSummary): string {
+ const flags: string[] = [];
+ if (node.inputsSpilled) flags.push('inputs spilled');
+ if (node.inputsTruncated) flags.push('inputs truncated');
+ if (node.outputsSpilled) flags.push('outputs spilled');
+ if (node.outputsTruncated) flags.push('outputs truncated');
+ return flags.join(', ') || '-';
+}
+
+function renderKeyList(keys: string[]): string {
+ return keys.length > 0 ? keys.join(', ') : '-';
+}
+
+function renderWarnings(warnings: string[]): string {
+ return warnings.length > 0 ? warnings.join('; ') : '-';
+}
+
+export function renderTemplateAuditMarkdown({
+ apiBase,
+ outputRoot,
+ generatedAt,
+ results,
+}: RenderTemplateAuditMarkdownOptions): string {
+ const counts = results.reduce>((acc, result) => {
+ acc[result.recommendation] = (acc[result.recommendation] ?? 0) + 1;
+ return acc;
+ }, {});
+
+ const lines: string[] = [
+ '# Template Library Live Audit',
+ '',
+ `API base: \`${apiBase}\``,
+ `Generated: ${generatedAt}`,
+ `Output directory: \`${outputRoot}\``,
+ '',
+ '## Summary',
+ '',
+ `- Templates audited: ${results.length}`,
+ `- Keep: ${counts.keep ?? 0}`,
+ `- Fix: ${counts.fix ?? 0}`,
+ `- Consolidate: ${counts.consolidate ?? 0}`,
+ `- Delete: ${counts.delete ?? 0}`,
+ `- Review: ${counts.review ?? 0}`,
+ '',
+ '## Results',
+ '',
+ '| Template | Class | Run | Artifacts | Recommendation | Rationale |',
+ '| --- | --- | --- | ---: | --- | --- |',
+ ];
+
+ for (const result of results) {
+ const run =
+ result.terminalStatus ??
+ (result.runStartError ? 'run failed to start' : result.runAttempted ? 'started' : 'not run');
+ lines.push(
+ `| ${escapeMarkdownTable(result.templateName)} | ${escapeMarkdownTable(
+ result.classification,
+ )} | ${escapeMarkdownTable(run)} | ${result.artifactsCount ?? 0} | ${escapeMarkdownTable(
+ result.recommendation,
+ )} | ${escapeMarkdownTable(result.rationale)} |`,
+ );
+ }
+
+ lines.push('', '## Node I/O Evidence', '');
+ for (const result of results) {
+ lines.push(`### ${result.templateName}`, '');
+ const nodes = result.nodeIo ?? [];
+ if (nodes.length === 0) {
+ lines.push('No node I/O evidence captured.', '');
+ continue;
+ }
+
+ lines.push(
+ '| Node | Component | Status | Duration | Input Keys | Output Keys | Flags | Warnings | Error |',
+ '| --- | --- | --- | ---: | --- | --- | --- | --- | --- |',
+ );
+ for (const node of nodes) {
+ lines.push(
+ `| ${escapeMarkdownTable(node.nodeRef)} | ${escapeMarkdownTable(
+ node.componentId ?? '-',
+ )} | ${escapeMarkdownTable(node.status ?? '-')} | ${node.durationMs ?? '-'} | ${escapeMarkdownTable(
+ renderKeyList(node.inputKeys),
+ )} | ${escapeMarkdownTable(renderKeyList(node.outputKeys))} | ${escapeMarkdownTable(
+ renderNodeFlags(node),
+ )} | ${escapeMarkdownTable(renderWarnings(node.warnings))} | ${escapeMarkdownTable(
+ node.errorMessage ?? '-',
+ )} |`,
+ );
+ }
+ lines.push('');
+ }
+
+ lines.push('## Credential-Gated Templates', '');
+ for (const result of results.filter((item) => item.requiredSecrets.length > 0)) {
+ lines.push(`- ${result.templateName}: ${result.requiredSecrets.join(', ')}`);
+ }
+
+ lines.push('', '## Delete Candidates', '');
+ for (const result of results.filter((item) => item.recommendation === 'delete')) {
+ lines.push(`- ${result.seedFile ?? result.templateName}: ${result.rationale}`);
+ }
+
+ lines.push('', '## Fix Candidates', '');
+ for (const result of results.filter((item) => item.recommendation === 'fix')) {
+ lines.push(`- ${result.seedFile ?? result.templateName}: ${result.rationale}`);
+ }
+
+ return `${lines.join('\n')}\n`;
+}
+
+export async function waitForNodeIoEvidence({
+ runId,
+ expectedNodeCount,
+ timeoutMs,
+ pollIntervalMs,
+ fetchNodeIo,
+ sleep = defaultSleep,
+}: WaitForNodeIoEvidenceOptions): Promise {
+ const targetNodeCount = Math.max(1, expectedNodeCount ?? 1);
+ const interval = Math.max(1, pollIntervalMs);
+ const maxAttempts = Math.max(1, Math.ceil(Math.max(0, timeoutMs) / interval) + 1);
+ let latest: NodeIoEvidenceResponse = { runId, nodes: [] };
+
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
+ latest = await fetchNodeIo();
+ if (hasEnoughNodeEvidence(latest, targetNodeCount)) {
+ return latest;
+ }
+ if (attempt < maxAttempts - 1) {
+ await sleep(interval);
+ }
+ }
+
+ return latest;
+}
diff --git a/scripts/template-library-live-audit.ts b/scripts/template-library-live-audit.ts
new file mode 100644
index 00000000..7ca4e2cc
--- /dev/null
+++ b/scripts/template-library-live-audit.ts
@@ -0,0 +1,661 @@
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import {
+ getNodeIoWarningSignals,
+ renderTemplateAuditMarkdown,
+ summarizeNodeIoNode,
+ waitForNodeIoEvidence,
+} from './template-library-live-audit-utils';
+
+type JsonObject = Record;
+
+interface RuntimeInput {
+ id: string;
+ label?: string;
+ type?: string;
+ required?: boolean;
+ description?: string;
+}
+
+interface RequiredSecret {
+ name: string;
+ type: string;
+ description?: string;
+}
+
+interface GraphNode {
+ id: string;
+ type?: string;
+ data?: {
+ label?: string;
+ config?: {
+ params?: JsonObject;
+ inputOverrides?: JsonObject;
+ };
+ };
+}
+
+interface SeedTemplate {
+ _metadata?: {
+ name?: string;
+ description?: string;
+ category?: string;
+ tags?: string[];
+ author?: string;
+ version?: string;
+ };
+ manifest?: {
+ name?: string;
+ category?: string;
+ tags?: string[];
+ };
+ graph?: {
+ name?: string;
+ nodes?: GraphNode[];
+ edges?: unknown[];
+ };
+ requiredSecrets?: RequiredSecret[];
+}
+
+interface ApiTemplate {
+ id: string;
+ name: string;
+ description?: string | null;
+ category?: string | null;
+ tags?: string[] | null;
+ graph?: SeedTemplate['graph'] | null;
+ requiredSecrets?: RequiredSecret[] | null;
+ path?: string | null;
+ repository?: string | null;
+}
+
+interface CreatedWorkflowResponse {
+ workflow?: {
+ id?: string;
+ };
+ templateId?: string;
+ templateName?: string;
+}
+
+interface RunStartResponse {
+ runId: string;
+ workflowId: string;
+ temporalRunId?: string;
+ status?: string;
+}
+
+interface RunStatusResponse {
+ status: string;
+ [key: string]: unknown;
+}
+
+interface NodeIoSummary {
+ nodeRef: string;
+ componentId?: string;
+ status?: string;
+ durationMs?: number | null;
+ errorMessage?: string | null;
+ inputKeys?: string[];
+ outputKeys?: string[];
+ warnings?: string[];
+ inputsSpilled?: boolean;
+ inputsTruncated?: boolean;
+ outputsSpilled?: boolean;
+ outputsTruncated?: boolean;
+}
+
+interface AuditResult {
+ templateId: string;
+ templateName: string;
+ seedFile: string | null;
+ category: string | null;
+ components: string[];
+ requiredSecrets: string[];
+ runtimeInputs: RuntimeInput[];
+ classification: 'live-run' | 'credential-gated' | 'run-start-probe' | 'create-only';
+ workflowId?: string;
+ createOk: boolean;
+ createError?: string;
+ runAttempted: boolean;
+ runStartOk?: boolean;
+ runStartError?: string;
+ runId?: string;
+ terminalStatus?: string;
+ statusError?: string;
+ artifactsCount?: number;
+ nodeIo?: NodeIoSummary[];
+ recommendation: 'keep' | 'fix' | 'consolidate' | 'delete' | 'review';
+ rationale: string;
+}
+
+const DEFAULT_INSTANCE = Number.parseInt(
+ process.env.SENTRIS_INSTANCE ?? process.env.E2E_INSTANCE ?? '0',
+ 10,
+);
+const API_BASE =
+ process.env.SENTRIS_API_BASE_URL ??
+ process.env.API_BASE ??
+ `http://127.0.0.1:${3211 + (Number.isFinite(DEFAULT_INSTANCE) ? DEFAULT_INSTANCE : 0) * 100}/api/v1`;
+const INTERNAL_TOKEN = process.env.SENTRIS_INTERNAL_TOKEN ?? 'local-internal-token';
+const ORG_ID = process.env.SENTRIS_ORG_ID ?? 'org_dev';
+const RUN_TIMEOUT_MS = Number.parseInt(process.env.TEMPLATE_AUDIT_TIMEOUT_MS ?? '420000', 10);
+const NODE_IO_CAPTURE_TIMEOUT_MS = Number.parseInt(
+ process.env.TEMPLATE_AUDIT_NODE_IO_TIMEOUT_MS ?? '30000',
+ 10,
+);
+const NODE_IO_CAPTURE_POLL_MS = Number.parseInt(
+ process.env.TEMPLATE_AUDIT_NODE_IO_POLL_MS ?? '1000',
+ 10,
+);
+const KEEP_WORKFLOWS = process.env.KEEP_AUDIT_WORKFLOWS === 'true';
+const OUTPUT_ROOT =
+ process.env.TEMPLATE_AUDIT_OUTPUT_DIR ??
+ join(tmpdir(), `sentris-template-live-audit-${new Date().toISOString().replace(/[:.]/g, '-')}`);
+const AUDIT_TEMPLATE_NAMES = new Set(
+ (process.env.TEMPLATE_AUDIT_NAMES ?? '')
+ .split(',')
+ .map((name) => name.trim())
+ .filter(Boolean),
+);
+
+const HEADERS = {
+ 'Content-Type': 'application/json',
+ 'x-internal-token': INTERNAL_TOKEN,
+ 'x-organization-id': ORG_ID,
+};
+
+const TERMINAL_STATUSES = new Set([
+ 'COMPLETED',
+ 'FAILED',
+ 'CANCELLED',
+ 'TERMINATED',
+ 'TIMED_OUT',
+ 'CONTINUED_AS_NEW',
+ 'UNKNOWN',
+]);
+
+const LIVE_INPUTS: Record = {
+ 'Bug Bounty Recon Triage': {
+ domains: ['example.com'],
+ authorizationNotes: 'Live audit fixture: public example domain, passive/bounded recon.',
+ },
+ 'CVE Impact Research Brief': {
+ cveId: 'CVE-2024-3094',
+ product: 'xz utils',
+ version: '5.6.1',
+ deploymentNotes: 'Live audit fixture for known public CVE research.',
+ },
+ 'Exposed Service CVE Mapper': {
+ targets: ['scanme.nmap.org'],
+ authorizationNotes: 'Live audit fixture: Nmap-provided scan target for bounded service checks.',
+ },
+ 'NPM Dependency CVE Hunt': {
+ packageSpecs: ['lodash@4.17.20', 'minimist@0.0.8', 'axios@0.21.1'],
+ researchNotes: 'Live audit fixture using public npm packages with known historical advisories.',
+ },
+ 'Web Attack Surface Quick Win Hunt': {
+ liveUrls: ['https://host.docker.internal:18443/api/health'],
+ outOfScopePaths: ['/logout', '/admin/delete'],
+ scanIntensity: 'safe',
+ },
+};
+
+function ensureOutputDir() {
+ mkdirSync(OUTPUT_ROOT, { recursive: true });
+}
+
+function sanitizeFileName(value: string): string {
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '')
+ .slice(0, 90);
+}
+
+async function apiFetch(path: string, init?: RequestInit): Promise {
+ const response = await fetch(`${API_BASE}${path}`, {
+ ...init,
+ headers: {
+ ...HEADERS,
+ ...(init?.headers ?? {}),
+ },
+ });
+
+ const text = await response.text();
+ let body: unknown = null;
+ if (text.trim().length > 0) {
+ try {
+ body = JSON.parse(text);
+ } catch {
+ body = text;
+ }
+ }
+
+ if (!response.ok) {
+ const message = typeof body === 'string' ? body : JSON.stringify(body);
+ throw new Error(`${response.status} ${response.statusText}: ${message}`);
+ }
+
+ return body as T;
+}
+
+function readSeedTemplates(): Map {
+ const seedDir = join(process.cwd(), 'backend', 'scripts', 'seed-templates');
+ const result = new Map();
+ const entries = Array.from(new Bun.Glob('*.json').scanSync(seedDir)).sort();
+
+ for (const file of entries) {
+ const template = JSON.parse(readFileSync(join(seedDir, file), 'utf8')) as SeedTemplate;
+ const name = template._metadata?.name ?? template.manifest?.name;
+ if (name) {
+ result.set(name, { file, template });
+ }
+ }
+
+ return result;
+}
+
+function getComponents(template: SeedTemplate | ApiTemplate): string[] {
+ const graph = template.graph;
+ const nodes = graph?.nodes ?? [];
+ return Array.from(new Set(nodes.map((node) => node.type).filter(Boolean) as string[])).sort();
+}
+
+function getRuntimeInputs(template: SeedTemplate | ApiTemplate): RuntimeInput[] {
+ const entry = template.graph?.nodes?.find((node) => node.type === 'core.workflow.entrypoint');
+ const raw = entry?.data?.config?.params?.runtimeInputs;
+ return Array.isArray(raw) ? (raw as RuntimeInput[]) : [];
+}
+
+function getEntryRuntimeInputState(
+ template: SeedTemplate | ApiTemplate,
+): 'missing' | 'empty' | 'present' {
+ const entry = template.graph?.nodes?.find((node) => node.type === 'core.workflow.entrypoint');
+ const raw = entry?.data?.config?.params?.runtimeInputs;
+ if (!Array.isArray(raw)) return 'missing';
+ return raw.length === 0 ? 'empty' : 'present';
+}
+
+function getRequiredSecretNames(template: SeedTemplate | ApiTemplate): string[] {
+ return (template.requiredSecrets ?? []).map((secret) => secret.name).filter(Boolean);
+}
+
+function hasUnmappedSlackNode(template: SeedTemplate | ApiTemplate): boolean {
+ return (template.graph?.nodes ?? []).some((node) => {
+ if (node.type !== 'core.notification.slack') return false;
+ const params = node.data?.config?.params ?? {};
+ const inputOverrides = node.data?.config?.inputOverrides ?? {};
+ const authType = params.authType ?? 'bot_token';
+ if (authType === 'webhook') return !inputOverrides.webhookUrl;
+ return !inputOverrides.slackToken;
+ });
+}
+
+function classifyTemplate(
+ template: ApiTemplate,
+ seed: SeedTemplate | undefined,
+): AuditResult['classification'] {
+ const runtimeInputs = getRuntimeInputs(seed ?? template);
+ const requiredSecrets = getRequiredSecretNames(seed ?? template);
+ if (LIVE_INPUTS[template.name] && requiredSecrets.length === 0) return 'live-run';
+ if (requiredSecrets.length > 0 && runtimeInputs.length > 0) return 'credential-gated';
+ if (requiredSecrets.length > 0 && runtimeInputs.length === 0) return 'run-start-probe';
+ return 'run-start-probe';
+}
+
+function analyzeRecommendation(
+ template: ApiTemplate,
+ seed: SeedTemplate | undefined,
+ result: Partial,
+): Pick {
+ const source = seed ?? template;
+ const runtimeState = getEntryRuntimeInputState(source);
+ const requiredSecrets = getRequiredSecretNames(source);
+ const components = getComponents(source);
+ const unmappedSlack = hasUnmappedSlackNode(source);
+ const nodeWarningSignals = getNodeIoWarningSignals(result.nodeIo ?? []);
+
+ if (result.terminalStatus === 'COMPLETED' && (result.artifactsCount ?? 0) > 0) {
+ if (nodeWarningSignals.length > 0) {
+ return {
+ recommendation: 'review',
+ rationale: `Live execution completed with artifact but emitted warnings: ${nodeWarningSignals
+ .slice(0, 3)
+ .join('; ')
+ .slice(0, 240)}`,
+ };
+ }
+
+ return {
+ recommendation: 'keep',
+ rationale: 'Live execution completed and produced at least one artifact.',
+ };
+ }
+
+ if (runtimeState === 'missing') {
+ return {
+ recommendation: 'delete',
+ rationale:
+ 'Entry point has no runtimeInputs configuration, so a user-created workflow cannot compile/run from the template.',
+ };
+ }
+
+ if (unmappedSlack) {
+ return {
+ recommendation: 'fix',
+ rationale:
+ 'Template has a Slack node with no connected/mapped Slack token or webhook input; remove optional notification or add real secret plumbing.',
+ };
+ }
+
+ if (requiredSecrets.length > 0) {
+ return {
+ recommendation: 'review',
+ rationale: `Credential-gated template requires: ${requiredSecrets.join(', ')}.`,
+ };
+ }
+
+ if (
+ components.includes('sentris.trivy.run') ||
+ components.includes('sentris.semgrep.run') ||
+ components.includes('sentris.ffuf.run') ||
+ components.includes('sentris.checkov.run')
+ ) {
+ return {
+ recommendation: 'fix',
+ rationale:
+ 'Scanner template needs user-facing runtime inputs for target code, repo, image, URL, wordlist, or IaC content.',
+ };
+ }
+
+ if (result.runStartError) {
+ return {
+ recommendation: 'fix',
+ rationale: result.runStartError.split('\n')[0].slice(0, 240),
+ };
+ }
+
+ return {
+ recommendation: 'review',
+ rationale: 'No terminal live result; review trace and template shape before retaining.',
+ };
+}
+
+async function maybeUseExistingHttpsFixture(): Promise {
+ // The previous live workflow harness leaves a local fixture on 18443 in some sessions.
+ // If it is absent, public HTTPS targets are still used by CVE/service flows.
+ const localFixture = 'https://localhost:18443/api/health';
+ try {
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
+ const response = await fetch(localFixture, { signal: AbortSignal.timeout(2000) });
+ if (response.ok) {
+ console.log(`Detected HTTPS fixture: ${localFixture}`);
+ return;
+ }
+ } catch {
+ LIVE_INPUTS['Web Attack Surface Quick Win Hunt'] = {
+ liveUrls: ['https://example.com'],
+ outOfScopePaths: ['/logout', '/admin/delete'],
+ scanIntensity: 'safe',
+ };
+ console.log(
+ 'No local HTTPS fixture detected; Web quick-win audit will use https://example.com.',
+ );
+ }
+}
+
+async function pollRun(runId: string, timeoutMs: number): Promise {
+ const started = Date.now();
+ let last: RunStatusResponse | null = null;
+
+ while (Date.now() - started < timeoutMs) {
+ last = await apiFetch(`/workflows/runs/${runId}/status`);
+ if (TERMINAL_STATUSES.has(last.status)) return last;
+ await Bun.sleep(1500);
+ }
+
+ throw new Error(
+ `Run ${runId} did not reach a terminal state in ${timeoutMs}ms; last status ${last?.status ?? 'unknown'}`,
+ );
+}
+
+async function cancelRun(runId: string): Promise {
+ try {
+ await apiFetch(`/workflows/runs/${runId}/cancel`, { method: 'POST' });
+ } catch (error) {
+ console.warn(`Failed to cancel ${runId}: ${error instanceof Error ? error.message : error}`);
+ }
+}
+
+async function deleteWorkflow(workflowId: string): Promise {
+ if (KEEP_WORKFLOWS) return;
+ try {
+ await apiFetch(`/workflows/${workflowId}`, { method: 'DELETE' });
+ } catch (error) {
+ console.warn(
+ `Failed to delete audit workflow ${workflowId}: ${error instanceof Error ? error.message : error}`,
+ );
+ }
+}
+
+async function captureRunEvidence(
+ runId: string,
+ prefix: string,
+ expectedNodeCount?: number,
+): Promise<{
+ artifactsCount: number;
+ nodeIo: NodeIoSummary[];
+}> {
+ const [artifacts, nodeIo, trace] = await Promise.all([
+ apiFetch<{ artifacts?: unknown[] }>(`/workflows/runs/${runId}/artifacts`).catch((error) => ({
+ error: error instanceof Error ? error.message : String(error),
+ })),
+ waitForNodeIoEvidence({
+ runId,
+ expectedNodeCount,
+ timeoutMs: NODE_IO_CAPTURE_TIMEOUT_MS,
+ pollIntervalMs: NODE_IO_CAPTURE_POLL_MS,
+ fetchNodeIo: () =>
+ apiFetch<{ nodes?: Record[] }>(`/workflows/runs/${runId}/node-io`).catch(
+ (error) => ({
+ runId,
+ nodes: [],
+ error: error instanceof Error ? error.message : String(error),
+ }),
+ ),
+ }),
+ apiFetch(`/workflows/runs/${runId}/trace`).catch((error) => ({
+ error: error instanceof Error ? error.message : String(error),
+ })),
+ ]);
+
+ writeFileSync(join(OUTPUT_ROOT, `${prefix}.artifacts.json`), JSON.stringify(artifacts, null, 2));
+ writeFileSync(join(OUTPUT_ROOT, `${prefix}.node-io.json`), JSON.stringify(nodeIo, null, 2));
+ writeFileSync(join(OUTPUT_ROOT, `${prefix}.trace.json`), JSON.stringify(trace, null, 2));
+
+ const artifactList = Array.isArray((artifacts as { artifacts?: unknown[] }).artifacts)
+ ? (artifacts as { artifacts: unknown[] }).artifacts
+ : Array.isArray(artifacts)
+ ? (artifacts as unknown[])
+ : [];
+
+ const nodes = Array.isArray((nodeIo as { nodes?: unknown[] }).nodes)
+ ? (nodeIo as { nodes: Array> }).nodes
+ : [];
+
+ return {
+ artifactsCount: artifactList.length,
+ nodeIo: nodes.map((node) => summarizeNodeIoNode(node)),
+ };
+}
+
+async function auditTemplate(
+ template: ApiTemplate,
+ seedRecord: { file: string; template: SeedTemplate } | undefined,
+): Promise {
+ const seed = seedRecord?.template;
+ const source = seed ?? template;
+ const classification = classifyTemplate(template, seed);
+ const components = getComponents(source);
+ const requiredSecrets = getRequiredSecretNames(source);
+ const runtimeInputs = getRuntimeInputs(source);
+ const prefix = `${sanitizeFileName(template.name)}-${template.id.slice(0, 8)}`;
+
+ const base: AuditResult = {
+ templateId: template.id,
+ templateName: template.name,
+ seedFile: seedRecord?.file ?? null,
+ category: template.category ?? seed?._metadata?.category ?? null,
+ components,
+ requiredSecrets,
+ runtimeInputs,
+ classification,
+ createOk: false,
+ runAttempted: false,
+ recommendation: 'review',
+ rationale: 'Audit did not reach recommendation step.',
+ };
+
+ let workflowId: string | undefined;
+
+ try {
+ const created = await apiFetch(`/templates/${template.id}/use`, {
+ method: 'POST',
+ body: JSON.stringify({
+ workflowName: `Template Live Audit - ${template.name} - ${new Date().toISOString()}`,
+ }),
+ });
+ workflowId = created.workflow?.id;
+ base.workflowId = workflowId;
+ base.createOk = Boolean(workflowId);
+ if (!workflowId) {
+ base.createError = `Use-template response had no workflow id: ${JSON.stringify(created)}`;
+ const recommendation = analyzeRecommendation(template, seed, base);
+ return { ...base, ...recommendation };
+ }
+ } catch (error) {
+ base.createError = error instanceof Error ? error.message : String(error);
+ const recommendation = analyzeRecommendation(template, seed, base);
+ return { ...base, ...recommendation };
+ }
+
+ const shouldRun =
+ classification === 'live-run' ||
+ (classification === 'run-start-probe' && requiredSecrets.length === 0) ||
+ (classification === 'run-start-probe' && runtimeInputs.length === 0);
+
+ if (!shouldRun) {
+ await deleteWorkflow(workflowId);
+ const recommendation = analyzeRecommendation(template, seed, base);
+ return { ...base, ...recommendation };
+ }
+
+ const inputs = LIVE_INPUTS[template.name] ?? {};
+ base.runAttempted = true;
+
+ try {
+ const started = await apiFetch(`/workflows/${workflowId}/run`, {
+ method: 'POST',
+ body: JSON.stringify({ inputs }),
+ });
+ base.runStartOk = true;
+ base.runId = started.runId;
+
+ if (requiredSecrets.length > 0 && classification !== 'live-run') {
+ await cancelRun(started.runId);
+ base.terminalStatus = 'CANCELLED';
+ base.statusError =
+ 'Run unexpectedly started for a credential-gated template; cancelled to avoid external side effects.';
+ } else {
+ const status = await pollRun(started.runId, RUN_TIMEOUT_MS);
+ base.terminalStatus = status.status;
+ }
+
+ const expectedNodeCount = template.graph?.nodes?.length ?? seed?.graph?.nodes?.length;
+ const evidence = await captureRunEvidence(started.runId, prefix, expectedNodeCount);
+ base.artifactsCount = evidence.artifactsCount;
+ base.nodeIo = evidence.nodeIo;
+ } catch (error) {
+ base.runStartOk = false;
+ base.runStartError = error instanceof Error ? error.message : String(error);
+ } finally {
+ if (workflowId) {
+ await deleteWorkflow(workflowId);
+ }
+ }
+
+ const recommendation = analyzeRecommendation(template, seed, base);
+ return { ...base, ...recommendation };
+}
+
+async function main() {
+ ensureOutputDir();
+ await maybeUseExistingHttpsFixture();
+
+ console.log(`Template audit output: ${OUTPUT_ROOT}`);
+ console.log(`API base: ${API_BASE}`);
+
+ await apiFetch('/health');
+ await apiFetch('/health/ready');
+
+ const seedTemplates = readSeedTemplates();
+ const templates = await apiFetch('/templates');
+ const selectedTemplates =
+ AUDIT_TEMPLATE_NAMES.size > 0
+ ? templates.filter((template) => AUDIT_TEMPLATE_NAMES.has(template.name))
+ : templates;
+ const missingTemplateNames = Array.from(AUDIT_TEMPLATE_NAMES).filter(
+ (name) => !selectedTemplates.some((template) => template.name === name),
+ );
+ if (missingTemplateNames.length > 0) {
+ throw new Error(`Requested template(s) not found: ${missingTemplateNames.join(', ')}`);
+ }
+
+ const results: AuditResult[] = [];
+
+ for (const template of selectedTemplates.sort((a, b) => a.name.localeCompare(b.name))) {
+ console.log(`\nAuditing: ${template.name}`);
+ const seedRecord = seedTemplates.get(template.name);
+ const result = await auditTemplate(template, seedRecord);
+ results.push(result);
+ console.log(
+ ` ${result.recommendation.toUpperCase()} ${result.terminalStatus ?? result.runStartError ?? result.createError ?? 'created'}`,
+ );
+ }
+
+ const jsonPath = join(OUTPUT_ROOT, 'template-live-audit.json');
+ const mdPath = join(OUTPUT_ROOT, 'template-live-audit.md');
+ writeFileSync(
+ jsonPath,
+ JSON.stringify({ apiBase: API_BASE, generatedAt: new Date().toISOString(), results }, null, 2),
+ );
+ writeFileSync(
+ mdPath,
+ renderTemplateAuditMarkdown({
+ apiBase: API_BASE,
+ outputRoot: OUTPUT_ROOT,
+ generatedAt: new Date().toISOString(),
+ results,
+ }),
+ );
+
+ console.log(`\nAudit complete.`);
+ console.log(`JSON: ${jsonPath}`);
+ console.log(`Markdown: ${mdPath}`);
+
+ const failingLiveRuns = results.filter(
+ (result) => result.classification === 'live-run' && result.terminalStatus !== 'COMPLETED',
+ );
+ if (failingLiveRuns.length > 0) {
+ process.exitCode = 1;
+ console.error(
+ `Live-run templates failed: ${failingLiveRuns.map((result) => result.templateName).join(', ')}`,
+ );
+ }
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/worker/src/components/core/__tests__/http-request.test.ts b/worker/src/components/core/__tests__/http-request.test.ts
index fb902aee..c5a6f6d3 100644
--- a/worker/src/components/core/__tests__/http-request.test.ts
+++ b/worker/src/components/core/__tests__/http-request.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
import { definition } from '../http-request';
import type { ExecutionContext } from '@sentris/component-sdk';
+import { extractPorts } from '@sentris/component-sdk/zod-ports';
// Helper to create a dummy context
const mockContext: ExecutionContext = {
@@ -74,6 +75,24 @@ describe('HTTP Request Component', () => {
server.stop();
});
+ test('resolvePorts preserves response outputs when auth inputs are dynamic', () => {
+ const resolvedPorts = definition.resolvePorts?.({
+ method: 'GET',
+ contentType: 'application/json',
+ authType: 'bearer',
+ timeout: 1000,
+ failOnError: true,
+ });
+
+ expect(resolvedPorts?.outputs).toBeDefined();
+
+ const outputIds = extractPorts(resolvedPorts!.outputs!).map((port) => port.id);
+ expect(outputIds).toContain('status');
+ expect(outputIds).toContain('data');
+ expect(outputIds).toContain('headers');
+ expect(outputIds).toContain('rawBody');
+ });
+
test('should handle basic GET request', async () => {
const result = await definition.execute(
{
@@ -233,4 +252,33 @@ describe('HTTP Request Component', () => {
expect(e.message).toContain('timed out');
}
});
+
+ test('should return structured timeout error if failOnError is false', async () => {
+ const result = await definition.execute(
+ {
+ inputs: {
+ url: `${baseUrl}/timeout`,
+ },
+ params: {
+ method: 'GET',
+ timeout: 50,
+ contentType: 'application/json',
+ failOnError: false,
+ authType: 'none',
+ },
+ },
+ mockContext,
+ );
+
+ expect(result.status).toBe(0);
+ expect(result.statusText).toBe('Timeout');
+ expect(result.rawBody).toBe('');
+ expect(result.headers).toEqual({});
+ expect(result.data).toMatchObject({
+ error: {
+ type: 'timeout',
+ message: expect.stringContaining('timed out'),
+ },
+ });
+ });
});
diff --git a/worker/src/components/core/http-request.ts b/worker/src/components/core/http-request.ts
index 00232de1..8c2f169e 100644
--- a/worker/src/components/core/http-request.ts
+++ b/worker/src/components/core/http-request.ts
@@ -211,7 +211,7 @@ const definition = defineComponent({
});
}
- return { inputs: inputs(inputShape) };
+ return { inputs: inputs(inputShape), outputs: outputSchema };
},
async execute({ inputs, params }, context) {
const { method, contentType, timeout, failOnError, authType } = params;
@@ -301,9 +301,30 @@ const definition = defineComponent({
} catch (error: any) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
- throw new TimeoutError(`HTTP request timed out after ${timeout}ms`, timeout, {
- details: { url, method },
- });
+ const timeoutError = new TimeoutError(
+ `HTTP request timed out after ${timeout}ms`,
+ timeout,
+ {
+ details: { url, method },
+ },
+ );
+ if (!failOnError) {
+ context.logger.warn(`[HTTP] ${method} ${url} timed out after ${timeout}ms`);
+ return {
+ status: 0,
+ statusText: 'Timeout',
+ data: {
+ error: {
+ type: 'timeout',
+ message: timeoutError.message,
+ timeoutMs: timeout,
+ },
+ },
+ headers: {},
+ rawBody: '',
+ };
+ }
+ throw timeoutError;
}
// Wrap network errors appropriately
if (
@@ -313,6 +334,21 @@ const definition = defineComponent({
error.message?.includes('socket hang up') ||
error.name === 'FetchError'
) {
+ if (!failOnError) {
+ context.logger.warn(`[HTTP] ${method} ${url} failed: ${error.message}`);
+ return {
+ status: 0,
+ statusText: 'Network Error',
+ data: {
+ error: {
+ type: 'network',
+ message: error.message ?? 'Network error',
+ },
+ },
+ headers: {},
+ rawBody: '',
+ };
+ }
throw NetworkError.from(error);
}
throw error;
diff --git a/worker/src/components/index.ts b/worker/src/components/index.ts
index 302bbc2c..56ce0234 100644
--- a/worker/src/components/index.ts
+++ b/worker/src/components/index.ts
@@ -72,6 +72,8 @@ import './security/katana';
import './security/ffuf';
import './security/trivy';
import './security/semgrep';
+import './security/osv';
+import './security/nvd';
import './security/yara';
// GitHub components
diff --git a/worker/src/components/security/__tests__/httpx.test.ts b/worker/src/components/security/__tests__/httpx.test.ts
index cb1928cf..93254fdd 100644
--- a/worker/src/components/security/__tests__/httpx.test.ts
+++ b/worker/src/components/security/__tests__/httpx.test.ts
@@ -1,12 +1,30 @@
import { describe, expect, test, beforeAll, afterEach, vi } from 'bun:test';
import * as sdk from '@sentris/component-sdk';
import { componentRegistry } from '../../index';
-import { parseHttpxOutput } from '../httpx';
+import { buildHttpxArgs, parseHttpxOutput } from '../httpx';
import type { HttpxOutput, InputShape, OutputShape } from '../httpx';
const runHttpxTests = process.env.ENABLE_HTTPX_COMPONENT_TESTS === 'true';
const describeHttpx = runHttpxTests ? describe : describe.skip;
+describe('httpx argument builder', () => {
+ test('does not emit unsupported prefer-https flag', () => {
+ const args = buildHttpxArgs({
+ ports: undefined,
+ statusCodes: undefined,
+ threads: undefined,
+ path: undefined,
+ followRedirects: true,
+ tlsProbe: true,
+ preferHttps: true,
+ });
+
+ expect(args).toContain('-follow-redirects');
+ expect(args).toContain('-tls-probe');
+ expect(args).not.toContain('-prefer-https');
+ });
+});
+
describeHttpx('httpx component', () => {
beforeAll(async () => {
await import('../../index');
@@ -128,7 +146,7 @@ describeHttpx('httpx component', () => {
context,
)) as HttpxOutput;
- expect(result.results).toHaveLength(1);
+ expect(result.responses).toHaveLength(1);
expect(result.resultCount).toBe(1);
expect(result.rawOutput).toContain('https://example.com');
expect(result.options.followRedirects).toBe(false);
@@ -145,7 +163,6 @@ describeHttpx('httpx component', () => {
const params = component.inputs.parse({
targets: ['https://example.com'],
- followRedirects: true,
});
const raw = [
@@ -155,13 +172,56 @@ describeHttpx('httpx component', () => {
vi.spyOn(sdk, 'runComponentWithRunner').mockResolvedValue(raw);
- const result = await component.execute({ inputs: params, params: {} }, context);
+ const result = await component.execute(
+ { inputs: params, params: { followRedirects: true } },
+ context,
+ );
expect(result.results).toHaveLength(2);
expect(result.options.followRedirects).toBe(true);
expect(result.rawOutput).toContain('https://other.example');
});
+ test('parses a single httpx JSON object returned by stdout fallback', async () => {
+ const component = componentRegistry.get('sentris.httpx.scan');
+ if (!component) throw new Error('Component not registered');
+
+ const context = sdk.createExecutionContext({
+ runId: 'test-run',
+ componentRef: 'httpx-test',
+ });
+
+ const params = component.inputs.parse({
+ targets: ['https://example.com'],
+ });
+
+ vi.spyOn(sdk, 'runComponentWithRunner').mockResolvedValue({
+ url: 'https://example.com',
+ input: 'https://example.com',
+ host: 'example.com',
+ status_code: 200,
+ title: 'Example Domain',
+ scheme: 'https',
+ webserver: 'ECS',
+ tech: ['HTTP'],
+ });
+
+ const result = await component.execute(
+ { inputs: params, params: { followRedirects: true } },
+ context,
+ );
+
+ expect(result.responses).toHaveLength(1);
+ expect(result.resultCount).toBe(1);
+ expect(result.responses[0]).toMatchObject({
+ url: 'https://example.com',
+ statusCode: 200,
+ title: 'Example Domain',
+ technologies: ['HTTP'],
+ });
+ expect(result.rawOutput).toContain('"url":"https://example.com"');
+ });
+
test('skips execution when no targets are provided', async () => {
const component = componentRegistry.get('sentris.httpx.scan');
if (!component) throw new Error('Component not registered');
diff --git a/worker/src/components/security/__tests__/katana.test.ts b/worker/src/components/security/__tests__/katana.test.ts
new file mode 100644
index 00000000..12dcb090
--- /dev/null
+++ b/worker/src/components/security/__tests__/katana.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from 'bun:test';
+import { buildKatanaArgs, mapKatanaScope } from '../katana';
+
+describe('katana argument builder', () => {
+ test('uses current JSONL output flag and maps Sentris scope values', () => {
+ const args = buildKatanaArgs({
+ depth: 2,
+ scope: 'strict',
+ timeout: 300,
+ headless: false,
+ customFlags: [],
+ });
+
+ expect(args).toContain('-jsonl');
+ expect(args).not.toContain('-json');
+ expect(args).toContain('-field-scope');
+ expect(args[args.indexOf('-field-scope') + 1]).toBe('fqdn');
+ expect(mapKatanaScope('fuzzy')).toBe('rdn');
+ expect(mapKatanaScope('subs')).toBe('dn');
+ });
+
+ test('appends browser and custom flags after stable defaults', () => {
+ const args = buildKatanaArgs({
+ depth: 1,
+ scope: 'subs',
+ timeout: 60,
+ headless: true,
+ customFlags: ['-known-files', 'all'],
+ });
+
+ expect(args).toContain('-headless');
+ expect(args).toContain('-timeout');
+ expect(args[args.indexOf('-timeout') + 1]).toBe('60');
+ expect(args.slice(-2)).toEqual(['-known-files', 'all']);
+ });
+});
diff --git a/worker/src/components/security/__tests__/nuclei.test.ts b/worker/src/components/security/__tests__/nuclei.test.ts
index c423c1a1..335e0a2a 100644
--- a/worker/src/components/security/__tests__/nuclei.test.ts
+++ b/worker/src/components/security/__tests__/nuclei.test.ts
@@ -1,6 +1,6 @@
import { describe, test, expect, beforeEach, mock } from 'bun:test';
import { componentRegistry } from '@sentris/component-sdk';
-import type { NucleiInput, NucleiOutput } from '../nuclei';
+import { buildNucleiDockerCommand, type NucleiInput, type NucleiOutput } from '../nuclei';
// Import to trigger registration
import '../nuclei';
@@ -111,7 +111,7 @@ describe('Nuclei Component', () => {
expect(parsedParams.retries).toBe(1);
expect(parsedParams.includeRaw).toBe(false);
expect(parsedParams.followRedirects).toBe(false);
- expect(parsedParams.updateTemplates).toBe(false);
+ expect(parsedParams.updateTemplates).toBe(true);
expect(parsedParams.disableHttpx).toBe(true);
});
@@ -216,6 +216,20 @@ describe('Nuclei Component', () => {
});
});
+describe('Nuclei Docker launcher', () => {
+ test('updates built-in templates before forwarding scanner arguments', () => {
+ const scanArgs = ['-jsonl', '-l', '/inputs/targets.txt', '-t', 'http/exposures/'];
+
+ const command = buildNucleiDockerCommand(scanArgs, true);
+
+ expect(command[0]).toBe('-lc');
+ expect(command[1]).toContain('nuclei -update-templates');
+ expect(command[1]).toContain('exec nuclei "$@"');
+ expect(command.slice(2)).toEqual(['nuclei', ...scanArgs]);
+ expect(command).not.toContain('-update-templates');
+ });
+});
+
describe('Nuclei Helper Functions', () => {
describe('validateNucleiTemplate', () => {
// Import the helper (we'll need to export it from nuclei.ts)
@@ -422,8 +436,9 @@ describe('Nuclei Integration', () => {
const component = componentRegistry.get('sentris.nuclei.scan')!;
expect(component!.runner.kind).toBe('docker');
if (component!.runner.kind === 'docker') {
- expect(component!.runner.image).toBe('ghcr.io/zebbern/nuclei:latest');
- expect(component!.runner.entrypoint).toBe('nuclei');
+ expect(component!.runner.image).toBe('projectdiscovery/nuclei:latest');
+ expect(component!.runner.entrypoint).toBe('sh');
+ expect(component!.runner.memoryLimit).toBe('1g');
}
});
diff --git a/worker/src/components/security/__tests__/nvd.test.ts b/worker/src/components/security/__tests__/nvd.test.ts
new file mode 100644
index 00000000..c892d0d4
--- /dev/null
+++ b/worker/src/components/security/__tests__/nvd.test.ts
@@ -0,0 +1,173 @@
+import { afterEach, describe, expect, it, vi } from 'bun:test';
+import {
+ componentRegistry,
+ createExecutionContext,
+ type ExecutionContext,
+} from '@sentris/component-sdk';
+import { buildNvdCveUrl, type NvdCveInput, type NvdCveOutput } from '../nvd';
+import '../nvd';
+
+const sampleNvdResponse = {
+ resultsPerPage: 1,
+ startIndex: 0,
+ totalResults: 1,
+ vulnerabilities: [
+ {
+ cve: {
+ id: 'CVE-2024-3094',
+ published: '2024-03-29T17:15:07.547',
+ lastModified: '2025-02-01T15:15:00.000',
+ vulnStatus: 'Analyzed',
+ descriptions: [
+ {
+ lang: 'en',
+ value: 'Sample backdoor vulnerability description.',
+ },
+ ],
+ },
+ },
+ ],
+};
+
+describe('NVD CVE query component', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('registers with component metadata', () => {
+ const component = componentRegistry.get('sentris.nvd.cve.query');
+
+ expect(component).toBeDefined();
+ expect(component?.label).toBe('NVD CVE Query');
+ expect(component?.category).toBe('security');
+ });
+
+ it('builds cveIds queries and excludes rejected CVEs by default', () => {
+ const url = buildNvdCveUrl({
+ cveIds: [' cve-2024-3094 ', 'CVE-2024-3094', 'CVE-2021-44228'],
+ keywordSearch: '',
+ resultsPerPage: 50,
+ includeRejected: false,
+ });
+
+ expect(url).toContain('https://services.nvd.nist.gov/rest/json/cves/2.0?');
+ expect(url).toContain('cveIds=CVE-2024-3094%2CCVE-2021-44228');
+ expect(url).toContain('resultsPerPage=50');
+ expect(url).toContain('startIndex=0');
+ expect(url).toContain('noRejected');
+ expect(url).not.toContain('cveId=');
+ });
+
+ it('builds keywordSearch queries when no CVE IDs are supplied', () => {
+ const url = buildNvdCveUrl({
+ cveIds: [],
+ keywordSearch: 'apache airflow',
+ resultsPerPage: 20,
+ includeRejected: true,
+ });
+
+ expect(url).toContain('keywordSearch=apache+airflow');
+ expect(url).toContain('resultsPerPage=20');
+ expect(url).not.toContain('noRejected');
+ });
+
+ it('queries NVD, forwards apiKey as a header, and returns source health metadata', async () => {
+ const component = componentRegistry.get('sentris.nvd.cve.query');
+ if (!component) throw new Error('NVD CVE component was not registered');
+
+ const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
+ expect(init?.method).toBe('GET');
+ expect((init?.headers as Record).apiKey).toBe('nvd-test-key');
+ return new Response(JSON.stringify(sampleNvdResponse), {
+ status: 200,
+ statusText: 'OK',
+ headers: { 'Content-Type': 'application/json' },
+ });
+ });
+
+ const context = createExecutionContext({
+ runId: 'test-run',
+ componentRef: 'nvd-test',
+ });
+ context.http.fetch = fetchMock as unknown as ExecutionContext['http']['fetch'];
+
+ const result = await component.execute(
+ {
+ inputs: {
+ cveIds: ['CVE-2024-3094'],
+ keywordSearch: '',
+ apiKey: 'nvd-test-key',
+ },
+ params: {
+ resultsPerPage: 20,
+ includeRejected: false,
+ timeoutMs: 30000,
+ failOnUnavailable: false,
+ },
+ },
+ context,
+ );
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(result).toMatchObject({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ warnings: [],
+ totalResults: 1,
+ returnedResults: 1,
+ });
+ expect(result.vulnerabilities).toHaveLength(1);
+ expect(result.dataSource).toMatchObject({
+ name: 'nvd',
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ });
+ expect(result.data).toEqual(sampleNvdResponse);
+ });
+
+ it('returns a non-fatal timeout result when failOnUnavailable is false', async () => {
+ const component = componentRegistry.get('sentris.nvd.cve.query');
+ if (!component) throw new Error('NVD CVE component was not registered');
+
+ const timeoutError = new DOMException('The operation was aborted.', 'AbortError');
+ const fetchMock = vi.fn(async () => {
+ throw timeoutError;
+ });
+
+ const context = createExecutionContext({
+ runId: 'test-run',
+ componentRef: 'nvd-timeout-test',
+ });
+ context.http.fetch = fetchMock as unknown as ExecutionContext['http']['fetch'];
+
+ const result = await component.execute(
+ {
+ inputs: {
+ cveIds: [],
+ keywordSearch: 'nginx',
+ apiKey: '',
+ },
+ params: {
+ resultsPerPage: 5,
+ includeRejected: false,
+ timeoutMs: 1000,
+ failOnUnavailable: false,
+ },
+ },
+ context,
+ );
+
+ expect(result).toMatchObject({
+ ok: false,
+ status: 0,
+ statusText: 'Timeout',
+ vulnerabilities: [],
+ totalResults: 0,
+ returnedResults: 0,
+ });
+ expect(result.warnings).toEqual(['NVD CVE query unavailable: Timeout']);
+ expect(result.data).toEqual({ error: 'Timeout' });
+ });
+});
diff --git a/worker/src/components/security/__tests__/osv.test.ts b/worker/src/components/security/__tests__/osv.test.ts
new file mode 100644
index 00000000..7b3cca1f
--- /dev/null
+++ b/worker/src/components/security/__tests__/osv.test.ts
@@ -0,0 +1,160 @@
+import { afterEach, describe, expect, it, vi } from 'bun:test';
+import {
+ componentRegistry,
+ createExecutionContext,
+ type ExecutionContext,
+} from '@sentris/component-sdk';
+import {
+ extractFixedVersions,
+ inferOsvSeverity,
+ parsePackageSpec,
+ type OsvInput,
+ type OsvOutput,
+} from '../osv';
+import '../osv';
+
+const sampleAdvisory = {
+ id: 'GHSA-test',
+ summary: 'Prototype Pollution in test package',
+ aliases: ['CVE-2026-12345'],
+ modified: '2026-01-01T00:00:00Z',
+ published: '2025-12-01T00:00:00Z',
+ database_specific: {
+ severity: 'HIGH',
+ },
+ references: [
+ {
+ type: 'ADVISORY',
+ url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-12345',
+ },
+ ],
+ affected: [
+ {
+ package: {
+ ecosystem: 'npm',
+ name: 'lodash',
+ },
+ ranges: [
+ {
+ type: 'SEMVER',
+ events: [{ introduced: '0' }, { fixed: '4.17.21' }],
+ },
+ ],
+ },
+ ],
+};
+
+describe('OSV dependency query component', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('registers with component metadata', () => {
+ const component = componentRegistry.get('sentris.osv.query');
+
+ expect(component).toBeDefined();
+ expect(component?.label).toBe('OSV Dependency Advisory Query');
+ expect(component?.category).toBe('security');
+ });
+
+ it('parses npm package specs with scoped package support', () => {
+ expect(parsePackageSpec('lodash@4.17.20', 'npm')).toEqual({
+ spec: 'lodash@4.17.20',
+ name: 'lodash',
+ version: '4.17.20',
+ ecosystem: 'npm',
+ });
+ expect(parsePackageSpec('@scope/pkg@1.2.3', 'npm')).toEqual({
+ spec: '@scope/pkg@1.2.3',
+ name: '@scope/pkg',
+ version: '1.2.3',
+ ecosystem: 'npm',
+ });
+ expect(parsePackageSpec('axios', 'npm')).toEqual({
+ spec: 'axios',
+ name: 'axios',
+ version: null,
+ ecosystem: 'npm',
+ });
+ expect(parsePackageSpec('', 'npm')).toBeNull();
+ });
+
+ it('infers severity and fixed versions from hydrated OSV advisories', () => {
+ expect(inferOsvSeverity(sampleAdvisory)).toBe('high');
+ expect(extractFixedVersions(sampleAdvisory)).toEqual(['4.17.21']);
+ });
+
+ it('queries OSV, hydrates advisories, and emits analytics-ready results', async () => {
+ const component = componentRegistry.get('sentris.osv.query');
+ if (!component) throw new Error('OSV component was not registered');
+
+ const fetchMock = vi.fn(async (url: string | URL | Request): Promise => {
+ const text = String(url);
+ if (text.endsWith('/v1/querybatch')) {
+ return new Response(
+ JSON.stringify({
+ results: [{ vulns: [{ id: 'GHSA-test', modified: '2026-01-01T00:00:00Z' }] }],
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+ }
+ if (text.endsWith('/v1/vulns/GHSA-test')) {
+ return new Response(JSON.stringify(sampleAdvisory), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ return new Response('not found', { status: 404 });
+ });
+
+ const context = createExecutionContext({
+ runId: 'test-run',
+ componentRef: 'osv-test',
+ });
+ context.http.fetch = fetchMock as unknown as ExecutionContext['http']['fetch'];
+
+ const result = await component.execute(
+ {
+ inputs: {
+ packageSpecs: ['lodash@4.17.20'],
+ },
+ params: {
+ ecosystem: 'npm',
+ severityFloor: 'medium',
+ hydrateAdvisories: true,
+ maxAdvisoriesPerPackage: 50,
+ includeUnknownSeverity: true,
+ },
+ },
+ context,
+ );
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(result.summary).toEqual({
+ packagesChecked: 1,
+ vulnerablePackages: 1,
+ findings: 1,
+ maliciousPackageRecords: 0,
+ countsBySeverity: { high: 1 },
+ });
+ expect(result.findings[0]).toMatchObject({
+ packageSpec: 'lodash@4.17.20',
+ packageName: 'lodash',
+ version: '4.17.20',
+ id: 'GHSA-test',
+ cves: ['CVE-2026-12345'],
+ fixedVersions: ['4.17.21'],
+ severity: 'high',
+ summary: 'Prototype Pollution in test package',
+ });
+ expect(result.results[0]).toMatchObject({
+ scanner: 'osv',
+ severity: 'high',
+ asset_key: 'lodash@4.17.20',
+ vulnerability_id: 'GHSA-test',
+ package_name: 'lodash',
+ installed_version: '4.17.20',
+ fixed_versions: ['4.17.21'],
+ });
+ });
+});
diff --git a/worker/src/components/security/httpx.ts b/worker/src/components/security/httpx.ts
index b26c0189..5dd8f02e 100644
--- a/worker/src/components/security/httpx.ts
+++ b/worker/src/components/security/httpx.ts
@@ -146,6 +146,15 @@ const findingSchema = z.object({
});
type Finding = z.infer;
+interface HttpxArgOptions {
+ ports?: string;
+ statusCodes?: string;
+ threads?: number;
+ path?: string;
+ followRedirects: boolean;
+ tlsProbe: boolean;
+ preferHttps: boolean;
+}
const outputSchema = outputs({
responses: port(z.array(findingSchema), {
@@ -188,12 +197,14 @@ const outputSchema = outputs({
}),
});
-const httpxRunnerOutputSchema = z.object({
- results: z.array(z.unknown()).optional().default([]),
- raw: z.string().optional().default(''),
- stderr: z.string().optional().default(''),
- exitCode: z.number().optional().default(0),
-});
+const httpxRunnerOutputSchema = z
+ .object({
+ results: z.array(z.unknown()).optional().default([]),
+ raw: z.string().optional().default(''),
+ stderr: z.string().optional().default(''),
+ exitCode: z.number().optional().default(0),
+ })
+ .strict();
const dockerTimeoutSeconds = (() => {
const raw = process.env.HTTPX_TIMEOUT_SECONDS;
@@ -322,29 +333,7 @@ const definition = defineComponent({
'targets.txt': targets.join('\n'),
});
- const httpxArgs: string[] = ['-json', '-silent', '-l', '/inputs/targets.txt', '-stream'];
-
- if (runnerParams.ports) {
- httpxArgs.push('-ports', runnerParams.ports);
- }
- if (runnerParams.statusCodes) {
- httpxArgs.push('-status-code', runnerParams.statusCodes);
- }
- if (typeof runnerParams.threads === 'number') {
- httpxArgs.push('-threads', String(runnerParams.threads));
- }
- if (runnerParams.path) {
- httpxArgs.push('-path', runnerParams.path);
- }
- if (runnerParams.followRedirects) {
- httpxArgs.push('-follow-redirects');
- }
- if (runnerParams.tlsProbe) {
- httpxArgs.push('-tls-probe');
- }
- if (runnerParams.preferHttps) {
- httpxArgs.push('-prefer-https');
- }
+ const httpxArgs = buildHttpxArgs(runnerParams);
const runnerConfig = {
...definition.runner,
@@ -542,6 +531,31 @@ function parseHttpxOutput(raw: string): Finding[] {
return findings;
}
+export function buildHttpxArgs(options: HttpxArgOptions): string[] {
+ const httpxArgs: string[] = ['-json', '-silent', '-l', '/inputs/targets.txt', '-stream'];
+
+ if (options.ports) {
+ httpxArgs.push('-ports', options.ports);
+ }
+ if (options.statusCodes) {
+ httpxArgs.push('-status-code', options.statusCodes);
+ }
+ if (typeof options.threads === 'number') {
+ httpxArgs.push('-threads', String(options.threads));
+ }
+ if (options.path) {
+ httpxArgs.push('-path', options.path);
+ }
+ if (options.followRedirects) {
+ httpxArgs.push('-follow-redirects');
+ }
+ if (options.tlsProbe) {
+ httpxArgs.push('-tls-probe');
+ }
+
+ return httpxArgs;
+}
+
function normaliseNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
diff --git a/worker/src/components/security/katana.ts b/worker/src/components/security/katana.ts
index f5c4d140..66794e72 100644
--- a/worker/src/components/security/katana.ts
+++ b/worker/src/components/security/katana.ts
@@ -144,6 +144,55 @@ const splitCliArgs = (input: string): string[] => {
return args;
};
+type KatanaScope = z.infer['scope'];
+
+export function mapKatanaScope(scope: KatanaScope): 'fqdn' | 'rdn' | 'dn' {
+ switch (scope) {
+ case 'fuzzy':
+ return 'rdn';
+ case 'subs':
+ return 'dn';
+ case 'strict':
+ default:
+ return 'fqdn';
+ }
+}
+
+export function buildKatanaArgs(options: {
+ depth: number;
+ scope: KatanaScope;
+ timeout?: number;
+ headless: boolean;
+ customFlags: string[];
+}): string[] {
+ const args: string[] = [
+ '-list',
+ '/inputs/targets.txt',
+ '-jsonl',
+ '-silent',
+ '-depth',
+ String(options.depth),
+ '-field-scope',
+ mapKatanaScope(options.scope),
+ ];
+
+ if (options.headless) {
+ args.push('-headless');
+ }
+
+ if (options.timeout) {
+ args.push('-timeout', String(options.timeout));
+ }
+
+ for (const flag of options.customFlags) {
+ if (flag.length > 0) {
+ args.push(flag);
+ }
+ }
+
+ return args;
+}
+
const katanaRetryPolicy: ComponentRetryPolicy = {
maxAttempts: 2,
initialIntervalSeconds: 5,
@@ -152,11 +201,13 @@ const katanaRetryPolicy: ComponentRetryPolicy = {
nonRetryableErrorTypes: ['ContainerError', 'ValidationError', 'ConfigurationError'],
};
-const runnerOutputSchema = z.object({
- stdout: z.string().optional().default(''),
- stderr: z.string().optional().default(''),
- exitCode: z.number().optional().default(0),
-});
+const runnerOutputSchema = z
+ .object({
+ stdout: z.string().optional().default(''),
+ stderr: z.string().optional().default(''),
+ exitCode: z.number().optional().default(0),
+ })
+ .strict();
const definition = defineComponent({
id: 'sentris.katana.run',
@@ -195,7 +246,7 @@ const definition = defineComponent({
isLatest: true,
deprecated: false,
example:
- '`katana -u https://example.com -depth 3 -json` - Crawl example.com to depth 3 and output JSON.',
+ '`katana -u https://example.com -depth 3 -jsonl` - Crawl example.com to depth 3 and output JSONL.',
examples: [
'Discover hidden endpoints and API routes before vulnerability scanning.',
'Map application attack surface by crawling JavaScript files and forms.',
@@ -262,32 +313,13 @@ const definition = defineComponent({
});
context.logger.info(`[Katana] Created isolated volume: ${volume.getVolumeName()}`);
- // Build Katana CLI args
- const args: string[] = [
- '-list',
- '/inputs/targets.txt',
- '-json',
- '-silent',
- '-depth',
- String(parsedParams.depth),
- '-field-scope',
- parsedParams.scope,
- ];
-
- if (parsedParams.headless) {
- args.push('-headless');
- }
-
- if (parsedParams.timeout) {
- args.push('-timeout', String(parsedParams.timeout));
- }
-
- // Append custom flags last
- for (const flag of customFlagArgs) {
- if (flag.length > 0) {
- args.push(flag);
- }
- }
+ const args = buildKatanaArgs({
+ depth: parsedParams.depth,
+ scope: parsedParams.scope,
+ timeout: parsedParams.timeout,
+ headless: parsedParams.headless,
+ customFlags: customFlagArgs,
+ });
const runnerConfig: DockerRunnerConfig = {
kind: 'docker',
@@ -316,7 +348,7 @@ const definition = defineComponent({
if (parsed.success) {
rawOutput = parsed.data.stdout ?? '';
} else {
- rawOutput = '';
+ rawOutput = JSON.stringify(result);
}
} else {
rawOutput = '';
diff --git a/worker/src/components/security/nuclei.ts b/worker/src/components/security/nuclei.ts
index 18e9fa01..b2fb7fe1 100644
--- a/worker/src/components/security/nuclei.ts
+++ b/worker/src/components/security/nuclei.ts
@@ -143,7 +143,7 @@ const parameterSchema = parameters({
},
),
updateTemplates: param(
- z.boolean().default(false).describe('Update built-in templates before scanning'),
+ z.boolean().default(true).describe('Update built-in templates before scanning'),
{
label: 'Update Templates',
editor: 'boolean',
@@ -262,6 +262,14 @@ const nucleiRetryPolicy: ComponentRetryPolicy = {
nonRetryableErrorTypes: ['ContainerError', 'ValidationError', 'ConfigurationError'],
};
+export function buildNucleiDockerCommand(scanArgs: string[], updateTemplates: boolean): string[] {
+ const launcher = updateTemplates
+ ? 'nuclei -update-templates -silent || nuclei -update-templates || exit $?; exec nuclei "$@"'
+ : 'exec nuclei "$@"';
+
+ return ['-lc', launcher, 'nuclei', ...scanArgs];
+}
+
const definition = defineComponent({
id: 'sentris.nuclei.scan',
label: 'Nuclei Vulnerability Scanner',
@@ -269,19 +277,14 @@ const definition = defineComponent({
retryPolicy: nucleiRetryPolicy,
runner: {
kind: 'docker',
- // Custom image with pre-installed nuclei-templates baked in at build time.
- // The upstream projectdiscovery/nuclei:latest ships WITHOUT templates,
- // so severity-filtered scans find 0 templates and produce no results.
- // Build: docker build -t ghcr.io/zebbern/nuclei:latest docker/nuclei/
- image: 'ghcr.io/zebbern/nuclei:latest',
- entrypoint: 'nuclei',
+ image: 'projectdiscovery/nuclei:latest',
+ entrypoint: 'sh',
network: 'bridge',
timeoutSeconds: dockerTimeoutSeconds,
- // Direct binary execution (distroless image has no shell)
- // PTY compatibility achieved via -stream flag (prevents buffering)
+ memoryLimit: '1g',
command: [],
env: {
- HOME: '/home/nonroot', // Custom image runs as nonroot user
+ HOME: '/root',
},
},
inputs: inputSchema,
@@ -351,7 +354,6 @@ const definition = defineComponent({
'-duc', // Disable update check (templates pre-installed in image)
'-jsonl', // JSONL output format (nuclei v3.6.0+)
'-stream', // Stream mode: prevents buffering, required for PTY compatibility
- '-verbose', // Show findings in terminal (overrides silent mode)
'-l',
'/inputs/targets.txt', // Targets file
];
@@ -367,10 +369,6 @@ const definition = defineComponent({
args.push('-timeout', parsedParams.timeout.toString());
args.push('-retries', parsedParams.retries.toString());
- if (parsedParams.updateTemplates) {
- args.push('-update-templates');
- }
-
if (parsedParams.followRedirects) {
args.push('-follow-redirects');
}
@@ -500,14 +498,15 @@ const definition = defineComponent({
entrypoint: baseRunner.entrypoint,
network: baseRunner.network,
timeoutSeconds: baseRunner.timeoutSeconds,
+ memoryLimit: baseRunner.memoryLimit,
+ cpuLimit: baseRunner.cpuLimit,
+ pidsLimit: baseRunner.pidsLimit,
env: baseRunner.env,
- // ✅ Preserve shell wrapper + append TypeScript-built args
- command: [...(baseRunner.command ?? []), ...args],
- volumes: [
- volume.getVolumeConfig('/inputs', true),
- // ✅ Templates are pre-installed in ghcr.io/zebbern/nuclei:latest
- // No need for persistent volume since templates are baked into the image
- ],
+ command: buildNucleiDockerCommand(
+ [...(baseRunner.command ?? []), ...args],
+ parsedParams.updateTemplates,
+ ),
+ volumes: [volume.getVolumeConfig('/inputs', true)],
};
// ===== Execute nuclei =====
diff --git a/worker/src/components/security/nvd.ts b/worker/src/components/security/nvd.ts
new file mode 100644
index 00000000..a49153a9
--- /dev/null
+++ b/worker/src/components/security/nvd.ts
@@ -0,0 +1,455 @@
+import { z } from 'zod';
+import {
+ ComponentRetryPolicy,
+ componentRegistry,
+ defineComponent,
+ fromHttpResponse,
+ inputs,
+ outputs,
+ parameters,
+ param,
+ port,
+ ValidationError,
+ type ExecutionContext,
+} from '@sentris/component-sdk';
+
+const NVD_CVE_API_URL = 'https://services.nvd.nist.gov/rest/json/cves/2.0';
+const NVD_DOCS_URL = 'https://nvd.nist.gov/developers/vulnerabilities';
+
+const cveIdPattern = /^CVE-\d{4}-\d{4,}$/i;
+
+const cveIdsInputSchema = z.preprocess((value) => {
+ if (Array.isArray(value)) return value;
+ if (typeof value === 'string') {
+ return value
+ .split(/[,\s]+/)
+ .map((item) => item.trim())
+ .filter(Boolean);
+ }
+ return [];
+}, z.array(z.string()).default([]));
+
+const inputSchema = inputs({
+ cveIds: port(cveIdsInputSchema, {
+ label: 'CVE IDs',
+ description:
+ 'One or more CVE identifiers. When supplied, these take precedence over keyword search.',
+ connectionType: { kind: 'primitive', name: 'text' },
+ }),
+ keywordSearch: port(z.string().optional().default(''), {
+ label: 'Keyword Search',
+ description: 'NVD keyword search used when no CVE IDs are supplied.',
+ connectionType: { kind: 'primitive', name: 'text' },
+ }),
+ apiKey: port(z.string().optional().default(''), {
+ label: 'NVD API Key',
+ description: 'Optional NVD API key. Sent as the apiKey request header.',
+ connectionType: { kind: 'primitive', name: 'secret' },
+ }),
+});
+
+const parameterSchema = parameters({
+ resultsPerPage: param(
+ z
+ .number()
+ .int()
+ .min(1)
+ .max(2000)
+ .default(20)
+ .describe('Maximum CVE records to return from NVD.'),
+ {
+ label: 'Results Per Page',
+ editor: 'number',
+ min: 1,
+ max: 2000,
+ },
+ ),
+ includeRejected: param(
+ z.boolean().default(false).describe('Include CVE records marked rejected by NVD.'),
+ {
+ label: 'Include Rejected CVEs',
+ editor: 'boolean',
+ },
+ ),
+ timeoutMs: param(
+ z
+ .number()
+ .int()
+ .min(1000)
+ .max(120000)
+ .default(30000)
+ .describe('Request timeout in milliseconds.'),
+ {
+ label: 'Timeout (ms)',
+ editor: 'number',
+ min: 1000,
+ max: 120000,
+ },
+ ),
+ failOnUnavailable: param(
+ z
+ .boolean()
+ .default(false)
+ .describe('Throw when NVD is unavailable instead of returning warnings.'),
+ {
+ label: 'Fail On Unavailable',
+ editor: 'boolean',
+ },
+ ),
+});
+
+const dataSourceSchema = z.object({
+ name: z.literal('nvd'),
+ ok: z.boolean(),
+ status: z.number(),
+ statusText: z.string(),
+ url: z.string(),
+ docsUrl: z.string(),
+});
+
+const querySchema = z.object({
+ cveIds: z.array(z.string()),
+ keywordSearch: z.string().nullable(),
+ resultsPerPage: z.number(),
+ includeRejected: z.boolean(),
+});
+
+const summarySchema = z.object({
+ query: querySchema,
+ ok: z.boolean(),
+ status: z.number(),
+ statusText: z.string(),
+ totalResults: z.number(),
+ returnedResults: z.number(),
+ warnings: z.array(z.string()),
+});
+
+const outputSchema = outputs({
+ ok: port(z.boolean(), {
+ label: 'OK',
+ description: 'Whether NVD returned a successful HTTP response and valid JSON.',
+ }),
+ status: port(z.number(), {
+ label: 'HTTP Status',
+ description: 'NVD HTTP status code, or 0 for network/timeout failures.',
+ }),
+ statusText: port(z.string(), {
+ label: 'HTTP Status Text',
+ description: 'HTTP status text or normalized network failure reason.',
+ }),
+ url: port(z.string(), {
+ label: 'Request URL',
+ description: 'The NVD CVE API URL requested by this component.',
+ }),
+ dataSource: port(dataSourceSchema, {
+ label: 'Data Source',
+ description: 'NVD source health metadata for downstream reports.',
+ connectionType: { kind: 'primitive', name: 'json' },
+ }),
+ data: port(z.unknown(), {
+ label: 'Raw NVD Data',
+ description: 'Raw NVD CVE API response, or an error object when unavailable.',
+ allowAny: true,
+ reason: 'NVD response fields evolve over time and include nested CVE metadata.',
+ connectionType: { kind: 'primitive', name: 'json' },
+ }),
+ vulnerabilities: port(z.array(z.unknown()), {
+ label: 'Vulnerabilities',
+ description: 'NVD vulnerability records from the response body.',
+ allowAny: true,
+ reason: 'NVD CVE records contain a large schema that may evolve over time.',
+ connectionType: { kind: 'list', element: { kind: 'primitive', name: 'json' } },
+ }),
+ totalResults: port(z.number(), {
+ label: 'Total Results',
+ description: 'NVD totalResults value, or 0 when unavailable.',
+ }),
+ returnedResults: port(z.number(), {
+ label: 'Returned Results',
+ description: 'Number of vulnerability records returned in this response.',
+ }),
+ warnings: port(z.array(z.string()), {
+ label: 'Warnings',
+ description: 'Non-fatal availability or parsing warnings.',
+ }),
+ summary: port(summarySchema, {
+ label: 'Summary',
+ description: 'Query, source health, and result count summary.',
+ connectionType: { kind: 'primitive', name: 'json' },
+ }),
+});
+
+interface NvdCveUrlOptions {
+ cveIds: string[];
+ keywordSearch?: string | null;
+ resultsPerPage: number;
+ includeRejected: boolean;
+}
+
+const nvdRetryPolicy: ComponentRetryPolicy = {
+ maxAttempts: 2,
+ initialIntervalSeconds: 2,
+ maximumIntervalSeconds: 15,
+ backoffCoefficient: 2.0,
+ nonRetryableErrorTypes: ['ValidationError', 'AuthenticationError', 'ConfigurationError'],
+};
+
+function normalizeCveIds(cveIds: string[]): string[] {
+ const normalized = new Set();
+ for (const item of cveIds) {
+ const cveId = item.trim().toUpperCase();
+ if (cveIdPattern.test(cveId)) normalized.add(cveId);
+ }
+ return Array.from(normalized).slice(0, 100);
+}
+
+export function buildNvdCveUrl(options: NvdCveUrlOptions): string {
+ const cveIds = normalizeCveIds(options.cveIds);
+ const keywordSearch = String(options.keywordSearch ?? '').trim();
+ const url = new URL(NVD_CVE_API_URL);
+
+ if (cveIds.length > 0) {
+ url.searchParams.set('cveIds', cveIds.join(','));
+ } else if (keywordSearch.length > 0) {
+ url.searchParams.set('keywordSearch', keywordSearch);
+ }
+
+ url.searchParams.set('resultsPerPage', String(options.resultsPerPage));
+ url.searchParams.set('startIndex', '0');
+
+ const requestUrl = url.toString();
+ return options.includeRejected ? requestUrl : `${requestUrl}&noRejected`;
+}
+
+function classifyFetchError(error: unknown): string {
+ const name = typeof error === 'object' && error ? String((error as { name?: unknown }).name) : '';
+ if (name === 'AbortError') return 'Timeout';
+
+ const message = error instanceof Error ? error.message : String(error);
+ if (/timeout|aborted/i.test(message)) return 'Timeout';
+ return 'Network Error';
+}
+
+function fallbackResult({
+ url,
+ query,
+ status,
+ statusText,
+ error,
+}: {
+ url: string;
+ query: z.infer;
+ status: number;
+ statusText: string;
+ error?: string;
+}) {
+ const warnings = [`NVD CVE query unavailable: ${statusText || status || 'unknown error'}`];
+ const dataSource = {
+ name: 'nvd' as const,
+ ok: false,
+ status,
+ statusText,
+ url,
+ docsUrl: NVD_DOCS_URL,
+ };
+
+ return {
+ ok: false,
+ status,
+ statusText,
+ url,
+ dataSource,
+ data: { error: error || statusText },
+ vulnerabilities: [],
+ totalResults: 0,
+ returnedResults: 0,
+ warnings,
+ summary: {
+ query,
+ ok: false,
+ status,
+ statusText,
+ totalResults: 0,
+ returnedResults: 0,
+ warnings,
+ },
+ };
+}
+
+async function fetchNvdJson(
+ context: Pick,
+ url: string,
+ headers: Record,
+ timeoutMs: number,
+): Promise {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ return await context.http.fetch(url, {
+ method: 'GET',
+ headers,
+ signal: controller.signal,
+ });
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+const definition = defineComponent({
+ id: 'sentris.nvd.cve.query',
+ label: 'NVD CVE Query',
+ category: 'security',
+ runner: { kind: 'inline' },
+ retryPolicy: nvdRetryPolicy,
+ inputs: inputSchema,
+ outputs: outputSchema,
+ parameters: parameterSchema,
+ docs: 'Query the NVD CVE API by CVE ID or keyword and return raw data with normalized source health metadata.',
+ toolProvider: {
+ kind: 'component',
+ name: 'nvd_cve_query',
+ description: 'CVE metadata lookup using the NIST National Vulnerability Database.',
+ },
+ ui: {
+ slug: 'nvd-cve-query',
+ version: '1.0.0',
+ type: 'scan',
+ category: 'security',
+ description:
+ 'Look up CVE metadata in NVD by CVE ID or keyword with timeout-safe source status output.',
+ documentationUrl: NVD_DOCS_URL,
+ icon: 'ShieldAlert',
+ author: {
+ name: 'SentrisAI',
+ type: 'sentris',
+ },
+ isLatest: true,
+ deprecated: false,
+ examples: [
+ 'Fetch a known CVE such as CVE-2024-3094.',
+ 'Search for candidate CVEs from a detected service keyword such as nginx.',
+ ],
+ },
+ async execute({ inputs, params }, context) {
+ const parsedInputs = inputSchema.parse(inputs);
+ const parsedParams = parameterSchema.parse(params);
+ const cveIds = normalizeCveIds(parsedInputs.cveIds);
+ const keywordSearch = parsedInputs.keywordSearch.trim();
+
+ if (cveIds.length === 0 && keywordSearch.length === 0) {
+ throw new ValidationError('Provide at least one CVE ID or a keyword search value', {
+ fieldErrors: {
+ cveIds: ['Provide a CVE ID or keyword search value.'],
+ keywordSearch: ['Provide a CVE ID or keyword search value.'],
+ },
+ });
+ }
+
+ const query = {
+ cveIds,
+ keywordSearch: cveIds.length === 0 ? keywordSearch : null,
+ resultsPerPage: parsedParams.resultsPerPage,
+ includeRejected: parsedParams.includeRejected,
+ };
+ const url = buildNvdCveUrl(query);
+ const headers: Record = { Accept: 'application/json' };
+ const apiKey = parsedInputs.apiKey.trim();
+ if (apiKey.length > 0) headers.apiKey = apiKey;
+
+ context.logger.info(
+ `[NVD] Querying CVE API for ${cveIds.length > 0 ? cveIds.join(', ') : keywordSearch}`,
+ );
+ context.emitProgress({
+ message: `Querying NVD CVE API for ${cveIds.length > 0 ? cveIds.join(', ') : keywordSearch}`,
+ level: 'info',
+ });
+
+ try {
+ const response = await fetchNvdJson(context, url, headers, parsedParams.timeoutMs);
+ const statusText = response.statusText || `HTTP ${response.status}`;
+ if (!response.ok) {
+ const text = await response.text();
+ if (parsedParams.failOnUnavailable) throw fromHttpResponse(response, text);
+ return fallbackResult({
+ url,
+ query,
+ status: response.status,
+ statusText,
+ error: text || statusText,
+ });
+ }
+
+ let data: unknown;
+ try {
+ data = await response.json();
+ } catch (error) {
+ if (parsedParams.failOnUnavailable) throw error;
+ return fallbackResult({
+ url,
+ query,
+ status: response.status,
+ statusText: 'Invalid JSON',
+ error: error instanceof Error ? error.message : 'Invalid JSON',
+ });
+ }
+
+ const record = data && typeof data === 'object' ? (data as Record) : {};
+ const vulnerabilities = Array.isArray(record.vulnerabilities) ? record.vulnerabilities : [];
+ const totalResults =
+ typeof record.totalResults === 'number' ? record.totalResults : vulnerabilities.length;
+ const warnings: string[] = [];
+ const dataSource = {
+ name: 'nvd' as const,
+ ok: true,
+ status: response.status,
+ statusText,
+ url,
+ docsUrl: NVD_DOCS_URL,
+ };
+
+ context.logger.info(`[NVD] Returned ${vulnerabilities.length} CVE record(s)`);
+
+ return {
+ ok: true,
+ status: response.status,
+ statusText,
+ url,
+ dataSource,
+ data,
+ vulnerabilities,
+ totalResults,
+ returnedResults: vulnerabilities.length,
+ warnings,
+ summary: {
+ query,
+ ok: true,
+ status: response.status,
+ statusText,
+ totalResults,
+ returnedResults: vulnerabilities.length,
+ warnings,
+ },
+ };
+ } catch (error) {
+ if (parsedParams.failOnUnavailable) throw error;
+ const statusText = classifyFetchError(error);
+ context.logger.warn(
+ `[NVD] CVE query failed: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ return fallbackResult({
+ url,
+ query,
+ status: 0,
+ statusText,
+ });
+ }
+ },
+});
+
+componentRegistry.register(definition);
+
+type NvdCveInput = typeof inputSchema;
+type NvdCveOutput = typeof outputSchema;
+
+export type { NvdCveInput, NvdCveOutput };
+export { definition };
diff --git a/worker/src/components/security/osv.ts b/worker/src/components/security/osv.ts
new file mode 100644
index 00000000..62151028
--- /dev/null
+++ b/worker/src/components/security/osv.ts
@@ -0,0 +1,538 @@
+import { z } from 'zod';
+import {
+ ComponentRetryPolicy,
+ componentRegistry,
+ defineComponent,
+ fromHttpResponse,
+ generateFindingHash,
+ inputs,
+ outputs,
+ parameters,
+ param,
+ port,
+ ValidationError,
+ analyticsResultSchema,
+ type AnalyticsResult,
+ type ExecutionContext,
+} from '@sentris/component-sdk';
+
+const OSV_API_BASE = 'https://api.osv.dev/v1';
+
+const severityRank = {
+ unknown: 0,
+ low: 1,
+ medium: 2,
+ high: 3,
+ critical: 4,
+} as const;
+
+type OsvSeverity = keyof typeof severityRank;
+type AnalyticsSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info';
+
+const normalizedPackageSchema = z.object({
+ spec: z.string(),
+ name: z.string(),
+ version: z.string().nullable(),
+ ecosystem: z.string(),
+});
+
+const osvReferenceSchema = z.object({
+ type: z.string().optional(),
+ url: z.string().optional(),
+});
+
+const osvFindingSchema = z.object({
+ packageSpec: z.string(),
+ packageName: z.string().nullable(),
+ version: z.string().nullable(),
+ id: z.string().nullable(),
+ aliases: z.array(z.string()),
+ cves: z.array(z.string()),
+ isMaliciousPackageRecord: z.boolean(),
+ severity: z.enum(['critical', 'high', 'medium', 'low', 'unknown']),
+ summary: z.string().nullable(),
+ published: z.string().nullable(),
+ modified: z.string().nullable(),
+ fixedVersions: z.array(z.string()),
+ references: z.array(osvReferenceSchema),
+});
+
+const summarySchema = z.object({
+ packagesChecked: z.number(),
+ vulnerablePackages: z.number(),
+ findings: z.number(),
+ maliciousPackageRecords: z.number(),
+ countsBySeverity: z.record(z.string(), z.number()),
+});
+
+const inputSchema = inputs({
+ packageSpecs: port(
+ z
+ .array(z.string().min(1))
+ .min(1, 'At least one package spec is required')
+ .describe('Package names with optional versions, for example lodash@4.17.20.'),
+ {
+ label: 'Package Specs',
+ description:
+ 'Package names with optional versions. Scoped npm packages are supported, for example @scope/pkg@1.2.3.',
+ connectionType: { kind: 'list', element: { kind: 'primitive', name: 'text' } },
+ },
+ ),
+});
+
+const parameterSchema = parameters({
+ ecosystem: param(z.string().default('npm').describe('OSV package ecosystem to query.'), {
+ label: 'Ecosystem',
+ editor: 'text',
+ description: 'OSV ecosystem name, for example npm, PyPI, Go, Maven, or crates.io.',
+ }),
+ severityFloor: param(z.enum(['critical', 'high', 'medium', 'low', 'unknown']).default('medium'), {
+ label: 'Severity Floor',
+ editor: 'select',
+ options: [
+ { label: 'Critical', value: 'critical' },
+ { label: 'High', value: 'high' },
+ { label: 'Medium', value: 'medium' },
+ { label: 'Low', value: 'low' },
+ { label: 'Unknown', value: 'unknown' },
+ ],
+ description:
+ 'Known severities below this level are filtered out. Unknown severities are controlled separately.',
+ }),
+ hydrateAdvisories: param(
+ z.boolean().default(true).describe('Fetch full OSV advisory records for returned IDs.'),
+ {
+ label: 'Hydrate Advisories',
+ editor: 'boolean',
+ description:
+ 'OSV querybatch returns advisory IDs only. Hydration fetches summaries, aliases, references, severities, and fixed versions.',
+ },
+ ),
+ maxAdvisoriesPerPackage: param(
+ z
+ .number()
+ .int()
+ .min(1)
+ .max(100)
+ .default(50)
+ .describe('Maximum advisories to process per package.'),
+ {
+ label: 'Max Advisories Per Package',
+ editor: 'number',
+ min: 1,
+ max: 100,
+ },
+ ),
+ includeUnknownSeverity: param(
+ z.boolean().default(true).describe('Keep advisories where OSV does not expose severity.'),
+ {
+ label: 'Include Unknown Severity',
+ editor: 'boolean',
+ description:
+ 'Useful for malicious-package records and ecosystem advisories that do not include CVSS metadata.',
+ },
+ ),
+});
+
+const outputSchema = outputs({
+ findings: port(z.array(osvFindingSchema), {
+ label: 'Findings',
+ description: 'Prioritized OSV advisories for the queried package specs.',
+ connectionType: { kind: 'list', element: { kind: 'primitive', name: 'json' } },
+ }),
+ summary: port(summarySchema, {
+ label: 'Summary',
+ description: 'Counts by package, severity, and malicious-package record status.',
+ connectionType: { kind: 'primitive', name: 'json' },
+ }),
+ packages: port(z.array(normalizedPackageSchema), {
+ label: 'Normalized Packages',
+ description: 'Parsed package specs sent to OSV.',
+ connectionType: { kind: 'list', element: { kind: 'primitive', name: 'json' } },
+ }),
+ rawResults: port(z.unknown(), {
+ label: 'Raw OSV Results',
+ description: 'Raw OSV querybatch response for troubleshooting.',
+ allowAny: true,
+ reason: 'OSV response shape may evolve and can include pagination tokens.',
+ connectionType: { kind: 'primitive', name: 'json' },
+ }),
+ results: port(z.array(analyticsResultSchema()), {
+ label: 'Results',
+ description:
+ 'Analytics-ready findings with scanner, finding_hash, severity, package, and advisory details.',
+ }),
+});
+
+type NormalizedPackage = z.infer;
+type OsvFinding = z.infer;
+
+interface OsvListedVulnerability {
+ id?: string;
+ modified?: string;
+ [key: string]: unknown;
+}
+
+interface OsvQueryBatchResult {
+ results?: {
+ vulns?: OsvListedVulnerability[];
+ next_page_token?: string;
+ }[];
+}
+
+const osvRetryPolicy: ComponentRetryPolicy = {
+ maxAttempts: 3,
+ initialIntervalSeconds: 2,
+ maximumIntervalSeconds: 30,
+ backoffCoefficient: 2.0,
+ nonRetryableErrorTypes: ['ValidationError', 'AuthenticationError', 'ConfigurationError'],
+};
+
+function asRecord(value: unknown): Record {
+ return value && typeof value === 'object' ? (value as Record) : {};
+}
+
+function normalizeSeverity(value: unknown): OsvSeverity {
+ const text = String(value ?? '').toLowerCase();
+ if (text.includes('critical')) return 'critical';
+ if (text.includes('high')) return 'high';
+ if (text.includes('moderate') || text.includes('medium')) return 'medium';
+ if (text.includes('low')) return 'low';
+ return 'unknown';
+}
+
+function severityFromCvssVector(value: unknown): OsvSeverity {
+ const vector = String(value ?? '').toUpperCase();
+ if (!vector.startsWith('CVSS:')) return 'unknown';
+ if (
+ vector.includes('/AV:N') &&
+ vector.includes('/AC:L') &&
+ (vector.includes('/C:H') || vector.includes('/I:H') || vector.includes('/A:H'))
+ ) {
+ return 'high';
+ }
+ if (vector.includes('/C:H') || vector.includes('/I:H') || vector.includes('/A:H')) {
+ return 'medium';
+ }
+ if (vector.includes('/C:L') || vector.includes('/I:L') || vector.includes('/A:L')) {
+ return 'low';
+ }
+ return 'unknown';
+}
+
+function toAnalyticsSeverity(severity: OsvSeverity): AnalyticsSeverity {
+ return severity === 'unknown' ? 'info' : severity;
+}
+
+export function parsePackageSpec(spec: string, defaultEcosystem: string): NormalizedPackage | null {
+ const trimmed = spec.trim();
+ if (!trimmed) return null;
+
+ const versionAt = trimmed.lastIndexOf('@');
+ const hasVersion = versionAt > 0;
+ const name = hasVersion ? trimmed.slice(0, versionAt) : trimmed;
+ const version = hasVersion ? trimmed.slice(versionAt + 1) : null;
+
+ if (!name.trim()) return null;
+
+ return {
+ spec: trimmed,
+ name: name.trim(),
+ version: version?.trim() || null,
+ ecosystem: defaultEcosystem.trim() || 'npm',
+ };
+}
+
+export function inferOsvSeverity(vuln: unknown): OsvSeverity {
+ const record = asRecord(vuln);
+ const candidates: OsvSeverity[] = [];
+ const databaseSpecific = asRecord(record.database_specific ?? record.databaseSpecific);
+ const databaseSeverity = databaseSpecific.severity;
+ if (databaseSeverity) candidates.push(normalizeSeverity(databaseSeverity));
+
+ if (Array.isArray(record.severity)) {
+ for (const item of record.severity) {
+ const severityRecord = asRecord(item);
+ candidates.push(normalizeSeverity(severityRecord.score));
+ candidates.push(severityFromCvssVector(severityRecord.score));
+ }
+ }
+
+ return candidates.sort((a, b) => severityRank[b] - severityRank[a])[0] ?? 'unknown';
+}
+
+export function extractFixedVersions(vuln: unknown): string[] {
+ const fixedVersions = new Set();
+ const record = asRecord(vuln);
+ const affected = Array.isArray(record.affected) ? record.affected : [];
+
+ for (const affectedItem of affected) {
+ const affectedRecord = asRecord(affectedItem);
+ const ranges = Array.isArray(affectedRecord.ranges) ? affectedRecord.ranges : [];
+ for (const range of ranges) {
+ const rangeRecord = asRecord(range);
+ const events = Array.isArray(rangeRecord.events) ? rangeRecord.events : [];
+ for (const event of events) {
+ const fixed = asRecord(event).fixed;
+ if (typeof fixed === 'string' && fixed.trim().length > 0) {
+ fixedVersions.add(fixed.trim());
+ }
+ }
+ }
+ }
+
+ return Array.from(fixedVersions);
+}
+
+function getStringArray(value: unknown): string[] {
+ return Array.isArray(value)
+ ? value.map((item) => String(item)).filter((item) => item.length > 0)
+ : [];
+}
+
+function getReferences(value: unknown): { type?: string; url?: string }[] {
+ return Array.isArray(value)
+ ? value.slice(0, 8).map((item) => {
+ const reference = asRecord(item);
+ return {
+ type: typeof reference.type === 'string' ? reference.type : undefined,
+ url: typeof reference.url === 'string' ? reference.url : undefined,
+ };
+ })
+ : [];
+}
+
+function buildFinding(
+ listedVuln: OsvListedVulnerability,
+ hydratedVuln: unknown,
+ pkg: NormalizedPackage,
+): OsvFinding {
+ const vuln = asRecord(hydratedVuln);
+ const aliases = getStringArray(vuln.aliases);
+ const id =
+ typeof vuln.id === 'string'
+ ? vuln.id
+ : typeof listedVuln.id === 'string'
+ ? listedVuln.id
+ : null;
+
+ return {
+ packageSpec: pkg.spec,
+ packageName: pkg.name,
+ version: pkg.version,
+ id,
+ aliases,
+ cves: aliases.filter((alias) => alias.startsWith('CVE-')),
+ isMaliciousPackageRecord:
+ String(id ?? '').startsWith('MAL-') || aliases.some((alias) => alias.startsWith('MAL-')),
+ severity: inferOsvSeverity(vuln),
+ summary: typeof vuln.summary === 'string' ? vuln.summary : null,
+ published: typeof vuln.published === 'string' ? vuln.published : null,
+ modified:
+ typeof vuln.modified === 'string'
+ ? vuln.modified
+ : typeof listedVuln.modified === 'string'
+ ? listedVuln.modified
+ : null,
+ fixedVersions: extractFixedVersions(vuln),
+ references: getReferences(vuln.references),
+ };
+}
+
+type HttpFetchContext = Pick;
+
+async function fetchJson(
+ context: HttpFetchContext,
+ url: string,
+ init?: RequestInit,
+): Promise {
+ const response = await context.http.fetch(url, init);
+ if (!response.ok) {
+ const text = await response.text();
+ throw fromHttpResponse(response, text);
+ }
+ return response.json();
+}
+
+const definition = defineComponent({
+ id: 'sentris.osv.query',
+ label: 'OSV Dependency Advisory Query',
+ category: 'security',
+ runner: { kind: 'inline' },
+ retryPolicy: osvRetryPolicy,
+ inputs: inputSchema,
+ outputs: outputSchema,
+ parameters: parameterSchema,
+ docs: 'Query OSV.dev for known package vulnerabilities and malicious-package advisories. Supports package/version specs, advisory hydration, severity filtering, and analytics-ready output.',
+ toolProvider: {
+ kind: 'component',
+ name: 'osv_dependency_query',
+ description: 'Package vulnerability and malicious advisory lookup using OSV.dev.',
+ },
+ ui: {
+ slug: 'osv-query',
+ version: '1.0.0',
+ type: 'scan',
+ category: 'security',
+ description:
+ 'Check package/version specs against OSV.dev and return CVEs, fixed versions, references, and analytics-ready findings.',
+ documentationUrl: 'https://google.github.io/osv.dev/api/',
+ icon: 'Shield',
+ author: {
+ name: 'SentrisAI',
+ type: 'sentris',
+ },
+ isLatest: true,
+ deprecated: false,
+ examples: [
+ 'Check npm package versions such as lodash@4.17.20 and minimist@0.0.8.',
+ 'Look up malicious-package advisories for dependency triage.',
+ ],
+ },
+ async execute({ inputs, params }, context) {
+ const parsedParams = parameterSchema.parse(params);
+ const packages = inputs.packageSpecs
+ .map((spec) => parsePackageSpec(spec, parsedParams.ecosystem))
+ .filter((pkg): pkg is NormalizedPackage => Boolean(pkg));
+
+ if (packages.length === 0) {
+ throw new ValidationError('At least one valid package spec is required', {
+ fieldErrors: { packageSpecs: ['At least one valid package spec is required'] },
+ });
+ }
+
+ context.logger.info(`[OSV] Querying ${packages.length} package(s)`);
+ context.emitProgress({
+ message: `Querying OSV for ${packages.length} package(s)`,
+ level: 'info',
+ });
+
+ const rawResults = (await fetchJson(context, `${OSV_API_BASE}/querybatch`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ body: JSON.stringify({
+ queries: packages.map((pkg) => ({
+ package: {
+ name: pkg.name,
+ ecosystem: pkg.ecosystem,
+ },
+ ...(pkg.version ? { version: pkg.version } : {}),
+ })),
+ }),
+ })) as OsvQueryBatchResult;
+
+ const results = Array.isArray(rawResults.results) ? rawResults.results : [];
+ const hydratedCache = new Map();
+ const findings: OsvFinding[] = [];
+
+ for (let index = 0; index < results.length; index++) {
+ const result = results[index];
+ const pkg = packages[index];
+ if (!pkg) continue;
+
+ const listedVulns = Array.isArray(result.vulns)
+ ? result.vulns.slice(0, parsedParams.maxAdvisoriesPerPackage)
+ : [];
+
+ for (const listedVuln of listedVulns) {
+ let advisory: unknown = listedVuln;
+ const advisoryId = typeof listedVuln.id === 'string' ? listedVuln.id : '';
+
+ if (parsedParams.hydrateAdvisories && advisoryId.length > 0) {
+ if (hydratedCache.has(advisoryId)) {
+ advisory = hydratedCache.get(advisoryId);
+ } else {
+ try {
+ advisory = await fetchJson(
+ context,
+ `${OSV_API_BASE}/vulns/${encodeURIComponent(advisoryId)}`,
+ {
+ method: 'GET',
+ headers: { Accept: 'application/json' },
+ },
+ );
+ hydratedCache.set(advisoryId, advisory);
+ } catch (error) {
+ context.logger.warn(
+ `[OSV] Failed to hydrate ${advisoryId}: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ );
+ }
+ }
+ }
+
+ const finding = buildFinding(listedVuln, advisory, pkg);
+ if (finding.severity === 'unknown' && !parsedParams.includeUnknownSeverity) continue;
+ if (
+ finding.severity !== 'unknown' &&
+ severityRank[finding.severity] < severityRank[parsedParams.severityFloor]
+ ) {
+ continue;
+ }
+ findings.push(finding);
+ }
+ }
+
+ findings.sort(
+ (a, b) =>
+ severityRank[b.severity] - severityRank[a.severity] ||
+ String(b.modified ?? '').localeCompare(String(a.modified ?? '')),
+ );
+
+ const countsBySeverity = findings.reduce>((acc, finding) => {
+ acc[finding.severity] = (acc[finding.severity] ?? 0) + 1;
+ return acc;
+ }, {});
+
+ const vulnerablePackages = new Set(findings.map((finding) => finding.packageSpec));
+ const summary = {
+ packagesChecked: packages.length,
+ vulnerablePackages: vulnerablePackages.size,
+ findings: findings.length,
+ maliciousPackageRecords: findings.filter((finding) => finding.isMaliciousPackageRecord)
+ .length,
+ countsBySeverity,
+ };
+
+ const analyticsResults: AnalyticsResult[] = findings.map((finding) => ({
+ scanner: 'osv',
+ finding_hash: generateFindingHash(
+ finding.id ?? 'unknown-osv-advisory',
+ finding.packageSpec,
+ finding.version ?? '',
+ ),
+ severity: toAnalyticsSeverity(finding.severity),
+ asset_key: finding.packageSpec,
+ vulnerability_id: finding.id ?? undefined,
+ package_name: finding.packageName ?? undefined,
+ installed_version: finding.version ?? undefined,
+ fixed_versions: finding.fixedVersions,
+ aliases: finding.aliases,
+ cves: finding.cves,
+ title: finding.summary ?? undefined,
+ malicious_package_record: finding.isMaliciousPackageRecord,
+ }));
+
+ context.logger.info(`[OSV] Found ${findings.length} advisory finding(s)`);
+
+ return {
+ findings,
+ summary,
+ packages,
+ rawResults,
+ results: analyticsResults,
+ };
+ },
+});
+
+componentRegistry.register(definition);
+
+type OsvInput = typeof inputSchema;
+type OsvOutput = typeof outputSchema;
+
+export type { OsvInput, OsvOutput };
+export { definition };
diff --git a/worker/src/temporal/__tests__/workflow-diagnostics.test.ts b/worker/src/temporal/__tests__/workflow-diagnostics.test.ts
new file mode 100644
index 00000000..b77fd328
--- /dev/null
+++ b/worker/src/temporal/__tests__/workflow-diagnostics.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, test } from 'bun:test';
+
+import { shouldLogWorkflowDiagnostics } from '../workflow-diagnostics';
+
+describe('workflow diagnostics', () => {
+ test('does not throw when process is unavailable in the workflow sandbox', () => {
+ const originalProcess = (globalThis as { process?: unknown }).process;
+
+ try {
+ Reflect.deleteProperty(globalThis, 'process');
+
+ expect(() => shouldLogWorkflowDiagnostics()).not.toThrow();
+ expect(shouldLogWorkflowDiagnostics()).toBe(false);
+ } finally {
+ (globalThis as { process?: unknown }).process = originalProcess;
+ }
+ });
+});
diff --git a/worker/src/temporal/activities/__tests__/mcp.activity.test.ts b/worker/src/temporal/activities/__tests__/mcp.activity.test.ts
index a8aa9cb6..79dc5eb8 100644
--- a/worker/src/temporal/activities/__tests__/mcp.activity.test.ts
+++ b/worker/src/temporal/activities/__tests__/mcp.activity.test.ts
@@ -14,6 +14,7 @@ mock.module('node:util', () => ({
// Import AFTER mocks
import {
+ buildInternalMcpUrl,
registerComponentToolActivity,
registerRemoteMcpActivity,
registerLocalMcpActivity,
@@ -65,6 +66,15 @@ describe('MCP Activities', () => {
// ── callInternalApi (tested indirectly) ──────────────────────────────────
describe('callInternalApi error handling', () => {
+ it('does not double-prefix /api/v1 when SENTRIS_API_BASE_URL is already versioned', () => {
+ expect(buildInternalMcpUrl('http://localhost:3211/api/v1', 'cleanup')).toBe(
+ 'http://localhost:3211/api/v1/internal/mcp/cleanup',
+ );
+ expect(buildInternalMcpUrl('http://localhost:3211', 'cleanup')).toBe(
+ 'http://localhost:3211/api/v1/internal/mcp/cleanup',
+ );
+ });
+
it('throws non-retryable ApplicationFailure when INTERNAL_SERVICE_TOKEN is missing', async () => {
delete process.env.INTERNAL_SERVICE_TOKEN;
diff --git a/worker/src/temporal/activities/mcp.activity.ts b/worker/src/temporal/activities/mcp.activity.ts
index 4c8d481e..42b70be2 100644
--- a/worker/src/temporal/activities/mcp.activity.ts
+++ b/worker/src/temporal/activities/mcp.activity.ts
@@ -23,6 +23,16 @@ function normalizeBaseUrl(url: string): string {
return url.endsWith('/') ? url.slice(0, -1) : url;
}
+function normalizeVersionedApiBaseUrl(url: string): string {
+ const baseUrl = normalizeBaseUrl(url);
+ return baseUrl.endsWith('/api/v1') ? baseUrl : `${baseUrl}/api/v1`;
+}
+
+export function buildInternalMcpUrl(baseUrl: string, path: string): string {
+ const normalizedPath = path.replace(/^\/+/, '');
+ return `${normalizeVersionedApiBaseUrl(baseUrl)}/internal/mcp/${normalizedPath}`;
+}
+
async function callInternalApi(path: string, body: any) {
const internalToken = process.env.INTERNAL_SERVICE_TOKEN;
if (!internalToken) {
@@ -32,8 +42,7 @@ async function callInternalApi(path: string, body: any) {
);
}
- const baseUrl = normalizeBaseUrl(DEFAULT_API_BASE_URL);
- const url = `${baseUrl}/api/v1/internal/mcp/${path}`;
+ const url = buildInternalMcpUrl(DEFAULT_API_BASE_URL, path);
const response = await fetch(url, {
method: 'POST',
headers: {
diff --git a/worker/src/temporal/workflow-diagnostics.ts b/worker/src/temporal/workflow-diagnostics.ts
index 28f5f522..cac7d48e 100644
--- a/worker/src/temporal/workflow-diagnostics.ts
+++ b/worker/src/temporal/workflow-diagnostics.ts
@@ -1,5 +1,5 @@
export function shouldLogWorkflowDiagnostics(): boolean {
- return process.env.SENTRIS_DEBUG_WORKFLOW === '1';
+ return typeof process !== 'undefined' && process.env.SENTRIS_DEBUG_WORKFLOW === '1';
}
export function workflowDiagnosticLog(...args: Parameters): void {