conduct-cli 0.4.58__tar.gz → 0.4.60__tar.gz
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.
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/PKG-INFO +1 -1
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/pyproject.toml +1 -1
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/src/conduct_cli/guard.py +102 -44
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/README.md +0 -0
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/setup.cfg +0 -0
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/setup.py +0 -0
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/src/conduct_cli/main.py +0 -0
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.4.58 → conduct_cli-0.4.60}/tests/test_switch.py +0 -0
|
@@ -241,8 +241,75 @@ def _post_usage(session_id, tool_name, tokens_input, tokens_output, duration_ms)
|
|
|
241
241
|
)
|
|
242
242
|
|
|
243
243
|
|
|
244
|
+
SECRET_PATTERNS = [
|
|
245
|
+
(r"sk-[A-Za-z0-9]{20,}", "secret-leak", "high", "Potential OpenAI/Anthropic API key"),
|
|
246
|
+
(r"ghp_[A-Za-z0-9]{36}", "secret-leak", "high", "GitHub Personal Access Token"),
|
|
247
|
+
(r"AKIA[0-9A-Z]{16}", "secret-leak", "critical", "AWS Access Key ID"),
|
|
248
|
+
(r"Bearer\s+[A-Za-z0-9+/=]{20,}", "secret-leak", "high", "Bearer token in output"),
|
|
249
|
+
(r"""password\s*=\s*['"][^'"]{4,}""","secret-leak", "high", "Hardcoded password"),
|
|
250
|
+
(r"""api[_-]?key\s*=\s*['"][^'"]{4,}""","secret-leak","high", "Hardcoded API key"),
|
|
251
|
+
(r"\.\./\.\./\.\./", "path-traversal", "medium", "Path traversal sequence"),
|
|
252
|
+
(r"file://", "path-traversal", "medium", "File URI scheme in output"),
|
|
253
|
+
(r"\beval\s*\(", "injection", "high", "eval() in output"),
|
|
254
|
+
(r"\bexec\s*\(", "injection", "high", "exec() in output"),
|
|
255
|
+
(r"\b__import__\s*\(", "injection", "high", "__import__() in output"),
|
|
256
|
+
(r"ssl\.CERT_NONE", "crypto", "high", "SSL verification disabled"),
|
|
257
|
+
(r"verify\s*=\s*False", "crypto", "medium", "TLS verification bypassed"),
|
|
258
|
+
]
|
|
259
|
+
OWASP_KEYWORDS = [
|
|
260
|
+
("sql injection", "injection", "high", "SQL injection mentioned in AI output"),
|
|
261
|
+
("cross-site scripting","injection","high", "XSS mentioned in AI output"),
|
|
262
|
+
(" xss ", "injection", "high", "XSS mentioned in AI output"),
|
|
263
|
+
("idor", "injection", "medium", "IDOR mentioned in AI output"),
|
|
264
|
+
("ssrf", "injection", "high", "SSRF mentioned in AI output"),
|
|
265
|
+
("command injection","injection", "high", "Command injection mentioned in AI output"),
|
|
266
|
+
("auth bypass", "auth-bypass", "high", "Auth bypass mentioned in AI output"),
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _classify_text(text):
|
|
271
|
+
"""Return (finding_type, severity, description, matched_pattern) or (None,...) if clean."""
|
|
272
|
+
import re as _re
|
|
273
|
+
for pattern, ftype, sev, desc in SECRET_PATTERNS:
|
|
274
|
+
if _re.search(pattern, text, _re.IGNORECASE):
|
|
275
|
+
return ftype, sev, desc, pattern
|
|
276
|
+
lower = text.lower()
|
|
277
|
+
for kw, ftype, sev, desc in OWASP_KEYWORDS:
|
|
278
|
+
if kw in lower:
|
|
279
|
+
return ftype, sev, desc, kw
|
|
280
|
+
return None, None, None, None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _line_number_from_text(text, matched_pattern):
|
|
284
|
+
"""Extract line number where pattern matched.
|
|
285
|
+
Uses splitlines() and chr(10) — no backslash-n literals (safe inside _HOOK_SCRIPT).
|
|
286
|
+
"""
|
|
287
|
+
import re as _re
|
|
288
|
+
if not matched_pattern:
|
|
289
|
+
return None
|
|
290
|
+
try:
|
|
291
|
+
# Try cat-n format first (Read tool outputs ' N<TAB>code line')
|
|
292
|
+
for raw_line in text.splitlines():
|
|
293
|
+
m = _re.match(r"^\s*(\d+)\t(.*)$", raw_line)
|
|
294
|
+
if m:
|
|
295
|
+
lineno, content = int(m.group(1)), m.group(2)
|
|
296
|
+
try:
|
|
297
|
+
if _re.search(matched_pattern, content, _re.IGNORECASE):
|
|
298
|
+
return lineno
|
|
299
|
+
except Exception:
|
|
300
|
+
if matched_pattern.lower() in content.lower():
|
|
301
|
+
return lineno
|
|
302
|
+
# Fallback: count lines before the first match offset
|
|
303
|
+
m = _re.search(matched_pattern, text, _re.IGNORECASE)
|
|
304
|
+
if m:
|
|
305
|
+
return text[:m.start()].count(chr(10)) + 1
|
|
306
|
+
except Exception:
|
|
307
|
+
pass
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
244
311
|
def _maybe_emit_security_finding(tool_response, session_id, tool_name, tool_input=None):
|
|
245
|
-
"""Classify
|
|
312
|
+
"""Classify tool output + input for security findings; POST if flag ON. Never raises."""
|
|
246
313
|
try:
|
|
247
314
|
cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
|
|
248
315
|
except Exception:
|
|
@@ -255,55 +322,37 @@ def _maybe_emit_security_finding(tool_response, session_id, tool_name, tool_inpu
|
|
|
255
322
|
if not workspace_id:
|
|
256
323
|
return
|
|
257
324
|
|
|
258
|
-
|
|
259
|
-
text = str(tool_response)
|
|
260
|
-
|
|
261
|
-
# Fast-path classifier
|
|
262
|
-
SECRET_PATTERNS = [
|
|
263
|
-
(r"sk-[A-Za-z0-9]{20,}", "secret-leak", "high", "Potential OpenAI/Anthropic API key"),
|
|
264
|
-
(r"ghp_[A-Za-z0-9]{36}", "secret-leak", "high", "GitHub Personal Access Token"),
|
|
265
|
-
(r"AKIA[0-9A-Z]{16}", "secret-leak", "critical", "AWS Access Key ID"),
|
|
266
|
-
(r"Bearer\s+[A-Za-z0-9+/=]{20,}", "secret-leak", "high", "Bearer token in output"),
|
|
267
|
-
(r"""password\s*=\s*['"][^'"]{4,}""", "secret-leak", "high", "Hardcoded password"),
|
|
268
|
-
(r"""api[_-]?key\s*=\s*['"][^'"]{4,}""", "secret-leak", "high", "Hardcoded API key"),
|
|
269
|
-
(r"\.\./\.\./\.\./", "path-traversal", "medium", "Path traversal sequence"),
|
|
270
|
-
(r"file://", "path-traversal", "medium", "File URI scheme in output"),
|
|
271
|
-
(r"\beval\s*\(", "injection", "high", "eval() in output"),
|
|
272
|
-
(r"\bexec\s*\(", "injection", "high", "exec() in output"),
|
|
273
|
-
(r"\b__import__\s*\(", "injection", "high", "__import__() in output"),
|
|
274
|
-
(r"ssl\.CERT_NONE", "crypto", "high", "SSL verification disabled"),
|
|
275
|
-
(r"verify\s*=\s*False", "crypto", "medium", "TLS verification bypassed"),
|
|
276
|
-
]
|
|
277
|
-
OWASP_KEYWORDS = [
|
|
278
|
-
("sql injection", "injection", "high", "SQL injection mentioned in AI output"),
|
|
279
|
-
("cross-site scripting", "injection", "high", "XSS mentioned in AI output"),
|
|
280
|
-
(" xss ", "injection", "high", "XSS mentioned in AI output"),
|
|
281
|
-
("idor", "injection", "medium", "IDOR mentioned in AI output"),
|
|
282
|
-
("ssrf", "injection", "high", "SSRF mentioned in AI output"),
|
|
283
|
-
("command injection", "injection", "high", "Command injection mentioned in AI output"),
|
|
284
|
-
("auth bypass", "auth-bypass", "high", "Auth bypass mentioned in AI output"),
|
|
285
|
-
]
|
|
325
|
+
ti = tool_input or {}
|
|
286
326
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
327
|
+
# Build scan candidates: (text_to_scan, source_label)
|
|
328
|
+
# Priority: tool_response first (Read), then written content (Edit/Write), then command (Bash)
|
|
329
|
+
candidates = [("response", str(tool_response))]
|
|
330
|
+
if tool_name in ("edit", "multiedit"):
|
|
331
|
+
candidates.append(("input", ti.get("new_string", "")))
|
|
332
|
+
elif tool_name == "write":
|
|
333
|
+
candidates.append(("input", ti.get("content", "")))
|
|
334
|
+
elif tool_name in ("bash", "terminal"):
|
|
335
|
+
candidates.append(("input", ti.get("command", "")))
|
|
336
|
+
|
|
337
|
+
finding_type = severity = description = matched_pattern = scan_text = None
|
|
338
|
+
for _src, text in candidates:
|
|
339
|
+
ft, sv, desc, pat = _classify_text(text)
|
|
340
|
+
if ft:
|
|
341
|
+
finding_type, severity, description, matched_pattern, scan_text = ft, sv, desc, pat, text
|
|
291
342
|
break
|
|
292
|
-
|
|
293
|
-
lower = text.lower()
|
|
294
|
-
for kw, ftype, sev, desc in OWASP_KEYWORDS:
|
|
295
|
-
if kw in lower:
|
|
296
|
-
finding_type, severity, description = ftype, sev, desc
|
|
297
|
-
break
|
|
343
|
+
|
|
298
344
|
if not finding_type:
|
|
299
345
|
return
|
|
300
346
|
|
|
301
|
-
|
|
347
|
+
# File path
|
|
302
348
|
file_path = (
|
|
303
349
|
ti.get("file_path") or ti.get("path") or
|
|
304
350
|
(ti.get("command", "")[:120] if tool_name in ("bash", "terminal") else None)
|
|
305
351
|
) or None
|
|
306
352
|
|
|
353
|
+
# Line number
|
|
354
|
+
line_no = _line_number_from_text(scan_text, matched_pattern) if scan_text else None
|
|
355
|
+
|
|
307
356
|
payload = json.dumps({
|
|
308
357
|
"tool": _detect_ai_tool(),
|
|
309
358
|
"severity": severity,
|
|
@@ -312,6 +361,7 @@ def _maybe_emit_security_finding(tool_response, session_id, tool_name, tool_inpu
|
|
|
312
361
|
"source_run_id": session_id,
|
|
313
362
|
"reporter_email": cfg.get("user_email") or "",
|
|
314
363
|
"file": file_path,
|
|
364
|
+
"line": line_no,
|
|
315
365
|
})
|
|
316
366
|
script = (
|
|
317
367
|
"import urllib.request\\n"
|
|
@@ -760,17 +810,25 @@ def _best_python() -> str:
|
|
|
760
810
|
|
|
761
811
|
def _write_hook(path: Path) -> None:
|
|
762
812
|
"""Write _HOOK_SCRIPT to path, then py_compile-validate it.
|
|
763
|
-
|
|
764
|
-
|
|
813
|
+
On syntax failure: restores previous hook (or writes a safe stub) so the
|
|
814
|
+
system is never left without a working hook file."""
|
|
765
815
|
import py_compile, tempfile, os
|
|
816
|
+
# Stash existing hook so we can restore on failure
|
|
817
|
+
backup = None
|
|
818
|
+
if path.exists():
|
|
819
|
+
backup = path.read_text()
|
|
820
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
766
821
|
path.write_text(_HOOK_SCRIPT)
|
|
767
822
|
path.chmod(0o755)
|
|
768
823
|
try:
|
|
769
824
|
py_compile.compile(str(path), doraise=True)
|
|
770
825
|
except py_compile.PyCompileError as exc:
|
|
771
|
-
|
|
826
|
+
if backup is not None:
|
|
827
|
+
path.write_text(backup)
|
|
828
|
+
else:
|
|
829
|
+
path.write_text("#!/usr/bin/env python3\nimport sys\nsys.exit(0)\n")
|
|
772
830
|
raise RuntimeError(
|
|
773
|
-
f"hook.py failed syntax check
|
|
831
|
+
f"hook.py failed syntax check — previous hook restored.\n{exc}"
|
|
774
832
|
) from exc
|
|
775
833
|
|
|
776
834
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|