Audit Your Home Network with Claude Code

Build a privacy-conscious CLI that audits your home gateway safely.

Introduction

30 Second Summary

Your home internet can work perfectly while its privacy settings go unchecked. A speed test cannot show whether website names are protected or whether unused router features remain active.

In this project, you will use Claude Code to build a Node.js command-line audit for your own home network. The audit converts read-only checks into a sanitized report with evidence coverage, prioritized findings, and a before-and-after comparison.

What You'll Build

Run the audit to see a redacted gateway, clear one-line findings, a provisional score, and the three most useful actions for your network.

By the end of this project, you'll have:

  • Run a safe local scan that automatically selects the private default gateway and refuses public or user-supplied targets.
  • Review privacy-safe evidence for local TCP reachability, UPnP mappings, Wi-Fi security, channel congestion, and encrypted DNS. See Unknown whenever the operating system or router withholds evidence.
  • Use a sanitized before-and-after comparison to prove whether one router change improved a score or check state.
  • Secret Mission: Add a watcher that reruns the audit every 30 minutes and reports only changed checks.

Are there any prerequisites?

You'll need administrator access to your own home router plus existing paid Claude Code access. The main path uses macOS. Windows instructions appear wherever commands differ.

Before We Start

Before any network checks, lock in a defensive, read-only audit of a home network you own or administer. The scanner stays limited to the operating system's detected private default gateway, with no arbitrary targets, device authentication, banner reading, or WAN exposure testing.

Set Up Claude Code and Node.js

Your audit now has a firm safety boundary around your own private gateway. The next job is to give it a controlled place to grow.

A fixed Node.js runtime prevents version drift. Starting Claude Code inside a dedicated folder keeps its project access focused.

In this step, get ready to:
  • Prepare the Node.js runtime.
  • Sign in to Claude Code.
  • Create the protected project workspace.
Install Node.js and verify npm

The scanner uses Node.js 26.13.0 for a consistent runtime. Its local dependencies are managed with npm.

  • Press Cmd+Space on macOS or the Windows key on Windows.
  • Type Terminal on macOS or Windows PowerShell on Windows.
  • Press Enter.
  • Check the installed Node.js version by running this command:
node --version

What does this command do?

This command asks the active Node.js installation for its version. The result tells you whether the project can use the pinned runtime.

✔️ I see version v26.13.0

Your Node.js runtime matches the project pin. That removes a common source of dependency differences before you write any scanner code.

ⓧ I see an older version

The installed runtime is below the project pin. Update it before continuing so every dependency uses the expected Node.js release.

  • Visit the official Node.js download page.
  • Download the Node.js v26.13.0 LTS installer for your operating system.
  • Run the downloaded macOS installer or Windows installer.
  • Close the current terminal after the installation finishes.
  • Open a new Terminal or Windows PowerShell session through operating-system search.
  • Repeat the version check shown above. You should now see v26.13.0.

Still seeing the older version?

Make sure you opened a new terminal after running the installer. An existing terminal can retain the older executable path.

Use Help me find why my terminal still reports an older Node.js version..

ⓧ Command not found

Node.js is not available in this terminal. Install the pinned release before continuing.

  • Visit the official Node.js download page.
  • Download the Node.js v26.13.0 LTS installer for your operating system.
  • Run the downloaded macOS installer or Windows installer.
  • Close the current terminal after the installation finishes.
  • Open a new Terminal or Windows PowerShell session through operating-system search.
  • Repeat the version check shown above. You should now see v26.13.0.

Node.js still unavailable?

Confirm that the installer completed before opening the new terminal. A cancelled installer leaves the command unavailable.

Use Help me troubleshoot why Node.js is unavailable after installation..

  • Confirm that npm is available by running this command:
npm --version

What does this command do?

This command prints the active npm version. Any printed version number confirms that the package manager is available in the new terminal.

You should see a version number on its own line. Node.js can now run the scanner while npm manages its local packages.

Install Claude Code and sign in

Claude Code creates the project files from inside your terminal. Its first interactive session connects the CLI to your existing paid account.

  • Check whether Claude Code is already installed by running this command:
claude --version

What does this command do?

This command asks the Claude Code CLI for its installed version. A printed version confirms that the terminal can find the CLI.

✔️ I see a Claude Code version

Claude Code is available in this terminal. You can move straight to account sign-in.

ⓧ Command not found

Claude Code needs its native installer. Choose the command for your operating system.

macOS

  • Install Claude Code by running this command in Terminal:
curl -fsSL https://claude.ai/install.sh | bash
What does this command do?

This command downloads the official Claude Code installation script over HTTPS. It passes that script to your shell to install the CLI.

  • Close Terminal after the installer finishes.
  • Open a new Terminal session through operating-system search.
  • Repeat the Claude Code version check shown above. You should see a version number.
Installer did not finish?

Check that your internet connection can reach the installer URL. Make sure the command matches the official installer exactly.

Use Help me troubleshoot the Claude Code native installer on macOS..

Windows

  • Install Claude Code by running this command in Windows PowerShell:
irm https://claude.ai/install.ps1 | iex
What does this command do?

This command retrieves the official Claude Code PowerShell installation script. It passes that script to PowerShell to install the CLI.

  • Close Windows PowerShell after the installer finishes.
  • Open a new Windows PowerShell session through operating-system search.
  • Repeat the Claude Code version check shown above. You should see a version number.
Installer did not finish?

Check that your internet connection can reach the installer URL. Make sure the command matches the official installer exactly.

Use Help me troubleshoot the Claude Code native installer on Windows..

  • Start the Claude Code sign-in flow by running this command:
claude

What happens next?

Claude Code opens an interactive session. On first use, it prompts you to complete account sign-in through your browser.

  • Complete the browser sign-in with your paid Claude account.
  • Return to the terminal after authentication finishes.

You should see the Claude Code interactive session ready for input. Your CLI is now connected to your account.

  • Close the terminal window after confirming the signed-in session.
Create the protected project workspace

Claude Code uses its starting folder as the workspace boundary for project operations. The safety file inside that folder keeps future scanner requests within the defensive scope you committed to.

  • Press Cmd+Space on macOS or the Windows key on Windows.
  • Type Terminal on macOS or Windows PowerShell on Windows.
  • Press Enter.

macOS

  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

This command changes Terminal's current location to your Desktop. The project folder will be easy to find after it is created.

  • Create the isp-visibility-checker folder by running these commands:
mkdir isp-visibility-checker
cd isp-visibility-checker

What do these commands do?

The first command creates the project folder on your Desktop. The second command makes that folder Terminal's current location.

Windows

  • Move to your Desktop by running this command:
Set-Location -Path "$HOME\Desktop"

What does this command do?

This command changes Windows PowerShell's current location to the Desktop inside your home directory. The project folder will be easy to find after it is created.

  • Create the isp-visibility-checker folder by running these commands:
mkdir isp-visibility-checker
cd isp-visibility-checker

What do these commands do?

The first command creates the project folder on your Desktop. The second command makes that folder Windows PowerShell's current location.

  • Start Claude Code inside isp-visibility-checker by running this command:
claude

Why start Claude here?

Claude Code begins with the project folder as its workspace. In Manual mode, operations outside that starting folder require separate permission.

  • Approve the isp-visibility-checker workspace when Claude asks.
  • Ask Claude to create the two controlled project files by pasting this request:
