srclawyer 0.4.1 → 0.6.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.
Files changed (35) hide show
  1. package/README.md +28 -6
  2. package/dist/cloud/protectedLogicClient.d.ts +3 -0
  3. package/dist/cloud/protectedLogicClient.js +62 -20
  4. package/dist/cloud/resolveProtectedLogic.d.ts +1 -1
  5. package/dist/cloud/resolveProtectedLogic.js +15 -1
  6. package/dist/commands/init.js +14 -2
  7. package/dist/commands/scan.js +25 -7
  8. package/dist/config/config.d.ts +13 -1
  9. package/dist/config/config.js +19 -1
  10. package/dist/engine/discoverFiles.d.ts +1 -0
  11. package/dist/engine/discoverFiles.js +16 -2
  12. package/dist/engine/languageDetection.js +37 -7
  13. package/dist/engine/pythonAstUtils.d.ts +2 -0
  14. package/dist/engine/pythonAstUtils.js +44 -0
  15. package/dist/engine/scanEngine.js +42 -5
  16. package/dist/engine/types.d.ts +9 -0
  17. package/dist/grammars/LICENSE +21 -0
  18. package/dist/grammars/tree-sitter-python.wasm +0 -0
  19. package/dist/llm/resolveProviderForTier.d.ts +1 -1
  20. package/dist/llm/resolveProviderForTier.js +3 -2
  21. package/dist/policy/generatePolicy.d.ts +2 -2
  22. package/dist/policy/generatePolicy.js +15 -2
  23. package/dist/policy/legalNotices.d.ts +5 -0
  24. package/dist/policy/legalNotices.js +29 -0
  25. package/dist/report/formatText.js +1 -1
  26. package/dist/rules/dataCategoryLexicon.js +9 -3
  27. package/dist/rules/frameworkRules.js +89 -12
  28. package/dist/rules/pythonFrameworkRules.d.ts +3 -0
  29. package/dist/rules/pythonFrameworkRules.js +136 -0
  30. package/dist/rules/pythonSdkRules.d.ts +3 -0
  31. package/dist/rules/pythonSdkRules.js +73 -0
  32. package/dist/rules/safeFieldNames.js +14 -0
  33. package/dist/rules/zodPreFilter.d.ts +21 -1
  34. package/dist/rules/zodPreFilter.js +104 -13
  35. package/package.json +3 -2
