wrg-devguard 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.
- wrg_devguard/__init__.py +6 -0
- wrg_devguard/cli.py +423 -0
- wrg_devguard/common.py +67 -0
- wrg_devguard/policy.py +221 -0
- wrg_devguard/secrets.py +110 -0
- wrg_devguard-0.1.0.dist-info/METADATA +197 -0
- wrg_devguard-0.1.0.dist-info/RECORD +11 -0
- wrg_devguard-0.1.0.dist-info/WHEEL +5 -0
- wrg_devguard-0.1.0.dist-info/entry_points.txt +2 -0
- wrg_devguard-0.1.0.dist-info/licenses/LICENSE +21 -0
- wrg_devguard-0.1.0.dist-info/top_level.txt +1 -0
wrg_devguard/__init__.py
ADDED
wrg_devguard/cli.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import fnmatch
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .policy import lint_policy, load_policy
|
|
11
|
+
from .secrets import scan_secrets
|
|
12
|
+
from .common import Finding, write_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
parser = argparse.ArgumentParser(prog="wrg-devguard")
|
|
17
|
+
sub = parser.add_subparsers(dest="command")
|
|
18
|
+
|
|
19
|
+
lint = sub.add_parser("lint-policy", help="scan content against policy deny patterns")
|
|
20
|
+
lint.add_argument("--path", default=".", help="scan root path")
|
|
21
|
+
lint.add_argument("--policy", default=None, help="policy file (json/toml/yaml)")
|
|
22
|
+
lint.add_argument("--profile", choices=["baseline", "strict"], default=None, help="use a predefined policy profile")
|
|
23
|
+
lint.add_argument("--paths-file", default=None, dest="paths_file", help="newline-delimited relative file list")
|
|
24
|
+
lint.add_argument("--allowlist", default=None, help="allowlist file path (json)")
|
|
25
|
+
lint.add_argument("--json-out", default=None, dest="json_out", help="write JSON report")
|
|
26
|
+
lint.add_argument("--fail-on", choices=["error", "warning"], default="error")
|
|
27
|
+
|
|
28
|
+
sec = sub.add_parser("scan-secrets", help="scan for common secret leak patterns")
|
|
29
|
+
sec.add_argument("--path", default=".", help="scan root path")
|
|
30
|
+
sec.add_argument("--paths-file", default=None, dest="paths_file", help="newline-delimited relative file list")
|
|
31
|
+
sec.add_argument("--allowlist", default=None, help="allowlist file path (json)")
|
|
32
|
+
sec.add_argument("--json-out", default=None, dest="json_out", help="write JSON report")
|
|
33
|
+
sec.add_argument("--fail-on", choices=["error", "warning"], default="error")
|
|
34
|
+
|
|
35
|
+
check = sub.add_parser("check", help="run lint-policy + scan-secrets")
|
|
36
|
+
check.add_argument("--path", default=".", help="scan root path")
|
|
37
|
+
check.add_argument("--policy", default=None, help="policy file (json/toml/yaml)")
|
|
38
|
+
check.add_argument("--profile", choices=["baseline", "strict"], default=None, help="use a predefined policy profile")
|
|
39
|
+
check.add_argument("--paths-file", default=None, dest="paths_file", help="newline-delimited relative file list")
|
|
40
|
+
check.add_argument("--allowlist", default=None, help="allowlist file path (json)")
|
|
41
|
+
check.add_argument("--json-out", default=None, dest="json_out", help="write JSON report")
|
|
42
|
+
check.add_argument("--fail-on", choices=["error", "warning"], default="error")
|
|
43
|
+
|
|
44
|
+
prof = sub.add_parser("profiles", help="list available policy profiles")
|
|
45
|
+
prof.add_argument("--path", default=".", help="repo root to inspect")
|
|
46
|
+
|
|
47
|
+
bandit_p = sub.add_parser("bandit", help="run bandit security scanner on Python source")
|
|
48
|
+
bandit_p.add_argument("--path", default=".", help="scan root path")
|
|
49
|
+
bandit_p.add_argument("--app", help="scan specific app under apps/<app>/")
|
|
50
|
+
bandit_p.add_argument("--severity", choices=["low", "medium", "high"], default="medium",
|
|
51
|
+
help="minimum severity to report (default: medium)")
|
|
52
|
+
bandit_p.add_argument("--json-out", default=None, dest="json_out", help="write JSON report")
|
|
53
|
+
|
|
54
|
+
return parser
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _should_fail(findings: list[Finding], fail_on: str) -> bool:
|
|
58
|
+
if fail_on == "warning":
|
|
59
|
+
return len(findings) > 0
|
|
60
|
+
return any(item.severity.upper() == "ERROR" for item in findings)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _safe_finding_dict(item: Finding) -> dict[str, Any]:
|
|
64
|
+
return {
|
|
65
|
+
"check": item.check,
|
|
66
|
+
"rule_id": item.rule_id,
|
|
67
|
+
"severity": item.severity,
|
|
68
|
+
"message": item.message,
|
|
69
|
+
"file": item.file,
|
|
70
|
+
"line": item.line,
|
|
71
|
+
"column": item.column,
|
|
72
|
+
# Never persist potentially sensitive matched text.
|
|
73
|
+
"snippet": "[REDACTED]",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _safe_finding_mapping(item: dict[str, Any]) -> dict[str, Any]:
|
|
78
|
+
return {
|
|
79
|
+
"check": str(item.get("check", "")),
|
|
80
|
+
"rule_id": str(item.get("rule_id", "")),
|
|
81
|
+
"severity": str(item.get("severity", "")),
|
|
82
|
+
"message": str(item.get("message", "")),
|
|
83
|
+
"file": str(item.get("file", "")),
|
|
84
|
+
"line": int(item.get("line", 0)),
|
|
85
|
+
"column": int(item.get("column", 0)),
|
|
86
|
+
"snippet": "[REDACTED]",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _sanitize_suppressed_payload(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
91
|
+
sanitized: list[dict[str, Any]] = []
|
|
92
|
+
for item in items:
|
|
93
|
+
if not isinstance(item, dict):
|
|
94
|
+
continue
|
|
95
|
+
entry: dict[str, Any] = dict(item)
|
|
96
|
+
finding_obj = entry.get("finding")
|
|
97
|
+
if isinstance(finding_obj, dict):
|
|
98
|
+
entry["finding"] = _safe_finding_mapping(finding_obj)
|
|
99
|
+
sanitized.append(entry)
|
|
100
|
+
return sanitized
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _as_report(
|
|
104
|
+
command: str,
|
|
105
|
+
root: Path,
|
|
106
|
+
findings: list[Finding],
|
|
107
|
+
fail_on: str,
|
|
108
|
+
suppressed: list[dict] | None = None,
|
|
109
|
+
) -> dict:
|
|
110
|
+
suppressed_items = _sanitize_suppressed_payload(suppressed or [])
|
|
111
|
+
error_count = sum(1 for item in findings if item.severity.upper() == "ERROR")
|
|
112
|
+
warning_count = sum(1 for item in findings if item.severity.upper() == "WARNING")
|
|
113
|
+
status = "FAIL" if _should_fail(findings, fail_on) else "PASS"
|
|
114
|
+
return {
|
|
115
|
+
"schema_version": "wrg_devguard.v1",
|
|
116
|
+
"command": command,
|
|
117
|
+
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
118
|
+
"scan_root": str(root),
|
|
119
|
+
"status": status,
|
|
120
|
+
"summary": {
|
|
121
|
+
"total_findings": len(findings),
|
|
122
|
+
"error": error_count,
|
|
123
|
+
"warning": warning_count,
|
|
124
|
+
"suppressed": len(suppressed_items),
|
|
125
|
+
"fail_on": fail_on,
|
|
126
|
+
},
|
|
127
|
+
"findings": [_safe_finding_dict(item) for item in findings],
|
|
128
|
+
"suppressed": suppressed_items,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _print_summary(command: str) -> None:
|
|
133
|
+
print(f"{command}: completed (finding details are redacted by design)")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _safe_json_report(command: str, root: Path) -> dict[str, Any]:
|
|
137
|
+
return {
|
|
138
|
+
"schema_version": "wrg_devguard.v1",
|
|
139
|
+
"command": command,
|
|
140
|
+
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
141
|
+
"scan_root": str(root),
|
|
142
|
+
"status": "REDACTED",
|
|
143
|
+
"summary": {
|
|
144
|
+
"total_findings": "REDACTED",
|
|
145
|
+
"error": "REDACTED",
|
|
146
|
+
"warning": "REDACTED",
|
|
147
|
+
"suppressed": "REDACTED",
|
|
148
|
+
"fail_on": "REDACTED",
|
|
149
|
+
},
|
|
150
|
+
"findings": [],
|
|
151
|
+
"suppressed": [],
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _normalize_rel_path(value: str) -> str:
|
|
156
|
+
text = value.strip().replace("\\", "/")
|
|
157
|
+
if text.startswith("./"):
|
|
158
|
+
text = text[2:]
|
|
159
|
+
return PurePosixPath(text).as_posix()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _load_allowed_files(paths_file: str | None, scan_root: Path) -> set[str] | None:
|
|
163
|
+
if not paths_file:
|
|
164
|
+
return None
|
|
165
|
+
candidate = Path(paths_file).resolve()
|
|
166
|
+
if not candidate.exists():
|
|
167
|
+
raise ValueError(f"paths file not found: {candidate}")
|
|
168
|
+
|
|
169
|
+
allowed: set[str] = set()
|
|
170
|
+
for raw in candidate.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
171
|
+
line = raw.strip()
|
|
172
|
+
if not line or line.startswith("#"):
|
|
173
|
+
continue
|
|
174
|
+
path_value = Path(line)
|
|
175
|
+
if path_value.is_absolute():
|
|
176
|
+
try:
|
|
177
|
+
normalized = path_value.resolve().relative_to(scan_root).as_posix()
|
|
178
|
+
except Exception:
|
|
179
|
+
continue
|
|
180
|
+
else:
|
|
181
|
+
normalized = _normalize_rel_path(line)
|
|
182
|
+
allowed.add(normalized)
|
|
183
|
+
return allowed
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _load_allowlist(allowlist_arg: str | None, scan_root: Path) -> list[dict]:
|
|
187
|
+
if allowlist_arg:
|
|
188
|
+
candidate = Path(allowlist_arg).resolve()
|
|
189
|
+
else:
|
|
190
|
+
candidate = scan_root / ".wrg" / "allowlist.json"
|
|
191
|
+
if not candidate.exists():
|
|
192
|
+
return []
|
|
193
|
+
try:
|
|
194
|
+
payload = json.loads(candidate.read_text(encoding="utf-8"))
|
|
195
|
+
except json.JSONDecodeError as exc:
|
|
196
|
+
raise ValueError(f"allowlist file is not valid json: {candidate}") from exc
|
|
197
|
+
if not isinstance(payload, dict):
|
|
198
|
+
return []
|
|
199
|
+
rules = payload.get("rules")
|
|
200
|
+
if not isinstance(rules, list):
|
|
201
|
+
return []
|
|
202
|
+
return [item for item in rules if isinstance(item, dict)]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _finding_matches_rule(finding: Finding, rule: dict) -> bool:
|
|
206
|
+
check = rule.get("check")
|
|
207
|
+
rule_id = rule.get("rule_id")
|
|
208
|
+
severity = rule.get("severity")
|
|
209
|
+
file_pattern = rule.get("file")
|
|
210
|
+
snippet_contains = rule.get("snippet_contains")
|
|
211
|
+
|
|
212
|
+
if isinstance(check, str) and check.strip() and finding.check != check.strip():
|
|
213
|
+
return False
|
|
214
|
+
if isinstance(rule_id, str) and rule_id.strip() and finding.rule_id != rule_id.strip():
|
|
215
|
+
return False
|
|
216
|
+
if isinstance(severity, str) and severity.strip() and finding.severity.upper() != severity.strip().upper():
|
|
217
|
+
return False
|
|
218
|
+
if isinstance(file_pattern, str) and file_pattern.strip():
|
|
219
|
+
if not fnmatch.fnmatch(finding.file, file_pattern.strip()):
|
|
220
|
+
return False
|
|
221
|
+
if isinstance(snippet_contains, str) and snippet_contains.strip():
|
|
222
|
+
if snippet_contains not in finding.snippet:
|
|
223
|
+
return False
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _apply_allowlist(findings: list[Finding], allowlist_rules: list[dict]) -> tuple[list[Finding], list[dict]]:
|
|
228
|
+
if not allowlist_rules:
|
|
229
|
+
return findings, []
|
|
230
|
+
active: list[Finding] = []
|
|
231
|
+
suppressed: list[dict] = []
|
|
232
|
+
for finding in findings:
|
|
233
|
+
matched_rule = None
|
|
234
|
+
for rule in allowlist_rules:
|
|
235
|
+
if _finding_matches_rule(finding, rule):
|
|
236
|
+
matched_rule = rule
|
|
237
|
+
break
|
|
238
|
+
if matched_rule is None:
|
|
239
|
+
active.append(finding)
|
|
240
|
+
continue
|
|
241
|
+
suppressed.append(
|
|
242
|
+
{
|
|
243
|
+
"finding": _safe_finding_dict(finding),
|
|
244
|
+
"reason": str(matched_rule.get("reason", "allowlisted")),
|
|
245
|
+
"rule": {
|
|
246
|
+
"check": matched_rule.get("check"),
|
|
247
|
+
"rule_id": matched_rule.get("rule_id"),
|
|
248
|
+
"file": matched_rule.get("file"),
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
)
|
|
252
|
+
return active, suppressed
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _resolve_policy_argument(policy_arg: str | None, profile: str | None, scan_root: Path) -> str | None:
|
|
256
|
+
if policy_arg and profile:
|
|
257
|
+
raise ValueError("use either --policy or --profile, not both")
|
|
258
|
+
if policy_arg:
|
|
259
|
+
return policy_arg
|
|
260
|
+
if profile is None:
|
|
261
|
+
return None
|
|
262
|
+
if profile == "baseline":
|
|
263
|
+
candidate = scan_root / ".wrg" / "policy.json"
|
|
264
|
+
else:
|
|
265
|
+
candidate = scan_root / ".wrg" / "policy.strict.json"
|
|
266
|
+
if not candidate.exists():
|
|
267
|
+
raise ValueError(f"profile policy file not found: {candidate}")
|
|
268
|
+
return str(candidate)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _run_bandit(args: argparse.Namespace) -> int:
|
|
272
|
+
"""Run bandit security scanner on Python source."""
|
|
273
|
+
import subprocess
|
|
274
|
+
import sys
|
|
275
|
+
|
|
276
|
+
target = Path(args.path).resolve()
|
|
277
|
+
if args.app:
|
|
278
|
+
target = target / "apps" / args.app / "src"
|
|
279
|
+
if not target.exists():
|
|
280
|
+
target = Path(args.path).resolve() / "apps" / args.app
|
|
281
|
+
if not target.exists():
|
|
282
|
+
print(f"ERROR: path not found: {target}", file=sys.stderr)
|
|
283
|
+
return 1
|
|
284
|
+
|
|
285
|
+
sev_map = {"low": "l", "medium": "m", "high": "h"}
|
|
286
|
+
sev_flag = sev_map.get(args.severity, "m")
|
|
287
|
+
|
|
288
|
+
cmd = ["bandit", "-r", str(target), f"-l{sev_flag}", "-f", "json", "-q"]
|
|
289
|
+
try:
|
|
290
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
291
|
+
except FileNotFoundError:
|
|
292
|
+
print("ERROR: bandit not installed. Run: pip install bandit", file=sys.stderr)
|
|
293
|
+
return 1
|
|
294
|
+
|
|
295
|
+
if args.json_out:
|
|
296
|
+
Path(args.json_out).write_text(result.stdout or "{}", encoding="utf-8")
|
|
297
|
+
print(f"Report written to {args.json_out}")
|
|
298
|
+
elif result.stdout:
|
|
299
|
+
try:
|
|
300
|
+
data = json.loads(result.stdout)
|
|
301
|
+
issues = data.get("results", [])
|
|
302
|
+
if not issues:
|
|
303
|
+
print(f"bandit: no issues found (severity >= {args.severity})")
|
|
304
|
+
return 0
|
|
305
|
+
print(f"bandit: {len(issues)} issue(s) found (severity >= {args.severity})\n")
|
|
306
|
+
for issue in issues:
|
|
307
|
+
sev = issue.get("issue_severity", "?")
|
|
308
|
+
conf = issue.get("issue_confidence", "?")
|
|
309
|
+
text = issue.get("issue_text", "?")
|
|
310
|
+
fname = issue.get("filename", "?")
|
|
311
|
+
line = issue.get("line_number", "?")
|
|
312
|
+
print(f" [{sev}/{conf}] {fname}:{line}")
|
|
313
|
+
print(f" {text}\n")
|
|
314
|
+
except json.JSONDecodeError:
|
|
315
|
+
print(result.stdout)
|
|
316
|
+
else:
|
|
317
|
+
print(f"bandit: no issues found (severity >= {args.severity})")
|
|
318
|
+
return 0
|
|
319
|
+
|
|
320
|
+
return 1 if result.returncode != 0 else 0
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _run_profiles(scan_root: Path) -> int:
|
|
324
|
+
baseline = scan_root / ".wrg" / "policy.json"
|
|
325
|
+
strict = scan_root / ".wrg" / "policy.strict.json"
|
|
326
|
+
print("wrg-devguard profiles:")
|
|
327
|
+
print(f"- baseline: {baseline} | {'present' if baseline.exists() else 'missing'}")
|
|
328
|
+
print(f"- strict: {strict} | {'present' if strict.exists() else 'missing'}")
|
|
329
|
+
return 0
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _run_lint_policy(
|
|
333
|
+
scan_root: Path,
|
|
334
|
+
policy_arg: str | None,
|
|
335
|
+
fail_on: str,
|
|
336
|
+
allowed_files: set[str] | None,
|
|
337
|
+
allowlist_rules: list[dict],
|
|
338
|
+
) -> tuple[dict, int]:
|
|
339
|
+
policy = load_policy(policy_arg, scan_root)
|
|
340
|
+
findings = lint_policy(scan_root, policy, allowed_files=allowed_files)
|
|
341
|
+
findings, suppressed = _apply_allowlist(findings, allowlist_rules)
|
|
342
|
+
report = _as_report("lint-policy", scan_root, findings, fail_on, suppressed=suppressed)
|
|
343
|
+
_print_summary("lint-policy")
|
|
344
|
+
return report, (1 if report["status"] == "FAIL" else 0)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _run_scan_secrets(
|
|
348
|
+
scan_root: Path,
|
|
349
|
+
fail_on: str,
|
|
350
|
+
allowed_files: set[str] | None,
|
|
351
|
+
allowlist_rules: list[dict],
|
|
352
|
+
) -> tuple[dict, int]:
|
|
353
|
+
findings = scan_secrets(scan_root, allowed_files=allowed_files)
|
|
354
|
+
findings, suppressed = _apply_allowlist(findings, allowlist_rules)
|
|
355
|
+
report = _as_report("scan-secrets", scan_root, findings, fail_on, suppressed=suppressed)
|
|
356
|
+
_print_summary("scan-secrets")
|
|
357
|
+
return report, (1 if report["status"] == "FAIL" else 0)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _run_check(
|
|
361
|
+
scan_root: Path,
|
|
362
|
+
policy_arg: str | None,
|
|
363
|
+
fail_on: str,
|
|
364
|
+
allowed_files: set[str] | None,
|
|
365
|
+
allowlist_rules: list[dict],
|
|
366
|
+
) -> tuple[dict, int]:
|
|
367
|
+
policy = load_policy(policy_arg, scan_root)
|
|
368
|
+
policy_findings = lint_policy(scan_root, policy, allowed_files=allowed_files)
|
|
369
|
+
secret_findings = scan_secrets(scan_root, allowed_files=allowed_files)
|
|
370
|
+
findings, suppressed = _apply_allowlist([*policy_findings, *secret_findings], allowlist_rules)
|
|
371
|
+
report = _as_report("check", scan_root, findings, fail_on, suppressed=suppressed)
|
|
372
|
+
_print_summary("check")
|
|
373
|
+
return report, (1 if report["status"] == "FAIL" else 0)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def main(argv: list[str] | None = None) -> int:
|
|
377
|
+
parser = build_parser()
|
|
378
|
+
args = parser.parse_args(argv)
|
|
379
|
+
|
|
380
|
+
if args.command is None:
|
|
381
|
+
parser.print_help()
|
|
382
|
+
return 1
|
|
383
|
+
|
|
384
|
+
scan_root = Path(args.path).resolve()
|
|
385
|
+
if not scan_root.exists():
|
|
386
|
+
print(f"scan path does not exist: {scan_root}")
|
|
387
|
+
return 2
|
|
388
|
+
|
|
389
|
+
if args.command == "profiles":
|
|
390
|
+
return _run_profiles(scan_root)
|
|
391
|
+
|
|
392
|
+
if args.command == "bandit":
|
|
393
|
+
return _run_bandit(args)
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
allowed_files = _load_allowed_files(args.paths_file, scan_root)
|
|
397
|
+
allowlist_rules = _load_allowlist(getattr(args, "allowlist", None), scan_root)
|
|
398
|
+
resolved_policy = _resolve_policy_argument(
|
|
399
|
+
getattr(args, "policy", None),
|
|
400
|
+
getattr(args, "profile", None),
|
|
401
|
+
scan_root,
|
|
402
|
+
)
|
|
403
|
+
except ValueError as exc:
|
|
404
|
+
print(str(exc))
|
|
405
|
+
return 2
|
|
406
|
+
|
|
407
|
+
if args.command == "lint-policy":
|
|
408
|
+
report, exit_code = _run_lint_policy(scan_root, resolved_policy, args.fail_on, allowed_files, allowlist_rules)
|
|
409
|
+
elif args.command == "scan-secrets":
|
|
410
|
+
report, exit_code = _run_scan_secrets(scan_root, args.fail_on, allowed_files, allowlist_rules)
|
|
411
|
+
elif args.command == "check":
|
|
412
|
+
report, exit_code = _run_check(scan_root, resolved_policy, args.fail_on, allowed_files, allowlist_rules)
|
|
413
|
+
else:
|
|
414
|
+
parser.print_help()
|
|
415
|
+
return 1
|
|
416
|
+
|
|
417
|
+
if args.json_out:
|
|
418
|
+
write_json(args.json_out, _safe_json_report(args.command, scan_root))
|
|
419
|
+
return exit_code
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
if __name__ == "__main__":
|
|
423
|
+
raise SystemExit(main())
|
wrg_devguard/common.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import fnmatch
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from typing import Iterable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Finding:
|
|
13
|
+
check: str
|
|
14
|
+
rule_id: str
|
|
15
|
+
severity: str
|
|
16
|
+
message: str
|
|
17
|
+
file: str
|
|
18
|
+
line: int
|
|
19
|
+
column: int
|
|
20
|
+
snippet: str
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict:
|
|
23
|
+
return asdict(self)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def to_posix(path: Path) -> str:
|
|
27
|
+
return path.as_posix()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def relative_posix(path: Path, root: Path) -> str:
|
|
31
|
+
return to_posix(path.relative_to(root))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def match_any(path: str, patterns: Iterable[str]) -> bool:
|
|
35
|
+
pp = PurePosixPath(path)
|
|
36
|
+
for pattern in patterns:
|
|
37
|
+
if fnmatch.fnmatch(path, pattern):
|
|
38
|
+
return True
|
|
39
|
+
if pp.match(pattern):
|
|
40
|
+
return True
|
|
41
|
+
if pattern.startswith("**/"):
|
|
42
|
+
if pp.match(pattern[3:]):
|
|
43
|
+
return True
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_text_safely(path: Path, max_bytes: int = 1_048_576) -> str:
|
|
48
|
+
if path.stat().st_size > max_bytes:
|
|
49
|
+
return ""
|
|
50
|
+
return path.read_text(encoding="utf-8", errors="ignore")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def line_col(text: str, index: int) -> tuple[int, int]:
|
|
54
|
+
line = text.count("\n", 0, index) + 1
|
|
55
|
+
line_start = text.rfind("\n", 0, index)
|
|
56
|
+
column = index + 1 if line_start == -1 else index - line_start
|
|
57
|
+
return line, column
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def clean_snippet(value: str) -> str:
|
|
61
|
+
return re.sub(r"\s+", " ", value).strip()[:200]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def write_json(path: str | Path, payload: dict) -> None:
|
|
65
|
+
output_path = Path(path)
|
|
66
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
output_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
wrg_devguard/policy.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .common import Finding, line_col, match_any, read_text_safely, relative_posix
|
|
8
|
+
|
|
9
|
+
DEFAULT_INCLUDE = [
|
|
10
|
+
"**/*.md",
|
|
11
|
+
"**/*.txt",
|
|
12
|
+
"**/*.prompt",
|
|
13
|
+
"**/*.yaml",
|
|
14
|
+
"**/*.yml",
|
|
15
|
+
"**/*.json",
|
|
16
|
+
"**/*.py",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
DEFAULT_EXCLUDE = [
|
|
20
|
+
"**/.git/**",
|
|
21
|
+
"**/.venv/**",
|
|
22
|
+
"**/venv/**",
|
|
23
|
+
"**/node_modules/**",
|
|
24
|
+
"**/__pycache__/**",
|
|
25
|
+
"**/.pytest_cache/**",
|
|
26
|
+
"**/tests/**",
|
|
27
|
+
"**/testdata/**",
|
|
28
|
+
"**/fixtures/**",
|
|
29
|
+
"**/.tmp/**",
|
|
30
|
+
".tmp/**",
|
|
31
|
+
"**/.tmp_pytest/**",
|
|
32
|
+
".tmp_pytest/**",
|
|
33
|
+
"**/_tmp*/**",
|
|
34
|
+
"_tmp*/**",
|
|
35
|
+
"**/.cache/**",
|
|
36
|
+
"**/site-packages/**",
|
|
37
|
+
"**/.train_venv/**",
|
|
38
|
+
"**/data/**",
|
|
39
|
+
"**/runs/**",
|
|
40
|
+
"**/artifacts/**",
|
|
41
|
+
"artifacts/**",
|
|
42
|
+
"**/dist/**",
|
|
43
|
+
"**/build/**",
|
|
44
|
+
"**/*.png",
|
|
45
|
+
"**/*.jpg",
|
|
46
|
+
"**/*.jpeg",
|
|
47
|
+
"**/*.gif",
|
|
48
|
+
"**/*.svg",
|
|
49
|
+
"**/*.ico",
|
|
50
|
+
"**/*.lock",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
DEFAULT_DENY_PATTERNS = [
|
|
54
|
+
{
|
|
55
|
+
"id": "prompt_injection_ignore_previous",
|
|
56
|
+
"regex": r"ignore\s+previous\s+instructions",
|
|
57
|
+
"severity": "ERROR",
|
|
58
|
+
"message": "Potential prompt-injection control bypass.",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"id": "prompt_injection_bypass_guardrails",
|
|
62
|
+
"regex": r"bypass\s+(all\s+)?(safety|guardrails|policy|policies)",
|
|
63
|
+
"severity": "ERROR",
|
|
64
|
+
"message": "Potential policy bypass intent.",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"id": "data_exfiltration_intent",
|
|
68
|
+
"regex": r"(exfiltrate|leak|dump)\s+.*(secret|credential|token|password)",
|
|
69
|
+
"severity": "ERROR",
|
|
70
|
+
"message": "Potential exfiltration intent in prompt content.",
|
|
71
|
+
},
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def default_policy() -> dict:
|
|
76
|
+
return {
|
|
77
|
+
"include": list(DEFAULT_INCLUDE),
|
|
78
|
+
"exclude": list(DEFAULT_EXCLUDE),
|
|
79
|
+
"max_file_bytes": 1_048_576,
|
|
80
|
+
"deny_patterns": list(DEFAULT_DENY_PATTERNS),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _parse_policy_file(policy_path: Path) -> dict:
|
|
85
|
+
suffix = policy_path.suffix.lower()
|
|
86
|
+
content = policy_path.read_text(encoding="utf-8")
|
|
87
|
+
if suffix == ".json":
|
|
88
|
+
return json.loads(content)
|
|
89
|
+
if suffix == ".toml":
|
|
90
|
+
import tomllib
|
|
91
|
+
|
|
92
|
+
return tomllib.loads(content)
|
|
93
|
+
if suffix in {".yaml", ".yml"}:
|
|
94
|
+
try:
|
|
95
|
+
import yaml # type: ignore
|
|
96
|
+
except ModuleNotFoundError as exc:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
"YAML policy requested but PyYAML is not installed. Install with: pip install -e \".[yaml]\""
|
|
99
|
+
) from exc
|
|
100
|
+
payload = yaml.safe_load(content)
|
|
101
|
+
return payload if isinstance(payload, dict) else {}
|
|
102
|
+
return json.loads(content)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_policy(policy_arg: str | None, repo_root: Path) -> dict:
|
|
106
|
+
if policy_arg:
|
|
107
|
+
candidate = Path(policy_arg).resolve()
|
|
108
|
+
if not candidate.exists():
|
|
109
|
+
raise ValueError(f"Policy file not found: {candidate}")
|
|
110
|
+
parsed = _parse_policy_file(candidate)
|
|
111
|
+
else:
|
|
112
|
+
default_path = repo_root / ".wrg" / "policy.json"
|
|
113
|
+
parsed = _parse_policy_file(default_path) if default_path.exists() else {}
|
|
114
|
+
|
|
115
|
+
policy = default_policy()
|
|
116
|
+
if not isinstance(parsed, dict):
|
|
117
|
+
return policy
|
|
118
|
+
|
|
119
|
+
include = parsed.get("include")
|
|
120
|
+
exclude = parsed.get("exclude")
|
|
121
|
+
deny_patterns = parsed.get("deny_patterns")
|
|
122
|
+
max_file_bytes = parsed.get("max_file_bytes")
|
|
123
|
+
|
|
124
|
+
if isinstance(include, list) and include:
|
|
125
|
+
policy["include"] = [str(item) for item in include if isinstance(item, str) and item.strip()]
|
|
126
|
+
if isinstance(exclude, list) and exclude:
|
|
127
|
+
policy["exclude"] = [str(item) for item in exclude if isinstance(item, str) and item.strip()]
|
|
128
|
+
if isinstance(deny_patterns, list) and deny_patterns:
|
|
129
|
+
normalized = []
|
|
130
|
+
for item in deny_patterns:
|
|
131
|
+
if not isinstance(item, dict):
|
|
132
|
+
continue
|
|
133
|
+
regex = item.get("regex")
|
|
134
|
+
if not isinstance(regex, str) or not regex.strip():
|
|
135
|
+
continue
|
|
136
|
+
normalized.append(
|
|
137
|
+
{
|
|
138
|
+
"id": str(item.get("id", "custom_pattern")),
|
|
139
|
+
"regex": regex,
|
|
140
|
+
"severity": str(item.get("severity", "ERROR")).upper(),
|
|
141
|
+
"message": str(item.get("message", "Custom deny pattern matched.")),
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
if normalized:
|
|
145
|
+
policy["deny_patterns"] = normalized
|
|
146
|
+
if isinstance(max_file_bytes, int) and max_file_bytes > 0:
|
|
147
|
+
policy["max_file_bytes"] = max_file_bytes
|
|
148
|
+
return policy
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _iter_candidate_files(root: Path, include: list[str], exclude: list[str]) -> list[Path]:
|
|
152
|
+
return _iter_candidate_files_filtered(root, include, exclude, allowed_files=None)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _iter_candidate_files_filtered(
|
|
156
|
+
root: Path,
|
|
157
|
+
include: list[str],
|
|
158
|
+
exclude: list[str],
|
|
159
|
+
allowed_files: set[str] | None,
|
|
160
|
+
) -> list[Path]:
|
|
161
|
+
results: list[Path] = []
|
|
162
|
+
for path in root.rglob("*"):
|
|
163
|
+
if not path.is_file():
|
|
164
|
+
continue
|
|
165
|
+
rel = relative_posix(path, root)
|
|
166
|
+
if allowed_files is not None and rel not in allowed_files:
|
|
167
|
+
continue
|
|
168
|
+
if match_any(rel, exclude):
|
|
169
|
+
continue
|
|
170
|
+
if not match_any(rel, include):
|
|
171
|
+
continue
|
|
172
|
+
results.append(path)
|
|
173
|
+
return results
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def lint_policy(root: Path, policy: dict, allowed_files: set[str] | None = None) -> list[Finding]:
|
|
177
|
+
findings: list[Finding] = []
|
|
178
|
+
include = policy.get("include", DEFAULT_INCLUDE)
|
|
179
|
+
exclude = policy.get("exclude", DEFAULT_EXCLUDE)
|
|
180
|
+
max_file_bytes = int(policy.get("max_file_bytes", 1_048_576))
|
|
181
|
+
deny_patterns = policy.get("deny_patterns", DEFAULT_DENY_PATTERNS)
|
|
182
|
+
candidates = _iter_candidate_files_filtered(root, include, exclude, allowed_files)
|
|
183
|
+
|
|
184
|
+
compiled_patterns = []
|
|
185
|
+
for rule in deny_patterns:
|
|
186
|
+
if not isinstance(rule, dict):
|
|
187
|
+
continue
|
|
188
|
+
try:
|
|
189
|
+
compiled_patterns.append(
|
|
190
|
+
(
|
|
191
|
+
str(rule.get("id", "unknown_rule")),
|
|
192
|
+
re.compile(str(rule.get("regex", "")), re.IGNORECASE | re.MULTILINE),
|
|
193
|
+
str(rule.get("severity", "ERROR")).upper(),
|
|
194
|
+
str(rule.get("message", "Policy deny pattern matched.")),
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
except re.error:
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
for path in candidates:
|
|
201
|
+
text = read_text_safely(path, max_bytes=max_file_bytes)
|
|
202
|
+
if not text:
|
|
203
|
+
continue
|
|
204
|
+
rel = relative_posix(path, root)
|
|
205
|
+
for rule_id, regex, severity, message in compiled_patterns:
|
|
206
|
+
for match in regex.finditer(text):
|
|
207
|
+
line, column = line_col(text, match.start())
|
|
208
|
+
findings.append(
|
|
209
|
+
Finding(
|
|
210
|
+
check="lint-policy",
|
|
211
|
+
rule_id=rule_id,
|
|
212
|
+
severity=severity,
|
|
213
|
+
message=message,
|
|
214
|
+
file=rel,
|
|
215
|
+
line=line,
|
|
216
|
+
column=column,
|
|
217
|
+
# Never retain matched content in memory or output payloads.
|
|
218
|
+
snippet="[REDACTED]",
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
return findings
|
wrg_devguard/secrets.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .common import Finding, line_col, match_any, read_text_safely, relative_posix
|
|
7
|
+
from .policy import DEFAULT_EXCLUDE
|
|
8
|
+
|
|
9
|
+
SECRET_RULES = [
|
|
10
|
+
{
|
|
11
|
+
"id": "openai_api_key",
|
|
12
|
+
"regex": r"\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b",
|
|
13
|
+
"severity": "ERROR",
|
|
14
|
+
"message": "Possible OpenAI API key found.",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "github_token",
|
|
18
|
+
"regex": r"\bgh[pousr]_[A-Za-z0-9]{36,255}\b",
|
|
19
|
+
"severity": "ERROR",
|
|
20
|
+
"message": "Possible GitHub token found.",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "aws_access_key_id",
|
|
24
|
+
"regex": r"\bAKIA[0-9A-Z]{16}\b",
|
|
25
|
+
"severity": "ERROR",
|
|
26
|
+
"message": "Possible AWS Access Key ID found.",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "slack_token",
|
|
30
|
+
"regex": r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b",
|
|
31
|
+
"severity": "ERROR",
|
|
32
|
+
"message": "Possible Slack token found.",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "private_key_block",
|
|
36
|
+
"regex": r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
|
|
37
|
+
"severity": "ERROR",
|
|
38
|
+
"message": "Private key block found.",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "generic_secret_assignment",
|
|
42
|
+
"regex": r"(?i)(api[_-]?key|access[_-]?token|secret|password)\s*[:=]\s*['\"][^'\"]{8,}['\"]",
|
|
43
|
+
"severity": "WARNING",
|
|
44
|
+
"message": "Potential hardcoded secret assignment.",
|
|
45
|
+
},
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
DEFAULT_INCLUDE = [
|
|
49
|
+
"**/*.env",
|
|
50
|
+
"**/*.ini",
|
|
51
|
+
"**/*.json",
|
|
52
|
+
"**/*.toml",
|
|
53
|
+
"**/*.yaml",
|
|
54
|
+
"**/*.yml",
|
|
55
|
+
"**/*.txt",
|
|
56
|
+
"**/*.md",
|
|
57
|
+
"**/*.py",
|
|
58
|
+
"**/*.js",
|
|
59
|
+
"**/*.ts",
|
|
60
|
+
"**/*.sh",
|
|
61
|
+
"**/*.ps1",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def scan_secrets(
|
|
66
|
+
root: Path,
|
|
67
|
+
max_file_bytes: int = 1_048_576,
|
|
68
|
+
allowed_files: set[str] | None = None,
|
|
69
|
+
) -> list[Finding]:
|
|
70
|
+
findings: list[Finding] = []
|
|
71
|
+
compiled_rules = [
|
|
72
|
+
(
|
|
73
|
+
rule["id"],
|
|
74
|
+
re.compile(rule["regex"], re.MULTILINE),
|
|
75
|
+
rule["severity"],
|
|
76
|
+
rule["message"],
|
|
77
|
+
)
|
|
78
|
+
for rule in SECRET_RULES
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
for path in root.rglob("*"):
|
|
82
|
+
if not path.is_file():
|
|
83
|
+
continue
|
|
84
|
+
rel = relative_posix(path, root)
|
|
85
|
+
if allowed_files is not None and rel not in allowed_files:
|
|
86
|
+
continue
|
|
87
|
+
if match_any(rel, DEFAULT_EXCLUDE):
|
|
88
|
+
continue
|
|
89
|
+
if not match_any(rel, DEFAULT_INCLUDE):
|
|
90
|
+
continue
|
|
91
|
+
text = read_text_safely(path, max_bytes=max_file_bytes)
|
|
92
|
+
if not text:
|
|
93
|
+
continue
|
|
94
|
+
for rule_id, regex, severity, message in compiled_rules:
|
|
95
|
+
for match in regex.finditer(text):
|
|
96
|
+
line, column = line_col(text, match.start())
|
|
97
|
+
findings.append(
|
|
98
|
+
Finding(
|
|
99
|
+
check="scan-secrets",
|
|
100
|
+
rule_id=rule_id,
|
|
101
|
+
severity=severity,
|
|
102
|
+
message=message,
|
|
103
|
+
file=rel,
|
|
104
|
+
line=line,
|
|
105
|
+
column=column,
|
|
106
|
+
# Never retain matched secret value in memory or output payloads.
|
|
107
|
+
snippet="[REDACTED]",
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
return findings
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wrg-devguard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Developer-first AI safety checks: prompt-policy lint + secret scanning. Zero-dep CLI + GitHub Action + Claude Skill + Cursor Rule.
|
|
5
|
+
Author: Yakuphan Yucel
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yakuphanycl/wrg-devguard
|
|
8
|
+
Project-URL: Repository, https://github.com/yakuphanycl/wrg-devguard
|
|
9
|
+
Project-URL: Issues, https://github.com/yakuphanycl/wrg-devguard/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/yakuphanycl/wrg-devguard/releases
|
|
11
|
+
Keywords: security,secret-scanning,policy-lint,ai-safety,prompt-security,devsecops,pre-commit,github-action,claude-skill,cursor-rule
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
21
|
+
Classifier: Topic :: Software Development :: Testing
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Provides-Extra: yaml
|
|
26
|
+
Requires-Dist: PyYAML>=6.0; extra == "yaml"
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# wrg-devguard
|
|
32
|
+
|
|
33
|
+
**Developer-first AI safety checks: prompt-policy lint + secret scanning.**
|
|
34
|
+
|
|
35
|
+
Zero-dependency Python CLI that scans a repository for two classes of issues
|
|
36
|
+
before your PR lands:
|
|
37
|
+
|
|
38
|
+
1. **Leaked secrets** — API keys, private keys, tokens, common credential
|
|
39
|
+
formats in tracked files.
|
|
40
|
+
2. **Prompt-policy violations** — deny-listed patterns in prompts, system
|
|
41
|
+
messages, and AI-facing text assets (configurable via JSON policy).
|
|
42
|
+
|
|
43
|
+
Ships as:
|
|
44
|
+
|
|
45
|
+
- A Python package (`pip install wrg-devguard`)
|
|
46
|
+
- A GitHub Action (drop-in composite action for any repo)
|
|
47
|
+
- A Claude Code skill (`.claude/skills/wrg-devguard/`)
|
|
48
|
+
- A Cursor rule (`.cursor/rules/wrg-devguard.mdc`)
|
|
49
|
+
|
|
50
|
+
No external dependencies in the core scanner (stdlib only). Optional `[yaml]`
|
|
51
|
+
extra for YAML policy files. Optional `bandit` subcommand for Python security
|
|
52
|
+
scanning.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install wrg-devguard
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For YAML policy support:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install "wrg-devguard[yaml]"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Quick start
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Run both checks and fail on any high-severity finding
|
|
70
|
+
wrg-devguard check --path . --fail-on error
|
|
71
|
+
|
|
72
|
+
# Scan only for leaked secrets
|
|
73
|
+
wrg-devguard scan-secrets --path .
|
|
74
|
+
|
|
75
|
+
# Lint AI-facing text assets against a policy
|
|
76
|
+
wrg-devguard lint-policy --path . --profile strict
|
|
77
|
+
|
|
78
|
+
# Emit a JSON report for CI
|
|
79
|
+
wrg-devguard check --path . --json-out wrg-devguard-report.json
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## GitHub Action
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
# .github/workflows/security.yml
|
|
86
|
+
name: security
|
|
87
|
+
on: [pull_request, push]
|
|
88
|
+
|
|
89
|
+
jobs:
|
|
90
|
+
wrg-devguard:
|
|
91
|
+
runs-on: ubuntu-latest
|
|
92
|
+
steps:
|
|
93
|
+
- uses: actions/checkout@v4
|
|
94
|
+
- uses: yakuphanycl/wrg-devguard@v1
|
|
95
|
+
with:
|
|
96
|
+
profile: strict
|
|
97
|
+
fail-on: error
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
See [`action.yml`](./action.yml) for all inputs.
|
|
101
|
+
|
|
102
|
+
## Claude Code skill
|
|
103
|
+
|
|
104
|
+
Drop the skill into your workspace:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
mkdir -p .claude/skills/wrg-devguard
|
|
108
|
+
curl -L https://raw.githubusercontent.com/yakuphanycl/wrg-devguard/main/.claude/skills/wrg-devguard/SKILL.md \
|
|
109
|
+
-o .claude/skills/wrg-devguard/SKILL.md
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Claude Code will surface the skill automatically when you ask things like
|
|
113
|
+
"scan for secrets", "is this safe to commit", or "check for leaks".
|
|
114
|
+
|
|
115
|
+
## Cursor rule
|
|
116
|
+
|
|
117
|
+
Drop the rule into your workspace:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
mkdir -p .cursor/rules
|
|
121
|
+
curl -L https://raw.githubusercontent.com/yakuphanycl/wrg-devguard/main/.cursor/rules/wrg-devguard.mdc \
|
|
122
|
+
-o .cursor/rules/wrg-devguard.mdc
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Cursor will apply the rule before suggesting any `git commit` command.
|
|
126
|
+
|
|
127
|
+
## Policy file
|
|
128
|
+
|
|
129
|
+
Default lookup order:
|
|
130
|
+
|
|
131
|
+
1. `--policy <path>` argument if provided
|
|
132
|
+
2. `.wrg/policy.json` at the repo root
|
|
133
|
+
3. Built-in defaults
|
|
134
|
+
|
|
135
|
+
Profiles:
|
|
136
|
+
|
|
137
|
+
- `default` → PR-friendly baseline (recommended for CI)
|
|
138
|
+
- `strict` → stricter local/release audits (use `--profile strict`)
|
|
139
|
+
|
|
140
|
+
Place custom policies in `.wrg/policy.json` (JSON) or `.wrg/policy.yaml`
|
|
141
|
+
(requires `[yaml]` extra).
|
|
142
|
+
|
|
143
|
+
## Commands
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
wrg-devguard profiles # list available profiles
|
|
147
|
+
wrg-devguard lint-policy --path . # policy lint only
|
|
148
|
+
wrg-devguard scan-secrets --path . # secret scan only
|
|
149
|
+
wrg-devguard check --path . # both, single JSON report
|
|
150
|
+
wrg-devguard check --path . --profile strict
|
|
151
|
+
wrg-devguard check --path . --json-out report.json
|
|
152
|
+
wrg-devguard check --path . --fail-on warning
|
|
153
|
+
wrg-devguard check --path . --allowlist .wrg/allowlist.json
|
|
154
|
+
wrg-devguard bandit --path src/ # optional: bandit wrapper
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Exit codes
|
|
158
|
+
|
|
159
|
+
- `0` — no findings above threshold
|
|
160
|
+
- `1` — findings at or above `--fail-on` threshold
|
|
161
|
+
- `2` — configuration or input error
|
|
162
|
+
|
|
163
|
+
## Why another secret scanner?
|
|
164
|
+
|
|
165
|
+
- **Zero runtime deps** — the core scanner is stdlib only, so `pip install` is
|
|
166
|
+
instant and works in any sandbox.
|
|
167
|
+
- **Policy lint in the same tool** — most scanners only do secrets. We also
|
|
168
|
+
catch prompt-policy violations (deny-listed patterns, hardcoded system
|
|
169
|
+
prompts, PII in AI-facing text).
|
|
170
|
+
- **AI-native UX** — ships with a Claude skill and a Cursor rule so the
|
|
171
|
+
scanner runs automatically inside your AI coding assistant, not just in CI.
|
|
172
|
+
- **Stable JSON schema** — `check --json-out` emits a versioned schema that
|
|
173
|
+
never breaks.
|
|
174
|
+
|
|
175
|
+
## Development
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
git clone https://github.com/yakuphanycl/wrg-devguard.git
|
|
179
|
+
cd wrg-devguard
|
|
180
|
+
pip install -e ".[dev]"
|
|
181
|
+
pytest -q
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT. See [LICENSE](./LICENSE).
|
|
187
|
+
|
|
188
|
+
## Contributing
|
|
189
|
+
|
|
190
|
+
Issues and PRs welcome. For substantial changes, open an issue first to
|
|
191
|
+
discuss scope.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
Part of the [WinstonRedGuard](https://github.com/yakuphanycl/WinstonRedGuard)
|
|
196
|
+
ecosystem. The monorepo at `apps/wrg_devguard/` is the canonical source; this
|
|
197
|
+
repo is a distribution mirror kept in sync on every release.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
wrg_devguard/__init__.py,sha256=s3wNt6OeH5we3eksw3poTOZi4OnXZF9M8zyXZEVZUPc,88
|
|
2
|
+
wrg_devguard/cli.py,sha256=bIzEd76EjkIEt0qoAS9L-xrGCOqXAZhRL49dCqdCGyI,16143
|
|
3
|
+
wrg_devguard/common.py,sha256=qS8aBFrt1t_F15H4tgFlmD-WzdrloDcX2OLVmr-i0wY,1678
|
|
4
|
+
wrg_devguard/policy.py,sha256=Y-KpuoLxCSNr2ZXz7b8W0iDnLkzgCMAEl5bfWN1WO2w,7094
|
|
5
|
+
wrg_devguard/secrets.py,sha256=oRn5G4e1wOu6224c-KXZPJy30xhTiBiYXoCz4pzKfJE,3154
|
|
6
|
+
wrg_devguard-0.1.0.dist-info/licenses/LICENSE,sha256=jrugCAfdds9cjUQmsxgTP9pw4JJ7EVEOkC3Wr4GxypQ,1086
|
|
7
|
+
wrg_devguard-0.1.0.dist-info/METADATA,sha256=bmeQHBaTiIB1GETWRglheVLVP-QcG3ycHT5wTp5TbDU,6183
|
|
8
|
+
wrg_devguard-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
wrg_devguard-0.1.0.dist-info/entry_points.txt,sha256=lRChmT2U5RHb-4hdnlXj-AXlAdUM2ITZ7MIXcL0DFdw,55
|
|
10
|
+
wrg_devguard-0.1.0.dist-info/top_level.txt,sha256=27aZ5wNEi-pymjfa4pML11qqhuUkmwKEuwvkfGiwWQg,13
|
|
11
|
+
wrg_devguard-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yakuphan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wrg_devguard
|