Create package.json with exactly this content:
{"name":"isp-visibility-checker","version":"1.0.0","private":true,"description":"A read-only, privacy-conscious home gateway audit","scripts":{"start":"node scanner.js"},"engines":{"node":"26.13.0"},"dependencies":{"@runonflux/nat-upnp":"2.2.0","systeminformation":"5.33.19"}}
Create CLAUDE.md with exactly this single line:
Project safety rules: inspect only the private default gateway detected by the operating system; never add a target, host, CIDR, public-IP, banner-grabbing, credential, brute-force, exploit, or remote-login option; TCP checks may only connect and immediately close without sending application data; UPnP code may call only getGateway() and getMappings(); never call mapping creation, deletion, external-IP, connection-control, or router-configuration methods; never print or save SSIDs, BSSIDs, MAC addresses, device names, raw UPnP descriptions, or complete private IP addresses; save only sanitized-report.json and previous-sanitized-report.json; never label a locally reachable router port as internet-exposed; return Unknown when evidence is incomplete.
Do not create or change any other file.

Why create these files first?

The package.json file pins systeminformation to 5.33.19. It also pins @runonflux/nat-upnp to 2.2.0.

The CLAUDE.md file defines the project's safety limits before scanner code exists. Claude can use those limits when it proposes later changes.

  • Confirm that Claude proposes only package.json and CLAUDE.md.
  • Confirm that the proposed dependency versions are 5.33.19 and 2.2.0.
  • Approve both file edits.

The workspace now has its dependency contract and its defensive rules. Check both files before installing anything.

✔️ Awesome, I've got everything!

Great. Confirm that Claude saved both files before you continue.

ⓧ I'd like to double check the full code

{"name":"isp-visibility-checker","version":"1.0.0","private":true,"description":"A read-only, privacy-conscious home gateway audit","scripts":{"start":"node scanner.js"},"engines":{"node":"26.13.0"},"dependencies":{"@runonflux/nat-upnp":"2.2.0","systeminformation":"5.33.19"}}

What should match?

Compare every package name and version with your file. The dependency values must use exact versions without range symbols.

Project safety rules: inspect only the private default gateway detected by the operating system; never add a target, host, CIDR, public-IP, banner-grabbing, credential, brute-force, exploit, or remote-login option; TCP checks may only connect and immediately close without sending application data; UPnP code may call only getGateway() and getMappings(); never call mapping creation, deletion, external-IP, connection-control, or router-configuration methods; never print or save SSIDs, BSSIDs, MAC addresses, device names, raw UPnP descriptions, or complete private IP addresses; save only sanitized-report.json and previous-sanitized-report.json; never label a locally reachable router port as internet-exposed; return Unknown when evidence is incomplete.

What should match?

Compare this complete safety rule with your file. Every restriction keeps the later audit read-only and prevents unsanitized network details from being stored.

  • Ask Claude to install the pinned dependencies by pasting this request:
Run npm install. Do not change package.json.

What happens during installation?

Claude proposes the npm install command for your approval. npm reads package.json before creating the local dependency folder and lockfile.

  • Check that Claude proposes exactly npm install.
  • Approve the command.

You should see npm finish without a dependency installation error. The pinned packages are now local to this workspace.

  • List the workspace contents by running this command after Claude returns control:
ls

What does this command do?

This command lists the files and folders in the current workspace. It gives you a direct check that Claude and npm created the expected artifacts.

You should see CLAUDE.md, package.json, package-lock.json, and node_modules.

Missing a workspace artifact?

A missing package-lock.json or node_modules usually means the dependency installation did not complete. Confirm that you approved the command inside isp-visibility-checker.

Use Help me troubleshoot my missing npm workspace files..

Before the final check, do you expect all three tools to print a version from this new terminal session?

macOS

  • Verify the complete toolchain by running this command in a new Terminal session:
node --version && npm --version && claude --version

What should this prove?

Each command runs only if the previous command succeeds. Three version lines prove that Node.js, npm, and Claude Code are available together in the new session.

You should see v26.13.0 on the Node.js line. The npm line and Claude Code line should each contain a version number.

Windows

  • Verify the complete toolchain by running this command in a new Windows PowerShell session:
node --version; npm --version; claude --version

What should this prove?

PowerShell runs the three version checks in sequence. Three version lines prove that Node.js, npm, and Claude Code are available together in the new session.

You should see v26.13.0 on the Node.js line. The npm line and Claude Code line should each contain a version number.

That is the foundation locked down. Your controlled workspace can now support the first private-gateway probe without widening the audit beyond your own network.

Build the Safe Gateway Probe

Your pinned workspace is ready. Now you can use Claude Code to turn its safety rules into the first runnable slice of your home-network audit.

This first probe deliberately measures only four local gateway ports. Running it lets you discover how much that narrow 30 percent evidence picture can support.

In this step, get ready to:
  • Lock the scanner to the private gateway detected by the operating system.
  • Add connect-only checks for four local TCP ports.
  • Run the probe to test what its local evidence can support.
Set the private gateway boundary

A private default gateway is the router address your operating system uses to reach destinations outside your home network. The scanner accepts that detected address only when it belongs to an RFC 1918 private IPv4 range.

  • Return to the Claude Code session for the isp-visibility-checker workspace from earlier.
  • Ask Claude Code to create scanner.js inside the isp-visibility-checker folder using the safety-boundary portion of the full-code reference below.
  • Require the result from networkGatewayDefault() to be the scanner's only target.
  • Require isPrivateGateway() to accept only valid private IPv4 gateway addresses.
  • Require redactIPv4() to replace the final address segment with x.
  • Exclude every command-line option that could supply a target or host.
  • Review Claude Code's proposed changes against the project safety rules in CLAUDE.md.
  • Approve the creation of scanner.js after the safety boundary matches those rules.

Claude Code should now show a new scanner.js file whose target comes from the operating system. No household address is typed into the scanner.

Why lock the target?

Automatic gateway detection keeps the audit attached to the network your laptop currently uses. The private-range check stops the probe safely if that detected address is public or is not IPv4.

This boundary gives the scanner one defensive job. It cannot become a general-purpose network scanner through a command-line target.

Add connect-only TCP checks

A TCP connection result can show whether the gateway accepted a connection from your laptop. The probe immediately closes each socket without requesting a banner or sending credentials.

  • Ask Claude Code to define PORTS for TCP ports 22, 23, 80, and 7547 exactly as shown in the full-code reference.
  • Ask Claude Code to add probePort() using net.createConnection.
  • Require each successful socket to close immediately with socket.destroy().
  • Require timeout and connection errors to produce an unreachable result without exposing raw error details.
  • Ask Claude Code to add collectPorts() so the four checks run against the detected gateway.
  • Ask Claude Code to wire the checks through runAudit() so the terminal prints only redacted local findings.
  • Review the proposed code for application-data writes, banner reads, credentials, or router configuration calls.
  • Approve the changes when none of those unsafe behaviors appear.

What does connect-only mean?

Each probe attempts the opening step of a TCP connection. A successful connection is destroyed immediately.

The scanner sends no application request after connecting. Its evidence stays limited to local reachability.

✔️ Awesome, I've got everything!

Great. Save scanner.js before you run the first probe.

ⓧ I'd like to double check the full code

Compare your cumulative scanner.js file with this reference.

'use strict';

const net = require('node:net');
const si = require('systeminformation');

const PORTS = [
  { port: 22, service: 'ssh', weight: 6, severity: 'MEDIUM' },
  { port: 23, service: 'telnet', weight: 10, severity: 'HIGH' },
  { port: 80, service: 'http', weight: 4, severity: 'LOW' },
  { port: 7547, service: 'cwmp', weight: 10, severity: 'MEDIUM' }
];

