Files
rengine/shared/models/api_key.py
T
Yogesh OjhaandClaude Opus 5 d7c8b6e01f feat(vulns): keep listening for an out-of-band callback, and let a self-hosted server be reached
Blind injection is invisible unless the target calls back, and reNgine had the
switch and the server field without either of the two things that make them
work.

nuclei's -interactions-cooldown-period defaults to 5 seconds: it stops polling
five seconds after its last request, so a callback a queue, a cron or a report
generator fires minutes later is never heard. oast_wait_minutes (default 5) is
the one operator-facing number and drives both that and -interactions-eviction,
because a request evicted from nuclei's cache before its callback arrives leaves
the callback with nothing to attribute it to — eviction is always the wait plus
slack.

A self-hosted server is normally token-gated and -interactsh-token was never
passed, so a configured server silently fell back to the public one and the
callbacks went somewhere the operator does not own. The token is an
APIProvider.INTERACTSH row — encrypted at rest and masked on read like every
other key — rather than a stage field, because an OAST server is instance
infrastructure, not a per-engine choice. _oast_server() reduces whatever is
typed to a bare host: nuclei wants the host, and a scheme or trailing path makes
it register nowhere and fall back without saying so.

redact_command now matches on how a flag ends rather than on the whole word.
-interactsh-token was redacted only by the accident of the pattern matching its
-token suffix, while nuclei's own short form -itoken wrote the secret straight
into scan_commands.

What this deliberately is not: reNgine owning an interactsh session of its own —
registering a correlation id, decrypting polled interactions and injecting its
own payloads on the probe path. That is a package the size of connectors/, and
the only way to hear a callback for a request nuclei did not send.

20 tests. Two mutations survived the first cut — an empty token row becoming
`-interactsh-token ""`, and the token being read for a run that will not use it —
because the tests built options directly instead of going through the scanner.
Both are covered and dead now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013dKUV96WkQQETZBMoAG98C
2026-09-11 09:13:57 +05:30

122 lines
3.7 KiB
Python

import uuid
from datetime import datetime
from pydantic import BaseModel
from sqlalchemy import Column
from sqlalchemy.types import JSON
from sqlmodel import Field, SQLModel, UniqueConstraint
from shared.enums.api_key import APIProvider
from shared.utils.datetime import utc_now
API_PROVIDER_META: dict[str, dict] = {
APIProvider.VIEWDNS: {
"name": "ViewDNS.info",
"description": "Used for DNS intelligence, reverse lookups and DNS history",
"docs_url": "https://viewdns.info/api/?src=reNgine",
"requires_username": False,
"icon": "scan-search",
"color": "#6366f1",
},
APIProvider.CHAOS: {
"name": "Chaos",
"description": "ProjectDiscovery Chaos subdomain dataset for recon",
"docs_url": "https://cloud.projectdiscovery.io",
"requires_username": False,
"icon": "radar",
"color": "#f59e0b",
},
APIProvider.NETLAS: {
"name": "Netlas",
"description": "Internet-wide asset and attack-surface intelligence",
"docs_url": "https://netlas.io",
"requires_username": False,
"icon": "globe",
"color": "#10b981",
},
APIProvider.SECURITYTRAILS: {
"name": "SecurityTrails",
"description": "DNS, domain and subdomain history intelligence",
"docs_url": "https://securitytrails.com",
"requires_username": False,
"icon": "route",
"color": "#3b82f6",
},
APIProvider.HACKERONE: {
"name": "HackerOne",
"description": "Pull program scope and reports from HackerOne",
"docs_url": "https://api.hackerone.com",
"requires_username": True,
"icon": "shield",
"color": "#ef4444",
},
APIProvider.VULNX: {
"name": "vulnx",
"description": "ProjectDiscovery vulnerability intelligence: exploits, coverage and exposure",
"docs_url": "https://cloud.projectdiscovery.io",
"requires_username": False,
"icon": "biohazard",
"color": "#f43f5e",
},
APIProvider.INTERACTSH: {
"name": "Interactsh",
"description": "Auth token for your own out-of-band server, so blind callbacks reach you and nobody else",
"docs_url": "https://github.com/projectdiscovery/interactsh",
"requires_username": False,
"icon": "satellite-dish",
"color": "#8b5cf6",
},
}
class APIKey(SQLModel, table=True):
__tablename__ = "api_keys"
__table_args__ = (UniqueConstraint("provider", name="uq_api_key_provider"),)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
provider: APIProvider = Field(index=True)
key_value: str = Field(max_length=1000)
key_meta: dict | None = Field(default=None, sa_column=Column(JSON, nullable=True))
is_enabled: bool = Field(default=True)
usage_counter: int = Field(default=0)
last_used_at: datetime | None = Field(default=None)
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
class APIKeyCreate(BaseModel):
provider: APIProvider
key_value: str
key_meta: dict | None = None
class APIKeyUpdate(BaseModel):
key_value: str | None = None
is_enabled: bool | None = None
key_meta: dict | None = None
class APIKeyRead(BaseModel):
id: uuid.UUID
provider: APIProvider
key_value_masked: str
key_meta: dict | None = None
is_enabled: bool
usage_counter: int
last_used_at: datetime | None
created_at: datetime
updated_at: datetime
meta: dict
class ProviderInfo(BaseModel):
provider: APIProvider
name: str
description: str
docs_url: str
icon: str = "package"
color: str = "#64748b"
requires_username: bool = False
configured: bool = False
is_enabled: bool = False