eva.integration.retest.v1 El runner no cierra

API de retest

Contrato para el runner: poll, claim, heartbeat y result. El job es opaco. El runner no cambia el estado del hallazgo. La imagen se publica en un registro; ver Runner.

El runner no cierra hallazgos POST …/result guarda el outcome del job y puede adjuntar evidencias. No certifica ni transiciona el estado del hallazgo.

Cómo llega un job

EVA encola un job cuando el hallazgo es automatable: tool_id nuclei o nmap, execution_scope cloud u on_prem, y un asset usable (Nuclei: URL/endpoint; Nmap: IP/host). Si falta alguno, no hay job. GET pending no crea filas: solo entrega el job pending más antiguo o null. Un job en pending siempre trae target.value no vacío.

Rutas

Base: {EVA_API_BASE_URL}/api/v1/integrations

MétodoPathScopeAcción
GET/retests/pendingretest_readUn job pending existente o null. No crea filas.
POST/retests/jobs/{id}/claimretest_writeToma el job. Lease 300 s.
POST/retests/jobs/{id}/heartbeatretest_writeRenueva lease. claimedrunning.
POST/retests/jobs/{id}/resultretest_writeOutcome + evidencias. No cambia status del hallazgo.

{id} es UUID del job. Jobs de otro tenant → 404 JOB_NOT_FOUND. No hay listado de vulnerabilidades, SQL ni visor de logs.

Constantes: RETEST_JOB_CONTRACT = "eva.integration.retest.v1", LEASE_SECONDS = 300.

Query de pending: tool_id + execution_scope (obligatorios). site_key es opcional: si no se envía, la cola es la de hoy (todos los proyectos del tenant). El tenant sale del PAT. Varios runners pueden competir por el mismo tool+scope → 409 JOB_ALREADY_CLAIMED.

Sin CIDR ni cupo extract/ingest pending, claim, heartbeat y result no aplican allowed_cidrs ni el rate limit de 60/min. Un 403 INTEGRATION_IP_DENIED o 429 INTEGRATION_RATE_LIMITED no pertenece a esta superficie. Solo PAT + scope. El envelope del job no cambia.

Job opaco

El JSON nunca incluye: title, description, impact, recommendation, CVSS, CWEs, catálogo, severity, emails, URIs de evidencias previas, ni nombres de usuario.

Descargue: pending.example.json (Nuclei), pending.nmap.example.json (Nmap), pending.empty.json (sin trabajo). Todos los fixtures: examples/.

{
  "contract": "eva.integration.retest.v1",
  "job": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": "pending",
    "tool_id": "nuclei",
    "execution_scope": "on_prem",
    "lease_expires_at": null,
    "runner_id": null,
    "recipe": { "kind": "nuclei", "templates": ["http/vulnerabilities"] },
    "target": {
      "asset_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "asset_type": "url",
      "value": "https://app.example"
    }
  }
}

Sin trabajo: { "contract": "eva.integration.retest.v1", "job": null }. Un job por respuesta, no un array. Status del job: pending | claimed | running | succeeded | failed | expired.

Recetas v1

EVA arma la receta al encolar. El runner no la cambia. Por defecto, Nuclei usa http/vulnerabilities y Nmap puertos 80,443. Si hay un CVE mapeado, pueden ir templates o puertos concretos.

{ "kind": "nuclei", "templates": ["http/vulnerabilities"] }

Nmap:

{ "kind": "nmap", "ports": "80,443" }

Cualquier otro kindoutcome=not_applicable sin error_code.

GET pending

curl -sS \
  -H "Authorization: Bearer ${EVA_INTEGRATION_TOKEN}" \
  -H "Accept: application/json" \
  "${EVA_API_BASE_URL}/api/v1/integrations/retests/pending?tool_id=nuclei&execution_scope=on_prem"

Query obligatoria: tool_id = nuclei | nmap; execution_scope = cloud | on_prem. Valores fuera del set → 422. Query opcional site_key: filtrar solo si el runner la envía; omitida = cola actual.

Pending no reclama el job. Filtro: tenant del PAT, tool_id + execution_scope (y site_key si se envía). No hay query tenant_id.

POST claim

Body: claim.request.json

{ "runner_id": "nuclei-lab-01" }

runner_id: 1–128, charset ^[A-Za-z0-9._:-]+$. Efectos: pendingclaimed, lease 300 s.

POST heartbeat

{ "runner_id": "nuclei-lab-01" }

Debe coincidir el claim holder y el lease vigente. Si claimed, pasa a running. Intervalo sugerido 30 s (menor que 300 s). Respuesta 200:

{
  "ok": true,
  "status": "running",
  "lease_expires_at": "2026-08-21T03:15:00+00:00"
}

POST result

Fixture: result.request.json

{
  "runner_id": "nuclei-lab-01",
  "outcome": "still_open",
  "error_code": null,
  "evidences": [
    {
      "filename": "nuclei.log",
      "evidence_type": "log",
      "content_base64": "Tm91bmQgdnVsbmVyYWJpbGl0eQo=",
      "caption": "nuclei stdout"
    }
  ]
}
CampoRegla
outcomeappears_fixed | still_open | error | not_applicable
error_codeObligatorio si outcome=error. Set cerrado abajo.
evidencesMáximo 3. Filename 1–255 sin / ni ... Tipo log | file. Base64 decodificado 1–262144 bytes. Caption opcional máx. 200.

outcome=error → job failed. Cualquier otro → job succeeded. El resultado no cambia el estado del hallazgo. La respuesta 200 incluye vulnerability_status de solo lectura (estado actual).

{
  "ok": true,
  "status": "succeeded",
  "outcome": "still_open",
  "vulnerability_status": "draft"
}

Error de herramienta: result.error.json

{
  "runner_id": "nuclei-lab-01",
  "outcome": "error",
  "error_code": "target_unreachable",
  "evidences": []
}

error_code (set cerrado)

CódigoCuándo
auth_failedEl target del job exige autenticación que el tool no tiene. Un 401 de EVA (PAT) no se envía aquí: el runner lo loguea como class local y no llega a result.
target_unreachableDNS, timeout o conexión rehusada hacia el asset, no hacia Flask.
tool_crashNuclei/Nmap exit ≠ 0 no clasificable
timeoutEl runner mató el proceso

Cualquier otro error_code → 422. Un fallo de red hacia EVA no se reporta como error_code: el runner reintenta. Operación: Runner → Health.

Interpretación Nuclei / Nmap → outcome

Nuclei: exit 0 con match → still_open. Exit 0 sin matches → appears_fixed. Timeout → error/timeout. Unreachable → error/target_unreachable. Crash → error/tool_crash.

Nmap: al menos un puerto de la receta openstill_open. Todos closed/filtered y el host respondió → appears_fixed. Host down → target_unreachable.

Esto alimenta el job; no sustituye la validación en EVA.