diff --git a/Directory.Build.props b/Directory.Build.props index 08bf641..75ca962 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ enable enable true - 0.3.0 + 0.4.0 Manfred Heil Junior MT - Manfred Tecnologia MapNet - MT diff --git a/README.md b/README.md index c300232..0b23155 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Página do programa: **https://mapnet.manfred.com.br**. Download da versão mais Software livre, sob licença [GPL-3.0](LICENSE). -Não é scanner de vulnerabilidade: o programa não testa senha, não contorna autenticação e não explora vulnerabilidade. Para achar os equipamentos, ele manda ping, pedidos ARP e consultas de nome (DNS, NetBIOS e mDNS) aos endereços da sub-rede escolhida. Com a opção **Verificar portas** ligada, ele também abre e fecha uma conexão TCP em cada porta da lista, nos equipamentos que encontrou, sem enviar dados. +Não é scanner de vulnerabilidade: o programa não testa senha, não contorna autenticação e não explora vulnerabilidade. Para achar os equipamentos, ele manda ping, pedidos ARP e consultas de nome (DNS, NetBIOS e mDNS) aos endereços da sub-rede escolhida. Com a opção **Verificar portas** ligada, ele também abre e fecha uma conexão TCP em cada porta da lista, nos equipamentos que encontrou, sem enviar dados. Com **Identificar serviços**, que vem marcado junto com as portas, ele pede a página inicial dos serviços web que estiverem abertos (só um GET, sem senha), lê o certificado HTTPS e a primeira linha de SSH, FTP e SMTP, e faz uma busca UPnP na rede local. **Uso autorizado.** O MapNet - MT serve para inventariar equipamentos em redes nas quais você tem autorização para fazer a verificação. Confira o intervalo de endereços antes de iniciar e respeite as regras da rede e dos dispositivos examinados. @@ -25,7 +25,7 @@ As regras do projeto estão no [`AGENTS.md`](AGENTS.md) e o desenho em [`docs/su | 5 | Ações de manutenção com elevação sob demanda, na aba Manutenção do console (sai como v0.2.4) | `manutencao` | [#18](https://github.com/manfredjr/mapnet/pull/18) | Publicada na v0.2.5 | | 6 | Painel de detalhe do host, com ping, tracert, navegador, área de trabalho remota e pasta compartilhada (sai como v0.2.5) | `detalhe-host` | [#19](https://github.com/manfredjr/mapnet/pull/19) | Publicada na v0.2.5 | | 7 | Portas TCP comuns abertas, com lista configurável, desligadas por padrão e com confirmação por rede (sai como v0.3.0) | `portas` | Plano em [#26](https://github.com/manfredjr/mapnet/pull/26), código em [#31](https://github.com/manfredjr/mapnet/pull/31) | Publicada na v0.3.0 | -| 8 | Identificação leve dos serviços nas portas abertas: título da página, certificado HTTPS, banner SSH, FTP e SMTP, e UPnP (sai como v0.4.0) | `identificacao` | Plano em [#32](https://github.com/manfredjr/mapnet/pull/32) | Plano em revisão | +| 8 | Identificação leve dos serviços nas portas abertas: título da página, certificado HTTPS, banner SSH, FTP e SMTP, e UPnP (sai como v0.4.0) | `identificacao-servicos` | Plano em [#32](https://github.com/manfredjr/mapnet/pull/32), código em [#34](https://github.com/manfredjr/mapnet/pull/34) | Em revisão | | 9 e 10 | Classificação por tipo de equipamento e relatório também em XML e CSV (até a v1.0.0) | a definir | - | A fazer | A versão 0.2 segue o desenho em [`docs/superpowers/specs/2026-09-25-mapnet-0.2-painel-do-tecnico-design.md`](docs/superpowers/specs/2026-09-25-mapnet-0.2-painel-do-tecnico-design.md). @@ -41,7 +41,7 @@ O programa é um arquivo só, `mapnet.exe`. Não precisa instalar nada no comput 1. Abra o `mapnet.exe`. 2. Na faixa verde do topo, escolha a interface de rede. A primeira da lista é a que tem gateway, quase sempre a certa. A coluna **Minha máquina** mostra o computador (nome, grupo de trabalho ou domínio, usuário), a placa (tipo, MAC, velocidade, MTU), os endereços (IPv4, IPv6, gateway, DNS, sufixo e a sub-rede que vai ser varrida) e o DHCP com a validade da concessão. No Wi-Fi, mostra também a rede, a banda, o canal e o sinal. 3. O botão **Consultar IP público**, na faixa do topo, pergunta o IP ao serviço `1.1.1.1`, do Cloudflare, e só roda com o clique. Se o relatório for gravado depois da consulta, o IP público vai junto. -4. Para saber também as portas abertas de cada equipamento, marque **Verificar portas** acima da tabela. A **Lista** vem com `padrão`, as 24 portas comuns (a dica do campo mostra quais são), ou aceita até 100 portas separadas por vírgula, como `80,443,9100`. Os aparelhos com MAC aleatório, quase sempre celulares e notebooks pessoais, ficam de fora, a não ser que você marque **Incluir aparelhos com MAC aleatório**. Na primeira varredura com portas em cada rede, o programa diz o que vai enviar e pede confirmação. Use só em rede que você tem autorização para verificar. +4. Para saber também as portas abertas de cada equipamento, marque **Verificar portas** acima da tabela. A **Lista** vem com `padrão`, as 24 portas comuns (a dica do campo mostra quais são), ou aceita até 100 portas separadas por vírgula, como `80,443,9100`. Os aparelhos com MAC aleatório, quase sempre celulares e notebooks pessoais, ficam de fora, a não ser que você marque **Incluir aparelhos com MAC aleatório**. **Identificar serviços** vem marcado e preenche a coluna **Serviço** com o que o equipamento diz de si: modelo pelo UPnP, título da página, certificado ou banner. Na primeira varredura com portas em cada rede, o programa diz o que vai enviar e pede confirmação. Use só em rede que você tem autorização para verificar. 5. Clique em **Iniciar varredura**. Clicar numa linha da tabela abre, ao lado, o detalhe do host: MAC, fabricante, os nomes pelo DNS reverso, NetBIOS e mDNS, o grupo de trabalho, o ping com o TTL, uma pista do sistema pelo TTL e as portas abertas, com o serviço de cada uma. Os botões do detalhe fazem ping contínuo e tracert no console, abrem http e https no navegador (na 8080, 8000 ou 8443 quando só ela está aberta, e desligados quando as portas web foram verificadas e estão fechadas), a área de trabalho remota e a pasta compartilhada, e copiam os dados. Um novo clique na mesma linha, a tecla Esc ou o botão **Fechar** fecham o detalhe. A tabela vai se enchendo enquanto os hosts respondem, e o mesmo botão vira **Cancelar**. O campo **Filtrar** procura por IP, nome, MAC, fabricante, observação ou número de porta aberta (`9100` lista as impressoras), e cada coluna ordena com um clique no título. 6. O console embaixo registra o andamento e os avisos, e a barra de estado mostra o resumo. O console tem também uma aba para cada ferramenta: Ping (normal ou contínuo), Tracert, DNS com escolha do servidor, ARP, Conexões e Rotas. Os campos de host e de servidor DNS já vêm com o gateway e o DNS da interface. Enquanto a ferramenta roda, **Executar** vira **Parar**. **Copiar** e **Limpar** valem para a aba aberta. 7. No fim, clique em **Salvar relatório** e escolha onde gravar o arquivo HTML. A janela abre em `Documentos\MapNet - MT` com um nome pronto, e da segunda vez abre na pasta usada por último. O programa não grava sozinho: fechar a janela com a varredura sem salvar pergunta antes. O botão **Abrir** abre o relatório salvo no navegador ou a pasta dele. @@ -69,6 +69,7 @@ mapnet --ajuda | `--sem-arp` | Descobre hosts só pelo ping | | `--portas [lista]` | Verifica as portas TCP dos hosts encontrados, só abrindo e fechando a conexão. Sem lista, usa as 24 portas comuns; com lista, até 100 portas, como `22,80,443`. Desligado sem esta opção | | `--incluir-mac-aleatorio` | Com `--portas`, inclui os aparelhos com MAC aleatório, que ficam de fora por padrão | +| `--sem-identificar` | Com `--portas`, não identifica os serviços das portas abertas | O `.exe` é de janela, então o Prompt de Comando devolve o cursor antes de o programa terminar. Para esperar o fim, use `start /wait mapnet --varrer` no Prompt de Comando ou termine a linha com `| Out-Host` no PowerShell. Redirecionar para arquivo (`> saida.txt`) funciona direto, em UTF-8. @@ -110,7 +111,8 @@ Se o valor bater com o do `.txt`, o executável é exatamente o que foi publicad - **Nome:** DNS reverso, NetBIOS (o mesmo do `nbtstat -A`, que traz também o grupo de trabalho ou domínio) e mDNS (nomes `.local` de celulares, Macs, impressoras e aparelhos Linux). - **Relatório HTML:** arquivo único, com resumo da varredura, cartões de contagem, tabela filtrável e ordenável, detalhe de cada host ao clicar na linha e a lista de fabricantes. -- **Portas (desde a v0.3.0, desligadas por padrão):** para cada host encontrado, abre e fecha a conexão TCP em cada porta da lista, sem enviar nenhum dado, com até 128 conexões ao mesmo tempo, 8 por host e 800 ms de espera por porta. Porta que aceita a conexão aparece como aberta. Este computador e os aparelhos com MAC aleatório ficam de fora, e o motivo aparece no detalhe. O relatório ganha a coluna Portas e diz quais portas foram verificadas. A identificação do serviço (título da página, banner, certificado) fica para a v0.4.0. +- **Portas (desde a v0.3.0, desligadas por padrão):** para cada host encontrado, abre e fecha a conexão TCP em cada porta da lista, sem enviar nenhum dado, com até 128 conexões ao mesmo tempo, 8 por host e 800 ms de espera por porta. Porta que aceita a conexão aparece como aberta. Este computador e os aparelhos com MAC aleatório ficam de fora, e o motivo aparece no detalhe. O relatório ganha a coluna Portas e diz quais portas foram verificadas. +- **Serviços (desde a v0.4.0, com as portas):** só nas portas abertas. Nas web (80, 8000, 8080, 443, 8443), um `GET /` para ler o título e o servidor, sem seguir redirecionamento, e o certificado HTTPS, aceitando autoassinado e vencido. Em SSH, FTP e SMTP, só lê a primeira linha que o servidor manda. O UPnP é um `M-SEARCH` na rede local, e a descrição do equipamento é lida dele mesmo. Telnet fica de fora. No máximo 16 ao mesmo tempo, 3 s por serviço e 64 KB lidos por resposta. Sub-rede maior que /22 (1022 endereços) é varrida só no bloco /22 em volta do IP do computador, com aviso no relatório. A faixa manual fica para uma próxima versão. diff --git a/docs/superpowers/pendencias.md b/docs/superpowers/pendencias.md index 98d4732..d40f739 100644 --- a/docs/superpowers/pendencias.md +++ b/docs/superpowers/pendencias.md @@ -65,6 +65,14 @@ Fechado em parte na fatia 3 (0.2.2). No tamanho padrão da janela, IP, Origem, M | Portas numa rede de verdade | A etapa foi testada com sonda simulada e no modo de demonstração. Não rodou numa rede de verdade no teste do agente | O Manfred varrer a rede da MT com as portas ligadas e conferir, por exemplo, a 80 ou 443 no roteador, a 9100 numa impressora e a 445 num Windows com compartilhamento | | Confirmação vale para a sessão | A pergunta aparece uma vez por sub-rede enquanto o programa está aberto. Fechar e abrir de novo pergunta outra vez | Guardar a confirmação, se o Manfred achar a pergunta repetitiva no uso | +## Fatia 8: identificação de serviços + +| Item | Motivo | O que fecha | +|---|---|---| +| Linha de comando com a identificação ligada por padrão | O plano previa `--identificar` para ligar. Com a decisão de 26/09/2026 de deixar **Identificar serviços** marcado na janela, a linha de comando segue o mesmo padrão: `--portas` já identifica e `--sem-identificar` desliga | O Manfred confirmar ou pedir `--identificar` | +| HTTP pedido pelo IP | O `GET /` vai com o IP no cabeçalho `Host`. Servidor que hospeda vários sites por nome pode mostrar uma página genérica | Usar o nome do DNS reverso no `Host`, se fizer falta no campo | +| Janela da identificação vista só no modo de demonstração | A identificação de verdade rodou na rede do Manfred pela linha de comando, com roteador, câmera, TV e Windows respondendo. A janela foi conferida com dados de exemplo | O Manfred varrer pela janela com as portas ligadas | + ## Página de privacidade | Item | Motivo | O que fecha | diff --git a/public/index.html b/public/index.html index 905581a..f4ad030 100644 --- a/public/index.html +++ b/public/index.html @@ -48,7 +48,7 @@

