1pub mod analyzer;
45pub mod config;
46pub mod extractor;
47pub mod llm_reviewer;
48pub mod prompts;
49pub mod report;
50pub mod sandbox;
51pub mod scanner;
52
53use std::collections::VecDeque;
54use std::sync::Arc;
55use std::time::Instant;
56
57use tokio::sync::{mpsc, RwLock};
58
59pub use analyzer::ShadowError;
60pub use config::ShadowConfig;
61
62use analyzer::{Analyzer, StaticAnalyzer};
63use extractor::CodeBlockExtractor;
64use llm_reviewer::LlmReviewer;
65use report::{build_summary, VulnReport, VulnSeverity};
66use sandbox::SandboxManager;
67
68use laminae_ollama::OllamaClient;
69
70#[derive(Debug, Clone, serde::Serialize)]
72#[serde(tag = "type")]
73pub enum ShadowEvent {
74 Started {
75 session_id: String,
76 },
77 Finding {
78 session_id: String,
79 finding: report::VulnFinding,
80 },
81 AnalyzerError {
82 session_id: String,
83 analyzer: String,
84 error: String,
85 },
86 Done {
87 session_id: String,
88 report: VulnReport,
89 },
90}
91
92pub type ReportStore = Arc<RwLock<VecDeque<VulnReport>>>;
94
95const MAX_REPORTS: usize = 100;
96
97pub fn create_report_store() -> ReportStore {
99 Arc::new(RwLock::new(VecDeque::with_capacity(MAX_REPORTS)))
100}
101
102pub struct ShadowEngine {
107 config: ShadowConfig,
108 static_analyzer: Arc<StaticAnalyzer>,
109 llm_reviewer: Arc<LlmReviewer>,
110 sandbox: Arc<SandboxManager>,
111 extractor: CodeBlockExtractor,
112 report_store: ReportStore,
113}
114
115impl ShadowEngine {
116 pub fn new(report_store: ReportStore) -> Self {
118 Self::with_ollama(report_store, OllamaClient::new())
119 }
120
121 pub fn with_ollama(report_store: ReportStore, ollama: OllamaClient) -> Self {
123 let config = ShadowConfig::load();
124
125 Self {
126 static_analyzer: Arc::new(StaticAnalyzer::new()),
127 llm_reviewer: Arc::new(LlmReviewer::new(ollama.clone(), &config)),
128 sandbox: Arc::new(SandboxManager::new(&config)),
129 extractor: CodeBlockExtractor::new(),
130 report_store,
131 config,
132 }
133 }
134
135 pub fn with_config(
137 report_store: ReportStore,
138 config: ShadowConfig,
139 ollama: OllamaClient,
140 ) -> Self {
141 Self {
142 static_analyzer: Arc::new(StaticAnalyzer::new()),
143 llm_reviewer: Arc::new(LlmReviewer::new(ollama, &config)),
144 sandbox: Arc::new(SandboxManager::new(&config)),
145 extractor: CodeBlockExtractor::new(),
146 report_store,
147 config,
148 }
149 }
150
151 pub fn config(&self) -> &ShadowConfig {
152 &self.config
153 }
154
155 pub fn reload_config(&mut self) {
157 let new_config = ShadowConfig::load();
158 let ollama = OllamaClient::new();
159 self.llm_reviewer = Arc::new(LlmReviewer::new(ollama, &new_config));
160 self.sandbox = Arc::new(SandboxManager::new(&new_config));
161 self.config = new_config;
162 }
163
164 pub fn analyze_async(
169 &self,
170 session_id: String,
171 ego_output: String,
172 ) -> mpsc::Receiver<ShadowEvent> {
173 let (tx, rx) = mpsc::channel::<ShadowEvent>(32);
174
175 if !self.config.enabled {
176 return rx;
177 }
178
179 let config = self.config.clone();
180 let static_analyzer = Arc::clone(&self.static_analyzer);
181 let llm_reviewer = Arc::clone(&self.llm_reviewer);
182 let sandbox = Arc::clone(&self.sandbox);
183 let extractor = self.extractor.clone();
184 let store = Arc::clone(&self.report_store);
185
186 tokio::spawn(async move {
187 let start = Instant::now();
188 let _ = tx
189 .send(ShadowEvent::Started {
190 session_id: session_id.clone(),
191 })
192 .await;
193
194 let code_blocks = extractor.extract(&ego_output);
195 let mut all_findings = Vec::new();
196 let mut static_run = false;
197 let mut llm_run = false;
198 let mut sandbox_run = false;
199
200 if config.aggressiveness >= 1 {
202 match static_analyzer.analyze(&ego_output, &code_blocks).await {
203 Ok(findings) => {
204 static_run = true;
205 for f in &findings {
206 let _ = tx
207 .send(ShadowEvent::Finding {
208 session_id: session_id.clone(),
209 finding: f.clone(),
210 })
211 .await;
212 }
213 all_findings.extend(findings);
214 }
215 Err(e) => {
216 tracing::warn!("Shadow static analyzer error: {e}");
217 let _ = tx
218 .send(ShadowEvent::AnalyzerError {
219 session_id: session_id.clone(),
220 analyzer: static_analyzer.name().to_string(),
221 error: e.to_string(),
222 })
223 .await;
224 }
225 }
226 }
227
228 if config.aggressiveness >= 2
230 && config.llm_review_enabled
231 && llm_reviewer.is_available().await
232 {
233 match llm_reviewer.analyze(&ego_output, &code_blocks).await {
234 Ok(findings) => {
235 llm_run = true;
236 for f in &findings {
237 let _ = tx
238 .send(ShadowEvent::Finding {
239 session_id: session_id.clone(),
240 finding: f.clone(),
241 })
242 .await;
243 }
244 all_findings.extend(findings);
245 }
246 Err(e) => {
247 tracing::warn!("Shadow LLM reviewer error: {e}");
248 let _ = tx
249 .send(ShadowEvent::AnalyzerError {
250 session_id: session_id.clone(),
251 analyzer: llm_reviewer.name().to_string(),
252 error: e.to_string(),
253 })
254 .await;
255 }
256 }
257 }
258
259 let has_substantial_code = code_blocks
261 .iter()
262 .any(|b| b.content.len() >= config.sandbox_min_code_len);
263
264 if config.aggressiveness >= 3
265 && config.sandbox_enabled
266 && has_substantial_code
267 && sandbox.is_available().await
268 {
269 match sandbox.analyze(&ego_output, &code_blocks).await {
270 Ok(findings) => {
271 sandbox_run = true;
272 for f in &findings {
273 let _ = tx
274 .send(ShadowEvent::Finding {
275 session_id: session_id.clone(),
276 finding: f.clone(),
277 })
278 .await;
279 }
280 all_findings.extend(findings);
281 }
282 Err(e) => {
283 tracing::warn!("Shadow sandbox error: {e}");
284 let _ = tx
285 .send(ShadowEvent::AnalyzerError {
286 session_id: session_id.clone(),
287 analyzer: sandbox.name().to_string(),
288 error: e.to_string(),
289 })
290 .await;
291 }
292 }
293 }
294
295 all_findings.sort_by(|a, b| {
297 a.category
298 .to_string()
299 .cmp(&b.category.to_string())
300 .then(a.title.cmp(&b.title))
301 .then(a.evidence.cmp(&b.evidence))
302 });
303 all_findings.dedup_by(|a, b| {
304 a.category == b.category && a.title == b.title && a.evidence == b.evidence
305 });
306
307 let max_severity = all_findings
308 .iter()
309 .map(|f| f.severity)
310 .max()
311 .unwrap_or(VulnSeverity::Info);
312
313 let clean = all_findings.is_empty();
314 let summary = build_summary(&all_findings, static_run, llm_run, sandbox_run);
315 let duration = start.elapsed();
316
317 let report = VulnReport {
318 session_id: session_id.clone(),
319 ego_response_excerpt: ego_output.chars().take(200).collect(),
320 findings: all_findings,
321 max_severity,
322 analysis_duration_ms: duration.as_millis() as u64,
323 static_run,
324 llm_run,
325 sandbox_run,
326 clean,
327 summary,
328 };
329
330 {
331 let mut reports = store.write().await;
332 if reports.len() >= MAX_REPORTS {
333 reports.pop_front();
334 }
335 reports.push_back(report.clone());
336 }
337
338 if !report.clean {
339 tracing::info!(
340 "Shadow found {} issue(s) (max severity: {}) in {}ms",
341 report.findings.len(),
342 report.max_severity,
343 report.analysis_duration_ms
344 );
345 }
346
347 let _ = tx.send(ShadowEvent::Done { session_id, report }).await;
348 });
349
350 rx
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[tokio::test]
359 async fn test_shadow_disabled() {
360 let store = create_report_store();
361 let engine = ShadowEngine::with_config(
362 store,
363 ShadowConfig {
364 enabled: false,
365 ..Default::default()
366 },
367 OllamaClient::new(),
368 );
369 let mut rx = engine.analyze_async("test".into(), "hello".into());
370 assert!(rx.recv().await.is_none());
371 }
372
373 #[tokio::test]
374 async fn test_shadow_clean_output() {
375 let store = create_report_store();
376 let config = ShadowConfig {
377 aggressiveness: 1,
378 enabled: true,
379 ..Default::default()
380 };
381 let engine = ShadowEngine::with_config(store.clone(), config, OllamaClient::new());
382
383 let mut rx = engine.analyze_async(
384 "test".into(),
385 "```rust\nfn greet() -> String { \"hello\".to_string() }\n```".into(),
386 );
387
388 let mut got_done = false;
389 while let Some(event) = rx.recv().await {
390 if let ShadowEvent::Done { report, .. } = event {
391 got_done = true;
392 assert!(report.clean);
393 assert!(report.static_run);
394 }
395 }
396 assert!(got_done);
397 }
398
399 #[tokio::test]
400 async fn test_shadow_detects_eval() {
401 let store = create_report_store();
402 let config = ShadowConfig {
403 aggressiveness: 1,
404 enabled: true,
405 ..Default::default()
406 };
407 let engine = ShadowEngine::with_config(store.clone(), config, OllamaClient::new());
408
409 let mut rx = engine.analyze_async("vuln".into(), "```js\neval(userInput);\n```".into());
410
411 let mut found = false;
412 while let Some(event) = rx.recv().await {
413 if let ShadowEvent::Finding { .. } = event {
414 found = true;
415 }
416 }
417 assert!(found);
418
419 let reports = store.read().await;
420 assert_eq!(reports.len(), 1);
421 assert!(!reports[0].clean);
422 }
423
424 #[tokio::test]
425 async fn test_report_store_bounded() {
426 let store = create_report_store();
427 let mut reports = store.write().await;
428 for i in 0..MAX_REPORTS + 5 {
429 reports.push_back(VulnReport::clean(
430 format!("s-{i}"),
431 "test".into(),
432 std::time::Duration::from_millis(1),
433 ));
434 if reports.len() > MAX_REPORTS {
435 reports.pop_front();
436 }
437 }
438 assert_eq!(reports.len(), MAX_REPORTS);
439 }
440}