srclawyer 0.5.0 → 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.
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  Static analysis engine that derives privacy policy documentation directly from source code, and keeps it in sync as the code changes.
4
4
 
5
- > This is a Phase 1 scaffold: the CLI (`init`, `scan`), the Layer 1 static analysis engine — SDK/framework pattern detection, HTML/browser-API detection, and OpenAPI/GraphQL schema parsing — LLM-assisted reasoning for ambiguous findings (Layer 2, bring-your-own-key or managed), and markdown privacy policy generation. CI/CD integration is not yet implemented.
5
+ > **Beta.** The CLI (`init`, `scan`), the Layer 1 static analysis engine, and markdown privacy policy generation all work today. JavaScript/TypeScript (Express, Next.js) is the most tested path; Python (Flask) is newer, with FastAPI and Django explicitly detected and flagged as unsupported rather than silently skipped — see [Supported languages and frameworks](#supported-languages-and-frameworks) and [Known gaps](#known-gaps) below. LLM-assisted reasoning for ambiguous findings (Layer 2) is available now under the bring-your-own-key tier, documented below as one optional step in `init` — a managed (hosted) tier is planned but not yet available.
6
6
  >
7
- > Scanning is manual-only (`srclawyer scan`, run on demand) — this is the free/solo tier's permanent behavior, not a placeholder. CI/CD integration (PR-triggered scans, a weekly backstop scan) is scoped for the paid/team tier only; see `PRODUCT_SPEC.md` Section 8.5.
7
+ > Scanning is manual-only (`srclawyer scan`, run on demand) — this is the free/solo tier's permanent behavior, not a placeholder. Automatic CI/CD updating (a scan that runs itself on every PR, or on a schedule) isn't available yet; it's scoped for a future paid/team tier. See `PRODUCT_SPEC.md` Section 8.5 for the plan.
8
8
 
9
9
  ## Usage
10
10
 
@@ -63,18 +63,34 @@ If none of this resolves it, `npx srclawyer scan` (see [Usage](#usage) above) si
63
63
 
64
64
  ## What `scan` does today
65
65
 
66
- - **Known SDK integrations** (import/require detection): Stripe, SendGrid, Twilio, Google Analytics, Mixpanel, Auth0, Firebase, Cognito — each mapped to the data categories it typically processes (`src/rules/processorProfiles.ts`).
67
- - **Request-body field collection** in Express (`req.body.x`, destructured `req.body`) and Next.js (both `pages/api` and the app router's `await request.json()`), classified against a field-name lexicon (`src/rules/dataCategoryLexicon.ts`).
66
+ - **Known SDK integrations** (import/require detection): Stripe, SendGrid, Twilio, Google Analytics, Mixpanel, Auth0, Firebase, Cognito for JS/TS; Stripe, SendGrid, Twilio, Mixpanel, Auth0, Firebase for Python — each mapped to the data categories it typically processes (`src/rules/processorProfiles.ts`).
67
+ - **Request-body field collection** in Express (`req.body.x`, destructured `req.body`) and Next.js (both `pages/api` and the app router's `await request.json()`), and in **Flask** (`request.form`/`.json`/`.args`, both subscript and `.get(...)` forms), classified against a field-name lexicon (`src/rules/dataCategoryLexicon.ts`). In both languages, a value assigned to a local variable earlier in the *same function* (`const body = req.body` / `data = request.get_json()`) is tracked one mechanical hop, so a later `body.email` / `data['email']` is still caught — not just the direct, unaliased form.
68
68
  - **OpenAPI/Swagger schemas** (`components.schemas.*.properties`) and **GraphQL SDL** type/input fields.
69
69
  - **HTML forms and inline scripts** (`src/rules/htmlRules.ts`): native `<input>` elements classified by `type` (password/email/tel are unambiguous native signals) or by `id`/`name` against the lexicon; inline `<script>` blocks are extracted and run through the same SDK/framework/web-API rules as any other file.
70
70
  - **Browser platform APIs** (`src/rules/webApiRules.ts`): `getUserMedia` (split into microphone and camera access), geolocation, and `localStorage`/`sessionStorage` writes classified by key name.
71
71
  - Fields that can't be confidently classified (generic names like `data`, `payload`) are surfaced with `requiresReview: true` rather than silently guessed.
72
- - **A field-name allowlist** (`src/rules/safeFieldNames.ts`) suppresses findings entirely for exact-match structural fields (`id`, `createdAt`, `status`, pagination params, etc.) so "needs review" stays a signal, not noise from routine boilerplate. Anything not on the allowlist keeps the existing classify-or-flag behavior above.
73
- - **Unsupported language/framework detection** (`src/engine/languageDetection.ts`) runs at the start of every scan: it checks for Django (`manage.py`), Rails (`Gemfile` + `app/controllers`), gRPC (`.proto` files), and falls back to a file-count ratio for Python/Ruby/Go/Java/PHP/C# vs JS/TS/HTML. When it fires, a bordered warning appears at the top of the CLI output *and* at the top of the generated `PRIVACY_POLICY.md` (not just a log line easy to miss), the warning is included in `--json` output, and the process exits with code 1 so CI can't silently treat a scan of an unsupported codebase as a pass.
72
+ - **A field-name allowlist** (`src/rules/safeFieldNames.ts`) suppresses findings entirely for exact-match structural and pagination/sort fields (`id`, `createdAt`, `status`, `per_page`, `cursor`, `sort_by`, etc.) so "needs review" stays a signal, not noise from routine boilerplate. Anything not on the allowlist keeps the existing classify-or-flag behavior above.
73
+ - **Unsupported language/framework detection** (`src/engine/languageDetection.ts`) runs at the start of every scan: it checks for Django (`manage.py`), FastAPI (`from fastapi import` / `import fastapi`), Rails (`Gemfile` + `app/controllers`), gRPC (`.proto` files), and falls back to a file-count ratio for Ruby/Go/Java/PHP/C# vs JS/TS/HTML/Python. When it fires, a bordered warning appears at the top of the CLI output *and* at the top of the generated `PRIVACY_POLICY.md` (not just a log line easy to miss), the warning is included in `--json` output, and the process exits with code 1 so CI can't silently treat a scan of an unsupported codebase as a pass.
74
74
  - **Generates a markdown privacy policy** (`src/policy/generatePolicy.ts`) in the same command: findings are grouped by data category, each clause cites the file:line that justified it, third-party processors get their own section, and anything flagged `requiresReview` is excluded from the body and listed separately under "Items Requiring Manual Review" instead of being guessed. The mandatory disclaimer (`src/policy/disclaimer.ts`) is always appended, unconditionally.
75
75
 
76
76
  Every finding traces back to a file and line (`src/engine/types.ts#Finding`).
77
77
 
78
+ ### Supported languages and frameworks
79
+
80
+ | Language | Frameworks | Status |
81
+ |---|---|---|
82
+ | JavaScript / TypeScript | Express, Next.js (pages + app router) | Supported |
83
+ | Python | Flask | Supported (beta — see "Known gaps" below) |
84
+ | Python | FastAPI, Django | Explicitly detected and flagged as unsupported, never silently skipped |
85
+ | Ruby, Go, Java, PHP, C#, gRPC | — | Explicitly detected and flagged as unsupported |
86
+
87
+ ### Known gaps
88
+
89
+ - **Cross-function aliasing isn't tracked, in either language.** The same-function alias tracking described above follows exactly one assignment hop within the function it occurs in. If that value is passed on — as a function parameter, through a closure, via a module-level variable — a field read from it afterward is not detected. Example: a Flask handler reads `data = request.get_json()` and calls `User.from_dict(data)`; a field read as `data['password']` *inside* `from_dict` is not caught, only reads inside the original handler function are.
90
+ - **Data sent to a third party via a raw HTTP call isn't attributed to that processor.** SDK-integration detection (above) is anchored to known package imports. A call like Python's `requests.post("https://api.some-provider.com/...", json={...})` or JS's `fetch("https://api.some-provider.com/...")` — bypassing a dedicated SDK entirely — is invisible to this detection today, in both languages, regardless of how sensitive the data it sends is.
91
+ - **Python support is based on one real-world corpus repository so far** (a Flask app; see `test-fixtures/golden-corpus/`), not the broader validation JS detection has had. Expect rougher edges on Flask codebases with unusual patterns.
92
+ - **OpenAPI/Swagger scanning reports every schema under `components.schemas`, including response-only ones** — a schema that's only ever returned by the server (never submitted by a client) gets identical treatment to a real request-input schema, since nothing currently checks whether a schema is actually reachable from an operation's `requestBody` or `parameters`. On a large, schema-heavy spec this can mean a long "Items Requiring Manual Review" list that includes fields the product never collects, only ever returns. Separately, a schema defined **inline** inside a `requestBody` (not as a named, `$ref`'d `components.schemas` entry) isn't read at all today, in either direction — only named component schemas are scanned.
93
+
78
94
  ## Network calls
79
95
 
80
96
  Detection itself is fully local and offline — no code leaves your machine for pattern matching. Once detection completes, `scan` makes one batched network call to grade the confidence of what it found (field names and derived yes/no signals only, never source code), and, for scans that use Zod schemas as request validators, one additional call sending a small redacted code fragment. This runs on every scan regardless of tier and is unrelated to the separate, optional LLM-assisted (Layer 2) reasoning described above. See `PRODUCT_SPEC.md`'s "Static Analysis Depth" section for the precise wording.
@@ -91,6 +107,12 @@ Detection itself is fully local and offline — no code leaves your machine for
91
107
 
92
108
  `npm test` never touches the network. A separate, real-world accuracy check lives in `test-fixtures/golden-corpus/` — see its own README for what it measures and why it's not part of the default test run.
93
109
 
110
+ ## Legal
111
+
112
+ SrcLawyer generates a *starting draft*, not a substitute for legal review — every generated policy carries that disclaimer (`src/policy/disclaimer.ts`), and `scan` repeats a short reminder of it at the end of its own output.
113
+
114
+ A dedicated privacy policy and terms of service **for SrcLawyer itself** (the tool, not the policy it writes for your project) don't exist yet. Until they do, this README is the authoritative reference for what this tool sends over the network and to whom — see [Network calls](#network-calls) above — and for how it's licensed — see [License](#license) below.
115
+
94
116
  ## License
95
117
 
96
118
  [Business Source License 1.1](LICENSE) (BUSL-1.1) — source-available, not OSI-approved open source.
@@ -3,6 +3,9 @@ export interface ProtectedLogicClientOptions {
3
3
  baseUrl: string;
4
4
  apiKey: string;
5
5
  batchSize?: number;
6
+ /** Max total JSON-serialized bytes of items per batch, in addition to batchSize -- whichever limit
7
+ * is hit first ends the batch. */
8
+ batchMaxBytes?: number;
6
9
  timeoutMs?: number;
7
10
  }
8
11
  export interface ScoreFieldsItem {
@@ -3,42 +3,83 @@
3
3
  * these calls are bound by). Mirrors resolveAmbiguousFindings.ts's established pattern deliberately:
4
4
  * batch, try/catch per batch, never throw out of this module — a failure here degrades the scan
5
5
  * (safe local fallback + a scan-level warning), it never crashes it.
6
+ *
7
+ * Batching is by total request-body bytes as well as item count: confirmed via wrangler tail that the
8
+ * Worker's own CPU time limit is hit intermittently on large detect-zod-schema batches (large
9
+ * code fragments mean more server-side parsing work per batch, not just more JSON to transfer). Count
10
+ * alone (the original design) doesn't bound that — 25 large fragments can still be a very large batch.
6
11
  */
7
12
  const DEFAULT_BATCH_SIZE = 25;
13
+ const DEFAULT_BATCH_MAX_BYTES = 50_000;
8
14
  const DEFAULT_TIMEOUT_MS = 10_000;
9
- function chunk(items, size) {
15
+ const RETRY_BACKOFF_MS = 500;
16
+ /**
17
+ * Greedily fills batches up to maxCount items AND maxBytes of JSON-serialized item size, whichever
18
+ * comes first. A single item already at or over maxBytes (shouldn't happen for zod candidates — see
19
+ * zodPreFilter.ts's own per-fragment cap, enforced upstream — but score-fields items have no such cap)
20
+ * still gets its own one-item batch rather than being dropped, so this never silently discards data;
21
+ * only the per-fragment cap upstream does that, and it always warns when it does.
22
+ */
23
+ function chunkByCountAndBytes(items, maxCount, maxBytes) {
10
24
  const batches = [];
11
- for (let i = 0; i < items.length; i += size)
12
- batches.push(items.slice(i, i + size));
25
+ let current = [];
26
+ let currentBytes = 0;
27
+ for (const item of items) {
28
+ const itemBytes = Buffer.byteLength(JSON.stringify(item), "utf8");
29
+ const wouldExceed = current.length > 0 && (current.length >= maxCount || currentBytes + itemBytes > maxBytes);
30
+ if (wouldExceed) {
31
+ batches.push(current);
32
+ current = [];
33
+ currentBytes = 0;
34
+ }
35
+ current.push(item);
36
+ currentBytes += itemBytes;
37
+ }
38
+ if (current.length > 0)
39
+ batches.push(current);
13
40
  return batches;
14
41
  }
42
+ function sleep(ms) {
43
+ return new Promise((resolve) => setTimeout(resolve, ms));
44
+ }
15
45
  async function postJson(url, apiKey, body, timeoutMs) {
16
- const controller = new AbortController();
17
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
18
- try {
19
- const response = await fetch(url, {
20
- method: "POST",
21
- headers: { "content-type": "application/json", "x-api-key": apiKey },
22
- body: JSON.stringify(body),
23
- signal: controller.signal,
24
- });
25
- if (!response.ok) {
26
- throw new Error(`protected-logic request failed (${response.status})`);
46
+ const MAX_ATTEMPTS = 2; // one retry, on a 5xx only -- a 4xx (bad request, auth) won't succeed on retry
47
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
48
+ const controller = new AbortController();
49
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
50
+ let status;
51
+ try {
52
+ const response = await fetch(url, {
53
+ method: "POST",
54
+ headers: { "content-type": "application/json", "x-api-key": apiKey },
55
+ body: JSON.stringify(body),
56
+ signal: controller.signal,
57
+ });
58
+ if (response.ok)
59
+ return (await response.json());
60
+ status = response.status;
27
61
  }
28
- return (await response.json());
29
- }
30
- finally {
31
- clearTimeout(timeout);
62
+ finally {
63
+ clearTimeout(timeout);
64
+ }
65
+ const isLastAttempt = attempt === MAX_ATTEMPTS;
66
+ if (status < 500 || isLastAttempt) {
67
+ throw new Error(`protected-logic request failed (${status})`);
68
+ }
69
+ await sleep(RETRY_BACKOFF_MS);
32
70
  }
71
+ // Unreachable: the loop above always either returns or throws.
72
+ throw new Error("protected-logic request failed (unreachable)");
33
73
  }
34
74
  export async function scoreFields(items, options) {
35
75
  const results = new Map();
36
76
  if (items.length === 0)
37
77
  return { results, error: null };
38
78
  const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
79
+ const batchMaxBytes = options.batchMaxBytes ?? DEFAULT_BATCH_MAX_BYTES;
39
80
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
40
81
  let error = null;
41
- for (const batch of chunk(items, batchSize)) {
82
+ for (const batch of chunkByCountAndBytes(items, batchSize, batchMaxBytes)) {
42
83
  try {
43
84
  const response = await postJson(`${options.baseUrl}/v1/score-fields`, options.apiKey, { fields: batch }, timeoutMs);
44
85
  for (const result of response.results)
@@ -55,9 +96,10 @@ export async function detectZodSchemas(items, options) {
55
96
  if (items.length === 0)
56
97
  return { results, error: null };
57
98
  const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
99
+ const batchMaxBytes = options.batchMaxBytes ?? DEFAULT_BATCH_MAX_BYTES;
58
100
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
59
101
  let error = null;
60
- for (const batch of chunk(items, batchSize)) {
102
+ for (const batch of chunkByCountAndBytes(items, batchSize, batchMaxBytes)) {
61
103
  try {
62
104
  const response = await postJson(`${options.baseUrl}/v1/detect-zod-schema`, options.apiKey, { candidates: batch }, timeoutMs);
63
105
  for (const result of response.results)
@@ -11,4 +11,4 @@ export interface ResolveProtectedLogicResult {
11
11
  * same way resolveAmbiguousFindings.ts is layered on top of it for Layer 2 — not called from inside
12
12
  * scan() itself, so scan()'s own purity/auditability claim stays true.
13
13
  */
14
- export declare function resolveProtectedLogic(findings: Finding[], pendingZodCandidates: ZodCandidate[], options: ProtectedLogicClientOptions): Promise<ResolveProtectedLogicResult>;
14
+ export declare function resolveProtectedLogic(findings: Finding[], pendingZodCandidates: ZodCandidate[], options: ProtectedLogicClientOptions, oversizedZodFiles?: string[]): Promise<ResolveProtectedLogicResult>;
@@ -1,4 +1,5 @@
1
1
  import { classifyFieldName, isAmbiguousFieldName } from "../rules/dataCategoryLexicon.js";
2
+ import { isSafeFieldName } from "../rules/safeFieldNames.js";
2
3
  import { countConfidentSiblings } from "../rules/confidenceFactors.js";
3
4
  import { detectZodSchemas, scoreFields } from "./protectedLogicClient.js";
4
5
  /**
@@ -7,9 +8,20 @@ import { detectZodSchemas, scoreFields } from "./protectedLogicClient.js";
7
8
  * same way resolveAmbiguousFindings.ts is layered on top of it for Layer 2 — not called from inside
8
9
  * scan() itself, so scan()'s own purity/auditability claim stays true.
9
10
  */
10
- export async function resolveProtectedLogic(findings, pendingZodCandidates, options) {
11
+ export async function resolveProtectedLogic(findings, pendingZodCandidates, options, oversizedZodFiles = []) {
11
12
  let workingFindings = findings;
12
13
  const warnings = [];
14
+ if (oversizedZodFiles.length > 0) {
15
+ // Kept to a single summarized line -- this list is meant to be rare now that the hard ceiling is
16
+ // only for pathological cases (see MAX_FRAGMENT_BYTES), but a long list here would still be an
17
+ // unreadable wall of text. The full, untruncated list is always in ScanResult.oversizedZodFiles
18
+ // (surfaced in --json output), so nothing here is actually lost, just not all printed inline.
19
+ const PREVIEW_COUNT = 3;
20
+ const preview = oversizedZodFiles.slice(0, PREVIEW_COUNT).join(", ");
21
+ const remaining = oversizedZodFiles.length - PREVIEW_COUNT;
22
+ const suffix = remaining > 0 ? `, and ${remaining} more (full list in --json output)` : "";
23
+ warnings.push(`Zod-schema detection skipped for ${oversizedZodFiles.length} file(s) because the extracted fragment was too large to send safely: ${preview}${suffix}.`);
24
+ }
13
25
  if (pendingZodCandidates.length > 0) {
14
26
  const byId = new Map(pendingZodCandidates.map((c) => [c.id, c]));
15
27
  const { results, error } = await detectZodSchemas(pendingZodCandidates.map((c) => ({ id: c.id, codeFragment: c.codeFragment })), options);
@@ -22,6 +34,8 @@ export async function resolveProtectedLogic(findings, pendingZodCandidates, opti
22
34
  continue;
23
35
  const allNames = result.fields.map((f) => f.name);
24
36
  for (const field of result.fields) {
37
+ if (isSafeFieldName(field.name))
38
+ continue;
25
39
  const sourceLine = candidate.fragmentLineToSourceLine[field.line - 1] ?? field.line;
26
40
  const lexiconCategory = classifyFieldName(field.name);
27
41
  const ambiguousName = isAmbiguousFieldName(field.name);
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { CONFIG_FILENAME, DEFAULT_CONFIG, configPath, hasUnmigratedLegacyConfig, legacyConfigMessage, writeConfig, } from "../config/config.js";
5
+ import { NETWORK_CALLS_NOTICE } from "../policy/legalNotices.js";
5
6
  function guessIndustry(root) {
6
7
  const pkgPath = join(root, "package.json");
7
8
  if (!existsSync(pkgPath))
@@ -36,12 +37,19 @@ export async function runInit(root) {
36
37
  const guessedIndustry = guessIndustry(root);
37
38
  const rl = createInterface({ input: process.stdin, output: process.stdout });
38
39
  const lines = rl[Symbol.asyncIterator]();
40
+ // Printed once, here, not re-shown on every scan -- informational only, never blocks: nothing below
41
+ // waits on an acknowledgment, so this is safe in non-interactive contexts (CI, scripted installs).
42
+ console.log(`${NETWORK_CALLS_NOTICE}\n`);
39
43
  console.log("SrcLawyer setup — answered once, reused on every scan.\n");
40
44
  const entityLocation = await ask(lines, "Where is your legal entity located? (country)", DEFAULT_CONFIG.entityLocation ?? "");
41
45
  const targetMarketsRaw = await ask(lines, "Which regions/markets do you serve? (comma-separated, e.g. US, EU)", "");
42
46
  const industry = await ask(lines, "What industry/vertical is this product in?", guessedIndustry ?? "");
43
47
  const childrensDataRaw = await ask(lines, "Does this product knowingly collect data from children under 13/16?", "no");
44
- const tierRaw = await ask(lines, "Tier: free (static analysis only) / byok (bring your own Anthropic key) / managed (not yet available)", "free");
48
+ // "managed" is deliberately not offered here -- it's a real SrcLawyerTier value and config.ts/
49
+ // resolveProviderForTier.ts both still support it fully (a hand-edited config with tier: managed
50
+ // still gets a clear, accurate error at scan time), but there's no advertised path to it from this
51
+ // prompt, so a tester reading the choices here can't select a dead end during setup.
52
+ const tierRaw = await ask(lines, "Tier: free (static analysis only) / byok (bring your own Anthropic key)", "free");
45
53
  const tier = normalizeTier(tierRaw);
46
54
  let apiKeyEnvVar = null;
47
55
  if (tier === "byok") {
@@ -8,6 +8,8 @@ import { resolveProviderForTier } from "../llm/resolveProviderForTier.js";
8
8
  import { resolveAmbiguousFindings } from "../llm/resolveAmbiguousFindings.js";
9
9
  import { resolveProtectedLogic } from "../cloud/resolveProtectedLogic.js";
10
10
  import { resolveProtectedLogicEndpoint } from "../cloud/config.js";
11
+ import { MAX_FRAGMENT_BYTES } from "../rules/zodPreFilter.js";
12
+ import { SCAN_DISCLAIMER_REMINDER, byokNetworkNotice } from "../policy/legalNotices.js";
11
13
  export async function runScan(root, options) {
12
14
  const config = loadConfig(root);
13
15
  let legacyConfigWarning = null;
@@ -22,15 +24,18 @@ export async function runScan(root, options) {
22
24
  }
23
25
  const result = await scan(root);
24
26
  let findings = result.findings;
27
+ if (result.maxZodFragmentBytes > 0) {
28
+ console.error(`Largest Zod-schema fragment this scan: ${result.maxZodFragmentBytes} bytes (hard ceiling: ${MAX_FRAGMENT_BYTES} bytes).`);
29
+ }
25
30
  // Not tier-gated, not behind --no-llm: confidence scoring and Zod-schema detection are Layer 1
26
31
  // capabilities now, needed on every real scan regardless of which LLM tier (if any) is configured.
27
32
  // A failure here degrades honestly (findings keep their safe local fallback) rather than blocking
28
33
  // the scan — see resolveProtectedLogic's own per-call error handling.
29
- const hasScorableFindings = findings.some((f) => f.confidenceFactors) || result.pendingZodCandidates.length > 0;
34
+ const hasScorableFindings = findings.some((f) => f.confidenceFactors) || result.pendingZodCandidates.length > 0 || result.oversizedZodFiles.length > 0;
30
35
  let protectedLogicWarning = null;
31
36
  if (hasScorableFindings) {
32
37
  const endpoint = resolveProtectedLogicEndpoint();
33
- const resolved = await resolveProtectedLogic(findings, result.pendingZodCandidates, endpoint);
38
+ const resolved = await resolveProtectedLogic(findings, result.pendingZodCandidates, endpoint, result.oversizedZodFiles);
34
39
  findings = resolved.findings;
35
40
  protectedLogicWarning = resolved.warning;
36
41
  if (protectedLogicWarning)
@@ -46,6 +51,9 @@ export async function runScan(root, options) {
46
51
  else if (provider) {
47
52
  const ambiguous = findings.filter((f) => f.requiresReview);
48
53
  if (ambiguous.length > 0) {
54
+ if (config.tier === "byok" && config.llm) {
55
+ console.log(byokNetworkNotice(config.llm.apiKeyEnvVar));
56
+ }
49
57
  console.log(`Sending ${ambiguous.length} ambiguous item(s) to ${provider.name}...`);
50
58
  const resolved = await resolveAmbiguousFindings(ambiguous, provider, {
51
59
  collectsChildrensData: config.collectsChildrensData,
@@ -58,7 +66,7 @@ export async function runScan(root, options) {
58
66
  const ambiguousCount = findings.filter((f) => f.requiresReview).length;
59
67
  const finalResult = { ...result, findings, ambiguousCount };
60
68
  const policyPath = resolve(root, options.out);
61
- writeFileSync(policyPath, generatePolicyMarkdown(finalResult, config, llmError));
69
+ writeFileSync(policyPath, generatePolicyMarkdown(finalResult, config, llmError, protectedLogicWarning));
62
70
  if (result.unsupportedStackWarning || llmError || protectedLogicWarning || legacyConfigWarning) {
63
71
  process.exitCode = 1;
64
72
  }
@@ -69,5 +77,8 @@ export async function runScan(root, options) {
69
77
  console.log(formatText(finalResult));
70
78
  if (protectedLogicWarning)
71
79
  console.log(`\n${protectedLogicWarning}`);
80
+ if (legacyConfigWarning)
81
+ console.log(`\n${legacyConfigWarning}`);
72
82
  console.log(`\nPrivacy policy written to ${join(options.out)}`);
83
+ console.log(`\n${SCAN_DISCLAIMER_REMINDER}`);
73
84
  }
@@ -1,5 +1,6 @@
1
1
  export interface DiscoveredFiles {
2
2
  codeFiles: string[];
3
+ pythonFiles: string[];
3
4
  htmlFiles: string[];
4
5
  schemaFiles: string[];
5
6
  manifestFiles: string[];
@@ -1,5 +1,6 @@
1
1
  import fg from "fast-glob";
2
2
  const CODE_PATTERNS = ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs"];
3
+ const PYTHON_PATTERNS = ["**/*.py"];
3
4
  const HTML_PATTERNS = ["**/*.html", "**/*.htm"];
4
5
  const SCHEMA_PATTERNS = ["**/*.graphql", "**/*.gql", "**/openapi.yaml", "**/openapi.yml", "**/openapi.json", "**/swagger.yaml", "**/swagger.yml", "**/swagger.json"];
5
6
  const MANIFEST_PATTERNS = ["**/package.json", "**/requirements.txt"];
@@ -14,12 +15,25 @@ const IGNORE = [
14
15
  "**/*.spec.*",
15
16
  "**/*.d.ts",
16
17
  ];
18
+ // Separate from IGNORE: node_modules has no Python analogue, and excluding a venv/site-packages
19
+ // directory matters even more here, since it's a full copy of every installed pip package's own
20
+ // source -- the Python equivalent of node_modules, and just as irrelevant/misleading to scan.
21
+ const PYTHON_IGNORE = [
22
+ ...IGNORE,
23
+ "**/venv/**",
24
+ "**/.venv/**",
25
+ "**/__pycache__/**",
26
+ "**/site-packages/**",
27
+ "**/test_*.py",
28
+ "**/*_test.py",
29
+ ];
17
30
  export async function discoverFiles(root) {
18
- const [codeFiles, htmlFiles, schemaFiles, manifestFiles] = await Promise.all([
31
+ const [codeFiles, pythonFiles, htmlFiles, schemaFiles, manifestFiles] = await Promise.all([
19
32
  fg(CODE_PATTERNS, { cwd: root, absolute: true, ignore: IGNORE, dot: false }),
33
+ fg(PYTHON_PATTERNS, { cwd: root, absolute: true, ignore: PYTHON_IGNORE, dot: false }),
20
34
  fg(HTML_PATTERNS, { cwd: root, absolute: true, ignore: IGNORE, dot: false }),
21
35
  fg(SCHEMA_PATTERNS, { cwd: root, absolute: true, ignore: ["**/node_modules/**"], dot: false }),
22
36
  fg(MANIFEST_PATTERNS, { cwd: root, absolute: true, ignore: ["**/node_modules/**"], dot: false }),
23
37
  ]);
24
- return { codeFiles, htmlFiles, schemaFiles, manifestFiles };
38
+ return { codeFiles, pythonFiles, htmlFiles, schemaFiles, manifestFiles };
25
39
  }
@@ -1,22 +1,25 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import fg from "fast-glob";
4
- const IGNORE = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.git/**", "**/vendor/**"];
5
- const SUPPORTED_EXTENSIONS = ["ts", "tsx", "js", "jsx", "mjs", "cjs", "html", "htm"];
4
+ const IGNORE = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.git/**", "**/vendor/**", "**/venv/**", "**/.venv/**", "**/__pycache__/**"];
5
+ // Python moved from UNSUPPORTED_LANGUAGES to here once Flask detection shipped (Stage A) -- it's a
6
+ // supported *language* now, but only one framework within it is actually understood. A Python project
7
+ // using a different framework still needs an honest gap warning; see detectUnsupportedPythonFramework.
8
+ const SUPPORTED_EXTENSIONS = ["ts", "tsx", "js", "jsx", "mjs", "cjs", "html", "htm", "py"];
6
9
  const UNSUPPORTED_LANGUAGES = [
7
- { label: "Python", extensions: ["py"] },
8
10
  { label: "Ruby", extensions: ["rb"] },
9
11
  { label: "Go", extensions: ["go"] },
10
12
  { label: "Java", extensions: ["java"] },
11
13
  { label: "PHP", extensions: ["php"] },
12
14
  { label: "C#", extensions: ["cs"] },
13
15
  ];
16
+ const FASTAPI_IMPORT = /\bfrom\s+fastapi\b|\bimport\s+fastapi\b/;
14
17
  function bannerize(message) {
15
- return `UNSUPPORTED CODEBASE — ${message} SrcLawyer only analyzes JavaScript/TypeScript and HTML today; the findings below (if any) are incomplete and MUST NOT be treated as a full compliance scan.`;
18
+ return `UNSUPPORTED CODEBASE — ${message} SrcLawyer only analyzes JavaScript/TypeScript, HTML, and Flask-based Python today; the findings below (if any) are incomplete and MUST NOT be treated as a full compliance scan.`;
16
19
  }
17
20
  async function detectFrameworkSignature(root) {
18
21
  if (existsSync(join(root, "manage.py"))) {
19
- return bannerize("Django detected (manage.py found at the project root).");
22
+ return bannerize("Django detected (manage.py found at the project root) — Django support isn't built yet.");
20
23
  }
21
24
  const hasGemfile = existsSync(join(root, "Gemfile"));
22
25
  const hasRailsLayout = existsSync(join(root, "config", "routes.rb")) || existsSync(join(root, "app", "controllers"));
@@ -29,6 +32,27 @@ async function detectFrameworkSignature(root) {
29
32
  }
30
33
  return null;
31
34
  }
35
+ /**
36
+ * Presence-based, like detectFrameworkSignature's manage.py check -- not an absence heuristic ("we
37
+ * found nothing so something must be wrong"), which can't tell a genuinely clean Flask file apart
38
+ * from an unsupported framework. Scans actual file content (not just a marker file) since FastAPI,
39
+ * unlike Django, has no single root-level marker file to check for instead.
40
+ */
41
+ async function detectUnsupportedPythonFramework(root, pythonFiles) {
42
+ for (const filePath of pythonFiles) {
43
+ let content;
44
+ try {
45
+ content = readFileSync(filePath, "utf8");
46
+ }
47
+ catch {
48
+ continue;
49
+ }
50
+ if (FASTAPI_IMPORT.test(content)) {
51
+ return bannerize("FastAPI detected (a `fastapi` import was found) — FastAPI support isn't built yet.");
52
+ }
53
+ }
54
+ return null;
55
+ }
32
56
  async function detectDominantUnsupportedLanguage(root) {
33
57
  const [supportedFiles, ...unsupportedGroups] = await Promise.all([
34
58
  fg(SUPPORTED_EXTENSIONS.map((ext) => `**/*.${ext}`), { cwd: root, ignore: IGNORE, dot: false }),
@@ -38,11 +62,17 @@ async function detectDominantUnsupportedLanguage(root) {
38
62
  const dominant = counts.filter((c) => c.count > 0).sort((a, b) => b.count - a.count)[0];
39
63
  if (!dominant || dominant.count <= supportedFiles.length)
40
64
  return null;
41
- return bannerize(`${dominant.label} detected as the primary language (${dominant.count} file(s), vs ${supportedFiles.length} supported JS/TS/HTML file(s)).`);
65
+ return bannerize(`${dominant.label} detected as the primary language (${dominant.count} file(s), vs ${supportedFiles.length} supported file(s)).`);
42
66
  }
43
67
  export async function detectUnsupportedStack(root) {
44
68
  const frameworkWarning = await detectFrameworkSignature(root);
45
69
  if (frameworkWarning)
46
70
  return frameworkWarning;
71
+ const pythonFiles = await fg("**/*.py", { cwd: root, absolute: true, ignore: IGNORE, dot: false });
72
+ if (pythonFiles.length > 0) {
73
+ const pythonFrameworkWarning = await detectUnsupportedPythonFramework(root, pythonFiles);
74
+ if (pythonFrameworkWarning)
75
+ return pythonFrameworkWarning;
76
+ }
47
77
  return detectDominantUnsupportedLanguage(root);
48
78
  }
@@ -0,0 +1,2 @@
1
+ import { type Tree } from "web-tree-sitter";
2
+ export declare function parsePythonSource(source: string): Promise<Tree | null>;
@@ -0,0 +1,44 @@
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { Parser, Language } from "web-tree-sitter";
4
+ // This file compiles to dist/engine/pythonAstUtils.js; the grammar is copied to dist/grammars/ by
5
+ // scripts/copyGrammars.js as part of `npm run build` (see grammars/README.md for why it's vendored
6
+ // rather than an npm dependency).
7
+ const HERE = dirname(fileURLToPath(import.meta.url));
8
+ const GRAMMAR_PATH = join(HERE, "..", "grammars", "tree-sitter-python.wasm");
9
+ // Initialized at most once per process, reused across every Python file in a scan -- mirrors
10
+ // tsProject.ts's lazy-memoized pattern for the same reason (real setup cost, no benefit to repeating
11
+ // it per file). Parser.init()/Language.load() are both async; parser.parse() itself is synchronous
12
+ // once a Parser is set up, and this module's scan-time usage is sequential (not concurrent), so one
13
+ // shared Parser instance is safe to reuse.
14
+ let parserPromise = null;
15
+ function initParser() {
16
+ if (!parserPromise) {
17
+ parserPromise = (async () => {
18
+ try {
19
+ await Parser.init();
20
+ const language = await Language.load(GRAMMAR_PATH);
21
+ const parser = new Parser();
22
+ parser.setLanguage(language);
23
+ return parser;
24
+ }
25
+ catch {
26
+ // Honest null, matching astUtils.ts's parseSource -- a missing/incompatible grammar build
27
+ // degrades this file's detection to nothing rather than crashing the whole scan.
28
+ return null;
29
+ }
30
+ })();
31
+ }
32
+ return parserPromise;
33
+ }
34
+ export async function parsePythonSource(source) {
35
+ const parser = await initParser();
36
+ if (!parser)
37
+ return null;
38
+ try {
39
+ return parser.parse(source);
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
@@ -4,10 +4,13 @@ import { discoverProjects } from "./discoverProjects.js";
4
4
  import { discoverFiles } from "./discoverFiles.js";
5
5
  import { detectUnsupportedStack } from "./languageDetection.js";
6
6
  import { parseSource } from "./astUtils.js";
7
+ import { parsePythonSource } from "./pythonAstUtils.js";
7
8
  import { runSdkRules } from "../rules/sdkRules.js";
8
9
  import { runFrameworkRules } from "../rules/frameworkRules.js";
9
10
  import { runWebApiRules } from "../rules/webApiRules.js";
10
11
  import { runTypedRequestBodyRules } from "../rules/typedRequestBodyRules.js";
12
+ import { runPythonSdkRules } from "../rules/pythonSdkRules.js";
13
+ import { runPythonFrameworkRules } from "../rules/pythonFrameworkRules.js";
11
14
  import { findZodCandidates } from "../rules/zodPreFilter.js";
12
15
  import { buildTsProject } from "./tsProject.js";
13
16
  import { runHtmlInputRules, extractInlineScripts } from "../rules/htmlRules.js";
@@ -34,9 +37,11 @@ function makeLazyTsProject(files) {
34
37
  return handle;
35
38
  };
36
39
  }
37
- function runCodeRules(filePath, source, root, getTsProject, zodCandidates) {
40
+ function runCodeRules(filePath, source, root, getTsProject, zodCandidates, oversizedZodFiles) {
38
41
  const ast = parseSource(source, filePath);
39
- zodCandidates.push(...findZodCandidates(source, filePath, root));
42
+ const zodResult = findZodCandidates(source, filePath, root);
43
+ zodCandidates.push(...zodResult.candidates);
44
+ oversizedZodFiles.push(...zodResult.oversizedFiles);
40
45
  if (!ast)
41
46
  return [];
42
47
  return [
@@ -46,6 +51,12 @@ function runCodeRules(filePath, source, root, getTsProject, zodCandidates) {
46
51
  ...runTypedRequestBodyRules(ast, filePath, source, root, getTsProject),
47
52
  ];
48
53
  }
54
+ async function runPythonCodeRules(filePath, source, root) {
55
+ const tree = await parsePythonSource(source);
56
+ if (!tree)
57
+ return [];
58
+ return [...runPythonSdkRules(tree, filePath, source, root), ...runPythonFrameworkRules(tree, filePath, source, root)];
59
+ }
49
60
  /**
50
61
  * Pure, local, offline — no network access anywhere in this function or anything it calls. Zod
51
62
  * schemas and confidence scoring can no longer be fully resolved here (see pendingZodCandidates and
@@ -59,10 +70,12 @@ export async function scan(root) {
59
70
  const [projects, unsupportedStackWarning] = await Promise.all([discoverProjects(root), detectUnsupportedStack(root)]);
60
71
  const findings = [];
61
72
  const pendingZodCandidates = [];
73
+ const oversizedZodFiles = [];
62
74
  let filesScanned = 0;
63
75
  const projectRootList = [...new Set(projects.map((p) => p.root))];
64
76
  const fileSets = await Promise.all(projectRootList.map((projectRoot) => discoverFiles(projectRoot)));
65
77
  const allCodeFiles = new Set();
78
+ const allPythonFiles = new Set();
66
79
  const allHtmlFiles = new Set();
67
80
  const allSchemaFiles = new Set();
68
81
  const rootForFile = new Map();
@@ -74,6 +87,7 @@ export async function scan(root) {
74
87
  allCodeFiles.add(f);
75
88
  rootForFile.set(f, projectRoot);
76
89
  });
90
+ set.pythonFiles.forEach((f) => allPythonFiles.add(f));
77
91
  set.htmlFiles.forEach((f) => allHtmlFiles.add(f));
78
92
  set.schemaFiles.forEach((f) => allSchemaFiles.add(f));
79
93
  });
@@ -90,7 +104,18 @@ export async function scan(root) {
90
104
  continue;
91
105
  }
92
106
  const getTsProject = tsProjectByRoot.get(rootForFile.get(filePath) ?? "") ?? noTsProject;
93
- findings.push(...runCodeRules(filePath, source, root, getTsProject, pendingZodCandidates));
107
+ findings.push(...runCodeRules(filePath, source, root, getTsProject, pendingZodCandidates, oversizedZodFiles));
108
+ }
109
+ for (const filePath of allPythonFiles) {
110
+ filesScanned += 1;
111
+ let source;
112
+ try {
113
+ source = readFileSync(filePath, "utf8");
114
+ }
115
+ catch {
116
+ continue;
117
+ }
118
+ findings.push(...(await runPythonCodeRules(filePath, source, root)));
94
119
  }
95
120
  for (const filePath of allHtmlFiles) {
96
121
  filesScanned += 1;
@@ -103,7 +128,7 @@ export async function scan(root) {
103
128
  }
104
129
  findings.push(...runHtmlInputRules(html, filePath, root));
105
130
  for (const script of extractInlineScripts(html)) {
106
- findings.push(...runCodeRules(filePath, script.code, root, noTsProject, pendingZodCandidates));
131
+ findings.push(...runCodeRules(filePath, script.code, root, noTsProject, pendingZodCandidates, oversizedZodFiles));
107
132
  }
108
133
  }
109
134
  for (const filePath of allSchemaFiles) {
@@ -126,5 +151,17 @@ export async function scan(root) {
126
151
  }
127
152
  }
128
153
  const ambiguousCount = findings.filter((f) => f.requiresReview).length;
129
- return { projects, findings, ambiguousCount, filesScanned, unsupportedStackWarning, pendingZodCandidates };
154
+ const maxZodFragmentBytes = pendingZodCandidates.length
155
+ ? Math.max(...pendingZodCandidates.map((c) => Buffer.byteLength(c.codeFragment, "utf8")))
156
+ : 0;
157
+ return {
158
+ projects,
159
+ findings,
160
+ ambiguousCount,
161
+ filesScanned,
162
+ unsupportedStackWarning,
163
+ pendingZodCandidates,
164
+ oversizedZodFiles,
165
+ maxZodFragmentBytes,
166
+ };
130
167
  }
@@ -68,4 +68,13 @@ export interface ScanResult {
68
68
  codeFragment: string;
69
69
  fragmentLineToSourceLine: number[];
70
70
  }>;
71
+ /** Relative paths of files whose extracted Zod fragment was too large to send safely and was
72
+ * dropped before ever reaching pendingZodCandidates -- surfaced in resolveProtectedLogic()'s
73
+ * warning so a schema going unchecked is never silent, just like a failed network call is. */
74
+ oversizedZodFiles: string[];
75
+ /** The largest pendingZodCandidates fragment's byte size this scan, or 0 if none. Logged by the
76
+ * caller so real-world fragment sizes keep being observed against MAX_FRAGMENT_BYTES
77
+ * (zodPreFilter.ts) and the batching cap (protectedLogicClient.ts) -- both are provisional,
78
+ * unvalidated against the Worker's real limit until its plan upgrade is confirmed. */
79
+ maxZodFragmentBytes: number;
71
80
  }