Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 114 additions & 10 deletions internal/cbm/service_patterns.c
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,20 @@ static const lib_pattern_t http_libraries[] = {
{"socket.http", CBM_SVC_HTTP, NULL},
{"resty.http", CBM_SVC_HTTP, NULL},

/* Glued-name libraries. match_qn needs an identifier boundary around an
* id, so a library whose own name glues a lowercase prefix/suffix onto
* another id ("grequests", "curlpp") is listed explicitly: no boundary
* rule can tell "grequests" from "myrequests". */
{"grequests", CBM_SVC_HTTP, NULL}, /* Python: gevent + requests */
{"txrequests", CBM_SVC_HTTP, NULL}, /* Python: Twisted + requests */
{"redaxios", CBM_SVC_HTTP, NULL}, /* JS: axios-compatible fetch client */
{"gaxios", CBM_SVC_HTTP, NULL}, /* JS: Google's axios-style client */
{"libcurl", CBM_SVC_HTTP, NULL}, /* C, node-libcurl */
{"curlpp", CBM_SVC_HTTP, NULL}, /* C++ libcurl wrapper */
{"curlcpp", CBM_SVC_HTTP, NULL}, /* C++ libcurl wrapper */
{"hyperlocal", CBM_SVC_HTTP, NULL}, /* Rust: hyper over unix sockets */
{"guzzlehttp", CBM_SVC_HTTP, NULL}, /* PHP: Guzzle's composer vendor */

{NULL, CBM_SVC_NONE, NULL},
};

Expand Down Expand Up @@ -263,6 +277,33 @@ static const lib_pattern_t async_libraries[] = {
{"dapr.clients.grpc", CBM_SVC_ASYNC, "dapr"},
{"DaprClient", CBM_SVC_ASYNC, "dapr"},

/* Glued-name libraries (see the note in http_libraries). */
{"aiokafka", CBM_SVC_ASYNC, "kafka"},
{"pykafka", CBM_SVC_ASYNC, "kafka"},
{"librdkafka", CBM_SVC_ASYNC, "kafka"},
{"rdkafkacpp", CBM_SVC_ASYNC, "kafka"},
{"rskafka", CBM_SVC_ASYNC, "kafka"},
{"aioamqp", CBM_SVC_ASYNC, "rabbitmq"},
{"amqpstorm", CBM_SVC_ASYNC, "rabbitmq"},
{"pamqp", CBM_SVC_ASYNC, "rabbitmq"},
{"pyamqp", CBM_SVC_ASYNC, "rabbitmq"},
{"amqprs", CBM_SVC_ASYNC, "rabbitmq"},
{"amqpcpp", CBM_SVC_ASYNC, "rabbitmq"},
{"pynats", CBM_SVC_ASYNC, "nats"},
{"jnats", CBM_SVC_ASYNC, "nats"},
{"aiomqtt", CBM_SVC_ASYNC, "mqtt"},
{"amqtt", CBM_SVC_ASYNC, "mqtt"},
{"hbmqtt", CBM_SVC_ASYNC, "mqtt"},
{"umqtt", CBM_SVC_ASYNC, "mqtt"},
{"mqttools", CBM_SVC_ASYNC, "mqtt"},
{"emqtt", CBM_SVC_ASYNC, "mqtt"},
{"rumqtt", CBM_SVC_ASYNC, "mqtt"},
{"gomqtt", CBM_SVC_ASYNC, "mqtt"},
{"libmosquitto", CBM_SVC_ASYNC, "mqtt"},
{"mosquittopp", CBM_SVC_ASYNC, "mqtt"},
{"pubsublite", CBM_SVC_ASYNC, "pubsub"},
{"gocelery", CBM_SVC_ASYNC, "celery"},

{NULL, CBM_SVC_NONE, NULL},
};

Expand Down Expand Up @@ -308,6 +349,13 @@ static const lib_pattern_t config_libraries[] = {
{"Application.get_env", CBM_SVC_CONFIG, NULL},
{"Application.fetch_env", CBM_SVC_CONFIG, NULL},

/* Glued-name accessors (see the note in http_libraries). */
{"wgetenv", CBM_SVC_CONFIG, NULL}, /* Windows CRT: _wgetenv, _wgetenv_s */
{"getenvb", CBM_SVC_CONFIG, NULL}, /* Python: os.getenvb */
{"qgetenv", CBM_SVC_CONFIG, NULL}, /* Qt */
{"dotenvx", CBM_SVC_CONFIG, NULL}, /* JS: @dotenvx/dotenvx */
{"phpdotenv", CBM_SVC_CONFIG, NULL}, /* PHP: vlucas/phpdotenv */

{NULL, CBM_SVC_NONE, NULL},
};

