srclawyer 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/commands/init.js +17 -2
- package/dist/commands/scan.js +7 -1
- package/dist/engine/discoverFiles.d.ts +2 -1
- package/dist/engine/discoverFiles.js +7 -7
- package/dist/engine/discoverProjects.d.ts +9 -1
- package/dist/engine/discoverProjects.js +71 -18
- package/dist/engine/globFiles.d.ts +10 -0
- package/dist/engine/globFiles.js +12 -0
- package/dist/engine/languageDetection.d.ts +2 -1
- package/dist/engine/languageDetection.js +10 -10
- package/dist/engine/rootBoundary.d.ts +29 -0
- package/dist/engine/rootBoundary.js +102 -0
- package/dist/engine/scanEngine.js +174 -58
- package/dist/engine/scanLimits.d.ts +29 -0
- package/dist/engine/scanLimits.js +70 -0
- package/dist/engine/tsProject.d.ts +2 -1
- package/dist/engine/tsProject.js +40 -3
- package/dist/engine/types.d.ts +16 -0
- package/dist/engine/workspacePatterns.d.ts +23 -0
- package/dist/engine/workspacePatterns.js +140 -0
- package/dist/policy/generatePolicy.js +33 -8
- package/dist/report/formatText.js +16 -0
- package/dist/rules/htmlRules.d.ts +11 -0
- package/dist/rules/htmlRules.js +73 -17
- package/dist/rules/zodPreFilter.js +10 -1
- package/dist/schemaParsers/openapi.d.ts +6 -1
- package/dist/schemaParsers/openapi.js +3 -1
- package/package.json +2 -2
|
@@ -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
|
+
}
|
|
@@ -108,6 +108,17 @@ 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
|
+
}
|
|
111
122
|
function placeholder(label) {
|
|
112
123
|
return `[TO COMPLETE: ${label}]`;
|
|
113
124
|
}
|
|
@@ -154,16 +165,22 @@ function renderIdentitySection(config, placeholders) {
|
|
|
154
165
|
// can simultaneously list detected third-party integrations (e.g. Stripe) in the Third-Party Data
|
|
155
166
|
// Processors section above. These sentences deliberately (1) use CCPA/CPRA's own defined terms rather
|
|
156
167
|
// 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)
|
|
158
|
-
//
|
|
159
|
-
//
|
|
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,
|
|
160
174
|
// attorney-reviewable starting wording, not settled language.
|
|
161
|
-
function renderDataPracticesSection(config, placeholders) {
|
|
175
|
+
function renderDataPracticesSection(config, placeholders, hasProcessorSection) {
|
|
162
176
|
const retention = valueOrPlaceholder(config?.dataRetentionPolicy, "data retention period or policy", placeholders);
|
|
163
177
|
const deletion = valueOrPlaceholder(config?.accountDeletionInstructions, "how a user can delete their account or request data deletion", placeholders);
|
|
164
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);
|
|
165
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);
|
|
166
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.";
|
|
167
184
|
return [
|
|
168
185
|
"## Data Retention, Deletion, and Use",
|
|
169
186
|
"",
|
|
@@ -171,7 +188,7 @@ function renderDataPracticesSection(config, placeholders) {
|
|
|
171
188
|
`To delete your account or request deletion of your personal data: ${deletion}`,
|
|
172
189
|
sellShare,
|
|
173
190
|
advertising,
|
|
174
|
-
|
|
191
|
+
processorsCrossReference,
|
|
175
192
|
aiServices,
|
|
176
193
|
].join("\n");
|
|
177
194
|
}
|
|
@@ -228,17 +245,26 @@ export function generatePolicyMarkdown(result, config, llmError, protectedLogicW
|
|
|
228
245
|
});
|
|
229
246
|
const declinedFindings = result.findings.filter((f) => outcomeOf(f) === "declined");
|
|
230
247
|
const reviewFindings = result.findings.filter((f) => outcomeOf(f) === "unresolved");
|
|
231
|
-
//
|
|
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
|
|
232
252
|
// populated in time for the top-of-document summary below.
|
|
233
253
|
const placeholders = [];
|
|
234
254
|
const identitySection = renderIdentitySection(config, placeholders);
|
|
235
|
-
const dataPracticesSection = renderDataPracticesSection(config, placeholders);
|
|
255
|
+
const dataPracticesSection = renderDataPracticesSection(config, placeholders, processorSection !== null);
|
|
236
256
|
const rightsSection = renderRightsSection(config, placeholders);
|
|
237
257
|
const childrensPrivacySection = renderChildrensPrivacySection(config, placeholders);
|
|
238
258
|
const parts = ["# Privacy Policy", ""];
|
|
239
259
|
if (result.unsupportedStackWarning) {
|
|
240
260
|
parts.push(renderUnsupportedStackWarning(result.unsupportedStackWarning), "");
|
|
241
261
|
}
|
|
262
|
+
if (result.workspaceWarning) {
|
|
263
|
+
parts.push(renderWorkspaceWarning(result.workspaceWarning), "");
|
|
264
|
+
}
|
|
265
|
+
if (result.skippedFilesWarning) {
|
|
266
|
+
parts.push(renderSkippedFilesWarning(result.skippedFilesWarning), "");
|
|
267
|
+
}
|
|
242
268
|
if (protectedLogicWarning) {
|
|
243
269
|
parts.push(renderProtectedLogicWarning(protectedLogicWarning), "");
|
|
244
270
|
}
|
|
@@ -259,7 +285,6 @@ export function generatePolicyMarkdown(result, config, llmError, protectedLogicW
|
|
|
259
285
|
else {
|
|
260
286
|
parts.push("No data-collection patterns were confidently identified in this codebase.");
|
|
261
287
|
}
|
|
262
|
-
const processorSection = renderProcessorSection(confidentFindings);
|
|
263
288
|
if (processorSection) {
|
|
264
289
|
parts.push("", processorSection);
|
|
265
290
|
}
|
|
@@ -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[];
|
package/dist/rules/htmlRules.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
63
|
-
const
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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) =>
|
|
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
|
-
|
|
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.
|
|
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"
|