xploitscan 1.2.2 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -155,6 +155,50 @@ legacy/** scanner
155
155
  For a single reviewed-and-accepted finding, prefer an inline
156
156
  `// VC<id>-OK: <reason>` comment instead of a file- or path-wide rule.
157
157
 
158
+ ### Vendored and generated code
159
+
160
+ Some of what a scanner finds isn't code you wrote. AI app builders copy
161
+ shadcn/ui into your project verbatim; codegen writes into `__generated__/`;
162
+ your README contains example code that exists to show what *not* to do.
163
+
164
+ XploitScan scans all of it and reports everything it finds — but findings in
165
+ these paths don't count toward your grade or the CI exit code:
166
+
167
+ ```
168
+ **/components/ui/** **/shadcn/** **/ui/shadcn/**
169
+ **/vendor/** **/vendored/** **/third_party/**
170
+ **/generated/** **/__generated__/**
171
+ **/*.md **/*.mdx
172
+ .claude/** .agent/** .cursor/** .superpowers/** .windsurf/**
173
+ ```
174
+
175
+ They still appear in every report, tagged `[not graded]`, with the count shown
176
+ next to the grade. Nothing is hidden — a grade that held findings out always
177
+ says so.
178
+
179
+ **Hardcoded credentials are the exception and are always graded**, in every
180
+ path above. A key committed to your repository is live no matter which
181
+ directory holds it, so "the generator wrote that file" doesn't apply. A
182
+ service-role key in `__generated__/client.ts` or a plaintext Kubernetes Secret
183
+ under `generated/` still counts against your grade.
184
+
185
+ Why: shadcn's `chart.tsx` writes CSS custom properties through
186
+ `dangerouslySetInnerHTML`, which trips a critical XSS rule. It ships
187
+ byte-identically in a large share of AI-scaffolded projects, and one critical
188
+ caps a project at grade D. Grading it means grading shadcn's code as yours.
189
+
190
+ To opt out and grade everything:
191
+
192
+ ```bash
193
+ xploitscan scan . --grade-vendored
194
+ ```
195
+
196
+ Or persistently, in `.xploitscanrc`:
197
+
198
+ ```json
199
+ { "scan": { "gradeVendored": true } }
200
+ ```
201
+
158
202
  ## Web Dashboard
159
203
 
160
204
  Scan via the web at [xploitscan.com](https://xploitscan.com):
@@ -14,7 +14,7 @@ import {
14
14
  storeToken,
15
15
  syncUser,
16
16
  uploadScanResults
17
- } from "./chunk-4OFR3SIS.js";
17
+ } from "./chunk-2VDAYFPC.js";
18
18
  export {
19
19
  checkUsage,
20
20
  clearProRulesCache,
@@ -29,4 +29,4 @@ export {
29
29
  syncUser,
30
30
  uploadScanResults
31
31
  };
32
- //# sourceMappingURL=api-MFCSQQ2Y.js.map
32
+ //# sourceMappingURL=api-6FD27EJY.js.map
@@ -43469,6 +43469,95 @@ function getSnippet(content, line, contextLines = 2) {
43469
43469
  }
43470
43470
  return out.join("\n");
43471
43471
  }
43472
+ var NON_APP_PATH_PATTERNS = [
43473
+ // 1. vendored UI component libraries
43474
+ "**/components/ui/**",
43475
+ "**/ui/shadcn/**",
43476
+ "**/shadcn/ui/**",
43477
+ "**/shadcn/**",
43478
+ // 2. documentation prose
43479
+ "**/*.md",
43480
+ "**/*.mdx",
43481
+ // 3. AI-agent and editor tooling artifacts. Each gets a `**/`-prefixed form
43482
+ // as well as the root-anchored one: these directories turn up per-package
43483
+ // in a monorepo, and a finding's `file` is occasionally an absolute path,
43484
+ // which makes even a repo-root `.claude/` a nested segment.
43485
+ ".claude/**",
43486
+ ".agent/**",
43487
+ ".cursor/**",
43488
+ ".superpowers/**",
43489
+ ".windsurf/**",
43490
+ "**/.claude/**",
43491
+ "**/.agent/**",
43492
+ "**/.cursor/**",
43493
+ "**/.superpowers/**",
43494
+ "**/.windsurf/**",
43495
+ // 4. explicitly third-party or machine-generated trees
43496
+ "**/vendor/**",
43497
+ "**/vendored/**",
43498
+ "**/third_party/**",
43499
+ "**/generated/**",
43500
+ "**/__generated__/**"
43501
+ ];
43502
+ function globToRegExp(pattern) {
43503
+ const segments = pattern.split("/");
43504
+ let src = "^";
43505
+ for (let i = 0; i < segments.length; i++) {
43506
+ const seg = segments[i];
43507
+ const isLast = i === segments.length - 1;
43508
+ if (seg === "**") {
43509
+ src += isLast ? ".+" : "(?:[^/]+/)*";
43510
+ continue;
43511
+ }
43512
+ src += seg.replace(/[.*+?^${}()|[\]\\]/g, (ch) => ch === "*" ? "[^/]*" : "\\" + ch);
43513
+ if (!isLast) src += "/";
43514
+ }
43515
+ return new RegExp(src + "$", "i");
43516
+ }
43517
+ var NON_APP_MATCHERS = NON_APP_PATH_PATTERNS.map(globToRegExp);
43518
+ function normalizeScanPath(filePath) {
43519
+ const raw = typeof filePath === "string" ? filePath : "";
43520
+ const trimmed = raw.trim();
43521
+ const looksWindows = !trimmed.includes("/") && trimmed.includes("\\");
43522
+ const withForwardSlashes = (looksWindows ? trimmed.replace(/\\/g, "/") : trimmed).replace(/^[A-Za-z]:\//, "/");
43523
+ const out = [];
43524
+ for (const segment of withForwardSlashes.split("/")) {
43525
+ if (segment === "" || segment === ".") continue;
43526
+ if (segment === "..") {
43527
+ if (out.length > 0 && out[out.length - 1] !== "..") out.pop();
43528
+ else out.push("..");
43529
+ continue;
43530
+ }
43531
+ out.push(segment);
43532
+ }
43533
+ return out.join("/");
43534
+ }
43535
+ function isVendoredPath(filePath) {
43536
+ const normalized = normalizeScanPath(filePath);
43537
+ if (!normalized) return false;
43538
+ return NON_APP_MATCHERS.some((re) => re.test(normalized));
43539
+ }
43540
+ var HEURISTIC_SECRET_RULES = /* @__PURE__ */ new Set(["ENTROPY"]);
43541
+ var ALWAYS_GRADED_RULES = /* @__PURE__ */ new Set(["VC115", "VC062"]);
43542
+ function isAlwaysGraded(finding) {
43543
+ const rule = finding?.rule ?? "";
43544
+ if (ALWAYS_GRADED_RULES.has(rule)) return true;
43545
+ if (finding?.category !== "Secrets") return false;
43546
+ return !HEURISTIC_SECRET_RULES.has(rule);
43547
+ }
43548
+ function isVendoredFinding(finding) {
43549
+ if (isAlwaysGraded(finding)) return false;
43550
+ return isVendoredPath(finding?.file ?? "");
43551
+ }
43552
+ function vendoredReason(filePath) {
43553
+ const normalized = normalizeScanPath(filePath);
43554
+ if (!normalized) return null;
43555
+ if (/\.mdx?$/.test(normalized)) return "documentation";
43556
+ if (/(^|\/)(components\/ui|shadcn)(\/|$)/.test(normalized)) return "vendored UI component library";
43557
+ if (/(^|\/)\.(claude|agent|cursor|superpowers|windsurf)(\/|$)/.test(normalized)) return "AI-agent tooling artifact";
43558
+ if (isVendoredPath(normalized)) return "third-party or generated code";
43559
+ return null;
43560
+ }
43472
43561
  var MAX_CACHE = 256;
43473
43562
  var cache = /* @__PURE__ */ new Map();
43474
43563
  function cacheKey(filename, contentHash) {
@@ -44174,8 +44263,8 @@ var hardcodedSecrets = {
44174
44263
  if (valMatch && nameMatch) {
44175
44264
  const value = valMatch[1];
44176
44265
  const isKeySuffix = nameMatch[1].endsWith("_KEY");
44177
- const isKebabIdentifier = value.length < 40 && /^[a-z0-9]+(-[a-z0-9]+)+$/.test(value);
44178
- if (isKeySuffix && isKebabIdentifier) continue;
44266
+ const isSlotIdentifier = value.length < 40 && /^[a-z0-9]+([_-][a-z0-9]+)+$/.test(value);
44267
+ if (isKeySuffix && isSlotIdentifier) continue;
44179
44268
  }
44180
44269
  }
44181
44270
  matches.push(rm);
@@ -45209,12 +45298,20 @@ function detectPlatform(files) {
45209
45298
  }
45210
45299
  return [...platforms];
45211
45300
  }
45212
- function calculateGrade(findings, _totalFiles) {
45213
- if (findings.length === 0) {
45214
- return { grade: "A+", score: 100, summary: "No security issues detected. Excellent." };
45301
+ function calculateGrade(findings, _totalFiles, options = {}) {
45302
+ const scored = options.gradeVendored ? findings : findings.filter((f) => !isVendoredFinding(f));
45303
+ const ungraded = findings.length - scored.length;
45304
+ const holdout = ungraded > 0 ? ` ${ungraded} finding${ungraded === 1 ? "" : "s"} in vendored, generated, or documentation files ${ungraded === 1 ? "is" : "are"} reported but not graded.` : "";
45305
+ if (scored.length === 0) {
45306
+ return {
45307
+ grade: "A+",
45308
+ score: 100,
45309
+ summary: (ungraded > 0 ? "No security issues detected in your own code." : "No security issues detected. Excellent.") + holdout,
45310
+ ungraded
45311
+ };
45215
45312
  }
45216
45313
  let critical = 0, high = 0, medium = 0, low = 0;
45217
- for (const f of findings) {
45314
+ for (const f of scored) {
45218
45315
  if (f.severity === "critical") critical++;
45219
45316
  else if (f.severity === "high") high++;
45220
45317
  else if (f.severity === "medium") medium++;
@@ -45254,7 +45351,7 @@ function calculateGrade(findings, _totalFiles) {
45254
45351
  } else {
45255
45352
  summary = "No security issues detected.";
45256
45353
  }
45257
- return { grade, score: rawScore, summary };
45354
+ return { grade, score: rawScore, summary: summary + holdout, ungraded };
45258
45355
  }
45259
45356
  var complianceMap = {
45260
45357
  VC001: { owasp: "A07:2021", cwe: "CWE-798" },
@@ -45684,13 +45781,16 @@ function runCustomRules(content, filePath, disabledRules = [], tier = "free", ex
45684
45781
  }
45685
45782
  if (maxLineLen > 5e4) return findings;
45686
45783
  if (/onboarding|demo-data|example-vulnerable|code-sample/i.test(filePath)) return findings;
45784
+ const vendoredFile = isVendoredPath(filePath);
45687
45785
  const ruleset = tier === "pro" && extraRules.length > 0 ? [...freeRules, ...extraRules] : freeRules;
45688
45786
  for (const rule of ruleset) {
45689
45787
  if (disabledRules.includes(rule.id)) continue;
45690
45788
  const matches = rule.check(content, filePath);
45691
45789
  const compliance = complianceMap[rule.id];
45790
+ const vendored = vendoredFile && isVendoredFinding({ file: filePath, rule: rule.id, category: rule.category });
45692
45791
  for (const match of matches) {
45693
45792
  findings.push({
45793
+ ...vendored ? { vendored: true } : {},
45694
45794
  id: `${match.rule}-${match.file}:${match.line}`,
45695
45795
  rule: match.rule,
45696
45796
  severity: match.severity,
@@ -45720,6 +45820,7 @@ Common false positive patterns you should catch:
45720
45820
  - The comparison is a type check (typeof x === "string") not a secret comparison
45721
45821
  - The file is a test, mock, or fixture file
45722
45822
  - The "secret" is a publishable/public key (pk_test_, NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY) designed to be client-side
45823
+ - The value is a slot/name identifier, not a credential: a SCREAMING_CASE *_KEY constant assigned a short lowercase word string (e.g. INDEX_KEY = "mc_saves_index", CACHE_KEY = "user-prefs-v2") is a localStorage/cookie/cache key NAME, not a secret. Real secrets are high-entropy and mixed-case (sk_live_aBcD\u2026), not dictionary words joined by - or _
45723
45824
  - The innerHTML/dangerouslySetInnerHTML uses a static constant or JSON.stringify, not user input
45724
45825
  - The redirect URL has already been validated (isAllowedRedirect, validateRedirect)
45725
45826
  - The webhook endpoint is for a non-Stripe service but flagged as "Stripe webhook"
@@ -45738,6 +45839,14 @@ var MAX_TOTAL_FINDINGS = (() => {
45738
45839
  if (!Number.isFinite(n) || n < 1) return DEFAULT_MAX_TOTAL_FINDINGS;
45739
45840
  return Math.min(n, 1e3);
45740
45841
  })();
45842
+ var DEFAULT_MAX_CONCURRENT = 6;
45843
+ var MAX_CONCURRENT = (() => {
45844
+ const raw = process.env.XPLOITSCAN_AI_CONCURRENCY;
45845
+ if (!raw) return DEFAULT_MAX_CONCURRENT;
45846
+ const n = parseInt(raw, 10);
45847
+ if (!Number.isFinite(n) || n < 1) return DEFAULT_MAX_CONCURRENT;
45848
+ return Math.min(n, 20);
45849
+ })();
45741
45850
  var SEVERITY_PRIORITY = {
45742
45851
  critical: 0,
45743
45852
  high: 1,
@@ -45789,7 +45898,7 @@ function parseReviewResponse(text) {
45789
45898
  }
45790
45899
  }
45791
45900
  async function filterFalsePositives(findings, fileContents) {
45792
- const empty = { findings, filteredFindings: [], aiReviewed: false, removedCount: 0, totalBefore: findings.length };
45901
+ const empty = { findings, filteredFindings: [], aiReviewed: false, removedCount: 0, totalBefore: findings.length, unreviewedCount: 0 };
45793
45902
  if (!process.env.ANTHROPIC_API_KEY) return empty;
45794
45903
  if (findings.length === 0) return empty;
45795
45904
  const prioritized = [...findings].sort((a, b) => {
@@ -45813,32 +45922,85 @@ async function filterFalsePositives(findings, fileContents) {
45813
45922
  return empty;
45814
45923
  }
45815
45924
  const fpMap = /* @__PURE__ */ new Map();
45925
+ let reviewedAny = false;
45926
+ let firstError = null;
45927
+ let failedBatches = 0;
45928
+ let unreviewedCount = 0;
45929
+ const tasks = [];
45816
45930
  for (const [file, fileFindings] of byFile) {
45817
45931
  const content = fileContents.get(file);
45818
45932
  if (!content) continue;
45819
45933
  for (let i = 0; i < fileFindings.length; i += MAX_FINDINGS_PER_BATCH) {
45820
- const batch = fileFindings.slice(i, i + MAX_FINDINGS_PER_BATCH);
45934
+ tasks.push({ batch: fileFindings.slice(i, i + MAX_FINDINGS_PER_BATCH), content });
45935
+ }
45936
+ }
45937
+ const reviewBatch = async (task) => {
45938
+ const { batch, content } = task;
45939
+ try {
45821
45940
  const prompt = buildReviewPrompt(batch, content);
45822
- try {
45823
- const response = await client.messages.create({
45941
+ const response = await client.messages.create(
45942
+ {
45824
45943
  model: "claude-haiku-4-5-20251001",
45825
45944
  max_tokens: 1024,
45945
+ // Pinned, NOT left to the API default of 1.0. This is a binary
45946
+ // classification (real vs. false positive), not generation — there
45947
+ // is no upside to sampling variety and a real downside to it: a
45948
+ // filtered finding is a finding the user never sees, so drift here
45949
+ // means scanning the same unchanged code twice can yield different
45950
+ // results. Temperature 0 does not make the model bit-deterministic
45951
+ // (Anthropic makes no such guarantee), but it removes the one
45952
+ // variance source that was ours to control. See
45953
+ // scripts/fp-filter-repeatability.cjs for the measured flip rate,
45954
+ // and the assertion in ai-fp-filter.test.ts that keeps it pinned.
45955
+ temperature: 0,
45826
45956
  system: REVIEW_SYSTEM_PROMPT,
45827
45957
  messages: [{ role: "user", content: prompt }]
45828
- });
45829
- const text = response.content.filter((b) => b.type === "text").map((b) => b.text).join("");
45830
- const results = parseReviewResponse(text);
45831
- for (const r of results) {
45832
- if (r.verdict === "fp" && r.index >= 0 && r.index < batch.length) {
45833
- const globalIndex = toReview.indexOf(batch[r.index]);
45834
- if (globalIndex !== -1) {
45835
- fpMap.set(globalIndex, r.reason);
45836
- }
45958
+ },
45959
+ // Cap a single call well under any serverless function timeout — the
45960
+ // SDK default is 10 minutes, long enough for one hung connection to
45961
+ // hold a worker until the platform SIGKILLs the whole scan request
45962
+ // (which would skip the graceful-degradation fallback). A timeout
45963
+ // turns a stuck batch into an unreviewed batch, handled below.
45964
+ { timeout: 2e4 }
45965
+ );
45966
+ const text = response.content.filter((b) => b.type === "text").map((b) => b.text).join("");
45967
+ const results = parseReviewResponse(text);
45968
+ reviewedAny = true;
45969
+ for (const r of results) {
45970
+ if (r.verdict === "fp" && r.index >= 0 && r.index < batch.length) {
45971
+ const globalIndex = toReview.indexOf(batch[r.index]);
45972
+ if (globalIndex !== -1) {
45973
+ fpMap.set(globalIndex, r.reason);
45837
45974
  }
45838
45975
  }
45839
- } catch {
45840
- continue;
45841
45976
  }
45977
+ } catch (err) {
45978
+ failedBatches++;
45979
+ unreviewedCount += batch.length;
45980
+ if (!firstError) firstError = err;
45981
+ }
45982
+ };
45983
+ let next = 0;
45984
+ const workerCount = Math.min(MAX_CONCURRENT, tasks.length);
45985
+ const workers = Array.from({ length: workerCount }, async () => {
45986
+ while (next < tasks.length) {
45987
+ const cur = next++;
45988
+ await reviewBatch(tasks[cur]);
45989
+ }
45990
+ });
45991
+ await Promise.all(workers);
45992
+ if (firstError) {
45993
+ const msg = firstError instanceof Error ? firstError.message : String(firstError);
45994
+ if (!reviewedAny) {
45995
+ console.error(
45996
+ "[ai-fp-filter] AI review unavailable \u2014 every model call failed; returning unreviewed findings:",
45997
+ msg
45998
+ );
45999
+ } else {
46000
+ console.warn(
46001
+ `[ai-fp-filter] partial AI review \u2014 ${failedBatches} of ${tasks.length} batch(es) failed; those findings were not reviewed:`,
46002
+ msg
46003
+ );
45842
46004
  }
45843
46005
  }
45844
46006
  const filtered = toReview.filter((_, i) => !fpMap.has(i));
@@ -45849,9 +46011,10 @@ async function filterFalsePositives(findings, fileContents) {
45849
46011
  return {
45850
46012
  findings: [...filtered, ...overflow],
45851
46013
  filteredFindings,
45852
- aiReviewed: true,
46014
+ aiReviewed: reviewedAny,
45853
46015
  removedCount: fpMap.size,
45854
- totalBefore
46016
+ totalBefore,
46017
+ unreviewedCount
45855
46018
  };
45856
46019
  }
45857
46020
  function shannonEntropy(str) {
@@ -45970,6 +46133,7 @@ function scanEntropy(files) {
45970
46133
  if (filePath.includes(".min.")) continue;
45971
46134
  if (/pro-rules-bundle|\.bundle\.|\.chunk\./i.test(filePath)) continue;
45972
46135
  if (/(?:\.test\.|\.spec\.|__tests__|__mocks__|fixtures?\/)/i.test(filePath)) continue;
46136
+ const vendored = isVendoredPath(filePath);
45973
46137
  const lines = content.split("\n");
45974
46138
  for (let i = 0; i < lines.length; i++) {
45975
46139
  const line = lines[i];
@@ -46000,6 +46164,7 @@ function scanEntropy(files) {
46000
46164
  if (entropy < 4.5 && !isLikelySecret) continue;
46001
46165
  const masked = value.substring(0, 6) + "..." + value.substring(value.length - 4);
46002
46166
  findings.push({
46167
+ ...vendored ? { vendored: true } : {},
46003
46168
  id: `ENTROPY-${filePath}:${i + 1}`,
46004
46169
  rule: "ENTROPY",
46005
46170
  severity: isLikelySecret ? "critical" : "high",
@@ -46110,8 +46275,11 @@ async function incrementUsage() {
46110
46275
  async function uploadScanResults(result) {
46111
46276
  const apiKey = getApiKey();
46112
46277
  if (!apiKey) return;
46278
+ const gradedFindings = result.findings.filter(
46279
+ (f) => !isVendoredFinding(f)
46280
+ );
46113
46281
  let critical = 0, high = 0, medium = 0, low = 0;
46114
- for (const f of result.findings) {
46282
+ for (const f of gradedFindings) {
46115
46283
  switch (f.severity) {
46116
46284
  case "critical":
46117
46285
  critical++;
@@ -46140,6 +46308,9 @@ async function uploadScanResults(result) {
46140
46308
  highCount: high,
46141
46309
  mediumCount: medium,
46142
46310
  lowCount: low,
46311
+ // Held out of the grade, not out of the record: every finding is still in
46312
+ // `findings`, each flagged `vendored`.
46313
+ ungradedCount: result.findings.length - gradedFindings.length,
46143
46314
  grade,
46144
46315
  score,
46145
46316
  duration: result.duration
@@ -46239,6 +46410,8 @@ function clearProRulesCache() {
46239
46410
  }
46240
46411
 
46241
46412
  export {
46413
+ isVendoredFinding,
46414
+ vendoredReason,
46242
46415
  detectFramework,
46243
46416
  detectPlatform,
46244
46417
  calculateGrade,
@@ -46258,4 +46431,4 @@ export {
46258
46431
  loadCachedProRules,
46259
46432
  clearProRulesCache
46260
46433
  };
46261
- //# sourceMappingURL=chunk-4OFR3SIS.js.map
46434
+ //# sourceMappingURL=chunk-2VDAYFPC.js.map