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
8 changes: 7 additions & 1 deletion .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,13 @@ jobs:
- name: Run Safety dependency check
env:
SAFETY_API_KEY: ${{ secrets.SAFETY_API_KEY }}
run: poetry run safety scan --policy-file .safety-policy.yml --output json > safety-report.json
run: |
if [ -n "${SAFETY_API_KEY}" ]; then
poetry run safety scan --policy-file .safety-policy.yml --output json > safety-report.json
else
echo "SAFETY_API_KEY not configured — skipping safety scan" >&2
echo '{"scan_status":"skipped","reason":"SAFETY_API_KEY not configured"}' > safety-report.json
fi

- name: Upload Safety report
uses: actions/upload-artifact@v4
Expand Down
15 changes: 13 additions & 2 deletions pentestgpt/agents/advanced_agent_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,15 @@ def __init__(self, **kwargs: Any) -> None:
logger.info("AdvancedAgentManager initialised with 10 components")

def start(self) -> None:
"""Boot children that have a start() method."""
"""Boot all child components that expose a start method."""
if self._initialized:
return
started: list[Any] = []
try:
self.state = "running"
for child in (self.system_integrator, self.health_monitor, self.performance_monitor):
child.start()
started.append(child)
self.state = "running"
self._initialized = True
except Exception:
for child in reversed(started):
Expand Down Expand Up @@ -134,35 +134,44 @@ def stop(self) -> None:
self._initialized = False

def register_agent(self, agent_id: str, agent: object) -> None:
"""Add an agent to the registry under the given ID."""
self.agents[agent_id] = agent

def unregister_agent(self, agent_id: str) -> None:
"""Remove an agent from the registry and active-agents set."""
self.agents.pop(agent_id, None)
self.active_agents.discard(agent_id)

def get_agent(self, agent_id: str) -> object | None:
"""Return the agent registered under agent_id, or None if absent."""
return self.agents.get(agent_id)

def create_agent(self, agent_config: Any) -> str:
"""Instantiate a new agent entry and return its generated ID."""
agent_id = f"agent_{uuid4().hex[:8]}"
self.agents[agent_id] = agent_config
return agent_id

def start_agent(self, agent_id: str) -> bool:
"""Start the agent identified by agent_id via the lifecycle manager."""
return self.lifecycle_manager.start_agent(agent_id)

def stop_agent(self, agent_id: str) -> bool:
"""Stop the agent identified by agent_id via the lifecycle manager."""
return self.lifecycle_manager.stop_agent(agent_id)

def restart_agent(self, agent_id: str) -> bool:
"""Stop then start the agent identified by agent_id."""
self.stop_agent(agent_id)
return self.start_agent(agent_id)

def remove_agent(self, agent_id: str) -> bool:
"""Unregister an agent and confirm it is no longer in the registry."""
self.unregister_agent(agent_id)
return agent_id not in self.agents

def get_agent_status(self, agent_id: str | None = None) -> dict[str, Any]:
"""Return status for a specific agent or all agents when agent_id is None."""
if agent_id is not None:
agent = self.agents.get(agent_id)
if agent is None:
Expand All @@ -178,6 +187,7 @@ def get_agent_status(self, agent_id: str | None = None) -> dict[str, Any]:
return self.get_status()

def get_status(self) -> dict[str, Any]:
"""Return a snapshot of manager state, all registered agents, and performance metrics."""
return {
"initialized": self._initialized,
"state": self.state,
Expand All @@ -195,6 +205,7 @@ def get_status(self) -> dict[str, Any]:
}

def get_system_status(self) -> dict[str, Any]:
"""Return system-wide status (delegates to get_status)."""
return self.get_status()


