Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ CYBERAI_USE_EXPLOIT_MEMORY=0
CYBERAI_USE_LAB_DOGFOOD=0
CYBERAI_WEB_ENABLE_BENCH_TRIGGER=0
CYBERAI_AIR_GAPPED=0
CYBERAI_STRICT_SCOPE=0
CYBERAI_ENABLE_MODEL_ROUTING=0

# Numeric / path settings (optional)
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ All notable changes to CyberAI are documented here.

### Changed

- **Development status: Alpha to Beta.** The classifier said Alpha while the
package carried a wired trust boundary, decontaminated benchmark proofs, an
architecture-tested README and 2200-odd tests. Beta claims the interfaces
are stable enough to build against and that the failure modes are known and
written down -- not that the tool is finished.

- **Licence: MIT to Apache-2.0.** Releases up to and including v1.5.0 were
published under the MIT License and remain available under those terms
permanently, on both GitHub and PyPI; the change is not retroactive and
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ Every setting can be driven from the environment (or a `.env` file - see
| `CYBERAI_ENABLE_REPLAN` | Critic-driven phase replan |
| `CYBERAI_USE_EXPLOIT_MEMORY` | Recall similar past exploit chains |
| `CYBERAI_AIR_GAPPED` | Force local-only (no-egress) LLM path |
| `CYBERAI_STRICT_SCOPE` | Refuse the exploit phase when no scope was given |
| `CYBERAI_ENABLE_MODEL_ROUTING` | Per-phase model selection |
| `CYBERAI_MAX_COST_USD` | LLM spend budget (0 = disabled) |
| `CYBERAI_OUTPUT_DIR` | Report output directory |
Expand Down
10 changes: 10 additions & 0 deletions cyberai/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def _apply_feature_overrides(
replan: bool | None = None,
planner: bool | None = None,
air_gapped: bool | None = None,
strict_scope: bool | None = None,
web_recon: bool | None = None,
web_exploit: bool | None = None,
oob: bool | None = None,
Expand Down Expand Up @@ -100,6 +101,8 @@ def _apply_feature_overrides(
config.enable_planner = planner
if air_gapped is not None:
config.air_gapped = air_gapped
if strict_scope is not None:
config.strict_scope = strict_scope
if web_recon is not None:
config.use_web_recon = web_recon
if web_exploit is not None:
Expand Down Expand Up @@ -162,6 +165,11 @@ def cli() -> None:
@click.option("--model", default=None, help="LLM model (overrides provider default)")
@click.option("--dry-run", is_flag=True, help="Run pipeline without real network calls")
@click.option("--scope", multiple=True, help="Authorized scope entry (repeatable)")
@click.option(
"--strict-scope/--no-strict-scope",
default=None,
help="Refuse to exploit when no scope was given, instead of warning",
)
@click.option(
"--auth",
multiple=True,
Expand Down Expand Up @@ -266,6 +274,7 @@ def scan(
replan: bool | None,
planner: bool | None,
air_gapped: bool | None,
strict_scope: bool | None,
web_recon: bool | None,
web_exploit: bool | None,
oob: bool | None,
Expand Down Expand Up @@ -305,6 +314,7 @@ def scan(
replan=replan,
planner=planner,
air_gapped=air_gapped,
strict_scope=strict_scope,
web_recon=web_recon,
web_exploit=web_exploit,
oob=oob,
Expand Down
19 changes: 17 additions & 2 deletions cyberai/agents/exploit/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ def _oob_confirmer(self, target: str):
confirmation entirely rather than spending its wait on every parameter
to learn the same thing each time.
"""
if not self._scope_ok(target):
self._log(f"target {target} out of authorized scope -- no OOB confirmation")
return None

configured = self.config.phantom.grid_url or ""
parsed = urlparse(configured)
port = f":{parsed.port}" if parsed.port else ""
Expand Down Expand Up @@ -254,6 +258,10 @@ def _run_web_exploit(self, target: str, classes: Optional[List[Any]] = None) ->
"""
from cyberai.core.scan_session import Severity

if not self._scope_ok(target):
self._log(f"target {target} out of authorized scope -- skipping web exploitation")
return {"confirmed": 0, "endpoints_tested": 0, "findings": []}

surface = self.kb.get("recon.web_surface", {}) or {}
endpoints = surface.get("endpoints", [])
if not endpoints:
Expand Down Expand Up @@ -494,8 +502,15 @@ def _scope_ok(self, target: str) -> bool:
"""True if `target` is authorized for live actions.

Empty scope preserves prior behaviour (no gate -> allowed); a non-empty
scope the target does not match blocks live actions (nuclei / OOB),
closing the bypass around the orchestrator-level scope gate.
scope the target does not match blocks live actions, closing the bypass
around the orchestrator-level scope gate.

Every method that puts a packet on the wire consults this. The guard
was written to cover nuclei and the out-of-band path and reached only
nuclei: `_oob_confirmer` built a collector for a target the scope
excluded, and the HTTP walk sent its whole payload corpus without ever
asking. The orchestrator gates `session.target`, which is the same
value on a normal run and not the value these methods are handed.
"""
scope = getattr(getattr(self, "session", None), "authorized_scope", None) or []
if not scope:
Expand Down
15 changes: 14 additions & 1 deletion cyberai/agents/exploit/safety_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def validate_exploit_scope(
target: str,
authorized_scope: List[str] = None,
attack_paths: List[Dict[str, Any]] = None,
strict: bool = False,
) -> ValidationResult:
"""
Validates that target and attack paths are within authorized scope.
Expand Down Expand Up @@ -79,7 +80,19 @@ def validate_exploit_scope(
violations.extend(ip_violations)

if not authorized_scope:
warnings.append("No authorized_scope provided — proceeding without scope check")
# Absence of scope is not authorisation. Under strict it is a refusal:
# the caller asked for a run that cannot touch anything it was not
# pointed at, and an empty list points at nothing. The default stays
# a warning, because the pipeline has always proceeded here and a
# flag that changes behaviour for people who did not ask for it is
# not a safety feature, it is a surprise.
if strict:
violations.append(
"No authorized_scope provided and --strict-scope is set "
"— refusing to exploit an unscoped target"
)
else:
warnings.append("No authorized_scope provided — proceeding without scope check")
elif not in_scope:
violations.append(f"Target '{target}' is NOT in authorized scope: {authorized_scope}")

Expand Down
45 changes: 40 additions & 5 deletions cyberai/agents/intel/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,53 @@ def _register_tools(self) -> None:
)
)

def _enrich_tls_findings(self) -> None:
"""Attach CVE context to the TLS findings recon left in the KB.
def _enrich_tls_findings(self, target: str) -> None:
"""Turn the TLS problems recon found into findings, with CVE context.

TLSCVEMapper existed with no product caller: recon wrote recon.tls,
the mapper knew how to read it, and nothing joined them. The findings
list from TLSTool.run is already the shape the mapper expects.
the mapper knew how to read it, and nothing joined them. Joining them
into the knowledge base reproduced the same defect one level up --
`intel.tls_findings` had no reader either, and the report renders
session.findings, not the KB. So an expired certificate, a negotiated
TLS 1.0 and RC4 in the suite were all probed, classified, matched to
CVEs, and then left out of the document the client reads.

The finding is raised here rather than in recon because the CVE
context is what makes it actionable, and recon has no mapper. Recon
publishes no TLS finding at all, so there is nothing to duplicate.

Severity travels from the TLS classifier rather than being decided
again here: it already knows a deprecated protocol is worse than a
weak cipher. An unrecognised level degrades to INFO instead of
raising, because a report that loses a finding over a spelling is
worse than one that under-rates it.
"""
tls_data = self.kb.get("recon.tls", {}) or {}
findings = tls_data.get("findings", []) if isinstance(tls_data, dict) else []
if not findings:
return
enriched = TLSCVEMapper().enrich(findings)
self.kb.set("intel.tls_findings", enriched, agent=self.AGENT_NAME)

for item in enriched:
level = str(item.get("severity") or "").upper()
severity = getattr(Severity, level, Severity.INFO)
cves = [c for c in (item.get("cves") or []) if c]
evidence = [item.get("detail", "")]
if item.get("remediation"):
evidence.append(f"Remediation: {item['remediation']}")
if not cves:
evidence.append("No CVE maps to this condition; it is a configuration issue")
self.session.add_finding(
severity=severity,
title=item.get("issue") or "TLS configuration issue",
description=item.get("detail", ""),
agent=self.AGENT_NAME,
target=target,
cve_ids=cves,
evidence=evidence,
)

with_cves = sum(1 for f in enriched if f.get("cves"))
self._log(f"TLS: {with_cves}/{len(enriched)} findings mapped to CVEs")

Expand All @@ -85,7 +119,8 @@ def run(self, target: str, context: Optional[Dict[str, Any]] = None) -> Dict[str
# This runs before the port checks below because those return early:
# an HTTPS target with no open ports in the nmap result would
# otherwise drop TLS data that recon already put in the KB.
self._enrich_tls_findings()

self._enrich_tls_findings(target)

nmap_data = self.kb.get("recon.nmap", {}) or {}
ports = nmap_data.get("ports", []) if isinstance(nmap_data, dict) else []
Expand Down
5 changes: 5 additions & 0 deletions cyberai/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,10 @@ class CyberAIConfig:
routing: "RoutingConfig" = field(default_factory=lambda: RoutingConfig())
# Flag-gated: force all LLM calls onto a local endpoint, assert no egress.
air_gapped: bool = False
# Flag-gated: an empty authorized scope becomes a violation instead of a
# warning, so the exploit phase refuses to run against a target nobody
# named. Off by default -- see the validator for why.
strict_scope: bool = False
# Cap nmap scan rate (packets/sec) on external/legal targets; None = uncapped.
max_rps: Optional[int] = None
# Headers every web request carries. A target that refuses an anonymous
Expand Down Expand Up @@ -257,4 +261,5 @@ def from_env(cls) -> "CyberAIConfig":
use_lab_dogfood=_env_bool("CYBERAI_USE_LAB_DOGFOOD", False),
web_enable_bench_trigger=_env_bool("CYBERAI_WEB_ENABLE_BENCH_TRIGGER", False),
air_gapped=_env_bool("CYBERAI_AIR_GAPPED", False),
strict_scope=_env_bool("CYBERAI_STRICT_SCOPE", False),
)
52 changes: 49 additions & 3 deletions cyberai/core/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,42 @@
from rich.panel import Panel

from cyberai.core.config import CyberAIConfig
from cyberai.core.cost_tracker import BudgetExceeded
from cyberai.core.egress_guard import EgressViolation
from cyberai.core.llm_usage import llm_usage_record, llm_zero_reason
from cyberai.core.logger import AuditLogger, get_logger
from cyberai.core.scan_session import ScanPhase, ScanSession

console = Console()
log = get_logger("orchestrator")

# A phase is allowed to fail. Two things are not phase failures at all: they
# are the end of a guarantee the run was given, and continuing past them
# turns that guarantee into a suggestion.
#
# BudgetExceeded means a spending cap was crossed. Swallowing it per phase
# leaves the next phase free to spend again, so the cap stops being a cap
# exactly when it starts to matter. EgressViolation means the air-gapped
# path was asked to talk to a remote provider; a run that continues after
# that has already broken the property it was started for.
#
# Everything else stays caught. The broad except below is deliberate and
# stays: a phase dispatches nmap, httpx, subprocesses, three LLM providers
# and several external binaries, and no honest list of what they raise can
# be written. Narrowing it would trade a failed phase for a failed run.
FATAL_TO_THE_RUN = (BudgetExceeded, EgressViolation)


def _describe(exc: BaseException) -> str:
"""Name the failure as well as describe it.

A recorded phase error of "refused" reads the same whether a target
dropped the connection or a defect raised it. The session export, the
critic that decides on a retry, and whoever opens session.json all get
the type as well, at the cost of one prefix.
"""
return f"{type(exc).__name__}: {exc}"


class Orchestrator:
"""
Expand Down Expand Up @@ -223,8 +252,14 @@ def _run_phase(self, session: ScanSession, phase: ScanPhase) -> None:
session.record_phase(phase, success=True, started=started, data=data)
console.print(f"[green]✓ {phase.value} done[/green]")

except FATAL_TO_THE_RUN as exc:
session.record_phase(phase, success=False, started=started, error=_describe(exc))
console.print(f"[bold red]✗ {phase.value} aborted the run: {exc}[/bold red]")
log.error(f"Phase {phase.value} hit a run-level limit", exc_info=True)
raise

except Exception as exc: # noqa: BLE001 — pipeline must survive one bad phase
session.record_phase(phase, success=False, started=started, error=str(exc))
session.record_phase(phase, success=False, started=started, error=_describe(exc))
console.print(f"[red]✗ {phase.value} error: {exc}[/red]")
log.error(f"Phase {phase.value} raised", exc_info=True)

Expand Down Expand Up @@ -328,7 +363,12 @@ def _run_exploit(self, session: ScanSession) -> Dict:
from cyberai.agents.exploit.safety_validator import validate_exploit_scope

paths = session.kb_get("intel", {}).get("ranked_cves", [])
v = validate_exploit_scope(session.target, session.authorized_scope, paths)
v = validate_exploit_scope(
session.target,
session.authorized_scope,
paths,
strict=self.config.strict_scope,
)

if not v.passed:
raise RuntimeError(f"Scope check failed: {v.violations}")
Expand Down Expand Up @@ -441,8 +481,14 @@ async def _run_phase_async(self, session: ScanSession, phase: ScanPhase) -> None
self._check_phase_injection(session, phase, data)
session.record_phase(phase, success=True, started=started, data=data)
console.print(f"[green]✓ {phase.value} done[/green]")
except FATAL_TO_THE_RUN as exc:
session.record_phase(phase, success=False, started=started, error=_describe(exc))
console.print(f"[bold red]✗ {phase.value} aborted the run: {exc}[/bold red]")
log.error(f"Phase {phase.value} hit a run-level limit", exc_info=True)
raise

except Exception as exc: # noqa: BLE001
session.record_phase(phase, success=False, started=started, error=str(exc))
session.record_phase(phase, success=False, started=started, error=_describe(exc))
console.print(f"[red]✗ {phase.value} error: {exc}[/red]")
log.error(f"Phase {phase.value} raised", exc_info=True)

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ authors = [
]
keywords = ["pentest", "security", "ai", "multi-agent", "offensive-security"]
classifiers = [
"Development Status :: 3 - Alpha",
"Development Status :: 4 - Beta",
"Intended Audience :: Information Technology",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
Expand Down
Loading
Loading