devguard-scanner 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.
- devguard/__init__.py +5 -0
- devguard/ai/__init__.py +0 -0
- devguard/ai/investigator.py +380 -0
- devguard/ai/prompts.py +113 -0
- devguard/ai/providers/__init__.py +0 -0
- devguard/ai/providers/anthropic.py +163 -0
- devguard/ai/providers/openai_compat.py +157 -0
- devguard/ai/registry.py +160 -0
- devguard/ai/run.py +119 -0
- devguard/ai/sdk.py +102 -0
- devguard/ai/testing.py +126 -0
- devguard/ai/tools.py +389 -0
- devguard/analyzers/__init__.py +0 -0
- devguard/analyzers/base.py +135 -0
- devguard/analyzers/carriers.py +345 -0
- devguard/analyzers/command.py +343 -0
- devguard/analyzers/content.py +240 -0
- devguard/analyzers/filesystem.py +142 -0
- devguard/analyzers/hidden.py +273 -0
- devguard/analyzers/imports.py +102 -0
- devguard/analyzers/jsonc.py +221 -0
- devguard/analyzers/surfaces/__init__.py +0 -0
- devguard/analyzers/surfaces/ai_assistants.py +318 -0
- devguard/analyzers/surfaces/autoload.py +142 -0
- devguard/analyzers/surfaces/common.py +242 -0
- devguard/analyzers/surfaces/deps.py +159 -0
- devguard/analyzers/surfaces/devcontainer.py +195 -0
- devguard/analyzers/surfaces/env.py +53 -0
- devguard/analyzers/surfaces/git.py +236 -0
- devguard/analyzers/surfaces/npm.py +304 -0
- devguard/analyzers/surfaces/python_pkg.py +138 -0
- devguard/analyzers/surfaces/vscode.py +515 -0
- devguard/cli.py +302 -0
- devguard/core/__init__.py +0 -0
- devguard/core/config.py +147 -0
- devguard/core/filetype.py +313 -0
- devguard/core/models.py +195 -0
- devguard/core/redact.py +72 -0
- devguard/core/safefs.py +280 -0
- devguard/core/scoring.py +60 -0
- devguard/core/service.py +211 -0
- devguard/correlate/__init__.py +0 -0
- devguard/correlate/chains.py +160 -0
- devguard/mcp/__init__.py +0 -0
- devguard/mcp/server.py +442 -0
- devguard/report/__init__.py +0 -0
- devguard/report/escape.py +31 -0
- devguard/report/markdown.py +242 -0
- devguard/report/sarif.py +96 -0
- devguard/report/terminal.py +47 -0
- devguard/report/writer.py +52 -0
- devguard/rules/__init__.py +0 -0
- devguard/rules/catalog.py +96 -0
- devguard/rules/content/ai_instructions.yaml +111 -0
- devguard/rules/content/cross_language.yaml +228 -0
- devguard/rules/content/docs.yaml +63 -0
- devguard/rules/content/javascript.yaml +171 -0
- devguard/rules/content/obfuscation.yaml +138 -0
- devguard/rules/content/python.yaml +144 -0
- devguard/rules/content/shell.yaml +109 -0
- devguard/rules/content/windows.yaml +95 -0
- devguard/rules/data/vscode_settings.yaml +81 -0
- devguard_scanner-0.1.0.dist-info/METADATA +84 -0
- devguard_scanner-0.1.0.dist-info/RECORD +67 -0
- devguard_scanner-0.1.0.dist-info/WHEEL +4 -0
- devguard_scanner-0.1.0.dist-info/entry_points.txt +7 -0
- devguard_scanner-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Dev Container configuration (TC-04.12)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from devguard.analyzers.base import AnalysisContext, FileView
|
|
8
|
+
from devguard.analyzers.jsonc import Node
|
|
9
|
+
from devguard.analyzers.surfaces.common import (
|
|
10
|
+
assess_and_trigger,
|
|
11
|
+
command_text,
|
|
12
|
+
env_findings,
|
|
13
|
+
env_pairs_from_node,
|
|
14
|
+
load_jsonc,
|
|
15
|
+
workspace_root_of,
|
|
16
|
+
)
|
|
17
|
+
from devguard.core.models import Finding
|
|
18
|
+
from devguard.core.redact import make_excerpt
|
|
19
|
+
from devguard.rules.catalog import build_finding, rule
|
|
20
|
+
|
|
21
|
+
R_HOST_CMD = rule(
|
|
22
|
+
"DG-DEVC-HOST-CMD-001",
|
|
23
|
+
"Dev container runs a command on the host",
|
|
24
|
+
"high",
|
|
25
|
+
"high",
|
|
26
|
+
"ide-autorun",
|
|
27
|
+
"TC-04.12",
|
|
28
|
+
"`initializeCommand` runs on the developer's machine (not inside the container) whenever the dev "
|
|
29
|
+
"container is built or opened.",
|
|
30
|
+
"Rarely needed; used for things like creating a host directory before mounting it.",
|
|
31
|
+
"Read the command before choosing 'Reopen in Container'.",
|
|
32
|
+
)
|
|
33
|
+
R_LIFECYCLE = rule(
|
|
34
|
+
"DG-DEVC-LIFECYCLE-001",
|
|
35
|
+
"Dev container lifecycle command",
|
|
36
|
+
"low",
|
|
37
|
+
"high",
|
|
38
|
+
"ide-autorun",
|
|
39
|
+
"TC-04.12",
|
|
40
|
+
"The dev container runs this command automatically (postCreate/postStart/postAttach...). It runs inside "
|
|
41
|
+
"the container, which typically has your workspace and forwarded credentials mounted.",
|
|
42
|
+
)
|
|
43
|
+
R_PRIVILEGED = rule(
|
|
44
|
+
"DG-DEVC-PRIVILEGED-001",
|
|
45
|
+
"Dev container gets host-level access",
|
|
46
|
+
"high",
|
|
47
|
+
"high",
|
|
48
|
+
"ide-autorun",
|
|
49
|
+
"TC-04.12, TC-04.11",
|
|
50
|
+
"The container is privileged, joins host namespaces, or mounts sensitive host paths (Docker socket, home "
|
|
51
|
+
"directory, SSH keys, root filesystem), which lets code inside it take over the host.",
|
|
52
|
+
"Docker-in-Docker setups mount the Docker socket on purpose.",
|
|
53
|
+
)
|
|
54
|
+
R_FEATURE = rule(
|
|
55
|
+
"DG-DEVC-FEATURE-001",
|
|
56
|
+
"Dev container feature from an unfamiliar source",
|
|
57
|
+
"low",
|
|
58
|
+
"low",
|
|
59
|
+
"ide-autorun",
|
|
60
|
+
"TC-04.12",
|
|
61
|
+
"A dev container feature (an install script that runs as root during the build) comes from outside the "
|
|
62
|
+
"official devcontainers registry.",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
LIFECYCLE_KEYS = (
|
|
66
|
+
"onCreateCommand",
|
|
67
|
+
"updateContentCommand",
|
|
68
|
+
"postCreateCommand",
|
|
69
|
+
"postStartCommand",
|
|
70
|
+
"postAttachCommand",
|
|
71
|
+
)
|
|
72
|
+
_PRIV_ARGS = re.compile(
|
|
73
|
+
r"^--privileged$|^--(cap-add)(=|$)|^--(network|net|pid|ipc|uts|userns)=host$|^--security-opt(=|$)"
|
|
74
|
+
)
|
|
75
|
+
_SENSITIVE_MOUNT = re.compile(
|
|
76
|
+
r"docker\.sock|\$\{localEnv:(HOME|USERPROFILE)\}(/(\.ssh|\.aws|\.kube|\.gnupg|\.config|\.docker))?[,\s]|"
|
|
77
|
+
r"\$\{localEnv:(HOME|USERPROFILE)\}/?(,|$)|source=/(,|$)|source=/etc\b|/\.ssh\b|/\.aws\b|/\.kube\b",
|
|
78
|
+
)
|
|
79
|
+
_OFFICIAL_FEATURES = ("ghcr.io/devcontainers/", "ghcr.io/devcontainers-contrib/", "ghcr.io/devcontainers-extra/")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class DevcontainerAnalyzer:
|
|
83
|
+
name = "devcontainer"
|
|
84
|
+
|
|
85
|
+
def applies(self, fv: FileView) -> bool:
|
|
86
|
+
return fv.text is not None and (
|
|
87
|
+
fv.name == ".devcontainer.json" or (fv.name == "devcontainer.json" and "/.devcontainer/" in f"/{fv.rel}")
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
91
|
+
out: list[Finding] = []
|
|
92
|
+
root = load_jsonc(fv, ctx, out)
|
|
93
|
+
if root is None or not isinstance(root.value, dict):
|
|
94
|
+
return out
|
|
95
|
+
base = [workspace_root_of(fv.rel), fv.parent]
|
|
96
|
+
init = root.get("initializeCommand")
|
|
97
|
+
for label, cmd, line in _commands(init):
|
|
98
|
+
out.append(
|
|
99
|
+
build_finding(
|
|
100
|
+
R_HOST_CMD.id, [fv.evidence_at_line(line)], detail=f"{label}: {make_excerpt(cmd, limit=160)}"
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
out += assess_and_trigger(
|
|
104
|
+
ctx,
|
|
105
|
+
fv,
|
|
106
|
+
cmd,
|
|
107
|
+
line,
|
|
108
|
+
kind="devcontainer-host",
|
|
109
|
+
label=label,
|
|
110
|
+
autorun=True,
|
|
111
|
+
context="devcontainer initializeCommand (host)",
|
|
112
|
+
base_dirs=base,
|
|
113
|
+
)
|
|
114
|
+
for key in LIFECYCLE_KEYS:
|
|
115
|
+
for label, cmd, line in _commands(root.get(key), key):
|
|
116
|
+
out.append(
|
|
117
|
+
build_finding(
|
|
118
|
+
R_LIFECYCLE.id, [fv.evidence_at_line(line)], detail=f"{label}: {make_excerpt(cmd, limit=160)}"
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
out += assess_and_trigger(
|
|
122
|
+
ctx,
|
|
123
|
+
fv,
|
|
124
|
+
cmd,
|
|
125
|
+
line,
|
|
126
|
+
kind="devcontainer",
|
|
127
|
+
label=label,
|
|
128
|
+
autorun=True,
|
|
129
|
+
context=f"devcontainer {key}",
|
|
130
|
+
base_dirs=base,
|
|
131
|
+
)
|
|
132
|
+
out += self._privileges(root, fv)
|
|
133
|
+
feats = root.get("features")
|
|
134
|
+
for fid, node in feats.items() if feats else []:
|
|
135
|
+
if "/" in fid and not fid.startswith(_OFFICIAL_FEATURES) and not fid.startswith("./"):
|
|
136
|
+
out.append(
|
|
137
|
+
build_finding(
|
|
138
|
+
R_FEATURE.id,
|
|
139
|
+
[fv.evidence_at_line(node.line)],
|
|
140
|
+
detail=f"Feature: {make_excerpt(fid, limit=120)}",
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
for key in ("containerEnv", "remoteEnv"):
|
|
144
|
+
if (env := root.get(key)) is not None:
|
|
145
|
+
out += env_findings(
|
|
146
|
+
ctx, fv, env_pairs_from_node(env), context=f"devcontainer {key}", autorun=True, base_dirs=base
|
|
147
|
+
)
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
def _privileges(self, root: Node, fv: FileView) -> list[Finding]:
|
|
151
|
+
out: list[Finding] = []
|
|
152
|
+
priv = root.get("privileged")
|
|
153
|
+
if priv is not None and priv.value is True:
|
|
154
|
+
out.append(build_finding(R_PRIVILEGED.id, [fv.evidence_at_line(priv.line)], detail="privileged: true"))
|
|
155
|
+
cap = root.get("capAdd")
|
|
156
|
+
if cap is not None and any(e.value in ("SYS_ADMIN", "ALL", "SYS_PTRACE") for e in cap.elements()):
|
|
157
|
+
out.append(build_finding(R_PRIVILEGED.id, [fv.evidence_at_line(cap.line)], detail="capAdd"))
|
|
158
|
+
run_args = root.get("runArgs")
|
|
159
|
+
for e in run_args.elements() if run_args else []:
|
|
160
|
+
if isinstance(e.value, str) and (_PRIV_ARGS.match(e.value) or _SENSITIVE_MOUNT.search(e.value + ",")):
|
|
161
|
+
out.append(
|
|
162
|
+
build_finding(
|
|
163
|
+
R_PRIVILEGED.id,
|
|
164
|
+
[fv.evidence_at_line(e.line)],
|
|
165
|
+
detail=f"runArgs: {make_excerpt(e.value, limit=120)}",
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
mounts = root.get("mounts")
|
|
169
|
+
for e in mounts.elements() if mounts else []:
|
|
170
|
+
text = (
|
|
171
|
+
e.value
|
|
172
|
+
if isinstance(e.value, str)
|
|
173
|
+
else ",".join(f"{k}={v.value}" for k, v in e.items() if isinstance(v.value, str))
|
|
174
|
+
)
|
|
175
|
+
if text and _SENSITIVE_MOUNT.search(text + ","):
|
|
176
|
+
out.append(
|
|
177
|
+
build_finding(
|
|
178
|
+
R_PRIVILEGED.id, [fv.evidence_at_line(e.line)], detail=f"mount: {make_excerpt(text, limit=160)}"
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _commands(node: Node | None, key: str = "initializeCommand") -> list[tuple[str, str, int]]:
|
|
185
|
+
if node is None:
|
|
186
|
+
return []
|
|
187
|
+
if isinstance(node.value, dict): # {"name": "cmd" | [...]} commands run in parallel
|
|
188
|
+
out = []
|
|
189
|
+
for name, sub in node.items():
|
|
190
|
+
cmd = command_text(sub)
|
|
191
|
+
if cmd:
|
|
192
|
+
out.append((f"{key}.{name}", cmd, sub.line))
|
|
193
|
+
return out
|
|
194
|
+
cmd = command_text(node)
|
|
195
|
+
return [(key, cmd, node.line)] if cmd else []
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Shell/environment auto-loaders: .envrc (direnv), .env files (TC-10)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from devguard.analyzers.base import AnalysisContext, FileView
|
|
8
|
+
from devguard.analyzers.surfaces.common import env_findings
|
|
9
|
+
from devguard.core.models import Finding
|
|
10
|
+
from devguard.rules.catalog import build_finding, rule
|
|
11
|
+
|
|
12
|
+
R_DIRENV = rule(
|
|
13
|
+
"DG-ENV-DIRENV-001",
|
|
14
|
+
"direnv file runs shell code on directory entry",
|
|
15
|
+
"low",
|
|
16
|
+
"high",
|
|
17
|
+
"trigger",
|
|
18
|
+
"TC-10.1",
|
|
19
|
+
"`.envrc` is executed by direnv every time a shell enters this directory (once allowed). Its contents "
|
|
20
|
+
"are checked by the shell rules.",
|
|
21
|
+
"direnv is a common, legitimate tool; review the file before running `direnv allow`.",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_ASSIGN = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _unquote(v: str) -> str:
|
|
28
|
+
v = v.strip()
|
|
29
|
+
if len(v) >= 2 and v[0] == v[-1] and v[0] in "'\"":
|
|
30
|
+
return v[1:-1]
|
|
31
|
+
return v.split(" #", 1)[0].strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EnvAnalyzer:
|
|
35
|
+
name = "env-loaders"
|
|
36
|
+
|
|
37
|
+
def applies(self, fv: FileView) -> bool:
|
|
38
|
+
return fv.text is not None and (fv.name == ".envrc" or fv.name.startswith(".env"))
|
|
39
|
+
|
|
40
|
+
def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
41
|
+
out: list[Finding] = []
|
|
42
|
+
base = [fv.parent]
|
|
43
|
+
if fv.name == ".envrc":
|
|
44
|
+
out.append(build_finding(R_DIRENV.id, [fv.evidence_at_line(1)]))
|
|
45
|
+
ctx.add_trigger("direnv", fv.rel, line=1, label=".envrc", autorun=True, references=[fv.rel])
|
|
46
|
+
ctx.autoloaded.add(fv.rel)
|
|
47
|
+
pairs: list[tuple[str, str, int | None]] = []
|
|
48
|
+
for i, line in enumerate(fv.lines, 1):
|
|
49
|
+
m = _ASSIGN.match(line)
|
|
50
|
+
if m:
|
|
51
|
+
pairs.append((m.group(1), _unquote(m.group(2)), i))
|
|
52
|
+
out.extend(env_findings(ctx, fv, pairs, context=fv.name, autorun=fv.name == ".envrc", base_dirs=base))
|
|
53
|
+
return out
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Git-level execution points (TC-06): .git/config, pre-populated hooks, .gitmodules, .gitattributes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from devguard.analyzers.base import AnalysisContext, FileView
|
|
8
|
+
from devguard.analyzers.surfaces.common import assess_and_trigger
|
|
9
|
+
from devguard.core.models import Finding, Severity
|
|
10
|
+
from devguard.core.redact import make_excerpt
|
|
11
|
+
from devguard.rules.catalog import build_finding, rule
|
|
12
|
+
|
|
13
|
+
R_CONFIG_EXEC = rule(
|
|
14
|
+
"DG-GIT-CONFIG-EXEC-001",
|
|
15
|
+
"Repository git config runs a program",
|
|
16
|
+
"high",
|
|
17
|
+
"high",
|
|
18
|
+
"vcs",
|
|
19
|
+
"TC-06.1",
|
|
20
|
+
"The repository's own .git/config sets an option that makes git run a program (fsmonitor, hooksPath, "
|
|
21
|
+
"sshCommand, pager, editor, filter/diff drivers, credential helper, alias with '!'). A normal clone never "
|
|
22
|
+
"contains this; it appears when a repository is shipped as an archive or a prepared folder.",
|
|
23
|
+
"Developers set these locally for their own tooling; a repository received from someone else should not.",
|
|
24
|
+
"Do not run git commands (or open the folder in an editor with git integration) until this is removed.",
|
|
25
|
+
)
|
|
26
|
+
R_FSMONITOR = rule(
|
|
27
|
+
"DG-GIT-FSMONITOR-001",
|
|
28
|
+
"git fsmonitor hook runs on every git status",
|
|
29
|
+
"critical",
|
|
30
|
+
"high",
|
|
31
|
+
"vcs",
|
|
32
|
+
"TC-06.1",
|
|
33
|
+
"`core.fsmonitor` names a program that git executes on almost every command, including the `git status` "
|
|
34
|
+
"that IDEs and shell prompts run automatically when you open or cd into the folder.",
|
|
35
|
+
)
|
|
36
|
+
R_HOOK = rule(
|
|
37
|
+
"DG-GIT-HOOK-001",
|
|
38
|
+
"Pre-populated git hook",
|
|
39
|
+
"high",
|
|
40
|
+
"high",
|
|
41
|
+
"vcs",
|
|
42
|
+
"TC-06.2",
|
|
43
|
+
"The repository contains an active git hook. Cloning never copies hooks, so this one was planted in an "
|
|
44
|
+
"archive or prepared folder and will run on commit/checkout/merge.",
|
|
45
|
+
)
|
|
46
|
+
R_SUBMODULE = rule(
|
|
47
|
+
"DG-GIT-SUBMODULE-001",
|
|
48
|
+
"Suspicious git submodule definition",
|
|
49
|
+
"high",
|
|
50
|
+
"medium",
|
|
51
|
+
"vcs",
|
|
52
|
+
"TC-06.4",
|
|
53
|
+
"A .gitmodules entry uses an unusual URL scheme, an option-like value, or a path that escapes the "
|
|
54
|
+
"repository; these patterns have been used to exploit git itself.",
|
|
55
|
+
)
|
|
56
|
+
R_ATTRIBUTES = rule(
|
|
57
|
+
"DG-GIT-ATTRIBUTES-DRIVER-001",
|
|
58
|
+
".gitattributes references a filter or diff driver",
|
|
59
|
+
"info",
|
|
60
|
+
"medium",
|
|
61
|
+
"vcs",
|
|
62
|
+
"TC-06.3",
|
|
63
|
+
"Files are assigned a filter/diff/merge driver. Drivers only run if configured in git config; this is "
|
|
64
|
+
"dangerous only together with a repository-provided config.",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
_EXEC_KEYS = {
|
|
68
|
+
"core.hookspath",
|
|
69
|
+
"core.sshcommand",
|
|
70
|
+
"core.pager",
|
|
71
|
+
"core.editor",
|
|
72
|
+
"core.gitproxy",
|
|
73
|
+
"core.askpass",
|
|
74
|
+
"sequence.editor",
|
|
75
|
+
"gpg.program",
|
|
76
|
+
"gpg.ssh.program",
|
|
77
|
+
"gpg.x509.program",
|
|
78
|
+
"uploadpack.packobjectshook",
|
|
79
|
+
"diff.external",
|
|
80
|
+
"credential.helper",
|
|
81
|
+
"include.path",
|
|
82
|
+
"core.attributesfile",
|
|
83
|
+
"core.excludesfile",
|
|
84
|
+
}
|
|
85
|
+
_SECTION = re.compile(r'^\s*\[\s*([A-Za-z0-9.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*\]\s*(.*)$')
|
|
86
|
+
_KV = re.compile(r"^\s*([A-Za-z][A-Za-z0-9-]*)\s*(?:=\s*(.*))?$")
|
|
87
|
+
_SAMPLE_HOOK = re.compile(r"\.sample$")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _git_value(raw: str) -> str:
|
|
91
|
+
raw = raw.strip()
|
|
92
|
+
if raw.startswith('"') and raw.endswith('"') and len(raw) >= 2:
|
|
93
|
+
return raw[1:-1].replace('\\"', '"').replace("\\\\", "\\")
|
|
94
|
+
return re.split(r"\s[;#]", raw, maxsplit=1)[0].strip()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class GitAnalyzer:
|
|
98
|
+
name = "git"
|
|
99
|
+
|
|
100
|
+
def applies(self, fv: FileView) -> bool:
|
|
101
|
+
rel = f"/{fv.rel}"
|
|
102
|
+
return (
|
|
103
|
+
rel.endswith("/.git/config")
|
|
104
|
+
or ("/.git/hooks/" in rel and not _SAMPLE_HOOK.search(rel))
|
|
105
|
+
or fv.name in (".gitmodules", ".gitattributes")
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
109
|
+
rel = f"/{fv.rel}"
|
|
110
|
+
if "/.git/hooks/" in rel:
|
|
111
|
+
hook = fv.name
|
|
112
|
+
ctx.add_trigger("git-hook", fv.rel, line=1, label=hook, autorun=True, references=[fv.rel])
|
|
113
|
+
return [build_finding(R_HOOK.id, [fv.evidence_at_line(1 if fv.text else None)], detail=f"Hook: {hook}")]
|
|
114
|
+
if fv.text is None:
|
|
115
|
+
return []
|
|
116
|
+
if rel.endswith("/.git/config"):
|
|
117
|
+
return self._config(fv, ctx)
|
|
118
|
+
if fv.name == ".gitmodules":
|
|
119
|
+
return self._submodules(fv)
|
|
120
|
+
return self._attributes(fv)
|
|
121
|
+
|
|
122
|
+
def _config(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
123
|
+
out: list[Finding] = []
|
|
124
|
+
repo_root = fv.rel.rsplit(".git/config", 1)[0].rstrip("/")
|
|
125
|
+
section, subsection = "", ""
|
|
126
|
+
for i, line in enumerate(fv.lines, 1):
|
|
127
|
+
s = line.strip()
|
|
128
|
+
if not s or s.startswith(("#", ";")):
|
|
129
|
+
continue
|
|
130
|
+
m = _SECTION.match(s)
|
|
131
|
+
if m:
|
|
132
|
+
section, subsection = m.group(1).lower(), (m.group(2) or "")
|
|
133
|
+
s = m.group(3).strip()
|
|
134
|
+
if not s:
|
|
135
|
+
continue
|
|
136
|
+
kv = _KV.match(s)
|
|
137
|
+
if not kv:
|
|
138
|
+
continue
|
|
139
|
+
key, value = kv.group(1).lower(), _git_value(kv.group(2) or "true")
|
|
140
|
+
full = f"{section}.{key}"
|
|
141
|
+
name = f"{section}.{subsection}.{key}" if subsection else full
|
|
142
|
+
is_exec = (
|
|
143
|
+
full in _EXEC_KEYS
|
|
144
|
+
or (section in ("filter",) and key in ("clean", "smudge", "process"))
|
|
145
|
+
or (section == "diff" and key in ("textconv", "command"))
|
|
146
|
+
or (section == "merge" and key == "driver")
|
|
147
|
+
or (section == "alias" and value.startswith("!"))
|
|
148
|
+
or (section == "url" and key in ("insteadof", "pushinsteadof"))
|
|
149
|
+
or section == "includeif"
|
|
150
|
+
)
|
|
151
|
+
if full == "core.fsmonitor":
|
|
152
|
+
if value.lower() in ("true", "false", "0", "1", ""):
|
|
153
|
+
continue
|
|
154
|
+
out.append(
|
|
155
|
+
build_finding(
|
|
156
|
+
R_FSMONITOR.id,
|
|
157
|
+
[fv.evidence_at_line(i)],
|
|
158
|
+
detail=f"core.fsmonitor = {make_excerpt(value, limit=160)}",
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
out += assess_and_trigger(
|
|
162
|
+
ctx,
|
|
163
|
+
fv,
|
|
164
|
+
value,
|
|
165
|
+
i,
|
|
166
|
+
kind="git-fsmonitor",
|
|
167
|
+
label=name,
|
|
168
|
+
autorun=True,
|
|
169
|
+
context="git core.fsmonitor",
|
|
170
|
+
base_dirs=[repo_root],
|
|
171
|
+
)
|
|
172
|
+
continue
|
|
173
|
+
if not is_exec:
|
|
174
|
+
continue
|
|
175
|
+
sev = Severity.LOW if section == "url" else None
|
|
176
|
+
out.append(
|
|
177
|
+
build_finding(
|
|
178
|
+
R_CONFIG_EXEC.id,
|
|
179
|
+
[fv.evidence_at_line(i)],
|
|
180
|
+
severity=sev,
|
|
181
|
+
detail=f"{name} = {make_excerpt(value, limit=160)}",
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
if section != "url":
|
|
185
|
+
autorun = full in ("core.hookspath", "core.pager", "include.path") or section in ("filter",)
|
|
186
|
+
out += assess_and_trigger(
|
|
187
|
+
ctx,
|
|
188
|
+
fv,
|
|
189
|
+
value.lstrip("!"),
|
|
190
|
+
i,
|
|
191
|
+
kind="git-config",
|
|
192
|
+
label=name,
|
|
193
|
+
autorun=autorun,
|
|
194
|
+
context=f"git config {name}",
|
|
195
|
+
base_dirs=[repo_root],
|
|
196
|
+
)
|
|
197
|
+
return out
|
|
198
|
+
|
|
199
|
+
def _submodules(self, fv: FileView) -> list[Finding]:
|
|
200
|
+
out: list[Finding] = []
|
|
201
|
+
count = 0
|
|
202
|
+
for i, line in enumerate(fv.lines, 1):
|
|
203
|
+
kv = _KV.match(line.strip())
|
|
204
|
+
if not kv:
|
|
205
|
+
continue
|
|
206
|
+
key, value = kv.group(1).lower(), _git_value(kv.group(2) or "")
|
|
207
|
+
if key == "url":
|
|
208
|
+
count += 1
|
|
209
|
+
if re.match(r"^(ext::|fd::|file://|-)", value) or value.startswith("--"):
|
|
210
|
+
out.append(
|
|
211
|
+
build_finding(
|
|
212
|
+
R_SUBMODULE.id, [fv.evidence_at_line(i)], detail=f"url = {make_excerpt(value, limit=120)}"
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
elif key in ("path", "update", "branch"):
|
|
216
|
+
if (
|
|
217
|
+
value.startswith("-")
|
|
218
|
+
or ".." in value.split("/")
|
|
219
|
+
or value.startswith(".git")
|
|
220
|
+
or (key == "update" and value.startswith("!"))
|
|
221
|
+
):
|
|
222
|
+
out.append(
|
|
223
|
+
build_finding(
|
|
224
|
+
R_SUBMODULE.id,
|
|
225
|
+
[fv.evidence_at_line(i)],
|
|
226
|
+
severity=Severity.CRITICAL if key == "update" else None,
|
|
227
|
+
detail=f"{key} = {make_excerpt(value, limit=120)}",
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
return out
|
|
231
|
+
|
|
232
|
+
def _attributes(self, fv: FileView) -> list[Finding]:
|
|
233
|
+
for i, line in enumerate(fv.lines, 1):
|
|
234
|
+
if re.search(r"\b(filter|diff|merge)=(?!lfs\b)[\w-]+", line) and not line.lstrip().startswith("#"):
|
|
235
|
+
return [build_finding(R_ATTRIBUTES.id, [fv.evidence_at_line(i)])]
|
|
236
|
+
return []
|