libsast 2.0.2__tar.gz → 3.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {libsast-2.0.2 → libsast-3.0.1}/PKG-INFO +10 -6
  2. {libsast-2.0.2 → libsast-3.0.1}/README.md +6 -4
  3. {libsast-2.0.2 → libsast-3.0.1}/libsast/__init__.py +6 -2
  4. {libsast-2.0.2 → libsast-3.0.1}/libsast/__main__.py +6 -1
  5. {libsast-2.0.2 → libsast-3.0.1}/libsast/common.py +12 -9
  6. libsast-3.0.1/libsast/core_matcher/choice_matcher.py +174 -0
  7. {libsast-2.0.2 → libsast-3.0.1}/libsast/core_matcher/helpers.py +34 -1
  8. libsast-3.0.1/libsast/core_matcher/matchers.py +104 -0
  9. libsast-3.0.1/libsast/core_matcher/pattern_matcher.py +157 -0
  10. libsast-3.0.1/libsast/scanner.py +102 -0
  11. libsast-3.0.1/libsast/standards/owasp_mobile_top10_2024.yaml +12 -0
  12. {libsast-2.0.2 → libsast-3.0.1}/pyproject.toml +2 -2
  13. libsast-2.0.2/libsast/core_matcher/choice_matcher.py +0 -149
  14. libsast-2.0.2/libsast/core_matcher/choices.py +0 -31
  15. libsast-2.0.2/libsast/core_matcher/matchers.py +0 -119
  16. libsast-2.0.2/libsast/core_matcher/pattern_matcher.py +0 -138
  17. libsast-2.0.2/libsast/scanner.py +0 -94
  18. {libsast-2.0.2 → libsast-3.0.1}/LICENSE +0 -0
  19. {libsast-2.0.2 → libsast-3.0.1}/libsast/core_matcher/__init__.py +0 -0
  20. {libsast-2.0.2 → libsast-3.0.1}/libsast/core_sgrep/__init__.py +0 -0
  21. {libsast-2.0.2 → libsast-3.0.1}/libsast/core_sgrep/helpers.py +0 -0
  22. {libsast-2.0.2 → libsast-3.0.1}/libsast/core_sgrep/semantic_sgrep.py +0 -0
  23. {libsast-2.0.2 → libsast-3.0.1}/libsast/exceptions.py +0 -0
  24. {libsast-2.0.2 → libsast-3.0.1}/libsast/standards/cwe.yaml +0 -0
  25. {libsast-2.0.2 → libsast-3.0.1}/libsast/standards/owasp_masvs.yaml +0 -0
  26. {libsast-2.0.2 → libsast-3.0.1}/libsast/standards/owasp_mobile_top10_2016.yaml +0 -0
  27. {libsast-2.0.2 → libsast-3.0.1}/libsast/standards/owasp_web_top10_2017.yaml +0 -0
  28. {libsast-2.0.2 → libsast-3.0.1}/libsast/standards.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: libsast
3
- Version: 2.0.2
3
+ Version: 3.0.1
4
4
  Summary: A generic SAST library built on top of semgrep and regex
5
5
  Home-page: https://github.com/ajinabraham/libsast
6
6
  License: LGPL-3.0-or-later
@@ -16,10 +16,12 @@ Classifier: Programming Language :: Python :: 3.8
16
16
  Classifier: Programming Language :: Python :: 3.9
17
17
  Classifier: Programming Language :: Python :: 3.10
18
18
  Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
19
21
  Classifier: Topic :: Security
20
22
  Classifier: Topic :: Software Development :: Quality Assurance
21
23
  Requires-Dist: pyyaml (>=6.0)
22
- Requires-Dist: requests (>=2.27.1)
24
+ Requires-Dist: requests
23
25
  Requires-Dist: semgrep (==0.117.0) ; sys_platform != "win32"
24
26
  Project-URL: Bug Tracker, https://github.com/ajinabraham/libsast/issues
25
27
  Project-URL: Documentation, https://github.com/ajinabraham/libsast/blob/master/README.md
@@ -59,13 +61,13 @@ usage: libsast [-h] [-o OUTPUT] [-p PATTERN_FILE] [-s SGREP_PATTERN_FILE]
59
61
  [--ignore-filenames IGNORE_FILENAMES [IGNORE_FILENAMES ...]]