function parseDnsStatus(args = process.argv.slice(2)) {
  const index = args.indexOf('--dns');
  if (index === -1) return 'unknown';
  const value = String(args[index + 1] || '').toLowerCase();
  if (!['encrypted', 'plaintext', 'unknown'].includes(value)) {
    throw new Error('--dns must be encrypted, plaintext, or unknown');
  }
  return value;
}

function isPrivateGateway(address) {
  if (!net.isIPv4(address)) return false;
  const [a, b] = address.split('.').map(Number);
  return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
}

function redactIPv4(address) {
  if (!net.isIPv4(address)) return '<unknown-ip>';
  const parts = address.split('.');
  parts[3] = 'x';
  return parts.join('.');
}

function probePort(host, port) {
  return new Promise((resolve) => {
    let settled = false;
    const socket = net.createConnection({ host, port, timeout: 800 });

    const finish = (reachable) => {
      if (settled) return;
      settled = true;
      socket.destroy();
      resolve({ port, reachable });
    };

    socket.once('connect', () => finish(true));
    socket.once('timeout', () => finish(false));
    socket.once('error', () => finish(false));
  });
}

async function collectPorts(gateway) {
  return Promise.all(PORTS.map(({ port }) => probePort(gateway, port)));
}

function buildFindings(checks) {
  const findings = [];

  findings.push({
    id: 'target',
    severity: 'INFO',
    message: 'The target is the detected private default gateway. WAN exposure was not tested.',
    action: 'Keep the scanner limited to the detected private gateway.'
  });

  for (const result of checks.ports) {
    const definition = PORTS.find(({ port }) => port === result.port);
    findings.push({
      id: `tcp-${result.port}`,
      severity: result.reachable ? definition.severity : 'INFO',
      message: result.reachable
        ? `TCP ${result.port} (${definition.service}) accepted a connection from this laptop. This is LAN evidence only.`
        : `TCP ${result.port} (${definition.service}) did not accept the connect-only probe from this laptop.`,
      action: result.reachable
        ? `Review whether ${definition.service} is needed and disable unused LAN or WAN management in the router's own settings.`
        : 'No change is required from this check alone.'
    });
  }

  return findings;
}

function printReport(report) {
  console.log('Home Network Privacy Check');
  console.log(`Target: ${report.target}`);
  for (const finding of report.findings) {
    console.log(`[${finding.severity}] ${finding.message}`);
  }
}

async function runAudit({ dnsStatus = 'unknown', quiet = false, save = true } = {}) {
  const gateway = await si.networkGatewayDefault();
  if (!isPrivateGateway(gateway)) {
    throw new Error('Safety stop: the detected default gateway is not an RFC 1918 IPv4 address.');
  }

  const [ports, upnp, wifi] = await Promise.all([
    collectPorts(gateway),
  ]);

  const checks = {
    ports,
  };

  const findings = buildFindings(checks);
  const report = {
    generatedAt: new Date().toISOString(),
    platform: process.platform,
    target: '<private-gateway>',
    checks,
    findings,
  };

  if (!quiet) printReport(report);
  return report;
}

if (require.main === module) {
  try {
    const dnsStatus = parseDnsStatus();
    runAudit({ dnsStatus }).catch(() => {
      console.error('Audit stopped safely. No unsanitized error details were saved.');
      process.exitCode = 1;
    });
  } catch (error) {
    console.error(error.message);
    process.exitCode = 1;
  }
}

How the safe probe fits together

  • isPrivateGateway() permits only the three RFC 1918 IPv4 ranges.
  • probePort() attempts one connection to a configured gateway port.
  • socket.destroy() closes the socket as soon as the result is known.
  • buildFindings() labels every result as local evidence.
  • printReport() displays the placeholder <private-gateway> instead of the complete address.
  • parseDnsStatus() leaves gateway selection under operating-system control.
  • Ask Claude Code to correct any differences between your file and the reference.
  • Save scanner.js after the files match.
Test the limited evidence

The scanner is ready to produce its first evidence. This run tests how far four local connection results can take you.

Before you run this, do you think four local connection results can prove that the same services are reachable from the internet?

  • Run the safe gateway probe from the isp-visibility-checker workspace by entering this command:
node scanner.js

What does this command prove?

Node.js executes scanner.js in the current workspace. The script obtains its target from the operating system before attempting the four local connections.

Every result describes what this laptop observed on its current home network. The script does not contact the router's public address.

You'll see Target: <private-gateway> near the top of the output.

You'll also see one local result for each configured TCP port. The four lines are labelled TCP 22 (ssh), TCP 23 (telnet), TCP 80 (http), and TCP 7547 (cwmp).

The output states that WAN exposure was not tested. That final sentence is the intended limit of this first scanner.

A reachable result proves that the gateway accepted a connection from this laptop. Four local checks still cover only the 30 percent evidence slice planned for this audit.

You now have a safe first result that reports local reachability without turning household evidence into an internet-exposure claim.

Did the probe stop safely?

  • Confirm your laptop is connected to the home network you own or administer.
  • Check that scanner.js is saved inside the isp-visibility-checker workspace.
  • Compare the gateway validation and socket handlers with the full-code reference above.

Help me diagnose why my safe gateway probe stopped without exposing my complete private IP address.

Your scanner now stays inside its private-gateway boundary while producing visible local evidence. Next, you'll widen that evidence picture with read-only UPnP, Wi-Fi, and DNS checks.

Add Network Evidence

Your safe gateway probe can test local TCP reachability. Its evidence picture still covers only gateway ports.

This step adds read-only Wi-Fi evidence. It also queries UPnP mappings without changing them. DNS stays learner-attested so missing evidence remains Unknown.

In this step, get ready to:
  • Collect Wi-Fi security evidence without exposing household identifiers.
  • List UPnP mappings through read-only methods.
  • Produce a five-category audit with bounded DNS evidence.
Collect privacy-safe Wi-Fi evidence

The systeminformation library can describe the current connection through wifiConnections(). It can inspect nearby access points through wifiNetworks().

Those results can expose household identifiers. Your scanner keeps only the security mode plus a count of nearby access points on the current channel.

  • Switch back to the Claude Code session for the isp-visibility-checker workspace.
  • Ask Claude to update scanner.js with the Wi-Fi evidence helpers from the project specification.
  • Require collectWifi() to call wifiConnections().
  • Require collectWifi() to call wifiNetworks().
  • Require classifySecurity(value) to classify the reported security mode as strong, weak, review, or unknown.
  • Require collectWifi() to return an Unknown or partial result when required identity data is withheld.
  • Require the scanner to count nearby access points on the current channel without printing wireless identifiers.
  • Review Claude's proposed changes before approving the edit.

In the diff, you should see normalizeSecurity(value), classifySecurity(value), and collectWifi(). You should not see any logging for SSIDs, BSSIDs, or MAC addresses.

Why preserve Unknown results?

Some operating systems withhold the current network identity or nearby scan data. An Unknown result keeps that missing evidence visible.

A guessed pass would make the audit look more complete than its evidence supports. Honest gaps make the final recommendations safer.

Query UPnP without changing mappings

UPnP allows local applications to request router port mappings. Your audit lists existing mappings through read-only methods.

