mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-21 15:51:30 +02:00
reNgine could say what was new against a target's whole history, and 2.x could say `[new, removed]` for subdomains. Neither answers the question an operator actually asks after a scan finishes: what changed since that run, the named one I am comparing against. The hard part is not the diff. Two StandardRengine runs of vulnweb.com in this instance, eighteen minutes apart, went from 14 findings to 0. A set diff reports "14 findings resolved, 12 high". The truth is `vulnerability_scan.max_minutes` went 0 to 2, and the coverage rows record 57,100 requests over 368s against four hosts, then 27,582 over 120s against seven. The delta belongs to the run. So comparability is stated before any delta, per dimension: like for like, settings differ, runs differ in depth, not scanned. Stage settings and the runs' own setup are both compared — engine, context, auth type, intensity, proxy (as set/none, never the URL), scope exclusions, protocol, crawl. Facets that change what a run can find raise the verdict; threads and rate limits do not. Runs that ran between the pair are counted, a cancelled run in the pair lowers the verdict, and when the chosen pair is not like for like the nearest run that would be is offered. Identity is read off the per-scan unique constraints already on each table — `subdomains.name`, `endpoints.signature`, `ports.(ip, number, protocol)`, `ip_addresses.ip`, `vulnerabilities.fingerprint` — so the diff key is not a new invention. Four verbs: appeared, changed, disappeared, unchanged. Disappearance is only asserted when the producing stage finished cleanly; dnsx drops 20-60% of names silently, so a partial run's missing rows are unconfirmed, a bucket of their own. A dimension one run did not produce reports not scanned, never everything removed. EPSS and KEV movement is Class D and is counted apart. A disappeared row does not exist in the later run, so a query scoped to it can never return one. Rather than teach five grammars an `is:added` flag, the service flips the scope per verb, and every column, sort key and detail sheet keeps working. Rank is computed once as a SQL CASE and the signal name is recovered from the rank, so the order and the label cannot disagree. Nothing is stored: a 425k-row full outer join measures 66 ms, the whole endpoint about 31 ms. No table, no migration. Direction is a shape — solid, split, hollow rails — not green-and-red. New attack surface is not good news. Diff mode keeps the git glyphs for the same reason it colours them from the `--code-*` ramp: in a diff `+` is syntax. That mode is generated once on the server and shown through `code-block`, so the screen and the clipboard cannot drift. Eligibility is explained, never hidden. `REFUSAL_REASON` is one definition mirrored in `config/compare.ts`, so the disabled button's hint, the alert page and the API error say the same sentence, and an impossible pair reached by URL lands on an alert that lists the target's runs to pick from. `recheck.py` now reads its host field list from the shared definitions, so the product holds one answer to "did this host change". Also fixes the scan hero, which said "No change since the previous scan" while measuring only hosts. Beside a page reporting 21 changes that read as a contradiction; it now says what it measured. Vulnerability rows are one per finding rather than grouped by check, and comparing against a run that is still going is not built. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KAVgTQe8K3EvGzPo5HVmKF
416 lines
13 KiB
Python
416 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import uuid
|
|
from collections.abc import AsyncIterator
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
|
|
from shared.config import BaseAppSettings
|
|
from shared.enums.target import TargetType
|
|
from shared.models.endpoint import Endpoint
|
|
from shared.models.http_asset import HttpAsset
|
|
from shared.models.port import Port
|
|
from shared.models.project import Project
|
|
from shared.models.scan import Scan
|
|
from shared.models.scan_activity import ScanActivity
|
|
from shared.models.subdomain import Subdomain
|
|
from shared.models.target import Target
|
|
from shared.models.user import User
|
|
from shared.models.vulnerability import Vulnerability
|
|
from shared.utils.datetime import utc_now
|
|
|
|
TEST_DB = os.environ.get("POSTGRES_TEST_DB", "rengine_test")
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
ALEMBIC = shutil.which("alembic") or str(Path(sys.executable).parent / "alembic")
|
|
|
|
|
|
def _admin_url() -> str:
|
|
s = BaseAppSettings()
|
|
return (
|
|
f"postgresql+asyncpg://{s.POSTGRES_USER}:{s.POSTGRES_PASSWORD}"
|
|
f"@{s.POSTGRES_HOST}:{s.POSTGRES_PORT}/postgres"
|
|
)
|
|
|
|
|
|
def _test_url(driver: str = "postgresql+asyncpg") -> str:
|
|
s = BaseAppSettings()
|
|
return (
|
|
f"{driver}://{s.POSTGRES_USER}:{s.POSTGRES_PASSWORD}"
|
|
f"@{s.POSTGRES_HOST}:{s.POSTGRES_PORT}/{TEST_DB}"
|
|
)
|
|
|
|
|
|
async def _recreate_database() -> None:
|
|
engine = create_async_engine(_admin_url(), isolation_level="AUTOCOMMIT")
|
|
async with engine.connect() as conn:
|
|
await conn.execute(
|
|
sa.text(
|
|
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
|
|
"WHERE datname = :db AND pid <> pg_backend_pid()"
|
|
),
|
|
{"db": TEST_DB},
|
|
)
|
|
await conn.execute(sa.text(f'DROP DATABASE IF EXISTS "{TEST_DB}"'))
|
|
await conn.execute(sa.text(f'CREATE DATABASE "{TEST_DB}"'))
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="session")
|
|
async def database() -> AsyncIterator[str]:
|
|
"""A migrated database of its own."""
|
|
await _recreate_database()
|
|
env = {**os.environ, "POSTGRES_DB": TEST_DB}
|
|
proc = await asyncio.to_thread(
|
|
subprocess.run,
|
|
[ALEMBIC, "upgrade", "head"],
|
|
cwd=str(REPO_ROOT),
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if proc.returncode != 0:
|
|
pytest.fail(f"alembic upgrade failed:\n{proc.stdout}\n{proc.stderr}")
|
|
yield _test_url()
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="session")
|
|
async def engine(database: str):
|
|
eng = create_async_engine(database, future=True, poolclass=sa.pool.NullPool)
|
|
yield eng
|
|
await eng.dispose()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def session(engine) -> AsyncIterator[AsyncSession]:
|
|
"""One transaction per test, rolled back."""
|
|
maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
async with maker() as s:
|
|
try:
|
|
yield s
|
|
finally:
|
|
await s.rollback()
|
|
|
|
|
|
class Estate:
|
|
"""A project, a target and the scans under it, addressed by name."""
|
|
|
|
def __init__(self, session: AsyncSession):
|
|
self.session = session
|
|
self.project_id = uuid.uuid4()
|
|
self.user_id = uuid.uuid4()
|
|
self.targets: dict[str, uuid.UUID] = {}
|
|
self.scans: dict[str, uuid.UUID] = {}
|
|
|
|
async def setup(self) -> Estate:
|
|
self.session.add(
|
|
User(
|
|
id=self.user_id,
|
|
username=f"t{self.user_id.hex[:8]}",
|
|
email=f"{self.user_id.hex[:8]}@test.local",
|
|
hashed_password="x",
|
|
is_superuser=True,
|
|
)
|
|
)
|
|
await self.session.flush()
|
|
self.session.add(
|
|
Project(
|
|
id=self.project_id,
|
|
name="Test",
|
|
slug=f"test-{self.project_id.hex[:8]}",
|
|
created_by=self.user_id,
|
|
)
|
|
)
|
|
await self.session.flush()
|
|
return self
|
|
|
|
async def target(
|
|
self, value: str, kind: TargetType = TargetType.DOMAIN
|
|
) -> uuid.UUID:
|
|
if value in self.targets:
|
|
return self.targets[value]
|
|
tid = uuid.uuid4()
|
|
self.session.add(
|
|
Target(
|
|
id=tid,
|
|
project_id=self.project_id,
|
|
target_value=value,
|
|
target_type=kind,
|
|
created_by=self.user_id,
|
|
)
|
|
)
|
|
await self.session.flush()
|
|
self.targets[value] = tid
|
|
return tid
|
|
|
|
async def scan(
|
|
self,
|
|
target: str,
|
|
name: str,
|
|
*,
|
|
at: datetime,
|
|
status: str = "completed",
|
|
config: dict | None = None,
|
|
scope: str = "full",
|
|
engine: str | None = None,
|
|
) -> uuid.UUID:
|
|
tid = await self.target(target)
|
|
sid = uuid.uuid4()
|
|
self.session.add(
|
|
Scan(
|
|
id=sid,
|
|
project_id=self.project_id,
|
|
target_id=tid,
|
|
engine_name=engine or name,
|
|
execution_config=config or {},
|
|
scope=scope,
|
|
status=status,
|
|
created_by=self.user_id,
|
|
created_at=at,
|
|
started_at=at,
|
|
completed_at=at + timedelta(minutes=1),
|
|
)
|
|
)
|
|
await self.session.flush()
|
|
self.scans[name] = sid
|
|
return sid
|
|
|
|
async def hosts(
|
|
self,
|
|
scan: str,
|
|
names: list[str],
|
|
*,
|
|
at: datetime,
|
|
ips: list[str] | None = None,
|
|
status: int | None = None,
|
|
title: str | None = None,
|
|
tech: list[str] | None = None,
|
|
cname: str | None = None,
|
|
webserver: str | None = None,
|
|
cdn_name: str | None = None,
|
|
favicon: str | None = None,
|
|
sources: list[str] | None = None,
|
|
) -> None:
|
|
sid = self.scans[scan]
|
|
target_id = await self._target_of(sid)
|
|
for n in names:
|
|
self.session.add(
|
|
Subdomain(
|
|
project_id=self.project_id,
|
|
scan_id=sid,
|
|
target_id=target_id,
|
|
name=n,
|
|
discovered_at=at,
|
|
sources=sources or ["test"],
|
|
resolved_ips=ips or [],
|
|
http_status=status,
|
|
page_title=title,
|
|
tech=tech or [],
|
|
cname=cname,
|
|
webserver=webserver,
|
|
is_cdn=cdn_name is not None,
|
|
cdn_name=cdn_name,
|
|
favicon_hash=favicon,
|
|
)
|
|
)
|
|
await self.session.flush()
|
|
|
|
async def assets(
|
|
self,
|
|
scan: str,
|
|
rows: list[str],
|
|
*,
|
|
at: datetime,
|
|
ip: str | None = None,
|
|
port: int = 443,
|
|
status: int = 200,
|
|
content_hash: str | None = None,
|
|
jarm: str | None = None,
|
|
issuer: str | None = None,
|
|
body: str | None = None,
|
|
) -> None:
|
|
sid = self.scans[scan]
|
|
target_id = await self._target_of(sid)
|
|
for host in rows:
|
|
self.session.add(
|
|
HttpAsset(
|
|
project_id=self.project_id,
|
|
scan_id=sid,
|
|
target_id=target_id,
|
|
url=f"https://{host}:{port}",
|
|
host=host,
|
|
port=port,
|
|
status_code=status,
|
|
ip=ip,
|
|
content_hash=content_hash,
|
|
response_body=body,
|
|
jarm=jarm,
|
|
tls_issuer=issuer,
|
|
discovered_at=at,
|
|
)
|
|
)
|
|
await self.session.flush()
|
|
|
|
async def endpoints(
|
|
self,
|
|
scan: str,
|
|
paths: list[str],
|
|
*,
|
|
at: datetime,
|
|
host: str = "www.example.com",
|
|
status: int | None = None,
|
|
kind: str = "page",
|
|
sources: list[str] | None = None,
|
|
interest: list[str] | None = None,
|
|
params: int = 0,
|
|
) -> None:
|
|
sid = self.scans[scan]
|
|
target_id = await self._target_of(sid)
|
|
for path in paths:
|
|
directory, _, filename = path.rpartition("/")
|
|
extension = filename.rpartition(".")[2] if "." in filename else None
|
|
self.session.add(
|
|
Endpoint(
|
|
project_id=self.project_id,
|
|
scan_id=sid,
|
|
target_id=target_id,
|
|
signature=uuid.uuid4().hex,
|
|
url=f"https://{host}{path}",
|
|
host=host,
|
|
path=path,
|
|
dir_path=f"{directory}/" if directory else "/",
|
|
filename=filename or None,
|
|
extension=extension,
|
|
endpoint_class=kind,
|
|
sources=sources or ["seed"],
|
|
primary_source=(sources or ["seed"])[0],
|
|
interest=interest or [],
|
|
param_count=params,
|
|
is_probed=status is not None,
|
|
status_code=status,
|
|
discovered_at=at,
|
|
)
|
|
)
|
|
await self.session.flush()
|
|
|
|
async def activity(
|
|
self, scan: str, rows: dict[str, str], *, at: datetime | None = None
|
|
) -> None:
|
|
sid = self.scans[scan]
|
|
for name, state in rows.items():
|
|
self.session.add(
|
|
ScanActivity(
|
|
scan_id=sid,
|
|
project_id=self.project_id,
|
|
name=name,
|
|
title=name.replace("_", " ").title(),
|
|
status=state,
|
|
started_at=at,
|
|
completed_at=at,
|
|
)
|
|
)
|
|
await self.session.flush()
|
|
|
|
async def vulns(
|
|
self,
|
|
scan: str,
|
|
rows: list[tuple[str, str]],
|
|
*,
|
|
at: datetime,
|
|
host: str = "www.example.com",
|
|
kev: bool = False,
|
|
) -> None:
|
|
sid = self.scans[scan]
|
|
target_id = await self._target_of(sid)
|
|
for fingerprint, severity in rows:
|
|
self.session.add(
|
|
Vulnerability(
|
|
project_id=self.project_id,
|
|
scan_id=sid,
|
|
target_id=target_id,
|
|
fingerprint=fingerprint,
|
|
template_id=fingerprint,
|
|
template_name=fingerprint.replace("-", " ").title(),
|
|
severity=severity,
|
|
matched_at=f"https://{host}/",
|
|
host=host,
|
|
is_kev=kev,
|
|
discovered_at=at,
|
|
)
|
|
)
|
|
await self.session.flush()
|
|
|
|
async def _target_of(self, scan_id: uuid.UUID) -> uuid.UUID:
|
|
return await self.session.scalar(
|
|
sa.select(Scan.target_id).where(Scan.id == scan_id)
|
|
)
|
|
|
|
async def ports(
|
|
self, scan: str, rows: list[tuple[str, int, str]], *, at: datetime
|
|
) -> None:
|
|
sid = self.scans[scan]
|
|
target_id = await self._target_of(sid)
|
|
for ip, number, service in rows:
|
|
self.session.add(
|
|
Port(
|
|
project_id=self.project_id,
|
|
scan_id=sid,
|
|
target_id=target_id,
|
|
ip=ip,
|
|
number=number,
|
|
protocol="tcp",
|
|
service_name=service,
|
|
source="naabu",
|
|
discovered_at=at,
|
|
)
|
|
)
|
|
await self.session.flush()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def estate(session: AsyncSession) -> Estate:
|
|
return await Estate(session).setup()
|
|
|
|
|
|
async def _truncate(engine) -> None:
|
|
"""Truncate the roots; CASCADE clears the rest."""
|
|
async with engine.begin() as conn:
|
|
await conn.execute(
|
|
sa.text(
|
|
"TRUNCATE users, projects, instance_settings RESTART IDENTITY CASCADE"
|
|
)
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def durable(engine) -> AsyncIterator[AsyncSession]:
|
|
"""A session that may commit; tables are truncated around it."""
|
|
await _truncate(engine)
|
|
maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
async with maker() as s:
|
|
try:
|
|
yield s
|
|
finally:
|
|
await s.rollback()
|
|
await _truncate(engine)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def durable_estate(durable: AsyncSession) -> Estate:
|
|
return await Estate(durable).setup()
|
|
|
|
|
|
@pytest.fixture
|
|
def now() -> datetime:
|
|
return utc_now()
|