srclawyer 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +28 -6
  2. package/dist/cli.js +45 -3
  3. package/dist/cloud/protectedLogicClient.d.ts +3 -0
  4. package/dist/cloud/protectedLogicClient.js +62 -20
  5. package/dist/cloud/resolveProtectedLogic.d.ts +1 -1
  6. package/dist/cloud/resolveProtectedLogic.js +15 -1
  7. package/dist/commands/init.d.ts +16 -1
  8. package/dist/commands/init.js +107 -15
  9. package/dist/commands/scan.js +19 -4
  10. package/dist/config/config.d.ts +12 -1
  11. package/dist/config/config.js +12 -1
  12. package/dist/engine/discoverFiles.d.ts +1 -0
  13. package/dist/engine/discoverFiles.js +16 -2
  14. package/dist/engine/languageDetection.js +37 -7
  15. package/dist/engine/pythonAstUtils.d.ts +2 -0
  16. package/dist/engine/pythonAstUtils.js +44 -0
  17. package/dist/engine/scanEngine.js +42 -5
  18. package/dist/engine/types.d.ts +9 -0
  19. package/dist/grammars/LICENSE +21 -0
  20. package/dist/grammars/tree-sitter-python.wasm +0 -0
  21. package/dist/policy/generatePolicy.d.ts +1 -1
  22. package/dist/policy/generatePolicy.js +127 -3
  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
@@ -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
  }
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Max Brunsfeld
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,3 +1,3 @@
1
1
  import type { ScanResult } from "../engine/types.js";
2
2
  import { type SrcLawyerConfig } from "../config/config.js";
3
- export declare function generatePolicyMarkdown(result: ScanResult, config: SrcLawyerConfig | null, llmError?: string | null): string;
3
+ export declare function generatePolicyMarkdown(result: ScanResult, config: SrcLawyerConfig | null, llmError?: string | null, protectedLogicWarning?: string | null): string;
@@ -96,12 +96,122 @@ function renderContextSection(config) {
96
96
  lines.push(`- **Entity location:** ${config.entityLocation ?? "not specified"}`);
97
97
  lines.push(`- **Target markets:** ${config.targetMarkets.length > 0 ? config.targetMarkets.join(", ") : "not specified"}`);
98
98
  lines.push(`- **Industry:** ${config.industry ?? "not specified"}`);
99
- lines.push(`- **Collects children's data:** ${config.collectsChildrensData ? "yes" : "no"}`);
99
+ lines.push(`- **Collects children's data:** ${config.collectsChildrensData === null || config.collectsChildrensData === undefined ? "not specified" : config.collectsChildrensData ? "yes" : "no"}`);
100
100
  return lines.join("\n");
101
101
  }
102
102
  function renderUnsupportedStackWarning(warning) {
103
103
  return ["> **WARNING: INCOMPLETE SCAN — DO NOT PUBLISH WITHOUT MANUAL REVIEW**", ">", `> ${warning}`].join("\n");
104
104
  }
