srclawyer 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Validation for workspace glob patterns read from a SCANNED repo's own package.json -- i.e. input an
3
+ * attacker controls whenever the repo being scanned isn't the user's own. Brace expansion in glob
4
+ * libraries can be driven to stack overflow, memory exhaustion, or minutes of CPU by very short
5
+ * patterns (a 37-character `{1..300}{1..300}{1..300}` is enough), so patterns are bounded here before
6
+ * they ever reach a glob library. Real workspace patterns (`packages/*`, `{apps,packages}/*`,
7
+ * `tools/*\/packages/*`) are tens of characters with at most one small brace group.
8
+ */
9
+ export const MAX_WORKSPACE_PATTERNS = 100;
10
+ export const MAX_PATTERN_LENGTH = 200;
11
+ export const MAX_BRACE_DEPTH = 2;
12
+ export const MAX_BRACE_GROUPS = 3;
13
+ export const MAX_BRACE_EXPANSION = 64;
14
+ export const MAX_RANGE_SPAN = 100;
15
+ const NUMERIC_RANGE = /^(-?\d+)\.\.(-?\d+)(?:\.\.(-?\d+))?$/;
16
+ const ALPHA_RANGE = /^[A-Za-z]\.\.[A-Za-z](?:\.\.-?\d+)?$/;
17
+ /** Upper bound on how many strings a brace-bearing segment expands to; null if malformed or too wide. */
18
+ function estimateExpansion(segment) {
19
+ let total = 1;
20
+ let i = 0;
21
+ while (i < segment.length) {
22
+ if (segment[i] === "}")
23
+ return null;
24
+ if (segment[i] !== "{") {
25
+ i += 1;
26
+ continue;
27
+ }
28
+ let depth = 1;
29
+ let j = i + 1;
30
+ while (j < segment.length && depth > 0) {
31
+ if (segment[j] === "{")
32
+ depth += 1;
33
+ else if (segment[j] === "}")
34
+ depth -= 1;
35
+ j += 1;
36
+ }
37
+ if (depth !== 0)
38
+ return null;
39
+ const inner = segment.slice(i + 1, j - 1);
40
+ const groupEstimate = estimateGroup(inner);
41
+ if (groupEstimate === null)
42
+ return null;
43
+ total *= groupEstimate;
44
+ if (total > MAX_BRACE_EXPANSION)
45
+ return null;
46
+ i = j;
47
+ }
48
+ return total;
49
+ }
50
+ function estimateGroup(inner) {
51
+ const numeric = NUMERIC_RANGE.exec(inner);
52
+ if (numeric) {
53
+ const span = Math.abs(Number(numeric[2]) - Number(numeric[1])) + 1;
54
+ const step = Math.abs(Number(numeric[3] ?? 1)) || 1;
55
+ return span > MAX_RANGE_SPAN ? null : Math.ceil(span / step);
56
+ }
57
+ if (ALPHA_RANGE.test(inner))
58
+ return 52;
59
+ const parts = [];
60
+ let depth = 0;
61
+ let start = 0;
62
+ for (let k = 0; k < inner.length; k++) {
63
+ if (inner[k] === "{")
64
+ depth += 1;
65
+ else if (inner[k] === "}")
66
+ depth -= 1;
67
+ else if (inner[k] === "," && depth === 0) {
68
+ parts.push(inner.slice(start, k));
69
+ start = k + 1;
70
+ }
71
+ }
72
+ parts.push(inner.slice(start));
73
+ let sum = 0;
74
+ for (const part of parts) {
75
+ const estimate = estimateExpansion(part);
76
+ if (estimate === null)
77
+ return null;
78
+ sum += estimate;
79
+ }
80
+ return sum;
81
+ }
82
+ function braceShapeProblem(pattern) {
83
+ let depth = 0;
84
+ let maxDepth = 0;
85
+ let groups = 0;
86
+ for (const ch of pattern) {
87
+ if (ch === "{") {
88
+ depth += 1;
89
+ groups += 1;
90
+ maxDepth = Math.max(maxDepth, depth);
91
+ }
92
+ else if (ch === "}") {
93
+ depth -= 1;
94
+ if (depth < 0)
95
+ return "unbalanced braces";
96
+ }
97
+ }
98
+ if (depth !== 0)
99
+ return "unbalanced braces";
100
+ if (maxDepth > MAX_BRACE_DEPTH)
101
+ return `brace nesting deeper than ${MAX_BRACE_DEPTH}`;
102
+ if (groups > MAX_BRACE_GROUPS)
103
+ return `more than ${MAX_BRACE_GROUPS} brace groups`;
104
+ if (estimateExpansion(pattern) === null)
105
+ return `brace expansion wider than ${MAX_BRACE_EXPANSION} alternatives`;
106
+ return null;
107
+ }
108
+ function patternProblem(pattern) {
109
+ if (typeof pattern !== "string")
110
+ return "an entry is not a string";
111
+ if (pattern.length === 0)
112
+ return "an entry is empty";
113
+ if (pattern.length > MAX_PATTERN_LENGTH)
114
+ return `an entry is longer than ${MAX_PATTERN_LENGTH} characters`;
115
+ if (pattern.includes("\0"))
116
+ return "an entry contains a NUL character";
117
+ if (pattern.includes("\\"))
118
+ return "an entry contains a backslash";
119
+ const body = pattern.startsWith("!") ? pattern.slice(1) : pattern;
120
+ if (body.startsWith("!"))
121
+ return "an entry has more than one leading '!'";
122
+ if (body.startsWith("/") || /^[A-Za-z]:/.test(body))
123
+ return "an entry is an absolute path";
124
+ if (body.split("/").includes(".."))
125
+ return "an entry escapes the repo root with '..'";
126
+ return braceShapeProblem(body);
127
+ }
128
+ /** `raw` is whatever package.json's `workspaces` (or `workspaces.packages`) held, unvalidated. */
129
+ export function validateWorkspacePatterns(raw) {
130
+ if (!Array.isArray(raw))
131
+ return { ok: false, reason: "workspaces is not an array of patterns" };
132
+ if (raw.length > MAX_WORKSPACE_PATTERNS)
133
+ return { ok: false, reason: `more than ${MAX_WORKSPACE_PATTERNS} workspace patterns` };
134
+ for (const pattern of raw) {
135
+ const problem = patternProblem(pattern);
136
+ if (problem)
137
+ return { ok: false, reason: problem };
138
+ }
139
+ return { ok: true, patterns: raw };
140
+ }
@@ -96,7 +96,7 @@ 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) {
@@ -108,6 +108,127 @@ function renderUnsupportedStackWarning(warning) {
108
108
  function renderProtectedLogicWarning(warning) {
109
109
  return ["> **WARNING: PROTECTED-LOGIC SERVICE UNAVAILABLE — POLICY MAY BE INCOMPLETE**", ">", `> ${warning}`].join("\n");
110
110
  }
111
+ // Same reasoning as the other warning banners: the policy file is committed and shared without the
112
+ // console output, and a rejected workspace config means packages outside the default locations were
113
+ // never scanned.
114
+ function renderWorkspaceWarning(warning) {
115
+ return ["> **WARNING: WORKSPACE CONFIGURATION REJECTED — SCAN MAY BE INCOMPLETE**", ">", `> ${warning}`].join("\n");
116
+ }
117
+ // Same reasoning as the other banners: files skipped for size mean findings in them are missing from
118
+ // a policy file that will be committed and shared without the console output.
119
+ function renderSkippedFilesWarning(warning) {
120
+ return ["> **WARNING: FILES SKIPPED OR NOT ANALYZED — SCAN INCOMPLETE**", ">", `> ${warning}`].join("\n");
121
+ }
122
+ function placeholder(label) {
123
+ return `[TO COMPLETE: ${label}]`;
124
+ }
125
+ // The one function permitted to record that a fact was never answered -- every new section below
126
+ // goes through this (directly or via valueOrPlaceholder/triStateSentence) so renderPlaceholderSummary
127
+ // can list everything that's missing in one place, without a second pass over the config.
128
+ function placeholderLine(label, placeholders) {
129
+ placeholders.push(label);
130
+ return placeholder(label);
131
+ }
132
+ function valueOrPlaceholder(value, label, placeholders) {
133
+ if (value === null || value === undefined || value === "")
134
+ return placeholderLine(label, placeholders);
135
+ return value;
136
+ }
137
+ // Avoids a double terminal punctuation mark when a user-supplied value already ends with one --
138
+ // e.g. a company name ending in "." ("Acme Inc.") or a URL ending in "/" (where appending "." right
139
+ // after the slash reads as part of the path). The placeholder marker itself ends with "]", so this
140
+ // never double-punctuates that either.
141
+ function endSentence(prefix, value) {
142
+ const trimmed = value.trim();
143
+ return /[.!?/\]]$/.test(trimmed) ? `${prefix}${trimmed}` : `${prefix}${trimmed}.`;
144
+ }
145
+ function triStateSentence(value, label, trueText, falseText, placeholders) {
146
+ if (value === true)
147
+ return trueText;
148
+ if (value === false)
149
+ return falseText;
150
+ return placeholderLine(label, placeholders);
151
+ }
152
+ function renderIdentitySection(config, placeholders) {
153
+ const companyName = valueOrPlaceholder(config?.companyName, "company or product name", placeholders);
154
+ const siteUrl = valueOrPlaceholder(config?.siteUrl, "site or app URL", placeholders);
155
+ const privacyContactEmail = valueOrPlaceholder(config?.privacyContactEmail, "privacy contact email address", placeholders);
156
+ return [
157
+ "## About This Policy",
158
+ "",
159
+ endSentence("This policy is issued by ", companyName),
160
+ endSentence("It applies to the product or service available at ", siteUrl),
161
+ endSentence("Questions about this policy or requests regarding your personal data can be sent to ", privacyContactEmail),
162
+ ].join("\n");
163
+ }
164
+ // The sell/share and advertising sentences are the highest-risk new text in this document: the policy
165
+ // can simultaneously list detected third-party integrations (e.g. Stripe) in the Third-Party Data
166
+ // Processors section above. These sentences deliberately (1) use CCPA/CPRA's own defined terms rather
167
+ // than casual language, (2) frame the statement as the user's own self-declared setup answer, not a
168
+ // legal conclusion drawn by this tool, and (3) close with a cross-reference back to the
169
+ // detected-processors section, in all three states (true/false/placeholder), so the two sections are
170
+ // never read in isolation from each other -- EXCEPT that the cross-reference only names that section
171
+ // when it actually exists in this document (hasProcessorSection); otherwise it would point at a
172
+ // heading that was never rendered, whether because nothing was detected or because detection itself
173
+ // was incomplete (unsupported stack). See docs/legal-wording-review.md -- this is drafted,
174
+ // attorney-reviewable starting wording, not settled language.
175
+ function renderDataPracticesSection(config, placeholders, hasProcessorSection) {
176
+ const retention = valueOrPlaceholder(config?.dataRetentionPolicy, "data retention period or policy", placeholders);
177
+ const deletion = valueOrPlaceholder(config?.accountDeletionInstructions, "how a user can delete their account or request data deletion", placeholders);
178
+ 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);
179
+ 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);
180
+ 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);
181
+ const processorsCrossReference = hasProcessorSection
182
+ ? "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."
183
+ : "This document does not include a Third-Party Data Processors section (none were detected, or detection may have been incomplete -- see any warning above); the declarations above describe this product's own stated practices only and do not override any third-party integrations this codebase may actually use.";
184
+ return [
185
+ "## Data Retention, Deletion, and Use",
186
+ "",
187
+ `Personal data is retained as follows: ${retention}`,
188
+ `To delete your account or request deletion of your personal data: ${deletion}`,
189
+ sellShare,
190
+ advertising,
191
+ processorsCrossReference,
192
+ aiServices,
193
+ ].join("\n");
194
+ }
195
+ function renderRightsSection(config, placeholders) {
196
+ const euUk = config?.hasEuUkUsers;
197
+ const california = config?.hasCaliforniaUsers;
198
+ if (euUk === false && california === false) {
199
+ return ["## Your Rights", "", "This product has not declared users in jurisdictions with additional statutory privacy rights (EU/UK, California)."].join("\n");
200
+ }
201
+ const lines = ["## Your Rights", ""];
202
+ if (euUk === true) {
203
+ 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.");
204
+ }
205
+ else if (euUk !== false) {
206
+ lines.push(placeholderLine("whether this product has users in the EU/UK, which determines whether GDPR/UK GDPR disclosures apply", placeholders));
207
+ }
208
+ if (california === true) {
209
+ 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.");
210
+ }
211
+ else if (california !== false) {
212
+ lines.push(placeholderLine("whether this product has users in California, which determines whether CCPA/CPRA disclosures apply", placeholders));
213
+ }
214
+ return lines.join("\n");
215
+ }
216
+ function renderChildrensPrivacySection(config, placeholders) {
217
+ const collects = config?.collectsChildrensData;
218
+ 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);
219
+ const minimumAge = config?.minimumAge;
220
+ const ageLine = minimumAge === null || minimumAge === undefined
221
+ ? placeholderLine("minimum age required to use this product, if any", placeholders)
222
+ : `Users must be at least ${minimumAge} years old to use this product.`;
223
+ return ["## Children's Privacy", "", collectsLine, ageLine].join("\n");
224
+ }
225
+ function renderPlaceholderSummary(labels) {
226
+ const lines = ["## Placeholders — Complete Before Publishing", ""];
227
+ for (const label of labels) {
228
+ lines.push(`- ${placeholder(label)}`);
229
+ }
230
+ return lines.join("\n");
231
+ }
111
232
  function outcomeOf(finding) {
112
233
  if (!finding.requiresReview)
113
234
  return "confident";
@@ -124,17 +245,36 @@ export function generatePolicyMarkdown(result, config, llmError, protectedLogicW
124
245
  });
