mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 07:11:28 +02:00
Every result and management table gains multi-row selection and the floating action bar targets and scans already had: copy, export selection, delete, and the dimension's own actions. Rescan and triage move off the inline strip, which now only offers the rescan that acts on the whole query. Delete is dimension-local. A row takes only what cannot stand without it: a web asset its stored responses, a secret its sightings. Endpoints, services and findings on a deleted host are kept, so every count stays equal to its table. POST /surface/delete is the one route and the scope is a WHERE, so an id from another scan deletes nothing. Vulnerabilities are addressable by template_id so the Issues view deletes a whole check. Exposures dismiss rather than delete: an exposure row is a web asset. Selection export runs through the export pipeline. Every surface filter gains ids, capped by MAX_SELECTED_ROWS. Secrets and software become exportable, which fixes two breaks: a bundle export raised KeyError on the secrets dimension, and mcp read a SURFACE_COLUMNS entry for secrets that did not exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyDpdVXL7YVsr5D7BVj2zt
175 lines
5.4 KiB
Python
175 lines
5.4 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from fastapi import HTTPException
|
|
|
|
from app.services.surface_delete import SurfaceDeleteService
|
|
from shared.definitions.surface import SurfaceDimension
|
|
from shared.models.http_asset import HttpAsset
|
|
from shared.models.secret import Secret, SecretSighting
|
|
from shared.models.subdomain import Subdomain
|
|
from shared.models.vulnerability import Vulnerability
|
|
from shared.services.asset_query import QueryScope
|
|
|
|
pytestmark = pytest.mark.api
|
|
|
|
|
|
async def _seed(estate, now):
|
|
await estate.scan("example.com", "run", at=now)
|
|
await estate.scan("example.com", "other", at=now)
|
|
await estate.hosts("run", ["www.example.com", "api.example.com"], at=now)
|
|
await estate.hosts("other", ["www.example.com"], at=now)
|
|
await estate.assets("run", ["www.example.com"], at=now, port=443)
|
|
await estate.assets("run", ["www.example.com"], at=now, port=80)
|
|
await estate.assets("run", ["api.example.com"], at=now)
|
|
await estate.vulns("run", [("weak-tls-a", "low"), ("weak-tls-b", "low")], at=now)
|
|
await estate.session.flush()
|
|
|
|
|
|
def _scope(estate, name: str) -> QueryScope:
|
|
return QueryScope((estate.scans[name],), project_id=estate.project_id)
|
|
|
|
|
|
async def _host_id(estate, scan: str, name: str):
|
|
return await estate.session.scalar(
|
|
sa.select(Subdomain.id).where(
|
|
Subdomain.scan_id == estate.scans[scan], Subdomain.name == name
|
|
)
|
|
)
|
|
|
|
|
|
async def test_a_deleted_web_asset_takes_its_stored_responses(estate, now):
|
|
await _seed(estate, now)
|
|
doomed = await _host_id(estate, "run", "www.example.com")
|
|
|
|
result = await SurfaceDeleteService(estate.session).delete(
|
|
_scope(estate, "run"), SurfaceDimension.WEB_ASSETS.value, [str(doomed)]
|
|
)
|
|
|
|
assert result.deleted == 1
|
|
assert result.related == {"http_assets": 2}
|
|
left = await estate.session.scalar(
|
|
sa.select(sa.func.count()).select_from(
|
|
sa.select(HttpAsset.id)
|
|
.where(HttpAsset.scan_id == estate.scans["run"])
|
|
.subquery()
|
|
)
|
|
)
|
|
assert left == 1
|
|
|
|
|
|
async def test_a_row_outside_the_scope_is_not_deleted(estate, now):
|
|
await _seed(estate, now)
|
|
doomed = await _host_id(estate, "other", "www.example.com")
|
|
|
|
result = await SurfaceDeleteService(estate.session).delete(
|
|
_scope(estate, "run"), SurfaceDimension.WEB_ASSETS.value, [str(doomed)]
|
|
)
|
|
|
|
assert result.deleted == 0
|
|
assert await estate.session.get(Subdomain, doomed) is not None
|
|
|
|
|
|
async def test_an_issue_deletes_every_finding_of_that_check(estate, now):
|
|
await _seed(estate, now)
|
|
scan_id = estate.scans["run"]
|
|
estate.session.add(
|
|
Vulnerability(
|
|
project_id=estate.project_id,
|
|
scan_id=scan_id,
|
|
target_id=await estate._target_of(scan_id),
|
|
fingerprint="weak-tls-a-2",
|
|
template_id="weak-tls-a",
|
|
template_name="Weak Tls A",
|
|
severity="low",
|
|
matched_at="https://api.example.com/",
|
|
host="api.example.com",
|
|
discovered_at=now,
|
|
)
|
|
)
|
|
await estate.session.flush()
|
|
|
|
result = await SurfaceDeleteService(estate.session).delete(
|
|
_scope(estate, "run"),
|
|
SurfaceDimension.VULNERABILITIES.value,
|
|
["weak-tls-a"],
|
|
key="template_id",
|
|
)
|
|
|
|
assert result.deleted == 2
|
|
left = await estate.session.scalars(
|
|
sa.select(Vulnerability.template_id).where(Vulnerability.scan_id == scan_id)
|
|
)
|
|
assert set(left.all()) == {"weak-tls-b"}
|
|
|
|
|
|
async def test_a_deleted_secret_takes_its_sightings(estate, now):
|
|
await _seed(estate, now)
|
|
scan_id = estate.scans["run"]
|
|
target_id = await estate._target_of(scan_id)
|
|
secret = Secret(
|
|
project_id=estate.project_id,
|
|
scan_id=scan_id,
|
|
target_id=target_id,
|
|
fingerprint="f" * 8,
|
|
kind="google_api_key",
|
|
group="cloud",
|
|
value="AIza-test",
|
|
host="www.example.com",
|
|
url="https://www.example.com/",
|
|
discovered_at=now,
|
|
)
|
|
estate.session.add(secret)
|
|
await estate.session.flush()
|
|
estate.session.add(
|
|
SecretSighting(
|
|
secret_id=secret.id,
|
|
scan_id=scan_id,
|
|
target_id=target_id,
|
|
project_id=estate.project_id,
|
|
host="www.example.com",
|
|
url="https://www.example.com/app.js",
|
|
)
|
|
)
|
|
await estate.session.flush()
|
|
|
|
result = await SurfaceDeleteService(estate.session).delete(
|
|
_scope(estate, "run"), SurfaceDimension.SECRETS.value, [str(secret.id)]
|
|
)
|
|
|
|
assert result.deleted == 1
|
|
left = await estate.session.scalar(
|
|
sa.select(sa.func.count()).select_from(
|
|
sa.select(SecretSighting.id)
|
|
.where(SecretSighting.scan_id == scan_id)
|
|
.subquery()
|
|
)
|
|
)
|
|
assert left == 0
|
|
|
|
|
|
async def test_an_unknown_key_is_refused(estate, now):
|
|
await _seed(estate, now)
|
|
|
|
with pytest.raises(HTTPException) as raised:
|
|
await SurfaceDeleteService(estate.session).delete(
|
|
_scope(estate, "run"),
|
|
SurfaceDimension.SECRETS.value,
|
|
["x"],
|
|
key="value",
|
|
)
|
|
|
|
assert raised.value.status_code == 422
|
|
|
|
|
|
async def test_an_unknown_dimension_is_refused(estate, now):
|
|
await _seed(estate, now)
|
|
|
|
with pytest.raises(HTTPException) as raised:
|
|
await SurfaceDeleteService(estate.session).delete(
|
|
_scope(estate, "run"), "nope", ["x"]
|
|
)
|
|
|
|
assert raised.value.status_code == 422
|