60
62
  [--ignore-extensions IGNORE_EXTENSIONS [IGNORE_EXTENSIONS ...]]
61
63
  [--ignore-paths IGNORE_PATHS [IGNORE_PATHS ...]]
62
- [--show-progress] [-v]
63
- [path [path ...]]
64
+ [--show-progress] [--cpu-core CPU_CORE] [-v]
65
+ [path ...]
64
66
 
65
67
  positional arguments:
66
68
  path Path can be file(s) or directories
67
69
 
68
- optional arguments:
70
+ options:
69
71
  -h, --help show this help message and exit
70
72
  -o OUTPUT, --output OUTPUT
71
73
  Output filename to save JSON report.
@@ -74,7 +76,8 @@ optional arguments:
74
76
  -s SGREP_PATTERN_FILE, --sgrep-pattern-file SGREP_PATTERN_FILE
75
77
  sgrep rules directory
76
78
  --sgrep-file-extensions SGREP_FILE_EXTENSIONS [SGREP_FILE_EXTENSIONS ...]
77
- File extensions that should be scanned with sgrep
79
+ File extensions that should be scanned with semantic
80
+ grep
78
81
  --file-extensions FILE_EXTENSIONS [FILE_EXTENSIONS ...]
79
82
  File extensions that should be scanned with pattern
80
83
  matcher
@@ -85,6 +88,7 @@ optional arguments:
85
88
  --ignore-paths IGNORE_PATHS [IGNORE_PATHS ...]
86
89
  Path(s) to ignore
87
90
  --show-progress Show scan progress
91
+ --cpu-core CPU_CORE No of CPU cores to use. Use all cores by default
88
92
  -v, --version Show libsast version
89
93
  ```
90
94
 
@@ -31,13 +31,13 @@ usage: libsast [-h] [-o OUTPUT] [-p PATTERN_FILE] [-s SGREP_PATTERN_FILE]
31
31
  [--ignore-filenames IGNORE_FILENAMES [IGNORE_FILENAMES ...]]
32
32
  [--ignore-extensions IGNORE_EXTENSIONS [IGNORE_EXTENSIONS ...]]
33
33
  [--ignore-paths IGNORE_PATHS [IGNORE_PATHS ...]]
34
- [--show-progress] [-v]
35
- [path [path ...]]
34
+ [--show-progress] [--cpu-core CPU_CORE] [-v]
35
+ [path ...]
36
36
 
37
37
  positional arguments:
38
38
  path Path can be file(s) or directories
39
39
 
40
- optional arguments:
40
+ options:
41
41
  -h, --help show this help message and exit
42
42
  -o OUTPUT, --output OUTPUT
43
43
  Output filename to save JSON report.
@@ -46,7 +46,8 @@ optional arguments:
46
46
  -s SGREP_PATTERN_FILE, --sgrep-pattern-file SGREP_PATTERN_FILE
47
47
  sgrep rules directory
48
48
  --sgrep-file-extensions SGREP_FILE_EXTENSIONS [SGREP_FILE_EXTENSIONS ...]
49
- File extensions that should be scanned with sgrep
49
+ File extensions that should be scanned with semantic
50
+ grep
50
51
  --file-extensions FILE_EXTENSIONS [FILE_EXTENSIONS ...]
51
52
  File extensions that should be scanned with pattern
52
53
  matcher
@@ -57,6 +58,7 @@ optional arguments:
57
58
  --ignore-paths IGNORE_PATHS [IGNORE_PATHS ...]
58
59
  Path(s) to ignore
59
60
  --show-progress Show scan progress
61
+ --cpu-core CPU_CORE No of CPU cores to use. Use all cores by default
60
62
  -v, --version Show libsast version
61
63
  ```
62
64
 
@@ -1,14 +1,18 @@
1
1
  #!/usr/bin/env python
2
2
  # -*- coding: utf_8 -*-
3
+ from datetime import datetime
4
+
3
5
  from .core_matcher.pattern_matcher import PatternMatcher
4
6
  from .core_matcher.choice_matcher import ChoiceMatcher
5
7
  from .core_sgrep.semantic_sgrep import SemanticGrep
6
8
  from .scanner import Scanner
7
9
 
10
+
11
+ year = str(datetime.now().year)
8
12
  __title__ = 'libsast'
9
13
  __authors__ = 'Ajin Abraham'
