conduct-cli 0.4.57__tar.gz → 0.4.59__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.57 → conduct_cli-0.4.59}/PKG-INFO +1 -1
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/pyproject.toml +1 -1
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli/guard.py +103 -42
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli/main.py +2 -2
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/README.md +0 -0
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/setup.cfg +0 -0
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/setup.py +0 -0
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.4.57 → conduct_cli-0.4.59}/tests/test_switch.py +0 -0
|
@@ -241,8 +241,78 @@ def _post_usage(session_id, tool_name, tokens_input, tokens_output, duration_ms)
|
|
|
241
241
|
)
|
|
242
242
|
|
|
243
243
|
|
|
244
|
-
|
|
245
|
-
"""
|
|
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
|
+
|
|
286
|
+
Handles two formats:
|
|
287
|
+
- Read tool output: ' N\\tcode line' (cat -n style)
|
|
288
|
+
- Plain text: count newlines before match offset
|
|
289
|
+
"""
|
|
290
|
+
import re as _re
|
|
291
|
+
if not matched_pattern:
|
|
292
|
+
return None
|
|
293
|
+
try:
|
|
294
|
+
# Try cat-n format first (Read tool)
|
|
295
|
+
for raw_line in text.split("\n"):
|
|
296
|
+
m = _re.match(r"^\s*(\d+)\t(.*)$", raw_line)
|
|
297
|
+
if m:
|
|
298
|
+
lineno, content = int(m.group(1)), m.group(2)
|
|
299
|
+
try:
|
|
300
|
+
if _re.search(matched_pattern, content, _re.IGNORECASE):
|
|
301
|
+
return lineno
|
|
302
|
+
except Exception:
|
|
303
|
+
if matched_pattern.lower() in content.lower():
|
|
304
|
+
return lineno
|
|
305
|
+
# Fallback: count newlines before the first match
|
|
306
|
+
m = _re.search(matched_pattern, text, _re.IGNORECASE)
|
|
307
|
+
if m:
|
|
308
|
+
return text[:m.start()].count("\n") + 1
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _maybe_emit_security_finding(tool_response, session_id, tool_name, tool_input=None):
|
|
315
|
+
"""Classify tool output + input for security findings; POST if flag ON. Never raises."""
|
|
246
316
|
try:
|
|
247
317
|
cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
|
|
248
318
|
except Exception:
|
|
@@ -255,49 +325,37 @@ def _maybe_emit_security_finding(tool_response, session_id, tool_name):
|
|
|
255
325
|
if not workspace_id:
|
|
256
326
|
return
|
|
257
327
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
#
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
(
|
|
265
|
-
|
|
266
|
-
(
|
|
267
|
-
|
|
268
|
-
(
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
]
|
|
286
|
-
|
|
287
|
-
finding_type = severity = description = None
|
|
288
|
-
for pattern, ftype, sev, desc in SECRET_PATTERNS:
|
|
289
|
-
if _re2.search(pattern, text, _re2.IGNORECASE):
|
|
290
|
-
finding_type, severity, description = ftype, sev, desc
|
|
328
|
+
ti = tool_input or {}
|
|
329
|
+
|
|
330
|
+
# Build scan candidates: (text_to_scan, source_label)
|
|
331
|
+
# Priority: tool_response first (Read), then written content (Edit/Write), then command (Bash)
|
|
332
|
+
candidates = [("response", str(tool_response))]
|
|
333
|
+
if tool_name in ("edit", "multiedit"):
|
|
334
|
+
candidates.append(("input", ti.get("new_string", "")))
|
|
335
|
+
elif tool_name == "write":
|
|
336
|
+
candidates.append(("input", ti.get("content", "")))
|
|
337
|
+
elif tool_name in ("bash", "terminal"):
|
|
338
|
+
candidates.append(("input", ti.get("command", "")))
|
|
339
|
+
|
|
340
|
+
finding_type = severity = description = matched_pattern = scan_text = None
|
|
341
|
+
for _src, text in candidates:
|
|
342
|
+
ft, sv, desc, pat = _classify_text(text)
|
|
343
|
+
if ft:
|
|
344
|
+
finding_type, severity, description, matched_pattern, scan_text = ft, sv, desc, pat, text
|
|
291
345
|
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
|
|
346
|
+
|
|
298
347
|
if not finding_type:
|
|
299
348
|
return
|
|
300
349
|
|
|
350
|
+
# File path
|
|
351
|
+
file_path = (
|
|
352
|
+
ti.get("file_path") or ti.get("path") or
|
|
353
|
+
(ti.get("command", "")[:120] if tool_name in ("bash", "terminal") else None)
|
|
354
|
+
) or None
|
|
355
|
+
|
|
356
|
+
# Line number
|
|
357
|
+
line_no = _line_number_from_text(scan_text, matched_pattern) if scan_text else None
|
|
358
|
+
|
|
301
359
|
payload = json.dumps({
|
|
302
360
|
"tool": _detect_ai_tool(),
|
|
303
361
|
"severity": severity,
|
|
@@ -305,6 +363,8 @@ def _maybe_emit_security_finding(tool_response, session_id, tool_name):
|
|
|
305
363
|
"description": description,
|
|
306
364
|
"source_run_id": session_id,
|
|
307
365
|
"reporter_email": cfg.get("user_email") or "",
|
|
366
|
+
"file": file_path,
|
|
367
|
+
"line": line_no,
|
|
308
368
|
})
|
|
309
369
|
script = (
|
|
310
370
|
"import urllib.request\\n"
|
|
@@ -489,7 +549,8 @@ def post_usage_main():
|
|
|
489
549
|
|
|
490
550
|
# Security classifier runs regardless of transcript_path — scan every tool response
|
|
491
551
|
tool_response = data.get("tool_response") or data.get("output") or ""
|
|
492
|
-
|
|
552
|
+
tool_input = data.get("tool_input") or {}
|
|
553
|
+
_maybe_emit_security_finding(str(tool_response), session_id, tool_name, tool_input)
|
|
493
554
|
|
|
494
555
|
sys.exit(0)
|
|
495
556
|
|
|
@@ -2217,7 +2217,7 @@ def cmd_run(args):
|
|
|
2217
2217
|
# ── conduct sync / test-guard / test-security ────────────────────────────────
|
|
2218
2218
|
|
|
2219
2219
|
def cmd_sync(args):
|
|
2220
|
-
"""
|
|
2220
|
+
"""Sync Guard policies (and Security Loop policies if installed)."""
|
|
2221
2221
|
import conduct_cli.guard as _g
|
|
2222
2222
|
print(f"\n{BOLD}▶ conduct sync{RESET}\n")
|
|
2223
2223
|
_g.cmd_guard_sync(args)
|
|
@@ -2533,7 +2533,7 @@ def main():
|
|
|
2533
2533
|
mcp_sub.add_parser("install", help="Register conduct-mcp in Claude Code and Codex")
|
|
2534
2534
|
|
|
2535
2535
|
# conduct sync
|
|
2536
|
-
sub.add_parser("sync", help="Sync Guard policies
|
|
2536
|
+
sub.add_parser("sync", help="Sync Guard policies (and Security Loop policies if installed)")
|
|
2537
2537
|
|
|
2538
2538
|
# conduct test-guard / test-security
|
|
2539
2539
|
sub.add_parser("test-guard", help="Fire a synthetic event per guard policy rule and show decisions")
|
|
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
|