feat: audit cartography workbook with provenance and quarantine

This commit is contained in:
2026-09-28 20:48:34 +02:00
parent 2640dd6109
commit 439918d143
5 changed files with 340 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace App\Services;
use RuntimeException;
use SimpleXMLElement;
use ZipArchive;
/** Inspects an authorised local workbook without importing or transforming data. */
final class CartographyWorkbookAudit
{
/** @param array<string, mixed> $manifest
* @return array<string, mixed>
*/
public function inspect(string $path, array $manifest): array
{
if (! is_file($path) || filesize($path) > 20_000_000) {
throw new RuntimeException('Workbook missing or larger than 20 MB.');
}
$checksum = hash_file('sha256', $path);
if ($checksum === false) {
throw new RuntimeException('Unable to read workbook checksum.');
}
if (! isset($manifest['sha256']) || ! hash_equals(strtolower((string) $manifest['sha256']), $checksum)) {
throw new RuntimeException('Manifest checksum does not match the workbook.');
}
foreach (['inspection_authorization'] as $key) {
if (! is_string($manifest[$key] ?? null) || trim($manifest[$key]) === '') {
throw new RuntimeException('Missing manifest field: '.$key);
}
}
if (isset($manifest['classification']) && ! in_array($manifest['classification'], ['public', 'internal', 'confidential', 'restricted'], true)) {
throw new RuntimeException('Invalid classification.');
}
$zip = new ZipArchive;
if ($zip->open($path) !== true) {
throw new RuntimeException('Invalid XLSX container.');
}
try {
$bytes = 0;
if ($zip->numFiles > 1000) {
throw new RuntimeException('Too many archive entries.');
}
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->statIndex($i);
if ($entry === false) {
throw new RuntimeException('Unreadable archive entry.');
}
$bytes += $entry['size'];
if ($bytes > 50_000_000 || str_contains($entry['name'], '../')) {
throw new RuntimeException('Unsafe or oversized archive.');
}
}
$strings = [];
if ($zip->locateName('xl/sharedStrings.xml') !== false) {
foreach ($this->xml($zip, 'xl/sharedStrings.xml')->xpath('//*[local-name()="si"]') ?: [] as $item) {
$strings[] = implode('', array_map(fn ($t) => (string) $t, $item->xpath('.//*[local-name()="t"]') ?: []));
}
}
$relationships = [];
foreach ($this->xml($zip, 'xl/_rels/workbook.xml.rels')->children() as $rel) {
$target = (string) $rel['Target'];
if ((string) $rel['TargetMode'] !== 'External' && ! str_contains($target, '..')) {
$relationships[(string) $rel['Id']] = str_starts_with($target, '/') ? ltrim($target, '/') : 'xl/'.$target;
}
}
$sheets = [];
$rowCount = 0;
foreach ($this->xml($zip, 'xl/workbook.xml')->xpath('//*[local-name()="sheet"]') ?: [] as $sheet) {
$id = (string) $sheet->attributes('http://schemas.openxmlformats.org/officeDocument/2006/relationships')['id'];
if (! isset($relationships[$id])) {
throw new RuntimeException('Unresolved worksheet relationship.');
}
$rows = [];
$seen = [];
foreach ($this->xml($zip, $relationships[$id])->xpath('//*[local-name()="sheetData"]/*[local-name()="row"]') ?: [] as $row) {
if (++$rowCount > 50000) {
throw new RuntimeException('Audit limit of 50000 rows exceeded.');
}
$values = [];
$issues = [];
foreach ($row->children() as $cell) {
$address = (string) $cell['r'];
$value = (string) $cell->v;
if ((string) $cell['t'] === 's') {
$value = $strings[(int) $value] ?? '';
} elseif ((string) $cell['t'] === 'inlineStr') {
$value = implode('', array_map(fn ($t) => (string) $t, $cell->xpath('.//*[local-name()="t"]') ?: []));
}
$values[preg_replace('/[0-9]+$/', '', $address)] = $value;
if ($value === '') {
$issues[] = ['field' => $address, 'code' => 'empty_cell'];
}
if (isset($cell->f)) {
$issues[] = ['field' => $address, 'code' => 'formula_not_evaluated'];
}
if ((string) $cell['t'] === 'e') {
$issues[] = ['field' => $address, 'code' => 'spreadsheet_error'];
}
}
$hash = hash('sha256', json_encode($values, JSON_THROW_ON_ERROR));
$duplicate = isset($seen[$hash]);
$seen[$hash] = true;
$issues[] = ['field' => '*', 'code' => 'semantic_mapping_and_institutional_validation_pending'];
if (empty($manifest['original_crs'])) {
$issues[] = ['field' => '*', 'code' => 'original_crs_unknown'];
}
$rows[] = ['row' => (int) $row['r'], 'status' => $duplicate ? 'duplicate' : 'pending', 'populated_columns' => array_keys($values), 'issues' => $issues];
}
$sheets[] = ['name' => (string) $sheet['name'], 'state' => (string) $sheet['state'] ?: 'visible', 'rows' => $rows];
}
return ['sha256' => $checksum, 'version' => $manifest['version'] ?? null, 'classification' => $manifest['classification'] ?? null,
'pending_metadata' => array_values(array_filter(['owner', 'version', 'received_at', 'classification', 'original_crs'], fn ($key) => empty($manifest[$key]))),
'original_crs' => $manifest['original_crs'] ?? null, 'publication_allowed' => false, 'transformations' => [],
'status' => 'quarantined', 'sheets' => $sheets, 'rows_inspected' => $rowCount,
'limitations' => ['No semantic header mapping, omitted-cell inference, coordinate validation or external reference resolution.', 'No source values, coordinates or formulas copied to the report.']];
} finally {
$zip->close();
}
}
private function xml(ZipArchive $zip, string $name): SimpleXMLElement
{
$content = $zip->getFromName($name);
if ($content === false || preg_match('/<!DOCTYPE|<!ENTITY/i', $content)) {
throw new RuntimeException('Missing or unsafe workbook XML.');
}
$previous = libxml_use_internal_errors(true);
try {
$xml = simplexml_load_string($content, SimpleXMLElement::class, LIBXML_NONET);
if ($xml === false) {
throw new RuntimeException('Malformed workbook XML.');
}
return $xml;
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
}
}
+92
View File
@@ -0,0 +1,92 @@
# Cartografia GIS — execução incremental
Actualização: recebido o novo documento do modelo territorial, com autorização integral. Foi implementado o [núcleo do registo territorial e APIs editoriais](territorial-mvp.md), sem duplicar a identidade de geo_locations. O histórico abaixo descreve a auditoria anterior; não representa a cobertura integral do novo MVP.
## Inspecção do Excel indicado pelo utilizador
- SHA-256: `964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff`; tamanho 879.583 bytes.
- Uma folha `Sheet1`, intervalo `A1:H20408`, 20.408 linhas presentes, incluindo cabeçalho; 20.407 linhas subsequentes.
- Cabeçalhos: `ID`, `Provincia`, `Distrito`, `Posto_Administrativo`, `Localidade`, `Bairro_Urbano`, `Aldeia_Quarteirao`, `Designacao`.
- Sem coluna de coordenadas/WKT/GeoJSON nos cabeçalhos. Não gerar coordenadas nem presumir que `ID` seja código oficial.
- Auditoria: 20.408 linhas pendentes; nenhum duplicado de linha inteira detectado. Isto não prova unicidade de localidades ou chaves.
- Proprietário, versão documental, classificação e CRS não comprovados. Para uso exclusivamente alfanumérico, a aplicabilidade do CRS deve ser decidida no mapeamento; não atribuir EPSG:4326 a nomes de locais.
- Manifesto de inspecção e relatório por linha/campo: `storage/app/private/gis-audit-20260928/`. Autorização de inspecção: indicação explícita do utilizador. Não foi fabricado proprietário ou mandato de publicação.
- PHPStan aprovado após implementação; 4 testes unitários/13 verificações aprovadas. O auditor não abre ligação à base de dados.
Actualizado em 28/09/2026. Branch: `feature/gis-phase-0`.
O dono do produto autorizou a progressão automática por todas as fases nesta sessão, depois de fornecer o prompt GIS. Esta autorização substitui a espera entre fases; não constitui validação institucional de uma fonte, mandato de uma entidade ou autorização de divulgação de uma geometria.
## Estado por fase
| Fase | Estado e recomendação | Evidência / condição |
| --- | --- | --- |
| 0 — inventário | Parcial; Go condicionado para artefactos preparatórios | Código, índices, mapas e controlos inventariados abaixo. Excel localizado e inspeccionado após indicação do utilizador; documento original A.31 ainda não localizado. |
| 1 — modelo e regras | Proposta de reconciliação; Go condicionado | Reutilizar o cadastro existente, sem uma segunda base GIS. Reconciliar a proposta com A.31 antes de fechar o modelo. |
| 2 — serviços e importação | Auditor de XLSX implementado; restante pendente | Auditor local sem ligação à BD, com checksum, autorização de inspecção, limites e quarentena. Não é importador. |
| 3 — mapas e CRM | Base preexistente parcial; No-Go para declarar conformidade GIS | Catálogo e zonas têm mapas/listas; autorização geral, precisão e origem completa mapa→CRM ainda incompletas. |
| 4 — piloto e UAT | Testes sintéticos do auditor aprovados; No-Go para piloto real | Excel inspeccionado; faltam proveniência, classificação, mapeamento e amostras oficiais. |
| 5 — staging/produção | No-Go | Sem UAT, carga, matriz de mandatos, fontes e ensaio operacional do módulo completo. Nenhum deploy realizado. |
Não foram executadas migrations, importações, alterações de dados ou publicação de mapas nesta entrega GIS. O avanço técnico independente foi executado; as operações dependentes de fontes ausentes continuam pendentes, sem presumir os respectivos valores.
## Diagnóstico e evidências existentes
- `database/migrations/2026_09_24_003000_investmoz_geography.php`: `geo_locations` e `geo_geometries`, UUID, classificação, versão, soft delete e hierarquia. Tipos de localização limitados a país, província, distrito, município e local. Geometrias 2D/4326, tipos controlados, ST_IsValid, não vazias e limites globais.
- `2026_09_24_004000_investmoz_projects.php`: `project_locations` já suporta vários projectos/locais e papéis. Faltam validade e controlo de publicação da relação conforme novo prompt.
- `2026_09_24_012000_investmoz_indexes.php`: GiST, chaves de origem, código/tipo de localização e unicidade de geometria por local/tipo activo. A última regra precisa de reconciliação para permitir versões e representações internas/públicas distintas.
- `InvestmentCatalogue::map`: apenas oportunidades publicadas, localização/geometria públicas e não apagadas, pontos e limite 1000. **Lacuna de segurança RF-GEO-005/006:** classificação pública não comprova autorização institucional vigente; não existe nível de precisão aplicável neste caminho. Não carregar dados geográficos reais neste caminho até resolver a lacuna. Não foi demonstrada exposição de dados reais nesta execução.
- `InvestmentZoneGeometryController`: GeoJSON, validação PostGIS, revisão independente por utilizador designado, suspensão, fonte vigente quando associada, bbox e limite 100. A simplificação geométrica não substitui generalização por confidencialidade. Não reutilizar esta autorização como autorização geral de localizações.
- `config/investment-zones.php`, `RequireZoneMfa` e rotas de zonas: perfis configuráveis de revisão GIS/publicação, gestor verificado e MFA. O cadastro não prova mandato institucional. Não se enumeraram emails, credenciais ou outros dados pessoais.
- `resources/js/pages/investment/catalogue.tsx` e `zone-map.tsx`: lista/mapa e alternativa textual; falta experiência GIS transversal, comparação territorial e mapa interno completo.
- CRM: `investment_submission_zones` preserva zona/versão/intenção. Não preserva ainda a totalidade localização/camada/campanha/corredor/cluster exigida para conversão pelo mapa.
- APIs existentes: `/api/v1/investment/map` e `/api/v2/investment/zone-map`; não equivalem aos novos contratos `/api/v1/geo/*` e `/api/v1/maps/*`.
Pesquisaram-se nomes de ficheiros DOCX/XLSX/PDF no repositório (incluindo ignorados, excluindo dependências, `.git` e backups privados) e na pasta de anexos fornecida pelo utilizador. Na pesquisa inicial não se localizaram o documento original nem o Excel; posteriormente o utilizador indicou a localização do Excel, que foi inspeccionado. Documentação derivada menciona A.31; essas menções não substituem a secção original. Não se pesquisou indiscriminadamente o disco pessoal nem o servidor da outra máquina.
## Rastreabilidade e plano técnico
Os identificadores TECH abaixo são **provisórios locais**, não transcrição da A.31. Não se criou uma segunda sequência RF-GIS.
| RF-GEO | TECH provisório | Modelo / serviço previsto | API / evento previsto | Frontend e teste necessário | Estado |
| --- | --- | --- | --- | --- | --- |
| 001 | TECH-GEO-CAD | Adaptar geo_locations, proveniência, validade, revisão imutável | locations GET/POST/PATCH; location.revised | Cadastro; códigos duplicados, ciclos e concorrência | Base parcial |
| 002 | TECH-GEO-REL | Adaptar project_locations, papel, período, natureza | Relações explicitadas no contrato de localização | Vários projectos sem duplicar geometria | Base parcial |
| 003 | TECH-GEO-MULTI | Localização principal por período; secundárias | Ficha do projecto/localizações | Projecto multiterritorial; unicidade principal | Parcial |
| 004 | TECH-GEO-VALID | Geometria versionada, CRS original, método de transformação | geometries; validate; geometry.validated | Painel; SRID ausente, inválida, vazia e territorialmente divergente | PostGIS/zonas parcial |
| 005 | TECH-GEO-AUTH | geo_authorizations, documento, entidade e validade | authorize-publication; authorization.revoked | Painel de autorização; expiração/revogação em todos os caminhos | Geral em falta |
| 006 | TECH-GEO-PRECISION | Representação autorizada separada da geometria interna | Serviço comum a mapas, pesquisa, exportação e cache | Não reconstrução por bbox, raio, exportação ou respostas combinadas | Em falta |
| 007 | TECH-GEO-INSTITUTION | map_validation_requests, protocolo, anexos, decisões | validation.requested/decided | Painel interno; contestação impede publicação | Em falta |
| 008 | TECH-GEO-SUSPEND | Suspensão transaccional e tarefa de revalidação | suspend; map.suspended | Revogação imediata; cache e links históricos | Zonas parcial |
| 009 | TECH-GEO-SEARCH | GiST, filtros aplicados à representação autorizada | maps/search, projects, corridors, clusters, nearby | Lista sincronizada; limites, paginação, teclado e carga | Parcial |
| 010 | TECH-GEO-IMPORT | Batch, checksum, manifesto, relatório por linha, idempotência | import-jobs, errors, reconcile; import.reconciled | Upload privado; ZIP/XML hostil, duplicados e reconciliação | Auditor preparatório implementado |
## Modelo, contratos e autorização propostos — Fase 1
Adaptar as três tabelas existentes; acrescentar camadas, autorizações, pedidos de validação, revisões e batches apenas depois de comparar A.31. Não atribuir código territorial pelo nome nem fundir localidades por semelhança textual. Preservar sistema de referência original; ausência coloca em quarentena. Nunca calcular ponto aproximado ou centroide como autorização implícita de divulgação.
Uma política comum de publicação deverá exigir local e geometria validados, ausência de contestação, autorização/documento vigentes, versão correspondente e representação permitida. Pesquisa espacial pública deve operar sobre essa representação, não sobre a exacta restrita. Cache tem de invalidar em revisão, suspensão, revogação e expiração. Logs não devem conter coordenadas, conteúdo dos anexos ou dados privados de pontos focais.
APIs de gestão propostas: autenticação, gestor verificado, MFA, papel específico configurado, CSRF, limites de payload, correlation_id gerado, erro normalizado e comparação de versão. APIs públicas precisam de política de leitura pública explícita; o prompt pede autenticação em todos os endpoints e também mapa público. Registar esta divergência para resolução na especificação, sem inventar um token público com privilégios. Eventos devem transportar identificadores/versões, não geometria restrita, através da outbox existente.
Conversão pelo mapa deverá guardar referências verificadas no servidor e consentimento específico, nunca confiar num contexto arbitrário enviado pelo navegador. Pedido de reunião significa pedido de contacto até existir agenda integrada; não confirmar reunião automaticamente.
## Auditor XLSX — reprodução
Ficheiros criados: `app/Services/CartographyWorkbookAudit.php`, `tools/cartography-audit.php`, `tests/Unit/CartographyWorkbookAuditTest.php`, este documento e `source-manifest.example.json`.
Definir `INVESTMOZ_CARTOGRAPHY_FILE` e `INVESTMOZ_CARTOGRAPHY_MANIFEST` para ficheiros autorizados locais, fora do directório público; executar `php tools/cartography-audit.php`. O caminho não está codificado no projecto. O manifesto exige checksum SHA-256 exacto e evidência de autorização **de inspecção**, distinta de publicação. Proprietário, versão, recepção, classificação e CRS ausentes ficam explicitamente pendentes. O relatório deve ser guardado em armazenamento privado, pois os nomes de folhas também podem ser sensíveis.
Limites: 20 MB comprimidos, 50 MB declarados descomprimidos, 1000 entradas ZIP e 50000 linhas presentes. Leitura XML sem entidades/DTD nem acesso à rede. Fórmulas não são executadas; referências externas não são resolvidas. Relatório contém folha/linha/campo, duplicados exactos e anomalias, sem valores das células. Todas as linhas ficam pendentes ou duplicadas; o auditor não certifica validade territorial. Células omitidas não são reconstruídas; datas, unidades, separador decimal, cabeçalhos e chaves ainda exigem mapeamento autorizado. CRS declarado não é prova do CRS real.
Teste: `php vendor/bin/phpunit tests/Unit/CartographyWorkbookAuditTest.php --do-not-cache-result`. Resultado observado: **4 testes, 13 verificações aprovadas**, sem Laravel/BD, apenas XLSX sintético temporário. Cobertura: checksum divergente, autorização ausente, entidade XML externa, duplicados, fórmula, confidencialidade do relatório e quarentena por CRS ausente. Executado também sobre o Excel indicado, conforme resultado abaixo; não é certificação de qualidade do corpus nem de GIS completo.
## Migrations, rollback e decisões pendentes
Nenhuma migration criada ou executada nesta entrega. Futuras migrations devem ser aditivas, testadas em BD isolada e precedidas de backup/restauro e reconciliação no destino. Não usar DROP EXTENSION nem CASCADE destrutivo. Rollback de tabelas com evidência ou histórico deve recusar perda de dados; preferir suspensão e correcção progressiva.
Reversão desta entrega: retirar apenas os três ficheiros novos de código/teste do auditor e os seus documentos, depois de confirmar ausência de alterações posteriores. Não reverter o trabalho anterior do portal. A branch foi criada sobre `main`, mantendo as alterações de interface já em curso.
Pendências humanas concretas: localização e versão da A.31; Excel e seu manifesto; proprietário e autorização de uso; CRS e mapeamento de colunas; entidades e mandatos de validação; níveis de divulgação; resolução da autenticação do mapa público. Estas pendências são de informação e validação institucional, não um novo pedido de autorização para desenvolver.
Risco residual principal: endpoints preexistentes não estão certificados para as regras transversais do novo GIS. Produção e dados reais permanecem No-Go até fechar a política comum e os ensaios RF-GEO-005/006. A implementação integral solicitada ainda não está concluída.
@@ -0,0 +1,11 @@
{
"sha256": null,
"owner": null,
"version": null,
"received_at": null,
"classification": null,
"inspection_authorization": null,
"original_crs": null,
"publication_authorization": null,
"column_mapping": null
}
@@ -0,0 +1,75 @@
<?php
namespace Tests\Unit;
use App\Services\CartographyWorkbookAudit;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use ZipArchive;
class CartographyWorkbookAuditTest extends TestCase
{
private string $path;
protected function setUp(): void
{
parent::setUp();
$this->path = tempnam(sys_get_temp_dir(), 'gis-test-');
$zip = new ZipArchive;
$zip->open($this->path, ZipArchive::OVERWRITE);
$zip->addFromString('xl/workbook.xml', '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Synthetic" sheetId="1" r:id="rId1"/></sheets></workbook>');
$zip->addFromString('xl/_rels/workbook.xml.rels', '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Target="worksheets/sheet1.xml"/></Relationships>');
$zip->addFromString('xl/worksheets/sheet1.xml', '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Private synthetic label</t></is></c></row><row r="2"><c r="A2" t="inlineStr"><is><t>Private synthetic label</t></is></c></row><row r="3"><c r="B3"><f>1+1</f><v>2</v></c><c r="C3"/></row></sheetData></worksheet>');
$zip->close();
}
protected function tearDown(): void
{
unlink($this->path);
parent::tearDown();
}
/** @return array<string, string> */
private function manifest(): array
{
return ['sha256' => hash_file('sha256', $this->path), 'owner' => 'Synthetic owner', 'version' => 'test-1',
'received_at' => '2026-09-28', 'classification' => 'internal', 'inspection_authorization' => 'Synthetic test only'];
}
public function test_audit_quarantines_unknown_crs_and_reports_duplicates_without_copying_source_values(): void
{
$report = (new CartographyWorkbookAudit)->inspect($this->path, $this->manifest());
$this->assertSame(3, $report['rows_inspected']);
$this->assertSame('quarantined', $report['status']);
$this->assertFalse($report['publication_allowed']);
$this->assertSame([], $report['transformations']);
$this->assertSame('duplicate', $report['sheets'][0]['rows'][1]['status']);
$this->assertStringNotContainsString('Private synthetic label', json_encode($report));
$this->assertStringContainsString('formula_not_evaluated', json_encode($report));
$this->assertStringContainsString('original_crs_unknown', json_encode($report));
}
public function test_checksum_mismatch_is_rejected(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('checksum');
(new CartographyWorkbookAudit)->inspect($this->path, [...$this->manifest(), 'sha256' => str_repeat('0', 64)]);
}
public function test_inspection_requires_authorization_evidence(): void
{
$this->expectException(RuntimeException::class);
(new CartographyWorkbookAudit)->inspect($this->path, [...$this->manifest(), 'inspection_authorization' => '']);
}
public function test_xml_entities_are_rejected(): void
{
$zip = new ZipArchive;
$zip->open($this->path);
$zip->addFromString('xl/workbook.xml', '<!DOCTYPE workbook [<!ENTITY x SYSTEM "file:///secret">]><workbook>&x;</workbook>');
$zip->close();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('unsafe');
(new CartographyWorkbookAudit)->inspect($this->path, $this->manifest());
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
use App\Services\CartographyWorkbookAudit;
// Explicit paths only; no Laravel boot, database connection, import or publication.
require __DIR__.'/../vendor/autoload.php';
try {
$manifestPath = getenv('INVESTMOZ_CARTOGRAPHY_MANIFEST');
$workbookPath = getenv('INVESTMOZ_CARTOGRAPHY_FILE');
if (! $manifestPath || ! $workbookPath || ! is_file($manifestPath) || filesize($manifestPath) > 100000) {
throw new RuntimeException('Set INVESTMOZ_CARTOGRAPHY_FILE and INVESTMOZ_CARTOGRAPHY_MANIFEST to authorised local files.');
}
$manifest = json_decode(file_get_contents($manifestPath), true, 32, JSON_THROW_ON_ERROR);
$report = (new CartographyWorkbookAudit)->inspect($workbookPath, $manifest);
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL;
} catch (Throwable $exception) {
fwrite(STDERR, 'Audit failed: '.$exception->getMessage().PHP_EOL);
exit(1);
}