Claude Codeでnmap結果と資産台帳を突合し、未登録機器を洗う
nmap -oXで出したXMLと資産台帳CSVをClaude Codeに突合させ、台帳に無い機器と応答しない台帳機器を分けて確認先を割り当てる手順です。スキャン許可範囲の線引きも扱います。
nmapのスキャン結果をIT資産台帳と突き合わせると、台帳に載っていない機器と、台帳にあるのに応答しない機器が分かれて見えてきます。nmapのXML出力は機械で読める形式で、台帳はCSVにできます。この2つの突合は、Claude Codeにスクリプトを書かせて実行させるのに向いた作業です。
この記事では、スキャンは人が実行し、Claude Codeには結果ファイルだけを読ませる分担で進めます。突合の結果を未登録機器・未応答機器・登録内容の不一致に分け、それぞれの確認先を割り当てるところまでが範囲です。
スキャンは人が実行し、Claudeには結果だけを渡す
nmapを流す先はネットワーク上の実機です。Claude Codeに流させず、権限のある担当者が自分で実行します。nmapの公式ガイドは、対象ネットワークの管理者から書面で許可を得てからスキャンを始めることを、トラブルを避ける最良の方法としています。許可範囲は、スキャンするCIDR(例: 192.168.10.0/24)と日時を台帳の管理者と合意しておくと、後で突合結果の解釈も揃います。
Claude Code側には、nmap自体を実行させない設定を入れておけます。権限のdenyルールは Bash(コマンド *) の形で書き、一致した呼び出しはブロックされます。
{
"permissions": {
"deny": [
"Bash(nmap *)"
]
}
}この設定を .claude/settings.json に置くと、読み込み役のClaude Codeがうっかりスキャンを走らせる経路を塞げます。結果XMLと台帳CSVを作業フォルダに置いて読ませれば、突合に必要な作業は足ります。
nmapのXMLを台帳と突き合わせられる形で出す
突合に使う出力形式はXMLです。公式ガイドによれば、XMLはソフトウェアが解析しやすい安定した形式で、主要な言語向けのパーサーも揃っています。一方、grepable出力(-oG)は非推奨とされ、MACアドレス検出のような機能はこの形式に出ません。機械で読めて、MACも出る形式が -oX です。
スキャンは、ポートを調べずに生きているホストだけを探す形にします。-sn は、ホスト発見(ピングスキャン)だけを行う指定として公式ガイドのホスト発見の章で使われています。
sudo nmap -sn -R 192.168.10.0/24 \
-oX 'scan-%Y%m%d.xml'sudo を付ける理由は、MACを突合の主キーにしているからです。公式ガイドによれば、特権(root)ユーザーの既定では、スキャン対象がローカルのethernet(有線と無線LANを含む)にあるとき、ホスト発見にARPが使われます。MACとvendorが取れるのはこの経路です。特権の無い一般ユーザーの既定は、80番と443番へのTCP接続で、ARPは出てきません。sudoなしで実行すると mac の address 要素が出ない前提になり、突合はIPとホスト名の照合が中心になります。
オプションの意味を3点だけ押さえます。
-oXとファイル名の間のスペースは必須です。詰めて-oXscan.xmlと書くと、Xscan.xmlという名前の通常形式ファイルができます- ファイル名には
%Y%m%dのようなstrftime風の変換が使えます。上の例なら、日付入りのXMLが毎回別名で残ります -Rは、応答しなかったホストも含めて逆引きDNSを行うオプションです。ホスト名の手がかりが増えます
スキャン結果は上書きされるため、日付入りの名前で残すと、先月との比較もClaude Codeに頼めます。--append-output でXMLを追記する方法は避けます。公式ガイドが、追記したXMLは手で直すまで正しく解析できないことが多いと説明しているためです。
XMLのどこを見れば突合の鍵になるか
XMLの host 要素の中身は、突合に使う3つの情報でできています。
突合に使うXMLの要素
status
state属性がupのとき、そのホストは応答しています。DTDではupdownunknownskippedのいずれかです。address
addrに値が入り、addrtypeがipv4ipv6macのどれかを示します。MACの行には、省略可能なvendor属性が付くことがあります。hostnames
hostname要素が並び、typeがuser(指定された名前)かPTR(逆引きの結果)かを示します。
1台のホストに address 要素が複数付くため、IPとMACの両方を拾うには、要素ごとに addrtype を見る必要があります。MACの address が出ないホストが混じる前提で、突合の優先順位を決めておきます。この記事では、MAC、IP、ホスト名の順に照合します。
台帳CSVは、次のような列を持つものとします。
| 列 | 内容 |
|---|---|
| asset_id | 内容資産管理番号 |
| hostname | 内容登録ホスト名 |
| ip | 内容登録IP |
| mac | 内容登録MAC |
| owner | 内容管理部署または担当者 |
| location | 内容設置場所 |
突合をClaude Codeに書かせ、検算させる
突合ロジックは、目で追うより、スクリプトにして再実行できる形にします。Claude Codeには、次のように依頼します。
scan-20261020.xml(nmap -oX の出力)と asset_ledger.csv を突合する
Pythonスクリプト reconcile.py を作ってください。
- 標準ライブラリのみ(xml.etree.ElementTree, csv)
- status が up のホストだけを「応答あり」とする
- 照合の優先順位は MAC → IP → ホスト名
- 出力は3分類: 未登録(応答ありで台帳に無い) /
未応答(台帳にあるが応答なし) / 不一致(MACで台帳と一致したが、IPかホスト名が違う)
- 分類ごとにCSVを出力する
- 実行後、スキャンのup台数 = 未登録 + 台帳と一致した台数 になることを検算する「検算する」の一文は削らないでください。突合は、取りこぼしがあっても結果が出てしまう処理です。XMLのup台数と分類の合計が合うかどうかは、スクリプトが正しく動いたことを確かめる最も安い手段です。
作られるスクリプトの中身は、例えば次のような形になります(Claude Codeが書くコードの一例で、実際の出力は環境ごとに変わります)。
import csv
import sys
import xml.etree.ElementTree as ET
def norm_mac(s):
return s.strip().upper().replace("-", ":")
def load_scan(path):
hosts = []
for h in ET.parse(path).getroot().iter("host"):
st = h.find("status")
if st is None or st.get("state") != "up":
continue
rec = {"ip": "", "mac": "", "vendor": "", "names": []}
for a in h.findall("address"):
if a.get("addrtype") == "ipv4":
rec["ip"] = a.get("addr")
elif a.get("addrtype") == "mac":
rec["mac"] = norm_mac(a.get("addr"))
rec["vendor"] = a.get("vendor", "")
for n in h.iter("hostname"):
rec["names"].append(n.get("name", "").lower())
hosts.append(rec)
return hosts
def load_ledger(path):
with open(path, newline="", encoding="utf-8") as f:
return list(csv.DictReader(f))
def main(scan_xml, ledger_csv):
scan = load_scan(scan_xml)
ledger = load_ledger(ledger_csv)
by_mac = {norm_mac(r["mac"]): r for r in ledger if r["mac"]}
by_ip = {r["ip"]: r for r in ledger if r["ip"]}
by_name = {r["hostname"].lower(): r for r in ledger if r["hostname"]}
matched, unregistered, mismatch = set(), [], []
for h in scan:
row = by_mac.get(h["mac"]) if h["mac"] else None
via_mac = row is not None
if row is None:
row = by_ip.get(h["ip"])
if row is None:
row = next((by_name[n] for n in h["names"] if n in by_name), None)
if row is None:
unregistered.append(h)
continue
matched.add(row["asset_id"])
# 不一致はMACで当たった行だけ。IPとホスト名を台帳と比べる
if via_mac and (
h["ip"] != row["ip"]
or (row["hostname"] and row["hostname"].lower() not in h["names"])
):
mismatch.append((h, row))
silent = [r for r in ledger if r["asset_id"] not in matched]
# 検算: up台数 = 未登録 + 台帳と一致した台数
assert len(scan) == len(unregistered) + len(matched), "件数が合いません"
print(len(scan), len(unregistered), len(silent), len(mismatch))
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])上のひな形は件数を表示するだけです。分類ごとのCSVを出すには、main の最後の print の前に、次のような数行を足します(csv.writer で3ファイルに書き出す例です)。
def write_csv(path, header, rows):
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(header)
w.writerows(rows)
# main の末尾、print の前に置く
write_csv("unregistered.csv", ["ip", "mac", "vendor"],
[(h["ip"], h["mac"], h["vendor"]) for h in unregistered])
write_csv("silent.csv", ["asset_id", "hostname", "ip", "owner"],
[(r["asset_id"], r["hostname"], r["ip"], r["owner"]) for r in silent])
write_csv("mismatch.csv", ["asset_id", "scan_ip", "ledger_ip"],
[(r["asset_id"], h["ip"], r["ip"]) for h, r in mismatch])このコードはひな形です。台帳に同じMACの行が2つある場合や、DHCPで同じMACのIPが変わった場合など、自分の台帳に特有の例外はそのまま通りません。出力の件数を見て、おかしければClaude Codeに具体的な行を示して直させます。
検算が合わないときの切り分け
assert が落ちたときは、件数の差から原因を絞れます。このひな形で assert が落ちるのは、スキャンの複数のホストが台帳の同じ1行に当たったときです。応答したホストの数より matched が減るので、スキャンのup台数のほうが多くなります。原因は、スキャン結果の重複か、DHCPで同じMACが別のIPにも出た場合に絞られます。逆に、分類の合計のほうが多くなる状態は、matched が台帳の行番号の集合であるため起きません。差が出たときは、台帳の同じ行に当たったホストの組を一覧で吐かせ、Claude Codeに「同じ行に当たった組だけを表示して」と頼むと、重複の元がすぐ見つかります。
XMLの status が up でないホストは、突合の対象から外しています。down や unknown のホストをどう扱うかは、後続の未応答の判定にも響くため、スクリプトの冒頭に方針をコメントで残させます。
3つの分類をどう読み、誰に確認するか
突合結果は3つに分かれます。分類ごとに、確認先と問いの立て方が違います。
| 分類 | 意味 | 最初に確認する先 |
|---|---|---|
| 未登録 | 意味応答があり、台帳に無い | 最初に確認する先設置場所のフロア担当、ネットワーク管理者 |
| 未応答 | 意味台帳にあり、スキャンで応答なし | 最初に確認する先台帳の管理部署(owner) |
| 不一致 | 意味MACは一致、IPやホスト名が違う | 最初に確認する先機器の管理者、DHCP設定の担当 |
未応答は「撤去済み」と同じ意味ではない
未応答の機器を、直ちに撤去済みや廃棄済みと読むのは危険です。nmapの公式ガイドは、ICMPのpingへの返事がないからといって、そのホストが存在しないとは決められないと説明しています。ICMPだけのスキャンで6つのウェブサイトに当たったところ、返事があったのは2つで、ポート80へのSYNプローブに変えると6つ全部が応答した、という例も載っています。
つまり、未応答の分類は「今回の方法では見えなかった機器」の一覧です。電源が切れている、別のセグメントにある、ファイアウォールが応答を止めている、本当に撤去されている、のいずれかが考えられます。確認先には、機器の最終確認日や設置場所を尋ねる問いを用意します。
未登録は、台帳の抜けと不審な接続を同じ列に置く
未登録の機器には、新規購入後に台帳へ登録していない正規の機器と、持ち込まれた私物が混じります。Claude Codeには、MACの vendor 属性とホスト名を手がかりにした推測を添えさせられますが、推測は確認先の優先順位をつける補助にとどめます。例えば、社内標準のベンダー名が並ぶ機器は台帳の登録漏れを疑い、見慣れないベンダーのものは先に管理者へ回す、といった順です。
確認先の割り当ては、次の依頼で自動化できます。
未登録CSVの各行について、location列が空の場合はIPのサブネットから
フロアを推定して担当者名簿(floor_owners.csv)を引き、
確認依頼の一覧 followup.csv(ip, mac, vendor, 確認先, 確認する内容)
を作ってください。名簿に無いサブネットは「確認先不明」として別に出すこと。「確認先不明」を別に出させるのが肝です。割り当てに失敗した行は、台帳側ではなくネットワーク構成図に抜けがあるサインになります。
定例で回すときの運用メモ
突合を定例にするなら、手順を次のように固定します。
定例の突合の流れ
- 1
スキャンする
許可された範囲を、同じ引数でスキャンします。日付入りの
-oXファイルを残します。 - 2
突合する
Claude Codeに
reconcile.pyを実行させ、3つのCSVと検算の結果を出させます。 - 3
前回と比べる
前回の未登録リストと比べ、今回新しく増えた機器だけを確認対象にします。
- 4
台帳を更新する
確認が取れた機器を台帳に反映します。反映は人が行い、Claude Codeには更新案のCSVを作らせます。
台帳の更新まで任せないのは、台帳が資産の正本だからです。誤った行を機械的に書き足すと、次回の突合が誤りを前提に回ってしまいます。
つまずきやすい点
- スキャンの範囲が台帳の範囲より狭い: 台帳にあるが別セグメントにある機器は、すべて未応答に落ちます。スキャンしたCIDRを台帳の設置場所と見比べ、範囲外の行を先に除きます
- MACの表記ゆれ: コロン区切りとハイフン区切り、大文字小文字が台帳側で揃っていないと、一致するはずの行が外れます。上のひな形で
norm_macを挟んでいるのはこのためです - 仮想環境とルーターの先: 仮想マシンが増減する環境では、台帳のMACが実機と食い違うことがあります。その行はMACで当たらず、IPかホスト名の照合に回るので、これらの列も台帳に揃えておきます。またARPが使われるのは対象が同じローカルethernet上にあるときなので、ルーターを越えた先のホストはMACが出ないものとして、IPとホスト名の照合を主にします。sudoなしで実行したときも同じです
- 1回だけの結果で判断する: 電源が切れていただけの機器が未応答に入ることがあります。日をずらした2回のスキャンを比べてから確認依頼を出す運用もあります
まとめ
MACで突合できるかどうかは、スキャンを誰がどの権限で流すかで決まります。台帳にMAC列があるなら、sudo付きでスキャンする担当を先に決めてください。権限が取れない環境では、台帳のIPとホスト名の精度が突合の質を左右します。
未応答の一覧は、撤去済みの一覧として台帳から消す前に、機器の最終確認日を尋ねる問いとして回すのが安全です。件数の検算が合っていれば、残る差は台帳側の運用の問題として切り分けられます。
脆弱性スキャナーの結果を台帳と合わせて優先度づけする流れは、脆弱性スキャン結果の優先度付けをClaudeで進め、是正対応表にするにあります。台帳のサポート終了日を外部APIで突合する別の例は、資産台帳のサポート終了日を突合するです。コンテナイメージなど別の対象のスキャンと再確認の繰り返しは、Claude CodeでTrivyを回して脆弱性を直す手順で扱っています。