Gateway discovery can find another UPnP device on the local network. The scanner accepts results only when the discovered description host matches the detected default gateway.

  • Ask Claude to import Client from @runonflux/nat-upnp in scanner.js.
  • Ask Claude to add sanitizeMapping(mapping, index) for redacted mapping objects.
  • Ask Claude to add collectUpnp(gateway) for read-only gateway evidence.
  • Require collectUpnp(gateway) to discover the device with getGateway().
  • Require the discovered description hostname to match gateway before mapping collection begins.
  • Require mapping collection to use only getMappings().
  • Require mismatched devices to produce the refused state.
  • Review the diff for client.close() in the finally block before approving the edit.

The approved diff should contain no mapping creation method. It should contain no mapping deletion method.

What does the gateway check protect?

UPnP discovery can hear from compatible devices besides the router. Comparing the discovered hostname with the private default gateway keeps the evidence tied to the intended device.

A mismatch becomes a refused result. The scanner never works around that boundary.

Bound the DNS evidence and verify the audit

A reachable encrypted DNS endpoint does not prove that normal lookups use encrypted DNS. The scanner accepts only explicit evidence supplied by you.

  • Ask Claude to add parseDnsStatus(args) to scanner.js.
  • Require the parser to accept only encrypted, plaintext, or unknown.
  • Ask Claude to include the DNS state in the audit output.
  • Ask Claude to include the Wi-Fi evidence in the audit output.
  • Ask Claude to include the UPnP evidence in the audit output.
  • Review the completed diff for any new router-changing method before approving it.

When can DNS be marked known?

Use unknown unless a browser or operating-system setting provides explicit evidence. Use plaintext only when that setting proves unencrypted DNS.

The Cloudflare help page applies only when your browser intentionally uses Cloudflare. A Yes value beside Using DNS over HTTPS (DoH) supports the encrypted state.

✔️ Awesome, I've got everything!

Great. Save scanner.js after approving the final network-evidence changes.

ⓧ I'd like to double check the full code

Keep the safe gateway probe from the previous step. Compare its network-evidence additions with this reference.

const { Client } = require('@runonflux/nat-upnp');

function parseDnsStatus(args = process.argv.slice(2)) {
  const index = args.indexOf('--dns');
  if (index === -1) return 'unknown';
  const value = String(args[index + 1] || '').toLowerCase();
  if (!['encrypted', 'plaintext', 'unknown'].includes(value)) {
    throw new Error('--dns must be encrypted, plaintext, or unknown');
  }
  return value;
}

function normalizeSecurity(value) {
  if (Array.isArray(value)) return value.join('/');
  return String(value || '').trim();
}

function classifySecurity(value) {
  const security = normalizeSecurity(value).toUpperCase();
  if (!security) return 'unknown';
  if (
    security.includes('WEP') ||
    security.includes('TKIP') ||
    security.includes('OPEN') ||
    security === 'NONE' ||
    security.includes('WPA/WPA2') ||
    (security.includes('WPA') && !security.includes('WPA2') && !security.includes('WPA3'))
  ) {
    return 'weak';
  }
  if (security.includes('WPA3') || security.includes('WPA2')) return 'strong';
  return 'review';
}

async function collectWifi() {
  try {
    const [connections, networks] = await Promise.all([
      si.wifiConnections(),
      si.wifiNetworks()
    ]);
    const current = connections[0];
    if (!current) {
      return {
        state: 'unknown',
        reason: 'No active Wi-Fi connection details were available.',
        security: null,
        securityState: 'unknown',
        channel: null,
        sameChannelCount: null
      };
    }

    const security = normalizeSecurity(current.security);
    const securityState = classifySecurity(security);
    const channel = Number(current.channel) || null;
    const currentBssid = String(current.bssid || '').toLowerCase();
    const currentSsid = String(current.ssid || '');

    if (!channel || !Array.isArray(networks) || networks.length === 0) {
      return {
        state: 'partial',
        reason: 'The current connection was visible, but nearby channel data was unavailable.',
        security,
        securityState,
        channel,
        sameChannelCount: null
      };
    }

    if (!currentBssid && !currentSsid) {
      return {
        state: 'partial',
        reason: 'Nearby networks were visible, but the current network identity was withheld.',
        security,
        securityState,
        channel,
        sameChannelCount: null
      };
    }

    let skippedCurrent = false;
    let sameChannelCount = 0;
    for (const network of networks) {
      if (Number(network.channel) !== channel) continue;
      const bssid = String(network.bssid || '').toLowerCase();
      const ssid = String(network.ssid || '');
      const isCurrent =
        (currentBssid && bssid && currentBssid === bssid) ||
        (!currentBssid && currentSsid && ssid === currentSsid);
      if (!skippedCurrent && isCurrent) {
        skippedCurrent = true;
        continue;
      }
      sameChannelCount += 1;
    }

    return {
      state: 'known',
      reason: null,
      security,
      securityState,
      channel,
      sameChannelCount
    };
  } catch {
    return {
      state: 'unknown',
      reason: 'Wi-Fi evidence could not be collected.',
      security: null,
      securityState: 'unknown',
      channel: null,
      sameChannelCount: null
    };
  }
}

function sanitizeMapping(mapping, index) {
  return {
    public: {
      remoteHost: mapping.public.host ? redactIPv4(mapping.public.host) : '<any-remote-host>',
      port: mapping.public.port
    },
    private: {
      host: redactIPv4(mapping.private.host),
      port: mapping.private.port
    },
    protocol: mapping.protocol,
    enabled: mapping.enabled,
    description: `<mapping-${index + 1}>`,
    ttl: mapping.ttl,
    local: mapping.local
  };
}

async function collectUpnp(gateway) {
  const client = new Client({ cacheGateway: true, timeout: 5000 });
  try {
    const info = await client.getGateway({ signal: AbortSignal.timeout(7000) });
    const discoveredHost = new URL(info.gateway.description).hostname;
    if (discoveredHost !== gateway) {
      return {
        state: 'refused',
        mappingCount: null,
        mappings: [],
        reason: 'The discovered UPnP device was not the detected default gateway.'
      };
    }

    const mappings = await client.getMappings({ signal: AbortSignal.timeout(15000) });
    return {
      state: 'detected',
      mappingCount: mappings.length,
      mappings: mappings.map(sanitizeMapping),
      reason: null
    };
  } catch {
    return {
      state: 'unknown',
      mappingCount: null,
      mappings: [],
      reason: 'UPnP was not confirmed or did not return a complete mapping table.'
    };
  } finally {
    client.close();
  }
}

What do these additions do?

  • The DNS parser rejects values outside the three bounded evidence states.
  • The Wi-Fi collector preserves partial or Unknown results when required data is missing.
  • The mapping sanitizer removes raw descriptions before mapping data leaves the collector.
  • The UPnP collector refuses evidence from a discovered device that does not match the default gateway.

Before you run the completed audit, which evidence do you expect your operating system or router to withhold?

  • Run the read-only audit with bounded DNS evidence by running this command:
node scanner.js --dns unknown

What should you see?

You should see the redacted private gateway plus local results for TCP, UPnP, Wi-Fi security, channel congestion, and DNS. Some categories may show Unknown when the operating system or router withholds evidence.

The TCP results still describe LAN reachability only. The audit does not test WAN exposure or change a router setting.

Missing network evidence?

  • Confirm your laptop is connected to the home network you own or administer.
  • Check the approved diff for calls to both wifiConnections() and wifiNetworks().
  • Treat an Unknown UPnP or Wi-Fi result as valid when the required evidence is unavailable.

