gitnexus 1.6.8 → 1.6.9-rc.1

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.
@@ -109,10 +109,10 @@ export declare class PythonHarvester {
109
109
  /** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
110
110
  private conditionalDepth;
111
111
  /**
112
- * `call` node id → binding indices its single-target result is assigned to
113
- * (`x = f()` ⇒ `[x]`). Populated just before the value walk reaches the call
114
- * (see {@link registerResultDefs}) and consumed by {@link visitCall}. Mirrors
115
- * the Go harvester's `resultDefTargets`.
112
+ * `call` node id → binding indices its result is assigned to (`x = f()` ⇒
113
+ * `[x]`, `a, b = f()` ⇒ `[a, b]`). Populated just before the value walk reaches
114
+ * the call (see {@link registerResultDefs}) and consumed by {@link visitCall}.
115
+ * Mirrors the Go harvester's `resultDefTargets`.
116
116
  */
117
117
  private readonly resultDefTargets;
118
118
  constructor(fnNode: SyntaxNode);
@@ -184,13 +184,9 @@ export declare class PythonHarvester {
184
184
  * (it reads outer names), the body/filter after the targets are bound.
185
185
  */
186
186
  private walkComprehension;
187
- /**
188
- * When `value`'s root (after stripping parens) is a `call`, remember that
189
- * call site should carry `resultDefs` the binding indices of `targets`
190
- * (def-position identifiers). Consumed by {@link visitCall} once the value
191
- * walk reaches the node. Single-target only (the caller restricts to a plain
192
- * identifier LHS); the blank identifier (`_`) binds nothing and is skipped.
193
- */
187
+ /** Binding indices assigned by a target pattern, without mutating statement facts. */
188
+ private targetDefIndices;
189
+ /** Record result defs for a call-valued assignment. */
194
190
  private registerResultDefs;
195
191
  /** Strip `parenthesized_expression` wrappers around a value (`(f())`). */
196
192
  private unwrapValue;
@@ -201,6 +197,8 @@ export declare class PythonHarvester {
201
197
  * Python has no `new` — every site is `kind: 'call'`.
202
198
  */
203
199
  private visitCall;
200
+ /** Walk arguments, assigning only positional values to positional sink slots. */
201
+ private walkArgs;
204
202
  /**
205
203
  * `attribute` chain walk shared by value position and callee position. Records
206
204
  * the chain-root identifier as a use (identical to the old default descent)
@@ -15,10 +15,10 @@ export class PythonHarvester {
15
15
  /** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
16
16
  conditionalDepth = 0;
17
17
  /**
18
- * `call` node id → binding indices its single-target result is assigned to
19
- * (`x = f()` ⇒ `[x]`). Populated just before the value walk reaches the call
20
- * (see {@link registerResultDefs}) and consumed by {@link visitCall}. Mirrors
21
- * the Go harvester's `resultDefTargets`.
18
+ * `call` node id → binding indices its result is assigned to (`x = f()` ⇒
19
+ * `[x]`, `a, b = f()` ⇒ `[a, b]`). Populated just before the value walk reaches
20
+ * the call (see {@link registerResultDefs}) and consumed by {@link visitCall}.
21
+ * Mirrors the Go harvester's `resultDefTargets`.
22
22
  */
23
23
  resultDefTargets = new Map();
24
24
  constructor(fnNode) {
@@ -124,8 +124,20 @@ export class PythonHarvester {
124
124
  */
125
125
  prescan(node) {
126
126
  const t = node.type;
127
- if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId)
127
+ if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
128
+ if (t === 'function_definition') {
129
+ const name = node.childForFieldName('name');
130
+ if (name?.type === 'identifier')
131
+ this.declare(name, 'function');
132
+ }
128
133
  return;
134
+ }
135
+ if (t === 'class_definition') {
136
+ const name = node.childForFieldName('name');
137
+ if (name?.type === 'identifier')
138
+ this.declare(name, 'class');
139
+ return;
140
+ }
129
141
  switch (t) {
130
142
  case 'global_statement':
131
143
  case 'nonlocal_statement': {
@@ -469,10 +481,10 @@ export class PythonHarvester {
469
481
  const left = node.childForFieldName('left');
470
482
  const right = node.childForFieldName('right');
471
483
  // Register result-defs BEFORE walking the value so the nested call site
472
- // (reached during the value walk) carries them single identifier
473
- // target only (`x = f()`); a tuple/attribute LHS attaches nothing.
474
- if (left?.type === 'identifier' && right)
475
- this.registerResultDefs(right, [left]);
484
+ // (reached during the value walk) carries them. Plain and unpack targets
485
+ // are preserved; attribute/subscript LHS attaches nothing.
486
+ if (left && right)
487
+ this.registerResultDefs(right, this.targetDefIndices(left));
476
488
  if (right)
477
489
  this.walkValue(right, acc);
478
490
  if (left)
@@ -500,8 +512,9 @@ export class PythonHarvester {
500
512
  // walrus `(n := v)` — `n` is a def, `v` a use.
501
513
  const name = node.childForFieldName('name');
502
514
  const value = node.childForFieldName('value');
503
- if (name?.type === 'identifier' && value)
504
- this.registerResultDefs(value, [name]);
515
+ if (name?.type === 'identifier' && value) {
516
+ this.registerResultDefs(value, this.targetDefIndices(name));
517
+ }
505
518
  if (value)
506
519
  this.walkValue(value, acc);
507
520
  if (name?.type === 'identifier')
@@ -607,27 +620,34 @@ export class PythonHarvester {
607
620
  if (body)
608
621
  this.walkValue(body, acc);
609
622
  }
610
- // ── taint-site harvest (#2227 follow-up) ─────────────────────────────────
611
- /**
612
- * When `value`'s root (after stripping parens) is a `call`, remember that
613
- * call site should carry `resultDefs` the binding indices of `targets`
614
- * (def-position identifiers). Consumed by {@link visitCall} once the value
615
- * walk reaches the node. Single-target only (the caller restricts to a plain
616
- * identifier LHS); the blank identifier (`_`) binds nothing and is skipped.
617
- */
618
- registerResultDefs(value, targets) {
619
- const root = this.unwrapValue(value);
620
- if (root.type !== 'call')
621
- return;
622
- const defs = [];
623
- for (const target of targets) {
624
- if (target.type !== 'identifier' || target.text === '_')
625
- continue;
626
- defs.push(this.resolve(target));
623
+ /** Binding indices assigned by a target pattern, without mutating statement facts. */
624
+ targetDefIndices(target) {
625
+ if (target.type === 'identifier')
626
+ return target.text === '_' ? [] : [this.resolve(target)];
627
+ if (PATTERN_LIST_TYPES.has(target.type)) {
628
+ const out = [];
629
+ for (let i = 0; i < target.namedChildCount; i++) {
630
+ const c = target.namedChild(i);
631
+ if (c)
632
+ out.push(...this.targetDefIndices(c));
633
+ }
634
+ return out;
627
635
  }
628
- if (defs.length > 0)
629
- this.resultDefTargets.set(root.id, defs);
636
+ if (target.type === 'list_splat_pattern') {
637
+ const id = target.namedChild(0);
638
+ return id ? this.targetDefIndices(id) : [];
639
+ }
640
+ return [];
641
+ }
642
+ /** Record result defs for a call-valued assignment. */
643
+ registerResultDefs(value, defs) {
644
+ if (defs.length === 0)
645
+ return;
646
+ const root = this.unwrapValue(value);
647
+ if (root.type === 'call')
648
+ this.resultDefTargets.set(root.id, [...defs]);
630
649
  }
650
+ // ── taint-site harvest (#2227 follow-up) ─────────────────────────────────
631
651
  /** Strip `parenthesized_expression` wrappers around a value (`(f())`). */
632
652
  unwrapValue(node) {
633
653
  let n = node;
@@ -681,31 +701,44 @@ export class PythonHarvester {
681
701
  const resultDefs = this.resultDefTargets.get(node.id);
682
702
  if (resultDefs !== undefined)
683
703
  acc.setSiteResultDefs(siteIdx, resultDefs);
684
- if (argsNode) {
685
- let pos = 0;
686
- for (let i = 0; i < argsNode.namedChildCount; i++) {
687
- const arg = argsNode.namedChild(i);
688
- if (!arg || arg.type === 'comment')
689
- continue;
690
- if (arg.type === 'list_splat' || arg.type === 'dictionary_splat') {
691
- // `f(*args)` / `f(**kw)` — a spread. Mark the first spread position so
692
- // the matcher degrades soundly; the inner value still walks.
693
- acc.setFrameArg(pos);
694
- acc.setSiteSpread(siteIdx, pos);
695
- const inner = arg.namedChild(0);
696
- if (inner)
697
- this.walkValue(inner, acc);
698
- }
699
- else {
700
- // Positional or `keyword_argument` — the `keyword_argument` case in
701
- // `walkValue` walks only its `value`, so the key name is not a use.
702
- acc.setFrameArg(pos);
703
- this.walkValue(arg, acc);
704
- }
704
+ this.walkArgs(argsNode, acc, siteIdx);
705
+ acc.popFrame();
706
+ }
707
+ /** Walk arguments, assigning only positional values to positional sink slots. */
708
+ walkArgs(args, acc, siteIdx) {
709
+ if (!args)
710
+ return;
711
+ let pos = 0;
712
+ for (let i = 0; i < args.namedChildCount; i++) {
713
+ const arg = args.namedChild(i);
714
+ if (!arg || arg.type === 'comment')
715
+ continue;
716
+ if (arg.type === 'keyword_argument') {
717
+ const value = arg.childForFieldName('value');
718
+ // SiteRecord has no keyword-name metadata. Keep the value's ordinary
719
+ // uses/sources, but do not guess a positional sink slot from source order.
720
+ if (value)
721
+ acc.suppressOccurrences(() => this.walkValue(value, acc));
722
+ }
723
+ else if (arg.type === 'list_splat') {
724
+ acc.setFrameArg(pos);
725
+ acc.setSiteSpread(siteIdx, pos);
726
+ const inner = arg.namedChild(0);
727
+ if (inner)
728
+ this.walkValue(inner, acc);
729
+ pos++;
730
+ }
731
+ else if (arg.type === 'dictionary_splat') {
732
+ const inner = arg.namedChild(0);
733
+ if (inner)
734
+ acc.suppressOccurrences(() => this.walkValue(inner, acc));
735
+ }
736
+ else {
737
+ acc.setFrameArg(pos);
738
+ this.walkValue(arg, acc);
705
739
  pos++;
706
740
  }
707
741
  }
708
- acc.popFrame();
709
742
  }
710
743
  /**
711
744
  * `attribute` chain walk shared by value position and callee position. Records
@@ -520,11 +520,11 @@ export function runScopeResolution(input, provider) {
520
520
  // ── M3 taint setup (#2083 U4) ────────────────────────────────────────
521
521
  // Explicit model-registration seam (idempotent, cheap) — the registry
522
522
  // stays empty on non-pdg runs, preserving default-run parity. The
523
- // registry is keyed by `SupportedLanguages` enum VALUES ('typescript' /
524
- // 'javascript'), and `ScopeResolver.language` IS a `SupportedLanguages`
525
- // member registered under those same constants the join is direct
526
- // equality, no mapping table. A language without a registered spec
527
- // (python, go, …) skips taint entirely: no work, no warn spam (KTD8).
523
+ // registry is keyed by SupportedLanguages enum values, and
524
+ // ScopeResolver.language is registered under those same constants -
525
+ // the join is direct equality, with no mapping table. A language without a
526
+ // registered spec (go, ruby, ...) skips taint entirely: no work, no warn spam
527
+ // (KTD8).
528
528
  registerBuiltinTaintModels();
529
529
  const taintSpec = getSourceSinkConfig(provider.language);
530
530
  // Taint-side solver fact cap: the SAME derivation emitFileReachingDefs
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Built-in Python taint model (#2204 first slice).
3
+ *
4
+ * Keep the model intentionally conservative: import-aware sinks for standard
5
+ * library command execution, receiver-conventional database execution calls,
6
+ * and Flask/FastAPI-style request-object member reads. No sanitizers are
7
+ * registered yet because a false sanitizer kill can hide a real finding.
8
+ */
9
+ import type { SourceSinkSanitizerSpec } from './source-sink-config.js';
10
+ export declare const PYTHON_TAINT_MODEL: SourceSinkSanitizerSpec;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Built-in Python taint model (#2204 first slice).
3
+ *
4
+ * Keep the model intentionally conservative: import-aware sinks for standard
5
+ * library command execution, receiver-conventional database execution calls,
6
+ * and Flask/FastAPI-style request-object member reads. No sanitizers are
7
+ * registered yet because a false sanitizer kill can hide a real finding.
8
+ */
9
+ export const PYTHON_TAINT_MODEL = {
10
+ sources: [
11
+ {
12
+ kind: 'remote-input',
13
+ objects: ['request', 'req'],
14
+ properties: [
15
+ 'args',
16
+ 'form',
17
+ 'json',
18
+ 'data',
19
+ 'headers',
20
+ 'cookies',
21
+ 'path_params',
22
+ 'query_params',
23
+ ],
24
+ },
25
+ ],
26
+ sinks: [
27
+ { name: 'system', kind: 'command-injection', args: [0], module: 'os' },
28
+ { name: 'popen', kind: 'command-injection', args: [0], module: 'os' },
29
+ { name: 'call', kind: 'command-injection', args: [0], module: 'subprocess' },
30
+ { name: 'run', kind: 'command-injection', args: [0], module: 'subprocess' },
31
+ { name: 'Popen', kind: 'command-injection', args: [0], module: 'subprocess' },
32
+ { name: 'check_call', kind: 'command-injection', args: [0], module: 'subprocess' },
33
+ { name: 'check_output', kind: 'command-injection', args: [0], module: 'subprocess' },
34
+ { name: 'eval', kind: 'code-injection', args: [0], global: true },
35
+ { name: 'exec', kind: 'code-injection', args: [0], global: true },
36
+ { name: 'open', kind: 'path-traversal', args: [0], global: true },
37
+ { name: 'query', kind: 'sql-injection', args: [0], anyReceiver: true },
38
+ { name: 'execute', kind: 'sql-injection', args: [0], anyReceiver: true },
39
+ { name: 'executemany', kind: 'sql-injection', args: [0], anyReceiver: true },
40
+ { name: 'executescript', kind: 'sql-injection', args: [0], anyReceiver: true },
41
+ ],
42
+ sanitizers: [],
43
+ };
@@ -27,12 +27,20 @@ export declare const TS_JS_TAINT_MODEL: SourceSinkSanitizerSpec;
27
27
  * array order is semantic (entry identity) and intentionally preserved.
28
28
  */
29
29
  export declare function computeTaintModelVersion(spec: SourceSinkSanitizerSpec): string;
30
- /** Version stamp of the built-in TS/JS model (joins the RepoMeta pdg key in U5). */
30
+ export declare const BUILTIN_TAINT_MODELS: {
31
+ readonly javascript: SourceSinkSanitizerSpec;
32
+ readonly python: SourceSinkSanitizerSpec;
33
+ readonly typescript: SourceSinkSanitizerSpec;
34
+ };
35
+ /**
36
+ * Version stamp of every built-in model (joins the RepoMeta pdg key in U5).
37
+ * Adding a language model must invalidate existing persisted taint findings.
38
+ */
31
39
  export declare const taintModelVersion: string;
32
40
  /**
33
- * Register the built-in model for TypeScript and JavaScript. Explicit init
34
- * seam for the U4 emit path (call before the pdg window consumes the
35
- * registry); idempotent. Vue and other TS-adjacent language ids are
36
- * deliberately NOT registered the M3 scope is TS/JS only.
41
+ * Register the built-in models for TypeScript, JavaScript, and Python.
42
+ * Explicit init seam for the U4 emit path (call before the pdg window
43
+ * consumes the registry); idempotent. Other language ids remain unregistered
44
+ * until they have a dedicated model.
37
45
  */
38
46
  export declare function registerBuiltinTaintModels(): void;
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { createHash } from 'node:crypto';
18
18
  import { SupportedLanguages } from '../../../_shared/index.js';
19
+ import { PYTHON_TAINT_MODEL } from './python-model.js';
19
20
  import { registerSourceSinkConfig } from './source-sink-registry.js';
20
21
  /**
21
22
  * The built-in TS/JS model. Module provenance uses bare specifier names —
@@ -74,7 +75,10 @@ export const TS_JS_TAINT_MODEL = {
74
75
  * array order is semantic (entry identity) and intentionally preserved.
75
76
  */
76
77
  export function computeTaintModelVersion(spec) {
77
- return createHash('sha256').update(canonicalJson(spec)).digest('hex').slice(0, 12);
78
+ return computeModelDigest(spec);
79
+ }
80
+ function computeModelDigest(value) {
81
+ return createHash('sha256').update(canonicalJson(value)).digest('hex').slice(0, 12);
78
82
  }
79
83
  function canonicalJson(value) {
80
84
  if (Array.isArray(value))
@@ -88,15 +92,24 @@ function canonicalJson(value) {
88
92
  }
89
93
  return JSON.stringify(value);
90
94
  }
91
- /** Version stamp of the built-in TS/JS model (joins the RepoMeta pdg key in U5). */
92
- export const taintModelVersion = computeTaintModelVersion(TS_JS_TAINT_MODEL);
95
+ export const BUILTIN_TAINT_MODELS = {
96
+ [SupportedLanguages.JavaScript]: TS_JS_TAINT_MODEL,
97
+ [SupportedLanguages.Python]: PYTHON_TAINT_MODEL,
98
+ [SupportedLanguages.TypeScript]: TS_JS_TAINT_MODEL,
99
+ };
100
+ /**
101
+ * Version stamp of every built-in model (joins the RepoMeta pdg key in U5).
102
+ * Adding a language model must invalidate existing persisted taint findings.
103
+ */
104
+ export const taintModelVersion = computeModelDigest(BUILTIN_TAINT_MODELS);
93
105
  /**
94
- * Register the built-in model for TypeScript and JavaScript. Explicit init
95
- * seam for the U4 emit path (call before the pdg window consumes the
96
- * registry); idempotent. Vue and other TS-adjacent language ids are
97
- * deliberately NOT registered the M3 scope is TS/JS only.
106
+ * Register the built-in models for TypeScript, JavaScript, and Python.
107
+ * Explicit init seam for the U4 emit path (call before the pdg window
108
+ * consumes the registry); idempotent. Other language ids remain unregistered
109
+ * until they have a dedicated model.
98
110
  */
99
111
  export function registerBuiltinTaintModels() {
100
112
  registerSourceSinkConfig(SupportedLanguages.TypeScript, TS_JS_TAINT_MODEL);
101
113
  registerSourceSinkConfig(SupportedLanguages.JavaScript, TS_JS_TAINT_MODEL);
114
+ registerSourceSinkConfig(SupportedLanguages.Python, PYTHON_TAINT_MODEL);
102
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.8",
3
+ "version": "1.6.9-rc.1",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",