105
+ // Mirrors renderUnsupportedStackWarning's own reasoning exactly: this policy FILE gets committed and
106
+ // shared on its own, without the CLI output that already shows this warning -- a reader of the file
107
+ // alone has no other way to know confidence grading or Zod-schema detection didn't run for this scan.
108
+ function renderProtectedLogicWarning(warning) {
109
+ return ["> **WARNING: PROTECTED-LOGIC SERVICE UNAVAILABLE — POLICY MAY BE INCOMPLETE**", ">", `> ${warning}`].join("\n");
110
+ }
111
+ function placeholder(label) {
112
+ return `[TO COMPLETE: ${label}]`;
113
+ }
114
+ // The one function permitted to record that a fact was never answered -- every new section below
115
+ // goes through this (directly or via valueOrPlaceholder/triStateSentence) so renderPlaceholderSummary
116
+ // can list everything that's missing in one place, without a second pass over the config.
117
+ function placeholderLine(label, placeholders) {
118
+ placeholders.push(label);
119
+ return placeholder(label);
120
+ }
121
+ function valueOrPlaceholder(value, label, placeholders) {
122
+ if (value === null || value === undefined || value === "")
123
+ return placeholderLine(label, placeholders);
124
+ return value;
125
+ }
126
+ // Avoids a double terminal punctuation mark when a user-supplied value already ends with one --
127
+ // e.g. a company name ending in "." ("Acme Inc.") or a URL ending in "/" (where appending "." right
128
+ // after the slash reads as part of the path). The placeholder marker itself ends with "]", so this
129
+ // never double-punctuates that either.
130
+ function endSentence(prefix, value) {
131
+ const trimmed = value.trim();
132
+ return /[.!?/\]]$/.test(trimmed) ? `${prefix}${trimmed}` : `${prefix}${trimmed}.`;
133
+ }
134
+ function triStateSentence(value, label, trueText, falseText, placeholders) {
135
+ if (value === true)
136
+ return trueText;
137
+ if (value === false)
138
+ return falseText;
139
+ return placeholderLine(label, placeholders);
140
+ }
141
+ function renderIdentitySection(config, placeholders) {
142
+ const companyName = valueOrPlaceholder(config?.companyName, "company or product name", placeholders);
143
+ const siteUrl = valueOrPlaceholder(config?.siteUrl, "site or app URL", placeholders);
144
+ const privacyContactEmail = valueOrPlaceholder(config?.privacyContactEmail, "privacy contact email address", placeholders);
145
+ return [
146
+ "## About This Policy",
147
+ "",
148
+ endSentence("This policy is issued by ", companyName),
149
+ endSentence("It applies to the product or service available at ", siteUrl),
150
+ endSentence("Questions about this policy or requests regarding your personal data can be sent to ", privacyContactEmail),
151
+ ].join("\n");
152
+ }
153
+ // The sell/share and advertising sentences are the highest-risk new text in this document: the policy
154
+ // can simultaneously list detected third-party integrations (e.g. Stripe) in the Third-Party Data
155
+ // Processors section above. These sentences deliberately (1) use CCPA/CPRA's own defined terms rather
156
+ // than casual language, (2) frame the statement as the user's own self-declared setup answer, not a
157
+ // legal conclusion drawn by this tool, and (3) always close with an explicit cross-reference back to
158
+ // the detected-processors section, in all three states (true/false/placeholder), so the two sections
159
+ // are never read in isolation from each other. See docs/legal-wording-review.md -- this is drafted,
160
+ // attorney-reviewable starting wording, not settled language.
161
+ function renderDataPracticesSection(config, placeholders) {
162
+ const retention = valueOrPlaceholder(config?.dataRetentionPolicy, "data retention period or policy", placeholders);
163
+ const deletion = valueOrPlaceholder(config?.accountDeletionInstructions, "how a user can delete their account or request data deletion", placeholders);
164
+ const sellShare = triStateSentence(config?.sellsOrSharesData, 'whether personal data is sold or shared with third parties, as "sell" and "share" are defined under applicable law (e.g. CCPA/CPRA)', 'Based on information provided during setup, this product has been declared to sell or share personal data with third parties, as "sell" and "share" are defined under applicable law (e.g. CCPA/CPRA).', 'Based on information provided during setup, this product has not been declared to sell or share personal data with third parties, as "sell" and "share" are defined under applicable law (e.g. CCPA/CPRA).', placeholders);
165
+ const advertising = triStateSentence(config?.usesDataForAdvertising, "whether personal data is used for cross-context behavioral advertising", "Based on information provided during setup, this product has been declared to use personal data for cross-context behavioral advertising.", "Based on information provided during setup, this product has not been declared to use personal data for cross-context behavioral advertising.", placeholders);
166
+ const aiServices = triStateSentence(config?.sendsDataToAiServices, "whether user data is sent to third-party AI services as part of this product's own functionality", "User data may be sent to third-party AI services as part of this product's own functionality.", "User data is not sent to third-party AI services as part of this product's own functionality.", placeholders);
167
+ return [
168
+ "## Data Retention, Deletion, and Use",
169
+ "",
170
+ `Personal data is retained as follows: ${retention}`,
171
+ `To delete your account or request deletion of your personal data: ${deletion}`,
172
+ sellShare,
173
+ advertising,
174
+ "See the Third-Party Data Processors section above for the specific third-party integrations detected in this codebase; the declarations above describe this product's own stated practices and do not override what was actually detected there.",
175
+ aiServices,
176
+ ].join("\n");
177
+ }
178
+ function renderRightsSection(config, placeholders) {
179
+ const euUk = config?.hasEuUkUsers;
180
+ const california = config?.hasCaliforniaUsers;
181
+ if (euUk === false && california === false) {
182
+ return ["## Your Rights", "", "This product has not declared users in jurisdictions with additional statutory privacy rights (EU/UK, California)."].join("\n");
183
+ }
184
+ const lines = ["## Your Rights", ""];
185
+ if (euUk === true) {
186
+ lines.push("If you are located in the EU or UK, you may have rights under the GDPR and/or UK GDPR, including the right to access, correct, delete, or restrict the use of your personal data.");
187
+ }
188
+ else if (euUk !== false) {
189
+ lines.push(placeholderLine("whether this product has users in the EU/UK, which determines whether GDPR/UK GDPR disclosures apply", placeholders));
190
+ }
191
+ if (california === true) {
192
+ lines.push("If you are a California resident, you may have rights under the CCPA/CPRA, including the right to know, delete, correct, and opt out of the sale or sharing of your personal data.");
193
+ }
194
+ else if (california !== false) {
195
+ lines.push(placeholderLine("whether this product has users in California, which determines whether CCPA/CPRA disclosures apply", placeholders));
196
+ }
197
+ return lines.join("\n");
198
+ }
199
+ function renderChildrensPrivacySection(config, placeholders) {
200
+ const collects = config?.collectsChildrensData;
201
+ const collectsLine = triStateSentence(collects, "whether this product knowingly collects personal data from children", "This product knowingly collects personal data from children under applicable age thresholds (e.g. under 13 in the US, under 16 in the EU where applicable).", "This product does not knowingly collect personal data from children under applicable age thresholds.", placeholders);
202
+ const minimumAge = config?.minimumAge;
203
+ const ageLine = minimumAge === null || minimumAge === undefined
204
+ ? placeholderLine("minimum age required to use this product, if any", placeholders)
205
+ : `Users must be at least ${minimumAge} years old to use this product.`;
206
+ return ["## Children's Privacy", "", collectsLine, ageLine].join("\n");
207
+ }
208
+ function renderPlaceholderSummary(labels) {
209
+ const lines = ["## Placeholders — Complete Before Publishing", ""];
210
+ for (const label of labels) {
211
+ lines.push(`- ${placeholder(label)}`);
212
+ }
213
+ return lines.join("\n");
214
+ }
105
215
  function outcomeOf(finding) {
106
216
  if (!finding.requiresReview)
107
217
  return "confident";
@@ -111,21 +221,34 @@ function outcomeOf(finding) {
111
221
  return "declined";
112
222
  return "unresolved";
113
223
  }
114
- export function generatePolicyMarkdown(result, config, llmError) {
224
+ export function generatePolicyMarkdown(result, config, llmError, protectedLogicWarning) {
115
225
  const confidentFindings = result.findings.filter((f) => {
116
226
  const outcome = outcomeOf(f);
117
227
  return outcome === "confident" || outcome === "resolved";
118
228
  });
119
229
  const declinedFindings = result.findings.filter((f) => outcomeOf(f) === "declined");
120
230
  const reviewFindings = result.findings.filter((f) => outcomeOf(f) === "unresolved");
231
+ // Rendered first (before the final parts[] is assembled) so the shared placeholders array is fully
232
+ // populated in time for the top-of-document summary below.
233
+ const placeholders = [];
234
+ const identitySection = renderIdentitySection(config, placeholders);
235
+ const dataPracticesSection = renderDataPracticesSection(config, placeholders);
236
+ const rightsSection = renderRightsSection(config, placeholders);
237
+ const childrensPrivacySection = renderChildrensPrivacySection(config, placeholders);
121
238
  const parts = ["# Privacy Policy", ""];
122
239
  if (result.unsupportedStackWarning) {
123
240
  parts.push(renderUnsupportedStackWarning(result.unsupportedStackWarning), "");
124
241
  }
242
+ if (protectedLogicWarning) {
243
+ parts.push(renderProtectedLogicWarning(protectedLogicWarning), "");
244
+ }
125
245
  if (llmError) {
126
246
  parts.push(renderLlmErrorWarning(llmError), "");
127
247
  }
128
- parts.push(`_Generated by SrcLawyer from static analysis of this codebase (${result.filesScanned} file(s) scanned)._`, "", renderContextSection(config), "", "## Data We Collect", "");
248
+ if (placeholders.length > 0) {
249
+ parts.push(renderPlaceholderSummary([...new Set(placeholders)]), "");
250
+ }
251
+ parts.push(`_Generated by SrcLawyer from static analysis of this codebase (${result.filesScanned} file(s) scanned)._`, "", renderContextSection(config), "", identitySection, "", "## Data We Collect", "");
129
252
  const categorySections = renderCategorySections(confidentFindings);
130
253
  if (categorySections.length > 0) {
131
254
  parts.push(categorySections.join("\n\n"));
@@ -140,6 +263,7 @@ export function generatePolicyMarkdown(result, config, llmError) {
140
263
  if (processorSection) {
141
264
  parts.push("", processorSection);
142
265
  }
266
+ parts.push("", dataPracticesSection, "", rightsSection, "", childrensPrivacySection);
143
267
  const declinedSection = renderDeclinedSection(declinedFindings);
144
268
  if (declinedSection) {
145
269
  parts.push("", declinedSection);
@@ -0,0 +1,5 @@
1
+ export declare const PRIVACY_POLICY_URL = "https://github.com/SrcLawyer/srclawyer#legal";
2
+ export declare const TERMS_URL = "https://github.com/SrcLawyer/srclawyer#legal";
3
+ export declare const NETWORK_CALLS_NOTICE: string;
4
+ export declare const SCAN_DISCLAIMER_REMINDER = "Reminder: this is automated analysis, not legal advice \u2014 review the generated policy before publishing it.";
5
+ export declare function byokNetworkNotice(apiKeyEnvVar: string | null): string;
@@ -0,0 +1,29 @@
1
+ // No dedicated privacy-policy/terms site exists yet -- these point at the real, resolvable README
2
+ // "Legal" section that covers the same ground today (not a placeholder or a dead `.invalid` link), and
3
+ // should be repointed to a real site once one exists. Kept as named constants precisely so there's one
4
+ // place to update when that happens -- a one-line change, not a hunt through every call site.
5
+ //
6
+ // Deliberately NOT labeled "Privacy policy:" / "Terms:" below: that would claim those documents exist
7
+ // at this URL, when the Legal section itself says plainly that they don't yet -- this points at a
8
+ // notice and disclaimer, not a substitute for either.
9
+ export const PRIVACY_POLICY_URL = "https://github.com/SrcLawyer/srclawyer#legal";
10
+ export const TERMS_URL = "https://github.com/SrcLawyer/srclawyer#legal";
11
+ // Verbatim from README.md's own "Network calls" section (minus markdown backticks, since this prints
12
+ // to a terminal) -- one wording for this claim, not a second paraphrase that can drift from the first.
13
+ export const NETWORK_CALLS_NOTICE = [
14
+ "Heads up: SrcLawyer makes network calls.",
15
+ "",
16
+ "Detection itself is fully local and offline — no code leaves your machine for pattern matching.",
17
+ "Once detection completes, scan makes one batched network call to grade the confidence of what it",
18
+ "found (field names and derived yes/no signals only, never source code), and, for scans that use Zod",
19
+ "schemas as request validators, one additional call sending a small redacted code fragment. This runs",
20
+ "on every scan regardless of tier.",
21
+ "",
22
+ `Legal notice and disclaimer: ${PRIVACY_POLICY_URL}`,
23
+ "(A dedicated privacy policy and terms of service are coming; this points to what exists today.)",
24
+ ].join("\n");
25
+ export const SCAN_DISCLAIMER_REMINDER = "Reminder: this is automated analysis, not legal advice — review the generated policy before publishing it.";
26
+ export function byokNetworkNotice(apiKeyEnvVar) {
27
+ const keySource = apiKeyEnvVar ? `env var ${apiKeyEnvVar}` : "your configured key";
28
+ return `BYOK is configured: redacted code snippets for ambiguous findings will be sent to Anthropic under your own API key (${keySource}), for AI-assisted classification only.`;
29
+ }
@@ -38,7 +38,7 @@ export function formatText(result) {
38
38
  }
39
39
  else if (result.ambiguousCount > 0) {
40
40
  lines.push("");
41
- lines.push(`${result.ambiguousCount} item(s) require deeper analysis before they can be included in a policy. Run "srclawyer scan" with a "byok"/"managed" tier configured to attempt AI-assisted resolution, or resolve manually.`);
41
+ lines.push(`${result.ambiguousCount} item(s) require deeper analysis before they can be included in a policy. Run "srclawyer scan" with the "byok" tier configured (bring your own Anthropic key) to attempt AI-assisted resolution, or resolve manually. A "managed" tier is planned but not yet available.`);
42
42
  }
43
43
  return lines.join("\n");
44
44
  }
@@ -1,9 +1,15 @@
1
1
  const LEXICON = [
2
2
  { pattern: /email/i, category: "email" },
3
- // Checked before the generic "name" pattern below: bank_name/account_holder_name end in
4
- // "name" and would otherwise be classified as just "name", losing the payment-specific signal.
5
3
  { pattern: /bank[ _-]?name|routing[ _-]?number|account[ _-]?holder|iban|swift[ _-]?code/i, category: "payment_info" },
6
- { pattern: /(full)?name$|firstname|lastname|surname/i, category: "name" },
4
+ // Anchored to the whole field name, not a bare "...name$" suffix match: the old pattern matched
5
+ // ANY field ending in "name" -- hostname, filename, directoryName, className, teamName, etc. -- none
6
+ // of which are a person's name. This matches only "name" itself and the known person-name
7
+ // decompositions (first/last/middle/maiden/nick/display/legal name, username), each allowing an
8
+ // optional separator (first_name, firstName, first-name). A compound this doesn't recognize falls
9
+ // through with no category match, landing in the same "could not confidently classify — needs
10
+ // review" path as any other unclassified field -- not the safe list, not a new discard path, just
11
+ // the existing honest-ambiguity treatment.
12
+ { pattern: /^(full|first|middle|last|sur|maiden|nick|display|legal)[\s_-]*name$|^name$|^user[\s_-]*name$/i, category: "name" },
7
13
  { pattern: /phone|mobile|telephone/i, category: "phone" },
8
14
  { pattern: /address|street|city|zip ?code|postal ?code/i, category: "physical_address" },
9
15
  { pattern: /card ?number|creditcard|cvv|cvc|cardholder/i, category: "payment_info" },