fix(taint): avoid false positives for guarded Git arguments (#3703)

* fix(taint): refine values after accepted guards

Preserve bounded value restrictions through local flows and function
summaries. Check the complete command against the modeled sink use
before suppressing a finding. Unknown paths and exhausted budgets
retain conservative findings.

Refs #3490

* fix(taint): preserve guard proof identities

Reject destructured/rest formals before they inherit a whole-argument
proof, and include transitive alias uses in bounded guard certificates.

Add worker regressions, argument-heavy scaling coverage and global
refinement fallback diagnostics.

Refs #3703

* perf(cfg): reuse scope and argument facts

Reduce duplicate native AST reads and allocation in TypeScript CFG collection
while preserving argument forwarding and malformed-input recovery.

Add regression cases and a portable paired benchmark with recorded results.

* test(cfg): distinguish validator cases

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
This commit is contained in:
Gergő Magyar 2026-10-24 15:37:26 +01:00 • committed by GitHub
parent 5dd58a5eec
commit d8a256a03a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 3286 additions and 96 deletions

View file

@ -0,0 +1,123 @@
# Guard-aware taint collection optimization
Measured 2026-10-24 for PR #3703. The corrected final implementation meets the planned **<=5% guard-free CFG collection-time overhead** target on all three measured workloads. Against the original PR implementation, the selection workload is **12.55% faster** and the transitive-alias holdout is **18.38% faster**. No proof rule or budget was weakened.
## Confirmed results
Each row compares two revisions in one process with 15 alternating paired samples. Negative overhead means faster collection. Compare timings within a row: this shared host also ran tests, and load changed between comparisons. These are ratios of sample medians, not averages of per-pair ratios. [Raw samples and source hashes](guard-taint-overhead-results.json) accompany this report.
| Reference | Workload | Reference median (ms) | Optimized median (ms) | Change |
| ------------------- | ---------- | --------------------: | --------------------: | ------: |
| Pre-feature f3fa67b | arguments | 926.98 | 916.33 | -1.15% |
| Pre-feature f3fa67b | transitive | 1319.92 | 1370.44 | 3.83% |
| Pre-feature f3fa67b | direct | 548.73 | 540.19 | -1.56% |
| Original PR 6ee7ce1 | arguments | 801.44 | 700.84 | -12.55% |
| Original PR 6ee7ce1 | transitive | 1167.84 | 953.23 | -18.38% |
Every consecutive five-pair block also meets the target against the pre-feature reference:
| Workload | Pairs 1–5 | Pairs 6–10 | Pairs 11–15 |
| ---------- | --------: | ---------: | ----------: |
| arguments | 1.39% | -0.07% | -0.34% |
| transitive | 0.62% | -2.35% | -0.95% |
| direct | 0.17% | 0.74% | -2.62% |
The integrated savings against the original PR are 100.59 ms on argument forwarding and 214.61 ms on transitive aliases. The final confirmation includes the malformed-AST correction discovered during review. Earlier five-pair selection batches varied enough to cross the target; 15 pairs were specified before final confirmation. All inputs, timing boundaries, correctness gates and thresholds stayed fixed. This establishes the target for the measured workloads and run, not a statistical guarantee for every host or source program.
## What changed
Only the TypeScript/JavaScript fact harvester changes at runtime. It now:
- Reuses known parent and parameter scopes during declaration scanning.
- Records nearest scopes for identifiers, avoiding native ID reads and map entries for structural nodes.
- Reuses adjacent resolution results and captured call-argument bindings while still recording ordinary call-frame occurrences.
- Reads native child counts and source positions once where the AST is immutable.
- Skips well-formed string/regex internals. Malformed literals retain full traversal; template substitutions, qualified identifiers and unsafe-name checks remain covered.
- Constructs each statement fact record once and allocates predicate-site maps only when predicates can be recognized.
There is no new dependency, solver, persistent cache or wire format. Declaration order, shadowing, synthetic binding identity, call frames, shape/proof limits and conservative fallback behavior remain unchanged.
## Reproduce the measurements
The portable harness is [compare-guard-overhead.mjs](../../gitnexus/bench/cfg/compare-guard-overhead.mjs). Both source checkouts need dependencies installed. From the optimized checkout's gitnexus package:
```bash
node --expose-gc --import tsx bench/cfg/compare-guard-overhead.mjs \
--baseline /path/to/reference/gitnexus \
--workload arguments --pairs 15 --check --out result.json
```
Repeat with transitive and direct. Use CPU affinity on a suitable quiet core when available. The measured run pinned both versions to CPU 23. Reference revisions:
- Pre-feature target: f3fa67b1445b8e8eeb23ae253fd3110044dc3624.
- Original PR implementation: 6ee7ce1cc463a8ad55cbdf2aea8325e0eb0396e0.
- Integration head: 09d2ebdb1f6d1571fded04ea917fb1935c4787e1. The main merge introduced no CFG/taint changes.
- Final harvester SHA-256: 19b0ca87758a18009b1f740ed4a22c81ccbc12ee756ba53f2d7fc823090e78ff.
Each workload contains 4,000 functions. Parsing occurs once, outside collection timing. Three warmup repetitions precede alternating reference/candidate samples, with explicit GC before each sample. Timed work is collectFunctionCfgs through the production visitor. Serialization and correctness checks occur outside timing; prior CFG outputs are not deliberately retained while timing the next sample. Every final sample exceeded 100 ms.
The harness always requires exact legacy CFG equality, all 4,000 functions, and all 4,000 helper argument effects with the exact prefix/input/suffix composition and ordinary-shell contract. Its --check additionally requires at least five pairs, >=100 ms samples and <=5% median overhead. An empty-output probe of the original harness failed all correctness gates. No CI wiring or existing benchmark allowance changes are part of this optimization.
Function bodies use unique function and consumer names:
```ts
// arguments: selection workload
function f(x: string) {
const y = 'prefix ' + x;
return consume(y);
}
// transitive: separate confirmation workload
function f(x: string) {
const a = 'prefix ' + x;
const b = a + ' suffix';
return consume(b);
}
// direct: additional guard-free check
function f(x: string) {
return consume('prefix ' + x);
}
```
## Correctness and validation
- Ad hoc full-output comparison against the original PR: 633 fixture files plus 30 explicit edge cases, **1,140 CFGs with zero differences**, including refinement data and skip counts. Corpus output SHA-256: 0d663493b85105b8cd3f4eeb15dad0061473aef785a342344c6db1f511385cd0.
- A separate **2,520 malformed-input mutation corpus** produced 1,651 CFGs with zero differences. Forty malformed literal nodes exercised parser recovery.
- Independent adversarial review identified a possible literal-recovery omission. Real parser reproductions made two new tests fail; requiring !node.hasError fixed both. Seven new cases cover recovery, repeated arguments, capture caps and nested declaration scope fallback. All **177 CFG/refinement tests** pass.
- Corrected-source default unit project: **900 files, 20,696 passed, 47 skipped, zero failures**. Corrected-source CFG integration: **13 files, 141 passed**.
- Corrected-source CLI build, source and test typechecks pass. Changed-file ESLint reports zero errors; warning-level diagnostics remain.
- Corrected-source persisted tool accuracy: **32/32 fixed answers pass** with the existing oracle.
- Corrected-source CFG benchmark: **15/15 scenarios pass**, including existing time, serialized-size, retained-heap, fingerprint, reaching-definition and taint budgets.
- Graph analysis: the optimization changes six files and 77 symbols across three observed processes (**MEDIUM** risk). The entire PR against merge base 11a49a15c6befbe4d5de1cebe74b5456aeb44b99 changes 42 files and 399 symbols across 16 observed processes (**CRITICAL**, covered by the prior shared-taint approval). Neither result carries a partial/truncated flag. Process counts are bounded graph observations; this refreshed index does not include the PDG layer.
- Four-shard full suite: **1,338 files, 28,267 passed, 88 skipped, zero failures**.
The full-suite snapshot was frozen before the final malformed-literal correction. It covers all other optimized code at the integration head. The corrected source was separately rebuilt and checked by the unit, CFG integration, differential, scaling and accuracy runs above.
The focused review combined a local correctness read with an independent Claude CLI adversarial read. The peer receipt verifies claude-opus-5-5; requested effort was high and actual effort is unverified. The peer reviewed the runtime optimization before the correction; local follow-up verified the fix, added tests and portable harness. The public harness reproduces timing and synthetic correctness checks; the broader ad hoc corpus comparisons are local validation evidence, not a new CI gate.
## Limits and remaining costs
The <=5% target concerns collection time on these guard-free workloads. It is not an end-to-end latency or peak-memory guarantee. Existing guarded, capped, time, payload and retained-heap scaling budgets remain unchanged. This run makes no peak-heap improvement claim.
Serialized size is identical to the original PR's normalized proof facts. Argument forwarding remains 5,118,249 bytes versus 4,030,249 before the feature (**+27.00%**); transitive aliases remain +37.40%, direct arguments +23.69%. Removing the value facts loses forwarding precision. A compact wire format is a separate tradeoff and was not introduced here.
The initial profile attributed substantial cost to declaration scanning, name resolution and duplicate value traversal. The retained changes target that work. No fresh profile establishes the remaining bottleneck; broader parser rewrites or a new cache framework are not justified by these results.
## Experiment accounting
Twelve bounded experiments ran serially: seven component experiments remain in the final implementation and five were rejected. There were no deferred dependency requests, unselected parallel candidates, censored experiments, runner errors or timeouts. A malformed-input correctness fix followed selection; it was not a new optimization experiment.
The table preserves the original component forecasts and contemporaneous five-pair results. Forecasts estimated savings against the original or preceding candidate; measurements compared the accumulated candidate against the pre-feature revision. **Those references differ, so the results do not validate individual forecasts.** They also show the timing variance that motivated final 15-pair confirmation. Do not add component percentages; only the original-to-final paired comparisons establish the integrated gain.
| Retained component | Forecast savings | Accumulated candidate vs pre-feature during selection |
| ------------------------------------ | ---------------: | ----------------------------------------------------: |
| Parameter scope reuse | 4–8 pp | 575.61 → 551.19 ms (-4.24%) |
| Identifier-only scope cache | 5–12 pp | 487.28 → 520.21 ms (6.76%) |
| Reuse call argument reads | 3–6 pp | 544.05 → 569.75 ms (4.72%) |
| Reuse native loop bounds/positions | 2–6 pp | 479.96 → 507.24 ms (5.68%) |
| Reuse last resolved name | 2–5 pp | 574.21 → 581.69 ms (1.30%) |
| Skip well-formed literal internals | 2–6 pp | 516.22 → 533.60 ms (3.37%) |
| Reuse scope parent; build facts once | 3–7 pp | 554.10 → 496.07 ms (-10.47%) |
Early leaf shortcuts, bulk child enumeration, a per-node resolution map and standalone allocation changes did not show a reliable win. A generic identifier-leaf shortcut was also rejected because qualified identifier nodes have children. The final literal shortcut explicitly preserves parser recovery.
Stopping reason: the corrected final candidate meets the target and all synthetic correctness gates, including the original-to-final holdout. Optimization began at 10:35 UTC and corrected performance confirmation finished at 11:46 UTC (about 71 minutes); validation and publication continued afterward. No paid judge was used for timing. Compute and review spend were not metered, so whole-run cost is unrecorded. The local audit ledger and specification remain under .context/compound-engineering/ce-optimize/guard-taint-overhead/; this tracked report, raw results and portable harness are the shared evidence.

View file

@ -0,0 +1,380 @@
{
"measured_at": "2026-10-24T11:46:01.733Z",
"references": {
"target": "f3fa67b1445b8e8eeb23ae253fd3110044dc3624",
"original": "6ee7ce1cc463a8ad55cbdf2aea8325e0eb0396e0",
"integration": "09d2ebdb1f6d1571fded04ea917fb1935c4787e1"
},
"candidate_source_sha256": "19b0ca87758a18009b1f740ed4a22c81ccbc12ee756ba53f2d7fc823090e78ff",
"environment": {
"node": "v22.23.3",
"v8": "12.4.254.21-node.57",
"platform": "linux",
"arch": "x64",
"cpu": "12th Gen Intel(R) Core(TM) i9-12900K",
"affinity_cpu": 23,
"shared_host": true
},
"protocol": {
"functions": 4000,
"warmup_repetitions": 3,
"pairs": 15,
"sample_order": "baseline/candidate, then candidate/baseline, alternating",
"parse": "once outside timing",
"gc": "before each timed sample",
"metric": "ratio of candidate and baseline sample medians minus one",
"serialization": "outside timing",
"target_percent": 5,
"correctness_gates": ["legacy_equal", "functions_complete", "forwarding_complete"],
"heap_diagnostic": "after-collection heap growth; not peak heap"
},
"comparisons": [
{
"baseline": "target",
"kind": "selection",
"iteration": 12,
"final": true,
"post_review_fix": true,
"exit_code": 0,
"blocks": [
{
"medians_ms": [926.980541, 939.893447],
"time_overhead_pct": 1.393007234657806
},
{
"medians_ms": [874.998572, 874.380178],
"time_overhead_pct": -0.07067371533949762
},
{
"medians_ms": [936.643365, 933.451269],
"time_overhead_pct": -0.3408016454587326
}
],
"workload": "arguments",
"pairs": 15,
"time_overhead_pct": -1.1486265923677075,
"serialized_overhead_pct": 26.995850628583984,
"legacy_equal": 1,
"functions_complete": 1,
"forwarding_complete": 1,
"samples_ms": [
[
991.636407, 926.980541, 1052.223997, 874.353997, 814.005086, 846.39417, 939.310542,
968.675534, 850.55565, 874.998572, 924.263215, 816.897822, 936.643365, 960.946203,
952.875645
],
[
939.893447, 835.342256, 1062.366743, 980.586552, 785.881103, 819.862887, 1108.397972,
916.332996, 874.380178, 863.18481, 874.130014, 873.202115, 1027.155567, 1014.473391,
933.451269
]
],
"medians_ms": [926.980541, 916.332996],
"bytes": [4030249, 5118249],
"heap_growth_medians": [22076336, 21050152],
"base_hash": "0ceb296402cd3a89e3d9141152bd56d9584a50634fe2b718cabb5ee0e9aeab15",
"source_hashes": [
"eb5449241b235c26ed92d6f629a89868d5b3612c47095f96d3eb20b474cb85aa",
"19b0ca87758a18009b1f740ed4a22c81ccbc12ee756ba53f2d7fc823090e78ff"
]
},
{
"baseline": "target",
"kind": "holdout",
"iteration": 12,
"final": true,
"post_review_fix": true,
"exit_code": 0,
"blocks": [
{
"medians_ms": [1282.768951, 1290.729238],
"time_overhead_pct": 0.6205550106115565
},
{
"medians_ms": [1417.752374, 1384.478543],
"time_overhead_pct": -2.3469423582146653
},
{
"medians_ms": [1383.63807, 1370.441898],
"time_overhead_pct": -0.9537300458927089
}
],
"workload": "transitive",
"pairs": 15,
"time_overhead_pct": 3.8279811568370237,
"serialized_overhead_pct": 37.40160977949929,
"legacy_equal": 1,
"functions_complete": 1,
"forwarding_complete": 1,
"samples_ms": [
[
1319.915771, 1288.475793, 1236.561698, 1282.768951, 1255.029194, 1430.325594, 1478.997302,
1351.424832, 1317.151013, 1417.752374, 1394.826591, 1299.54403, 1287.726552, 1383.63807,
1483.755553
],
[
1290.729238, 1222.891716, 1303.250259, 1270.870905, 1489.054094, 1433.201519, 1567.401177,
1384.478543, 1383.860575, 1334.753843, 1386.789066, 1346.508798, 1370.441898, 1413.529929,
1272.542746
]
],
"medians_ms": [1319.915771, 1370.441898],
"bytes": [4502480, 6186480],
"heap_growth_medians": [30367416, 33670488],
"base_hash": "e9e120d2c163949405ae52dc5cd4d08a8d0c8647302f3fb1fafd3b431ab76930",
"source_hashes": [
"eb5449241b235c26ed92d6f629a89868d5b3612c47095f96d3eb20b474cb85aa",
"19b0ca87758a18009b1f740ed4a22c81ccbc12ee756ba53f2d7fc823090e78ff"
]
},
{
"baseline": "target",
"kind": "selection",
"iteration": 12,
"final": true,
"post_review_fix": true,
"exit_code": 0,
"blocks": [
{
"medians_ms": [546.287538, 547.190939],
"time_overhead_pct": 0.16537096989386324
},
{
"medians_ms": [548.729001, 552.784326],
"time_overhead_pct": 0.7390396703307989
},
{
"medians_ms": [554.717647, 540.177424],
"time_overhead_pct": -2.6211935168523826
}
],
"workload": "direct",
"pairs": 15,
"time_overhead_pct": -1.5555201172974042,
"serialized_overhead_pct": 23.685278087842153,
"legacy_equal": 1,
"functions_complete": 1,
"forwarding_complete": 1,
"samples_ms": [
[
585.832439, 604.653849, 508.324025, 528.45936, 546.287538, 590.143343, 542.103699,
506.672531, 548.729001, 653.093038, 566.802297, 554.717647, 556.273056, 510.102922,
519.793483
],
[
576.02371, 637.452543, 547.190939, 530.045111, 524.659083, 539.414348, 572.697724,
511.161353, 552.784326, 558.685312, 532.658077, 501.323318, 540.193411, 560.527328,
540.177424
]
],
"medians_ms": [548.729001, 540.193411],
"bytes": [3174968, 3926968],
"heap_growth_medians": [11372208, 18987256],
"base_hash": "e32fa1876c674740fa2d85a0e1b9bc263799c704f4d537694427da2dd197f264",
"source_hashes": [
"eb5449241b235c26ed92d6f629a89868d5b3612c47095f96d3eb20b474cb85aa",
"19b0ca87758a18009b1f740ed4a22c81ccbc12ee756ba53f2d7fc823090e78ff"
]
},
{
"baseline": "original",
"kind": "selection",
"iteration": 12,
"final": true,
"post_review_fix": true,
"exit_code": 0,
"blocks": [
{
"medians_ms": [839.997564, 715.49347],
"time_overhead_pct": -14.821958936061863
},
{
"medians_ms": [800.101563, 720.123923],
"time_overhead_pct": -9.995935978442782
},
{
"medians_ms": [801.435984, 675.281283],
"time_overhead_pct": -15.741082696381635
}
],
"workload": "arguments",
"pairs": 15,
"time_overhead_pct": -12.551510290059543,
"serialized_overhead_pct": 0,
"legacy_equal": 1,
"functions_complete": 1,
"forwarding_complete": 1,
"samples_ms": [
[
839.997564, 738.463058, 857.906524, 842.421851, 799.280266, 960.616255, 902.391745,
782.451825, 800.101563, 792.380063, 801.435984, 770.546193, 862.753015, 757.292758,
802.344464
],
[
659.721701, 715.49347, 700.843664, 742.920449, 842.665567, 751.378898, 864.312785,
694.810253, 682.495461, 720.123923, 635.26501, 668.832835, 705.642196, 687.253768,
675.281283
]
],
"medians_ms": [801.435984, 700.843664],
"bytes": [5118249, 5118249],
"heap_growth_medians": [30844760, 20766944],
"base_hash": "0ceb296402cd3a89e3d9141152bd56d9584a50634fe2b718cabb5ee0e9aeab15",
"source_hashes": [
"3b2a2bccf8254b61fd0ab1b8ab9423f89b29073afc402ad078189a31d67e0748",
"19b0ca87758a18009b1f740ed4a22c81ccbc12ee756ba53f2d7fc823090e78ff"
]
},
{
"baseline": "original",
"kind": "holdout",
"iteration": 12,
"final": true,
"post_review_fix": true,
"exit_code": 0,
"blocks": [
{
"medians_ms": [1022.240662, 943.977474],
"time_overhead_pct": -7.656043328082762
},
{
"medians_ms": [1074.679531, 1071.170551],
"time_overhead_pct": -0.3265140815266965
},
{
"medians_ms": [1214.845854, 953.230029],
"time_overhead_pct": -21.53489878066456
}
],
"workload": "transitive",
"pairs": 15,
"time_overhead_pct": -18.376410863434145,
"serialized_overhead_pct": 0,
"legacy_equal": 1,
"functions_complete": 1,
"forwarding_complete": 1,
"samples_ms": [
[
991.262411, 1022.240662, 954.091735, 1211.352262, 1328.408798, 994.117618, 1074.679531,
1113.739816, 1417.601299, 1056.288179, 1204.450932, 1214.845854, 1325.823707, 1225.401996,
1167.836454
],
[
855.369854, 899.467318, 944.243232, 1123.037271, 943.977474, 944.358269, 1071.170551,
1109.569003, 1180.041345, 1034.802502, 904.834811, 910.174455, 1218.088988, 1315.290326,
953.230029
]
],
"medians_ms": [1167.836454, 953.230029],
"bytes": [6186480, 6186480],
"heap_growth_medians": [22416784, 33734328],
"base_hash": "e9e120d2c163949405ae52dc5cd4d08a8d0c8647302f3fb1fafd3b431ab76930",
"source_hashes": [
"3b2a2bccf8254b61fd0ab1b8ab9423f89b29073afc402ad078189a31d67e0748",
"19b0ca87758a18009b1f740ed4a22c81ccbc12ee756ba53f2d7fc823090e78ff"
]
}
],
"validation": {
"corrected_source_sha256": "19b0ca87758a18009b1f740ed4a22c81ccbc12ee756ba53f2d7fc823090e78ff",
"default_units": {
"files": 900,
"passed": 20696,
"skipped": 47,
"failed": 0
},
"cfg_integration": {
"files": 13,
"passed": 141,
"failed": 0
},
"cfg_scaling": {
"scenarios": 15,
"passed": 15
},
"tool_accuracy": {
"checks": 32,
"passed": 32
},
"build": true,
"source_typecheck": true,
"test_typecheck": true,
"changed_file_lint_errors": 0,
"fixture_equivalence": {
"files": 633,
"custom_cases": 30,
"cfgs": 1140,
"mismatches": 0,
"sha256": "0d663493b85105b8cd3f4eeb15dad0061473aef785a342344c6db1f511385cd0"
},
"malformed_equivalence": {
"cases": 2520,
"cfgs": 1651,
"malformed_literal_nodes": 40,
"mismatches": 0
},
"graph": {
"base": "11a49a15c6befbe4d5de1cebe74b5456aeb44b99",
"optimization": {
"changed_count": 77,
"affected_count": 3,
"changed_files": 6,
"risk_level": "medium"
},
"whole_pr": {
"changed_count": 399,
"affected_count": 16,
"changed_files": 42,
"risk_level": "critical"
},
"partial_flag": false,
"truncated_flag": false,
"pdg_layer": false
},
"full_suite": {
"source_sha256": "37624680cb858fddfab7f7dcd167606b62f91b89cca7694091e5ac827bf7279c",
"revision_note": "Frozen candidate before !node.hasError correction. Corrected source separately validated as detailed above.",
"shards": [
{
"shard": 1,
"files": 334,
"passed": 7835,
"skipped": 10,
"failed": 0,
"total": 7845
},
{
"shard": 2,
"files": 335,
"passed": 7030,
"skipped": 8,
"failed": 0,
"total": 7038
},
{
"shard": 3,
"files": 335,
"passed": 6410,
"skipped": 14,
"failed": 0,
"total": 6424
},
{
"shard": 4,
"files": 334,
"passed": 6992,
"skipped": 56,
"failed": 0,
"total": 7048
}
],
"totals": {
"files": 1338,
"passed": 28267,
"skipped": 88,
"failed": 0,
"total": 28355
}
}
}
}

View file

@ -1,4 +1,14 @@
{
"guard-free-arguments": {
"fingerprint": "819f475c96ced034846e1c7d8aa08613386b258f6df276a1e4058944fffa9231",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"facts_large_min": 8000,
"disk_bytes_large_max": 6000000,
"_note": "#3703 review: 1000/4000 guard-free functions with const composition and argument forwarding exercise value-fact capture even without predicates. Initial normalized time/disk/heap/def-use ratios: 0.878/1.012/0.999/0.834; large payload 5,306,959 bytes. A separate five-pair comparison at the reviewed head versus the merge base measured +11.26% collection time and +27.0% serialized bytes on the same argument-heavy shape (different file/spacing). This exceeds the plan's proposed 5% guard-free target; the earlier ~1% samples do not cover this capture path. Keep the cost explicit; this gate checks scaling and payload growth, not a validated <=5% overhead claim. Existing scenario budgets remain unchanged."
},
"straight-line": {
"fingerprint": "792229965a726d2c6b527f9ee65440a2b3023839ee71cb51522fc30e2f2cb454",
"scaling_budget": 1.5,
@ -38,25 +48,25 @@
"disk_bytes_budget": 1.2,
"rd_scaling_budget": 2.0,
"facts_large_min": 150,
"_note": "#2201: N nested loops carrying ONE variable end-to-end (depth 40->160) -- the pathology the dense worklist is superlinear on and whose block-visit total drives it past the blocks×64 ceiling (it would TRUNCATE to empty). rd is measured under the PRODUCTION blocks×64 budget (rdProductionBudget) to prove the ceiling stops firing: the depth-INDEPENDENT SSA solver (phi-nodes capture loop merges statically; no fixpoint iteration) computes the full facts (measured 164 at large) with rd_scaling ~0.68 (linear in depth; measured ~0.57ms at depth 160). facts_large_min tightened 100->150 (#2201 review R7): a partial-truncation regression that still cleared the old floor of 100 (but lost facts of the measured 164) now fails, with ~9% headroom under 164 for noise; the companion rd_all_computed gate also catches any non-'computed' status. rd_scaling_budget 2.0 catches a regression back to superlinear. No heap_budget -- the deep-nest CFG payload is tiny and the retained-heap delta is GC-noise-dominated. Re-baseline the fingerprint only on an intentional CFG/visitor change. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged."
"_note": "#2201: N nested loops carrying ONE variable end-to-end (depth 40->160) -- the pathology the dense worklist is superlinear on and whose block-visit total drives it past the blocks\u00d764 ceiling (it would TRUNCATE to empty). rd is measured under the PRODUCTION blocks\u00d764 budget (rdProductionBudget) to prove the ceiling stops firing: the depth-INDEPENDENT SSA solver (phi-nodes capture loop merges statically; no fixpoint iteration) computes the full facts (measured 164 at large) with rd_scaling ~0.68 (linear in depth; measured ~0.57ms at depth 160). facts_large_min tightened 100->150 (#2201 review R7): a partial-truncation regression that still cleared the old floor of 100 (but lost facts of the measured 164) now fails, with ~9% headroom under 164 for noise; the companion rd_all_computed gate also catches any non-'computed' status. rd_scaling_budget 2.0 catches a regression back to superlinear. No heap_budget -- the deep-nest CFG payload is tiny and the retained-heap delta is GC-noise-dominated. Re-baseline the fingerprint only on an intentional CFG/visitor change. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged."
},
"wide-merge": {
"fingerprint": "4135a740376068c3f2dedc4751c6588cbe9ae36026ddc9f2ae9112b188980cda",
"fingerprint": "1b724ea967c2805d643cea6511628e8d248f47431e1db55591a23da3e2808df1",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"facts_large_min": 24000,
"_note": "#2201 review R7: N bindings, EACH assigned in a 3-way branch (a wide multi-operand phi per binding) inside a loop, then all used after the merge. Distinct from dense-bindings (one CHAINED redef per `if`): every binding fans into its OWN wide phi, so this exercises phi-placement + renaming + the reachByScc condensation across MANY independent wide merges. N bindings x constant arms => O(N) facts (measured 26008 at the large size), so the gate is rd_scaling LINEARITY: measured ~1.07 (time 9.3->39.8ms over the 4x size step); budget 2.0 catches a regression to the per-binding-rescan O(N^2) class -- the recurring solver antipattern the reachByScc alias fast path (review R2) guards against. rd is measured under the PRODUCTION blocks×64 budget (rdProductionBudget): all functions report 'computed' (the SSA path does not truncate here), and facts_large_min 24000 (measured 26008, ~7% headroom) + the rd_all_computed gate assert the wide merges compute fully. fp_blocks 82 / fp_edges 112 at FP_SIZE=15. Re-baseline the fingerprint only on an intentional CFG/harvest-shape change. #2227 U1 re-fingerprinted: SiteRecord.at call-site anchor [line,col] joins the statement-facts canon (resolved-callee-id stack); CFG topology unchanged. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged."
"_note": "#2201 review R7: N bindings, EACH assigned in a 3-way branch (a wide multi-operand phi per binding) inside a loop, then all used after the merge. Distinct from dense-bindings (one CHAINED redef per `if`): every binding fans into its OWN wide phi, so this exercises phi-placement + renaming + the reachByScc condensation across MANY independent wide merges. N bindings x constant arms => O(N) facts (measured 26008 at the large size), so the gate is rd_scaling LINEARITY: measured ~1.07 (time 9.3->39.8ms over the 4x size step); budget 2.0 catches a regression to the per-binding-rescan O(N^2) class -- the recurring solver antipattern the reachByScc alias fast path (review R2) guards against. rd is measured under the PRODUCTION blocks\u00d764 budget (rdProductionBudget): all functions report 'computed' (the SSA path does not truncate here), and facts_large_min 24000 (measured 26008, ~7% headroom) + the rd_all_computed gate assert the wide merges compute fully. fp_blocks 82 / fp_edges 112 at FP_SIZE=15. Re-baseline the fingerprint only on an intentional CFG/harvest-shape change. #2227 U1 re-fingerprinted: SiteRecord.at call-site anchor [line,col] joins the statement-facts canon (resolved-callee-id stack); CFG topology unchanged. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged. #3490: additive normalized argument-value facts; CFG topology unchanged; budgets retained."
},
"fact-fanout": {
"fingerprint": "57fa834df795d8dba99947cdfba2bb86b3f9111d8bf86d38b77c155273ae5c93",
"fingerprint": "f06409f4c6b3f9ef8caeb04d702ffa3ca3b387fba1a86aaab0419b9695e78de8",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 3.0,
"facts_large_max": 16000,
"_note": "#2082 M2 / #2083 M3 U1: N switch-arm defs of one variable + N later uses -- facts are O(defs x uses) BY SPEC, so the gate is BOUNDEDNESS, not linearity: with the production fact limit engaged (DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION=16000) the materialized fact count stays pinned at the limit as N grows (facts_large_max), and rd time stays bounded (measured ~1.4). Losing the maxFacts early-stop shows as facts_large exploding quadratically. M3 U1 re-fingerprinted (u{i}(x) call sites); disk_large 996737->1107627 (+11.1%). #2227 U1 re-fingerprinted: SiteRecord.at call-site anchor [line,col] joins the statement-facts canon (resolved-callee-id stack); CFG topology unchanged. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged."
"_note": "#2082 M2 / #2083 M3 U1: N switch-arm defs of one variable + N later uses -- facts are O(defs x uses) BY SPEC, so the gate is BOUNDEDNESS, not linearity: with the production fact limit engaged (DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION=16000) the materialized fact count stays pinned at the limit as N grows (facts_large_max), and rd time stays bounded (measured ~1.4). Losing the maxFacts early-stop shows as facts_large exploding quadratically. M3 U1 re-fingerprinted (u{i}(x) call sites); disk_large 996737->1107627 (+11.1%). #2227 U1 re-fingerprinted: SiteRecord.at call-site anchor [line,col] joins the statement-facts canon (resolved-callee-id stack); CFG topology unchanged. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged. #3490: additive normalized argument-value facts; CFG topology unchanged; budgets retained."
},
"taint-dense": {
"fingerprint": "f4570c7ae8fde4b7e63b51b4ea641417f911b4267f1f9f2eafadc520020dbf15",
@ -78,5 +88,45 @@
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#2195 U7: the first NON-TS scaling scenario -- the C-family analogue of `branchy`, driven through the Go grammar + Go CFG visitor (lang:'go'). ONE Go function with N sequential `if`s (block/edge growth in a single CFG). The `go:` key namespace keeps it out of the TS baseline keyspace (no collision/re-baseline of a TS scenario). Measured time ~1.08, disk ~1.03, heap ~1.0, rd ~1.06 (budgets mirror the TS `branchy` scenario: scaling 1.8 absorbs single-CFG noise + catches a ~4.0 quadratic). Cross-check: fp_blocks 32 / fp_edges 46 are IDENTICAL to the TS branchy fingerprint shape -- the Go visitor builds the same per-`if` block/edge topology. CFG-only (Go has no registered taint model), so no taint gates. Re-baseline the fingerprint only on an intentional Go CFG/harvest-shape change. #2227 U1 re-fingerprinted: SiteRecord.at call-site anchor [line,col] joins the statement-facts canon (resolved-callee-id stack); Go CFG topology unchanged."
},
"guarded-commands": {
"fingerprint": "02fe82d392ab2e6d842a47a4d9b982cf9a99e9a6199f15e9c09185b715186727",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#3490: bounded guard/value refinement. Production analysis asserts expected findings, forward effects, fallback at caps, and JSON replay equivalence; normalized 4x-size ratios guard linear scaling."
},
"guard-bypass": {
"fingerprint": "8a47f79c6d8a6fcb3a47cc05dc79936a7f4daf8b0e8947a852522a4def619225",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#3490: bounded guard/value refinement. Production analysis asserts expected findings, forward effects, fallback at caps, and JSON replay equivalence; normalized 4x-size ratios guard linear scaling."
},
"guard-forwarders": {
"fingerprint": "5121f44d067c58c6b4e40df6306fd237c0ea4edf9f860e8cb4d666aa5502e773",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#3490: bounded guard/value refinement. Production analysis asserts expected findings, forward effects, fallback at caps, and JSON replay equivalence; normalized 4x-size ratios guard linear scaling."
},
"guard-capped": {
"fingerprint": "c7694d17d006ff15db094d588726bbbb9776de1181d3bf0f1e769d876a423de9",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#3490: bounded guard/value refinement. Production analysis asserts expected findings, forward effects, fallback at caps, and JSON replay equivalence; normalized 4x-size ratios guard linear scaling."
},
"guard-discriminants": {
"fingerprint": "2d04158a9c8eae54f567ffad7f585b5e84b059da2386756e9b703ac8c4e3a0f1",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#3490: bounded guard/value refinement. Production analysis asserts expected findings, forward effects, fallback at caps, and JSON replay equivalence; normalized 4x-size ratios guard linear scaling."
}
}

View file

@ -0,0 +1,154 @@
/**
* Compare guard-free CFG collection with another source checkout.
* Run with --expose-gc --import tsx; both checkouts need installed dependencies.
* Parsing, serialization, and correctness checks stay outside the timed region.
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { parseArgs } from 'node:util';
const { values } = parseArgs({
options: {
baseline: { type: 'string' },
candidate: { type: 'string' },
workload: { type: 'string', default: 'arguments' },
pairs: { type: 'string', default: '15' },
out: { type: 'string' },
check: { type: 'boolean', default: false },
help: { type: 'boolean', default: false },
},
});
if (values.help) {
console.log(`Usage: node --expose-gc --import tsx bench/cfg/compare-guard-overhead.mjs
--baseline /path/to/reference/gitnexus [--candidate /path/to/current/gitnexus]
[--workload arguments|transitive|direct] [--pairs 15] [--out result.json] [--check]
--check requires at least five pairs, samples >=100ms, and <=5% median overhead.
Exact legacy facts, function coverage, and value forwarding are always checked.`);
process.exit(0);
}
if (!values.baseline || !global.gc) throw new Error('Supply --baseline and run with --expose-gc');
const pairs = Number(values.pairs);
if (!Number.isSafeInteger(pairs) || pairs < 1)
throw new Error('--pairs must be a positive integer');
if (!['arguments', 'transitive', 'direct'].includes(values.workload)) {
throw new Error('Unknown workload');
}
const candidate = path.resolve(
values.candidate ?? path.join(path.dirname(fileURLToPath(import.meta.url)), '../..'),
);
const baseline = path.resolve(values.baseline);
const require = createRequire(path.join(candidate, 'package.json'));
const Parser = require('tree-sitter');
const grammar = require('tree-sitter-typescript').typescript;
const load = (root, relative) =>
import(pathToFileURL(path.join(root, 'src/core/ingestion', relative)).href);
const versions = [];
for (const root of [baseline, candidate]) {
const { collectFunctionCfgs } = await load(root, 'cfg/collect.ts');
const { createTypeScriptCfgVisitor } = await load(root, 'cfg/visitors/typescript.ts');
versions.push({ collect: collectFunctionCfgs, visitor: createTypeScriptCfgVisitor() });
}
const count = 4000;
const source = Array.from({ length: count }, (_, i) => {
if (values.workload === 'arguments') {
return `function f${i}(x:string){ const y="prefix "+x; return consume${i}(y); }`;
}
if (values.workload === 'direct') {
return `function f${i}(x:string){ return consume${i}("prefix "+x); }`;
}
return `function f${i}(x:string){ const a="prefix "+x; const b=a+" suffix"; return consume${i}(b); }`;
}).join('');
const parser = new Parser();
parser.setLanguage(grammar);
const tree = parser.parse(source, undefined, { bufferSize: 1024 * 1024 });
const collect = (version) => version.collect(tree.rootNode, version.visitor, 'perf.ts', 0).cfgs;
for (let rep = 0; rep < 3; rep++) for (const version of versions) collect(version);
const median = (numbers) => [...numbers].sort((a, b) => a - b)[Math.floor(numbers.length / 2)];
const samples = [[], []];
const heaps = [[], []];
const bytes = [];
for (let rep = 0; rep < pairs; rep++) {
for (const index of rep % 2 ? [1, 0] : [0, 1]) {
global.gc();
const before = process.memoryUsage().heapUsed;
const start = process.hrtime.bigint();
const cfgs = collect(versions[index]);
samples[index].push(Number(process.hrtime.bigint() - start) / 1e6);
heaps[index].push(process.memoryUsage().heapUsed - before);
bytes[index] = Buffer.byteLength(JSON.stringify(cfgs));
}
}
// Gather outputs separately; do not retain previous CFG arrays during timing.
const outputs = versions.map(collect);
const legacy = (cfgs) =>
JSON.stringify(cfgs, (key, value) =>
['refinement', 'refinementSafe', 'primitiveBindings'].includes(key) ? undefined : value,
);
const hash = (value) => crypto.createHash('sha256').update(value).digest('hex');
const { createFunctionRefinement } = await load(candidate, 'taint/guard-refinement.ts');
const expectedParts = [
{ kind: 'literal', value: 'prefix ' },
{ kind: 'input' },
...(values.workload === 'transitive' ? [{ kind: 'literal', value: ' suffix' }] : []),
];
const flatten = (effect) => (effect?.kind === 'concat' ? effect.parts.flatMap(flatten) : [effect]);
let effects = 0;
for (const cfg of outputs[1]) {
const refinement = createFunctionRefinement(cfg);
for (const block of cfg.blocks) {
for (
let statementIndex = 0;
statementIndex < (block.statements?.length ?? 0);
statementIndex++
) {
const statement = block.statements[statementIndex];
for (let site = 0; site < (statement.sites?.length ?? 0); site++) {
if (!statement.sites[site].callee?.startsWith('consume')) continue;
const effect = refinement.argument(block.index, statementIndex, site, 0, 0);
if (
effect?.kind === 'concat' &&
JSON.stringify(flatten(effect)) === JSON.stringify(expectedParts) &&
refinement.defaultShell(block.index, statementIndex, site)
)
effects++;
}
}
}
}
const medians = samples.map(median);
const result = {
workload: values.workload,
pairs,
time_overhead_pct: (medians[1] / medians[0] - 1) * 100,
serialized_overhead_pct: (bytes[1] / bytes[0] - 1) * 100,
legacy_equal: hash(legacy(outputs[0])) === hash(legacy(outputs[1])) ? 1 : 0,
functions_complete: outputs[1].length === count ? 1 : 0,
forwarding_complete: effects === count ? 1 : 0,
samples_ms: samples,
medians_ms: medians,
bytes,
// An after-collection heap delta is diagnostic; it is not peak heap.
heap_growth_medians: heaps.map(median),
base_hash: hash(legacy(outputs[0])),
source_hashes: [baseline, candidate].map((root) =>
hash(fs.readFileSync(path.join(root, 'src/core/ingestion/cfg/visitors/typescript-harvest.ts'))),
),
};
const json = JSON.stringify(result, null, 2) + '\n';
if (values.out) fs.writeFileSync(values.out, json);
console.log(json);
if (!result.legacy_equal || !result.functions_complete || !result.forwarding_complete) {
process.exitCode = 1;
}
if (
values.check &&
(pairs < 5 || samples.flat().some((ms) => ms < 100) || result.time_overhead_pct > 5)
) {
process.exitCode = 1;
}

View file

@ -59,6 +59,8 @@ import {
DEFAULT_PDG_MAX_TAINT_HOPS,
} from '../../src/core/ingestion/taint/propagate.ts';
import { encodeTaintPath } from '../../src/core/ingestion/taint/path-codec.ts';
import { createMemoizedRefinement } from '../../src/core/ingestion/taint/guard-refinement.ts';
import { harvestFunctionSummary } from '../../src/core/ingestion/taint/summary-harvest.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
@ -114,6 +116,83 @@ function langToolkit(langId) {
// ---- synthetic generators (one cost dimension each) ----
const SCENARIOS = [
{
name: 'guard-free-arguments',
small: 1000,
large: 4000,
gen: (n) =>
Array.from(
{ length: n },
(_, i) => `function f${i}(x: string) { const y = 'prefix ' + x; return consume${i}(y); }`,
).join('\n'),
},
{
name: 'guarded-commands',
small: 60,
large: 240,
guard: { findingsPerFunction: 0 },
gen: (n) =>
Array.from(
{ length: n },
(_, i) => `function guarded${i}(req: any) {
const value: string = req.query.ref;
if (!/^[0-9a-f]{7,40}$/.test(value)) return;
execSync('git show ' + value);
}`,
).join('\n'),
},
{
name: 'guard-bypass',
small: 60,
large: 240,
guard: { findingsPerFunction: 1 },
gen: (n) =>
Array.from(
{ length: n },
(_, i) => `function bypass${i}(req: any, flag: boolean) {
const value: string = req.query.ref;
if (flag) { if (!/^[0-9a-f]{7,40}$/.test(value)) return; }
execSync('git show ' + value);
}`,
).join('\n'),
},
{
name: 'guard-forwarders',
small: 60,
large: 240,
guard: { findingsPerFunction: 0, forwarding: true },
gen: (n) =>
Array.from(
{ length: n },
(_, i) => `function forwarder${i}(value: string) {
if (!/^[0-9a-f]{7,40}$/.test(value)) return;
forward(value);
}`,
).join('\n'),
},
{
name: 'guard-capped',
small: 80,
large: 320,
guard: { findingsPerFunction: 1, fallback: true },
gen: (n) =>
`function capped(req: any) { const value: string = req.query.ref;\n` +
Array.from({ length: n }, () => 'if (!/^[0-9a-f]{7,40}$/.test(value)) return;').join('\n') +
`\nexecSync('git show ' + value); }`,
},
{
name: 'guard-discriminants',
small: 100,
large: 400,
guard: { findingsPerFunction: 0 },
gen: (n) =>
Array.from(
{ length: n },
(_, i) => `function discriminant${i}(kind: string) {
if (kind === 'read') return 1; return 2;
}`,
).join('\n'),
},
{
name: 'straight-line',
// One function, N coalescing simple statements → all fold into one basic
@ -388,6 +467,7 @@ function measureReachingDefs(cfgs, reps, maxFacts, blockVisitsMul = 0) {
function measureTaint(cfgs, reps, cap, taintModel) {
const importIndex = buildTaintImportIndex([]); // bench callees are globals
const pass = () => {
const refine = createMemoizedRefinement();
let analyzed = 0;
let kept = 0;
let dropped = 0;
@ -398,10 +478,16 @@ function measureTaint(cfgs, reps, cap, taintModel) {
const du = computeReachingDefs(c, {
maxFacts: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
});
const flows = computeTaintFlows(c, du, matches, {
maxFindingsPerFunction: cap,
maxHops: DEFAULT_PDG_MAX_TAINT_HOPS,
});
const flows = computeTaintFlows(
c,
du,
matches,
{
maxFindingsPerFunction: cap,
maxHops: DEFAULT_PDG_MAX_TAINT_HOPS,
},
refine(c),
);
if (flows.status !== 'computed') continue;
analyzed++;
kept += flows.findings.length;
@ -428,6 +514,61 @@ function measureTaint(cfgs, reps, cap, taintModel) {
return { ms: median(samples), ...out };
}
// Use the production policy and matching/solver path; only the fixture's global
// spelling replaces import resolution, which is covered by integration tests.
const GUARD_MODEL = {
...TS_JS_TAINT_MODEL,
sinks: TS_JS_TAINT_MODEL.sinks
.filter((entry) => entry.name === 'execSync')
.map((entry) => ({ ...entry, module: undefined, global: true })),
};
function measureGuards(cfgs, reps) {
const imports = buildTaintImportIndex([]);
const pass = (input) => {
const refine = createMemoizedRefinement();
let visits = 0,
fallbacks = 0,
findings = 0,
forwardEffects = 0;
for (const cfg of input) {
const refinement = refine(cfg);
visits += refinement.guards.visits;
if (refinement.guards.status === 'fallback') fallbacks++;
const matches = matchFunctionSites(cfg, GUARD_MODEL, imports);
const du = computeReachingDefs(cfg);
if (matches.hasSource && matches.hasSink)
findings += computeTaintFlows(cfg, du, matches, undefined, refinement).findings.length;
forwardEffects += harvestFunctionSummary(
cfg,
du,
matches,
refinement,
).facts.paramToCallArg.filter(
(edge) => edge.calleeName === 'forward' && edge.valueEffect?.kind === 'restrict',
).length;
}
return { visits, fallbacks, findings, forwardEffects };
};
pass(cfgs);
const samples = [];
let result;
for (let i = 0; i < reps; i++) {
const start = process.hrtime.bigint();
result = pass(cfgs);
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
}
const replay = JSON.parse(JSON.stringify(cfgs));
const start = process.hrtime.bigint();
const replayResult = pass(replay);
return {
ms: median(samples),
...result,
replayMs: Number(process.hrtime.bigint() - start) / 1e6,
replayEqual: JSON.stringify(result) === JSON.stringify(replayResult),
};
}
// ---- memory growth: retained heap of the cfgSideChannel payload ----
// Needs `node --expose-gc` to force collection for a clean delta; without it the
@ -559,7 +700,33 @@ function measureScenario(scenario) {
};
}
let guardMetrics = {};
if (scenario.guard) {
const guarded = measureGuards(large.cfgs, REPS);
guardMetrics = {
guard_ms_large: Number(guarded.ms.toFixed(3)),
guard_visits: guarded.visits,
guard_fallbacks: guarded.fallbacks,
guard_findings: guarded.findings,
guard_forward_effects: guarded.forwardEffects,
guard_replay_ms: Number(guarded.replayMs.toFixed(3)),
guard_replay_equal: guarded.replayEqual,
guard_functions: large.cfgs.length,
};
if (
guarded.findings !== large.cfgs.length * scenario.guard.findingsPerFunction ||
!guarded.replayEqual ||
(scenario.guard.fallback ? guarded.fallbacks !== large.cfgs.length : guarded.visits === 0) ||
(scenario.guard.forwarding && guarded.forwardEffects < large.cfgs.length) ||
guarded.visits > large.cfgs.length * 50_000
) {
throw new Error(
`${scenario.name}: refinement correctness/budget/replay gate failed: ${JSON.stringify(guarded)}`,
);
}
}
return {
...guardMetrics,
...taintMetrics,
scenario: scenario.name,
elapsed_ms_small: Number(small.ms.toFixed(3)),

View file

@ -30,7 +30,7 @@ errors always exit nonzero. `--check` additionally enforces the regression gate.
| Fixture / checks | Public repro | Fixed-answer contract |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `rename/` | [#3486](https://github.com/abhigyanpatwari/GitNexus/issues/3486) | All three Writer.close references; zero edits to comments, strings or unrelated homonyms; typed DeferredWriter.flush parameters refuse without edits or source changes. |
| `rename/` | [#3486](https://github.com/abhigyanpatwari/GitNexus/issues/3486) | All three Writer.close references; zero edits to comments, strings or unrelated homonyms; typed DeferredWriter.flush parameters refuse without edits or source changes. |
| `src/server/`, `test/stub.test.ts` | [#3487](https://github.com/abhigyanpatwari/GitNexus/issues/3487) | Server beats a same-path test stub; `app.all` has method `*`; Map.get is absent; helper-registered literal route is present. |
| `src/web/` | [#3488](https://github.com/abhigyanpatwari/GitNexus/issues/3488) | Both the base-URL template passed to a configured wrapper and a direct literal fetch link their consumer files. |
| `src/security.ts`, `src/server/inline.ts` | [#3489](https://github.com/abhigyanpatwari/GitNexus/issues/3489), [#3490](https://github.com/abhigyanpatwari/GitNexus/issues/3490), [#3491](https://github.com/abhigyanpatwari/GitNexus/issues/3491) | Named and inline unsafe handlers reach the sink; guarded and constant commands have zero taint; statement and function-hop lines are 1-based. |
@ -66,7 +66,10 @@ checks and all four #3499 checks, and their allowances were removed: **18/31
pass, 13 fail**.
The JSON report records every desired and actual answer independently of gate
status. Inline-callback taint now passes and its allowance is removed. Guard
taint remains a documented limitation and a visible known gap.
taint now passes for the modeled immutable SHA predicate and Git data-argument
use (#3490), and its allowance is removed. The current corpus passes **32/32**
fixed answers with no known-gap allowances. Unsupported guard forms remain
conservative findings; this does not claim complete guard coverage.
`known-gaps.json` is an explicit, reviewed list of failing check IDs with issue,
reason and observed baseline answers. There is no wildcard, skip, automatic

View file

@ -1,12 +1,5 @@
{
"schemaVersion": 1,
"baselineSourceSha": "dd096e690c06e5d54ab3595c63b83f15e04b6191",
"gaps": [
{
"id": "explain.guard",
"issue": 3490,
"reason": "Guard-style sanitizers are a documented taint limitation (#3490).",
"baselineActual": ["handleGuarded -> runGit command-injection"]
}
]
"gaps": []
}

View file

@ -124,6 +124,7 @@ export function collectFunctionCfgs(
// Site anchors must match the coordinate space of scope reference captures.
siteCoordinates: 'source' | 'parse-buffer' = 'source',
): CollectedCfgs {
visitor = visitor.forFile?.(root, filePath) ?? visitor;
const cfgs: FunctionCfg[] = [];
let tooManyLines = 0;
let tooDeeplyNested = 0;

View file

@ -1,3 +1,5 @@
import type { StatementValueFacts } from '../taint/value-refinement.js';
/**
* CFG data model — plain, JSON-serializable types (issue #2081, M1).
*
@ -199,6 +201,8 @@ export interface StatementFacts {
readonly uses: readonly number[];
readonly mayDefs?: readonly number[];
readonly sites?: readonly SiteRecord[];
/** Optional normalized value/predicate structure; absent on legacy channels. */
readonly refinement?: StatementValueFacts;
}
/** A basic block: a maximal straight-line run of statements between leaders. */
@ -278,6 +282,10 @@ export interface FunctionCfg {
* {@link BasicBlockData.statements}. Present iff statement facts are.
*/
readonly bindings?: readonly BindingEntry[];
/** Provider checked unsupported effects, including invisible closure writes. */
readonly refinementSafe?: boolean;
/** Exact primitive-string declaration contracts, never assertion-derived. */
readonly primitiveBindings?: readonly number[];
}
/**
@ -287,6 +295,8 @@ export interface FunctionCfg {
* `undefined` when the node is not a CFG-bearing function (the caller skips it).
*/
export interface CfgVisitor<TNode = unknown> {
/** File-bound worker context, released after collection. Existing providers need no hook. */
forFile?(root: TNode, filePath: string): CfgVisitor<TNode>;
buildFunctionCfg(fnNode: TNode, filePath: string): FunctionCfg | undefined;
/**

View file

@ -38,6 +38,14 @@
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import {
hasDefaultShellArguments,
readPredicate,
readValueExpression,
type RefinementFileContext,
} from './typescript-refinement.js';
import type { FunctionCfg } from '../types.js';
import type { StatementValueFacts, ValueExpression } from '../../taint/value-refinement.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, SiteArgOccurrence, SiteRecord, StatementFacts } from '../types.js';
@ -114,7 +122,34 @@ interface Scope {
readonly table: Map<string, number>;
}
const UNSAFE_REFINEMENT_NODES = new Set([
'try_statement',
'await_expression',
'yield_expression',
'with_statement',
]);
const PREDICATE_NODES = new Set([
'parenthesized_expression',
'unary_expression',
'binary_expression',
'call_expression',
]);
export class TsHarvester {
private refinementSafe = true;
private primitiveBindings?: Set<number>;
private hasRefinementFacts = false;
private needsValueFacts = false;
private shapeCount = 0;
private predicateCount = 0;
private captureShapes = false;
private readonly checkDynamicNames: boolean;
private readonly hasGuardCandidate: boolean;
private activeShapes?: {
sites?: Map<number, number>;
definitions?: NonNullable<StatementValueFacts['definitions']>[number][];
calls?: NonNullable<StatementValueFacts['calls']>[number][];
};
private readonly bindings: BindingEntry[] = [];
/** Scope-opening node id → its scope. */
private readonly scopeByNode = new Map<number, Scope>();
@ -123,13 +158,16 @@ export class TsHarvester {
private readonly synthetic = new Map<string, number>();
private readonly fnId: number;
/**
* Innermost enclosing scope per visited node id, filled during the prescan
* Innermost enclosing scope per identifier id, filled during the prescan
* (which already touches every named node once). Makes phase-2 resolution
* O(scope-chain) instead of O(AST-depth) per identifier — a deeply-chained
* single-statement expression (generated code) otherwise turns the
* parent-chain walk quadratic (tri-review perf finding).
*/
private readonly nearestScopeCache = new Map<number, Scope>();
/** Adjacent def/use and value-shape reads share the completed scope tables. */
private lastResolvedNodeId?: number;
private lastResolvedBinding?: number;
/**
* >0 while walking a conditionally-evaluated subexpression (short-circuit
* right operand, ternary arm, logical-assignment target, case test). Defs
@ -148,13 +186,29 @@ export class TsHarvester {
*/
private readonly resultDefTargets = new Map<number, number[]>();
constructor(private readonly fnNode: SyntaxNode) {
constructor(
private readonly fnNode: SyntaxNode,
private readonly refinementContext?: RefinementFileContext,
) {
const source = fnNode.text;
this.checkDynamicNames =
source.includes('eval') || source.includes('arguments') || source.includes('\\');
this.hasGuardCandidate =
source.includes('test') || source.includes('===') || source.includes('!==');
if (
fnNode.type.includes('generator') ||
(source.includes('async') && fnNode.children.some((child) => child.type === 'async'))
)
this.refinementSafe = false;
this.fnId = fnNode.id;
this.scopeByNode.set(fnNode.id, this.root);
this.scopeByNode.set(this.fnId, this.root);
this.declareParams(fnNode);
const body = fnNode.childForFieldName('body');
if (body)
this.prescan(body, body.type === 'statement_block' ? this.openScope(body) : this.root);
this.prescan(
body,
body.type === 'statement_block' ? this.openScope(body, this.root) : this.root,
);
}
/** The completed binding table — pass to `CfgBuilder.finish`. */
@ -162,23 +216,30 @@ export class TsHarvester {
return this.bindings;
}
// ── phase 1: declaration pre-scan ────────────────────────────────────────
private openScope(node: SyntaxNode): Scope {
const existing = this.scopeByNode.get(node.id);
if (existing) return existing;
const scope: Scope = { parent: this.nearestScopeOf(node), table: new Map() };
this.scopeByNode.set(node.id, scope);
return scope;
decorate(cfg: FunctionCfg): FunctionCfg {
if (!this.refinementSafe) return { ...cfg, refinementSafe: false };
if (!this.hasRefinementFacts) return cfg;
return {
...cfg,
refinementSafe: this.refinementSafe,
...(this.primitiveBindings?.size ? { primitiveBindings: [...this.primitiveBindings] } : {}),
};
}
private nearestScopeOf(node: SyntaxNode): Scope {
for (let p = node.parent; p; p = p.parent) {
const s = this.scopeByNode.get(p.id);
if (s) return s;
if (p.id === this.fnId) break;
}
return this.root;
private addPrimitiveBinding(binding: number): void {
if ((this.primitiveBindings?.size ?? 0) >= 256) this.refinementSafe = false;
else (this.primitiveBindings ??= new Set()).add(binding);
}
// ── phase 1: declaration pre-scan ────────────────────────────────────────
private openScope(node: SyntaxNode, parent: Scope): Scope {
const id = node.id;
const existing = this.scopeByNode.get(id);
if (existing) return existing;
const scope: Scope = { parent, table: new Map() };
this.scopeByNode.set(id, scope);
return scope;
}
private declare(
@ -189,6 +250,9 @@ export class TsHarvester {
formalIndex?: number,
): void {
const target = hoistToRoot ? this.root : scope;
// Parameters are outside the body prescan. Their scope is already known;
// retain it so parameter facts need not climb through their AST wrappers.
if (kind === 'param') this.nearestScopeCache.set(nameNode.id, this.root);
const name = nameNode.text;
// `var` multi-declaration (and a param + `var` of the same name) is ONE
// binding — first declaration in source order is canonical. The dedup is
@ -196,10 +260,11 @@ export class TsHarvester {
// `var x` still gets its own entry in its own scope.
if (target.table.has(name)) return;
target.table.set(name, this.bindings.length);
const position = nameNode.startPosition;
this.bindings.push({
name,
declLine: nameNode.startPosition.row + 1,
declColumn: nameNode.startPosition.column,
declLine: position.row + 1,
declColumn: position.column,
kind,
// Carry the enclosing formal position for params so the PDG call-summary
// consumer joins by FORMAL slot, never the flattened binding ordinal. A
@ -220,13 +285,25 @@ export class TsHarvester {
// several inner names, but ALL inherit this one formal index, so the PDG
// call-summary never misattributes an inner name to a later formal's slot.
let formalIndex = 0;
for (let i = 0; i < params.namedChildCount; i++) {
for (let i = 0, count = params.namedChildCount; i < count; i++) {
const p = params.namedChild(i);
if (!p) continue;
// TS wraps each param (required_parameter/optional_parameter, field
// `pattern`); plain JS puts the pattern directly in formal_parameters.
const pattern = p.childForFieldName('pattern') ?? p;
this.declarePattern(pattern, 'param', this.root, true, formalIndex);
// A formal index identifies the argument slot, not a value extracted
// from it. Extraction/default/rest effects are not modeled as identity.
if (pattern.type !== 'identifier') this.refinementSafe = false;
if (
this.hasGuardCandidate &&
pattern.type === 'identifier' &&
p.type === 'required_parameter' &&
p.childForFieldName('type')?.text.replace(/\s/g, '') === ':string'
) {
this.addPrimitiveBinding(this.root.table.get(pattern.text)!);
}
if (p.childForFieldName('value')) this.refinementSafe = false;
formalIndex++;
}
}
@ -280,9 +357,22 @@ export class TsHarvester {
}
private prescan(node: SyntaxNode, scope: Scope): void {
this.nearestScopeCache.set(node.id, scope);
const t = node.type;
// Only names are resolved. Reading native ids and caching scopes for
// structural/literal nodes adds work without helping either fact walk.
if (t.includes('identifier')) this.nearestScopeCache.set(node.id, scope);
if (UNSAFE_REFINEMENT_NODES.has(t)) this.refinementSafe = false;
if (this.checkDynamicNames && t === 'identifier') {
const name = node.text;
// The legacy binding table does not canonicalize escaped identifier spellings.
if (name === 'arguments' || name === 'eval' || name.includes('\\'))
this.refinementSafe = false;
}
// Well-formed literal internals contain no declarations/references. Error
// recovery can place identifiers inside them; retain that ordinary walk.
if (t === 'identifier' || ((t === 'string' || t === 'regex') && !node.hasError)) return;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
this.refinementSafe = false;
// A nested function's NAME binds in the enclosing scope; its body is opaque.
if (FUNCTION_DECL_TYPES.has(t)) {
const name = node.childForFieldName('name');
@ -292,11 +382,12 @@ export class TsHarvester {
}
let childScope = scope;
if (SCOPE_TYPES.has(t)) childScope = this.openScope(node);
if (SCOPE_TYPES.has(t)) childScope = this.openScope(node, scope);
switch (t) {
case 'lexical_declaration': {
const kind = node.child(0)?.type === 'const' ? 'const' : 'let';
if (kind === 'const') this.needsValueFacts = true;
this.declareDeclarators(node, kind, childScope, false);
break;
}
@ -329,7 +420,9 @@ export class TsHarvester {
break;
}
for (let i = 0; i < node.namedChildCount; i++) {
const childCount = node.namedChildCount;
if (t === 'arguments' && childCount > 0) this.needsValueFacts = true;
for (let i = 0; i < childCount; i++) {
const c = node.namedChild(i);
if (c) this.prescan(c, childScope);
}
@ -341,7 +434,7 @@ export class TsHarvester {
scope: Scope,
hoistToRoot: boolean,
): void {
for (let i = 0; i < declNode.namedChildCount; i++) {
for (let i = 0, count = declNode.namedChildCount; i < count; i++) {
const d = declNode.namedChild(i);
if (d?.type !== 'variable_declarator') continue;
const name = d.childForFieldName('name');
@ -357,8 +450,42 @@ export class TsHarvester {
*/
facts(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.walkValue(node, acc);
return acc.finish();
if (!this.refinementSafe || (!this.hasGuardCandidate && !this.needsValueFacts)) {
this.walkValue(node, acc);
return acc.finish();
}
const prior = this.activeShapes;
const priorCapture = this.captureShapes;
this.activeShapes = undefined;
this.captureShapes = true;
try {
this.walkValue(node, acc);
const shapes = this.activeShapes;
let predicate =
this.hasGuardCandidate && PREDICATE_NODES.has(node.type)
? readPredicate(
node,
(identifier) => this.resolve(identifier),
this.refinementContext,
(call) => shapes?.sites?.get(call.id),
)
: undefined;
if (predicate && ++this.predicateCount > 64) {
this.refinementSafe = false;
predicate = undefined;
}
if (!predicate && !shapes?.definitions?.length && !shapes?.calls?.length) return acc.finish();
this.hasRefinementFacts = true;
const refinement: StatementValueFacts = {
...(predicate ? { predicate } : {}),
...(shapes?.definitions?.length ? { definitions: shapes.definitions } : {}),
...(shapes?.calls?.length ? { calls: shapes.calls } : {}),
};
return acc.finish(refinement);
} finally {
this.activeShapes = prior;
this.captureShapes = priorCapture;
}
}
/**
@ -391,7 +518,7 @@ export class TsHarvester {
if (params.type === 'identifier') {
this.def(params, acc);
} else {
for (let i = 0; i < params.namedChildCount; i++) {
for (let i = 0, count = params.namedChildCount; i < count; i++) {
const p = params.namedChild(i);
if (!p) continue;
const pattern = p.childForFieldName('pattern') ?? p;
@ -413,13 +540,15 @@ export class TsHarvester {
}
private resolve(nameNode: SyntaxNode): number {
const nodeId = nameNode.id;
if (nodeId === this.lastResolvedNodeId) return this.lastResolvedBinding!;
const name = nameNode.text;
// Fast path: the prescan cached every visited node's innermost scope, so
// Fast path: the prescan cached every visited identifier's innermost scope, so
// resolution walks the SCOPE chain (shallow), not the AST parent chain
// (arbitrarily deep in chained expressions). The parent-chain walk remains
// as fallback for the few nodes the prescan never visits (e.g. a nested
// function declaration's own name node).
const cached = this.nearestScopeCache.get(nameNode.id);
const cached = this.nearestScopeCache.get(nodeId);
let startScope: Scope | null = cached ?? null;
if (!startScope) {
for (let p: SyntaxNode | null = nameNode; p; p = p.parent) {
@ -436,7 +565,11 @@ export class TsHarvester {
}
for (let s: Scope | null = startScope; s; s = s.parent) {
const idx = s.table.get(name);
if (idx !== undefined) return idx;
if (idx !== undefined) {
this.lastResolvedNodeId = nodeId;
this.lastResolvedBinding = idx;
return idx;
}
}
// No in-function declaration — synthetic module-level binding, shared by
// defs and uses so `notDeclared = 1; use(notDeclared)` still forms a fact.
@ -446,6 +579,8 @@ export class TsHarvester {
this.synthetic.set(name, idx);
this.bindings.push({ name, declLine: 0, declColumn: 0, kind: 'module', synthetic: true });
}
this.lastResolvedNodeId = nodeId;
this.lastResolvedBinding = idx;
return idx;
}
@ -493,6 +628,7 @@ export class TsHarvester {
return;
}
if ((t === 'string' || t === 'regex') && !node.hasError) return;
switch (t) {
case 'identifier':
case 'shorthand_property_identifier':
@ -500,11 +636,12 @@ export class TsHarvester {
return;
case 'lexical_declaration':
case 'variable_declaration':
for (let i = 0; i < node.namedChildCount; i++) {
for (let i = 0, count = node.namedChildCount; i < count; i++) {
const d = node.namedChild(i);
if (d?.type !== 'variable_declarator') continue;
const name = d.childForFieldName('name');
const value = d.childForFieldName('value');
let declaredBinding: number | undefined;
// A bare `var x;` mid-function is hoisted and writes NOTHING at
// runtime — harvesting it as a def would fabricate a kill of the
// live def (`x = source(); var x; sink(x)` must keep source→sink;
@ -512,9 +649,33 @@ export class TsHarvester {
if (name && (value || t === 'lexical_declaration')) {
const snap = acc.defSnapshot();
this.walkDefPattern(name, acc);
if (value) this.registerResultDefs(value, acc.defsSince(snap));
if (value) {
const defs = acc.defsSince(snap);
this.registerResultDefs(value, defs);
if (defs.length === 1) declaredBinding = defs[0];
}
}
if (value) this.walkValue(value, acc);
if (
this.captureShapes &&
value &&
declaredBinding !== undefined &&
this.bindings[declaredBinding]?.kind === 'const' &&
name?.type === 'identifier'
) {
const shapes = (this.activeShapes ??= {});
if (++this.shapeCount > 256) this.refinementSafe = false;
else
(shapes.definitions ??= []).push({
binding: declaredBinding,
value: readValueExpression(value, (identifier) => this.resolve(identifier)),
});
if (
this.hasGuardCandidate &&
d.childForFieldName('type')?.text.replace(/\s/g, '') === ':string'
)
this.addPrimitiveBinding(declaredBinding);
}
}
return;
case 'assignment_expression': {
@ -632,7 +793,7 @@ export class TsHarvester {
// arg 0 with `x` (review fix). Defs/uses stay byte-identical to the old
// default descent; only the sites layer narrows.
const operands: SyntaxNode[] = [];
for (let i = 0; i < node.namedChildCount; i++) {
for (let i = 0, count = node.namedChildCount; i < count; i++) {
const c = node.namedChild(i);
if (c) operands.push(c);
}
@ -644,7 +805,7 @@ export class TsHarvester {
return;
}
default:
for (let i = 0; i < node.namedChildCount; i++) {
for (let i = 0, count = node.namedChildCount; i < count; i++) {
const c = node.namedChild(i);
if (c) this.walkValue(c, acc);
}
@ -735,7 +896,31 @@ export class TsHarvester {
const argsNode = node.childForFieldName('arguments');
// `node` IS the call_expression/new_expression — the SAME node the
// scope-extractor anchors `@reference.call.*` (its `atRange`) on (KTD7).
const siteIdx = acc.openCallSite(kind, [node.startPosition.row + 1, node.startPosition.column]);
const position = node.startPosition;
const siteIdx = acc.openCallSite(kind, [position.row + 1, position.column]);
let capturedArgs: SyntaxNode[] | undefined;
let argumentValues: ValueExpression[] | undefined;
if (argsNode?.type === 'arguments' && argsNode.namedChildCount > 0 && this.captureShapes) {
const shapes = (this.activeShapes ??= {});
if (this.hasGuardCandidate) (shapes.sites ??= new Map()).set(node.id, siteIdx);
if (++this.shapeCount > 256) this.refinementSafe = false;
else {
const args = (capturedArgs = argsNode.namedChildren.filter(
(argument) => argument.type !== 'comment',
));
if (args.length > 32) this.refinementSafe = false;
else {
argumentValues = args.map((argument) =>
readValueExpression(argument, (identifier) => this.resolve(identifier)),
);
(shapes.calls ??= []).push({
site: siteIdx,
arguments: argumentValues,
...(hasDefaultShellArguments(args) ? { defaultShell: true } : {}),
});
}
}
}
acc.pushFrame(siteIdx);
let calleePath: string | undefined;
if (calleeNode) {
@ -773,9 +958,8 @@ export class TsHarvester {
this.walkValue(argsNode, acc);
} else if (argsNode) {
let pos = 0;
for (let i = 0; i < argsNode.namedChildCount; i++) {
const arg = argsNode.namedChild(i);
if (!arg || arg.type === 'comment') continue;
for (const arg of capturedArgs ?? argsNode.namedChildren) {
if (arg.type === 'comment') continue;
acc.setFrameArg(pos);
if (arg.type === 'spread_element') {
acc.setSiteSpread(siteIdx, pos);
@ -787,7 +971,11 @@ export class TsHarvester {
// resolves require'd aliases like ESM imports (plan KTD7).
acc.setSiteRequireArg(siteIdx, stringLiteralText(arg));
}
this.walkValue(arg, acc);
const value = argumentValues?.[pos];
// Value capture already resolved a bare identifier against the same
// completed scopes. Still record its use in the active call frame.
if (arg.type === 'identifier' && value?.kind === 'binding') acc.addUse(value.binding);
else this.walkValue(arg, acc);
}
pos++;
}
@ -1101,7 +1289,7 @@ class FactAccumulator {
list.push(via === undefined ? bindingIdx : [bindingIdx, via]);
}
finish(): StatementFacts {
finish(refinement?: StatementValueFacts): StatementFacts {
return {
line: this.line,
defs: this.defs,
@ -1112,6 +1300,7 @@ class FactAccumulator {
// Sites likewise omit-when-empty (#2083 M3 U1): flag-off runs never
// harvest, and most fact-bearing statements carry no calls.
...(this.sites.length > 0 ? { sites: this.sites.map(finalizeSite) } : {}),
...(refinement ? { refinement } : {}),
};
}
}

View file

@ -0,0 +1,298 @@
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import {
MAX_VALUE_BYTES,
MAX_VALUE_NODES,
parseAnchoredAsciiPattern,
type StringRestriction,
type ValueExpression,
} from '../../taint/value-refinement.js';
export interface PredicateFact {
readonly site?: number;
readonly subject: number;
readonly accepts: boolean;
readonly restriction: StringRestriction;
readonly establishesPrimitive?: boolean;
}
export interface RefinementFileContext {
readonly validators: ReadonlyMap<string, StringRestriction>;
readonly enabled: boolean;
}
const UNKNOWN_EXPRESSION: ValueExpression = Object.freeze({ kind: 'unknown' });
const UNCERTAIN_RUNTIME_ROOTS = new Set([
'RegExp',
'eval',
'Function',
'globalThis',
'global',
'window',
'self',
]);
function regexRestriction(node: SyntaxNode): StringRestriction | undefined {
if (node.type !== 'regex' || node.text.length > MAX_VALUE_BYTES) return;
const pattern = node.childForFieldName('pattern');
const flags =
node.childForFieldName('flags')?.text ?? node.text.slice(node.text.lastIndexOf('/') + 1);
return pattern ? parseAnchoredAsciiPattern(pattern.text, flags) : undefined;
}
/** One bounded file scan; every non-test reference invalidates a mutable RegExp object. */
export function createRefinementFileContext(root: SyntaxNode): RefinementFileContext {
if (!/\.\s*test\b/.test(root.text)) return { validators: new Map(), enabled: false };
const candidates = new Map<string, { declaration: number; restriction?: StringRestriction }>();
const identifiers: SyntaxNode[] = [];
const pending = [root];
let visits = 0;
let bytes = 0;
let enabled = true;
while (pending.length) {
if (++visits > 100_000) return { validators: new Map(), enabled: false };
const node = pending.pop()!;
if (node.type.includes('identifier')) identifiers.push(node);
if (node.type === 'regex') {
const declaration = node.parent;
const lexical = declaration?.parent;
const indexedInitializer =
declaration?.type === 'variable_declarator' &&
declaration.childForFieldName('value')?.id === node.id &&
declaration.childForFieldName('name')?.type === 'identifier' &&
lexical?.type === 'lexical_declaration' &&
lexical.parent?.type === 'program' &&
lexical.firstChild?.text === 'const';
const member = node.parent;
const call = member?.parent;
const directTest =
member?.type === 'member_expression' &&
member.childForFieldName('object')?.id === node.id &&
member.childForFieldName('property')?.text === 'test' &&
call?.type === 'call_expression' &&
call.childForFieldName('function')?.id === member.id;
if (!indexedInitializer && !directTest) enabled = false;
}
if (node.type === 'identifier') {
const name = node.text;
if (name.includes('\\') || UNCERTAIN_RUNTIME_ROOTS.has(name)) enabled = false;
}
if (
node.type === 'variable_declarator' &&
node.parent?.type === 'lexical_declaration' &&
node.parent.parent?.type === 'program' &&
node.parent.firstChild?.text === 'const'
) {
const name = node.childForFieldName('name');
const value = node.childForFieldName('value');
const restriction = value && regexRestriction(value);
if (name?.type === 'identifier' && value?.type === 'regex') {
bytes += value!.text.length;
if (candidates.size >= 256 || bytes > 8192)
return { validators: new Map(), enabled: false };
if (candidates.has(name.text)) enabled = false;
candidates.set(name.text, { declaration: name.id, restriction });
}
}
for (let i = node.namedChildCount - 1; i >= 0; i--) {
const child = node.namedChild(i);
if (child) pending.push(child);
}
}
for (const node of identifiers) {
if (++visits > 100_000) return { validators: new Map(), enabled: false };
const candidate = candidates.get(node.text);
if (!candidate || node.id === candidate.declaration) continue;
const member = node.parent;
const call = member?.parent;
if (
node.type !== 'identifier' ||
member?.type !== 'member_expression' ||
member.childForFieldName('object')?.id !== node.id ||
member.childForFieldName('property')?.text !== 'test' ||
call?.type !== 'call_expression' ||
call.childForFieldName('function')?.id !== member.id
)
enabled = false;
}
const validators = new Map<string, StringRestriction>();
if (enabled)
for (const [name, candidate] of candidates) {
if (candidate.restriction) validators.set(name, candidate.restriction);
}
return { validators, enabled };
}
function unwrap(node: SyntaxNode): SyntaxNode {
while (
['parenthesized_expression', 'as_expression', 'non_null_expression', 'type_assertion'].includes(
node.type,
)
) {
const inner = node.childForFieldName('expression') ?? node.firstNamedChild;
if (!inner || inner.id === node.id) break;
node = inner;
}
return node;
}
/** Preserve only exact string/binding composition, with a fixed traversal budget. */
export function readValueExpression(
node: SyntaxNode,
resolve: (identifier: SyntaxNode) => number,
): ValueExpression {
let remaining = MAX_VALUE_NODES;
let bytes = 0;
let exhausted = false;
const visit = (raw: SyntaxNode): ValueExpression => {
if (--remaining < 0) {
exhausted = true;
return UNKNOWN_EXPRESSION;
}
const value = unwrap(raw);
if (value.type === 'identifier') return { kind: 'binding', binding: resolve(value) };
if (value.type === 'string') {
const text = value.text;
bytes += text.length;
if (bytes > MAX_VALUE_BYTES) {
exhausted = true;
return UNKNOWN_EXPRESSION;
}
return text.length <= MAX_VALUE_BYTES && !text.includes('\\')
? { kind: 'literal', value: text.slice(1, -1) }
: UNKNOWN_EXPRESSION;
}
if (value.type === 'binary_expression' && value.childForFieldName('operator')?.text === '+') {
const left = value.childForFieldName('left');
const right = value.childForFieldName('right');
return left && right
? { kind: 'concat', parts: [visit(left), visit(right)] }
: UNKNOWN_EXPRESSION;
}
if (
value.type === 'template_string' &&
value.text.length <= MAX_VALUE_BYTES &&
!value.text.includes('\\')
) {
const parts: ValueExpression[] = [];
for (const child of value.namedChildren) {
if (child.type === 'string_fragment') {
bytes += child.text.length;
if (bytes > MAX_VALUE_BYTES) {
exhausted = true;
return UNKNOWN_EXPRESSION;
}
parts.push({ kind: 'literal', value: child.text });
} else if (child.type === 'template_substitution' && child.firstNamedChild)
parts.push(visit(child.firstNamedChild));
else return UNKNOWN_EXPRESSION;
}
return { kind: 'concat', parts };
}
if (
[
'object',
'new_expression',
'array',
'number',
'true',
'false',
'null',
'regex',
'function_expression',
'arrow_function',
].includes(value.type)
)
return { kind: 'non-string' };
return UNKNOWN_EXPRESSION;
};
const result = visit(node);
return exhausted ? UNKNOWN_EXPRESSION : result;
}
export function readPredicate(
raw: SyntaxNode,
resolve: (identifier: SyntaxNode) => number,
context: RefinementFileContext | undefined,
siteOf?: (call: SyntaxNode) => number | undefined,
): PredicateFact | undefined {
let node = unwrap(raw);
let accepts = true;
let remaining = 8;
while (node.type === 'unary_expression' && node.firstChild?.text === '!' && remaining-- > 0) {
const argument = node.childForFieldName('argument');
if (!argument) return;
accepts = !accepts;
node = unwrap(argument);
}
if (remaining <= 0) return;
if (node.type === 'call_expression') {
if (!context?.enabled) return;
const callee = node.childForFieldName('function');
const object = callee?.childForFieldName('object');
const args = node.childForFieldName('arguments')?.namedChildren;
if (
callee?.type !== 'member_expression' ||
callee.childForFieldName('property')?.text !== 'test' ||
!object ||
!args ||
args.length !== 1 ||
args[0].type !== 'identifier' ||
node.text.includes('?.')
)
return;
const restriction =
object.type === 'regex'
? regexRestriction(object)
: object.type === 'identifier'
? context?.validators.get(object.text)
: undefined;
if (restriction) {
const site = siteOf?.(node);
return {
subject: resolve(args[0]),
accepts,
restriction,
...(site !== undefined ? { site } : {}),
};
}
}
if (node.type === 'binary_expression') {
const operator = node.childForFieldName('operator')?.text;
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (!left || !right || (operator !== '===' && operator !== '!==')) return;
const subject =
left.type === 'identifier' ? left : right.type === 'identifier' ? right : undefined;
const literal = subject?.id === left.id ? right : left;
if (!subject || literal.type !== 'string') return;
const expression = readValueExpression(literal, resolve);
if (
expression.kind !== 'literal' ||
!expression.value ||
expression.value.length > MAX_VALUE_BYTES
)
return;
return {
subject: resolve(subject),
accepts: operator === '===' ? accepts : !accepts,
restriction: {
chars: [...new Set(expression.value)].sort().join(''),
min: expression.value.length,
max: expression.value.length,
},
establishesPrimitive: true,
};
}
}
/** Known ordinary options only; inherited shell settings and spread/getters are unknown. */
export function hasDefaultShellArguments(args: readonly SyntaxNode[]): boolean {
if (args.length === 1) return true;
if (args.length !== 2 || args[1].type !== 'object') return false;
return args[1].namedChildren.every(
(pair) =>
pair.type === 'pair' &&
pair.childForFieldName('key')?.text === 'encoding' &&
pair.childForFieldName('value')?.type === 'string',
);
}

View file

@ -67,6 +67,10 @@ import {
import type { TraversalResult } from '../traversal-result.js';
import type { CfgVisitor, FunctionCfg } from '../types.js';
import { TsHarvester } from './typescript-harvest.js';
import {
createRefinementFileContext,
type RefinementFileContext,
} from './typescript-refinement.js';
/** TS/JS node types that own a CFG-bearing function body. */
const TS_FUNCTION_TYPES = new Set([
@ -723,7 +727,11 @@ class TsCfgWalk {
}
/** Build the CFG for one TS/JS function node (or `undefined` if not a function). */
function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined {
function buildFunctionCfg(
fnNode: SyntaxNode,
filePath: string,
refinementContext?: RefinementFileContext,
): FunctionCfg | undefined {
if (!TS_FUNCTION_TYPES.has(fnNode.type)) return undefined;
const startLine = startLineOf(fnNode);
const endLine = endLineOf(fnNode);
@ -735,7 +743,7 @@ function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | u
// Phase-1 declaration pre-scan (#2082 M2 U1) — must complete before any
// facts are extracted; the CFG walk below is not source-order.
const harvest = new TsHarvester(fnNode);
const harvest = new TsHarvester(fnNode, refinementContext);
// Parameters define at ENTRY (facts only — never touch the entry block's
// text or span: bench fingerprints and CFG snapshots include block text).
@ -754,18 +762,18 @@ function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | u
);
builder.edge(builder.entryIndex, blk, 'seq');
builder.edge(blk, builder.exitIndex, 'return');
return builder.finish(harvest.table());
return harvest.decorate(builder.finish(harvest.table()));
}
const walk = new TsCfgWalk(builder, harvest);
const res = walk.visitSeq(body.namedChildren.filter((c) => c.type !== 'comment'));
if (!res) {
builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body
return builder.finish(harvest.table());
return harvest.decorate(builder.finish(harvest.table()));
}
builder.edge(builder.entryIndex, res.entry, 'seq');
builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT
return builder.finish(harvest.table());
return harvest.decorate(builder.finish(harvest.table()));
}
/** Whether a node is a TS/JS function this visitor builds a CFG for. */
@ -775,7 +783,14 @@ function isFunction(node: SyntaxNode): boolean {
/** The TS/JS CFG visitor (shared by TypeScript and JavaScript). */
export function createTypeScriptCfgVisitor(): CfgVisitor<SyntaxNode> {
return { buildFunctionCfg, isFunction };
return {
buildFunctionCfg,
isFunction,
forFile(root) {
const context = createRefinementFileContext(root);
return { buildFunctionCfg: (fn, path) => buildFunctionCfg(fn, path, context), isFunction };
},
};
}
export { TS_FUNCTION_TYPES };

View file

@ -85,6 +85,11 @@ export const taintSummariesPhase: PipelinePhase<TaintSummariesOutput> = {
const maxEdges = ctx.options?.pdgMaxInterprocEdges ?? DEFAULT_PDG_MAX_INTERPROC_EDGES;
const solved = solveInterprocTaint(summaryMap, callEdges, { maxFindings, maxHops });
if (solved.refinementFallback) {
logger.warn(
'[taint-interproc] refinement memory budget exhausted; resumed conservative analysis',
);
}
const emit = emitInterprocTaint(ctx.graph, solved.findings, { maxEdges }, (m) =>
logger.warn(m),
);

View file

@ -67,6 +67,7 @@ import {
type TaintEmitLimits,
} from '../../taint/emit.js';
import { registerBuiltinTaintModels } from '../../taint/typescript-model.js';
import { createMemoizedRefinement } from '../../taint/guard-refinement.js';
import { getSourceSinkConfig } from '../../taint/source-sink-registry.js';
import {
buildFunctionNodeIndex,
@ -1796,6 +1797,9 @@ export function runScopeResolution(
kills: 0,
dropped: 0,
hopsTruncated: 0,
guardFunctions: 0,
guardFallbacks: 0,
guardVisits: 0,
gapExamples: [] as string[],
dropExamples: [] as string[],
};
@ -1878,6 +1882,11 @@ export function runScopeResolution(
// per (limits) bucket instead of 3–4× (#2227 tri-review). File-scoped: it
// is re-created each iteration, so its per-function facts drop with the file.
const rdSolve = createMemoizedReachingDefs();
const refine = createMemoizedRefinement((result) => {
if (result.guards.status === 'computed') taintTotals.guardFunctions++;
if (result.guards.status === 'fallback') taintTotals.guardFallbacks++;
taintTotals.guardVisits += result.guards.visits;
});
const t0 = PROF ? performance.now() : 0;
const rd = emitFileReachingDefs(
pdgTarget,
@ -1956,6 +1965,7 @@ export function runScopeResolution(
(message) => logger.warn(message), // unconditional — R4/R6
rdSolve,
taintParsed,
refine,
);
if (PROF) taintMs += performance.now() - t1;
taintTotals.analyzed += taint.functionsAnalyzed;
@ -1991,6 +2001,7 @@ export function runScopeResolution(
: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
rdSolve,
taintParsed,
refine,
);
harvestedSummaries.push(...harvest.summaries);
summaryUnresolved += harvest.unresolved;
@ -2028,6 +2039,7 @@ export function runScopeResolution(
? `; taint: ${taintTotals.findings} TAINTED, ${taintTotals.kills} SANITIZES ` +
`(${taintTotals.analyzed} function(s) analyzed, ` +
`${taintTotals.noMatch} skipped: no source/sink match` +
`; guards: ${taintTotals.guardFunctions} analyzed, ${taintTotals.guardFallbacks} fallback, ${taintTotals.guardVisits} visits` +
(taintTotals.hopsTruncated > 0
? `, ${taintTotals.hopsTruncated} finding(s) with truncated hop paths`
: '') +

View file

@ -64,6 +64,7 @@
*/
import type { ParsedFile, ParsedImport } from 'gitnexus-shared';
import { createFunctionRefinement, type RefinementSolver } from './guard-refinement.js';
import type { KnowledgeGraph } from '../../graph/types.js';
import { generateId } from '../../../lib/utils.js';
import {
@ -154,6 +155,7 @@ const pushExample = (list: string[], anchor: string): void => {
* re-validated (`hasTaintSafeSites`). Never throws on well-formed input;
* the caller's per-file try isolates the rest.
*/
export function emitFileTaint(
graph: KnowledgeGraph,
cfgs: readonly FunctionCfg[],
@ -166,6 +168,7 @@ export function emitFileTaint(
// functions request it, hitting the cache the call-summary harvest warmed.
solve: ReachingDefsSolver = computeReachingDefs,
parsed?: ParsedFile,
refine: RefinementSolver = createFunctionRefinement,
): TaintEmitResult {
const result: TaintEmitResult = {
functionsAnalyzed: 0,
@ -215,7 +218,13 @@ export function emitFileTaint(
}
const defUse = solve(cfg, { maxFacts });
const flows = computeTaintFlows(cfg, defUse, matches, { maxFindingsPerFunction, maxHops });
const flows = computeTaintFlows(
cfg,
defUse,
matches,
{ maxFindingsPerFunction, maxHops },
refine(cfg),
);
if (flows.status === 'coverage-gap') {
// R4: skipped entirely, counted by reason; aggregate-warned by the
// caller (the RD layer already per-function-warned this solver status).

View file

@ -0,0 +1,437 @@
import type { FunctionCfg } from '../cfg/types.js';
import {
applyValueEffect,
MAX_VALUE_BYTES,
MAX_VALUE_NODES,
UNKNOWN_VALUE,
type StatementValueFacts,
type StringRestriction,
type ValueEffect,
type ValueExpression,
} from './value-refinement.js';
const MAX_REFINEMENT_VISITS = 50_000;
const MAX_REFINEMENT_WORDS = 65_536;
/** Normalized worker facts. No AST, executable predicates or model callbacks. */
export interface GuardFacts {
readonly eligible: boolean;
readonly primitiveBindings: readonly number[];
readonly predicates: readonly {
readonly block: number;
readonly subject: number;
readonly accepts: boolean;
readonly restriction: StringRestriction;
readonly establishesPrimitive?: boolean;
}[];
}
export interface GuardRefinements {
readonly status: 'computed' | 'no-guard' | 'fallback';
readonly visits: number;
at(binding: number, block: number): StringRestriction | undefined;
}
export interface FunctionRefinement {
readonly guards: GuardRefinements;
argument(
block: number,
statement: number,
site: number,
argument: number,
inputBinding?: number,
): ValueEffect | undefined;
defaultShell(block: number, statement: number, site: number): boolean;
isPredicate(block: number, statement: number, site: number): boolean;
}
/** Run/file-owned context shared by the two consumers; no AST or global cache. */
export function createFunctionRefinement(cfg: FunctionCfg): FunctionRefinement {
if (cfg.refinementSafe !== true)
return cfg.refinementSafe === false ? FALLBACK_REFINEMENT : NO_REFINEMENT;
if (cfg.blocks.length + cfg.edges.length > MAX_REFINEMENT_WORDS) return FALLBACK_REFINEMENT;
let scanVisits = 0;
const predicates: GuardFacts['predicates'][number][] = [];
const definitions = new Map<number, ValueExpression>();
const written = new Set<number>();
const calls = new Map<string, NonNullable<StatementValueFacts['calls']>[number]>();
const predicateSites = new Set<string>();
let factCount = 0;
const validBinding = (binding: unknown): binding is number =>
++scanVisits <= MAX_REFINEMENT_VISITS &&
Number.isInteger(binding) &&
typeof binding === 'number' &&
binding >= 0 &&
binding < (cfg.bindings?.length ?? 0);
const validExpression = (expression: ValueExpression): boolean => {
let nodes = MAX_VALUE_NODES;
let bytes = 0;
const visit = (value: ValueExpression): boolean => {
if (
++scanVisits > MAX_REFINEMENT_VISITS ||
!value ||
typeof value !== 'object' ||
--nodes < 0
)
return false;
if (value.kind === 'unknown' || value.kind === 'non-string') return true;
if (value.kind === 'binding') return validBinding(value.binding);
if (value.kind === 'literal') {
if (typeof value.value !== 'string') return false;
bytes += value.value.length;
return bytes <= MAX_VALUE_BYTES;
}
return (
value.kind === 'concat' &&
Array.isArray(value.parts) &&
value.parts.length <= MAX_VALUE_NODES &&
value.parts.every(visit)
);
};
return visit(expression);
};
if (
cfg.primitiveBindings !== undefined &&
(!Array.isArray(cfg.primitiveBindings) ||
cfg.primitiveBindings.length > MAX_REFINEMENT_WORDS ||
!cfg.primitiveBindings.every(validBinding))
)
return FALLBACK_REFINEMENT;
for (const block of cfg.blocks)
for (let index = 0; index < (block.statements?.length ?? 0); index++) {
if (++scanVisits > MAX_REFINEMENT_VISITS) return FALLBACK_REFINEMENT;
const statement = block.statements![index];
if (
!Array.isArray(statement.defs) ||
(statement.mayDefs !== undefined && !Array.isArray(statement.mayDefs))
)
return FALLBACK_REFINEMENT;
const shape = statement.refinement;
if (
shape !== undefined &&
(!shape ||
typeof shape !== 'object' ||
(shape.definitions !== undefined &&
(!Array.isArray(shape.definitions) || shape.definitions.length > 256)) ||
(shape.calls !== undefined && (!Array.isArray(shape.calls) || shape.calls.length > 256)))
)
return FALLBACK_REFINEMENT;
if (shape?.predicate) {
const predicate = shape.predicate;
if (
!validBinding(predicate.subject) ||
typeof predicate.accepts !== 'boolean' ||
(predicate.establishesPrimitive !== undefined &&
predicate.establishesPrimitive !== true) ||
applyValueEffect(
{ kind: 'restrict', value: { kind: 'input' }, restriction: predicate.restriction },
UNKNOWN_VALUE,
).kind !== 'string'
)
return FALLBACK_REFINEMENT;
if (predicate.site !== undefined) {
if (
!Number.isInteger(predicate.site) ||
statement.sites?.[predicate.site]?.kind !== 'call'
)
return FALLBACK_REFINEMENT;
predicateSites.add(`${block.index}:${index}:${predicate.site}`);
}
if (predicates.length >= 64) return FALLBACK_REFINEMENT;
predicates.push({ ...predicate, block: block.index });
}
for (const definition of shape?.definitions ?? []) {
if (
++factCount > 256 ||
!definition ||
!validBinding(definition.binding) ||
!validExpression(definition.value)
)
return FALLBACK_REFINEMENT;
if (definitions.has(definition.binding)) written.add(definition.binding);
else definitions.set(definition.binding, definition.value);
}
for (const bindings of [statement.defs, statement.mayDefs ?? []])
for (const binding of bindings) {
if (++scanVisits > MAX_REFINEMENT_VISITS) return FALLBACK_REFINEMENT;
const entry = cfg.bindings?.[binding];
if (
entry?.kind === 'param'
? block.index !== cfg.entryIndex
: entry?.kind !== 'const' || entry.declLine !== statement.line
)
written.add(binding);
}
for (const call of shape?.calls ?? []) {
if (
++factCount > 256 ||
!call ||
!Number.isInteger(call.site) ||
!statement.sites?.[call.site] ||
!Array.isArray(call.arguments) ||
call.arguments.length > MAX_VALUE_NODES ||
!call.arguments.every(validExpression) ||
(call.defaultShell !== undefined && typeof call.defaultShell !== 'boolean')
)
return FALLBACK_REFINEMENT;
calls.set(`${block.index}:${index}:${call.site}`, call);
}
}
const guards = computeGuardRefinements(
cfg,
{
eligible: cfg.refinementSafe === true,
primitiveBindings: cfg.primitiveBindings ?? [],
predicates,
},
undefined,
definitions,
);
const active = cfg.refinementSafe === true && guards.status !== 'fallback';
return {
guards,
defaultShell: (block, statement, site) =>
active && calls.get(`${block}:${statement}:${site}`)?.defaultShell === true,
isPredicate: (block, statement, site) =>
active && predicateSites.has(`${block}:${statement}:${site}`),
argument(block, statement, site, argument, inputBinding) {
const expression = calls.get(`${block}:${statement}:${site}`)?.arguments[argument];
if (!active || !expression) return;
let remaining = MAX_VALUE_NODES;
const seen = new Set<number>();
let exhausted = false;
const visit = (value: ValueExpression): ValueEffect => {
if (--remaining < 0) {
exhausted = true;
return { kind: 'unknown' };
}
if (value.kind === 'literal' || value.kind === 'non-string' || value.kind === 'unknown')
return value;
if (value.kind === 'concat') return { kind: 'concat', parts: value.parts.map(visit) };
if (value.kind !== 'binding') return { kind: 'unknown' };
const binding = value.binding;
if (written.has(binding) || seen.has(binding)) return { kind: 'unknown' };
seen.add(binding);
const definition = definitions.get(binding);
const base: ValueEffect =
binding === inputBinding
? { kind: 'input' }
: definition
? visit(definition)
: { kind: 'unknown' };
seen.delete(binding);
const restriction = guards.at(binding, block);
return restriction ? { kind: 'restrict', value: base, restriction } : base;
};
const result = visit(expression);
return exhausted ? undefined : result;
},
};
}
const NO_GUARDS: GuardRefinements = Object.freeze({
status: 'no-guard',
visits: 0,
at: () => undefined,
});
const NO_REFINEMENT: FunctionRefinement = Object.freeze({
guards: NO_GUARDS,
argument: () => undefined,
defaultShell: () => false,
isPredicate: () => false,
});
const FALLBACK_REFINEMENT: FunctionRefinement = Object.freeze({
...NO_REFINEMENT,
guards: { ...NO_GUARDS, status: 'fallback' as const },
});
export type RefinementSolver = (cfg: FunctionCfg) => FunctionRefinement;
export function createMemoizedRefinement(
onCompute?: (result: FunctionRefinement) => void,
): RefinementSolver {
const cache = new WeakMap<FunctionCfg, FunctionRefinement>();
return (cfg) => {
let result = cache.get(cfg);
if (!result) {
result = createFunctionRefinement(cfg);
cache.set(cfg, result);
onCompute?.(result);
}
return result;
};
}
/**
* Removing an accepted edge must make the use unreachable from ENTRY.
* Definitions are the validated value expressions reconstructed by the caller.
* Expanding their uses shares the traversal cap; no certificates survive exhaustion.
*/
export function computeGuardRefinements(
cfg: FunctionCfg,
facts?: GuardFacts,
limits?: { maxVisits?: number; maxGuards?: number; maxWords?: number },
definitions?: ReadonlyMap<number, ValueExpression>,
): GuardRefinements {
if (!facts?.predicates?.length) return NO_GUARDS;
let visits = 0;
const fallback = (): GuardRefinements => ({ status: 'fallback', visits, at: () => undefined });
const maxVisits = limits?.maxVisits ?? MAX_REFINEMENT_VISITS;
const maxGuards = limits?.maxGuards ?? 64;
const maxWords = limits?.maxWords ?? MAX_REFINEMENT_WORDS;
const count = cfg.blocks.length;
if (
facts.eligible !== true ||
!Array.isArray(facts.predicates) ||
!Array.isArray(facts.primitiveBindings) ||
facts.predicates.length > maxGuards ||
facts.primitiveBindings.length > maxWords ||
count + cfg.edges.length > maxWords ||
!Number.isInteger(cfg.entryIndex) ||
cfg.entryIndex < 0 ||
cfg.entryIndex >= count
)
return fallback();
const successors: number[][] = Array.from({ length: count }, () => []);
for (let i = 0; i < cfg.edges.length; i++) {
const edge = cfg.edges[i];
if (
!Number.isInteger(edge.from) ||
!Number.isInteger(edge.to) ||
edge.from < 0 ||
edge.from >= count ||
edge.to < 0 ||
edge.to >= count
)
return fallback();
successors[edge.from].push(i);
}
const reachable = (removed = -1): Uint8Array | undefined => {
const seen = new Uint8Array(count);
const pending = [cfg.entryIndex];
seen[cfg.entryIndex] = 1;
while (pending.length) {
if (++visits > maxVisits) return;
const block = pending.pop()!;
for (const index of successors[block]) {
if (++visits > maxVisits) return;
if (index === removed) continue;
const to = cfg.edges[index].to;
if (!seen[to]) {
seen[to] = 1;
pending.push(to);
}
}
}
return seen;
};
const original = reachable();
if (!original) return fallback();
const primitives = new Set(facts.primitiveBindings);
const written = new Set<number>();
const uses = new Map<number, Set<number>>();
const subjects = new Set(facts.predicates.map((predicate) => predicate.subject));
for (const block of cfg.blocks) {
for (const statement of block.statements ?? []) {
if (++visits > maxVisits) return fallback();
if (
!Array.isArray(statement.defs) ||
!Array.isArray(statement.uses) ||
(statement.mayDefs !== undefined && !Array.isArray(statement.mayDefs))
)
return fallback();
for (const bindings of [statement.defs, statement.mayDefs ?? []])
for (const binding of bindings) {
if (++visits > maxVisits) return fallback();
if (!subjects.has(binding)) continue;
const declaration = cfg.bindings?.[binding];
const isInitial =
declaration?.kind === 'param'
? block.index === cfg.entryIndex
: declaration?.kind === 'const' && statement.line === declaration.declLine;
if (!isInitial) written.add(binding);
}
for (const binding of statement.uses) {
// Argument reconstruction expands aliases at the sink. Certify the
// subjects reached through those same definitions at that use block.
const pending: ValueExpression[] = [{ kind: 'binding', binding }];
const seen = new Set<number>();
while (pending.length) {
if (++visits > maxVisits) return fallback();
const value = pending.pop()!;
if (value.kind === 'concat') pending.push(...value.parts);
else if (value.kind === 'binding' && !seen.has(value.binding)) {
seen.add(value.binding);
if (subjects.has(value.binding)) {
let blocks = uses.get(value.binding);
if (!blocks) uses.set(value.binding, (blocks = new Set()));
blocks.add(block.index);
}
const definition = definitions?.get(value.binding);
if (definition) pending.push(definition);
}
}
}
}
}
const certificates = new Map<number, Map<number, StringRestriction>>();
let words = count + cfg.edges.length;
for (const predicate of facts.predicates) {
if (
!Number.isInteger(predicate.block) ||
!Number.isInteger(predicate.subject) ||
!original[predicate.block] ||
!cfg.bindings?.[predicate.subject] ||
written.has(predicate.subject) ||
(!primitives.has(predicate.subject) && !predicate.establishesPrimitive)
)
continue;
const checked = applyValueEffect(
{ kind: 'restrict', value: { kind: 'input' }, restriction: predicate.restriction },
UNKNOWN_VALUE,
);
if (checked.kind !== 'string') continue;
const edges = successors[predicate.block];
// An unlabeled seq leg is the false fallthrough only alongside one true edge.
const explicit = edges.filter(
(index) => cfg.edges[index].kind === (predicate.accepts ? 'cond-true' : 'cond-false'),
);
const accepted =
explicit.length === 1
? explicit[0]
: !predicate.accepts &&
explicit.length === 0 &&
edges.length === 2 &&
edges.some((index) => cfg.edges[index].kind === 'cond-true')
? edges.find((index) => cfg.edges[index].kind === 'seq')
: undefined;
if (accepted === undefined) continue;
const bypass = reachable(accepted);
if (!bypass) return fallback();
for (const block of uses.get(predicate.subject) ?? []) {
if (++visits > maxVisits || ++words > maxWords) return fallback();
if (!original[block] || bypass[block]) continue;
let points = certificates.get(predicate.subject);
if (!points) certificates.set(predicate.subject, (points = new Map()));
const previous = points.get(block);
const restriction = predicate.restriction;
if (!previous) points.set(block, restriction);
else {
const chars = [...new Set(previous.chars)]
.filter((char) => restriction.chars.includes(char))
.sort()
.join('');
const min = Math.max(previous.min, restriction.min);
const max = Math.min(previous.max, restriction.max);
if (chars && min <= max) points.set(block, { chars, min, max });
}
}
}
return {
status: 'computed',
visits,
at: (binding, block) => certificates.get(binding)?.get(block),
};
}

View file

@ -51,6 +51,13 @@
import type { SinkKind } from './source-sink-config.js';
import type { FunctionSummary } from './summary-model.js';
import {
applyValueEffect,
joinRefinedValues,
provesInertCommand,
UNKNOWN_VALUE,
type RefinedValue,
} from './value-refinement.js';
/**
* One resolved call edge from the `CALLS` graph. The join to a summary's
@ -86,6 +93,8 @@ export interface InterprocFinding {
}
export interface InterprocLimits {
/** Internal proof-payload budget; exhaustion restores baseline taint. */
readonly maxRefinementBytes?: number;
/** Max functions in a single flow's hop chain. `undefined`/0 ⇒ default 32. */
readonly maxHops?: number;
/** Max findings overall (post-dedup). `undefined`/0 ⇒ unlimited. */
@ -93,6 +102,7 @@ export interface InterprocLimits {
}
export interface InterprocResult {
readonly refinementFallback?: boolean;
readonly findings: readonly InterprocFinding[];
/** Findings dropped by `maxFindings` (post-dedup). */
readonly droppedFindings: number;
@ -113,6 +123,7 @@ export const DEFAULT_PDG_MAX_INTERPROC_FINDINGS = 2000;
/** A tainted parameter, with the flow that first tainted it (for path reconstruction). */
interface TaintedParam {
readonly value?: RefinedValue;
readonly fnId: string;
readonly paramIndex: number;
readonly sourceFnId: string;
@ -205,6 +216,19 @@ export function solveInterprocTaint(
const findingsByKey = new Map<string, InterprocFinding>();
const tainted = new Map<string, TaintedParam>();
const queue: TaintedParam[] = [];
let refinementEnabled = true;
// Queue entries retain earlier values after map replacement, so charge cumulatively.
let refinementBytes = 0;
const refinementBudget = limits?.maxRefinementBytes ?? 64 * 1024 * 1024;
const valueBytes = (value: RefinedValue | undefined): number =>
value?.kind === 'string'
? 32 +
value.parts.reduce(
(total, part) =>
total + (typeof part === 'string' ? part.length * 2 : part.chars.length * 2 + 32),
0,
)
: 0;
const recordFinding = (
sourceFnId: string,
@ -225,6 +249,16 @@ export function solveInterprocTaint(
for (const ps of summary.paramToSink) {
if (ps.param !== tp.paramIndex) continue;
if (tp.neutralized.has(ps.sinkKind)) continue; // sanitised across the boundary (P1-2)
if (
refinementEnabled &&
ps.sinkKind === 'command-injection' &&
ps.inertCommandPrefixes &&
provesInertCommand(
applyValueEffect(ps.valueEffect, tp.value ?? UNKNOWN_VALUE),
ps.inertCommandPrefixes,
)
)
continue;
// `tp.hops` already terminates at this (tainted) function — it IS the
// source→sink chain, no extra hop to append.
recordFinding(tp.sourceFnId, tp.fnId, ps.sinkKind, tp.hops, tp.truncated);
@ -241,20 +275,44 @@ export function solveInterprocTaint(
*/
const taint = (tp: TaintedParam): void => {
const key = pkey(tp.fnId, tp.paramIndex, tp.sourceFnId);
const existing = tainted.get(key);
let existing = tainted.get(key);
let value = refinementEnabled
? existing
? joinRefinedValues(existing.value ?? UNKNOWN_VALUE, tp.value ?? UNKNOWN_VALUE)
: (tp.value ?? UNKNOWN_VALUE)
: UNKNOWN_VALUE;
if (refinementEnabled && refinementBytes + valueBytes(value) > refinementBudget) {
refinementEnabled = false;
refinementBytes = 0;
for (const [priorKey, prior] of tainted) {
const widened = { ...prior, value: UNKNOWN_VALUE };
tainted.set(priorKey, widened);
queue.push(widened);
fireSinks(widened);
}
existing = tainted.get(key);
value = UNKNOWN_VALUE;
}
if (existing) {
const inter = new Set<SinkKind>();
for (const k of existing.neutralized) if (tp.neutralized.has(k)) inter.add(k);
if (inter.size >= existing.neutralized.size) return; // no shrink — cycle-safe
const merged: TaintedParam = { ...existing, neutralized: inter };
if (
inter.size >= existing.neutralized.size &&
JSON.stringify(value) === JSON.stringify(existing.value ?? UNKNOWN_VALUE)
)
return;
refinementBytes += valueBytes(value);
const merged: TaintedParam = { ...existing, neutralized: inter, value };
tainted.set(key, merged);
queue.push(merged);
fireSinks(merged);
return;
}
tainted.set(key, tp);
queue.push(tp);
fireSinks(tp);
const added = { ...tp, value };
refinementBytes += valueBytes(value);
tainted.set(key, added);
queue.push(added);
fireSinks(added);
};
// ── seeds: every source→callee-arg, resolved against CALLS ────────────────
@ -278,6 +336,9 @@ export function solveInterprocTaint(
hops: seed.hops,
truncated: seed.truncated,
neutralized: new Set(sc.neutralized ?? []),
value: refinementEnabled
? applyValueEffect(sc.valueEffect, UNKNOWN_VALUE)
: UNKNOWN_VALUE,
});
}
}
@ -379,6 +440,9 @@ export function solveInterprocTaint(
hops: next.hops,
truncated: tp.truncated || next.truncated,
neutralized,
value: refinementEnabled
? applyValueEffect(pc.valueEffect, tp.value ?? UNKNOWN_VALUE)
: UNKNOWN_VALUE,
});
}
}
@ -395,6 +459,7 @@ export function solveInterprocTaint(
const findings = all.length > maxFindings ? all.slice(0, maxFindings) : all;
return {
...(!refinementEnabled ? { refinementFallback: true } : {}),
findings,
droppedFindings: all.length - findings.length,
unmatchedCallSites,

View file

@ -96,6 +96,8 @@
* bypassed sanitizer (`exec(x + escape(x))`) killed nothing.
*/
import { createFunctionRefinement, type FunctionRefinement } from './guard-refinement.js';
import { applyValueEffect, provesInertCommand, UNKNOWN_VALUE } from './value-refinement.js';
import type { FunctionCfg, SiteRecord, StatementFacts } from '../cfg/types.js';
import { pointKey } from '../cfg/reaching-defs.js';
import type { DefUseFact, FunctionDefUse, ProgramPoint } from '../cfg/reaching-defs.js';
@ -294,11 +296,13 @@ interface TaintState {
* Compute taint flows for one function. See the module doc for the two-rule
* model, the kind-set exclusion semantics, and the precision floor.
*/
export function computeTaintFlows(
cfg: FunctionCfg,
defUse: FunctionDefUse,
matches: FunctionSiteMatches,
limits?: TaintLimits,
refinement: FunctionRefinement = createFunctionRefinement(cfg),
): FunctionTaintResult {
if (defUse.status !== 'computed') {
return {
@ -311,6 +315,22 @@ export function computeTaintFlows(
}
const bindings = defUse.bindings;
const guardProvesSink = (
block: number,
statement: number,
site: number,
argument: number,
prefixes: readonly string[] | undefined,
): boolean =>
argument === 0 &&
prefixes !== undefined &&
refinement.defaultShell(block, statement, site) &&
cfg.blocks[block]?.statements?.[statement]?.sites?.[site]?.spread === undefined &&
!cfg.blocks[block]?.statements?.[statement]?.sites?.[site]?.template &&
provesInertCommand(
applyValueEffect(refinement.argument(block, statement, site, argument), UNKNOWN_VALUE),
prefixes,
);
// ── per-statement context (built lazily; statements revisit often) ────────
const matchByPoint = new Map<string, StatementMatches>();
@ -766,6 +786,17 @@ export function computeTaintFlows(
for (const sink of ctx.sinksBySite.get(siteIndex) ?? []) {
if (!sink.argPositions.includes(argPos)) continue;
const kind = sink.entry.kind;
if (
kind === 'command-injection' &&
guardProvesSink(
ctx.point.blockIndex,
ctx.point.stmtIndex,
siteIndex,
argPos,
sink.entry.inertCommandPrefixes,
)
)
continue;
if (!sofar.kinds.has(kind)) {
recordFinding(
kind,
@ -847,6 +878,17 @@ export function computeTaintFlows(
for (const sink of sinks) {
const kind = sink.entry.kind;
for (const argPos of sink.argPositions) {
if (
kind === 'command-injection' &&
guardProvesSink(
ctx.point.blockIndex,
ctx.point.stmtIndex,
siteIndex,
argPos,
sink.entry.inertCommandPrefixes,
)
)
continue;
const paths = pathsIntoPosition(ctx, b, siteIndex, argPos);
if (paths.length === 0) continue;
if (E.has(kind)) continue; // suppressed at def time; kill already recorded

View file

@ -66,6 +66,8 @@ export interface TaintCallableMatcher<K extends string = string> {
* names (`res.send` / `res.write`); exactly `<receiver>.<name>`, name-based.
*/
export interface TaintSinkEntry extends TaintCallableMatcher<SinkKind> {
/** Reviewed fixed-operation prefixes with one inert data-word argument. */
readonly inertCommandPrefixes?: readonly string[];
readonly module?: string;
readonly global?: boolean;
/** Only meaningful with `global`: match `new <name>(…)` sites only. */

View file

@ -26,6 +26,7 @@
* remain unresolved rather than attaching a summary to a different function.
*/
import { createFunctionRefinement, type RefinementSolver } from './guard-refinement.js';
import type { ParsedFile, ParsedImport, GraphNode } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../graph/types.js';
import { computeReachingDefs, type ReachingDefsSolver } from '../cfg/reaching-defs.js';
@ -124,6 +125,7 @@ export interface FileSummaryResult {
* `isEmitSafeCfg`-filtered (the same `wellFormed` array fed to `emitFileTaint`).
* Pure aside from the read-only graph lookup; never throws on valid input.
*/
export function harvestFileSummaries(
fnIndex: FunctionNodeIndex,
cfgs: readonly FunctionCfg[],
@ -133,6 +135,7 @@ export function harvestFileSummaries(
// U12: shared per-file memoized solver (harvest/taint bucket — no maxBlockVisits).
solve: ReachingDefsSolver = computeReachingDefs,
parsed?: ParsedFile,
refine: RefinementSolver = createFunctionRefinement,
): FileSummaryResult {
const importIndex = buildTaintImportIndex(parsedImports, parsed);
const summaries: FunctionSummary[] = [];
@ -147,7 +150,7 @@ export function harvestFileSummaries(
}
const defUse = solve(cfg, { maxFacts });
const matches = matchFunctionSites(cfg, spec, importIndex);
const harvested = harvestFunctionSummary(cfg, defUse, matches);
const harvested = harvestFunctionSummary(cfg, defUse, matches, refine(cfg));
if (harvested.status !== 'computed') {
gaps++;
continue;

View file

@ -63,7 +63,9 @@
import type { FunctionCfg, SiteRecord } from '../cfg/types.js';
import { pointKey, type FunctionDefUse, type ProgramPoint } from '../cfg/reaching-defs.js';
import type { FunctionSiteMatches } from './match.js';
import type { FunctionSiteMatches, MatchedSinkCall } from './match.js';
import { createFunctionRefinement, type FunctionRefinement } from './guard-refinement.js';
import { valueEffectKey } from './value-refinement.js';
import { sinkKindRank, sortSinkKinds, type SinkKind } from './source-sink-config.js';
import type {
CallResult,
@ -129,6 +131,7 @@ export function harvestFunctionSummary(
cfg: FunctionCfg,
defUse: FunctionDefUse,
matches: FunctionSiteMatches,
refinement: FunctionRefinement = createFunctionRefinement(cfg),
): HarvestResult {
if (defUse.status !== 'computed') {
return { status: 'coverage-gap', gapReason: defUse.status, facts: EMPTY_FACTS };
@ -144,6 +147,25 @@ export function harvestFunctionSummary(
const paramIndexOf = new Map<number, number>();
paramBindings.forEach((e, paramIdx) => paramIndexOf.set(e.idx, paramIdx));
const paramCount = paramBindings.length;
// Legacy flattened ports cannot carry a new proof across an ambiguous formal.
const precisePorts = paramBindings.every((entry, index) => entry.b.formalIndex === index);
const effectAt = (
block: number,
statement: number,
site: number,
argument: number,
param?: number,
) => {
if (!precisePorts) return undefined;
const effect = refinement.argument(
block,
statement,
site,
argument,
param === undefined ? undefined : paramBindings[param]?.idx,
);
return effect?.kind === 'unknown' ? undefined : effect;
};
// ── return points: source block of every `return` CFG edge ────────────────
// The M2 edge-kind invariant: a `return` edge's SOURCE block terminates in
@ -166,8 +188,13 @@ export function harvestFunctionSummary(
// call-result seed (#2084 review P1-1): their result semantics are already
// modelled (a sanitizer's result rides U2 exclusions; a sink returns void).
const modeledSites = new Set<string>();
const matchedSinks = new Map<string, Map<number, MatchedSinkCall[]>>();
for (const sm of matches.statements) {
const stmtKey = `${sm.blockIndex}:${sm.statementIndex}`;
const siteSinks = new Map<number, MatchedSinkCall[]>();
for (const sink of sm.sinks)
siteSinks.set(sink.siteIndex, [...(siteSinks.get(sink.siteIndex) ?? []), sink]);
matchedSinks.set(stmtKey, siteSinks);
for (const s of sm.sinks) modeledSites.add(`${stmtKey}:${s.siteIndex}`);
for (const s of sm.sanitizers) modeledSites.add(`${stmtKey}:${s.siteIndex}`);
if (sm.sinks.length > 0) {
@ -433,6 +460,7 @@ export function harvestFunctionSummary(
const sinkBySite = sinkPosBySite.get(useKey);
const kindBySite = sinkKindByEntry.get(useKey);
useStmt.sites?.forEach((site, siteIndex) => {
if (refinement.isPredicate(fact.use.blockIndex, fact.use.stmtIndex, siteIndex)) return;
const argHits = occurrencesInArgs(site, b);
for (const argPos of argHits) {
const callLine = useStmt.line;
@ -445,13 +473,21 @@ export function harvestFunctionSummary(
dest: { to: 'callArg', ...(tail ? { toCallee: tail } : {}), argIndex: argPos },
});
} else if (t.seedId >= 0) {
const caKey = `${t.seedId}:${callLine}:${argPos}:${tail ?? ''}`;
const valueEffect = effectAt(
fact.use.blockIndex,
fact.use.stmtIndex,
siteIndex,
argPos,
t.seedId,
);
const caKey = `${t.seedId}:${callLine}:${argPos}:${tail ?? ''}:${valueEffectKey(valueEffect)}`;
if (!paramCallArg.has(caKey)) {
paramCallArg.set(caKey, {
param: t.seedId,
callLine,
argIndex: argPos,
...(tail ? { calleeName: tail } : {}),
...(valueEffect ? { valueEffect } : {}),
});
}
// Carry the sanitizer exclusions on the path INTO this call arg,
@ -461,13 +497,20 @@ export function harvestFunctionSummary(
// Source-seeded: a generated source flowing into a call argument is
// a fixpoint SEED (it taints the callee's param). One source kind
// today ('remote-input'); when more exist the seed must carry it.
const scKey = `${callLine}:${argPos}:${tail ?? ''}`;
const valueEffect = effectAt(
fact.use.blockIndex,
fact.use.stmtIndex,
siteIndex,
argPos,
);
const scKey = `${callLine}:${argPos}:${tail ?? ''}:${valueEffectKey(valueEffect)}`;
if (!sourceCallArg.has(scKey)) {
sourceCallArg.set(scKey, {
sourceKind: 'remote-input',
callLine,
argIndex: argPos,
...(tail ? { calleeName: tail } : {}),
...(valueEffect ? { valueEffect } : {}),
});
}
intersectKinds(sourceCallArgKinds, scKey, t.exclusions);
@ -484,10 +527,37 @@ export function harvestFunctionSummary(
dest: { to: 'sink', sinkKind: kind },
});
} else if (t.seedId >= 0) {
const sKey = `${t.seedId}:${kind}`;
const sinkEntries = (matchedSinks.get(useKey)?.get(siteIndex) ?? []).filter(
(sink) => sink.entry.kind === kind && sink.argPositions.includes(argPos),
);
const canRefine =
kind === 'command-injection' &&
argPos === 0 &&
site.spread === undefined &&
!site.template &&
refinement.defaultShell(fact.use.blockIndex, fact.use.stmtIndex, siteIndex) &&
sinkEntries.length > 0 &&
sinkEntries.every((sink) => sink.entry.inertCommandPrefixes);
const prefixes = canRefine
? sinkEntries[0].entry.inertCommandPrefixes!.filter((prefix) =>
sinkEntries.every((sink) =>
sink.entry.inertCommandPrefixes!.includes(prefix),
),
)
: undefined;
const valueEffect = prefixes?.length
? effectAt(fact.use.blockIndex, fact.use.stmtIndex, siteIndex, argPos, t.seedId)
: undefined;
const sKey = `${t.seedId}:${kind}:${valueEffectKey(valueEffect)}:${JSON.stringify(prefixes ?? [])}`;
if (!paramSink.has(sKey)) {
paramSink.add(sKey);
paramSinkOut.push({ param: t.seedId, sinkKind: kind });
paramSinkOut.push({
param: t.seedId,
sinkKind: kind,
...(valueEffect && prefixes?.length
? { valueEffect, inertCommandPrefixes: prefixes }
: {}),
});
}
}
}
@ -539,7 +609,10 @@ export function harvestFunctionSummary(
);
const paramToSink = paramSinkOut.sort(
(a, b) => a.param - b.param || sinkKindRank(a.sinkKind) - sinkKindRank(b.sinkKind),
(a, b) =>
a.param - b.param ||
sinkKindRank(a.sinkKind) - sinkKindRank(b.sinkKind) ||
valueEffectKey(a.valueEffect).localeCompare(valueEffectKey(b.valueEffect)),
);
const sourceToReturn: SourceToReturn[] =

View file

@ -34,6 +34,7 @@
*/
import type { SinkKind, SourceKind } from './source-sink-config.js';
import { valueEffectKey, type ValueEffect } from './value-refinement.js';
/**
* Source-relative parameter index (0-based, in declaration order). A
@ -61,6 +62,7 @@ export type ParamIndex = number;
* callees of one caller, never as the primary join key.
*/
export interface ParamToCallArg {
readonly valueEffect?: ValueEffect;
readonly param: ParamIndex;
readonly callLine: number;
readonly argIndex: number;
@ -96,6 +98,8 @@ export interface ParamToReturn {
/** `param i` reaches a modelled sink of kind `sinkKind` inside this function. */
export interface ParamToSink {
readonly valueEffect?: ValueEffect;
readonly inertCommandPrefixes?: readonly string[];
readonly param: ParamIndex;
readonly sinkKind: SinkKind;
}
@ -140,6 +144,7 @@ export interface CallResult {
* procedural `source → sink` partial flow whose sink lives in the callee.
*/
export interface SourceToCallArg {
readonly valueEffect?: ValueEffect;
readonly sourceKind: SourceKind;
/** Carried for hop display + same-name tie-break; NOT the join key (see
* {@link ParamToCallArg} — the solver joins by `calleeName`). */
@ -227,17 +232,24 @@ export function ownFactsDigest(
...s.paramToCallArg
.map(
(c) =>
`c:${c.param}:${c.callLine}:${c.argIndex}:${c.calleeName ?? ''}:${[...(c.neutralized ?? [])].sort().join(',')}`,
`c:${c.param}:${c.callLine}:${c.argIndex}:${c.calleeName ?? ''}:${[...(c.neutralized ?? [])].sort().join(',')}${c.valueEffect ? ':' + valueEffectKey(c.valueEffect) : ''}`,
)
.sort(),
);
parts.push(
...s.paramToSink
.map(
(k) =>
`k:${k.param}:${k.sinkKind}${k.valueEffect ? ':' + valueEffectKey(k.valueEffect) : ''}${k.inertCommandPrefixes ? ':' + JSON.stringify([...k.inertCommandPrefixes].sort()) : ''}`,
)
.sort(),
);
parts.push(...s.paramToSink.map((k) => `k:${k.param}:${k.sinkKind}`).sort());
parts.push(...s.sourceToReturn.map((g) => `g:${g.sourceKind}`).sort());
parts.push(
...s.sourceToCallArg
.map(
(g) =>
`s:${g.sourceKind}:${g.callLine}:${g.argIndex}:${g.calleeName ?? ''}:${[...(g.neutralized ?? [])].sort().join(',')}`,
`s:${g.sourceKind}:${g.callLine}:${g.argIndex}:${g.calleeName ?? ''}:${[...(g.neutralized ?? [])].sort().join(',')}${g.valueEffect ? ':' + valueEffectKey(g.valueEffect) : ''}`,
)
.sort(),
);

View file

@ -40,8 +40,20 @@ export const TS_JS_TAINT_MODEL: SourceSinkSanitizerSpec = {
sinks: [
// Command execution — shell strings are arg 0; argv-form APIs also treat
// the argv array at arg 1 as command/option injection surface.
{ name: 'exec', kind: 'command-injection', args: [0], module: 'child_process' },
{ name: 'execSync', kind: 'command-injection', args: [0], module: 'child_process' },
{
name: 'exec',
kind: 'command-injection',
args: [0],
module: 'child_process',
inertCommandPrefixes: ['git log -1 ', 'git log -1 --format=%s ', 'git show '],
},
{
name: 'execSync',
kind: 'command-injection',
args: [0],
module: 'child_process',
inertCommandPrefixes: ['git log -1 ', 'git log -1 --format=%s ', 'git show '],
},
{ name: 'spawn', kind: 'command-injection', args: [0, 1], module: 'child_process' },
{ name: 'spawnSync', kind: 'command-injection', args: [0, 1], module: 'child_process' },
{ name: 'execFile', kind: 'command-injection', args: [0, 1], module: 'child_process' },

View file

@ -0,0 +1,245 @@
/** A deliberately closed string domain. Unknown always preserves ordinary taint. */
export interface StringRestriction {
readonly chars: string;
readonly min: number;
readonly max: number;
}
/** Small, provider-normalized expression vocabulary; absent shape stays unknown. */
export type ValueExpression =
| { readonly kind: 'unknown' | 'non-string' }
| { readonly kind: 'binding'; readonly binding: number }
| { readonly kind: 'literal'; readonly value: string }
| { readonly kind: 'concat'; readonly parts: readonly ValueExpression[] };
export interface StatementValueFacts {
readonly predicate?: {
readonly site?: number;
readonly subject: number;
readonly accepts: boolean;
readonly restriction: StringRestriction;
readonly establishesPrimitive?: boolean;
};
readonly definitions?: readonly { readonly binding: number; readonly value: ValueExpression }[];
readonly calls?: readonly {
readonly site: number;
readonly arguments: readonly ValueExpression[];
readonly defaultShell?: boolean;
}[];
}
export type ValueEffect =
| { readonly kind: 'unknown' | 'input' | 'non-string' }
| { readonly kind: 'literal'; readonly value: string }
| { readonly kind: 'concat'; readonly parts: readonly ValueEffect[] }
| {
readonly kind: 'restrict';
readonly value: ValueEffect;
readonly restriction: StringRestriction;
};
export type RefinedValue =
| { readonly kind: 'unknown' | 'non-string' }
| { readonly kind: 'string'; readonly parts: readonly (string | StringRestriction)[] };
export const UNKNOWN_VALUE: RefinedValue = Object.freeze({ kind: 'unknown' });
export const MAX_VALUE_NODES = 32;
export const MAX_VALUE_BYTES = 256;
function validRestriction(value: StringRestriction): boolean {
return (
typeof value?.chars === 'string' &&
value.chars.length > 0 &&
value.chars.length <= 128 &&
[...value.chars].every((c) => c.charCodeAt(0) < 128) &&
Number.isInteger(value.min) &&
Number.isInteger(value.max) &&
value.min >= 0 &&
value.max >= value.min &&
value.max <= MAX_VALUE_BYTES
);
}
/** Parse a linear, fully anchored ASCII subset; never execute a repository regex. */
export function parseAnchoredAsciiPattern(
pattern: string,
flags: string,
): StringRestriction | undefined {
if (
flags !== '' ||
pattern.length > MAX_VALUE_BYTES ||
!pattern.startsWith('^') ||
!pattern.endsWith('$')
)
return;
const body = pattern.slice(1, -1);
let index = 0;
let min = 0;
let max = 0;
const alphabet = new Set<string>();
while (index < body.length) {
const chars: string[] = [];
if (body[index] === '[') {
const end = body.indexOf(']', index + 1);
if (end < 0) return;
const raw = body.slice(index + 1, end);
if (!raw || /[^A-Za-z0-9-]/.test(raw)) return;
for (let i = 0; i < raw.length; i++) {
if (raw[i] === '-') return;
if (raw[i + 1] === '-') {
const first = raw.charCodeAt(i);
const last = raw.charCodeAt(i + 2);
if (!Number.isFinite(last) || last < first || last - first > 127) return;
for (let c = first; c <= last; c++) chars.push(String.fromCharCode(c));
i += 2;
} else chars.push(raw[i]);
}
index = end + 1;
} else {
const char = body[index++];
if (!/[A-Za-z0-9]/.test(char)) return;
chars.push(char);
}
let lo = 1;
let hi = 1;
if (body[index] === '{') {
const end = body.indexOf('}', index);
if (end < 0) return;
const match = /^(\d{1,3})(?:,(\d{1,3}))?$/.exec(body.slice(index + 1, end));
if (!match) return;
lo = Number(match[1]);
hi = Number(match[2] ?? match[1]);
if (hi < lo || hi > MAX_VALUE_BYTES) return;
index = end + 1;
}
min += lo;
max += hi;
if (max > MAX_VALUE_BYTES) return;
for (const char of chars) alphabet.add(char);
}
if (alphabet.size === 0 || min === 0) return;
return { chars: [...alphabet].sort().join(''), min, max };
}
/** A bounded transfer. Missing, malformed, oversized and incompatible effects are unknown. */
export function applyValueEffect(
effect: ValueEffect | undefined,
input: RefinedValue,
): RefinedValue {
// A contradicted primitive contract must not revive at a later typed boundary.
if (input.kind === 'non-string') return input;
let remaining = MAX_VALUE_NODES;
let exhausted = false;
const visit = (node: ValueEffect | undefined): RefinedValue => {
if (!node) return UNKNOWN_VALUE;
if (--remaining < 0) {
exhausted = true;
return UNKNOWN_VALUE;
}
switch (node.kind) {
case 'input':
return input;
case 'non-string':
return { kind: 'non-string' };
case 'unknown':
return UNKNOWN_VALUE;
case 'literal':
return typeof node.value === 'string' && node.value.length <= MAX_VALUE_BYTES
? { kind: 'string', parts: [node.value] }
: UNKNOWN_VALUE;
case 'restrict': {
if (!validRestriction(node.restriction)) return UNKNOWN_VALUE;
const prior = visit(node.value);
return prior.kind === 'non-string' ? prior : { kind: 'string', parts: [node.restriction] };
}
case 'concat': {
if (!Array.isArray(node.parts) || node.parts.length > MAX_VALUE_NODES) return UNKNOWN_VALUE;
const parts: (string | StringRestriction)[] = [];
let bytes = 0;
for (const part of node.parts) {
const value = visit(part);
if (value.kind !== 'string') return value;
for (const segment of value.parts) {
bytes += typeof segment === 'string' ? segment.length : segment.max;
if (bytes > MAX_VALUE_BYTES || parts.length >= MAX_VALUE_NODES) return UNKNOWN_VALUE;
if (typeof segment === 'string' && typeof parts.at(-1) === 'string') {
parts[parts.length - 1] = String(parts.at(-1)) + segment;
} else parts.push(segment);
}
}
return { kind: 'string', parts };
}
default:
return UNKNOWN_VALUE;
}
};
const result = visit(effect);
return exhausted ? UNKNOWN_VALUE : result;
}
/** Guarantees weaken across routes. Different shapes widen immediately (including recursion). */
export function joinRefinedValues(left: RefinedValue, right: RefinedValue): RefinedValue {
if (left.kind === 'non-string') return left;
if (right.kind === 'non-string') return right;
if (left.kind !== 'string' || right.kind !== 'string') {
return UNKNOWN_VALUE;
}
if (left.parts.length !== right.parts.length) return UNKNOWN_VALUE;
const parts: (string | StringRestriction)[] = [];
for (let i = 0; i < left.parts.length; i++) {
const a = left.parts[i];
const b = right.parts[i];
if (typeof a === 'string' || typeof b === 'string') {
if (a !== b) return UNKNOWN_VALUE;
parts.push(a);
} else {
parts.push({
chars: [...new Set(a.chars + b.chars)].sort().join(''),
min: Math.min(a.min, b.min),
max: Math.max(a.max, b.max),
});
}
}
return { kind: 'string', parts };
}
/** Operation/argument roles, not a blanket executable exemption. */
export function provesInertCommand(value: RefinedValue, prefixes: readonly string[]): boolean {
if (value.kind !== 'string' || value.parts.length !== 2) return false;
const [prefix, argument] = value.parts;
return (
typeof prefix === 'string' &&
prefixes.includes(prefix) &&
typeof argument !== 'string' &&
validRestriction(argument) &&
argument.min > 0 &&
// ASCII letters/digits cannot introduce a shell operator, whitespace or option.
/^[A-Za-z0-9]+$/.test(argument.chars)
);
}
/** Canonical bounded identity, independent of JSON property insertion order. */
export function valueEffectKey(effect: ValueEffect | undefined): string {
if (!effect) return '';
let remaining = MAX_VALUE_NODES;
const encode = (value: ValueEffect): unknown => {
if (--remaining < 0) return '?';
switch (value.kind) {
case 'literal':
return ['literal', value.value];
case 'concat':
return ['concat', ...value.parts.map(encode)];
case 'restrict':
return [
'restrict',
encode(value.value),
[...new Set(value.restriction.chars)].sort().join(''),
value.restriction.min,
value.restriction.max,
];
default:
return [value.kind];
}
};
return JSON.stringify(encode(effect));
}

View file

@ -32,6 +32,7 @@ import {
getLbugDbIdentity,
} from '../../core/lbug/pool-adapter.js';
import { queryClassBeanMetadata } from './bean-metadata.js';
import { GUARD_TAINT_LIMITATION } from '../taint-limitations.js';
import { querySpringAopMetadata } from './aop-metadata.js';
import {
SYMBOL_IDENTITY_RECOVERY_SUGGESTION,
@ -6279,7 +6280,10 @@ export class LocalBackend {
findings: pageFindings,
totalFindings: totalFindings + interprocTotal,
...(truncated ? { truncated: true } : {}),
note: 'Intra-procedural (TAINTED, statement hops) AND cross-function (TAINT_PATH, function hops, `interprocedural: true`) flows are modeled, including sources read inside inline callbacks and returned handlers. Captured closure-variable, property/field, and implicit flows are NOT modeled; absence of a finding is not proof of safety. Cross-function findings are context-insensitive and may over-attribute among same-named callees. All line numbers are 1-based; for cross-function findings, `source.line`, `sink.line` and `hops[].line` are the declaration lines of the functions, not statement lines. SANITIZES (kill) edges are queryable via cypher.',
note:
'Intra-procedural (TAINTED, statement hops) AND cross-function (TAINT_PATH, function hops, `interprocedural: true`) flows are modeled, including sources read inside inline callbacks and returned handlers. Captured closure-variable, property/field, and implicit flows are NOT modeled; absence of a finding is not proof of safety. ' +
GUARD_TAINT_LIMITATION +
' Cross-function findings are context-insensitive and may over-attribute among same-named callees. All line numbers are 1-based; for cross-function findings, `source.line`, `sink.line` and `hops[].line` are the declaration lines of the functions, not statement lines. SANITIZES (kill) edges are queryable via cypher.',
};
}

View file

@ -0,0 +1,2 @@
export const GUARD_TAINT_LIMITATION =
'Modeled guards can refine unchanged primitive-string values for reviewed sink uses. Imported and wrapper validators, reassigned values, and unsupported predicates or sink uses remain conservative findings. Declared string types are analysis assumptions, not runtime validation.';

View file

@ -7,6 +7,7 @@
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
import { REL_TYPES } from 'gitnexus-shared';
import { GUARD_TAINT_LIMITATION } from './taint-limitations.js';
import {
CONTEXT_RELATION_TYPES,
CONTEXT_RESPONSE_SECTIONS,
@ -751,7 +752,8 @@ CONTRACT CAVEATS (absent flows are NOT proof of safety):
- Cross-function matching is by callee NAME (context-insensitive): when one caller invokes two distinct same-named callees, a flow into one over-attributes to both — a cross-function finding does not prove the taint reached every same-named function (sound over-report, never a missed flow).
- Sources read inside inline callbacks and returned handlers can flow through helper functions. Captured closure-variable flows are not modeled (e.g. const y = source(); arr.forEach(() => sink(y))).
- Property/field flows are not tracked (obj.x = taint; sink(obj.y) has no chain).
- Guard-style sanitizers (if (isValid(x))) and implicit/control-dependence flows are not modeled.
- ${GUARD_TAINT_LIMITATION}
- Implicit/control-dependence flows are not modeled.
- CommonJS aliasing is partially modeled (require('<literal>') joins resolve; dynamic requires do not).
- Exception-path over-approximation can produce false-positive noise.

View file

@ -53,17 +53,22 @@ export function makeCfgHarness(
};
const cfgOf = (code: string, index = 0): FunctionCfg => {
const fn = collectFunctions(parse(code))[index];
const root = parse(code);
const bound = visitor.forFile?.(root, filePath) ?? visitor;
const fn = collectFunctions(root)[index];
if (!fn) throw new Error(`no function at index ${index}`);
const cfg = visitor.buildFunctionCfg(fn, filePath);
const cfg = bound.buildFunctionCfg(fn, filePath);
if (!cfg) throw new Error('buildFunctionCfg returned undefined');
return cfg;
};
const cfgsOf = (code: string): FunctionCfg[] =>
collectFunctions(parse(code))
.map((fn) => visitor.buildFunctionCfg(fn, filePath))
const cfgsOf = (code: string): FunctionCfg[] => {
const root = parse(code);
const bound = visitor.forFile?.(root, filePath) ?? visitor;
return collectFunctions(root)
.map((fn) => bound.buildFunctionCfg(fn, filePath))
.filter((c): c is FunctionCfg => c !== undefined);
};
return { parse, collectFunctions, cfgOf, cfgsOf };
}

View file

@ -0,0 +1,188 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
import { createTempDirPool } from '../../helpers/temp-dir-pool.js';
const repos = createTempDirPool('gn-guard-taint-');
describe('guard-aware command taint (#3490)', () => {
it('preserves alias proofs across blocks and retains destructuring findings in real workers', async () => {
const repo = repos.dir();
fs.writeFileSync(
path.join(repo, 'aliases.ts'),
`
import {execSync} from 'node:child_process';
function indexed({9: command}: any) { execSync(command); }
function defaulted(context: any, {command = context.query.command}: any) { execSync(command); }
function forward(command: string, log: boolean) {
const alias = command;
if (log) console.log('validated');
execSync(alias);
}
export function handleIndexed(req: any) {
const ref: string = req.query.ref;
if (!/^[a-z]{1,40}$/.test(ref)) return;
indexed('git show ' + ref);
}
export function handleDefaulted(req: any) {
const ref: string = req.query.ref;
if (!/^[a-z]{1,40}$/.test(ref)) return;
defaulted(req, 'git show ' + ref);
}
export function handleAlias(req: any, log: boolean) {
const ref: string = req.query.ref;
if (!/^[0-9a-f]{7,40}$/.test(ref)) return;
const args = 'show ' + ref;
const command = 'git ' + args;
if (log) console.log('validated');
execSync(command);
forward(command, log);
}
export function handleBypass(req: any, log: boolean) {
const ref: string = req.query.ref;
if (log) { if (!/^[0-9a-f]{7,40}$/.test(ref)) return; }
const command = 'git show ' + ref;
if (log) console.log('unchecked');
execSync(command);
forward(command, log);
}
`,
);
const result = await runPipelineFromRepo(repo, () => {}, { pdg: true, workerPoolSize: 1 });
const paths = [...result.graph.iterRelationships()]
.filter((edge) => edge.type === 'TAINT_PATH')
.map((edge) => result.graph.getNode(edge.sourceId)?.properties.name);
expect(paths).toContain('handleIndexed');
expect(paths).toContain('handleDefaulted');
expect(paths).toContain('handleBypass');
expect(paths).not.toContain('handleAlias');
const localSources = [...result.graph.iterRelationships()]
.filter((edge) => edge.type === 'TAINTED')
.map((edge) => result.graph.getNode(edge.sourceId)?.properties.startLine);
const source = fs.readFileSync(path.join(repo, 'aliases.ts'), 'utf8').split('\n');
// TAINTED starts at the request read, immediately after the declaration.
const aliasLine =
source.findIndex((line) => line.startsWith('export function handleAlias')) + 2;
const bypassLine =
source.findIndex((line) => line.startsWith('export function handleBypass')) + 2;
expect(localSources).not.toContain(aliasLine);
expect(localSources).toContain(bypassLine);
}, 120_000);
it('retains findings for bypasses, mutation, uncertain types and unsupported sink uses', async () => {
const cases: Record<string, string> = {
bypass:
'const value: string = req.query.ref; if (flag) { if (!/^[0-9a-f]{7,40}$/.test(value)) return; } execSync("git show " + value);',
reassigned:
'let value: string = req.query.ref; if (!/^[0-9a-f]{7,40}$/.test(value)) return; value = req.query.other; execSync("git show " + value);',
wrongValue:
'const value: string = req.query.ref; const other: string = "abcdef0"; if (!/^[0-9a-f]{7,40}$/.test(other)) return; execSync("git show " + value);',
lateGuard:
'const value: string = req.query.ref; execSync("git show " + value); if (!/^[0-9a-f]{7,40}$/.test(value)) return;',
partialPattern:
'const value: string = req.query.ref; if (!/^[0-9a-f]{7,40}/.test(value)) return; execSync("git show " + value);',
multiline:
'const value: string = req.query.ref; if (!/^[0-9a-f]{7,40}$/m.test(value)) return; execSync("git show " + value);',
customShell:
'const value: string = req.query.ref; if (!/^[0-9a-f]{7,40}$/.test(value)) return; execSync("git show " + value, {shell: "custom-shell"});',
interpreter:
'const value: string = req.query.ref; if (!/^[0-9a-f]{7,40}$/.test(value)) return; execSync("bash -c " + value);',
executable:
'const value: string = req.query.ref; if (!/^[0-9a-f]{7,40}$/.test(value)) return; spawn(value, []);',
extraInput:
'const value: string = req.query.ref; if (!/^[0-9a-f]{7,40}$/.test(value)) return; execSync("git show " + value + req.query.other);',
caught:
'const value: string = req.query.ref; try { if (!/^[0-9a-f]{7,40}$/.test(value)) throw new Error(); } catch {} execSync("git show " + value);',
unknownObject:
'const value: any = req.query.ref; if (!/^[0-9a-f]{7,40}$/.test(value)) return; execSync("git show " + value);',
suspended:
'const value: string = req.query.ref; if (!/^[0-9a-f]{7,40}$/.test(value)) return; await pending(); execSync("git show " + value);',
};
const repo = repos.dir();
fs.writeFileSync(
path.join(repo, 'unsafe.ts'),
'import {execSync, spawn} from "node:child_process";\n' +
Object.entries(cases)
.map(
([name, body]) =>
`export ${name === 'suspended' ? 'async ' : ''}function ${name}(req: any, flag: boolean) { ${body} }`,
)
.join('\n') +
String.raw`
function escapedValue(value: string, req: any) {
if (!/^[0-9a-f]{7,40}$/.test(value)) return;
\u0076alue = req.query.other;
execSync('git show ' + value);
}
export function handleEscaped(req: any) { escapedValue(req.query.ref, req); }
`,
);
const result = await runPipelineFromRepo(repo, () => {}, { pdg: true, workerPoolSize: 1 });
const crossSources = [...result.graph.iterRelationships()]
.filter((edge) => edge.type === 'TAINT_PATH')
.map((edge) => result.graph.getNode(edge.sourceId)?.properties.name);
expect(crossSources).toContain('handleEscaped');
const sourceLines = [...result.graph.iterRelationships()]
.filter((edge) => edge.type === 'TAINTED')
.map((edge) => Number(result.graph.getNode(edge.sourceId)?.properties.startLine));
for (const [index, name] of Object.keys(cases).entries()) {
expect(sourceLines, `missing conservative finding for ${name}`).toContain(index + 2);
}
}, 120_000);
it('preserves a validated value across command construction without hiding unsafe siblings', async () => {
const repo = repos.dir();
fs.writeFileSync(
path.join(repo, 'commands.ts'),
`import { execSync } from 'node:child_process';
const SHA_RE = /^[0-9a-f]{7,40}$/;
function runGit(args: string) { return execSync('git ' + args); }
function commitSubject(ref: string) {
if (!SHA_RE.test(ref)) throw new Error('invalid sha');
return runGit('log -1 --format=%s ' + ref).trim();
}
function showRevision(ref: string) {
if (!/^[0-9a-f]{7,40}$/.test(ref)) return;
return runGit('show ' + ref);
}
export function handleGuarded(req: any) { return commitSubject(req.query.ref as string); }
export function handleShow(req: any) { return showRevision(req.query.ref as string); }
export function handleUnsafe(req: any) { return runGit('blame ' + req.query.ref); }
export function handleInterpreter(req: any) {
const value: string = req.query.ref;
if (!SHA_RE.test(value)) return;
execSync('bash -c ' + value);
}
export function handleLocal(req: any) {
const ref: string = req.query.ref;
if (!SHA_RE.test(ref)) return;
execSync('git log -1 ' + ref);
}
export function handleWrongBranch(req: any) {
const ref: string = req.query.ref;
if (SHA_RE.test(ref)) return;
execSync('git log -1 ' + ref);
}
export function handleLiteral(req: any) {
const ref = req.query.ref;
if (ref !== 'abcdef0') return;
execSync('git show ' + ref);
}
`,
);
const result = await runPipelineFromRepo(repo, () => {}, { pdg: true, workerPoolSize: 1 });
const edges = [...result.graph.iterRelationships()];
const crossFunctionSources = edges
.filter((edge) => edge.type === 'TAINT_PATH')
.map((edge) => result.graph.getNode(edge.sourceId)?.properties.name);
expect(crossFunctionSources).toContain('handleUnsafe');
expect(crossFunctionSources).not.toContain('handleGuarded');
expect(crossFunctionSources).not.toContain('handleShow');
const localFindings = edges.filter((edge) => edge.type === 'TAINTED');
const localSourceLines = localFindings.map(
(edge) => result.graph.getNode(edge.sourceId)?.properties.startLine,
);
expect([...new Set(localSourceLines)].sort((a, b) => Number(a) - Number(b))).toEqual([16, 26]);
}, 120_000);
});

View file

@ -156,6 +156,8 @@ withTestLbugDB(
expect(vuln.pathIncomplete).toBeUndefined();
// The intra-procedural contract caveat reaches the consumer.
expect(result.note).toMatch(/intra-procedural/i);
expect(result.note).toContain('Imported and wrapper validators');
expect(result.note).toContain('not runtime validation');
});
it('anchorless enumerate is deterministic across calls', async () => {

View file

@ -475,6 +475,56 @@ function siteFact(cfg: FunctionCfg, line?: number): StatementFacts {
}
describe('M3 U1 — taint-site harvest: call sites', () => {
it.each([2, 33])('preserves every repeated argument occurrence with %i arguments', (count) => {
const cfg = cfgOf(`function f(x) { exec(${Array(count).fill('x').join(',')}); }`);
const fact = siteFact(cfg, 1);
const x = bindingIdx(cfg, 'x');
expect(fact.sites![0].args).toEqual(Array.from({ length: count }, () => [x]));
expect(fact.uses.filter((binding) => binding === x)).toHaveLength(1);
if (count > 32) expect(cfg.refinementSafe).toBe(false);
else
expect(fact.refinement?.calls?.[0].arguments).toEqual(
Array(count).fill({ kind: 'binding', binding: x }),
);
});
it('retains ordinary call occurrences after exhausting the value-shape budget', () => {
const cfg = cfgOf(`function f(x) { ${'exec(x);'.repeat(257)} }`);
const facts = allFacts(cfg).filter((fact) =>
fact.sites?.some((site) => site.callee === 'exec'),
);
const x = bindingIdx(cfg, 'x');
expect(facts).toHaveLength(257);
expect(
facts.every((fact) => fact.uses.includes(x) && fact.sites![0].args?.[0]?.[0] === x),
).toBe(true);
expect(facts[0].refinement?.calls).toHaveLength(1);
expect(facts[256].refinement).toBeUndefined();
expect(cfg.refinementSafe).toBe(false);
});
it.each(['label: function g() {}', 'if (condition) function g() {}'])(
'resolves a declaration beneath a non-scope parent: %s',
(declaration) => {
const cfg = cfgOf(`function f(g) {
{
let g = source();
${declaration}
use(g);
}
use(g);
}`);
const [outer, inner] = bindingIdxs(cfg, 'g');
expect(bindingIdxs(cfg, 'g')).toHaveLength(2);
const facts = allFacts(cfg);
const declared = facts.filter((fact) => fact.line === 4).flatMap((fact) => fact.defs);
expect(declared).toContain(inner);
expect(declared).not.toContain(outer);
expect(facts.find((fact) => fact.line === 5)?.uses).toContain(inner);
expect(facts.find((fact) => fact.line === 7)?.uses).toContain(outer);
},
);
it('exec(a, b) → one call site mapping position 0→[a], 1→[b]', () => {
const cfg = cfgOf(`function f(a, b) { exec(a, b); }`);
const sites = siteFact(cfg, 1).sites!;

View file

@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest';
import { cfgOf, parse } from '../../helpers/ts-cfg-harness.js';
import {
createRefinementFileContext,
readPredicate,
readValueExpression,
} from '../../../src/core/ingestion/cfg/visitors/typescript-refinement.js';
import type { SyntaxNode } from '../../../src/core/ingestion/utils/ast-helpers.js';
import { collectFunctionCfgs } from '../../../src/core/ingestion/cfg/collect.js';
import { createTypeScriptCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/typescript.js';
import { createFunctionRefinement } from '../../../src/core/ingestion/taint/guard-refinement.js';
import {
applyValueEffect,
provesInertCommand,
UNKNOWN_VALUE,
} from '../../../src/core/ingestion/taint/value-refinement.js';
function find(root: SyntaxNode, type: string): SyntaxNode {
if (root.type === type) return root;
for (const child of root.namedChildren) {
try {
return find(child, type);
} catch {
/* next subtree */
}
}
throw new Error(`No ${type}`);
}
function predicate(source: string) {
const root = parse(source);
return readPredicate(
find(root, 'if_statement').childForFieldName('condition')!,
() => 0,
createRefinementFileContext(root),
);
}
describe('normalized predicate and expression facts', () => {
it.each(['"prefix\narguments"', '/prefix\narguments/'])(
'preserves error-recovery uses and conservative fallback inside %s',
(literal) => {
const cfg = cfgOf(`function f(x) { const value = ${literal}; consume(x, value); }`);
const recovered = cfg.bindings!.findIndex((binding) => binding.name === 'arguments');
expect(recovered).toBeGreaterThanOrEqual(0);
expect(
cfg.blocks.flatMap((block) => (block.statements ?? []).flatMap((s) => s.uses)),
).toContain(recovered);
expect(cfg.refinementSafe).toBe(false);
expect(createFunctionRefinement(cfg).guards.status).toBe('fallback');
},
);
it.each([
'{command}',
'{9: command}',
'[command]',
'[, command]',
'...command',
'{command = fallback()}',
'{nested: {command}}',
])('keeps non-identity formal bindings conservative: %s', (pattern) => {
const cfg = cfgOf(`function f(${pattern}: any) { consume(command); }`);
expect(cfg.refinementSafe).toBe(false);
expect(createFunctionRefinement(cfg).guards.status).toBe('fallback');
});
it('preserves guard and value identity through embedded coordinate mappings', () => {
const root = parse(`const SHA_RE = /^[0-9a-f]{7,40}$/;
function f(ref: string) {
if (!SHA_RE.test(ref)) return;
consume('git show ' + ref);
}`);
for (const mapLine of [undefined, (row: number) => row + 12]) {
const { cfgs } = collectFunctionCfgs(
root,
createTypeScriptCfgVisitor(),
'Component.vue',
0,
12,
mapLine,
);
expect(cfgs).toHaveLength(1);
const cfg = cfgs[0];
expect(cfg.functionStartLine).toBe(14);
const refinement = createFunctionRefinement(cfg);
const values = cfg.blocks.flatMap((block) =>
(block.statements ?? []).flatMap((statement, index) =>
(statement.sites ?? []).flatMap((site, siteIndex) =>
site.callee === 'consume'
? [
applyValueEffect(
refinement.argument(block.index, index, siteIndex, 0),
UNKNOWN_VALUE,
),
]
: [],
),
),
);
expect(values).toHaveLength(1);
expect(provesInertCommand(values[0], ['git show '])).toBe(true);
}
});
it('resolves the immutable module validator and its accepted polarity', () => {
const fact = predicate(
'const SHA_RE = /^[0-9a-f]{7,40}$/; function f(ref: string) { if (!SHA_RE.test(ref)) return; }',
);
expect(fact).toMatchObject({ subject: 0, accepts: false, restriction: { min: 7, max: 40 } });
});
it('recognizes an inline regex validator without treating it as a named callee', () => {
expect(
predicate('function f(ref: string) { if (!/^[0-9a-f]{7,40}$/.test(ref)) return; }'),
).toMatchObject({ subject: 0, accepts: false, restriction: { min: 7, max: 40 } });
});
it.each([
'const SHA_RE = /^[0-9a-f]{7,40}$/m; function f(ref: string) { if (!SHA_RE.test(ref)) return; }',
'const SHA_RE = /^[0-9a-f]{7,40}$/; SHA_RE.test = other; function f(ref: string) { if (!SHA_RE.test(ref)) return; }',
'const SHA_RE = /^[0-9a-f]{7,40}$/; function f(SHA_RE: any, ref: string) { if (!SHA_RE.test(ref)) return; }',
'const SHA_RE = /^[0-9a-f]{7,40}$/; export { SHA_RE }; function f(ref: string) { if (!SHA_RE.test(ref)) return; }',
'import { SHA_RE } from "./patterns"; function f(ref: string) { if (!SHA_RE.test(ref)) return; }',
'/x/.constructor.prototype.test = () => true; function f(ref: string) { if (!/^[0-9a-f]{7,40}$/.test(ref)) return; }',
'const OTHER = /x/; OTHER.constructor.prototype.test = () => true; function f(ref: string) { if (!/^[0-9a-f]{7,40}$/.test(ref)) return; }',
'const SHA_RE = /^[0-9a-f]{7,40}$/; mutate(SHA_RE); function f(ref: string) { if (!/^[0-9a-f]{7,40}$/.test(ref)) return; }',
String.raw`\u0067lobalThis.RegExp.prototype.test = () => true; function f(ref: string) { if (!/^[0-9a-f]{7,40}$/.test(ref)) return; }`,
'globalThis.RegExp.prototype.test = () => true; function f(ref: string) { if (!/^[0-9a-f]{7,40}$/.test(ref)) return; }',
'eval("RegExp.prototype.test = () => true"); function f(ref: string) { if (!/^[0-9a-f]{7,40}$/.test(ref)) return; }',
])('retains unknown validator identity, mutation and unsupported flags: %s', (source) => {
expect(predicate(source)).toBeUndefined();
});
it('strict literal equality establishes a primitive without a cast assumption', () => {
expect(predicate('function f(ref: any) { if (ref !== "abcdef0") return; }')).toMatchObject({
subject: 0,
accepts: false,
establishesPrimitive: true,
});
});
it('normalizes exact literal concatenation and rejects unknown transformations', () => {
const expression = find(parse('const value = "git " + args;'), 'binary_expression');
expect(readValueExpression(expression, () => 2)).toEqual({
kind: 'concat',
parts: [
{ kind: 'literal', value: 'git ' },
{ kind: 'binding', binding: 2 },
],
});
expect(
readValueExpression(find(parse('transform(args);'), 'call_expression'), () => 2),
).toEqual({ kind: 'unknown' });
expect(
readValueExpression(find(parse('new Wrapper(args);'), 'new_expression'), () => 2),
).toEqual({ kind: 'non-string' });
});
});

View file

@ -39,6 +39,8 @@ describe('parse cache artifact identity', () => {
'src/core/ingestion/languages/new-provider.ts',
'src/core/ingestion/preprocess.ts',
'src/core/ingestion/workers/helper.ts',
'src/core/ingestion/taint/guard-refinement.ts',
'src/core/ingestion/taint/value-refinement.ts',
'src/core/ingestion/languages/cobol/flow-source.ts',
'node_modules/gitnexus-shared/src/index.ts',
'node_modules/gitnexus-shared/dist/index.js',

View file

@ -6,6 +6,7 @@ import { tmpdir } from 'os';
import path from 'path';
import type { ParsedFile } from 'gitnexus-shared';
import { TEST_PARSE_IDENTITY } from '../helpers/cache-identity.js';
import { cfgsOf } from '../helpers/ts-cfg-harness.js';
import {
clearParsedFileStore,
persistParsedFileChunk,
@ -76,6 +77,27 @@ function makeStoreEntry(filePath: string, overrides: Record<string, unknown>): P
}
describe('parsedfile-store', () => {
it('preserves normalized guard and value facts through the disk seal', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-guard-'));
try {
const cfgs = cfgsOf(`function guarded(ref: string) {
if (!/^[0-9a-f]{7,40}$/.test(ref)) return;
consume('git show ' + ref);
}`);
expect(cfgs[0].refinementSafe).toBe(true);
expect(
cfgs[0].blocks.some((block) =>
block.statements?.some((statement) => statement.refinement?.predicate),
),
).toBe(true);
const file: ParsedFile = { ...makeParsedFile('guard.ts'), cfgSideChannel: cfgs };
await persistParsedFileChunk(dir, 'guard-facts', [file]);
const loaded = await loadParsedFilesForPaths(dir, new Set(['guard.ts']));
expect(loaded.get('guard.ts')?.cfgSideChannel).toEqual(cfgs);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('round-trips ParsedFiles (incl. Scope Maps) and filters by requested paths', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-'));
try {

View file

@ -96,7 +96,12 @@ describe('taint emission after the disk scope seal', () => {
expect(sinks(1)).toEqual([]);
expect(sinks(2)).toEqual([
{ param: 0, sinkKind: 'command-injection' },
{ param: 1, sinkKind: 'command-injection' },
{
param: 1,
sinkKind: 'command-injection',
valueEffect: { kind: 'input' },
inertCommandPrefixes: ['git log -1 ', 'git log -1 --format=%s ', 'git show '],
},
]);
expect(sinks(3)).toEqual([]);
});

View file

@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';
import { cfgOf, parse } from '../../helpers/ts-cfg-harness.js';
import {
computeGuardRefinements,
createFunctionRefinement,
type GuardFacts,
} from '../../../src/core/ingestion/taint/guard-refinement.js';
function fixture(body = 'if (!valid(x)) return; consume(x);') {
const cfg = cfgOf(`function f(x: string) { ${body} }`);
const subject = cfg.bindings!.findIndex((binding) => binding.name === 'x');
const condition = cfg.blocks.find((block) => block.text.includes('valid(x)'))!.index;
const sink = cfg.blocks.find((block) => block.text.includes('consume(x)'))!.index;
const facts: GuardFacts = {
eligible: true,
primitiveBindings: [subject],
predicates: [
{
block: condition,
subject,
accepts: false,
restriction: { chars: 'abcdef0123456789', min: 7, max: 40 },
},
],
};
return { cfg, subject, sink, facts };
}
describe('accepted-edge guard proof', () => {
it('discards malformed optional call facts without breaking baseline analysis', () => {
const cfg = cfgOf(
'function f(x: string) { if (!/^[0-9a-f]{7,40}$/.test(x)) return; consume("git show " + x); }',
);
const malformed = {
...cfg,
blocks: cfg.blocks.map((block) => ({
...block,
statements: block.statements?.map((statement) => ({
...statement,
...(statement.refinement?.calls
? {
refinement: {
...statement.refinement,
calls: statement.refinement.calls.map((call) => ({ ...call, site: 9999 })),
},
}
: {}),
})),
})),
};
const refinement = createFunctionRefinement(malformed);
expect(refinement.argument(4, 0, 0, 0)).toBeUndefined();
expect(refinement.isPredicate(2, 0, 0)).toBe(false);
expect(
createFunctionRefinement({ ...cfg, refinementSafe: undefined }).argument(4, 0, 0, 0),
).toBeUndefined();
});
it('charges large statement operand lists against the traversal budget', () => {
const { cfg, facts } = fixture();
const oversized = {
...cfg,
blocks: cfg.blocks.map((block) => ({
...block,
statements: block.statements?.map((statement) => ({
...statement,
uses: Array(1000).fill(0),
})),
})),
};
const proof = computeGuardRefinements(oversized, facts, { maxVisits: 128 });
expect(proof.status).toBe('fallback');
expect(proof.visits).toBeLessThanOrEqual(129);
});
it.each(['if (!/^[0-9a-f]{7,40}$/.test(ref)) return;', 'if (ref !== "abcdef0") return;'])(
'rejects a proof when an escaped spelling can hide a write: %s',
(guard) => {
const source =
'function f(ref:string) {' +
guard +
String.raw` \u0072ef = input(); consume('git show ' + ref); }`;
expect(parse(source).hasError).toBe(false);
const cfg = cfgOf(source);
expect(cfg.refinementSafe).toBe(false);
expect(createFunctionRefinement(cfg).guards.status).toBe('fallback');
},
);
it('certifies only uses dominated by the accepted edge', () => {
const { cfg, facts, subject, sink } = fixture();
const proof = computeGuardRefinements(cfg, facts);
expect(proof.status).toBe('computed');
expect(proof.at(subject, sink)?.min).toBe(7);
expect(proof.at(subject, cfg.entryIndex)).toBeUndefined();
});
it('does not borrow validation from the rejected edge', () => {
const { cfg, facts, subject, sink } = fixture('if (valid(x)) return; consume(x);');
const predicates = facts.predicates.map((predicate) => ({ ...predicate, accepts: true }));
expect(
computeGuardRefinements(cfg, { ...facts, predicates }).at(subject, sink),
).toBeUndefined();
});
it('rejects a guard with a bypass path', () => {
const { cfg, facts, subject, sink } = fixture(
'if (flag) { if (!valid(x)) return; } consume(x);',
);
expect(computeGuardRefinements(cfg, facts).at(subject, sink)).toBeUndefined();
});
it('charges transitive alias expansion to the same traversal budget', () => {
const { cfg, facts, subject } = fixture();
const aliases = new Map(
Array.from({ length: 200 }, (_, index) => [
subject + index,
{ kind: 'binding' as const, binding: subject + index + 1 },
]),
);
const proof = computeGuardRefinements(cfg, facts, { maxVisits: 128 }, aliases);
expect(proof.status).toBe('fallback');
expect(proof.visits).toBeLessThanOrEqual(129);
expect(proof.at(subject, cfg.entryIndex)).toBeUndefined();
});
it('does not certify reassigned parameters or unknown primitive identity', () => {
const { cfg, facts, subject, sink } = fixture(
'if (!valid(x)) return; x = input(); consume(x);',
);
expect(computeGuardRefinements(cfg, facts).at(subject, sink)).toBeUndefined();
expect(
computeGuardRefinements(cfg, { ...facts, primitiveBindings: [] }).at(subject, sink),
).toBeUndefined();
});
it('retains no proof after a traversal budget failure or unsupported region', () => {
const { cfg, facts, subject, sink } = fixture();
const capped = computeGuardRefinements(cfg, facts, { maxVisits: 1 });
expect(capped.status).toBe('fallback');
expect(capped.at(subject, sink)).toBeUndefined();
expect(
computeGuardRefinements(cfg, { ...facts, eligible: false }).at(subject, sink),
).toBeUndefined();
});
});

View file

@ -42,6 +42,144 @@ function summary(
const map = (...ss: FunctionSummary[]) => new Map(ss.map((s) => [s.fnId, s]));
describe('guard value composition', () => {
it('caps retained worklist payload across weakening alternatives', () => {
const source = summary('source', {
paramCount: 0,
sourceToCallArg: ['a', 'b', 'c'].map((chars) => ({
sourceKind: 'remote-input',
callLine: 1,
argIndex: 0,
calleeName: 'sink',
valueEffect: {
kind: 'concat',
parts: [
{ kind: 'literal', value: 'git show ' },
{ kind: 'restrict', value: { kind: 'input' }, restriction: { chars, min: 1, max: 1 } },
],
},
})),
});
const sink = summary('sink', {
paramToSink: [
{
param: 0,
sinkKind: 'command-injection',
inertCommandPrefixes: ['git show '],
valueEffect: { kind: 'input' },
},
],
});
const result = solveInterprocTaint(
map(source, sink),
[{ callerId: source.fnId, calleeId: sink.fnId, calleeName: 'sink' }],
{ maxRefinementBytes: 150 },
);
expect(result.refinementFallback).toBe(true);
expect(result.findings).toHaveLength(1);
});
const safeValue = {
kind: 'concat',
parts: [
{ kind: 'literal', value: 'git show ' },
{
kind: 'restrict',
value: { kind: 'input' },
restriction: { chars: '0123456789abcdef', min: 7, max: 40 },
},
],
} as const;
const source = (id: string, unsafe = false) =>
summary(id, {
paramCount: 0,
sourceToCallArg: [
{
sourceKind: 'remote-input',
callLine: 1,
argIndex: 0,
calleeName: 'sink',
valueEffect: safeValue,
},
...(unsafe
? [{ sourceKind: 'remote-input' as const, callLine: 2, argIndex: 0, calleeName: 'sink' }]
: []),
],
});
const sink = () =>
summary('sink', {
paramToSink: [
{
param: 0,
sinkKind: 'command-injection',
valueEffect: { kind: 'input' },
inertCommandPrefixes: ['git show '],
},
],
});
it('suppresses only the source whose complete command meets the sink contract', () => {
const safe = source('safe');
const unsafe = summary('unsafe', {
sourceToCallArg: [
{ sourceKind: 'remote-input', callLine: 1, argIndex: 0, calleeName: 'sink' },
],
});
const result = solveInterprocTaint(map(safe, unsafe, sink()), [
{ callerId: 'safe', calleeId: 'sink', calleeName: 'sink' },
{ callerId: 'unsafe', calleeId: 'sink', calleeName: 'sink' },
]);
expect(result.findings.map((finding) => finding.sourceFnId)).toEqual(['unsafe']);
});
it('keeps a mixed safe/unsafe route from the same source reported', () => {
const mixed = source('mixed', true);
const result = solveInterprocTaint(map(mixed, sink()), [
{ callerId: 'mixed', calleeId: 'sink', calleeName: 'sink' },
]);
expect(result.findings).toHaveLength(1);
});
it('reconsiders earlier clean states when the global proof budget is exhausted', () => {
const result = solveInterprocTaint(
map(source('first'), source('second'), sink()),
[
{ callerId: 'first', calleeId: 'sink', calleeName: 'sink' },
{ callerId: 'second', calleeId: 'sink', calleeName: 'sink' },
],
{ maxRefinementBytes: 150 },
);
expect(result.refinementFallback).toBe(true);
expect(result.findings.map((finding) => finding.sourceFnId)).toEqual(['first', 'second']);
});
it('widens recursive command growth instead of enumerating paths', () => {
const recursive = sink();
const result = solveInterprocTaint(
map(source('source'), {
...recursive,
paramToCallArg: [
{
param: 0,
callLine: 3,
argIndex: 0,
calleeName: 'sink',
valueEffect: {
kind: 'concat',
parts: [{ kind: 'literal', value: 'again ' }, { kind: 'input' }],
},
},
],
}),
[
{ callerId: 'source', calleeId: 'sink', calleeName: 'sink' },
{ callerId: 'sink', calleeId: 'sink', calleeName: 'sink' },
],
);
expect(result.findings).toHaveLength(1);
});
});
describe('solveInterprocTaint — seed path respects maxHops (#2084 review P2-7)', () => {
it('caps the seed path at maxHops:1 (truncated prefix, not a 2-entry path)', () => {
const A = summary('Function:a.ts:A', {

View file

@ -276,7 +276,12 @@ function unrelated(req, value) {
expect(harvested.gaps).toBe(0);
expect(
harvested.summaries.find((summary) => summary.fnId === 'function:0')?.paramToSink,
).toContainEqual({ param: 1, sinkKind: 'command-injection' });
).toContainEqual({
param: 1,
sinkKind: 'command-injection',
valueEffect: { kind: 'input' },
inertCommandPrefixes: ['git log -1 ', 'git log -1 --format=%s ', 'git show '],
});
expect(
harvested.summaries.find((summary) => summary.fnId === 'function:1')?.paramToSink,
).toEqual([]);

View file

@ -546,6 +546,36 @@ describe('multi-source identity — distinct sources do not merge at one def', (
// ── kind-set exclusion model (real built-in model) ──────────────────────────
describe('kind-set exclusions — sanitizers neutralize their kinds only', () => {
it.each([
['direct alias', 'const command = "git show " + ref;', 'command', 0],
[
'transitive alias',
'const args = "show " + ref; const command = "git " + args;',
'command',
0,
],
['template alias', 'const command = `git show ${ref}`;', 'command', 0],
['unsafe template', 'const command = `git show ;${ref}`;', 'command', 1],
['bypassed guard', 'const command = "git show " + ref;', 'command', 1],
['mutated subject', 'ref = req.query.other; const command = "git show " + ref;', 'command', 1],
['mutated alias', 'let command = "git show " + ref; command = req.query.other;', 'command', 1],
])('keeps certificate semantics across blocks: %s', (name, definition, argument, findings) => {
const guard = 'if (!/^[0-9a-f]{7,40}$/.test(ref)) return;';
const r = analyze(
`import {execSync} from 'node:child_process';
function f(req: any, log: boolean) {
${name === 'mutated subject' ? 'let' : 'const'} ref: string = req.query.ref;
${name === 'bypassed guard' ? `if (log) { ${guard} }` : guard}
${definition}
if (log) console.log('validated');
execSync(${argument});
}`,
{ spec: TS_JS_TAINT_MODEL },
);
expect(r.status).toBe('computed');
expect(r.findings).toHaveLength(findings);
});
it('built-in model catches argv-form child_process command sinks', () => {
const r = analyze(
`import { execFileSync } from 'node:child_process';

View file

@ -28,6 +28,31 @@ const baseFacts: Facts = {
};
describe('ownFactsDigest', () => {
it('includes value effects and sink-use contracts in summary identity', () => {
const restricted: Facts = {
...baseFacts,
paramToSink: [
{
param: 0,
sinkKind: 'command-injection',
valueEffect: { kind: 'input' },
inertCommandPrefixes: ['git show ', 'git log -1 '],
},
],
};
const other: Facts = {
...restricted,
paramToSink: [{ ...restricted.paramToSink[0], valueEffect: { kind: 'unknown' } }],
};
const reordered: Facts = {
...restricted,
paramToSink: [
{ ...restricted.paramToSink[0], inertCommandPrefixes: ['git log -1 ', 'git show '] },
],
};
expect(ownFactsDigest(restricted)).not.toBe(ownFactsDigest(other));
expect(ownFactsDigest(restricted)).toBe(ownFactsDigest(reordered));
});
it('is stable for identical facts', () => {
expect(ownFactsDigest(baseFacts)).toBe(ownFactsDigest({ ...baseFacts }));
});

View file

@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import {
applyValueEffect,
joinRefinedValues,
parseAnchoredAsciiPattern,
provesInertCommand as evaluateCommand,
UNKNOWN_VALUE,
type ValueEffect,
type RefinedValue,
} from '../../../src/core/ingestion/taint/value-refinement.js';
const provesInertCommand = (value: RefinedValue) =>
evaluateCommand(value, ['git log -1 ', 'git log -1 --format=%s ', 'git show ']);
describe('bounded value refinements', () => {
const restriction = { chars: '0123456789abcdef', min: 7, max: 40 };
const validated: ValueEffect = { kind: 'restrict', value: { kind: 'input' }, restriction };
const prefix = (text: string, value: ValueEffect): ValueEffect => ({
kind: 'concat',
parts: [{ kind: 'literal', value: text }, value],
});
it('composes validator and literal construction across function boundaries', () => {
const args = applyValueEffect(prefix('log -1 ', validated), UNKNOWN_VALUE);
const command = applyValueEffect(prefix('git ', { kind: 'input' }), args);
expect(provesInertCommand(command)).toBe(true);
expect(
provesInertCommand(
applyValueEffect(prefix('git log -1 --format=%s ', validated), UNKNOWN_VALUE),
),
).toBe(true);
expect(
provesInertCommand(applyValueEffect(prefix('git show ', validated), UNKNOWN_VALUE)),
).toBe(true);
});
it.each(['bash -c ', 'git -c alias.foo=!sh foo ', 'git ', 'env '])(
'does not certify executable or interpreter selection: %s',
(text) =>
expect(provesInertCommand(applyValueEffect(prefix(text, validated), UNKNOWN_VALUE))).toBe(
false,
),
);
it('keeps an independent unknown contribution unsafe', () => {
const effect: ValueEffect = {
kind: 'concat',
parts: [prefix('git log -1 ', validated), { kind: 'unknown' }],
};
expect(provesInertCommand(applyValueEffect(effect, UNKNOWN_VALUE))).toBe(false);
});
it('weakens safe/unsafe joins and recursive template growth to unknown', () => {
const safe = applyValueEffect(prefix('git show ', validated), UNKNOWN_VALUE);
expect(joinRefinedValues(safe, UNKNOWN_VALUE)).toEqual(UNKNOWN_VALUE);
const growing = applyValueEffect(prefix('again ', { kind: 'input' }), safe);
expect(joinRefinedValues(safe, growing)).toEqual(UNKNOWN_VALUE);
});
it('does not turn a known incompatible object into a primitive proof', () => {
const incompatible: RefinedValue = { kind: 'non-string' };
const first = applyValueEffect(validated, incompatible);
expect(first).toEqual(incompatible);
for (const value of [first, joinRefinedValues(incompatible, UNKNOWN_VALUE)]) {
const second = applyValueEffect(prefix('git show ', validated), value);
expect(provesInertCommand(second)).toBe(false);
}
expect(joinRefinedValues(UNKNOWN_VALUE, incompatible)).toEqual(incompatible);
expect(applyValueEffect(prefix('git show ', { kind: 'input' }), incompatible)).toEqual(
incompatible,
);
});
it('discards all proof when an expression exceeds its budget', () => {
let effect: ValueEffect = validated;
for (let i = 0; i < 40; i++) effect = { kind: 'restrict', value: effect, restriction };
expect(applyValueEffect(effect, UNKNOWN_VALUE)).toEqual(UNKNOWN_VALUE);
});
it('parses only the bounded, fully anchored ASCII predicate subset', () => {
expect(parseAnchoredAsciiPattern('^[0-9a-f]{7,40}$', '')).toEqual(restriction);
expect(parseAnchoredAsciiPattern('^[a-z][0-9]{2}$', '')).toEqual({
chars: '0123456789abcdefghijklmnopqrstuvwxyz',
min: 3,
max: 3,
});
for (const [pattern, flags] of [
['[0-9a-f]{7,40}', ''],
['^[0-9a-f]+$', ''],
['^[^;]+$', ''],
['^[0-9a-f]{7,40}$', 'g'],
['^[0-9a-f]{7,40}$', 'm'],
['^(a+)+$', ''],
['^[a-z]{1,999999999}$', ''],
])
expect(parseAnchoredAsciiPattern(pattern, flags)).toBeUndefined();
});
});

View file

@ -489,7 +489,14 @@ describe('GITNEXUS_TOOLS', () => {
'sources read inside inline callbacks and returned handlers can flow through helper functions',
);
expect(d.toLowerCase()).toContain('property/field');
expect(d.toLowerCase()).toContain('guard-style');
expect(d.toLowerCase()).toContain(
'modeled guards can refine unchanged primitive-string values',
);
expect(d.toLowerCase()).toContain('imported and wrapper validators');
expect(d.toLowerCase()).toContain(
'unsupported predicates or sink uses remain conservative findings',
);
expect(d.toLowerCase()).toContain('declared string types are analysis assumptions');
expect(d.toLowerCase()).toContain('cross-function');
expect(d.toLowerCase()).toContain('commonjs');
expect(d.toLowerCase()).toContain('exception');