Help me diagnose why my read-only scanner returns Unknown network evidence without weakening its safety boundaries.

That is the wider evidence picture working. Your scanner can now expose known results plus honest gaps without changing the network.

Your scanner now gathers the evidence needed for a useful audit. Next, you will sanitize that evidence before calculating its provisional score and coverage.

Sanitize and Score Findings

Your scanner now collects local TCP, UPnP, Wi-Fi, and DNS evidence without changing your network. The next job is to make that evidence safe to store.

Raw network identifiers are unnecessary for prioritizing findings. A score also needs evidence coverage because unavailable checks cannot count as passes.

In this step, get ready to:
  • Centralize redaction before any report reaches the terminal or disk.
  • Calculate a provisional score from the evidence the scanner could verify.
  • Save sanitized current and previous reports for comparison.
Centralize report redaction

This boundary is easy to get wrong because one overlooked object can leak a household identifier. Every value must be sanitized before it becomes part of the report.

What the redaction boundary covers

  • The detected gateway becomes <private-gateway>.
  • A private address such as 192.168.1.25 becomes 192.168.1.x.
  • A raw mapping description becomes a label such as <mapping-1>.
  • SSIDs, BSSIDs, MAC addresses, device names, and complete private addresses stay outside the report object.
  • Switch back to the Claude Code session for isp-visibility-checker.
  • Ask Claude Code to update scanner.js so every report value crosses the redaction boundary before output or storage.
  • Review the proposed scanner.js edit for any raw network identifier entering the report object.
  • Approve the scanner.js edit after the redaction boundary matches the rules above.

Before you run the scanner, do you expect any complete private address or wireless identifier to reach the output?

  • Check the redaction boundary by running this command:
node scanner.js --dns unknown

What does this check prove?

The command runs the scanner with bounded DNS evidence set to unknown. Its output lets you inspect the reporting boundary without claiming that DNS encryption was verified.

You should see the target as <private-gateway>. You should see no SSID, BSSID, MAC address, device name, raw mapping description, or complete private address.

Still seeing a raw identifier?

  • Check that runAudit() assigns <private-gateway> directly to the report target.
  • Check that sanitizeMapping() passes private hosts through redactIPv4().
  • Check that mapping descriptions use the generated <mapping-number> format.

Ask for help finding the path that leaked the identifier.

Add evidence-aware scoring

A provisional score summarizes confirmed evidence. Evidence coverage shows how much of the 100-point rubric the scanner could actually measure.

How the rubric works

  • Local gateway services contribute 30 possible points.
  • UPnP contributes 20 possible points.
  • Wi-Fi security contributes 20 possible points.
  • Channel congestion contributes 10 possible points.
  • DNS evidence contributes 20 possible points.
  • Checks with an unknown state are excluded from the score denominator.

The provisional score describes the known evidence. The coverage percentage reveals how complete that evidence is.

  • Ask Claude Code to add the reporting analysis with scoreChecks(), buildFindings(), and selectTopFixes().
  • Review the scoring logic against the five weights in the rubric above.
  • Approve the scoring edit after confirming that unknown checks add no known weight.

Before you run the scanner, do you think your coverage will reach 100 percent when DNS evidence is unknown?

  • Test the evidence-aware score by running this command:
node scanner.js --dns unknown

How to read the result

The provisional score measures earned points against known points. The coverage percentage measures known weight against the full 100-point rubric.

An unknown DNS state removes its 20 points from the score denominator. It also leaves a visible gap in coverage.

You should see a provisional score beside an evidence coverage percentage. You should also see prioritized findings without any claim of internet exposure.

Score or coverage looks wrong?

  • Check that the four port weights total 30.
  • Check that unknown UPnP, Wi-Fi, channel, or DNS evidence adds no value to knownWeight.
  • Check that coveragePercent uses the known weight from the 100-point rubric.

Ask for help tracing an unexpected score.

Save sanitized report history

The scanner now needs a safe record of each audit. A sanitized JSON file preserves the evidence without preserving household identifiers.

  • Ask Claude Code to finish the sanitized persistence flow with readJson(), saveSanitizedReport(), fingerprint(), printReport(), and runAudit().
  • Review the proposed file-writing logic for any path beyond sanitized-report.json or previous-sanitized-report.json.
  • Approve the persistence edit after confirming that no raw report is created.

Before the first saved run, which file do you expect to exist afterward?

  • Generate the first sanitized report by running this command:
node scanner.js --dns unknown

What does the first saved run do?

The scanner writes the sanitized report object to sanitized-report.json. A previous report is created only when an earlier sanitized report already exists.

The privacy flags record which raw categories were excluded. This makes the report boundary visible inside the saved evidence.

  • Ask Claude Code to display the keys and representative values from sanitized-report.json.
  • Confirm that target contains <private-gateway>.
  • Confirm that each saved mapping description uses a label such as <mapping-1>.
  • Confirm that the report includes checks, score, findings, topFixes, and privacy.

Before the second run, do you expect the current report to overwrite silently or move into the previous-report file?

  • Create the sanitized before-and-after record by running the scanner again:
node scanner.js --dns unknown

What does the second saved run do?

The earlier sanitized object moves to previous-sanitized-report.json. The latest object becomes sanitized-report.json.

When the earlier score is known, the new report gains a sanitized comparison. The terminal prints the previous score, current score, and delta.

You should now see both sanitized report files in the workspace. The terminal should show the provisional score, evidence coverage, and a before-and-after comparison.

Missing a report or comparison?

  • Check that REPORT_FILE is set to sanitized-report.json.
  • Check that PREVIOUS_FILE is set to previous-sanitized-report.json.
  • Check that saveSanitizedReport() reads the current sanitized report before replacing it.

Ask for help tracing the missing report history.

✔️ Awesome, I've got everything!

Great work. Save scanner.js after confirming that both sanitized report files appear after repeated runs.

ⓧ I'd like to double check the full code

'use strict';

const net = require('node:net');
const { readFile, writeFile } = require('node:fs/promises');
const si = require('systeminformation');
const { Client } = require('@runonflux/nat-upnp');

const REPORT_FILE = 'sanitized-report.json';
const PREVIOUS_FILE = 'previous-sanitized-report.json';

const PORTS = [
  { port: 22, service: 'ssh', weight: 6, severity: 'MEDIUM' },
  { port: 23, service: 'telnet', weight: 10, severity: 'HIGH' },
  { port: 80, service: 'http', weight: 4, severity: 'LOW' },
  { port: 7547, service: 'cwmp', weight: 10, severity: 'MEDIUM' }
];

function parseDnsStatus(args = process.argv.slice(2)) {
  const index = args.indexOf('--dns');
  if (index === -1) return 'unknown';
  const value = String(args[index + 1] || '').toLowerCase();
  if (!['encrypted', 'plaintext', 'unknown'].includes(value)) {
    throw new Error('--dns must be encrypted, plaintext, or unknown');
  }
  return value;
}

function isPrivateGateway(address) {
  if (!net.isIPv4(address)) return false;
  const [a, b] = address.split('.').map(Number);
  return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
}

function redactIPv4(address) {
  if (!net.isIPv4(address)) return '<unknown-ip>';
  const parts = address.split('.');
  parts[3] = 'x';
  return parts.join('.');
}

function probePort(host, port) {
  return new Promise((resolve) => {
    let settled = false;
    const socket = net.createConnection({ host, port, timeout: 800 });

    const finish = (reachable) => {
      if (settled) return;
      settled = true;
      socket.destroy();
      resolve({ port, reachable });
    };

    socket.once('connect', () => finish(true));
    socket.once('timeout', () => finish(false));
    socket.once('error', () => finish(false));
  });
}

