feat(model): a region for the code that is in no function

contributor-scan 2.5. VAmPI's `host='0.0.0.0'` is now reported exactly
once, at app.py:17:<module>. Before task 2.2 it was reported eight times
in eight files that do not contain it; after 2.2 it was reported nowhere.

Two causes, one shape: nothing could ask about module-level code.

**The main-guard marker is not a function.** `__main__` is what the mapper
emits for `if __name__ == '__main__':`. Sent to the engine as a function
name it matched nothing, because a CPG calls a file's top-level code
`<module>` -- verified against the VAmPI CPG rather than assumed. The
region model translates the marker, which is the one place that turns what
the mapper meant into what the scanner asks.

**A function-scoped file had no region for its module body.** Settings,
route registration and globals live in no function, and every region's
query filters by function name. A file-level region beside the handlers
would report each handler's findings a second time, which is why the
earlier rule dropped it as "a second full-file pass". A module-scope region
PARTITIONS the file instead of overlapping it. That only became cheap after
batching: before it, a region was a JVM.

Two things this needed to be correct rather than merely present:

- A module method spans its whole file, so the taint query's line-range
  containment made `<module>` the parent of every function in it. Under
  module scope, membership is direct -- otherwise the module region reports
  every handler's sinks again, which is the duplication the old rule was
  avoiding.
- A module body is not a handler. The marker-derived region inherited the
  handler families and entailed a missing authorization check on the main
  guard: a finding with no possible remediation. Caught by the first
  repository run that had the region, and now covered by a test.

  bind address   8 wrong files -> 0 -> 1, at app.py:17:<module>
  regions        22 -> 25
  entailed       8 -> 9
  query time     37.4s -> 41.3s
  peak RSS       827MB -> 823MB
  cost           $0.0067 -> $0.0068

Three more regions for four seconds and no more model calls: the budget is
a total and was already spent, so regions_unasked rose 17 -> 20 -- those
regions got the free pass and are reported as unasked, not as clean.

`<module>` is Joern's Python name for a module body; JavaScript uses
`:program`, so this is verified for Python only.

