vigilsec 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.
- vigil/__init__.py +2 -0
- vigil/cli.py +175 -0
- vigil/config.py +40 -0
- vigil/engine.py +68 -0
- vigil/reporter.py +119 -0
- vigil/rules/__init__.py +103 -0
- vigil/rules/agency.py +174 -0
- vigil/rules/base.py +48 -0
- vigil/rules/deps.py +122 -0
- vigil/rules/docker.py +145 -0
- vigil/rules/dockerfile.py +130 -0
- vigil/rules/iam.py +101 -0
- vigil/rules/k8s.py +75 -0
- vigil/rules/mcp_security.py +147 -0
- vigil/rules/nginx.py +85 -0
- vigil/rules/prompt_injection.py +185 -0
- vigil/rules/secrets.py +143 -0
- vigil/rules/shell.py +96 -0
- vigil/rules/trivy.py +62 -0
- vigil/telemetry.py +78 -0
- vigilsec-0.1.0.dist-info/METADATA +290 -0
- vigilsec-0.1.0.dist-info/RECORD +26 -0
- vigilsec-0.1.0.dist-info/WHEEL +5 -0
- vigilsec-0.1.0.dist-info/entry_points.txt +2 -0
- vigilsec-0.1.0.dist-info/licenses/LICENSE +73 -0
- vigilsec-0.1.0.dist-info/top_level.txt +1 -0
vigil/__init__.py
ADDED
vigil/cli.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""vigil scan <file|dir> [--format terminal|json|sarif] [--severity CRITICAL|HIGH|...]
|
|
2
|
+
vigil init [--global]
|
|
3
|
+
vigil feedback
|
|
4
|
+
|
|
5
|
+
Exit codes (scan):
|
|
6
|
+
0 — no findings
|
|
7
|
+
1 — advisory findings only (MEDIUM/LOW/INFO)
|
|
8
|
+
2 — CRITICAL or HIGH findings present (causes Claude Code to block the write)
|
|
9
|
+
"""
|
|
10
|
+
import argparse
|
|
11
|
+
import json as _json
|
|
12
|
+
import sys
|
|
13
|
+
import webbrowser
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .config import load_config
|
|
17
|
+
from .engine import Engine
|
|
18
|
+
from .reporter import report_terminal, report_json, report_sarif
|
|
19
|
+
from .rules import DEFAULT_RULES, Severity, SEVERITY_ORDER
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _find_hook_sh() -> Path | None:
|
|
23
|
+
"""Locate plugin/hook.sh relative to this file (works for editable installs)."""
|
|
24
|
+
here = Path(__file__).resolve().parent
|
|
25
|
+
candidates = [
|
|
26
|
+
here.parent.parent / "plugin" / "hook.sh", # editable: src/vigil/ → project root
|
|
27
|
+
Path.home() / ".vigil" / "plugin" / "hook.sh",
|
|
28
|
+
Path("/usr/local/share/vigil/hook.sh"),
|
|
29
|
+
]
|
|
30
|
+
return next((p for p in candidates if p.exists()), None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _run_init(global_install: bool) -> None:
|
|
34
|
+
hook_sh = _find_hook_sh()
|
|
35
|
+
if hook_sh is None:
|
|
36
|
+
print(
|
|
37
|
+
"vigil init: could not locate plugin/hook.sh.\n"
|
|
38
|
+
"Run from the vigil project directory or see plugin/README_INSTALL.md.",
|
|
39
|
+
file=sys.stderr,
|
|
40
|
+
)
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
|
|
43
|
+
# Ensure hook.sh is executable — git clones and zip downloads often strip the bit
|
|
44
|
+
if not hook_sh.stat().st_mode & 0o111:
|
|
45
|
+
hook_sh.chmod(hook_sh.stat().st_mode | 0o755)
|
|
46
|
+
print(f"Fixed execute permission on {hook_sh.name}")
|
|
47
|
+
|
|
48
|
+
settings_path = (
|
|
49
|
+
Path.home() / ".claude" / "settings.json"
|
|
50
|
+
if global_install
|
|
51
|
+
else Path.cwd() / ".claude" / "settings.json"
|
|
52
|
+
)
|
|
53
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
settings: dict = {}
|
|
56
|
+
if settings_path.exists():
|
|
57
|
+
try:
|
|
58
|
+
settings = _json.loads(settings_path.read_text())
|
|
59
|
+
except (_json.JSONDecodeError, OSError):
|
|
60
|
+
settings = {}
|
|
61
|
+
|
|
62
|
+
post_tool_use: list = settings.setdefault("hooks", {}).setdefault("PostToolUse", [])
|
|
63
|
+
for entry in post_tool_use:
|
|
64
|
+
for h in entry.get("hooks", []):
|
|
65
|
+
if Path(h.get("command", "")).name == hook_sh.name:
|
|
66
|
+
print(f"Vigil hook already installed in {settings_path}")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
post_tool_use.append({
|
|
70
|
+
"matcher": "Write|Edit|MultiEdit",
|
|
71
|
+
"hooks": [{"type": "command", "command": str(hook_sh)}],
|
|
72
|
+
})
|
|
73
|
+
settings_path.write_text(_json.dumps(settings, indent=2) + "\n")
|
|
74
|
+
print(f"Vigil hook installed → {settings_path}")
|
|
75
|
+
print("Reload Claude Code to activate.")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main() -> None:
|
|
79
|
+
parser = argparse.ArgumentParser(
|
|
80
|
+
prog="vigil",
|
|
81
|
+
description="AI coding security co-pilot — blocks insecure code at generation time.",
|
|
82
|
+
)
|
|
83
|
+
sub = parser.add_subparsers(dest="command")
|
|
84
|
+
|
|
85
|
+
scan_p = sub.add_parser("scan", help="Scan a file or directory")
|
|
86
|
+
scan_p.add_argument("path", type=Path)
|
|
87
|
+
scan_p.add_argument(
|
|
88
|
+
"--format", choices=["terminal", "json", "sarif"], default="terminal",
|
|
89
|
+
help="Output format (default: terminal)",
|
|
90
|
+
)
|
|
91
|
+
scan_p.add_argument(
|
|
92
|
+
"--severity", choices=[s.value for s in Severity], default=None,
|
|
93
|
+
help="Only report findings at this severity level or higher",
|
|
94
|
+
)
|
|
95
|
+
scan_p.add_argument("--no-color", action="store_true", help="Disable ANSI color output")
|
|
96
|
+
|
|
97
|
+
init_p = sub.add_parser("init", help="Wire the Vigil PostToolUse hook into .claude/settings.json")
|
|
98
|
+
init_p.add_argument(
|
|
99
|
+
"--global", dest="global_install", action="store_true",
|
|
100
|
+
help="Install into ~/.claude/settings.json (user-wide) instead of ./.claude/settings.json",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
sub.add_parser("feedback", help="Open the Vigil feedback & waitlist page")
|
|
104
|
+
|
|
105
|
+
args = parser.parse_args()
|
|
106
|
+
if args.command is None:
|
|
107
|
+
parser.print_help()
|
|
108
|
+
sys.exit(0)
|
|
109
|
+
|
|
110
|
+
if args.command == "feedback":
|
|
111
|
+
url = "https://thefwss.com/vigil"
|
|
112
|
+
print(f"Opening {url}")
|
|
113
|
+
print("Or email: prem.fwss@gmail.com")
|
|
114
|
+
webbrowser.open(url)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
if args.command == "init":
|
|
118
|
+
_run_init(args.global_install)
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
path = args.path.resolve()
|
|
122
|
+
config = load_config(path)
|
|
123
|
+
rules = [r for r in DEFAULT_RULES if r.id not in config.disabled_rules]
|
|
124
|
+
engine = Engine(rules=rules, telemetry_enabled=config.telemetry)
|
|
125
|
+
|
|
126
|
+
if path.is_file():
|
|
127
|
+
# Honour exclude_paths for single-file scans too (same logic scan_dir uses)
|
|
128
|
+
if config.exclude_paths and any(part in config.exclude_paths for part in path.parts):
|
|
129
|
+
results = {}
|
|
130
|
+
else:
|
|
131
|
+
findings = engine.scan(path)
|
|
132
|
+
results = {path: findings} if findings else {}
|
|
133
|
+
elif path.is_dir():
|
|
134
|
+
results = engine.scan_dir(path, extra_skip=set(config.exclude_paths))
|
|
135
|
+
else:
|
|
136
|
+
print(f"vigil: path not found: {path}", file=sys.stderr)
|
|
137
|
+
sys.exit(1)
|
|
138
|
+
|
|
139
|
+
all_findings = [f for fs in results.values() for f in fs]
|
|
140
|
+
|
|
141
|
+
# --severity flag takes priority over .vigilrc min_severity
|
|
142
|
+
effective_sev = args.severity or config.min_severity
|
|
143
|
+
if effective_sev:
|
|
144
|
+
min_order = SEVERITY_ORDER[Severity(effective_sev)]
|
|
145
|
+
all_findings = [f for f in all_findings if SEVERITY_ORDER[f.severity] <= min_order]
|
|
146
|
+
results = {
|
|
147
|
+
p: [f for f in fs if SEVERITY_ORDER[f.severity] <= min_order]
|
|
148
|
+
for p, fs in results.items()
|
|
149
|
+
}
|
|
150
|
+
results = {p: fs for p, fs in results.items() if fs}
|
|
151
|
+
|
|
152
|
+
if args.format == "json":
|
|
153
|
+
print(report_json(results))
|
|
154
|
+
elif args.format == "sarif":
|
|
155
|
+
print(report_sarif(results))
|
|
156
|
+
else:
|
|
157
|
+
for _path, file_findings in results.items():
|
|
158
|
+
report_terminal(file_findings, use_color=not args.no_color)
|
|
159
|
+
if all_findings:
|
|
160
|
+
_dim = "\033[2m" if not args.no_color else ""
|
|
161
|
+
_rst = "\033[0m" if not args.no_color else ""
|
|
162
|
+
print(
|
|
163
|
+
f"{_dim}── Vigil · feedback & waitlist → thefwss.com/vigil ──{_rst}",
|
|
164
|
+
file=sys.stderr,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if engine.blocking(all_findings):
|
|
168
|
+
sys.exit(2)
|
|
169
|
+
if all_findings:
|
|
170
|
+
sys.exit(1)
|
|
171
|
+
sys.exit(0)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
main()
|
vigil/config.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import tomllib
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class VigilConfig:
|
|
9
|
+
disabled_rules: list[str] = field(default_factory=list)
|
|
10
|
+
min_severity: str | None = None
|
|
11
|
+
exclude_paths: list[str] = field(default_factory=list)
|
|
12
|
+
telemetry: bool = True
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_config(start: Path) -> VigilConfig:
|
|
16
|
+
"""Walk up from start looking for .vigilrc (TOML format).
|
|
17
|
+
|
|
18
|
+
Searches the given path (or its parent if a file) and all ancestor
|
|
19
|
+
directories until the filesystem root. Returns defaults if not found.
|
|
20
|
+
"""
|
|
21
|
+
current = start if start.is_dir() else start.parent
|
|
22
|
+
while True:
|
|
23
|
+
candidate = current / ".vigilrc"
|
|
24
|
+
if candidate.is_file():
|
|
25
|
+
try:
|
|
26
|
+
with open(candidate, "rb") as f:
|
|
27
|
+
data = tomllib.load(f)
|
|
28
|
+
except Exception:
|
|
29
|
+
return VigilConfig()
|
|
30
|
+
return VigilConfig(
|
|
31
|
+
disabled_rules=data.get("disabled_rules", []),
|
|
32
|
+
min_severity=data.get("min_severity"),
|
|
33
|
+
exclude_paths=data.get("exclude_paths", []),
|
|
34
|
+
telemetry=data.get("telemetry", True),
|
|
35
|
+
)
|
|
36
|
+
parent = current.parent
|
|
37
|
+
if parent == current:
|
|
38
|
+
break
|
|
39
|
+
current = parent
|
|
40
|
+
return VigilConfig()
|
vigil/engine.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from .rules import DEFAULT_RULES, Finding, Rule, Severity, SEVERITY_ORDER
|
|
3
|
+
from . import telemetry as _telemetry
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Engine:
|
|
7
|
+
def __init__(self, rules: list[Rule] | None = None, telemetry_enabled: bool = True) -> None:
|
|
8
|
+
self.rules = rules if rules is not None else DEFAULT_RULES
|
|
9
|
+
self._telemetry = telemetry_enabled
|
|
10
|
+
|
|
11
|
+
def scan(self, path: Path) -> list[Finding]:
|
|
12
|
+
"""Scan a single file. Returns findings sorted by severity (CRITICAL first).
|
|
13
|
+
|
|
14
|
+
Lines containing '# vigil: ignore' are suppressed — same pattern as
|
|
15
|
+
'# noqa' (flake8) and '# nosec' (bandit).
|
|
16
|
+
"""
|
|
17
|
+
if not path.is_file():
|
|
18
|
+
return []
|
|
19
|
+
try:
|
|
20
|
+
source_lines = path.read_text(errors="ignore").splitlines()
|
|
21
|
+
except OSError:
|
|
22
|
+
source_lines = []
|
|
23
|
+
|
|
24
|
+
applicable = [r for r in self.rules if r.applies_to(path)]
|
|
25
|
+
findings: list[Finding] = []
|
|
26
|
+
for rule in applicable:
|
|
27
|
+
for f in rule.check(path):
|
|
28
|
+
if (
|
|
29
|
+
f.line
|
|
30
|
+
and f.line <= len(source_lines)
|
|
31
|
+
and "# vigil: ignore" in source_lines[f.line - 1]
|
|
32
|
+
):
|
|
33
|
+
continue
|
|
34
|
+
findings.append(f)
|
|
35
|
+
sorted_findings = sorted(findings, key=lambda f: SEVERITY_ORDER[f.severity])
|
|
36
|
+
_telemetry.record(sorted_findings, telemetry_enabled=self._telemetry)
|
|
37
|
+
return sorted_findings
|
|
38
|
+
|
|
39
|
+
def scan_dir(
|
|
40
|
+
self,
|
|
41
|
+
root: Path,
|
|
42
|
+
skip: set[str] | None = None,
|
|
43
|
+
extra_skip: set[str] | None = None,
|
|
44
|
+
) -> dict[Path, list[Finding]]:
|
|
45
|
+
"""Recursively scan all scannable files under root.
|
|
46
|
+
|
|
47
|
+
skip: replaces the default skip set when provided.
|
|
48
|
+
extra_skip: merged with the default skip set (use for .vigilrc exclude_paths).
|
|
49
|
+
"""
|
|
50
|
+
_default = {".venv", "venv", "node_modules", ".git", "build", "dist", "__pycache__", "Pods"}
|
|
51
|
+
_skip = (skip if skip is not None else _default) | (extra_skip or set())
|
|
52
|
+
results: dict[Path, list[Finding]] = {}
|
|
53
|
+
for path in root.rglob("*"):
|
|
54
|
+
if not path.is_file():
|
|
55
|
+
continue
|
|
56
|
+
if any(s in path.parts for s in _skip):
|
|
57
|
+
continue
|
|
58
|
+
if not any(r.applies_to(path) for r in self.rules):
|
|
59
|
+
continue
|
|
60
|
+
findings = self.scan(path)
|
|
61
|
+
if findings:
|
|
62
|
+
results[path] = findings
|
|
63
|
+
return results
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def blocking(findings: list[Finding]) -> bool:
|
|
67
|
+
"""True if any finding is CRITICAL or HIGH — should block the AI write."""
|
|
68
|
+
return any(f.severity in (Severity.CRITICAL, Severity.HIGH) for f in findings)
|
vigil/reporter.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from .rules import Finding, Severity
|
|
5
|
+
|
|
6
|
+
_SARIF_LEVEL = {
|
|
7
|
+
Severity.CRITICAL: "error",
|
|
8
|
+
Severity.HIGH: "error",
|
|
9
|
+
Severity.MEDIUM: "warning",
|
|
10
|
+
Severity.LOW: "note",
|
|
11
|
+
Severity.INFO: "note",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
_COLORS = {
|
|
15
|
+
Severity.CRITICAL: "\033[91m",
|
|
16
|
+
Severity.HIGH: "\033[93m",
|
|
17
|
+
Severity.MEDIUM: "\033[94m",
|
|
18
|
+
Severity.LOW: "\033[96m",
|
|
19
|
+
Severity.INFO: "\033[37m",
|
|
20
|
+
}
|
|
21
|
+
_RESET = "\033[0m"
|
|
22
|
+
_BOLD = "\033[1m"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _c(sev: Severity, text: str, use_color: bool) -> str:
|
|
26
|
+
return f"{_COLORS[sev]}{text}{_RESET}" if use_color else text
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def report_terminal(findings: list[Finding], use_color: bool = True) -> None:
|
|
30
|
+
if not findings:
|
|
31
|
+
return
|
|
32
|
+
blocking = [f for f in findings if f.severity in (Severity.CRITICAL, Severity.HIGH)]
|
|
33
|
+
advisory = [f for f in findings if f.severity not in (Severity.CRITICAL, Severity.HIGH)]
|
|
34
|
+
|
|
35
|
+
if blocking:
|
|
36
|
+
label = _c(Severity.CRITICAL, "BLOCKED", use_color)
|
|
37
|
+
print(f"\n{label} — {len(blocking)} CRITICAL/HIGH finding(s):", file=sys.stderr)
|
|
38
|
+
print("─" * 60, file=sys.stderr)
|
|
39
|
+
|
|
40
|
+
for f in findings:
|
|
41
|
+
loc = f"{f.file_path.name}:{f.line}" if f.line else f.file_path.name
|
|
42
|
+
sev_label = _c(f.severity, f"[{f.severity.value}]", use_color)
|
|
43
|
+
print(f"{sev_label} {f.rule_id} — {f.message}", file=sys.stderr)
|
|
44
|
+
print(f" at {loc}", file=sys.stderr)
|
|
45
|
+
if f.snippet:
|
|
46
|
+
print(f" → {f.snippet[:120]}", file=sys.stderr)
|
|
47
|
+
if f.fix:
|
|
48
|
+
print(f" fix: {f.fix}", file=sys.stderr)
|
|
49
|
+
print(file=sys.stderr)
|
|
50
|
+
|
|
51
|
+
if advisory:
|
|
52
|
+
print(
|
|
53
|
+
f"Advisory: {len(advisory)} MEDIUM/LOW/INFO finding(s) — not blocking.",
|
|
54
|
+
file=sys.stderr,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def report_sarif(results: dict[Path, list[Finding]], tool_version: str = "0.1.0") -> str:
|
|
59
|
+
all_findings = [f for fs in results.values() for f in fs]
|
|
60
|
+
rule_index: dict[str, int] = {}
|
|
61
|
+
for f in all_findings:
|
|
62
|
+
if f.rule_id not in rule_index:
|
|
63
|
+
rule_index[f.rule_id] = len(rule_index)
|
|
64
|
+
|
|
65
|
+
sarif_rules = [
|
|
66
|
+
{"id": rid, "name": rid.replace("-", ""), "shortDescription": {"text": rid}}
|
|
67
|
+
for rid in rule_index
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
sarif_results = []
|
|
71
|
+
for path, findings in results.items():
|
|
72
|
+
for f in findings:
|
|
73
|
+
sarif_results.append({
|
|
74
|
+
"ruleId": f.rule_id,
|
|
75
|
+
"ruleIndex": rule_index[f.rule_id],
|
|
76
|
+
"level": _SARIF_LEVEL[f.severity],
|
|
77
|
+
"message": {"text": f.message},
|
|
78
|
+
"locations": [{
|
|
79
|
+
"physicalLocation": {
|
|
80
|
+
"artifactLocation": {
|
|
81
|
+
"uri": str(path),
|
|
82
|
+
"uriBaseId": "%SRCROOT%",
|
|
83
|
+
},
|
|
84
|
+
"region": {"startLine": f.line or 1},
|
|
85
|
+
}
|
|
86
|
+
}],
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return json.dumps({
|
|
90
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
91
|
+
"version": "2.1.0",
|
|
92
|
+
"runs": [{
|
|
93
|
+
"tool": {
|
|
94
|
+
"driver": {
|
|
95
|
+
"name": "vigil",
|
|
96
|
+
"version": tool_version,
|
|
97
|
+
"informationUri": "https://github.com/fwss/vigil",
|
|
98
|
+
"rules": sarif_rules,
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"results": sarif_results,
|
|
102
|
+
}],
|
|
103
|
+
}, indent=2)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def report_json(results: dict[Path, list[Finding]]) -> str:
|
|
107
|
+
out = []
|
|
108
|
+
for path, findings in results.items():
|
|
109
|
+
for f in findings:
|
|
110
|
+
out.append({
|
|
111
|
+
"rule_id": f.rule_id,
|
|
112
|
+
"severity": f.severity.value,
|
|
113
|
+
"message": f.message,
|
|
114
|
+
"file": str(path),
|
|
115
|
+
"line": f.line,
|
|
116
|
+
"snippet": f.snippet,
|
|
117
|
+
"fix": f.fix,
|
|
118
|
+
})
|
|
119
|
+
return json.dumps(out, indent=2)
|
vigil/rules/__init__.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from .base import Finding, Rule, Severity, SEVERITY_ORDER
|
|
2
|
+
from .secrets import (
|
|
3
|
+
AwsAccessKeyRule,
|
|
4
|
+
HardcodedPasswordRule,
|
|
5
|
+
HardcodedApiKeyRule,
|
|
6
|
+
HardcodedTokenRule,
|
|
7
|
+
EvalInjectionRule,
|
|
8
|
+
ShellTrueRule,
|
|
9
|
+
OsSystemRule,
|
|
10
|
+
JwtSecretRule,
|
|
11
|
+
PemPrivateKeyRule,
|
|
12
|
+
CredentialUrlRule,
|
|
13
|
+
StripeLiveKeyRule,
|
|
14
|
+
SlackTokenRule,
|
|
15
|
+
GenericProviderKeyRule,
|
|
16
|
+
)
|
|
17
|
+
from .docker import DockerPortExposureRule, DockerComposeEnvSecretRule
|
|
18
|
+
from .dockerfile import DockerfileEnvSecretRule, DockerfileRootUserRule, DockerfileLatestTagRule
|
|
19
|
+
from .nginx import NginxSecurityHeadersRule
|
|
20
|
+
from .trivy import TrivyIacScanRule
|
|
21
|
+
from .deps import PipAuditRule, NpmAuditRule
|
|
22
|
+
from .k8s import K8sSecurityRule
|
|
23
|
+
from .iam import IamWildcardRule
|
|
24
|
+
from .agency import LlmShellExecRule, AutoApprovalBypassRule, UnboundedAgentLoopRule, LlmOutputFileWriteRule
|
|
25
|
+
from .mcp_security import McpToolPoisoningRule, McpDynamicDescriptionRule, McpShellToolRule
|
|
26
|
+
from .prompt_injection import (
|
|
27
|
+
UserInputInSystemPromptRule,
|
|
28
|
+
RawRequestAsLlmContentRule,
|
|
29
|
+
TemplateInjectionInPromptRule,
|
|
30
|
+
UnsanitizedToolOutputRule,
|
|
31
|
+
)
|
|
32
|
+
from .shell import ShellSecretInjectionRule
|
|
33
|
+
|
|
34
|
+
DEFAULT_RULES: list[Rule] = [
|
|
35
|
+
# Secrets — hardcoded credentials
|
|
36
|
+
AwsAccessKeyRule(),
|
|
37
|
+
HardcodedPasswordRule(),
|
|
38
|
+
HardcodedApiKeyRule(),
|
|
39
|
+
HardcodedTokenRule(),
|
|
40
|
+
JwtSecretRule(),
|
|
41
|
+
PemPrivateKeyRule(),
|
|
42
|
+
CredentialUrlRule(),
|
|
43
|
+
StripeLiveKeyRule(),
|
|
44
|
+
SlackTokenRule(),
|
|
45
|
+
GenericProviderKeyRule(),
|
|
46
|
+
# Code injection
|
|
47
|
+
EvalInjectionRule(),
|
|
48
|
+
ShellTrueRule(),
|
|
49
|
+
OsSystemRule(),
|
|
50
|
+
# Docker IaC
|
|
51
|
+
DockerPortExposureRule(),
|
|
52
|
+
DockerComposeEnvSecretRule(),
|
|
53
|
+
# Dockerfile hardening
|
|
54
|
+
DockerfileEnvSecretRule(),
|
|
55
|
+
DockerfileRootUserRule(),
|
|
56
|
+
DockerfileLatestTagRule(),
|
|
57
|
+
# nginx security
|
|
58
|
+
NginxSecurityHeadersRule(),
|
|
59
|
+
# Trivy IaC deep scan
|
|
60
|
+
TrivyIacScanRule(),
|
|
61
|
+
# Dependency CVE scanning
|
|
62
|
+
PipAuditRule(),
|
|
63
|
+
NpmAuditRule(),
|
|
64
|
+
# Kubernetes manifest security
|
|
65
|
+
K8sSecurityRule(),
|
|
66
|
+
# IAM policy wildcards
|
|
67
|
+
IamWildcardRule(),
|
|
68
|
+
# Excessive agency — AI agents without human oversight
|
|
69
|
+
LlmShellExecRule(),
|
|
70
|
+
AutoApprovalBypassRule(),
|
|
71
|
+
UnboundedAgentLoopRule(),
|
|
72
|
+
LlmOutputFileWriteRule(),
|
|
73
|
+
# MCP server security
|
|
74
|
+
McpToolPoisoningRule(),
|
|
75
|
+
McpDynamicDescriptionRule(),
|
|
76
|
+
McpShellToolRule(),
|
|
77
|
+
# Prompt injection in AI-calling code
|
|
78
|
+
UserInputInSystemPromptRule(),
|
|
79
|
+
RawRequestAsLlmContentRule(),
|
|
80
|
+
TemplateInjectionInPromptRule(),
|
|
81
|
+
UnsanitizedToolOutputRule(),
|
|
82
|
+
# Shell script secret leakage
|
|
83
|
+
ShellSecretInjectionRule(),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
__all__ = [
|
|
87
|
+
"Finding", "Rule", "Severity", "SEVERITY_ORDER", "DEFAULT_RULES",
|
|
88
|
+
"AwsAccessKeyRule", "HardcodedPasswordRule", "HardcodedApiKeyRule",
|
|
89
|
+
"HardcodedTokenRule", "EvalInjectionRule", "ShellTrueRule", "OsSystemRule",
|
|
90
|
+
"JwtSecretRule", "PemPrivateKeyRule", "CredentialUrlRule",
|
|
91
|
+
"StripeLiveKeyRule", "SlackTokenRule", "GenericProviderKeyRule",
|
|
92
|
+
"DockerPortExposureRule", "DockerComposeEnvSecretRule",
|
|
93
|
+
"DockerfileEnvSecretRule", "DockerfileRootUserRule", "DockerfileLatestTagRule",
|
|
94
|
+
"NginxSecurityHeadersRule",
|
|
95
|
+
"TrivyIacScanRule",
|
|
96
|
+
"PipAuditRule", "NpmAuditRule",
|
|
97
|
+
"K8sSecurityRule",
|
|
98
|
+
"IamWildcardRule",
|
|
99
|
+
"LlmShellExecRule", "AutoApprovalBypassRule", "UnboundedAgentLoopRule", "LlmOutputFileWriteRule",
|
|
100
|
+
"McpToolPoisoningRule", "McpDynamicDescriptionRule", "McpShellToolRule",
|
|
101
|
+
"UserInputInSystemPromptRule", "RawRequestAsLlmContentRule",
|
|
102
|
+
"TemplateInjectionInPromptRule", "UnsanitizedToolOutputRule",
|
|
103
|
+
]
|
vigil/rules/agency.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""VGL-A001–A004: Excessive agency — AI agents without human oversight.
|
|
2
|
+
|
|
3
|
+
These rules catch the patterns that make autonomous AI agents unsafe:
|
|
4
|
+
LLM output piped directly to shell, unbounded loops with no kill switch,
|
|
5
|
+
hardcoded auto-approval bypasses, and LLM content written to disk unchecked.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from .base import Finding, Rule, Severity
|
|
11
|
+
|
|
12
|
+
_AI_EXTS = {".py", ".ts", ".js"}
|
|
13
|
+
|
|
14
|
+
_LLM_IMPORT = re.compile(
|
|
15
|
+
r"(?:import|from|require)\s+.*(?:anthropic|openai|langchain|llama|cohere|mistral|google\.generativeai)",
|
|
16
|
+
re.IGNORECASE,
|
|
17
|
+
)
|
|
18
|
+
_LLM_CALL = re.compile(
|
|
19
|
+
r"(?:client\.messages\.create|openai\.chat\.completions|\.chat\.|llm\.invoke|llm\.run|chain\.run|agent\.run|model\.generate)",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LlmShellExecRule(Rule):
|
|
24
|
+
"""VGL-A001: LLM output piped directly into shell execution."""
|
|
25
|
+
id = "VGL-A001"
|
|
26
|
+
severity = Severity.CRITICAL
|
|
27
|
+
|
|
28
|
+
_PAT = re.compile(
|
|
29
|
+
r"(?:subprocess\.(?:run|call|Popen)|os\.system|os\.popen)\s*\([^)]*"
|
|
30
|
+
r"\b(?:response|completion|result|output|content|message)\b[^)]*\.(?:content|text|output)",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def applies_to(self, path: Path) -> bool:
|
|
34
|
+
return path.suffix in _AI_EXTS
|
|
35
|
+
|
|
36
|
+
def check(self, path: Path) -> list[Finding]:
|
|
37
|
+
try:
|
|
38
|
+
lines = path.read_text(errors="ignore").splitlines()
|
|
39
|
+
except OSError:
|
|
40
|
+
return []
|
|
41
|
+
findings = []
|
|
42
|
+
for i, line in enumerate(lines, 1):
|
|
43
|
+
if self._PAT.search(line):
|
|
44
|
+
findings.append(Finding(
|
|
45
|
+
rule_id=self.id,
|
|
46
|
+
severity=self.severity,
|
|
47
|
+
message="LLM output passed directly to shell — command injection risk",
|
|
48
|
+
file_path=path,
|
|
49
|
+
line=i,
|
|
50
|
+
snippet=line.strip()[:120],
|
|
51
|
+
fix=(
|
|
52
|
+
"Never pass LLM output directly to shell. Parse structured output, "
|
|
53
|
+
"validate against an allowlist, require human approval, or use a sandboxed executor."
|
|
54
|
+
),
|
|
55
|
+
))
|
|
56
|
+
return findings
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AutoApprovalBypassRule(Rule):
|
|
60
|
+
"""VGL-A002: Hardcoded auto-approval — disables human-in-the-loop gate."""
|
|
61
|
+
id = "VGL-A002"
|
|
62
|
+
severity = Severity.HIGH
|
|
63
|
+
|
|
64
|
+
_PAT = re.compile(
|
|
65
|
+
r"(?i)\b(auto_approve|skip_approval|bypass_approval|approve_all|skip_confirmation|skip_review)\s*=\s*True",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def applies_to(self, path: Path) -> bool:
|
|
69
|
+
return path.suffix in _AI_EXTS
|
|
70
|
+
|
|
71
|
+
def check(self, path: Path) -> list[Finding]:
|
|
72
|
+
try:
|
|
73
|
+
lines = path.read_text(errors="ignore").splitlines()
|
|
74
|
+
except OSError:
|
|
75
|
+
return []
|
|
76
|
+
findings = []
|
|
77
|
+
for i, line in enumerate(lines, 1):
|
|
78
|
+
stripped = line.strip()
|
|
79
|
+
if stripped.startswith("#"): # commented-out code is not live code
|
|
80
|
+
continue
|
|
81
|
+
if self._PAT.search(line):
|
|
82
|
+
findings.append(Finding(
|
|
83
|
+
rule_id=self.id,
|
|
84
|
+
severity=self.severity,
|
|
85
|
+
message=f"Auto-approval hardcoded — HITL gate bypassed: {stripped[:60]}",
|
|
86
|
+
file_path=path,
|
|
87
|
+
line=i,
|
|
88
|
+
snippet=stripped[:120],
|
|
89
|
+
fix=(
|
|
90
|
+
"Require explicit human approval for agent actions. Read approval state "
|
|
91
|
+
"from SSM or a runtime flag — never hardcode True in source."
|
|
92
|
+
),
|
|
93
|
+
))
|
|
94
|
+
return findings
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class UnboundedAgentLoopRule(Rule):
|
|
98
|
+
"""VGL-A003: Unbounded agentic loop — while True with LLM calls, no iteration limit."""
|
|
99
|
+
id = "VGL-A003"
|
|
100
|
+
severity = Severity.HIGH
|
|
101
|
+
|
|
102
|
+
_WHILE_TRUE = re.compile(r"^\s*while\s+True\s*:")
|
|
103
|
+
_ITER_LIMIT = re.compile(
|
|
104
|
+
r"\b(max_iterations|max_turns|max_steps|max_loops|iteration_limit|step_limit)\b",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def applies_to(self, path: Path) -> bool:
|
|
108
|
+
return path.suffix in _AI_EXTS
|
|
109
|
+
|
|
110
|
+
def check(self, path: Path) -> list[Finding]:
|
|
111
|
+
try:
|
|
112
|
+
text = path.read_text(errors="ignore")
|
|
113
|
+
except OSError:
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
# Only flag when the file actually makes LLM calls
|
|
117
|
+
if not _LLM_IMPORT.search(text) and not _LLM_CALL.search(text):
|
|
118
|
+
return []
|
|
119
|
+
if self._ITER_LIMIT.search(text):
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
lines = text.splitlines()
|
|
123
|
+
findings = []
|
|
124
|
+
for i, line in enumerate(lines, 1):
|
|
125
|
+
if self._WHILE_TRUE.match(line):
|
|
126
|
+
findings.append(Finding(
|
|
127
|
+
rule_id=self.id,
|
|
128
|
+
severity=self.severity,
|
|
129
|
+
message="Unbounded 'while True' loop with LLM calls — no iteration limit found",
|
|
130
|
+
file_path=path,
|
|
131
|
+
line=i,
|
|
132
|
+
snippet=line.strip(),
|
|
133
|
+
fix=(
|
|
134
|
+
"Add max_iterations and a kill-switch check at the top of the loop. "
|
|
135
|
+
"Read the kill switch from SSM so it can be toggled without a deploy."
|
|
136
|
+
),
|
|
137
|
+
))
|
|
138
|
+
return findings
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class LlmOutputFileWriteRule(Rule):
|
|
142
|
+
"""VGL-A004: LLM response written directly to disk without validation."""
|
|
143
|
+
id = "VGL-A004"
|
|
144
|
+
severity = Severity.HIGH
|
|
145
|
+
|
|
146
|
+
_PAT = re.compile(
|
|
147
|
+
r"(?:write_text|\.write)\s*\([^)]*\b(?:llm_output|ai_output|model_output|"
|
|
148
|
+
r"response\.content|completion\.content|message\.content|result\.text)\b",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def applies_to(self, path: Path) -> bool:
|
|
152
|
+
return path.suffix in _AI_EXTS
|
|
153
|
+
|
|
154
|
+
def check(self, path: Path) -> list[Finding]:
|
|
155
|
+
try:
|
|
156
|
+
lines = path.read_text(errors="ignore").splitlines()
|
|
157
|
+
except OSError:
|
|
158
|
+
return []
|
|
159
|
+
findings = []
|
|
160
|
+
for i, line in enumerate(lines, 1):
|
|
161
|
+
if self._PAT.search(line):
|
|
162
|
+
findings.append(Finding(
|
|
163
|
+
rule_id=self.id,
|
|
164
|
+
severity=self.severity,
|
|
165
|
+
message="LLM output written to filesystem without validation",
|
|
166
|
+
file_path=path,
|
|
167
|
+
line=i,
|
|
168
|
+
snippet=line.strip()[:120],
|
|
169
|
+
fix=(
|
|
170
|
+
"Validate and sanitize LLM output before writing to disk. "
|
|
171
|
+
"Consider schema validation, content-type checks, or a human review step."
|
|
172
|
+
),
|
|
173
|
+
))
|
|
174
|
+
return findings
|