Expand Down Expand Up @@ -377,6 +425,11 @@ static const lib_pattern_t route_reg_libraries[] = {
{"akka.http.scaladsl.server", CBM_SVC_ROUTE_REG, NULL},
{"play.api.routing", CBM_SVC_ROUTE_REG, NULL},

/* Glued-name frameworks (see the note in http_libraries). */
{"apiflask", CBM_SVC_ROUTE_REG, NULL}, /* Python: Flask-based */
{"honox", CBM_SVC_ROUTE_REG, NULL}, /* JS: Hono meta-framework */
{"fasthttprouter", CBM_SVC_ROUTE_REG, NULL}, /* Go: httprouter for fasthttp */

{NULL, CBM_SVC_NONE, NULL},
};

Expand Down Expand Up @@ -448,6 +501,11 @@ static const lib_pattern_t graphql_libraries[] = {
{"async-graphql", CBM_SVC_GRAPHQL, NULL},
{"juniper", CBM_SVC_GRAPHQL, NULL},

/* Glued-name libraries (see the note in http_libraries). */
{"gqlparser", CBM_SVC_GRAPHQL, NULL}, /* Go: vektah/gqlparser */
{"gqlgenc", CBM_SVC_GRAPHQL, NULL}, /* Go: gqlgen client generator */
{"aiogqlc", CBM_SVC_GRAPHQL, NULL}, /* Python: asyncio GraphQL client */

{NULL, CBM_SVC_NONE, NULL},
};