10
- __copyright__ = 'Copyright 2020 Ajin Abraham, OpenSecurity'
11
- __version__ = '2.0.2'
14
+ __copyright__ = f'Copyright {year} Ajin Abraham, opensecurity.in'
15
+ __version__ = '3.0.1'
12
16
  __version_info__ = tuple(int(i) for i in __version__.split('.'))
13
17
  __all__ = [
14
18
  'Scanner',
@@ -51,7 +51,7 @@ def main():
51
51
  parser.add_argument('--sgrep-file-extensions',
52
52
  nargs='+',
53
53
  help=('File extensions that should be scanned'
54
- ' with sgrep'),
54
+ ' with semantic grep'),
55
55
  required=False)
56
56
  parser.add_argument('--file-extensions',
57
57
  nargs='+',
@@ -74,6 +74,10 @@ def main():
74
74
  help='Show scan progress',
75
75
  required=False,
76
76
  action='store_true')
77
+ parser.add_argument('--cpu-core',
78
+ help='No of CPU cores to use. Use all cores by default',
79
+ type=int,
80
+ required=False)
77
81
  parser.add_argument('-v', '--version',
78
82
  help='Show libsast version',
79
83
  required=False,
@@ -89,6 +93,7 @@ def main():
89
93
  'ignore_extensions': args.ignore_extensions,
90
94
  'ignore_paths': args.ignore_paths,
91
95
  'show_progress': args.show_progress,
96
+ 'cpu_core': args.cpu_core,
92
97
  }
93
98
  result = Scanner(options, args.path).scan()
94
99
  output(args.output, result)
@@ -26,7 +26,7 @@ class ProgressBar:
26
26
  self.output.write(f'- {self.prefix} {prog} {index}\r')
27
27
  self.output.flush()
28
28
 
29
- def progrees_loop(self, iterator):
29
+ def progress_loop(self, iterator):
30
30
  """Show progress for loop."""
31
31
  self.progress_print(0)
32
32
  for index, item in enumerate(iterator):
@@ -74,15 +74,18 @@ def read_yaml(file_obj, text=False):
74
74
 
75
75
  def get_worker_count():
76
76
  """Get worker count for pool."""
77
+ libsast_workers = os.getenv('LIBSAST_WORKERS')
78
+ if libsast_workers:
79
+ try:
80
+ return int(libsast_workers)
81
+ except ValueError:
82
+ return 1
77
83
  try:
78
84
  worker_count = os.cpu_count()
79
- if not worker_count:
80
- worker_count = 1
81
- if worker_count != 1 and sys.platform == 'win32':
82
- # Work around https://bugs.python.org/issue26903
83
- worker_count = min(worker_count, 61)
84
- if os.getenv('LIBSAST_WORKERS'):
85
- worker_count = int(os.getenv('LIBSAST_WORKERS'))
86
85
  except Exception:
87
- worker_count = 16
86
+ worker_count = 1
87
+
88
+ # Adjust worker count for Windows
89
+ if sys.platform == 'win32':
90
+ worker_count = min(worker_count, 61)
88
91
  return worker_count
