#!/usr/bin/env python3 """ GuardDog multi-repo scanner. Identifies malicious PyPI/npm packages and performs behavioral analysis on MCP servers. """ import json import logging import os import re import sys import time import tomllib from concurrent.futures import ThreadPoolExecutor import emoji from packaging.requirements import InvalidRequirement, Requirement from guarddog.scanners import pypi_package_scanner from guarddog.scanners.npm_package_scanner import NPMPackageScanner from rules import ( AGENTS_MD_RULES, CLAUDE_MD_RULES, COPILOT_INSTRUCTIONS_RULES, MCP_JSON_RULES, SKILLS_MD_RULES, ) from sarif_rules import SARIF_RULE_DEFINITIONS # Setup Logging logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s") logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) # GuardDog Rule Set ALL_RULES = { "cmd-overwrite", "deceptive_author", "repository_integrity_mismatch", "single_python_file", "clipboard-access", "steganography", "download-executable", "shady-links", "code-execution", "silent-process-execution", "dll-hijacking", "empty_information", "bundled_binary", "potentially_compromised_email_domain", "typosquatting", "exec-base64", "release_zero", "unclaimed_maintainer_email_domain", "obfuscation", "exfiltrate-sensitive-data", } INCLUDE_RULES = ALL_RULES SCAN_WORKERS = int(os.environ.get("SCAN_WORKERS", "8")) # PyPI workers NPM_SCAN_WORKERS = int( os.environ.get("NPM_SCAN_WORKERS", "4") ) # npm workers — lower to avoid DNS exhaustion EXCLUDE_PACKAGES = set( pkg.strip() for pkg in os.environ.get("EXCLUDE_PACKAGES", "").split(",") if pkg.strip() ) # PRIVATE npm scopes to skip PRIVATE_NPM_SCOPES = set( s.strip().lstrip("@") for s in os.environ.get("PRIVATE_NPM_SCOPES", "theorchard").split(",") if s.strip() ) # Initialize Scanners pypi_scanner = pypi_package_scanner.PypiPackageScanner() npm_scanner = NPMPackageScanner() # --- Helpers --- def emojize(msg): return emoji.emojize(msg, language="alias") def _strip_inline_comment(line): return re.sub(r"\s+#.*$", "", line).strip() def parse_requirements(path): if not os.path.exists(path): return with open(path) as f: for idx, line in enumerate(f, 1): cleaned = _strip_inline_comment(line) if not cleaned or cleaned.startswith("-"): continue try: req = Requirement(cleaned) version = next( (s.version for s in req.specifier if s.operator in {"==", "==="}), None, ) if req.name not in EXCLUDE_PACKAGES: yield req.name, version, path, idx, cleaned except InvalidRequirement: continue def parse_pyproject_toml(path): """Yield (name, version, path) tuples from a pyproject.toml file. Handles: - [project].dependencies (PEP 508 list, PEP 517/518) - [dependency-groups].* (PEP 735 — string entries only) - [tool.poetry.dependencies] (dict of name: version/constraint) """ if not os.path.exists(path): return try: with open(path, "rb") as f: data = tomllib.load(f) except Exception: return pep508_lists = [] # PEP 517/518 — [project].dependencies project_deps = data.get("project", {}).get("dependencies", []) if isinstance(project_deps, list): pep508_lists.append(project_deps) # PEP 735 — [dependency-groups]. (entries may be strings or dicts) for group_entries in data.get("dependency-groups", {}).values(): if isinstance(group_entries, list): pep508_lists.append([e for e in group_entries if isinstance(e, str)]) for dep_list in pep508_lists: for entry in dep_list: cleaned = _strip_inline_comment(entry).strip() if not cleaned: continue try: req = Requirement(cleaned) version = next( (s.version for s in req.specifier if s.operator in {"==", "==="}), None, ) if req.name not in EXCLUDE_PACKAGES: yield req.name, version, path except InvalidRequirement: continue # Poetry — [tool.poetry.dependencies] (dict: name → version string or dict) poetry_deps = data.get("tool", {}).get("poetry", {}).get("dependencies", {}) if isinstance(poetry_deps, dict): for name, spec in poetry_deps.items(): if name.lower() in ("python",) or name in EXCLUDE_PACKAGES: continue version = None if isinstance(spec, str) and spec.startswith("=="): version = spec[2:] elif isinstance(spec, dict): v = spec.get("version", "") if isinstance(v, str) and v.startswith("=="): version = v[2:] yield name, version, path def parse_npm_dependencies(path): try: with open(path) as f: data = json.load(f) deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})} for name, version in deps.items(): if name not in EXCLUDE_PACKAGES: yield name, version, path except Exception: return # --- Scan Engines --- def scan_pypi_remote(pkg): name, version, file_path, line_num, orig_line = pkg print(emojize(f":mag_right: Scanning PyPI: {name} {version or ''}").strip()) try: return { "ok": True, "result": pypi_scanner.scan_remote(name, version, rules=INCLUDE_RULES), "pkg": pkg, } except Exception as e: return {"ok": False, "error": str(e), "pkg": pkg} TRANSIENT_ERRORS = ( "Name or service not known", "closing socket", "Connection refused", "Connection reset", "timed out", "RemoteDisconnected", "IncompleteRead", ) def _is_transient(error: str) -> bool: return any(e.lower() in error.lower() for e in TRANSIENT_ERRORS) def scan_npm_remote(pkg, max_retries: int = 3, retry_delay: float = 2.0): name, version, file_path = pkg # Skip packages belonging to a known private scope if name.startswith("@"): scope = name.split("/")[0].lstrip("@") if scope in PRIVATE_NPM_SCOPES: print( emojize( f":warning: Skipping npm: {name} (private scope @{scope})" ).strip() ) return { "ok": True, "skipped": True, "result": {"findings": [], "errors": {}}, "pkg": pkg, } print(emojize(f":mag_right: Scanning npm: {name} {version or ''}").strip()) for attempt in range(1, max_retries + 1): try: result = npm_scanner.scan_remote(name, version, rules=INCLUDE_RULES) return {"ok": True, "skipped": False, "result": result, "pkg": pkg} except Exception as e: error_str = str(e) if _is_transient(error_str) and attempt < max_retries: wait = retry_delay * attempt print( emojize( f":warning: npm: {name} — transient error (attempt {attempt}/{max_retries}), retrying in {wait:.0f}s..." ) ) time.sleep(wait) else: return {"ok": False, "error": error_str, "pkg": pkg} return {"ok": False, "error": "max retries exceeded", "pkg": pkg} def scan_claude_md(file_path): """Scan a claude.md file for prompt injection and other suspicious patterns.""" return _scan_instruction_file(file_path, CLAUDE_MD_RULES) def scan_agents_md(file_path): """Scan an AGENTS.md file (OpenAI Codex/GPT agent instructions) for suspicious patterns.""" return _scan_instruction_file(file_path, AGENTS_MD_RULES) def scan_copilot_instructions(file_path): """Scan a .github/copilot-instructions.md file for prompt injection and suspicious patterns.""" return _scan_instruction_file(file_path, COPILOT_INSTRUCTIONS_RULES) def scan_skills_md(file_path): """Scan a skills.md file for prompt injection and suspicious patterns.""" return _scan_instruction_file(file_path, SKILLS_MD_RULES) # Instruction fields inside Claude plugin manifests that may carry prompt injections. _CLAUDE_PLUGIN_TEXT_FIELDS = ( "description_for_model", "description_for_human", "instructions", "system_prompt", "prompt", ) def scan_claude_plugin(file_path): """Scan a Claude marketplace plugin manifest for prompt injection and suspicious config. Handles two shapes: - Plugin manifest (claude-plugin.json / claude_plugin.json): top-level text fields such as description_for_model, instructions, api.url … - MCP-style config (claude_desktop_config.json / .claude/settings.json): mcpServers block — delegated to scan_mcp_json(). """ findings = [] try: with open(file_path) as f: data = json.load(f) except Exception as e: return [ {"rule": "read-error", "description": str(e), "line": None, "snippet": None} ] # --- MCP servers block (Claude Desktop / Claude Code) --- if "mcpServers" in data: for hit in scan_mcp_json(file_path): findings.append( { "rule": hit["rule"], "description": hit["description"], "line": None, "snippet": hit.get("snippet"), } ) # --- Plugin manifest text fields (prompt injection) --- for field in _CLAUDE_PLUGIN_TEXT_FIELDS: value = data.get(field) if not isinstance(value, str) or not value.strip(): continue for line_num, line in enumerate(value.splitlines(), 1): for pattern, rule_id, description in CLAUDE_MD_RULES: if pattern.flags & re.S: continue if pattern.search(line): findings.append( { "rule": "claude-plugin-injection", "description": f"{description} (field: {field})", "line": None, "snippet": line.strip()[:120], } ) # multiline patterns for pattern, rule_id, description in CLAUDE_MD_RULES: if pattern.flags & re.S and pattern.search(value): findings.append( { "rule": "claude-plugin-injection", "description": f"{description} (field: {field})", "line": None, "snippet": None, } ) # --- API / OAuth URL checks --- api_url = ( data.get("api", {}).get("url", "") if isinstance(data.get("api"), dict) else "" ) auth_url = ( data.get("auth", {}).get("client_url", "") or data.get("auth", {}).get("authorization_url", "") if isinstance(data.get("auth"), dict) else "" ) for label, url in (("api.url", api_url), ("auth.url", auth_url)): if not url: continue for pattern, rule_id, description in MCP_JSON_RULES: if pattern.search(url): findings.append( { "rule": rule_id, "description": f"{description} (field: {label})", "line": None, "snippet": url[:120], } ) break # one hit per URL is enough return findings def _scan_instruction_file(file_path, rules): """Generic scanner for AI instruction markdown files.""" findings = [] try: content = open(file_path).read() except Exception as e: return [{"rule": "read-error", "description": str(e), "line": None}] for line_num, line in enumerate(content.splitlines(), 1): for pattern, rule_id, description in rules: if pattern.flags & re.S: continue if pattern.search(line): findings.append( { "rule": rule_id, "description": description, "line": line_num, "snippet": line.strip(), } ) for pattern, rule_id, description in rules: if pattern.flags & re.S: if pattern.search(content): findings.append( { "rule": rule_id, "description": description, "line": None, "snippet": None, } ) return findings def scan_mcp_json(file_path): """Scan an mcp.json config for suspicious commands, args and env values.""" findings = [] try: with open(file_path) as f: config = json.load(f) except Exception as e: return [ {"rule": "read-error", "description": str(e), "server": None, "field": None} ] servers = config.get("mcpServers", {}) for server_name, server_cfg in servers.items(): # Collect all string values from command, args, and env candidates = [] if "command" in server_cfg: candidates.append(("command", str(server_cfg["command"]))) for arg in server_cfg.get("args", []): candidates.append(("args", str(arg))) for k, v in server_cfg.get("env", {}).items(): candidates.append((f"env.{k}", str(v))) for field, value in candidates: for pattern, rule_id, description in MCP_JSON_RULES: if pattern.search(value): findings.append( { "rule": rule_id, "description": description, "server": server_name, "field": field, "snippet": value[:120], } ) return findings SCAN_TYPE_ALL = "all" SCAN_TYPE_AGENTIC = "AgenticScan" SCAN_TYPE_PACKAGES = "Packages" SCAN_TYPES = [SCAN_TYPE_ALL, SCAN_TYPE_AGENTIC, SCAN_TYPE_PACKAGES] # Which file lists are relevant to each scan type _AGENTIC_LISTS = ( "claude_md_files", "copilot_files", "agents_md_files", "skills_md_files", "claude_plugin_files", "mcp_configs", ) _PACKAGE_LISTS = ("requirements_files", "pyproject_files", "package_json_files") _AGENTIC_EXPECTED = "claude.md, AGENTS.md, skills.md, .github/copilot-instructions.md, mcp.json, claude_desktop_config.json, .claude/settings.json, claude-plugin.json" _PACKAGE_EXPECTED = "requirements.txt, pyproject.toml, package.json" def main(): import argparse parser = argparse.ArgumentParser(description="GuardDog Multi-Scanner") parser.add_argument("target_repo", nargs="?", default=".", help="Path to scan") parser.add_argument( "--scan-type", choices=SCAN_TYPES, default=SCAN_TYPE_ALL, help=( "all (default): run every scanner; " "AgenticScan: instruction-file + MCP only (fast, offline); " "Packages: PyPI + npm supply-chain only" ), ) args = parser.parse_args() target_path = os.path.abspath(args.target_repo) scan_type = args.scan_type if not os.path.exists(target_path): print(emojize(f":x: Target path does not exist: {target_path}")) sys.exit(1) if not os.path.isdir(target_path): print(emojize(f":x: Target path is not a directory: {target_path}")) sys.exit(1) print(emojize(f":dog: Starting GuardDog Multi-Scanner on: {target_path}")) print(emojize(f":mag: Scan type: {scan_type}")) ( requirements_files, package_json_files, mcp_configs, claude_md_files, copilot_files, agents_md_files, pyproject_files, skills_md_files, claude_plugin_files, ) = [], [], [], [], [], [], [], [], [] # Claude Desktop / Claude Code / plugin manifest filenames _CLAUDE_PLUGIN_FILENAMES = { "claude_desktop_config.json", "claude-plugin.json", "claude_plugin.json", } for root, _, files in os.walk(target_path): for file in files: full_path = os.path.join(root, file) if file == "requirements.txt": requirements_files.append(full_path) elif file == "pyproject.toml": pyproject_files.append(full_path) elif file == "package.json": package_json_files.append(full_path) elif file == "mcp.json": mcp_configs.append(full_path) elif file.lower() == "claude.md": claude_md_files.append(full_path) elif file.upper() == "AGENTS.MD": agents_md_files.append(full_path) elif file.lower() == "skills.md": skills_md_files.append(full_path) elif file.lower() == "copilot-instructions.md" and ".github" in root: copilot_files.append(full_path) elif file in _CLAUDE_PLUGIN_FILENAMES: claude_plugin_files.append(full_path) elif file == "settings.json" and os.path.basename(root) == ".claude": claude_plugin_files.append(full_path) # Guard: ensure the chosen scan type has at least one file to work with _agentic_present = False _packages_present = False if scan_type in (SCAN_TYPE_ALL, SCAN_TYPE_AGENTIC): _agentic_present = any( [ mcp_configs, claude_md_files, copilot_files, agents_md_files, skills_md_files, claude_plugin_files, ] ) if scan_type in (SCAN_TYPE_ALL, SCAN_TYPE_PACKAGES): _packages_present = any( [requirements_files, pyproject_files, package_json_files] ) if scan_type == SCAN_TYPE_AGENTIC and not _agentic_present: print(emojize(f":x: No agentic files found in: {target_path}")) print(emojize(f":x: Expected at least one of: {_AGENTIC_EXPECTED}")) sys.exit(1) elif scan_type == SCAN_TYPE_PACKAGES and not _packages_present: print(emojize(f":x: No package manifest files found in: {target_path}")) print(emojize(f":x: Expected at least one of: {_PACKAGE_EXPECTED}")) sys.exit(1) elif scan_type == SCAN_TYPE_ALL and not _agentic_present and not _packages_present: print(emojize(f":x: No scannable files found in: {target_path}")) print( emojize( f":x: Expected at least one of: {_AGENTIC_EXPECTED}, {_PACKAGE_EXPECTED}" ) ) sys.exit(1) findings_found = False sarif_results = [] run_agentic = scan_type in (SCAN_TYPE_ALL, SCAN_TYPE_AGENTIC) run_packages = scan_type in (SCAN_TYPE_ALL, SCAN_TYPE_PACKAGES) # --- 0. claude.md / CLAUDE.md Prompt-Injection Scan --- if run_agentic and claude_md_files: print( emojize( f":brain: Scanning {len(claude_md_files)} claude.md file(s) for prompt injection..." ) ) for claude_file in claude_md_files: rel = os.path.relpath(claude_file, start=target_path) file_findings = scan_claude_md(claude_file) if file_findings: findings_found = True print(emojize(f":rotating_light: Suspicious content in {rel}:")) for f in file_findings: loc = f"line {f['line']}" if f["line"] else "full-file" print(f" [{f['rule']}] {f['description']} ({loc})") if f.get("snippet"): print(f" → {f['snippet'][:120]}") sarif_results.append( { "ruleId": f["rule"], "level": "error", "message": {"text": f["description"]}, "locations": [ { "physicalLocation": { "artifactLocation": {"uri": rel}, "region": {"startLine": f["line"] or 1}, } } ], } ) else: print( emojize(f":white_check_mark: {rel} — no suspicious patterns found") ) # --- 0b. .github/copilot-instructions.md Scan --- if run_agentic and copilot_files: print( emojize( f":robot_face: Scanning {len(copilot_files)} copilot-instructions.md file(s)..." ) ) for copilot_file in copilot_files: rel = os.path.relpath(copilot_file, start=target_path) file_findings = scan_copilot_instructions(copilot_file) if file_findings: findings_found = True print(emojize(f":rotating_light: Suspicious content in {rel}:")) for f in file_findings: loc = f"line {f['line']}" if f["line"] else "full-file" print(f" [{f['rule']}] {f['description']} ({loc})") if f.get("snippet"): print(f" → {f['snippet'][:120]}") sarif_results.append( { "ruleId": f["rule"], "level": "error", "message": {"text": f["description"]}, "locations": [ { "physicalLocation": { "artifactLocation": {"uri": rel}, "region": {"startLine": f["line"] or 1}, } } ], } ) else: print( emojize(f":white_check_mark: {rel} — no suspicious patterns found") ) # --- 0c. AGENTS.md Scan (OpenAI Codex / GPT agent instructions) --- if run_agentic and agents_md_files: print( emojize( f":robot: Scanning {len(agents_md_files)} AGENTS.md file(s) for prompt injection..." ) ) for agents_file in agents_md_files: rel = os.path.relpath(agents_file, start=target_path) file_findings = scan_agents_md(agents_file) if file_findings: findings_found = True print(emojize(f":rotating_light: Suspicious content in {rel}:")) for f in file_findings: loc = f"line {f['line']}" if f["line"] else "full-file" print(f" [{f['rule']}] {f['description']} ({loc})") if f.get("snippet"): print(f" → {f['snippet'][:120]}") sarif_results.append( { "ruleId": f["rule"], "level": "error", "message": {"text": f["description"]}, "locations": [ { "physicalLocation": { "artifactLocation": {"uri": rel}, "region": {"startLine": f["line"] or 1}, } } ], } ) else: print( emojize(f":white_check_mark: {rel} — no suspicious patterns found") ) # --- 0d. skills.md Scan --- if run_agentic and skills_md_files: print( emojize( f":books: Scanning {len(skills_md_files)} skills.md file(s) for prompt injection..." ) ) for skills_file in skills_md_files: rel = os.path.relpath(skills_file, start=target_path) file_findings = scan_skills_md(skills_file) if file_findings: findings_found = True print(emojize(f":rotating_light: Suspicious content in {rel}:")) for f in file_findings: loc = f"line {f['line']}" if f["line"] else "full-file" print(f" [{f['rule']}] {f['description']} ({loc})") if f.get("snippet"): print(f" → {f['snippet'][:120]}") sarif_results.append( { "ruleId": f["rule"], "level": "error", "message": {"text": f["description"]}, "locations": [ { "physicalLocation": { "artifactLocation": {"uri": rel}, "region": {"startLine": f["line"] or 1}, } } ], } ) else: print( emojize(f":white_check_mark: {rel} — no suspicious patterns found") ) # --- 0e. Claude Marketplace Plugin / Desktop Config Scan --- if run_agentic and claude_plugin_files: print( emojize( f":purple_heart: Scanning {len(claude_plugin_files)} Claude plugin/config file(s)..." ) ) for plugin_file in claude_plugin_files: rel = os.path.relpath(plugin_file, start=target_path) print(emojize(f":mag: Auditing Claude plugin/config: {rel}")) file_findings = scan_claude_plugin(plugin_file) if file_findings: findings_found = True print(emojize(f":rotating_light: Suspicious content in {rel}:")) for f in file_findings: loc = f"line {f['line']}" if f.get("line") else "full-file" print(f" [{f['rule']}] {f['description']} ({loc})") if f.get("snippet"): print(f" → {f['snippet'][:120]}") sarif_results.append( { "ruleId": f["rule"], "level": "error", "message": {"text": f["description"]}, "locations": [ { "physicalLocation": { "artifactLocation": {"uri": rel}, "region": {"startLine": f.get("line") or 1}, } } ], } ) else: print( emojize(f":white_check_mark: {rel} — no suspicious patterns found") ) # --- 1. MCP Server Behavioral Scan --- if run_agentic and mcp_configs: print( emojize( f":robot: Detected {len(mcp_configs)} MCP Server(s). Running behavioral audit..." ) ) for mcp_file in mcp_configs: rel = os.path.relpath(mcp_file, start=target_path) print(emojize(f":mag: Auditing MCP config: {rel}")) # 1a. Scan the JSON config itself for suspicious commands/args/env config_findings = scan_mcp_json(mcp_file) if config_findings: findings_found = True print(emojize(f":rotating_light: Suspicious config in {rel}:")) for f in config_findings: loc = ( f"server={f['server']} field={f['field']}" if f["server"] else "config" ) print(f" [{f['rule']}] {f['description']} ({loc})") if f.get("snippet"): print(f" → {f['snippet']}") sarif_results.append( { "ruleId": f["rule"], "level": "error", "message": { "text": f"{f['description']} in {f.get('server', '?')}.{f.get('field', '?')}" }, "locations": [ { "physicalLocation": { "artifactLocation": {"uri": rel}, "region": {"startLine": 1}, } } ], } ) else: print( emojize( f":white_check_mark: {rel} config — no suspicious patterns found" ) ) # 1b. Scan the MCP server source code if present mcp_dir = os.path.dirname(mcp_file) try: result = pypi_scanner.scan_local(mcp_dir, rules=INCLUDE_RULES) src_findings = result.get("findings", []) if src_findings: findings_found = True print( emojize( f":rotating_light: Malicious patterns in MCP source at {mcp_dir}:" ) ) for f in src_findings: print(f" - {f}") except Exception as e: logger.debug("MCP source scan skipped for %s: %s", mcp_dir, e) # --- 2. Parallel PyPI Scan --- if run_packages: all_pypi = [] for f in requirements_files: all_pypi.extend(list(parse_requirements(f))) for f in pyproject_files: all_pypi.extend( (name, version, path, None, f"{name}") for name, version, path in parse_pyproject_toml(f) ) if all_pypi: print(emojize(f":rocket: Scanning {len(all_pypi)} PyPI packages...")) with ThreadPoolExecutor(max_workers=SCAN_WORKERS) as executor: for item in executor.map(scan_pypi_remote, all_pypi): if not item["ok"]: continue findings = item["result"].get("findings", []) if findings: findings_found = True print( emojize( f":rotating_light: Findings for PyPI {item['pkg'][0]}: {findings}" ) ) # --- 3. Parallel NPM Scan --- if run_packages: all_npm = [] for f in package_json_files: all_npm.extend(list(parse_npm_dependencies(f))) if all_npm: print( emojize( f":rocket: Scanning {len(all_npm)} npm packages with {NPM_SCAN_WORKERS} workers..." ) ) with ThreadPoolExecutor(max_workers=NPM_SCAN_WORKERS) as executor: for item in executor.map(scan_npm_remote, all_npm): if item.get("skipped"): continue if not item["ok"]: print( emojize( f":warning: npm: {item['pkg'][0]} — skipped after retries: {item['error']}" ) ) continue # Package not on public npm registry — skip, don't fail if item["result"].get("errors", {}).get("download-package"): print( emojize( f":warning: Skipping npm: {item['pkg'][0]} (not on public npm registry)" ) ) continue findings = item["result"].get("findings", []) if findings: findings_found = True print( emojize( f":rotating_light: Findings for npm {item['pkg'][0]}: {findings}" ) ) # --- Write SARIF report --- # Collect only the rule IDs that appear in this scan's results used_rule_ids = {r["ruleId"] for r in sarif_results} sarif_rules = [ { "id": rule_id, "name": SARIF_RULE_DEFINITIONS[rule_id][0] if rule_id in SARIF_RULE_DEFINITIONS else rule_id, "shortDescription": { "text": SARIF_RULE_DEFINITIONS[rule_id][1] if rule_id in SARIF_RULE_DEFINITIONS else rule_id }, "helpUri": "https://github.com/theorchard/python-deployment-utils/tree/master/guarddog", } for rule_id in sorted(used_rule_ids) ] sarif_output = { "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json", "version": "2.1.0", "runs": [ { "tool": { "driver": { "name": "guarddog", "informationUri": "https://github.com/theorchard/python-deployment-utils/tree/master/guarddog", "rules": sarif_rules, } }, "results": sarif_results, } ], } sarif_path = "guarddog-results.sarif" with open(sarif_path, "w") as f: json.dump(sarif_output, f, indent=2) print(emojize(f"\n:page_facing_up: SARIF report ({len(sarif_results)} result(s)):")) print(json.dumps(sarif_output, indent=2)) # --- Output Result --- if findings_found: print(emojize(":x: Scan failed. Actionable vulnerabilities found.")) sys.exit(1) else: print(emojize(":white_check_mark: All clear! No malicious patterns detected.")) sys.exit(0) if __name__ == "__main__": main()