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.
Files changed (67) hide show
  1. devguard/__init__.py +5 -0
  2. devguard/ai/__init__.py +0 -0
  3. devguard/ai/investigator.py +380 -0
  4. devguard/ai/prompts.py +113 -0
  5. devguard/ai/providers/__init__.py +0 -0
  6. devguard/ai/providers/anthropic.py +163 -0
  7. devguard/ai/providers/openai_compat.py +157 -0
  8. devguard/ai/registry.py +160 -0
  9. devguard/ai/run.py +119 -0
  10. devguard/ai/sdk.py +102 -0
  11. devguard/ai/testing.py +126 -0
  12. devguard/ai/tools.py +389 -0
  13. devguard/analyzers/__init__.py +0 -0
  14. devguard/analyzers/base.py +135 -0
  15. devguard/analyzers/carriers.py +345 -0
  16. devguard/analyzers/command.py +343 -0
  17. devguard/analyzers/content.py +240 -0
  18. devguard/analyzers/filesystem.py +142 -0
  19. devguard/analyzers/hidden.py +273 -0
  20. devguard/analyzers/imports.py +102 -0
  21. devguard/analyzers/jsonc.py +221 -0
  22. devguard/analyzers/surfaces/__init__.py +0 -0
  23. devguard/analyzers/surfaces/ai_assistants.py +318 -0
  24. devguard/analyzers/surfaces/autoload.py +142 -0
  25. devguard/analyzers/surfaces/common.py +242 -0
  26. devguard/analyzers/surfaces/deps.py +159 -0
  27. devguard/analyzers/surfaces/devcontainer.py +195 -0
  28. devguard/analyzers/surfaces/env.py +53 -0
  29. devguard/analyzers/surfaces/git.py +236 -0
  30. devguard/analyzers/surfaces/npm.py +304 -0
  31. devguard/analyzers/surfaces/python_pkg.py +138 -0
  32. devguard/analyzers/surfaces/vscode.py +515 -0
  33. devguard/cli.py +302 -0
  34. devguard/core/__init__.py +0 -0
  35. devguard/core/config.py +147 -0
  36. devguard/core/filetype.py +313 -0
  37. devguard/core/models.py +195 -0
  38. devguard/core/redact.py +72 -0
  39. devguard/core/safefs.py +280 -0
  40. devguard/core/scoring.py +60 -0
  41. devguard/core/service.py +211 -0
  42. devguard/correlate/__init__.py +0 -0
  43. devguard/correlate/chains.py +160 -0
  44. devguard/mcp/__init__.py +0 -0
  45. devguard/mcp/server.py +442 -0
  46. devguard/report/__init__.py +0 -0
  47. devguard/report/escape.py +31 -0
  48. devguard/report/markdown.py +242 -0
  49. devguard/report/sarif.py +96 -0
  50. devguard/report/terminal.py +47 -0
  51. devguard/report/writer.py +52 -0
  52. devguard/rules/__init__.py +0 -0
  53. devguard/rules/catalog.py +96 -0
  54. devguard/rules/content/ai_instructions.yaml +111 -0
  55. devguard/rules/content/cross_language.yaml +228 -0
  56. devguard/rules/content/docs.yaml +63 -0
  57. devguard/rules/content/javascript.yaml +171 -0
  58. devguard/rules/content/obfuscation.yaml +138 -0
  59. devguard/rules/content/python.yaml +144 -0
  60. devguard/rules/content/shell.yaml +109 -0
  61. devguard/rules/content/windows.yaml +95 -0
  62. devguard/rules/data/vscode_settings.yaml +81 -0
  63. devguard_scanner-0.1.0.dist-info/METADATA +84 -0
  64. devguard_scanner-0.1.0.dist-info/RECORD +67 -0
  65. devguard_scanner-0.1.0.dist-info/WHEEL +4 -0
  66. devguard_scanner-0.1.0.dist-info/entry_points.txt +7 -0
  67. 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())