Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions services/bin/shodan-internetdb.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env node
import { fileURLToPath } from "node:url";
import { runServiceMain } from "@chaitin-ai/octobus-sdk";
import { service } from "../shodan__internetdb/src/service.js";
runServiceMain(service, { entryFile: fileURLToPath(new URL("../shodan__internetdb/bin/shodan-internetdb.js", import.meta.url)) });
41 changes: 41 additions & 0 deletions services/shodan__internetdb/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Shodan InternetDB Service Package

OctoBus package for [Shodan InternetDB](https://internetdb.shodan.io/) — a free, no-signup IP intelligence API.

## Features

- **No API key required** — unlimited usage
- **No signup needed**
- **Shodan** is a well-known security company (network device search engine)

## Methods

| RPC | HTTP API | Type | Description |
|-----|----------|------|-------------|
| `LookupIP` | `GET /{ip}` | Read | IP intelligence (ports, hostnames, CVEs, tags) |

## Configuration

| Config field | Default | Description |
|-------------|---------|-------------|
| `timeoutMs` | 10000 | HTTP request timeout |

No authentication required — `secret.schema.json` is empty.

## Usage

```bash
# Create instance
octobus instance create shodan-test \
--service shodan-internetdb

# Create capset
octobus capset create threat-intel --name Threat-Intel
octobus capset add-instance threat-intel shodan-test

# Lookup IP
curl -X POST \
'http://127.0.0.1:9000/capsets/threat-intel/connect/shodan-test/Shodan_InternetDB.Shodan_InternetDB/LookupIP' \
-H 'Content-Type: application/json' \
-d '{"ip":"8.8.8.8"}'
```
4 changes: 4 additions & 0 deletions services/shodan__internetdb/bin/shodan-internetdb.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env node
import { runServiceMain } from '@chaitin-ai/octobus-sdk';
import { service } from '../src/service.js';
runServiceMain(service);
13 changes: 13 additions & 0 deletions services/shodan__internetdb/config.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {
"timeoutMs": {
"type": "integer",
"minimum": 1,
"default": 10000,
"description": "HTTP timeout in milliseconds."
}
}
}
12 changes: 12 additions & 0 deletions services/shodan__internetdb/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "shodan-internetdb",
"version": "0.0.0",
"private": true,
"type": "module",
"bin": {
"shodan-internetdb": "bin/shodan-internetdb.js"
},
"dependencies": {
"@chaitin-ai/octobus-sdk": "^0.5.0"
}
}
24 changes: 24 additions & 0 deletions services/shodan__internetdb/proto/shodan_internetdb.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
syntax = "proto3";

package Shodan_InternetDB;

option go_package = "miner/grpc-service/Shodan_InternetDB";

service Shodan_InternetDB {
rpc LookupIP(LookupIPRequest) returns (LookupIPResponse) {}
}

message LookupIPRequest {
string ip = 1;
}

message LookupIPResponse {
int32 code = 1;
string message = 2;
string ip = 3;
repeated string hostnames = 4;
repeated int32 ports = 5;
repeated string cpes = 6;
repeated string tags = 7;
repeated string vulns = 8;
}
6 changes: 6 additions & 0 deletions services/shodan__internetdb/secret.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {}
}
29 changes: 29 additions & 0 deletions services/shodan__internetdb/service.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"schema": "chaitin.octobus.service.v1",
"name": "shodan-internetdb",
"displayName": "Shodan InternetDB",
"description": "OctoBus package for Shodan InternetDB — free IP intelligence (ports, hostnames, CVEs) without API key.",
"runtime": {
"mode": "long-running"
},
"proto": {
"roots": [
"proto"
],
"files": [
"proto/shodan_internetdb.proto"
]
},
"configSchema": "config.schema.json",
"secretSchema": "secret.schema.json",
"sdk": {
"cli": {
"commands": {
"Shodan_InternetDB.Shodan_InternetDB/LookupIP": {
"name": "lookup-ip",
"description": "Look up IP intelligence from Shodan InternetDB (ports, hostnames, CVEs)."
}
}
}
}
}
4 changes: 4 additions & 0 deletions services/shodan__internetdb/src/service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { defineService } from '@chaitin-ai/octobus-sdk';
import { handlers } from './shodan-internetdb.js';
export { handlers } from './shodan-internetdb.js';
export const service = defineService({ handlers });
154 changes: 154 additions & 0 deletions services/shodan__internetdb/src/shodan-internetdb.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import net from 'node:net';
import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk';

// ---- Method paths ----

const METHOD_LOOKUP_FULL = 'Shodan_InternetDB.Shodan_InternetDB/LookupIP';

// ---- Constants ----

const API_BASE = 'https://internetdb.shodan.io';
const SDK_REF = 'Shodan_InternetDB';
const DEFAULT_TIMEOUT_MS = 10000;

// ---- Error helpers ----

const grpcCodeFor = (code) => ({
FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION,
INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT,
PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED,
UNAVAILABLE: grpcStatus.UNAVAILABLE,
UNKNOWN: grpcStatus.UNKNOWN,
})[code] ?? grpcStatus.UNKNOWN;

const errorWithCode = (code, message) => {
const err = new GrpcError(grpcCodeFor(code), `${code}: ${message}`);
err.legacyCode = code;
return err;
};

// ---- Value helpers ----

