Constants
ACCOUNT_NAMESPACE_SEGMENT_PATTERN = /(?:^|\/)(user|users|account|accounts|auth)(?:\/|$)/i
ACCOUNT_REGISTER_SEGMENT_PATTERN = /(?:^|\/)(register|sign[_-]?up|signup)(?:\/|$)/i
ANNOTATION_SEGMENT = /\A\d+: (?:@|#\[|\[)/
AUTH_LIFECYCLE_SEGMENT_PATTERN = /(?:^|\/)(login|log[_-]?in|logout|log[_-]?out|authenticate|refresh[_-]?token|verify|verification|reset[_-]?password|forgot[_-]?password|password[_-]?reset)(?:\/|$)/i
AUTH_NAMESPACE_PATTERN = /(?:^|\/)auth(?:\/|$)/i
BODY_JUDGED_SINK_KINDS =
Set {"template_render", "deserialization"}
Sink kinds whose callee names are words a project reuses for its own
helpers (render, unserialize). The other kinds keep the name match:
their catalogs miss common body forms (Python open(, Django
HttpResponseRedirect), so judging a helper's body would drop real ones.
BODY_LIKE_PARAM_TYPES =
Set {"json", "form"}
CALLEE_SNIPPET_RADIUS = 1
CLASS_HEADER = /\A\s*(?:(?:public|private|protected|internal|static|final|abstract|sealed|open|data|partial|export|default|inner)\s+)*(?:class|object|module|struct|interface|trait|impl|enum|record|defmodule)\b/
COMPANION_HEADER = /\A\s*(?:(?:private|internal|public)\s+)?companion\s+object\b/
A Kotlin companion object belongs to its class: a bare call reaches it.
CREDENTIAL_INPUT_NAME_PATTERNS = PARAM_PATTERNS.find! do |pattern| pattern.kind == "credential_input" end.name_patterns
CREDENTIAL_KEY_IN_RESPONSE =
Regex.new("(?:[^\"'\\w](#{
CREDENTIAL_NOUNS_IN_RESPONSE})|([\"'])(#{
CREDENTIAL_NOUNS_IN_RESPONSE})\\2)\\s*:",
Regex::Options::IGNORE_CASE)
Capture 1 is the bare-key noun, capture 3 the quoted-key noun;
exactly one of them is set per match.
CREDENTIAL_NOUNS_IN_RESPONSE = "password|passwd|token|secret|api[_-]?key|session_id|access_token|refresh_token|private_key"
Sensitive-response detection runs as a two-step check on the
route-scope snippet:
- RESPONSE_EMITTER_PATTERN — the snippet serializes a response,
either through a helper (
res.json, jsonify, render json:,
to_json, …) or by returning a literal (return {...}).
- CREDENTIAL_KEY_IN_RESPONSE — the snippet also has a
credential noun as a key, in either of the two spellings
a response literal uses:
bare —
{ token: … } (JS/TS shorthand, Ruby, YAML)
quoted — { "token": … } (JSON, Python dicts, Go maps,
PHP arrays, any serialized body)
Both have to match in the same scope. The earlier single-regex
version was too loose and caught english prose mentioning
credentials in response strings, so the fix required the noun
not be preceded by a quote. That over-corrected: it also
excluded every quoted key, which is the dominant shape in
most languages, and the signal quietly stopped firing on them.
The two branches below keep the original guard for bare keys
while admitting quoted ones, and stay narrow because the quoted
branch requires the string to close immediately after the
noun. { message: "Set X-API-KEY header" } still doesn't fire —
the noun sits mid-string, so no closing quote follows it.
CREDENTIAL_SOURCE_PATTERNS = [/(?:const|let|var)\s*\{[^}]*\b(password|passwd|token|secret|api[_-]?key|jwt|bearer)\b[^}]*\}\s*=\s*req\.body/i, /\breq\.body\.(password|passwd|token|secret|api[_-]?key|jwt|bearer)\b/i, /\brequest\.(form|json|data)\[\s*['"](password|passwd|token|secret|api[_-]?key|jwt|bearer)['"]/i, /\b(payload|credentials|creds|input|body)\.(password|passwd|token|secret|api[_-]?key|jwt|bearer)\b/i, /\b(?:r|req|request)\.(?:FormValue|PostFormValue)\s*\(\s*['"](password|passwd|token|secret|api[_-]?key|jwt|bearer)['"]/i, /\b(?:c|ctx|context)\.(?:PostForm|DefaultPostForm|FormValue|QueryParam)\s*\(\s*['"](password|passwd|token|secret|api[_-]?key|jwt|bearer)['"]/i, /\b(?:context\.Request|HttpContext\.Request|Request)\.(?:Form|Query|Headers|Cookies)\s*\[\s*['"](password|passwd|token|secret|api[_-]?key|authorization|jwt|bearer)['"]/i, /\b(password|passwd|token|secret|api[_-]?key|jwt|bearer)\s*=\s*req\./i]
Some analyzers (express's loose destructuring, Java route extractors
that don't follow @RequestBody Credentials c.password, …) miss
credential-bearing parameters at extract time. When that happens
the param-based credential_input signal never fires and downstream
heuristics like rate_limit_absence silently skip the route.
As a backstop, scan the route-scope snippet for the canonical
request-body destructuring shapes (req.body.password,
request.form['password'], { password } = req.body, …) and
emit a credential_input signal with a slightly lower confidence
than the param-based one. The kind is the same so downstream
rate_limit_absence / guard_absence logic catches it transparently.
CRYPTO_FACTORY_CALLEE = /\
A(?:create(?:
Hash|
Hmac|
Cipheriv|
Decipheriv|
Sign|
Verify)|md5|sha\d*)\z/
DATA_FRAME_RECEIVER = /(?:\A|\.)(?:\w*_?df|\w*[fF]rame|pl|pd)\.select\z/i
FOREIGN_IDENTIFIER_ASSIGNMENT_PATTERN = /\b([A-Za-z_][A-Za-z0-9_]*Id)\s*=\s*(?:[A-Za-z_][A-Za-z0-9_]*\.)?([A-Za-z_][A-Za-z0-9_]*Id)\b/
GRAPHQL_OPERATION_ROOTS = ["Query", "Mutation", "Subscription"]
GRAPHQL_RESOLVER_ANNOTATION_PATTERN = /@(QueryMapping|MutationMapping|SchemaMapping|SubscriptionMapping|BatchMapping|DgsQuery|DgsMutation|DgsData|DgsSubscription)\b/
HASH_COMMENT_EXTENSIONS = {".rb", ".rake", ".ru", ".cr", ".ex", ".exs", ".py", ".pl", ".pm"}
HELPER_BODY_CALL = /(?:(?<recv>\$?\w+)?(?<op>\.|->|::)\s*)?(?<![\w$\\])(?<name>\w+)\s*\(/
Every call in a helper body: an optional receiver (torch, $smarty,
none after )), the operator, and the called name.
HELPER_BODY_KEYWORDS =
Set {"if", "elif", "for", "while", "switch", "catch", "return", "def", "fun", "function", "func", "fn", "sub", "new", "not", "and", "or", "in", "print", "super", "lambda", "with", "match", "when", "unless", "until"}
HELPER_BODY_SINK_CALLS = {"template_render" => /\brender_to_string\b|\b
Execute(?:
Template)?\s*\(|\b\w*(?:engine|template|tpl|tmpl|view)\w*\.(?:process|render|execute|evaluate|merge|fill)\w*\s*\(|\bget_template\b|\b
TemplateResponse\b|\b
ModelAndView\b|\b(?:include|require)(?:_once)?\b[^;|]*\$|(?<![\w.>])view\s*\(|\b(?:
ERB|
Erubi|
Liquid|
Handlebars|
Mustache|
Jinja2?|
Velocity|
Twig)\b/i, "deserialization" => /\b\w*pickle\.\w*load|\b(?:dill|shelve|marshal|jsonpickle)\.\w*load|\b
Unpickler\b|(?<![\w.])loads?\s*\(|\b(?:yaml|
YAML|
Psych)\.(?:unsafe_|full_)?load\b|\b
Marshal\.(?:load|restore)\b|\b
ObjectInputStream\b|\bread
Object\b|\b(?:
BinaryFormatter|
SoapFormatter|
LosFormatter|
NetDataContractSerializer)\b|\bunserialize\s*\(/}
What a helper's body does when it renders or revives, beyond the kind's
own catalog: Django render_to_string, Go tpls.ExecuteTemplate(w, ...),
Thymeleaf engine.process(view, c), a PHP include of a variable view
path; from pickle import loads + bare loads(d), yaml.unsafe_load,
cloudpickle.loads, pickle.Unpickler(...).load(), Ruby
Marshal.restore. Body-only: the callee-name catalog stays as narrow as
it was.
IDENTIFIER_INPUT_NAME_PATTERNS = PARAM_PATTERNS.find! do |pattern| pattern.kind == "identifier_input" end.name_patterns
Resolved once from the catalog instead of re-scanning PARAM_PATTERNS
on every call. identifier_like? in particular runs several times
per parameter per endpoint, and the answer never changes.
IMPORT_LINE = /\A\s*(?:from\s+\S+\s+import|import|use)\b|\brequire\s*\(/
Lines that bring names in from elsewhere: Python from x import a as render, import / use statements, require(...).
KOTLIN_COLLECTION_ID_LOOKUP_PATTERN = /\b([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*(firstOrNull|find|singleOrNull)\s*\{[^}]*\b(?:it|[A-Za-z_][A-Za-z0-9_]*)\.(?:id|[A-Za-z_][A-Za-z0-9_]*Id)\s*==/i
KOTLIN_COLLECTION_ID_LOOKUP_THROWS_PATTERN = /\b(?:firstOrNull|find|singleOrNull)\s*\{[^}]*\b(?:id|[A-Za-z_][A-Za-z0-9_]*Id)\b[^}]*\}[\s\S]{0,180}(?:\?:\s*throw|\borElseThrow\b|\bthrow\s+\w*(?:NotFound|NotExist|Missing)\b)/i
KOTLIN_CREDENTIAL_RETURN_PATTERN = /\bfun\s+\w+[^{|;]*=\s*(?:this\.)?(password|passwd|token|secret|api[_-]?key|session_?id|access_?token|refresh_?token|private_?key)\b/i
KOTLIN_WRITE_IN_SNIPPET_PATTERN = /\b(?:save|insert|persist|update|create|add|addAll)\s*\(|\.\s*(?:save|insert|persist|update|add|addAll)\s*\(/i
LOG_EMITTER_PATTERN = /\b(?:logger\.(?:info|warn|warning|error|debug|critical|fatal)|log\.(?:info|warn|warning|error|debug)|console\.(?:log|info|warn|error|debug)|print|puts|printf|
System\.out\.println|
Log\.[dwiev])\b/i
Log injection / sensitive data in logs. Catches the canonical
"log user input or credential field directly" shape. False
positives are bounded because both the log call AND a user-
input reference (or credential noun) need to be on the same
line / snippet window.
LOG_INPUT_OR_CRED_PATTERN = /\b(?:req\.body|req\.query|req\.params|request\.form|request\.json|request\.args|params\[|password|passwd|token|secret|api[_-]?key|jwt|session_id|access_token|refresh_token)\b/i
METHOD_DISPATCH_PATTERN = /(?:request\.method\s*==|req\.method\s*==|r\.Method\s*==|request\.getMethod\(\)\s*\.equals|\.match\(\s*['"](?:GET|POST|PUT|PATCH|DELETE|QUERY)['"])/i
Suppression for unsafe_method: route handlers that branch on
request.method (Flask / Django) or req.method (Express,
Node, Go's r.Method, Java's request.getMethod()) typically
register a single route under multiple methods. Analyzers
split that into one endpoint per method but share the
callees list. The mutating callee in the GET endpoint is
often the POST branch's call, not actually reachable via GET.
MOBILE_SOURCE_EXTS =
Set {".swift", ".m", ".mm", ".kt", ".java"}
MUTATING_CALLEE_PATTERN = /\b(create|destroy|delete|update|save|insert|remove|drop|truncate|persist|flush|commit|rollback|set_)\w*/i
Each verb may be followed by additional word chars
(destroy_all, createMany, deleteOne, updateUser), so we
don't anchor with a trailing \b — that would miss those
suffixed forms because _ and word continuation count as the
same word in regex.
MUTATING_POST_CALLEE_PATTERN = /(?:\A|[.:_])(?:create|save|update|delete|insert|remove|destroy|modify|persist|revoke|store)|(?:Create|Save|Update|Delete|Insert|Remove|Destroy|Modify|Persist|Revoke|Store)/
A mutating verb anywhere in the callee name (start, after a separator, or
as a CamelCase segment) disqualifies the endpoint from "read-only POST".
Without this, a callee like getOrCreate/findAndDelete matches the
read-only pattern via its leading read verb, silently suppressing the
state-change review signal on an endpoint that does mutate.
MUTATING_VERB_SEGMENT = /\A(?:create|destroy|delete|update|save|insert|remove|drop|truncate|persist|flush|commit|rollback|set_)/i
OBJECT_LOOKUP_FALLBACK_CALLEE_PATTERN = /(?:^|\.)(?:deleteById|removeById|get\w*ById|retrieve\w*)\b|\b(?:session|db|repo|repository)\.get\b/i
OBJECT_LOOKUP_PRIMARY_CALLEE_PATTERN = /(?:^|\.)(?:findById|findOne|getOne|existsById|find\w*ById|find\w*By\w*Id|(?:find|count|exists)By\w*Id)\b/i
OUTBOUND_ANY_URI_PATTERN = /\.\s*(?:uri|getForObject|postForObject|exchange)\s*\(\s*["']([^"']+)["']/
OUTBOUND_TEMPLATE_URI_PATTERN = /\.\s*(?:getForObject|postForObject|exchange)\s*\(\s*["']([^"']+)["']/
OUTBOUND_URI_PATTERN = /\.\s*uri\s*\(\s*["']([^"']+)["']/
OWN_CLASS_CALL_EXTS =
Set {".java", ".kt", ".cs", ".scala", ".groovy", ".swift", ".dart", ".rb", ".cr"}
Extensions where a bare call reaches a method of the caller's own class
(Kotlin render(id) inside the controller, a Ruby private method).
Elsewhere a bare name is a module-level function.
PERL_HASH_DELIMITED_OP = /(?<![\w$@%])(?:s|m|y|tr|q|qq|qw|qr)\z/
POST_AUTHORIZE_ANNOTATION = /@PostAuthorize\b[^|]*/
@PostAuthorize(...) up to the scope's next line: Spring evaluates it
after the handler returns, so on a write it guards nothing.
PRIORITY_SCORING_SINK_BLACKLIST =
Set {"sql", "data_store_query", "command_exec", "code_eval", "deserialization", "template_injection", "xss", "mass_assignment", "crypto_weak", "webview_load", "intent_redirect"}
Categories whose mere presence is a security-review signal —
used alongside concrete signals to compute the overall
priority bucket.
PRIVILEGED_REALTIME_TAGS =
Set {"admin"}
PURE_CALLEE_RECEIVER = /\A(?:crypto|hashlib|hmac|hash|hasher|digest|\w*SearchParams|searchParams|params|urlParams|queryParams|headers|url|map|set)\z/
Receivers whose mutating-named methods touch only a local value: a
URL or search-param bag (params.delete, redirSearchParams.delete),
headers, a bare Map/Set, a hash module, and a hash/HMAC built by a
call (crypto.createHash().update(), hashlib.sha256().update).
Case-sensitive: Url.create and Digest.create are model classes.
PURE_TEXT_HELPER_CALLEE = /\Aremove_?\w*?(?:links|html|markup|whitespace|spaces|newlines|accents|diacritics|emojis?|punctuation|chars|characters|ansi|prefix|suffix|quotes|markdown|formatting|trailing_?slash(?:es)?|slashes|extension|indent(?:ation)?)\z/i
ponytail: name-only guess for receiverless text helpers
(remove_links, remove_playground_links); resolve the callee's
definition if more of these turn up.
QUOTED_LITERAL = /(["'])[^"'\n]*\1/
READ_ONLY_POST_CALLEE_PATTERN = /(?:^|[.:])(?:find|findAll|findOne|findBy\w+|get|list|listAll|count|search|query|retrieve)\w*\b/i
REPOSITORY_SOURCE_EXTS =
Set {".java", ".kt"}
REQUEST_COOKIE_READ_PATTERN = /\b[A-Za-z_][A-Za-z0-9_]*\s*\.\s*(?:cookies\b|getCookies\s*\()/i
RESPONSE_EMITTER_PATTERN = /\b(jsonify|res\.json|res\.send|json_response|JsonResponse|JSONResponse|make_response|render\s+json:|to_json|respond_with)\b|\breturn\s*\{/i
The emitter list was built from helper calls (jsonify, res.json,
…) and so missed the frameworks that serialize a returned literal
with no helper at all: FastAPI and modern Flask handlers just
return {...}, which is the single most common response shape in
the Python corpus. Without it the credential-key check could not run
on those handlers no matter what the payload held.
REVIEW_PRIORITY_SIGNAL_KINDS =
Set {"guard_absence", "authz_absence", "rate_limit_absence", "idor_review", "csrf_exempt", "jwt_unsafe", "cors_open", "open_redirect", "ssrf", "path_traversal", "sensitive_response", "server_secret_source", "unsafe_method", "log_injection", "deep_link_input"}
Concrete review-worthy signal kinds. These are the "this is
actually scary" categories — not the structural ones
(route_definition, technology, path_param) that every
endpoint surfaces.
ROCKET_PARAM_BINDING = /<(\w+)(?:\.\.)?>/
ROUTE_ARG = /(?:"[^"\n]*"|'[^'\n]*'|`[^`\n]*`|:?[A-Za-z_][\w$]*(?:(?:\.|::)[A-Za-z_][\w$]*)*)/
A route path written as a constant, a qualified name, a literal or a
symbol; a list of them (@GetMapping({A, B})); and a handler string
after it (Rails get 'authorize' => 'oauth#authorize', Laravel
Route::get('authorize', 'OAuthController@authorize')).
ROUTE_ARG_LIST = /#{ROUTE_ARG.source}(?:\s*,\s*#{ROUTE_ARG.source})*/
ROUTE_DECL_ARG_TAIL = /(?=\s*(?:[,)\]}+]|=>|do\b|\z))/
ROUTE_DECL_FIRST_ARG = /\A(\d+: #{ROUTE_DECL_HEAD.source}(?:\s*\(\s*|\s+)(?:(?:value|path)\s*=\s*)?)(?:\[\s*#{ROUTE_ARG_LIST.source}\s*\]|\{\s*#{ROUTE_ARG_LIST.source}\s*\}|arrayOf\s*\(\s*#{ROUTE_ARG_LIST.source}\s*\)|#{ROUTE_ARG.source}(?:\s*(?:=>|,)\s*#{ROUTE_HANDLER_STR.source})?)#{ROUTE_DECL_ARG_TAIL.source}/i
The first argument of the route's own verb or mapping call: its path,
written as a constant (@GetMapping(AUTHORIZE_PATH)) or as a segment
without a leading / (Grape post 'authorize' do). Only the first
argument: a middleware passed after it (router.get('/x', authenticate, h)) is a guard.
ponytail: first positional (or value =/path =) argument only;
@RequestMapping(method = GET, value = X) is not masked.
ROUTE_DECL_HEAD = /(?:[@#]\[?|\[)?(?:[\w$]+(?:\.|::))*(?:get|post|put|patch|delete|path|\w*mapping|http(?:get|post|put|patch|delete)|route(?:\s*\(?\s*:\w+\s*,)?)/
ROUTE_HANDLER_STR = /(?:"[\w\\:\/]+[@#]\w+"|'[\w\\:\/]+[@#]\w+')/
ROUTE_PATH_LITERAL = /(["'`])\/[^"'`\n]*\1/
SAFE_DECODE_RECEIVER = /\
A(?:json|simplejson|ujson|orjson|
JSON|base64|binascii|codecs)\z/
Receivers whose loads / decode revive no objects: the JSON
modules, byte codecs, and a name bound in the body to itsdangerous'
signed-JSON serializers (CTFd: s = URLSafeTimedSerializer(secret)
then s.loads(...)). Ruby's JSON.load / JSON.restore are the
exception: they honour create_additions and revive objects.
SAFE_METHODS = SAFE_HTTP_METHODS
HTTP method intent vs implementation mismatch. A GET/HEAD
endpoint that mutates server state through a callee
(User.create, record.destroy, db.delete, …) is a textbook
CSRF / side-effect-on-read bug — the verb says "safe / idempotent"
but the code says otherwise. QUERY makes the same promise
(RFC 10008 defines it safe + idempotent), so a mutating QUERY
handler earns the same signal. The shared set keeps this file,
the taggers, and the probe path-filler agreeing on what a read is.
SAFE_SERIALIZER_CLASS = /\b(?:URLSafe)?(?:Timed)?(?:JSONWebSignature)?Serializer\b|\b(?:Timestamp)?Signer\b|\bJSONDecoder\b/
SCOPE_SEGMENT_LINE = /\A(\d+): /
SELECT_BUILT_ARGUMENT = /#\{|\$\{|\bf["']|["']\s*(?:\+|%|\.\s*\$)|\+\s*["']|\btext\s*\(|\bArel\.sql\b|\bSprintf\b|\.format\s*\(/
User.select("#{params[:col]}"), SQLAlchemy select(text(request.args["c"])):
a select whose argument is built at run time is SQL whatever the
receiver. A fixed column list (User.select(*attrs)) stays out, and so
do a lambda or block argument (LINQ _items.Select(x => x.Name + ""),
Ruby select(&:active?), JS .select((x) => "#" + x)) and a data
frame's column pick (polars df.select(f"col_{i}")).
SELECT_LAMBDA_ARGUMENT = /\A\s*(?:\(?[\w\s,]*\)?\s*=>|&:|\{\s*\||lambda\b|->|fn\b|function\b|\|)/
SEND_IN_MEMORY_BUFFER = /\bsend_?file\s*\(\s*#{IN_MEMORY_BUFFER}/i
SENSITIVE_RESPONSE_SCOPE_CHARS = 1200
SENSITIVE_RESPONSE_SCOPE_LINES = 40
This check needs the response emitter and the credential key in the
same window, and in real handlers they sit far apart — the body
assembles a dict/struct first and serializes it at the end. Under the
default 12-line / 240-char route scope the two could not co-occur, so
the signal never fired on the shape it exists to catch. Widen the
window for this check only; the scope walk still stops at the end of
the handler, so it cannot pair one route's emitter with another
route's payload.
SINKISH_CALL_NAME = {"template_render" => /render|template|tpl|tmpl|view|display|fetch|result|process|merge|evaluate|compile/i, "deserialization" => /load|restore|deserial|unserial|unmarshal|unpickle|decode|revive|thaw/i}
A call in a helper's body the judge cannot follow but that may well be
the render / revive, by the call's last segment, qualified or not
(torch.load(b), Oj.load, $smarty->display($tpl),
ERB.new(src).result(binding), cfg.getTemplate(n).process(m, out)):
such a helper keeps its sink.
SOAP_READ_OPERATION_PATTERN = /\A(?:[Gg][Ee][Tt]|[Ll][Ii][Ss][Tt]|[Ff][Ii][Nn][Dd])(?:[A-Z]|[_-]|\z)/
SOAP transports every operation over POST. Treat read-style operation
names as reads so HTTP method heuristics do not label them as mutations.
SPRING_CONFIG_KEY_PATTERN = /\$\{([^}:]+)/
SPRING_MVC_MAPPING_ANNOTATION = /@(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping|RequestMapping)\b/
SPRING_MVC_VIEW_EXPR = /\bfun\b[^{\n]*:\s*
String\b[^{\n]*=\s*["']([^"']+)["']/
SPRING_MVC_VIEW_RETURN = /\breturn\s+["']([^"']+)["']/
SPRING_SECRET_NAME_PATTERN = /\b(pass(word)?|secret|token|api[_.-]?key|credential|jwt|private[_.-]?key)\b/i
SPRING_VALUE_ANNOTATION_PATTERN = /@
Value\s*\(([^)]*)\)/
STATE_CHANGING_METHODS =
Set {"
POST", "
PUT", "
PATCH", "
DELETE"}
TYPE_NAME = /\b[A-Z]\w*\b/
TYPED_PARAM = /\b([A-Za-z_]\w*)\s*:\s*((?:&(?:'\w+\s+)?)?(?:\w+::)*\w+(?:<[^()|]*?>)?)/