@@ -0,0 +1,174 @@
1
+ # -*- coding: utf_8 -*-
2
+ """Choice Matcher."""
3
+ import re
4
+ from pathlib import Path
5
+ from concurrent.futures import (
6
+ ProcessPoolExecutor,
7
+ ThreadPoolExecutor,
8
+ )
9
+ from functools import lru_cache
10
+
11
+ from libsast.core_matcher.helpers import (
12
+ get_rules,
13
+ is_file_valid,
14
+ strip_comments,
15
+ )
16
+ from libsast import (
17
+ common,
18
+ exceptions,
19
+ )
20
+
21
+
22
+ class ChoiceMatcher:
23
+ def __init__(self, options: dict) -> None:
24
+ self.scan_rules = get_rules(options.get('choice_rules'))
25
+ self.show_progress = options.get('show_progress')
26
+ self.cpu = options.get('cpu_core')
27
+ self.alternative_path = options.get('alternative_path')
28
+ exts = options.get('choice_extensions')
29
+ self.exts = [ext.lower() for ext in exts] if exts else []
30
+ self.findings = {}
31
+
32
+ def scan(self, paths: list) -> dict:
33
+ """Scan file(s) or directory per rule."""
34
+ if not (self.scan_rules and paths):
35
+ return
36
+ self.validate_rules()
37
+
38
+ if self.show_progress:
39
+ pbar = common.ProgressBar('Choice Match', len(self.scan_rules))
40
+ self.scan_rules = pbar.progress_loop(self.scan_rules)
41
+
42
+ choice_args = []
43
+ for rule in self.scan_rules:
44
+ scan_paths = paths
45
+ if rule['type'] != 'code' and self.alternative_path:
46
+ # Scan only alternative path
47
+ scan_paths = [Path(self.alternative_path)]
48
+ choice_args.append((scan_paths, rule))
49
+
50
+ # Use ThreadPoolExecutor for reading file contents and
51
+ # ProcessPoolExecutor for processing regex
52
+ with ThreadPoolExecutor() as io_executor, ProcessPoolExecutor(
53
+ max_workers=self.cpu) as cpu_executor:
54
+ futures = []
55
+ for args_tuple in choice_args:
56
+ # Submit each read task and store the future along with the args
57
+ future = io_executor.submit(
58
+ self._read_file_contents, args_tuple)
59
+ futures.append((future, args_tuple))
60
+
61
+ results = []
62
+ for future, _ in futures:
63
+ file_contents = future.result()
64
+ # This will block until the file reading is done
65
+ # Process the file contents with ProcessPoolExecutor
66
+ process_future = cpu_executor.submit(
67
+ self.choice_matcher, file_contents)
68
+ results.append(process_future.result())
69
+
70
+ self.add_finding(results)
71
+ return self.findings
72
+
73
+ def validate_rules(self):
74
+ """Validate Rules before scanning."""
75
+ for rule in self.scan_rules:
76
+ if not isinstance(rule, dict):
77
+ raise exceptions.InvalidRuleFormatError(
78
+ 'Choice Matcher Rule format is invalid.')
79
+ required_keys = [
80
+ 'id',
81
+ 'type',
82
+ 'choice_type',
83
+ 'selection',
84
+ 'choice']
85
+ for key in required_keys:
86
+ if not rule.get(key):
87
+ raise exceptions.PatternKeyMissingError(
88
+ f'The rule is missing the key "{key}"')
89
+
90
+ def _read_file_contents(self, args_tuple):
91
+ """Read file contents for the given paths and rule."""
92
+ scan_paths, rule = args_tuple
93
+ results = []
94
+ for sfile in scan_paths:
95
+ if not is_file_valid(sfile, self.exts, 5):
96
+ continue
97
+ try:
98
+ data = self._format_content(
99
+ sfile.read_text('utf-8', 'ignore'),
100
+ sfile.suffix.lower())
101
+ results.append((data, rule))
102
+ except Exception as e:
103
+ raise exceptions.RuleProcessingError(
104
+ 'Error reading file: {}'.format(sfile)) from e
105
+ return results
106
+
107
+ def find_choices(self, data, rule):
108
+ """Find Choices."""
109
+ all_matches = set()
110
+ for idx, choice in enumerate(rule['choice']):
111
+ typ = rule['choice_type']
112
+ if typ == 'and':
113
+ # Return on first and choice
114
+ if all(re.compile(and_regx).search(data) for and_regx in choice[0]):
115
+ return (all_matches, [idx])
116
+ elif re.compile(choice[0]).search(data):
117
+ if typ == 'or':
118
+ # Return on first or choice
119
+ return (all_matches, [idx])
120
+ elif typ == 'all':
121
+ # Extract all choice(s) from all files
122
+ all_matches.add(choice[1])
123
+ return (all_matches, None) # None means no matches found.
124
+
125
+ def choice_matcher(self, file_contents):
126
+ """Process regex matches on the file contents."""
127
+ results = []
128
+ for data, rule in file_contents:
129
+ match = self.find_choices(data, rule)
130
+ results.append({
131
+ 'rule': rule,
132
+ 'matches': match[1] if isinstance(match, tuple) else set(),
133
+ 'all_matches': match[0] if isinstance(match, tuple) else match,
134
+ })
135
+ return results
136
+
137
+ @staticmethod
138
+ @lru_cache(maxsize=128)
139
+ def _format_content(data, file_suffix):
140
+ return strip_comments(data, file_suffix)
141
+
142
+ def add_finding(self, results):
143
+ """Add Choice Findings and generate metadata."""
144
+ for res_list in results:
145
+ if not res_list:
146
+ continue
147
+ for match_dict in res_list:
148
+ rule = match_dict['rule']
149
+ all_matches = match_dict['all_matches']
150
+ matches = match_dict['matches']
151
+
152
+ # Determine selection string
153
+ if all_matches:
154
+ selection = rule['selection'].format(list(all_matches))
155
+ elif matches:
156
+ select = rule['choice'][min(matches)][1]
157
+ selection = rule['selection'].format(select)
158
+ else:
159
+ selection = rule['selection'].format(rule.get('else', ''))
160
+
161
+ # Create metadata dictionary
162
+ meta_dict = {
163
+ 'choice': selection,
164
+ 'description': rule['message'],
165
+ **{key: rule[key] for key in rule if key not in {
166
+ 'choice',
167
+ 'message',
168
+ 'id',
169
+ 'type',
170
+ 'choice_type',
171
+ 'selection',
172
+ 'else'}}}
173
+
174
+ self.findings[rule['id']] = meta_dict
@@ -85,7 +85,7 @@ def comment_replacer(matches, data):
85
85
  return data