Gates byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
Marius Ciepluch
2026-09-09 09:43:53 +02:00
co-authored by Claude Opus 5
parent ca7b5ba370
commit 0fcd5feade
6 changed files with 274 additions and 21 deletions
+14 -7
View File
@@ -113,14 +113,21 @@
seen
}
// A module body spans its whole file, so line-range containment would make `<module>` the parent of every
// function in it -- and a module-scope region would then report every handler's sinks a second time. The
// module region exists to cover the code that is in NO function, so its membership is direct.
val moduleScope = function == "<module>"
def nestedInLabeled(m: io.shiftleft.codepropertygraph.generated.nodes.Method): Boolean =
labeledMethods.exists { lm =>
m.name == function || (
m.filename == lm.filename &&
m.lineNumber.getOrElse(-1) >= lm.lineNumber.getOrElse(0) &&
m.lineNumberEnd.getOrElse(-1) <= lm.lineNumberEnd.getOrElse(Int.MaxValue)
)
}
if (moduleScope) m.name == function
else
labeledMethods.exists { lm =>
m.name == function || (
m.filename == lm.filename &&
m.lineNumber.getOrElse(-1) >= lm.lineNumber.getOrElse(0) &&
m.lineNumberEnd.getOrElse(-1) <= lm.lineNumberEnd.getOrElse(Int.MaxValue)
)
}
// Parameter sources: the labeled function's OWN parameters, never a nested closure's. In a function-level
// pair the function boundary IS the trust boundary -- the corpus is built so the labeled function's inputs
+45 -2
View File
@@ -39,6 +39,15 @@ _ACCESS_RANK: dict[str, float] = {
_LANGUAGES = {"python", "javascript", "typescript", "java", "c", "c_cpp"}
_NORMALISE = {"typescript": "javascript", "c_cpp": "c"}
# What a CPG calls a file's top-level code. A module body is a method like any other to the engine, and
# naming it here is what lets a region ask about the code that is not inside any function.
MODULE_SCOPE = "<module>"
# What the mapper emits for `if __name__ == '__main__':` -- a marker for a module-level entry point, not
# the name of a function. Sent to the engine as a function name it matched nothing, so a repository's
# module-level settings were unaskable: VAmPI's `host='0.0.0.0'` went unreported.
_MODULE_MARKERS = frozenset({"__main__"})
@dataclass(frozen=True)
class ScanRegion:
@@ -49,7 +58,7 @@ class ScanRegion:
language: str
families: tuple[str, ...]
rank: float
source: str # "entry_point" | "file_fallback"
source: str # "entry_point" | "module_scope" | "file_fallback"
def regions_for(entries: Sequence[object], targets: Sequence[object]) -> tuple[ScanRegion, ...]:
@@ -78,16 +87,50 @@ def regions_for(entries: Sequence[object], targets: Sequence[object]) -> tuple[S
if not families:
continue
function = getattr(entry, "function_name", None) or None
module_body = function in _MODULE_MARKERS
if module_body:
# A module body is not a handler, whatever the mapper called it, so it is offered the families a
# file gets rather than the families a handler gets. Carrying `access_control` here entailed a
# missing authorization check on `if __name__ == '__main__':`.
function, families = MODULE_SCOPE, _families(language, has_handler=False)
if not families:
continue
rank = _ACCESS_RANK.get(str(getattr(entry, "access_level", "")), 0.5)
key = (path, function)
current = best.get(key)
if current is None or rank > current.rank:
best[key] = ScanRegion(path=path, function=function, language=language, families=families, rank=rank, source="entry_point")
best[key] = ScanRegion(
path=path,
function=function,
language=language,
families=families,
rank=rank,
source="module_scope" if module_body else "entry_point",
)
# A nameless entry point IS the file. Where a named region already covers that file it adds nothing but
# a second full-file pass, so it is dropped -- unless the file has no named region at all.
named_paths = {path for path, function in best if function is not None}
regions = [region for (path, function), region in best.items() if function is not None or path not in named_paths]
# Every file whose regions are all function-scoped still has code that is in no function: the settings, the
# route registrations, the globals. Those regions cannot see it -- their queries filter by function name --
# and there is no file-level region beside them, because one would duplicate every function's findings.
# So the module body gets its own region, which partitions the file rather than overlapping it.
#
# This was cheap to add only after batching: before it, a region was a JVM.
scoped = {region.path for region in regions if region.function is not None}
have_module = {region.path for region in regions if region.function == MODULE_SCOPE}
for path in sorted(scoped - have_module):
language = by_path.get(path)
if language is None:
continue
families = _families(language, has_handler=False)
if families:
regions.append(
ScanRegion(path=path, function=MODULE_SCOPE, language=language, families=families, rank=0.15, source="module_scope")
)
covered = {region.path for region in regions}
for path, language in by_path.items():
+5 -2
View File
@@ -31,7 +31,7 @@ from .config_value import request_params as config_params
from .dominance import request_params as dominance_params
from .ladder import Rung
from .pipeline import ModelFinding, scan_region
from .regions import ScanRegion
from .regions import MODULE_SCOPE, ScanRegion
from .specs import ConfigSpec, DominanceSpec, TaintSpec, config_specs, dominance_specs, taint_specs
from .taint import request_params as taint_params
from .taxonomy import load_families
@@ -261,8 +261,11 @@ def _is_entry_point(region: ScanRegion) -> bool:
A named function the entry-point mapper found, not a file the walker fell back to. The distinction is
what makes treating parameters as untrusted sound: a handler's parameters carry request data, an
arbitrary helper's carry whatever its caller had.
A module body is excluded even when the mapper marked it an entry point: a module has no parameters, so
there is nothing for the parameter-source rule to mean there.
"""
return region.source == "entry_point" and bool(region.function)
return region.source == "entry_point" and bool(region.function) and region.function != MODULE_SCOPE
def _spec_for(family: str, language: str) -> ArbiterSpec | None: