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,240 @@
|
|
|
1
|
+
"""Declarative YAML content rules (ADR-004).
|
|
2
|
+
|
|
3
|
+
Rule match expressions:
|
|
4
|
+
{regex: "...", min_count: 1, ignorecase: false}
|
|
5
|
+
{all: [expr, ...]} every child must match (optionally within N lines, see `within`)
|
|
6
|
+
{any: [expr, ...]} at least one child matches
|
|
7
|
+
{not: expr} child must not match
|
|
8
|
+
`within: file` (default) or `within: "lines:N"` (top-level `all` only).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import fnmatch
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Iterable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from importlib import resources
|
|
18
|
+
from itertools import islice
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
from pydantic import BaseModel, Field, field_validator
|
|
23
|
+
|
|
24
|
+
from devguard.analyzers.base import AnalysisContext, FileView
|
|
25
|
+
from devguard.core.models import Confidence, Finding, Severity, Source
|
|
26
|
+
from devguard.rules.catalog import RuleMeta, build_finding, register
|
|
27
|
+
|
|
28
|
+
_MAX_MATCHES = 500
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RuleTests(BaseModel):
|
|
32
|
+
path: str | None = None
|
|
33
|
+
positive: list[str] = Field(min_length=1)
|
|
34
|
+
negative: list[str] = Field(min_length=1)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class YamlRule(BaseModel):
|
|
38
|
+
id: str
|
|
39
|
+
title: str
|
|
40
|
+
threat: list[str] = Field(min_length=1)
|
|
41
|
+
languages: list[str] = Field(default_factory=list)
|
|
42
|
+
paths: list[str] = Field(default_factory=list)
|
|
43
|
+
severity: Severity
|
|
44
|
+
confidence: Confidence
|
|
45
|
+
category: str = "capability"
|
|
46
|
+
match: dict[str, Any]
|
|
47
|
+
within: str = "file"
|
|
48
|
+
explanation: str
|
|
49
|
+
benign_context: str = ""
|
|
50
|
+
next_step: str = "Read the surrounding code. Do not run it. Check where data comes from and goes to."
|
|
51
|
+
tags: list[str] = Field(default_factory=list)
|
|
52
|
+
tests: RuleTests
|
|
53
|
+
|
|
54
|
+
@field_validator("id")
|
|
55
|
+
@classmethod
|
|
56
|
+
def _id_format(cls, v: str) -> str:
|
|
57
|
+
if not re.fullmatch(r"DG-[A-Z0-9]+(-[A-Z0-9]+)*-\d{3}", v):
|
|
58
|
+
raise ValueError(f"bad rule id {v!r}")
|
|
59
|
+
return v
|
|
60
|
+
|
|
61
|
+
@field_validator("within")
|
|
62
|
+
@classmethod
|
|
63
|
+
def _within_format(cls, v: str) -> str:
|
|
64
|
+
if v != "file" and not re.fullmatch(r"lines:\d+", v):
|
|
65
|
+
raise ValueError("within must be 'file' or 'lines:N'")
|
|
66
|
+
return v
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RulePack(BaseModel):
|
|
70
|
+
pack: str
|
|
71
|
+
rules: list[YamlRule]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class _Leaf:
|
|
76
|
+
pattern: re.Pattern[str]
|
|
77
|
+
min_count: int
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class _Node:
|
|
82
|
+
op: Literal["all", "any", "not", "leaf"]
|
|
83
|
+
children: list[_Node]
|
|
84
|
+
leaf: _Leaf | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _compile(expr: dict[str, Any]) -> _Node:
|
|
88
|
+
if "regex" in expr:
|
|
89
|
+
flags = re.MULTILINE | (re.IGNORECASE if expr.get("ignorecase") else 0)
|
|
90
|
+
return _Node("leaf", [], _Leaf(re.compile(expr["regex"], flags), int(expr.get("min_count", 1))))
|
|
91
|
+
for op in ("all", "any"):
|
|
92
|
+
if op in expr:
|
|
93
|
+
return _Node(op, [_compile(e) for e in expr[op]]) # type: ignore[arg-type]
|
|
94
|
+
if "not" in expr:
|
|
95
|
+
return _Node("not", [_compile(expr["not"])])
|
|
96
|
+
raise ValueError(f"unknown match expression: {expr}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
Span = tuple[int, int]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _eval(node: _Node, text: str) -> list[Span] | None:
|
|
103
|
+
if node.op == "leaf":
|
|
104
|
+
assert node.leaf is not None
|
|
105
|
+
spans = [m.span() for m in islice(node.leaf.pattern.finditer(text), _MAX_MATCHES)]
|
|
106
|
+
return spans if len(spans) >= node.leaf.min_count else None
|
|
107
|
+
if node.op == "not":
|
|
108
|
+
return [] if _eval(node.children[0], text) is None else None
|
|
109
|
+
results = [_eval(c, text) for c in node.children]
|
|
110
|
+
if node.op == "all":
|
|
111
|
+
if any(r is None for r in results):
|
|
112
|
+
return None
|
|
113
|
+
return [s for r in results if r for s in r]
|
|
114
|
+
hits = [r for r in results if r is not None]
|
|
115
|
+
return [s for r in hits for s in r] if hits else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class CompiledRule:
|
|
120
|
+
spec: YamlRule
|
|
121
|
+
root: _Node
|
|
122
|
+
window: int | None
|
|
123
|
+
|
|
124
|
+
def applies(self, rel: str, lang: str | None) -> bool:
|
|
125
|
+
if self.spec.languages and "any" not in self.spec.languages and lang not in self.spec.languages:
|
|
126
|
+
return False
|
|
127
|
+
if self.spec.paths:
|
|
128
|
+
name = rel.rsplit("/", 1)[-1]
|
|
129
|
+
return any(fnmatch.fnmatchcase(rel, p) or fnmatch.fnmatchcase(name, p) for p in self.spec.paths)
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
def evaluate(self, text: str, line_of: Any) -> list[Span] | None:
|
|
133
|
+
if self.window is None or self.root.op != "all":
|
|
134
|
+
return _eval(self.root, text)
|
|
135
|
+
per_child: list[list[Span]] = []
|
|
136
|
+
for child in self.root.children:
|
|
137
|
+
res = _eval(child, text)
|
|
138
|
+
if res is None:
|
|
139
|
+
return None
|
|
140
|
+
if child.op != "not":
|
|
141
|
+
per_child.append(res)
|
|
142
|
+
if not per_child:
|
|
143
|
+
return []
|
|
144
|
+
child_lines = [[(line_of(s[0]), s) for s in spans] for spans in per_child]
|
|
145
|
+
for anchor_line, anchor_span in child_lines[0]:
|
|
146
|
+
picked = [anchor_span]
|
|
147
|
+
for others in child_lines[1:]:
|
|
148
|
+
near = next((s for ln, s in others if abs(ln - anchor_line) <= self.window), None)
|
|
149
|
+
if near is None:
|
|
150
|
+
break
|
|
151
|
+
picked.append(near)
|
|
152
|
+
else:
|
|
153
|
+
return picked
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ContentRuleEngine:
|
|
158
|
+
name = "content-rules"
|
|
159
|
+
|
|
160
|
+
def __init__(self, rules: list[CompiledRule]) -> None:
|
|
161
|
+
self.rules = rules
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def load_builtin(cls) -> ContentRuleEngine:
|
|
165
|
+
packs: list[RulePack] = []
|
|
166
|
+
base = resources.files("devguard.rules").joinpath("content")
|
|
167
|
+
for entry in sorted(base.iterdir(), key=lambda e: e.name):
|
|
168
|
+
if entry.name.endswith((".yaml", ".yml")):
|
|
169
|
+
packs.append(RulePack.model_validate(yaml.safe_load(entry.read_text("utf-8"))))
|
|
170
|
+
return cls.from_packs(packs)
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def from_packs(cls, packs: Iterable[RulePack]) -> ContentRuleEngine:
|
|
174
|
+
compiled: list[CompiledRule] = []
|
|
175
|
+
for pack in packs:
|
|
176
|
+
for r in pack.rules:
|
|
177
|
+
window = int(r.within.split(":")[1]) if r.within.startswith("lines:") else None
|
|
178
|
+
compiled.append(CompiledRule(r, _compile(r.match), window))
|
|
179
|
+
register(
|
|
180
|
+
RuleMeta(
|
|
181
|
+
id=r.id,
|
|
182
|
+
title=r.title,
|
|
183
|
+
severity=r.severity,
|
|
184
|
+
confidence=r.confidence,
|
|
185
|
+
category=r.category,
|
|
186
|
+
threat_refs=tuple(r.threat),
|
|
187
|
+
explanation=r.explanation.strip(),
|
|
188
|
+
benign_context=r.benign_context.strip(),
|
|
189
|
+
next_step=r.next_step.strip(),
|
|
190
|
+
tags=tuple(r.tags),
|
|
191
|
+
origin=pack.pack,
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
return cls(compiled)
|
|
195
|
+
|
|
196
|
+
def applies(self, fv: FileView) -> bool:
|
|
197
|
+
return fv.text is not None
|
|
198
|
+
|
|
199
|
+
def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
200
|
+
assert fv.text is not None
|
|
201
|
+
text = fv.text
|
|
202
|
+
limit = ctx.config.limits.max_regex_text
|
|
203
|
+
if len(text) > limit:
|
|
204
|
+
ctx.warn("content-rules", f"only the first {limit} characters were checked by content rules", fv.rel)
|
|
205
|
+
text = text[:limit]
|
|
206
|
+
findings: list[Finding] = []
|
|
207
|
+
for rule in self.rules:
|
|
208
|
+
if not rule.applies(fv.rel, fv.lang):
|
|
209
|
+
continue
|
|
210
|
+
spans = rule.evaluate(text, lambda off: fv.line_col(off)[0])
|
|
211
|
+
if spans is None:
|
|
212
|
+
continue
|
|
213
|
+
findings.append(self._finding(rule, fv, spans))
|
|
214
|
+
return findings
|
|
215
|
+
|
|
216
|
+
def match_text(self, rule_id: str, rel: str, text: str, lang: str | None) -> bool:
|
|
217
|
+
"""Used by the rule self-tests: does `rule_id` fire on this text?"""
|
|
218
|
+
from devguard.core.safefs import FileRecord, Kind
|
|
219
|
+
|
|
220
|
+
rule = next(r for r in self.rules if r.spec.id == rule_id)
|
|
221
|
+
fv = FileView(FileRecord(rel, Kind.FILE, len(text), 0o644), text.encode(), text, lang, None, False)
|
|
222
|
+
if not rule.applies(rel, lang):
|
|
223
|
+
return False
|
|
224
|
+
return rule.evaluate(text, lambda off: fv.line_col(off)[0]) is not None
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _finding(rule: CompiledRule, fv: FileView, spans: list[Span]) -> Finding:
|
|
228
|
+
seen_lines: set[int] = set()
|
|
229
|
+
evidence = []
|
|
230
|
+
for start, end in sorted(spans):
|
|
231
|
+
line = fv.line_col(start)[0]
|
|
232
|
+
if line in seen_lines:
|
|
233
|
+
continue
|
|
234
|
+
seen_lines.add(line)
|
|
235
|
+
evidence.append(fv.evidence_at_offset(start, end))
|
|
236
|
+
if len(evidence) == 3:
|
|
237
|
+
break
|
|
238
|
+
if not evidence:
|
|
239
|
+
evidence.append(fv.evidence_at_line(None))
|
|
240
|
+
return build_finding(rule.spec.id, evidence, source=Source.RULE)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Inventory-level checks: symlinks, special files, deceptive names, vendored code (TC-12, TC-11.6)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import posixpath
|
|
6
|
+
import re
|
|
7
|
+
import unicodedata
|
|
8
|
+
from pathlib import PurePosixPath
|
|
9
|
+
|
|
10
|
+
from devguard.core.models import Evidence, Finding, Severity
|
|
11
|
+
from devguard.core.redact import visible
|
|
12
|
+
from devguard.core.safefs import Inventory
|
|
13
|
+
from devguard.rules.catalog import build_finding, rule
|
|
14
|
+
|
|
15
|
+
R_SYMLINK = rule(
|
|
16
|
+
"DG-FS-SYMLINK-ESCAPE-001",
|
|
17
|
+
"Symlink points outside the repository",
|
|
18
|
+
"high",
|
|
19
|
+
"high",
|
|
20
|
+
"filesystem",
|
|
21
|
+
"TC-12.1",
|
|
22
|
+
"A symbolic link resolves outside the project. Build steps or tools that read 'project files' through it "
|
|
23
|
+
"can read (and leak) files elsewhere on your machine.",
|
|
24
|
+
"Some monorepos link to sibling checkouts.",
|
|
25
|
+
)
|
|
26
|
+
R_SPECIAL = rule(
|
|
27
|
+
"DG-FS-SPECIAL-FILE-001",
|
|
28
|
+
"Special file in the repository",
|
|
29
|
+
"medium",
|
|
30
|
+
"high",
|
|
31
|
+
"filesystem",
|
|
32
|
+
"TC-12.4",
|
|
33
|
+
"The repository contains a FIFO, socket, or device file. These do not belong in source trees and can hang "
|
|
34
|
+
"or confuse tools that read them.",
|
|
35
|
+
)
|
|
36
|
+
R_NAME = rule(
|
|
37
|
+
"DG-FS-DECEPTIVE-NAME-001",
|
|
38
|
+
"Deceptive file or directory name",
|
|
39
|
+
"medium",
|
|
40
|
+
"high",
|
|
41
|
+
"filesystem",
|
|
42
|
+
"TC-12.2",
|
|
43
|
+
"A path contains invisible/bidi characters, trailing spaces or dots, or consists only of whitespace, "
|
|
44
|
+
"which makes it look like a different file or hides it in listings.",
|
|
45
|
+
)
|
|
46
|
+
R_VENDORED = rule(
|
|
47
|
+
"DG-FS-VENDORED-001",
|
|
48
|
+
"Dependency directories committed to the repository",
|
|
49
|
+
"info",
|
|
50
|
+
"high",
|
|
51
|
+
"deps",
|
|
52
|
+
"TC-11.6",
|
|
53
|
+
"Installed dependencies (node_modules, vendor, virtualenvs) are committed. They are a known place to hide "
|
|
54
|
+
"modified packages. They were inventoried but not deep-scanned (use --include-vendored); files they "
|
|
55
|
+
"contain that are referenced by a trigger were scanned anyway.",
|
|
56
|
+
)
|
|
57
|
+
R_EXEC_BIT = rule(
|
|
58
|
+
"DG-FS-EXEC-BIT-001",
|
|
59
|
+
"Data file marked executable",
|
|
60
|
+
"info",
|
|
61
|
+
"medium",
|
|
62
|
+
"filesystem",
|
|
63
|
+
"TC-12.6",
|
|
64
|
+
"A file with a data extension (image, font, JSON, text) has the executable permission bit set.",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
_SENSITIVE_TARGET = re.compile(
|
|
68
|
+
r"(^|/)(\.ssh|\.aws|\.gnupg|\.kube|\.docker|\.config|\.npmrc|\.pypirc|\.netrc|\.git-credentials|"
|
|
69
|
+
r"Library/Keychains|Login Data|Cookies|wallet)|^/etc/(shadow|passwd|sudoers)|^/proc/|^/dev/|^/root\b"
|
|
70
|
+
)
|
|
71
|
+
_DATA_EXT = (
|
|
72
|
+
".png",
|
|
73
|
+
".jpg",
|
|
74
|
+
".jpeg",
|
|
75
|
+
".gif",
|
|
76
|
+
".ico",
|
|
77
|
+
".svg",
|
|
78
|
+
".json",
|
|
79
|
+
".md",
|
|
80
|
+
".txt",
|
|
81
|
+
".woff",
|
|
82
|
+
".woff2",
|
|
83
|
+
".ttf",
|
|
84
|
+
".otf",
|
|
85
|
+
".css",
|
|
86
|
+
".csv",
|
|
87
|
+
".lock",
|
|
88
|
+
".yml",
|
|
89
|
+
".yaml",
|
|
90
|
+
".xml",
|
|
91
|
+
".pdf",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _deceptive(name: str) -> str | None:
|
|
96
|
+
if any(unicodedata.category(c) in ("Cf", "Cc") or 0xFE00 <= ord(c) <= 0xFE0F for c in name):
|
|
97
|
+
return "contains invisible or control characters"
|
|
98
|
+
if name != name.rstrip(" .") and name not in (".", ".."):
|
|
99
|
+
return "ends with a space or dot"
|
|
100
|
+
if not name.strip():
|
|
101
|
+
return "consists only of whitespace"
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def filesystem_findings(inv: Inventory) -> list[Finding]:
|
|
106
|
+
out: list[Finding] = []
|
|
107
|
+
for link in inv.symlinks:
|
|
108
|
+
if link.vendored: # virtualenvs and node_modules/.bin are full of outward symlinks
|
|
109
|
+
continue
|
|
110
|
+
target = link.symlink_target or ""
|
|
111
|
+
parent = posixpath.dirname(link.rel)
|
|
112
|
+
joined = posixpath.normpath(posixpath.join(parent, target)) if not target.startswith(("/", "~")) else target
|
|
113
|
+
escapes = target.startswith(("/", "~")) or joined.startswith("..") or re.match(r"^[A-Za-z]:[\\/]", target)
|
|
114
|
+
if escapes:
|
|
115
|
+
sev = Severity.CRITICAL if _SENSITIVE_TARGET.search(target) else None
|
|
116
|
+
out.append(
|
|
117
|
+
build_finding(R_SYMLINK.id, [Evidence(path=link.rel, excerpt=visible(f"-> {target}"))], severity=sev)
|
|
118
|
+
)
|
|
119
|
+
for sp in inv.special:
|
|
120
|
+
out.append(build_finding(R_SPECIAL.id, [Evidence(path=sp.rel)], detail=f"Type: {sp.special_type}."))
|
|
121
|
+
seen: set[str] = set()
|
|
122
|
+
for rel in [*inv.dirs, *(f.rel for f in inv.files), *(s.rel for s in inv.symlinks)]:
|
|
123
|
+
for part in PurePosixPath(rel).parts:
|
|
124
|
+
reason = _deceptive(part)
|
|
125
|
+
if reason and part not in seen:
|
|
126
|
+
seen.add(part)
|
|
127
|
+
sev = Severity.HIGH if "invisible" in reason else None
|
|
128
|
+
out.append(
|
|
129
|
+
build_finding(
|
|
130
|
+
R_NAME.id,
|
|
131
|
+
[Evidence(path=visible(rel))],
|
|
132
|
+
severity=sev,
|
|
133
|
+
detail=f"Name {visible(part)!r} {reason}.",
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
if inv.vendored_dirs:
|
|
137
|
+
shown = ", ".join(inv.vendored_dirs[:10]) + (" …" if len(inv.vendored_dirs) > 10 else "")
|
|
138
|
+
out.append(build_finding(R_VENDORED.id, [Evidence(path=inv.vendored_dirs[0])], detail=f"Directories: {shown}."))
|
|
139
|
+
for f in inv.files:
|
|
140
|
+
if f.executable_bit and f.rel.lower().endswith(_DATA_EXT):
|
|
141
|
+
out.append(build_finding(R_EXEC_BIT.id, [Evidence(path=f.rel)]))
|
|
142
|
+
return out
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Hidden content: bidi controls, invisible Unicode, off-screen code, encoded blobs (TC-08)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from collections import Counter
|
|
8
|
+
|
|
9
|
+
from devguard.analyzers.base import AnalysisContext, FileView
|
|
10
|
+
from devguard.analyzers.surfaces.ai_assistants import is_ai_instruction_file
|
|
11
|
+
from devguard.core.models import Evidence, Finding, Severity
|
|
12
|
+
from devguard.core.redact import make_excerpt
|
|
13
|
+
from devguard.rules.catalog import build_finding, rule
|
|
14
|
+
|
|
15
|
+
R_BIDI = rule(
|
|
16
|
+
"DG-HID-BIDI-001",
|
|
17
|
+
"Unicode bidirectional control characters",
|
|
18
|
+
"high",
|
|
19
|
+
"high",
|
|
20
|
+
"hidden",
|
|
21
|
+
"TC-08.2",
|
|
22
|
+
"The file contains bidi override/isolate characters (Trojan Source, CVE-2021-42574). They make code "
|
|
23
|
+
"display in a different order than the compiler or interpreter reads it.",
|
|
24
|
+
"Legitimate in right-to-left natural-language text; not in code or configuration.",
|
|
25
|
+
"View the file with invisible characters shown (e.g. `cat -A`), not in a normal editor.",
|
|
26
|
+
)
|
|
27
|
+
R_INVISIBLE = rule(
|
|
28
|
+
"DG-HID-INVISIBLE-001",
|
|
29
|
+
"Invisible Unicode characters",
|
|
30
|
+
"medium",
|
|
31
|
+
"medium",
|
|
32
|
+
"hidden",
|
|
33
|
+
"TC-08.3",
|
|
34
|
+
"The file contains zero-width, tag, or variation-selector characters that render as nothing. They can "
|
|
35
|
+
"smuggle instructions to AI assistants or encode payloads that reviewers cannot see.",
|
|
36
|
+
"Zero-width joiners inside emoji and in some scripts (Persian, Indic) are normal.",
|
|
37
|
+
)
|
|
38
|
+
R_OFFSCREEN = rule(
|
|
39
|
+
"DG-HID-OFFSCREEN-001",
|
|
40
|
+
"Code hidden off-screen after a run of whitespace",
|
|
41
|
+
"high",
|
|
42
|
+
"medium",
|
|
43
|
+
"hidden",
|
|
44
|
+
"TC-08.1",
|
|
45
|
+
"A line contains a long run of spaces/tabs followed by more code, pushing that code far to the right where "
|
|
46
|
+
"editors and code review tools do not show it. Malicious 'interview' repositories hide loaders this way.",
|
|
47
|
+
"Generated files and ASCII art occasionally contain long whitespace runs.",
|
|
48
|
+
"Inspect the full line (e.g. `sed -n 'Np' file | cut -c1-2000`).",
|
|
49
|
+
)
|
|
50
|
+
R_BLOB = rule(
|
|
51
|
+
"DG-HID-ENCODED-BLOB-001",
|
|
52
|
+
"Large encoded data blob in source",
|
|
53
|
+
"low",
|
|
54
|
+
"medium",
|
|
55
|
+
"obfuscation",
|
|
56
|
+
"TC-08.5",
|
|
57
|
+
"The file contains a long base64/hex string or a high-entropy literal. Encoded blobs are how payloads "
|
|
58
|
+
"are embedded in otherwise readable code.",
|
|
59
|
+
"Embedded images, fonts, certificates, and test vectors are common.",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
_BIDI = re.compile("[\u202a-\u202e\u2066-\u2069]")
|
|
63
|
+
_TAGS = re.compile("[\U000e0000-\U000e007f]")
|
|
64
|
+
_VS_SUPP = re.compile("[\U000e0100-\U000e01ef]")
|
|
65
|
+
_VS_BASIC = re.compile("[\ufe00-\ufe0e]") # FE0F (emoji presentation) is excluded on purpose
|
|
66
|
+
_ZERO_WIDTH = re.compile("[\u200b\u200c\u200d\u2060-\u2064\u180e\u115f\u1160\u3164\uffa0\u17b4\u17b5\u034f\u00ad]")
|
|
67
|
+
_WS_RUN = re.compile(r"\S([ \t]{150,})(\S)|^([ \t]{150,})(\S)")
|
|
68
|
+
_B64 = re.compile(r"[A-Za-z0-9+/]{200,}={0,2}")
|
|
69
|
+
_HEX = re.compile(r"\b(?:[0-9a-fA-F]{2}){128,}\b")
|
|
70
|
+
_QUOTED = re.compile(r"""(["'`])([^"'`\n\\]{120,})\1""")
|
|
71
|
+
_DATA_URI = re.compile(r"data:(image|font|application/font|audio|video)/[\w.+-]+;base64,")
|
|
72
|
+
_EXEC_NEARBY = re.compile(r"\b(eval|exec|Function|child_process|spawn|Invoke-Expression|iex|system|popen)\b")
|
|
73
|
+
|
|
74
|
+
_SKIP_SUFFIXES = (
|
|
75
|
+
".map",
|
|
76
|
+
".lock",
|
|
77
|
+
".svg",
|
|
78
|
+
".min.css",
|
|
79
|
+
".snap",
|
|
80
|
+
".pem",
|
|
81
|
+
".crt",
|
|
82
|
+
".cer",
|
|
83
|
+
".key",
|
|
84
|
+
".pub",
|
|
85
|
+
".asc",
|
|
86
|
+
".gpg",
|
|
87
|
+
".sum",
|
|
88
|
+
".ipynb",
|
|
89
|
+
)
|
|
90
|
+
_SKIP_NAMES = (
|
|
91
|
+
"package-lock.json",
|
|
92
|
+
"yarn.lock",
|
|
93
|
+
"pnpm-lock.yaml",
|
|
94
|
+
"poetry.lock",
|
|
95
|
+
"uv.lock",
|
|
96
|
+
"Cargo.lock",
|
|
97
|
+
"go.sum",
|
|
98
|
+
"composer.lock",
|
|
99
|
+
"Gemfile.lock",
|
|
100
|
+
"Pipfile.lock",
|
|
101
|
+
"bun.lock",
|
|
102
|
+
)
|
|
103
|
+
_PROSE_LANGS = {"markdown", "text", None}
|
|
104
|
+
_CODE_LANGS = {
|
|
105
|
+
"javascript",
|
|
106
|
+
"python",
|
|
107
|
+
"shell",
|
|
108
|
+
"powershell",
|
|
109
|
+
"batch",
|
|
110
|
+
"ruby",
|
|
111
|
+
"php",
|
|
112
|
+
"perl",
|
|
113
|
+
"go",
|
|
114
|
+
"java",
|
|
115
|
+
"kotlin",
|
|
116
|
+
"csharp",
|
|
117
|
+
"rust",
|
|
118
|
+
"lua",
|
|
119
|
+
"groovy",
|
|
120
|
+
"vbscript",
|
|
121
|
+
"json",
|
|
122
|
+
"yaml",
|
|
123
|
+
"toml",
|
|
124
|
+
"ini",
|
|
125
|
+
"xml",
|
|
126
|
+
"dockerfile",
|
|
127
|
+
"make",
|
|
128
|
+
"dotenv",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def shannon_entropy(s: str) -> float:
|
|
133
|
+
if not s:
|
|
134
|
+
return 0.0
|
|
135
|
+
counts = Counter(s)
|
|
136
|
+
n = len(s)
|
|
137
|
+
return -sum(c / n * math.log2(c / n) for c in counts.values())
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class HiddenContentAnalyzer:
|
|
141
|
+
name = "hidden-content"
|
|
142
|
+
|
|
143
|
+
def applies(self, fv: FileView) -> bool:
|
|
144
|
+
return fv.text is not None and not fv.lossy
|
|
145
|
+
|
|
146
|
+
def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
147
|
+
assert fv.text is not None
|
|
148
|
+
out: list[Finding] = []
|
|
149
|
+
text = fv.text
|
|
150
|
+
ai_file = is_ai_instruction_file(fv.rel)
|
|
151
|
+
code = fv.lang in _CODE_LANGS
|
|
152
|
+
out += self._bidi(fv, text, code, ai_file)
|
|
153
|
+
out += self._invisible(fv, text, code, ai_file)
|
|
154
|
+
if code or ai_file:
|
|
155
|
+
out += self._offscreen(fv)
|
|
156
|
+
if code and not fv.rel.endswith(_SKIP_SUFFIXES) and fv.name not in _SKIP_NAMES:
|
|
157
|
+
out += self._blobs(fv, text)
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
def _bidi(self, fv: FileView, text: str, code: bool, ai_file: bool) -> list[Finding]:
|
|
161
|
+
hits = list(_BIDI.finditer(text))
|
|
162
|
+
if not hits:
|
|
163
|
+
return []
|
|
164
|
+
sev = Severity.HIGH if (code or ai_file) else Severity.LOW
|
|
165
|
+
ev = [fv.evidence_at_offset(m.start()) for m in hits[:3]]
|
|
166
|
+
return [build_finding(R_BIDI.id, ev, severity=sev, detail=f"{len(hits)} occurrence(s).")]
|
|
167
|
+
|
|
168
|
+
def _invisible(self, fv: FileView, text: str, code: bool, ai_file: bool) -> list[Finding]:
|
|
169
|
+
tags = list(_TAGS.finditer(text))
|
|
170
|
+
vs_supp = list(_VS_SUPP.finditer(text))
|
|
171
|
+
zw = [m for m in _ZERO_WIDTH.finditer(text) if not _benign_zw(text, m.start())]
|
|
172
|
+
vs_basic = [m for m in _VS_BASIC.finditer(text) if not _after_symbol(text, m.start())]
|
|
173
|
+
total = len(tags) + len(vs_supp) + len(zw) + len(vs_basic)
|
|
174
|
+
if total == 0:
|
|
175
|
+
return []
|
|
176
|
+
parts = []
|
|
177
|
+
if tags:
|
|
178
|
+
decoded = "".join(chr(ord(m.group()) - 0xE0000) for m in tags[:200])
|
|
179
|
+
parts.append(f"{len(tags)} Unicode tag characters (decoded: {make_excerpt(decoded, limit=160)!r})")
|
|
180
|
+
if vs_supp:
|
|
181
|
+
parts.append(f"{len(vs_supp)} supplementary variation selectors")
|
|
182
|
+
if vs_basic:
|
|
183
|
+
parts.append(f"{len(vs_basic)} variation selectors outside emoji")
|
|
184
|
+
if zw:
|
|
185
|
+
parts.append(f"{len(zw)} zero-width/filler characters")
|
|
186
|
+
strong = bool(tags or len(vs_supp) >= 2)
|
|
187
|
+
if strong or ai_file:
|
|
188
|
+
sev = Severity.HIGH if (code or ai_file) else Severity.MEDIUM
|
|
189
|
+
elif code or total >= 10:
|
|
190
|
+
sev = Severity.MEDIUM
|
|
191
|
+
else:
|
|
192
|
+
sev = Severity.LOW
|
|
193
|
+
first = sorted([*tags, *vs_supp, *zw, *vs_basic], key=lambda m: m.start())[:3]
|
|
194
|
+
ev = [fv.evidence_at_offset(m.start()) for m in first]
|
|
195
|
+
return [build_finding(R_INVISIBLE.id, ev, severity=sev, detail="; ".join(parts) + ".")]
|
|
196
|
+
|
|
197
|
+
def _offscreen(self, fv: FileView) -> list[Finding]:
|
|
198
|
+
out: list[Finding] = []
|
|
199
|
+
for i, line in enumerate(fv.lines, 1):
|
|
200
|
+
if len(line) < 150:
|
|
201
|
+
continue
|
|
202
|
+
m = _WS_RUN.search(line)
|
|
203
|
+
if not m:
|
|
204
|
+
continue
|
|
205
|
+
gap_start = m.start(1) if m.group(1) else m.start(3)
|
|
206
|
+
gap = m.group(1) or m.group(3)
|
|
207
|
+
hidden_col = gap_start + len(gap) + 1
|
|
208
|
+
visible_part = line[:gap_start].strip()
|
|
209
|
+
hidden_part = line[gap_start + len(gap) :]
|
|
210
|
+
excerpt = make_excerpt(
|
|
211
|
+
f"{visible_part[:100]} <\u2026{len(gap)} whitespace chars\u2026> {hidden_part[:180]}", limit=320
|
|
212
|
+
)
|
|
213
|
+
out.append(
|
|
214
|
+
build_finding(
|
|
215
|
+
R_OFFSCREEN.id,
|
|
216
|
+
[Evidence(path=fv.rel, line_start=i, column=hidden_col, excerpt=excerpt)],
|
|
217
|
+
detail=f"Hidden content starts at column {hidden_col}.",
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
if len(out) >= 5:
|
|
221
|
+
break
|
|
222
|
+
return out
|
|
223
|
+
|
|
224
|
+
def _blobs(self, fv: FileView, text: str) -> list[Finding]:
|
|
225
|
+
candidates: list[tuple[int, int, str]] = []
|
|
226
|
+
for m in _B64.finditer(text):
|
|
227
|
+
prefix = text[max(0, m.start() - 60) : m.start()]
|
|
228
|
+
if _DATA_URI.search(prefix):
|
|
229
|
+
continue
|
|
230
|
+
candidates.append((m.start(), m.end(), "base64"))
|
|
231
|
+
for m in _HEX.finditer(text):
|
|
232
|
+
candidates.append((m.start(), m.end(), "hex"))
|
|
233
|
+
for m in _QUOTED.finditer(text):
|
|
234
|
+
s = m.group(2)
|
|
235
|
+
if " " not in s and shannon_entropy(s) >= 5.0:
|
|
236
|
+
candidates.append((m.start(2), m.end(2), "high-entropy"))
|
|
237
|
+
if not candidates:
|
|
238
|
+
return []
|
|
239
|
+
candidates.sort()
|
|
240
|
+
biggest = max(candidates, key=lambda c: c[1] - c[0])
|
|
241
|
+
exec_nearby = bool(_EXEC_NEARBY.search(text))
|
|
242
|
+
sev = Severity.MEDIUM if exec_nearby else Severity.LOW
|
|
243
|
+
kinds = sorted({c[2] for c in candidates})
|
|
244
|
+
ev = [fv.evidence_at_offset(biggest[0], biggest[1])]
|
|
245
|
+
return [
|
|
246
|
+
build_finding(
|
|
247
|
+
R_BLOB.id,
|
|
248
|
+
ev,
|
|
249
|
+
severity=sev,
|
|
250
|
+
detail=f"{len(candidates)} blob(s) ({', '.join(kinds)}); largest {biggest[1] - biggest[0]} chars"
|
|
251
|
+
f"{'; the file also uses dynamic execution APIs' if exec_nearby else ''}.",
|
|
252
|
+
)
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _benign_zw(text: str, pos: int) -> bool:
|
|
257
|
+
ch = text[pos]
|
|
258
|
+
if pos == 0 and ch == "\ufeff":
|
|
259
|
+
return True
|
|
260
|
+
prev = text[pos - 1] if pos > 0 else ""
|
|
261
|
+
nxt = text[pos + 1] if pos + 1 < len(text) else ""
|
|
262
|
+
if ch == "\u200d": # ZWJ in emoji sequences
|
|
263
|
+
return ord(prev or " ") > 0x2000 and ord(nxt or " ") > 0x2000
|
|
264
|
+
if ch == "\u200c": # ZWNJ in Persian/Indic text
|
|
265
|
+
return prev.isalpha() and ord(prev) > 0x0590 and (nxt.isalpha() or nxt == "")
|
|
266
|
+
if ch == "\u00ad": # soft hyphen inside words in prose
|
|
267
|
+
return prev.isalpha() and nxt.isalpha()
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _after_symbol(text: str, pos: int) -> bool:
|
|
272
|
+
prev = text[pos - 1] if pos > 0 else ""
|
|
273
|
+
return bool(prev) and (ord(prev) > 0x2000 or not prev.isascii())
|