diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index 412b8988..cccd4b54 100644 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -5,6 +5,10 @@ import { runServiceMain } from "@chaitin-ai/octobus-sdk"; import { Command } from "commander"; const services = { + "reportedip": { + entryFile: "../reportedip__reportedip/bin/reportedip.js", + serviceModule: "../reportedip__reportedip/src/service.js", + }, "aliyun-waf3": { entryFile: "../aliyun__waf3/bin/aliyun-waf3.js", serviceModule: "../aliyun__waf3/src/service.js", diff --git a/services/bin/reportedip.js b/services/bin/reportedip.js new file mode 100644 index 00000000..c24a5307 --- /dev/null +++ b/services/bin/reportedip.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; +import { service } from "../reportedip__reportedip/src/service.js"; +runServiceMain(service, { entryFile: fileURLToPath(new URL("../reportedip__reportedip/bin/reportedip.js", import.meta.url)) }); diff --git a/services/package.json b/services/package.json index 91438aeb..41b5a7e5 100644 --- a/services/package.json +++ b/services/package.json @@ -70,7 +70,11 @@ "volcengine-cloud-firewall": "bin/volcengine-cloud-firewall.js", "wangsu-label-ip": "bin/wangsu-label-ip.js", "wd-k01": "bin/wd-k01.js", - "opencti": "bin/opencti.js" + "opencti": "bin/opencti.js", + "tencent-bh": "bin/tencent-bh.js", + "alibaba-sas": "bin/alibaba-sas.js", + "misp": "bin/misp.js", + "reportedip": "bin/reportedip.js" }, "files": [ "bin/aliyun-waf3.js", @@ -139,6 +143,7 @@ "bin/wd-k01.js", "bin/opencti.js", "bin/wangsu-label-ip.js", + "bin/reportedip.js", "alibaba-cloud__simple-application-server-firewall", "chaitin__cloudatlas", "chaitin__dsensor_ds-s_h_40-25.07.001", @@ -206,7 +211,8 @@ "wangsu__label-ip", "scripts", "aliyun__waf3", - "bin/octobus-tentacles.js" + "bin/octobus-tentacles.js", + "reportedip__reportedip" ], "scripts": { "validate": "node scripts/validate-service-package.mjs", diff --git a/services/reportedip__reportedip/README.md b/services/reportedip__reportedip/README.md new file mode 100644 index 00000000..0b11f92a --- /dev/null +++ b/services/reportedip__reportedip/README.md @@ -0,0 +1,30 @@ +# ReportedIP Service Package + +OctoBus package for [ReportedIP](https://reportedip.de/) — a free, EU-hosted IP reputation service. + +## Features + +- **No API key required** for public check endpoint (100 req/day per IP) +- **EU/GDPR compliant** — hosted in Germany +- **1 RPC method** for IP reputation check + +## Methods + +| RPC | HTTP API | Type | Description | +|-----|----------|------|-------------| +| `CheckIP` | `GET /check-public?ip={ip}` | Read | IP reputation (confidence score, ISP, geo, hostnames) | + +## Usage + +```bash +# Create instance +octobus instance create reportedip-test --service reportedip + +# Create capset +octobus capset create threat-intel --name Threat-Intel +octobus capset add-instance threat-intel reportedip-test + +# Check IP +curl -X POST 'http://127.0.0.1:9000/capsets/threat-intel/connect/reportedip-test/ReportedIP.ReportedIP/CheckIP' \ + -H 'Content-Type: application/json' -d '{"ip":"8.8.8.8"}' +``` diff --git a/services/reportedip__reportedip/bin/reportedip.js b/services/reportedip__reportedip/bin/reportedip.js new file mode 100644 index 00000000..604f5241 --- /dev/null +++ b/services/reportedip__reportedip/bin/reportedip.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runServiceMain } from '@chaitin-ai/octobus-sdk'; +import { service } from '../src/service.js'; +runServiceMain(service); diff --git a/services/reportedip__reportedip/config.schema.json b/services/reportedip__reportedip/config.schema.json new file mode 100644 index 00000000..08bb8310 --- /dev/null +++ b/services/reportedip__reportedip/config.schema.json @@ -0,0 +1 @@ +{"$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."}}} \ No newline at end of file diff --git a/services/reportedip__reportedip/package.json b/services/reportedip__reportedip/package.json new file mode 100644 index 00000000..bb69e8c2 --- /dev/null +++ b/services/reportedip__reportedip/package.json @@ -0,0 +1 @@ +{"name":"reportedip","version":"0.0.0","private":true,"type":"module","bin":{"reportedip":"bin/reportedip.js"},"dependencies":{"@chaitin-ai/octobus-sdk":"^0.5.0"}} \ No newline at end of file diff --git a/services/reportedip__reportedip/proto/reportedip.proto b/services/reportedip__reportedip/proto/reportedip.proto new file mode 100644 index 00000000..8207b58e --- /dev/null +++ b/services/reportedip__reportedip/proto/reportedip.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package ReportedIP; + +import "google/protobuf/wrappers.proto"; + +option go_package = "miner/grpc-service/ReportedIP"; + +service ReportedIP { + rpc CheckIP(CheckIPRequest) returns (CheckIPResponse) {} +} + +message CheckIPRequest { + string ip = 1; +} + +message CheckIPResponse { + int32 code = 1; + string message = 2; + string ip = 3; + int32 abuse_confidence_percentage = 4; + string country_code = 5; + string usage_type = 6; + string isp = 7; + string domain = 8; + repeated string hostnames = 9; +} diff --git a/services/reportedip__reportedip/secret.schema.json b/services/reportedip__reportedip/secret.schema.json new file mode 100644 index 00000000..c7690cf2 --- /dev/null +++ b/services/reportedip__reportedip/secret.schema.json @@ -0,0 +1 @@ +{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":true,"properties":{},"description":"ReportedIP public endpoint does not require authentication."} \ No newline at end of file diff --git a/services/reportedip__reportedip/service.json b/services/reportedip__reportedip/service.json new file mode 100644 index 00000000..ed8277f9 --- /dev/null +++ b/services/reportedip__reportedip/service.json @@ -0,0 +1,17 @@ +{ + "name": "reportedip", + "displayName": "ReportedIP", + "description": "OctoBus package for ReportedIP — EU-hosted IP reputation service.", + "schema": "chaitin.octobus.service.v1", + "runtime": {"mode": "long-running"}, + "proto": {"roots": ["proto"], "files": ["proto/reportedip.proto"]}, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "ReportedIP.ReportedIP/CheckIP": {"name": "check-ip", "description": "Check IP reputation against ReportedIP database."} + } + } + } +} \ No newline at end of file diff --git a/services/reportedip__reportedip/src/reportedip.js b/services/reportedip__reportedip/src/reportedip.js new file mode 100644 index 00000000..e3226708 --- /dev/null +++ b/services/reportedip__reportedip/src/reportedip.js @@ -0,0 +1,61 @@ +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; +import net from 'node:net'; + +const METHOD_CHECK = 'ReportedIP.ReportedIP/CheckIP'; +const API_BASE = 'https://reportedip.de/wp-json/reportedip/v2'; +const DEFAULT_TIMEOUT_MS = 10000; +const SDK_REF = 'ReportedIP'; + +const grpcCodeFor = (c) => ({ FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, UNAVAILABLE: grpcStatus.UNAVAILABLE, UNKNOWN: grpcStatus.UNKNOWN })[c] ?? grpcStatus.UNKNOWN; +const errorWithCode = (code, msg) => { const e = new GrpcError(grpcCodeFor(code), `${code}: ${msg}`); e.legacyCode = code; return e; }; +const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o ?? {}, k); +const firstDefined = (...v) => v.find(x => x !== undefined && x !== null); +const unwrapString = (s) => { if (s === undefined || s === null) return ''; if (typeof s === 'object' && hasOwn(s, 'value')) return unwrapString(s.value); return String(s); }; +const logInfo = (m, a, p) => { try { console.log(`[${SDK_REF}][${a}]`, JSON.stringify(p)); } catch { console.log(`[${SDK_REF}][${a}]`, p); } }; +const logError = (m, a, p) => { try { console.error(`[${SDK_REF}][${a}]`, JSON.stringify(p)); } catch { console.error(`[${SDK_REF}][${a}]`, p); } }; +const parseJson = (t) => { if (!String(t || '').trim()) return null; try { return JSON.parse(t); } catch { throw errorWithCode('UNKNOWN', 'response is not valid JSON'); } }; +const mapHttpError = (res, bt) => { if (res.status >= 400 && res.status < 500) throw errorWithCode('FAILED_PRECONDITION', `upstream http ${res.status}`); throw errorWithCode('UNAVAILABLE', `upstream http ${res.status}`); }; +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 = {}, b = {}) => firstDefined(ctx.limits?.timeoutMs, b.timeoutMs, DEFAULT_TIMEOUT_MS); + +const fetchJson = async (url, init, { bindings = {}, timeoutMs }) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs || DEFAULT_TIMEOUT_MS); + try { + const 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; + throw errorWithCode('UNAVAILABLE', err?.cause?.message || err?.message || 'fetch failed'); + } finally { clearTimeout(timer); } +}; + +const makeRuntime = (ctx = {}) => { + const cc = resolveCallContext(ctx); + const bindings = cc.bindings || {}; const meta = cc.meta || {}; const tm = resolveTimeoutMs(cc, bindings); + + const runCheck = 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}/check-public?ip=${encodeURIComponent(ip)}`; + logInfo(meta, 'CheckIP:start', { ip }); + + let result; + try { result = await fetchJson(url, { method: 'GET' }, { bindings, timeoutMs: tm }); } + catch (err) { logError(meta, 'CheckIP:http-error', { ip, error: err.message }); throw err; } + + const data = result.json?.data || {}; + logInfo(meta, 'CheckIP:success', { ip }); + return { code: 0, message: 'ok', ip: data.ip || ip, abuse_confidence_percentage: data.abuseConfidencePercentage ?? 0, country_code: data.countryCode || '', usage_type: data.usageType || '', isp: data.isp || '', domain: data.domain || '', hostnames: data.hostnames || [] }; + }; + + return { runCheck }; +}; + +export const handlers = { [METHOD_CHECK]: (ctx) => makeRuntime(ctx).runCheck(ctx.request ?? {}) }; +export const _test = { errorWithCode, firstDefined, hasOwn, logInfo, logError, makeRuntime, mapHttpError, parseJson, resolveCallContext, resolveTimeoutMs, unwrapString }; diff --git a/services/reportedip__reportedip/src/service.js b/services/reportedip__reportedip/src/service.js new file mode 100644 index 00000000..83632447 --- /dev/null +++ b/services/reportedip__reportedip/src/service.js @@ -0,0 +1,4 @@ +import { defineService } from '@chaitin-ai/octobus-sdk'; +import { handlers } from './reportedip.js'; +export { handlers } from './reportedip.js'; +export const service = defineService({ handlers }); diff --git a/services/reportedip__reportedip/test/reportedip.test.js b/services/reportedip__reportedip/test/reportedip.test.js new file mode 100644 index 00000000..056136f5 --- /dev/null +++ b/services/reportedip__reportedip/test/reportedip.test.js @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { GrpcError } from '@chaitin-ai/octobus-sdk'; +import { handlers, _test } from '../src/reportedip.js'; +import { service } from '../src/service.js'; + +const originalFetch = globalThis.fetch, originalCL = console.log, originalCE = console.error; +const CHECK_RESP = { data: { ip: '8.8.8.8', abuseConfidencePercentage: 67, countryCode: 'US', usageType: 'Data Center', isp: 'GOOGLE', domain: 'dns.google', hostnames: ['dns.google'] } }; +const ctx = (o = {}) => ({ config: {}, secret: {}, bindings: {}, limits: { timeoutMs: 2000, ...(o.limits || {}) }, meta: { instance_id: 'inst', request_id: 'req', ...(o.meta || {}) } }); +const resp = (s, b) => ({ ok: s >= 200 && s < 300, status: s, headers: { get: () => 'application/json' }, text: async () => (typeof b === 'string' ? b : JSON.stringify(b)) }); +const setFetch = (i) => { globalThis.fetch = i; }; +const expectErr = async (fn, code) => { let c; try { await fn(); } catch (err) { c = err; } assert.ok(c instanceof GrpcError); assert.equal(c.legacyCode, code); }; + +test.beforeEach(() => { console.log = () => {}; console.error = () => {}; }); +test.afterEach(() => { globalThis.fetch = originalFetch; console.log = originalCL; console.error = originalCE; }); + +test('service exports handlers', () => { assert.equal(typeof handlers['ReportedIP.ReportedIP/CheckIP'], 'function'); }); +test('CheckIP returns reputation data', async () => { + setFetch(async (url) => { assert.ok(url.includes('/check-public')); return resp(200, CHECK_RESP); }); + const r = await handlers['ReportedIP.ReportedIP/CheckIP']({ ...ctx(), request: { ip: '8.8.8.8' } }); + assert.equal(r.code, 0); assert.equal(r.abuse_confidence_percentage, 67); assert.equal(r.isp, 'GOOGLE'); +}); +test('CheckIP validates ip', async () => { await expectErr(() => handlers['ReportedIP.ReportedIP/CheckIP']({ ...ctx(), request: {} }), 'INVALID_ARGUMENT'); }); +test('CheckIP validates IP format', async () => { await expectErr(() => handlers['ReportedIP.ReportedIP/CheckIP']({ ...ctx(), request: { ip: 'not-an-ip' } }), 'INVALID_ARGUMENT'); }); +test('handles HTTP errors', async () => { + setFetch(async () => resp(500, '')); await expectErr(() => handlers['ReportedIP.ReportedIP/CheckIP']({ ...ctx(), request: { ip: '8.8.8.8' } }), 'UNAVAILABLE'); +}); +test('helper utilities', () => { assert.equal(_test.firstDefined(undefined, null, 'x'), 'x'); });