gitnexus 1.6.8-rc.2 → 1.6.8-rc.21
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/README.md +20 -0
- package/dist/_shared/scope-resolution/parsed-file.d.ts +21 -0
- package/dist/_shared/scope-resolution/parsed-file.d.ts.map +1 -1
- package/dist/_shared/scope-resolution/symbol-definition.d.ts +4 -0
- package/dist/_shared/scope-resolution/symbol-definition.d.ts.map +1 -1
- package/dist/cli/ai-context.js +1 -0
- package/dist/cli/analyze-config.js +40 -0
- package/dist/cli/analyze.d.ts +22 -0
- package/dist/cli/analyze.js +36 -6
- package/dist/cli/clean.d.ts +1 -0
- package/dist/cli/clean.js +43 -1
- package/dist/cli/eval-server.js +43 -0
- package/dist/cli/help-i18n.js +11 -0
- package/dist/cli/i18n/en.d.ts +13 -0
- package/dist/cli/i18n/en.js +13 -0
- package/dist/cli/i18n/resources.d.ts +26 -0
- package/dist/cli/i18n/zh-CN.d.ts +13 -0
- package/dist/cli/i18n/zh-CN.js +13 -0
- package/dist/cli/index.js +19 -0
- package/dist/cli/list.js +12 -0
- package/dist/cli/optional-grammars.d.ts +6 -8
- package/dist/cli/optional-grammars.js +8 -11
- package/dist/cli/skill-gen.js +1 -0
- package/dist/cli/status.js +26 -5
- package/dist/cli/tool.d.ts +12 -1
- package/dist/cli/tool.js +41 -1
- package/dist/core/embeddings/embedder.js +4 -0
- package/dist/core/embeddings/embedding-pipeline.js +27 -16
- package/dist/core/embeddings/onnxruntime-common-resolver.d.ts +6 -0
- package/dist/core/embeddings/onnxruntime-common-resolver.js +130 -0
- package/dist/core/graph/import-cycles.d.ts +10 -0
- package/dist/core/graph/import-cycles.js +103 -0
- package/dist/core/group/extractors/grpc-patterns/proto.js +10 -6
- package/dist/core/group/extractors/http-patterns/java.js +3 -50
- package/dist/core/group/extractors/http-patterns/kotlin.js +7 -7
- package/dist/core/group/extractors/include-extractor.js +7 -7
- package/dist/core/ingestion/cfg/cfg-builder.d.ts +64 -0
- package/dist/core/ingestion/cfg/cfg-builder.js +129 -0
- package/dist/core/ingestion/cfg/collect.d.ts +30 -0
- package/dist/core/ingestion/cfg/collect.js +34 -0
- package/dist/core/ingestion/cfg/control-flow-context.d.ts +97 -0
- package/dist/core/ingestion/cfg/control-flow-context.js +113 -0
- package/dist/core/ingestion/cfg/emit.d.ts +144 -0
- package/dist/core/ingestion/cfg/emit.js +315 -0
- package/dist/core/ingestion/cfg/reaching-defs.d.ts +90 -0
- package/dist/core/ingestion/cfg/reaching-defs.js +364 -0
- package/dist/core/ingestion/cfg/traversal-result.d.ts +20 -0
- package/dist/core/ingestion/cfg/traversal-result.js +2 -0
- package/dist/core/ingestion/cfg/types.d.ts +231 -0
- package/dist/core/ingestion/cfg/types.js +13 -0
- package/dist/core/ingestion/cfg/visitors/typescript-harvest.d.ts +144 -0
- package/dist/core/ingestion/cfg/visitors/typescript-harvest.js +1032 -0
- package/dist/core/ingestion/cfg/visitors/typescript.d.ts +66 -0
- package/dist/core/ingestion/cfg/visitors/typescript.js +584 -0
- package/dist/core/ingestion/language-provider.d.ts +36 -2
- package/dist/core/ingestion/languages/c-cpp.js +11 -4
- package/dist/core/ingestion/languages/cpp/arity-metadata.js +6 -2
- package/dist/core/ingestion/languages/cpp/captures.js +24 -1
- package/dist/core/ingestion/languages/cpp/query.js +23 -0
- package/dist/core/ingestion/languages/java.js +3 -0
- package/dist/core/ingestion/languages/kotlin/query.js +3 -2
- package/dist/core/ingestion/languages/kotlin.js +5 -1
- package/dist/core/ingestion/languages/typescript.js +5 -0
- package/dist/core/ingestion/method-extractors/configs/c-cpp.js +6 -13
- package/dist/core/ingestion/method-extractors/generic.js +1 -0
- package/dist/core/ingestion/method-types.d.ts +2 -0
- package/dist/core/ingestion/model/symbol-table.d.ts +1 -0
- package/dist/core/ingestion/model/symbol-table.js +1 -0
- package/dist/core/ingestion/parsing-processor.js +22 -0
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +18 -1
- package/dist/core/ingestion/pipeline-phases/routes.js +64 -14
- package/dist/core/ingestion/pipeline.d.ts +57 -0
- package/dist/core/ingestion/route-extractors/spring-shared.d.ts +50 -0
- package/dist/core/ingestion/route-extractors/spring-shared.js +80 -0
- package/dist/core/ingestion/route-extractors/spring.d.ts +35 -0
- package/dist/core/ingestion/route-extractors/spring.js +136 -0
- package/dist/core/ingestion/scope-extractor.js +3 -0
- package/dist/core/ingestion/scope-resolution/passes/free-call-fallback.js +12 -0
- package/dist/core/ingestion/scope-resolution/passes/receiver-bound-calls.js +145 -24
- package/dist/core/ingestion/scope-resolution/pipeline/phase.js +6 -0
- package/dist/core/ingestion/scope-resolution/pipeline/reconcile-ownership.js +43 -3
- package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +21 -0
- package/dist/core/ingestion/scope-resolution/pipeline/run.js +207 -0
- package/dist/core/ingestion/scope-resolution/resolution-outcome.d.ts +1 -1
- package/dist/core/ingestion/taint/emit.d.ts +124 -0
- package/dist/core/ingestion/taint/emit.js +204 -0
- package/dist/core/ingestion/taint/match.d.ts +153 -0
- package/dist/core/ingestion/taint/match.js +278 -0
- package/dist/core/ingestion/taint/path-codec.d.ts +134 -0
- package/dist/core/ingestion/taint/path-codec.js +190 -0
- package/dist/core/ingestion/taint/propagate.d.ts +216 -0
- package/dist/core/ingestion/taint/propagate.js +664 -0
- package/dist/core/ingestion/taint/site-safety.d.ts +29 -0
- package/dist/core/ingestion/taint/site-safety.js +98 -0
- package/dist/core/ingestion/taint/source-sink-config.d.ts +94 -23
- package/dist/core/ingestion/taint/source-sink-config.js +11 -11
- package/dist/core/ingestion/taint/source-sink-registry.d.ts +6 -4
- package/dist/core/ingestion/taint/source-sink-registry.js +6 -4
- package/dist/core/ingestion/taint/typescript-model.d.ts +38 -0
- package/dist/core/ingestion/taint/typescript-model.js +102 -0
- package/dist/core/ingestion/utils/method-props.js +1 -0
- package/dist/core/ingestion/workers/clone-safety.d.ts +109 -0
- package/dist/core/ingestion/workers/clone-safety.js +465 -0
- package/dist/core/ingestion/workers/parse-worker.d.ts +11 -0
- package/dist/core/ingestion/workers/parse-worker.js +70 -53
- package/dist/core/ingestion/workers/post-result.d.ts +22 -0
- package/dist/core/ingestion/workers/post-result.js +87 -0
- package/dist/core/ingestion/workers/result-merge.d.ts +20 -0
- package/dist/core/ingestion/workers/result-merge.js +43 -0
- package/dist/core/ingestion/workers/worker-pool.d.ts +15 -0
- package/dist/core/ingestion/workers/worker-pool.js +33 -16
- package/dist/core/lbug/lbug-adapter.d.ts +19 -0
- package/dist/core/lbug/lbug-adapter.js +56 -1
- package/dist/core/run-analyze.d.ts +87 -0
- package/dist/core/run-analyze.js +280 -25
- package/dist/core/tree-sitter/parser-loader.js +5 -4
- package/dist/core/tree-sitter/vendored-grammars.d.ts +39 -0
- package/dist/core/tree-sitter/vendored-grammars.js +57 -0
- package/dist/mcp/core/embedder.js +4 -0
- package/dist/mcp/local/local-backend.d.ts +86 -2
- package/dist/mcp/local/local-backend.js +657 -27
- package/dist/mcp/resources.js +1 -0
- package/dist/mcp/tools.d.ts +9 -0
- package/dist/mcp/tools.js +109 -0
- package/dist/server/analyze-launch.d.ts +29 -0
- package/dist/server/analyze-launch.js +137 -0
- package/dist/server/analyze-upload.d.ts +33 -0
- package/dist/server/analyze-upload.js +123 -0
- package/dist/server/analyze-worker-ipc.d.ts +58 -0
- package/dist/server/analyze-worker-ipc.js +16 -0
- package/dist/server/analyze-worker.d.ts +17 -1
- package/dist/server/analyze-worker.js +7 -1
- package/dist/server/api.js +46 -140
- package/dist/server/git-clone.d.ts +1 -0
- package/dist/server/git-clone.js +1 -1
- package/dist/server/middleware.d.ts +11 -0
- package/dist/server/middleware.js +27 -0
- package/dist/server/upload-ingest.d.ts +56 -0
- package/dist/server/upload-ingest.js +276 -0
- package/dist/server/upload-paths.d.ts +31 -0
- package/dist/server/upload-paths.js +51 -0
- package/dist/server/upload-sweep.d.ts +20 -0
- package/dist/server/upload-sweep.js +57 -0
- package/dist/storage/branch-index.d.ts +52 -0
- package/dist/storage/branch-index.js +65 -0
- package/dist/storage/git.d.ts +11 -0
- package/dist/storage/git.js +28 -0
- package/dist/storage/parse-cache.d.ts +22 -1
- package/dist/storage/parse-cache.js +32 -10
- package/dist/storage/repo-manager.d.ts +122 -10
- package/dist/storage/repo-manager.js +162 -21
- package/hooks/antigravity/gitnexus-antigravity-hook.cjs +42 -8
- package/hooks/claude/gitnexus-hook.cjs +36 -8
- package/hooks/claude/hook-db-lock-probe.cjs +123 -2
- package/package.json +4 -2
- package/scripts/assert-publish-grammar-coverage.cjs +31 -0
- package/scripts/build-tree-sitter-grammars.cjs +16 -10
- package/skills/gitnexus-guide.md +11 -0
- package/vendor/tree-sitter-c/package.json +1 -1
- package/vendor/tree-sitter-dart/package.json +1 -1
- package/vendor/tree-sitter-kotlin/package.json +1 -1
- package/vendor/tree-sitter-proto/package.json +1 -1
- package/vendor/tree-sitter-swift/package.json +1 -1
- package/web/assets/{agent-CKPMqImC.js → agent-ay4LD70X.js} +1 -1
- package/web/assets/{architectureDiagram-UL44E2DR-D1EXI0zA.js → architectureDiagram-UL44E2DR-Dc-viYhd.js} +1 -1
- package/web/assets/{chunk-LCXTWHL2-Dfmux4m1.js → chunk-LCXTWHL2-4rpojOyj.js} +1 -1
- package/web/assets/{chunk-RG4AUYOV-CAkzcoRj.js → chunk-RG4AUYOV-BnOy944n.js} +1 -1
- package/web/assets/{classDiagram-KGZ6W3CR-Bv93af_b.js → classDiagram-KGZ6W3CR-Bsgpy98Q.js} +1 -1
- package/web/assets/{classDiagram-v2-72OJOZXJ-CLfEqHUa.js → classDiagram-v2-72OJOZXJ-D5atDGjc.js} +1 -1
- package/web/assets/{diagram-3NCE3AQN-BqAtKUpW.js → diagram-3NCE3AQN-Cz1OEMVi.js} +1 -1
- package/web/assets/{diagram-GF46GFSD-CaBG6n6o.js → diagram-GF46GFSD-CWYwzfP8.js} +1 -1
- package/web/assets/{diagram-QXG6HAR7-CZ-O3rcV.js → diagram-QXG6HAR7-DtBObr8L.js} +1 -1
- package/web/assets/{diagram-WEQXMOUZ-P4lSL4GH.js → diagram-WEQXMOUZ-BUV44Ov_.js} +1 -1
- package/web/assets/{erDiagram-L5TCEMPS-QN2eEP1e.js → erDiagram-L5TCEMPS-Ds9s-sRF.js} +1 -1
- package/web/assets/{flowDiagram-H6V6AXG4-CXbXImlN.js → flowDiagram-H6V6AXG4-CupStHQb.js} +1 -1
- package/web/assets/index-COMMmbxW.css +2 -0
- package/web/assets/{index-CG6q8eTs.js → index-Do8AE5yF.js} +85 -85
- package/web/assets/{infoDiagram-3YFTVSEB-DmLICZx1.js → infoDiagram-3YFTVSEB-DPTXSbgu.js} +1 -1
- package/web/assets/{ishikawaDiagram-BNXS4ZKH-6LKOvBfp.js → ishikawaDiagram-BNXS4ZKH-CeK-Qhet.js} +1 -1
- package/web/assets/{kanban-definition-75IXJCU3-DgDi9oJT.js → kanban-definition-75IXJCU3-D4tx6QQ-.js} +1 -1
- package/web/assets/{mindmap-definition-2TDM6QVE-CwR5sBB-.js → mindmap-definition-2TDM6QVE-Bcto_kGa.js} +1 -1
- package/web/assets/{pieDiagram-CU6KROY3-By8g6f6B.js → pieDiagram-CU6KROY3-DPIeBTG6.js} +1 -1
- package/web/assets/{requirementDiagram-JXO7QTGE-7oDcJ1_J.js → requirementDiagram-JXO7QTGE-DDGXjVKO.js} +1 -1
- package/web/assets/{sequenceDiagram-VS2MUI6T-FySeKCUy.js → sequenceDiagram-VS2MUI6T-BzDqpcVW.js} +1 -1
- package/web/assets/{stateDiagram-7D4R322I-CB2nABwH.js → stateDiagram-7D4R322I-Bx__als3.js} +1 -1
- package/web/assets/{stateDiagram-v2-36443NZ5-COBGd2RL.js → stateDiagram-v2-36443NZ5-QqOh2yO-.js} +1 -1
- package/web/assets/{timeline-definition-O6YCAMPW-Ds2CnVZK.js → timeline-definition-O6YCAMPW-C1eogTOG.js} +1 -1
- package/web/assets/{vennDiagram-MWXL3ELB-DUIEwXWp.js → vennDiagram-MWXL3ELB-D20F4rSW.js} +1 -1
- package/web/assets/{wardleyDiagram-CUQ6CDDI-DEiFPQih.js → wardleyDiagram-CUQ6CDDI-BeBHg7ST.js} +1 -1
- package/web/assets/{xychartDiagram-N2JHSOCM-BUbayhST.js → xychartDiagram-N2JHSOCM-CdOWeoNI.js} +1 -1
- package/web/index.html +2 -2
- package/scripts/materialize-vendor-grammars.cjs +0 -97
- package/web/assets/index-BKWA-m7o.css +0 -2
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-phase taint emission (#2083 M3 U4, plan KTD1/KTD6).
|
|
3
|
+
*
|
|
4
|
+
* Per-file driver for the M3 taint pass: gate → match → solve → propagate →
|
|
5
|
+
* persist sparse `TAINTED` + `SANITIZES` edges. Invoked from the pdg window in
|
|
6
|
+
* scope-resolution (`pipeline/run.ts`), immediately after `emitFileReachingDefs`
|
|
7
|
+
* inside the SAME per-file try — per-file isolation for free (KTD1). Mirrors
|
|
8
|
+
* `emitFileReachingDefs` (cfg/emit.ts) for the budget/dedup/warn discipline and
|
|
9
|
+
* the telemetry-result shape.
|
|
10
|
+
*
|
|
11
|
+
* ## Per-function pipeline (ordering is load-bearing)
|
|
12
|
+
*
|
|
13
|
+
* 1. `hasTaintSafeSites` — a corrupted-store site annotation degrades to
|
|
14
|
+
* SKIP-TAINT-KEEP-RD for this function (counted + warned), never a crash
|
|
15
|
+
* (KTD2; the matcher/propagator dereference indices unvalidated).
|
|
16
|
+
* 2. `matchFunctionSites` against the language spec (the import index is
|
|
17
|
+
* built ONCE per file — imports are a file-level fact).
|
|
18
|
+
* 3. ZERO-MATCH FAST PATH: the solver runs only when the function has at
|
|
19
|
+
* least one matched source AND one matched sink. In a typical repo almost
|
|
20
|
+
* no function has both; an unconditional second `computeReachingDefs` per
|
|
21
|
+
* function would ship a near-2× solve cost to every `--pdg` user.
|
|
22
|
+
* 4. `computeReachingDefs` with the taint `maxFacts` — by DEFAULT the M2
|
|
23
|
+
* derived `DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION` (deliberate
|
|
24
|
+
* reuse, not a new constant: the fact-materialization envelope is a
|
|
25
|
+
* memory question, O(defs×uses), orthogonal to the findings cap, and M2
|
|
26
|
+
* already validated exactly this envelope on the same solver in the same
|
|
27
|
+
* window). The run.ts caller derives `limits.maxFacts` from the SAME
|
|
28
|
+
* RD-edge-cap formula `emitFileReachingDefs` uses, so taint coverage and
|
|
29
|
+
* RD coverage truncate together — a function is never `truncated` for one
|
|
30
|
+
* layer and `computed` for the other.
|
|
31
|
+
* 5. `computeTaintFlows` — a non-`computed` status is a per-function
|
|
32
|
+
* COVERAGE GAP (R4: counted by `gapReason`, function skipped entirely,
|
|
33
|
+
* never partially analyzed).
|
|
34
|
+
* 6. Emit one `TAINTED` edge per finding and one `SANITIZES` edge per kill.
|
|
35
|
+
* Kills are emitted even when findings are zero — a fully-sanitized
|
|
36
|
+
* function's kills are exactly its evidence of safety.
|
|
37
|
+
*
|
|
38
|
+
* ## Identity, dedup, budget (KTD6)
|
|
39
|
+
*
|
|
40
|
+
* Findings carry STATEMENT-LEVEL identity — function anchor + sink kind +
|
|
41
|
+
* source occurrence (point/site/object-binding/property) + sink occurrence
|
|
42
|
+
* (point/site/arg/binding) — NOT the REACHING_DEF block-level key (block-pair
|
|
43
|
+
* conflation would drop `exec(req.body, req.query)`'s second finding). The
|
|
44
|
+
* propagation engine dedups by this exact key BEFORE its deterministic cap
|
|
45
|
+
* (`maxFindingsPerFunction`) and counts the overflow; this module templates
|
|
46
|
+
* the same coordinates into the edge id (binding identity via the shared
|
|
47
|
+
* `bindingKey`; the free-text `property` rides LAST so it can never collide
|
|
48
|
+
* into another component) and warns with the drop count on truncation.
|
|
49
|
+
*
|
|
50
|
+
* `reason` carries the versioned hop encoding (`taint/path-codec.ts` — U6's
|
|
51
|
+
* `explain` decodes the same module) for `TAINTED`, and the killed binding's
|
|
52
|
+
* plain name for `SANITIZES` (M0/S1 queryability verdict, like REACHING_DEF).
|
|
53
|
+
*
|
|
54
|
+
* ## Warn split (R4 vs noise)
|
|
55
|
+
*
|
|
56
|
+
* Unsafe-site skips and cap drops warn PER FUNCTION here (rare, actionable —
|
|
57
|
+
* mirrors `emitFileReachingDefs`' malformed/cap warns). Solver coverage gaps
|
|
58
|
+
* (`truncated`/`overflow`) do NOT re-warn per function: the RD layer already
|
|
59
|
+
* warned for the same function with the same solver status (same `maxFacts`
|
|
60
|
+
* derivation — see step 4), and a duplicate `[taint]` line per mega-function
|
|
61
|
+
* would be pure spam. They are counted (+ exampled) in the result and the
|
|
62
|
+
* run.ts caller aggregates them into ONE unconditional `logger.warn` per
|
|
63
|
+
* language (R4) — never dropped on the floor (the M2 lesson).
|
|
64
|
+
*/
|
|
65
|
+
import { generateId } from '../../../lib/utils.js';
|
|
66
|
+
import { basicBlockId, bindingKey, DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION, } from '../cfg/emit.js';
|
|
67
|
+
import { computeReachingDefs, pointKey } from '../cfg/reaching-defs.js';
|
|
68
|
+
import { hasTaintSafeSites } from './site-safety.js';
|
|
69
|
+
import { buildTaintImportIndex, matchFunctionSites } from './match.js';
|
|
70
|
+
import { computeTaintFlows, DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION, DEFAULT_PDG_MAX_TAINT_HOPS, } from './propagate.js';
|
|
71
|
+
// Re-exported so the pipeline (run.ts) sources the taint default caps through
|
|
72
|
+
// this orchestration module rather than reaching into propagate.ts directly.
|
|
73
|
+
export { DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION, DEFAULT_PDG_MAX_TAINT_HOPS, } from './propagate.js';
|
|
74
|
+
import { encodeTaintPath } from './path-codec.js';
|
|
75
|
+
/** Cap on example anchors carried per result (aggregate-warn material, R4). */
|
|
76
|
+
const MAX_EXAMPLES = 5;
|
|
77
|
+
const pushExample = (list, anchor) => {
|
|
78
|
+
if (list.length < MAX_EXAMPLES)
|
|
79
|
+
list.push(anchor);
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Run the taint pass over one file's emit-safe CFGs and persist TAINTED +
|
|
83
|
+
* SANITIZES edges. `cfgs` MUST already be `isEmitSafeCfg`-filtered (the same
|
|
84
|
+
* `wellFormed` array the caller fed `emitFileCfgs`/`emitFileReachingDefs`) —
|
|
85
|
+
* block/edge anchors are trusted here; only the M3 `sites` layer is
|
|
86
|
+
* re-validated (`hasTaintSafeSites`). Never throws on well-formed input;
|
|
87
|
+
* the caller's per-file try isolates the rest.
|
|
88
|
+
*/
|
|
89
|
+
export function emitFileTaint(graph, cfgs, parsedImports, spec, limits, onWarn) {
|
|
90
|
+
const result = {
|
|
91
|
+
functionsAnalyzed: 0,
|
|
92
|
+
functionsSkippedNoMatch: 0,
|
|
93
|
+
functionsSkippedUnsafeSites: 0,
|
|
94
|
+
functionsCoverageGap: { truncated: 0, overflow: 0, 'no-facts': 0 },
|
|
95
|
+
findingsEmitted: 0,
|
|
96
|
+
killsEmitted: 0,
|
|
97
|
+
findingsDropped: 0,
|
|
98
|
+
hopsTruncatedFindings: 0,
|
|
99
|
+
coverageGapExamples: [],
|
|
100
|
+
droppedExamples: [],
|
|
101
|
+
};
|
|
102
|
+
// Imports are a FILE-level fact — build the index once, not per function.
|
|
103
|
+
const importIndex = buildTaintImportIndex(parsedImports);
|
|
104
|
+
const maxFindingsPerFunction = limits?.maxFindingsPerFunction ?? DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION;
|
|
105
|
+
const maxHops = limits?.maxHops ?? DEFAULT_PDG_MAX_TAINT_HOPS;
|
|
106
|
+
const maxFacts = limits?.maxFacts ?? DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION;
|
|
107
|
+
// Defensive cross-CFG id guard: finding identity is unique WITHIN a
|
|
108
|
+
// function by construction (the propagation engine dedups), so a repeat can
|
|
109
|
+
// only mean two CFGs sharing an anchor — skip, never double-insert.
|
|
110
|
+
const seenEdgeIds = new Set();
|
|
111
|
+
for (const cfg of cfgs) {
|
|
112
|
+
const { filePath, functionStartLine, functionStartColumn } = cfg;
|
|
113
|
+
const anchor = `${filePath}:${functionStartLine}`;
|
|
114
|
+
if (!hasTaintSafeSites(cfg)) {
|
|
115
|
+
result.functionsSkippedUnsafeSites++;
|
|
116
|
+
pushExample(result.coverageGapExamples, anchor);
|
|
117
|
+
onWarn?.(`[taint] ${anchor}: malformed site annotations (out-of-range binding/site ` +
|
|
118
|
+
`indices) — taint skipped for this function; its CFG and REACHING_DEF ` +
|
|
119
|
+
`layers are unaffected`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const matches = matchFunctionSites(cfg, spec, importIndex);
|
|
123
|
+
if (!matches.hasSource || !matches.hasSink) {
|
|
124
|
+
// Zero-match fast path: no solver call (see module doc step 3).
|
|
125
|
+
result.functionsSkippedNoMatch++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const defUse = computeReachingDefs(cfg, { maxFacts });
|
|
129
|
+
const flows = computeTaintFlows(cfg, defUse, matches, { maxFindingsPerFunction, maxHops });
|
|
130
|
+
if (flows.status === 'coverage-gap') {
|
|
131
|
+
// R4: skipped entirely, counted by reason; aggregate-warned by the
|
|
132
|
+
// caller (the RD layer already per-function-warned this solver status).
|
|
133
|
+
result.functionsCoverageGap[flows.gapReason ?? 'no-facts']++;
|
|
134
|
+
pushExample(result.coverageGapExamples, anchor);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
result.functionsAnalyzed++;
|
|
138
|
+
const bindings = cfg.bindings ?? [];
|
|
139
|
+
const fnAnchor = `${filePath}:${functionStartLine}:${functionStartColumn}`;
|
|
140
|
+
const blockId = (p) => basicBlockId(filePath, functionStartLine, functionStartColumn, p.blockIndex);
|
|
141
|
+
const bKey = (idx) => {
|
|
142
|
+
const b = bindings[idx];
|
|
143
|
+
return b === undefined ? `#${idx}` : bindingKey(b);
|
|
144
|
+
};
|
|
145
|
+
// SANITIZES — one edge per kill, REGARDLESS of findings (kills can and do
|
|
146
|
+
// exist with zero findings: a fully-sanitized flow IS the kill evidence).
|
|
147
|
+
for (const kill of flows.kills) {
|
|
148
|
+
const id = generateId('SANITIZES', `${fnAnchor}:${pointKey(kill.sanitizer)}->${pointKey(kill.killedDef)}:` +
|
|
149
|
+
bKey(kill.bindingIdx));
|
|
150
|
+
if (seenEdgeIds.has(id))
|
|
151
|
+
continue;
|
|
152
|
+
seenEdgeIds.add(id);
|
|
153
|
+
graph.addRelationship({
|
|
154
|
+
id,
|
|
155
|
+
type: 'SANITIZES',
|
|
156
|
+
sourceId: blockId(kill.sanitizer),
|
|
157
|
+
targetId: blockId(kill.killedDef),
|
|
158
|
+
confidence: 1.0,
|
|
159
|
+
reason: bindings[kill.bindingIdx]?.name ?? `#${kill.bindingIdx}`,
|
|
160
|
+
});
|
|
161
|
+
result.killsEmitted++;
|
|
162
|
+
}
|
|
163
|
+
// TAINTED — one edge per finding (already deduped + capped upstream).
|
|
164
|
+
for (const finding of flows.findings) {
|
|
165
|
+
const { source, sink } = finding;
|
|
166
|
+
// KTD6 statement-level identity: function anchor + kind + source
|
|
167
|
+
// occurrence + sink occurrence + binding keys. The rule-(b) occurrence
|
|
168
|
+
// coordinates (site index / arg index) distinguish
|
|
169
|
+
// `exec(req.body, req.query)`'s two findings; `property` is free-text
|
|
170
|
+
// (string-literal subscripts) and rides LAST so it cannot collide into
|
|
171
|
+
// another component.
|
|
172
|
+
const id = generateId('TAINTED', `${fnAnchor}:${finding.sinkKind}:` +
|
|
173
|
+
`${pointKey(source.point)}.${source.siteIndex}:${bKey(source.objectBindingIdx)}:` +
|
|
174
|
+
`${pointKey(sink.point)}.${sink.siteIndex}.${sink.argIndex}:${bKey(sink.bindingIdx)}:` +
|
|
175
|
+
`${sink.entryName}:${source.property}`);
|
|
176
|
+
if (seenEdgeIds.has(id))
|
|
177
|
+
continue;
|
|
178
|
+
seenEdgeIds.add(id);
|
|
179
|
+
// `kind` rides the reason's `;<kind>` header — the only persisted
|
|
180
|
+
// channel for the finding's category (the edge id embedding it is not a
|
|
181
|
+
// stored column; `step` is INT32). U6's `explain` decodes it back.
|
|
182
|
+
const encoded = encodeTaintPath(finding.hops.map((h) => ({ name: h.name, line: h.point.line, viaCall: h.viaCall })), { truncated: finding.hopsTruncated === true, kind: finding.sinkKind });
|
|
183
|
+
if (encoded.truncated)
|
|
184
|
+
result.hopsTruncatedFindings++;
|
|
185
|
+
graph.addRelationship({
|
|
186
|
+
id,
|
|
187
|
+
type: 'TAINTED',
|
|
188
|
+
sourceId: blockId(source.point),
|
|
189
|
+
targetId: blockId(sink.point),
|
|
190
|
+
confidence: 1.0,
|
|
191
|
+
reason: encoded.reason,
|
|
192
|
+
});
|
|
193
|
+
result.findingsEmitted++;
|
|
194
|
+
}
|
|
195
|
+
if (flows.droppedFindings > 0) {
|
|
196
|
+
result.findingsDropped += flows.droppedFindings;
|
|
197
|
+
pushExample(result.droppedExamples, anchor);
|
|
198
|
+
onWarn?.(`[taint] ${anchor}: per-function taint findings cap ` +
|
|
199
|
+
`(${maxFindingsPerFunction}) reached — dropped ${flows.droppedFindings} of ` +
|
|
200
|
+
`${flows.findings.length + flows.droppedFindings} deduped findings`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import-aware taint-site matcher (#2083 M3 U2, plan KTD7).
|
|
3
|
+
*
|
|
4
|
+
* Classifies a function's harvested {@link SiteRecord}s against a registered
|
|
5
|
+
* {@link SourceSinkSanitizerSpec}: which member reads are SOURCES, which
|
|
6
|
+
* call/new sites are SINKS (and at which argument positions), and which are
|
|
7
|
+
* SANITIZERS. Pure main-thread data work — sites + bindings come from the U1
|
|
8
|
+
* worker harvest, imports from `ParsedFile.parsedImports`; no AST, no I/O.
|
|
9
|
+
*
|
|
10
|
+
* PRECONDITION: the caller must gate the CFG through `hasTaintSafeSites`
|
|
11
|
+
* (taint/site-safety.ts) first — this module dereferences binding/site
|
|
12
|
+
* indices without re-validating them.
|
|
13
|
+
*
|
|
14
|
+
* ## Callee resolution precedence (bare and member-rooted calls)
|
|
15
|
+
*
|
|
16
|
+
* 1. ESM import join — the callee root's local name is resolved through the
|
|
17
|
+
* {@link TaintImportIndex} built from `parsedImports` (`named`/`alias`
|
|
18
|
+
* members, `namespace`/default-import module handles); `import { exec as
|
|
19
|
+
* run } from 'child_process'` makes `run(c)` resolve to
|
|
20
|
+
* `child_process.exec`, and `import * as cp …` makes `cp.exec(c)` resolve
|
|
21
|
+
* the same way.
|
|
22
|
+
* 2. require-literal join — a binding whose in-function defining site carries
|
|
23
|
+
* `requireArg` resolves like a namespace handle (`const cp =
|
|
24
|
+
* require('child_process'); cp.exec(c)`). A BARE call of a require-joined
|
|
25
|
+
* binding is matched under BOTH interpretations, `<module>.default` (the
|
|
26
|
+
* module/default export invoked directly) and `<module>.<localName>`
|
|
27
|
+
* (non-renamed destructured require — the harvest attaches `resultDefs`
|
|
28
|
+
* to destructured bindings without recording the property path, and the
|
|
29
|
+
* binding name IS the member name in the non-renamed case).
|
|
30
|
+
* 3. Bare-name fallback — TRUE GLOBALS only (`global: true` entries: `eval`,
|
|
31
|
+
* `new Function`, `encodeURIComponent`), and only when the name is neither
|
|
32
|
+
* import-bound nor shadowed. Conventional receiver names (`req`/`request`
|
|
33
|
+
* member-read sources, `res.send`, `.query`/`.execute`) are matched
|
|
34
|
+
* name-based by their own mechanisms, never via the global fallback.
|
|
35
|
+
*
|
|
36
|
+
* ## Shadowing rule (exact)
|
|
37
|
+
*
|
|
38
|
+
* A name is treated as function-local — blocking import/global resolution —
|
|
39
|
+
* iff the function's binding table contains a NON-`synthetic` entry with that
|
|
40
|
+
* name (an in-function `function exec(){}` / `const exec = …`). Synthetic
|
|
41
|
+
* bindings (kind `module`, `synthetic: true`) are imports, true globals, or
|
|
42
|
+
* enclosing-scope captures and do not shadow. Member-call roots use the
|
|
43
|
+
* harvested `receiver` binding index directly (no name scan).
|
|
44
|
+
*
|
|
45
|
+
* ## Documented resolution gaps (direction stated, per plan KTD10)
|
|
46
|
+
*
|
|
47
|
+
* - MODULE-LEVEL `const cp = require('child_process')`: the binding is
|
|
48
|
+
* synthetic inside the function, produces no `ParsedImport`, and its
|
|
49
|
+
* defining site lives outside the function's harvested sites — the
|
|
50
|
+
* require join cannot see it. Module-mechanism sinks miss (FN) and
|
|
51
|
+
* sanitizers don't kill (FP noise — never a false kill, the safe
|
|
52
|
+
* direction). Only in-function requires resolve.
|
|
53
|
+
* - RENAMED destructured require (`const { exec: run } = require(…)`):
|
|
54
|
+
* the dual interpretation resolves `run` to `child_process.run` — no
|
|
55
|
+
* match (FN). Non-renamed destructures resolve exactly.
|
|
56
|
+
* - CONSERVATIVE shadow scan for bare calls: ANY non-synthetic binding of
|
|
57
|
+
* the callee name anywhere in the function blocks import/global
|
|
58
|
+
* resolution, even when the shadow is block-scoped elsewhere and the call
|
|
59
|
+
* site actually sees the import (FN; rare; safe for sanitizers).
|
|
60
|
+
* - MODULE-LEVEL user declarations are indistinguishable from imports in
|
|
61
|
+
* the binding table (both synthetic). ESM forbids a module-level
|
|
62
|
+
* declaration colliding with an import name, so the import join is
|
|
63
|
+
* authoritative when an import exists; a module-level user function
|
|
64
|
+
* shadowing a TRUE GLOBAL (e.g. a local `encodeURIComponent`) is not
|
|
65
|
+
* detectable and would still match (pathological; accepted).
|
|
66
|
+
* - Handle COPIES (`const c2 = cp; c2.exec(…)`) are not followed — joins
|
|
67
|
+
* are one level deep (binding → import/require), never through
|
|
68
|
+
* assignments (FN).
|
|
69
|
+
* - `this.`/`super.`-rooted and call-rooted callee chains have no
|
|
70
|
+
* resolvable root: only the syntactic `anyReceiver`/`receivers`
|
|
71
|
+
* mechanisms can match them.
|
|
72
|
+
* - `reexport`/`wildcard`/`dynamic-*`/`side-effect` imports introduce no
|
|
73
|
+
* matcher-visible local binding and are skipped by the index.
|
|
74
|
+
*/
|
|
75
|
+
import type { ParsedImport } from '../../../_shared/index.js';
|
|
76
|
+
import type { FunctionCfg } from '../cfg/types.js';
|
|
77
|
+
import type { SourceSinkSanitizerSpec, TaintMemberSourceEntry, TaintSanitizerEntry, TaintSinkEntry } from './source-sink-config.js';
|
|
78
|
+
/** What a local name imported into the file denotes. */
|
|
79
|
+
export interface TaintImportBinding {
|
|
80
|
+
/** Normalized module specifier (`node:` scheme stripped). */
|
|
81
|
+
readonly module: string;
|
|
82
|
+
/**
|
|
83
|
+
* Exported member bound by a named/aliased import; `undefined` when the
|
|
84
|
+
* local name is a MODULE HANDLE (namespace import, or a default import —
|
|
85
|
+
* CJS interop makes the default export ≈ the module object).
|
|
86
|
+
*/
|
|
87
|
+
readonly member?: string;
|
|
88
|
+
}
|
|
89
|
+
/** Local name → import provenance for one file. Build once per file (U4). */
|
|
90
|
+
export type TaintImportIndex = ReadonlyMap<string, TaintImportBinding>;
|
|
91
|
+
/** A member-read site matched as a taint source. */
|
|
92
|
+
export interface MatchedSourceRead {
|
|
93
|
+
/** Index into the owning statement's `sites` array. */
|
|
94
|
+
readonly siteIndex: number;
|
|
95
|
+
readonly entry: TaintMemberSourceEntry;
|
|
96
|
+
}
|
|
97
|
+
/** A call/new site matched as a sink. */
|
|
98
|
+
export interface MatchedSinkCall {
|
|
99
|
+
/** Index into the owning statement's `sites` array. */
|
|
100
|
+
readonly siteIndex: number;
|
|
101
|
+
readonly entry: TaintSinkEntry;
|
|
102
|
+
/**
|
|
103
|
+
* Positions (indices into `site.args`) that are registered sink positions
|
|
104
|
+
* AND carry at least one recorded binding occurrence, after the spread
|
|
105
|
+
* rule (a recorded position ≥ `site.spread` matches when any registered
|
|
106
|
+
* position ≥ the spread index exists — runtime positions after a spread
|
|
107
|
+
* are unknowable) and the template rule (`template: true` aggregates all
|
|
108
|
+
* substitutions at position 0 and matches any-position). Never empty — a
|
|
109
|
+
* sink whose dangerous positions carry no occurrences cannot produce a
|
|
110
|
+
* finding and is not reported.
|
|
111
|
+
*/
|
|
112
|
+
readonly argPositions: readonly number[];
|
|
113
|
+
}
|
|
114
|
+
/** A call site matched as a sanitizer (import-aware/global only — see module doc). */
|
|
115
|
+
export interface MatchedSanitizerCall {
|
|
116
|
+
/** Index into the owning statement's `sites` array. */
|
|
117
|
+
readonly siteIndex: number;
|
|
118
|
+
readonly entry: TaintSanitizerEntry;
|
|
119
|
+
/**
|
|
120
|
+
* Bindings the sanitizer's result defines directly (`const b = escape(t)`
|
|
121
|
+
* ⇒ `b`) — U3's kill targets (KTD4b). Empty for value-position sanitizer
|
|
122
|
+
* calls (`exec(escape(x))`), whose effect is occurrence INTERPOSITION via
|
|
123
|
+
* the site's `parent`/via-tag chain, not a def kill.
|
|
124
|
+
*/
|
|
125
|
+
readonly resultDefs: readonly number[];
|
|
126
|
+
}
|
|
127
|
+
/** All matches within one statement. Emitted only when at least one list is non-empty. */
|
|
128
|
+
export interface StatementMatches {
|
|
129
|
+
readonly blockIndex: number;
|
|
130
|
+
readonly statementIndex: number;
|
|
131
|
+
readonly line: number;
|
|
132
|
+
readonly sources: readonly MatchedSourceRead[];
|
|
133
|
+
readonly sinks: readonly MatchedSinkCall[];
|
|
134
|
+
readonly sanitizers: readonly MatchedSanitizerCall[];
|
|
135
|
+
}
|
|
136
|
+
/** Classified sites for one function, in (block, statement, site, entry) order. */
|
|
137
|
+
export interface FunctionSiteMatches {
|
|
138
|
+
readonly statements: readonly StatementMatches[];
|
|
139
|
+
/** Fast-path gates for U4: the solver runs only when both are true. */
|
|
140
|
+
readonly hasSource: boolean;
|
|
141
|
+
readonly hasSink: boolean;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Build the local-name → module/member index from a file's `parsedImports`.
|
|
145
|
+
* Only `named`/`alias`/`namespace` kinds bind matcher-visible local names;
|
|
146
|
+
* `importedName === 'default'` collapses to a module handle.
|
|
147
|
+
*/
|
|
148
|
+
export declare function buildTaintImportIndex(imports: readonly ParsedImport[]): TaintImportIndex;
|
|
149
|
+
/**
|
|
150
|
+
* Classify a function's harvested sites against a language spec. See the
|
|
151
|
+
* module doc for resolution precedence, the shadowing rule, and gaps.
|
|
152
|
+
*/
|
|
153
|
+
export declare function matchFunctionSites(cfg: FunctionCfg, spec: SourceSinkSanitizerSpec, imports: TaintImportIndex): FunctionSiteMatches;
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import-aware taint-site matcher (#2083 M3 U2, plan KTD7).
|
|
3
|
+
*
|
|
4
|
+
* Classifies a function's harvested {@link SiteRecord}s against a registered
|
|
5
|
+
* {@link SourceSinkSanitizerSpec}: which member reads are SOURCES, which
|
|
6
|
+
* call/new sites are SINKS (and at which argument positions), and which are
|
|
7
|
+
* SANITIZERS. Pure main-thread data work — sites + bindings come from the U1
|
|
8
|
+
* worker harvest, imports from `ParsedFile.parsedImports`; no AST, no I/O.
|
|
9
|
+
*
|
|
10
|
+
* PRECONDITION: the caller must gate the CFG through `hasTaintSafeSites`
|
|
11
|
+
* (taint/site-safety.ts) first — this module dereferences binding/site
|
|
12
|
+
* indices without re-validating them.
|
|
13
|
+
*
|
|
14
|
+
* ## Callee resolution precedence (bare and member-rooted calls)
|
|
15
|
+
*
|
|
16
|
+
* 1. ESM import join — the callee root's local name is resolved through the
|
|
17
|
+
* {@link TaintImportIndex} built from `parsedImports` (`named`/`alias`
|
|
18
|
+
* members, `namespace`/default-import module handles); `import { exec as
|
|
19
|
+
* run } from 'child_process'` makes `run(c)` resolve to
|
|
20
|
+
* `child_process.exec`, and `import * as cp …` makes `cp.exec(c)` resolve
|
|
21
|
+
* the same way.
|
|
22
|
+
* 2. require-literal join — a binding whose in-function defining site carries
|
|
23
|
+
* `requireArg` resolves like a namespace handle (`const cp =
|
|
24
|
+
* require('child_process'); cp.exec(c)`). A BARE call of a require-joined
|
|
25
|
+
* binding is matched under BOTH interpretations, `<module>.default` (the
|
|
26
|
+
* module/default export invoked directly) and `<module>.<localName>`
|
|
27
|
+
* (non-renamed destructured require — the harvest attaches `resultDefs`
|
|
28
|
+
* to destructured bindings without recording the property path, and the
|
|
29
|
+
* binding name IS the member name in the non-renamed case).
|
|
30
|
+
* 3. Bare-name fallback — TRUE GLOBALS only (`global: true` entries: `eval`,
|
|
31
|
+
* `new Function`, `encodeURIComponent`), and only when the name is neither
|
|
32
|
+
* import-bound nor shadowed. Conventional receiver names (`req`/`request`
|
|
33
|
+
* member-read sources, `res.send`, `.query`/`.execute`) are matched
|
|
34
|
+
* name-based by their own mechanisms, never via the global fallback.
|
|
35
|
+
*
|
|
36
|
+
* ## Shadowing rule (exact)
|
|
37
|
+
*
|
|
38
|
+
* A name is treated as function-local — blocking import/global resolution —
|
|
39
|
+
* iff the function's binding table contains a NON-`synthetic` entry with that
|
|
40
|
+
* name (an in-function `function exec(){}` / `const exec = …`). Synthetic
|
|
41
|
+
* bindings (kind `module`, `synthetic: true`) are imports, true globals, or
|
|
42
|
+
* enclosing-scope captures and do not shadow. Member-call roots use the
|
|
43
|
+
* harvested `receiver` binding index directly (no name scan).
|
|
44
|
+
*
|
|
45
|
+
* ## Documented resolution gaps (direction stated, per plan KTD10)
|
|
46
|
+
*
|
|
47
|
+
* - MODULE-LEVEL `const cp = require('child_process')`: the binding is
|
|
48
|
+
* synthetic inside the function, produces no `ParsedImport`, and its
|
|
49
|
+
* defining site lives outside the function's harvested sites — the
|
|
50
|
+
* require join cannot see it. Module-mechanism sinks miss (FN) and
|
|
51
|
+
* sanitizers don't kill (FP noise — never a false kill, the safe
|
|
52
|
+
* direction). Only in-function requires resolve.
|
|
53
|
+
* - RENAMED destructured require (`const { exec: run } = require(…)`):
|
|
54
|
+
* the dual interpretation resolves `run` to `child_process.run` — no
|
|
55
|
+
* match (FN). Non-renamed destructures resolve exactly.
|
|
56
|
+
* - CONSERVATIVE shadow scan for bare calls: ANY non-synthetic binding of
|
|
57
|
+
* the callee name anywhere in the function blocks import/global
|
|
58
|
+
* resolution, even when the shadow is block-scoped elsewhere and the call
|
|
59
|
+
* site actually sees the import (FN; rare; safe for sanitizers).
|
|
60
|
+
* - MODULE-LEVEL user declarations are indistinguishable from imports in
|
|
61
|
+
* the binding table (both synthetic). ESM forbids a module-level
|
|
62
|
+
* declaration colliding with an import name, so the import join is
|
|
63
|
+
* authoritative when an import exists; a module-level user function
|
|
64
|
+
* shadowing a TRUE GLOBAL (e.g. a local `encodeURIComponent`) is not
|
|
65
|
+
* detectable and would still match (pathological; accepted).
|
|
66
|
+
* - Handle COPIES (`const c2 = cp; c2.exec(…)`) are not followed — joins
|
|
67
|
+
* are one level deep (binding → import/require), never through
|
|
68
|
+
* assignments (FN).
|
|
69
|
+
* - `this.`/`super.`-rooted and call-rooted callee chains have no
|
|
70
|
+
* resolvable root: only the syntactic `anyReceiver`/`receivers`
|
|
71
|
+
* mechanisms can match them.
|
|
72
|
+
* - `reexport`/`wildcard`/`dynamic-*`/`side-effect` imports introduce no
|
|
73
|
+
* matcher-visible local binding and are skipped by the index.
|
|
74
|
+
*/
|
|
75
|
+
const stripNodeScheme = (specifier) => specifier.startsWith('node:') ? specifier.slice('node:'.length) : specifier;
|
|
76
|
+
/**
|
|
77
|
+
* Build the local-name → module/member index from a file's `parsedImports`.
|
|
78
|
+
* Only `named`/`alias`/`namespace` kinds bind matcher-visible local names;
|
|
79
|
+
* `importedName === 'default'` collapses to a module handle.
|
|
80
|
+
*/
|
|
81
|
+
export function buildTaintImportIndex(imports) {
|
|
82
|
+
const index = new Map();
|
|
83
|
+
for (const imp of imports) {
|
|
84
|
+
if (imp.kind === 'named' || imp.kind === 'alias') {
|
|
85
|
+
const module = stripNodeScheme(imp.targetRaw);
|
|
86
|
+
index.set(imp.localName, imp.importedName === 'default' ? { module } : { module, member: imp.importedName });
|
|
87
|
+
}
|
|
88
|
+
else if (imp.kind === 'namespace') {
|
|
89
|
+
index.set(imp.localName, { module: stripNodeScheme(imp.targetRaw) });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return index;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Classify a function's harvested sites against a language spec. See the
|
|
96
|
+
* module doc for resolution precedence, the shadowing rule, and gaps.
|
|
97
|
+
*/
|
|
98
|
+
export function matchFunctionSites(cfg, spec, imports) {
|
|
99
|
+
const bindings = cfg.bindings ?? [];
|
|
100
|
+
// Non-synthetic (in-function-declared) binding indices by name — the
|
|
101
|
+
// shadow scan + bare-call require-join lookup.
|
|
102
|
+
const nonSyntheticByName = new Map();
|
|
103
|
+
bindings.forEach((b, i) => {
|
|
104
|
+
if (b.synthetic === true)
|
|
105
|
+
return;
|
|
106
|
+
const list = nonSyntheticByName.get(b.name);
|
|
107
|
+
if (list)
|
|
108
|
+
list.push(i);
|
|
109
|
+
else
|
|
110
|
+
nonSyntheticByName.set(b.name, [i]);
|
|
111
|
+
});
|
|
112
|
+
// require-literal join: binding index → module specifier. A binding def'd
|
|
113
|
+
// by two DIFFERENT require literals is conflicted → dropped (resolving it
|
|
114
|
+
// either way could fabricate a sanitizer kill).
|
|
115
|
+
const requireByBinding = new Map();
|
|
116
|
+
const conflicted = new Set();
|
|
117
|
+
for (const block of cfg.blocks) {
|
|
118
|
+
for (const stmt of block.statements ?? []) {
|
|
119
|
+
for (const site of stmt.sites ?? []) {
|
|
120
|
+
if (site.requireArg === undefined || site.resultDefs === undefined)
|
|
121
|
+
continue;
|
|
122
|
+
const module = stripNodeScheme(site.requireArg);
|
|
123
|
+
for (const def of site.resultDefs) {
|
|
124
|
+
if (conflicted.has(def))
|
|
125
|
+
continue;
|
|
126
|
+
const prior = requireByBinding.get(def);
|
|
127
|
+
if (prior === undefined)
|
|
128
|
+
requireByBinding.set(def, module);
|
|
129
|
+
else if (prior !== module) {
|
|
130
|
+
requireByBinding.delete(def);
|
|
131
|
+
conflicted.add(def);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const resolveCallee = (site) => {
|
|
138
|
+
if (site.callee === undefined)
|
|
139
|
+
return undefined;
|
|
140
|
+
const path = site.callee.split('.');
|
|
141
|
+
const root = path[0];
|
|
142
|
+
const rest = path.slice(1);
|
|
143
|
+
const canonical = [];
|
|
144
|
+
let globalRoot = false;
|
|
145
|
+
if (site.receiver !== undefined) {
|
|
146
|
+
// Member chain with an identifier root — origin known by binding index.
|
|
147
|
+
const rb = bindings[site.receiver];
|
|
148
|
+
if (rb.synthetic === true) {
|
|
149
|
+
const imp = imports.get(rb.name);
|
|
150
|
+
if (imp !== undefined) {
|
|
151
|
+
const base = imp.member === undefined ? [imp.module] : [imp.module, imp.member];
|
|
152
|
+
canonical.push([...base, ...rest].join('.'));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
const module = requireByBinding.get(site.receiver);
|
|
157
|
+
if (module !== undefined)
|
|
158
|
+
canonical.push([module, ...rest].join('.'));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else if (path.length === 1) {
|
|
162
|
+
// Bare call. `this`/`super`/call-rooted chains never get here (those
|
|
163
|
+
// are dotted-without-receiver or callee-less).
|
|
164
|
+
const locals = nonSyntheticByName.get(root);
|
|
165
|
+
if (locals !== undefined) {
|
|
166
|
+
// Shadowed by an in-function declaration — only the require join
|
|
167
|
+
// applies, under the dual interpretation (module doc).
|
|
168
|
+
for (const idx of locals) {
|
|
169
|
+
const module = requireByBinding.get(idx);
|
|
170
|
+
if (module !== undefined)
|
|
171
|
+
canonical.push(`${module}.default`, `${module}.${root}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
const imp = imports.get(root);
|
|
176
|
+
if (imp !== undefined) {
|
|
177
|
+
canonical.push(imp.member === undefined ? `${imp.module}.default` : `${imp.module}.${imp.member}`);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
globalRoot = true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { path, canonical, globalRoot };
|
|
185
|
+
};
|
|
186
|
+
/** The spread/template/registered-position rule (see MatchedSinkCall doc). */
|
|
187
|
+
const positionMatches = (entry, site, p) => {
|
|
188
|
+
if (site.template === true)
|
|
189
|
+
return true;
|
|
190
|
+
if (entry.args === undefined)
|
|
191
|
+
return true;
|
|
192
|
+
if (site.spread !== undefined && p >= site.spread) {
|
|
193
|
+
const spread = site.spread;
|
|
194
|
+
return entry.args.some((q) => q >= spread);
|
|
195
|
+
}
|
|
196
|
+
return entry.args.includes(p);
|
|
197
|
+
};
|
|
198
|
+
const sinkMechanismHit = (entry, site, r) => {
|
|
199
|
+
if (entry.module !== undefined)
|
|
200
|
+
return r.canonical.includes(`${entry.module}.${entry.name}`);
|
|
201
|
+
if (entry.global === true) {
|
|
202
|
+
return (r.globalRoot &&
|
|
203
|
+
r.path.length === 1 &&
|
|
204
|
+
r.path[0] === entry.name &&
|
|
205
|
+
(entry.newOnly !== true || site.kind === 'new'));
|
|
206
|
+
}
|
|
207
|
+
if (entry.anyReceiver === true) {
|
|
208
|
+
return r.path.length >= 2 && r.path[r.path.length - 1] === entry.name;
|
|
209
|
+
}
|
|
210
|
+
if (entry.receivers !== undefined) {
|
|
211
|
+
return r.path.length === 2 && entry.receivers.includes(r.path[0]) && r.path[1] === entry.name;
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
};
|
|
215
|
+
// Sanitizers: module + global mechanisms ONLY — never receiver-conventional,
|
|
216
|
+
// never bare-name for non-globals (a false kill is the forbidden direction).
|
|
217
|
+
const sanitizerMechanismHit = (entry, r) => {
|
|
218
|
+
if (entry.module !== undefined)
|
|
219
|
+
return r.canonical.includes(`${entry.module}.${entry.name}`);
|
|
220
|
+
if (entry.global === true) {
|
|
221
|
+
return r.globalRoot && r.path.length === 1 && r.path[0] === entry.name;
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
};
|
|
225
|
+
const statements = [];
|
|
226
|
+
let hasSource = false;
|
|
227
|
+
let hasSink = false;
|
|
228
|
+
cfg.blocks.forEach((block, blockIndex) => {
|
|
229
|
+
block.statements?.forEach((stmt, statementIndex) => {
|
|
230
|
+
const sites = stmt.sites;
|
|
231
|
+
if (sites === undefined || sites.length === 0)
|
|
232
|
+
return;
|
|
233
|
+
const sources = [];
|
|
234
|
+
const sinks = [];
|
|
235
|
+
const sanitizers = [];
|
|
236
|
+
sites.forEach((site, siteIndex) => {
|
|
237
|
+
if (site.kind === 'member-read') {
|
|
238
|
+
if (site.object === undefined || site.property === undefined)
|
|
239
|
+
return;
|
|
240
|
+
const objectName = bindings[site.object].name;
|
|
241
|
+
const property = site.property;
|
|
242
|
+
for (const entry of spec.sources) {
|
|
243
|
+
if (entry.objects.includes(objectName) && entry.properties.includes(property)) {
|
|
244
|
+
sources.push({ siteIndex, entry });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
// call / new
|
|
250
|
+
const resolved = resolveCallee(site);
|
|
251
|
+
if (resolved === undefined)
|
|
252
|
+
return;
|
|
253
|
+
for (const entry of spec.sinks) {
|
|
254
|
+
if (!sinkMechanismHit(entry, site, resolved))
|
|
255
|
+
continue;
|
|
256
|
+
const argPositions = [];
|
|
257
|
+
site.args?.forEach((occurrences, p) => {
|
|
258
|
+
if (occurrences.length > 0 && positionMatches(entry, site, p))
|
|
259
|
+
argPositions.push(p);
|
|
260
|
+
});
|
|
261
|
+
if (argPositions.length > 0)
|
|
262
|
+
sinks.push({ siteIndex, entry, argPositions });
|
|
263
|
+
}
|
|
264
|
+
for (const entry of spec.sanitizers) {
|
|
265
|
+
if (!sanitizerMechanismHit(entry, resolved))
|
|
266
|
+
continue;
|
|
267
|
+
sanitizers.push({ siteIndex, entry, resultDefs: site.resultDefs ?? [] });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
if (sources.length === 0 && sinks.length === 0 && sanitizers.length === 0)
|
|
271
|
+
return;
|
|
272
|
+
hasSource ||= sources.length > 0;
|
|
273
|
+
hasSink ||= sinks.length > 0;
|
|
274
|
+
statements.push({ blockIndex, statementIndex, line: stmt.line, sources, sinks, sanitizers });
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
return { statements, hasSource, hasSink };
|
|
278
|
+
}
|