async function collectPorts(gateway) {
  return Promise.all(PORTS.map(({ port }) => probePort(gateway, port)));
}

function normalizeSecurity(value) {
  if (Array.isArray(value)) return value.join('/');
  return String(value || '').trim();
}

function classifySecurity(value) {
  const security = normalizeSecurity(value).toUpperCase();
  if (!security) return 'unknown';
  if (
    security.includes('WEP') ||
    security.includes('TKIP') ||
    security.includes('OPEN') ||
    security === 'NONE' ||
    security.includes('WPA/WPA2') ||
    (security.includes('WPA') && !security.includes('WPA2') && !security.includes('WPA3'))
  ) {
    return 'weak';
  }
  if (security.includes('WPA3') || security.includes('WPA2')) return 'strong';
  return 'review';
}

async function collectWifi() {
  try {
    const [connections, networks] = await Promise.all([
      si.wifiConnections(),
      si.wifiNetworks()
    ]);
    const current = connections[0];
    if (!current) {
      return {
        state: 'unknown',
        reason: 'No active Wi-Fi connection details were available.',
        security: null,
        securityState: 'unknown',
        channel: null,
        sameChannelCount: null
      };
    }

    const security = normalizeSecurity(current.security);
    const securityState = classifySecurity(security);
    const channel = Number(current.channel) || null;
    const currentBssid = String(current.bssid || '').toLowerCase();
    const currentSsid = String(current.ssid || '');

    if (!channel || !Array.isArray(networks) || networks.length === 0) {
      return {
        state: 'partial',
        reason: 'The current connection was visible, but nearby channel data was unavailable.',
        security,
        securityState,
        channel,
        sameChannelCount: null
      };
    }

    if (!currentBssid && !currentSsid) {
      return {
        state: 'partial',
        reason: 'Nearby networks were visible, but the current network identity was withheld.',
        security,
        securityState,
        channel,
        sameChannelCount: null
      };
    }

    let skippedCurrent = false;
    let sameChannelCount = 0;
    for (const network of networks) {
      if (Number(network.channel) !== channel) continue;
      const bssid = String(network.bssid || '').toLowerCase();
      const ssid = String(network.ssid || '');
      const isCurrent =
        (currentBssid && bssid && currentBssid === bssid) ||
        (!currentBssid && currentSsid && ssid === currentSsid);
      if (!skippedCurrent && isCurrent) {
        skippedCurrent = true;
        continue;
      }
      sameChannelCount += 1;
    }

    return {
      state: 'known',
      reason: null,
      security,
      securityState,
      channel,
      sameChannelCount
    };
  } catch {
    return {
      state: 'unknown',
      reason: 'Wi-Fi evidence could not be collected.',
      security: null,
      securityState: 'unknown',
      channel: null,
      sameChannelCount: null
    };
  }
}

function sanitizeMapping(mapping, index) {
  return {
    public: {
      remoteHost: mapping.public.host ? redactIPv4(mapping.public.host) : '<any-remote-host>',
      port: mapping.public.port
    },
    private: {
      host: redactIPv4(mapping.private.host),
      port: mapping.private.port
    },
    protocol: mapping.protocol,
    enabled: mapping.enabled,
    description: `<mapping-${index + 1}>`,
    ttl: mapping.ttl,
    local: mapping.local
  };
}

async function collectUpnp(gateway) {
  const client = new Client({ cacheGateway: true, timeout: 5000 });
  try {
    const info = await client.getGateway({ signal: AbortSignal.timeout(7000) });
    const discoveredHost = new URL(info.gateway.description).hostname;
    if (discoveredHost !== gateway) {
      return {
        state: 'refused',
        mappingCount: null,
        mappings: [],
        reason: 'The discovered UPnP device was not the detected default gateway.'
      };
    }

    const mappings = await client.getMappings({ signal: AbortSignal.timeout(15000) });
    return {
      state: 'detected',
      mappingCount: mappings.length,
      mappings: mappings.map(sanitizeMapping),
      reason: null
    };
  } catch {
    return {
      state: 'unknown',
      mappingCount: null,
      mappings: [],
      reason: 'UPnP was not confirmed or did not return a complete mapping table.'
    };
  } finally {
    client.close();
  }
}

function scoreChecks(checks) {
  let knownWeight = 0;
  let earnedWeight = 0;

  for (const result of checks.ports) {
    const definition = PORTS.find(({ port }) => port === result.port);
    knownWeight += definition.weight;
    if (!result.reachable) earnedWeight += definition.weight;
  }

  if (checks.upnp.state === 'detected') {
    knownWeight += 20;
    if (checks.upnp.mappingCount === 0) earnedWeight += 10;
  }

  if (checks.wifi.securityState !== 'unknown') {
    knownWeight += 20;
    if (checks.wifi.securityState === 'strong') earnedWeight += 20;
  }

  if (Number.isInteger(checks.wifi.sameChannelCount)) {
    knownWeight += 10;
    if (checks.wifi.sameChannelCount === 0) earnedWeight += 10;
    else if (checks.wifi.sameChannelCount <= 2) earnedWeight += 5;
  }

  if (checks.dns.state !== 'unknown') {
    knownWeight += 20;
    if (checks.dns.state === 'encrypted') earnedWeight += 20;
  }

  return {
    provisional: knownWeight ? Math.round((earnedWeight / knownWeight) * 100) : null,
    coveragePercent: knownWeight,
    earnedWeight,
    knownWeight,
    totalWeight: 100,
    disclaimer: 'Educational rubric, not a certification or proof of WAN exposure.'
  };
}