Expand Down Expand Up @@ -534,17 +592,67 @@ static const method_suffix_t method_suffixes[] = {

/* ── Matching implementation ───────────────────────────────────── */

/* Check if any library identifier appears as a substring in the QN.
* Case-sensitive: "requests" matches "project.venv.requests.api.get"
* but not "Requests". Library names are specific enough to avoid
* false positives even with substring matching. */
static bool qn_is_lower(char ch) {
return ch >= 'a' && ch <= 'z';
}

static bool is_digit_char(char ch) {
return ch >= '0' && ch <= '9';
}

static bool qn_is_alnum(char ch) {
return qn_is_lower(ch) || is_digit_char(ch) || (ch >= 'A' && ch <= 'Z');
}

/* True when the occurrence of `id` at `hit` sits on identifier boundaries.
*
* A raw substring match let short ids fire inside unrelated words: "gin."
* in "plugin.", "dio" in "studio", "surf" in "surface", "express" in
* "expression". Every character that is not an ASCII letter or digit is a
* separator ('.', '/', '\\', ':', '_', '-', '$', '@', ...).
*
* BEFORE: start of string, a separator, an id that itself starts with a
* separator ("@trpc/server"), or an id that starts with an
* uppercase letter — a capital opens a new CamelCase word whatever
* precedes it ("AsyncHttpClient", "IHttpClientFactory",
* "NSURLSession"). Rejected: a lowercase/digit-initial id glued to
* a letter or digit ("plugin." / "studio" / "myrequests").
* AFTER: end of string, a separator, an id that itself ends in a
* separator ("gin."), an uppercase letter (CamelCase glue:
* "GuzzleHttp", "FeignClient", "KafkaProducer", "kafkaProducer")
* or a digit (version suffix: "urllib2", "amqp091-go").
* Rejected: a lowercase letter continuing the word ("surface",
* "curly", "expression"). */
static bool qn_hit_on_boundary(const char *qn, const char *hit, const char *id, size_t id_len) {
char first = id[0];
char last = id[id_len - 1];
if (hit > qn && qn_is_alnum(hit[-1]) && (qn_is_lower(first) || is_digit_char(first))) {
return false;
}
if (qn_is_alnum(last) && qn_is_lower(hit[id_len])) {
return false;
}
return true;
}

/* Check if any library identifier appears in the QN on identifier
* boundaries (see qn_hit_on_boundary). Case-sensitive: "requests" matches
* "project.venv.requests.api.get" and "svc.requests_get" but neither
* "Requests" nor "myrequests". */
static const lib_pattern_t *match_qn(const char *qn, const lib_pattern_t *patterns) {
if (!qn || !qn[0]) {
return NULL;
}
for (int i = 0; patterns[i].library_id != NULL; i++) {
if (strstr(qn, patterns[i].library_id) != NULL) {
return &patterns[i];
const char *id = patterns[i].library_id;
size_t id_len = strlen(id);
if (id_len == 0) {
continue;
}
for (const char *hit = strstr(qn, id); hit != NULL; hit = strstr(hit + 1, id)) {
if (qn_hit_on_boundary(qn, hit, id, id_len)) {
return &patterns[i];
}
}
}
return NULL;
Expand Down Expand Up @@ -574,10 +682,6 @@ static bool contains_segment(const char *path, const char *segment) {
return false;
}

static bool is_digit_char(char ch) {
return ch >= '0' && ch <= '9';
}

static bool has_http_route_marker(const char *path) {
if (starts_with_segment(path, "api") || starts_with_segment(path, "apis") ||
starts_with_segment(path, "graphql") || starts_with_segment(path, "health") ||
Expand Down
183 changes: 181 additions & 2 deletions tests/test_infrascan.c
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
#include "test_framework.h"
#include "graph_buffer/graph_buffer.h"
#include "pipeline/pipeline_internal.h"
#include "service_patterns.h"

#include <stdbool.h>
#include <stdint.h>

bool cbm_service_pattern_is_http_route_literal(const char *literal, const char *callee_name);
#include <stdio.h>

static int has_data_flow(cbm_gbuf_t *gb, int64_t source_id, int64_t target_id) {
const cbm_gbuf_edge_t **edges = NULL;
Expand Down Expand Up @@ -48,6 +48,183 @@ TEST(infrascan_http_route_literal_guard_rejects_filesystem_paths) {
PASS();
}

/* ── Service-pattern QN boundaries (distilled from PR #1245) ─────────
* A library id must sit on an identifier boundary inside the QN. A raw
* substring match classified "proj.plugin.loader" as gin ("gin.") and
* "proj.studio.x" as Dio ("dio"), which minted bogus Route / HTTP_CALLS
* edges. The rule must stay open for CamelCase glue ("GuzzleHttp",
* "FeignClient", "KafkaProducer", "IHttpClientFactory") and for version
* suffixes ("urllib2", "amqp091-go"). */
typedef struct {
const char *qn;
cbm_svc_kind_t want;
} svc_case_t;

static int svc_case_mismatches(const svc_case_t *cases) {
int bad = 0;
for (int i = 0; cases[i].qn != NULL; i++) {
cbm_svc_kind_t got = cbm_service_pattern_match(cases[i].qn);
if (got != cases[i].want) {
printf(" service-pattern mismatch: \"%s\" -> %d, expected %d\n", cases[i].qn, (int)got,
(int)cases[i].want);
bad++;
}
}
return bad;
}

TEST(infrascan_service_pattern_match_rejects_ids_inside_words) {
static const svc_case_t cases[] = {
/* "gin." inside plugin. */
{"proj.plugins.autorun.tests.test_plugin._dispatch", CBM_SVC_NONE},
{"proj.plugin.loader", CBM_SVC_NONE},
/* "chi." / "echo." / "fiber." */
{"proj.hibachi.grill", CBM_SVC_NONE},
{"sound.reecho.apply", CBM_SVC_NONE},
{"textile.microfiber.weave", CBM_SVC_NONE},
/* "dio" inside studio / radio */
{"proj.studio.x", CBM_SVC_NONE},
{"radio.tune", CBM_SVC_NONE},
/* "surf" / "resty" / "curl" / "phin" / "hyper" / "sling" / "needle" / "treq" */
{"app.surface.draw", CBM_SVC_NONE},
{"ui.restyle.apply", CBM_SVC_NONE},
/* control: "forestry" spells "restr", never contained "resty" */
{"pkg.forestry.trees", CBM_SVC_NONE},
{"util.curly.brace", CBM_SVC_NONE},
{"sea.dolphin.swim", CBM_SVC_NONE},
{"math.hyperbolic.tanh", CBM_SVC_NONE},
{"farm.gosling.walk", CBM_SVC_NONE},
{"sewing.needles.sort", CBM_SVC_NONE},
{"util.streq", CBM_SVC_NONE},
/* "requests" glued to a lowercase prefix (upstream's case) */
{"proj.myrequests.client.get", CBM_SVC_NONE},
/* "express" / "rocket" / "tonic" / "nconf" */
{"ast.expression.eval", CBM_SVC_NONE},
{"mq.rocketmq.send", CBM_SVC_NONE},
{"geo.tectonic.plates", CBM_SVC_NONE},
{"app.unconfigured.warn", CBM_SVC_NONE},
{"cli.runconfig.load", CBM_SVC_NONE},
{"aws.s3express.session", CBM_SVC_NONE},
/* not clients of the id they contain: ASGI server, URL library,
* nginx platform, Memgraph OGM, Celery settings module, a word */
{"hypercorn.run.serve", CBM_SVC_NONE},
{"hyperlink.URL.from_text", CBM_SVC_NONE},
{"openresty.core.request", CBM_SVC_NONE},
{"gqlalchemy.Memgraph.execute", CBM_SVC_NONE},
{"celeryconfig.broker_url", CBM_SVC_NONE},
{"kafkaesque.story.tell", CBM_SVC_NONE},
{NULL, CBM_SVC_NONE},
};
ASSERT_EQ(svc_case_mismatches(cases), 0);
PASS();
}

TEST(infrascan_service_pattern_match_keeps_real_library_qns) {
static const svc_case_t cases[] = {
/* Route registration: ids that end in a separator, own segment */
{"github.com/gin-gonic/gin.Default", CBM_SVC_ROUTE_REG},
{"proj.gin.router.GET", CBM_SVC_ROUTE_REG},
{"gin.GET", CBM_SVC_ROUTE_REG},
{"chi.NewRouter", CBM_SVC_ROUTE_REG},
{"github.com/labstack/echo.New", CBM_SVC_ROUTE_REG},
{"fiber.New", CBM_SVC_ROUTE_REG},
{"proj.express.router.get", CBM_SVC_ROUTE_REG},
{"@hapi/hapi.server.route", CBM_SVC_ROUTE_REG},
/* HTTP: own segment / separator-joined wrapper */
{"proj.venv.requests.api.get", CBM_SVC_HTTP},
{"proj.service.requests_get", CBM_SVC_HTTP},
{"package:dio/dio.dart", CBM_SVC_HTTP},
{"dio.Dio.get", CBM_SVC_HTTP},
{"pkg.net.curl.get", CBM_SVC_HTTP},
{"curl_exec", CBM_SVC_HTTP},
{"surf.get", CBM_SVC_HTTP},
{"hyper.Client.request", CBM_SVC_HTTP},
{"phin.promisified", CBM_SVC_HTTP},
{"needle.get", CBM_SVC_HTTP},
{"github.com/go-resty/resty.New", CBM_SVC_HTTP},
{"github.com/dghubble/sling.New", CBM_SVC_HTTP},
/* HTTP: CamelCase glue after the id */
{"GuzzleHttp\\Client", CBM_SVC_HTTP},
{"proj.GuzzleHttp.Client.get", CBM_SVC_HTTP},
{"org.springframework.cloud.openfeign.FeignClientBuilder.build", CBM_SVC_HTTP},
{"com.acme.net.HttpClientFactory.create", CBM_SVC_HTTP},
/* HTTP: CamelCase glue before the id */
{"System.Net.Http.IHttpClientFactory.CreateClient", CBM_SVC_HTTP},
{"org.asynchttpclient.AsyncHttpClient.prepareGet", CBM_SVC_HTTP},
{"Foundation.NSURLSession.dataTask", CBM_SVC_HTTP},
/* HTTP: version suffix after the id */
{"urllib2.urlopen", CBM_SVC_HTTP},
{"Mint.HTTP2.request", CBM_SVC_HTTP},
/* Async */
{"com.acme.events.KafkaProducer.send", CBM_SVC_ASYNC},
{"this.kafkaProducer.send", CBM_SVC_ASYNC},
{"org.apache.kafka.clients.producer.KafkaProducer.send", CBM_SVC_ASYNC},
{"github.com/rabbitmq/amqp091-go.Channel.Publish", CBM_SVC_ASYNC},
{"github.com/nats-io/nats.go.Conn.Publish", CBM_SVC_ASYNC},
/* Config */
{"os.getenv", CBM_SVC_CONFIG},
{"os.Getenv", CBM_SVC_CONFIG},
/* Glued-name libraries: the library's own name glues a lowercase
* prefix/suffix onto another id, so each has an explicit table entry.
* Every QN below contains NO other id — only the entry can match. */
{"grequests.map", CBM_SVC_HTTP},
{"txrequests.Session.send", CBM_SVC_HTTP},
{"redaxios.create", CBM_SVC_HTTP},
{"gaxios.request", CBM_SVC_HTTP},
{"libcurl.version", CBM_SVC_HTTP},
{"node-libcurl.Easy.perform", CBM_SVC_HTTP},
{"curlpp.Easy.perform", CBM_SVC_HTTP},
{"curlcpp.easy.perform", CBM_SVC_HTTP},
{"hyperlocal.UnixConnector.call", CBM_SVC_HTTP},
{"guzzlehttp/psr7.Utils.streamFor", CBM_SVC_HTTP},
{"aiokafka.helpers.create_ssl_context", CBM_SVC_ASYNC},
{"pykafka.topic.Topic.get_producer", CBM_SVC_ASYNC},
{"librdkafka.rd_version", CBM_SVC_ASYNC},
{"rdkafkacpp.version", CBM_SVC_ASYNC},
{"rskafka.client.partition.write", CBM_SVC_ASYNC},
{"aioamqp.connect", CBM_SVC_ASYNC},
{"amqpstorm.Connection.channel", CBM_SVC_ASYNC},
{"pamqp.frame.marshal", CBM_SVC_ASYNC},
{"kombu.transport.pyamqp.Transport.establish_connection", CBM_SVC_ASYNC},
{"amqprs.channel.basic_publish", CBM_SVC_ASYNC},
{"amqpcpp.TcpChannel.publish", CBM_SVC_ASYNC},
{"pynats.client.send", CBM_SVC_ASYNC},
{"jnats.options.build", CBM_SVC_ASYNC},
{"aiomqtt.client.publish", CBM_SVC_ASYNC},
{"amqtt.session.deliver", CBM_SVC_ASYNC},
{"hbmqtt.session.deliver", CBM_SVC_ASYNC},
{"umqtt.simple.publish", CBM_SVC_ASYNC},
{"mqttools.client.publish", CBM_SVC_ASYNC},
{"emqtt.publish", CBM_SVC_ASYNC},
{"rumqtt.client.publish", CBM_SVC_ASYNC},
{"github.com/256dpi/gomqtt/client.Publish", CBM_SVC_ASYNC},
{"libmosquitto.publish", CBM_SVC_ASYNC},
{"mosquittopp.publish", CBM_SVC_ASYNC},
{"google.cloud.pubsublite.admin.create_topic", CBM_SVC_ASYNC},
{"gocelery.client.delay", CBM_SVC_ASYNC},
{"_wgetenv", CBM_SVC_CONFIG},
{"_wgetenv_s", CBM_SVC_CONFIG},
{"os.getenvb", CBM_SVC_CONFIG},
{"qgetenv", CBM_SVC_CONFIG},
{"@dotenvx/dotenvx.config", CBM_SVC_CONFIG},
{"vlucas/phpdotenv.load", CBM_SVC_CONFIG},
{"apiflask.scaffold.get", CBM_SVC_ROUTE_REG},
{"honox.factory.createRoute", CBM_SVC_ROUTE_REG},
{"fasthttprouter.Router.GET", CBM_SVC_ROUTE_REG},
{"github.com/vektah/gqlparser/v2.LoadSchema", CBM_SVC_GRAPHQL},
{"github.com/Yamashou/gqlgenc.client.Post", CBM_SVC_GRAPHQL},
{"aiogqlc.client.execute", CBM_SVC_GRAPHQL},
{NULL, CBM_SVC_NONE},
};
ASSERT_EQ(svc_case_mismatches(cases), 0);
ASSERT_STR_EQ(cbm_service_pattern_broker("aiokafka.helpers.create_ssl_context"), "kafka");
ASSERT_STR_EQ(cbm_service_pattern_broker("umqtt.simple.publish"), "mqtt");
ASSERT_STR_EQ(cbm_service_pattern_broker("com.acme.events.KafkaProducer.send"), "kafka");
ASSERT_STR_EQ(cbm_service_pattern_broker("github.com/rabbitmq/amqp091-go.Channel.Publish"),
"rabbitmq");
PASS();
}

TEST(infrascan_route_nodes_skip_bad_http_url_paths) {
cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_route_guard");
ASSERT_NOT_NULL(gb);
Expand Down Expand Up @@ -128,6 +305,8 @@ TEST(infrascan_http_calls_join_matching_handler_route) {

SUITE(infrascan) {
RUN_TEST(infrascan_http_route_literal_guard_rejects_filesystem_paths);
RUN_TEST(infrascan_service_pattern_match_rejects_ids_inside_words);
RUN_TEST(infrascan_service_pattern_match_keeps_real_library_qns);
RUN_TEST(infrascan_route_nodes_skip_bad_http_url_paths);
RUN_TEST(infrascan_http_calls_join_matching_handler_route);
}
Loading