-
Notifications
You must be signed in to change notification settings - Fork 85
feat(reportedip): add ReportedIP IP reputation service package #363
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 "../reportedip__reportedip/src/service.js"; | ||
| runServiceMain(service, { entryFile: fileURLToPath(new URL("../reportedip__reportedip/bin/reportedip.js", import.meta.url)) }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"}' | ||
| ``` |
| 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); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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."}}} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"}} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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."} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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."} | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 上游 API 响应字段缺少类型校验与转换,存在数据完整性风险runCheck 返回结果时直接将上游 API 的 Problem code: Recommendation: |
||
| }; | ||
|
|
||
| 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 }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 测试用例缺少 IPv6 覆盖,存在功能回归风险测试用例中仅覆盖了 IPv4( Problem code: Recommendation: |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mapHttpError 调用处缺少显式 throw,存在维护隐患
代码中使用
if (!res.ok) mapHttpError(res, text);来响应非 2xx 状态码。该语句依赖mapHttpError内部抛出异常来中断执行流程,语义上不够清晰。如果未来重构时将mapHttpError改为返回错误对象而非抛出,此处将静默吞掉 HTTP 错误并继续执行到return { json: parseJson(text), text };,导致严重的逻辑缺陷。Problem code:
Recommendation:
将调用处改为显式抛出:
if (!res.ok) throw mapHttpError(res, text);。更稳妥的做法是移除mapHttpError函数,直接在fetchJson内联错误映射逻辑,以避免对函数副作用的隐式依赖。