srclawyer 0.4.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/LICENSE +112 -0
- package/README.md +58 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +32 -0
- package/dist/cloud/config.d.ts +5 -0
- package/dist/cloud/config.js +23 -0
- package/dist/cloud/protectedLogicClient.d.ts +38 -0
- package/dist/cloud/protectedLogicClient.js +71 -0
- package/dist/cloud/resolveProtectedLogic.d.ts +14 -0
- package/dist/cloud/resolveProtectedLogic.js +67 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +67 -0
- package/dist/commands/scan.d.ts +6 -0
- package/dist/commands/scan.js +66 -0
- package/dist/config/config.d.ts +18 -0
- package/dist/config/config.js +26 -0
- package/dist/engine/astUtils.d.ts +2 -0
- package/dist/engine/astUtils.js +22 -0
- package/dist/engine/discoverFiles.d.ts +7 -0
- package/dist/engine/discoverFiles.js +25 -0
- package/dist/engine/discoverProjects.d.ts +3 -0
- package/dist/engine/discoverProjects.js +34 -0
- package/dist/engine/languageDetection.d.ts +1 -0
- package/dist/engine/languageDetection.js +48 -0
- package/dist/engine/scanEngine.d.ts +11 -0
- package/dist/engine/scanEngine.js +130 -0
- package/dist/engine/secretRedaction.d.ts +2 -0
- package/dist/engine/secretRedaction.js +31 -0
- package/dist/engine/tsProject.d.ts +4 -0
- package/dist/engine/tsProject.js +68 -0
- package/dist/engine/types.d.ts +71 -0
- package/dist/engine/types.js +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +9 -0
- package/dist/llm/anthropicProvider.d.ts +8 -0
- package/dist/llm/anthropicProvider.js +130 -0
- package/dist/llm/buildRequestItem.d.ts +10 -0
- package/dist/llm/buildRequestItem.js +17 -0
- package/dist/llm/resolveAmbiguousFindings.d.ts +7 -0
- package/dist/llm/resolveAmbiguousFindings.js +103 -0
- package/dist/llm/resolveProviderForTier.d.ts +3 -0
- package/dist/llm/resolveProviderForTier.js +31 -0
- package/dist/llm/types.d.ts +29 -0
- package/dist/llm/types.js +1 -0
- package/dist/policy/dataCategoryLabels.d.ts +2 -0
- package/dist/policy/dataCategoryLabels.js +18 -0
- package/dist/policy/disclaimer.d.ts +1 -0
- package/dist/policy/disclaimer.js +1 -0
- package/dist/policy/generatePolicy.d.ts +3 -0
- package/dist/policy/generatePolicy.js +149 -0
- package/dist/report/formatText.d.ts +2 -0
- package/dist/report/formatText.js +53 -0
- package/dist/rules/confidenceFactors.d.ts +14 -0
- package/dist/rules/confidenceFactors.js +14 -0
- package/dist/rules/dataCategoryLexicon.d.ts +3 -0
- package/dist/rules/dataCategoryLexicon.js +26 -0
- package/dist/rules/frameworkRules.d.ts +5 -0
- package/dist/rules/frameworkRules.js +98 -0
- package/dist/rules/htmlRules.d.ts +6 -0
- package/dist/rules/htmlRules.js +75 -0
- package/dist/rules/processorProfiles.d.ts +2 -0
- package/dist/rules/processorProfiles.js +42 -0
- package/dist/rules/safeFieldNames.d.ts +1 -0
- package/dist/rules/safeFieldNames.js +25 -0
- package/dist/rules/sdkRules.d.ts +3 -0
- package/dist/rules/sdkRules.js +65 -0
- package/dist/rules/typedRequestBodyRules.d.ts +4 -0
- package/dist/rules/typedRequestBodyRules.js +83 -0
- package/dist/rules/webApiRules.d.ts +3 -0
- package/dist/rules/webApiRules.js +89 -0
- package/dist/rules/zodPreFilter.d.ts +9 -0
- package/dist/rules/zodPreFilter.js +91 -0
- package/dist/schemaParsers/graphql.d.ts +2 -0
- package/dist/schemaParsers/graphql.js +52 -0
- package/dist/schemaParsers/openapi.d.ts +2 -0
- package/dist/schemaParsers/openapi.js +60 -0
- package/package.json +51 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { classifyFieldName } from "./dataCategoryLexicon.js";
|
|
3
|
+
import { isSafeFieldName } from "./safeFieldNames.js";
|
|
4
|
+
const INPUT_TAG_RE = /<input\b[^>]*>/gi;
|
|
5
|
+
const SCRIPT_TAG_RE = /<script([^>]*)>([\s\S]*?)<\/script>/gi;
|
|
6
|
+
const NON_DATA_INPUT_TYPES = new Set(["submit", "button", "reset", "image"]);
|
|
7
|
+
function getAttr(tag, name) {
|
|
8
|
+
const match = new RegExp(`${name}\\s*=\\s*["']([^"']*)["']`, "i").exec(tag);
|
|
9
|
+
return match ? match[1] : null;
|
|
10
|
+
}
|
|
11
|
+
function lineAt(html, index) {
|
|
12
|
+
return html.slice(0, index).split("\n").length;
|
|
13
|
+
}
|
|
14
|
+
export function runHtmlInputRules(html, filePath, root) {
|
|
15
|
+
const relPath = relative(root, filePath);
|
|
16
|
+
const findings = [];
|
|
17
|
+
let match;
|
|
18
|
+
while ((match = INPUT_TAG_RE.exec(html))) {
|
|
19
|
+
const tag = match[0];
|
|
20
|
+
const type = (getAttr(tag, "type") ?? "text").toLowerCase();
|
|
21
|
+
if (NON_DATA_INPUT_TYPES.has(type))
|
|
22
|
+
continue;
|
|
23
|
+
const identifier = getAttr(tag, "id") ?? getAttr(tag, "name");
|
|
24
|
+
const line = lineAt(html, match.index);
|
|
25
|
+
if (type === "password") {
|
|
26
|
+
findings.push(makeFinding(relPath, line, tag, "authentication_credentials", "high", false, `<input type="password"> field collects a credential.`));
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (type === "email") {
|
|
30
|
+
findings.push(makeFinding(relPath, line, tag, "email", "high", false, `<input type="email"> field collects an email address.`));
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (type === "tel") {
|
|
34
|
+
findings.push(makeFinding(relPath, line, tag, "phone", "high", false, `<input type="tel"> field collects a phone number.`));
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!identifier || isSafeFieldName(identifier))
|
|
38
|
+
continue;
|
|
39
|
+
const category = classifyFieldName(identifier);
|
|
40
|
+
findings.push(makeFinding(relPath, line, tag, category ?? "generic_pii", category ? "medium" : "low", !category, category
|
|
41
|
+
? `Form field "${identifier}" classified as ${category}.`
|
|
42
|
+
: `Form field "${identifier}" could not be confidently classified — needs review.`));
|
|
43
|
+
}
|
|
44
|
+
return findings;
|
|
45
|
+
}
|
|
46
|
+
function makeFinding(relPath, line, tag, category, confidence, requiresReview, description) {
|
|
47
|
+
return {
|
|
48
|
+
id: `html-input:${relPath}:${line}`,
|
|
49
|
+
dataCategories: [category],
|
|
50
|
+
processor: null,
|
|
51
|
+
description,
|
|
52
|
+
confidence,
|
|
53
|
+
source: "html-input-rule",
|
|
54
|
+
location: { file: relPath, line, column: 0 },
|
|
55
|
+
evidence: tag.replace(/\s+/g, " ").trim().slice(0, 160),
|
|
56
|
+
requiresReview,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export function extractInlineScripts(html) {
|
|
60
|
+
const scripts = [];
|
|
61
|
+
let match;
|
|
62
|
+
while ((match = SCRIPT_TAG_RE.exec(html))) {
|
|
63
|
+
const attrs = match[1];
|
|
64
|
+
if (/\bsrc\s*=/i.test(attrs))
|
|
65
|
+
continue;
|
|
66
|
+
if (/\btype\s*=\s*["'](?!(?:text\/javascript|module|application\/javascript)["'])[^"']*["']/i.test(attrs))
|
|
67
|
+
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 });
|
|
73
|
+
}
|
|
74
|
+
return scripts;
|
|
75
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const PROCESSOR_PROFILES = {
|
|
2
|
+
stripe: {
|
|
3
|
+
name: "Stripe",
|
|
4
|
+
dataCategories: ["payment_info", "name", "email", "physical_address"],
|
|
5
|
+
disclosureNotes: "Payment processor. Handles card data, billing name/address, and email for receipts.",
|
|
6
|
+
},
|
|
7
|
+
sendgrid: {
|
|
8
|
+
name: "SendGrid",
|
|
9
|
+
dataCategories: ["email", "name"],
|
|
10
|
+
disclosureNotes: "Transactional/marketing email delivery. Processes recipient email and name.",
|
|
11
|
+
},
|
|
12
|
+
twilio: {
|
|
13
|
+
name: "Twilio",
|
|
14
|
+
dataCategories: ["phone"],
|
|
15
|
+
disclosureNotes: "SMS/voice communications provider. Processes phone numbers.",
|
|
16
|
+
},
|
|
17
|
+
"google-analytics": {
|
|
18
|
+
name: "Google Analytics",
|
|
19
|
+
dataCategories: ["analytics_usage", "ip_address", "device_id"],
|
|
20
|
+
disclosureNotes: "Web analytics. Processes IP address, device identifiers, and usage/behavioral data.",
|
|
21
|
+
},
|
|
22
|
+
mixpanel: {
|
|
23
|
+
name: "Mixpanel",
|
|
24
|
+
dataCategories: ["analytics_usage", "device_id"],
|
|
25
|
+
disclosureNotes: "Product analytics. Processes event/usage data and device identifiers.",
|
|
26
|
+
},
|
|
27
|
+
auth0: {
|
|
28
|
+
name: "Auth0",
|
|
29
|
+
dataCategories: ["email", "authentication_credentials", "name"],
|
|
30
|
+
disclosureNotes: "Identity provider. Processes login credentials, email, and profile data.",
|
|
31
|
+
},
|
|
32
|
+
firebase: {
|
|
33
|
+
name: "Firebase",
|
|
34
|
+
dataCategories: ["email", "authentication_credentials", "device_id", "analytics_usage"],
|
|
35
|
+
disclosureNotes: "Backend platform (auth, database, analytics, messaging) depending on which modules are used.",
|
|
36
|
+
},
|
|
37
|
+
cognito: {
|
|
38
|
+
name: "Amazon Cognito",
|
|
39
|
+
dataCategories: ["email", "authentication_credentials", "phone"],
|
|
40
|
+
disclosureNotes: "Identity provider. Processes login credentials, email, and phone for MFA.",
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isSafeFieldName(fieldName: string): boolean;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const SAFE_FIELD_NAMES = new Set([
|
|
2
|
+
"id",
|
|
3
|
+
"uuid",
|
|
4
|
+
"guid",
|
|
5
|
+
"createdat",
|
|
6
|
+
"updatedat",
|
|
7
|
+
"deletedat",
|
|
8
|
+
"timestamp",
|
|
9
|
+
"status",
|
|
10
|
+
"state",
|
|
11
|
+
"version",
|
|
12
|
+
"page",
|
|
13
|
+
"limit",
|
|
14
|
+
"offset",
|
|
15
|
+
"count",
|
|
16
|
+
"total",
|
|
17
|
+
"sort",
|
|
18
|
+
"order",
|
|
19
|
+
]);
|
|
20
|
+
function normalize(fieldName) {
|
|
21
|
+
return fieldName.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
22
|
+
}
|
|
23
|
+
export function isSafeFieldName(fieldName) {
|
|
24
|
+
return SAFE_FIELD_NAMES.has(normalize(fieldName));
|
|
25
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import _traverse from "@babel/traverse";
|
|
2
|
+
import { relative } from "node:path";
|
|
3
|
+
import { PROCESSOR_PROFILES } from "./processorProfiles.js";
|
|
4
|
+
import { redactSecrets } from "../engine/secretRedaction.js";
|
|
5
|
+
// @babel/traverse's ESM default export is wrapped under interop; unwrap it defensively.
|
|
6
|
+
const traverse = (_traverse.default ?? _traverse);
|
|
7
|
+
const SDK_MATCHERS = [
|
|
8
|
+
{ processorKey: "stripe", packagePatterns: [/^stripe$/] },
|
|
9
|
+
{ processorKey: "sendgrid", packagePatterns: [/^@sendgrid\/mail$/] },
|
|
10
|
+
{ processorKey: "twilio", packagePatterns: [/^twilio$/] },
|
|
11
|
+
{ processorKey: "google-analytics", packagePatterns: [/^react-ga4?$/, /^universal-analytics$/, /^@google-analytics\/data$/] },
|
|
12
|
+
{ processorKey: "mixpanel", packagePatterns: [/^mixpanel-browser$/, /^mixpanel$/] },
|
|
13
|
+
{ processorKey: "auth0", packagePatterns: [/^auth0$/, /^@auth0\//] },
|
|
14
|
+
{ processorKey: "firebase", packagePatterns: [/^firebase$/, /^firebase-admin/, /^@firebase\//] },
|
|
15
|
+
{ processorKey: "cognito", packagePatterns: [/^amazon-cognito-identity-js$/, /^@aws-sdk\/client-cognito-identity-provider$/] },
|
|
16
|
+
];
|
|
17
|
+
function matchProcessor(source) {
|
|
18
|
+
return SDK_MATCHERS.find((matcher) => matcher.packagePatterns.some((p) => p.test(source)));
|
|
19
|
+
}
|
|
20
|
+
export function runSdkRules(ast, filePath, source, root) {
|
|
21
|
+
const findings = [];
|
|
22
|
+
const lines = source.split("\n");
|
|
23
|
+
const seenProcessors = new Set();
|
|
24
|
+
traverse(ast, {
|
|
25
|
+
ImportDeclaration(path) {
|
|
26
|
+
const src = path.node.source.value;
|
|
27
|
+
const matcher = matchProcessor(src);
|
|
28
|
+
if (!matcher)
|
|
29
|
+
return;
|
|
30
|
+
emitFinding(matcher, path.node.loc?.start.line ?? 1);
|
|
31
|
+
},
|
|
32
|
+
CallExpression(path) {
|
|
33
|
+
const callee = path.node.callee;
|
|
34
|
+
if (callee.type !== "Identifier" || callee.name !== "require")
|
|
35
|
+
return;
|
|
36
|
+
const arg = path.node.arguments[0];
|
|
37
|
+
if (!arg || arg.type !== "StringLiteral")
|
|
38
|
+
return;
|
|
39
|
+
const matcher = matchProcessor(arg.value);
|
|
40
|
+
if (!matcher)
|
|
41
|
+
return;
|
|
42
|
+
emitFinding(matcher, path.node.loc?.start.line ?? 1);
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
function emitFinding(matcher, line) {
|
|
46
|
+
const dedupeKey = `${matcher.processorKey}:${filePath}`;
|
|
47
|
+
if (seenProcessors.has(dedupeKey))
|
|
48
|
+
return;
|
|
49
|
+
seenProcessors.add(dedupeKey);
|
|
50
|
+
const profile = PROCESSOR_PROFILES[matcher.processorKey];
|
|
51
|
+
const evidenceLine = lines[line - 1] ?? "";
|
|
52
|
+
findings.push({
|
|
53
|
+
id: `sdk:${matcher.processorKey}:${relative(root, filePath)}:${line}`,
|
|
54
|
+
dataCategories: profile.dataCategories,
|
|
55
|
+
processor: profile.name,
|
|
56
|
+
description: `${profile.name} integration detected. ${profile.disclosureNotes}`,
|
|
57
|
+
confidence: "high",
|
|
58
|
+
source: "sdk-rule",
|
|
59
|
+
location: { file: relative(root, filePath), line, column: 0 },
|
|
60
|
+
evidence: redactSecrets(evidenceLine.trim()).slice(0, 200),
|
|
61
|
+
requiresReview: false,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return findings;
|
|
65
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { File } from "@babel/types";
|
|
2
|
+
import type { Finding } from "../engine/types.js";
|
|
3
|
+
import type { TsProjectHandle } from "../engine/tsProject.js";
|
|
4
|
+
export declare function runTypedRequestBodyRules(ast: File, filePath: string, source: string, root: string, getTsProject: () => TsProjectHandle | null): Finding[];
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import _traverse from "@babel/traverse";
|
|
2
|
+
import { relative } from "node:path";
|
|
3
|
+
import { classifyFieldName, isAmbiguousFieldName } from "./dataCategoryLexicon.js";
|
|
4
|
+
import { isSafeFieldName } from "./safeFieldNames.js";
|
|
5
|
+
import { redactSecrets } from "../engine/secretRedaction.js";
|
|
6
|
+
import { REQUEST_BODY_PROPS, REQUEST_OBJECT_NAMES } from "./frameworkRules.js";
|
|
7
|
+
import { countConfidentSiblings } from "./confidenceFactors.js";
|
|
8
|
+
const traverse = (_traverse.default ?? _traverse);
|
|
9
|
+
function isRequestJsonOrBodyEntryPoint(node) {
|
|
10
|
+
if (node.type === "MemberExpression") {
|
|
11
|
+
return (node.object.type === "Identifier" &&
|
|
12
|
+
REQUEST_OBJECT_NAMES.test(node.object.name) &&
|
|
13
|
+
node.property.type === "Identifier" &&
|
|
14
|
+
REQUEST_BODY_PROPS.test(node.property.name));
|
|
15
|
+
}
|
|
16
|
+
if (node.type === "AwaitExpression" && node.argument.type === "CallExpression") {
|
|
17
|
+
const callee = node.argument.callee;
|
|
18
|
+
return (callee.type === "MemberExpression" &&
|
|
19
|
+
callee.object.type === "Identifier" &&
|
|
20
|
+
REQUEST_OBJECT_NAMES.test(callee.object.name) &&
|
|
21
|
+
callee.property.type === "Identifier" &&
|
|
22
|
+
callee.property.name === "json");
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
export function runTypedRequestBodyRules(ast, filePath, source, root, getTsProject) {
|
|
27
|
+
const findings = [];
|
|
28
|
+
const lines = source.split("\n");
|
|
29
|
+
const relPath = relative(root, filePath);
|
|
30
|
+
const emitted = new Set();
|
|
31
|
+
function emit(fieldName, line, siblingFieldNames) {
|
|
32
|
+
if (isSafeFieldName(fieldName))
|
|
33
|
+
return;
|
|
34
|
+
const dedupeKey = `${fieldName}:${line}`;
|
|
35
|
+
if (emitted.has(dedupeKey))
|
|
36
|
+
return;
|
|
37
|
+
emitted.add(dedupeKey);
|
|
38
|
+
const lexiconCategory = classifyFieldName(fieldName);
|
|
39
|
+
const ambiguousName = isAmbiguousFieldName(fieldName);
|
|
40
|
+
const siblingConfidentCount = countConfidentSiblings(siblingFieldNames, fieldName);
|
|
41
|
+
const evidenceLine = lines[line - 1] ?? "";
|
|
42
|
+
// runtimeVerified: false, always — a type annotation is compile-time-only and can silently lie
|
|
43
|
+
// relative to runtime reality (an `as X` cast, a stale interface). The scoring call's hard gate
|
|
44
|
+
// means requiresReview will come back true regardless of category/ambiguousName; confidence/
|
|
45
|
+
// requiresReview here are the provisional safe fallback until that call patches them in.
|
|
46
|
+
findings.push({
|
|
47
|
+
id: `type:${relPath}:${line}:${fieldName}`,
|
|
48
|
+
dataCategories: lexiconCategory ? [lexiconCategory] : ["generic_pii"],
|
|
49
|
+
processor: null,
|
|
50
|
+
description: lexiconCategory
|
|
51
|
+
? `Request field "${fieldName}" inferred from a TypeScript type annotation (not runtime-verified); classified as ${lexiconCategory}.`
|
|
52
|
+
: `Request field "${fieldName}" inferred from a TypeScript type annotation (not runtime-verified); could not confidently classify — needs review.`,
|
|
53
|
+
confidence: "low",
|
|
54
|
+
source: "type-rule",
|
|
55
|
+
location: { file: relPath, line, column: 0 },
|
|
56
|
+
evidence: redactSecrets(evidenceLine.trim()).slice(0, 200),
|
|
57
|
+
requiresReview: true,
|
|
58
|
+
confidenceFactors: { lexiconCategory, ambiguousName, runtimeVerified: false, siblingConfidentCount },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
traverse(ast, {
|
|
62
|
+
VariableDeclarator(path) {
|
|
63
|
+
const { id, init } = path.node;
|
|
64
|
+
if (!init || !isRequestJsonOrBodyEntryPoint(init))
|
|
65
|
+
return;
|
|
66
|
+
if (id.type !== "Identifier" || !id.typeAnnotation || id.typeAnnotation.type !== "TSTypeAnnotation")
|
|
67
|
+
return;
|
|
68
|
+
const pos = id.loc?.start;
|
|
69
|
+
if (!pos)
|
|
70
|
+
return;
|
|
71
|
+
const tsProject = getTsProject();
|
|
72
|
+
if (!tsProject)
|
|
73
|
+
return;
|
|
74
|
+
const members = tsProject.getObjectTypeMembersAt(filePath, pos.line, pos.column);
|
|
75
|
+
if (!members)
|
|
76
|
+
return;
|
|
77
|
+
const line = path.node.loc?.start.line ?? pos.line;
|
|
78
|
+
for (const fieldName of members)
|
|
79
|
+
emit(fieldName, line, members);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
return findings;
|
|
83
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import _traverse from "@babel/traverse";
|
|
2
|
+
import { relative } from "node:path";
|
|
3
|
+
import { classifyFieldName, isAmbiguousFieldName } from "./dataCategoryLexicon.js";
|
|
4
|
+
import { isSafeFieldName } from "./safeFieldNames.js";
|
|
5
|
+
import { redactSecrets } from "../engine/secretRedaction.js";
|
|
6
|
+
const traverse = (_traverse.default ?? _traverse);
|
|
7
|
+
const STORAGE_OBJECT_NAMES = /^(localStorage|sessionStorage)$/;
|
|
8
|
+
function hasTruthyProperty(obj, key) {
|
|
9
|
+
return obj.properties.some((prop) => {
|
|
10
|
+
if (prop.type !== "ObjectProperty" || prop.key.type !== "Identifier" || prop.key.name !== key)
|
|
11
|
+
return false;
|
|
12
|
+
return !(prop.value.type === "BooleanLiteral" && prop.value.value === false);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function runWebApiRules(ast, filePath, source, root) {
|
|
16
|
+
const findings = [];
|
|
17
|
+
const lines = source.split("\n");
|
|
18
|
+
const relPath = relative(root, filePath);
|
|
19
|
+
const emitted = new Set();
|
|
20
|
+
function emit(id, line, description, dataCategories, confidence, requiresReview) {
|
|
21
|
+
const dedupeKey = `${id}:${line}`;
|
|
22
|
+
if (emitted.has(dedupeKey))
|
|
23
|
+
return;
|
|
24
|
+
emitted.add(dedupeKey);
|
|
25
|
+
const evidenceLine = lines[line - 1] ?? "";
|
|
26
|
+
findings.push({
|
|
27
|
+
id: `webapi:${relPath}:${line}:${id}`,
|
|
28
|
+
dataCategories,
|
|
29
|
+
processor: null,
|
|
30
|
+
description,
|
|
31
|
+
confidence,
|
|
32
|
+
source: "web-api-rule",
|
|
33
|
+
location: { file: relPath, line, column: 0 },
|
|
34
|
+
evidence: redactSecrets(evidenceLine.trim()).slice(0, 200),
|
|
35
|
+
requiresReview,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
traverse(ast, {
|
|
39
|
+
CallExpression(path) {
|
|
40
|
+
const callee = path.node.callee;
|
|
41
|
+
if (callee.type !== "MemberExpression" || callee.property.type !== "Identifier")
|
|
42
|
+
return;
|
|
43
|
+
const methodName = callee.property.name;
|
|
44
|
+
const line = path.node.loc?.start.line ?? 1;
|
|
45
|
+
if (methodName === "getUserMedia") {
|
|
46
|
+
const arg = path.node.arguments[0];
|
|
47
|
+
if (arg && arg.type === "ObjectExpression") {
|
|
48
|
+
const wantsAudio = hasTruthyProperty(arg, "audio");
|
|
49
|
+
const wantsVideo = hasTruthyProperty(arg, "video");
|
|
50
|
+
if (wantsAudio) {
|
|
51
|
+
emit("getUserMedia:audio", line, "Microphone access requested via getUserMedia().", ["microphone_audio"], "high", false);
|
|
52
|
+
}
|
|
53
|
+
if (wantsVideo) {
|
|
54
|
+
emit("getUserMedia:video", line, "Camera access requested via getUserMedia().", ["camera_video"], "high", false);
|
|
55
|
+
}
|
|
56
|
+
if (!wantsAudio && !wantsVideo) {
|
|
57
|
+
emit("getUserMedia:unknown", line, "getUserMedia() called but audio/video constraints could not be determined statically.", ["generic_pii"], "low", true);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
emit("getUserMedia:dynamic", line, "getUserMedia() called with a non-literal constraints object — audio/video scope could not be determined statically.", ["microphone_audio", "camera_video"], "low", true);
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (methodName === "getCurrentPosition" || methodName === "watchPosition") {
|
|
66
|
+
emit(`geolocation:${methodName}`, line, `Precise device location requested via ${methodName}().`, ["location"], "medium", false);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (methodName === "setItem" && callee.object.type === "Identifier" && STORAGE_OBJECT_NAMES.test(callee.object.name)) {
|
|
70
|
+
const storageKind = callee.object.name;
|
|
71
|
+
const keyArg = path.node.arguments[0];
|
|
72
|
+
if (keyArg && keyArg.type === "StringLiteral" && isSafeFieldName(keyArg.value)) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (keyArg && keyArg.type === "StringLiteral") {
|
|
76
|
+
const category = classifyFieldName(keyArg.value);
|
|
77
|
+
const ambiguous = isAmbiguousFieldName(keyArg.value);
|
|
78
|
+
emit(`${storageKind}:${keyArg.value}`, line, category
|
|
79
|
+
? `Value stored in ${storageKind} under key "${keyArg.value}", classified as ${category}.`
|
|
80
|
+
: `Value stored in ${storageKind} under key "${keyArg.value}"; could not confidently classify — needs review.`, [category ?? "generic_pii"], category ? "medium" : "low", ambiguous || !category);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
emit(`${storageKind}:dynamic`, line, `Value stored in ${storageKind} under a dynamically computed key — needs review.`, ["generic_pii"], "low", true);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
return findings;
|
|
89
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface ZodCandidate {
|
|
2
|
+
id: string;
|
|
3
|
+
codeFragment: string;
|
|
4
|
+
/** fragmentLineToSourceLine[i] = the original 1-based source line for fragment line i+1. Lets
|
|
5
|
+
* resolveProtectedLogic() translate a server-reported fragment line back to a real location,
|
|
6
|
+
* since the fragment is a non-contiguous excerpt of the original file, not a raw slice. */
|
|
7
|
+
fragmentLineToSourceLine: number[];
|
|
8
|
+
}
|
|
9
|
+
export declare function findZodCandidates(source: string, filePath: string, root: string): ZodCandidate[];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { redactSecrets } from "../engine/secretRedaction.js";
|
|
3
|
+
import { parseSource } from "../engine/astUtils.js";
|
|
4
|
+
/**
|
|
5
|
+
* The real Zod schema/anchor detection (which call sites count as "this validates incoming request
|
|
6
|
+
* data" vs. a third-party API response or an env-var schema — the actual scope-boundary reasoning)
|
|
7
|
+
* runs server-side now (protected-logic cloud service). This module's only job is deciding WHETHER a
|
|
8
|
+
* file is worth sending, and extracting a syntactically complete, minimal fragment to send — not the
|
|
9
|
+
* tuned "is this genuinely a request-input validator" logic itself.
|
|
10
|
+
*
|
|
11
|
+
* Extraction is AST-boundary-based, not a raw line-count window: earlier drafts joined arbitrary
|
|
12
|
+
* +/-N-line windows around each anchor/declaration, which silently produced invalid syntax whenever a
|
|
13
|
+
* window boundary landed inside a multi-line import, or inside a long enclosing function — a real
|
|
14
|
+
* golden-corpus file (formbricks' signup action) hit both, and the whole fragment failed to parse
|
|
15
|
+
* server-side with zero error surfaced (a schema simply went undetected). Extracting whole top-level
|
|
16
|
+
* statements instead guarantees every included chunk is independently valid, so concatenating a subset
|
|
17
|
+
* of them is always a valid Program — no re-implementation of the protected scope-boundary reasoning
|
|
18
|
+
* is needed to get that guarantee, just "which top-level statement is this line part of."
|
|
19
|
+
*/
|
|
20
|
+
const ZOD_IMPORT_RE = /from\s+["']zod(?:\/v[34](?:-mini)?)?["']/;
|
|
21
|
+
const ANCHOR_CALL_RE = /\.(?:parse|safeParse|parseAsync|safeParseAsync|inputSchema)\s*\(/;
|
|
22
|
+
// Resolving "which identifier does this anchor call reference, and where is IT declared at the top
|
|
23
|
+
// level" is plain lexical lookup, not the protected scope-boundary judgment — safe to do locally so a
|
|
24
|
+
// schema declared far from its anchor (very common: define near the top, use in an export lower down)
|
|
25
|
+
// still gets included.
|
|
26
|
+
const INPUT_SCHEMA_ARG_RE = /\.inputSchema\(\s*([A-Za-z_$][\w$]*)/;
|
|
27
|
+
const PARSE_RECEIVER_RE = /([A-Za-z_$][\w$]*)\.(?:parse|safeParse|parseAsync|safeParseAsync)\(/;
|
|
28
|
+
function lineRange(node) {
|
|
29
|
+
if (!node.loc)
|
|
30
|
+
return null;
|
|
31
|
+
return [node.loc.start.line - 1, node.loc.end.line - 1]; // 0-based, inclusive
|
|
32
|
+
}
|
|
33
|
+
function findEnclosingStatement(body, line0) {
|
|
34
|
+
return body.find((stmt) => stmt.loc && stmt.loc.start.line - 1 <= line0 && line0 <= stmt.loc.end.line - 1);
|
|
35
|
+
}
|
|
36
|
+
function findDeclarationStatement(body, name) {
|
|
37
|
+
return body.find((stmt) => stmt.type === "VariableDeclaration" &&
|
|
38
|
+
stmt.declarations.some((d) => d.id.type === "Identifier" && d.id.name === name));
|
|
39
|
+
}
|
|
40
|
+
export function findZodCandidates(source, filePath, root) {
|
|
41
|
+
if (!ZOD_IMPORT_RE.test(source))
|
|
42
|
+
return [];
|
|
43
|
+
const lines = source.split("\n");
|
|
44
|
+
const matchLineIndexes = [];
|
|
45
|
+
lines.forEach((line, i) => {
|
|
46
|
+
if (ANCHOR_CALL_RE.test(line))
|
|
47
|
+
matchLineIndexes.push(i);
|
|
48
|
+
});
|
|
49
|
+
if (matchLineIndexes.length === 0)
|
|
50
|
+
return [];
|
|
51
|
+
const ast = parseSource(source, filePath);
|
|
52
|
+
if (!ast)
|
|
53
|
+
return [];
|
|
54
|
+
const body = ast.program.body;
|
|
55
|
+
const includedLines = new Set();
|
|
56
|
+
const includeRange = (range) => {
|
|
57
|
+
if (!range)
|
|
58
|
+
return;
|
|
59
|
+
for (let i = range[0]; i <= range[1]; i++)
|
|
60
|
+
includedLines.add(i);
|
|
61
|
+
};
|
|
62
|
+
for (const directive of ast.program.directives)
|
|
63
|
+
includeRange(lineRange(directive));
|
|
64
|
+
for (const stmt of body) {
|
|
65
|
+
if (stmt.type === "ImportDeclaration")
|
|
66
|
+
includeRange(lineRange(stmt));
|
|
67
|
+
}
|
|
68
|
+
// One candidate per file, not per match — a single fragment covering every anchor-shaped
|
|
69
|
+
// statement plus the top-of-file imports, merged and deduped. Keeps this a single call per file
|
|
70
|
+
// rather than one per match; the server already handles "multiple schemas in one fragment" naturally.
|
|
71
|
+
for (const matchLine of matchLineIndexes) {
|
|
72
|
+
const enclosing = findEnclosingStatement(body, matchLine);
|
|
73
|
+
if (!enclosing)
|
|
74
|
+
continue;
|
|
75
|
+
includeRange(lineRange(enclosing));
|
|
76
|
+
const line = lines[matchLine];
|
|
77
|
+
const referencedName = line.match(INPUT_SCHEMA_ARG_RE)?.[1] ?? line.match(PARSE_RECEIVER_RE)?.[1];
|
|
78
|
+
if (!referencedName)
|
|
79
|
+
continue;
|
|
80
|
+
const declStmt = findDeclarationStatement(body, referencedName);
|
|
81
|
+
if (declStmt)
|
|
82
|
+
includeRange(lineRange(declStmt));
|
|
83
|
+
}
|
|
84
|
+
if (includedLines.size === 0)
|
|
85
|
+
return [];
|
|
86
|
+
const sortedIndexes = [...includedLines].sort((a, b) => a - b);
|
|
87
|
+
const fragment = sortedIndexes.map((i) => lines[i]).join("\n");
|
|
88
|
+
const fragmentLineToSourceLine = sortedIndexes.map((i) => i + 1);
|
|
89
|
+
const relPath = relative(root, filePath);
|
|
90
|
+
return [{ id: relPath, codeFragment: redactSecrets(fragment), fragmentLineToSourceLine }];
|
|
91
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { relative } from "node:path";
|
|
3
|
+
import { classifyFieldName, isAmbiguousFieldName } from "../rules/dataCategoryLexicon.js";
|
|
4
|
+
import { isSafeFieldName } from "../rules/safeFieldNames.js";
|
|
5
|
+
const TYPE_BLOCK_START = /^\s*(type|input)\s+(\w+)/;
|
|
6
|
+
const FIELD_LINE = /^\s*(\w+)\s*(\([^)]*\))?\s*:\s*[\[\]!\w]+/;
|
|
7
|
+
const GRAPHQL_CONNECTION_FIELDS = new Set(["edges", "node", "pageInfo", "cursor", "hasNextPage", "hasPreviousPage", "totalCount"]);
|
|
8
|
+
export function parseGraphQLSchema(filePath, root) {
|
|
9
|
+
const raw = readFileSync(filePath, "utf8");
|
|
10
|
+
const relPath = relative(root, filePath);
|
|
11
|
+
const lines = raw.split("\n");
|
|
12
|
+
const findings = [];
|
|
13
|
+
let depth = 0;
|
|
14
|
+
let inTypeBlock = false;
|
|
15
|
+
lines.forEach((rawLine, idx) => {
|
|
16
|
+
const line = rawLine.split("#")[0];
|
|
17
|
+
if (TYPE_BLOCK_START.test(line)) {
|
|
18
|
+
inTypeBlock = true;
|
|
19
|
+
depth = 0;
|
|
20
|
+
}
|
|
21
|
+
if (inTypeBlock) {
|
|
22
|
+
depth += (line.match(/{/g) ?? []).length;
|
|
23
|
+
depth -= (line.match(/}/g) ?? []).length;
|
|
24
|
+
if (depth <= 0 && line.includes("}")) {
|
|
25
|
+
inTypeBlock = false;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const fieldMatch = FIELD_LINE.exec(line);
|
|
29
|
+
if (fieldMatch && !TYPE_BLOCK_START.test(line)) {
|
|
30
|
+
const fieldName = fieldMatch[1];
|
|
31
|
+
if (GRAPHQL_CONNECTION_FIELDS.has(fieldName) || isSafeFieldName(fieldName))
|
|
32
|
+
return;
|
|
33
|
+
const category = classifyFieldName(fieldName);
|
|
34
|
+
const ambiguous = isAmbiguousFieldName(fieldName);
|
|
35
|
+
findings.push({
|
|
36
|
+
id: `graphql:${relPath}:${idx + 1}:${fieldName}`,
|
|
37
|
+
dataCategories: category ? [category] : ["generic_pii"],
|
|
38
|
+
processor: null,
|
|
39
|
+
description: category
|
|
40
|
+
? `GraphQL field "${fieldName}" classified as ${category}.`
|
|
41
|
+
: `GraphQL field "${fieldName}" could not be confidently classified — needs review.`,
|
|
42
|
+
confidence: category ? "medium" : "low",
|
|
43
|
+
source: "graphql",
|
|
44
|
+
location: { file: relPath, line: idx + 1, column: 0 },
|
|
45
|
+
evidence: line.trim(),
|
|
46
|
+
requiresReview: ambiguous || !category,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
return findings;
|
|
52
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { relative } from "node:path";
|
|
3
|
+
import { parseDocument, LineCounter, isMap, isPair, isScalar } from "yaml";
|
|
4
|
+
import { classifyFieldName, isAmbiguousFieldName } from "../rules/dataCategoryLexicon.js";
|
|
5
|
+
import { isSafeFieldName } from "../rules/safeFieldNames.js";
|
|
6
|
+
export function parseOpenApiSpec(filePath, root) {
|
|
7
|
+
const raw = readFileSync(filePath, "utf8");
|
|
8
|
+
const lineCounter = new LineCounter();
|
|
9
|
+
const doc = parseDocument(raw, { lineCounter, keepSourceTokens: true });
|
|
10
|
+
if (!doc.contents)
|
|
11
|
+
return [];
|
|
12
|
+
const relPath = relative(root, filePath);
|
|
13
|
+
const findings = [];
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
const schemasNode = doc.getIn(["components", "schemas"], true);
|
|
16
|
+
if (isMap(schemasNode)) {
|
|
17
|
+
for (const schemaPair of schemasNode.items) {
|
|
18
|
+
if (!isPair(schemaPair))
|
|
19
|
+
continue;
|
|
20
|
+
const schemaBody = schemaPair.value;
|
|
21
|
+
if (!isMap(schemaBody))
|
|
22
|
+
continue;
|
|
23
|
+
const properties = schemaBody.get("properties", true);
|
|
24
|
+
if (!isMap(properties))
|
|
25
|
+
continue;
|
|
26
|
+
for (const propPair of properties.items) {
|
|
27
|
+
if (!isPair(propPair) || !isScalar(propPair.key))
|
|
28
|
+
continue;
|
|
29
|
+
const fieldName = String(propPair.key.value);
|
|
30
|
+
const range = propPair.key.range;
|
|
31
|
+
const line = lineCounter.linePos(range ? range[0] : 0).line;
|
|
32
|
+
pushFinding(fieldName, line, relPath, findings, seen);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return findings;
|
|
37
|
+
}
|
|
38
|
+
function pushFinding(fieldName, line, relPath, findings, seen) {
|
|
39
|
+
if (isSafeFieldName(fieldName))
|
|
40
|
+
return;
|
|
41
|
+
const key = `${relPath}:${fieldName}`;
|
|
42
|
+
if (seen.has(key))
|
|
43
|
+
return;
|
|
44
|
+
seen.add(key);
|
|
45
|
+
const category = classifyFieldName(fieldName);
|
|
46
|
+
const ambiguous = isAmbiguousFieldName(fieldName);
|
|
47
|
+
findings.push({
|
|
48
|
+
id: `openapi:${relPath}:${line}:${fieldName}`,
|
|
49
|
+
dataCategories: category ? [category] : ["generic_pii"],
|
|
50
|
+
processor: null,
|
|
51
|
+
description: category
|
|
52
|
+
? `OpenAPI schema field "${fieldName}" classified as ${category}.`
|
|
53
|
+
: `OpenAPI schema field "${fieldName}" could not be confidently classified — needs review.`,
|
|
54
|
+
confidence: category ? "medium" : "low",
|
|
55
|
+
source: "openapi",
|
|
56
|
+
location: { file: relPath, line, column: 0 },
|
|
57
|
+
evidence: fieldName,
|
|
58
|
+
requiresReview: ambiguous || !category,
|
|
59
|
+
});
|
|
60
|
+
}
|