Skip to content

First-class tagging for JavaScript frontend frameworks - #201

Merged
prabhu merged 32 commits into
mainfrom
feat/js-frontend-frameworks
Oct 12, 2026
Merged

prabhu merged 32 commits into
mainfrom
feat/js-frontend-frameworks

Conversation

@prabhu

@prabhu prabhu commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

This teaches the taggers about frontend frameworks directly, so atom's reachables finds real flows in SPAs and their server routes without a cdxgen SBOM. It covers Angular, React (Next.js, React Router, TanStack, Solid, Qwik), Vue (Nuxt, Nitro), Svelte/SvelteKit, Astro, Lit, Alpine, Ember, jQuery and plain DOM code. Package purls for sinks come from the project's own package.json and lockfile.

The recognizers live in taggers/js, one per framework, and match on import bindings, Angular DI types and file conventions rather than code regexes. Where a framework hands a value across by convention (a loader to useLoaderData, parent to child props, stores, directives, middleware locals), a bridge joins the two only when a source actually reaches the value, and tags the consumer with via:<file>#<line> so you can see where it came from.

Along the way: a few JavaScript dataflow fixes (nested member reads, closure captures, template operators that only read their arguments), satisfies lowering, component elements lowered to calls of the component, and much less EasyTagsPass noise on JS. It covers what #196 set out to do for signal inputs.

On a labelled corpus of 12 apps this finds 232 of 237 flows with no false positives (44 of 227 before). A hold-out set written blind scores 26 of 56 with 20 false positives, so there is more to do, mostly around props precision and allow-list style validation.

Needs to be released before AppThreat/atom#279. The astgen side is AppThreat/atom-parsetools#18.

webdevred and others added 22 commits October 11, 2026 18:46
…; URL-part request sinks

- Angular: resolve calls into the project's own injected services to the service's method;
  httpResource URLs, reactive form valueChanges, host metadata window listeners and
  Router.navigateByUrl.
- A request sink is the part of the URL that decides the target (whole URL, variable base,
  path variable), never the request call, so bodies and options are no forgery; a URL that is
  configuration (this.host, SCREAMING_CASE, environment.*) is none. Package attribution
  follows the URL part to its request.
- Web Storage reads are service-ingress (stored data, like a response).
- pages/api handlers: only req is input.
- Controller-file heuristic: in a frontend repo carrying a Node server, a matching file that
  makes requests or imports a frontend framework is a client, not a controller.
…om the base use

The nested-container rule linked an inner call to the access on its result
(route.query -> route.query.next) but not an inner identifier, so a read like
data.motd - whose base is a plain identifier - had no incoming edge at all and a
walk starting at it (the {@html data.motd} sink) found nothing.
…same-run call sites

ReactiveBridge tags the derivation call itself when its callback returns a
source - a composable that returns computed(...)/useState(key, fn) hands the
taint to every caller through its return value; useState joined the
derivations with its first function argument as the callback. MemberTaintBridge
learned captured local containers (term.value = v, q.set(v)): the container's
value crosses methods only through this bridge, like the class fields it
already handled. sourceTagThrough follows a parameter to the arguments its
callers pass, so a bridge can decide taintedness through one resolved call;
JsTagger records the CALL edges it resolves because they land in the graph only
when the pass ends.
…ted members as predicates

