xploitscan 1.2.1 → 1.3.0

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-LWX7UPO5.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-AX6R4QAD.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,74 @@ 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,
45826
45945
  system: REVIEW_SYSTEM_PROMPT,
45827
45946
  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
- }
45947
+ },
45948
+ // Cap a single call well under any serverless function timeout — the
45949
+ // SDK default is 10 minutes, long enough for one hung connection to
45950
+ // hold a worker until the platform SIGKILLs the whole scan request
45951
+ // (which would skip the graceful-degradation fallback). A timeout
45952
+ // turns a stuck batch into an unreviewed batch, handled below.
45953
+ { timeout: 2e4 }
45954
+ );
45955
+ const text = response.content.filter((b) => b.type === "text").map((b) => b.text).join("");
45956
+ const results = parseReviewResponse(text);
45957
+ reviewedAny = true;
45958
+ for (const r of results) {
45959
+ if (r.verdict === "fp" && r.index >= 0 && r.index < batch.length) {
45960
+ const globalIndex = toReview.indexOf(batch[r.index]);
45961
+ if (globalIndex !== -1) {
45962
+ fpMap.set(globalIndex, r.reason);
45837
45963
  }
45838
45964
  }
45839
- } catch {
45840
- continue;
45841
45965
  }
45966
+ } catch (err) {
45967
+ failedBatches++;
45968
+ unreviewedCount += batch.length;
45969
+ if (!firstError) firstError = err;
45970
+ }
45971
+ };
45972
+ let next = 0;
45973
+ const workerCount = Math.min(MAX_CONCURRENT, tasks.length);
45974
+ const workers = Array.from({ length: workerCount }, async () => {
45975
+ while (next < tasks.length) {
45976
+ const cur = next++;
45977
+ await reviewBatch(tasks[cur]);
45978
+ }
45979
+ });
45980
+ await Promise.all(workers);
45981
+ if (firstError) {
45982
+ const msg = firstError instanceof Error ? firstError.message : String(firstError);
45983
+ if (!reviewedAny) {
45984
+ console.error(
45985
+ "[ai-fp-filter] AI review unavailable \u2014 every model call failed; returning unreviewed findings:",
45986
+ msg
45987
+ );
45988
+ } else {
45989
+ console.warn(
45990
+ `[ai-fp-filter] partial AI review \u2014 ${failedBatches} of ${tasks.length} batch(es) failed; those findings were not reviewed:`,
45991
+ msg
45992
+ );
45842
45993
  }
45843
45994
  }
45844
45995
  const filtered = toReview.filter((_, i) => !fpMap.has(i));
@@ -45849,9 +46000,10 @@ async function filterFalsePositives(findings, fileContents) {
45849
46000
  return {
45850
46001
  findings: [...filtered, ...overflow],
45851
46002
  filteredFindings,
45852
- aiReviewed: true,
46003
+ aiReviewed: reviewedAny,
45853
46004
  removedCount: fpMap.size,
45854
- totalBefore
46005
+ totalBefore,
46006
+ unreviewedCount
45855
46007
  };
45856
46008
  }
45857
46009
  function shannonEntropy(str) {
@@ -45970,6 +46122,7 @@ function scanEntropy(files) {
45970
46122
  if (filePath.includes(".min.")) continue;
45971
46123
  if (/pro-rules-bundle|\.bundle\.|\.chunk\./i.test(filePath)) continue;
45972
46124
  if (/(?:\.test\.|\.spec\.|__tests__|__mocks__|fixtures?\/)/i.test(filePath)) continue;
46125
+ const vendored = isVendoredPath(filePath);
45973
46126
  const lines = content.split("\n");
45974
46127
  for (let i = 0; i < lines.length; i++) {
45975
46128
  const line = lines[i];
@@ -46000,6 +46153,7 @@ function scanEntropy(files) {
46000
46153
  if (entropy < 4.5 && !isLikelySecret) continue;
46001
46154
  const masked = value.substring(0, 6) + "..." + value.substring(value.length - 4);
46002
46155
  findings.push({
46156
+ ...vendored ? { vendored: true } : {},
46003
46157
  id: `ENTROPY-${filePath}:${i + 1}`,
46004
46158
  rule: "ENTROPY",
46005
46159
  severity: isLikelySecret ? "critical" : "high",
@@ -46110,8 +46264,11 @@ async function incrementUsage() {
46110
46264
  async function uploadScanResults(result) {
46111
46265
  const apiKey = getApiKey();
46112
46266
  if (!apiKey) return;
46267
+ const gradedFindings = result.findings.filter(
46268
+ (f) => !isVendoredFinding(f)
46269
+ );
46113
46270
  let critical = 0, high = 0, medium = 0, low = 0;
46114
- for (const f of result.findings) {
46271
+ for (const f of gradedFindings) {
46115
46272
  switch (f.severity) {
46116
46273
  case "critical":
46117
46274
  critical++;
@@ -46140,6 +46297,9 @@ async function uploadScanResults(result) {
46140
46297
  highCount: high,
46141
46298
  mediumCount: medium,
46142
46299
  lowCount: low,
46300
+ // Held out of the grade, not out of the record: every finding is still in
46301
+ // `findings`, each flagged `vendored`.
46302
+ ungradedCount: result.findings.length - gradedFindings.length,
46143
46303
  grade,
46144
46304
  score,
46145
46305
  duration: result.duration
@@ -46239,6 +46399,8 @@ function clearProRulesCache() {
46239
46399
  }
46240
46400
 
46241
46401
  export {
46402
+ isVendoredFinding,
46403
+ vendoredReason,
46242
46404
  detectFramework,
46243
46405
  detectPlatform,
46244
46406
  calculateGrade,
@@ -46258,4 +46420,4 @@ export {
46258
46420
  loadCachedProRules,
46259
46421
  clearProRulesCache
46260
46422
  };
46261
- //# sourceMappingURL=chunk-4OFR3SIS.js.map
46423
+ //# sourceMappingURL=chunk-LWX7UPO5.js.map