Top Level Namespace

Defined in:

Constant Summary

ALLOWED_HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD", "TRACE", "CONNECT", "QUERY"]

The real HTTP methods an endpoint can carry. QUERY (RFC 10008) is accepted core-wide, but a framework analyzer adds it to its own verb table only when the upstream framework actually routes the verb — adding it speculatively would report endpoints the framework itself answers with 405.

CFML_FRAMEWORK_TECHS = Set {"cfml_taffy", "cfml_coldbox", "cfml_wheels", "cfml_fw1"}

CFML frameworks that own their application's route table.

CLI_ENDPOINT_METHOD = "CLI"

The synthetic verb CLI entry points carry (cli://<binary>/<subcommand>).

ENDPOINT_METHODS = (ALLOWED_HTTP_METHODS + SYNTHETIC_ENDPOINT_METHODS) + [CLI_ENDPOINT_METHOD]

Every verb that can appear as Endpoint#method. Consumers that have to tell "this token names a method" from "this token is part of a URL" (the probe matchers) read this instead of keeping their own list.

SAFE_HTTP_METHODS = Set {"GET", "HEAD", "OPTIONS", "QUERY"}

The verbs the read-vs-write heuristics treat as reads: GET/HEAD/OPTIONS plus QUERY (safe + idempotent per RFC 10008). TRACE, though RFC-safe, is excluded — it echoes the request rather than reading a resource, and no consumer ever counted it as a read. One shared set so the admin/webhook taggers, the ai_context unsafe-method signal, and the probe path-filler cannot drift on which verbs are safe. Callers normalize case themselves.

SYNTHETIC_ANY_METHODS = ["ANY", "ALL", "*"]
SYNTHETIC_ENDPOINT_METHODS = ["ANY", "PUBLISH", "SUBSCRIBE", "SEND", "RECEIVE"]

Verbs an endpoint can carry that are not HTTP methods: the wildcard ANY, and the AsyncAPI / messaging verbs the optimizer allow-lists so event-driven endpoints aren't downgraded to GET.

WILDCARD_HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "TRACE"]

