circle-ir 3.181.0 → 3.185.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analysis/config-loader.d.ts.map +1 -1
- package/dist/analysis/config-loader.js +97 -0
- package/dist/analysis/config-loader.js.map +1 -1
- package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
- package/dist/analysis/passes/language-sources-pass.js +207 -0
- package/dist/analysis/passes/language-sources-pass.js.map +1 -1
- package/dist/analysis/passes/taint-propagation-pass.d.ts.map +1 -1
- package/dist/analysis/passes/taint-propagation-pass.js +158 -0
- package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
- package/dist/analysis/taint-matcher.js +27 -1
- package/dist/analysis/taint-matcher.js.map +1 -1
- package/dist/analysis/taint-propagation.d.ts.map +1 -1
- package/dist/analysis/taint-propagation.js +47 -11
- package/dist/analysis/taint-propagation.js.map +1 -1
- package/dist/browser/circle-ir.js +413 -10
- package/dist/core/circle-ir-core.cjs +189 -9
- package/dist/core/circle-ir-core.js +189 -9
- package/dist/core/extractors/dfg.js +99 -0
- package/dist/core/extractors/dfg.js.map +1 -1
- package/package.json +1 -1
|
@@ -10066,6 +10066,10 @@ function processGoBlock(node, defs, uses, scopeStack, counters) {
|
|
|
10066
10066
|
} else if (child.type === "for_statement") {
|
|
10067
10067
|
const rangeClause = findChildByTypeGo(child, "range_clause");
|
|
10068
10068
|
if (rangeClause) {
|
|
10069
|
+
const right = rangeClause.childForFieldName("right");
|
|
10070
|
+
if (right) {
|
|
10071
|
+
extractGoUses(right, uses, scopeStack);
|
|
10072
|
+
}
|
|
10069
10073
|
const left = rangeClause.childForFieldName("left");
|
|
10070
10074
|
if (left) {
|
|
10071
10075
|
extractGoLhsDefs(left, defs, scopeStack, child.startPosition.row + 1);
|
|
@@ -10073,6 +10077,7 @@ function processGoBlock(node, defs, uses, scopeStack, counters) {
|
|
|
10073
10077
|
}
|
|
10074
10078
|
} else if (child.type === "call_expression") {
|
|
10075
10079
|
extractGoUses(child, uses, scopeStack);
|
|
10080
|
+
recordGoOpaqueCodecDestDef(child, defs, scopeStack);
|
|
10076
10081
|
} else if (child.type === "return_statement") {
|
|
10077
10082
|
for (let i2 = 0; i2 < child.childCount; i2++) {
|
|
10078
10083
|
const expr = child.child(i2);
|
|
@@ -10165,6 +10170,63 @@ function findChildByTypeGo(node, type) {
|
|
|
10165
10170
|
}
|
|
10166
10171
|
return null;
|
|
10167
10172
|
}
|
|
10173
|
+
var GO_OPAQUE_CODEC_METHODS = /* @__PURE__ */ new Set([
|
|
10174
|
+
"Unmarshal",
|
|
10175
|
+
// json/xml/yaml/toml/gob
|
|
10176
|
+
"Decode",
|
|
10177
|
+
// json.NewDecoder(r).Decode(&dest), gob.Decoder.Decode
|
|
10178
|
+
"NewDecoder",
|
|
10179
|
+
// wrapper; handled via chained Decode above
|
|
10180
|
+
"UnmarshalYAML",
|
|
10181
|
+
"UnmarshalJSON",
|
|
10182
|
+
"UnmarshalText",
|
|
10183
|
+
"UnmarshalBinary"
|
|
10184
|
+
]);
|
|
10185
|
+
function recordGoOpaqueCodecDestDef(call, defs, scopeStack) {
|
|
10186
|
+
const fn = call.childForFieldName("function");
|
|
10187
|
+
if (!fn || fn.type !== "selector_expression") return;
|
|
10188
|
+
const fieldNode = fn.childForFieldName("field");
|
|
10189
|
+
if (!fieldNode) return;
|
|
10190
|
+
const method = getNodeText(fieldNode);
|
|
10191
|
+
if (!GO_OPAQUE_CODEC_METHODS.has(method)) return;
|
|
10192
|
+
const argsNode = call.childForFieldName("arguments");
|
|
10193
|
+
if (!argsNode) return;
|
|
10194
|
+
const args2 = [];
|
|
10195
|
+
for (let i2 = 0; i2 < argsNode.childCount; i2++) {
|
|
10196
|
+
const c = argsNode.child(i2);
|
|
10197
|
+
if (!c) continue;
|
|
10198
|
+
if (c.type === "," || c.type === "(" || c.type === ")") continue;
|
|
10199
|
+
args2.push(c);
|
|
10200
|
+
}
|
|
10201
|
+
if (args2.length === 0) return;
|
|
10202
|
+
const destArg = args2.length >= 2 ? args2[1] : args2[0];
|
|
10203
|
+
const destName = extractGoAddressableVarName(destArg);
|
|
10204
|
+
if (!destName || destName === "_") return;
|
|
10205
|
+
const line = call.startPosition.row + 1;
|
|
10206
|
+
const def = {
|
|
10207
|
+
id: defs.length + 1,
|
|
10208
|
+
variable: destName,
|
|
10209
|
+
kind: "local",
|
|
10210
|
+
line
|
|
10211
|
+
};
|
|
10212
|
+
defs.push(def);
|
|
10213
|
+
currentScope(scopeStack).set(destName, def.id);
|
|
10214
|
+
}
|
|
10215
|
+
function extractGoAddressableVarName(node) {
|
|
10216
|
+
if (node.type === "unary_expression") {
|
|
10217
|
+
const operand = node.childForFieldName("operand") ?? node.child(1) ?? null;
|
|
10218
|
+
if (operand) return extractGoAddressableVarName(operand);
|
|
10219
|
+
return null;
|
|
10220
|
+
}
|
|
10221
|
+
if (node.type === "identifier") {
|
|
10222
|
+
return getNodeText(node);
|
|
10223
|
+
}
|
|
10224
|
+
if (node.type === "selector_expression") {
|
|
10225
|
+
const field = node.childForFieldName("field");
|
|
10226
|
+
if (field) return getNodeText(field);
|
|
10227
|
+
}
|
|
10228
|
+
return null;
|
|
10229
|
+
}
|
|
10168
10230
|
|
|
10169
10231
|
// src/analysis/config-loader.ts
|
|
10170
10232
|
function parseConfig(content) {
|
|
@@ -10659,6 +10721,49 @@ var DEFAULT_SOURCES = [
|
|
|
10659
10721
|
// Server-side interceptor receives `Metadata headers`; `headers.get(KEY)`
|
|
10660
10722
|
// returns caller-supplied header values.
|
|
10661
10723
|
{ method: "get", class: "Metadata", type: "http_header", severity: "high", return_tainted: true, languages: ["java"] },
|
|
10724
|
+
// --- WebSocket transport channels (cognium-dev #213 second slice) ---
|
|
10725
|
+
//
|
|
10726
|
+
// Server-side WebSocket handlers receive attacker-authored frames on
|
|
10727
|
+
// every call to a receive-shaped method. Untrusted the moment they
|
|
10728
|
+
// return, regardless of any application-level auth on the socket.
|
|
10729
|
+
//
|
|
10730
|
+
// Python — FastAPI / Starlette (`from fastapi import WebSocket`):
|
|
10731
|
+
// data = await websocket.receive_text()
|
|
10732
|
+
// data = await websocket.receive_bytes()
|
|
10733
|
+
// data = await websocket.receive_json() # parsed dict/list
|
|
10734
|
+
// data = await websocket.receive() # {'type', 'text'|'bytes'}
|
|
10735
|
+
{ method: "receive_text", class: "WebSocket", type: "network_input", severity: "high", return_tainted: true, languages: ["python"] },
|
|
10736
|
+
{ method: "receive_bytes", class: "WebSocket", type: "network_input", severity: "high", return_tainted: true, languages: ["python"] },
|
|
10737
|
+
{ method: "receive_json", class: "WebSocket", type: "http_body", severity: "high", return_tainted: true, languages: ["python"] },
|
|
10738
|
+
//
|
|
10739
|
+
// `receive` intentionally class-scoped to WebSocket (Starlette pattern).
|
|
10740
|
+
// Broadening to unqualified would collide with queue/signal `.receive()`.
|
|
10741
|
+
{ method: "receive", class: "WebSocket", type: "network_input", severity: "high", return_tainted: true, languages: ["python"] },
|
|
10742
|
+
// Django Channels `AsyncJsonWebsocketConsumer` / `WebsocketConsumer`
|
|
10743
|
+
// expose the same receive_* names on `self`; the class filter matches
|
|
10744
|
+
// `WebSocket` only, so add unqualified fallbacks for the receive_json /
|
|
10745
|
+
// receive_text convention (broad — no class filter possible without
|
|
10746
|
+
// hardcoding Django's consumer names).
|
|
10747
|
+
{ method: "receive_json", type: "http_body", severity: "high", return_tainted: true, languages: ["python"] },
|
|
10748
|
+
{ method: "receive_text", type: "network_input", severity: "high", return_tainted: true, languages: ["python"] },
|
|
10749
|
+
{ method: "receive_bytes", type: "network_input", severity: "high", return_tainted: true, languages: ["python"] },
|
|
10750
|
+
// Go — gorilla/websocket (`github.com/gorilla/websocket`):
|
|
10751
|
+
// messageType, message, err := conn.ReadMessage()
|
|
10752
|
+
// _, r, err := conn.NextReader()
|
|
10753
|
+
{ method: "ReadMessage", class: "Conn", type: "network_input", severity: "high", return_tainted: true, languages: ["go"] },
|
|
10754
|
+
{ method: "NextReader", class: "Conn", type: "network_input", severity: "high", return_tainted: true, languages: ["go"] },
|
|
10755
|
+
// nhooyr.io/websocket exposes a `Read` method on `*websocket.Conn`;
|
|
10756
|
+
// signature is `func (c *Conn) Read(ctx) (MessageType, []byte, error)`.
|
|
10757
|
+
{ method: "Read", class: "Conn", type: "network_input", severity: "high", return_tainted: true, languages: ["go"] },
|
|
10758
|
+
// Java — Jakarta / Java WebSocket API (`javax.websocket` / `jakarta.websocket`):
|
|
10759
|
+
// session.getBasicRemote().sendText(input); // OUT (not a source)
|
|
10760
|
+
// @OnMessage public void onMessage(String msg) — `msg` is a callback
|
|
10761
|
+
// param, not a return; handled via param_tainted when annotation is set.
|
|
10762
|
+
{ annotation: "OnMessage", type: "network_input", severity: "high", param_tainted: true, languages: ["java"] },
|
|
10763
|
+
// Spring `@MessageMapping` STOMP-over-WebSocket handlers receive
|
|
10764
|
+
// untrusted payloads on every dispatched frame.
|
|
10765
|
+
{ annotation: "MessageMapping", type: "http_body", severity: "high", param_tainted: true, languages: ["java"] },
|
|
10766
|
+
{ annotation: "SubscribeMapping", type: "http_body", severity: "high", param_tainted: true, languages: ["java"] },
|
|
10662
10767
|
// --- Cache reads (second-order taint — Redis / Memcached / Django cache) ---
|
|
10663
10768
|
// The cache round-trip is a canonical second-order sink: whatever was
|
|
10664
10769
|
// written previously (potentially attacker-controlled) resurfaces on read.
|
|
@@ -12731,6 +12836,60 @@ var DEFAULT_SANITIZERS = [
|
|
|
12731
12836
|
// Python Type coercion
|
|
12732
12837
|
{ method: "int", removes: ["sql_injection", "command_injection", "xss"] },
|
|
12733
12838
|
{ method: "float", removes: ["sql_injection", "command_injection"] },
|
|
12839
|
+
// Python URL encoding (cognium-dev #213 fifth slice).
|
|
12840
|
+
//
|
|
12841
|
+
// urllib.parse.quote(x) — RFC-3986 percent-encode; safe for URL path
|
|
12842
|
+
// urllib.parse.quote_plus(x) — same + space→+; safe for query string
|
|
12843
|
+
// urllib.parse.urlencode(d) — encode a dict of pairs
|
|
12844
|
+
//
|
|
12845
|
+
// Bounded to URL-context sinks. Class-scoped to the specific
|
|
12846
|
+
// `urllib.parse` receiver to avoid colliding with unrelated bare
|
|
12847
|
+
// `quote(...)` calls in other libraries.
|
|
12848
|
+
{ method: "quote", class: "urllib.parse", removes: ["ssrf", "open_redirect", "xss", "path_traversal"] },
|
|
12849
|
+
{ method: "quote_plus", class: "urllib.parse", removes: ["ssrf", "open_redirect", "xss", "path_traversal"] },
|
|
12850
|
+
{ method: "urlencode", class: "urllib.parse", removes: ["ssrf", "open_redirect", "xss"] },
|
|
12851
|
+
// Bare aliases — `from urllib.parse import quote` then unqualified.
|
|
12852
|
+
{ method: "quote_plus", removes: ["ssrf", "open_redirect", "xss", "path_traversal"] },
|
|
12853
|
+
{ method: "urlencode", removes: ["ssrf", "open_redirect", "xss"] },
|
|
12854
|
+
// `quote` bare is intentionally NOT registered — it collides with
|
|
12855
|
+
// shlex.quote (bare-imported) which is a command_injection sanitizer,
|
|
12856
|
+
// not a URL sanitizer. The class-scoped variant above catches the
|
|
12857
|
+
// qualified `urllib.parse.quote(...)` shape; unqualified callers who
|
|
12858
|
+
// want URL-context credit should use `quote_plus` (which does not
|
|
12859
|
+
// collide with any command_injection sanitizer).
|
|
12860
|
+
// Python XSS — additional common sanitizers.
|
|
12861
|
+
//
|
|
12862
|
+
// bleach.clean(...) — already covered above (line 2898)
|
|
12863
|
+
// bleach.linkify(...) — turns URLs into anchor tags; sanitizes as well
|
|
12864
|
+
// django.utils.html.escape / strip_tags — Django's XSS escape helpers
|
|
12865
|
+
// jinja2.escape — Jinja2's Markup escape (aliased from markupsafe)
|
|
12866
|
+
// flask.escape — Flask re-export of markupsafe.escape
|
|
12867
|
+
// saxutils.escape — stdlib xml.sax.saxutils.escape for XML docs
|
|
12868
|
+
{ method: "linkify", class: "bleach", removes: ["xss"] },
|
|
12869
|
+
// Bare `linkify(...)` alias — `from bleach import linkify`.
|
|
12870
|
+
{ method: "linkify", removes: ["xss"] },
|
|
12871
|
+
{ method: "escape", class: "django.utils.html", removes: ["xss"] },
|
|
12872
|
+
{ method: "strip_tags", class: "django.utils.html", removes: ["xss"] },
|
|
12873
|
+
{ method: "escape", class: "jinja2", removes: ["xss"] },
|
|
12874
|
+
{ method: "escape", class: "flask", removes: ["xss"] },
|
|
12875
|
+
{ method: "escape", class: "saxutils", removes: ["xss"] },
|
|
12876
|
+
{ method: "escape", class: "xml.sax.saxutils", removes: ["xss"] },
|
|
12877
|
+
{ method: "quoteattr", class: "saxutils", removes: ["xss"] },
|
|
12878
|
+
{ method: "quoteattr", class: "xml.sax.saxutils", removes: ["xss"] },
|
|
12879
|
+
// Python ReDoS — `re.escape(user)` when building a regex from user input
|
|
12880
|
+
// strips regex metacharacters. Downstream `re.compile / re.match` cannot
|
|
12881
|
+
// interpret user-supplied alternations or quantifiers. Also covers the
|
|
12882
|
+
// `code_injection` categorization that `re.compile` currently emits
|
|
12883
|
+
// (a re.escape-wrapped pattern cannot execute anything, so both are safe).
|
|
12884
|
+
{ method: "escape", class: "re", removes: ["redos", "code_injection"] },
|
|
12885
|
+
// Python SQLAlchemy — `text(...).bindparams(...)` binds params safely.
|
|
12886
|
+
// The `bindparams` call is the sanitizer; the parent `text` wraps the
|
|
12887
|
+
// template. Also add `expression.literal` for explicit SQL literals.
|
|
12888
|
+
{ method: "bindparams", removes: ["sql_injection"] },
|
|
12889
|
+
// psycopg2 sql-composition helpers
|
|
12890
|
+
{ method: "Identifier", class: "sql", removes: ["sql_injection"] },
|
|
12891
|
+
{ method: "Literal", class: "sql", removes: ["sql_injection"] },
|
|
12892
|
+
{ method: "Placeholder", class: "sql", removes: ["sql_injection"] },
|
|
12734
12893
|
// =========================================================================
|
|
12735
12894
|
// Rust Sanitizers
|
|
12736
12895
|
// =========================================================================
|
|
@@ -14559,7 +14718,14 @@ function matchesSanitizerPattern(call, pattern) {
|
|
|
14559
14718
|
return false;
|
|
14560
14719
|
}
|
|
14561
14720
|
if (pattern.class) {
|
|
14562
|
-
if (!call.receiver
|
|
14721
|
+
if (!call.receiver) {
|
|
14722
|
+
const target = call.resolution?.target;
|
|
14723
|
+
const expectedTail = `${pattern.class}.${pattern.method}`;
|
|
14724
|
+
if (target && (target === expectedTail || target.endsWith("." + expectedTail))) {
|
|
14725
|
+
} else {
|
|
14726
|
+
return false;
|
|
14727
|
+
}
|
|
14728
|
+
} else if (!receiverMightBeClass(call.receiver, pattern.class)) {
|
|
14563
14729
|
return false;
|
|
14564
14730
|
}
|
|
14565
14731
|
}
|
|
@@ -14987,15 +15153,21 @@ function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinksOrSanit
|
|
|
14987
15153
|
const callsAtSink = callsByLine.get(sink.line) ?? [];
|
|
14988
15154
|
for (const call of callsAtSink) {
|
|
14989
15155
|
for (const arg of call.arguments) {
|
|
14990
|
-
if (
|
|
14991
|
-
if (sink.argPositions
|
|
14992
|
-
|
|
14993
|
-
continue;
|
|
14994
|
-
}
|
|
15156
|
+
if (sink.argPositions && sink.argPositions.length > 0) {
|
|
15157
|
+
if (!sink.argPositions.includes(arg.position)) {
|
|
15158
|
+
continue;
|
|
14995
15159
|
}
|
|
14996
|
-
|
|
14997
|
-
|
|
15160
|
+
}
|
|
15161
|
+
const candidateUses = arg.variable ? usesAtSink.filter((u) => u.variable === arg.variable) : usesAtSink;
|
|
15162
|
+
{
|
|
15163
|
+
for (const use of candidateUses) {
|
|
15164
|
+
if (use.def_id !== null) {
|
|
14998
15165
|
if (allTaintedDefIds.has(use.def_id)) {
|
|
15166
|
+
if (!arg.variable) {
|
|
15167
|
+
if (typeof arg.expression !== "string" || arg.expression.length === 0) continue;
|
|
15168
|
+
const re = new RegExp(`(?:^|[^A-Za-z0-9_$])${use.variable.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:[^A-Za-z0-9_$]|$)`);
|
|
15169
|
+
if (!re.test(arg.expression)) continue;
|
|
15170
|
+
}
|
|
14999
15171
|
const taintInfo = taintByDefId.get(use.def_id);
|
|
15000
15172
|
if (taintInfo) {
|
|
15001
15173
|
const isSanitized = checkSanitized(
|
|
@@ -15033,7 +15205,15 @@ function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinksOrSanit
|
|
|
15033
15205
|
}
|
|
15034
15206
|
}
|
|
15035
15207
|
}
|
|
15036
|
-
|
|
15208
|
+
const seen = /* @__PURE__ */ new Set();
|
|
15209
|
+
const deduped = [];
|
|
15210
|
+
for (const f of flows) {
|
|
15211
|
+
const key = `${f.source.line}|${f.sink.line}|${f.sink.type}|${f.path.map((p) => p.variable).join(">")}`;
|
|
15212
|
+
if (seen.has(key)) continue;
|
|
15213
|
+
seen.add(key);
|
|
15214
|
+
deduped.push(f);
|
|
15215
|
+
}
|
|
15216
|
+
return { taintedVars, flows: deduped, reachableSinks };
|
|
15037
15217
|
}
|
|
15038
15218
|
function findInitialTaint(sources, callsByLine, defsByLine) {
|
|
15039
15219
|
const tainted = [];
|
|
@@ -10000,6 +10000,10 @@ function processGoBlock(node, defs, uses, scopeStack, counters) {
|
|
|
10000
10000
|
} else if (child.type === "for_statement") {
|
|
10001
10001
|
const rangeClause = findChildByTypeGo(child, "range_clause");
|
|
10002
10002
|
if (rangeClause) {
|
|
10003
|
+
const right = rangeClause.childForFieldName("right");
|
|
10004
|
+
if (right) {
|
|
10005
|
+
extractGoUses(right, uses, scopeStack);
|
|
10006
|
+
}
|
|
10003
10007
|
const left = rangeClause.childForFieldName("left");
|
|
10004
10008
|
if (left) {
|
|
10005
10009
|
extractGoLhsDefs(left, defs, scopeStack, child.startPosition.row + 1);
|
|
@@ -10007,6 +10011,7 @@ function processGoBlock(node, defs, uses, scopeStack, counters) {
|
|
|
10007
10011
|
}
|
|
10008
10012
|
} else if (child.type === "call_expression") {
|
|
10009
10013
|
extractGoUses(child, uses, scopeStack);
|
|
10014
|
+
recordGoOpaqueCodecDestDef(child, defs, scopeStack);
|
|
10010
10015
|
} else if (child.type === "return_statement") {
|
|
10011
10016
|
for (let i2 = 0; i2 < child.childCount; i2++) {
|
|
10012
10017
|
const expr = child.child(i2);
|
|
@@ -10099,6 +10104,63 @@ function findChildByTypeGo(node, type) {
|
|
|
10099
10104
|
}
|
|
10100
10105
|
return null;
|
|
10101
10106
|
}
|
|
10107
|
+
var GO_OPAQUE_CODEC_METHODS = /* @__PURE__ */ new Set([
|
|
10108
|
+
"Unmarshal",
|
|
10109
|
+
// json/xml/yaml/toml/gob
|
|
10110
|
+
"Decode",
|
|
10111
|
+
// json.NewDecoder(r).Decode(&dest), gob.Decoder.Decode
|
|
10112
|
+
"NewDecoder",
|
|
10113
|
+
// wrapper; handled via chained Decode above
|
|
10114
|
+
"UnmarshalYAML",
|
|
10115
|
+
"UnmarshalJSON",
|
|
10116
|
+
"UnmarshalText",
|
|
10117
|
+
"UnmarshalBinary"
|
|
10118
|
+
]);
|
|
10119
|
+
function recordGoOpaqueCodecDestDef(call, defs, scopeStack) {
|
|
10120
|
+
const fn = call.childForFieldName("function");
|
|
10121
|
+
if (!fn || fn.type !== "selector_expression") return;
|
|
10122
|
+
const fieldNode = fn.childForFieldName("field");
|
|
10123
|
+
if (!fieldNode) return;
|
|
10124
|
+
const method = getNodeText(fieldNode);
|
|
10125
|
+
if (!GO_OPAQUE_CODEC_METHODS.has(method)) return;
|
|
10126
|
+
const argsNode = call.childForFieldName("arguments");
|
|
10127
|
+
if (!argsNode) return;
|
|
10128
|
+
const args2 = [];
|
|
10129
|
+
for (let i2 = 0; i2 < argsNode.childCount; i2++) {
|
|
10130
|
+
const c = argsNode.child(i2);
|
|
10131
|
+
if (!c) continue;
|
|
10132
|
+
if (c.type === "," || c.type === "(" || c.type === ")") continue;
|
|
10133
|
+
args2.push(c);
|
|
10134
|
+
}
|
|
10135
|
+
if (args2.length === 0) return;
|
|
10136
|
+
const destArg = args2.length >= 2 ? args2[1] : args2[0];
|
|
10137
|
+
const destName = extractGoAddressableVarName(destArg);
|
|
10138
|
+
if (!destName || destName === "_") return;
|
|
10139
|
+
const line = call.startPosition.row + 1;
|
|
10140
|
+
const def = {
|
|
10141
|
+
id: defs.length + 1,
|
|
10142
|
+
variable: destName,
|
|
10143
|
+
kind: "local",
|
|
10144
|
+
line
|
|
10145
|
+
};
|
|
10146
|
+
defs.push(def);
|
|
10147
|
+
currentScope(scopeStack).set(destName, def.id);
|
|
10148
|
+
}
|
|
10149
|
+
function extractGoAddressableVarName(node) {
|
|
10150
|
+
if (node.type === "unary_expression") {
|
|
10151
|
+
const operand = node.childForFieldName("operand") ?? node.child(1) ?? null;
|
|
10152
|
+
if (operand) return extractGoAddressableVarName(operand);
|
|
10153
|
+
return null;
|
|
10154
|
+
}
|
|
10155
|
+
if (node.type === "identifier") {
|
|
10156
|
+
return getNodeText(node);
|
|
10157
|
+
}
|
|
10158
|
+
if (node.type === "selector_expression") {
|
|
10159
|
+
const field = node.childForFieldName("field");
|
|
10160
|
+
if (field) return getNodeText(field);
|
|
10161
|
+
}
|
|
10162
|
+
return null;
|
|
10163
|
+
}
|
|
10102
10164
|
|
|
10103
10165
|
// src/analysis/config-loader.ts
|
|
10104
10166
|
function parseConfig(content) {
|
|
@@ -10593,6 +10655,49 @@ var DEFAULT_SOURCES = [
|
|
|
10593
10655
|
// Server-side interceptor receives `Metadata headers`; `headers.get(KEY)`
|
|
10594
10656
|
// returns caller-supplied header values.
|
|
10595
10657
|
{ method: "get", class: "Metadata", type: "http_header", severity: "high", return_tainted: true, languages: ["java"] },
|
|
10658
|
+
// --- WebSocket transport channels (cognium-dev #213 second slice) ---
|
|
10659
|
+
//
|
|
10660
|
+
// Server-side WebSocket handlers receive attacker-authored frames on
|
|
10661
|
+
// every call to a receive-shaped method. Untrusted the moment they
|
|
10662
|
+
// return, regardless of any application-level auth on the socket.
|
|
10663
|
+
//
|
|
10664
|
+
// Python — FastAPI / Starlette (`from fastapi import WebSocket`):
|
|
10665
|
+
// data = await websocket.receive_text()
|
|
10666
|
+
// data = await websocket.receive_bytes()
|
|
10667
|
+
// data = await websocket.receive_json() # parsed dict/list
|
|
10668
|
+
// data = await websocket.receive() # {'type', 'text'|'bytes'}
|
|
10669
|
+
{ method: "receive_text", class: "WebSocket", type: "network_input", severity: "high", return_tainted: true, languages: ["python"] },
|
|
10670
|
+
{ method: "receive_bytes", class: "WebSocket", type: "network_input", severity: "high", return_tainted: true, languages: ["python"] },
|
|
10671
|
+
{ method: "receive_json", class: "WebSocket", type: "http_body", severity: "high", return_tainted: true, languages: ["python"] },
|
|
10672
|
+
//
|
|
10673
|
+
// `receive` intentionally class-scoped to WebSocket (Starlette pattern).
|
|
10674
|
+
// Broadening to unqualified would collide with queue/signal `.receive()`.
|
|
10675
|
+
{ method: "receive", class: "WebSocket", type: "network_input", severity: "high", return_tainted: true, languages: ["python"] },
|
|
10676
|
+
// Django Channels `AsyncJsonWebsocketConsumer` / `WebsocketConsumer`
|
|
10677
|
+
// expose the same receive_* names on `self`; the class filter matches
|
|
10678
|
+
// `WebSocket` only, so add unqualified fallbacks for the receive_json /
|
|
10679
|
+
// receive_text convention (broad — no class filter possible without
|
|
10680
|
+
// hardcoding Django's consumer names).
|
|
10681
|
+
{ method: "receive_json", type: "http_body", severity: "high", return_tainted: true, languages: ["python"] },
|
|
10682
|
+
{ method: "receive_text", type: "network_input", severity: "high", return_tainted: true, languages: ["python"] },
|
|
10683
|
+
{ method: "receive_bytes", type: "network_input", severity: "high", return_tainted: true, languages: ["python"] },
|
|
10684
|
+
// Go — gorilla/websocket (`github.com/gorilla/websocket`):
|
|
10685
|
+
// messageType, message, err := conn.ReadMessage()
|
|
10686
|
+
// _, r, err := conn.NextReader()
|
|
10687
|
+
{ method: "ReadMessage", class: "Conn", type: "network_input", severity: "high", return_tainted: true, languages: ["go"] },
|
|
10688
|
+
{ method: "NextReader", class: "Conn", type: "network_input", severity: "high", return_tainted: true, languages: ["go"] },
|
|
10689
|
+
// nhooyr.io/websocket exposes a `Read` method on `*websocket.Conn`;
|
|
10690
|
+
// signature is `func (c *Conn) Read(ctx) (MessageType, []byte, error)`.
|
|
10691
|
+
{ method: "Read", class: "Conn", type: "network_input", severity: "high", return_tainted: true, languages: ["go"] },
|
|
10692
|
+
// Java — Jakarta / Java WebSocket API (`javax.websocket` / `jakarta.websocket`):
|
|
10693
|
+
// session.getBasicRemote().sendText(input); // OUT (not a source)
|
|
10694
|
+
// @OnMessage public void onMessage(String msg) — `msg` is a callback
|
|
10695
|
+
// param, not a return; handled via param_tainted when annotation is set.
|
|
10696
|
+
{ annotation: "OnMessage", type: "network_input", severity: "high", param_tainted: true, languages: ["java"] },
|
|
10697
|
+
// Spring `@MessageMapping` STOMP-over-WebSocket handlers receive
|
|
10698
|
+
// untrusted payloads on every dispatched frame.
|
|
10699
|
+
{ annotation: "MessageMapping", type: "http_body", severity: "high", param_tainted: true, languages: ["java"] },
|
|
10700
|
+
{ annotation: "SubscribeMapping", type: "http_body", severity: "high", param_tainted: true, languages: ["java"] },
|
|
10596
10701
|
// --- Cache reads (second-order taint — Redis / Memcached / Django cache) ---
|
|
10597
10702
|
// The cache round-trip is a canonical second-order sink: whatever was
|
|
10598
10703
|
// written previously (potentially attacker-controlled) resurfaces on read.
|
|
@@ -12665,6 +12770,60 @@ var DEFAULT_SANITIZERS = [
|
|
|
12665
12770
|
// Python Type coercion
|
|
12666
12771
|
{ method: "int", removes: ["sql_injection", "command_injection", "xss"] },
|
|
12667
12772
|
{ method: "float", removes: ["sql_injection", "command_injection"] },
|
|
12773
|
+
// Python URL encoding (cognium-dev #213 fifth slice).
|
|
12774
|
+
//
|
|
12775
|
+
// urllib.parse.quote(x) — RFC-3986 percent-encode; safe for URL path
|
|
12776
|
+
// urllib.parse.quote_plus(x) — same + space→+; safe for query string
|
|
12777
|
+
// urllib.parse.urlencode(d) — encode a dict of pairs
|
|
12778
|
+
//
|
|
12779
|
+
// Bounded to URL-context sinks. Class-scoped to the specific
|
|
12780
|
+
// `urllib.parse` receiver to avoid colliding with unrelated bare
|
|
12781
|
+
// `quote(...)` calls in other libraries.
|
|
12782
|
+
{ method: "quote", class: "urllib.parse", removes: ["ssrf", "open_redirect", "xss", "path_traversal"] },
|
|
12783
|
+
{ method: "quote_plus", class: "urllib.parse", removes: ["ssrf", "open_redirect", "xss", "path_traversal"] },
|
|
12784
|
+
{ method: "urlencode", class: "urllib.parse", removes: ["ssrf", "open_redirect", "xss"] },
|
|
12785
|
+
// Bare aliases — `from urllib.parse import quote` then unqualified.
|
|
12786
|
+
{ method: "quote_plus", removes: ["ssrf", "open_redirect", "xss", "path_traversal"] },
|
|
12787
|
+
{ method: "urlencode", removes: ["ssrf", "open_redirect", "xss"] },
|
|
12788
|
+
// `quote` bare is intentionally NOT registered — it collides with
|
|
12789
|
+
// shlex.quote (bare-imported) which is a command_injection sanitizer,
|
|
12790
|
+
// not a URL sanitizer. The class-scoped variant above catches the
|
|
12791
|
+
// qualified `urllib.parse.quote(...)` shape; unqualified callers who
|
|
12792
|
+
// want URL-context credit should use `quote_plus` (which does not
|
|
12793
|
+
// collide with any command_injection sanitizer).
|
|
12794
|
+
// Python XSS — additional common sanitizers.
|
|
12795
|
+
//
|
|
12796
|
+
// bleach.clean(...) — already covered above (line 2898)
|
|
12797
|
+
// bleach.linkify(...) — turns URLs into anchor tags; sanitizes as well
|
|
12798
|
+
// django.utils.html.escape / strip_tags — Django's XSS escape helpers
|
|
12799
|
+
// jinja2.escape — Jinja2's Markup escape (aliased from markupsafe)
|
|
12800
|
+
// flask.escape — Flask re-export of markupsafe.escape
|
|
12801
|
+
// saxutils.escape — stdlib xml.sax.saxutils.escape for XML docs
|
|
12802
|
+
{ method: "linkify", class: "bleach", removes: ["xss"] },
|
|
12803
|
+
// Bare `linkify(...)` alias — `from bleach import linkify`.
|
|
12804
|
+
{ method: "linkify", removes: ["xss"] },
|
|
12805
|
+
{ method: "escape", class: "django.utils.html", removes: ["xss"] },
|
|
12806
|
+
{ method: "strip_tags", class: "django.utils.html", removes: ["xss"] },
|
|
12807
|
+
{ method: "escape", class: "jinja2", removes: ["xss"] },
|
|
12808
|
+
{ method: "escape", class: "flask", removes: ["xss"] },
|
|
12809
|
+
{ method: "escape", class: "saxutils", removes: ["xss"] },
|
|
12810
|
+
{ method: "escape", class: "xml.sax.saxutils", removes: ["xss"] },
|
|
12811
|
+
{ method: "quoteattr", class: "saxutils", removes: ["xss"] },
|
|
12812
|
+
{ method: "quoteattr", class: "xml.sax.saxutils", removes: ["xss"] },
|
|
12813
|
+
// Python ReDoS — `re.escape(user)` when building a regex from user input
|
|
12814
|
+
// strips regex metacharacters. Downstream `re.compile / re.match` cannot
|
|
12815
|
+
// interpret user-supplied alternations or quantifiers. Also covers the
|
|
12816
|
+
// `code_injection` categorization that `re.compile` currently emits
|
|
12817
|
+
// (a re.escape-wrapped pattern cannot execute anything, so both are safe).
|
|
12818
|
+
{ method: "escape", class: "re", removes: ["redos", "code_injection"] },
|
|
12819
|
+
// Python SQLAlchemy — `text(...).bindparams(...)` binds params safely.
|
|
12820
|
+
// The `bindparams` call is the sanitizer; the parent `text` wraps the
|
|
12821
|
+
// template. Also add `expression.literal` for explicit SQL literals.
|
|
12822
|
+
{ method: "bindparams", removes: ["sql_injection"] },
|
|
12823
|
+
// psycopg2 sql-composition helpers
|
|
12824
|
+
{ method: "Identifier", class: "sql", removes: ["sql_injection"] },
|
|
12825
|
+
{ method: "Literal", class: "sql", removes: ["sql_injection"] },
|
|
12826
|
+
{ method: "Placeholder", class: "sql", removes: ["sql_injection"] },
|
|
12668
12827
|
// =========================================================================
|
|
12669
12828
|
// Rust Sanitizers
|
|
12670
12829
|
// =========================================================================
|
|
@@ -14493,7 +14652,14 @@ function matchesSanitizerPattern(call, pattern) {
|
|
|
14493
14652
|
return false;
|
|
14494
14653
|
}
|
|
14495
14654
|
if (pattern.class) {
|
|
14496
|
-
if (!call.receiver
|
|
14655
|
+
if (!call.receiver) {
|
|
14656
|
+
const target = call.resolution?.target;
|
|
14657
|
+
const expectedTail = `${pattern.class}.${pattern.method}`;
|
|
14658
|
+
if (target && (target === expectedTail || target.endsWith("." + expectedTail))) {
|
|
14659
|
+
} else {
|
|
14660
|
+
return false;
|
|
14661
|
+
}
|
|
14662
|
+
} else if (!receiverMightBeClass(call.receiver, pattern.class)) {
|
|
14497
14663
|
return false;
|
|
14498
14664
|
}
|
|
14499
14665
|
}
|
|
@@ -14921,15 +15087,21 @@ function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinksOrSanit
|
|
|
14921
15087
|
const callsAtSink = callsByLine.get(sink.line) ?? [];
|
|
14922
15088
|
for (const call of callsAtSink) {
|
|
14923
15089
|
for (const arg of call.arguments) {
|
|
14924
|
-
if (
|
|
14925
|
-
if (sink.argPositions
|
|
14926
|
-
|
|
14927
|
-
continue;
|
|
14928
|
-
}
|
|
15090
|
+
if (sink.argPositions && sink.argPositions.length > 0) {
|
|
15091
|
+
if (!sink.argPositions.includes(arg.position)) {
|
|
15092
|
+
continue;
|
|
14929
15093
|
}
|
|
14930
|
-
|
|
14931
|
-
|
|
15094
|
+
}
|
|
15095
|
+
const candidateUses = arg.variable ? usesAtSink.filter((u) => u.variable === arg.variable) : usesAtSink;
|
|
15096
|
+
{
|
|
15097
|
+
for (const use of candidateUses) {
|
|
15098
|
+
if (use.def_id !== null) {
|
|
14932
15099
|
if (allTaintedDefIds.has(use.def_id)) {
|
|
15100
|
+
if (!arg.variable) {
|
|
15101
|
+
if (typeof arg.expression !== "string" || arg.expression.length === 0) continue;
|
|
15102
|
+
const re = new RegExp(`(?:^|[^A-Za-z0-9_$])${use.variable.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:[^A-Za-z0-9_$]|$)`);
|
|
15103
|
+
if (!re.test(arg.expression)) continue;
|
|
15104
|
+
}
|
|
14933
15105
|
const taintInfo = taintByDefId.get(use.def_id);
|
|
14934
15106
|
if (taintInfo) {
|
|
14935
15107
|
const isSanitized = checkSanitized(
|
|
@@ -14967,7 +15139,15 @@ function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinksOrSanit
|
|
|
14967
15139
|
}
|
|
14968
15140
|
}
|
|
14969
15141
|
}
|
|
14970
|
-
|
|
15142
|
+
const seen = /* @__PURE__ */ new Set();
|
|
15143
|
+
const deduped = [];
|
|
15144
|
+
for (const f of flows) {
|
|
15145
|
+
const key = `${f.source.line}|${f.sink.line}|${f.sink.type}|${f.path.map((p) => p.variable).join(">")}`;
|
|
15146
|
+
if (seen.has(key)) continue;
|
|
15147
|
+
seen.add(key);
|
|
15148
|
+
deduped.push(f);
|
|
15149
|
+
}
|
|
15150
|
+
return { taintedVars, flows: deduped, reachableSinks };
|
|
14971
15151
|
}
|
|
14972
15152
|
function findInitialTaint(sources, callsByLine, defsByLine) {
|
|
14973
15153
|
const tainted = [];
|
|
@@ -1385,6 +1385,14 @@ function processGoBlock(node, defs, uses, scopeStack, counters) {
|
|
|
1385
1385
|
// range clause: for k, v := range expr
|
|
1386
1386
|
const rangeClause = findChildByTypeGo(child, 'range_clause');
|
|
1387
1387
|
if (rangeClause) {
|
|
1388
|
+
const right = rangeClause.childForFieldName('right');
|
|
1389
|
+
// Record uses of the range source on the same line as the loop-var
|
|
1390
|
+
// defs, so `computeChains` links the source def → loop-var def, which
|
|
1391
|
+
// is what carries taint into the loop body. Emit before defs so RHS
|
|
1392
|
+
// uses see the outer binding, not the new loop var. (Issue #243.)
|
|
1393
|
+
if (right) {
|
|
1394
|
+
extractGoUses(right, uses, scopeStack);
|
|
1395
|
+
}
|
|
1388
1396
|
const left = rangeClause.childForFieldName('left');
|
|
1389
1397
|
if (left) {
|
|
1390
1398
|
extractGoLhsDefs(left, defs, scopeStack, child.startPosition.row + 1);
|
|
@@ -1394,6 +1402,14 @@ function processGoBlock(node, defs, uses, scopeStack, counters) {
|
|
|
1394
1402
|
else if (child.type === 'call_expression') {
|
|
1395
1403
|
// Extract uses from call arguments
|
|
1396
1404
|
extractGoUses(child, uses, scopeStack);
|
|
1405
|
+
// Opaque-codec destination re-def (cognium-dev #243).
|
|
1406
|
+
//
|
|
1407
|
+
// Calls like `json.Unmarshal(bytes, &dest)`, `xml.Unmarshal(...)`,
|
|
1408
|
+
// `gob.NewDecoder(r).Decode(&dest)`, `yaml.Unmarshal(...)` populate
|
|
1409
|
+
// `dest` via reflection. Model that as a re-definition of `dest` on
|
|
1410
|
+
// this line so `computeChains` links the source-arg use to a fresh
|
|
1411
|
+
// `dest` def and taint propagates through the codec.
|
|
1412
|
+
recordGoOpaqueCodecDestDef(child, defs, scopeStack);
|
|
1397
1413
|
}
|
|
1398
1414
|
else if (child.type === 'return_statement') {
|
|
1399
1415
|
// Extract uses from return expressions
|
|
@@ -1514,4 +1530,87 @@ function findChildByTypeGo(node, type) {
|
|
|
1514
1530
|
}
|
|
1515
1531
|
return null;
|
|
1516
1532
|
}
|
|
1533
|
+
/**
|
|
1534
|
+
* cognium-dev #243 — opaque codec destination re-defs for Go.
|
|
1535
|
+
*
|
|
1536
|
+
* Package/method pairs whose second (or only) arg is a destination that the
|
|
1537
|
+
* call populates via reflection. Modelling them as re-defs of the dest lets
|
|
1538
|
+
* `computeChains` link source-arg → dest and taint propagates through the
|
|
1539
|
+
* codec.
|
|
1540
|
+
*/
|
|
1541
|
+
const GO_OPAQUE_CODEC_METHODS = new Set([
|
|
1542
|
+
'Unmarshal', // json/xml/yaml/toml/gob
|
|
1543
|
+
'Decode', // json.NewDecoder(r).Decode(&dest), gob.Decoder.Decode
|
|
1544
|
+
'NewDecoder', // wrapper; handled via chained Decode above
|
|
1545
|
+
'UnmarshalYAML',
|
|
1546
|
+
'UnmarshalJSON',
|
|
1547
|
+
'UnmarshalText',
|
|
1548
|
+
'UnmarshalBinary',
|
|
1549
|
+
]);
|
|
1550
|
+
function recordGoOpaqueCodecDestDef(call, defs, scopeStack) {
|
|
1551
|
+
// Only handle direct selector calls: `pkg.Method(...)` or `receiver.Decode(...)`.
|
|
1552
|
+
const fn = call.childForFieldName('function');
|
|
1553
|
+
if (!fn || fn.type !== 'selector_expression')
|
|
1554
|
+
return;
|
|
1555
|
+
const fieldNode = fn.childForFieldName('field');
|
|
1556
|
+
if (!fieldNode)
|
|
1557
|
+
return;
|
|
1558
|
+
const method = getNodeText(fieldNode);
|
|
1559
|
+
if (!GO_OPAQUE_CODEC_METHODS.has(method))
|
|
1560
|
+
return;
|
|
1561
|
+
const argsNode = call.childForFieldName('arguments');
|
|
1562
|
+
if (!argsNode)
|
|
1563
|
+
return;
|
|
1564
|
+
// Positional args (skip commas / parens).
|
|
1565
|
+
const args = [];
|
|
1566
|
+
for (let i = 0; i < argsNode.childCount; i++) {
|
|
1567
|
+
const c = argsNode.child(i);
|
|
1568
|
+
if (!c)
|
|
1569
|
+
continue;
|
|
1570
|
+
if (c.type === ',' || c.type === '(' || c.type === ')')
|
|
1571
|
+
continue;
|
|
1572
|
+
args.push(c);
|
|
1573
|
+
}
|
|
1574
|
+
if (args.length === 0)
|
|
1575
|
+
return;
|
|
1576
|
+
// Destination convention:
|
|
1577
|
+
// - Unmarshal(bytes, &dest) → args[1]
|
|
1578
|
+
// - Decode(&dest) → args[0]
|
|
1579
|
+
const destArg = args.length >= 2 ? args[1] : args[0];
|
|
1580
|
+
const destName = extractGoAddressableVarName(destArg);
|
|
1581
|
+
if (!destName || destName === '_')
|
|
1582
|
+
return;
|
|
1583
|
+
const line = call.startPosition.row + 1;
|
|
1584
|
+
const def = {
|
|
1585
|
+
id: defs.length + 1,
|
|
1586
|
+
variable: destName,
|
|
1587
|
+
kind: 'local',
|
|
1588
|
+
line,
|
|
1589
|
+
};
|
|
1590
|
+
defs.push(def);
|
|
1591
|
+
currentScope(scopeStack).set(destName, def.id);
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Return the addressed variable name for an opaque-codec destination arg.
|
|
1595
|
+
* Handles `&x`, bare `x`, and `&pkg.Y`-style receivers (returns `Y` — the
|
|
1596
|
+
* codec still populates that concrete addressable location).
|
|
1597
|
+
*/
|
|
1598
|
+
function extractGoAddressableVarName(node) {
|
|
1599
|
+
if (node.type === 'unary_expression') {
|
|
1600
|
+
// &x — operand carries the identifier
|
|
1601
|
+
const operand = node.childForFieldName('operand') ?? node.child(1) ?? null;
|
|
1602
|
+
if (operand)
|
|
1603
|
+
return extractGoAddressableVarName(operand);
|
|
1604
|
+
return null;
|
|
1605
|
+
}
|
|
1606
|
+
if (node.type === 'identifier') {
|
|
1607
|
+
return getNodeText(node);
|
|
1608
|
+
}
|
|
1609
|
+
if (node.type === 'selector_expression') {
|
|
1610
|
+
const field = node.childForFieldName('field');
|
|
1611
|
+
if (field)
|
|
1612
|
+
return getNodeText(field);
|
|
1613
|
+
}
|
|
1614
|
+
return null;
|
|
1615
|
+
}
|
|
1517
1616
|
//# sourceMappingURL=dfg.js.map
|