@@ -0,0 +1,73 @@
1
+ import { relative } from "node:path";
2
+ import { PROCESSOR_PROFILES } from "./processorProfiles.js";
3
+ import { redactSecrets } from "../engine/secretRedaction.js";
4
+ /**
5
+ * Deliberately narrower than sdkRules.ts's JS table -- not every processor has a reliable
6
+ * import-name signal in Python's ecosystem:
7
+ * - Cognito: Python code almost always calls it through boto3, AWS's *general* SDK used for every
8
+ * AWS service (S3, DynamoDB, SQS, ...). Mapping a bare `import boto3` to "Amazon Cognito" would be
9
+ * wrong far more often than right; reliably detecting it needs finding the specific
10
+ * `boto3.client("cognito-idp")` call, not just the import -- a real gap, not covered here.
11
+ * - Google Analytics: no single well-known, unambiguous pip import root the way `stripe` or
12
+ * `twilio` are for their processors. Left out rather than guessed.
13
+ */
14
+ const SDK_MATCHERS = [
15
+ { processorKey: "stripe", moduleNames: ["stripe"] },
16
+ { processorKey: "sendgrid", moduleNames: ["sendgrid"] },
17
+ { processorKey: "twilio", moduleNames: ["twilio"] },
18
+ { processorKey: "mixpanel", moduleNames: ["mixpanel"] },
19
+ { processorKey: "auth0", moduleNames: ["auth0"] },
20
+ // PyPI package is "firebase-admin"; its import root is firebase_admin.
21
+ { processorKey: "firebase", moduleNames: ["firebase_admin"] },
22
+ ];
23
+ function matchProcessor(moduleRoot) {
24
+ return SDK_MATCHERS.find((matcher) => matcher.moduleNames.includes(moduleRoot));
25
+ }
26
+ /** The root package name from a dotted module path node (e.g. "twilio" from "twilio.rest"). */
27
+ function moduleRootName(moduleNameNode) {
28
+ if (!moduleNameNode)
29
+ return null;
30
+ if (moduleNameNode.type === "dotted_name") {
31
+ return moduleNameNode.child(0)?.text ?? null;
32
+ }
33
+ if (moduleNameNode.type === "aliased_import") {
34
+ return moduleRootName(moduleNameNode.childForFieldName("name"));
35
+ }
36
+ // relative_import ("from . import x") and anything else has no external package name.
37
+ return null;
38
+ }
39
+ export function runPythonSdkRules(tree, filePath, source, root) {
40
+ const findings = [];
41
+ const lines = source.split("\n");
42
+ const relPath = relative(root, filePath);
43
+ const seenProcessors = new Set();
44
+ const importNodes = [
45
+ ...tree.rootNode.descendantsOfType("import_statement"),
46
+ ...tree.rootNode.descendantsOfType("import_from_statement"),
47
+ ];
48
+ for (const node of importNodes) {
49
+ const fieldName = node.type === "import_statement" ? "name" : "module_name";
50
+ const moduleRoot = moduleRootName(node.childForFieldName(fieldName));
51
+ if (!moduleRoot)
52
+ continue;
53
+ const matcher = matchProcessor(moduleRoot);
54
+ if (!matcher || seenProcessors.has(matcher.processorKey))
55
+ continue;
56
+ seenProcessors.add(matcher.processorKey);
57
+ const profile = PROCESSOR_PROFILES[matcher.processorKey];
58
+ const line = node.startPosition.row + 1;
59
+ const evidenceLine = lines[line - 1] ?? "";
60
+ findings.push({
61
+ id: `sdk:${matcher.processorKey}:${relPath}:${line}`,
62
+ dataCategories: profile.dataCategories,
63
+ processor: profile.name,
64
+ description: `${profile.name} integration detected. ${profile.disclosureNotes}`,
65
+ confidence: "high",
66
+ source: "sdk-rule",
67
+ location: { file: relPath, line, column: 0 },
68
+ evidence: redactSecrets(evidenceLine.trim()),
69
+ requiresReview: false,
70
+ });
71
+ }
72
+ return findings;
73
+ }
@@ -1,3 +1,9 @@
1
+ // Exact-match only, deliberately narrow -- this is the one silent-drop path in the whole pipeline
2
+ // (a name on this list never becomes a finding at all, not even a low-confidence one flagged for
3
+ // review). Names that are ALSO plausible PII in some real API (e.g. "next"/"prev" as redirect
4
+ // targets, "since"/"until"/"before"/"after" as ambiguous short words) are deliberately left off even
5
+ // when they're common pagination/cursor names elsewhere, so they stay visible for manual review
6
+ // rather than being silently and permanently suppressed.
1
7
  const SAFE_FIELD_NAMES = new Set([
2
8
  "id",
3
9
  "uuid",
@@ -16,6 +22,14 @@ const SAFE_FIELD_NAMES = new Set([
16
22
  "total",
17
23
  "sort",
18
24
  "order",
25
+ "perpage",
26
+ "pagesize",
27
+ "cursor",
28
+ "nextcursor",
29
+ "prevcursor",
30
+ "sortby",
31
+ "orderby",
32
+ "sortorder",
19
33
  ]);
20
34
  function normalize(fieldName) {
21
35
  return fieldName.toLowerCase().replace(/[^a-z0-9]/g, "");
@@ -1,3 +1,17 @@
1
+ /**
2
+ * A hard ceiling, not a normal operating limit: protectedLogicClient.ts batches by total bytes, so a
3
+ * large fragment is sent ALONE in its own request rather than bundled with others -- the actual
4
+ * per-batch CPU risk that caused intermittent "Worker exceeded CPU time limit" failures (confirmed via
5
+ * wrangler tail) is handled there, not here. This constant exists only to refuse something pathological
6
+ * (e.g. a machine-generated file with one enormous schema), not to cap ordinary real-world files -- and
7
+ * trimmed extraction (above) means ordinary files need it even less than before.
8
+ *
9
+ * Measured (uncapped, pre-trimming) across the three golden-corpus repos: max 41,992 bytes
10
+ * (formbricks/packages/types/surveys/types.ts). This value is PROVISIONAL and unvalidated against the
11
+ * real Worker limit: that depends on a Cloudflare plan upgrade that hasn't happened yet. Revisit once
12
+ * the upgrade is confirmed and the 10-run wrangler tail test has been repeated.
13
+ */
14
+ export declare const MAX_FRAGMENT_BYTES = 65536;
1
15
  export interface ZodCandidate {
2
16
  id: string;
3
17
  codeFragment: string;
@@ -6,4 +20,10 @@ export interface ZodCandidate {
6
20
  * since the fragment is a non-contiguous excerpt of the original file, not a raw slice. */
7
21
  fragmentLineToSourceLine: number[];
8
22
  }
9
- export declare function findZodCandidates(source: string, filePath: string, root: string): ZodCandidate[];
23
+ export interface FindZodCandidatesResult {
24
+ candidates: ZodCandidate[];
25
+ /** Relative paths of files whose extracted fragment exceeded MAX_FRAGMENT_BYTES and was dropped
26
+ * rather than sent -- the caller must surface these, never drop them silently. */
27
+ oversizedFiles: string[];
28
+ }
29
+ export declare function findZodCandidates(source: string, filePath: string, root: string): FindZodCandidatesResult;
@@ -1,6 +1,8 @@
1
1
  import { relative } from "node:path";
2
+ import _traverse from "@babel/traverse";
2
3
  import { redactSecrets } from "../engine/secretRedaction.js";
3
4
  import { parseSource } from "../engine/astUtils.js";
5
+ const traverse = (_traverse.default ?? _traverse);
4
6
  /**
5
7
  * The real Zod schema/anchor detection (which call sites count as "this validates incoming request
6
8
  * data" vs. a third-party API response or an env-var schema — the actual scope-boundary reasoning)
@@ -16,9 +18,30 @@ import { parseSource } from "../engine/astUtils.js";
16
18
  * statements instead guarantees every included chunk is independently valid, so concatenating a subset
17
19
  * of them is always a valid Program — no re-implementation of the protected scope-boundary reasoning
18
20
  * is needed to get that guarantee, just "which top-level statement is this line part of."
21
+ *
22
+ * Within the anchor's own enclosing statement, any NESTED function body that doesn't itself contain an
23
+ * anchor match is elided down to an empty block (`{}`) rather than sent whole: the schema judgment only
24
+ * needs the schema and the shape of the anchor call (`X.inputSchema(Y)` / `Y.parse(...)`), never the
25
+ * unrelated business logic a real handler wraps around it (formbricks' signup action alone was ~130
26
+ * lines of that). Eliding is itself statement-boundary-based (a whole function body, start to end), so
27
+ * it can't produce invalid syntax the way an arbitrary line cut could.
19
28
  */
20
29
  const ZOD_IMPORT_RE = /from\s+["']zod(?:\/v[34](?:-mini)?)?["']/;
21
30
  const ANCHOR_CALL_RE = /\.(?:parse|safeParse|parseAsync|safeParseAsync|inputSchema)\s*\(/;
31
+ /**
32
+ * A hard ceiling, not a normal operating limit: protectedLogicClient.ts batches by total bytes, so a
33
+ * large fragment is sent ALONE in its own request rather than bundled with others -- the actual
34
+ * per-batch CPU risk that caused intermittent "Worker exceeded CPU time limit" failures (confirmed via
35
+ * wrangler tail) is handled there, not here. This constant exists only to refuse something pathological
36
+ * (e.g. a machine-generated file with one enormous schema), not to cap ordinary real-world files -- and
37
+ * trimmed extraction (above) means ordinary files need it even less than before.
38
+ *
39
+ * Measured (uncapped, pre-trimming) across the three golden-corpus repos: max 41,992 bytes
40
+ * (formbricks/packages/types/surveys/types.ts). This value is PROVISIONAL and unvalidated against the
41
+ * real Worker limit: that depends on a Cloudflare plan upgrade that hasn't happened yet. Revisit once
42
+ * the upgrade is confirmed and the 10-run wrangler tail test has been repeated.
43
+ */
44
+ export const MAX_FRAGMENT_BYTES = 65_536;
22
45
  // Resolving "which identifier does this anchor call reference, and where is IT declared at the top
23
46
  // level" is plain lexical lookup, not the protected scope-boundary judgment — safe to do locally so a
24
47
  // schema declared far from its anchor (very common: define near the top, use in an export lower down)
@@ -30,6 +53,9 @@ function lineRange(node) {
30
53
  return null;
31
54
  return [node.loc.start.line - 1, node.loc.end.line - 1]; // 0-based, inclusive
32
55
  }
56
+ function containsLine(range, line0) {
57
+ return line0 >= range[0] && line0 <= range[1];
58
+ }
33
59
  function findEnclosingStatement(body, line0) {
34
60
  return body.find((stmt) => stmt.loc && stmt.loc.start.line - 1 <= line0 && line0 <= stmt.loc.end.line - 1);
35
61
  }
@@ -37,9 +63,51 @@ function findDeclarationStatement(body, name) {
37
63
  return body.find((stmt) => stmt.type === "VariableDeclaration" &&
38
64
  stmt.declarations.some((d) => d.id.type === "Identifier" && d.id.name === name));
39
65
  }
66
+ /**
67
+ * Within `root`'s own line range, finds every function body that contains none of `protectedLines`
68
+ * (the real anchor match lines) and returns the set of its INNER line numbers (never the lines holding
69
+ * the opening/closing brace themselves, so the brace pair that keeps the surrounding statement valid is
70
+ * always kept). A function that DOES contain a protected line is left alone -- its content is exactly
71
+ * the point of sending this fragment -- but still descended into, in case it wraps a further, genuinely
72
+ * unrelated nested closure of its own.
73
+ */
74
+ function findElidableInnerLines(file, rootRange, protectedLines) {
75
+ const elidable = new Set();
76
+ // @babel/traverse requires a Program/File root (not an arbitrary sub-statement) -- traverse the
77
+ // whole file and use rootRange to scope the search to just the one enclosing statement.
78
+ traverse(file, {
79
+ Function(path) {
80
+ const body = path.node.body;
81
+ if (body.type !== "BlockStatement")
82
+ return; // concise arrow body (`() => x`) -- nothing to strip
83
+ const bodyRange = lineRange(body);
84
+ if (!bodyRange)
85
+ return;
86
+ if (bodyRange[0] < rootRange[0] || bodyRange[1] > rootRange[1])
87
+ return; // outside root entirely
88
+ // Only the lines STRICTLY BETWEEN the braces count as "inside" this block for protection
89
+ // purposes -- the opening-brace line very often also carries the anchor itself (formbricks'
90
+ // `actionClient.inputSchema(X).action(async () => {` is one line), and that anchor is textually
91
+ // before the `{`, not inside the block it introduces. Using the full inclusive range here would
92
+ // make every such block look "protected" and defeat elision for exactly the common case this
93
+ // exists to handle.
94
+ const innerRange = [bodyRange[0] + 1, bodyRange[1] - 1];
95
+ if (protectedLines.some((line0) => containsLine(innerRange, line0)))
96
+ return; // keep + descend further
97
+ for (let i = innerRange[0]; i <= innerRange[1]; i++)
98
+ elidable.add(i);
99
+ path.skip(); // the whole block is already elided; no need to look inside it
100
+ },
101
+ });
102
+ return elidable;
103
+ }
104
+ function importedLocalNames(decl) {
105
+ return decl.specifiers.map((spec) => spec.local.name);
106
+ }
40
107
  export function findZodCandidates(source, filePath, root) {
108
+ const none = { candidates: [], oversizedFiles: [] };
41
109
  if (!ZOD_IMPORT_RE.test(source))
42
- return [];
110
+ return none;
43
111
  const lines = source.split("\n");
44
112
  const matchLineIndexes = [];
45
113
  lines.forEach((line, i) => {
@@ -47,12 +115,13 @@ export function findZodCandidates(source, filePath, root) {
47
115
  matchLineIndexes.push(i);
48
116
  });
49
117
  if (matchLineIndexes.length === 0)
50
- return [];
118
+ return none;
51
119
  const ast = parseSource(source, filePath);
52
120
  if (!ast)
53
- return [];
121
+ return none;
54
122
  const body = ast.program.body;
55
123
  const includedLines = new Set();
124
+ const excludedLines = new Set();
56
125
  const includeRange = (range) => {
57
126
  if (!range)
58
127
  return;
@@ -61,31 +130,53 @@ export function findZodCandidates(source, filePath, root) {
61
130
  };
62
131
  for (const directive of ast.program.directives)
63
132
  includeRange(lineRange(directive));
64
- for (const stmt of body) {
65
- if (stmt.type === "ImportDeclaration")
66
- includeRange(lineRange(stmt));
67
- }
133
+ const processedStatements = new Set();
68
134
  // One candidate per file, not per match — a single fragment covering every anchor-shaped
69
- // statement plus the top-of-file imports, merged and deduped. Keeps this a single call per file
135
+ // statement plus the schemas it references, merged and deduped. Keeps this a single call per file
70
136
  // rather than one per match; the server already handles "multiple schemas in one fragment" naturally.
71
137
  for (const matchLine of matchLineIndexes) {
72
138
  const enclosing = findEnclosingStatement(body, matchLine);
73
139
  if (!enclosing)
74
140
  continue;
75
- includeRange(lineRange(enclosing));
141
+ if (!processedStatements.has(enclosing)) {
142
+ processedStatements.add(enclosing);
143
+ const stmtRange = lineRange(enclosing);
144
+ if (stmtRange) {
145
+ includeRange(stmtRange);
146
+ for (const line0 of findElidableInnerLines(ast, stmtRange, matchLineIndexes))
147
+ excludedLines.add(line0);
148
+ }
149
+ }
76
150
  const line = lines[matchLine];
77
151
  const referencedName = line.match(INPUT_SCHEMA_ARG_RE)?.[1] ?? line.match(PARSE_RECEIVER_RE)?.[1];
78
152
  if (!referencedName)
79
153
  continue;
80
154
  const declStmt = findDeclarationStatement(body, referencedName);
81
155
  if (declStmt)
82
- includeRange(lineRange(declStmt));
156
+ includeRange(lineRange(declStmt)); // the schema itself -- never elided internally
83
157
  }
84
158
  if (includedLines.size === 0)
85
- return [];
86
- const sortedIndexes = [...includedLines].sort((a, b) => a - b);
159
+ return none;
160
+ // Only the imports actually referenced by what's being sent -- not every import in the file. A
161
+ // simple whole-word scan of the (not yet import-inclusive) fragment text, not real scope resolution:
162
+ // this can only ever over-include (a name that merely appears in a comment or string still pulls its
163
+ // import in), never under-include a genuinely used binding, which is the safe direction to err in.
164
+ const coreIndexes = [...includedLines].filter((i) => !excludedLines.has(i));
165
+ const coreText = coreIndexes.map((i) => lines[i]).join("\n");
166
+ for (const stmt of body) {
167
+ if (stmt.type !== "ImportDeclaration")
168
+ continue;
169
+ const referenced = importedLocalNames(stmt).some((name) => new RegExp(`\\b${name}\\b`).test(coreText));
170
+ if (referenced)
171
+ includeRange(lineRange(stmt));
172
+ }
173
+ const sortedIndexes = [...includedLines].filter((i) => !excludedLines.has(i)).sort((a, b) => a - b);
87
174
  const fragment = sortedIndexes.map((i) => lines[i]).join("\n");
88
175
  const fragmentLineToSourceLine = sortedIndexes.map((i) => i + 1);
89
176
  const relPath = relative(root, filePath);
90
- return [{ id: relPath, codeFragment: redactSecrets(fragment), fragmentLineToSourceLine }];
177
+ const redacted = redactSecrets(fragment);
178
+ if (Buffer.byteLength(redacted, "utf8") > MAX_FRAGMENT_BYTES) {
179
+ return { candidates: [], oversizedFiles: [relPath] };
180
+ }
181
+ return { candidates: [{ id: relPath, codeFragment: redacted, fragmentLineToSourceLine }], oversizedFiles: [] };
91
182
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "srclawyer",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Static analysis engine that derives privacy policy documentation directly from source code",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -24,7 +24,7 @@
24
24
  "postinstall.cjs"
25
25
  ],
26
26
  "scripts": {
27
- "build": "tsc -p tsconfig.json",
27
+ "build": "tsc -p tsconfig.json && node scripts/copyGrammars.js",
28
28
  "prepare": "npm run build",
29
29
  "postinstall": "node postinstall.cjs",
30
30
  "dev": "tsx src/cli.ts",
@@ -41,6 +41,7 @@
41
41
  "fast-glob": "^3.3.2",
42
42
  "js-yaml": "^4.1.0",
43
43
  "typescript": "^5.6.2",
44
+ "web-tree-sitter": "^0.27.0",
44
45
  "yaml": "^2.5.1"
45
46
  },
46
47
  "devDependencies": {