125
246
  const declinedFindings = result.findings.filter((f) => outcomeOf(f) === "declined");
126
247
  const reviewFindings = result.findings.filter((f) => outcomeOf(f) === "unresolved");
248
+ // Computed first so renderDataPracticesSection knows whether the Third-Party Data Processors
249
+ // section (rendered later, below) actually exists in this document before it cross-references it.
250
+ const processorSection = renderProcessorSection(confidentFindings);
251
+ // Rendered next (before the final parts[] is assembled) so the shared placeholders array is fully
252
+ // populated in time for the top-of-document summary below.
253
+ const placeholders = [];
254
+ const identitySection = renderIdentitySection(config, placeholders);
255
+ const dataPracticesSection = renderDataPracticesSection(config, placeholders, processorSection !== null);
256
+ const rightsSection = renderRightsSection(config, placeholders);
257
+ const childrensPrivacySection = renderChildrensPrivacySection(config, placeholders);
127
258
  const parts = ["# Privacy Policy", ""];
128
259
  if (result.unsupportedStackWarning) {
129
260
  parts.push(renderUnsupportedStackWarning(result.unsupportedStackWarning), "");
130
261
  }
262
+ if (result.workspaceWarning) {
263
+ parts.push(renderWorkspaceWarning(result.workspaceWarning), "");
264
+ }
265
+ if (result.skippedFilesWarning) {
266
+ parts.push(renderSkippedFilesWarning(result.skippedFilesWarning), "");
267
+ }
131
268
  if (protectedLogicWarning) {
132
269
  parts.push(renderProtectedLogicWarning(protectedLogicWarning), "");
133
270
  }
