From 38b78da0cf1e6df7744999cca6b31eb1d3f3a3c1 Mon Sep 17 00:00:00 2001 From: Manfred Heil Junior Date: Sat, 26 Sep 2026 13:39:57 -0300 Subject: [PATCH 1/3] Grava o relatorio tambem em CSV e XML Fatia 10, que fecha a versao 1.0 e sai como v1.0.0. O CSV tem uma linha por host, com ponto e virgula e BOM para o Excel em portugues abrir certo, e poe um apostrofo na frente de campo que comeca com =, +, - ou @, para texto vindo da rede nao virar formula. O XML traz tudo o que o HTML mostra e tira o caractere que o XML recusa. Na janela, Salvar relatorio oferece os tres tipos. Na linha de comando, --formato escolhe html, csv, xml ou todos, e arquivo com extensao em --saida define o formato. Conferido na rede do Manfred: os tres arquivos com os mesmos 14 hosts. Autores: Manfred Heil Junior --- Directory.Build.props | 2 +- README.md | 12 +- docs/superpowers/pendencias.md | 7 + .../2026-09-26-mapnet-1.0-plano-geral.md | 18 +- public/index.html | 2 +- .../linha-de-comando/argumentos.cs | 61 ++++- src/mapnet.nucleo/painel/demonstracao.cs | 2 +- src/mapnet.nucleo/painel/painel-varredura.cs | 2 +- .../relatorios/relatorio-dados.cs | 226 ++++++++++++++++++ src/mapnet/app.manifest | 2 +- src/mapnet/janela-principal.xaml.cs | 15 +- src/mapnet/modo-linha-de-comando.cs | 27 +-- .../mapnet.testes/relatorios-dados-testes.cs | 190 +++++++++++++++ 13 files changed, 532 insertions(+), 34 deletions(-) create mode 100644 src/mapnet.nucleo/relatorios/relatorio-dados.cs create mode 100644 testes/mapnet.testes/relatorios-dados-testes.cs diff --git a/Directory.Build.props b/Directory.Build.props index d93c178..b49bce1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ enable enable true - 0.5.0 + 1.0.0 Manfred Heil Junior MT - Manfred Tecnologia MapNet - MT diff --git a/README.md b/README.md index 741bfc4..25d8750 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MapNet - MT -Scanner de inventário de rede para Windows, da MT - Manfred Tecnologia. Roda na rede do cliente e mostra quais equipamentos estão ligados nela, com IP, MAC, fabricante e nome, num relatório HTML que abre em qualquer navegador. +Scanner de inventário de rede para Windows, da MT - Manfred Tecnologia. Roda na rede do cliente e mostra quais equipamentos estão ligados nela, com IP, MAC, fabricante, nome, tipo provável e, se você pedir, portas abertas e serviços, num relatório em HTML, CSV ou XML. Página do programa: **https://mapnet.manfred.com.br**. Download da versão mais recente: [mapnet.exe](https://github.com/manfredjr/mapnet/releases/latest/download/mapnet.exe). @@ -27,7 +27,7 @@ As regras do projeto estão no [`AGENTS.md`](AGENTS.md) e o desenho em [`docs/su | 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-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 | Classificação por tipo de equipamento, com os motivos à vista (sai como v0.5.0) | `classificacao` | [#35](https://github.com/manfredjr/mapnet/pull/35) | Em revisão | -| 10 | Relatório também em XML e CSV (sai como v1.0.0) | a definir | - | A fazer | +| 10 | Relatório também em CSV e XML, pela janela e pela linha de comando (sai como v1.0.0) | `relatorios` | [PREENCHER] | Em revisão | 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). @@ -45,7 +45,7 @@ O programa é um arquivo só, `mapnet.exe`. Não precisa instalar nada no comput 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. +7. No fim, clique em **Salvar relatório**, escolha onde gravar e o tipo: HTML, para abrir no navegador; CSV, para o Excel; ou XML, para outro programa ler. 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. 8. A aba **Manutenção** tem quatro botões, que rodam os comandos oficiais do Windows e mostram a saída ali mesmo: **Limpar cache DNS** (`ipconfig /flushdns`), **Liberar e renovar IP** (`ipconfig /release` e `/renew`), **Limpar tabela ARP** (`netsh interface ip delete arpcache`) e **Resetar Winsock e TCP/IP** (`netsh winsock reset` e `netsh int ip reset`). O programa continua abrindo como usuário comum. Quando a ação pede administrador, o Windows mostra a tela do UAC só para aquele comando. Renovar o IP e resetar o Winsock derrubam a rede por alguns segundos e pedem confirmação antes. O reset só vale depois de reiniciar o computador. ### Pela linha de comando @@ -61,9 +61,10 @@ mapnet --ajuda | Opção | Para que serve | |---|---| | `--interfaces` | Lista as interfaces ativas, numeradas | -| `--varrer` | Varre a sub-rede e grava o relatório HTML | +| `--varrer` | Varre a sub-rede e grava o relatório (HTML, ou o formato de `--formato`) | | `--interface ` | Escolhe a interface pelo número de `--interfaces`, pelo nome ou pelo IP | -| `--saida ` | Onde gravar o relatório. Padrão: a pasta atual | +| `--saida ` | Onde gravar o relatório. Padrão: a pasta atual. Arquivo terminado em `.html`, `.csv` ou `.xml` define o formato | +| `--formato ` | `html`, `csv`, `xml` ou `todos`. Padrão: `html`. Com `todos`, grava os três na pasta de `--saida` | | `--abrir` | Abre o relatório no navegador ao terminar | | `--tempo-ping ` | Espera de cada ping, de 100 a 10000. Padrão: 1000 | | `--paralelo ` | Endereços sondados ao mesmo tempo, de 1 a 256. Padrão: 64 | @@ -111,6 +112,7 @@ Se o valor bater com o do `.txt`, o executável é exatamente o que foi publicad - **Fabricante:** pelos três primeiros bytes do MAC, na tabela OUI do IEEE embutida no programa (35.084 prefixos). MAC com o bit de administração local ligado aparece como "MAC aleatório (privativo)", que é o caso do celular com endereço privado. - **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. +- **CSV e XML (desde a v1.0.0):** o CSV tem uma linha por host, com ponto e vírgula e acentos certos no Excel, e protege contra texto que viraria fórmula. O XML traz tudo o que o HTML mostra, para outro programa ler. - **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. - **Tipo provável (desde a v0.5.0):** roteador, impressora, câmera ou gravador, NAS, computador Windows, Linux, access point, TV ou mídia, videogame, celular ou tablet, automação ou desconhecido. É um palpite pelo que as outras etapas levantaram (fabricante, nomes, TTL, portas e serviços), sem mandar nada para a rede, e o detalhe mostra os motivos. diff --git a/docs/superpowers/pendencias.md b/docs/superpowers/pendencias.md index a25e0cc..283cf84 100644 --- a/docs/superpowers/pendencias.md +++ b/docs/superpowers/pendencias.md @@ -80,6 +80,13 @@ Fechado em parte na fatia 3 (0.2.2). No tamanho padrão da janela, IP, Origem, M | Tabela de sinais | Os fabricantes e as palavras que apontam cada tipo saíram do que é comum em escritório e do teste na rede do Manfred. Equipamento de marca que não está na lista cai em Desconhecido | Acrescentar fabricante ou palavra quando o técnico achar um caso errado no campo | | Switch gerenciável | Switch quase não se mostra: não costuma ter nome, porta aberta diferente nem UPnP. A fatia não arrisca esse tipo | Rever se aparecer sinal confiável, como título de página de switch conhecido | +## Fatia 10: relatório em CSV e XML + +| Item | Motivo | O que fecha | +|---|---|---| +| Esquema do XML | O XML tem o atributo `formato="1"` para mudar sem quebrar quem lê, mas não tem um XSD publicado | Publicar o esquema se alguém for integrar o XML a outro sistema | +| CSV no Excel com outro idioma | O ponto e vírgula é o separador do Excel em português. Num Excel em inglês, o arquivo abre numa coluna só | Importar pelo assistente de texto, ou oferecer vírgula como opção se fizer falta | + ## Página de privacidade | Item | Motivo | O que fecha | diff --git a/docs/superpowers/plans/2026-09-26-mapnet-1.0-plano-geral.md b/docs/superpowers/plans/2026-09-26-mapnet-1.0-plano-geral.md index f2f1020..d7bcd4d 100644 --- a/docs/superpowers/plans/2026-09-26-mapnet-1.0-plano-geral.md +++ b/docs/superpowers/plans/2026-09-26-mapnet-1.0-plano-geral.md @@ -156,4 +156,20 @@ Sai como v0.5.0. Escrita junto com o código, porque o Manfred pediu em 26/09/20 ### Testes -Cada tipo pelos seus sinais, o mínimo para arriscar, o sinal mais forte ganhando, a palavra inteira, este computador como Windows, e o tipo na tabela, no detalhe e no relatório. \ No newline at end of file +Cada tipo pelos seus sinais, o mínimo para arriscar, o sinal mais forte ganhando, a palavra inteira, este computador como Windows, e o tipo na tabela, no detalhe e no relatório. + +## Fatia 10: relatório em XML e CSV + +Sai como v1.0.0 e fecha a versão 1.0. Escrita junto com o código, como a fatia 9. + +### O que entra + +- **CSV**, uma linha por host, para abrir no Excel: IP, nome e origem, MAC, fabricante, tipo provável e motivos, ping, TTL, portas, serviço, serviços, observação e grupo NetBIOS. Separador ponto e vírgula, que é o do Excel em português, e UTF-8 com BOM, para os acentos abrirem certos. +- **Proteção contra fórmula:** campo que começa com `=`, `+`, `-` ou `@` ganha um apóstrofo na frente. Nome de equipamento vem da rede e não pode virar fórmula na planilha do cliente. +- **XML**, com tudo o que o HTML mostra (varredura, Minha máquina, avisos e cada host com nomes, ping, ARP, tipo e motivos, portas e serviços), para outro programa ler. Caractere que o XML recusa é tirado antes. +- **Janela:** o botão **Salvar relatório** oferece os três tipos na lista da janela de salvar. +- **Linha de comando:** `--formato html|csv|xml|todos`. Arquivo com extensão em `--saida` define o formato; com `todos`, `--saida` é a pasta dos três. + +### Testes + +XML válido e completo, texto da rede codificado, caractere inválido tirado; CSV com cabeçalho, aspas, ponto e vírgula e a proteção contra fórmula; BOM no CSV e não no XML; formato pela extensão; caminhos da linha de comando e os erros de formato. \ No newline at end of file diff --git a/public/index.html b/public/index.html index 09516b4..85afd0e 100644 --- a/public/index.html +++ b/public/index.html @@ -51,7 +51,7 @@

