Skip to content
Merged
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
12 changes: 6 additions & 6 deletions public/registry/index.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
10 changes: 5 additions & 5 deletions public/registry/widgets.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": 1,
"updatedAt": "2026-08-09T10:05:51.719Z",
"updatedAt": "2026-08-09T10:23:15.471Z",
"widgets": [
{
"id": "atcoder",
Expand Down Expand Up @@ -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",
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion public/registry/widgets/shodan-instance-audit/api.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions public/registry/widgets/shodan-instance-audit/meta.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
84 changes: 68 additions & 16 deletions public/registry/widgets/shodan-instance-audit/widget.is
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) }

Expand Down Expand Up @@ -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", [])
Expand Down Expand Up @@ -475,30 +501,45 @@ var fromCache = false
Ui:C:mfm({ text: `<center>$[fg.color={LINE} ───────────────]</center>` })
}

@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} 🔌 開放ポート情報なし]` }) ]
}
var expected = []
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]
Expand Down Expand Up @@ -654,8 +695,19 @@ var fromCache = false
body.push(Ui:C:mfm({ text: `<center>$[fg.color={MUTED} {loc}]</center>` }))
}

// CDN のエッジに当たった = オリジンが公開スキャンから隠れている。
// これは不具合ではなく防御が効いている証拠なので、警告でなく所見として出す。
let cdn = cdnOf(get(result, "org", ""))
let isCdn = cdn != ""
if (isCdn) {
body.push(divider())
body.push(Ui:C:mfm({ text: `<center>$[fg.color={OK} 🛡 CDN の背後にあります] $[fg.color={MUTED} {truncName(cdn, 20)}]</center>` }))
body.push(Ui:C:mfm({ text: `<center>$[fg.color={MUTED} オリジンは公開スキャンから隠れています]</center>` }))
body.push(Ui:C:mfm({ text: `<center>$[fg.color={MUTED} 以下はエッジの情報です]</center>` }))
}

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())
Expand Down Expand Up @@ -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))
Expand Down
Loading