feat: browser session persistence, auto-screenshots, context optimization, user cleanup
Some checks failed
CI / Check (pull_request) Failing after 5m55s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped

Browser tool:
- Session-persistent Chrome tab (same tab reused across all calls in a pentest)
- Auto-screenshot on every navigate and click (stored in attack chain for report)
- Fill uses CDP Input.insertText (fixes WebSocket corruption on special chars)
- Switched from browserless/chromium to chromedp/headless-shell (stable WS)

Context window optimization:
- Strip screenshot_base64 from LLM conversation (kept in DB for report)
- Truncate HTML to 2KB, page text to 1.5KB in LLM messages
- Cap element/link arrays at 15 items
- SAST triage: batch 30 findings per LLM call instead of all at once

Report improvements:
- Auto-embed screenshots in attack chain timeline (navigate + click nodes)
- Cover page shows best app screenshot
- Attack chain phases capped at 8 (no more 20x "Final")

User cleanup:
- TestUserRecord model tracks created test users per session
- cleanup.rs: Keycloak (Admin REST API), Auth0 (Management API), Okta (Users API)
- Auto-cleanup on session completion when cleanup_test_user is enabled
- Env vars: KEYCLOAK_ADMIN_USERNAME, KEYCLOAK_ADMIN_PASSWORD

System prompt:
- Explicit browser usage instructions (navigate → get_content → click → fill)
- SPA auth bypass guidance (check page content, not HTTP status)
- Screenshot instructions for evidence collection

Other:
- Pin mongo:7 in docker-compose (mongo:latest/8 segfaults on kernel 6.19)
- Add deploy/docker-compose.mailserver.yml for Postfix + Dovecot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-03-17 19:53:55 +01:00
parent a737c36bc9
commit 37690ce734
18 changed files with 1122 additions and 215 deletions

View File

@@ -149,6 +149,23 @@ fn build_chain_html(chain: &[AttackChainNode]) -> String {
)
};
// Render inline screenshot if this is a browser screenshot action
let screenshot_html = if node.tool_name == "browser" {
node.tool_output
.as_ref()
.and_then(|out| out.get("screenshot_base64"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|b64| {
format!(
r#"<div class="step-screenshot"><img src="data:image/png;base64,{b64}" alt="Browser screenshot" style="max-width:100%;border:1px solid #e2e8f0;border-radius:6px;margin-top:8px;"/></div>"#
)
})
.unwrap_or_default()
} else {
String::new()
};
chain_html.push_str(&format!(
r#"<div class="step-row">
<div class="step-num">{num}</div>
@@ -161,6 +178,7 @@ fn build_chain_html(chain: &[AttackChainNode]) -> String {
{risk_badge}
</div>
{reasoning_html}
{screenshot_html}
</div>
</div>"#,
num = i + 1,

View File

@@ -7,7 +7,18 @@ pub(super) fn cover(
target_url: &str,
requester_name: &str,
requester_email: &str,
app_screenshot_b64: Option<&str>,
) -> String {
let screenshot_html = app_screenshot_b64
.filter(|s| !s.is_empty())
.map(|b64| {
format!(
r#"<div style="margin: 20px auto; max-width: 560px; border: 1px solid #cbd5e1; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.08);">
<img src="data:image/png;base64,{b64}" alt="Application screenshot" style="width:100%;display:block;"/>
</div>"#
)
})
.unwrap_or_default();
format!(
r##"<!-- ═══════════════ COVER PAGE ═══════════════ -->
<div class="cover">
@@ -42,6 +53,8 @@ pub(super) fn cover(
<strong>Prepared for:</strong> {requester_name} ({requester_email})
</div>
{screenshot_html}
<div class="cover-footer">
Compliance Scanner &mdash; AI-Powered Security Assessment Platform
</div>

View File

@@ -37,6 +37,50 @@ pub(super) fn build_html_report(ctx: &ReportContext) -> String {
names
};
// Find the best app screenshot for the cover page:
// prefer the first navigate to the target URL that has a screenshot,
// falling back to any navigate with a screenshot
let app_screenshot: Option<String> = ctx
.attack_chain
.iter()
.filter(|n| n.tool_name == "browser")
.filter_map(|n| {
n.tool_output
.as_ref()?
.get("screenshot_base64")?
.as_str()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
})
// Skip the Keycloak login page screenshots — prefer one that shows the actual app
.find(|_| {
ctx.attack_chain
.iter()
.filter(|n| n.tool_name == "browser")
.any(|n| {
n.tool_output
.as_ref()
.and_then(|o| o.get("title"))
.and_then(|t| t.as_str())
.is_some_and(|t| t.contains("Compliance") || t.contains("Dashboard"))
})
})
.or_else(|| {
// Fallback: any screenshot
ctx.attack_chain
.iter()
.filter(|n| n.tool_name == "browser")
.filter_map(|n| {
n.tool_output
.as_ref()?
.get("screenshot_base64")?
.as_str()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
})
.next()
});
let styles_html = styles::styles();
let cover_html = cover::cover(
&ctx.target_name,
@@ -45,6 +89,7 @@ pub(super) fn build_html_report(ctx: &ReportContext) -> String {
&ctx.target_url,
&ctx.requester_name,
&ctx.requester_email,
app_screenshot.as_deref(),
);
let exec_html = executive_summary::executive_summary(
&ctx.findings,