const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj ?? {}, key);
const firstDefined = (...values) => values.find((v) => v !== undefined && v !== null);

const unwrapString = (source) => {
if (source === undefined || source === null) return '';
if (typeof source === 'object' && source !== null && hasOwn(source, 'value')) return unwrapString(source.value);
return String(source);
};

const logInfo = (meta, action, payload) => {
const prefix = `[${SDK_REF}][${action}]`;
try { console.log(prefix, JSON.stringify(payload)); } catch { console.log(prefix, payload); }
};

const logError = (meta, action, payload) => {
const prefix = `[${SDK_REF}][${action}]`;
try { console.error(prefix, JSON.stringify(payload)); } catch { console.error(prefix, payload); }
};

// ---- HTTP ----

const parseJson = (text) => {
if (!String(text || '').trim()) return null;
try { return JSON.parse(text); } catch { throw errorWithCode('UNKNOWN', 'response is not valid JSON'); }
};

const mapHttpError = (res, bodyText) => {
if (res.status === 401 || res.status === 403) throw errorWithCode('PERMISSION_DENIED', `upstream http ${res.status}`);
if (res.status === 429) throw errorWithCode('UNAVAILABLE', `upstream http ${res.status}`);
if (res.status >= 400 && res.status < 500) throw errorWithCode('FAILED_PRECONDITION', `upstream http ${res.status}`);
throw errorWithCode('UNAVAILABLE', `upstream http ${res.status}`);
};

const fetchJson = async (url, init, { bindings = {}, timeoutMs }) => {
let res;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs || DEFAULT_TIMEOUT_MS);
try {
res = await fetch(url, { ...init, signal: controller.signal });
const text = await res.text();
if (!res.ok) mapHttpError(res, text);
return { json: parseJson(text), text };
} catch (err) {
if (err instanceof GrpcError) throw err;
const reason = err?.cause?.message || err?.message || 'fetch failed';
throw errorWithCode('UNAVAILABLE', reason);
} finally {
clearTimeout(timer);
Comment thread
monkeyscan[bot] marked this conversation as resolved.
}
};

// ---- Context resolution ----

const mergedBindings = (ctx = {}) => ({ ...(ctx.config ?? {}), ...(ctx.secret ?? {}), ...(ctx.bindings ?? {}) });

const resolveCallContext = (ctx = {}) => ({
...ctx, bindings: mergedBindings(ctx), limits: ctx.limits ?? {}, meta: ctx.meta ?? {}, req: ctx.req ?? ctx.request ?? {},
});

const resolveTimeoutMs = (ctx = {}, bindings = {}) =>
firstDefined(ctx.limits?.timeoutMs, bindings.timeoutMs, DEFAULT_TIMEOUT_MS);

// ---- Handlers ----

const makeRuntime = (ctx = {}) => {
const callCtx = resolveCallContext(ctx);
const bindings = callCtx.bindings || {};
const meta = callCtx.meta || {};
const timeoutMs = resolveTimeoutMs(callCtx, bindings);

const runLookup = async (req = {}) => {
const ip = unwrapString(firstDefined(req.ip)).trim();
if (!ip) throw errorWithCode('INVALID_ARGUMENT', 'ip is required');
if (!net.isIP(ip)) throw errorWithCode('INVALID_ARGUMENT', 'invalid IP address format');

const url = `${API_BASE}/${encodeURIComponent(ip)}`;

logInfo(meta, 'LookupIP:start', { ip });
Comment thread
monkeyscan[bot] marked this conversation as resolved.

let result;
try {
result = await fetchJson(url, { method: 'GET' }, { bindings, timeoutMs });
} catch (err) {
logError(meta, 'LookupIP:http-error', { ip, error: err.message });
throw err;
}

logInfo(meta, 'LookupIP:success', { ip });

return {
code: 0,
message: 'ok',
ip: result.json?.ip || ip,
hostnames: result.json?.hostnames || [],
ports: result.json?.ports || [],
cpes: result.json?.cpes || [],
tags: result.json?.tags || [],
vulns: result.json?.vulns || [],
};
};

return { runLookup };
};

// ---- Exports ----

export const handlers = {
[METHOD_LOOKUP_FULL]: (ctx) => makeRuntime(ctx).runLookup(ctx.request ?? {}),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handler 请求对象来源与 resolveCallContext 的归一化逻辑不一致

resolveCallContext 内部显式做了 req: ctx.req ?? ctx.request ?? {} 的归一化,意味着作者预期运行时可能通过 ctx.req 传递请求体。但 handlers 中直接调用 makeRuntime(ctx).runLookup(ctx.request ?? {}),完全绕过了该归一化结果。若实际框架使用 ctx.req 而非 ctx.request,runLookup 会收到空对象,导致 IP 必填校验直接失败。

Problem code:

Changed code at services/shodan__internetdb/src/shodan-internetdb.js:141-143

Recommendation:
在 handler 中将请求体传入改为 ctx.req ?? ctx.request ?? {},或如果框架始终使用 request,则移除 resolveCallContext 中未实际消费的 req 归一化逻辑,避免误导后续维护者。

export const _test = {
errorWithCode,
firstDefined,
hasOwn,
logInfo,
logError,
makeRuntime,
mapHttpError,
parseJson,
resolveCallContext,
resolveTimeoutMs,
unwrapString,
};
Loading