Last active
May 10, 2026 20:30
-
-
Save muhanLuo/8c888dc2764a55724eeadf035b830e98 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| Script Name: wp_find-missing-auth-ajax-vulns.py | |
| Description: Scans a directory of WordPress plugins for AJAX hooks with missing authorization issues. Then triages these findings using Claude. Output format is .sarif | |
| Author: Muhan Luo | |
| Date: 2026-05-10 | |
| (Note: This script is specifically intended to be used with the following Semgrep rule, otherwise the script doesn't work: https://github.com/muhanLuo/wordpress-plugin-semgrep-rules/blob/main/missing-auth/wp-ajax-hook-missing-auth.yml) | |
| Dependency Versions: | |
| anthropic==0.100.0 | |
| semgrep==1.157.0 | |
| Example Usage: | |
| python3 wp_find-missing-auth-ajax-vulns.py --plugin-repo "/home/user/Top 10000 WP Plugins/unzipped/" --rule-location "/home/user/missing-auth/wp-ajax-hook-missing-auth.yml" | |
| """ | |
| import os | |
| import subprocess | |
| import anthropic | |
| import json | |
| import argparse | |
| # Sets up the Claude Client | |
| claude_client = anthropic.Anthropic( | |
| api_key="<API_KEY>", | |
| ) | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument('-p', '--plugin-repo', help='Directory containing all the WordPress plugins to be scanned', required=True) | |
| parser.add_argument('-r', '--rule-location', help='Location of the Missing Auth Semgrep rule', required=True) | |
| args = parser.parse_args() | |
| plugin_repo = args.plugin_repo # Directory containing all the WordPress plugins to be scanned | |
| rule_location = args.rule_location # Location of the Missing Auth Semgrep rule | |
| # Loads the cache file. This file stores a list of plugins which have been already scanned and reviewed. | |
| already_scanned_plugins = [] | |
| if ".already-scanned" in os.listdir(): | |
| cache_file = open(".already-scanned") | |
| already_scanned_plugins = cache_file.read().split("\n") | |
| cache_file.close() | |
| for plugin in os.listdir(plugin_repo): | |
| if plugin in already_scanned_plugins: | |
| print(f"Skipping {plugin}, already scanned...") | |
| else: | |
| print("----------------------") | |
| print(f"Scanning {plugin}...") | |
| sarif_location = f"{plugin}-scan.sarif" | |
| command = [ | |
| "semgrep", | |
| "scan", | |
| "--config", | |
| rule_location, | |
| "--sarif", | |
| "--output", | |
| sarif_location, | |
| "--quiet", # Suppress non-essential output | |
| "--timeout", | |
| "360", | |
| plugin_repo + plugin, | |
| ] | |
| try: | |
| # Run semgrep to scan the plugin's codebase. Output is a SARIF file | |
| subprocess.run(command, check=True) | |
| print(f"Semgrep analysis completed for {plugin}.") | |
| # Read the SARIF file created by Semgrep and analyze it using Claude | |
| print(f"Starting Claude Analysis for {sarif_location}.") | |
| f = open(sarif_location) | |
| new_sarif_output = json.loads(f.read()) | |
| f.close() | |
| results = new_sarif_output["runs"][0]["results"] | |
| # Iterate through each finding in the SARIF file | |
| for finding in range(len(results)): | |
| # Extract the AJAX hook's callback | |
| code_snippet = results[finding]["locations"][0]["physicalLocation"]["region"]["snippet"]["text"] | |
| """ | |
| Send the code snippet to Claude and have Claude review it. | |
| """ | |
| claude_response = claude_client.messages.create( | |
| model="claude-opus-4-6", | |
| max_tokens=20000, | |
| temperature=1, | |
| system=[{"type":"text", | |
| "cache_control": {"type": "ephemeral"}, | |
| "text":"You are an expert application security engineer reviewing WordPress PHP code. You will be provided with a PHP function snippet (let's call this $VULN_FUNC) which was found to not include capability checks or nonce verification according to Semgrep. This was determined by looking for functions which did not contain any of these functions in their body: current_user_can(), wp_verify_nonce(), check_ajax_referer(), and check_admin_referer(). \n\nYour goal is to determine if it would be a security risk if $VULN_FUNC could be called by a Subscriber-level user in WordPress. You will return a risk score from 1-5 ( 1 meaning very low security risk and 5 very high security risk).\n\nScore 5: High likelihood + High impact (e.g., arbitrary file deletion, SQL injection)\nScore 4: High likelihood + Medium impact OR Medium likelihood + High impact\nScore 3: Medium likelihood + Medium impact\nScore 2: Low likelihood OR low impact\nScore 1: Minimal risk (e.g., reading non-sensitive public data)\n\nYour methodology for determining the score is as follows:\n\n- First, determine the likelihood of exploitation. We are only looking functions with no-auth and which do not perform nonce checks. If $VULN_FUNC's body contains any function whose name strongly suggests that it performs nonce verification or authorization checks, significantly lower the score. (Ex: check_nonce(), verify_auth(), but obviously not limited to these examples) Important note that the is_admin() function in WordPress does not actually check for authorization. If uncertain, err towards lower scores.\n- Next, determine the impact. If $VULN_FUNC could be called by a Subscriber-level user, would this be a security issue? For example, downgrade the score if $VULN_FUNC doesn't appear to take user input and/or doesn't perform any sensitive or business critical mutating operations. Again, err towards lower scores if uncertain. \n\nYour return format should be a string that follows this format:\n\nscore, explanation\n\n\"score\" should be an integer value 1-5 representing the risk score determined. The second part \"explanation\" should contain a very brief (150 words or less) explanation of how the score was determined."}], | |
| messages=[ | |
| { | |
| "role": "user", | |
| "content": [ | |
| { | |
| "type": "text", | |
| "text": code_snippet | |
| } | |
| ] | |
| } | |
| ] | |
| ) | |
| output_message = claude_response.content[0].text # Extracts the response message from the Textblock object returned by Claude | |
| new_sarif_output["runs"][0]["results"][finding]["message"]["text"] = output_message # Save the message to the new output file | |
| # Write Claude's analysis to the SARIF file | |
| o = open(sarif_location, "w") | |
| o.write(json.dumps(new_sarif_output)) | |
| o.close() | |
| # Write to cache file that we've scanned this plugin's code with Semgrep + analyzed with Claude | |
| cache_file = open(".already-scanned", "a") | |
| cache_file.write(plugin + "\n") | |
| cache_file.close() | |
| print(f"Finished Semgrep + Claude Analysis for {sarif_location}.") | |
| except Exception as e: | |
| print(f"Semgrep + Claude failed for {plugin}") | |
| print(e) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment