fullshield 0.1.3 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,53 @@
2
2
 
3
3
  All notable changes to FullShield are documented here.
4
4
 
5
- ## Unreleased
5
+ ## 0.2.6 — 2026-10-16
6
+
7
+ - v0.2.5 reached staging with a fresh token, then sigstore threw `TLOG_CREATE_ENTRY_ERROR` because Rekor already had that transparency-log entry (HTTP 409). npm 12.2.0 leaves `fetchOnConflict` off, so the publish stopped. Staging now turns that on before loading the publisher, and gives the Rekor request 120 seconds. It still refuses to publish without provenance.
8
+
9
+ ## 0.2.5 — 2026-10-16
10
+
11
+ - The publish probe saved the GitHub OIDC token, then verify ran for 24 minutes. npm rejected that expired token with HTTP 401 when staging started. Staging now requests a new token immediately before the exchange, and the probe does not save one. The job pins npm 12.2.0.
12
+
13
+ ## 0.2.4 — 2026-10-16
14
+
15
+ - Publish installs npm 12. `npm pack --json` is now an object, and the pack budget was parsing the inner `files` array. It reads the pack entry from either shape.
16
+
17
+ ## 0.2.3 — 2026-10-16
18
+
19
+ - The publish checkout was shallow, so `semver-check` could see only the tag being released. That job now fetches full history and tags before comparing versions.
20
+
21
+ ## 0.2.2 — 2026-10-16
22
+
23
+ - `v0.2.1` failed typecheck before staging. The baseline-tag test now types its tag argument.
24
+
25
+ ## 0.2.1 — 2026-10-16
26
+
27
+ - `v0.2.0` was tagged and the publish job failed before staging. `semver-check` treated that tag as the previous release and compared `0.2.0` to itself. The check now skips a tag that points at HEAD.
28
+
29
+ ## 0.2.0 — 2026-10-16
30
+
31
+ Phases 36–45 ship in this version. `0.3.0` and `0.4.0` were not tagged.
32
+
33
+ ### Phase 36 — provenance and semver
34
+
35
+ - Publish refuses to stage without provenance. `0.1.3` has no attestation because the stage retried after a sigstore 409. The publish job now polls `dist.attestations` and fails if it stays empty.
36
+ - `scripts/semver-check.mjs`, `scripts/pack-budget.mjs`, and `scripts/provenance-gate.mjs`.
37
+ - TypeORM `class User extends BaseEntity` plus `User.find` is not `SAST-NOSQLI-001` when the file does not import `typeorm`. A bare `User.find(req.body)` is unchanged.
38
+ - Default-scan snapshots are `bench/snapshots/0.1.1.json`, `bench/snapshots/0.1.3.json`, and `bench/snapshots/HEAD.json`. Finding keys are `ruleId`, `file`, `line`, `severity`. Published `0.1.3` and this tree match: vulnerable-app 35, clean-app 0, nodegoat 379, juice-shop 105, dvna 23, goof 354, vulnerable-node 61. `0.1.1` has no `--offline-safe` flag, so that snapshot is `scan --json` only. It differs. The printed diff is `bench/snapshots/DIFF.txt`. That file is not a human approval.
39
+
40
+ ### Phases 37–45
41
+
42
+ - Holdout registry, three untouched apps, and review sheets. Those apps were not scanned.
43
+ - Comparison harness. One local run is in `COMPARISON.md`: semgrep 1.180.0, osv-scanner 2.7.0, npm 11.19.0. Mapped class buckets stayed empty. osv-scanner returned no JSON for juice-shop and dvna (no lockfile at those pins). Commercial scanners were not executed. This is not a recall claim.
44
+ - Symbol reachability when an advisory's `affected_functions` list is present. Advisories without that list stay on the import-level class. `DEPS-MALICIOUS-001` is not downgraded.
45
+ - Taint propagation for callbacks, `call`/`apply`, `Object.assign`, spread, `this` fields, and closures behind `FULLSHIELD_EXPERIMENTAL_TAINT_V2=1`. Default scan does not enable it.
46
+ - Ten real-world repos are pinned. Nine produced finding counts in `BENCHMARK.md`. next.js aborted with a JavaScript heap out of memory and has no count. Nothing is labeled, so precision is not sampled.
47
+ - Parse pool behind `FULLSHIELD_PARSE_POOL=1`. Measured times are in `bench/perf-baseline.json`. next.js aborted (SIGABRT) on every sample. nest and undici finished under the cold and warm targets and missed the 5s incremental target.
48
+ - Promotion report lists every experimental flag as unmet. Nothing was promoted.
49
+ - `fullshield doctor`, `FS-E###` error codes, and console output capped at 10 findings.
50
+ - GitHub Actions in the project workflows are pinned by commit SHA. Scorecard and CodeQL workflows are added. Scorecard is v2.5.0 from `ghcr.io` because `gcr.io` refused the pull. A tag workflow attaches a CycloneDX SBOM to the GitHub Release.
51
+ - `node scripts/repro-build.mjs --check` on 2026-10-16: 26 `dist` files hashed the same across two builds.
6
52
 
7
53
  ## 0.1.3 — 2026-10-15
8
54
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # FullShield
2
2
 
3
- **v0.1.3 / alpha.** One install for Node.js/TypeScript backends: detect → classify → report → notify → fix → gate (CI `--fail-on` + runtime monitor). It does not stop attacks by itself.
3
+ **v0.2.6 / alpha.** One install for Node.js/TypeScript backends: detect → classify → report → notify → fix → gate (CI `--fail-on` + runtime monitor). It does not stop attacks by itself.
4
4
 
5
5
  **Headline: dependency reachability.** A lockfile CVE is not the same as an unused package. FullShield classifies each OSV hit as `reachable` (imported and called), `imported-not-called` (−1), `reachable-via-dependency` (lockfile path from an imported direct dep — **severity kept**, path shown, e.g. `express > body-parser > qs`), `not-imported` (−2, only when no such path exists), or `reachability-unknown` (severity kept) when the lockfile format has no parsed dependency edges. DevDependency-only vulns are `info` with a `dev-only` note unless production sources import the package. Pair it with `fullshield sbom --format cyclonedx`.
6
6
 
@@ -89,8 +89,11 @@ In-scope public-bench patterns we still do not claim (one line each):
89
89
  - Juice Shop track-order XSS — server returns `res.json`; we do not treat JSON fields as HTML sinks.
90
90
  - Juice Shop `url.includes(allowedUrl)` — weak allowlist helper is not a redirect sink; we flag `res.redirect(tainted)`.
91
91
  - Juice Shop `{ _id: req.body.id }` — flagged as `SAST-NOSQLI-001` (medium) unless coerced (`String()`/`Number()`/`Boolean()`), schema-validated, or mongo-sanitize is applied. Same for goof `User.find({ password: req.body.password })`.
92
+ - Juice Shop `data/static/codefixes/*` — six `SAST-NOSQLI-001` hits are copies of the challenge fixes. They stay. There is no special-case exclusion for that directory.
92
93
  - `DEPS-VULN-001` — when an OSV hit exists, the finding is attributed to the `package.json` name line; `--offline-safe` on a cache-less clone still emits no OSV hits (`package.json:N` will not match).
93
94
  - DVNA calculator “stack leak” — that line is `mathjs.eval` (`SAST-EVAL-001`), not `err.stack`.
95
+ - Worker-pool timings from `node bench/perf.mjs --measure` on 2026-10-16 (`bench/perf-baseline.json`): nest cold p50 9.2s, warm 9.4s, peak 938 MB; undici cold p50 7.9s, warm 8.1s, peak 609 MB. Both missed the 5s incremental target (nest 8.8s, undici 8.0s). next.js aborted with SIGABRT on all five samples (JavaScript heap out of memory); the recorded times are time-to-abort (cold p50 55.4s, warm 56.7s, incremental 51.9s, peak 1705 MB), not finished scans.
96
+ - Real-world precision is not sampled. Nine of the ten pins in `bench/realworld.json` have finding counts in `BENCHMARK.md`. next.js produced no JSON. No false-positive rate is claimed.
94
97
 
95
98
  Public-app in-scope detection on **unreviewed** ground truth (`"reviewed": false` in `bench/*/ground-truth.json`). These are not product recall claims. Matching is class + file + line ±3. Helmet comment-only line attribution is not counted.
96
99
 
@@ -190,6 +193,22 @@ Public-app detection results (OWASP NodeGoat, Juice Shop, DVNA, seen holdouts `s
190
193
 
191
194
  `propose_fix` returns a diff only. It never writes your tree.
192
195
 
196
+ ## Developer commands
197
+
198
+ ```bash
199
+ npx fullshield doctor
200
+ npx fullshield doctor --offline
201
+ npx fullshield scan --debug
202
+ ```
203
+
204
+ `doctor` prints the Node version, lockfile, cache directory, experimental flags that are set to `1`, whether `fullshield.config.json` parses, and whether OSV is allowed to use the network. `--offline` skips that network. CLI failures use `FS-E###` codes. The message stays the same. A stack is printed only with `--debug`.
205
+
206
+ Console output shows the highest 10 findings, then `N more — use --json`. Each line has the file, the finding message, a fix line, and `rules/<rule-id>.md`.
207
+
208
+ Experimental taint propagation (`FULLSHIELD_EXPERIMENTAL_TAINT_V2=1`) and the parse pool (`FULLSHIELD_PARSE_POOL=1`) are off by default. Symbol reachability runs only when an OSV advisory lists `affected_functions`. Advisories without that list keep the import-level class.
209
+
210
+ `COMPARISON.md` is not linked here. The mapping has not been reviewed. A local semgrep / osv-scanner / npm audit run is in that file, and it is not a recall claim. Benchmark numbers stay in [`BENCHMARK.md`](BENCHMARK.md) with the unreviewed label.
211
+
193
212
  See [`SECURITY.md`](SECURITY.md), [`CONTRIBUTING.md`](CONTRIBUTING.md), and [`RELEASING.md`](RELEASING.md).
194
213
 
195
214
  ## License
package/dist/agent.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { createHash } from 'crypto';
2
2
  import { readFile, mkdir, writeFile, stat, readdir } from 'fs/promises';
3
- import { resolve, join, basename, dirname, relative, posix } from 'path';
3
+ import { resolve, join, basename, dirname, posix, relative } from 'path';
4
4
  import { execFile, spawnSync, spawn, execFileSync } from 'child_process';
5
5
  import { createInterface } from 'readline';
6
6
  import { promisify } from 'util';
7
7
  import { existsSync, statSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'fs';
8
8
  import { performance } from 'perf_hooks';
9
- import { homedir } from 'os';
10
- import ignoreModule from 'ignore';
9
+ import { homedir, availableParallelism } from 'os';
10
+ import { Worker } from 'worker_threads';
11
11
  import { fileURLToPath } from 'url';
12
+ import ignoreModule from 'ignore';
12
13
 
13
14
  var __defProp = Object.defineProperty;
14
15
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -1139,6 +1140,9 @@ function classesEnabled() {
1139
1140
  function mcpCfgEnabled() {
1140
1141
  return process.env.FULLSHIELD_EXPERIMENTAL_MCPCFG === "1";
1141
1142
  }
1143
+ function taintV2Enabled() {
1144
+ return process.env.FULLSHIELD_EXPERIMENTAL_TAINT_V2 === "1";
1145
+ }
1142
1146
 
1143
1147
  // src/modules/config/finding.ts
1144
1148
  init_finding();
@@ -1945,6 +1949,103 @@ function exactPinsFromManifest(pkg) {
1945
1949
 
1946
1950
  // src/modules/deps/reachability.ts
1947
1951
  init_finding();
1952
+
1953
+ // src/modules/deps/symbol-reachability.ts
1954
+ function affectedFunctionNames(vuln) {
1955
+ const names = /* @__PURE__ */ new Set();
1956
+ for (const affected of vuln.affected ?? []) {
1957
+ const list = affected.ecosystem_specific?.affected_functions;
1958
+ if (!Array.isArray(list)) continue;
1959
+ for (const item of list) {
1960
+ if (typeof item === "string" && item.length > 0) names.add(item);
1961
+ }
1962
+ }
1963
+ return [...names];
1964
+ }
1965
+ function escapeRegExp(value) {
1966
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1967
+ }
1968
+ function called(source, local) {
1969
+ return new RegExp(`\\b${escapeRegExp(local)}\\s*\\(`).test(source);
1970
+ }
1971
+ function memberCalled(source, object, member) {
1972
+ return new RegExp(`\\b${escapeRegExp(object)}\\s*\\.\\s*${escapeRegExp(member)}\\s*\\(`).test(source);
1973
+ }
1974
+ function sourceCallsAffectedSymbol(source, symbol) {
1975
+ const esc = escapeRegExp(symbol);
1976
+ const named = new RegExp(
1977
+ `import\\s*\\{([^}]+)\\}\\s*from\\s*['"][^'"]+['"]`,
1978
+ "g"
1979
+ );
1980
+ let match;
1981
+ while ((match = named.exec(source)) !== null) {
1982
+ const parts = (match[1] ?? "").split(",");
1983
+ for (const part of parts) {
1984
+ const bits = part.trim().split(/\s+as\s+/);
1985
+ const imported = bits[0]?.trim();
1986
+ const local = (bits[1] ?? bits[0])?.trim();
1987
+ if (imported === symbol && local !== void 0 && called(source, local)) return true;
1988
+ }
1989
+ }
1990
+ const star = /import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['"][^'"]+['"]/g;
1991
+ while ((match = star.exec(source)) !== null) {
1992
+ const local = match[1];
1993
+ if (local !== void 0 && memberCalled(source, local, symbol)) return true;
1994
+ }
1995
+ const subDefault = new RegExp(
1996
+ `import\\s+([A-Za-z_$][\\w$]*)\\s+from\\s+['"][^'"]+/${esc}['"]`
1997
+ );
1998
+ const sub = subDefault.exec(source);
1999
+ if (sub?.[1] !== void 0 && called(source, sub[1])) return true;
2000
+ const reqMember = new RegExp(
2001
+ `(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*require\\(\\s*['"][^'"]+['"]\\s*\\)\\s*\\.\\s*${esc}`
2002
+ );
2003
+ const reqHit = reqMember.exec(source);
2004
+ if (reqHit?.[1] !== void 0 && called(source, reqHit[1])) return true;
2005
+ const reqNs = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"][^'"]+['"]\s*\)/g;
2006
+ while ((match = reqNs.exec(source)) !== null) {
2007
+ const local = match[1];
2008
+ if (local !== void 0 && memberCalled(source, local, symbol)) return true;
2009
+ }
2010
+ const reqNamed = /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\(\s*['"][^'"]+['"]\s*\)/g;
2011
+ while ((match = reqNamed.exec(source)) !== null) {
2012
+ const parts = (match[1] ?? "").split(",");
2013
+ for (const part of parts) {
2014
+ const bits = part.trim().split(/\s*:\s*/);
2015
+ const imported = bits[0]?.trim();
2016
+ const local = (bits[1] ?? bits[0])?.trim();
2017
+ if (imported === symbol && local !== void 0 && called(source, local)) return true;
2018
+ }
2019
+ }
2020
+ const reqSub = new RegExp(
2021
+ `(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*require\\(\\s*['"][^'"]+/${esc}['"]\\s*\\)`
2022
+ );
2023
+ const subReq = reqSub.exec(source);
2024
+ if (subReq?.[1] !== void 0 && called(source, subReq[1])) return true;
2025
+ const barrel = new RegExp(`export\\s*\\{([^}]+)\\}\\s*from\\s*['"][^'"]+['"]`, "g");
2026
+ while ((match = barrel.exec(source)) !== null) {
2027
+ const parts = (match[1] ?? "").split(",");
2028
+ for (const part of parts) {
2029
+ const bits = part.trim().split(/\s+as\s+/);
2030
+ const exported = (bits[1] ?? bits[0])?.trim();
2031
+ if (exported === symbol) {
2032
+ const local = bits[0]?.trim();
2033
+ if (local !== void 0 && called(source, local)) return true;
2034
+ }
2035
+ }
2036
+ }
2037
+ return false;
2038
+ }
2039
+ function classifySymbolUse(symbols, sources) {
2040
+ for (const symbol of symbols) {
2041
+ for (const source of sources.values()) {
2042
+ if (sourceCallsAffectedSymbol(source, symbol)) return "symbol-reachable";
2043
+ }
2044
+ }
2045
+ return "symbol-not-called";
2046
+ }
2047
+
2048
+ // src/modules/deps/reachability.ts
1948
2049
  function packageNameFromSnippet(snippet) {
1949
2050
  if (snippet.startsWith("@")) {
1950
2051
  const second = snippet.indexOf("@", 1);
@@ -1973,9 +2074,9 @@ function isProductionSource(rel) {
1973
2074
  }
1974
2075
  function indexPackageUsage(sources) {
1975
2076
  const usage = /* @__PURE__ */ new Map();
1976
- const bump = (pkg, called) => {
2077
+ const bump = (pkg, called2) => {
1977
2078
  const prev = usage.get(pkg) ?? { called: false };
1978
- usage.set(pkg, { imported: true, called: prev.called || called });
2079
+ usage.set(pkg, { imported: true, called: prev.called || called2 });
1979
2080
  };
1980
2081
  const nsRe = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)/g;
1981
2082
  const namedRe = /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)/g;
@@ -2063,10 +2164,10 @@ function classifyReachability(pkgName, productionUsage, graph) {
2063
2164
  return { reachability: "not-imported" };
2064
2165
  }
2065
2166
  function reachabilitySteps(reachability) {
2066
- if (reachability === "reachable" || reachability === "reachable-via-dependency" || reachability === "reachability-unknown") {
2167
+ if (reachability === "reachable" || reachability === "reachable-via-dependency" || reachability === "reachability-unknown" || reachability === "symbol-reachable") {
2067
2168
  return 0;
2068
2169
  }
2069
- if (reachability === "imported-not-called") return 1;
2170
+ if (reachability === "imported-not-called" || reachability === "symbol-not-called") return 1;
2070
2171
  return 2;
2071
2172
  }
2072
2173
  function downgradeSeverity(severity, steps) {
@@ -2083,7 +2184,7 @@ function isDevOnly(pkgName, ctx) {
2083
2184
  if (inProd) return false;
2084
2185
  return directDev.has(pkgName) || reachableFrom(pkgName, directDev, edges);
2085
2186
  }
2086
- function applyReachability(findings, ctx) {
2187
+ function applyReachability(findings, ctx, symbols = /* @__PURE__ */ new Map()) {
2087
2188
  return findings.map((finding3) => {
2088
2189
  if (finding3.ruleId === "DEPS-MALICIOUS-001") {
2089
2190
  if (finding3.snippet === void 0) return finding3;
@@ -2100,7 +2201,12 @@ function applyReachability(findings, ctx) {
2100
2201
  if (finding3.ruleId !== "DEPS-VULN-001" || finding3.snippet === void 0) return finding3;
2101
2202
  const pkg = packageNameFromSnippet(finding3.snippet);
2102
2203
  if (pkg === void 0 || pkg.length === 0) return finding3;
2103
- const classified = classifyReachability(pkg, ctx.productionUsage, ctx.graph);
2204
+ let classified = classifyReachability(pkg, ctx.productionUsage, ctx.graph);
2205
+ const named = symbols.get(finding3.id) ?? [];
2206
+ if (named.length > 0 && ctx.sources !== void 0) {
2207
+ const symbolClass = classifySymbolUse(named, ctx.sources);
2208
+ classified = { reachability: symbolClass, path: classified.path };
2209
+ }
2104
2210
  const steps = reachabilitySteps(classified.reachability);
2105
2211
  let severity = steps === 0 ? finding3.severity : downgradeSeverity(finding3.severity, steps);
2106
2212
  let message = finding3.message;
@@ -4408,14 +4514,17 @@ var depsModule = {
4408
4514
  findings2.push(typosquatFinding("package.json", hit));
4409
4515
  }
4410
4516
  const osvHits2 = await queryOsv(pins, osvOpts);
4517
+ const symbols2 = /* @__PURE__ */ new Map();
4411
4518
  for (const hit of osvHits2) {
4412
4519
  for (const vuln of hit.vulns) {
4413
- findings2.push(
4414
- isMaliciousAdvisory(vuln) ? maliciousFinding("package.json", hit.pkg, vuln) : vulnFinding("package.json", hit.pkg, vuln, depLoc(hit.pkg.name))
4415
- );
4520
+ const finding3 = isMaliciousAdvisory(vuln) ? maliciousFinding("package.json", hit.pkg, vuln) : vulnFinding("package.json", hit.pkg, vuln, depLoc(hit.pkg.name));
4521
+ const names = affectedFunctionNames(vuln);
4522
+ if (names.length > 0 && finding3.ruleId === "DEPS-VULN-001") symbols2.set(finding3.id, names);
4523
+ findings2.push(finding3);
4416
4524
  }
4417
4525
  }
4418
- return applyReachability(findings2, await reachabilityCtx(ctx.cwd, ctx.files, graphFromManifest(manifest)));
4526
+ const noLockCtx = await reachabilityCtx(ctx.cwd, ctx.files, graphFromManifest(manifest));
4527
+ return applyReachability(findings2, noLockCtx, symbols2);
4419
4528
  }
4420
4529
  let contents;
4421
4530
  try {
@@ -4436,14 +4545,16 @@ var depsModule = {
4436
4545
  }
4437
4546
  }
4438
4547
  const osvHits = await queryOsv(packages, osvOpts);
4548
+ const symbols = /* @__PURE__ */ new Map();
4439
4549
  for (const hit of osvHits) {
4440
4550
  for (const vuln of hit.vulns) {
4441
- findings.push(
4442
- isMaliciousAdvisory(vuln) ? maliciousFinding(lockFile, hit.pkg, vuln) : vulnFinding(lockFile, hit.pkg, vuln, depLoc(hit.pkg.name))
4443
- );
4551
+ const finding3 = isMaliciousAdvisory(vuln) ? maliciousFinding(lockFile, hit.pkg, vuln) : vulnFinding(lockFile, hit.pkg, vuln, depLoc(hit.pkg.name));
4552
+ const names = affectedFunctionNames(vuln);
4553
+ if (names.length > 0 && finding3.ruleId === "DEPS-VULN-001") symbols.set(finding3.id, names);
4554
+ findings.push(finding3);
4444
4555
  }
4445
4556
  }
4446
- let reached = applyReachability(findings, await reachabilityCtx(ctx.cwd, ctx.files, graph));
4557
+ let reached = applyReachability(findings, await reachabilityCtx(ctx.cwd, ctx.files, graph), symbols);
4447
4558
  if (experimentalVex()) {
4448
4559
  reached = attachOverrideProposals(reached, graph, lockFile, manifestRaw);
4449
4560
  reached.push(...scanInstalledPackages(ctx.cwd, packages));
@@ -4477,7 +4588,7 @@ async function reachabilityCtx(cwd, files, graph) {
4477
4588
  } catch {
4478
4589
  }
4479
4590
  }
4480
- return { productionUsage: indexPackageUsage(prodSources), graph };
4591
+ return { productionUsage: indexPackageUsage(prodSources), graph, sources: prodSources };
4481
4592
  }
4482
4593
 
4483
4594
  // src/modules/mcp/index.ts
@@ -5015,6 +5126,26 @@ function isXssSanitizerCall(node) {
5015
5126
  return false;
5016
5127
  }
5017
5128
  var NO_RETURNERS = /* @__PURE__ */ new Set();
5129
+ var CALLBACK_METHODS = /* @__PURE__ */ new Set([
5130
+ "then",
5131
+ "catch",
5132
+ "finally",
5133
+ "map",
5134
+ "forEach",
5135
+ "filter",
5136
+ "flatMap",
5137
+ "find",
5138
+ "reduce"
5139
+ ]);
5140
+ var taintedThisFields = /* @__PURE__ */ new WeakMap();
5141
+ function enclosingClass(node) {
5142
+ let current = node.parent;
5143
+ while (current !== void 0) {
5144
+ if (current.type === "ClassDeclaration" || current.type === "ClassExpression") return current;
5145
+ current = current.parent;
5146
+ }
5147
+ return void 0;
5148
+ }
5018
5149
  function exprIsTainted(node, tainted, returners = NO_RETURNERS) {
5019
5150
  if (node === void 0) return false;
5020
5151
  if (isTaintSource(node) || isFrameworkMemberTaint(node) || isFrameworkTaintCall(node) || isMcpArgsTaintSource(node)) {
@@ -5026,8 +5157,34 @@ function exprIsTainted(node, tainted, returners = NO_RETURNERS) {
5026
5157
  case "Identifier":
5027
5158
  return tainted.has(node.name);
5028
5159
  case "MemberExpression":
5160
+ if (taintV2Enabled() && node.object.type === "ThisExpression") {
5161
+ const cls = enclosingClass(node);
5162
+ const name = propName(node);
5163
+ if (cls !== void 0 && name !== void 0 && taintedThisFields.get(cls)?.has(name) === true) {
5164
+ return true;
5165
+ }
5166
+ }
5029
5167
  return exprIsTainted(node.object, tainted, returners);
5168
+ case "ArrayExpression":
5169
+ if (!taintV2Enabled()) return false;
5170
+ return node.elements.some((el) => {
5171
+ if (el === null) return false;
5172
+ if (el.type === "SpreadElement") return exprIsTainted(el.argument, tainted, returners);
5173
+ return exprIsTainted(el, tainted, returners);
5174
+ });
5175
+ case "ObjectExpression":
5176
+ if (!taintV2Enabled()) return false;
5177
+ return node.properties.some((prop) => {
5178
+ if (prop.type === "SpreadElement") return exprIsTainted(prop.argument, tainted, returners);
5179
+ if (prop.type !== "Property") return false;
5180
+ return exprIsTainted(prop.value, tainted, returners);
5181
+ });
5030
5182
  case "CallExpression":
5183
+ if (taintV2Enabled() && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && node.callee.object.name === "Object" && propName(node.callee) === "assign") {
5184
+ return node.arguments.some(
5185
+ (arg) => arg.type !== "SpreadElement" && exprIsTainted(arg, tainted, returners)
5186
+ );
5187
+ }
5031
5188
  if (isSchemaValidatorCall(node)) return false;
5032
5189
  if (node.callee.type === "Identifier" && returners.has(node.callee.name)) return true;
5033
5190
  if (node.callee.type === "MemberExpression" && exprIsTainted(node.callee.object, tainted, returners)) {
@@ -5258,6 +5415,65 @@ function scopeReturnsTaint(scope, tainted, returners) {
5258
5415
  return yes;
5259
5416
  }
5260
5417
  var MAX_TAINT_HOPS = 5;
5418
+ function seedTaintV2(functions, facts, seeds, returners, nameToFn) {
5419
+ let changed = false;
5420
+ const mark = (fn, name) => {
5421
+ if (name === void 0) return;
5422
+ const set = seeds.get(fn) ?? /* @__PURE__ */ new Set();
5423
+ if (set.has(name)) return;
5424
+ set.add(name);
5425
+ seeds.set(fn, set);
5426
+ changed = true;
5427
+ };
5428
+ for (const fn of functions) {
5429
+ const f = facts.get(fn);
5430
+ if (f === void 0) continue;
5431
+ let parent = fn.parent;
5432
+ while (parent !== void 0 && !isFunctionNode(parent)) {
5433
+ parent = parent.parent;
5434
+ }
5435
+ if (parent !== void 0 && isFunctionNode(parent)) {
5436
+ const outer = facts.get(parent);
5437
+ if (outer !== void 0) {
5438
+ for (const name of outer.tainted) mark(fn, name);
5439
+ }
5440
+ }
5441
+ walkScope(fn, (node) => {
5442
+ if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression") {
5443
+ if (node.left.object.type === "ThisExpression" && exprIsTainted(node.right, f.tainted, returners)) {
5444
+ const cls = enclosingClass(node);
5445
+ const field = propName(node.left);
5446
+ if (cls !== void 0 && field !== void 0) {
5447
+ const fields = taintedThisFields.get(cls) ?? /* @__PURE__ */ new Set();
5448
+ if (!fields.has(field)) {
5449
+ fields.add(field);
5450
+ taintedThisFields.set(cls, fields);
5451
+ changed = true;
5452
+ }
5453
+ }
5454
+ }
5455
+ }
5456
+ if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return;
5457
+ const method = propName(node.callee);
5458
+ if (method === void 0) return;
5459
+ if (CALLBACK_METHODS.has(method) && exprIsTainted(node.callee.object, f.tainted, returners)) {
5460
+ const callback = node.arguments[0];
5461
+ if (callback !== void 0 && isFunctionNode(callback)) {
5462
+ mark(callback, boundNames(callback.params[0])[0]);
5463
+ }
5464
+ }
5465
+ if ((method === "call" || method === "apply") && node.callee.object.type === "Identifier") {
5466
+ const target = nameToFn.get(node.callee.object.name);
5467
+ if (target === void 0 || !isFunctionNode(target)) return;
5468
+ const arg = node.arguments[1];
5469
+ if (arg !== void 0 && arg.type !== "SpreadElement" && exprIsTainted(arg, f.tainted, returners)) {
5470
+ mark(target, boundNames(target.params[0])[0]);
5471
+ }
5472
+ }
5473
+ });
5474
+ }
5475
+ return changed;
5476
+ }
5261
5477
  function analyzeFile(ast, extraReturners = NO_RETURNERS) {
5262
5478
  attachParents(ast);
5263
5479
  const facts = /* @__PURE__ */ new WeakMap();
@@ -5312,6 +5528,9 @@ function analyzeFile(ast, extraReturners = NO_RETURNERS) {
5312
5528
  });
5313
5529
  });
5314
5530
  }
5531
+ if (taintV2Enabled() && seedTaintV2(functions, facts, seeds, returners, nameToFn)) {
5532
+ changed = true;
5533
+ }
5315
5534
  if (!changed) break;
5316
5535
  }
5317
5536
  return facts;
@@ -5454,6 +5673,21 @@ function requireCall(node) {
5454
5673
  }
5455
5674
  return void 0;
5456
5675
  }
5676
+ function reexportsOf(ast) {
5677
+ const out = /* @__PURE__ */ new Map();
5678
+ walk(ast, (node) => {
5679
+ if (node.type !== "ExportNamedDeclaration" || node.source === null || node.source === void 0) return;
5680
+ const spec = literalString(node.source);
5681
+ if (spec === void 0) return;
5682
+ for (const specNode of node.specifiers) {
5683
+ if (specNode.type !== "ExportSpecifier") continue;
5684
+ const exported = specNode.exported.type === "Identifier" ? specNode.exported.name : void 0;
5685
+ const local = specNode.local.type === "Identifier" ? specNode.local.name : exported;
5686
+ if (exported !== void 0 && local !== void 0) out.set(exported, { spec, name: local });
5687
+ }
5688
+ });
5689
+ return out;
5690
+ }
5457
5691
  function resolveRelative(fromFile, spec, files) {
5458
5692
  if (!spec.startsWith(".")) return void 0;
5459
5693
  const base = posix.normalize(posix.join(posix.dirname(fromFile), spec));
@@ -5531,6 +5765,22 @@ function runInterproc(parsed) {
5531
5765
  functions.set(`${fn.file}#${fn.name}`, fn);
5532
5766
  }
5533
5767
  }
5768
+ if (taintV2Enabled()) {
5769
+ const barrels = /* @__PURE__ */ new Map();
5770
+ for (const p of parsed) barrels.set(p.file, reexportsOf(p.ast));
5771
+ for (const ns of requires.values()) {
5772
+ for (const [local, target] of ns) {
5773
+ const hash = target.indexOf("#");
5774
+ if (hash < 0) continue;
5775
+ const file = target.slice(0, hash);
5776
+ const name = target.slice(hash + 1);
5777
+ const re = barrels.get(file)?.get(name);
5778
+ if (re === void 0) continue;
5779
+ const resolved = resolveRelative(file, re.spec, files);
5780
+ if (resolved !== void 0) ns.set(local, `${resolved}#${re.name}`);
5781
+ }
5782
+ }
5783
+ }
5534
5784
  const queue = [];
5535
5785
  for (const p of parsed) {
5536
5786
  const facts = analyzeFile(p.ast);
@@ -6046,7 +6296,10 @@ function isMongoReceiver(node, source) {
6046
6296
  const obj = node.callee.object;
6047
6297
  if (obj.type === "Identifier") {
6048
6298
  if (isCollectionName(obj.name)) return true;
6049
- if (isPascalModel(obj.name)) return !fileHasSqlOrm;
6299
+ if (isPascalModel(obj.name)) {
6300
+ if (/extends\s+BaseEntity\b/.test(source)) return false;
6301
+ return !fileHasSqlOrm;
6302
+ }
6050
6303
  return fileHasMongo;
6051
6304
  }
6052
6305
  if (obj.type === "MemberExpression") {
@@ -7107,6 +7360,72 @@ function scanPromptInjection(file, source) {
7107
7360
  });
7108
7361
  return findings;
7109
7362
  }
7363
+ function packageVersion() {
7364
+ let dir = dirname(fileURLToPath(import.meta.url));
7365
+ for (let depth = 0; depth < 6; depth += 1) {
7366
+ try {
7367
+ const raw = readFileSync(join(dir, "package.json"), "utf8");
7368
+ const pkg = JSON.parse(raw);
7369
+ if (pkg.name === "fullshield" && typeof pkg.version === "string") {
7370
+ return pkg.version;
7371
+ }
7372
+ } catch {
7373
+ }
7374
+ const parent = dirname(dir);
7375
+ if (parent === dir) break;
7376
+ dir = parent;
7377
+ }
7378
+ throw new Error("fullshield package.json not found");
7379
+ }
7380
+
7381
+ // src/core/parse-pool.ts
7382
+ var workerUrl = new URL("./parse-worker.mjs", import.meta.url);
7383
+ var cache = /* @__PURE__ */ new Map();
7384
+ function parsePoolEnabled() {
7385
+ return process.env.FULLSHIELD_PARSE_POOL === "1";
7386
+ }
7387
+ function parsePoolSize() {
7388
+ const raw = process.env.FULLSHIELD_PARSE_WORKERS;
7389
+ if (raw !== void 0 && raw !== "") {
7390
+ const parsed = Number(raw);
7391
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.floor(parsed);
7392
+ }
7393
+ return Math.max(1, availableParallelism() - 1);
7394
+ }
7395
+ function parseCacheKey(code) {
7396
+ return createHash("sha256").update(packageVersion()).update("\0").update(code).digest("hex");
7397
+ }
7398
+ function parseOne(code) {
7399
+ const key = parseCacheKey(code);
7400
+ const hit = cache.get(key);
7401
+ if (hit !== void 0) return Promise.resolve(hit);
7402
+ return new Promise((resolve5, reject) => {
7403
+ const worker = new Worker(fileURLToPath(workerUrl), { workerData: { code } });
7404
+ worker.once("message", (ast) => {
7405
+ cache.set(key, ast);
7406
+ resolve5(ast);
7407
+ });
7408
+ worker.once("error", reject);
7409
+ });
7410
+ }
7411
+ async function parseWithPool(codes) {
7412
+ const size = parsePoolSize();
7413
+ const out = new Array(codes.length);
7414
+ let next = 0;
7415
+ async function run() {
7416
+ for (; ; ) {
7417
+ const index = next;
7418
+ next += 1;
7419
+ if (index >= codes.length) return;
7420
+ const code = codes[index];
7421
+ if (code === void 0) return;
7422
+ out[index] = await parseOne(code);
7423
+ }
7424
+ }
7425
+ const workers = Math.min(size, Math.max(1, codes.length));
7426
+ await Promise.all(Array.from({ length: workers }, () => run()));
7427
+ return out;
7428
+ }
7110
7429
 
7111
7430
  // src/modules/sast/engine.ts
7112
7431
  var sastRules = [
@@ -7240,7 +7559,8 @@ var sastModule = {
7240
7559
  continue;
7241
7560
  }
7242
7561
  try {
7243
- const ast = await parseSource(source);
7562
+ const ast = parsePoolEnabled() ? (await parseWithPool([source]))[0] : await parseSource(source);
7563
+ if (ast === void 0) continue;
7244
7564
  parsed.push({ file: rel, source, ast });
7245
7565
  } catch {
7246
7566
  }
@@ -7618,7 +7938,7 @@ function createAiClient(options) {
7618
7938
  const useFixtures = options.useFixtures ?? !isLiveAi();
7619
7939
  const model = options.model ?? process.env.FULLSHIELD_AI_MODEL ?? (useFixtures ? "recorded-fixture" : "qwen2.5-coder:7b");
7620
7940
  const provider = options.provider ?? process.env.FULLSHIELD_AI_PROVIDER ?? (useFixtures ? "fixture" : "ollama");
7621
- const cache = /* @__PURE__ */ new Map();
7941
+ const cache2 = /* @__PURE__ */ new Map();
7622
7942
  let spentUsd = 0;
7623
7943
  let networkCalls = 0;
7624
7944
  const liveCost = provider === "ollama" ? 0 : FIXTURE_COST;
@@ -7638,7 +7958,7 @@ function createAiClient(options) {
7638
7958
  const redactedPrompt = `${modeInstruction(req.mode)}
7639
7959
  ${wrapUntrustedCode(req.prompt)}`;
7640
7960
  const key = cacheKey(req.model ?? model, redactedPrompt);
7641
- const cached = cache.get(key);
7961
+ const cached = cache2.get(key);
7642
7962
  if (cached !== void 0) {
7643
7963
  return { ...cached, cached: true, costUsd: 0 };
7644
7964
  }
@@ -7652,7 +7972,7 @@ ${wrapUntrustedCode(req.prompt)}`;
7652
7972
  cached: false,
7653
7973
  redactedPrompt
7654
7974
  };
7655
- cache.set(key, response2);
7975
+ cache2.set(key, response2);
7656
7976
  return response2;
7657
7977
  }
7658
7978
  const fetchImpl = options.fetchImpl ?? fetch;
@@ -7702,7 +8022,7 @@ ${wrapUntrustedCode(req.prompt)}`;
7702
8022
  cached: false,
7703
8023
  redactedPrompt
7704
8024
  };
7705
- cache.set(key, response);
8025
+ cache2.set(key, response);
7706
8026
  return response;
7707
8027
  }
7708
8028
  };
@@ -7929,23 +8249,6 @@ async function listStagedFiles(cwd) {
7929
8249
  // src/core/engine.ts
7930
8250
  init_suppression();
7931
8251
  init_finding();
7932
- function packageVersion() {
7933
- let dir = dirname(fileURLToPath(import.meta.url));
7934
- for (let depth = 0; depth < 6; depth += 1) {
7935
- try {
7936
- const raw = readFileSync(join(dir, "package.json"), "utf8");
7937
- const pkg = JSON.parse(raw);
7938
- if (pkg.name === "fullshield" && typeof pkg.version === "string") {
7939
- return pkg.version;
7940
- }
7941
- } catch {
7942
- }
7943
- const parent = dirname(dir);
7944
- if (parent === dir) break;
7945
- dir = parent;
7946
- }
7947
- throw new Error("fullshield package.json not found");
7948
- }
7949
8252
 
7950
8253
  // src/policy/index.ts
7951
8254
  init_finding();