86
86
 
87
87
 
88
- def strip_comments(data):
88
+ def strip_comments1(data):
89
89
  """Remove Comments.
90
90
 
91
91
  Replace multiline comments first and
@@ -128,3 +128,36 @@ def get_match_lines(content, pos):
128
128
  if filepos >= pos[1] and skip:
129
129
  # Multiline march, find end line
130
130
  return (start_line, idx)
131
+
132
+
133
+ def is_file_valid(sfile, allowed_extensions=None, max_size_mb=5):
134
+ """Check if the file is valid based on its extension and size.
135
+
136
+ Args:
137
+ sfile (Path): The file to check.
138
+ allowed_extensions (set): A set of allowed file extensions.
139
+ max_size_mb (int): The maximum file size in MB.
140
+
141
+ Returns:
142
+ bool: True if the file is valid, False otherwise.
143
+ """
144
+ # Get the file extension in lowercase
145
+ ext = sfile.suffix.lower()
146
+
147
+ # Check if the file extension is allowed
148
+ if allowed_extensions and ext not in allowed_extensions:
149
+ return False
150
+
151
+ # Check if the file size exceeds the maximum limit (in bytes)
152
+ max_size_bytes = max_size_mb * 1024 * 1024 # Convert MB to bytes
153
+ if sfile.stat().st_size > max_size_bytes:
154
+ return False
155
+
156
+ return True
157
+
158
+
159
+ def strip_comments(data, ext):
160
+ """Format content by stripping comments based on file type."""
161
+ if ext in ('.html', '.xml'):
162
+ return strip_comments2(data)
163
+ return strip_comments1(data)
@@ -0,0 +1,104 @@
1
+ # -*- coding: utf_8 -*-
2
+ """Optimized Algorithms for Pattern Matching with Caching."""
3
+ import re
4
+ from abc import ABC, abstractmethod
5
+ from functools import lru_cache
6
+
7
+ from libsast.core_matcher.helpers import get_match_lines
8
+
9
+
10
+ def get_pos(match):
11
+ """Adjust zero index in match span."""
12
+ start, end = match.span()
13
+ return (1 if start == 0 else start, end)
14
+
15
+
16
+ # Cache compiled regex patterns
17
+ @lru_cache(maxsize=128)
18
+ def get_compiled_pattern(pattern):
19
+ """Compile and cache regex patterns."""
20
+ return re.compile(pattern)
21
+
22
+
23
+ class MatchCommand:
24
+ def __init__(self):
25
+ self.patterns = {}
26
+
27
+ def _find_match(self, pattern_name, content, rule):
28
+ pattern_class = self.patterns.get(pattern_name) or globals()[pattern_name]()
29
+ self.patterns.setdefault(pattern_name, pattern_class)
30
+ return pattern_class._perform_search(content, rule)
31
+
32
+
33
+ class MatchStrategy(ABC):
34
+ @abstractmethod
35
+ def _perform_search(self, content, rule):
36
+ """Search for instances of a pattern match in content."""
37
+
38
+
39
+ class Regex(MatchStrategy):
40
+ def _perform_search(self, content, rule):
41
+ pattern = get_compiled_pattern(rule['pattern'])
42
+ return self._find_matches(content, pattern)
43
+
44
+ @staticmethod
45
+ def _find_matches(content, pattern):
46
+ """Helper to find all matches in content and extract details."""
47
+ matches = set()
48
+ for match in pattern.finditer(content):
49
+ if match.group():
50
+ match_pos = get_pos(match)
51
+ match_lines = get_match_lines(content, match_pos)
52
+ matches.add((match.group(), match_pos, match_lines))
53
+ return matches
54
+
55
+
56
+ class RegexAnd(MatchStrategy):
57
+ def _perform_search(self, content, rule):
58
+ patterns = rule['pattern'] if isinstance(
59
+ rule['pattern'], list) else [rule['pattern']]
60
+ all_matches = set()
61
+ for regex in patterns:
62
+ pattern = get_compiled_pattern(regex)
63
+ matches = Regex._find_matches(content, pattern)
64
+ if not matches:
65
+ return False
66
+ all_matches.update(matches)
67
+ return all_matches
68
+
69
+
70
+ class RegexOr(MatchStrategy):
71
+ def _perform_search(self, content, rule):
72
+ patterns = rule['pattern'] if isinstance(
73
+ rule['pattern'], list) else [rule['pattern']]
74
+ matches = set()
75
+ for regex in patterns:
76
+ pattern = get_compiled_pattern(regex)
77
+ matches.update(Regex._find_matches(content, pattern))
78
+ return matches
79
+
80
+
81
+ class RegexAndNot(MatchStrategy):
82
+ def _perform_search(self, content, rule):
83
+ present_pattern, not_pattern = rule['pattern']
84
+ not_found = get_compiled_pattern(not_pattern).search(content) is None
85
+ if not not_found:
86
+ return False
87
+ present_matches = Regex._find_matches(
88
+ content, get_compiled_pattern(present_pattern))
89
+ return present_matches if present_matches else False
90
+
91
+
92
+ class RegexAndOr(MatchStrategy):
93
+ def _perform_search(self, content, rule):
94
+ and_pattern, or_patterns = rule['pattern']
95
+ and_matches = Regex._find_matches(content, get_compiled_pattern(and_pattern))
96
+ or_matches = set()
97
+ for regex in or_patterns:
98
+ or_pattern = get_compiled_pattern(regex)
99
+ or_matches = Regex._find_matches(content, or_pattern)
100
+ if or_matches:
101
+ break
102
+ if and_matches and or_matches:
103
+ return and_matches.union(or_matches)
104
+ return False
@@ -0,0 +1,157 @@
1
+ # -*- coding: utf_8 -*-
2
+ """Pattern Matcher."""
3
+ from operator import itemgetter
4
+ from concurrent.futures import (
5
+ ProcessPoolExecutor,
6
+ ThreadPoolExecutor,
7
+ )
8
+ from functools import lru_cache
9
+
10
+ from libsast.core_matcher.helpers import (
11
+ get_rules,
12
+ is_file_valid,
13
+ strip_comments,
14
+ )
15
+ from libsast.core_matcher import matchers
16
+ from libsast import (
17
+ common,
18
+ exceptions,
19
+ )
20
+
21
+
22
+ class PatternMatcher:
23
+ def __init__(self, options: dict) -> None:
24
+ self.matcher = matchers.MatchCommand()
25
+ self.scan_rules = get_rules(options.get('match_rules'))
26
+ self.show_progress = options.get('show_progress')
27
+ self.cpu = options.get('cpu_core')
28
+ exts = options.get('match_extensions')
29
+ self.exts = [ext.lower() for ext in exts] if exts else []
30
+ self.findings = {}
31
+
32
+ def scan(self, paths: list) -> dict:
33
+ """Scan file(s) or directory."""
34
+ if not (self.scan_rules and paths):
35
+ return
36
+ self.validate_rules()
37
+
38
+ if self.show_progress:
39
+ pbar = common.ProgressBar('Pattern Match', len(paths))
40
+ paths = pbar.progress_loop(paths)
41
+
42
+ # Filter files by extension and size, prepare list for processing
43
+ files_to_scan = {
44
+ sfile for sfile in paths
45
+ if is_file_valid(sfile, self.exts, 5)
46
+ }
47
+
48
+ # Use a ThreadPool for file reading, and ProcessPool for CPU-bound regex
49
+ with ThreadPoolExecutor() as io_executor, ProcessPoolExecutor(
50
+ max_workers=self.cpu) as cpu_executor:
51
+
52
+ # Read all files
53
+ file_contents = list(io_executor.map(
54
+ self._read_file_content, files_to_scan))
55
+
56
+ # Run regex on file data
57
+ results = cpu_executor.map(
58
+ self.pattern_matcher,
59
+ file_contents,
60
+ )
61
+
62
+ # Compile findings
63
+ self.add_finding(results)
64
+ return self.findings
65
+
66
+ def validate_rules(self):
67
+ """Validate Rules before scanning."""
68
+ available_matchers = {m for m in dir(matchers) if m.startswith('R')}
69
+ required_keys = ['type', 'pattern']
70
+
71
+ for rule in self.scan_rules:
72
+ if not isinstance(rule, dict):
73
+ raise exceptions.InvalidRuleFormatError(
74
+ 'Pattern Matcher Rule format is invalid.')
75
+
76
+ # Check for missing required keys
77
+ missing_keys = [key for key in required_keys if key not in rule]
78
+ if missing_keys:
79
+ mkeys = ', '.join(missing_keys)
80
+ raise exceptions.PatternKeyMissingError(
81
+ f'The rule is missing the keys: {mkeys}')
82
+
83
+ pattern_name = rule['type']
84
+ if pattern_name not in available_matchers:
85
+ supported = ', '.join(available_matchers)
86
+ raise exceptions.MatcherNotFoundError(
87
+ f'Matcher {pattern_name} is not supported.'
88
+ f' Available matchers are {supported}.')
89
+
90
+ @staticmethod
91
+ def _read_file_content(file_path):
92
+ """Read file content with encoding handling."""
93
+ try:
94
+ return file_path, file_path.read_text('utf-8', 'ignore')
95
+ except Exception as e:
96
+ print(f'Error reading {file_path}: {e}')
97
+ return file_path, ''
98
+
99
+ def pattern_matcher(self, file_data):
100
+ """Static Analysis Pattern Matcher."""
101
+ file_path, data = file_data
102
+ results = []
103
+ try:
104
+ fmt_data = self._format_content(data, file_path.suffix.lower())
105
+ for rule in self.scan_rules:
106
+ case = rule.get('input_case')
107
+ if case == 'lower':
108
+ fmt_data = fmt_data.lower()
109
+ elif case == 'upper':
110
+ fmt_data = fmt_data.upper()
111
+ matches = self.matcher._find_match(rule['type'], fmt_data, rule)
112
+ if matches:
113
+ results.append({
114
+ 'file': file_path.as_posix(),
115
+ 'rule': rule,
116
+ 'matches': matches,
117
+ })
118
+ except Exception as e:
119
+ msg = f'Error processing rule for {file_path}: {e}'
120
+ raise exceptions.RuleProcessingError(msg)
121
+ return results
122
+
123
+ @staticmethod
124
+ @lru_cache(maxsize=128)
125
+ def _format_content(data, file_suffix):
126
+ return strip_comments(data, file_suffix)
127
+
128
+ def add_finding(self, results):
129
+ """Add Code Analysis Findings."""
130
+ for res_list in results:
131
+ if not res_list:
132
+ continue
133
+ for match_dict in res_list:
134
+ rule = match_dict['rule']
135
+ rule_id = rule['id']
136
+ files = self.findings.setdefault(
137
+ rule_id, {'files': [], 'metadata': {}})['files']
138
+
139
+ for match in match_dict['matches']:
140
+ file_details = {
141
+ 'file_path': match_dict['file'],
142
+ 'match_string': match[0],
143
+ 'match_position': match[1],
144
+ 'match_lines': match[2],
145
+ }
146
+ files.append(file_details)
147
+
148
+ if not self.findings[rule_id]['metadata']:
149
+ meta = rule.get('metadata', {})
150
+ # message & severity are mandatory
151
+ meta['description'] = rule['message']
152
+ meta['severity'] = rule['severity']
153
+ self.findings[rule_id]['metadata'] = meta
154
+
155
+ # Sort files by specified criteria
156
+ self.findings[rule_id]['files'].sort(
157
+ key=itemgetter('file_path', 'match_string', 'match_lines'))