134
271
  if (llmError) {
135
272
  parts.push(renderLlmErrorWarning(llmError), "");
136
273
  }
137
- parts.push(`_Generated by SrcLawyer from static analysis of this codebase (${result.filesScanned} file(s) scanned)._`, "", renderContextSection(config), "", "## Data We Collect", "");
274
+ if (placeholders.length > 0) {
275
+ parts.push(renderPlaceholderSummary([...new Set(placeholders)]), "");
276
+ }
277
+ parts.push(`_Generated by SrcLawyer from static analysis of this codebase (${result.filesScanned} file(s) scanned)._`, "", renderContextSection(config), "", identitySection, "", "## Data We Collect", "");
138
278
  const categorySections = renderCategorySections(confidentFindings);
139
279
  if (categorySections.length > 0) {
140
280
  parts.push(categorySections.join("\n\n"));
@@ -145,10 +285,10 @@ export function generatePolicyMarkdown(result, config, llmError, protectedLogicW
145
285
  else {
146
286
  parts.push("No data-collection patterns were confidently identified in this codebase.");
147
287
  }
148
- const processorSection = renderProcessorSection(confidentFindings);
149
288
  if (processorSection) {
150
289
  parts.push("", processorSection);
151
290
  }
291
+ parts.push("", dataPracticesSection, "", rightsSection, "", childrensPrivacySection);
152
292
  const declinedSection = renderDeclinedSection(declinedFindings);
153
293
  if (declinedSection) {
154
294
  parts.push("", declinedSection);
@@ -8,7 +8,23 @@ export function formatText(result) {
8
8
  lines.push(BANNER_RULE);
9
9
  lines.push("");
10
10
  }
11
+ if (result.workspaceWarning) {
12
+ lines.push(BANNER_RULE);
13
+ lines.push("WARNING:");
14
+ lines.push(result.workspaceWarning);
15
+ lines.push(BANNER_RULE);
16
+ lines.push("");
17
+ }
18
+ if (result.skippedFilesWarning) {
19
+ lines.push(BANNER_RULE);
20
+ lines.push("WARNING:");
21
+ lines.push(result.skippedFilesWarning);
22
+ lines.push(BANNER_RULE);
23
+ lines.push("");
24
+ }
11
25
  lines.push(`Scanned ${result.filesScanned} file(s) across ${result.projects.length} project(s).`);
26
+ if (result.templateSyntaxNote)
27
+ lines.push(`Note: ${result.templateSyntaxNote}`);
12
28
  lines.push(`Found ${result.findings.length} finding(s), ${result.ambiguousCount} requiring review.`);
13
29
  lines.push("");
14
30
  const byProcessor = result.findings.filter((f) => f.processor);
@@ -1,6 +1,17 @@
1
1
  import type { Finding } from "../engine/types.js";
2
+ export declare const MAX_INLINE_SCRIPT_PADDING_CHARS = 16000000;
3
+ export declare function findInputTags(html: string): Array<{
4
+ index: number;
5
+ tag: string;
6
+ }>;
2
7
  export declare function runHtmlInputRules(html: string, filePath: string, root: string): Finding[];
3
8
  export interface ExtractedScript {
4
9
  code: string;
5
10
  }
11
+ export interface InlineScriptExtraction {
12
+ scripts: ExtractedScript[];
13
+ /** Inline scripts not extracted because the padding budget ran out (see MAX_INLINE_SCRIPT_PADDING_CHARS). */
14
+ skippedCount: number;
15
+ }
16
+ export declare function extractInlineScriptsBounded(html: string): InlineScriptExtraction;
6
17
  export declare function extractInlineScripts(html: string): ExtractedScript[];
@@ -1,27 +1,65 @@
1
1
  import { relative } from "node:path";
2
2
  import { classifyFieldName } from "./dataCategoryLexicon.js";
3
3
  import { isSafeFieldName } from "./safeFieldNames.js";
4
- const INPUT_TAG_RE = /<input\b[^>]*>/gi;
5
- const SCRIPT_TAG_RE = /<script([^>]*)>([\s\S]*?)<\/script>/gi;
4
+ // Tag matching is a start-anchor regex plus indexOf, not `<input\b[^>]*>` / `<script([^>]*)>([\s\S]*?)</script>`:
5
+ // on a file full of unclosed tags those single regexes rescan to the end of the file from every start
6
+ // (quadratic -- 200KB of "<input " took 8s), whereas this finds the same matches in one linear pass,
7
+ // since a tag with no closing delimiter after one start can't have one after any later start either.
8
+ const INPUT_START_RE = /<input\b/gi;
9
+ const SCRIPT_START_RE = /<script/gi;
10
+ const SCRIPT_CLOSE_RE = /<\/script>/gi;
11
+ // Padding inline scripts with one "\n" per preceding line (so rule line numbers match the original
12
+ // file) costs O(script count x line count) characters on a file with many scripts; this bounds the
13
+ // total so a hostile file can't exhaust memory. Scripts past the budget are reported, never dropped
14
+ // silently.
15
+ export const MAX_INLINE_SCRIPT_PADDING_CHARS = 16_000_000;
6
16
  const NON_DATA_INPUT_TYPES = new Set(["submit", "button", "reset", "image"]);
7
17
  function getAttr(tag, name) {
8
18
  const match = new RegExp(`${name}\\s*=\\s*["']([^"']*)["']`, "i").exec(tag);
9
19
  return match ? match[1] : null;
10
20
  }
11
- function lineAt(html, index) {
12
- return html.slice(0, index).split("\n").length;
21
+ // 1-based line number of a character offset, via a one-time index of line starts and a binary search
22
+ // (the previous slice-and-split per lookup was O(file size) each).
23
+ function makeLineLookup(text) {
24
+ const lineStarts = [0];
25
+ for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1))
26
+ lineStarts.push(i + 1);
27
+ return (index) => {
28
+ let lo = 0;
29
+ let hi = lineStarts.length - 1;
30
+ while (lo < hi) {
31
+ const mid = (lo + hi + 1) >> 1;
32
+ if (lineStarts[mid] <= index)
33
+ lo = mid;
34
+ else
35
+ hi = mid - 1;
36
+ }
37
+ return lo + 1;
38
+ };
39
+ }
40
+ export function findInputTags(html) {
41
+ const tags = [];
42
+ const startRe = new RegExp(INPUT_START_RE.source, INPUT_START_RE.flags);
43
+ let match;
44
+ while ((match = startRe.exec(html))) {
45
+ const end = html.indexOf(">", match.index);
46
+ if (end === -1)
47
+ break;
48
+ startRe.lastIndex = end + 1;
49
+ tags.push({ index: match.index, tag: html.slice(match.index, end + 1) });
50
+ }
51
+ return tags;
13
52
  }
14
53
  export function runHtmlInputRules(html, filePath, root) {
15
54
  const relPath = relative(root, filePath);
16
55
  const findings = [];
17
- let match;
18
- while ((match = INPUT_TAG_RE.exec(html))) {
19
- const tag = match[0];
56
+ const lineAt = makeLineLookup(html);
57
+ for (const { index, tag } of findInputTags(html)) {
20
58
  const type = (getAttr(tag, "type") ?? "text").toLowerCase();
21
59
  if (NON_DATA_INPUT_TYPES.has(type))
22
60
  continue;
23
61
  const identifier = getAttr(tag, "id") ?? getAttr(tag, "name");
24
- const line = lineAt(html, match.index);
62
+ const line = lineAt(index);
25
63
  if (type === "password") {
26
64
  findings.push(makeFinding(relPath, line, tag, "authentication_credentials", "high", false, `<input type="password"> field collects a credential.`));
27
65
  continue;
@@ -56,20 +94,38 @@ function makeFinding(relPath, line, tag, category, confidence, requiresReview, d
56
94
  requiresReview,
57
95
  };
58
96
  }
59
- export function extractInlineScripts(html) {
97
+ export function extractInlineScriptsBounded(html) {
60
98
  const scripts = [];
99
+ const lineAt = makeLineLookup(html);
100
+ const startRe = new RegExp(SCRIPT_START_RE.source, SCRIPT_START_RE.flags);
101
+ const closeRe = new RegExp(SCRIPT_CLOSE_RE.source, SCRIPT_CLOSE_RE.flags);
102
+ let paddingUsed = 0;
103
+ let skippedCount = 0;
61
104
  let match;
62
- while ((match = SCRIPT_TAG_RE.exec(html))) {
63
- const attrs = match[1];
105
+ while ((match = startRe.exec(html))) {
106
+ const openEnd = html.indexOf(">", match.index);
107
+ if (openEnd === -1)
108
+ break;
109
+ closeRe.lastIndex = openEnd + 1;
110
+ const close = closeRe.exec(html);
111
+ if (!close)
112
+ break;
113
+ startRe.lastIndex = close.index + close[0].length;
114
+ const attrs = html.slice(match.index + "<script".length, openEnd);
64
115
  if (/\bsrc\s*=/i.test(attrs))
65
116
  continue;
66
117
  if (/\btype\s*=\s*["'](?!(?:text\/javascript|module|application\/javascript)["'])[^"']*["']/i.test(attrs))
67
118
  continue;
68
- const body = match[2];
69
- const openTagLength = "<script".length + attrs.length + 1;
70
- const contentStart = match.index + openTagLength;
71
- const linesBefore = html.slice(0, contentStart).split("\n").length - 1;
72
- scripts.push({ code: "\n".repeat(linesBefore) + body });
119
+ const linesBefore = lineAt(openEnd + 1) - 1;
120
+ if (paddingUsed + linesBefore > MAX_INLINE_SCRIPT_PADDING_CHARS) {
121
+ skippedCount += 1;
122
+ continue;
123
+ }
124
+ paddingUsed += linesBefore;
125
+ scripts.push({ code: "\n".repeat(linesBefore) + html.slice(openEnd + 1, close.index) });
73
126
  }
74
- return scripts;
127
+ return { scripts, skippedCount };
128
+ }
129
+ export function extractInlineScripts(html) {
130
+ return extractInlineScriptsBounded(html).scripts;
75
131
  }
@@ -101,6 +101,15 @@ function findElidableInnerLines(file, rootRange, protectedLines) {
101
101
  });
102
102
  return elidable;
103
103
  }
104
+ function escapeRegExp(text) {
105
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
106
+ }
107
+ // Whole-identifier match with explicit identifier-character boundaries, not `\b${name}\b`: `$` is a
108
+ // legal identifier character but a regex anchor, and `\b` doesn't treat it as a word character, so the
109
+ // old form silently never matched an import named like `$schema` and dropped it from the fragment.
110
+ function mentionsIdentifier(text, name) {
111
+ return new RegExp(`(?<![\\w$])${escapeRegExp(name)}(?![\\w$])`).test(text);
112
+ }
104
113
  function importedLocalNames(decl) {
105
114
  return decl.specifiers.map((spec) => spec.local.name);
106
115
  }
@@ -166,7 +175,7 @@ export function findZodCandidates(source, filePath, root) {
166
175
  for (const stmt of body) {
167
176
  if (stmt.type !== "ImportDeclaration")
168
177
  continue;
169
- const referenced = importedLocalNames(stmt).some((name) => new RegExp(`\\b${name}\\b`).test(coreText));
178
+ const referenced = importedLocalNames(stmt).some((name) => mentionsIdentifier(coreText, name));
170
179
  if (referenced)
171
180
  includeRange(lineRange(stmt));
172
181
  }
@@ -1,2 +1,7 @@
1
1
  import type { Finding } from "../engine/types.js";
2
- export declare function parseOpenApiSpec(filePath: string, root: string): Finding[];
2
+ /** Set by parseOpenApiSpec so the caller can surface a malformed document instead of silently getting
3
+ * fewer (or no) findings -- the YAML parser recovers from errors rather than throwing. */
4
+ export interface OpenApiParseReport {
5
+ hadErrors: boolean;
6
+ }
7
+ export declare function parseOpenApiSpec(filePath: string, root: string, report?: OpenApiParseReport): Finding[];
@@ -3,10 +3,12 @@ import { relative } from "node:path";
3
3
  import { parseDocument, LineCounter, isMap, isPair, isScalar } from "yaml";
4
4
  import { classifyFieldName, isAmbiguousFieldName } from "../rules/dataCategoryLexicon.js";
5
5
  import { isSafeFieldName } from "../rules/safeFieldNames.js";
6
- export function parseOpenApiSpec(filePath, root) {
6
+ export function parseOpenApiSpec(filePath, root, report) {
7
7
  const raw = readFileSync(filePath, "utf8");
8
8
  const lineCounter = new LineCounter();
9
9
  const doc = parseDocument(raw, { lineCounter, keepSourceTokens: true });
10
+ if (report && doc.errors.length > 0)
11
+ report.hadErrors = true;
10
12
  if (!doc.contents)
11
13
  return [];
12
14
  const relPath = relative(root, filePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "srclawyer",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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",
@@ -38,8 +38,8 @@
38
38
  "@babel/parser": "^7.25.6",
39
39
  "@babel/traverse": "^7.25.6",
40
40
  "commander": "^12.1.0",
41
- "fast-glob": "^3.3.2",
42
41
  "js-yaml": "^4.1.0",
42
+ "tinyglobby": "^0.2.17",
43
43
  "typescript": "^5.6.2",
44
44
  "web-tree-sitter": "^0.27.0",
45
45
  "yaml": "^2.5.1"