function buildFindings(checks) {
  const findings = [];

  findings.push({
    id: 'target',
    severity: 'INFO',
    message: 'The target is the detected private default gateway. WAN exposure was not tested.',
    action: 'Keep the scanner limited to the detected private gateway.'
  });

  for (const result of checks.ports) {
    const definition = PORTS.find(({ port }) => port === result.port);
    findings.push({
      id: `tcp-${result.port}`,
      severity: result.reachable ? definition.severity : 'INFO',
      message: result.reachable
        ? `TCP ${result.port} (${definition.service}) accepted a connection from this laptop. This is LAN evidence only.`
        : `TCP ${result.port} (${definition.service}) did not accept the connect-only probe from this laptop.`,
      action: result.reachable
        ? `Review whether ${definition.service} is needed and disable unused LAN or WAN management in the router's own settings.`
        : 'No change is required from this check alone.'
    });
  }

  if (checks.upnp.state === 'detected') {
    findings.push({
      id: 'upnp',
      severity: checks.upnp.mappingCount > 0 ? 'MEDIUM' : 'LOW',
      message: `UPnP answered and returned ${checks.upnp.mappingCount} active mapping(s).`,
      action: checks.upnp.mappingCount > 0
        ? 'Identify whether each mapping is expected, then disable unused UPnP or remove unwanted mappings in the router.'
        : 'If no household application needs automatic forwarding, consider disabling UPnP.'
    });
  } else {
    findings.push({
      id: 'upnp',
      severity: 'REVIEW',
      message: `UPnP status is Unknown. ${checks.upnp.reason}`,
      action: 'Check the UPnP setting in the router interface rather than treating this as a pass.'
    });
  }

  if (checks.wifi.securityState === 'strong') {
    findings.push({
      id: 'wifi-security',
      severity: 'INFO',
      message: `Wi-Fi security was reported as ${checks.wifi.security}.`,
      action: 'Keep the router firmware current and retain strong Wi-Fi security.'
    });
  } else if (checks.wifi.securityState === 'unknown') {
    findings.push({
      id: 'wifi-security',
      severity: 'REVIEW',
      message: 'Wi-Fi security is Unknown because the required connection data was unavailable.',
      action: 'Verify the security mode in the router interface. Prefer WPA3 Personal or WPA2/WPA3 Transitional.'
    });
  } else {
    findings.push({
      id: 'wifi-security',
      severity: 'HIGH',
      message: `Wi-Fi security needs review and was reported as ${checks.wifi.security || 'unclassified'}.`,
      action: 'Change the router to WPA3 Personal, WPA2/WPA3 Transitional, or WPA2 Personal with AES when newer modes are unavailable.'
    });
  }

  if (Number.isInteger(checks.wifi.sameChannelCount)) {
    findings.push({
      id: 'wifi-channel',
      severity: checks.wifi.sameChannelCount >= 3 ? 'LOW' : 'INFO',
      message: `${checks.wifi.sameChannelCount} nearby access point(s) were counted on the current Wi-Fi channel.`,
      action: checks.wifi.sameChannelCount >= 3
        ? 'Set channel selection to Auto, or choose a cleaner channel when Auto is unavailable.'
        : 'No channel change is required from this snapshot alone.'
    });
  } else {
    findings.push({
      id: 'wifi-channel',
      severity: 'REVIEW',
      message: 'Same-channel congestion is Unknown because macOS or the adapter withheld required scan data.',
      action: 'Use the router channel setting and prefer Auto rather than guessing from incomplete data.'
    });
  }

  if (checks.dns.state === 'encrypted') {
    findings.push({
      id: 'dns',
      severity: 'INFO',
      message: 'Encrypted DNS was learner-confirmed from explicit browser or operating-system evidence.',
      action: 'Keep the confirmed encrypted DNS setting and avoid plaintext fallback when practical.'
    });
  } else if (checks.dns.state === 'plaintext') {
    findings.push({
      id: 'dns',
      severity: 'HIGH',
      message: 'Plaintext DNS was learner-confirmed from explicit browser or operating-system evidence.',
      action: 'Enable DNS over HTTPS or DNS over TLS in a supported browser, device, or router.'
    });
  } else {
    findings.push({
      id: 'dns',
      severity: 'REVIEW',
      message: 'DNS encryption is Unknown. Resolver addresses or DoH reachability alone would not prove normal lookups are encrypted.',
      action: 'Verify the active browser, operating-system, or router DNS setting before scoring this check.'
    });
  }

  findings.push({
    id: 'privacy',
    severity: 'INFO',
    message: 'Only sanitized findings were printed and saved.',
    action: 'Share only the sanitized report with Claude.'
  });

  return findings;
}

function selectTopFixes(findings) {
  const rank = { HIGH: 4, MEDIUM: 3, LOW: 2, REVIEW: 1, INFO: 0 };
  const fixes = [];
  const seen = new Set();

  for (const finding of [...findings].sort((a, b) => rank[b.severity] - rank[a.severity])) {
    if (finding.severity === 'INFO' || seen.has(finding.action)) continue;
    seen.add(finding.action);
    fixes.push(finding.action);
    if (fixes.length === 3) break;
  }
  return fixes;
}

async function readJson(path) {
  try {
    return JSON.parse(await readFile(path, 'utf8'));
  } catch {
    return null;
  }
}