O que ele faz

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.

+

Relatório em HTML, CSV e XML

Para guardar com a documentação do cliente, abrir no Excel ou levar a outro programa.

Sem instalar nada

Um arquivo só, sem administrador e sem precisar de internet.

diff --git a/src/mapnet.nucleo/linha-de-comando/argumentos.cs b/src/mapnet.nucleo/linha-de-comando/argumentos.cs index 8292bb2..6551cf9 100644 --- a/src/mapnet.nucleo/linha-de-comando/argumentos.cs +++ b/src/mapnet.nucleo/linha-de-comando/argumentos.cs @@ -28,7 +28,10 @@ mapnet abre a janela Opções de --varrer: --interface interface pelo número de --interfaces ou por parte do nome (padrão: a primeira da lista, que é a que tem gateway) - --saida onde gravar o relatório (padrão: pasta atual) + --saida onde gravar o relatório (padrão: pasta atual). Arquivo + terminado em .html, .csv ou .xml define o formato + --formato html, csv, xml ou todos (padrão: html). Com todos, + grava os três na pasta de --saida --abrir abre o relatório no navegador ao terminar --tempo-ping espera de cada ping, de 100 a 10000 (padrão: 1000) --paralelo endereços sondados ao mesmo tempo, de 1 a 256 (padrão: 64) @@ -58,6 +61,9 @@ Use a verificação de portas só em rede que você tem autorização para verif public bool Abrir { get; private set; } + /// Formatos a gravar. Padrão: só o HTML. + public IReadOnlyList Formatos { get; private set; } = [FormatoRelatorio.Html]; + public OpcoesVarredura Opcoes { get; } = new(); public List Erros { get; } = []; @@ -69,6 +75,7 @@ public static ArgumentosCli Interpretar(IReadOnlyList args) var a = new ArgumentosCli(); var comandos = 0; var semIdentificar = false; + var formatoDado = false; for (var i = 0; i < args.Count; i++) { @@ -101,6 +108,25 @@ public static ArgumentosCli Interpretar(IReadOnlyList args) case "--abrir": a.Abrir = true; break; + case "--formato": + var formato = Valor(args, ref i, arg, a.Erros)?.Trim().ToLowerInvariant(); + IReadOnlyList? escolhidos = formato switch + { + null => null, + "html" => [FormatoRelatorio.Html], + "csv" => [FormatoRelatorio.Csv], + "xml" => [FormatoRelatorio.Xml], + "todos" => [FormatoRelatorio.Html, FormatoRelatorio.Csv, FormatoRelatorio.Xml], + _ => null, + }; + if (escolhidos is null && formato != null) + { + a.Erros.Add($"Formato desconhecido: {formato}. Use html, csv, xml ou todos."); + } + + a.Formatos = escolhidos ?? a.Formatos; + formatoDado = true; + break; case "--sem-arp": a.Opcoes.UsarArp = false; break; @@ -149,10 +175,23 @@ public static ArgumentosCli Interpretar(IReadOnlyList args) a.Erros.Add("Use só um comando por vez: --varrer, --interfaces, --ajuda ou --versao."); } - var opcoesDeVarredura = a.Interface != null || a.Saida != null || a.Abrir || a.Opcoes.OlharPortas; + var opcoesDeVarredura = a.Interface != null || a.Saida != null || a.Abrir || a.Opcoes.OlharPortas || formatoDado; if (opcoesDeVarredura && a.Comando == ComandoCli.Janela) { - a.Erros.Add("As opções --interface, --saida, --abrir e --portas pedem o comando --varrer."); + a.Erros.Add("As opções --interface, --saida, --formato, --abrir e --portas pedem o comando --varrer."); + } + + // Arquivo com extensão conhecida em --saida define o formato. Com mais de um formato, --saida tem que ser pasta. + if (a.Saida is { } saida && Path.HasExtension(saida) && Path.GetExtension(saida).ToLowerInvariant() is ".html" or ".htm" or ".csv" or ".xml") + { + if (a.Formatos.Count > 1) + { + a.Erros.Add("Com --formato todos, --saida tem que ser uma pasta."); + } + else + { + a.Formatos = [Relatorios.FormatoDe(saida)]; + } } if (a.Opcoes.PortasEmMacAleatorio && !a.Opcoes.OlharPortas) @@ -168,6 +207,22 @@ public static ArgumentosCli Interpretar(IReadOnlyList args) return a; } + /// + /// Arquivos a gravar, um por formato. Sem --saida, vão para a pasta atual; com uma pasta, para + /// ela; com um arquivo de extensão conhecida, é ele mesmo. + /// + public IReadOnlyList Caminhos(ResultadoVarredura resultado, string pastaAtual) + { + if (Saida is { } saida && Formatos.Count == 1 + && Path.GetExtension(saida).ToLowerInvariant() is ".html" or ".htm" or ".csv" or ".xml") + { + return [saida]; + } + + var pasta = string.IsNullOrWhiteSpace(Saida) ? pastaAtual : Saida; + return Formatos.Select(f => Path.Combine(pasta, Relatorios.NomeArquivo(resultado, f))).ToList(); + } + /// /// Escolhe a interface pelo número (a partir de 1, na ordem de --interfaces) ou por parte /// do nome ou da descrição. Sem seletor, fica a primeira da lista. diff --git a/src/mapnet.nucleo/painel/demonstracao.cs b/src/mapnet.nucleo/painel/demonstracao.cs index d208d70..e301332 100644 --- a/src/mapnet.nucleo/painel/demonstracao.cs +++ b/src/mapnet.nucleo/painel/demonstracao.cs @@ -47,7 +47,7 @@ public static DependenciasPainel Dependencias() Opcoes = opcoes, ListarInterfaces = () => [wifi, cabo], Varrer = (i, p, c) => VarrerAsync(i, opcoes, p, c), - SalvarEm = (r, caminho) => RelatorioHtml.SalvarAsync(r, caminho), + SalvarEm = (r, caminho) => Relatorios.SalvarAsync(r, caminho), // Documentos Públicos: o caminho não traz o nome do usuário, que apareceria nas imagens. PastaRelatorios = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonDocuments), "MapNet - MT", "demonstracao"), LerMaquina = i => Task.FromResult(LeitorMaquina.Ler(i, Fontes(), new OpcoesVarredura().PrefixoMinimo)), diff --git a/src/mapnet.nucleo/painel/painel-varredura.cs b/src/mapnet.nucleo/painel/painel-varredura.cs index 756b9c0..8d62b07 100644 --- a/src/mapnet.nucleo/painel/painel-varredura.cs +++ b/src/mapnet.nucleo/painel/painel-varredura.cs @@ -91,7 +91,7 @@ public static DependenciasPainel Padrao() Opcoes = opcoes, ListarInterfaces = LeitorInterfaces.Listar, Varrer = (i, p, c) => new Varredor(opcoes).VarrerAsync(i, p, c), - SalvarEm = (r, caminho) => RelatorioHtml.SalvarAsync(r, caminho), + SalvarEm = (r, caminho) => Relatorios.SalvarAsync(r, caminho), LerMaquina = i => Task.Run(() => LeitorMaquina.Ler(i, FontesMaquina.Padrao(), prefixo)), IpPublico = new IpPublicoCloudflare(), Ferramentas = FerramentasPadrao, diff --git a/src/mapnet.nucleo/relatorios/relatorio-dados.cs b/src/mapnet.nucleo/relatorios/relatorio-dados.cs new file mode 100644 index 0000000..63c563f --- /dev/null +++ b/src/mapnet.nucleo/relatorios/relatorio-dados.cs @@ -0,0 +1,226 @@ +using System.Globalization; +using System.Text; +using System.Xml; +using System.Xml.Linq; + +namespace MapNet.Nucleo; + +/// Formatos em que o relatório pode ser gravado. +public enum FormatoRelatorio +{ + Html, + Xml, + Csv, +} + +/// Escolhe o formato pela extensão do arquivo e grava. +public static class Relatorios +{ + public static string Extensao(FormatoRelatorio formato) => formato switch + { + FormatoRelatorio.Xml => ".xml", + FormatoRelatorio.Csv => ".csv", + _ => ".html", + }; + + /// .xml e .csv pelo nome; qualquer outra extensão vira HTML. + public static FormatoRelatorio FormatoDe(string caminho) => Path.GetExtension(caminho).ToLowerInvariant() switch + { + ".xml" => FormatoRelatorio.Xml, + ".csv" => FormatoRelatorio.Csv, + _ => FormatoRelatorio.Html, + }; + + /// Nome sugerido, como mapnet-20260926-1430-192-0-2-0-24.csv. + public static string NomeArquivo(ResultadoVarredura r, FormatoRelatorio formato) => + Path.ChangeExtension(RelatorioHtml.NomeArquivo(r), Extensao(formato)); + + public static async Task SalvarAsync(ResultadoVarredura r, string caminho, CancellationToken cancelamento = default) + { + var formato = FormatoDe(caminho); + if (formato == FormatoRelatorio.Html) + { + return await RelatorioHtml.SalvarAsync(r, caminho, cancelamento).ConfigureAwait(false); + } + + var pasta = Path.GetDirectoryName(Path.GetFullPath(caminho)); + if (!string.IsNullOrEmpty(pasta)) + { + Directory.CreateDirectory(pasta); + } + + // CSV com BOM, para o Excel abrir os acentos certos; XML sem, como é o costume. + var (texto, codificacao) = formato == FormatoRelatorio.Csv + ? (RelatorioCsv.Gerar(r), new UTF8Encoding(true)) + : (RelatorioXml.Gerar(r), new UTF8Encoding(false)); + await File.WriteAllTextAsync(caminho, texto, codificacao, cancelamento).ConfigureAwait(false); + return Path.GetFullPath(caminho); + } +} + +/// +/// Relatório em CSV, uma linha por host, para abrir em planilha. Separador ponto e vírgula, que é +/// o que o Excel em português espera. Campo que começa com =, +, - ou @ ganha um apóstrofo na +/// frente, para a planilha não tratar texto vindo da rede como fórmula. +/// +public static class RelatorioCsv +{ + public const char Separador = ';'; + + public static readonly string[] Colunas = + [ + "IP", "Nome", "Origem do nome", "MAC", "Fabricante", "Tipo provável", "Motivos do tipo", "Ping (ms)", "TTL", + "Portas abertas", "Serviço", "Serviços", "Observação", "Grupo NetBIOS", + ]; + + public static string Gerar(ResultadoVarredura r) + { + var sb = new StringBuilder(); + sb.AppendLine(string.Join(Separador, Colunas.Select(Campo))); + foreach (var h in r.Hosts.OrderBy(h => h.IpNumero)) + { + var c = h.Classificacao; + var valores = new[] + { + h.Ip.ToString(), h.Nome, h.OrigemNome, h.MacTexto, h.Fabricante, + c.Tipo == TipoEquipamento.Desconhecido ? string.Empty : c.Nome, string.Join(", ", c.Motivos), + h.TempoPingMs?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, h.Ttl?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + h.PortasTexto, h.ServicoResumo, string.Join(" | ", h.Servicos.Select(s => s.Texto)), + string.Join(", ", h.Marcas), h.GrupoNetBios ?? string.Empty, + }; + sb.AppendLine(string.Join(Separador, valores.Select(Campo))); + } + + return sb.ToString(); + } + + /// Aspas quando precisa, aspas dobradas por dentro e o apóstrofo contra fórmula. + public static string Campo(string? valor) + { + var v = (valor ?? string.Empty).Replace("\r", " ").Replace("\n", " "); + if (v.Length > 0 && v[0] is '=' or '+' or '-' or '@' or '\t') + { + v = "'" + v; + } + + return v.IndexOfAny([Separador, '"', ',']) >= 0 ? "\"" + v.Replace("\"", "\"\"") + "\"" : v; + } +} + +/// +/// Relatório em XML, com tudo o que o HTML mostra, para outro programa ler. Caractere que o XML não +/// aceita é tirado antes, porque nome de equipamento pode trazer qualquer coisa. +/// +public static class RelatorioXml +{ + public const string Versao = "1"; + + public static string Gerar(ResultadoVarredura r) + { + var i = r.Interface; + var raiz = new XElement("inventario", + new XAttribute("formato", Versao), + new XAttribute("programa", $"MapNet - MT {ResultadoVarredura.VersaoPrograma}"), + new XElement("varredura", + A("inicio", r.Inicio.ToString("o", CultureInfo.InvariantCulture)), + A("fim", r.Fim.ToString("o", CultureInfo.InvariantCulture)), + A("duracaoSegundos", ((int)r.Duracao.TotalSeconds).ToString(CultureInfo.InvariantCulture)), + A("interrompida", r.Cancelada ? "sim" : "nao"), + A("computador", r.NomeComputador), + A("subRede", r.SubRedeVarrida.ToString()), + A("interface", i.Nome), + A("ip", i.Ip.ToString()), + A("gateway", i.Gateway?.ToString()), + A("dns", string.Join(" ", i.Dns)), + A("ipPublico", r.IpPublico), + A("portasVerificadas", r.PortasVerificadas is { } p ? string.Join(" ", p) : null), + A("identificacaoDeServicos", r.IdentificacaoFeita ? "sim" : "nao")), + new XElement("avisos", r.Avisos.Select(a => new XElement("aviso", T(a))))); + + if (r.Maquina is { } maquina) + { + raiz.Add(new XElement("maquina", maquina.Grupos(r.Fim, r.IpPublico).Select(g => + new XElement("grupo", A("titulo", g.Titulo), g.Itens.Select(it => new XElement("item", A("rotulo", it.Rotulo), T(it.Valor))))))); + } + + raiz.Add(new XElement("hosts", r.Hosts.OrderBy(h => h.IpNumero).Select(Host))); + var doc = new XDocument(new XDeclaration("1.0", "utf-8", null), raiz); + + var sb = new StringBuilder(); + using (var escritor = XmlWriter.Create(new StringWriterUtf8(sb), new XmlWriterSettings { Indent = true, Encoding = new UTF8Encoding(false) })) + { + doc.Save(escritor); + } + + return sb.ToString(); + } + + private static XElement Host(HostEncontrado h) + { + var c = h.Classificacao; + return new XElement("host", + A("ip", h.Ip.ToString()), + A("mac", h.MacTexto), + A("fabricante", h.Fabricante), + A("macAleatorio", h.MacAleatorio ? "sim" : null), + A("gateway", h.EhGateway ? "sim" : null), + A("esteComputador", h.EhEsteComputador ? "sim" : null), + new XElement("nomes", + A("exibido", h.Nome), A("origem", h.OrigemNome), A("dns", h.NomeDns), A("netbios", h.NomeNetBios), A("grupo", h.GrupoNetBios), A("mdns", h.NomeMdns)), + new XElement("ping", A("respondeu", h.RespondeuPing ? "sim" : "nao"), A("ms", h.TempoPingMs?.ToString(CultureInfo.InvariantCulture)), A("ttl", h.Ttl?.ToString(CultureInfo.InvariantCulture))), + new XElement("arp", A("respondeu", h.RespondeuArp ? "sim" : "nao")), + new XElement("tipo", A("nome", c.Nome), c.Motivos.Select(m => new XElement("motivo", T(m)))), + new XElement("portas", + A("verificadas", h.PortasVerificadas ? "sim" : "nao"), + A("motivo", h.MotivoSemPortas), + h.PortasAbertas.Select(p => new XElement("porta", A("numero", p.ToString(CultureInfo.InvariantCulture)), A("servico", ListaPortas.Servico(p))))), + new XElement("servicos", h.Servicos.Select(s => new XElement("servico", + A("porta", s.Porta > 0 ? s.Porta.ToString(CultureInfo.InvariantCulture) : null), + A("protocolo", s.Protocolo), + A("codigoHttp", s.CodigoHttp?.ToString(CultureInfo.InvariantCulture)), + A("titulo", s.Titulo), + A("servidor", s.Servidor), + A("redireciona", s.Redireciona), + A("certificado", s.CertificadoNome), + A("emissor", s.CertificadoEmissor), + A("validade", s.CertificadoValidade?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), + A("banner", s.Banner), + A("modelo", s.Modelo)))), + new XElement("marcas", h.Marcas.Select(m => new XElement("marca", T(m))))); + } + + /// Atributo só quando há valor, já sem caractere que o XML recusa. + private static XAttribute? A(string nome, string? valor) => + string.IsNullOrEmpty(valor) ? null : new XAttribute(nome, T(valor)); + + /// Tira o caractere que o XML 1.0 não aceita. + public static string T(string? texto) + { + if (string.IsNullOrEmpty(texto)) + { + return string.Empty; + } + + var sb = new StringBuilder(texto.Length); + for (var k = 0; k < texto.Length; k++) + { + var c = texto[k]; + if (char.IsHighSurrogate(c) && k + 1 < texto.Length && char.IsLowSurrogate(texto[k + 1])) + { + sb.Append(c).Append(texto[++k]); + } + else if (XmlConvert.IsXmlChar(c)) + { + sb.Append(c); + } + } + + return sb.ToString(); + } + + /// StringWriter que declara UTF-8, para o cabeçalho do XML sair certo. + private sealed class StringWriterUtf8(StringBuilder sb) : StringWriter(sb, CultureInfo.InvariantCulture) + { + public override Encoding Encoding => new UTF8Encoding(false); + } +} diff --git a/src/mapnet/app.manifest b/src/mapnet/app.manifest index d88abcd..7d2d2a0 100644 --- a/src/mapnet/app.manifest +++ b/src/mapnet/app.manifest @@ -1,6 +1,6 @@ - + diff --git a/src/mapnet/janela-principal.xaml.cs b/src/mapnet/janela-principal.xaml.cs index 66a227c..f37349e 100644 --- a/src/mapnet/janela-principal.xaml.cs +++ b/src/mapnet/janela-principal.xaml.cs @@ -126,11 +126,14 @@ private async Task SalvarComDialogoAsync() } Directory.CreateDirectory(_painel.PastaInicial); + // O nome vai sem extensão: o tipo escolhido na lista acrescenta .html, .csv ou .xml. var dialogo = new SaveFileDialog { Title = "Salvar relatório", - Filter = "Relatório HTML (*.html)|*.html", - FileName = _painel.NomeSugerido, + Filter = "Relatório HTML, para abrir no navegador (*.html)|*.html|Planilha CSV, para o Excel (*.csv)|*.csv|Dados XML, para outro programa (*.xml)|*.xml", + FileName = Path.GetFileNameWithoutExtension(_painel.NomeSugerido), + DefaultExt = ".html", + AddExtension = true, InitialDirectory = _painel.PastaInicial, }; if (dialogo.ShowDialog(this) != true) @@ -138,7 +141,13 @@ private async Task SalvarComDialogoAsync() return false; } - await _painel.SalvarComoAsync(dialogo.FileName!); + var caminho = dialogo.FileName!; + if (!Path.HasExtension(caminho)) + { + caminho += dialogo.FilterIndex switch { 2 => ".csv", 3 => ".xml", _ => ".html" }; + } + + await _painel.SalvarComoAsync(caminho); return !_painel.RelatorioNaoSalvo; } diff --git a/src/mapnet/modo-linha-de-comando.cs b/src/mapnet/modo-linha-de-comando.cs index ba9650b..5577736 100644 --- a/src/mapnet/modo-linha-de-comando.cs +++ b/src/mapnet/modo-linha-de-comando.cs @@ -148,30 +148,23 @@ private static async Task VarrerAsync(ArgumentosCli argumentos) // Na linha de comando não há clique, então o relatório sai sem o IP público. resultado.Maquina = LeitorMaquina.Ler(interfaceRede, FontesMaquina.Padrao(), argumentos.Opcoes.PrefixoMinimo); - var caminho = DefinirCaminho(argumentos.Saida, resultado); - caminho = await RelatorioHtml.SalvarAsync(resultado, caminho).ConfigureAwait(false); Console.WriteLine(); - Console.WriteLine($"Relatório gravado em: {caminho}"); - - if (argumentos.Abrir) + var gravados = new List(); + foreach (var caminho in argumentos.Caminhos(resultado, Environment.CurrentDirectory)) { - Process.Start(new ProcessStartInfo(caminho) { UseShellExecute = true }); + var gravado = await Relatorios.SalvarAsync(resultado, caminho).ConfigureAwait(false); + gravados.Add(gravado); + Console.WriteLine($"Relatório gravado em: {gravado}"); } - return resultado.Cancelada ? CodigoCancelado : CodigoSucesso; - } - - private static string DefinirCaminho(string? saida, ResultadoVarredura resultado) - { - var nome = RelatorioHtml.NomeArquivo(resultado); - if (string.IsNullOrWhiteSpace(saida)) + // Abre o HTML, se houver; senão, o primeiro arquivo gravado. + if (argumentos.Abrir && gravados.Count > 0) { - return Path.Combine(Environment.CurrentDirectory, nome); + var abrir = gravados.FirstOrDefault(g => Relatorios.FormatoDe(g) == FormatoRelatorio.Html) ?? gravados[0]; + Process.Start(new ProcessStartInfo(abrir) { UseShellExecute = true }); } - return saida.EndsWith(".html", StringComparison.OrdinalIgnoreCase) || saida.EndsWith(".htm", StringComparison.OrdinalIgnoreCase) - ? saida - : Path.Combine(saida, nome); + return resultado.Cancelada ? CodigoCancelado : CodigoSucesso; } private static string Cortar(string texto, int tamanho) => texto.Length <= tamanho ? texto : texto[..(tamanho - 3)] + "..."; diff --git a/testes/mapnet.testes/relatorios-dados-testes.cs b/testes/mapnet.testes/relatorios-dados-testes.cs new file mode 100644 index 0000000..2be750d --- /dev/null +++ b/testes/mapnet.testes/relatorios-dados-testes.cs @@ -0,0 +1,190 @@ +using System.Net; +using System.Text; +using System.Xml.Linq; +using MapNet.Nucleo; + +namespace MapNet.Testes; + +/// Relatório em XML e CSV: conteúdo, codificação do texto vindo da rede e escolha do formato. +public class RelatoriosDadosTestes +{ + [Fact] + public void Xml_e_valido_e_traz_tudo_do_host() + { + var xml = RelatorioXml.Gerar(Resultado()); + var doc = XDocument.Parse(xml); + var host = doc.Root!.Element("hosts")!.Elements("host").First(); + + Assert.StartsWith("", xml); + Assert.Equal("1", doc.Root.Attribute("formato")!.Value); + Assert.Equal("192.0.2.0/24", doc.Root.Element("varredura")!.Attribute("subRede")!.Value); + Assert.Equal("192.0.2.5", host.Attribute("ip")!.Value); + Assert.Equal("Impressora", host.Element("tipo")!.Attribute("nome")!.Value); + Assert.Contains(host.Element("portas")!.Elements("porta"), p => p.Attribute("numero")!.Value == "9100" && p.Attribute("servico")!.Value == "Impressão direta"); + Assert.Equal("Impressora & \"cia\"", host.Element("servicos")!.Element("servico")!.Attribute("titulo")!.Value); + Assert.Equal("IMPRESSORA", host.Element("nomes")!.Attribute("netbios")!.Value); + } + + [Fact] + public void Xml_tira_caractere_que_o_xml_recusa() + { + var r = Resultado(); + r.Hosts[0].NomeMdns = "nome\u0001com\u0008controle.local"; + + var doc = XDocument.Parse(RelatorioXml.Gerar(r)); + + Assert.Equal("nomecomcontrole.local", doc.Root!.Element("hosts")!.Element("host")!.Element("nomes")!.Attribute("mdns")!.Value); + } + + [Fact] + public void Csv_tem_cabecalho_e_uma_linha_por_host() + { + var linhas = RelatorioCsv.Gerar(Resultado()).TrimEnd().Split(Environment.NewLine); + + Assert.Equal(3, linhas.Length); + Assert.StartsWith("IP;Nome;Origem do nome;MAC;Fabricante;Tipo provável", linhas[0]); + Assert.StartsWith("192.0.2.5;IMPRESSORA;NetBIOS;00:80:77:12:34:05;", linhas[1]); + Assert.Contains(";Impressora;", linhas[1]); + } + + [Theory] + [InlineData("simples", "simples")] + [InlineData("a;b", "\"a;b\"")] + [InlineData("diz \"oi\"", "\"diz \"\"oi\"\"\"")] + [InlineData("=HYPERLINK(\"x\")", "\"'=HYPERLINK(\"\"x\"\")\"")] + [InlineData("+1", "'+1")] + [InlineData("-cmd", "'-cmd")] + [InlineData("@soma", "'@soma")] + [InlineData("duas\nlinhas", "duas linhas")] + [InlineData(null, "")] + public void Campo_do_csv_e_protegido(string? valor, string esperado) + { + Assert.Equal(esperado, RelatorioCsv.Campo(valor)); + } + + [Theory] + [InlineData("r.html", FormatoRelatorio.Html)] + [InlineData("r.HTM", FormatoRelatorio.Html)] + [InlineData("r.csv", FormatoRelatorio.Csv)] + [InlineData("r.XML", FormatoRelatorio.Xml)] + [InlineData("r", FormatoRelatorio.Html)] + public void Formato_pela_extensao(string caminho, FormatoRelatorio esperado) + { + Assert.Equal(esperado, Relatorios.FormatoDe(caminho)); + } + + [Fact] + public async Task Salvar_grava_cada_formato_com_a_codificacao_certa() + { + var pasta = Path.Combine(Path.GetTempPath(), "mapnet-testes-formatos-" + Guid.NewGuid().ToString("N")); + try + { + var r = Resultado(); + var csv = await Relatorios.SalvarAsync(r, Path.Combine(pasta, "a.csv")); + var xml = await Relatorios.SalvarAsync(r, Path.Combine(pasta, "a.xml")); + var html = await Relatorios.SalvarAsync(r, Path.Combine(pasta, "a.html")); + + var bytesCsv = await File.ReadAllBytesAsync(csv); + Assert.Equal(Encoding.UTF8.GetPreamble(), bytesCsv[..3]); + Assert.NotEqual(0xEF, (await File.ReadAllBytesAsync(xml))[0]); + XDocument.Load(xml); + Assert.StartsWith("", await File.ReadAllTextAsync(html)); + } + finally + { + if (Directory.Exists(pasta)) + { + Directory.Delete(pasta, recursive: true); + } + } + } + + [Fact] + public void Nome_sugerido_por_formato() + { + var r = Resultado(); + + Assert.EndsWith(".csv", Relatorios.NomeArquivo(r, FormatoRelatorio.Csv)); + Assert.Equal(Path.GetFileNameWithoutExtension(RelatorioHtml.NomeArquivo(r)), Path.GetFileNameWithoutExtension(Relatorios.NomeArquivo(r, FormatoRelatorio.Xml))); + } + + [Fact] + public void Linha_de_comando_grava_html_por_padrao() + { + var a = ArgumentosCli.Interpretar(["--varrer"]); + + var caminhos = a.Caminhos(Resultado(), @"C:\atual"); + + Assert.Single(caminhos); + Assert.StartsWith(@"C:\atual", caminhos[0]); + Assert.EndsWith(".html", caminhos[0]); + } + + [Fact] + public void Linha_de_comando_todos_os_formatos_numa_pasta() + { + var a = ArgumentosCli.Interpretar(["--varrer", "--formato", "todos", "--saida", "relatorios"]); + + var caminhos = a.Caminhos(Resultado(), "atual"); + + Assert.True(a.Valido); + Assert.Equal([".html", ".csv", ".xml"], caminhos.Select(Path.GetExtension)); + Assert.All(caminhos, c => Assert.StartsWith("relatorios", c)); + } + + [Fact] + public void Linha_de_comando_arquivo_define_o_formato() + { + var a = ArgumentosCli.Interpretar(["--varrer", "--saida", "inventario.csv"]); + + Assert.Equal([FormatoRelatorio.Csv], a.Formatos); + Assert.Equal(["inventario.csv"], a.Caminhos(Resultado(), "atual")); + } + + [Theory] + [InlineData(new[] { "--varrer", "--formato", "pdf" }, "Formato desconhecido")] + [InlineData(new[] { "--varrer", "--formato", "todos", "--saida", "a.html" }, "tem que ser uma pasta")] + [InlineData(new[] { "--formato", "csv" }, "pedem o comando --varrer")] + public void Linha_de_comando_recusa_formato_errado(string[] args, string trecho) + { + var a = ArgumentosCli.Interpretar(args); + + Assert.False(a.Valido); + Assert.Contains(a.Erros, e => e.Contains(trecho)); + } + + private static ResultadoVarredura Resultado() + { + var i = new InterfaceRede + { + Id = "teste", + Nome = "Ethernet", + Descricao = "Placa de teste", + Tipo = TipoInterface.Cabo, + Ip = IPAddress.Parse("192.0.2.10"), + Prefixo = 24, + Gateway = IPAddress.Parse("192.0.2.1"), + Dns = [IPAddress.Parse("192.0.2.1")], + }; + var inicio = new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero); + var r = new ResultadoVarredura { Interface = i, SubRedeVarrida = i.SubRede, Inicio = inicio, Fim = inicio.AddSeconds(30), PortasVerificadas = ListaPortas.Padrao, IdentificacaoFeita = true }; + r.Hosts.Add(new HostEncontrado + { + Ip = IPAddress.Parse("192.0.2.5"), + Mac = [0x00, 0x80, 0x77, 0x12, 0x34, 0x05], + Fabricante = "Brother industries, LTD.", + RespondeuPing = true, + RespondeuArp = true, + TempoPingMs = 3, + Ttl = 64, + NomeNetBios = "IMPRESSORA", + PortasAbertas = [80, 9100], + PortasTestadas = ListaPortas.Padrao, + PortasVerificadas = true, + Servicos = [new ServicoIdentificado { Porta = 80, Protocolo = "HTTP", CodigoHttp = 200, Titulo = "Impressora & \"cia\"" }], + ServicosIdentificados = true, + }); + r.Hosts.Add(new HostEncontrado { Ip = IPAddress.Parse("192.0.2.10"), EhEsteComputador = true }); + return r; + } +} From 9e2ba1b92d679272707906755b4e556e0a0ed82b Mon Sep 17 00:00:00 2001 From: Manfred Heil Junior Date: Sat, 26 Sep 2026 13:40:14 -0300 Subject: [PATCH 2/3] Aponta o PR da fatia 10 no README O numero do PR so existe depois de aberto. Autores: Manfred Heil Junior --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 25d8750..8690bb0 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ As regras do projeto estão no [`AGENTS.md`](AGENTS.md) e o desenho em [`docs/su | 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-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 | Classificação por tipo de equipamento, com os motivos à vista (sai como v0.5.0) | `classificacao` | [#35](https://github.com/manfredjr/mapnet/pull/35) | Em revisão | -| 10 | Relatório também em CSV e XML, pela janela e pela linha de comando (sai como v1.0.0) | `relatorios` | [PREENCHER] | Em revisão | +| 10 | Relatório também em CSV e XML, pela janela e pela linha de comando (sai como v1.0.0) | `relatorios` | [#36](https://github.com/manfredjr/mapnet/pull/36) | Em revisão | 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). From 9ea0f3da25207666fd3ae82baf02d5ab2c7d6253 Mon Sep 17 00:00:00 2001 From: Manfred Heil Junior Date: Sat, 26 Sep 2026 13:53:25 -0300 Subject: [PATCH 3/3] Marca as fatias 8 a 10 como publicadas na v1.0.0 O Manfred autorizou juntar os PRs 34 a 36 e publicar a v1.0.0, que leva as tres fatias. A situacao do projeto ja sai certa no merge. Autores: Manfred Heil Junior --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8690bb0..431fe84 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ 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-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 | Classificação por tipo de equipamento, com os motivos à vista (sai como v0.5.0) | `classificacao` | [#35](https://github.com/manfredjr/mapnet/pull/35) | Em revisão | -| 10 | Relatório também em CSV e XML, pela janela e pela linha de comando (sai como v1.0.0) | `relatorios` | [#36](https://github.com/manfredjr/mapnet/pull/36) | 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) | Publicada na v1.0.0 | +| 9 | Classificação por tipo de equipamento, com os motivos à vista (sai como v0.5.0) | `classificacao` | [#35](https://github.com/manfredjr/mapnet/pull/35) | Publicada na v1.0.0 | +| 10 | Relatório também em CSV e XML, pela janela e pela linha de comando (sai como v1.0.0) | `relatorios` | [#36](https://github.com/manfredjr/mapnet/pull/36) | Publicada na v1.0.0 | 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).