useLoaderData/useActionData resolve to the loader their type argument names
(or the file's single data function); a route component's $props() resolves to
the sibling load; a transformPageChunk callback's return value is an HTML sink;
runTask('db:reindex') resolves to the task file's run method; inject(key)
resolves to the callback behind provide(key, computed(...)) with keys
normalised to project paths; pinia store hooks resolve to their setup and their
method calls to the functions it returns; a call to a project function that
returns a derivation resolves to it. A validateSearch schema's non-text fields
(zod/valibot/yup number, enum, boolean, literal) make reads of those members of
useSearch() results predicates - nothing to inject - while z.string()
neutralises nothing. SvelteKit handlers' request members are tagged where they
are read, and useLocation().state reads are sources.
An element whose name is a capitalized identifier (<RichText html={bio} />)
is a component reference; the element now also lowers to a CALL named after
it, with the element's first computed attribute value as its argument. The
attribute expressions are lowered a second time for the call (constants and
boolean attributes carry nothing); the originals stay under the attribute
template nodes where the template sinks are tagged. ComponentCallBridge
resolves the call to the component - the import that bound the name, or the
one project method of that name - so a parent's value reaches the
component's props parameter, which is its only input boundary. A component
that is a FILE (.vue/.svelte) has no parameter to receive the argument and
stays unresolved; its props-side flow starts at the defineProps()/$props()
read.
…efix

Alpine's x-html="expr" interpolates without escaping like v-html and
{@html}; x-bind:href names the href binding the way v-bind: and : do.
…ngs read the inputs

An Angular component's x.component.html is linked to its component by the
file convention. [(ngModel)]="field" means the control writes the field
with user input, so the field's this-reads are sources; a binding that
names an input (caption(), partner()) reads the component's input. An
HttpClient request whose URL was built into a local keeps the local's
origin shape, so a host the service configures is not a request sink; a
template attribute's name strips an Angular property-binding bracket.
…ts and html templates as lowerings; validation as a neutraliser
…work

Component elements: the lowered call carries one props object, <operator>.jsxProps over every
value the element computes (it carried only the first attribute, so a tainted second prop was
lost and a tainted first one stood for all). ComponentCallBridge finds element calls by their
element child, resolves only function components in script files - a default import to the
module's default export, a named one to its export - instead of the first anonymous method of
whatever file matched, and resolves extension-qualified imports. Template interpolations are
wrapped even when the value is a call, so a signal read (`partner()`, `props.html`) can be the
source and the interpolation the sink.

Angular templates: linked by @component templateUrl, not by a filename regex. Property bindings
keep their brackets and are no markup sinks - Angular sanitises them; only [href]/[attr.href] on
<a>/<area> is a link sink. Pipes resolve to the @pipe class's transform, so a bypassing pipe is
reached through its own code; the member bridge also tags a component's template reads.

Provenance: bridges tag what they make sources with via:<file>#<line> - the value they carried
and the source behind it - through JsTagger.tagVia. Astro locals become a bridge from middleware
writes (they were a source unconditionally); Nitro event.context, the member and reactive
bridges record provenance too.

Precision: SvelteKit handler members are no longer tagged a second time (it doubled the app's
flows to match labels); data functions keep framework-route and gain framework-data, whose
return value goes to the page; React Router loaders count as resource routes only when exported
from a module with no component; JSON responses neutralise; a server handler's new Response
with an unfixed body is a sink unless served as data; a component's action/src props are no URL
sinks (`<Form action={ack}>`); `this.href = v` is a component field; xhr.send, map.delete and
cookies.delete are no SDK actions; useHead sinks are the head entry's values, not the call;
NextResponse binds through the `next` package.
…e provenance

Single-file components (.vue, .svelte, .astro) cannot be resolved as functions; where an element
passes one a value a source reaches, the props entry (defineProps(), $props(), Astro.props) and a
Vue component's template reads of its declared props are tagged that source's kind with
provenance. Vue directives' binding parameters are sources only where a template binds the
directive to such a value (they were unconditionally). Both run in the bridge loop, after the
member and reactive bridges.

Provenance records the call-site arguments a value crossed (`store.setTerm(route.query.term)`),
not only the source and the bridged write. The source search follows a definition reached through
another read's definitions to what it was assigned - a template's `article` read behind
`v-if="article"` reached `article = _tmp.data` and stopped.

Vue <script setup> props declared by type or object (`defineProps<{ html: string }>()`) make the
template's bare reads of them sources.
…etServerSideProps, wrappers, registries

Bridges that join what a framework hands from one place to another by convention, each only
where a source reaches the value, with provenance: Svelte `use:` actions (conditional now, like
Vue directives), Alpine.data components property by property (a sanitising getter stays clean),
Vue Options API computed/data into the template, Next.js getServerSideProps/getStaticProps props
into the page (entrypoints found by export name too). Calls of SolidStart query/action wrappers
resolve to the wrapped function; calls of a function looked up in a Map resolve to what was put
into it, through a registering helper's parameter and namespace-import members.

Fixes found on the way:
- JsIndexes.assignedValues counted a nested function's assignments as the method's own, so a
  callback that destructures the same names made the outer variable ambiguous and origin gave up
  (forkJoin([a, b]).subscribe(([a, b]) => ...)).
- jssrc2cpg lowered `x satisfies T` without a reference to a function `x`, so `export const
  getServerSideProps = (async () => ...) satisfies ...` assigned nothing.
- <operator>.interpolation and <operator>.jsxProps no longer generate definitions of their
  arguments: a template's read of one field reached every later read off the same object (an
  Alpine sanitising getter was reported). The operator set lives in an object - generation runs
  while the transfer function is constructed.
- The identifier-base edge GLM added to the nested-container rule is gone: with every template
  value interpolated, the walk no longer starts at a bare field read, and the edge only spread
  one field's taint to its siblings.
- The provenance search names every source an object carries (bounded), so a page's props from
  `{ props: { id, notice } }` name both.
- A URL prefix that is a query or fragment of the current page, or a relative path segment, keeps
  the origin; hasFixedOrigin shares fixesOrigin.
…ound SQL parameters, schema parses; no binding to implicit receivers
…ds, hook and composable returns by member, refs written later, captured parameters, imported directives, provenance keeps the chain
…ndow messages, template literals and implicit receivers define nothing
- JsPathAliases: tsconfig paths/baseUrl, a package's own name with exports/imports, and the @/ ~/ $lib conventions resolve to project files; JsModules and ImportResolverPass use them
- Ember: route model() -> template @model, controller queryParams -> route template reads, getter reads, modifiers resolved to the wrapped callback; .gjs/.gts/.hbs components take their @Args through the prop links
- BabelJsonParser slices code from the source astgen sends for a rewritten file
- fetch: the response comes from the URL and the receiver, not the request body
- new URL('<fixed path>', base) has a fixed origin; bind() arguments, hook returns, anchored zod regexes
…tack Start server functions and routes; SolidStart API routes; renamed and nested destructured parameters

- Ember: injected service calls and fields, helpers, controller getters in route templates, {{#each}} items, router.currentURL
- TanStack Start: createServerFn handlers take client data and resolve their callers (useServerFn too), a guarding validator sanitises data, server-route handlers, loaderDeps -> loader deps, redirect({ href })
- SolidStart API routes, @solidjs/router redirect, Solid signal getter reads
- jssrc2cpg: { key: local } and { key: { inner } } parameters bind the right locals
- getServerSideProps redirect destination is a sink; res.json and literal headers are no output; SVG responses are markup; constant Map lookups sanitise; this.member callbacks resolve
@prabhu prabhu added the ai-auto label Oct 12, 2026
@prabhu
prabhu enabled auto-merge October 12, 2026 16:30
@prabhu
prabhu added this pull request to the merge queue Oct 12, 2026
Merged via the queue into main with commit b39a6d6 Oct 12, 2026
@prabhu
prabhu deleted the feat/js-frontend-frameworks branch October 12, 2026 17:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants