fix(pipeline): guard USAGE/WRITES/READS against cross-language binds

USAGE, WRITES and READS edges resolve through the same short-name
registry as CALLS but never consulted the #725 cross-language guard.
On a Go tree with eBPF C probes every Go identifier spelled like a C
one produced a reference edge into the C file: 31.5% of all WRITES on
the originally measured repo crossed the Go->C boundary, led by dozens
of Go test locals named event writing a C probe's automatic variable.

Add cbm_suppress_cross_language_ref() - the reference-edge analog of
cbm_suppress_cross_language_suffix_match - and consult it on BOTH
resolvers of each edge type: the sequential pass (pass_usages.c
resolve_usage_edges registry-fallback branch, resolve_rw_edges) and
their parallel twins (pass_parallel.c resolve_file_usages,
resolve_file_rw). The sequential-only version of this change left 344
Go->C WRITES alive on a ~1150-file repo because large repos resolve
through pass_parallel.c - the field census caught it, and the
parallel-twin test now pins it.

Unlike the CALLS guard the predicate takes no strategy parameter: a
reference edge carries no import-closure evidence, so every registry
strategy is a bare-name guess across a boundary. LSP-backed semantic
references resolve before the fallback and are unaffected. JS/TS stay
one family, and C/C++ count as one family too (.h maps to
CBM_LANG_CPP, so a .c file referencing its own header is not a
boundary).

Field-validated on the ~1150-file Go+C repo: Go->C/C++ WRITES 835->0,
USAGE 1545->0; C->Go 15/90->0; 6432 reference edges dropped in total,
every one cross-language (Go->.json 3034, Go->.hpp/.h 1612, Go->.c
746, Go->.sh/.yaml/.yml 443, ...) and none same-language. CALLS and
IMPORTS totals are byte-identical to main.

Fixes #1928

Signed-off-by: Ilya Brykau <ilya.brykau@orca.security>
This commit is contained in:
Ilya Brykau
2026-08-30 14:53:07 +02:00
parent 3de05cd607
commit d6417ada0e
6 changed files with 252 additions and 7 deletions
+14 -2
View File
@@ -2654,6 +2654,12 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws,
continue;
}
tgt = cbm_gbuf_find_by_qn(rc->main_gbuf, res.qualified_name);
/* #1928: the registry fallback is a bare-name guess — never let it
* bind a reference across a language boundary. Mirrors the
* sequential twin (pass_usages.c). */
if (tgt && cbm_suppress_cross_language_ref(lang, tgt->file_path)) {
continue;
}
if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow ||
cbm_pipeline_node_is_callable_target(tgt))) {
continue;
@@ -2713,7 +2719,7 @@ static void resolve_file_throws(resolve_ctx_t *rc, resolve_worker_state_t *ws,
/* Resolve reads/writes for one file. */
static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFileResult *result,
const char *rel, const char *module_qn, const char **imp_keys,
const char **imp_vals, int imp_count) {
const char **imp_vals, int imp_count, CBMLanguage lang) {
for (int r = 0; r < result->rw.count; r++) {
CBMReadWrite *rw = &result->rw.items[r];
if (!rw->var_name) {
@@ -2733,6 +2739,12 @@ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFi
if (!tgt || src->id == tgt->id) {
continue;
}
/* #1928: every resolution here is a bare-name registry guess — never
* let it bind a read/write across a language boundary. Mirrors the
* sequential twin (pass_usages.c). */
if (cbm_suppress_cross_language_ref(lang, tgt->file_path)) {
continue;
}
const char *etype = rw->is_write ? "WRITES" : "READS";
cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, tgt->id, etype, "{}");
}
@@ -3179,7 +3191,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) {
/* ── READS / WRITES ────────────────────────────────────── */
_ph_t0 = extract_now_ns();
resolve_file_rw(rc, ws, result, rel, module_qn, imp_keys, imp_vals, imp_count);
resolve_file_rw(rc, ws, result, rel, module_qn, imp_keys, imp_vals, imp_count, lang);
atomic_fetch_add_explicit(&rc->time_ns_rw, extract_now_ns() - _ph_t0, memory_order_relaxed);
/* ── INHERITS + DECORATES + IMPLEMENTS ──────────────────── */
+14 -2
View File
@@ -210,6 +210,12 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res
continue;
}
tgt = cbm_gbuf_find_by_qn(ctx->gbuf, res.qualified_name);
/* #1928: the registry fallback is a bare-name guess — never let it
* bind a reference across a language boundary (the LSP-backed
* semantic branch above is not affected). */
if (tgt && cbm_suppress_cross_language_ref(lang, tgt->file_path)) {
continue;
}
if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow ||
cbm_pipeline_node_is_callable_target(tgt))) {
continue;
@@ -270,7 +276,7 @@ static int resolve_throw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res
/* Resolve READS/WRITES edges for one file's extracted read/write accesses. */
static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result, const char *rel,
const char *module_qn, const char **imp_keys, const char **imp_vals,
int imp_count) {
int imp_count, CBMLanguage lang) {
int resolved = 0;
for (int r = 0; r < result->rw.count; r++) {
CBMReadWrite *rw = &result->rw.items[r];
@@ -293,6 +299,11 @@ static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result
if (!tgt || src->id == tgt->id) {
continue;
}
/* #1928: every resolution here is a bare-name registry guess — never
* let it bind a read/write across a language boundary. */
if (cbm_suppress_cross_language_ref(lang, tgt->file_path)) {
continue;
}
const char *edge_type = rw->is_write ? "WRITES" : "READS";
cbm_gbuf_insert_edge(ctx->gbuf, src->id, tgt->id, edge_type, "{}");
@@ -359,7 +370,8 @@ int cbm_pipeline_pass_usages(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *fil
imp_count, files[i].language);
throw_resolved +=
resolve_throw_edges(ctx, result, rel, module_qn, imp_keys, imp_vals, imp_count);
rw_resolved += resolve_rw_edges(ctx, result, rel, module_qn, imp_keys, imp_vals, imp_count);
rw_resolved += resolve_rw_edges(ctx, result, rel, module_qn, imp_keys, imp_vals, imp_count,
files[i].language);
free(module_qn);
free_import_map(imp_keys, imp_vals, imp_count);
+8
View File
@@ -288,6 +288,14 @@ bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *st
bool cbm_suppress_cross_language_suffix_match(CBMLanguage caller_lang, const char *target_file_path,
const char *strategy);
/* #1928: USAGE/WRITES/READS analog of the CALLS guard above. Reference edges
* resolved by the short-name registry carry no import-closure evidence, so a
* cross-language binding is a bare-name collision for EVERY strategy — drop
* it whenever the caller's language and the target file's language disagree
* (JS/TS family members and the C/C++ header family excepted). Pure;
* unit-tested in test_registry.c. */
bool cbm_suppress_cross_language_ref(CBMLanguage caller_lang, const char *target_file_path);
/* Get the label of a qualified name, or NULL if not found. */
const char *cbm_registry_label_of(const cbm_registry_t *r, const char *qn);
+38
View File
@@ -469,6 +469,13 @@ static bool js_ts_family(CBMLanguage lang) {
lang == CBM_LANG_ARKTS;
}
/* C and C++ are one family for cross-language checks: .h maps to CBM_LANG_CPP
* in the extension table, so a .c file referencing a symbol declared in its
* own header would otherwise read as a language boundary. */
static bool c_cpp_family(CBMLanguage lang) {
return lang == CBM_LANG_C || lang == CBM_LANG_CPP;
}
static const char *path_basename(const char *path) {
if (!path || !path[0]) {
return path;
@@ -508,6 +515,37 @@ bool cbm_suppress_cross_language_suffix_match(CBMLanguage caller_lang, const cha
return true;
}
bool cbm_suppress_cross_language_ref(CBMLanguage caller_lang, const char *target_file_path) {
/* #1928: USAGE / WRITES / READS analog of the CALLS guard above. A
* variable or field reference resolved by the short-name registry must
* not cross a language boundary: unlike CALLS, a reference edge carries
* no import-closure evidence at all — a Go test's local `event` and an
* eBPF C probe's automatic `event` share nothing but the spelling, so
* EVERY registry strategy is a bare-name guess here and none is exempt.
* LSP-backed semantic references resolve before the registry fallback
* and never reach this predicate, which is where a genuine cross-language
* binding (a future cgo resolver) would live. The JS/TS family keeps its
* exemption (.js/.ts/.d.ts pairs legitimately share symbols), and C/C++
* count as one family (.h maps to CBM_LANG_CPP). */
if (caller_lang == CBM_LANG_COUNT || !target_file_path || !target_file_path[0]) {
return false;
}
CBMLanguage target_lang = cbm_language_for_filename(path_basename(target_file_path));
if (target_lang == CBM_LANG_COUNT) {
return false;
}
if (caller_lang == target_lang) {
return false;
}
if (js_ts_family(caller_lang) && js_ts_family(target_lang)) {
return false;
}
if (c_cpp_family(caller_lang) && c_cpp_family(target_lang)) {
return false;
}
return true;
}
/* ── Lifecycle ──────────────────────────────────────────────────── */
cbm_registry_t *cbm_registry_new(void) {
+149 -3
View File
@@ -824,8 +824,8 @@ TEST(pipeline_calls_resolution) {
/* True iff a CALLS edge exists from a node named src_name to a node named
* tgt_name. Used to assert cross-file call resolution survives a reindex. */
static bool cross_file_call_exists(cbm_store_t *s, const char *project, const char *src_name,
const char *tgt_name) {
static bool cross_file_edge_exists(cbm_store_t *s, const char *project, const char *src_name,
const char *tgt_name, const char *edge_type) {
cbm_node_t *srcs = NULL;
cbm_node_t *tgts = NULL;
int sc = 0;
@@ -836,7 +836,7 @@ static bool cross_file_call_exists(cbm_store_t *s, const char *project, const ch
for (int i = 0; i < sc && !found; i++) {
cbm_edge_t *edges = NULL;
int ec = 0;
cbm_store_find_edges_by_source_type(s, srcs[i].id, "CALLS", &edges, &ec);
cbm_store_find_edges_by_source_type(s, srcs[i].id, edge_type, &edges, &ec);
for (int j = 0; j < ec && !found; j++) {
for (int k = 0; k < tc; k++) {
if (edges[j].target_id == tgts[k].id) {
@@ -858,6 +858,11 @@ static bool cross_file_call_exists(cbm_store_t *s, const char *project, const ch
return found;
}
static bool cross_file_call_exists(cbm_store_t *s, const char *project, const char *src_name,
const char *tgt_name) {
return cross_file_edge_exists(s, project, src_name, tgt_name, "CALLS");
}
/* True iff the exact named CALLS edge exists and its serialized strategy
* contains `strategy_fragment`. Parallel synthetic-carrier regressions use
* this on a separate ordinary-call control: it proves the cross-file LSP ran
@@ -4688,6 +4693,145 @@ TEST(pipeline_python_receiver_suppresses_weak_method_edge) {
PASS();
}
/* Fixture for the #1928 cross-language reference-guard probes (sequential and
* parallel twins). pad_files > 0 adds filler files to push the index over the
* parallel-pipeline threshold, since USAGE/WRITES/READS have one resolver per
* path and both must consult the guard. */
static void write_go_c_ref_guard_fixture(const char *tmp, int pad_files) {
write_temp_file(tmp, "go.mod", "module example.com/fxguard\n\ngo 1.22\n");
/* The C probe: a local named `event` and a file-scope function `handle`,
* the two shapes Go identifiers collide with. */
write_temp_file(tmp, "probe/probe.c",
"static int total_events = 0;\n"
"\n"
"static int handle(void) {\n"
" int event = 0;\n"
" total_events += event;\n"
" return event;\n"
"}\n");
/* Go: a local write named like the C local, and a value use named like
* the C function. Neither can touch anything in a C translation unit. */
write_temp_file(tmp, "app/app.go",
"package app\n"
"\n"
"func TrackEvent() int {\n"
"\tevent := 1\n"
"\treturn event\n"
"}\n"
"\n"
"func UsesHandle() int {\n"
"\th := handle\n"
"\t_ = h\n"
"\treturn 4\n"
"}\n"
"\n"
"func WriteTotal() {\n"
"\ttotal_events := 5\n"
"\t_ = total_events\n"
"}\n");
/* Same-language control: a Go package-level var written from another
* file must keep its WRITES edge. */
write_temp_file(tmp, "state/vars.go",
"package state\n"
"\n"
"var Counter int\n");
write_temp_file(tmp, "state/state.go",
"package state\n"
"\n"
"func Bump() {\n"
"\tCounter = 2\n"
"}\n");
for (int i = 0; i < pad_files; i++) {
char name[64];
char body[128];
snprintf(name, sizeof(name), "pad/filler%d.go", i);
snprintf(body, sizeof(body), "package pad\n\nfunc filler%d() int { return %d }\n", i, i);
write_temp_file(tmp, name, body);
}
}
TEST(pipeline_go_rw_usage_never_cross_into_c) {
/* #1928: USAGE and WRITES/READS resolve through the same short-name
* registry as CALLS but never consulted the #725 cross-language guard.
* On a Go tree with eBPF C probes every Go identifier spelled like a C
* one produced an edge into the C file — 31.5% of all WRITES on the
* measured repo crossed the Go/C boundary. Go code cannot write a C
* automatic variable, so every edge in that class is false. This is the
* SEQUENTIAL-path twin; the parallel twin follows. */
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_rw_XXXXXX");
if (!cbm_mkdtemp(tmp)) {
FAIL("tmpdir");
}
write_go_c_ref_guard_fixture(tmp, 0);
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/go_rw.db", tmp);
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
const char *project = cbm_pipeline_project_name(p);
cbm_store_t *s = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s);
/* Reproduce-first: RED before the fix — the Go local write binds the C
* probe's global, and the Go value use binds the C `handle`. */
ASSERT_FALSE(cross_file_edge_exists(s, project, "TrackEvent", "event", "WRITES"));
ASSERT_FALSE(cross_file_edge_exists(s, project, "TrackEvent", "event", "READS"));
ASSERT_FALSE(cross_file_edge_exists(s, project, "UsesHandle", "handle", "WRITES"));
ASSERT_FALSE(cross_file_edge_exists(s, project, "UsesHandle", "handle", "READS"));
ASSERT_FALSE(cross_file_edge_exists(s, project, "UsesHandle", "handle", "USAGE"));
ASSERT_FALSE(cross_file_edge_exists(s, project, "WriteTotal", "total_events", "WRITES"));
ASSERT_FALSE(cross_file_edge_exists(s, project, "WriteTotal", "total_events", "USAGE"));
/* Same-language reference edges survive the guard. */
ASSERT_TRUE(cross_file_edge_exists(s, project, "Bump", "Counter", "WRITES"));
cbm_store_close(s);
cbm_pipeline_free(p);
th_rmtree(tmp);
PASS();
}
TEST(pipeline_go_rw_usage_never_cross_into_c_parallel) {
/* Parallel twin of the test above: USAGE and WRITES/READS each have a
* second, independent resolver in pass_parallel.c (resolve_file_usages /
* resolve_file_rw) that must consult the same guard — the field census
* that motivated this caught the sequential-only fix leaving 344 Go→C
* WRITES alive on a ~1150-file repo. >= 50 files forces the parallel
* pipeline. */
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_rwp_XXXXXX");
if (!cbm_mkdtemp(tmp)) {
FAIL("tmpdir");
}
write_go_c_ref_guard_fixture(tmp, 52);
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/go_rwp.db", tmp);
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
const char *project = cbm_pipeline_project_name(p);
cbm_store_t *s = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s);
ASSERT_FALSE(cross_file_edge_exists(s, project, "TrackEvent", "event", "WRITES"));
ASSERT_FALSE(cross_file_edge_exists(s, project, "TrackEvent", "event", "READS"));
ASSERT_FALSE(cross_file_edge_exists(s, project, "UsesHandle", "handle", "WRITES"));
ASSERT_FALSE(cross_file_edge_exists(s, project, "UsesHandle", "handle", "READS"));
ASSERT_FALSE(cross_file_edge_exists(s, project, "UsesHandle", "handle", "USAGE"));
ASSERT_FALSE(cross_file_edge_exists(s, project, "WriteTotal", "total_events", "WRITES"));
ASSERT_FALSE(cross_file_edge_exists(s, project, "WriteTotal", "total_events", "USAGE"));
ASSERT_TRUE(cross_file_edge_exists(s, project, "Bump", "Counter", "WRITES"));
cbm_store_close(s);
cbm_pipeline_free(p);
th_rmtree(tmp);
PASS();
}
/* Count nodes with the given exact name in the project (e.g. a Route path). */
static int count_nodes_named(cbm_store_t *s, const char *project, const char *name) {
cbm_node_t *ns = NULL;
@@ -12805,6 +12949,8 @@ SUITE(pipeline) {
#endif
RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge);
RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge);
RUN_TEST(pipeline_go_rw_usage_never_cross_into_c);
RUN_TEST(pipeline_go_rw_usage_never_cross_into_c_parallel);
RUN_TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges);
RUN_TEST(pipeline_python_receiver_parallel_suppresses_weak_method_edges);
RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier);
+29
View File
@@ -811,6 +811,34 @@ TEST(cross_language_suffix_match_drops_py_vs_js) {
PASS();
}
TEST(cross_language_ref_drops_go_vs_c) {
/* #1928: the USAGE/WRITES/READS analog of #725. Reference edges carry no
* import-closure evidence, so EVERY registry strategy is a bare-name
* guess and no strategy-level carve-out applies — the predicate takes no
* strategy at all. */
ASSERT_TRUE(cbm_suppress_cross_language_ref(CBM_LANG_GO, "bpf/probe.c"));
ASSERT_TRUE(cbm_suppress_cross_language_ref(CBM_LANG_GO, "driver/mock.hpp"));
ASSERT_TRUE(cbm_suppress_cross_language_ref(CBM_LANG_C, "pkg/events/event.go"));
ASSERT_TRUE(cbm_suppress_cross_language_ref(CBM_LANG_PYTHON, "web/src/pages/Editor.js"));
/* Same language → keep. */
ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_GO, "pkg/state/state.go"));
/* C and C++ are one family: .h maps to CBM_LANG_CPP, and a .c file
* referencing its own header's declarations is not a boundary. */
ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_C, "bpf/probe.h"));
ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_CPP, "driver/compat.c"));
/* …but Go into a header is still a boundary. */
ASSERT_TRUE(cbm_suppress_cross_language_ref(CBM_LANG_GO, "bpf/probe.h"));
/* JS/TS/TSX/ArkTS are one family. */
ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_JAVASCRIPT, "lib/util.ts"));
ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_TYPESCRIPT, "ui/Panel.tsx"));
/* Unknown caller/target language or no path → nothing to judge → keep. */
ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_COUNT, "store.py"));
ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_GO, NULL));
ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_GO, ""));
ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_GO, "Makefile.inc.unknownext"));
PASS();
}
TEST(dynamic_suppress_drops_weak_method_matches) {
/* #592/#606/#1276: a member call whose receiver the LSP could not type, that
* landed via a WEAK short-name strategy, is generic-resolver noise → drop.
@@ -945,6 +973,7 @@ SUITE(registry) {
RUN_TEST(perl_suppress_drops_weak_builtin_and_method_matches);
RUN_TEST(perl_suppress_keeps_high_confidence_and_genuine_calls);
RUN_TEST(cross_language_suffix_match_drops_py_vs_js);
RUN_TEST(cross_language_ref_drops_go_vs_c);
RUN_TEST(dynamic_suppress_drops_weak_method_matches);
RUN_TEST(dynamic_suppress_keeps_high_confidence_and_non_methods);
}