O que ele faz

Todos os equipamentos da sub-rede

Por ping e por ARP. Quem bloqueia ping também aparece.

Fabricante e nome

Pelo MAC, na tabela do IEEE, e por DNS, NetBIOS e mDNS.

De onde vem a rede

DHCP, gateway, DNS, Wi-Fi e, com um clique, o IP público.

-

Detalhe de cada equipamento

Nomes, TTL, portas abertas e atalhos para ping, navegador e área de trabalho remota.

+

Detalhe de cada equipamento

Nomes, TTL, portas abertas, serviços e atalhos para ping, navegador e área de trabalho remota.

Ferramentas de rede

Ping, tracert, DNS, ARP, conexões e rotas, com a saída em português.

Manutenção

Limpar o cache DNS, renovar o IP, limpar o ARP e resetar o Winsock.

Relatório em HTML

Arquivo único para guardar com a documentação do cliente.

@@ -74,7 +74,7 @@

Como usar

Uso autorizado

O MapNet serve para inventariar equipamentos em redes nas quais você tem autorização para fazer a verificação.

diff --git a/src/mapnet.nucleo/identificacao/etapa-identificacao.cs b/src/mapnet.nucleo/identificacao/etapa-identificacao.cs new file mode 100644 index 0000000..b01292c --- /dev/null +++ b/src/mapnet.nucleo/identificacao/etapa-identificacao.cs @@ -0,0 +1,114 @@ +using System.Net; + +namespace MapNet.Nucleo; + +/// +/// Etapa de identificação, depois das portas. Só conversa com as portas que a etapa de portas +/// achou abertas, e só nos hosts que ela verificou: nunca sonda porta nem host novo. O UPnP é uma +/// busca só na rede local, e das respostas ficam só as dos hosts verificados. +/// +public static class EtapaIdentificacao +{ + public const string Nome = "Identificação de serviços"; + + /// Protocolo usado em cada porta. Porta fora desta lista não é identificada (Telnet fica de fora de propósito). + public static ProtocoloServico? Protocolo(int porta) => porta switch + { + 80 or 8000 or 8080 => ProtocoloServico.Http, + 443 or 8443 => ProtocoloServico.Https, + 21 or 22 or 25 => ProtocoloServico.Banner, + _ => null, + }; + + public static string NomeBanner(int porta) => porta switch + { + 21 => "FTP", + 22 => "SSH", + 25 => "SMTP", + _ => "TCP", + }; + + public static async Task IdentificarAsync( + IReadOnlyList hosts, + IPAddress origem, + OpcoesVarredura opcoes, + IIdentificador identificador, + IProgress? progresso, + CancellationToken cancelamento) + { + var verificados = hosts.Where(h => h.PortasVerificadas).ToList(); + var tarefas = verificados + .SelectMany(h => h.PortasAbertas.Where(p => Protocolo(p) != null).Select(p => (Host: h, Porta: p))) + .ToList(); + var total = tarefas.Count + 1; + var concluidos = 0; + progresso?.Report(new ProgressoVarredura(Nome, 0, total)); + + using var vagas = new SemaphoreSlim(Math.Max(1, opcoes.IdentificacoesSimultaneas)); + var servicos = tarefas.Select(async t => + { + await vagas.WaitAsync(cancelamento).ConfigureAwait(false); + try + { + var s = await identificador.IdentificarAsync(t.Host.Ip, t.Porta, Protocolo(t.Porta)!.Value, opcoes.TempoIdentificacaoMs, cancelamento) + .ConfigureAwait(false); + if (s != null) + { + lock (t.Host) + { + t.Host.Servicos = [.. t.Host.Servicos, s]; + } + } + } + finally + { + vagas.Release(); + } + + progresso?.Report(new ProgressoVarredura(Nome, Interlocked.Increment(ref concluidos), total, t.Host)); + }); + + var upnp = UpnpAsync(verificados, origem, opcoes, identificador, cancelamento); + await Task.WhenAll(servicos.Append(upnp)).ConfigureAwait(false); + progresso?.Report(new ProgressoVarredura(Nome, Interlocked.Increment(ref concluidos), total)); + + foreach (var h in verificados) + { + h.Servicos = [.. h.Servicos.OrderBy(s => s.Porta)]; + h.ServicosIdentificados = true; + } + } + + private static async Task UpnpAsync( + IReadOnlyList verificados, + IPAddress origem, + OpcoesVarredura opcoes, + IIdentificador identificador, + CancellationToken cancelamento) + { + var porIp = verificados.ToDictionary(h => h.IpNumero); + var respostas = await identificador.BuscarUpnpAsync(origem, opcoes.TempoIdentificacaoMs, cancelamento).ConfigureAwait(false); + + // Um equipamento responde várias vezes, uma por serviço: fica a primeira de cada IP. + var unicas = respostas + .Where(r => r.Origem.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork && porIp.ContainsKey(SubRede.ParaNumero(r.Origem))) + .GroupBy(r => SubRede.ParaNumero(r.Origem)) + .Select(g => g.First()); + + await Task.WhenAll(unicas.Select(async r => + { + var host = porIp[SubRede.ParaNumero(r.Origem)]; + string? modelo = null; + if (Ssdp.DescricaoDoProprio(r) is { } endereco) + { + modelo = Ssdp.Modelo(await identificador.LerDescricaoAsync(endereco, opcoes.TempoIdentificacaoMs, cancelamento).ConfigureAwait(false)); + } + + var s = new ServicoIdentificado { Porta = 0, Protocolo = "UPnP", Modelo = modelo, Servidor = r.Servidor }; + lock (host) + { + host.Servicos = [.. host.Servicos, s]; + } + })).ConfigureAwait(false); + } +} diff --git a/src/mapnet.nucleo/identificacao/identificador.cs b/src/mapnet.nucleo/identificacao/identificador.cs new file mode 100644 index 0000000..7b324ec --- /dev/null +++ b/src/mapnet.nucleo/identificacao/identificador.cs @@ -0,0 +1,218 @@ +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace MapNet.Nucleo; + +/// Conversa com os serviços. Os testes trocam por uma versão simulada. +public interface IIdentificador +{ + /// Identifica o serviço de uma porta aberta. Null quando ele não respondeu no tempo. + Task IdentificarAsync(IPAddress ip, int porta, ProtocoloServico protocolo, int tempoMs, CancellationToken cancelamento); + + /// Manda um M-SEARCH pela interface de origem e junta as respostas que chegarem no tempo. + Task> BuscarUpnpAsync(IPAddress origem, int tempoMs, CancellationToken cancelamento); + + /// Lê a descrição UPnP do próprio equipamento, por http. + Task LerDescricaoAsync(Uri endereco, int tempoMs, CancellationToken cancelamento); +} + +/// +/// Identificação pela rede, sem administrador. HTTP e HTTPS mandam só um GET na raiz; SSH, FTP e +/// SMTP não recebem nada, o programa só lê o que o servidor diz ao conectar. +/// +public sealed class IdentificadorRede : IIdentificador +{ + public async Task IdentificarAsync(IPAddress ip, int porta, ProtocoloServico protocolo, int tempoMs, CancellationToken cancelamento) + { + using var limite = CancellationTokenSource.CreateLinkedTokenSource(cancelamento); + limite.CancelAfter(tempoMs); + try + { + using var cliente = new TcpClient(ip.AddressFamily) { NoDelay = true }; + await cliente.ConnectAsync(ip, porta, limite.Token).ConfigureAwait(false); + var rede = cliente.GetStream(); + return protocolo switch + { + ProtocoloServico.Http => await HttpAsync(rede, ip, porta, "HTTP", null, limite.Token, cancelamento).ConfigureAwait(false), + ProtocoloServico.Https => await HttpsAsync(rede, ip, porta, limite.Token, cancelamento).ConfigureAwait(false), + _ => await BannerAsync(rede, porta, limite.Token).ConfigureAwait(false), + }; + } + catch (OperationCanceledException) when (!cancelamento.IsCancellationRequested) + { + return null; + } + catch (Exception e) when (e is SocketException or IOException or AuthenticationException) + { + return null; + } + } + + public async Task> BuscarUpnpAsync(IPAddress origem, int tempoMs, CancellationToken cancelamento) + { + var respostas = new List(); + using var limite = CancellationTokenSource.CreateLinkedTokenSource(cancelamento); + limite.CancelAfter(tempoMs); + try + { + using var cliente = new UdpClient(new IPEndPoint(origem, 0)); + await cliente.SendAsync(Ssdp.Pedido(), Ssdp.Grupo, limite.Token).ConfigureAwait(false); + while (true) + { + var r = await cliente.ReceiveAsync(limite.Token).ConfigureAwait(false); + if (Ssdp.Interpretar(r.RemoteEndPoint.Address, r.Buffer) is { } resposta) + { + respostas.Add(resposta); + } + } + } + catch (OperationCanceledException) when (!cancelamento.IsCancellationRequested) + { + return respostas; + } + catch (SocketException) + { + return respostas; + } + } + + public async Task LerDescricaoAsync(Uri endereco, int tempoMs, CancellationToken cancelamento) + { + using var limite = CancellationTokenSource.CreateLinkedTokenSource(cancelamento); + limite.CancelAfter(tempoMs); + try + { + using var cliente = new TcpClient(AddressFamily.InterNetwork) { NoDelay = true }; + await cliente.ConnectAsync(endereco.Host, endereco.Port, limite.Token).ConfigureAwait(false); + var rede = cliente.GetStream(); + await rede.WriteAsync(RespostaHttp.Pedido(endereco.Authority, endereco.PathAndQuery), limite.Token).ConfigureAwait(false); + var dados = RespostaHttp.Interpretar(await LerAsync(rede, limite.Token, cancelamento, ateTitulo: false).ConfigureAwait(false)); + return dados.Codigo == 200 ? dados.Corpo : null; + } + catch (OperationCanceledException) when (!cancelamento.IsCancellationRequested) + { + return null; + } + catch (Exception e) when (e is SocketException or IOException) + { + return null; + } + } + + private static async Task HttpAsync(Stream rede, IPAddress ip, int porta, string protocolo, ServicoIdentificado? base_, CancellationToken cancelamento, CancellationToken usuario) + { + await rede.WriteAsync(RespostaHttp.Pedido(ip.ToString(), "/"), cancelamento).ConfigureAwait(false); + var dados = RespostaHttp.Interpretar(await LerAsync(rede, cancelamento, usuario, ateTitulo: true).ConfigureAwait(false)); + var s = base_ ?? new ServicoIdentificado { Porta = porta, Protocolo = protocolo }; + s.CodigoHttp = dados.Codigo; + s.Titulo = dados.Titulo; + s.Servidor = dados.Servidor; + s.Redireciona = dados.Codigo is >= 300 and < 400 ? dados.Localizacao : null; + return s; + } + + /// + /// Começa o TLS aceitando qualquer certificado: equipamento de rede usa quase sempre um + /// autoassinado ou vencido, e aqui o objetivo é ler o certificado, não confiar nele. + /// + private static async Task HttpsAsync(Stream rede, IPAddress ip, int porta, CancellationToken cancelamento, CancellationToken usuario) + { + X509Certificate2? certificado = null; + await using var tls = new SslStream(rede, leaveInnerStreamOpen: false, (_, cert, _, _) => + { + if (cert != null) + { + certificado = new X509Certificate2(cert); + } + + return true; + }); + await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = ip.ToString() }, cancelamento).ConfigureAwait(false); + var s = new ServicoIdentificado { Porta = porta, Protocolo = "HTTPS" }; + if (certificado != null) + { + s.CertificadoNome = TextoRede.Limpar(certificado.GetNameInfo(X509NameType.SimpleName, forIssuer: false)); + s.CertificadoEmissor = TextoRede.Limpar(certificado.GetNameInfo(X509NameType.SimpleName, forIssuer: true)); + s.CertificadoValidade = new DateTimeOffset(certificado.NotAfter); + certificado.Dispose(); + } + + try + { + return await HttpAsync(tls, ip, porta, "HTTPS", s, cancelamento, usuario).ConfigureAwait(false); + } + catch (Exception e) when (e is IOException || (e is OperationCanceledException && !usuario.IsCancellationRequested)) + { + // O certificado já foi lido; a página pode ter fechado a conexão ou não respondido no tempo. + return s; + } + } + + /// Só lê a primeira linha que o servidor manda ao conectar. Não envia nada. + private static async Task BannerAsync(Stream rede, int porta, CancellationToken cancelamento) + { + var buffer = new byte[512]; + var lidos = 0; + while (lidos < buffer.Length) + { + var n = await rede.ReadAsync(buffer.AsMemory(lidos), cancelamento).ConfigureAwait(false); + if (n == 0) + { + break; + } + + lidos += n; + if (Array.IndexOf(buffer, (byte)'\n', 0, lidos) >= 0) + { + break; + } + } + + var linha = Encoding.UTF8.GetString(buffer, 0, lidos).Split('\n')[0]; + return new ServicoIdentificado { Porta = porta, Protocolo = EtapaIdentificacao.NomeBanner(porta), Banner = TextoRede.Limpar(linha) }; + } + + /// + /// Lê até o servidor fechar, até o limite de 64 KB ou, na página, até o fim do título. Se o + /// tempo acabar no meio, fica o que já chegou. Só o cancelamento de quem usa interrompe. + /// + private static async Task LerAsync(Stream rede, CancellationToken limite, CancellationToken usuario, bool ateTitulo) + { + var buffer = new byte[RespostaHttp.MaximoBytes]; + var lidos = 0; + try + { + while (lidos < buffer.Length) + { + var n = await rede.ReadAsync(buffer.AsMemory(lidos), limite).ConfigureAwait(false); + if (n == 0) + { + break; + } + + lidos += n; + if (ateTitulo && TemFimDoTitulo(buffer.AsSpan(0, lidos))) + { + break; + } + } + } + catch (OperationCanceledException) when (lidos > 0 && !usuario.IsCancellationRequested) + { + // O tempo acabou com a resposta pela metade: fica o que chegou. + } + catch (IOException) when (lidos > 0) + { + // Servidor fechou de um jeito brusco depois de mandar a resposta: fica o que chegou. + } + + return buffer[..lidos]; + } + + private static bool TemFimDoTitulo(ReadOnlySpan dados) => + dados.IndexOf("= 0 || dados.IndexOf("= 0 || dados.IndexOf("= 0; +} \ No newline at end of file diff --git a/src/mapnet.nucleo/identificacao/servicos.cs b/src/mapnet.nucleo/identificacao/servicos.cs new file mode 100644 index 0000000..2d2dfde --- /dev/null +++ b/src/mapnet.nucleo/identificacao/servicos.cs @@ -0,0 +1,351 @@ +using System.Globalization; +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml; + +namespace MapNet.Nucleo; + +/// Como o programa conversa com cada porta na identificação. +public enum ProtocoloServico +{ + Http, + Https, + Banner, +} + +/// O que um serviço disse de si. Todo texto aqui veio da rede e já passou por . +public sealed class ServicoIdentificado +{ + public required int Porta { get; init; } + + /// Nome curto do serviço: HTTP, HTTPS, SSH, FTP, SMTP ou UPnP. + public required string Protocolo { get; init; } + + public int? CodigoHttp { get; set; } + + public string? Titulo { get; set; } + + public string? Servidor { get; set; } + + /// Endereço do redirecionamento, anotado sem seguir. + public string? Redireciona { get; set; } + + public string? CertificadoNome { get; set; } + + public string? CertificadoEmissor { get; set; } + + public DateTimeOffset? CertificadoValidade { get; set; } + + public string? Banner { get; set; } + + /// Nome amigável, fabricante e modelo pela descrição UPnP. + public string? Modelo { get; set; } + + /// Uma linha para o detalhe: 80 HTTP: título "Painel", servidor lighttpd. + public string Texto + { + get + { + var partes = new List(); + if (Modelo != null) + { + partes.Add(Modelo); + } + + if (Titulo != null) + { + partes.Add($"título \"{Titulo}\""); + } + + if (Servidor != null) + { + partes.Add($"servidor {Servidor}"); + } + + if (Redireciona != null) + { + partes.Add($"redireciona para {Redireciona}"); + } + + if (CertificadoNome != null) + { + var validade = CertificadoValidade is { } v ? $", válido até {v.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture)}" : string.Empty; + partes.Add($"certificado {CertificadoNome}" + (CertificadoEmissor != null && CertificadoEmissor != CertificadoNome ? $" emitido por {CertificadoEmissor}" : string.Empty) + validade); + } + + if (Banner != null) + { + partes.Add(Banner); + } + + var porta = Porta > 0 ? $"{Porta.ToString(CultureInfo.InvariantCulture)} " : string.Empty; + return $"{porta}{Protocolo}: " + (partes.Count > 0 ? string.Join(", ", partes) : "respondeu sem se identificar"); + } + } +} + +/// Limpeza do texto que vem da rede, que pode trazer qualquer coisa. +public static class TextoRede +{ + public const int Maximo = 200; + + /// Tira caractere de controle, junta espaços, apara e corta em . Vazio vira null. + public static string? Limpar(string? texto, int maximo = Maximo) + { + if (string.IsNullOrEmpty(texto)) + { + return null; + } + + var sb = new StringBuilder(Math.Min(texto.Length, maximo + 1)); + var espaco = false; + foreach (var c in texto) + { + if (char.IsWhiteSpace(c) || char.IsControl(c)) + { + espaco = sb.Length > 0; + continue; + } + + if (char.GetUnicodeCategory(c) == UnicodeCategory.Format) + { + continue; + } + + if (espaco) + { + sb.Append(' '); + espaco = false; + } + + sb.Append(c); + if (sb.Length >= maximo) + { + break; + } + } + + return sb.Length > 0 ? sb.ToString() : null; + } +} + +/// Leitura de uma resposta HTTP já recebida: código, cabeçalhos e título da página. +public static partial class RespostaHttp +{ + public const int MaximoBytes = 64 * 1024; + + public sealed record Dados(int? Codigo, string? Servidor, string? Localizacao, string? Titulo, string? Corpo); + + /// Pedido que o programa manda: só GET, sem cookie, sem credencial. + public static byte[] Pedido(string host, string caminho) => + Encoding.ASCII.GetBytes( + $"GET {caminho} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: MapNet-MT/{ResultadoVarredura.VersaoPrograma}\r\n" + + "Accept: text/html,application/xml;q=0.9,*/*;q=0.5\r\nConnection: close\r\n\r\n"); + + public static Dados Interpretar(ReadOnlySpan resposta) + { + var bytes = resposta.Length > MaximoBytes ? resposta[..MaximoBytes] : resposta; + var fim = bytes.IndexOf("\r\n\r\n"u8); + var cabecalho = Encoding.Latin1.GetString(fim >= 0 ? bytes[..fim] : bytes); + var corpo = fim >= 0 ? bytes[(fim + 4)..] : []; + var linhas = cabecalho.Split("\r\n"); + + int? codigo = null; + var primeira = linhas[0].Split(' ', 3); + if (primeira.Length >= 2 && primeira[0].StartsWith("HTTP/", StringComparison.Ordinal) + && int.TryParse(primeira[1], NumberStyles.None, CultureInfo.InvariantCulture, out var c)) + { + codigo = c; + } + + string? servidor = null, localizacao = null, tipo = null; + foreach (var linha in linhas.Skip(1)) + { + var dois = linha.IndexOf(':'); + if (dois <= 0) + { + continue; + } + + var nome = linha[..dois].Trim(); + var valor = linha[(dois + 1)..].Trim(); + if (nome.Equals("Server", StringComparison.OrdinalIgnoreCase)) + { + servidor = valor; + } + else if (nome.Equals("Location", StringComparison.OrdinalIgnoreCase)) + { + localizacao = valor; + } + else if (nome.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) + { + tipo = valor; + } + } + + var texto = Decodificar(corpo, tipo); + return new Dados(codigo, TextoRede.Limpar(servidor), TextoRede.Limpar(localizacao), Titulo(texto), texto); + } + + /// Título da página, com as entidades HTML resolvidas. + public static string? Titulo(string? html) + { + if (html is null) + { + return null; + } + + var m = TagTitulo().Match(html); + return m.Success ? TextoRede.Limpar(WebUtility.HtmlDecode(m.Groups[1].Value)) : null; + } + + /// Usa o charset do cabeçalho, depois o da página, e por fim UTF-8. + private static string Decodificar(ReadOnlySpan corpo, string? tipo) + { + var nome = CharsetDe(tipo); + if (nome is null) + { + var inicio = Encoding.Latin1.GetString(corpo[..Math.Min(corpo.Length, 2048)]); + var meta = MetaCharset().Match(inicio); + nome = meta.Success ? meta.Groups[1].Value : null; + } + + var codificacao = Encoding.UTF8; + if (nome != null) + { + try + { + codificacao = nome.Equals("iso-8859-1", StringComparison.OrdinalIgnoreCase) || nome.Equals("latin1", StringComparison.OrdinalIgnoreCase) + ? Encoding.Latin1 + : Encoding.GetEncoding(nome); + } + catch (ArgumentException) + { + // Charset que o .NET não conhece: fica o UTF-8. + } + } + + return codificacao.GetString(corpo); + } + + private static string? CharsetDe(string? tipo) + { + if (tipo is null) + { + return null; + } + + var m = CharsetCabecalho().Match(tipo); + return m.Success ? m.Groups[1].Value : null; + } + + [GeneratedRegex(@"]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline)] + private static partial Regex TagTitulo(); + + [GeneratedRegex(@"]+charset\s*=\s*[""']?([A-Za-z0-9_\-]+)", RegexOptions.IgnoreCase)] + private static partial Regex MetaCharset(); + + [GeneratedRegex(@"charset\s*=\s*[""']?([A-Za-z0-9_\-]+)", RegexOptions.IgnoreCase)] + private static partial Regex CharsetCabecalho(); +} + +/// Resposta de um equipamento ao M-SEARCH do UPnP. +public sealed record RespostaSsdp(IPAddress Origem, string? Servidor, string? Localizacao); + +/// UPnP: o pedido M-SEARCH, a leitura das respostas e a descrição XML do equipamento. +public static class Ssdp +{ + public static readonly IPEndPoint Grupo = new(IPAddress.Parse("239.255.255.250"), 1900); + + public static byte[] Pedido() => Encoding.ASCII.GetBytes( + "M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nMAN: \"ssdp:discover\"\r\nMX: 2\r\nST: upnp:rootdevice\r\n\r\n"); + + public static RespostaSsdp? Interpretar(IPAddress origem, ReadOnlySpan pacote) + { + var texto = Encoding.Latin1.GetString(pacote); + var linhas = texto.Split("\r\n"); + if (!linhas[0].StartsWith("HTTP/1.1 200", StringComparison.Ordinal)) + { + return null; + } + + string? servidor = null, local = null; + foreach (var linha in linhas.Skip(1)) + { + var dois = linha.IndexOf(':'); + if (dois <= 0) + { + continue; + } + + var nome = linha[..dois].Trim(); + var valor = linha[(dois + 1)..].Trim(); + if (nome.Equals("SERVER", StringComparison.OrdinalIgnoreCase)) + { + servidor = TextoRede.Limpar(valor); + } + else if (nome.Equals("LOCATION", StringComparison.OrdinalIgnoreCase)) + { + local = valor; + } + } + + return new RespostaSsdp(origem, servidor, local); + } + + /// + /// Endereço da descrição, só quando aponta para o próprio equipamento que respondeu, por http. + /// Assim a leitura nunca vai a outro host. + /// + public static Uri? DescricaoDoProprio(RespostaSsdp r) => + Uri.TryCreate(r.Localizacao, UriKind.Absolute, out var uri) + && uri.Scheme == Uri.UriSchemeHttp + && IPAddress.TryParse(uri.Host, out var ip) && ip.Equals(r.Origem) + ? uri + : null; + + /// "Nome amigável (fabricante modelo)" da descrição UPnP. DTD é recusado, o que barra entidade externa. + public static string? Modelo(string? xml) + { + if (string.IsNullOrWhiteSpace(xml)) + { + return null; + } + + var ajustes = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = RespostaHttp.MaximoBytes }; + string? amigavel = null, fabricante = null, modelo = null; + try + { + using var leitor = XmlReader.Create(new StringReader(xml), ajustes); + leitor.Read(); + while (!leitor.EOF) + { + // ReadElementContentAsString já anda para o nó seguinte, então não chama Read depois dele. + if (leitor.NodeType == XmlNodeType.Element && leitor.LocalName == "friendlyName" && amigavel is null) + { + amigavel = TextoRede.Limpar(leitor.ReadElementContentAsString()); + } + else if (leitor.NodeType == XmlNodeType.Element && leitor.LocalName == "manufacturer" && fabricante is null) + { + fabricante = TextoRede.Limpar(leitor.ReadElementContentAsString()); + } + else if (leitor.NodeType == XmlNodeType.Element && leitor.LocalName == "modelName" && modelo is null) + { + modelo = TextoRede.Limpar(leitor.ReadElementContentAsString()); + } + else + { + leitor.Read(); + } + } + } + catch (XmlException) + { + return null; + } + + var complemento = string.Join(' ', new[] { fabricante, modelo }.Where(s => s != null)); + return TextoRede.Limpar(amigavel is null ? complemento : complemento.Length > 0 ? $"{amigavel} ({complemento})" : amigavel); + } +} diff --git a/src/mapnet.nucleo/linha-de-comando/argumentos.cs b/src/mapnet.nucleo/linha-de-comando/argumentos.cs index 919d405..8292bb2 100644 --- a/src/mapnet.nucleo/linha-de-comando/argumentos.cs +++ b/src/mapnet.nucleo/linha-de-comando/argumentos.cs @@ -38,6 +38,10 @@ mapnet abre a janela com lista, por exemplo 22,80,443 (até 100 portas) --incluir-mac-aleatorio inclui nas portas os aparelhos com MAC aleatório, quase sempre pessoais, que ficam de fora por padrão + --sem-identificar com --portas, não identifica os serviços. Sem esta + opção, nas portas abertas o programa pede a página + inicial web, lê o certificado HTTPS e o banner de SSH, + FTP e SMTP, e faz uma busca UPnP na rede local Exemplos: mapnet --varrer --interface Wi-Fi --saida C:\Relatorios --abrir @@ -64,6 +68,7 @@ public static ArgumentosCli Interpretar(IReadOnlyList args) { var a = new ArgumentosCli(); var comandos = 0; + var semIdentificar = false; for (var i = 0; i < args.Count; i++) { @@ -115,6 +120,10 @@ public static ArgumentosCli Interpretar(IReadOnlyList args) case "--incluir-mac-aleatorio": a.Opcoes.PortasEmMacAleatorio = true; break; + case "--sem-identificar": + a.Opcoes.IdentificarServicos = false; + semIdentificar = true; + break; case "--tempo-ping": if (Numero(Valor(args, ref i, arg, a.Erros), 100, 10000, arg, a.Erros) is int tempo) { @@ -151,6 +160,11 @@ public static ArgumentosCli Interpretar(IReadOnlyList args) a.Erros.Add("A opção --incluir-mac-aleatorio só vale junto com --portas."); } + if (semIdentificar && !a.Opcoes.OlharPortas) + { + a.Erros.Add("A opção --sem-identificar só vale junto com --portas."); + } + return a; } diff --git a/src/mapnet.nucleo/painel/demonstracao.cs b/src/mapnet.nucleo/painel/demonstracao.cs index 02ab1f1..d208d70 100644 --- a/src/mapnet.nucleo/painel/demonstracao.cs +++ b/src/mapnet.nucleo/painel/demonstracao.cs @@ -250,6 +250,26 @@ HostEncontrado Host(byte final, string mac, long? ping, Action? [142] = [554, 8000], }; + /// Serviços de exemplo, com nomes e modelos inventados, pelo último número do IP. + private static IReadOnlyList ServicosExemplo(byte final) => final switch + { + 1 => + [ + new() { Porta = 0, Protocolo = "UPnP", Modelo = "Roteador do escritório (Exemplo Redes RX-100)" }, + new() { Porta = 80, Protocolo = "HTTP", CodigoHttp = 302, Redireciona = "https://192.0.2.1/" }, + new() { Porta = 443, Protocolo = "HTTPS", CodigoHttp = 200, Titulo = "Painel do roteador", CertificadoNome = "roteador.escritorio.example", CertificadoEmissor = "roteador.escritorio.example", CertificadoValidade = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero) }, + ], + 5 => + [ + new() { Porta = 0, Protocolo = "UPnP", Modelo = "Impressora da recepção (Exemplo Print P-200)" }, + new() { Porta = 80, Protocolo = "HTTP", CodigoHttp = 200, Titulo = "Impressora P-200", Servidor = "exemplo-httpd/1.0" }, + ], + 12 => [new() { Porta = 22, Protocolo = "SSH", Banner = "SSH-2.0-OpenSSH_9.2p1" }, new() { Porta = 80, Protocolo = "HTTP", CodigoHttp = 200, Titulo = "Painel de senhas" }], + 57 => [new() { Porta = 0, Protocolo = "UPnP", Modelo = "Servidor de arquivos (Exemplo NAS N-4)" }], + 142 => [new() { Porta = 8000, Protocolo = "HTTP", CodigoHttp = 200, Titulo = "Gravador de câmeras", Servidor = "exemplo-nvr" }], + _ => [], + }; + /// /// Varredura de mentira: anda pelos 254 endereços em uns 4 segundos e relata os hosts de /// exemplo. Com as portas ligadas, preenche as portas de exemplo, com a mesma regra de quem @@ -293,6 +313,19 @@ private static async Task VarrerAsync(InterfaceRede i, Opcoe progresso.Report(new ProgressoVarredura(EtapaPortas.Nome, n + 1, alvos.Count, h)); await Task.Delay(120, CancellationToken.None).ConfigureAwait(true); } + + if (opcoes.IdentificarServicos) + { + resultado.IdentificacaoFeita = true; + for (var n = 0; n < alvos.Count; n++) + { + var h = alvos[n]; + h.Servicos = ServicosExemplo(h.Ip.GetAddressBytes()[3]).Where(s => s.Porta == 0 || h.PortasAbertas.Contains(s.Porta)).ToList(); + h.ServicosIdentificados = true; + progresso.Report(new ProgressoVarredura(EtapaIdentificacao.Nome, n + 1, alvos.Count, h)); + await Task.Delay(80, CancellationToken.None).ConfigureAwait(true); + } + } } resultado.Fim = DateTimeOffset.Now; diff --git a/src/mapnet.nucleo/painel/detalhe-host.cs b/src/mapnet.nucleo/painel/detalhe-host.cs index d6acd7c..9803baa 100644 --- a/src/mapnet.nucleo/painel/detalhe-host.cs +++ b/src/mapnet.nucleo/painel/detalhe-host.cs @@ -85,6 +85,10 @@ public static string TextoPortas(HostEncontrado h) => ? (h.PortasAbertas.Count > 0 ? ListaPortas.Texto(h.PortasAbertas) : "nenhuma das portas verificadas está aberta") : h.MotivoSemPortas ?? "não verificadas nesta varredura"; + /// Uma linha por serviço, para o detalhe e o relatório. + public static string TextoServicos(HostEncontrado h) => + h.Servicos.Count > 0 ? string.Join(Environment.NewLine, h.Servicos.Select(s => s.Texto)) : "nenhum serviço se identificou"; + /// Pista do sistema pelo TTL da resposta ao ping. public static string? PistaDoTtl(int? ttl) => ttl switch { @@ -116,6 +120,10 @@ public static IReadOnlyList Itens(HostEncontrado h) itens.Add(new("ARP", h.RespondeuArp ? "respondeu" : h.EhEsteComputador ? "não se aplica (este computador)" : "não respondeu")); itens.Add(new("Portas abertas", TextoPortas(h))); + if (h.ServicosIdentificados) + { + itens.Add(new("Serviços", TextoServicos(h))); + } if (h.Marcas.Count > 0) { itens.Add(new("Observação", string.Join(", ", h.Marcas))); diff --git a/src/mapnet.nucleo/painel/linha-host.cs b/src/mapnet.nucleo/painel/linha-host.cs index 63e7189..ece0426 100644 --- a/src/mapnet.nucleo/painel/linha-host.cs +++ b/src/mapnet.nucleo/painel/linha-host.cs @@ -39,6 +39,12 @@ public LinhaHost(HostEncontrado host) public string Observacao => string.Join(", ", _host.Marcas); + /// O texto mais útil que os serviços deram: modelo, título da página, certificado ou banner. + public string Servico => _host.ServicoResumo; + + /// Dica da coluna Serviço: uma linha por serviço identificado. + public string? ServicoDica => _host.Servicos.Count > 0 ? string.Join(Environment.NewLine, _host.Servicos.Select(s => s.Texto)) : null; + /// Portas abertas, só os números: "22, 80, 443". public string Portas => _host.PortasTexto; @@ -80,6 +86,7 @@ public bool Contem(string texto) || (macBusca.Length > 0 && Mac.Replace(":", string.Empty).Contains(macBusca, StringComparison.OrdinalIgnoreCase)) || Fabricante.Contains(t, StringComparison.OrdinalIgnoreCase) || Observacao.Contains(t, StringComparison.OrdinalIgnoreCase) + || _host.Servicos.Any(s => s.Texto.Contains(t, StringComparison.OrdinalIgnoreCase)) || _host.PortasAbertas.Any(p => p.ToString(System.Globalization.CultureInfo.InvariantCulture) == t); } diff --git a/src/mapnet.nucleo/painel/painel-varredura.cs b/src/mapnet.nucleo/painel/painel-varredura.cs index 0fed974..756b9c0 100644 --- a/src/mapnet.nucleo/painel/painel-varredura.cs +++ b/src/mapnet.nucleo/painel/painel-varredura.cs @@ -637,16 +637,36 @@ public bool PortasEmMacAleatorio } } + /// Identifica os serviços das portas abertas. Marcado ao abrir, e só vale com as portas ligadas. + public bool IdentificarServicos + { + get => _dep.Opcoes.IdentificarServicos; + set + { + if (_dep.Opcoes.IdentificarServicos == value || !PodeTrocarInterface) + { + return; + } + + _dep.Opcoes.IdentificarServicos = value; + Avisar(); + } + } + public string DicaPortasPadrao => "Portas da lista padrão: " + ListaPortas.Texto(ListaPortas.Padrao); /// /// Pergunta feita antes da primeira verificação de portas em cada sub-rede. Diz o que vai ser /// enviado e lembra da autorização, como recomenda a análise jurídica de 26/09/2026. /// - public static string PerguntaPortas(SubRede subRede, int quantidade, bool incluiMacAleatorio) => + public static string PerguntaPortas(SubRede subRede, int quantidade, bool incluiMacAleatorio, bool identificar = false) => $"Verificar portas em {subRede}?\n\n" + $"Para cada equipamento encontrado, o MapNet abre e fecha uma conexão TCP em {quantidade} porta(s), sem enviar dados. " + "Não testa senha nem explora falha.\n\n" + + (identificar + ? "Com Identificar serviços, nas portas que estiverem abertas ele também pede a página inicial dos serviços web (só GET, sem senha), " + + "lê o certificado HTTPS e a primeira linha de SSH, FTP e SMTP, e faz uma busca UPnP na rede local.\n\n" + : string.Empty) + (incluiMacAleatorio ? "Os aparelhos com MAC aleatório, quase sempre celulares e notebooks pessoais, também entram.\n\n" : "Os aparelhos com MAC aleatório, quase sempre pessoais, ficam de fora.\n\n") @@ -792,6 +812,10 @@ public async Task VarrerAsync() if (_dep.Opcoes.OlharPortas) { Console.Escrever($"Portas: {_dep.Opcoes.Portas.Count} porta(s) TCP em cada host encontrado, só abrindo e fechando a conexão."); + if (_dep.Opcoes.IdentificarServicos) + { + Console.Escrever("Serviços: página inicial web, certificado HTTPS, banner de SSH, FTP e SMTP e busca UPnP, só nas portas abertas."); + } } if (aviso != null) @@ -870,13 +894,13 @@ private bool PrepararPortas(InterfaceRede interfaceRede) _dep.Opcoes.Portas = portas; var subRede = Varredor.SubRedeAVarrer(interfaceRede, _dep.PrefixoMinimo, out _); - var chave = $"{subRede}|{_dep.Opcoes.PortasEmMacAleatorio}"; + var chave = $"{subRede}|{_dep.Opcoes.PortasEmMacAleatorio}|{_dep.Opcoes.IdentificarServicos}"; if (_redesConfirmadas.Contains(chave)) { return true; } - if (_dep.Confirmar?.Invoke(PerguntaPortas(subRede, portas.Count, _dep.Opcoes.PortasEmMacAleatorio)) != true) + if (_dep.Confirmar?.Invoke(PerguntaPortas(subRede, portas.Count, _dep.Opcoes.PortasEmMacAleatorio, _dep.Opcoes.IdentificarServicos)) != true) { Console.Escrever("Varredura não iniciada: a verificação de portas não foi confirmada."); return false; diff --git a/src/mapnet.nucleo/relatorios/relatorio-html.cs b/src/mapnet.nucleo/relatorios/relatorio-html.cs index 78f3f55..d4834ed 100644 --- a/src/mapnet.nucleo/relatorios/relatorio-html.cs +++ b/src/mapnet.nucleo/relatorios/relatorio-html.cs @@ -100,6 +100,7 @@ public static string Gerar(ResultadoVarredura r) DNS{{C(i.Dns.Count > 0 ? string.Join(", ", i.Dns) : "não configurado")}} Início{{C(Data(r.Inicio))}} Portas verificadas{{C(r.PortasVerificadas is { } portas ? $"{portas.Count} porta(s) TCP, só abrindo e fechando a conexão: {ListaPortas.Texto(portas)}" : "não verificadas nesta varredura")}} + Identificação de serviços{{C(r.IdentificacaoFeita ? "feita nas portas abertas: página inicial web, certificado HTTPS, banner de SSH, FTP e SMTP e busca UPnP" : "não feita nesta varredura")}} Duração{{C(Duracao(r.Duracao))}}{{(r.Cancelada ? " (interrompida)" : "")}} Computador{{C(r.NomeComputador)}} @@ -120,6 +121,7 @@ public static string Gerar(ResultadoVarredura r) Fabricante Ping (ms) Portas + Serviço Observação @@ -148,7 +150,7 @@ public static string Gerar(ResultadoVarredura r)
Relatório gerado pelo MapNet - MT {{C(ResultadoVarredura.VersaoPrograma)}}, da MT - Manfred Tecnologia. - Levantamento de inventário por ping, ARP e consultas de nome{{(r.PortasVerificadas is null ? "" : ", com verificação de portas por conexão TCP")}}. O programa não testa senha nem explora falha. + Levantamento de inventário por ping, ARP e consultas de nome{{(r.PortasVerificadas is null ? "" : ", com verificação de portas por conexão TCP")}}{{(r.IdentificacaoFeita ? " e identificação leve dos serviços" : "")}}. O programa não testa senha nem explora falha.
@@ -178,7 +180,7 @@ private static void AcrescentarMaquina(StringBuilder html, IReadOnlyList s.Texto))) .ToLowerInvariant(); var classe = h.EhGateway ? " class=\"host gateway\"" : h.EhEsteComputador ? " class=\"host proprio\"" : " class=\"host\""; @@ -189,10 +191,11 @@ private static void AcrescentarHost(StringBuilder html, HostEncontrado h) html.Append($"{C(h.Fabricante)}"); html.Append($"{(h.TempoPingMs.HasValue ? h.TempoPingMs.Value.ToString(_ptBr) : "-")}"); html.Append($" 0 ? ListaPortas.Texto(h.PortasAbertas) : null)}\">{C(h.PortasTexto)}"); + html.Append($" s.Texto)))}\">{C(h.ServicoResumo)}"); html.Append($"{string.Concat(marcas.Select(m => $"{C(m)}"))}"); html.Append(""); - html.Append("
"); + html.Append("
"); Item(html, "IP", h.Ip.ToString()); Item(html, "MAC", h.MacTexto.Length > 0 ? h.MacTexto : "não obtido"); Item(html, "Fabricante", h.Fabricante.Length > 0 ? h.Fabricante : "não identificado"); @@ -204,6 +207,11 @@ private static void AcrescentarHost(StringBuilder html, HostEncontrado h) Item(html, "Ping", h.RespondeuPing ? $"respondeu em {h.TempoPingMs} ms" + (h.Ttl.HasValue ? $", TTL {h.Ttl}" : "") : "não respondeu"); Item(html, "ARP", h.RespondeuArp ? "respondeu" : h.EhEsteComputador ? "não se aplica (este computador)" : "não respondeu"); Item(html, "Portas abertas", DetalheHost.TextoPortas(h)); + if (h.ServicosIdentificados) + { + Item(html, "Serviços", DetalheHost.TextoServicos(h)); + } + html.Append("
"); } @@ -260,7 +268,7 @@ public static string Duracao(TimeSpan duracao) tr.detalhe td { background: #fbfdf9; } dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 6px 16px; margin: 4px 0; } dt { font-size: 12px; color: var(--suave); } - dd { margin: 0; word-break: break-word; } + dd { margin: 0; word-break: break-word; white-space: pre-line; } .num { text-align: right; } .mono { font-family: Consolas, "Courier New", monospace; } .etiqueta { display: inline-block; font-size: 12px; background: #e5f3dc; color: var(--destaque); border-radius: 10px; padding: 1px 8px; margin: 1px 4px 1px 0; white-space: nowrap; } diff --git a/src/mapnet.nucleo/varredura/modelos.cs b/src/mapnet.nucleo/varredura/modelos.cs index 9623abd..6229b13 100644 --- a/src/mapnet.nucleo/varredura/modelos.cs +++ b/src/mapnet.nucleo/varredura/modelos.cs @@ -39,6 +39,13 @@ public sealed class OpcoesVarredura public int ConexoesPorHost { get; set; } = 8; public int TempoPortaMs { get; set; } = 800; + + /// Identifica os serviços das portas abertas. Só vale com . Ligado por padrão, decisão de 26/09/2026. + public bool IdentificarServicos { get; set; } = true; + + public int TempoIdentificacaoMs { get; set; } = 3000; + + public int IdentificacoesSimultaneas { get; set; } = 16; } /// Andamento da varredura, para barra de progresso e mensagem na tela. @@ -89,6 +96,23 @@ public sealed class HostEncontrado /// Por que as portas deste host não foram verificadas. Null quando foram, ou quando a etapa não rodou. public string? MotivoSemPortas { get; set; } + /// Serviços identificados nas portas abertas, em ordem de porta, com o UPnP primeiro. + public IReadOnlyList Servicos { get; set; } = []; + + public bool ServicosIdentificados { get; set; } + + /// + /// O texto mais útil para a tabela: modelo pelo UPnP, título da página, nome do certificado, + /// banner e, por fim, o servidor. Título de página de erro (403, 404...) não serve de resumo. + /// + public string ServicoResumo => + Servicos.Select(s => s.Modelo).FirstOrDefault(t => t != null) + ?? Servicos.Where(s => s.CodigoHttp is null or < 400).Select(s => s.Titulo).FirstOrDefault(t => t != null) + ?? Servicos.Select(s => s.CertificadoNome).FirstOrDefault(t => t != null) + ?? Servicos.Select(s => s.Banner).FirstOrDefault(t => t != null) + ?? Servicos.Select(s => s.Servidor).FirstOrDefault(t => t != null) + ?? string.Empty; + /// Portas para a tabela: "22, 80, 443". public string PortasTexto => string.Join(", ", PortasAbertas); @@ -156,6 +180,9 @@ public sealed class ResultadoVarredura /// Lista de portas que a etapa de portas usou. Null quando a etapa não rodou. public IReadOnlyList? PortasVerificadas { get; set; } + /// A etapa de identificação de serviços rodou. + public bool IdentificacaoFeita { get; set; } + public string NomeComputador { get; init; } = Environment.MachineName; /// Dados da coluna "Minha máquina" para a seção do relatório. Null quando não foram lidos. diff --git a/src/mapnet.nucleo/varredura/varredor.cs b/src/mapnet.nucleo/varredura/varredor.cs index 4812605..42d3b05 100644 --- a/src/mapnet.nucleo/varredura/varredor.cs +++ b/src/mapnet.nucleo/varredura/varredor.cs @@ -18,13 +18,15 @@ public sealed class Varredor private readonly TabelaOui _oui; private readonly ISondaArp? _arp; private readonly ISondaPorta _portas; + private readonly IIdentificador _identificador; - public Varredor(OpcoesVarredura? opcoes = null, TabelaOui? oui = null, ISondaArp? arp = null, ISondaPorta? portas = null) + public Varredor(OpcoesVarredura? opcoes = null, TabelaOui? oui = null, ISondaArp? arp = null, ISondaPorta? portas = null, IIdentificador? identificador = null) { _opcoes = opcoes ?? new OpcoesVarredura(); _oui = oui ?? TabelaOui.Embutida; _arp = _opcoes.UsarArp ? arp ?? SondaArp.Padrao() : null; _portas = portas ?? new SondaPortaTcp(); + _identificador = identificador ?? new IdentificadorRede(); } /// @@ -106,6 +108,19 @@ public async Task VarrerAsync( resultado.Cancelada = true; } + if (!resultado.Cancelada && _opcoes.IdentificarServicos) + { + resultado.IdentificacaoFeita = true; + try + { + await EtapaIdentificacao.IdentificarAsync(resultado.Hosts, interfaceRede.Ip, _opcoes, _identificador, progresso, cancelamento).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + resultado.Cancelada = true; + } + } + var fora = resultado.Hosts.Count(h => h.MotivoSemPortas == EtapaPortas.MotivoMacAleatorio); if (fora > 0) { diff --git a/src/mapnet/app.manifest b/src/mapnet/app.manifest index 887b182..dde660e 100644 --- a/src/mapnet/app.manifest +++ b/src/mapnet/app.manifest @@ -1,6 +1,6 @@ - + diff --git a/src/mapnet/janela-principal.xaml b/src/mapnet/janela-principal.xaml index f94307e..71ca9d5 100644 --- a/src/mapnet/janela-principal.xaml +++ b/src/mapnet/janela-principal.xaml @@ -143,6 +143,9 @@ + @@ -201,23 +204,30 @@ --> - - - - + - + + + + + diff --git a/src/mapnet/modo-linha-de-comando.cs b/src/mapnet/modo-linha-de-comando.cs index f9966f4..3bcc384 100644 --- a/src/mapnet/modo-linha-de-comando.cs +++ b/src/mapnet/modo-linha-de-comando.cs @@ -119,6 +119,10 @@ private static async Task VarrerAsync(ArgumentosCli argumentos) { Console.WriteLine($"Portas: {argumentos.Opcoes.Portas.Count} porta(s) TCP em cada host, só abrindo e fechando a conexão" + (argumentos.Opcoes.PortasEmMacAleatorio ? ", inclusive nos aparelhos com MAC aleatório" : "")); + if (argumentos.Opcoes.IdentificarServicos) + { + Console.WriteLine("Serviços: página inicial web, certificado HTTPS, banner de SSH, FTP e SMTP e busca UPnP, só nas portas abertas"); + } } Console.WriteLine(); @@ -137,7 +141,8 @@ private static async Task VarrerAsync(ArgumentosCli argumentos) { var marcas = h.Marcas.Count > 0 ? $" [{string.Join(", ", h.Marcas)}]" : string.Empty; var portas = h.PortasAbertas.Count > 0 ? $" portas {h.PortasTexto}" : string.Empty; - Console.WriteLine($" {h.Ip,-15} {h.MacTexto,-17} {Cortar(h.Fabricante, 28),-28} {h.Nome}{marcas}{portas}"); + var servico = h.ServicoResumo.Length > 0 ? $" ({Cortar(h.ServicoResumo, 50)})" : string.Empty; + Console.WriteLine($" {h.Ip,-15} {h.MacTexto,-17} {Cortar(h.Fabricante, 28),-28} {h.Nome}{marcas}{portas}{servico}"); } // Na linha de comando não há clique, então o relatório sai sem o IP público. diff --git a/testes/mapnet.testes/identificacao-testes.cs b/testes/mapnet.testes/identificacao-testes.cs new file mode 100644 index 0000000..dd90aa0 --- /dev/null +++ b/testes/mapnet.testes/identificacao-testes.cs @@ -0,0 +1,479 @@ +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using MapNet.Nucleo; + +namespace MapNet.Testes; + +/// +/// Identificação leve: leitura de HTTP, certificado, banner e UPnP, limpeza do texto que vem da +/// rede, a etapa com seus limites e o que aparece na tabela, no detalhe e no relatório. +/// +public class IdentificacaoTestes +{ + [Theory] + [InlineData("Painel do roteador", "Painel do roteador")] + [InlineData("\n Impressão & digitalização \r\n", "Impressão & digitalização")] + [InlineData("Câmera – entrada", "Câmera \u2013 entrada")] + [InlineData("sem título", null)] + public void Titulo_da_pagina(string html, string? esperado) + { + Assert.Equal(esperado, RespostaHttp.Titulo(html)); + } + + [Fact] + public void Resposta_http_traz_codigo_servidor_e_localizacao() + { + var dados = RespostaHttp.Interpretar(Encoding.ASCII.GetBytes( + "HTTP/1.1 302 Found\r\nServer: lighttpd/1.4\r\nLocation: https://192.0.2.1/login\r\n\r\n")); + + Assert.Equal(302, dados.Codigo); + Assert.Equal("lighttpd/1.4", dados.Servidor); + Assert.Equal("https://192.0.2.1/login", dados.Localizacao); + } + + [Fact] + public void Charset_do_cabecalho_e_da_pagina_sao_respeitados() + { + var latin = Encoding.Latin1; + var cabecalho = latin.GetBytes("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=ISO-8859-1\r\n\r\nConfiguração"); + var meta = latin.GetBytes("HTTP/1.1 200 OK\r\n\r\nCâmera"); + + Assert.Equal("Configuração", RespostaHttp.Interpretar(cabecalho).Titulo); + Assert.Equal("Câmera", RespostaHttp.Interpretar(meta).Titulo); + } + + [Fact] + public void Texto_da_rede_perde_controle_e_e_cortado() + { + Assert.Equal("a b c", TextoRede.Limpar("a\u0000\u001b[31m b\t\tc\r\n".Replace("[31m", string.Empty))); + Assert.Equal(TextoRede.Maximo, TextoRede.Limpar(new string('x', 5000))!.Length); + Assert.Null(TextoRede.Limpar(" \r\n\t ")); + Assert.Equal("ab", TextoRede.Limpar("a\u200Bb")); + } + + [Fact] + public void Resposta_maior_que_o_limite_e_cortada() + { + var grande = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\n\r\n" + new string('x', 100_000) + "fora"); + + Assert.Null(RespostaHttp.Interpretar(grande).Titulo); + } + + [Fact] + public void Ssdp_le_servidor_e_localizacao() + { + var r = Ssdp.Interpretar(IPAddress.Parse("192.0.2.5"), Encoding.ASCII.GetBytes( + "HTTP/1.1 200 OK\r\nSERVER: Linux/5 UPnP/1.0 Exemplo/1\r\nLOCATION: http://192.0.2.5:49152/desc.xml\r\nST: upnp:rootdevice\r\n\r\n")); + + Assert.NotNull(r); + Assert.Equal("Linux/5 UPnP/1.0 Exemplo/1", r!.Servidor); + Assert.Equal(new Uri("http://192.0.2.5:49152/desc.xml"), Ssdp.DescricaoDoProprio(r)); + Assert.Null(Ssdp.Interpretar(IPAddress.Parse("192.0.2.5"), Encoding.ASCII.GetBytes("NOTIFY * HTTP/1.1\r\n\r\n"))); + } + + [Theory] + [InlineData("http://192.0.2.9:49152/desc.xml")] + [InlineData("https://192.0.2.5/desc.xml")] + [InlineData("http://exemplo.example/desc.xml")] + [InlineData("nao e endereco")] + public void Descricao_so_e_lida_do_proprio_equipamento_por_http(string local) + { + Assert.Null(Ssdp.DescricaoDoProprio(new RespostaSsdp(IPAddress.Parse("192.0.2.5"), null, local))); + } + + [Fact] + public void Descricao_upnp_da_o_modelo() + { + const string xml = """ + + + NAS da salaExemploN-4 + + """; + + Assert.Equal("NAS da sala (Exemplo N-4)", Ssdp.Modelo(xml)); + } + + [Fact] + public void Descricao_com_dtd_e_recusada() + { + const string xml = """ + + ]> + &x; + """; + + Assert.Null(Ssdp.Modelo(xml)); + } + + [Fact] + public async Task Http_de_verdade_le_titulo_e_servidor() + { + await using var servidor = ServidorLocal.Iniciar(async (fluxo, pedido) => + { + Assert.StartsWith("GET / HTTP/1.1", pedido); + Assert.Contains("User-Agent: MapNet-MT/", pedido); + await fluxo.WriteAsync(Encoding.UTF8.GetBytes("HTTP/1.1 200 OK\r\nServer: exemplo\r\nContent-Type: text/html; charset=utf-8\r\n\r\nPainel")); + }); + + var s = await new IdentificadorRede().IdentificarAsync(IPAddress.Loopback, servidor.Porta, ProtocoloServico.Http, 3000, CancellationToken.None); + + Assert.NotNull(s); + Assert.Equal("Painel", s!.Titulo); + Assert.Equal("exemplo", s.Servidor); + } + + [Fact] + public async Task Https_le_certificado_vencido_e_autoassinado() + { + using var certificado = Certificado("painel.example", DateTimeOffset.Now.AddYears(-2), DateTimeOffset.Now.AddYears(-1)); + await using var servidor = ServidorLocal.Iniciar(async (fluxo, _) => + await fluxo.WriteAsync(Encoding.UTF8.GetBytes("HTTP/1.1 200 OK\r\n\r\nSeguro")), certificado); + + var s = await new IdentificadorRede().IdentificarAsync(IPAddress.Loopback, servidor.Porta, ProtocoloServico.Https, 5000, CancellationToken.None); + + Assert.NotNull(s); + Assert.Equal("painel.example", s!.CertificadoNome); + Assert.True(s.CertificadoValidade < DateTimeOffset.Now); + Assert.Equal("Seguro", s.Titulo); + } + + [Fact] + public async Task Banner_so_le_e_nao_envia_nada() + { + var recebidos = -1; + await using var servidor = ServidorLocal.Banner("SSH-2.0-OpenSSH_9.2\r\n", n => recebidos = n); + + var s = await new IdentificadorRede().IdentificarAsync(IPAddress.Loopback, servidor.Porta, ProtocoloServico.Banner, 3000, CancellationToken.None); + await servidor.DisposeAsync(); + + Assert.Equal("SSH-2.0-OpenSSH_9.2", s!.Banner); + Assert.Equal(0, recebidos); + } + + [Fact] + public async Task Servidor_mudo_termina_sem_servico() + { + await using var servidor = ServidorLocal.Banner(null, _ => { }); + + var s = await new IdentificadorRede().IdentificarAsync(IPAddress.Loopback, servidor.Porta, ProtocoloServico.Banner, 300, CancellationToken.None); + + Assert.Null(s); + } + + [Theory] + [InlineData(80, ProtocoloServico.Http)] + [InlineData(8000, ProtocoloServico.Http)] + [InlineData(8443, ProtocoloServico.Https)] + [InlineData(22, ProtocoloServico.Banner)] + [InlineData(23, null)] + [InlineData(445, null)] + public void Protocolo_de_cada_porta(int porta, ProtocoloServico? esperado) + { + Assert.Equal(esperado, EtapaIdentificacao.Protocolo(porta)); + } + + [Fact] + public async Task Etapa_so_conversa_com_portas_abertas_de_hosts_verificados() + { + var verificado = Host("192.0.2.1", [22, 23, 80, 445]); + var fora = Host("192.0.2.2", []); + fora.PortasAbertas = [80]; + var falso = new IdentificadorFalso + { + Upnp = + [ + new(IPAddress.Parse("192.0.2.1"), "Exemplo UPnP", "http://192.0.2.1:49152/d.xml"), + new(IPAddress.Parse("192.0.2.1"), "Exemplo UPnP", "http://192.0.2.1:49152/d.xml"), + new(IPAddress.Parse("192.0.2.2"), "Outro", null), + new(IPAddress.Parse("198.51.100.7"), "De fora", null), + ], + }; + + await EtapaIdentificacao.IdentificarAsync([verificado, fora], IPAddress.Parse("192.0.2.10"), new OpcoesVarredura(), falso, null, CancellationToken.None); + + Assert.Equal([(IPAddress.Parse("192.0.2.1"), 22), (IPAddress.Parse("192.0.2.1"), 80)], falso.Pedidos.OrderBy(p => p.Porta)); + Assert.Equal([0, 22, 80], verificado.Servicos.Select(s => s.Porta)); + Assert.Equal("NAS (Exemplo N-4)", verificado.ServicoResumo); + Assert.True(verificado.ServicosIdentificados); + Assert.Empty(fora.Servicos); + Assert.False(fora.ServicosIdentificados); + } + + [Fact] + public async Task Etapa_respeita_o_limite_de_simultaneas() + { + var hosts = Enumerable.Range(1, 30).Select(n => Host($"192.0.2.{n}", [80, 443])).ToList(); + var falso = new IdentificadorFalso { EsperaMs = 10 }; + + await EtapaIdentificacao.IdentificarAsync(hosts, IPAddress.Parse("192.0.2.200"), new OpcoesVarredura { IdentificacoesSimultaneas = 5 }, falso, null, CancellationToken.None); + + Assert.Equal(60, falso.Pedidos.Count); + Assert.InRange(falso.MaximoJuntas, 1, 5); + } + + [Fact] + public async Task Cancelar_para_a_identificacao() + { + var hosts = Enumerable.Range(1, 30).Select(n => Host($"192.0.2.{n}", [80])).ToList(); + using var cancelamento = new CancellationTokenSource(); + var falso = new IdentificadorFalso { EsperaMs = 20, AoIdentificar = cancelamento.Cancel }; + + await Assert.ThrowsAnyAsync(() => + EtapaIdentificacao.IdentificarAsync(hosts, IPAddress.Parse("192.0.2.200"), new OpcoesVarredura(), falso, null, cancelamento.Token)); + + Assert.True(falso.Pedidos.Count < 30); + } + + [Fact] + public void Tabela_detalhe_e_relatorio_mostram_os_servicos_codificados() + { + var h = Host("192.0.2.5", [80]); + h.Servicos = [new ServicoIdentificado { Porta = 80, Protocolo = "HTTP", Titulo = "", Servidor = "exemplo" }]; + h.ServicosIdentificados = true; + var linha = new LinhaHost(h); + + Assert.Equal("", linha.Servico); + Assert.Equal("80 HTTP: título \"\", servidor exemplo", linha.ServicoDica); + Assert.True(linha.Contem("exemplo")); + Assert.Equal(linha.ServicoDica, DetalheHost.Itens(h).Single(i => i.Rotulo == "Serviços").Valor); + + var r = Resultado(h); + r.IdentificacaoFeita = true; + var html = RelatorioHtml.Gerar(r); + Assert.DoesNotContain("