sec-scan 0.1.0__py3-none-any.whl
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.
- sec_scan/__init__.py +0 -0
- sec_scan/checks/__init__.py +0 -0
- sec_scan/checks/registry.py +21 -0
- sec_scan/checks/sql_injection.py +53 -0
- sec_scan/checks/sql_injection_deep.py +155 -0
- sec_scan/scanner.py +108 -0
- sec_scan-0.1.0.dist-info/METADATA +8 -0
- sec_scan-0.1.0.dist-info/RECORD +11 -0
- sec_scan-0.1.0.dist-info/WHEEL +5 -0
- sec_scan-0.1.0.dist-info/entry_points.txt +2 -0
- sec_scan-0.1.0.dist-info/top_level.txt +1 -0
sec_scan/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def load_checks():
|
|
6
|
+
checks_dir = os.path.dirname(__file__)
|
|
7
|
+
modules = []
|
|
8
|
+
|
|
9
|
+
for filename in sorted(os.listdir(checks_dir)):
|
|
10
|
+
if not filename.endswith(".py"):
|
|
11
|
+
continue
|
|
12
|
+
if filename in ("__init__.py", "registry.py"):
|
|
13
|
+
continue
|
|
14
|
+
|
|
15
|
+
module_name = f"sec_scan.checks.{filename[:-3]}"
|
|
16
|
+
module = importlib.import_module(module_name)
|
|
17
|
+
|
|
18
|
+
if hasattr(module, "EXTENSIONS") and hasattr(module, "run"):
|
|
19
|
+
modules.append(module)
|
|
20
|
+
|
|
21
|
+
return modules
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
EXTENSIONS = {".py", ".js", ".ts", ".php", ".java", ".cs", ".rb", ".go"}
|
|
4
|
+
|
|
5
|
+
SINK_PATTERN = re.compile(
|
|
6
|
+
r"\b(execute|executemany|executeQuery|executeUpdate|"
|
|
7
|
+
r"ExecuteReader|ExecuteNonQuery|ExecuteScalar|"
|
|
8
|
+
r"createStatement|query|raw)\s*\(",
|
|
9
|
+
re.IGNORECASE,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
DANGER_PATTERNS = [
|
|
13
|
+
re.compile(r'["\']\s*\+\s*\w'),
|
|
14
|
+
re.compile(r'\w\s*\+\s*["\']'),
|
|
15
|
+
re.compile(r'f["\'].*\{.*\}'),
|
|
16
|
+
re.compile(r'\.format\('),
|
|
17
|
+
re.compile(r'\$\{'),
|
|
18
|
+
re.compile(r'\$"'),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
SAFE_PATTERNS = [
|
|
22
|
+
re.compile(r'\?'),
|
|
23
|
+
re.compile(r'%s'),
|
|
24
|
+
re.compile(r'@\w+'),
|
|
25
|
+
re.compile(r':\w+'),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run(file_path, content):
|
|
30
|
+
findings = []
|
|
31
|
+
|
|
32
|
+
for lineno, line in enumerate(content.splitlines(), start=1):
|
|
33
|
+
if not SINK_PATTERN.search(line):
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
has_danger = any(p.search(line) for p in DANGER_PATTERNS)
|
|
37
|
+
has_safe = any(p.search(line) for p in SAFE_PATTERNS)
|
|
38
|
+
|
|
39
|
+
if has_danger and not has_safe:
|
|
40
|
+
findings.append({
|
|
41
|
+
"check_id": "SQL-INJECTION",
|
|
42
|
+
"severity": "HIGH",
|
|
43
|
+
"file": file_path,
|
|
44
|
+
"line": lineno,
|
|
45
|
+
"message": (
|
|
46
|
+
"Query looks like it's built with string "
|
|
47
|
+
"concatenation/interpolation instead of parameters. "
|
|
48
|
+
"Possible SQL injection."
|
|
49
|
+
),
|
|
50
|
+
"snippet": line,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
return findings
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sql_injection_deep.py — Python-only, tree-based taint tracking for SQL
|
|
3
|
+
injection. This is the upgrade over sql_injection.py's line-by-line
|
|
4
|
+
regex approach: instead of reading text, it parses the file into a
|
|
5
|
+
real syntax tree (via tree-sitter) and tracks which variables were
|
|
6
|
+
built unsafely, following that variable across lines within the same
|
|
7
|
+
function — which is exactly the gap the line-based check can't see:
|
|
8
|
+
|
|
9
|
+
query = "SELECT ... " + username # tainted here
|
|
10
|
+
cursor.execute(query) # caught here, two lines later
|
|
11
|
+
|
|
12
|
+
LIMITATIONS (same honesty policy as the line-based check):
|
|
13
|
+
- Python only. Other languages still rely on sql_injection.py.
|
|
14
|
+
- Taint tracking stays within one function — it does not follow a
|
|
15
|
+
value if it's passed as an argument into another function.
|
|
16
|
+
- If a tainted variable is reassigned safely, taint is cleared —
|
|
17
|
+
but only for simple `name = ...` reassignment, not more complex
|
|
18
|
+
patterns (tuple unpacking, augmented assignment with +=, etc.).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
import tree_sitter_python as tspython
|
|
23
|
+
from tree_sitter import Language, Parser
|
|
24
|
+
|
|
25
|
+
EXTENSIONS = {".py"}
|
|
26
|
+
|
|
27
|
+
_PY_LANGUAGE = Language(tspython.language())
|
|
28
|
+
_parser = Parser(_PY_LANGUAGE)
|
|
29
|
+
|
|
30
|
+
SINK_NAMES = {"execute", "executemany", "query", "raw"}
|
|
31
|
+
SAFE_PLACEHOLDER = re.compile(r'\?|%s|@\w+|:\w+')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _node_text(node, source):
|
|
35
|
+
return source[node.start_byte:node.end_byte]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_unsafe_string(node):
|
|
39
|
+
# An f-string counts as unsafe if it actually interpolates a value.
|
|
40
|
+
if node.type == "string":
|
|
41
|
+
return any(c.type == "interpolation" for c in node.children)
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _contains_concat(node):
|
|
46
|
+
# Walks a binary_operator chain looking for a '+' anywhere in it.
|
|
47
|
+
if node.type == "binary_operator":
|
|
48
|
+
for c in node.children:
|
|
49
|
+
if c.type == "+":
|
|
50
|
+
return True
|
|
51
|
+
return any(_contains_concat(c) for c in node.children)
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _expr_is_unsafe(node):
|
|
56
|
+
if node.type == "string":
|
|
57
|
+
return _is_unsafe_string(node)
|
|
58
|
+
if node.type == "binary_operator":
|
|
59
|
+
return _contains_concat(node)
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _get_call_name(call_node):
|
|
64
|
+
func = call_node.child_by_field_name("function")
|
|
65
|
+
if func is None:
|
|
66
|
+
return None
|
|
67
|
+
if func.type == "attribute":
|
|
68
|
+
attr = func.child_by_field_name("attribute")
|
|
69
|
+
return attr.text.decode() if attr else None
|
|
70
|
+
if func.type == "identifier":
|
|
71
|
+
return func.text.decode()
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_finding(file_path, call_node, source, arg_src, tracked):
|
|
76
|
+
row = call_node.start_point[0] + 1
|
|
77
|
+
line_text = source.splitlines()[call_node.start_point[0]].decode(errors="ignore")
|
|
78
|
+
if tracked:
|
|
79
|
+
message = (
|
|
80
|
+
f"Variable '{arg_src}' was built from unsafe string "
|
|
81
|
+
f"concatenation/interpolation earlier, then passed into a "
|
|
82
|
+
f"SQL execution call."
|
|
83
|
+
)
|
|
84
|
+
else:
|
|
85
|
+
message = "Query built via unsafe string concatenation/interpolation directly in this call."
|
|
86
|
+
return {
|
|
87
|
+
"check_id": "SQL-INJECTION-DEEP",
|
|
88
|
+
"severity": "HIGH",
|
|
89
|
+
"file": file_path,
|
|
90
|
+
"line": row,
|
|
91
|
+
"message": message,
|
|
92
|
+
"snippet": line_text.strip(),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _check_call(call_node, tainted, source, file_path, findings):
|
|
97
|
+
name = _get_call_name(call_node)
|
|
98
|
+
if not name or name.lower() not in SINK_NAMES:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
args_node = call_node.child_by_field_name("arguments")
|
|
102
|
+
if not args_node:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
for arg in args_node.children:
|
|
106
|
+
if arg.type in ("(", ")", ","):
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
arg_src = _node_text(arg, source).decode(errors="ignore")
|
|
110
|
+
if SAFE_PLACEHOLDER.search(arg_src):
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
if arg.type == "identifier" and arg_src in tainted:
|
|
114
|
+
findings.append(_build_finding(file_path, call_node, source, arg_src, True))
|
|
115
|
+
elif _expr_is_unsafe(arg):
|
|
116
|
+
findings.append(_build_finding(file_path, call_node, source, arg_src, False))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _scan_scope(func_node, source, file_path, findings):
|
|
120
|
+
# tainted = names of variables currently known to hold an unsafely
|
|
121
|
+
# built string, within THIS function's scope only.
|
|
122
|
+
tainted = set()
|
|
123
|
+
|
|
124
|
+
def walk(node):
|
|
125
|
+
# Nested function = new scope, tracked separately.
|
|
126
|
+
if node.type == "function_definition" and node is not func_node:
|
|
127
|
+
_scan_scope(node, source, file_path, findings)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if node.type == "assignment":
|
|
131
|
+
left = node.child_by_field_name("left")
|
|
132
|
+
right = node.child_by_field_name("right")
|
|
133
|
+
if left is not None and right is not None and left.type == "identifier":
|
|
134
|
+
name = _node_text(left, source).decode()
|
|
135
|
+
if _expr_is_unsafe(right):
|
|
136
|
+
tainted.add(name)
|
|
137
|
+
else:
|
|
138
|
+
# Reassigned to something safe — taint no longer applies.
|
|
139
|
+
tainted.discard(name)
|
|
140
|
+
|
|
141
|
+
if node.type == "call":
|
|
142
|
+
_check_call(node, tainted, source, file_path, findings)
|
|
143
|
+
|
|
144
|
+
for child in node.children:
|
|
145
|
+
walk(child)
|
|
146
|
+
|
|
147
|
+
walk(func_node)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def run(file_path, content):
|
|
151
|
+
source = content.encode()
|
|
152
|
+
tree = _parser.parse(source)
|
|
153
|
+
findings = []
|
|
154
|
+
_scan_scope(tree.root_node, source, file_path, findings)
|
|
155
|
+
return findings
|
sec_scan/scanner.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
scanner.py — entry point for the security scanner CLI.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python3 scanner.py <path-to-scan>
|
|
7
|
+
|
|
8
|
+
How it works:
|
|
9
|
+
1. Walk every file under <path-to-scan>.
|
|
10
|
+
2. For each file, look at its extension to guess the language.
|
|
11
|
+
3. Ask every check module in checks/ whether it applies to that
|
|
12
|
+
language, and if so, run it against the file's content.
|
|
13
|
+
4. Collect all findings and print a report at the end.
|
|
14
|
+
|
|
15
|
+
Adding a new check later = adding one new file to checks/ that
|
|
16
|
+
follows the same shape as checks/sql_injection.py. Nothing in this
|
|
17
|
+
file needs to change.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
import colorama
|
|
25
|
+
from colorama import Fore, Style
|
|
26
|
+
|
|
27
|
+
from .checks.registry import load_checks
|
|
28
|
+
|
|
29
|
+
# init(autoreset=True) means we don't have to manually reset color
|
|
30
|
+
# after every colored print — colorama resets it for us automatically.
|
|
31
|
+
# On Windows, this is also what makes ANSI colors render at all in
|
|
32
|
+
# terminals that don't support them natively (older cmd.exe).
|
|
33
|
+
colorama.init(autoreset=True)
|
|
34
|
+
|
|
35
|
+
SKIP_DIRS = {".git", "node_modules", "venv", ".venv", "__pycache__",
|
|
36
|
+
"dist", "build", "bin", "obj", ".idea", ".vscode"}
|
|
37
|
+
|
|
38
|
+
SEVERITY_COLORS = {
|
|
39
|
+
"HIGH": Fore.RED,
|
|
40
|
+
"MEDIUM": Fore.YELLOW,
|
|
41
|
+
"LOW": Fore.CYAN,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def collect_files(root):
|
|
46
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
47
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
48
|
+
for name in filenames:
|
|
49
|
+
yield os.path.join(dirpath, name)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main():
|
|
53
|
+
parser = argparse.ArgumentParser(description="Scan a codebase for security issues.")
|
|
54
|
+
parser.add_argument("path", nargs="?", default=".",
|
|
55
|
+
help="File or folder to scan (default: current directory)")
|
|
56
|
+
args = parser.parse_args()
|
|
57
|
+
|
|
58
|
+
if not os.path.exists(args.path):
|
|
59
|
+
print(f"{Fore.RED}Error: path '{args.path}' does not exist.{Style.RESET_ALL}")
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
|
|
62
|
+
checks = load_checks()
|
|
63
|
+
if not checks:
|
|
64
|
+
print("No checks registered yet.")
|
|
65
|
+
sys.exit(0)
|
|
66
|
+
|
|
67
|
+
files = [args.path] if os.path.isfile(args.path) else list(collect_files(args.path))
|
|
68
|
+
|
|
69
|
+
all_findings = []
|
|
70
|
+
for file_path in files:
|
|
71
|
+
try:
|
|
72
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
73
|
+
content = f.read()
|
|
74
|
+
except (IsADirectoryError, PermissionError):
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
ext = os.path.splitext(file_path)[1]
|
|
78
|
+
|
|
79
|
+
for check in checks:
|
|
80
|
+
if ext in check.EXTENSIONS:
|
|
81
|
+
findings = check.run(file_path, content)
|
|
82
|
+
all_findings.extend(findings)
|
|
83
|
+
|
|
84
|
+
print_report(all_findings, len(files), len(checks))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def print_report(findings, file_count, check_count):
|
|
88
|
+
print(f"\nScanned {file_count} file(s) with {check_count} check(s).\n")
|
|
89
|
+
|
|
90
|
+
if not findings:
|
|
91
|
+
print(f"{Fore.GREEN}No issues found.{Style.RESET_ALL}")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
findings.sort(key=lambda f: f["line"])
|
|
95
|
+
|
|
96
|
+
for f in findings:
|
|
97
|
+
color = SEVERITY_COLORS.get(f["severity"], "")
|
|
98
|
+
print(f"{color}[{f['severity']}] {f['check_id']}{Style.RESET_ALL}")
|
|
99
|
+
print(f" {f['file']}:{f['line']}")
|
|
100
|
+
print(f" {f['message']}")
|
|
101
|
+
print(f" >> {f['snippet'].strip()}")
|
|
102
|
+
print()
|
|
103
|
+
|
|
104
|
+
print(f"{Fore.RED}Total findings: {len(findings)}{Style.RESET_ALL}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
main()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
sec_scan/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
sec_scan/scanner.py,sha256=gLGpBcxbGrecFFu45T4Yy3AFYGXij7sw_lWmVgUN18Q,3321
|
|
3
|
+
sec_scan/checks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
sec_scan/checks/registry.py,sha256=q_wgfbGrPyyAi8MFrDD8gGGcIZtKx2Kb_o89sIUEsDM,535
|
|
5
|
+
sec_scan/checks/sql_injection.py,sha256=BtQVzd-cIF64G7lagzA0bUoeXTQcNTPTPS7utNNM8hA,1436
|
|
6
|
+
sec_scan/checks/sql_injection_deep.py,sha256=AwX58ehrS7KQsVYoRJdZxzYXDxY14ZbsJSUMGolv_pI,5281
|
|
7
|
+
sec_scan-0.1.0.dist-info/METADATA,sha256=HOEcPiyDyqiqGXJKwPm2f9ZTBVCd2uIMgbpiKf2vC_g,222
|
|
8
|
+
sec_scan-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
sec_scan-0.1.0.dist-info/entry_points.txt,sha256=Xcn-SBvZfjd2Lr0bnjuO1no3mY5-7erBzYhfDubdgUY,51
|
|
10
|
+
sec_scan-0.1.0.dist-info/top_level.txt,sha256=TYGZDOhFNQjPro91cAC5Y59-bITlE4Hkb4QMMinLt_s,9
|
|
11
|
+
sec_scan-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sec_scan
|