Expand Down
1 change: 1 addition & 0 deletions pentestgpt/agents/components/health_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def __init__(self, manager: Any) -> None:
def start(self) -> None:
"""Start health monitoring."""
if not self.monitoring_active:
self._stop_event.clear()
self.monitoring_active = True
self.monitor_thread = threading.Thread(
target=self._monitoring_loop,
Expand Down
24 changes: 10 additions & 14 deletions pentestgpt/agents/components/task_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,17 @@ def assign_task(self, task_data: dict[str, Any]) -> str | None:
Optional[str]: Task ID if assignment was successful, None otherwise
"""
try:
# Create task in database
task = self.db_manager.create_task("", task_data) # Agent ID will be set
if not task:
return None

# Find best agent for the task
best_agent_id = self.find_best_agent(task)
# Select agent before persisting: create_task raises on unknown agent_id
task_stub = AgentTask(task_type=task_data.get("task_type", ""))
best_agent_id = self.find_best_agent(task_stub)
if not best_agent_id:
logger.error("No suitable agent found for task")
return None

# Update task with assigned agent
task.agent_id = self.manager.agents[best_agent_id].id
session = self.db_manager.get_session()
session.commit()
# Create task in database with the confirmed agent
task = self.db_manager.create_task(best_agent_id, task_data)
if not task:
return None

# Create assignment record
assignment = TaskAssignment(
Expand Down Expand Up @@ -199,8 +195,8 @@ def get_task_status(self, task_id: str) -> dict[str, Any]:
"task_id": task.task_id,
"status": task.status,
"agent_id": task.agent_id,
"progress": task.progress,
"assigned_at": task.assigned_at,
"progress": task.progress_percent,
"started_at": task.started_at,
}
if task_id in self.manager.pending_tasks:
task = self.manager.pending_tasks[task_id]
Expand Down Expand Up @@ -230,7 +226,7 @@ def update_task_progress(self, task_id: str, status: TaskStatus) -> bool:
if task_id in self.manager.running_tasks:
task = self.manager.running_tasks[task_id]
task.status = status.value
task.progress = self._calculate_progress(status)
task.progress_percent = float(self._calculate_progress(status))

# Update in database
session = self.db_manager.get_session()
Expand Down
20 changes: 13 additions & 7 deletions pentestgpt/agents/network_operations_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,11 @@ def stop(self) -> bool:
self.performance_monitor_active = False
self.performance_thread.join(timeout=5)

# Stop scan processor
if self.scan_processor_thread:
self.scan_processor_active = False
self.scan_processor_thread.join(timeout=5)

# Cancel active operations
self._cancel_active_operations()

Expand Down Expand Up @@ -284,13 +289,14 @@ def _start_performance_monitoring(self) -> None:

def _start_scan_processor(self) -> None:
"""Start network scan processor thread."""
self.scan_processor_active = True
self.scan_processor_thread = threading.Thread(
target=self._scan_processor_loop,
daemon=True,
)
self.scan_processor_thread.start()
logger.info("Network scan processor started")
if not self.scan_processor_active:
self.scan_processor_active = True
self.scan_processor_thread = threading.Thread(
target=self._scan_processor_loop,
daemon=True,
)
self.scan_processor_thread.start()
logger.info("Network scan processor started")

def _performance_monitoring_loop(self) -> None:
"""Run the main performance monitoring loop."""
Expand Down
1 change: 1 addition & 0 deletions pentestgpt/api/knowledge_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ def validate_source_type(cls, v: str) -> str:
@field_validator("cve_id")
@classmethod
def validate_cve_id(cls, v: str | None) -> str | None:
"""Validate that the CVE ID matches the canonical CVE-YYYY-NNNNN format."""
if v is None:
return v
if not _data_validator.validate_cve(v):
Expand Down
8 changes: 5 additions & 3 deletions pentestgpt/experimental/security/update_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@
from packaging import version

_LOG_CONFIGURED = False
_LOG_CONFIG_LOCK = threading.Lock()


def _configure_logging() -> None:
global _LOG_CONFIGURED
if not _LOG_CONFIGURED:
logger.add("update_scheduler.log", rotation="10 MB", level="INFO")
_LOG_CONFIGURED = True
with _LOG_CONFIG_LOCK:
if not _LOG_CONFIGURED:
logger.add("update_scheduler.log", rotation="10 MB", level="INFO")
_LOG_CONFIGURED = True


class UpdateSchedule(Enum):
Expand Down
1 change: 1 addition & 0 deletions pentestgpt/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class InterceptHandler(logging.Handler):
"""

def emit(self, record: logging.LogRecord) -> None:
"""Route a stdlib log record through loguru."""
# Map stdlib level name to loguru level.
try:
level: str | int = logger.level(record.levelname).name
Expand Down
7 changes: 5 additions & 2 deletions pentestgpt/security/security_auditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,11 @@ def _normalize_findings_for_tracker(
self,
audit_report: dict[str, Any],
) -> dict[str, Any]:
"""Convert VulnerabilityFinding dataclasses into the dict shape that
VulnerabilityTracker.integrate_with_security_auditor expects."""
"""Convert VulnerabilityFinding dataclasses into the dict shape VulnerabilityTracker expects.

Reshapes the audit report so that
``VulnerabilityTracker.integrate_with_security_auditor`` can consume it.
"""
out: dict[str, Any] = {
"vulnerability_scan": {"findings": []},
"code_analysis": {"findings": []},
Expand Down
86 changes: 45 additions & 41 deletions pentestgpt/shared/pattern_matching_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,42 @@ def _analyze_text_patterns(

return results

def _flatten_item_patterns(
self,
key: str,
value: Any,
) -> dict[str, dict[str, Any]]:
"""Normalise one item_result pattern entry into a flat name → stats dict.

Structured items return nested dicts; text items return flat dicts.
Both forms are collapsed to a single level keyed by dotted names.
"""
if not isinstance(value, dict):
return {}
if all(isinstance(v, dict) for v in value.values()):
if "count" in value and "coverage" in value:
return {key: value}
return {f"{key}.{pname}": pdata for pname, pdata in value.items() if isinstance(pdata, dict)}
return {key: value}

def _merge_flat_patterns(
self,
dest: dict[str, Any],
flat_patterns: dict[str, dict[str, Any]],
) -> None:
"""Accumulate flat_patterns into the dest patterns aggregation dict."""
for pattern_name, pattern_data in flat_patterns.items():
if pattern_name not in dest:
dest[pattern_name] = {
"count": 0,
"items_matched": 0,
"total_coverage": 0.0,
"confidence": pattern_data.get("confidence", 0.8),
}
dest[pattern_name]["count"] += pattern_data.get("count", 0)
dest[pattern_name]["total_coverage"] += pattern_data.get("coverage", 0.0)
dest[pattern_name]["items_matched"] += 1

def _analyze_collection_patterns(
self,
items: list[str] | list[dict[str, Any]] | list[Any],
Expand All @@ -149,67 +185,35 @@ def _analyze_collection_patterns(
"confidence": 0.0,
}

# Get patterns to match from context or use defaults
patterns = self._get_patterns_from_context(context)
self._get_patterns_from_context(context)

# Analyze each item individually
item_results = []
for i, item in enumerate(items):
if isinstance(item, str):
item_result = self._analyze_text_patterns(item, context)
elif isinstance(item, dict):
item_result = self._analyze_structured_patterns(item, context)
else:
# Fallback to string representation
item_result = self._analyze_text_patterns(str(item), context)

item_result["item_index"] = i
item_results.append(item_result)
results["matches"].extend(item_result["matches"])

# Aggregate pattern statistics — structured items return
# {field: {pattern: {count,coverage,confidence}}} while text
# items return {pattern: {count,coverage,confidence}}.
for key, value in item_result["patterns"].items():
flat_patterns: dict[str, dict[str, Any]]
if isinstance(value, dict) and all(isinstance(v, dict) for v in value.values()):
if "count" in value and "coverage" in value:
flat_patterns = {key: value}
else:
flat_patterns = {
f"{key}.{pname}": pdata for pname, pdata in value.items() if isinstance(pdata, dict)
}
else:
flat_patterns = {key: value} if isinstance(value, dict) else {}

for pattern_name, pattern_data in flat_patterns.items():
if pattern_name not in results["patterns"]:
results["patterns"][pattern_name] = {
"count": 0,
"items_matched": 0,
"total_coverage": 0.0,
"confidence": pattern_data.get("confidence", 0.8),
}

results["patterns"][pattern_name]["count"] += pattern_data.get("count", 0)
results["patterns"][pattern_name]["total_coverage"] += pattern_data.get("coverage", 0.0)
results["patterns"][pattern_name]["items_matched"] += 1
self._merge_flat_patterns(
results["patterns"],
self._flatten_item_patterns(key, value),
)

# Calculate overall metrics
if results["patterns"]:
for pattern_name, pattern_data in results["patterns"].items():
for pattern_data in results["patterns"].values():
pattern_data["avg_coverage"] = (
pattern_data["total_coverage"] / pattern_data["items_matched"]
if pattern_data["items_matched"] > 0
else 0.0
)

results["coverage"] = sum(p["avg_coverage"] for p in results["patterns"].values()) / len(
results["patterns"],
)
results["confidence"] = sum(p["confidence"] for p in results["patterns"].values()) / len(
results["patterns"],
)
n = len(results["patterns"])
results["coverage"] = sum(p["avg_coverage"] for p in results["patterns"].values()) / n
results["confidence"] = sum(p["confidence"] for p in results["patterns"].values()) / n

return results

Expand Down
Loading
Loading