The concrete verbs a wildcard route (ANY/ALL/*) fans out to.

QUERY (RFC 10008) is deliberately absent: fanning every wildcard route out to a QUERY endpoint would add one endpoint per catch-all route in every framework — reported, probed, and exported — for a verb almost no deployed app intends to serve. A QUERY endpoint is emitted only where a route declares the verb explicitly.

Method Summary

Method Detail

def analysis_endpoints(options : Hash(String, YAML::Any), techs, logger : NoirLogger, failures : Array(AnalyzerFailure) | Nil = nil) #

failures is an out-parameter rather than a second return value so the existing signature keeps working: library embedders and specs call this positionally and have no interest in the failure list. Callers that do care (the CLI, via NoirRunner) pass an array and get every tech that raised.


[View source]
def any_to_bool(any) : Bool #

[View source]
def apply_cfml_components_only!(options : Hash(String, YAML::Any), selected_techs : Array(String)) : Nil #

Publishes the narrowing decision into the options hash, which is how Analyzer::Cfml::Pure receives it.

Assigns on every pass, never only when true. The key is written into the caller's options hash and nothing clears it — CodeLocator's lifecycle resets cannot reach it, because it is not a locator key. Written conditionally (the previous if cfml_components_only? ... = true shape), a true from one scan survived into the next analysis_endpoints call on the same hash: a library embedder that scanned a ColdBox app and then a plain-CFML app got the second scan silently narrowed to components-only and lost its entire .cfm page surface. Same shape as the scan-lifecycle leak #2503 fixed, in the one remaining channel that carries state between passes.

The CLI never hit it — cli/commands/scan.cr dups the hash before the diff scan — so only embedders reusing one hash were affected.

Extracted from analysis_endpoints for the same reason cfml_components_only? was: in place, the only way to exercise it was to run every analyzer.


[View source]
def build_detector_list(options : Hash(String, YAML::Any)) : Array(Detector) #

[View source]
def cfml_components_only?(selected_techs : Array(String)) : Bool #

Whether the generic CFML analyzer should narrow to components-only mode.

This is a narrowing, not a supersede, which is why it stays here rather than moving to the catalog's :supersedes alongside the four framework-shadows-framework rules. A CFML framework owns the .cfm page surface, so dropping cfml_pure outright would lose the half no framework analyzer covers — the access="remote" methods on a ColdBox app's proxy components, which are HTTP-callable whatever framework fronts them. Narrowing keeps those and still leaves the page surface to its owner.

Extracted from analysis_endpoints so it can be tested at all: in place it sat inside the function that runs every analyzer.


[View source]
def detect_techs(base_paths : Array(String), options : Hash(String, YAML::Any), passive_scans : Array(PassiveScan), logger : NoirLogger) #

[View source]
def endpoint_method_token?(token : String) : Bool #

[View source]
def escape_glob_path(path : String) : String #

Escapes glob metacharacters in a path string. This is necessary when the path contains characters like { } [ ] * ?
which would otherwise be interpreted as glob patterns. Example: "/path/{{cookiecutter}}/file" -> "/path/\{\{cookiecutter\}\}/file"


[View source]
def expand_synthetic_http_methods(method : String) : Array(String) #

[View source]
def filter_redundant_generic_techs(techs : Array(String)) : Array(String) #

Drops techs that another detected tech supersedes. The rules live on the superseding tech's catalog entry as :supersedes; see the doc on NoirTechs::SUPERSEDES for what belongs there and — more importantly — what must never be added.

Presence is evaluated against the input list rather than the array being filtered, so the result no longer depends on the order the rules happen to be written in. SUPERSEDES chains are rejected by the tech-registry integrity spec, which keeps that equivalence true.


[View source]
def get_allowed_methods #

[View source]
def get_relative_path(base_path : String, path : String) : String #

[View source]
def get_symbol(method : String) #

[View source]
def initialize_analyzers(logger : NoirLogger) #

[View source]
def json_any?(content : String) : JSON::Any | Nil #

Strict JSON.parse-or-nil. Replaces the valid_json?(content) + JSON.parse(content) idiom in detectors, which parsed the same content twice per file.


[View source]
def parse_yaml(content : String) : YAML::Any #

Parses YAML, recovering from a stray-tab failure that libyaml (Crystal's YAML backend) is stricter about than most other parsers.

Real-world OpenAPI/Swagger documents occasionally carry a TAB character on an otherwise-blank line inside a block scalar (descriptions, examples, embedded code). libyaml rejects it with "found a tab character where an indentation space is expected", which drops the entire document — and with it every endpoint noir would have found — even though PyYAML, JS, and Go parsers accept it. As a last resort we blank out lines that consist solely of whitespace and retry. That transformation never touches real indentation, keys, or values, so a document that already parses is returned unchanged.


[View source]
def regex_matches_bounded?(regex : Regex, input : String) : Bool #

Matches regex against input, treating a backtracking blow-up as "no match" rather than an exception.

The bound is PCRE2's own match limit, which is the only mechanism that can actually interrupt a running match. When a pattern backtracks past it, Regex#matches? raises Regex::Error; that is the ReDoS ceiling, and it applies whether or not anything here wraps the call.

This used to spawn a fiber and race it against select ... when timeout. That could not work: Regex#matches? is a single pcre2_match FFI call with no yield point, and noir has no preview_mt build, so the timeout branch could never be reached while the match was running. Measured directly — with timeout set to 10ms, the wrapper returned after 17ms, having run the match to completion. It bounded nothing, and cost a fiber spawn plus a channel per line scanned on the one hot path that used it.


[View source]
def remove_start_slash(input_path : String) : String #

[View source]
def requestable_http_methods(method : String) : Array(String) #

[View source]
def run_options_parser #

[View source]
def synthetic_any_method?(method : String) : Bool #

[View source]
def valid_json?(content : String) : Bool #

[View source]
def valid_yaml?(content : String) : Bool #

[View source]
def yaml_any?(content : String) : YAML::Any | Nil #

Strict YAML.parse-or-nil. Replaces the valid_yaml?(content) + YAML.parse(content) idiom in detectors, which paid for two full libyaml passes over the same content per file.


[View source]