solana-privacy-scanner-core 0.6.0 → 0.6.2

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
@@ -1,14 +1,14 @@
1
1
  # solana-privacy-scanner-core
2
2
 
3
- Core scanning engine for Solana privacy analysis. Analyze on-chain privacy exposure using heuristic-based risk detection.
3
+ Core scanning engine for Solana privacy analysis. Analyze on-chain privacy exposure using 13 heuristic-based detections plus static code analysis.
4
4
 
5
5
  ## Features
6
6
 
7
- - 🔍 **Privacy Risk Detection** - Identifies balance traceability, amount reuse, counterparty patterns, and timing correlations
8
- - 🏷️ **Known Entity Detection** - Flags interactions with exchanges, bridges, and KYC services
9
- - 📊 **Structured Reports** - Generates detailed JSON reports with risk scores, evidence, and mitigations
10
- - **Fast & Efficient** - Built with esbuild, supports both ESM and CJS
11
- - 🔒 **Privacy-First** - All analysis happens locally, no data sent to external servers
7
+ - **13 Privacy Heuristics** - Fee payer reuse, signer overlap, memo PII, ATA linkage, priority fee fingerprinting, staking patterns, identity metadata exposure, and more
8
+ - **Static Code Analyzer** - AST-based detection of privacy anti-patterns in TypeScript/JavaScript source code
9
+ - **Known Entity Detection** - Flags interactions with exchanges, bridges, and KYC services
10
+ - **Structured Reports** - JSON reports with risk scores, evidence, and actionable mitigations
11
+ - **Works Out of the Box** - Built-in RPC endpoint, no configuration required
12
12
 
13
13
  ## Installation
14
14
 
@@ -18,31 +18,72 @@ npm install solana-privacy-scanner-core
18
18
 
19
19
  ## Quick Start
20
20
 
21
- ```typescript
22
- import { scan, RPCClient } from 'solana-privacy-scanner-core';
21
+ ### Scan a wallet
23
22
 
24
- // Create an RPC client
25
- const rpc = new RPCClient('https://api.mainnet-beta.solana.com');
23
+ ```typescript
24
+ import {
25
+ RPCClient,
26
+ collectWalletData,
27
+ normalizeWalletData,
28
+ createDefaultLabelProvider,
29
+ generateReport,
30
+ } from 'solana-privacy-scanner-core';
26
31
 
27
- // Scan a wallet
28
- const report = await scan({
29
- target: 'YourWalletAddressHere',
30
- targetType: 'wallet',
31
- rpcClient: rpc,
32
- maxSignatures: 100,
33
- });
32
+ const rpc = new RPCClient(); // uses built-in RPC
33
+ const raw = await collectWalletData(rpc, 'YourWalletAddress', { maxSignatures: 100 });
34
+ const labels = createDefaultLabelProvider();
35
+ const context = normalizeWalletData(raw, labels);
36
+ const report = generateReport(context);
34
37
 
35
- console.log('Risk Level:', report.overallRisk);
38
+ console.log('Risk:', report.overallRisk);
36
39
  console.log('Signals:', report.signals.length);
37
40
  ```
38
41
 
42
+ ### Analyze source code
43
+
44
+ ```typescript
45
+ import { analyze } from 'solana-privacy-scanner-core';
46
+
47
+ const result = await analyze(['src/**/*.ts']);
48
+ console.log(`Found ${result.summary.total} privacy issues`);
49
+ result.issues.forEach(i => console.log(` ${i.severity} ${i.type}: ${i.message}`));
50
+ ```
51
+
52
+ ### Use individual heuristics
53
+
54
+ ```typescript
55
+ import { detectFeePayerReuse, detectMemoExposure } from 'solana-privacy-scanner-core';
56
+ import type { ScanContext } from 'solana-privacy-scanner-core';
57
+
58
+ const signals = detectFeePayerReuse(context);
59
+ const memoSignals = detectMemoExposure(context);
60
+ ```
61
+
62
+ ## The 13 Heuristics
63
+
64
+ | # | Heuristic | What it checks |
65
+ |---|-----------|---------------|
66
+ | 1 | Fee Payer Reuse | One wallet paying fees for multiple accounts |
67
+ | 2 | Signer Overlap | Same signers appearing across transactions |
68
+ | 3 | Memo Exposure | Personal information in memo fields |
69
+ | 4 | Known Entity Interaction | Transfers to/from exchanges, bridges, KYC services |
70
+ | 5 | Identity Metadata Exposure | .sol domain and NFT metadata linkage |
71
+ | 6 | ATA Linkage | One wallet funding token accounts for multiple owners |
72
+ | 7 | Address Reuse | Using one address across many different protocols |
73
+ | 8 | Counterparty Reuse | Repeated transfers to the same address |
74
+ | 9 | Instruction Fingerprinting | Repeated program call patterns |
75
+ | 10 | Token Account Lifecycle | Frequent create/close cycles |
76
+ | 11 | Priority Fee Fingerprinting | Consistent priority fee amounts |
77
+ | 12 | Staking Delegation | Concentrated validator delegation |
78
+ | 13 | Timing Patterns | Burst activity and regular intervals |
79
+
39
80
  ## Documentation
40
81
 
41
- Full documentation available at: https://taylorferran.github.io/solana-privacy-scanner
82
+ Full documentation: https://taylorferran.github.io/solana-privacy-scanner
42
83
 
43
84
  - [Getting Started](https://taylorferran.github.io/solana-privacy-scanner/guide/getting-started)
44
85
  - [Library Usage](https://taylorferran.github.io/solana-privacy-scanner/library/usage)
45
- - [API Reference](https://taylorferran.github.io/solana-privacy-scanner/library/examples)
86
+ - [Heuristics Reference](https://taylorferran.github.io/solana-privacy-scanner/reports/heuristics)
46
87
 
47
88
  ## License
48
89
 
package/dist/index.cjs CHANGED
@@ -47,6 +47,8 @@ __export(index_exports, {
47
47
  detectATALinkage: () => detectATALinkage,
48
48
  detectAddressReuse: () => detectAddressReuse,
49
49
  detectCounterpartyReuse: () => detectCounterpartyReuse,
50
+ detectFeePayerReuse: () => detectFeePayerReuse,
51
+ detectFeePayerReuseInCode: () => detectFeePayerReuseInCode,
50
52
  detectIdentityMetadataExposure: () => detectIdentityMetadataExposure,
51
53
  detectInstructionFingerprinting: () => detectInstructionFingerprinting,
52
54
  detectKnownEntityInteraction: () => detectKnownEntityInteraction,
@@ -80,7 +82,7 @@ var import_web3 = require("@solana/web3.js");
80
82
  // src/constants.ts
81
83
  var _RPC_ENCODED = "aHR0cHM6Ly9zZXJlbmUtcm91Z2gtcG9vbC5zb2xhbmEtbWFpbm5ldC5xdWlrbm9kZS5wcm8vYTliM2RkNGRkMzc0MzYwYzQzNzY4YzQyMTI2NmE2ZGNlZDU4MTI3Ny8=";
82
84
  var DEFAULT_RPC_URL = /* @__PURE__ */ Buffer.from(_RPC_ENCODED, "base64").toString("utf-8");
83
- var VERSION = "0.6.0";
85
+ var VERSION = "0.6.2";
84
86
 
85
87
  // src/rpc/client.ts
86
88
  var RateLimiter = class {
@@ -2695,6 +2697,7 @@ function createDefaultLabelProvider() {
2695
2697
  var fs = __toESM(require("fs/promises"), 1);
2696
2698
  var path = __toESM(require("path"), 1);
2697
2699
  var import_glob = require("glob");
2700
+ var CODE_EXTENSIONS = ["ts", "tsx", "js", "jsx"];
2698
2701
  async function readFile2(filePath) {
2699
2702
  try {
2700
2703
  return await fs.readFile(filePath, "utf-8");
@@ -2702,15 +2705,29 @@ async function readFile2(filePath) {
2702
2705
  throw new Error(`Failed to read file ${filePath}: ${error}`);
2703
2706
  }
2704
2707
  }
2708
+ async function expandPattern(pattern) {
2709
+ try {
2710
+ const cleaned = pattern.replace(/\/+$/, "");
2711
+ const stat2 = await fs.stat(cleaned);
2712
+ if (stat2.isDirectory()) {
2713
+ return CODE_EXTENSIONS.map((ext) => path.join(cleaned, "**", `*.${ext}`));
2714
+ }
2715
+ } catch {
2716
+ }
2717
+ return [pattern];
2718
+ }
2705
2719
  async function findFiles(patterns, options) {
2706
2720
  const files = [];
2707
2721
  for (const pattern of patterns) {
2708
- const matches = await (0, import_glob.glob)(pattern, {
2709
- ignore: options?.exclude || [],
2710
- nodir: true,
2711
- absolute: true
2712
- });
2713
- files.push(...matches);
2722
+ const expanded = await expandPattern(pattern);
2723
+ for (const p of expanded) {
2724
+ const matches = await (0, import_glob.glob)(p, {
2725
+ ignore: options?.exclude || [],
2726
+ nodir: true,
2727
+ absolute: true
2728
+ });
2729
+ files.push(...matches);
2730
+ }
2714
2731
  }
2715
2732
  return [...new Set(files)];
2716
2733
  }
@@ -2781,7 +2798,7 @@ function isNodeInsideNode(searchNode, parentNode) {
2781
2798
  }
2782
2799
 
2783
2800
  // src/analyzer/detectors/fee-payer-reuse.ts
2784
- function detectFeePayerReuse2(content, filePath) {
2801
+ function detectFeePayerReuseInCode(content, filePath) {
2785
2802
  const issues = [];
2786
2803
  try {
2787
2804
  const ast = (0, import_typescript_estree.parse)(content, {
@@ -2800,7 +2817,7 @@ function detectFeePayerReuse2(content, filePath) {
2800
2817
  traverse(ast, (node) => {
2801
2818
  if (node.type === "VariableDeclarator" && node.id.type === "Identifier" && node.init && isKeypairGenerate(node.init)) {
2802
2819
  const varName = node.id.name;
2803
- if (varName.toLowerCase().includes("fee") || varName.toLowerCase().includes("payer")) {
2820
+ if (isFeePayerName(varName)) {
2804
2821
  const isInsideLoop = loops.some((loop) => {
2805
2822
  return isNodeInsideNode(node, loop.body);
2806
2823
  });
@@ -2817,6 +2834,22 @@ function detectFeePayerReuse2(content, filePath) {
2817
2834
  }
2818
2835
  }
2819
2836
  }
2837
+ if ((node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") && node.params) {
2838
+ for (const param of node.params) {
2839
+ const paramName = param.type === "Identifier" ? param.name : param.type === "AssignmentPattern" && param.left.type === "Identifier" ? param.left.name : null;
2840
+ if (paramName && isFeePayerName(paramName)) {
2841
+ feePayerVars.set(paramName, {
2842
+ name: paramName,
2843
+ declaration: {
2844
+ line: param.loc.start.line,
2845
+ column: param.loc.start.column,
2846
+ file: filePath
2847
+ },
2848
+ usages: []
2849
+ });
2850
+ }
2851
+ }
2852
+ }
2820
2853
  });
2821
2854
  for (const [varName, varInfo] of feePayerVars) {
2822
2855
  for (const loop of loops) {
@@ -2831,6 +2864,34 @@ function detectFeePayerReuse2(content, filePath) {
2831
2864
  });
2832
2865
  }
2833
2866
  }
2867
+ if (node.type === "CallExpression" && !isTransactionCall(node)) {
2868
+ for (const arg of node.arguments) {
2869
+ if (arg.type === "Identifier" && arg.name === varName) {
2870
+ const alreadyTracked = varInfo.usages.some(
2871
+ (u) => u.line === node.loc.start.line && u.column === node.loc.start.column
2872
+ );
2873
+ if (!alreadyTracked) {
2874
+ varInfo.usages.push({
2875
+ line: node.loc.start.line,
2876
+ column: node.loc.start.column,
2877
+ file: filePath
2878
+ });
2879
+ }
2880
+ break;
2881
+ }
2882
+ }
2883
+ }
2884
+ if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression" && node.left.property.type === "Identifier" && node.left.property.name === "feePayer") {
2885
+ const right = node.right;
2886
+ const assignedName = right.type === "MemberExpression" && right.object.type === "Identifier" ? right.object.name : right.type === "Identifier" ? right.name : null;
2887
+ if (assignedName === varName) {
2888
+ varInfo.usages.push({
2889
+ line: node.loc.start.line,
2890
+ column: node.loc.start.column,
2891
+ file: filePath
2892
+ });
2893
+ }
2894
+ }
2834
2895
  });
2835
2896
  }
2836
2897
  if (varInfo.usages.length > 0) {
@@ -2902,6 +2963,10 @@ function traverse(node, visitor) {
2902
2963
  }
2903
2964
  }
2904
2965
  }
2966
+ function isFeePayerName(name) {
2967
+ const lower = name.toLowerCase();
2968
+ return lower.includes("fee") || lower.includes("payer");
2969
+ }
2905
2970
  function isKeypairGenerate(node) {
2906
2971
  if (node.type === "CallExpression") {
2907
2972
  const callee = node.callee;
@@ -2933,10 +2998,15 @@ function findFeePayerArgument(node) {
2933
2998
  for (const arg of node.arguments) {
2934
2999
  if (arg.type === "ArrayExpression") {
2935
3000
  const elements = arg.elements.filter((e) => e !== null);
3001
+ for (const el of elements) {
3002
+ if (el && el.type === "Identifier" && isFeePayerName(el.name)) {
3003
+ return el;
3004
+ }
3005
+ }
2936
3006
  if (elements.length > 0) {
2937
- const lastElement = elements[elements.length - 1];
2938
- if (lastElement && lastElement.type !== "SpreadElement") {
2939
- return lastElement;
3007
+ const first = elements[0];
3008
+ if (first && first.type !== "SpreadElement") {
3009
+ return first;
2940
3010
  }
2941
3011
  }
2942
3012
  }
@@ -2993,6 +3063,7 @@ var PII_PATTERNS = [
2993
3063
  function detectMemoPII(content, filePath) {
2994
3064
  const issues = [];
2995
3065
  const lines = content.split("\n");
3066
+ const hasMemoProgram = /MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr/.test(content);
2996
3067
  lines.forEach((line, index) => {
2997
3068
  let memoValue = null;
2998
3069
  let columnOffset = 0;
@@ -3002,6 +3073,9 @@ function detectMemoPII(content, filePath) {
3002
3073
  } else if (line.includes("createMemoInstruction") || line.includes("MemoInstruction")) {
3003
3074
  memoValue = extractMemoFromFunction(line);
3004
3075
  columnOffset = line.indexOf("Instruction") + 1;
3076
+ } else if (hasMemoProgram && line.includes("Buffer.from(")) {
3077
+ memoValue = extractBufferFromValue(line);
3078
+ columnOffset = line.indexOf("Buffer.from") + 1;
3005
3079
  }
3006
3080
  if (memoValue) {
3007
3081
  for (const pattern of PII_PATTERNS) {
@@ -3021,6 +3095,20 @@ function detectMemoPII(content, filePath) {
3021
3095
  });
3022
3096
  }
3023
3097
  }
3098
+ const piiVarMatches = memoValue.matchAll(/\$\{(\w*(?:email|name|user|phone|ssn|address|identity|account)\w*)\}/gi);
3099
+ for (const match of piiVarMatches) {
3100
+ issues.push({
3101
+ type: "memo-pii",
3102
+ severity: "HIGH",
3103
+ file: filePath,
3104
+ line: index + 1,
3105
+ column: columnOffset,
3106
+ message: `Variable '${match[1]}' interpolated into memo likely contains PII`,
3107
+ suggestion: "Do not interpolate user-identifying variables into memo fields. Memos are permanently public on-chain.",
3108
+ codeSnippet: extractSnippet(content, index + 1),
3109
+ identifier: `pii-variable: ${match[1]}`
3110
+ });
3111
+ }
3024
3112
  if (isDescriptiveContent(memoValue)) {
3025
3113
  issues.push({
3026
3114
  type: "memo-pii",
@@ -3071,6 +3159,19 @@ function extractMemoFromFunction(line) {
3071
3159
  }
3072
3160
  return null;
3073
3161
  }
3162
+ function extractBufferFromValue(line) {
3163
+ const patterns = [
3164
+ /Buffer\.from\(\s*["']([^"']+)["']/,
3165
+ /Buffer\.from\(\s*`([^`]+)`/
3166
+ ];
3167
+ for (const pattern of patterns) {
3168
+ const match = line.match(pattern);
3169
+ if (match) {
3170
+ return match[1];
3171
+ }
3172
+ }
3173
+ return null;
3174
+ }
3074
3175
  function isDescriptiveContent(memo) {
3075
3176
  if (memo.length > 20 && /\s/.test(memo)) {
3076
3177
  return true;
@@ -3154,7 +3255,7 @@ var SolanaPrivacyAnalyzer = class {
3154
3255
  async analyzeFile(filePath) {
3155
3256
  const content = await readFile2(filePath);
3156
3257
  const issues = [];
3157
- issues.push(...detectFeePayerReuse2(content, filePath));
3258
+ issues.push(...detectFeePayerReuseInCode(content, filePath));
3158
3259
  issues.push(...detectMemoPII(content, filePath));
3159
3260
  return issues;
3160
3261
  }
@@ -3581,6 +3682,8 @@ function getTestWallet(config) {
3581
3682
  detectATALinkage,
3582
3683
  detectAddressReuse,
3583
3684
  detectCounterpartyReuse,
3685
+ detectFeePayerReuse,
3686
+ detectFeePayerReuseInCode,
3584
3687
  detectIdentityMetadataExposure,
3585
3688
  detectInstructionFingerprinting,
3586
3689
  detectKnownEntityInteraction,