gitrupt 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.
- gitrupt/__init__.py +13 -0
- gitrupt/cli.py +546 -0
- gitrupt/config.py +269 -0
- gitrupt/git.py +590 -0
- gitrupt/hooks/__init__.py +7 -0
- gitrupt/hooks/install.py +255 -0
- gitrupt/hooks/pre_commit.py +103 -0
- gitrupt/hooks/pre_push.py +178 -0
- gitrupt/models.py +190 -0
- gitrupt/policy.py +36 -0
- gitrupt/reporting.py +316 -0
- gitrupt/risk.py +197 -0
- gitrupt/scanner.py +117 -0
- gitrupt/scanners/__init__.py +17 -0
- gitrupt/scanners/adapters.py +166 -0
- gitrupt/scanners/base.py +113 -0
- gitrupt/scanners/binaries.py +185 -0
- gitrupt/scanners/code_rules/__init__.py +36 -0
- gitrupt/scanners/code_rules/base.py +27 -0
- gitrupt/scanners/code_rules/go.py +65 -0
- gitrupt/scanners/code_rules/javascript.py +106 -0
- gitrupt/scanners/code_rules/php.py +71 -0
- gitrupt/scanners/code_rules/powershell.py +85 -0
- gitrupt/scanners/code_rules/python.py +153 -0
- gitrupt/scanners/code_rules/ruby.py +76 -0
- gitrupt/scanners/code_rules/rust.py +41 -0
- gitrupt/scanners/code_rules/shell.py +112 -0
- gitrupt/scanners/dependencies.py +244 -0
- gitrupt/scanners/ecosystems/__init__.py +30 -0
- gitrupt/scanners/ecosystems/base.py +60 -0
- gitrupt/scanners/ecosystems/node.py +128 -0
- gitrupt/scanners/ecosystems/python.py +157 -0
- gitrupt/scanners/entropy.py +123 -0
- gitrupt/scanners/forbidden_files.py +201 -0
- gitrupt/scanners/malware.py +219 -0
- gitrupt/scanners/osv_client.py +221 -0
- gitrupt/scanners/registry.py +66 -0
- gitrupt/scanners/secret_rules.py +368 -0
- gitrupt/scanners/secrets.py +558 -0
- gitrupt/scanners/suspicious_code.py +208 -0
- gitrupt/scanners/yara_loader.py +65 -0
- gitrupt/scanners/yara_rules_builtin.py +141 -0
- gitrupt-0.1.0.dist-info/METADATA +342 -0
- gitrupt-0.1.0.dist-info/RECORD +48 -0
- gitrupt-0.1.0.dist-info/WHEEL +5 -0
- gitrupt-0.1.0.dist-info/entry_points.txt +2 -0
- gitrupt-0.1.0.dist-info/licenses/LICENSE +23 -0
- gitrupt-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Suspicious-code rules for Python."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from gitrupt.models import Severity
|
|
8
|
+
from gitrupt.scanners.code_rules.base import CodeRule
|
|
9
|
+
|
|
10
|
+
RULES: tuple[CodeRule, ...] = (
|
|
11
|
+
CodeRule(
|
|
12
|
+
rule_id="py-download-exec",
|
|
13
|
+
language="python",
|
|
14
|
+
pattern=re.compile(
|
|
15
|
+
r"\b(requests|urllib|httpx|urllib3)\.\w+\s*\(",
|
|
16
|
+
re.IGNORECASE,
|
|
17
|
+
),
|
|
18
|
+
severity=Severity.MEDIUM,
|
|
19
|
+
confidence=0.55,
|
|
20
|
+
message="Outbound HTTP call in committed code",
|
|
21
|
+
description=(
|
|
22
|
+
"Code makes a network request. Combined with exec/eval/subprocess "
|
|
23
|
+
"in the same file, this is a common download-and-execute pattern."
|
|
24
|
+
),
|
|
25
|
+
recommendation="Verify the destination URL and the payload's handling.",
|
|
26
|
+
),
|
|
27
|
+
CodeRule(
|
|
28
|
+
rule_id="py-eval-dynamic",
|
|
29
|
+
language="python",
|
|
30
|
+
pattern=re.compile(r"\beval\s*\(\s*(?!['\"])"),
|
|
31
|
+
severity=Severity.MEDIUM,
|
|
32
|
+
confidence=0.7,
|
|
33
|
+
message="Dynamic eval() call",
|
|
34
|
+
description="eval() on non-literal input can execute arbitrary code.",
|
|
35
|
+
recommendation="Replace eval() with an explicit parser or dispatch table.",
|
|
36
|
+
),
|
|
37
|
+
CodeRule(
|
|
38
|
+
rule_id="py-exec-dynamic",
|
|
39
|
+
language="python",
|
|
40
|
+
pattern=re.compile(r"\bexec\s*\(\s*(?!['\"])"),
|
|
41
|
+
severity=Severity.HIGH,
|
|
42
|
+
confidence=0.75,
|
|
43
|
+
message="Dynamic exec() call",
|
|
44
|
+
description="exec() on non-literal input can execute arbitrary code.",
|
|
45
|
+
recommendation="Avoid exec(); restructure the logic explicitly.",
|
|
46
|
+
),
|
|
47
|
+
CodeRule(
|
|
48
|
+
rule_id="py-os-system",
|
|
49
|
+
language="python",
|
|
50
|
+
pattern=re.compile(r"\bos\.system\s*\("),
|
|
51
|
+
severity=Severity.MEDIUM,
|
|
52
|
+
confidence=0.65,
|
|
53
|
+
message="os.system() call",
|
|
54
|
+
description="os.system() executes a shell command.",
|
|
55
|
+
recommendation="Prefer subprocess.run([...]) with an argument list.",
|
|
56
|
+
),
|
|
57
|
+
CodeRule(
|
|
58
|
+
rule_id="py-subprocess-shell-true",
|
|
59
|
+
language="python",
|
|
60
|
+
pattern=re.compile(r"subprocess\.\w+\s*\([^)]*shell\s*=\s*True"),
|
|
61
|
+
severity=Severity.HIGH,
|
|
62
|
+
confidence=0.8,
|
|
63
|
+
message="subprocess with shell=True",
|
|
64
|
+
description="shell=True exposes the call to shell injection.",
|
|
65
|
+
recommendation="Pass an argument list and drop shell=True.",
|
|
66
|
+
),
|
|
67
|
+
CodeRule(
|
|
68
|
+
rule_id="py-base64-exec",
|
|
69
|
+
language="python",
|
|
70
|
+
pattern=re.compile(r"base64\.b64decode\s*\("),
|
|
71
|
+
severity=Severity.MEDIUM,
|
|
72
|
+
confidence=0.6,
|
|
73
|
+
message="base64 decode in source",
|
|
74
|
+
description=(
|
|
75
|
+
"Encoded payloads are a common obfuscation technique. "
|
|
76
|
+
"Review what the decoded bytes are used for."
|
|
77
|
+
),
|
|
78
|
+
recommendation="Ensure the decoded payload is not passed to exec/eval/subprocess.",
|
|
79
|
+
),
|
|
80
|
+
CodeRule(
|
|
81
|
+
rule_id="py-pickle-loads",
|
|
82
|
+
language="python",
|
|
83
|
+
pattern=re.compile(r"\bpickle\.loads?\s*\("),
|
|
84
|
+
severity=Severity.MEDIUM,
|
|
85
|
+
confidence=0.65,
|
|
86
|
+
message="pickle deserialization",
|
|
87
|
+
description="pickle can execute arbitrary code during deserialization.",
|
|
88
|
+
recommendation="Use JSON or another safe format for untrusted data.",
|
|
89
|
+
),
|
|
90
|
+
CodeRule(
|
|
91
|
+
rule_id="py-yaml-unsafe-load",
|
|
92
|
+
language="python",
|
|
93
|
+
pattern=re.compile(r"\byaml\.load\s*\((?![^)]*Loader\s*=)"),
|
|
94
|
+
severity=Severity.MEDIUM,
|
|
95
|
+
confidence=0.7,
|
|
96
|
+
message="yaml.load() without safe Loader",
|
|
97
|
+
description="yaml.load() without Loader can instantiate arbitrary objects.",
|
|
98
|
+
recommendation="Use yaml.safe_load() or pass Loader=yaml.SafeLoader.",
|
|
99
|
+
),
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
CodeRule(
|
|
104
|
+
rule_id="py-marshal-loads",
|
|
105
|
+
language="python",
|
|
106
|
+
pattern=re.compile(r"\bmarshal\.loads?\s*\("),
|
|
107
|
+
severity=Severity.HIGH,
|
|
108
|
+
confidence=0.75,
|
|
109
|
+
message="marshal deserialization",
|
|
110
|
+
description="marshal can execute arbitrary code; used by compiled malware.",
|
|
111
|
+
recommendation="Avoid marshal on untrusted data.",
|
|
112
|
+
),
|
|
113
|
+
CodeRule(
|
|
114
|
+
rule_id="py-pty-spawn",
|
|
115
|
+
language="python",
|
|
116
|
+
pattern=re.compile(r"\bpty\.spawn\s*\("),
|
|
117
|
+
severity=Severity.HIGH,
|
|
118
|
+
confidence=0.8,
|
|
119
|
+
message="pty.spawn — interactive shell",
|
|
120
|
+
description="Common in reverse-shell payloads.",
|
|
121
|
+
recommendation="Review what command is being spawned.",
|
|
122
|
+
),
|
|
123
|
+
CodeRule(
|
|
124
|
+
rule_id="py-reverse-shell",
|
|
125
|
+
language="python",
|
|
126
|
+
pattern=re.compile(r"socket\.socket\s*\([^)]*\)\s*.*connect\s*\("),
|
|
127
|
+
severity=Severity.CRITICAL,
|
|
128
|
+
confidence=0.85,
|
|
129
|
+
message="Possible reverse shell — socket connect",
|
|
130
|
+
description="Raw socket connection followed by shell redirection.",
|
|
131
|
+
recommendation="Never run untrusted code containing this pattern.",
|
|
132
|
+
),
|
|
133
|
+
CodeRule(
|
|
134
|
+
rule_id="py-ctypes-call",
|
|
135
|
+
language="python",
|
|
136
|
+
pattern=re.compile(r"\bctypes\.(?:CDLL|WinDLL|cdll)\.\w+\s*\("),
|
|
137
|
+
severity=Severity.MEDIUM,
|
|
138
|
+
confidence=0.6,
|
|
139
|
+
message="ctypes native call",
|
|
140
|
+
description="Native library calls can bypass Python's safety model.",
|
|
141
|
+
recommendation="Audit the DLL and function being called.",
|
|
142
|
+
),
|
|
143
|
+
CodeRule(
|
|
144
|
+
rule_id="py-runpy",
|
|
145
|
+
language="python",
|
|
146
|
+
pattern=re.compile(r"\brunpy\.(?:run_path|run_module)\s*\("),
|
|
147
|
+
severity=Severity.HIGH,
|
|
148
|
+
confidence=0.75,
|
|
149
|
+
message="runpy dynamic execution",
|
|
150
|
+
description="Loads and runs arbitrary Python modules at runtime.",
|
|
151
|
+
recommendation="Avoid runpy on user-controlled paths.",
|
|
152
|
+
),
|
|
153
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Suspicious-code rules for Ruby."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from gitrupt.models import Severity
|
|
8
|
+
from gitrupt.scanners.code_rules.base import CodeRule
|
|
9
|
+
|
|
10
|
+
RULES: tuple[CodeRule, ...] = (
|
|
11
|
+
CodeRule(
|
|
12
|
+
rule_id="rb-shell-exec",
|
|
13
|
+
language="ruby",
|
|
14
|
+
pattern=re.compile(r"\b(?:system|exec|spawn)\s*\("),
|
|
15
|
+
severity=Severity.MEDIUM,
|
|
16
|
+
confidence=0.6,
|
|
17
|
+
message="Ruby shell execution",
|
|
18
|
+
description="Executes an OS-level command.",
|
|
19
|
+
recommendation="Prefer Open3 with argument arrays.",
|
|
20
|
+
),
|
|
21
|
+
CodeRule(
|
|
22
|
+
rule_id="rb-backticks",
|
|
23
|
+
language="ruby",
|
|
24
|
+
pattern=re.compile(r"`[^`\n]*#\{[^}]*\}[^`\n]*`"),
|
|
25
|
+
severity=Severity.HIGH,
|
|
26
|
+
confidence=0.8,
|
|
27
|
+
message="Backtick shell with interpolation",
|
|
28
|
+
description="Backticks with #{...} interpolate into a shell command.",
|
|
29
|
+
recommendation="Avoid interpolating untrusted values into backticks.",
|
|
30
|
+
),
|
|
31
|
+
CodeRule(
|
|
32
|
+
rule_id="rb-eval-dynamic",
|
|
33
|
+
language="ruby",
|
|
34
|
+
pattern=re.compile(r"\beval\s*\(\s*(?!['\"])"),
|
|
35
|
+
severity=Severity.HIGH,
|
|
36
|
+
confidence=0.75,
|
|
37
|
+
message="Dynamic eval() call",
|
|
38
|
+
description="eval() on non-literal input executes arbitrary code.",
|
|
39
|
+
recommendation="Avoid eval(); use send() with an allowlist.",
|
|
40
|
+
),
|
|
41
|
+
CodeRule(
|
|
42
|
+
rule_id="rb-open-pipe",
|
|
43
|
+
language="ruby",
|
|
44
|
+
pattern=re.compile(r"\bIO\.popen\s*\("),
|
|
45
|
+
severity=Severity.MEDIUM,
|
|
46
|
+
confidence=0.65,
|
|
47
|
+
message="IO.popen() call",
|
|
48
|
+
description="Spawns a shell process.",
|
|
49
|
+
recommendation="Prefer Open3.capture3 with argument arrays.",
|
|
50
|
+
),
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
CodeRule(
|
|
57
|
+
rule_id="rb-reverse-shell",
|
|
58
|
+
language="ruby",
|
|
59
|
+
pattern=re.compile(r"TCPSocket\.open\s*\([^)]*\)[^\n]*while"),
|
|
60
|
+
severity=Severity.CRITICAL,
|
|
61
|
+
confidence=0.85,
|
|
62
|
+
message="Ruby reverse shell via TCPSocket",
|
|
63
|
+
description="Raw TCP socket in a loop — typical backdoor shape.",
|
|
64
|
+
recommendation="Treat as malicious.",
|
|
65
|
+
),
|
|
66
|
+
CodeRule(
|
|
67
|
+
rule_id="rb-open-uri-exec",
|
|
68
|
+
language="ruby",
|
|
69
|
+
pattern=re.compile(r"open(?:-uri)?\s*\([^)]*\)\s*.*\beval\b", re.DOTALL),
|
|
70
|
+
severity=Severity.HIGH,
|
|
71
|
+
confidence=0.75,
|
|
72
|
+
message="Ruby remote fetch + eval",
|
|
73
|
+
description="Download and execute pattern.",
|
|
74
|
+
recommendation="Never eval remote content.",
|
|
75
|
+
),
|
|
76
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Suspicious-code rules for Rust."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from gitrupt.models import Severity
|
|
8
|
+
from gitrupt.scanners.code_rules.base import CodeRule
|
|
9
|
+
|
|
10
|
+
RULES: tuple[CodeRule, ...] = (
|
|
11
|
+
CodeRule(
|
|
12
|
+
rule_id="rs-command-shell",
|
|
13
|
+
language="rust",
|
|
14
|
+
pattern=re.compile(r"Command::new\s*\(\s*\"(?:/bin/)?(?:ba)?sh\""),
|
|
15
|
+
severity=Severity.HIGH,
|
|
16
|
+
confidence=0.8,
|
|
17
|
+
message="Rust shell invocation",
|
|
18
|
+
description="Command::new on a shell binary.",
|
|
19
|
+
recommendation="Call the target binary directly.",
|
|
20
|
+
),
|
|
21
|
+
CodeRule(
|
|
22
|
+
rule_id="rs-from-utf8-unchecked",
|
|
23
|
+
language="rust",
|
|
24
|
+
pattern=re.compile(r"from_utf8_unchecked\s*\("),
|
|
25
|
+
severity=Severity.MEDIUM,
|
|
26
|
+
confidence=0.6,
|
|
27
|
+
message="unsafe UTF-8 conversion",
|
|
28
|
+
description="from_utf8_unchecked bypasses validation.",
|
|
29
|
+
recommendation="Prefer std::str::from_utf8 with error handling.",
|
|
30
|
+
),
|
|
31
|
+
CodeRule(
|
|
32
|
+
rule_id="rs-transmute",
|
|
33
|
+
language="rust",
|
|
34
|
+
pattern=re.compile(r"std::mem::transmute\s*(?:::<[^>]*>)?\s*\("),
|
|
35
|
+
severity=Severity.MEDIUM,
|
|
36
|
+
confidence=0.5,
|
|
37
|
+
message="std::mem::transmute",
|
|
38
|
+
description="Type-punning; unsafe and hard to audit.",
|
|
39
|
+
recommendation="Use safe conversions where possible.",
|
|
40
|
+
),
|
|
41
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Suspicious-code rules for shell scripts (sh, bash, zsh)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from gitrupt.models import Severity
|
|
8
|
+
from gitrupt.scanners.code_rules.base import CodeRule
|
|
9
|
+
|
|
10
|
+
RULES: tuple[CodeRule, ...] = (
|
|
11
|
+
CodeRule(
|
|
12
|
+
rule_id="sh-curl-pipe-shell",
|
|
13
|
+
language="shell",
|
|
14
|
+
pattern=re.compile(r"\b(?:curl|wget)\b[^\n|]*\|\s*(?:ba|z|k)?sh\b"),
|
|
15
|
+
severity=Severity.HIGH,
|
|
16
|
+
confidence=0.85,
|
|
17
|
+
message="curl/wget piped to shell",
|
|
18
|
+
description="Classic download-and-execute pattern.",
|
|
19
|
+
recommendation="Download to a file, verify its hash, then execute.",
|
|
20
|
+
),
|
|
21
|
+
CodeRule(
|
|
22
|
+
rule_id="sh-base64-decode-exec",
|
|
23
|
+
language="shell",
|
|
24
|
+
pattern=re.compile(r"base64\s+(?:-d|--decode)\b[^\n|]*\|\s*(?:ba|z|k)?sh\b"),
|
|
25
|
+
severity=Severity.HIGH,
|
|
26
|
+
confidence=0.85,
|
|
27
|
+
message="base64 decode piped to shell",
|
|
28
|
+
description="Obfuscated command execution.",
|
|
29
|
+
recommendation="Decode and inspect the payload before executing.",
|
|
30
|
+
),
|
|
31
|
+
CodeRule(
|
|
32
|
+
rule_id="sh-eval",
|
|
33
|
+
language="shell",
|
|
34
|
+
pattern=re.compile(r"\beval\s+[\"'\$]"),
|
|
35
|
+
severity=Severity.MEDIUM,
|
|
36
|
+
confidence=0.6,
|
|
37
|
+
message="eval in shell script",
|
|
38
|
+
description="eval on variable content can execute injected commands.",
|
|
39
|
+
recommendation="Avoid eval; use arrays and explicit arguments.",
|
|
40
|
+
),
|
|
41
|
+
CodeRule(
|
|
42
|
+
rule_id="sh-rm-rf-root",
|
|
43
|
+
language="shell",
|
|
44
|
+
pattern=re.compile(r"\brm\s+-[a-z]*r[a-z]*f?[a-z]*\s+/(?:\s|$|\*)"),
|
|
45
|
+
severity=Severity.CRITICAL,
|
|
46
|
+
confidence=0.9,
|
|
47
|
+
message="rm -rf / (destructive command)",
|
|
48
|
+
description="Wipes the filesystem root.",
|
|
49
|
+
recommendation="Never run this. Review the surrounding script.",
|
|
50
|
+
),
|
|
51
|
+
CodeRule(
|
|
52
|
+
rule_id="sh-chmod-777",
|
|
53
|
+
language="shell",
|
|
54
|
+
pattern=re.compile(r"\bchmod\s+(?:-R\s+)?777\b"),
|
|
55
|
+
severity=Severity.LOW,
|
|
56
|
+
confidence=0.6,
|
|
57
|
+
message="chmod 777 (world-writable)",
|
|
58
|
+
description="World-writable permissions are a security risk.",
|
|
59
|
+
recommendation="Grant the minimum required permissions.",
|
|
60
|
+
),
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
CodeRule(
|
|
65
|
+
rule_id="sh-bash-reverse-shell",
|
|
66
|
+
language="shell",
|
|
67
|
+
pattern=re.compile(r"(?:bash|sh)\s+-i\s+>&\s+/dev/(?:tcp|udp)/"),
|
|
68
|
+
severity=Severity.CRITICAL,
|
|
69
|
+
confidence=0.95,
|
|
70
|
+
message="Bash reverse shell via /dev/tcp",
|
|
71
|
+
description="Classic reverse shell one-liner.",
|
|
72
|
+
recommendation="Never run this. Treat the whole script as malicious.",
|
|
73
|
+
),
|
|
74
|
+
CodeRule(
|
|
75
|
+
rule_id="sh-nc-reverse",
|
|
76
|
+
language="shell",
|
|
77
|
+
pattern=re.compile(r"\bnc\b[^\n]*-e\s+(?:/bin/)?(?:ba)?sh\b"),
|
|
78
|
+
severity=Severity.CRITICAL,
|
|
79
|
+
confidence=0.95,
|
|
80
|
+
message="Netcat reverse shell",
|
|
81
|
+
description="nc -e /bin/sh connects a shell to a remote host.",
|
|
82
|
+
recommendation="Never run this.",
|
|
83
|
+
),
|
|
84
|
+
CodeRule(
|
|
85
|
+
rule_id="sh-sudo-pipe",
|
|
86
|
+
language="shell",
|
|
87
|
+
pattern=re.compile(r"\bsudo\b[^\n]*\|"),
|
|
88
|
+
severity=Severity.LOW,
|
|
89
|
+
confidence=0.5,
|
|
90
|
+
message="sudo piped to another command",
|
|
91
|
+
description="Privilege-escalation pattern — review carefully.",
|
|
92
|
+
recommendation="Avoid piping sudo output to interpreters.",
|
|
93
|
+
),
|
|
94
|
+
CodeRule(
|
|
95
|
+
rule_id="sh-curl-exfil",
|
|
96
|
+
language="shell",
|
|
97
|
+
pattern=re.compile(
|
|
98
|
+
r"\bcurl\b[^\n]*(?:\$AWS_|\$SECRET|\$TOKEN|\$API_KEY|\$PASSWORD|\$GITHUB_TOKEN|\$STRIPE_)"
|
|
99
|
+
),
|
|
100
|
+
severity=Severity.CRITICAL,
|
|
101
|
+
confidence=0.9,
|
|
102
|
+
message="curl with secret environment variable",
|
|
103
|
+
description="Exfiltrates a known secret env var to a remote host.",
|
|
104
|
+
recommendation="Audit the destination URL. This is a credential-theft pattern.",
|
|
105
|
+
),
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Native dependency scanner.
|
|
3
|
+
|
|
4
|
+
Parses manifests/lockfiles for enabled ecosystems, queries OSV for known
|
|
5
|
+
vulnerabilities, and emits normalized Finding objects.
|
|
6
|
+
|
|
7
|
+
Never blocks. Never requires a network. Never executes anything.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
from fnmatch import fnmatch
|
|
15
|
+
from typing import Callable
|
|
16
|
+
|
|
17
|
+
from gitrupt.config import DependenciesConfig
|
|
18
|
+
from gitrupt.models import Finding, ScanTarget, Severity
|
|
19
|
+
from gitrupt.scanners.base import Scanner
|
|
20
|
+
from gitrupt.scanners.ecosystems import get_adapters
|
|
21
|
+
from gitrupt.scanners.ecosystems.base import Dependency, EcosystemAdapter
|
|
22
|
+
from gitrupt.scanners.osv_client import OSVClient
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
ContentProvider = Callable[[str, str], str | None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _default_content_provider(repo_root: str, path: str) -> str | None:
|
|
30
|
+
from gitrupt.git import GitAdapter
|
|
31
|
+
|
|
32
|
+
return GitAdapter.get_staged_file_content(repo_root, path)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── Severity extraction from an OSV vuln record ─────────────────────────────
|
|
36
|
+
|
|
37
|
+
_SEVERITY_MAP = {
|
|
38
|
+
"LOW": Severity.LOW,
|
|
39
|
+
"MEDIUM": Severity.MEDIUM,
|
|
40
|
+
"MODERATE": Severity.MEDIUM,
|
|
41
|
+
"HIGH": Severity.HIGH,
|
|
42
|
+
"CRITICAL": Severity.CRITICAL,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _extract_severity(vuln: dict) -> Severity:
|
|
47
|
+
"""
|
|
48
|
+
Best-effort severity from an OSV record.
|
|
49
|
+
|
|
50
|
+
Order:
|
|
51
|
+
1. database_specific.severity (string or {score: str})
|
|
52
|
+
2. severity[].score CVSS vector prefix (best-effort)
|
|
53
|
+
3. Fallback: MEDIUM
|
|
54
|
+
"""
|
|
55
|
+
db = vuln.get("database_specific") or {}
|
|
56
|
+
raw = db.get("severity")
|
|
57
|
+
if isinstance(raw, str):
|
|
58
|
+
s = _SEVERITY_MAP.get(raw.upper())
|
|
59
|
+
if s:
|
|
60
|
+
return s
|
|
61
|
+
elif isinstance(raw, dict):
|
|
62
|
+
score = raw.get("score")
|
|
63
|
+
if isinstance(score, str):
|
|
64
|
+
s = _SEVERITY_MAP.get(score.upper())
|
|
65
|
+
if s:
|
|
66
|
+
return s
|
|
67
|
+
|
|
68
|
+
for entry in vuln.get("severity") or []:
|
|
69
|
+
if not isinstance(entry, dict):
|
|
70
|
+
continue
|
|
71
|
+
# OSV usually gives a CVSS vector string in `score`.
|
|
72
|
+
vector = entry.get("score", "")
|
|
73
|
+
if isinstance(vector, str):
|
|
74
|
+
# Very rough: parse just the numeric base score if present as ".../X.X"
|
|
75
|
+
m = re.search(r"/(\d{1,2}\.\d)\b", vector)
|
|
76
|
+
if m:
|
|
77
|
+
try:
|
|
78
|
+
v = float(m.group(1))
|
|
79
|
+
except ValueError:
|
|
80
|
+
continue
|
|
81
|
+
if v >= 9.0:
|
|
82
|
+
return Severity.CRITICAL
|
|
83
|
+
if v >= 7.0:
|
|
84
|
+
return Severity.HIGH
|
|
85
|
+
if v >= 4.0:
|
|
86
|
+
return Severity.MEDIUM
|
|
87
|
+
return Severity.LOW
|
|
88
|
+
|
|
89
|
+
return Severity.MEDIUM
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _extract_fixed_version(vuln: dict) -> str | None:
|
|
93
|
+
"""First fixed version appearing in any affected range."""
|
|
94
|
+
for affected in vuln.get("affected") or []:
|
|
95
|
+
for r in affected.get("ranges") or []:
|
|
96
|
+
for event in r.get("events") or []:
|
|
97
|
+
fixed = event.get("fixed")
|
|
98
|
+
if isinstance(fixed, str):
|
|
99
|
+
return fixed
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _extract_summary(vuln: dict) -> str:
|
|
104
|
+
summary = vuln.get("summary")
|
|
105
|
+
if isinstance(summary, str) and summary.strip():
|
|
106
|
+
return summary.strip()
|
|
107
|
+
details = vuln.get("details")
|
|
108
|
+
if isinstance(details, str) and details.strip():
|
|
109
|
+
return details.strip().split("\n", 1)[0]
|
|
110
|
+
return "Vulnerable dependency"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class DependencyScanner(Scanner):
|
|
114
|
+
"""
|
|
115
|
+
Scans staged manifests/lockfiles for known vulnerabilities.
|
|
116
|
+
|
|
117
|
+
Config (DependenciesConfig):
|
|
118
|
+
ecosystems: ["python", "node"]
|
|
119
|
+
offline: skip network entirely
|
|
120
|
+
timeout_seconds: HTTP timeout
|
|
121
|
+
cache_ttl_hours: OSV cache lifetime
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
config: DependenciesConfig | None = None,
|
|
127
|
+
content_provider: ContentProvider | None = None,
|
|
128
|
+
osv_client: OSVClient | None = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
self._config = config or DependenciesConfig()
|
|
131
|
+
self._content_provider = content_provider or _default_content_provider
|
|
132
|
+
self._adapters: list[EcosystemAdapter] = get_adapters(self._config.ecosystems)
|
|
133
|
+
self._osv = osv_client or OSVClient(
|
|
134
|
+
timeout=self._config.timeout_seconds,
|
|
135
|
+
cache_ttl_hours=self._config.cache_ttl_hours,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# ── Scanner interface ───────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def name(self) -> str:
|
|
142
|
+
return "dependencies"
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def description(self) -> str:
|
|
146
|
+
return "Detects vulnerable dependencies via OSV."
|
|
147
|
+
|
|
148
|
+
def scan(self, target: ScanTarget) -> list[Finding]:
|
|
149
|
+
deps = self._collect_dependencies(target)
|
|
150
|
+
if not deps:
|
|
151
|
+
return []
|
|
152
|
+
|
|
153
|
+
if self._config.offline:
|
|
154
|
+
logger.info(
|
|
155
|
+
"Dependency scanner offline; %d dependency(ies) not checked", len(deps)
|
|
156
|
+
)
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
vulns_by_dep = self._osv.query_batch(deps)
|
|
160
|
+
|
|
161
|
+
findings: list[Finding] = []
|
|
162
|
+
for dep in deps:
|
|
163
|
+
key = (dep.osv_ecosystem, dep.name, dep.version)
|
|
164
|
+
for vuln in vulns_by_dep.get(key, []):
|
|
165
|
+
findings.append(self._to_finding(dep, vuln))
|
|
166
|
+
return findings
|
|
167
|
+
|
|
168
|
+
# ── Collection ──────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
def _collect_dependencies(self, target: ScanTarget) -> list[Dependency]:
|
|
171
|
+
deps: list[Dependency] = []
|
|
172
|
+
for staged in target.staged_files:
|
|
173
|
+
if staged.status == "D":
|
|
174
|
+
continue
|
|
175
|
+
for adapter in self._adapters:
|
|
176
|
+
if not self._matches(staged.path, adapter):
|
|
177
|
+
continue
|
|
178
|
+
content = self._content_provider(target.repo_root, staged.path)
|
|
179
|
+
if content is None:
|
|
180
|
+
continue
|
|
181
|
+
try:
|
|
182
|
+
deps.extend(adapter.parse(staged.path, content))
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger.warning(
|
|
185
|
+
"Ecosystem %s failed to parse %s: %s",
|
|
186
|
+
adapter.name,
|
|
187
|
+
staged.path,
|
|
188
|
+
e,
|
|
189
|
+
)
|
|
190
|
+
# Deduplicate identical (ecosystem, name, version, source_file)
|
|
191
|
+
seen: set[tuple[str, str, str, str]] = set()
|
|
192
|
+
unique: list[Dependency] = []
|
|
193
|
+
for d in deps:
|
|
194
|
+
key = (d.osv_ecosystem, d.name, d.version, d.source_file)
|
|
195
|
+
if key in seen:
|
|
196
|
+
continue
|
|
197
|
+
seen.add(key)
|
|
198
|
+
unique.append(d)
|
|
199
|
+
return unique
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _matches(path: str, adapter: EcosystemAdapter) -> bool:
|
|
203
|
+
name = path.replace("\\", "/").rsplit("/", 1)[-1]
|
|
204
|
+
patterns = adapter.manifest_patterns + adapter.lockfile_patterns
|
|
205
|
+
return any(fnmatch(name, p) for p in patterns)
|
|
206
|
+
|
|
207
|
+
# ── Finding construction ────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
def _to_finding(self, dep: Dependency, vuln: dict) -> Finding:
|
|
210
|
+
vuln_id = str(vuln.get("id") or "UNKNOWN")
|
|
211
|
+
aliases = [a for a in (vuln.get("aliases") or []) if isinstance(a, str)]
|
|
212
|
+
cve = next((a for a in aliases if a.startswith("CVE-")), None)
|
|
213
|
+
|
|
214
|
+
severity = _extract_severity(vuln)
|
|
215
|
+
fixed = _extract_fixed_version(vuln)
|
|
216
|
+
|
|
217
|
+
message = f"Vulnerable dependency: {dep.name} {dep.version} ({vuln_id})"
|
|
218
|
+
description = _extract_summary(vuln)
|
|
219
|
+
if cve:
|
|
220
|
+
description = f"{cve}: {description}"
|
|
221
|
+
|
|
222
|
+
if fixed:
|
|
223
|
+
recommendation = (
|
|
224
|
+
f"Upgrade {dep.name} to {fixed} or later "
|
|
225
|
+
f"({dep.source_file})."
|
|
226
|
+
)
|
|
227
|
+
else:
|
|
228
|
+
recommendation = (
|
|
229
|
+
f"Review and update {dep.name} in {dep.source_file}."
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return Finding(
|
|
233
|
+
scanner="dependencies",
|
|
234
|
+
rule_id=f"osv-{vuln_id}",
|
|
235
|
+
severity=severity,
|
|
236
|
+
confidence=0.9,
|
|
237
|
+
file=dep.source_file,
|
|
238
|
+
line=dep.line,
|
|
239
|
+
message=message,
|
|
240
|
+
description=description,
|
|
241
|
+
evidence=f"{dep.name}=={dep.version}",
|
|
242
|
+
recommendation=recommendation,
|
|
243
|
+
can_override=True,
|
|
244
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Per-ecosystem dependency adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from gitrupt.scanners.ecosystems.base import Dependency, EcosystemAdapter
|
|
6
|
+
from gitrupt.scanners.ecosystems.node import NodeAdapter
|
|
7
|
+
from gitrupt.scanners.ecosystems.python import PythonAdapter
|
|
8
|
+
|
|
9
|
+
# Registry of built-in adapters, keyed by config name.
|
|
10
|
+
_ADAPTERS: dict[str, EcosystemAdapter] = {
|
|
11
|
+
"python": PythonAdapter(),
|
|
12
|
+
"node": NodeAdapter(),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_adapters(names: list[str]) -> list[EcosystemAdapter]:
|
|
17
|
+
"""Return adapters for the given ecosystem names. Unknown names are ignored."""
|
|
18
|
+
return [_ADAPTERS[n] for n in names if n in _ADAPTERS]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def supported_ecosystems() -> tuple[str, ...]:
|
|
22
|
+
return tuple(sorted(_ADAPTERS.keys()))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Dependency",
|
|
27
|
+
"EcosystemAdapter",
|
|
28
|
+
"get_adapters",
|
|
29
|
+
"supported_ecosystems",
|
|
30
|
+
]
|