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.
- {libsast-2.0.2 → libsast-3.0.1}/PKG-INFO +10 -6
- {libsast-2.0.2 → libsast-3.0.1}/README.md +6 -4
- {libsast-2.0.2 → libsast-3.0.1}/libsast/__init__.py +6 -2
- {libsast-2.0.2 → libsast-3.0.1}/libsast/__main__.py +6 -1
- {libsast-2.0.2 → libsast-3.0.1}/libsast/common.py +12 -9
- libsast-3.0.1/libsast/core_matcher/choice_matcher.py +174 -0
- {libsast-2.0.2 → libsast-3.0.1}/libsast/core_matcher/helpers.py +34 -1
- libsast-3.0.1/libsast/core_matcher/matchers.py +104 -0
- libsast-3.0.1/libsast/core_matcher/pattern_matcher.py +157 -0
- libsast-3.0.1/libsast/scanner.py +102 -0
- libsast-3.0.1/libsast/standards/owasp_mobile_top10_2024.yaml +12 -0
- {libsast-2.0.2 → libsast-3.0.1}/pyproject.toml +2 -2
- libsast-2.0.2/libsast/core_matcher/choice_matcher.py +0 -149
- libsast-2.0.2/libsast/core_matcher/choices.py +0 -31
- libsast-2.0.2/libsast/core_matcher/matchers.py +0 -119
- libsast-2.0.2/libsast/core_matcher/pattern_matcher.py +0 -138
- libsast-2.0.2/libsast/scanner.py +0 -94
- {libsast-2.0.2 → libsast-3.0.1}/LICENSE +0 -0
- {libsast-2.0.2 → libsast-3.0.1}/libsast/core_matcher/__init__.py +0 -0
- {libsast-2.0.2 → libsast-3.0.1}/libsast/core_sgrep/__init__.py +0 -0
- {libsast-2.0.2 → libsast-3.0.1}/libsast/core_sgrep/helpers.py +0 -0
- {libsast-2.0.2 → libsast-3.0.1}/libsast/core_sgrep/semantic_sgrep.py +0 -0
- {libsast-2.0.2 → libsast-3.0.1}/libsast/exceptions.py +0 -0
- {libsast-2.0.2 → libsast-3.0.1}/libsast/standards/cwe.yaml +0 -0
- {libsast-2.0.2 → libsast-3.0.1}/libsast/standards/owasp_masvs.yaml +0 -0
- {libsast-2.0.2 → libsast-3.0.1}/libsast/standards/owasp_mobile_top10_2016.yaml +0 -0
- {libsast-2.0.2 → libsast-3.0.1}/libsast/standards/owasp_web_top10_2017.yaml +0 -0
- {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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
11
|
-
__version__ = '
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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'))
|