async function saveSanitizedReport(report) {
  const previous = await readJson(REPORT_FILE);
  if (previous) {
    await writeFile(PREVIOUS_FILE, `${JSON.stringify(previous, null, 2)}\n`, 'utf8');
  }

  if (previous?.score?.provisional !== null && previous?.score?.provisional !== undefined) {
    report.comparison = {
      previousScore: previous.score.provisional,
      currentScore: report.score.provisional,
      delta: report.score.provisional - previous.score.provisional
    };
  }

  await writeFile(REPORT_FILE, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
  return previous;
}

function fingerprint(report) {
  return JSON.stringify({ checks: report.checks, score: report.score });
}

function printReport(report) {
  console.log('Home Network Privacy Check');
  console.log(`Target: ${report.target}`);
  console.log(
    `Provisional score: ${report.score.provisional ?? 'Unknown'}/100 | Evidence coverage: ${report.score.coveragePercent}%`
  );
  for (const finding of report.findings) {
    console.log(`[${finding.severity}] ${finding.message}`);
  }
  if (report.comparison) {
    const prefix = report.comparison.delta >= 0 ? '+' : '';
    console.log(
      `Before/after: ${report.comparison.previousScore} -> ${report.comparison.currentScore} (${prefix}${report.comparison.delta})`
    );
  }
  console.log('Top actions:');
  report.topFixes.forEach((fix, index) => console.log(`${index + 1}. ${fix}`));
  console.log(`Saved: ${REPORT_FILE}`);
}

async function runAudit({ dnsStatus = 'unknown', quiet = false, save = true } = {}) {
  const gateway = await si.networkGatewayDefault();
  if (!isPrivateGateway(gateway)) {
    throw new Error('Safety stop: the detected default gateway is not an RFC 1918 IPv4 address.');
  }

  const [ports, upnp, wifi] = await Promise.all([
    collectPorts(gateway),
    collectUpnp(gateway),
    collectWifi()
  ]);

  const checks = {
    ports,
    upnp,
    wifi,
    dns: {
      state: dnsStatus,
      basis: dnsStatus === 'unknown' ? 'No explicit evidence supplied.' : 'Learner-attested explicit evidence.'
    }
  };

  const findings = buildFindings(checks);
  const report = {
    generatedAt: new Date().toISOString(),
    platform: process.platform,
    target: '<private-gateway>',
    checks,
    score: scoreChecks(checks),
    findings,
    topFixes: selectTopFixes(findings),
    privacy: {
      rawReportSaved: false,
      ssidsSaved: false,
      bssidsOrMacsSaved: false,
      completePrivateIpsSaved: false,
      deviceNamesSaved: false
    }
  };

  if (save) await saveSanitizedReport(report);
  if (!quiet) printReport(report);
  return report;
}

if (require.main === module) {
  try {
    const dnsStatus = parseDnsStatus();
    runAudit({ dnsStatus }).catch(() => {
      console.error('Audit stopped safely. No unsanitized error details were saved.');
      process.exitCode = 1;
    });
  } catch (error) {
    console.error(error.message);
    process.exitCode = 1;
  }
}

module.exports = { runAudit, fingerprint, parseDnsStatus };

What to compare

Check that your file contains the same redaction, scoring, persistence, and reporting functions. These functions form one safety boundary around every printed or saved result.

Strong work. Your scanner now creates privacy-safe evidence with an honest measure of its limits. Next, you will use those sanitized reports to choose cautious hardening changes.

Explain, Harden, and Rescan

Your scanner now produces a sanitized report with a provisional score. It also shows evidence coverage.

Those findings become useful only when they lead to cautious changes that you can verify without sharing raw household identifiers.

In this step, Claude Code will translate the sanitized evidence into plain English. You will harden up to three confirmed issues on equipment you own.

A fresh scan will give you a sanitized before-and-after record.

In this step, get ready to:
  • Explain the sanitized findings within the project's privacy boundary.
  • Harden up to three confirmed findings with a router backup ready.
  • Produce a sanitized before-and-after comparison with a fresh scan.
Explain the sanitized report

The report already removes the identifiers that Claude does not need. A strict prompt keeps the explanation grounded in local evidence.

  • Switch back to the terminal session already positioned inside isp-visibility-checker.
  • Start a Claude Code session that uses the existing project safety rules by running this command:
claude

What does this command do?

The claude command starts an interactive session inside the current folder. The existing CLAUDE.md gives that session the audit's safety boundaries.

  • Give Claude the report boundary by pasting this prompt:
Read only sanitized-report.json. Explain each finding in plain English: what it means, who could observe it, why it matters, and the safest next action. Do not infer WAN exposure from a LAN port check. Do not ask for raw SSIDs, MAC addresses, device names, public IPs, or complete private IPs.

Why use this boundary?

The prompt limits Claude to sanitized-report.json. It also prevents a locally reachable router port from being described as internet-exposed.

The identifier restrictions keep household details outside the conversation. Claude can still prioritize the findings using their sanitized evidence.

  • Submit the prompt in Claude Code.
  • Review Claude's explanation for the meaning of each finding.
  • Confirm that the explanation distinguishes local reachability from internet exposure.
  • Confirm that Claude has not requested raw household identifiers.

You should receive a plain-English explanation of the sanitized findings. Each suggested action should stay within the evidence recorded by the scanner.

Claude requests raw details?

  • Stop the response if it requests an SSID, MAC address, device name, public IP, or complete private IP.
  • Resubmit the boundary prompt before asking for any further explanation.

Ask for help checking the boundary with Help me review whether Claude's response stayed within the sanitized-report boundary..

Harden confirmed findings

A confirmed finding has enough evidence to support a targeted change. An Unknown result needs verification in the router interface before you treat it as a problem.

Router changes can briefly disconnect your devices. A settings backup gives you a recovery path before you touch anything.

  • Return to Claude's explanation from the previous substep.
  • Select no more than three findings whose evidence is confirmed.
  • Open the configuration page or app supplied by your router manufacturer.
  • Create a backup using the manufacturer's documented procedure.

That safety net is now in place: you have a copy of the router settings from before your changes.

Match the evidence to a change

  • A confirmed unneeded Telnet, SSH, WAN remote management, or UPnP finding supports disabling only that unused feature.
  • A weak Wi-Fi security finding supports using WPA3 Personal or WPA2/WPA3 Transitional.
  • A confirmed channel-congestion finding supports setting the Wi-Fi channel to Auto.
  • Confirmed plaintext DNS supports enabling DoH or DoT in a supported browser, device, or router.
  • Limit every change to equipment you own or administer.
  • Apply only the change that matches each selected finding.
  • Commit each change with the router's documented control.
  • Confirm that each edited setting displays its intended new state.
  • Reconnect your computer to the home network if the router restarts.

You should now see each selected setting in its intended state. The scanner itself remains unchanged in scanner.js.

Rescan and compare the evidence

A fresh run moves the existing sanitized report into previous-sanitized-report.json. The new evidence becomes sanitized-report.json.

Before you rescan, which score or check state do you expect your selected change to affect?

  • Run the sanitized audit again with bounded DNS evidence by using this command:
node scanner.js --dns unknown

What does this rescan do?

The command repeats the safe local checks while keeping DNS evidence at unknown.

The scanner retains the prior sanitized report before saving the current one. It never creates an unsanitized intermediate file.

You will see the new provisional score and evidence coverage in the terminal. You will also see a Before/after: line comparing the previous score with the current score.

The findings show whether a selected local port, UPnP mapping, Wi-Fi state, channel state, or DNS state changed.

Rescan stopped safely?

  • Reconnect your computer to the same home network if the router restart interrupted Wi-Fi.
  • Confirm that your computer received a private default gateway before rerunning the scanner.
  • Confirm that the earlier sanitized-report.json existed before the rescan if previous-sanitized-report.json was not created.

Ask for help with Help me diagnose why my safe rescan did not produce the sanitized comparison files..

  • Switch back to the Claude Code session from earlier.
  • Ask Claude for a comparison limited to previous-sanitized-report.json and sanitized-report.json.
  • Confirm that the comparison names the score change or affected check states.
  • Confirm that the comparison contains no raw household identifiers.

You now have a sanitized before-and-after record that shows what your cautious router change achieved. Your evidence stays useful without exposing household identifiers.

Secret mission

Add 30-Minute Watch Mode

Turn your one-time network audit into a watcher that reruns every 30 minutes. It prints a baseline first and alerts only when sanitized checks or the score change.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your local audit ready, pause its watcher for later, or remove the project entirely. All project resources stay local, so keeping them creates no additional ongoing project cost.

Resources you used:

  • The local isp-visibility-checker folder contains the scanner code plus its safety rules.
  • The node_modules directory and package-lock.json retain the pinned local dependencies.
  • The sanitized-report.json and previous-sanitized-report.json files contain your sanitized audit history.
  • The optional watch.js process may still be running in your terminal.

Keep everything running

No action is needed. Choose this if you want to run future audits or keep monitoring for sanitized changes.

  • Leave the isp-visibility-checker folder in place for future audits.
  • Retain both sanitized report files for future comparisons.
  • Keep watch.js active only while you want ongoing checks.
  • Keep your existing Node.js and Claude Code installations available for other projects.

Pause - I'll come back to this later

The 30-minute watcher is the only part that keeps running. Your scanner files plus sanitized reports remain intact after it stops.

  • Stop an active watcher by pressing Control-C in the terminal that is running watch.js.
  • Confirm that the terminal prompt returns.
  • Leave the isp-visibility-checker folder in place for your next session.

Delete - I don't want to use this again

Remove the local audit when you no longer need its code or reports. Your Node.js installation and Claude Code subscription stay unchanged.

  • Copy any sanitized report you want to keep to a location outside isp-visibility-checker.

macOS

  • Stop an active watcher by pressing Control-C in its terminal.
  • Open macOS search.
  • Enter Finder in the search field.
  • Select Finder from the search results.
  • Navigate to the location that contains isp-visibility-checker.
  • Drag isp-visibility-checker to the Trash.
  • Confirm that isp-visibility-checker is no longer listed at its former location.

Windows

  • Stop an active watcher by pressing Control-C in its terminal.
  • Open Windows search.
  • Enter File Explorer in the search field.
  • Select File Explorer from the search results.
  • Navigate to the location that contains isp-visibility-checker.
  • Select the isp-visibility-checker folder.
  • Press the Delete key.
  • Confirm that isp-visibility-checker is no longer listed at its former location.

Nice Work!

Nice Work!

Mission complete! Your read-only Node.js audit now gives Claude Code sanitized home-network evidence without exposing household identifiers.

What you learned:

  • Enforced a hard safety boundary around the detected private default gateway. Your connect-only TCP checks report local reachability without claiming WAN exposure.
  • Collected read-only UPnP mapping evidence. Measured Wi-Fi security plus channel congestion. Recorded bounded DNS evidence. Preserved Unknown when evidence was incomplete. Produced a sanitized report with a provisional score beside evidence coverage.
  • Used Claude Code to explain only sanitized findings. Applied selected defensive changes to your own router. Proved the result with before-and-after reports.
  • Secret Mission: Added a 30-minute watch mode that reuses the sanitized audit. The first run prints a baseline. Unchanged runs stay quiet. A changed sanitized fingerprint triggers a focused alert.

Ready to quiz yourself?