1use std::collections::{HashMap, HashSet};
47use std::path::Path;
48
49use aho_corasick::AhoCorasick;
50use once_cell::sync::Lazy;
51use rayon::prelude::*;
52use serde::{Deserialize, Serialize};
53use streaming_iterator::StreamingIterator;
54use tree_sitter::{Node, Query, QueryCursor, Tree};
55
56use crate::callgraph::scanner::{ProjectScanner, ScanConfig};
57use crate::error::{Result, BrrrError};
58use crate::lang::LanguageRegistry;
59use crate::util::format_query_error;
60
61static USER_INPUT_PATTERNS: Lazy<AhoCorasick> = Lazy::new(|| {
68 AhoCorasick::new([
69 "request", "req", "params", "query", "body", "input",
70 "user", "filename", "file_name", "filepath", "file_path",
71 "name", "path", "url", "uri", "data", "arg", "param",
72 "stdin", "argv",
73 ]).expect("USER_INPUT_PATTERNS: invalid patterns")
74});
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
82#[serde(rename_all = "lowercase")]
83pub enum Severity {
84 Info,
86 Low,
88 Medium,
90 High,
92 Critical,
94}
95
96impl std::fmt::Display for Severity {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 match self {
99 Self::Info => write!(f, "INFO"),
100 Self::Low => write!(f, "LOW"),
101 Self::Medium => write!(f, "MEDIUM"),
102 Self::High => write!(f, "HIGH"),
103 Self::Critical => write!(f, "CRITICAL"),
104 }
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
110#[serde(rename_all = "lowercase")]
111pub enum Confidence {
112 Low,
114 Medium,
116 High,
118}
119
120impl std::fmt::Display for Confidence {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 match self {
123 Self::Low => write!(f, "LOW"),
124 Self::Medium => write!(f, "MEDIUM"),
125 Self::High => write!(f, "HIGH"),
126 }
127 }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
132#[serde(rename_all = "snake_case")]
133pub enum FileOperationType {
134 Read,
136 Write,
138 Append,
140 Delete,
142 Exists,
144 ListDir,
146 Create,
148 Move,
150 Copy,
152 Open,
154}
155
156impl std::fmt::Display for FileOperationType {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 match self {
159 Self::Read => write!(f, "read"),
160 Self::Write => write!(f, "write"),
161 Self::Append => write!(f, "append"),
162 Self::Delete => write!(f, "delete"),
163 Self::Exists => write!(f, "exists"),
164 Self::ListDir => write!(f, "list_dir"),
165 Self::Create => write!(f, "create"),
166 Self::Move => write!(f, "move"),
167 Self::Copy => write!(f, "copy"),
168 Self::Open => write!(f, "open"),
169 }
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case")]
176pub enum VulnerablePattern {
177 DirectUserInput,
179 UnsafePathJoin,
181 PathConcatenation,
183 HardcodedTraversal,
185 UnvalidatedVariable,
187 PathInterpolation,
189 MissingValidation,
191}
192
193impl std::fmt::Display for VulnerablePattern {
194 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195 match self {
196 Self::DirectUserInput => write!(f, "direct_user_input"),
197 Self::UnsafePathJoin => write!(f, "unsafe_path_join"),
198 Self::PathConcatenation => write!(f, "path_concatenation"),
199 Self::HardcodedTraversal => write!(f, "hardcoded_traversal"),
200 Self::UnvalidatedVariable => write!(f, "unvalidated_variable"),
201 Self::PathInterpolation => write!(f, "path_interpolation"),
202 Self::MissingValidation => write!(f, "missing_validation"),
203 }
204 }
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
209pub struct SourceLocation {
210 pub file: String,
212 pub line: usize,
214 pub column: usize,
216 pub end_line: usize,
218 pub end_column: usize,
220}
221
222impl std::fmt::Display for SourceLocation {
223 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224 write!(f, "{}:{}:{}", self.file, self.line, self.column)
225 }
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct PathTraversalFinding {
231 pub location: SourceLocation,
233 pub severity: Severity,
235 pub sink_function: String,
237 pub operation_type: FileOperationType,
239 pub path_expression: String,
241 pub confidence: Confidence,
243 pub pattern: VulnerablePattern,
245 #[serde(skip_serializing_if = "Vec::is_empty")]
247 pub involved_variables: Vec<String>,
248 #[serde(skip_serializing_if = "Option::is_none")]
250 pub code_snippet: Option<String>,
251 pub description: String,
253 pub remediation: String,
255 pub symlink_risk: bool,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct ScanResult {
262 pub findings: Vec<PathTraversalFinding>,
264 pub files_scanned: usize,
266 pub sinks_found: usize,
268 pub severity_counts: HashMap<String, usize>,
270 pub language: String,
272}
273
274#[derive(Debug, Clone)]
280pub struct FileSink {
281 pub language: &'static str,
283 pub module: Option<&'static str>,
285 pub function: &'static str,
287 pub path_arg_index: usize,
289 pub operation_type: FileOperationType,
291 pub severity: Severity,
293 pub description: &'static str,
295}
296
297pub fn get_file_sinks(language: &str) -> Vec<FileSink> {
299 match language {
300 "python" => python_sinks(),
301 "typescript" | "javascript" => typescript_sinks(),
302 "rust" => rust_sinks(),
303 "go" => go_sinks(),
304 "c" | "cpp" => c_sinks(),
305 _ => vec![],
306 }
307}
308
309fn python_sinks() -> Vec<FileSink> {
310 vec![
311 FileSink {
313 language: "python",
314 module: None,
315 function: "open",
316 path_arg_index: 0,
317 operation_type: FileOperationType::Open,
318 severity: Severity::High,
319 description: "Built-in open() function",
320 },
321 FileSink {
323 language: "python",
324 module: Some("pathlib"),
325 function: "read_text",
326 path_arg_index: 0,
327 operation_type: FileOperationType::Read,
328 severity: Severity::High,
329 description: "Path.read_text() reads file contents",
330 },
331 FileSink {
332 language: "python",
333 module: Some("pathlib"),
334 function: "read_bytes",
335 path_arg_index: 0,
336 operation_type: FileOperationType::Read,
337 severity: Severity::High,
338 description: "Path.read_bytes() reads binary file contents",
339 },
340 FileSink {
341 language: "python",
342 module: Some("pathlib"),
343 function: "write_text",
344 path_arg_index: 0,
345 operation_type: FileOperationType::Write,
346 severity: Severity::Critical,
347 description: "Path.write_text() writes file contents",
348 },
349 FileSink {
350 language: "python",
351 module: Some("pathlib"),
352 function: "write_bytes",
353 path_arg_index: 0,
354 operation_type: FileOperationType::Write,
355 severity: Severity::Critical,
356 description: "Path.write_bytes() writes binary contents",
357 },
358 FileSink {
359 language: "python",
360 module: Some("pathlib"),
361 function: "unlink",
362 path_arg_index: 0,
363 operation_type: FileOperationType::Delete,
364 severity: Severity::Critical,
365 description: "Path.unlink() deletes file",
366 },
367 FileSink {
368 language: "python",
369 module: Some("pathlib"),
370 function: "rmdir",
371 path_arg_index: 0,
372 operation_type: FileOperationType::Delete,
373 severity: Severity::Critical,
374 description: "Path.rmdir() removes directory",
375 },
376 FileSink {
377 language: "python",
378 module: Some("pathlib"),
379 function: "mkdir",
380 path_arg_index: 0,
381 operation_type: FileOperationType::Create,
382 severity: Severity::Medium,
383 description: "Path.mkdir() creates directory",
384 },
385 FileSink {
387 language: "python",
388 module: Some("os"),
389 function: "remove",
390 path_arg_index: 0,
391 operation_type: FileOperationType::Delete,
392 severity: Severity::Critical,
393 description: "os.remove() deletes file",
394 },
395 FileSink {
396 language: "python",
397 module: Some("os"),
398 function: "unlink",
399 path_arg_index: 0,
400 operation_type: FileOperationType::Delete,
401 severity: Severity::Critical,
402 description: "os.unlink() deletes file",
403 },
404 FileSink {
405 language: "python",
406 module: Some("os"),
407 function: "rmdir",
408 path_arg_index: 0,
409 operation_type: FileOperationType::Delete,
410 severity: Severity::Critical,
411 description: "os.rmdir() removes directory",
412 },
413 FileSink {
414 language: "python",
415 module: Some("os"),
416 function: "mkdir",
417 path_arg_index: 0,
418 operation_type: FileOperationType::Create,
419 severity: Severity::Medium,
420 description: "os.mkdir() creates directory",
421 },
422 FileSink {
423 language: "python",
424 module: Some("os"),
425 function: "makedirs",
426 path_arg_index: 0,
427 operation_type: FileOperationType::Create,
428 severity: Severity::Medium,
429 description: "os.makedirs() creates directory tree",
430 },
431 FileSink {
432 language: "python",
433 module: Some("os"),
434 function: "listdir",
435 path_arg_index: 0,
436 operation_type: FileOperationType::ListDir,
437 severity: Severity::Medium,
438 description: "os.listdir() lists directory contents",
439 },
440 FileSink {
441 language: "python",
442 module: Some("os"),
443 function: "rename",
444 path_arg_index: 0,
445 operation_type: FileOperationType::Move,
446 severity: Severity::Critical,
447 description: "os.rename() moves/renames file",
448 },
449 FileSink {
450 language: "python",
451 module: Some("os"),
452 function: "replace",
453 path_arg_index: 0,
454 operation_type: FileOperationType::Move,
455 severity: Severity::Critical,
456 description: "os.replace() replaces file atomically",
457 },
458 FileSink {
460 language: "python",
461 module: Some("os.path"),
462 function: "join",
463 path_arg_index: 1, operation_type: FileOperationType::Open,
465 severity: Severity::High, description: "os.path.join() does NOT sanitize - still vulnerable to absolute paths and ..",
467 },
468 FileSink {
470 language: "python",
471 module: Some("shutil"),
472 function: "copy",
473 path_arg_index: 0,
474 operation_type: FileOperationType::Copy,
475 severity: Severity::High,
476 description: "shutil.copy() copies file",
477 },
478 FileSink {
479 language: "python",
480 module: Some("shutil"),
481 function: "copy2",
482 path_arg_index: 0,
483 operation_type: FileOperationType::Copy,
484 severity: Severity::High,
485 description: "shutil.copy2() copies file with metadata",
486 },
487 FileSink {
488 language: "python",
489 module: Some("shutil"),
490 function: "copyfile",
491 path_arg_index: 0,
492 operation_type: FileOperationType::Copy,
493 severity: Severity::High,
494 description: "shutil.copyfile() copies file contents",
495 },
496 FileSink {
497 language: "python",
498 module: Some("shutil"),
499 function: "copytree",
500 path_arg_index: 0,
501 operation_type: FileOperationType::Copy,
502 severity: Severity::High,
503 description: "shutil.copytree() copies entire directory",
504 },
505 FileSink {
506 language: "python",
507 module: Some("shutil"),
508 function: "rmtree",
509 path_arg_index: 0,
510 operation_type: FileOperationType::Delete,
511 severity: Severity::Critical,
512 description: "shutil.rmtree() deletes entire directory tree",
513 },
514 FileSink {
515 language: "python",
516 module: Some("shutil"),
517 function: "move",
518 path_arg_index: 0,
519 operation_type: FileOperationType::Move,
520 severity: Severity::Critical,
521 description: "shutil.move() moves file or directory",
522 },
523 ]
524}
525
526fn typescript_sinks() -> Vec<FileSink> {
527 vec![
528 FileSink {
530 language: "typescript",
531 module: Some("fs"),
532 function: "readFile",
533 path_arg_index: 0,
534 operation_type: FileOperationType::Read,
535 severity: Severity::High,
536 description: "fs.readFile() reads file contents",
537 },
538 FileSink {
539 language: "typescript",
540 module: Some("fs"),
541 function: "readFileSync",
542 path_arg_index: 0,
543 operation_type: FileOperationType::Read,
544 severity: Severity::High,
545 description: "fs.readFileSync() synchronously reads file",
546 },
547 FileSink {
548 language: "typescript",
549 module: Some("fs"),
550 function: "writeFile",
551 path_arg_index: 0,
552 operation_type: FileOperationType::Write,
553 severity: Severity::Critical,
554 description: "fs.writeFile() writes file contents",
555 },
556 FileSink {
557 language: "typescript",
558 module: Some("fs"),
559 function: "writeFileSync",
560 path_arg_index: 0,
561 operation_type: FileOperationType::Write,
562 severity: Severity::Critical,
563 description: "fs.writeFileSync() synchronously writes file",
564 },
565 FileSink {
566 language: "typescript",
567 module: Some("fs"),
568 function: "appendFile",
569 path_arg_index: 0,
570 operation_type: FileOperationType::Append,
571 severity: Severity::High,
572 description: "fs.appendFile() appends to file",
573 },
574 FileSink {
575 language: "typescript",
576 module: Some("fs"),
577 function: "appendFileSync",
578 path_arg_index: 0,
579 operation_type: FileOperationType::Append,
580 severity: Severity::High,
581 description: "fs.appendFileSync() synchronously appends",
582 },
583 FileSink {
584 language: "typescript",
585 module: Some("fs"),
586 function: "unlink",
587 path_arg_index: 0,
588 operation_type: FileOperationType::Delete,
589 severity: Severity::Critical,
590 description: "fs.unlink() deletes file",
591 },
592 FileSink {
593 language: "typescript",
594 module: Some("fs"),
595 function: "unlinkSync",
596 path_arg_index: 0,
597 operation_type: FileOperationType::Delete,
598 severity: Severity::Critical,
599 description: "fs.unlinkSync() synchronously deletes file",
600 },
601 FileSink {
602 language: "typescript",
603 module: Some("fs"),
604 function: "rmdir",
605 path_arg_index: 0,
606 operation_type: FileOperationType::Delete,
607 severity: Severity::Critical,
608 description: "fs.rmdir() removes directory",
609 },
610 FileSink {
611 language: "typescript",
612 module: Some("fs"),
613 function: "rm",
614 path_arg_index: 0,
615 operation_type: FileOperationType::Delete,
616 severity: Severity::Critical,
617 description: "fs.rm() removes file or directory",
618 },
619 FileSink {
620 language: "typescript",
621 module: Some("fs"),
622 function: "mkdir",
623 path_arg_index: 0,
624 operation_type: FileOperationType::Create,
625 severity: Severity::Medium,
626 description: "fs.mkdir() creates directory",
627 },
628 FileSink {
629 language: "typescript",
630 module: Some("fs"),
631 function: "readdir",
632 path_arg_index: 0,
633 operation_type: FileOperationType::ListDir,
634 severity: Severity::Medium,
635 description: "fs.readdir() lists directory contents",
636 },
637 FileSink {
638 language: "typescript",
639 module: Some("fs"),
640 function: "rename",
641 path_arg_index: 0,
642 operation_type: FileOperationType::Move,
643 severity: Severity::Critical,
644 description: "fs.rename() moves/renames file",
645 },
646 FileSink {
647 language: "typescript",
648 module: Some("fs"),
649 function: "copyFile",
650 path_arg_index: 0,
651 operation_type: FileOperationType::Copy,
652 severity: Severity::High,
653 description: "fs.copyFile() copies file",
654 },
655 FileSink {
656 language: "typescript",
657 module: Some("fs"),
658 function: "createReadStream",
659 path_arg_index: 0,
660 operation_type: FileOperationType::Read,
661 severity: Severity::High,
662 description: "fs.createReadStream() opens read stream",
663 },
664 FileSink {
665 language: "typescript",
666 module: Some("fs"),
667 function: "createWriteStream",
668 path_arg_index: 0,
669 operation_type: FileOperationType::Write,
670 severity: Severity::Critical,
671 description: "fs.createWriteStream() opens write stream",
672 },
673 FileSink {
675 language: "typescript",
676 module: Some("fs/promises"),
677 function: "readFile",
678 path_arg_index: 0,
679 operation_type: FileOperationType::Read,
680 severity: Severity::High,
681 description: "fsPromises.readFile() async reads file",
682 },
683 FileSink {
684 language: "typescript",
685 module: Some("fs/promises"),
686 function: "writeFile",
687 path_arg_index: 0,
688 operation_type: FileOperationType::Write,
689 severity: Severity::Critical,
690 description: "fsPromises.writeFile() async writes file",
691 },
692 FileSink {
694 language: "typescript",
695 module: Some("path"),
696 function: "join",
697 path_arg_index: 1,
698 operation_type: FileOperationType::Open,
699 severity: Severity::High,
700 description: "path.join() does NOT sanitize - still vulnerable to ..",
701 },
702 FileSink {
703 language: "typescript",
704 module: Some("path"),
705 function: "resolve",
706 path_arg_index: 0,
707 operation_type: FileOperationType::Open,
708 severity: Severity::High,
709 description: "path.resolve() resolves to absolute path but doesn't validate",
710 },
711 ]
712}
713
714fn rust_sinks() -> Vec<FileSink> {
715 vec![
716 FileSink {
718 language: "rust",
719 module: Some("std::fs"),
720 function: "read",
721 path_arg_index: 0,
722 operation_type: FileOperationType::Read,
723 severity: Severity::High,
724 description: "std::fs::read() reads file to Vec<u8>",
725 },
726 FileSink {
727 language: "rust",
728 module: Some("std::fs"),
729 function: "read_to_string",
730 path_arg_index: 0,
731 operation_type: FileOperationType::Read,
732 severity: Severity::High,
733 description: "std::fs::read_to_string() reads file to String",
734 },
735 FileSink {
736 language: "rust",
737 module: Some("std::fs"),
738 function: "write",
739 path_arg_index: 0,
740 operation_type: FileOperationType::Write,
741 severity: Severity::Critical,
742 description: "std::fs::write() writes data to file",
743 },
744 FileSink {
745 language: "rust",
746 module: Some("std::fs"),
747 function: "remove_file",
748 path_arg_index: 0,
749 operation_type: FileOperationType::Delete,
750 severity: Severity::Critical,
751 description: "std::fs::remove_file() deletes file",
752 },
753 FileSink {
754 language: "rust",
755 module: Some("std::fs"),
756 function: "remove_dir",
757 path_arg_index: 0,
758 operation_type: FileOperationType::Delete,
759 severity: Severity::Critical,
760 description: "std::fs::remove_dir() removes empty directory",
761 },
762 FileSink {
763 language: "rust",
764 module: Some("std::fs"),
765 function: "remove_dir_all",
766 path_arg_index: 0,
767 operation_type: FileOperationType::Delete,
768 severity: Severity::Critical,
769 description: "std::fs::remove_dir_all() recursively deletes directory",
770 },
771 FileSink {
772 language: "rust",
773 module: Some("std::fs"),
774 function: "create_dir",
775 path_arg_index: 0,
776 operation_type: FileOperationType::Create,
777 severity: Severity::Medium,
778 description: "std::fs::create_dir() creates directory",
779 },
780 FileSink {
781 language: "rust",
782 module: Some("std::fs"),
783 function: "create_dir_all",
784 path_arg_index: 0,
785 operation_type: FileOperationType::Create,
786 severity: Severity::Medium,
787 description: "std::fs::create_dir_all() creates directory tree",
788 },
789 FileSink {
790 language: "rust",
791 module: Some("std::fs"),
792 function: "copy",
793 path_arg_index: 0,
794 operation_type: FileOperationType::Copy,
795 severity: Severity::High,
796 description: "std::fs::copy() copies file contents",
797 },
798 FileSink {
799 language: "rust",
800 module: Some("std::fs"),
801 function: "rename",
802 path_arg_index: 0,
803 operation_type: FileOperationType::Move,
804 severity: Severity::Critical,
805 description: "std::fs::rename() moves/renames file",
806 },
807 FileSink {
808 language: "rust",
809 module: Some("std::fs"),
810 function: "read_dir",
811 path_arg_index: 0,
812 operation_type: FileOperationType::ListDir,
813 severity: Severity::Medium,
814 description: "std::fs::read_dir() lists directory contents",
815 },
816 FileSink {
818 language: "rust",
819 module: Some("std::fs"),
820 function: "File::open",
821 path_arg_index: 0,
822 operation_type: FileOperationType::Read,
823 severity: Severity::High,
824 description: "File::open() opens file for reading",
825 },
826 FileSink {
827 language: "rust",
828 module: Some("std::fs"),
829 function: "File::create",
830 path_arg_index: 0,
831 operation_type: FileOperationType::Write,
832 severity: Severity::Critical,
833 description: "File::create() creates/truncates file",
834 },
835 FileSink {
837 language: "rust",
838 module: Some("std::path"),
839 function: "Path::new",
840 path_arg_index: 0,
841 operation_type: FileOperationType::Open,
842 severity: Severity::Medium,
843 description: "Path::new() with user input may enable traversal",
844 },
845 FileSink {
847 language: "rust",
848 module: Some("tokio::fs"),
849 function: "read",
850 path_arg_index: 0,
851 operation_type: FileOperationType::Read,
852 severity: Severity::High,
853 description: "tokio::fs::read() async reads file",
854 },
855 FileSink {
856 language: "rust",
857 module: Some("tokio::fs"),
858 function: "write",
859 path_arg_index: 0,
860 operation_type: FileOperationType::Write,
861 severity: Severity::Critical,
862 description: "tokio::fs::write() async writes file",
863 },
864 ]
865}
866
867fn go_sinks() -> Vec<FileSink> {
868 vec![
869 FileSink {
871 language: "go",
872 module: Some("os"),
873 function: "Open",
874 path_arg_index: 0,
875 operation_type: FileOperationType::Read,
876 severity: Severity::High,
877 description: "os.Open() opens file for reading",
878 },
879 FileSink {
880 language: "go",
881 module: Some("os"),
882 function: "OpenFile",
883 path_arg_index: 0,
884 operation_type: FileOperationType::Open,
885 severity: Severity::High,
886 description: "os.OpenFile() opens file with specified flags",
887 },
888 FileSink {
889 language: "go",
890 module: Some("os"),
891 function: "Create",
892 path_arg_index: 0,
893 operation_type: FileOperationType::Write,
894 severity: Severity::Critical,
895 description: "os.Create() creates/truncates file",
896 },
897 FileSink {
898 language: "go",
899 module: Some("os"),
900 function: "Remove",
901 path_arg_index: 0,
902 operation_type: FileOperationType::Delete,
903 severity: Severity::Critical,
904 description: "os.Remove() deletes file",
905 },
906 FileSink {
907 language: "go",
908 module: Some("os"),
909 function: "RemoveAll",
910 path_arg_index: 0,
911 operation_type: FileOperationType::Delete,
912 severity: Severity::Critical,
913 description: "os.RemoveAll() recursively deletes path",
914 },
915 FileSink {
916 language: "go",
917 module: Some("os"),
918 function: "Rename",
919 path_arg_index: 0,
920 operation_type: FileOperationType::Move,
921 severity: Severity::Critical,
922 description: "os.Rename() moves/renames file",
923 },
924 FileSink {
925 language: "go",
926 module: Some("os"),
927 function: "Mkdir",
928 path_arg_index: 0,
929 operation_type: FileOperationType::Create,
930 severity: Severity::Medium,
931 description: "os.Mkdir() creates directory",
932 },
933 FileSink {
934 language: "go",
935 module: Some("os"),
936 function: "MkdirAll",
937 path_arg_index: 0,
938 operation_type: FileOperationType::Create,
939 severity: Severity::Medium,
940 description: "os.MkdirAll() creates directory tree",
941 },
942 FileSink {
943 language: "go",
944 module: Some("os"),
945 function: "ReadDir",
946 path_arg_index: 0,
947 operation_type: FileOperationType::ListDir,
948 severity: Severity::Medium,
949 description: "os.ReadDir() lists directory entries",
950 },
951 FileSink {
952 language: "go",
953 module: Some("os"),
954 function: "ReadFile",
955 path_arg_index: 0,
956 operation_type: FileOperationType::Read,
957 severity: Severity::High,
958 description: "os.ReadFile() reads entire file",
959 },
960 FileSink {
961 language: "go",
962 module: Some("os"),
963 function: "WriteFile",
964 path_arg_index: 0,
965 operation_type: FileOperationType::Write,
966 severity: Severity::Critical,
967 description: "os.WriteFile() writes entire file",
968 },
969 FileSink {
971 language: "go",
972 module: Some("ioutil"),
973 function: "ReadFile",
974 path_arg_index: 0,
975 operation_type: FileOperationType::Read,
976 severity: Severity::High,
977 description: "ioutil.ReadFile() reads entire file",
978 },
979 FileSink {
980 language: "go",
981 module: Some("ioutil"),
982 function: "WriteFile",
983 path_arg_index: 0,
984 operation_type: FileOperationType::Write,
985 severity: Severity::Critical,
986 description: "ioutil.WriteFile() writes entire file",
987 },
988 FileSink {
989 language: "go",
990 module: Some("ioutil"),
991 function: "ReadDir",
992 path_arg_index: 0,
993 operation_type: FileOperationType::ListDir,
994 severity: Severity::Medium,
995 description: "ioutil.ReadDir() lists directory",
996 },
997 FileSink {
999 language: "go",
1000 module: Some("filepath"),
1001 function: "Join",
1002 path_arg_index: 1,
1003 operation_type: FileOperationType::Open,
1004 severity: Severity::High,
1005 description: "filepath.Join() does NOT sanitize - cleans but allows ..",
1006 },
1007 ]
1008}
1009
1010fn c_sinks() -> Vec<FileSink> {
1011 vec![
1012 FileSink {
1014 language: "c",
1015 module: None,
1016 function: "fopen",
1017 path_arg_index: 0,
1018 operation_type: FileOperationType::Open,
1019 severity: Severity::High,
1020 description: "fopen() opens file stream",
1021 },
1022 FileSink {
1023 language: "c",
1024 module: None,
1025 function: "freopen",
1026 path_arg_index: 0,
1027 operation_type: FileOperationType::Open,
1028 severity: Severity::High,
1029 description: "freopen() reopens file stream",
1030 },
1031 FileSink {
1032 language: "c",
1033 module: None,
1034 function: "open",
1035 path_arg_index: 0,
1036 operation_type: FileOperationType::Open,
1037 severity: Severity::High,
1038 description: "open() POSIX file open",
1039 },
1040 FileSink {
1041 language: "c",
1042 module: None,
1043 function: "openat",
1044 path_arg_index: 1,
1045 operation_type: FileOperationType::Open,
1046 severity: Severity::High,
1047 description: "openat() opens file relative to directory fd",
1048 },
1049 FileSink {
1050 language: "c",
1051 module: None,
1052 function: "creat",
1053 path_arg_index: 0,
1054 operation_type: FileOperationType::Write,
1055 severity: Severity::Critical,
1056 description: "creat() creates file",
1057 },
1058 FileSink {
1059 language: "c",
1060 module: None,
1061 function: "remove",
1062 path_arg_index: 0,
1063 operation_type: FileOperationType::Delete,
1064 severity: Severity::Critical,
1065 description: "remove() deletes file",
1066 },
1067 FileSink {
1068 language: "c",
1069 module: None,
1070 function: "unlink",
1071 path_arg_index: 0,
1072 operation_type: FileOperationType::Delete,
1073 severity: Severity::Critical,
1074 description: "unlink() removes file",
1075 },
1076 FileSink {
1077 language: "c",
1078 module: None,
1079 function: "rmdir",
1080 path_arg_index: 0,
1081 operation_type: FileOperationType::Delete,
1082 severity: Severity::Critical,
1083 description: "rmdir() removes directory",
1084 },
1085 FileSink {
1086 language: "c",
1087 module: None,
1088 function: "mkdir",
1089 path_arg_index: 0,
1090 operation_type: FileOperationType::Create,
1091 severity: Severity::Medium,
1092 description: "mkdir() creates directory",
1093 },
1094 FileSink {
1095 language: "c",
1096 module: None,
1097 function: "rename",
1098 path_arg_index: 0,
1099 operation_type: FileOperationType::Move,
1100 severity: Severity::Critical,
1101 description: "rename() moves/renames file",
1102 },
1103 FileSink {
1104 language: "c",
1105 module: None,
1106 function: "opendir",
1107 path_arg_index: 0,
1108 operation_type: FileOperationType::ListDir,
1109 severity: Severity::Medium,
1110 description: "opendir() opens directory for reading",
1111 },
1112 FileSink {
1113 language: "c",
1114 module: None,
1115 function: "stat",
1116 path_arg_index: 0,
1117 operation_type: FileOperationType::Exists,
1118 severity: Severity::Low,
1119 description: "stat() gets file status",
1120 },
1121 FileSink {
1122 language: "c",
1123 module: None,
1124 function: "lstat",
1125 path_arg_index: 0,
1126 operation_type: FileOperationType::Exists,
1127 severity: Severity::Low,
1128 description: "lstat() gets symlink status",
1129 },
1130 FileSink {
1131 language: "c",
1132 module: None,
1133 function: "access",
1134 path_arg_index: 0,
1135 operation_type: FileOperationType::Exists,
1136 severity: Severity::Low,
1137 description: "access() checks file permissions",
1138 },
1139 ]
1140}
1141
1142const PYTHON_SINK_QUERY: &str = r#"
1148; Built-in open()
1149(call function: (identifier) @func
1150 (#eq? @func "open")
1151 arguments: (argument_list) @args) @sink
1152
1153; pathlib Path methods - read/write
1154(call function: (attribute object: (call function: (identifier) @pathlib) attribute: (identifier) @method)
1155 (#eq? @pathlib "Path")
1156 (#any-of? @method "read_text" "read_bytes" "write_text" "write_bytes" "unlink" "rmdir" "mkdir")
1157 arguments: (argument_list) @args) @sink
1158
1159; pathlib Path(x).method() pattern
1160(call function: (attribute attribute: (identifier) @method)
1161 (#any-of? @method "read_text" "read_bytes" "write_text" "write_bytes" "unlink" "rmdir" "mkdir" "open")) @sink
1162
1163; os module functions
1164(call function: (attribute object: (identifier) @module attribute: (identifier) @func)
1165 (#eq? @module "os")
1166 (#any-of? @func "remove" "unlink" "rmdir" "mkdir" "makedirs" "listdir" "rename" "replace")
1167 arguments: (argument_list) @args) @sink
1168
1169; os.path.join - IMPORTANT: flag this as vulnerable pattern
1170(call function: (attribute object: (attribute object: (identifier) @os attribute: (identifier) @path) attribute: (identifier) @func)
1171 (#eq? @os "os")
1172 (#eq? @path "path")
1173 (#eq? @func "join")
1174 arguments: (argument_list) @args) @join_sink
1175
1176; shutil functions
1177(call function: (attribute object: (identifier) @module attribute: (identifier) @func)
1178 (#eq? @module "shutil")
1179 (#any-of? @func "copy" "copy2" "copyfile" "copytree" "rmtree" "move")
1180 arguments: (argument_list) @args) @sink
1181
1182; String literals containing ".." (hardcoded traversal)
1183(string) @string_lit
1184"#;
1185
1186const TYPESCRIPT_SINK_QUERY: &str = r#"
1188; fs module functions
1189(call_expression function: (member_expression object: (identifier) @module property: (property_identifier) @func)
1190 (#any-of? @module "fs" "fsp")
1191 (#any-of? @func "readFile" "readFileSync" "writeFile" "writeFileSync" "appendFile" "appendFileSync"
1192 "unlink" "unlinkSync" "rmdir" "rm" "mkdir" "readdir" "rename" "copyFile"
1193 "createReadStream" "createWriteStream")
1194 arguments: (arguments) @args) @sink
1195
1196; require('fs').method pattern
1197(call_expression function: (member_expression object: (call_expression function: (identifier) @req arguments: (arguments (string) @mod))
1198 property: (property_identifier) @func)
1199 (#eq? @req "require")
1200 (#match? @mod "fs")
1201 (#any-of? @func "readFile" "readFileSync" "writeFile" "writeFileSync" "unlink" "unlinkSync")
1202 arguments: (arguments) @args) @sink
1203
1204; path.join - flag as vulnerable
1205(call_expression function: (member_expression object: (identifier) @module property: (property_identifier) @func)
1206 (#eq? @module "path")
1207 (#any-of? @func "join" "resolve")
1208 arguments: (arguments) @args) @join_sink
1209
1210; String literals containing ".."
1211(string) @string_lit
1212(template_string) @template_lit
1213"#;
1214
1215const GO_SINK_QUERY: &str = r#"
1217; os package functions
1218(call_expression function: (selector_expression operand: (identifier) @pkg field: (field_identifier) @func)
1219 (#eq? @pkg "os")
1220 (#any-of? @func "Open" "OpenFile" "Create" "Remove" "RemoveAll" "Rename" "Mkdir" "MkdirAll"
1221 "ReadDir" "ReadFile" "WriteFile")
1222 arguments: (argument_list) @args) @sink
1223
1224; ioutil functions (deprecated but used)
1225(call_expression function: (selector_expression operand: (identifier) @pkg field: (field_identifier) @func)
1226 (#eq? @pkg "ioutil")
1227 (#any-of? @func "ReadFile" "WriteFile" "ReadDir")
1228 arguments: (argument_list) @args) @sink
1229
1230; filepath.Join - flag as vulnerable pattern
1231(call_expression function: (selector_expression operand: (identifier) @pkg field: (field_identifier) @func)
1232 (#eq? @pkg "filepath")
1233 (#eq? @func "Join")
1234 arguments: (argument_list) @args) @join_sink
1235
1236; String literals containing ".."
1237(interpreted_string_literal) @string_lit
1238(raw_string_literal) @string_lit
1239"#;
1240
1241const RUST_SINK_QUERY: &str = r#"
1243; std::fs functions
1244(call_expression function: (scoped_identifier) @func
1245 (#match? @func "fs::(read|read_to_string|write|remove_file|remove_dir|remove_dir_all|create_dir|create_dir_all|copy|rename|read_dir)")
1246 arguments: (arguments) @args) @sink
1247
1248; File::open and File::create
1249(call_expression function: (scoped_identifier) @func
1250 (#match? @func "File::(open|create)")
1251 arguments: (arguments) @args) @sink
1252
1253; Path::new with user input
1254(call_expression function: (scoped_identifier) @func
1255 (#match? @func "Path::new")
1256 arguments: (arguments) @args) @path_new_sink
1257
1258; tokio::fs functions
1259(call_expression function: (scoped_identifier) @func
1260 (#match? @func "tokio::fs::")
1261 arguments: (arguments) @args) @sink
1262
1263; String literals containing ".."
1264(string_literal) @string_lit
1265"#;
1266
1267const C_SINK_QUERY: &str = r#"
1269; File operations
1270(call_expression function: (identifier) @func
1271 (#any-of? @func "fopen" "freopen" "open" "creat" "remove" "unlink" "rmdir" "mkdir" "rename" "opendir" "stat" "lstat" "access")
1272 arguments: (argument_list) @args) @sink
1273
1274; openat has path at index 1
1275(call_expression function: (identifier) @func
1276 (#eq? @func "openat")
1277 arguments: (argument_list) @args) @sink_openat
1278
1279; String literals containing ".."
1280(string_literal) @string_lit
1281"#;
1282
1283fn get_sink_query(language: &str) -> Option<&'static str> {
1285 match language {
1286 "python" => Some(PYTHON_SINK_QUERY),
1287 "typescript" | "javascript" => Some(TYPESCRIPT_SINK_QUERY),
1288 "go" => Some(GO_SINK_QUERY),
1289 "rust" => Some(RUST_SINK_QUERY),
1290 "c" | "cpp" => Some(C_SINK_QUERY),
1291 _ => None,
1292 }
1293}
1294
1295pub fn scan_path_traversal(path: &Path, language: Option<&str>) -> Result<Vec<PathTraversalFinding>> {
1310 let path_str = path.to_str().ok_or_else(|| {
1311 BrrrError::InvalidArgument("Invalid path encoding".to_string())
1312 })?;
1313
1314 let scanner = ProjectScanner::new(path_str)?;
1315 let config = match language {
1316 Some(lang) => ScanConfig::for_language(lang),
1317 None => ScanConfig::default(),
1318 };
1319
1320 let scan_result = scanner.scan_with_config(&config)?;
1321 let files = scan_result.files;
1322
1323 let findings: Vec<PathTraversalFinding> = files
1325 .par_iter()
1326 .filter_map(|file| {
1327 scan_file_path_traversal(file, language).ok()
1328 })
1329 .flatten()
1330 .collect();
1331
1332 Ok(findings)
1333}
1334
1335pub fn scan_file_path_traversal(file: &Path, language: Option<&str>) -> Result<Vec<PathTraversalFinding>> {
1346 let registry = LanguageRegistry::global();
1347
1348 let lang = match language {
1350 Some(lang_name) => registry
1351 .get_by_name(lang_name)
1352 .ok_or_else(|| BrrrError::UnsupportedLanguage(lang_name.to_string()))?,
1353 None => registry
1354 .detect_language(file)
1355 .ok_or_else(|| BrrrError::UnsupportedLanguage(
1356 file.extension()
1357 .and_then(|e| e.to_str())
1358 .unwrap_or("unknown")
1359 .to_string(),
1360 ))?,
1361 };
1362
1363 let lang_name = lang.name();
1364
1365 let sink_query_str = get_sink_query(lang_name)
1367 .ok_or_else(|| BrrrError::UnsupportedLanguage(format!("{} (no path traversal query)", lang_name)))?;
1368
1369 let source = std::fs::read(file).map_err(|e| BrrrError::io_with_path(e, file))?;
1371 let mut parser = lang.parser_for_path(file)?;
1372 let tree = parser.parse(&source, None).ok_or_else(|| BrrrError::Parse {
1373 file: file.display().to_string(),
1374 message: "Failed to parse file".to_string(),
1375 })?;
1376
1377 let ts_lang = tree.language();
1378 let file_path = file.display().to_string();
1379
1380 find_path_traversal_vulnerabilities(&tree, &source, &ts_lang, sink_query_str, lang_name, &file_path)
1382}
1383
1384fn find_path_traversal_vulnerabilities(
1386 tree: &Tree,
1387 source: &[u8],
1388 ts_lang: &tree_sitter::Language,
1389 query_str: &str,
1390 lang_name: &str,
1391 file_path: &str,
1392) -> Result<Vec<PathTraversalFinding>> {
1393 let query = Query::new(ts_lang, query_str)
1394 .map_err(|e| BrrrError::TreeSitter(format_query_error(lang_name, "path_traversal", query_str, &e)))?;
1395
1396 let mut cursor = QueryCursor::new();
1397 let mut matches = cursor.matches(&query, tree.root_node(), source);
1398
1399 let sink_idx = query.capture_index_for_name("sink");
1401 let join_sink_idx = query.capture_index_for_name("join_sink");
1402 let path_new_idx = query.capture_index_for_name("path_new_sink");
1403 let func_idx = query.capture_index_for_name("func");
1404 let args_idx = query.capture_index_for_name("args");
1405 let string_lit_idx = query.capture_index_for_name("string_lit");
1406 let template_lit_idx = query.capture_index_for_name("template_lit");
1407
1408 let mut findings = Vec::new();
1409 let known_sinks = get_file_sinks(lang_name);
1410
1411 let validation_patterns = find_validation_patterns(tree, source, lang_name);
1413
1414 while let Some(match_) = matches.next() {
1415 if let Some(idx) = string_lit_idx {
1417 if let Some(capture) = match_.captures.iter().find(|c| c.index == idx) {
1418 let text = node_text(capture.node, source);
1419 if text.contains("../") || text.contains("..\\") {
1420 let location = node_to_location(capture.node, file_path);
1421
1422 if !is_likely_test_or_comment(capture.node, source) {
1424 findings.push(PathTraversalFinding {
1425 location,
1426 severity: Severity::Medium,
1427 sink_function: "string_literal".to_string(),
1428 operation_type: FileOperationType::Open,
1429 path_expression: text.to_string(),
1430 confidence: Confidence::High,
1431 pattern: VulnerablePattern::HardcodedTraversal,
1432 involved_variables: vec![],
1433 code_snippet: extract_code_snippet(source, capture.node),
1434 description: "Hardcoded path traversal sequence '../' found. This may indicate intentional traversal or a vulnerability.".to_string(),
1435 remediation: "Remove hardcoded '../' sequences. Use absolute paths or validate that the resolved path stays within the intended directory.".to_string(),
1436 symlink_risk: false,
1437 });
1438 }
1439 }
1440 }
1441 }
1442
1443 if let Some(idx) = template_lit_idx {
1445 if let Some(capture) = match_.captures.iter().find(|c| c.index == idx) {
1446 let text = node_text(capture.node, source);
1447 if (text.contains("../") || text.contains("${")) && has_substitution(capture.node) {
1448 let location = node_to_location(capture.node, file_path);
1449 let vars = extract_template_variables(capture.node, source);
1450
1451 findings.push(PathTraversalFinding {
1452 location,
1453 severity: Severity::High,
1454 sink_function: "template_literal".to_string(),
1455 operation_type: FileOperationType::Open,
1456 path_expression: text.to_string(),
1457 confidence: Confidence::Medium,
1458 pattern: VulnerablePattern::PathInterpolation,
1459 involved_variables: vars,
1460 code_snippet: extract_code_snippet(source, capture.node),
1461 description: "Template literal with path interpolation detected. User input may enable path traversal.".to_string(),
1462 remediation: "Validate interpolated values. Use path.basename() to extract only filename, or validate with realpath() + startswith() check.".to_string(),
1463 symlink_risk: true,
1464 });
1465 }
1466 }
1467 }
1468
1469 if let Some(idx) = join_sink_idx {
1471 if let Some(capture) = match_.captures.iter().find(|c| c.index == idx) {
1472 let call_node = capture.node;
1473 let args_node = args_idx.and_then(|i| match_.captures.iter().find(|c| c.index == i)).map(|c| c.node);
1474
1475 if let Some(args) = args_node {
1476 let (path_expr, vars) = extract_path_argument(args, source, 1);
1477
1478 if looks_like_user_input(&path_expr, &vars) {
1480 let location = node_to_location(call_node, file_path);
1481 let func_name = get_join_function_name(lang_name);
1482
1483 findings.push(PathTraversalFinding {
1484 location,
1485 severity: Severity::High,
1486 sink_function: func_name.to_string(),
1487 operation_type: FileOperationType::Open,
1488 path_expression: path_expr,
1489 confidence: Confidence::High,
1490 pattern: VulnerablePattern::UnsafePathJoin,
1491 involved_variables: vars,
1492 code_snippet: extract_code_snippet(source, call_node),
1493 description: format!("{}() with user input is NOT safe! It does not prevent absolute paths or '..' sequences.", func_name),
1494 remediation: get_join_remediation(lang_name),
1495 symlink_risk: true,
1496 });
1497 }
1498 }
1499 }
1500 }
1501
1502 if let Some(idx) = sink_idx {
1504 if let Some(capture) = match_.captures.iter().find(|c| c.index == idx) {
1505 let call_node = capture.node;
1506 let func_node = func_idx.and_then(|i| match_.captures.iter().find(|c| c.index == i)).map(|c| c.node);
1507 let args_node = args_idx.and_then(|i| match_.captures.iter().find(|c| c.index == i)).map(|c| c.node);
1508
1509 let func_name = func_node
1510 .map(|n| node_text(n, source))
1511 .unwrap_or("unknown");
1512
1513 let sink_def = known_sinks.iter().find(|s| s.function == func_name || s.function.ends_with(&format!("::{}", func_name)));
1515
1516 if let Some(args) = args_node {
1517 let path_arg_idx = sink_def.map(|s| s.path_arg_index).unwrap_or(0);
1518 let (path_expr, vars) = extract_path_argument(args, source, path_arg_idx);
1519
1520 let has_validation = check_nearby_validation(&validation_patterns, call_node, &vars);
1522
1523 if !has_validation && !vars.is_empty() {
1524 let operation_type = sink_def.map(|s| s.operation_type).unwrap_or(FileOperationType::Open);
1525 let base_severity = sink_def.map(|s| s.severity).unwrap_or(Severity::Medium);
1526
1527 let (confidence, pattern) = analyze_path_expression(&path_expr, &vars, lang_name);
1528
1529 let severity = if confidence == Confidence::High {
1531 base_severity
1532 } else if confidence == Confidence::Medium {
1533 match base_severity {
1534 Severity::Critical => Severity::High,
1535 s => s,
1536 }
1537 } else {
1538 Severity::Low
1539 };
1540
1541 let location = node_to_location(call_node, file_path);
1542
1543 findings.push(PathTraversalFinding {
1544 location,
1545 severity,
1546 sink_function: func_name.to_string(),
1547 operation_type,
1548 path_expression: path_expr.clone(),
1549 confidence,
1550 pattern,
1551 involved_variables: vars.clone(),
1552 code_snippet: extract_code_snippet(source, call_node),
1553 description: generate_description(func_name, &pattern, &vars),
1554 remediation: generate_remediation(lang_name, operation_type, &pattern),
1555 symlink_risk: operation_type != FileOperationType::Exists,
1556 });
1557 }
1558 }
1559 }
1560 }
1561
1562 if let Some(idx) = path_new_idx {
1564 if let Some(capture) = match_.captures.iter().find(|c| c.index == idx) {
1565 let call_node = capture.node;
1566 let args_node = args_idx.and_then(|i| match_.captures.iter().find(|c| c.index == i)).map(|c| c.node);
1567
1568 if let Some(args) = args_node {
1569 let (path_expr, vars) = extract_path_argument(args, source, 0);
1570
1571 if looks_like_user_input(&path_expr, &vars) {
1572 let location = node_to_location(call_node, file_path);
1573
1574 findings.push(PathTraversalFinding {
1575 location,
1576 severity: Severity::Medium,
1577 sink_function: "Path::new".to_string(),
1578 operation_type: FileOperationType::Open,
1579 path_expression: path_expr,
1580 confidence: Confidence::Medium,
1581 pattern: VulnerablePattern::UnvalidatedVariable,
1582 involved_variables: vars,
1583 code_snippet: extract_code_snippet(source, call_node),
1584 description: "Path::new() with user input may enable path traversal when used with file operations.".to_string(),
1585 remediation: "Validate the path after canonicalizing: use std::fs::canonicalize() and verify it starts with the expected base directory.".to_string(),
1586 symlink_risk: true,
1587 });
1588 }
1589 }
1590 }
1591 }
1592 }
1593
1594 Ok(findings)
1595}
1596
1597fn node_text<'a>(node: Node<'a>, source: &'a [u8]) -> &'a str {
1603 std::str::from_utf8(&source[node.start_byte()..node.end_byte()]).unwrap_or("")
1604}
1605
1606fn node_to_location(node: Node, file_path: &str) -> SourceLocation {
1608 SourceLocation {
1609 file: file_path.to_string(),
1610 line: node.start_position().row + 1,
1611 column: node.start_position().column + 1,
1612 end_line: node.end_position().row + 1,
1613 end_column: node.end_position().column + 1,
1614 }
1615}
1616
1617fn extract_code_snippet(source: &[u8], node: Node) -> Option<String> {
1619 let source_str = std::str::from_utf8(source).ok()?;
1620 let lines: Vec<&str> = source_str.lines().collect();
1621
1622 let start_line = node.start_position().row;
1623 let end_line = node.end_position().row;
1624
1625 let context_start = start_line.saturating_sub(1);
1627 let context_end = (end_line + 2).min(lines.len());
1628
1629 let snippet: Vec<String> = lines[context_start..context_end]
1630 .iter()
1631 .enumerate()
1632 .map(|(i, line)| format!("{:4} | {}", context_start + i + 1, line))
1633 .collect();
1634
1635 Some(snippet.join("\n"))
1636}
1637
1638fn extract_path_argument(args_node: Node, source: &[u8], arg_index: usize) -> (String, Vec<String>) {
1640 let mut positional_args = Vec::new();
1641 let mut cursor = args_node.walk();
1642
1643 for child in args_node.children(&mut cursor) {
1644 match child.kind() {
1645 "(" | ")" | "," | "keyword_argument" => continue,
1646 _ => positional_args.push(child),
1647 }
1648 }
1649
1650 if let Some(arg_node) = positional_args.get(arg_index) {
1651 let text = node_text(*arg_node, source).to_string();
1652 let vars = collect_variables(*arg_node, source);
1653 (text, vars)
1654 } else if !positional_args.is_empty() {
1655 let text = node_text(positional_args[0], source).to_string();
1657 let vars = collect_variables(positional_args[0], source);
1658 (text, vars)
1659 } else {
1660 (String::new(), Vec::new())
1661 }
1662}
1663
1664fn collect_variables(node: Node, source: &[u8]) -> Vec<String> {
1666 let mut vars = Vec::new();
1667 collect_variables_recursive(node, source, &mut vars);
1668 vars.sort();
1669 vars.dedup();
1670 vars
1671}
1672
1673fn collect_variables_recursive(node: Node, source: &[u8], vars: &mut Vec<String>) {
1674 if node.kind() == "identifier" {
1675 let name = node_text(node, source).to_string();
1676 let ignore_list = ["True", "False", "None", "self", "cls", "os", "fs", "path", "shutil", "ioutil", "filepath", "std"];
1678 if !ignore_list.contains(&name.as_str()) && !name.is_empty() {
1679 vars.push(name);
1680 }
1681 }
1682
1683 let mut cursor = node.walk();
1684 for child in node.children(&mut cursor) {
1685 collect_variables_recursive(child, source, vars);
1686 }
1687}
1688
1689fn looks_like_user_input(expr: &str, vars: &[String]) -> bool {
1692 let lower = expr.to_lowercase();
1693 if USER_INPUT_PATTERNS.is_match(&lower) {
1694 return true;
1695 }
1696
1697 for var in vars {
1698 let lower_var = var.to_lowercase();
1699 if USER_INPUT_PATTERNS.is_match(&lower_var) {
1700 return true;
1701 }
1702 }
1703
1704 if !vars.is_empty() && !expr.starts_with('"') && !expr.starts_with('\'') && !expr.starts_with('`') {
1706 return true;
1707 }
1708
1709 false
1710}
1711
1712fn analyze_path_expression(expr: &str, vars: &[String], _lang: &str) -> (Confidence, VulnerablePattern) {
1714 let suspicious = [
1715 "request", "req", "params", "query", "body", "input",
1716 "user", "filename", "file_name", "filepath", "file_path",
1717 ];
1718
1719 let lower = expr.to_lowercase();
1720
1721 for pattern in suspicious {
1723 if lower.contains(pattern) {
1724 return (Confidence::High, VulnerablePattern::DirectUserInput);
1725 }
1726 }
1727
1728 if expr.contains('+') || expr.contains("format") || expr.contains('%') {
1730 return (Confidence::Medium, VulnerablePattern::PathConcatenation);
1731 }
1732
1733 if expr.contains('{') && expr.contains('}') {
1735 return (Confidence::Medium, VulnerablePattern::PathInterpolation);
1736 }
1737
1738 if expr.contains("${") {
1740 return (Confidence::Medium, VulnerablePattern::PathInterpolation);
1741 }
1742
1743 if !vars.is_empty() {
1745 return (Confidence::Low, VulnerablePattern::UnvalidatedVariable);
1746 }
1747
1748 (Confidence::Low, VulnerablePattern::MissingValidation)
1749}
1750
1751fn find_validation_patterns(_tree: &Tree, source: &[u8], lang: &str) -> HashSet<String> {
1753 let mut validated_vars = HashSet::new();
1754 let source_str = std::str::from_utf8(source).unwrap_or("");
1755
1756 match lang {
1757 "python" => {
1758 if source_str.contains("realpath") && source_str.contains("startswith") {
1760 validated_vars.insert("_validated_".to_string());
1761 }
1762 if source_str.contains("basename") {
1764 validated_vars.insert("_basename_".to_string());
1765 }
1766 }
1767 "typescript" | "javascript" => {
1768 if source_str.contains("resolve") && source_str.contains("startsWith") {
1770 validated_vars.insert("_validated_".to_string());
1771 }
1772 if source_str.contains("basename") {
1774 validated_vars.insert("_basename_".to_string());
1775 }
1776 }
1777 "go" => {
1778 if source_str.contains("filepath.Clean") && source_str.contains("HasPrefix") {
1780 validated_vars.insert("_validated_".to_string());
1781 }
1782 if source_str.contains("filepath.Base") {
1784 validated_vars.insert("_basename_".to_string());
1785 }
1786 }
1787 "rust" => {
1788 if source_str.contains("canonicalize") && source_str.contains("starts_with") {
1790 validated_vars.insert("_validated_".to_string());
1791 }
1792 if source_str.contains("file_name()") {
1794 validated_vars.insert("_basename_".to_string());
1795 }
1796 }
1797 "c" | "cpp" => {
1798 if source_str.contains("realpath") && (source_str.contains("strncmp") || source_str.contains("strstr")) {
1800 validated_vars.insert("_validated_".to_string());
1801 }
1802 if source_str.contains("basename") {
1804 validated_vars.insert("_basename_".to_string());
1805 }
1806 }
1807 _ => {}
1808 }
1809
1810 validated_vars
1811}
1812
1813fn check_nearby_validation(validation_patterns: &HashSet<String>, _node: Node, _vars: &[String]) -> bool {
1815 !validation_patterns.is_empty()
1818}
1819
1820fn is_likely_test_or_comment(node: Node, source: &[u8]) -> bool {
1822 let mut current = Some(node);
1824 while let Some(n) = current {
1825 let text = node_text(n, source).to_lowercase();
1826 if text.contains("test") || text.contains("mock") || text.contains("spec") {
1827 return true;
1828 }
1829 current = n.parent();
1830 }
1831 false
1832}
1833
1834fn has_substitution(node: Node) -> bool {
1836 let mut cursor = node.walk();
1837 for child in node.children(&mut cursor) {
1838 if child.kind() == "template_substitution" {
1839 return true;
1840 }
1841 }
1842 false
1843}
1844
1845fn extract_template_variables(node: Node, source: &[u8]) -> Vec<String> {
1847 let mut vars = Vec::new();
1848 let mut cursor = node.walk();
1849
1850 for child in node.children(&mut cursor) {
1851 if child.kind() == "template_substitution" {
1852 vars.extend(collect_variables(child, source));
1853 }
1854 }
1855
1856 vars
1857}
1858
1859fn get_join_function_name(lang: &str) -> &'static str {
1861 match lang {
1862 "python" => "os.path.join",
1863 "typescript" | "javascript" => "path.join",
1864 "go" => "filepath.Join",
1865 _ => "path.join",
1866 }
1867}
1868
1869fn get_join_remediation(lang: &str) -> String {
1871 match lang {
1872 "python" => {
1873 "os.path.join() does NOT sanitize user input! Fix:\n\
1874 1. Use os.path.basename() to extract only filename: safe_name = os.path.basename(user_input)\n\
1875 2. Or validate with realpath: resolved = os.path.realpath(os.path.join(base, user_input))\n\
1876 if not resolved.startswith(os.path.realpath(base)):\n\
1877 raise ValueError('Path traversal detected')".to_string()
1878 }
1879 "typescript" | "javascript" => {
1880 "path.join() does NOT sanitize user input! Fix:\n\
1881 1. Use path.basename() to extract only filename: const safeName = path.basename(userInput)\n\
1882 2. Or validate: const resolved = path.resolve(base, userInput)\n\
1883 if (!resolved.startsWith(path.resolve(base))) throw new Error('Path traversal')".to_string()
1884 }
1885 "go" => {
1886 "filepath.Join() does NOT sanitize user input! Fix:\n\
1887 1. Use filepath.Base() to extract only filename: safeName := filepath.Base(userInput)\n\
1888 2. Or validate: resolved := filepath.Clean(filepath.Join(base, userInput))\n\
1889 if !strings.HasPrefix(resolved, filepath.Clean(base)) { return error }".to_string()
1890 }
1891 _ => "Path join functions do NOT sanitize input. Use basename() or validate the resolved path.".to_string(),
1892 }
1893}
1894
1895fn generate_description(func_name: &str, pattern: &VulnerablePattern, vars: &[String]) -> String {
1897 let var_list = if vars.is_empty() {
1898 "unknown variable".to_string()
1899 } else {
1900 vars.join(", ")
1901 };
1902
1903 match pattern {
1904 VulnerablePattern::DirectUserInput => {
1905 format!(
1906 "Potential path traversal in {}(). Variable '{}' appears to be user-controlled and is passed directly to file operation.",
1907 func_name, var_list
1908 )
1909 }
1910 VulnerablePattern::UnsafePathJoin => {
1911 format!(
1912 "Unsafe path join in {}() with user input '{}'. Path join functions do NOT prevent path traversal!",
1913 func_name, var_list
1914 )
1915 }
1916 VulnerablePattern::PathConcatenation => {
1917 format!(
1918 "Path concatenation in {}() with variable '{}'. String concatenation for paths is vulnerable to traversal.",
1919 func_name, var_list
1920 )
1921 }
1922 VulnerablePattern::HardcodedTraversal => {
1923 "Hardcoded '../' path traversal sequence detected. This may allow escaping the intended directory.".to_string()
1924 }
1925 VulnerablePattern::UnvalidatedVariable => {
1926 format!(
1927 "Variable '{}' passed to {}() without visible validation. May enable path traversal if user-controlled.",
1928 var_list, func_name
1929 )
1930 }
1931 VulnerablePattern::PathInterpolation => {
1932 format!(
1933 "Path interpolation in {}() with variables '{}'. Interpolated paths are vulnerable to traversal attacks.",
1934 func_name, var_list
1935 )
1936 }
1937 VulnerablePattern::MissingValidation => {
1938 format!(
1939 "File operation {}() without visible path validation. Ensure paths are validated before use.",
1940 func_name
1941 )
1942 }
1943 }
1944}
1945
1946fn generate_remediation(lang: &str, operation_type: FileOperationType, pattern: &VulnerablePattern) -> String {
1948 let op_warning = match operation_type {
1949 FileOperationType::Write | FileOperationType::Append => "WARNING: Write operation - attackers could overwrite critical files!",
1950 FileOperationType::Delete => "CRITICAL: Delete operation - attackers could delete arbitrary files!",
1951 FileOperationType::Read => "Attackers could read sensitive files like /etc/passwd or config files.",
1952 FileOperationType::Copy | FileOperationType::Move => "Attackers could copy/move files to/from unintended locations.",
1953 _ => "Attackers could access files outside the intended directory.",
1954 };
1955
1956 let fix = match lang {
1957 "python" => {
1958 "Fix for Python:\n\
1959 1. Extract filename only: safe_name = os.path.basename(user_input)\n\
1960 2. Or validate resolved path:\n\
1961 base = os.path.realpath('/safe/base/dir')\n\
1962 resolved = os.path.realpath(os.path.join(base, user_input))\n\
1963 if not resolved.startswith(base + os.sep):\n\
1964 raise ValueError('Path traversal attempt')"
1965 }
1966 "typescript" | "javascript" => {
1967 "Fix for JavaScript/TypeScript:\n\
1968 1. Extract filename only: const safeName = path.basename(userInput)\n\
1969 2. Or validate resolved path:\n\
1970 const base = path.resolve('/safe/base/dir')\n\
1971 const resolved = path.resolve(base, userInput)\n\
1972 if (!resolved.startsWith(base + path.sep)) {\n\
1973 throw new Error('Path traversal attempt')\n\
1974 }"
1975 }
1976 "go" => {
1977 "Fix for Go:\n\
1978 1. Extract filename only: safeName := filepath.Base(userInput)\n\
1979 2. Or validate resolved path:\n\
1980 base, _ := filepath.Abs(\"/safe/base/dir\")\n\
1981 resolved := filepath.Clean(filepath.Join(base, userInput))\n\
1982 if !strings.HasPrefix(resolved, base + string(os.PathSeparator)) {\n\
1983 return errors.New(\"path traversal attempt\")\n\
1984 }"
1985 }
1986 "rust" => {
1987 "Fix for Rust:\n\
1988 1. Extract filename only: let safe_name = Path::new(user_input).file_name()\n\
1989 2. Or validate canonical path:\n\
1990 let base = std::fs::canonicalize(\"/safe/base/dir\")?;\n\
1991 let resolved = std::fs::canonicalize(base.join(user_input))?;\n\
1992 if !resolved.starts_with(&base) {\n\
1993 return Err(\"path traversal attempt\")\n\
1994 }"
1995 }
1996 "c" | "cpp" => {
1997 "Fix for C/C++:\n\
1998 1. Extract filename only: char *safe_name = basename(user_input)\n\
1999 2. Or validate with realpath:\n\
2000 char base[PATH_MAX], resolved[PATH_MAX];\n\
2001 realpath(\"/safe/base/dir\", base);\n\
2002 realpath(combined_path, resolved);\n\
2003 if (strncmp(resolved, base, strlen(base)) != 0) {\n\
2004 // Path traversal detected\n\
2005 }"
2006 }
2007 _ => "Use basename() to extract only filename, or validate that the resolved absolute path starts with the expected base directory.",
2008 };
2009
2010 let symlink_warning = match pattern {
2011 VulnerablePattern::HardcodedTraversal => "",
2012 _ => "\n\nSymlink Warning: Even with validation, symlink attacks may be possible. Consider:\n\
2013 - Use O_NOFOLLOW flag (POSIX) to prevent symlink following\n\
2014 - Validate paths at the moment of use (TOCTOU protection)\n\
2015 - Use chroot or containerization for stronger isolation"
2016 };
2017
2018 format!("{}\n\n{}{}", op_warning, fix, symlink_warning)
2019}
2020
2021#[cfg(test)]
2026mod tests {
2027 use super::*;
2028 use std::io::Write;
2029 use tempfile::NamedTempFile;
2030
2031 fn create_temp_file(content: &str, extension: &str) -> NamedTempFile {
2032 let mut file = tempfile::Builder::new()
2033 .suffix(extension)
2034 .tempfile()
2035 .expect("Failed to create temp file");
2036 file.write_all(content.as_bytes()).expect("Failed to write");
2037 file
2038 }
2039
2040 #[test]
2045 fn test_python_direct_open_user_input() {
2046 let source = r#"
2047def read_file(request):
2048 filename = request.args.get('filename')
2049 with open(filename) as f:
2050 return f.read()
2051"#;
2052 let file = create_temp_file(source, ".py");
2053 let findings = scan_file_path_traversal(file.path(), Some("python"))
2054 .expect("Scan should succeed");
2055
2056 assert!(!findings.is_empty(), "Should detect open() with user input");
2057 let finding = &findings[0];
2058 assert_eq!(finding.sink_function, "open");
2059 assert!(finding.severity >= Severity::Medium);
2060 }
2061
2062 #[test]
2063 fn test_python_unsafe_path_join() {
2064 let source = r#"
2065import os
2066
2067def download_file(user_filename):
2068 base_dir = "/var/www/uploads"
2069 filepath = os.path.join(base_dir, user_filename) # NOT SAFE!
2070 with open(filepath, 'rb') as f:
2071 return f.read()
2072"#;
2073 let file = create_temp_file(source, ".py");
2074 let findings = scan_file_path_traversal(file.path(), Some("python"))
2075 .expect("Scan should succeed");
2076
2077 assert!(!findings.is_empty(), "Should detect path traversal risk");
2079 }
2080
2081 #[test]
2082 fn test_python_hardcoded_traversal() {
2083 let source = r#"
2084def get_parent_config():
2085 with open("../config.ini") as f:
2086 return f.read()
2087"#;
2088 let file = create_temp_file(source, ".py");
2089 let findings = scan_file_path_traversal(file.path(), Some("python"))
2090 .expect("Scan should succeed");
2091
2092 let traversal_finding = findings.iter()
2093 .find(|f| f.pattern == VulnerablePattern::HardcodedTraversal);
2094 assert!(traversal_finding.is_some(), "Should detect hardcoded '../'");
2095 }
2096
2097 #[test]
2098 fn test_python_shutil_rmtree() {
2099 let source = r#"
2100import shutil
2101
2102def delete_user_folder(user_path):
2103 shutil.rmtree(user_path) # Critical!
2104"#;
2105 let file = create_temp_file(source, ".py");
2106 let findings = scan_file_path_traversal(file.path(), Some("python"))
2107 .expect("Scan should succeed");
2108
2109 assert!(!findings.is_empty(), "Should detect shutil.rmtree");
2110 let finding = findings.iter().find(|f| f.sink_function == "rmtree");
2112 assert!(finding.is_some() || !findings.is_empty());
2113 }
2114
2115 #[test]
2120 fn test_typescript_fs_readfile() {
2121 let source = r#"
2122import * as fs from 'fs';
2123
2124function readUserFile(req: Request) {
2125 const filename = req.params.filename;
2126 return fs.readFileSync(filename);
2127}
2128"#;
2129 let file = create_temp_file(source, ".ts");
2130 let findings = scan_file_path_traversal(file.path(), Some("typescript"))
2131 .expect("Scan should succeed");
2132
2133 assert!(!findings.is_empty(), "Should detect fs.readFileSync with user input");
2134 }
2135
2136 #[test]
2137 fn test_typescript_path_join() {
2138 let source = r#"
2139import * as fs from 'fs';
2140import * as path from 'path';
2141
2142function getFile(userPath: string) {
2143 const fullPath = path.join('/uploads', userPath); // NOT SAFE!
2144 return fs.readFileSync(fullPath);
2145}
2146"#;
2147 let file = create_temp_file(source, ".ts");
2148 let findings = scan_file_path_traversal(file.path(), Some("typescript"))
2149 .expect("Scan should succeed");
2150
2151 assert!(!findings.is_empty(), "Should detect path.join vulnerability");
2152 }
2153
2154 #[test]
2155 fn test_typescript_template_literal() {
2156 let source = r#"
2157import * as fs from 'fs';
2158
2159function readConfig(userId: string) {
2160 const path = `/data/${userId}/config.json`;
2161 return fs.readFileSync(path);
2162}
2163"#;
2164 let file = create_temp_file(source, ".ts");
2165 let findings = scan_file_path_traversal(file.path(), Some("typescript"))
2166 .expect("Scan should succeed");
2167
2168 if !findings.is_empty() {
2170 println!("Found {} findings", findings.len());
2171 }
2172 }
2173
2174 #[test]
2179 fn test_go_os_open() {
2180 let source = r#"
2181package main
2182
2183import "os"
2184
2185func readFile(userPath string) ([]byte, error) {
2186 f, err := os.Open(userPath)
2187 if err != nil {
2188 return nil, err
2189 }
2190 defer f.Close()
2191 return io.ReadAll(f)
2192}
2193"#;
2194 let file = create_temp_file(source, ".go");
2195 let findings = scan_file_path_traversal(file.path(), Some("go"))
2196 .expect("Scan should succeed");
2197
2198 assert!(!findings.is_empty(), "Should detect os.Open with user path");
2199 }
2200
2201 #[test]
2202 fn test_go_filepath_join() {
2203 let source = r#"
2204package main
2205
2206import (
2207 "os"
2208 "path/filepath"
2209)
2210
2211func getFile(basePath, userInput string) ([]byte, error) {
2212 path := filepath.Join(basePath, userInput) // NOT SAFE!
2213 return os.ReadFile(path)
2214}
2215"#;
2216 let file = create_temp_file(source, ".go");
2217 let findings = scan_file_path_traversal(file.path(), Some("go"))
2218 .expect("Scan should succeed");
2219
2220 assert!(!findings.is_empty(), "Should detect filepath.Join vulnerability");
2221 }
2222
2223 #[test]
2228 fn test_rust_fs_read() {
2229 let source = r#"
2230use std::fs;
2231
2232fn read_user_file(user_path: &str) -> std::io::Result<String> {
2233 fs::read_to_string(user_path)
2234}
2235"#;
2236 let file = create_temp_file(source, ".rs");
2237 let findings = scan_file_path_traversal(file.path(), Some("rust"))
2238 .expect("Scan should succeed");
2239
2240 println!("Rust findings: {:?}", findings);
2243 }
2244
2245 #[test]
2250 fn test_c_fopen() {
2251 let source = r#"
2252#include <stdio.h>
2253
2254void read_file(const char* user_path) {
2255 FILE* f = fopen(user_path, "r");
2256 if (f) {
2257 // read file
2258 fclose(f);
2259 }
2260}
2261"#;
2262 let file = create_temp_file(source, ".c");
2263 let findings = scan_file_path_traversal(file.path(), Some("c"))
2264 .expect("Scan should succeed");
2265
2266 assert!(!findings.is_empty(), "Should detect fopen with user path");
2267 assert_eq!(findings[0].sink_function, "fopen");
2268 }
2269
2270 #[test]
2271 fn test_c_hardcoded_traversal() {
2272 let source = r#"
2273#include <stdio.h>
2274
2275void read_parent() {
2276 FILE* f = fopen("../secret.txt", "r");
2277 fclose(f);
2278}
2279"#;
2280 let file = create_temp_file(source, ".c");
2281 let findings = scan_file_path_traversal(file.path(), Some("c"))
2282 .expect("Scan should succeed");
2283
2284 let traversal = findings.iter()
2285 .find(|f| f.pattern == VulnerablePattern::HardcodedTraversal);
2286 assert!(traversal.is_some(), "Should detect hardcoded '../'");
2287 }
2288
2289 #[test]
2294 fn test_severity_ordering() {
2295 assert!(Severity::Critical > Severity::High);
2296 assert!(Severity::High > Severity::Medium);
2297 assert!(Severity::Medium > Severity::Low);
2298 assert!(Severity::Low > Severity::Info);
2299 }
2300
2301 #[test]
2302 fn test_confidence_ordering() {
2303 assert!(Confidence::High > Confidence::Medium);
2304 assert!(Confidence::Medium > Confidence::Low);
2305 }
2306
2307 #[test]
2308 fn test_file_operation_type_display() {
2309 assert_eq!(format!("{}", FileOperationType::Read), "read");
2310 assert_eq!(format!("{}", FileOperationType::Write), "write");
2311 assert_eq!(format!("{}", FileOperationType::Delete), "delete");
2312 }
2313
2314 #[test]
2315 fn test_vulnerable_pattern_display() {
2316 assert_eq!(format!("{}", VulnerablePattern::UnsafePathJoin), "unsafe_path_join");
2317 assert_eq!(format!("{}", VulnerablePattern::HardcodedTraversal), "hardcoded_traversal");
2318 }
2319
2320 #[test]
2321 fn test_looks_like_user_input() {
2322 assert!(looks_like_user_input("request.args.get('file')", &[]));
2323 assert!(looks_like_user_input("user_filename", &["user_filename".to_string()]));
2324 assert!(looks_like_user_input("filepath", &["filepath".to_string()]));
2325 assert!(!looks_like_user_input("\"static.txt\"", &[]));
2326 }
2327
2328 #[test]
2329 fn test_get_file_sinks_coverage() {
2330 let languages = ["python", "typescript", "javascript", "go", "rust", "c", "cpp"];
2331 for lang in languages {
2332 let sinks = get_file_sinks(lang);
2333 assert!(!sinks.is_empty(), "Should have sinks for {}", lang);
2334 }
2335 }
2336}