sec-scan 0.1.0__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.
@@ -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,91 @@
1
+ # sec-scan
2
+
3
+ A command-line security scanner, built from scratch and growing one
4
+ vulnerability check at a time. It walks a codebase and flags patterns
5
+ that look like known vulnerability classes. No external scanning
6
+ services, no network calls, no data leaves your machine.
7
+
8
+ ## What it checks for right now
9
+
10
+ - **SQL Injection (line-based)** — flags lines where a SQL-executing
11
+ call (`execute`, `query`, `ExecuteReader`, etc.) is combined with
12
+ string concatenation or interpolation (`+`, f-strings, `.format()`,
13
+ template literals, C#'s `$"..."`) instead of a safe parameterized
14
+ placeholder (`?`, `%s`, `@name`, `:name`).
15
+ Applies to: `.py .js .ts .php .java .cs .rb .go`
16
+
17
+ - **SQL Injection (deep, Python only)** — parses Python into a real
18
+ syntax tree instead of reading text line by line, and tracks
19
+ variables built from unsafe string concatenation across multiple
20
+ lines within a function. This catches a common gap in the
21
+ line-based check: a query assembled on one line and executed
22
+ several lines later.
23
+
24
+ For Python files, both checks run and you may see two findings for
25
+ the same underlying bug — one from each check. That's expected for
26
+ now; deduplication is a known future improvement.
27
+
28
+ More checks, and deep analysis for more languages, will be added
29
+ over time — each one gets its own section here, the same way the
30
+ entries above do.
31
+
32
+ ## Install
33
+
34
+ pip install -e .
35
+
36
+ If pip refuses with an "externally managed environment" error:
37
+
38
+ pip install -e . --break-system-packages
39
+
40
+ (Or use a virtualenv if you'd rather keep it isolated — either works.)
41
+
42
+ This installs two dependencies alongside sec-scan itself:
43
+ `tree-sitter` and `tree-sitter-python`, which power the deep Python
44
+ check above.
45
+
46
+ ## Use
47
+
48
+ From inside any project you want to check:
49
+
50
+ sec-scan .
51
+
52
+ That scans the current directory. To scan a specific folder or file
53
+ instead:
54
+
55
+ sec-scan path/to/folder
56
+ sec-scan path/to/file.py
57
+
58
+ ## How it works
59
+
60
+ This isn't a full production-grade static analyzer — it's
61
+ intentionally lightweight detection, built in stages:
62
+
63
+ - The line-based check reads each line as text and looks for a
64
+ dangerous pattern sitting next to a SQL call. Fast, works on any
65
+ of the eight supported languages, but only sees one line at a
66
+ time — a query built across multiple lines can slip past it.
67
+ - The deep Python check instead parses the file into a proper syntax
68
+ tree and follows a variable's origin within a function, so it can
69
+ catch the multi-line case above. It's Python-only for now, and it
70
+ doesn't follow a value once it's passed into another function.
71
+
72
+ Findings should be reviewed by a human, not treated as a guarantee
73
+ of safety or the absence of bugs — that's true of every static
74
+ analysis tool, not just this one. Known limitations for each check
75
+ are documented in that check's own source file.
76
+
77
+ ## Project structure
78
+
79
+ sec-scan/
80
+ ├── pyproject.toml
81
+ └── sec_scan/
82
+ ├── scanner.py # CLI entry point, walks files, runs checks
83
+ └── checks/
84
+ ├── registry.py # auto-discovers check modules
85
+ ├── sql_injection.py # line-based SQL injection check
86
+ └── sql_injection_deep.py # tree-sitter based SQL injection check (Python)
87
+
88
+ Adding a new check means adding one new file to `sec_scan/checks/`
89
+ that defines `EXTENSIONS` (a set of file extensions) and
90
+ `run(file_path, content)` (returns a list of finding dicts). It's
91
+ picked up automatically — nothing else needs to change.
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "sec-scan"
3
+ version = "0.1.0"
4
+ description = "A from-scratch CLI security scanner"
5
+ requires-python = ">=3.8"
6
+ dependencies = [
7
+ "tree-sitter>=0.21",
8
+ "tree-sitter-python>=0.21",
9
+ "colorama>=0.4",
10
+ ]
11
+
12
+ [project.scripts]
13
+ sec-scan = "sec_scan.scanner:main"
14
+
15
+ [tool.setuptools]
16
+ packages = ["sec_scan", "sec_scan.checks"]
17
+
18
+ [build-system]
19
+ requires = ["setuptools>=61.0"]
20
+ build-backend = "setuptools.build_meta"
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
@@ -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,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ sec_scan/__init__.py
4
+ sec_scan/scanner.py
5
+ sec_scan.egg-info/PKG-INFO
6
+ sec_scan.egg-info/SOURCES.txt
7
+ sec_scan.egg-info/dependency_links.txt
8
+ sec_scan.egg-info/entry_points.txt
9
+ sec_scan.egg-info/requires.txt
10
+ sec_scan.egg-info/top_level.txt
11
+ sec_scan/checks/__init__.py
12
+ sec_scan/checks/registry.py
13
+ sec_scan/checks/sql_injection.py
14
+ sec_scan/checks/sql_injection_deep.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sec-scan = sec_scan.scanner:main
@@ -0,0 +1,3 @@
1
+ tree-sitter>=0.21
2
+ tree-sitter-python>=0.21
3
+ colorama>=0.4
@@ -0,0 +1 @@
1
+ sec_scan
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+