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 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,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: sec-scan
3
+ Version: 0.1.0
4
+ Summary: A from-scratch CLI security scanner
5
+ Requires-Python: >=3.8
6
+ Requires-Dist: tree-sitter>=0.21
7
+ Requires-Dist: tree-sitter-python>=0.21
8
+ Requires-Dist: colorama>=0.4
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sec-scan = sec_scan.scanner:main
@@ -0,0 +1 @@
1
+ sec_scan