diff --git a/public/registry/index.json b/public/registry/index.json index 0eb44d1..98a98ee 100644 --- a/public/registry/index.json +++ b/public/registry/index.json @@ -1,24 +1,24 @@ { "version": 1, - "updatedAt": "2026-08-09T10:05:51.719Z", + "updatedAt": "2026-08-09T10:23:15.471Z", "plugins": { "count": 24, - "updatedAt": "2026-08-09T10:05:51.719Z" + "updatedAt": "2026-08-09T10:23:15.471Z" }, "themes": { "count": 54, - "updatedAt": "2026-08-09T10:05:51.719Z" + "updatedAt": "2026-08-09T10:23:15.471Z" }, "widgets": { "count": 45, - "updatedAt": "2026-08-09T10:05:51.719Z" + "updatedAt": "2026-08-09T10:23:15.471Z" }, "skills": { "count": 29, - "updatedAt": "2026-08-09T10:05:51.719Z" + "updatedAt": "2026-08-09T10:23:15.471Z" }, "queries": { "count": 24, - "updatedAt": "2026-08-09T10:05:51.719Z" + "updatedAt": "2026-08-09T10:23:15.471Z" } } diff --git a/public/registry/widgets.json b/public/registry/widgets.json index 1972104..c86c793 100644 --- a/public/registry/widgets.json +++ b/public/registry/widgets.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-08-09T10:05:51.719Z", + "updatedAt": "2026-08-09T10:23:15.471Z", "widgets": [ { "id": "atcoder", @@ -398,9 +398,9 @@ { "id": "shodan-instance-audit", "name": "Shodan インスタンス監査", - "version": "1.0.2", + "version": "1.1.0", "author": "@hitalin", - "description": "指定した Misskey インスタンスのホストを Shodan で監査。露出ポートを危険度別に色分けし、稼働サービス・TLS 失効判定・CVE (CVSS と実証/推定の別)・Shodan のスキャン日を表示する管理者向けの外部露出チェック。結果はキャッシュされ、取得は初回と手動更新のみ (1 回 1 クエリクレジット)。API キーは Secret Vault (クエリ認証 param=key) で注入し widget.is には書かない", + "description": "指定した Misskey インスタンスのホストを Shodan で監査。露出ポートを危険度別に色分けし、稼働サービス・TLS 失効判定・CVE (CVSS と実証/推定の別)・Shodan のスキャン日を表示する管理者向けの外部露出チェック。CDN の背後にある場合はオリジンが隠れている証拠として肯定的に示す。結果はキャッシュされ、取得は初回と手動更新のみ (1 回 1 クエリクレジット)。API キーは Secret Vault (クエリ認証 param=key) で注入し widget.is には書かない", "icon": "ti-radar", "autoRun": true, "category": "stats", @@ -419,9 +419,9 @@ ], "sourceUrl": "https://store.notedeck.io/registry/widgets/shodan-instance-audit/widget.is", "apiUrl": "https://store.notedeck.io/registry/widgets/shodan-instance-audit/api.json", - "sha512": "0338a2246f21ffd2e8d52d54e54b8c4703336a3651c406f71826515de1f404e224e43708e11d04dac9851c181324fc9381397560a641707e0d43ca58487f0d55", + "sha512": "7a68aceb44cd53b609b22b632011fcbf37988e3997dc7357ed40c9a9c3070cbe16a15f76b55fce8f1346c6a1a5e5b2439063bf5ec9dc44da6cf70734c14413f7", "createdAt": "2026-08-09T07:54:40+09:00", - "updatedAt": "2026-08-09T07:54:40+09:00", + "updatedAt": "2026-08-09T19:06:28+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "iconUrl": "https://store.notedeck.io/registry/widgets/shodan-instance-audit/icon.svg" diff --git a/public/registry/widgets/shodan-instance-audit/api.json b/public/registry/widgets/shodan-instance-audit/api.json index 7599818..8cf5d02 100644 --- a/public/registry/widgets/shodan-instance-audit/api.json +++ b/public/registry/widgets/shodan-instance-audit/api.json @@ -1 +1 @@ -{"type":"widget","data":"/// @ 1.2.1\n/// Shodan インスタンス監査ウィジェット\n/// 指定した Misskey インスタンス (自分のサーバ等) が外からどう見えているかを\n/// Shodan で可視化する管理者向けの防御的セルフ監査ツール。\n/// 露出ポートの危険度・稼働サービス・TLS 失効判定・CVE (CVSS/検証状態付き)・\n/// データ鮮度・残クレジットを表示する。\n/// API Key は widget.is には一切書かず、Vault がサーバーサイドで\n/// クエリ (?key=...) に注入する。\n///\n/// ■ 事前準備\n/// 1. Shodan の API Key を取得:\n/// https://account.shodan.io/ (Account → API Key)\n/// 2. NoteDeck の Secret Vault で接続を作成:\n/// 名前 : Shodan\n/// baseURL : https://api.shodan.io\n/// 認証方式 : 「クエリ」を選ぶ (param 名 = key)\n/// シークレット: Shodan の API Key (生の値)\n/// \"AI からのアクセスを許可\" を ON にする\n/// → Vault が ?key= を各リクエストに自動付与する。\n/// 3. 設定 (⚙️) で監査対象のホストを入力\n/// 例: misskey.io / your-instance.example / 203.0.113.10\n///\n/// ■ 注意\n/// - /shodan/host/ は 1 回につきクエリクレジットを 1 消費する。\n/// このため自動更新は行わず、結果を Mk:save にキャッシュする。\n/// カラムを開き直してもキャッシュを描くだけでクレジットは減らない。\n/// 実際に取得するのは (a) そのホストで初めて監査するとき\n/// (b) 「更新」を押したとき の 2 つだけ。\n/// - 表示されるのは Shodan が収集済みのスキャン結果 (OSINT) であり、\n/// リアルタイムの状態ではない。「データ取得」の日付を必ず併記する。\n/// - 自分の管理下のインスタンスの露出点検を主用途とする。\n/// 設計の元ネタ: wakatime (Vault) / atcoder (設定入力・表示ループ) / cve (脆弱性表示)。\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n/// MFM/HTML 制御文字を無害化。org 名・product 名など外部由来文字列は\n/// そのまま MFM に流さず、ブレイクアウトを防ぐ。\n@sanitizeText(s) {\n if (Core:type(s) != \"str\") { return \"(不明)\" }\n var txt = s.replace(\"$[\", \"$ \")\n txt = txt.replace(\"]\", \"]\")\n txt = txt.replace(\"[\", \"[\")\n txt = txt.replace(\"<\", \"<\")\n txt = txt.replace(\">\", \">\")\n txt = txt.replace(\"`\", \"`\")\n txt = txt.replace(\"*\", \"*\")\n txt = txt.replace(\"@\", \"@\")\n txt = txt.replace(\"#\", \"#\")\n txt = txt.replace(\"\\\\\", \"\\")\n txt = txt.replace(\"://\", \"://\")\n txt\n}\n\n@truncName(s, n) {\n let clean = sanitizeText(s)\n if (clean.len > n) { `{clean.slice(0, n)}…` } else { clean }\n}\n\n/// 数値/文字列配列を sep で連結 (arr.join が無いので手動)\n@joinArr(arr, sep) {\n if (Core:type(arr) != \"arr\") { return \"\" }\n var s = \"\"\n for (let i, arr.len) {\n if (i > 0) { s = `{s}{sep}` }\n s = `{s}{arr[i]}`\n }\n s\n}\n\n// ─────────────────────────────\n// ホスト名の正規化と検証\n// ─────────────────────────────\nlet HOST_CHARS = \"abcdefghijklmnopqrstuvwxyz0123456789.-\"\n\n/// 入力を Shodan に渡せるホスト名に正規化する。\n/// scheme / path / query / fragment / ポート / 前後空白を落とし、小文字化する。\n/// Shodan の /dns/resolve は小文字キーで返すため、小文字化は照合のために必須。\n@normHost(s) {\n if (Core:type(s) != \"str\") { return \"\" }\n var h = s.trim().lower()\n h = h.replace(\"https://\", \"\")\n h = h.replace(\"http://\", \"\")\n h = h.split(\"/\")[0]\n h = h.split(\"?\")[0]\n h = h.split(\"#\")[0]\n h = h.split(\" \")[0]\n h = h.split(\":\")[0] // ポート番号を除去\n h\n}\n\n/// 正規化後のホスト名が妥当か。不正な文字はここで弾き、\n/// クエリ文字列へそのまま流れ込むのを防ぐ。\n@hostError(h) {\n if (h == \"\") { return \"監査対象のホストが未設定です\" }\n if (h.len > 253) { return \"ホスト名が長すぎます\" }\n let cs = h.to_arr()\n for (let i, cs.len) {\n if (!HOST_CHARS.incl(cs[i])) { return \"ホスト名に使えない文字が含まれています\" }\n }\n if (!h.incl(\".\")) { return \"ホスト名にドットがありません (例: misskey.io)\" }\n if (h.slice(0, 1) == \".\" || h.slice(h.len - 1, h.len) == \".\") { return \"ホスト名の先頭/末尾がドットです\" }\n if (h.slice(0, 1) == \"-\") { return \"ホスト名の先頭がハイフンです\" }\n \"\"\n}\n\n/// IPv4 リテラルか。真なら /dns/resolve を省略できる (リクエスト 1 本節約)。\n@isIpv4(h) {\n let parts = h.split(\".\")\n if (parts.len != 4) { return false }\n for (let i, 4) {\n let p = parts[i]\n if (p == \"\" || p.len > 3) { return false }\n let ds = p.to_arr()\n for (let j, ds.len) {\n if (!\"0123456789\".incl(ds[j])) { return false }\n }\n }\n true\n}\n\n// ─────────────────────────────\n// 日付\n// ─────────────────────────────\nlet DAY_MS = 86400000\n\n/// Shodan の cert.expires (\"20260401120000Z\") を ISO に。失敗時は \"\"\n@certIso(s) {\n if (Core:type(s) != \"str\" || s.len < 14) { return \"\" }\n `{s.slice(0, 4)}-{s.slice(4, 6)}-{s.slice(6, 8)}T{s.slice(8, 10)}:{s.slice(10, 12)}:{s.slice(12, 14)}Z`\n}\n\n/// ISO 文字列 → epoch ms。パースできなければ null\n@toEpoch(iso) {\n if (iso == \"\") { return null }\n let t = Date:parse(iso)\n if (Core:type(t) != \"num\") { return null }\n t\n}\n\n/// epoch ms から「今日から見た残り日数」(負なら経過日数)\n@daysFrom(ts) { Math:floor((ts - Date:now()) / DAY_MS) }\n\n/// epoch ms → \"YYYY-MM-DD\" (Date:month は 8 のように 0 埋めされないので補う)\n@fmtYmd(ts) {\n `{Date:year(ts)}-{Core:to_str(Date:month(ts)).pad_start(2, \"0\")}-{Core:to_str(Date:day(ts)).pad_start(2, \"0\")}`\n}\n\n// ─────────────────────────────\n// 定数\n// ─────────────────────────────\nlet CONNECTION = \"Shodan\"\nlet MAX_SERVICES = 6 // 表示する稼働サービス数\nlet MAX_VULNS = 8 // 表示する CVE 数\nlet STALE_DAYS = 30 // これより古い Shodan データは鮮度警告を出す\nlet CACHE_AGE_MS = 21600000 // 6h。これを超えたキャッシュには「古い」印を付ける\n // (印を付けるだけで、自動再取得はしない = クレジットを使わない)\n\n// Misskey インスタンスとして公開されて当然のポート。これ以外は棚卸し対象。\nlet EXPECTED_PORTS = [80, 443]\n\n// 公開インターネットに晒すべきでない代表的なポート (データストア / 管理系)。\nlet CRITICAL_PORTS = {\n \"23\": \"Telnet\", \"21\": \"FTP\", \"3389\": \"RDP\", \"5900\": \"VNC\"\n \"3306\": \"MySQL\", \"5432\": \"PostgreSQL\", \"6379\": \"Redis\", \"27017\": \"MongoDB\"\n \"9200\": \"Elasticsearch\", \"11211\": \"Memcached\", \"5672\": \"RabbitMQ\"\n \"2375\": \"Docker API\", \"2376\": \"Docker API\", \"9100\": \"node_exporter\"\n \"9090\": \"Prometheus\", \"8086\": \"InfluxDB\", \"25\": \"SMTP\"\n}\n// 用途は正当だが公開範囲を確認したいポート\nlet REVIEW_PORTS = { \"22\": \"SSH\", \"3000\": \"アプリ直開き\", \"8080\": \"HTTP 代替\" }\n\nlet ACCENT = \"c8102e\" // Shodan アクセント (赤)\nlet INFO = \"3ea6ff\" // 情報 (青)\nlet OK = \"43a047\" // 良好 (緑)\nlet WARN = \"f0a020\" // 要確認 (橙)\nlet CRIT = \"ea3943\" // 危険 (赤)\nlet MUTED = \"8a919e\"\nlet LINE = \"5a6573\"\n\n// ─────────────────────────────\n// 状態\n// ─────────────────────────────\n// 監査対象ホスト。Mk:save で永続化。デフォルトは未設定 (誤ってクレジットを\n// 消費しないよう、ホスト未設定なら自動取得しない)。\nvar host = dflt(Mk:load(\"host\"), \"\")\nvar result = null\nvar firstRun = true\n// 取得中フラグ。「更新」連打で 1 押しごとにクレジットが飛ぶのを防ぐ。\nvar busy = false\n// いま表示しているデータをいつ取得したか / それがキャッシュ由来か\nvar shownAt = null\nvar fromCache = false\n\n/// 直近の監査結果のキャッシュ。{ host, savedAt, data }\n/// カラムを開くたびに再取得するとその都度クレジットを消費するため、\n/// autoRun ではこれを描くだけにして取得しない。\n@loadCache() {\n let c = Mk:load(\"cache\")\n if (Core:type(c) != \"obj\") { return null }\n if (Core:type(get(c, \"data\", null)) != \"obj\") { return null }\n if (get(c, \"host\", \"\") != normHost(host)) { return null } // 対象が変わったら使わない\n c\n}\n\n@saveCache(data) {\n Mk:save(\"cache\", { host: normHost(host), savedAt: Date:now(), data: data })\n}\n\n// ─────────────────────────────\n// データ取得 (Key は Vault が ?key= に注入。headers に key は書かない)\n// ─────────────────────────────\n@vaultGet(path) {\n Nd:call(\"vault.fetch\", {\n connectionRef: CONNECTION\n path: path\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n}\n\n/// 200 のときだけ JSON を返す。パース不能なら null\n@jsonOf(res) {\n if (res.status != 200) { return null }\n let b = res.body\n let t = Core:type(b)\n // Vault の実装によっては本文が既にパース済みで返る。その場合はそのまま使う\n if (t == \"obj\" || t == \"arr\") { return b }\n // Json:parse は非文字列を渡すと例外で落ちるので必ず型を見てから呼ぶ\n if (t != \"str\") { return null }\n let d = Json:parse(b)\n if (Core:type(d) != \"obj\" && Core:type(d) != \"arr\") { return null }\n d\n}\n\n/// 解釈できなかったときに「何が返ってきたのか」を短く要約する。\n/// これが無いと利用者も作者も原因を切り分けられない。\n/// 本文は外部由来なので必ずサニタイズしてから表示する。\n@bodyBrief(res) {\n let b = res.body\n let t = Core:type(b)\n if (t != \"str\") { return `本文の型が {t} でした` }\n if (b.len == 0) { return \"本文が空でした\" }\n let head = if (b.len > 60) { `{b.slice(0, 60)}…` } else { b }\n `{b.len} 文字: {sanitizeText(head)}`\n}\n\n/// data[] の banner から {port, product, version} を抽出\n@normalizeServices(banners) {\n if (Core:type(banners) != \"arr\") { return [] }\n var items = []\n for (let i, banners.len) {\n let b = banners[i]\n if (Core:type(b) == \"obj\") {\n items.push({\n port: get(b, \"port\", 0)\n product: get(b, \"product\", \"\")\n version: get(b, \"version\", \"\")\n })\n }\n }\n items\n}\n\n/// CVE を {id, cvss, verified} で収集する。\n/// banner 側 (data[].vulns) はメタデータを持つのでそちらを優先し、\n/// top-level の vulns 配列はメタ無しとして補完する。\n/// Shodan の CVE の多くはバナーのバージョン文字列からの推測 (unverified) なので、\n/// 検証状態を落とさずに持ち回るのが肝心。\n@collectVulns(data) {\n var found = []\n var index = {}\n\n @put(id, cvss, verified) {\n if (Core:type(id) != \"str\" || id == \"\") { return null }\n if (Obj:has(index, id)) {\n // 既出。メタデータが後から来たら埋める\n let cur = found[index[id]]\n if (Core:type(cur.cvss) == \"null\" && Core:type(cvss) == \"num\") { cur.cvss = cvss }\n if (verified) { cur.verified = true }\n return null\n }\n index[id] = found.len\n found.push({ id: id, cvss: cvss, verified: verified })\n }\n\n let banners = get(data, \"data\", [])\n if (Core:type(banners) == \"arr\") {\n for (let i, banners.len) {\n let bv = get(banners[i], \"vulns\", null)\n if (Core:type(bv) == \"obj\") {\n let ks = Obj:keys(bv)\n for (let j, ks.len) {\n let v = bv[ks[j]]\n let cvss = if (Core:type(get(v, \"cvss\", null)) == \"num\") { v.cvss } else { null }\n put(ks[j], cvss, get(v, \"verified\", false) == true)\n }\n }\n }\n }\n\n let top = get(data, \"vulns\", null)\n if (Core:type(top) == \"arr\") {\n for (let i, top.len) { put(top[i], null, false) }\n }\n\n // CVSS 降順。値が無いものは末尾へ\n found.sort(@(a, b) {\n let av = if (Core:type(a.cvss) == \"num\") { a.cvss } else { 0 - 1 }\n let bv = if (Core:type(b.cvss) == \"num\") { b.cvss } else { 0 - 1 }\n bv - av\n })\n}\n\n/// 全 banner の証明書のうち「最も早く切れるもの」を返す。\n/// 失効を見逃さないため、最初に見つかったものではなく最小値を取る。\n@earliestCert(banners) {\n if (Core:type(banners) != \"arr\") { return null }\n var best = null\n for (let i, banners.len) {\n let ssl = get(banners[i], \"ssl\", null)\n if (Core:type(ssl) == \"obj\") {\n let cert = get(ssl, \"cert\", null)\n if (Core:type(cert) == \"obj\") {\n let ts = toEpoch(certIso(get(cert, \"expires\", \"\")))\n if (Core:type(ts) == \"num\") {\n if (Core:type(best) == \"null\" || ts < best) { best = ts }\n }\n }\n }\n }\n best\n}\n\n@fetchCredits() {\n let d = jsonOf(vaultGet(\"/api-info\"))\n if (Core:type(d) != \"obj\") { return null }\n get(d, \"query_credits\", null)\n}\n\n@fail(msg, hint) {\n busy = false\n result = { error: msg, hint: hint }\n render()\n}\n\n/// 取得完了。結果をキャッシュしてから描画する。\n@finish(data) {\n busy = false\n shownAt = Date:now()\n fromCache = false\n result = data\n saveCache(data)\n render()\n}\n\n@fetchData() { runAudit(false) }\n\n/// 簡易表示。Shodan の minify=true は大きいフィールドを切り詰めて返すので、\n/// 応答が肥大して壊れるホストでも通ることがある。\n/// バナー詳細が落ちるぶん稼働サービス / TLS は出ないが、ポートと CVE は残る。\n@fetchMinified() { runAudit(true) }\n\n@runAudit(minify) {\n if (busy) { return null } // 連打防止。1 押し = 1 クレジット\n let h = normHost(host)\n let herr = hostError(h)\n if (herr != \"\") {\n fail(herr, \"設定 (⚙️) で監査対象のホストを入力してください (例: misskey.io)\")\n return null\n }\n\n busy = true\n renderLoading(`{h} を照会中…`)\n\n // 1. ホスト名 → IP (IP 直指定なら解決を省略してリクエストを 1 本節約)\n var ip = h\n if (!isIpv4(h)) {\n // hostError で文字種は検証済みだが、クエリ文字列に入れる値は必ずエスケープする\n let rres = vaultGet(`/dns/resolve?hostnames={Uri:encode_component(h)}`)\n if (rres.status == 401) {\n fail(\"認証エラー (401)\", \"Vault の Shodan 接続と API Key を確認してください\")\n return null\n }\n if (rres.status == 429) {\n fail(\"レート制限中です (429)\", \"少し待ってから再試行してください\")\n return null\n }\n if (rres.status != 200) {\n fail(`名前解決に失敗 (HTTP {rres.status})`, \"Vault 接続 (baseURL: https://api.shodan.io) を確認してください\")\n return null\n }\n let rmap = jsonOf(rres)\n let resolved = get(rmap, h, null)\n if (Core:type(resolved) != \"str\" || resolved == \"\") {\n fail(`{sanitizeText(h)} を解決できませんでした`, \"ホスト名の綴りと、その名前が公開 DNS に存在するかを確認してください\")\n return null\n }\n ip = resolved\n }\n\n // 2. ホスト詳細 (クレジットを 1 消費)\n let hres = if (minify) { vaultGet(`/shodan/host/{ip}?minify=true`) } else { vaultGet(`/shodan/host/{ip}`) }\n if (hres.status == 404) {\n // Shodan 未インデックス = 公開スキャンに引っかかっていない。防御的にはむしろ良い。\n finish({ notIndexed: true, host: h, ip: ip, credits: fetchCredits() })\n return null\n }\n if (hres.status == 401) {\n fail(\"認証エラー (401)\", \"Vault の Shodan 接続と API Key を確認してください\")\n return null\n }\n if (hres.status == 403) {\n fail(\"このプランでは参照できません (403)\", \"Shodan のプランで host 参照が有効か確認してください\")\n return null\n }\n if (hres.status == 429) {\n fail(\"レート制限中です (429)\", \"少し待ってから再試行してください\")\n return null\n }\n if (hres.status != 200) {\n fail(`ホスト情報の取得に失敗 (HTTP {hres.status})`, \"時間をおいて再試行してください\")\n return null\n }\n\n let data = jsonOf(hres)\n if (Core:type(data) != \"obj\") {\n // 何が返ってきたのかを出す。応答が途中で切れている場合は\n // 簡易表示 (minify) で通ることがあるので、その導線も出す。\n busy = false\n result = {\n error: \"ホスト情報を解釈できませんでした\"\n hint: `応答: {bodyBrief(hres)}`\n canMinify: !minify\n }\n render()\n return null\n }\n\n let banners = get(data, \"data\", [])\n var ports = get(data, \"ports\", [])\n if (Core:type(ports) == \"arr\") { ports = ports.sort(@(a, b) { a - b }) }\n\n let lastRaw = get(data, \"last_update\", \"\")\n let lastTs = if (Core:type(lastRaw) == \"str\" && lastRaw.len >= 10) { toEpoch(`{lastRaw.slice(0, 10)}T00:00:00Z`) } else { null }\n\n finish({\n host: h\n ip: ip\n minified: minify\n org: get(data, \"org\", \"\")\n country: get(data, \"country_name\", \"\")\n ports: ports\n services: normalizeServices(banners)\n certTs: earliestCert(banners)\n vulns: collectVulns(data)\n lastDate: if (Core:type(lastRaw) == \"str\" && lastRaw.len >= 10) { lastRaw.slice(0, 10) } else { \"\" }\n lastAge: if (Core:type(lastTs) == \"num\") { 0 - daysFrom(lastTs) } else { null }\n credits: fetchCredits()\n })\n}\n\n// ─────────────────────────────\n// UI 構築\n// ─────────────────────────────\n@divider() {\n Ui:C:mfm({ text: `
$[fg.color={LINE} ───────────────]
` })\n}\n\n@portLabel(p) {\n let k = Core:to_str(p)\n if (Obj:has(CRITICAL_PORTS, k)) { { color: CRIT, name: CRITICAL_PORTS[k], level: 2 } }\n elif (Obj:has(REVIEW_PORTS, k)) { { color: WARN, name: REVIEW_PORTS[k], level: 1 } }\n elif (EXPECTED_PORTS.incl(p)) { { color: MUTED, name: \"\", level: 0 } }\n else { { color: WARN, name: \"\", level: 1 } }\n}\n\n/// 露出ポートの棚卸し。80/443 以外は必ず目に留まるようにする。\n@portsView(ports) {\n if (Core:type(ports) != \"arr\" || ports.len == 0) {\n return [ Ui:C:mfm({ text: `$[fg.color={MUTED} 🔌 開放ポート情報なし]` }) ]\n }\n var expected = []\n var flagged = []\n for (let i, ports.len) {\n let p = ports[i]\n let info = portLabel(p)\n if (info.level == 0) { expected.push(p) } else { flagged.push({ port: p, info: info }) }\n }\n\n var out2 = [ Ui:C:mfm({ text: `$[fg.color={MUTED} 🔌 開放ポート {ports.len} 件]` }) ]\n if (expected.len > 0) {\n out2.push(Ui:C:mfm({ text: ` $[fg.color={OK} ✓ 想定内] $[fg.color={MUTED} {joinArr(expected, \", \")}]` }))\n }\n for (let i, flagged.len) {\n let f = flagged[i]\n let nm = if (f.info.name == \"\") { \"要確認\" } else { `{f.info.name}` }\n let mark = if (f.info.level == 2) { \"🚨\" } else { \"⚠\" }\n out2.push(Ui:C:mfm({ text: ` $[fg.color={f.info.color} {mark} {f.port}] $[fg.color={MUTED} {sanitizeText(nm)}]` }))\n }\n out2\n}\n\n@servicesView(svcs) {\n if (Core:type(svcs) != \"arr\" || svcs.len == 0) { return [] }\n var out2 = [ Ui:C:mfm({ text: `$[fg.color={MUTED} 🧩 稼働サービス]` }) ]\n let n = if (svcs.len > MAX_SERVICES) { MAX_SERVICES } else { svcs.len }\n for (let i, n) {\n let s = svcs[i]\n let prod = if (s.product == \"\") { \"(不明)\" } else { truncName(s.product, 18) }\n let ver = if (Core:type(s.version) == \"str\" && s.version != \"\") { ` $[fg.color={MUTED} {truncName(s.version, 10)}]` } else { \"\" }\n out2.push(Ui:C:mfm({ text: ` $[fg.color={ACCENT} ▸] $[fg.color={INFO} {s.port}] {prod}{ver}` }))\n }\n if (svcs.len > MAX_SERVICES) {\n out2.push(Ui:C:mfm({ text: ` $[fg.color={MUTED} …ほか {svcs.len - MAX_SERVICES} 件]` }))\n }\n out2\n}\n\n/// 証明書は「失効しているか / あと何日か」を判定して色を変える。\n/// 日付をそのまま緑で出すと失効済みを見逃すため。\n@certView(ts) {\n if (Core:type(ts) != \"num\") { return [] }\n let d = daysFrom(ts)\n let ymd = fmtYmd(ts)\n if (d < 0) {\n [ Ui:C:mfm({ text: `$[fg.color={CRIT} 🚨 TLS 証明書は失効済み] $[fg.color={MUTED} {ymd} ({0 - d} 日前)]` }) ]\n } elif (d <= 14) {\n [ Ui:C:mfm({ text: `$[fg.color={CRIT} ⚠ TLS 期限まで {d} 日] $[fg.color={MUTED} {ymd}]` }) ]\n } elif (d <= 30) {\n [ Ui:C:mfm({ text: `$[fg.color={WARN} ⚠ TLS 期限まで {d} 日] $[fg.color={MUTED} {ymd}]` }) ]\n } else {\n [ Ui:C:mfm({ text: `$[fg.color={OK} 🔒 TLS 有効] $[fg.color={MUTED} {ymd} まで ({d} 日)]` }) ]\n }\n}\n\n@vulnColor(cvss) {\n if (Core:type(cvss) != \"num\") { return WARN }\n if (cvss >= 9) { CRIT } elif (cvss >= 7) { CRIT } elif (cvss >= 4) { WARN } else { MUTED }\n}\n\n/// CVE は CVSS 降順で、検証状態を明示する。\n/// Shodan の CVE はバナー由来の推測が多く、未検証を同列の赤で並べると\n/// 警報疲れを起こして本当に危険なものが埋もれる。\n@vulnsView(vulns) {\n if (Core:type(vulns) != \"arr\" || vulns.len == 0) {\n return [ Ui:C:mfm({ text: `
$[fg.color={OK} ✅ 既知の脆弱性は報告されていません]
` }) ]\n }\n var verified = 0\n for (let i, vulns.len) { if (vulns[i].verified) { verified += 1 } }\n\n var out2 = [\n Ui:C:mfm({ text: `
$[fg.color={CRIT} ⚠️ 脆弱性候補 {vulns.len} 件] $[fg.color={MUTED} (実証済み {verified} 件)]
` })\n ]\n let n = if (vulns.len > MAX_VULNS) { MAX_VULNS } else { vulns.len }\n for (let i, n) {\n let v = vulns[i]\n let score = if (Core:type(v.cvss) == \"num\") { `CVSS {v.cvss}` } else { \"CVSS 不明\" }\n let mark = if (v.verified) { \"実証済\" } else { \"推定\" }\n out2.push(Ui:C:mfm({ text: ` $[fg.color={vulnColor(v.cvss)} ▸ {truncName(v.id, 20)}] $[fg.color={MUTED} {score} · {mark}]` }))\n }\n if (vulns.len > MAX_VULNS) {\n out2.push(Ui:C:mfm({ text: ` $[fg.color={MUTED} …ほか {vulns.len - MAX_VULNS} 件]` }))\n }\n out2.push(Ui:C:mfm({ text: ` $[fg.color={MUTED} 「推定」はバナーのバージョン表記からの推測です]` }))\n out2\n}\n\n/// Shodan のデータは数ヶ月古いことがある。いつ時点のスキャンかを必ず出す。\n@freshnessView(result) {\n if (result.lastDate == \"\") { return [] }\n let age = result.lastAge\n if (Core:type(age) == \"num\" && age > STALE_DAYS) {\n [ Ui:C:mfm({ text: `
$[fg.color={WARN} 🕒 Shodan スキャン {result.lastDate} ({age} 日前)]
` }) ]\n } else {\n [ Ui:C:mfm({ text: `
$[fg.color={MUTED} 🕒 Shodan スキャン {result.lastDate}]
` }) ]\n }\n}\n\n/// この表示がいつ取得したものか。キャッシュ由来なら明示する。\n/// (Shodan のスキャン日時とは別物なので別行・別アイコンで出す)\n@cacheView() {\n if (Core:type(shownAt) != \"num\") { return [] }\n let elapsed = Date:now() - shownAt\n let mins = Math:floor(elapsed / 60000)\n let age = if (mins < 1) { \"たった今\" } elif (mins < 60) { `{mins} 分前` } else { `{Math:floor(mins / 60)} 時間前` }\n let label = if (fromCache) { \"キャッシュ\" } else { \"取得\" }\n let col = if (elapsed > CACHE_AGE_MS) { WARN } else { MUTED }\n [ Ui:C:mfm({ text: `
$[fg.color={col} 🗃 {label} · {age}]
` }) ]\n}\n\n@creditsView(credits) {\n if (Core:type(credits) != \"num\") { return [] }\n [ Ui:C:mfm({ text: `
$[fg.color={MUTED} 残クエリクレジット {credits}]
` }) ]\n}\n\n@headerView(h, ip) {\n [\n Ui:C:mfm({ text: `
$[fg.color={ACCENT} **🛰 Shodan 監査**] $[fg.color={MUTED} {truncName(h, 28)}]
` })\n Ui:C:mfm({ text: `
$[fg.color={INFO} {sanitizeText(ip)}]
` })\n ]\n}\n\n@pushAll(dst, src) {\n for (let i, src.len) { dst.push(src[i]) }\n dst\n}\n\n/// 押すと 1 クレジット消費することをラベルに出す (押す前にコストが分かるように)\n@refreshButton() {\n Ui:C:button({ text: \"更新 (1 クレジット)\", primary: true, onClick: @() { fetchData() } })\n}\n\n@configFolder(opened) {\n Ui:C:folder({\n title: \"⚙️ 設定\"\n opened: opened\n children: [\n Ui:C:textInput({\n onInput: @(v) {\n host = v\n Mk:save(\"host\", v)\n }\n default: host\n label: \"インスタンスのホスト名\"\n text: \"監査対象 (例: misskey.io / 203.0.113.10)\"\n })\n Ui:C:mfm({ text: `$[fg.color={MUTED} 監査 1 回につきクエリクレジットを 1 消費します]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} 結果はキャッシュされ、カラムを開き直しても消費しません]` })\n Ui:C:button({ text: \"この対象で監査 (1 クレジット)\", primary: true, onClick: @() { fetchData() } })\n ]\n })\n}\n\n@auditView(result) {\n var body = headerView(result.host, result.ip)\n\n // org / country は片方だけのこともある。区切りが宙に浮かないよう組み立てる\n var loc = \"\"\n if (Core:type(result.country) == \"str\" && result.country != \"\") { loc = sanitizeText(result.country) }\n if (Core:type(result.org) == \"str\" && result.org != \"\") {\n let o = truncName(result.org, 24)\n loc = if (loc == \"\") { o } else { `{loc} · {o}` }\n }\n if (loc != \"\") {\n body.push(Ui:C:mfm({ text: `
$[fg.color={MUTED} {loc}]
` }))\n }\n\n body.push(divider())\n pushAll(body, portsView(result.ports))\n pushAll(body, servicesView(result.services))\n pushAll(body, certView(result.certTs))\n body.push(divider())\n pushAll(body, vulnsView(result.vulns))\n if (get(result, \"minified\", false)) {\n body.push(Ui:C:mfm({ text: `
$[fg.color={MUTED} 簡易表示 (バナー詳細を省略して取得)]
` }))\n }\n pushAll(body, freshnessView(result))\n pushAll(body, cacheView())\n pushAll(body, creditsView(result.credits))\n body\n}\n\n@renderLoading(msg) {\n Ui:render([\n Ui:C:container({ align: \"center\", children: [ Ui:C:text({ text: `{msg} 🛰` }) ] })\n ])\n}\n\n@render() {\n // 起動時はまずキャッシュを見る。あればクレジットを使わずそれを描く。\n // 実取得するのは「そのホストで初めて監査するとき」だけ。\n if (firstRun && Core:type(result) == \"null\") {\n firstRun = false\n let c = loadCache()\n let herr = hostError(normHost(host))\n if (Core:type(c) == \"obj\") {\n result = c.data\n shownAt = get(c, \"savedAt\", null)\n fromCache = true\n } elif (herr == \"\") {\n fetchData()\n return null\n } elif (host != \"\") {\n // 保存済みホストが不正。理由を出すだけで取得はしない (クレジット消費なし)\n result = { error: herr, hint: \"設定 (⚙️) でホスト名を修正してください\" }\n }\n }\n firstRun = false\n\n var children = []\n if (Core:type(result) == \"null\") {\n // 未設定時の初期画面。入力欄は最初から開いておく\n children = [\n Ui:C:mfm({ text: `
$[fg.color={ACCENT} **🛰 Shodan インスタンス監査**]
` })\n Ui:C:mfm({ text: `
$[fg.color={MUTED} 監査対象のホストを設定してください]
` })\n configFolder(true)\n ]\n } elif (Obj:has(result, \"notIndexed\")) {\n children = headerView(result.host, result.ip)\n children.push(divider())\n children.push(Ui:C:mfm({ text: `
$[fg.color={OK} 🫥 Shodan にインデックスされていません]
` }))\n children.push(Ui:C:mfm({ text: `
$[fg.color={MUTED} 公開スキャンに露出情報が見つかりませんでした]
` }))\n pushAll(children, cacheView())\n pushAll(children, creditsView(result.credits))\n children.push(configFolder(false))\n children.push(refreshButton())\n } elif (Obj:has(result, \"error\")) {\n children = [\n Ui:C:mfm({ text: `
$[fg.color={CRIT} ⚠️ {sanitizeText(result.error)}]
` })\n Ui:C:mfm({ text: `
$[fg.color={MUTED} {sanitizeText(result.hint)}]
` })\n ]\n // 応答が壊れていたときだけ、簡易表示で取り直す導線を出す\n if (get(result, \"canMinify\", false)) {\n children.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 応答が大きすぎて途中で切れている可能性があります。簡易表示なら通ることがあります (バナー詳細は省略)]` }))\n children.push(Ui:C:button({ text: \"簡易表示で取得 (1 クレジット)\", primary: true, onClick: @() { fetchMinified() } }))\n }\n children.push(configFolder(true))\n children.push(Ui:C:button({ text: \"再試行 (1 クレジット)\", onClick: @() { fetchData() } }))\n } else {\n children = auditView(result)\n children.push(divider())\n children.push(Ui:C:mfm({ text: `
[Shodan で開く →](https://www.shodan.io/host/{result.ip})
` }))\n children.push(configFolder(false))\n children.push(refreshButton())\n }\n Ui:render([ Ui:C:container({ children: children }) ])\n}\n\nrender()\n// 自動更新はしない (host 参照はクエリクレジットを消費するため、更新は手動)\n"} +{"type":"widget","data":"/// @ 1.2.1\n/// Shodan インスタンス監査ウィジェット\n/// 指定した Misskey インスタンス (自分のサーバ等) が外からどう見えているかを\n/// Shodan で可視化する管理者向けの防御的セルフ監査ツール。\n/// 露出ポートの危険度・稼働サービス・TLS 失効判定・CVE (CVSS/検証状態付き)・\n/// データ鮮度・残クレジットを表示する。\n/// API Key は widget.is には一切書かず、Vault がサーバーサイドで\n/// クエリ (?key=...) に注入する。\n///\n/// ■ 事前準備\n/// 1. Shodan の API Key を取得:\n/// https://account.shodan.io/ (Account → API Key)\n/// 2. NoteDeck の Secret Vault で接続を作成:\n/// 名前 : Shodan\n/// baseURL : https://api.shodan.io\n/// 認証方式 : 「クエリ」を選ぶ (param 名 = key)\n/// シークレット: Shodan の API Key (生の値)\n/// \"AI からのアクセスを許可\" を ON にする\n/// → Vault が ?key= を各リクエストに自動付与する。\n/// 3. 設定 (⚙️) で監査対象のホストを入力\n/// 例: misskey.io / your-instance.example / 203.0.113.10\n///\n/// ■ 注意\n/// - /shodan/host/ は 1 回につきクエリクレジットを 1 消費する。\n/// このため自動更新は行わず、結果を Mk:save にキャッシュする。\n/// カラムを開き直してもキャッシュを描くだけでクレジットは減らない。\n/// 実際に取得するのは (a) そのホストで初めて監査するとき\n/// (b) 「更新」を押したとき の 2 つだけ。\n/// - 表示されるのは Shodan が収集済みのスキャン結果 (OSINT) であり、\n/// リアルタイムの状態ではない。「データ取得」の日付を必ず併記する。\n/// - 自分の管理下のインスタンスの露出点検を主用途とする。\n/// 設計の元ネタ: wakatime (Vault) / atcoder (設定入力・表示ループ) / cve (脆弱性表示)。\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n/// MFM/HTML 制御文字を無害化。org 名・product 名など外部由来文字列は\n/// そのまま MFM に流さず、ブレイクアウトを防ぐ。\n@sanitizeText(s) {\n if (Core:type(s) != \"str\") { return \"(不明)\" }\n var txt = s.replace(\"$[\", \"$ \")\n txt = txt.replace(\"]\", \"]\")\n txt = txt.replace(\"[\", \"[\")\n txt = txt.replace(\"<\", \"<\")\n txt = txt.replace(\">\", \">\")\n txt = txt.replace(\"`\", \"`\")\n txt = txt.replace(\"*\", \"*\")\n txt = txt.replace(\"@\", \"@\")\n txt = txt.replace(\"#\", \"#\")\n txt = txt.replace(\"\\\\\", \"\\")\n txt = txt.replace(\"://\", \"://\")\n txt\n}\n\n@truncName(s, n) {\n let clean = sanitizeText(s)\n if (clean.len > n) { `{clean.slice(0, n)}…` } else { clean }\n}\n\n/// 数値/文字列配列を sep で連結 (arr.join が無いので手動)\n@joinArr(arr, sep) {\n if (Core:type(arr) != \"arr\") { return \"\" }\n var s = \"\"\n for (let i, arr.len) {\n if (i > 0) { s = `{s}{sep}` }\n s = `{s}{arr[i]}`\n }\n s\n}\n\n// ─────────────────────────────\n// ホスト名の正規化と検証\n// ─────────────────────────────\nlet HOST_CHARS = \"abcdefghijklmnopqrstuvwxyz0123456789.-\"\n\n/// 入力を Shodan に渡せるホスト名に正規化する。\n/// scheme / path / query / fragment / ポート / 前後空白を落とし、小文字化する。\n/// Shodan の /dns/resolve は小文字キーで返すため、小文字化は照合のために必須。\n@normHost(s) {\n if (Core:type(s) != \"str\") { return \"\" }\n var h = s.trim().lower()\n h = h.replace(\"https://\", \"\")\n h = h.replace(\"http://\", \"\")\n h = h.split(\"/\")[0]\n h = h.split(\"?\")[0]\n h = h.split(\"#\")[0]\n h = h.split(\" \")[0]\n h = h.split(\":\")[0] // ポート番号を除去\n h\n}\n\n/// 正規化後のホスト名が妥当か。不正な文字はここで弾き、\n/// クエリ文字列へそのまま流れ込むのを防ぐ。\n@hostError(h) {\n if (h == \"\") { return \"監査対象のホストが未設定です\" }\n if (h.len > 253) { return \"ホスト名が長すぎます\" }\n let cs = h.to_arr()\n for (let i, cs.len) {\n if (!HOST_CHARS.incl(cs[i])) { return \"ホスト名に使えない文字が含まれています\" }\n }\n if (!h.incl(\".\")) { return \"ホスト名にドットがありません (例: misskey.io)\" }\n if (h.slice(0, 1) == \".\" || h.slice(h.len - 1, h.len) == \".\") { return \"ホスト名の先頭/末尾がドットです\" }\n if (h.slice(0, 1) == \"-\") { return \"ホスト名の先頭がハイフンです\" }\n \"\"\n}\n\n/// IPv4 リテラルか。真なら /dns/resolve を省略できる (リクエスト 1 本節約)。\n@isIpv4(h) {\n let parts = h.split(\".\")\n if (parts.len != 4) { return false }\n for (let i, 4) {\n let p = parts[i]\n if (p == \"\" || p.len > 3) { return false }\n let ds = p.to_arr()\n for (let j, ds.len) {\n if (!\"0123456789\".incl(ds[j])) { return false }\n }\n }\n true\n}\n\n// ─────────────────────────────\n// 日付\n// ─────────────────────────────\nlet DAY_MS = 86400000\n\n/// Shodan の cert.expires (\"20260401120000Z\") を ISO に。失敗時は \"\"\n@certIso(s) {\n if (Core:type(s) != \"str\" || s.len < 14) { return \"\" }\n `{s.slice(0, 4)}-{s.slice(4, 6)}-{s.slice(6, 8)}T{s.slice(8, 10)}:{s.slice(10, 12)}:{s.slice(12, 14)}Z`\n}\n\n/// ISO 文字列 → epoch ms。パースできなければ null\n@toEpoch(iso) {\n if (iso == \"\") { return null }\n let t = Date:parse(iso)\n if (Core:type(t) != \"num\") { return null }\n t\n}\n\n/// epoch ms から「今日から見た残り日数」(負なら経過日数)\n@daysFrom(ts) { Math:floor((ts - Date:now()) / DAY_MS) }\n\n/// epoch ms → \"YYYY-MM-DD\" (Date:month は 8 のように 0 埋めされないので補う)\n@fmtYmd(ts) {\n `{Date:year(ts)}-{Core:to_str(Date:month(ts)).pad_start(2, \"0\")}-{Core:to_str(Date:day(ts)).pad_start(2, \"0\")}`\n}\n\n// ─────────────────────────────\n// 定数\n// ─────────────────────────────\nlet CONNECTION = \"Shodan\"\nlet MAX_SERVICES = 6 // 表示する稼働サービス数\nlet MAX_VULNS = 8 // 表示する CVE 数\nlet STALE_DAYS = 30 // これより古い Shodan データは鮮度警告を出す\nlet CACHE_AGE_MS = 21600000 // 6h。これを超えたキャッシュには「古い」印を付ける\n // (印を付けるだけで、自動再取得はしない = クレジットを使わない)\n\n// Misskey インスタンスとして公開されて当然のポート。これ以外は棚卸し対象。\nlet EXPECTED_PORTS = [80, 443]\n\n// CDN の共有エッジに当たったことを org 名で判定する。\n// この場合そこは利用者のサーバーではないので、ポートを警告してはいけない。\nlet CDN_ORGS = [\n \"cloudflare\", \"fastly\", \"akamai\", \"cloudfront\", \"incapsula\", \"imperva\"\n \"sucuri\", \"stackpath\", \"bunny\", \"gcore\", \"cdn77\", \"keycdn\"\n]\n// CDN がプロキシに使う標準ポート。エッジなら開いていて当たり前で、\n// 利用者の設定不備ではない。\nlet CDN_PORTS = [80, 443, 2052, 2053, 2082, 2083, 2086, 2087, 2095, 2096, 8080, 8443, 8880]\n\n// 公開インターネットに晒すべきでない代表的なポート (データストア / 管理系)。\nlet CRITICAL_PORTS = {\n \"23\": \"Telnet\", \"21\": \"FTP\", \"3389\": \"RDP\", \"5900\": \"VNC\"\n \"3306\": \"MySQL\", \"5432\": \"PostgreSQL\", \"6379\": \"Redis\", \"27017\": \"MongoDB\"\n \"9200\": \"Elasticsearch\", \"11211\": \"Memcached\", \"5672\": \"RabbitMQ\"\n \"2375\": \"Docker API\", \"2376\": \"Docker API\", \"9100\": \"node_exporter\"\n \"9090\": \"Prometheus\", \"8086\": \"InfluxDB\", \"25\": \"SMTP\"\n}\n// 用途は正当だが公開範囲を確認したいポート\nlet REVIEW_PORTS = { \"22\": \"SSH\", \"3000\": \"アプリ直開き\", \"8080\": \"HTTP 代替\" }\n\nlet ACCENT = \"c8102e\" // Shodan アクセント (赤)\nlet INFO = \"3ea6ff\" // 情報 (青)\nlet OK = \"43a047\" // 良好 (緑)\nlet WARN = \"f0a020\" // 要確認 (橙)\nlet CRIT = \"ea3943\" // 危険 (赤)\nlet MUTED = \"8a919e\"\nlet LINE = \"5a6573\"\n\n// ─────────────────────────────\n// 状態\n// ─────────────────────────────\n// 監査対象ホスト。Mk:save で永続化。デフォルトは未設定 (誤ってクレジットを\n// 消費しないよう、ホスト未設定なら自動取得しない)。\nvar host = dflt(Mk:load(\"host\"), \"\")\nvar result = null\nvar firstRun = true\n// 取得中フラグ。「更新」連打で 1 押しごとにクレジットが飛ぶのを防ぐ。\nvar busy = false\n// いま表示しているデータをいつ取得したか / それがキャッシュ由来か\nvar shownAt = null\nvar fromCache = false\n\n/// 直近の監査結果のキャッシュ。{ host, savedAt, data }\n/// カラムを開くたびに再取得するとその都度クレジットを消費するため、\n/// autoRun ではこれを描くだけにして取得しない。\n@loadCache() {\n let c = Mk:load(\"cache\")\n if (Core:type(c) != \"obj\") { return null }\n if (Core:type(get(c, \"data\", null)) != \"obj\") { return null }\n if (get(c, \"host\", \"\") != normHost(host)) { return null } // 対象が変わったら使わない\n c\n}\n\n@saveCache(data) {\n Mk:save(\"cache\", { host: normHost(host), savedAt: Date:now(), data: data })\n}\n\n// ─────────────────────────────\n// データ取得 (Key は Vault が ?key= に注入。headers に key は書かない)\n// ─────────────────────────────\n@vaultGet(path) {\n Nd:call(\"vault.fetch\", {\n connectionRef: CONNECTION\n path: path\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n}\n\n/// 200 のときだけ JSON を返す。パース不能なら null\n@jsonOf(res) {\n if (res.status != 200) { return null }\n let b = res.body\n let t = Core:type(b)\n // Vault の実装によっては本文が既にパース済みで返る。その場合はそのまま使う\n if (t == \"obj\" || t == \"arr\") { return b }\n // Json:parse は非文字列を渡すと例外で落ちるので必ず型を見てから呼ぶ\n if (t != \"str\") { return null }\n let d = Json:parse(b)\n if (Core:type(d) != \"obj\" && Core:type(d) != \"arr\") { return null }\n d\n}\n\n/// 解釈できなかったときに「何が返ってきたのか」を短く要約する。\n/// これが無いと利用者も作者も原因を切り分けられない。\n/// 本文は外部由来なので必ずサニタイズしてから表示する。\n@bodyBrief(res) {\n let b = res.body\n let t = Core:type(b)\n if (t != \"str\") { return `本文の型が {t} でした` }\n if (b.len == 0) { return \"本文が空でした\" }\n let head = if (b.len > 60) { `{b.slice(0, 60)}…` } else { b }\n `{b.len} 文字: {sanitizeText(head)}`\n}\n\n/// data[] の banner から {port, product, version} を抽出\n@normalizeServices(banners) {\n if (Core:type(banners) != \"arr\") { return [] }\n var items = []\n for (let i, banners.len) {\n let b = banners[i]\n if (Core:type(b) == \"obj\") {\n items.push({\n port: get(b, \"port\", 0)\n product: get(b, \"product\", \"\")\n version: get(b, \"version\", \"\")\n })\n }\n }\n items\n}\n\n/// CVE を {id, cvss, verified} で収集する。\n/// banner 側 (data[].vulns) はメタデータを持つのでそちらを優先し、\n/// top-level の vulns 配列はメタ無しとして補完する。\n/// Shodan の CVE の多くはバナーのバージョン文字列からの推測 (unverified) なので、\n/// 検証状態を落とさずに持ち回るのが肝心。\n@collectVulns(data) {\n var found = []\n var index = {}\n\n @put(id, cvss, verified) {\n if (Core:type(id) != \"str\" || id == \"\") { return null }\n if (Obj:has(index, id)) {\n // 既出。メタデータが後から来たら埋める\n let cur = found[index[id]]\n if (Core:type(cur.cvss) == \"null\" && Core:type(cvss) == \"num\") { cur.cvss = cvss }\n if (verified) { cur.verified = true }\n return null\n }\n index[id] = found.len\n found.push({ id: id, cvss: cvss, verified: verified })\n }\n\n let banners = get(data, \"data\", [])\n if (Core:type(banners) == \"arr\") {\n for (let i, banners.len) {\n let bv = get(banners[i], \"vulns\", null)\n if (Core:type(bv) == \"obj\") {\n let ks = Obj:keys(bv)\n for (let j, ks.len) {\n let v = bv[ks[j]]\n let cvss = if (Core:type(get(v, \"cvss\", null)) == \"num\") { v.cvss } else { null }\n put(ks[j], cvss, get(v, \"verified\", false) == true)\n }\n }\n }\n }\n\n let top = get(data, \"vulns\", null)\n if (Core:type(top) == \"arr\") {\n for (let i, top.len) { put(top[i], null, false) }\n }\n\n // CVSS 降順。値が無いものは末尾へ\n found.sort(@(a, b) {\n let av = if (Core:type(a.cvss) == \"num\") { a.cvss } else { 0 - 1 }\n let bv = if (Core:type(b.cvss) == \"num\") { b.cvss } else { 0 - 1 }\n bv - av\n })\n}\n\n/// 全 banner の証明書のうち「最も早く切れるもの」を返す。\n/// 失効を見逃さないため、最初に見つかったものではなく最小値を取る。\n@earliestCert(banners) {\n if (Core:type(banners) != \"arr\") { return null }\n var best = null\n for (let i, banners.len) {\n let ssl = get(banners[i], \"ssl\", null)\n if (Core:type(ssl) == \"obj\") {\n let cert = get(ssl, \"cert\", null)\n if (Core:type(cert) == \"obj\") {\n let ts = toEpoch(certIso(get(cert, \"expires\", \"\")))\n if (Core:type(ts) == \"num\") {\n if (Core:type(best) == \"null\" || ts < best) { best = ts }\n }\n }\n }\n }\n best\n}\n\n@fetchCredits() {\n let d = jsonOf(vaultGet(\"/api-info\"))\n if (Core:type(d) != \"obj\") { return null }\n get(d, \"query_credits\", null)\n}\n\n@fail(msg, hint) {\n busy = false\n result = { error: msg, hint: hint }\n render()\n}\n\n/// 取得完了。結果をキャッシュしてから描画する。\n@finish(data) {\n busy = false\n shownAt = Date:now()\n fromCache = false\n result = data\n saveCache(data)\n render()\n}\n\n/// 前回このホストで応答が上限超過したか。超過していたなら最初から簡易表示で取る\n/// (通常表示でもう一度上限に当たってクレジットを捨てるのを防ぐ)。\n@needsMinify() { Mk:load(\"needMinify\") == normHost(host) }\n\n@fetchData() { runAudit(needsMinify()) }\n\n/// 簡易表示。Shodan の minify=true は大きいフィールドを切り詰めて返すので、\n/// 応答が上限を超えるホストでも収まることがある。\n/// バナー詳細が落ちるぶん稼働サービス / TLS は出ないが、ポートと CVE は残る。\n@fetchMinified() { runAudit(true) }\n\n@runAudit(minify) {\n if (busy) { return null } // 連打防止。1 押し = 1 クレジット\n let h = normHost(host)\n let herr = hostError(h)\n if (herr != \"\") {\n fail(herr, \"設定 (⚙️) で監査対象のホストを入力してください (例: misskey.io)\")\n return null\n }\n\n busy = true\n renderLoading(`{h} を照会中…`)\n\n // 1. ホスト名 → IP (IP 直指定なら解決を省略してリクエストを 1 本節約)\n var ip = h\n if (!isIpv4(h)) {\n // hostError で文字種は検証済みだが、クエリ文字列に入れる値は必ずエスケープする\n let rres = vaultGet(`/dns/resolve?hostnames={Uri:encode_component(h)}`)\n if (rres.status == 401) {\n fail(\"認証エラー (401)\", \"Vault の Shodan 接続と API Key を確認してください\")\n return null\n }\n if (rres.status == 429) {\n fail(\"レート制限中です (429)\", \"少し待ってから再試行してください\")\n return null\n }\n if (rres.status != 200) {\n fail(`名前解決に失敗 (HTTP {rres.status})`, \"Vault 接続 (baseURL: https://api.shodan.io) を確認してください\")\n return null\n }\n let rmap = jsonOf(rres)\n let resolved = get(rmap, h, null)\n if (Core:type(resolved) != \"str\" || resolved == \"\") {\n fail(`{sanitizeText(h)} を解決できませんでした`, \"ホスト名の綴りと、その名前が公開 DNS に存在するかを確認してください\")\n return null\n }\n ip = resolved\n }\n\n // 2. ホスト詳細 (クレジットを 1 消費)\n let hres = if (minify) { vaultGet(`/shodan/host/{ip}?minify=true`) } else { vaultGet(`/shodan/host/{ip}`) }\n if (hres.status == 404) {\n // Shodan 未インデックス = 公開スキャンに引っかかっていない。防御的にはむしろ良い。\n finish({ notIndexed: true, host: h, ip: ip, credits: fetchCredits() })\n return null\n }\n if (hres.status == 401) {\n fail(\"認証エラー (401)\", \"Vault の Shodan 接続と API Key を確認してください\")\n return null\n }\n if (hres.status == 403) {\n fail(\"このプランでは参照できません (403)\", \"Shodan のプランで host 参照が有効か確認してください\")\n return null\n }\n if (hres.status == 429) {\n fail(\"レート制限中です (429)\", \"少し待ってから再試行してください\")\n return null\n }\n if (hres.status != 200) {\n fail(`ホスト情報の取得に失敗 (HTTP {hres.status})`, \"時間をおいて再試行してください\")\n return null\n }\n\n let data = jsonOf(hres)\n if (Core:type(data) != \"obj\") {\n busy = false\n // vault.fetch は応答が上限を超えると truncated を立てて切り詰める\n // (notedeck: src-tauri/src/vault/fetch.rs の MAX_RESPONSE_BYTES = 512 KiB)。\n // 推測せずこのフラグで断定し、次回は最初から簡易表示で取るよう覚えておく。\n if (get(hres, \"truncated\", false) == true) {\n Mk:save(\"needMinify\", normHost(host))\n result = {\n error: \"応答が 512 KiB の上限を超えました\"\n hint: `{get(hres, \"bytesTotal\", 0)} バイトで打ち切られたため解釈できません`\n canMinify: !minify\n }\n } else {\n result = {\n error: \"ホスト情報を解釈できませんでした\"\n hint: `応答: {bodyBrief(hres)}`\n canMinify: !minify\n }\n }\n render()\n return null\n }\n // 通常表示で取れたなら、簡易表示に固定する必要はもう無い\n if (!minify) { Mk:save(\"needMinify\", \"\") }\n\n let banners = get(data, \"data\", [])\n var ports = get(data, \"ports\", [])\n if (Core:type(ports) == \"arr\") { ports = ports.sort(@(a, b) { a - b }) }\n\n let lastRaw = get(data, \"last_update\", \"\")\n let lastTs = if (Core:type(lastRaw) == \"str\" && lastRaw.len >= 10) { toEpoch(`{lastRaw.slice(0, 10)}T00:00:00Z`) } else { null }\n\n finish({\n host: h\n ip: ip\n minified: minify\n org: get(data, \"org\", \"\")\n country: get(data, \"country_name\", \"\")\n ports: ports\n services: normalizeServices(banners)\n certTs: earliestCert(banners)\n vulns: collectVulns(data)\n lastDate: if (Core:type(lastRaw) == \"str\" && lastRaw.len >= 10) { lastRaw.slice(0, 10) } else { \"\" }\n lastAge: if (Core:type(lastTs) == \"num\") { 0 - daysFrom(lastTs) } else { null }\n credits: fetchCredits()\n })\n}\n\n// ─────────────────────────────\n// UI 構築\n// ─────────────────────────────\n@divider() {\n Ui:C:mfm({ text: `
$[fg.color={LINE} ───────────────]
` })\n}\n\n/// org 名が CDN のものなら、その名前を返す (でなければ \"\")\n@cdnOf(org) {\n if (Core:type(org) != \"str\" || org == \"\") { return \"\" }\n let lo = org.lower()\n for (let i, CDN_ORGS.len) {\n if (lo.incl(CDN_ORGS[i])) { return org }\n }\n \"\"\n}\n\n/// isCdn のときは CDN 標準ポートを警告しない。そこは利用者のサーバーではなく\n/// CDN の共有エッジなので、開いていることが設定不備を意味しないため。\n@portLabel(p, isCdn) {\n let k = Core:to_str(p)\n if (EXPECTED_PORTS.incl(p)) { { color: MUTED, name: \"\", level: 0 } }\n elif (isCdn && CDN_PORTS.incl(p)) { { color: MUTED, name: \"\", level: 0 } }\n elif (Obj:has(CRITICAL_PORTS, k)) { { color: CRIT, name: CRITICAL_PORTS[k], level: 2 } }\n elif (Obj:has(REVIEW_PORTS, k)) { { color: WARN, name: REVIEW_PORTS[k], level: 1 } }\n else { { color: WARN, name: \"\", level: 1 } }\n}\n\n/// 露出ポートの棚卸し。80/443 以外は必ず目に留まるようにする。\n/// ただし CDN のエッジなら CDN 標準ポートは想定内に畳む。\n@portsView(ports, isCdn) {\n if (Core:type(ports) != \"arr\" || ports.len == 0) {\n return [ Ui:C:mfm({ text: `$[fg.color={MUTED} 🔌 開放ポート情報なし]` }) ]\n }\n var expected = []\n var flagged = []\n for (let i, ports.len) {\n let p = ports[i]\n let info = portLabel(p, isCdn)\n if (info.level == 0) { expected.push(p) } else { flagged.push({ port: p, info: info }) }\n }\n\n var out2 = [ Ui:C:mfm({ text: `$[fg.color={MUTED} 🔌 開放ポート {ports.len} 件]` }) ]\n if (expected.len > 0) {\n let label = if (isCdn) { \"✓ CDN 標準\" } else { \"✓ 想定内\" }\n out2.push(Ui:C:mfm({ text: ` $[fg.color={OK} {label}] $[fg.color={MUTED} {joinArr(expected, \", \")}]` }))\n }\n for (let i, flagged.len) {\n let f = flagged[i]\n let nm = if (f.info.name == \"\") { \"要確認\" } else { `{f.info.name}` }\n let mark = if (f.info.level == 2) { \"🚨\" } else { \"⚠\" }\n out2.push(Ui:C:mfm({ text: ` $[fg.color={f.info.color} {mark} {f.port}] $[fg.color={MUTED} {sanitizeText(nm)}]` }))\n }\n out2\n}\n\n@servicesView(svcs) {\n if (Core:type(svcs) != \"arr\" || svcs.len == 0) { return [] }\n var out2 = [ Ui:C:mfm({ text: `$[fg.color={MUTED} 🧩 稼働サービス]` }) ]\n let n = if (svcs.len > MAX_SERVICES) { MAX_SERVICES } else { svcs.len }\n for (let i, n) {\n let s = svcs[i]\n let prod = if (s.product == \"\") { \"(不明)\" } else { truncName(s.product, 18) }\n let ver = if (Core:type(s.version) == \"str\" && s.version != \"\") { ` $[fg.color={MUTED} {truncName(s.version, 10)}]` } else { \"\" }\n out2.push(Ui:C:mfm({ text: ` $[fg.color={ACCENT} ▸] $[fg.color={INFO} {s.port}] {prod}{ver}` }))\n }\n if (svcs.len > MAX_SERVICES) {\n out2.push(Ui:C:mfm({ text: ` $[fg.color={MUTED} …ほか {svcs.len - MAX_SERVICES} 件]` }))\n }\n out2\n}\n\n/// 証明書は「失効しているか / あと何日か」を判定して色を変える。\n/// 日付をそのまま緑で出すと失効済みを見逃すため。\n@certView(ts) {\n if (Core:type(ts) != \"num\") { return [] }\n let d = daysFrom(ts)\n let ymd = fmtYmd(ts)\n if (d < 0) {\n [ Ui:C:mfm({ text: `$[fg.color={CRIT} 🚨 TLS 証明書は失効済み] $[fg.color={MUTED} {ymd} ({0 - d} 日前)]` }) ]\n } elif (d <= 14) {\n [ Ui:C:mfm({ text: `$[fg.color={CRIT} ⚠ TLS 期限まで {d} 日] $[fg.color={MUTED} {ymd}]` }) ]\n } elif (d <= 30) {\n [ Ui:C:mfm({ text: `$[fg.color={WARN} ⚠ TLS 期限まで {d} 日] $[fg.color={MUTED} {ymd}]` }) ]\n } else {\n [ Ui:C:mfm({ text: `$[fg.color={OK} 🔒 TLS 有効] $[fg.color={MUTED} {ymd} まで ({d} 日)]` }) ]\n }\n}\n\n@vulnColor(cvss) {\n if (Core:type(cvss) != \"num\") { return WARN }\n if (cvss >= 9) { CRIT } elif (cvss >= 7) { CRIT } elif (cvss >= 4) { WARN } else { MUTED }\n}\n\n/// CVE は CVSS 降順で、検証状態を明示する。\n/// Shodan の CVE はバナー由来の推測が多く、未検証を同列の赤で並べると\n/// 警報疲れを起こして本当に危険なものが埋もれる。\n@vulnsView(vulns) {\n if (Core:type(vulns) != \"arr\" || vulns.len == 0) {\n return [ Ui:C:mfm({ text: `
$[fg.color={OK} ✅ 既知の脆弱性は報告されていません]
` }) ]\n }\n var verified = 0\n for (let i, vulns.len) { if (vulns[i].verified) { verified += 1 } }\n\n var out2 = [\n Ui:C:mfm({ text: `
$[fg.color={CRIT} ⚠️ 脆弱性候補 {vulns.len} 件] $[fg.color={MUTED} (実証済み {verified} 件)]
` })\n ]\n let n = if (vulns.len > MAX_VULNS) { MAX_VULNS } else { vulns.len }\n for (let i, n) {\n let v = vulns[i]\n let score = if (Core:type(v.cvss) == \"num\") { `CVSS {v.cvss}` } else { \"CVSS 不明\" }\n let mark = if (v.verified) { \"実証済\" } else { \"推定\" }\n out2.push(Ui:C:mfm({ text: ` $[fg.color={vulnColor(v.cvss)} ▸ {truncName(v.id, 20)}] $[fg.color={MUTED} {score} · {mark}]` }))\n }\n if (vulns.len > MAX_VULNS) {\n out2.push(Ui:C:mfm({ text: ` $[fg.color={MUTED} …ほか {vulns.len - MAX_VULNS} 件]` }))\n }\n out2.push(Ui:C:mfm({ text: ` $[fg.color={MUTED} 「推定」はバナーのバージョン表記からの推測です]` }))\n out2\n}\n\n/// Shodan のデータは数ヶ月古いことがある。いつ時点のスキャンかを必ず出す。\n@freshnessView(result) {\n if (result.lastDate == \"\") { return [] }\n let age = result.lastAge\n if (Core:type(age) == \"num\" && age > STALE_DAYS) {\n [ Ui:C:mfm({ text: `
$[fg.color={WARN} 🕒 Shodan スキャン {result.lastDate} ({age} 日前)]
` }) ]\n } else {\n [ Ui:C:mfm({ text: `
$[fg.color={MUTED} 🕒 Shodan スキャン {result.lastDate}]
` }) ]\n }\n}\n\n/// この表示がいつ取得したものか。キャッシュ由来なら明示する。\n/// (Shodan のスキャン日時とは別物なので別行・別アイコンで出す)\n@cacheView() {\n if (Core:type(shownAt) != \"num\") { return [] }\n let elapsed = Date:now() - shownAt\n let mins = Math:floor(elapsed / 60000)\n let age = if (mins < 1) { \"たった今\" } elif (mins < 60) { `{mins} 分前` } else { `{Math:floor(mins / 60)} 時間前` }\n let label = if (fromCache) { \"キャッシュ\" } else { \"取得\" }\n let col = if (elapsed > CACHE_AGE_MS) { WARN } else { MUTED }\n [ Ui:C:mfm({ text: `
$[fg.color={col} 🗃 {label} · {age}]
` }) ]\n}\n\n@creditsView(credits) {\n if (Core:type(credits) != \"num\") { return [] }\n [ Ui:C:mfm({ text: `
$[fg.color={MUTED} 残クエリクレジット {credits}]
` }) ]\n}\n\n@headerView(h, ip) {\n [\n Ui:C:mfm({ text: `
$[fg.color={ACCENT} **🛰 Shodan 監査**] $[fg.color={MUTED} {truncName(h, 28)}]
` })\n Ui:C:mfm({ text: `
$[fg.color={INFO} {sanitizeText(ip)}]
` })\n ]\n}\n\n@pushAll(dst, src) {\n for (let i, src.len) { dst.push(src[i]) }\n dst\n}\n\n/// 押すと 1 クレジット消費することをラベルに出す (押す前にコストが分かるように)\n@refreshButton() {\n Ui:C:button({ text: \"更新 (1 クレジット)\", primary: true, onClick: @() { fetchData() } })\n}\n\n@configFolder(opened) {\n Ui:C:folder({\n title: \"⚙️ 設定\"\n opened: opened\n children: [\n Ui:C:textInput({\n onInput: @(v) {\n host = v\n Mk:save(\"host\", v)\n }\n default: host\n label: \"インスタンスのホスト名\"\n text: \"監査対象 (例: misskey.io / 203.0.113.10)\"\n })\n Ui:C:mfm({ text: `$[fg.color={MUTED} 監査 1 回につきクエリクレジットを 1 消費します]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} 結果はキャッシュされ、カラムを開き直しても消費しません]` })\n Ui:C:button({ text: \"この対象で監査 (1 クレジット)\", primary: true, onClick: @() { fetchData() } })\n ]\n })\n}\n\n@auditView(result) {\n var body = headerView(result.host, result.ip)\n\n // org / country は片方だけのこともある。区切りが宙に浮かないよう組み立てる\n var loc = \"\"\n if (Core:type(result.country) == \"str\" && result.country != \"\") { loc = sanitizeText(result.country) }\n if (Core:type(result.org) == \"str\" && result.org != \"\") {\n let o = truncName(result.org, 24)\n loc = if (loc == \"\") { o } else { `{loc} · {o}` }\n }\n if (loc != \"\") {\n body.push(Ui:C:mfm({ text: `
$[fg.color={MUTED} {loc}]
` }))\n }\n\n // CDN のエッジに当たった = オリジンが公開スキャンから隠れている。\n // これは不具合ではなく防御が効いている証拠なので、警告でなく所見として出す。\n let cdn = cdnOf(get(result, \"org\", \"\"))\n let isCdn = cdn != \"\"\n if (isCdn) {\n body.push(divider())\n body.push(Ui:C:mfm({ text: `
$[fg.color={OK} 🛡 CDN の背後にあります] $[fg.color={MUTED} {truncName(cdn, 20)}]
` }))\n body.push(Ui:C:mfm({ text: `
$[fg.color={MUTED} オリジンは公開スキャンから隠れています]
` }))\n body.push(Ui:C:mfm({ text: `
$[fg.color={MUTED} 以下はエッジの情報です]
` }))\n }\n\n body.push(divider())\n pushAll(body, portsView(result.ports, isCdn))\n pushAll(body, servicesView(result.services))\n pushAll(body, certView(result.certTs))\n body.push(divider())\n pushAll(body, vulnsView(result.vulns))\n if (get(result, \"minified\", false)) {\n body.push(Ui:C:mfm({ text: `
$[fg.color={MUTED} 簡易表示 (バナー詳細を省略して取得)]
` }))\n }\n pushAll(body, freshnessView(result))\n pushAll(body, cacheView())\n pushAll(body, creditsView(result.credits))\n body\n}\n\n@renderLoading(msg) {\n Ui:render([\n Ui:C:container({ align: \"center\", children: [ Ui:C:text({ text: `{msg} 🛰` }) ] })\n ])\n}\n\n@render() {\n // 起動時はまずキャッシュを見る。あればクレジットを使わずそれを描く。\n // 実取得するのは「そのホストで初めて監査するとき」だけ。\n if (firstRun && Core:type(result) == \"null\") {\n firstRun = false\n let c = loadCache()\n let herr = hostError(normHost(host))\n if (Core:type(c) == \"obj\") {\n result = c.data\n shownAt = get(c, \"savedAt\", null)\n fromCache = true\n } elif (herr == \"\") {\n fetchData()\n return null\n } elif (host != \"\") {\n // 保存済みホストが不正。理由を出すだけで取得はしない (クレジット消費なし)\n result = { error: herr, hint: \"設定 (⚙️) でホスト名を修正してください\" }\n }\n }\n firstRun = false\n\n var children = []\n if (Core:type(result) == \"null\") {\n // 未設定時の初期画面。入力欄は最初から開いておく\n children = [\n Ui:C:mfm({ text: `
$[fg.color={ACCENT} **🛰 Shodan インスタンス監査**]
` })\n Ui:C:mfm({ text: `
$[fg.color={MUTED} 監査対象のホストを設定してください]
` })\n configFolder(true)\n ]\n } elif (Obj:has(result, \"notIndexed\")) {\n children = headerView(result.host, result.ip)\n children.push(divider())\n children.push(Ui:C:mfm({ text: `
$[fg.color={OK} 🫥 Shodan にインデックスされていません]
` }))\n children.push(Ui:C:mfm({ text: `
$[fg.color={MUTED} 公開スキャンに露出情報が見つかりませんでした]
` }))\n pushAll(children, cacheView())\n pushAll(children, creditsView(result.credits))\n children.push(configFolder(false))\n children.push(refreshButton())\n } elif (Obj:has(result, \"error\")) {\n children = [\n Ui:C:mfm({ text: `
$[fg.color={CRIT} ⚠️ {sanitizeText(result.error)}]
` })\n Ui:C:mfm({ text: `
$[fg.color={MUTED} {sanitizeText(result.hint)}]
` })\n ]\n // 応答が壊れていたときだけ、簡易表示で取り直す導線を出す\n if (get(result, \"canMinify\", false)) {\n children.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 簡易表示ならバナー詳細を省くぶん上限に収まります (ポートと CVE は残ります)]` }))\n children.push(Ui:C:button({ text: \"簡易表示で取得 (1 クレジット)\", primary: true, onClick: @() { fetchMinified() } }))\n }\n children.push(configFolder(true))\n children.push(Ui:C:button({ text: \"再試行 (1 クレジット)\", onClick: @() { fetchData() } }))\n } else {\n children = auditView(result)\n children.push(divider())\n children.push(Ui:C:mfm({ text: `
[Shodan で開く →](https://www.shodan.io/host/{result.ip})
` }))\n children.push(configFolder(false))\n children.push(refreshButton())\n }\n Ui:render([ Ui:C:container({ children: children }) ])\n}\n\nrender()\n// 自動更新はしない (host 参照はクエリクレジットを消費するため、更新は手動)\n"} diff --git a/public/registry/widgets/shodan-instance-audit/meta.json b/public/registry/widgets/shodan-instance-audit/meta.json index fafdc63..dc76f40 100644 --- a/public/registry/widgets/shodan-instance-audit/meta.json +++ b/public/registry/widgets/shodan-instance-audit/meta.json @@ -1,10 +1,10 @@ { "id": "shodan-instance-audit", "name": "Shodan インスタンス監査", - "version": "1.0.2", + "version": "1.1.0", "author": "@hitalin", "authorUrl": "https://github.com/hitalin", - "description": "指定した Misskey インスタンスのホストを Shodan で監査。露出ポートを危険度別に色分けし、稼働サービス・TLS 失効判定・CVE (CVSS と実証/推定の別)・Shodan のスキャン日を表示する管理者向けの外部露出チェック。結果はキャッシュされ、取得は初回と手動更新のみ (1 回 1 クエリクレジット)。API キーは Secret Vault (クエリ認証 param=key) で注入し widget.is には書かない", + "description": "指定した Misskey インスタンスのホストを Shodan で監査。露出ポートを危険度別に色分けし、稼働サービス・TLS 失効判定・CVE (CVSS と実証/推定の別)・Shodan のスキャン日を表示する管理者向けの外部露出チェック。CDN の背後にある場合はオリジンが隠れている証拠として肯定的に示す。結果はキャッシュされ、取得は初回と手動更新のみ (1 回 1 クエリクレジット)。API キーは Secret Vault (クエリ認証 param=key) で注入し widget.is には書かない", "icon": "ti-radar", "autoRun": true, "category": "stats", diff --git a/public/registry/widgets/shodan-instance-audit/widget.is b/public/registry/widgets/shodan-instance-audit/widget.is index d4c4272..2317a0f 100644 --- a/public/registry/widgets/shodan-instance-audit/widget.is +++ b/public/registry/widgets/shodan-instance-audit/widget.is @@ -167,6 +167,16 @@ let CACHE_AGE_MS = 21600000 // 6h。これを超えたキャッシュには「 // Misskey インスタンスとして公開されて当然のポート。これ以外は棚卸し対象。 let EXPECTED_PORTS = [80, 443] +// CDN の共有エッジに当たったことを org 名で判定する。 +// この場合そこは利用者のサーバーではないので、ポートを警告してはいけない。 +let CDN_ORGS = [ + "cloudflare", "fastly", "akamai", "cloudfront", "incapsula", "imperva" + "sucuri", "stackpath", "bunny", "gcore", "cdn77", "keycdn" +] +// CDN がプロキシに使う標準ポート。エッジなら開いていて当たり前で、 +// 利用者の設定不備ではない。 +let CDN_PORTS = [80, 443, 2052, 2053, 2082, 2083, 2086, 2087, 2095, 2096, 8080, 8443, 8880] + // 公開インターネットに晒すべきでない代表的なポート (データストア / 管理系)。 let CRITICAL_PORTS = { "23": "Telnet", "21": "FTP", "3389": "RDP", "5900": "VNC" @@ -362,10 +372,14 @@ var fromCache = false render() } -@fetchData() { runAudit(false) } +/// 前回このホストで応答が上限超過したか。超過していたなら最初から簡易表示で取る +/// (通常表示でもう一度上限に当たってクレジットを捨てるのを防ぐ)。 +@needsMinify() { Mk:load("needMinify") == normHost(host) } + +@fetchData() { runAudit(needsMinify()) } /// 簡易表示。Shodan の minify=true は大きいフィールドを切り詰めて返すので、 -/// 応答が肥大して壊れるホストでも通ることがある。 +/// 応答が上限を超えるホストでも収まることがある。 /// バナー詳細が落ちるぶん稼働サービス / TLS は出ないが、ポートと CVE は残る。 @fetchMinified() { runAudit(true) } @@ -433,17 +447,29 @@ var fromCache = false let data = jsonOf(hres) if (Core:type(data) != "obj") { - // 何が返ってきたのかを出す。応答が途中で切れている場合は - // 簡易表示 (minify) で通ることがあるので、その導線も出す。 busy = false - result = { - error: "ホスト情報を解釈できませんでした" - hint: `応答: {bodyBrief(hres)}` - canMinify: !minify + // vault.fetch は応答が上限を超えると truncated を立てて切り詰める + // (notedeck: src-tauri/src/vault/fetch.rs の MAX_RESPONSE_BYTES = 512 KiB)。 + // 推測せずこのフラグで断定し、次回は最初から簡易表示で取るよう覚えておく。 + if (get(hres, "truncated", false) == true) { + Mk:save("needMinify", normHost(host)) + result = { + error: "応答が 512 KiB の上限を超えました" + hint: `{get(hres, "bytesTotal", 0)} バイトで打ち切られたため解釈できません` + canMinify: !minify + } + } else { + result = { + error: "ホスト情報を解釈できませんでした" + hint: `応答: {bodyBrief(hres)}` + canMinify: !minify + } } render() return null } + // 通常表示で取れたなら、簡易表示に固定する必要はもう無い + if (!minify) { Mk:save("needMinify", "") } let banners = get(data, "data", []) var ports = get(data, "ports", []) @@ -475,16 +501,30 @@ var fromCache = false Ui:C:mfm({ text: `
$[fg.color={LINE} ───────────────]
` }) } -@portLabel(p) { +/// org 名が CDN のものなら、その名前を返す (でなければ "") +@cdnOf(org) { + if (Core:type(org) != "str" || org == "") { return "" } + let lo = org.lower() + for (let i, CDN_ORGS.len) { + if (lo.incl(CDN_ORGS[i])) { return org } + } + "" +} + +/// isCdn のときは CDN 標準ポートを警告しない。そこは利用者のサーバーではなく +/// CDN の共有エッジなので、開いていることが設定不備を意味しないため。 +@portLabel(p, isCdn) { let k = Core:to_str(p) - if (Obj:has(CRITICAL_PORTS, k)) { { color: CRIT, name: CRITICAL_PORTS[k], level: 2 } } + if (EXPECTED_PORTS.incl(p)) { { color: MUTED, name: "", level: 0 } } + elif (isCdn && CDN_PORTS.incl(p)) { { color: MUTED, name: "", level: 0 } } + elif (Obj:has(CRITICAL_PORTS, k)) { { color: CRIT, name: CRITICAL_PORTS[k], level: 2 } } elif (Obj:has(REVIEW_PORTS, k)) { { color: WARN, name: REVIEW_PORTS[k], level: 1 } } - elif (EXPECTED_PORTS.incl(p)) { { color: MUTED, name: "", level: 0 } } else { { color: WARN, name: "", level: 1 } } } /// 露出ポートの棚卸し。80/443 以外は必ず目に留まるようにする。 -@portsView(ports) { +/// ただし CDN のエッジなら CDN 標準ポートは想定内に畳む。 +@portsView(ports, isCdn) { if (Core:type(ports) != "arr" || ports.len == 0) { return [ Ui:C:mfm({ text: `$[fg.color={MUTED} 🔌 開放ポート情報なし]` }) ] } @@ -492,13 +532,14 @@ var fromCache = false var flagged = [] for (let i, ports.len) { let p = ports[i] - let info = portLabel(p) + let info = portLabel(p, isCdn) if (info.level == 0) { expected.push(p) } else { flagged.push({ port: p, info: info }) } } var out2 = [ Ui:C:mfm({ text: `$[fg.color={MUTED} 🔌 開放ポート {ports.len} 件]` }) ] if (expected.len > 0) { - out2.push(Ui:C:mfm({ text: ` $[fg.color={OK} ✓ 想定内] $[fg.color={MUTED} {joinArr(expected, ", ")}]` })) + let label = if (isCdn) { "✓ CDN 標準" } else { "✓ 想定内" } + out2.push(Ui:C:mfm({ text: ` $[fg.color={OK} {label}] $[fg.color={MUTED} {joinArr(expected, ", ")}]` })) } for (let i, flagged.len) { let f = flagged[i] @@ -654,8 +695,19 @@ var fromCache = false body.push(Ui:C:mfm({ text: `
$[fg.color={MUTED} {loc}]
` })) } + // CDN のエッジに当たった = オリジンが公開スキャンから隠れている。 + // これは不具合ではなく防御が効いている証拠なので、警告でなく所見として出す。 + let cdn = cdnOf(get(result, "org", "")) + let isCdn = cdn != "" + if (isCdn) { + body.push(divider()) + body.push(Ui:C:mfm({ text: `
$[fg.color={OK} 🛡 CDN の背後にあります] $[fg.color={MUTED} {truncName(cdn, 20)}]
` })) + body.push(Ui:C:mfm({ text: `
$[fg.color={MUTED} オリジンは公開スキャンから隠れています]
` })) + body.push(Ui:C:mfm({ text: `
$[fg.color={MUTED} 以下はエッジの情報です]
` })) + } + body.push(divider()) - pushAll(body, portsView(result.ports)) + pushAll(body, portsView(result.ports, isCdn)) pushAll(body, servicesView(result.services)) pushAll(body, certView(result.certTs)) body.push(divider()) @@ -720,7 +772,7 @@ var fromCache = false ] // 応答が壊れていたときだけ、簡易表示で取り直す導線を出す if (get(result, "canMinify", false)) { - children.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 応答が大きすぎて途中で切れている可能性があります。簡易表示なら通ることがあります (バナー詳細は省略)]` })) + children.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 簡易表示ならバナー詳細を省くぶん上限に収まります (ポートと CVE は残ります)]` })) children.push(Ui:C:button({ text: "簡易表示で取得 (1 クレジット)", primary: true, onClick: @() { fetchMinified() } })) } children.push(configFolder(true))