feat: pentest onboarding — streaming, browser automation, reports, user cleanup (#16)
All checks were successful
All checks were successful
Complete pentest feature overhaul: SSE streaming, session-persistent browser tool (CDP), AES-256 credential encryption, auto-screenshots in reports, code-level remediation correlation, SAST triage chunking, context window optimization, test user cleanup (Keycloak/Auth0/Okta), wizard dropdowns, attack chain improvements, architecture docs with Mermaid diagrams. Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
@@ -27,6 +27,14 @@ pub struct AgentConfig {
|
||||
pub ssh_key_path: String,
|
||||
pub keycloak_url: Option<String>,
|
||||
pub keycloak_realm: Option<String>,
|
||||
pub keycloak_admin_username: Option<String>,
|
||||
pub keycloak_admin_password: Option<SecretString>,
|
||||
// Pentest defaults
|
||||
pub pentest_verification_email: Option<String>,
|
||||
pub pentest_imap_host: Option<String>,
|
||||
pub pentest_imap_port: Option<u16>,
|
||||
pub pentest_imap_username: Option<String>,
|
||||
pub pentest_imap_password: Option<SecretString>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -28,8 +28,9 @@ pub use graph::{
|
||||
pub use issue::{IssueStatus, TrackerIssue, TrackerType};
|
||||
pub use mcp::{McpServerConfig, McpServerStatus, McpTransport};
|
||||
pub use pentest::{
|
||||
AttackChainNode, AttackNodeStatus, CodeContextHint, PentestEvent, PentestMessage,
|
||||
PentestSession, PentestStats, PentestStatus, PentestStrategy, SeverityDistribution,
|
||||
AttackChainNode, AttackNodeStatus, AuthMode, CodeContextHint, Environment, IdentityProvider,
|
||||
PentestAuthConfig, PentestConfig, PentestEvent, PentestMessage, PentestSession, PentestStats,
|
||||
PentestStatus, PentestStrategy, SeverityDistribution, TestUserRecord, TesterInfo,
|
||||
ToolCallRecord,
|
||||
};
|
||||
pub use repository::{ScanTrigger, TrackedRepository};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -50,6 +52,132 @@ impl std::fmt::Display for PentestStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication mode for the pentest target
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuthMode {
|
||||
#[default]
|
||||
None,
|
||||
Manual,
|
||||
AutoRegister,
|
||||
}
|
||||
|
||||
/// Target environment classification
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Environment {
|
||||
#[default]
|
||||
Development,
|
||||
Staging,
|
||||
Production,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Environment {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Development => write!(f, "Development"),
|
||||
Self::Staging => write!(f, "Staging"),
|
||||
Self::Production => write!(f, "Production"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tester identity for the engagement record
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TesterInfo {
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// Authentication configuration for the pentest session
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PentestAuthConfig {
|
||||
pub mode: AuthMode,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
/// Optional — if omitted the orchestrator uses Playwright to discover it.
|
||||
pub registration_url: Option<String>,
|
||||
/// Base email for plus-addressing (e.g. `pentest@scanner.example.com`).
|
||||
/// The orchestrator generates `base+{session_id}@domain` per session.
|
||||
pub verification_email: Option<String>,
|
||||
/// IMAP server to poll for verification emails (e.g. `imap.example.com`).
|
||||
pub imap_host: Option<String>,
|
||||
/// IMAP port (default 993 for TLS).
|
||||
pub imap_port: Option<u16>,
|
||||
/// IMAP username (defaults to `verification_email` if omitted).
|
||||
pub imap_username: Option<String>,
|
||||
/// IMAP password / app-specific password.
|
||||
pub imap_password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cleanup_test_user: bool,
|
||||
}
|
||||
|
||||
/// Full wizard configuration for a pentest session
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PentestConfig {
|
||||
// Step 1: Target & Scope
|
||||
pub app_url: String,
|
||||
pub git_repo_url: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
pub commit_hash: Option<String>,
|
||||
pub app_type: Option<String>,
|
||||
pub rate_limit: Option<u32>,
|
||||
|
||||
// Step 2: Authentication
|
||||
#[serde(default)]
|
||||
pub auth: PentestAuthConfig,
|
||||
#[serde(default)]
|
||||
pub custom_headers: HashMap<String, String>,
|
||||
|
||||
// Step 3: Strategy & Instructions
|
||||
pub strategy: Option<String>,
|
||||
#[serde(default)]
|
||||
pub allow_destructive: bool,
|
||||
pub initial_instructions: Option<String>,
|
||||
#[serde(default)]
|
||||
pub scope_exclusions: Vec<String>,
|
||||
|
||||
// Step 4: Disclaimer & Confirm
|
||||
#[serde(default)]
|
||||
pub disclaimer_accepted: bool,
|
||||
pub disclaimer_accepted_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub environment: Environment,
|
||||
#[serde(default)]
|
||||
pub tester: TesterInfo,
|
||||
pub max_duration_minutes: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub skip_mode: bool,
|
||||
}
|
||||
|
||||
/// Identity provider type for cleanup routing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IdentityProvider {
|
||||
Keycloak,
|
||||
Auth0,
|
||||
Okta,
|
||||
Firebase,
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// Details of a test user created during a pentest session.
|
||||
/// Stored so the cleanup step knows exactly what to delete and where.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TestUserRecord {
|
||||
/// Username used to register
|
||||
pub username: Option<String>,
|
||||
/// Email used to register
|
||||
pub email: Option<String>,
|
||||
/// User ID returned by the identity provider (if known)
|
||||
pub provider_user_id: Option<String>,
|
||||
/// Which identity provider holds this user
|
||||
pub provider: Option<IdentityProvider>,
|
||||
/// Whether cleanup has been completed
|
||||
#[serde(default)]
|
||||
pub cleaned_up: bool,
|
||||
}
|
||||
|
||||
/// A pentest session initiated via the chat interface
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PentestSession {
|
||||
@@ -60,7 +188,12 @@ pub struct PentestSession {
|
||||
pub repo_id: Option<String>,
|
||||
pub status: PentestStatus,
|
||||
pub strategy: PentestStrategy,
|
||||
/// Wizard configuration (None for legacy sessions)
|
||||
pub config: Option<PentestConfig>,
|
||||
pub created_by: Option<String>,
|
||||
/// Test user created during auto-register (for cleanup)
|
||||
#[serde(default)]
|
||||
pub test_user: Option<TestUserRecord>,
|
||||
/// Total number of tool invocations in this session
|
||||
pub tool_invocations: u32,
|
||||
/// Total successful tool invocations
|
||||
@@ -83,7 +216,9 @@ impl PentestSession {
|
||||
repo_id: None,
|
||||
status: PentestStatus::Running,
|
||||
strategy,
|
||||
config: None,
|
||||
created_by: None,
|
||||
test_user: None,
|
||||
tool_invocations: 0,
|
||||
tool_successes: 0,
|
||||
findings_count: 0,
|
||||
@@ -261,6 +396,10 @@ pub enum PentestEvent {
|
||||
Complete { summary: String },
|
||||
/// Error occurred
|
||||
Error { message: String },
|
||||
/// Session paused
|
||||
Paused,
|
||||
/// Session resumed
|
||||
Resumed,
|
||||
}
|
||||
|
||||
/// Aggregated stats for the pentest dashboard
|
||||
|
||||
@@ -436,6 +436,150 @@ fn pentest_event_serde_finding() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PentestEvent Paused/Resumed ───
|
||||
|
||||
#[test]
|
||||
fn pentest_event_serde_paused() {
|
||||
let event = pentest::PentestEvent::Paused;
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains(r#""type":"paused""#));
|
||||
let back: pentest::PentestEvent = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(back, pentest::PentestEvent::Paused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pentest_event_serde_resumed() {
|
||||
let event = pentest::PentestEvent::Resumed;
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains(r#""type":"resumed""#));
|
||||
let back: pentest::PentestEvent = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(back, pentest::PentestEvent::Resumed));
|
||||
}
|
||||
|
||||
// ─── PentestConfig serde ───
|
||||
|
||||
#[test]
|
||||
fn pentest_config_serde_roundtrip() {
|
||||
let config = pentest::PentestConfig {
|
||||
app_url: "https://example.com".into(),
|
||||
git_repo_url: Some("https://github.com/org/repo".into()),
|
||||
branch: Some("main".into()),
|
||||
commit_hash: None,
|
||||
app_type: Some("web".into()),
|
||||
rate_limit: Some(10),
|
||||
auth: pentest::PentestAuthConfig {
|
||||
mode: pentest::AuthMode::Manual,
|
||||
username: Some("admin".into()),
|
||||
password: Some("pass123".into()),
|
||||
registration_url: None,
|
||||
verification_email: None,
|
||||
imap_host: None,
|
||||
imap_port: None,
|
||||
imap_username: None,
|
||||
imap_password: None,
|
||||
cleanup_test_user: true,
|
||||
},
|
||||
custom_headers: [("X-Token".to_string(), "abc".to_string())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
strategy: Some("comprehensive".into()),
|
||||
allow_destructive: false,
|
||||
initial_instructions: Some("Test the login flow".into()),
|
||||
scope_exclusions: vec!["/admin".into()],
|
||||
disclaimer_accepted: true,
|
||||
disclaimer_accepted_at: Some(chrono::Utc::now()),
|
||||
environment: pentest::Environment::Staging,
|
||||
tester: pentest::TesterInfo {
|
||||
name: "Alice".into(),
|
||||
email: "alice@example.com".into(),
|
||||
},
|
||||
max_duration_minutes: Some(30),
|
||||
skip_mode: false,
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let back: pentest::PentestConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.app_url, "https://example.com");
|
||||
assert_eq!(back.auth.mode, pentest::AuthMode::Manual);
|
||||
assert_eq!(back.auth.username, Some("admin".into()));
|
||||
assert!(back.auth.cleanup_test_user);
|
||||
assert_eq!(back.scope_exclusions, vec!["/admin".to_string()]);
|
||||
assert_eq!(back.environment, pentest::Environment::Staging);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pentest_auth_config_default() {
|
||||
let auth = pentest::PentestAuthConfig::default();
|
||||
assert_eq!(auth.mode, pentest::AuthMode::None);
|
||||
assert!(auth.username.is_none());
|
||||
assert!(auth.password.is_none());
|
||||
assert!(auth.verification_email.is_none());
|
||||
assert!(auth.imap_host.is_none());
|
||||
assert!(!auth.cleanup_test_user);
|
||||
}
|
||||
|
||||
// ─── TestUserRecord ───
|
||||
|
||||
#[test]
|
||||
fn test_user_record_default() {
|
||||
let r = pentest::TestUserRecord::default();
|
||||
assert!(r.username.is_none());
|
||||
assert!(r.email.is_none());
|
||||
assert!(r.provider_user_id.is_none());
|
||||
assert!(r.provider.is_none());
|
||||
assert!(!r.cleaned_up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_record_serde_roundtrip() {
|
||||
let r = pentest::TestUserRecord {
|
||||
username: Some("pentestuser".into()),
|
||||
email: Some("pentest+abc@scanner.example.com".into()),
|
||||
provider_user_id: Some("kc-uuid-123".into()),
|
||||
provider: Some(pentest::IdentityProvider::Keycloak),
|
||||
cleaned_up: false,
|
||||
};
|
||||
let json = serde_json::to_string(&r).unwrap();
|
||||
let back: pentest::TestUserRecord = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.username, Some("pentestuser".into()));
|
||||
assert_eq!(back.provider, Some(pentest::IdentityProvider::Keycloak));
|
||||
assert!(!back.cleaned_up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_provider_serde_all_variants() {
|
||||
for (variant, expected) in [
|
||||
(pentest::IdentityProvider::Keycloak, "\"keycloak\""),
|
||||
(pentest::IdentityProvider::Auth0, "\"auth0\""),
|
||||
(pentest::IdentityProvider::Okta, "\"okta\""),
|
||||
(pentest::IdentityProvider::Firebase, "\"firebase\""),
|
||||
(pentest::IdentityProvider::Custom, "\"custom\""),
|
||||
] {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(json, expected);
|
||||
let back: pentest::IdentityProvider = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pentest_session_with_test_user() {
|
||||
let mut s = pentest::PentestSession::new("t".into(), pentest::PentestStrategy::Quick);
|
||||
assert!(s.test_user.is_none());
|
||||
s.test_user = Some(pentest::TestUserRecord {
|
||||
username: Some("pentester".into()),
|
||||
email: Some("pentest+123@example.com".into()),
|
||||
provider_user_id: None,
|
||||
provider: Some(pentest::IdentityProvider::Auth0),
|
||||
cleaned_up: false,
|
||||
});
|
||||
let bson_doc = bson::to_document(&s).unwrap();
|
||||
let back: pentest::PentestSession = bson::from_document(bson_doc).unwrap();
|
||||
assert!(back.test_user.is_some());
|
||||
let tu = back.test_user.as_ref().unwrap();
|
||||
assert_eq!(tu.username, Some("pentester".into()));
|
||||
assert_eq!(tu.provider, Some(pentest::IdentityProvider::Auth0));
|
||||
}
|
||||
|
||||
// ─── Serde helpers (BSON datetime) ───
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user