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,103 @@
|
|
|
1
|
+
import { DATA_CATEGORY_LABELS } from "../policy/dataCategoryLabels.js";
|
|
2
|
+
import { buildRequestItem } from "./buildRequestItem.js";
|
|
3
|
+
const KNOWN_CATEGORIES = new Set(Object.keys(DATA_CATEGORY_LABELS));
|
|
4
|
+
const DEFAULT_BATCH_SIZE = 15;
|
|
5
|
+
const DEFAULT_BATCH_CHAR_BUDGET = 6000;
|
|
6
|
+
export async function resolveAmbiguousFindings(findings, provider, options) {
|
|
7
|
+
const batches = chunkFindings(findings, options.batchSize ?? DEFAULT_BATCH_SIZE);
|
|
8
|
+
const resolvedById = new Map();
|
|
9
|
+
for (const batch of batches) {
|
|
10
|
+
const items = batch.map((finding) => buildRequestItem(finding, options.collectsChildrensData));
|
|
11
|
+
const isHighStakesById = new Map(items.map((item) => [item.findingId, item.isHighStakes]));
|
|
12
|
+
let responses;
|
|
13
|
+
try {
|
|
14
|
+
responses = await provider.resolveBatch({ items });
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
18
|
+
for (const finding of batch) {
|
|
19
|
+
resolvedById.set(finding.id, {
|
|
20
|
+
outcome: "unresolved",
|
|
21
|
+
reason: `API request failed: ${message}`,
|
|
22
|
+
provider: provider.name,
|
|
23
|
+
model: provider.model,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const responseById = new Map(responses.map((response) => [response.findingId, response]));
|
|
29
|
+
for (const finding of batch) {
|
|
30
|
+
const response = responseById.get(finding.id);
|
|
31
|
+
const isHighStakes = isHighStakesById.get(finding.id) ?? false;
|
|
32
|
+
resolvedById.set(finding.id, toResolution(response, isHighStakes, provider));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return findings.map((finding) => {
|
|
36
|
+
const resolution = resolvedById.get(finding.id);
|
|
37
|
+
return resolution ? { ...finding, llmResolution: resolution } : finding;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function toResolution(response, isHighStakes, provider) {
|
|
41
|
+
if (!response) {
|
|
42
|
+
return {
|
|
43
|
+
outcome: "unresolved",
|
|
44
|
+
reason: "Response missing or malformed for this item",
|
|
45
|
+
provider: provider.name,
|
|
46
|
+
model: provider.model,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Hard code-level override: a high-stakes item can never come back "resolved",
|
|
50
|
+
// regardless of what the model claims. This is enforced here, not just in the
|
|
51
|
+
// prompt, since model output text is never trusted as sole enforcement.
|
|
52
|
+
if (isHighStakes && response.outcome === "resolved") {
|
|
53
|
+
return {
|
|
54
|
+
outcome: "declined",
|
|
55
|
+
reason: "High-stakes category (children's data) — always requires manual/legal review, never auto-resolved.",
|
|
56
|
+
provider: provider.name,
|
|
57
|
+
model: provider.model,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (response.outcome === "declined") {
|
|
61
|
+
return {
|
|
62
|
+
outcome: "declined",
|
|
63
|
+
reason: response.reason ?? "The model declined to classify this item without giving a reason.",
|
|
64
|
+
provider: provider.name,
|
|
65
|
+
model: provider.model,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const validCategories = (response.resolvedDataCategories ?? []).filter((category) => KNOWN_CATEGORIES.has(category));
|
|
69
|
+
if (validCategories.length === 0 || !response.resolvedDescription) {
|
|
70
|
+
return {
|
|
71
|
+
outcome: "unresolved",
|
|
72
|
+
reason: "Model reported a resolution but did not provide a recognized data category and description",
|
|
73
|
+
provider: provider.name,
|
|
74
|
+
model: provider.model,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
outcome: "resolved",
|
|
79
|
+
reason: null,
|
|
80
|
+
provider: provider.name,
|
|
81
|
+
model: provider.model,
|
|
82
|
+
resolvedDataCategories: validCategories,
|
|
83
|
+
resolvedDescription: response.resolvedDescription,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function chunkFindings(findings, batchSize) {
|
|
87
|
+
const batches = [];
|
|
88
|
+
let current = [];
|
|
89
|
+
let currentChars = 0;
|
|
90
|
+
for (const finding of findings) {
|
|
91
|
+
const approxSize = finding.evidence.length + finding.description.length;
|
|
92
|
+
if (current.length > 0 && (current.length >= batchSize || currentChars + approxSize > DEFAULT_BATCH_CHAR_BUDGET)) {
|
|
93
|
+
batches.push(current);
|
|
94
|
+
current = [];
|
|
95
|
+
currentChars = 0;
|
|
96
|
+
}
|
|
97
|
+
current.push(finding);
|
|
98
|
+
currentChars += approxSize;
|
|
99
|
+
}
|
|
100
|
+
if (current.length > 0)
|
|
101
|
+
batches.push(current);
|
|
102
|
+
return batches;
|
|
103
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AnthropicProvider } from "./anthropicProvider.js";
|
|
2
|
+
export function resolveProviderForTier(config) {
|
|
3
|
+
const tier = config.tier ?? "free";
|
|
4
|
+
if (tier === "free") {
|
|
5
|
+
return { provider: null, error: null };
|
|
6
|
+
}
|
|
7
|
+
if (tier === "byok") {
|
|
8
|
+
const envVar = config.llm?.apiKeyEnvVar;
|
|
9
|
+
if (!envVar) {
|
|
10
|
+
return {
|
|
11
|
+
provider: null,
|
|
12
|
+
error: 'Tier is "byok" but no API key environment variable is configured in .privacypolicy.yml. Run "srclawyer init" to set one.',
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
const apiKey = process.env[envVar];
|
|
16
|
+
if (!apiKey) {
|
|
17
|
+
return {
|
|
18
|
+
provider: null,
|
|
19
|
+
error: `Tier is "byok" but the environment variable ${envVar} is not set. Set it to your Anthropic API key and re-run.`,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return { provider: new AnthropicProvider(apiKey), error: null };
|
|
23
|
+
}
|
|
24
|
+
if (tier === "managed") {
|
|
25
|
+
return {
|
|
26
|
+
provider: null,
|
|
27
|
+
error: 'Tier is "managed" but SrcLawyer\'s managed backend is not available yet. Use "byok" with your own Anthropic API key, or "free".',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return { provider: null, error: `Unknown tier "${tier}" in .privacypolicy.yml.` };
|
|
31
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DataCategory } from "../engine/types.js";
|
|
2
|
+
export interface LlmRequestItem {
|
|
3
|
+
findingId: string;
|
|
4
|
+
redactedEvidence: string;
|
|
5
|
+
redactedDescription: string;
|
|
6
|
+
dataCategories: DataCategory[];
|
|
7
|
+
isHighStakes: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface LlmBatchRequest {
|
|
10
|
+
items: LlmRequestItem[];
|
|
11
|
+
}
|
|
12
|
+
export interface LlmResponseItem {
|
|
13
|
+
findingId: string;
|
|
14
|
+
outcome: "resolved" | "declined";
|
|
15
|
+
reason: string | null;
|
|
16
|
+
resolvedDataCategories?: DataCategory[];
|
|
17
|
+
resolvedDescription?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface LlmProvider {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly model: string;
|
|
22
|
+
resolveBatch(request: LlmBatchRequest): Promise<LlmResponseItem[]>;
|
|
23
|
+
}
|
|
24
|
+
export interface ProviderResolution {
|
|
25
|
+
provider: LlmProvider | null;
|
|
26
|
+
/** Non-null means: do not run Layer 2, and this message must be surfaced
|
|
27
|
+
* loudly — never swallowed. */
|
|
28
|
+
error: string | null;
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const DATA_CATEGORY_LABELS = {
|
|
2
|
+
email: "Email addresses",
|
|
3
|
+
name: "Names",
|
|
4
|
+
phone: "Phone numbers",
|
|
5
|
+
physical_address: "Physical or mailing addresses",
|
|
6
|
+
payment_info: "Payment information",
|
|
7
|
+
government_id: "Government-issued identifiers",
|
|
8
|
+
device_id: "Device identifiers",
|
|
9
|
+
ip_address: "IP addresses",
|
|
10
|
+
location: "Location data",
|
|
11
|
+
analytics_usage: "Usage and analytics data",
|
|
12
|
+
authentication_credentials: "Authentication credentials",
|
|
13
|
+
childrens_data: "Children's data",
|
|
14
|
+
health_data: "Health data",
|
|
15
|
+
microphone_audio: "Microphone audio",
|
|
16
|
+
camera_video: "Camera video",
|
|
17
|
+
generic_pii: "Other personal data",
|
|
18
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const MANDATORY_DISCLAIMER = "This document was generated based on automated code analysis and does not constitute legal advice; consult an attorney to verify applicability to your specific circumstances.";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const MANDATORY_DISCLAIMER = "This document was generated based on automated code analysis and does not constitute legal advice; consult an attorney to verify applicability to your specific circumstances.";
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { DATA_CATEGORY_LABELS } from "./dataCategoryLabels.js";
|
|
2
|
+
import { MANDATORY_DISCLAIMER } from "./disclaimer.js";
|
|
3
|
+
function locationRef(finding) {
|
|
4
|
+
return `${finding.location.file}:${finding.location.line}`;
|
|
5
|
+
}
|
|
6
|
+
function effectiveCategories(finding) {
|
|
7
|
+
return finding.llmResolution?.resolvedDataCategories ?? finding.dataCategories;
|
|
8
|
+
}
|
|
9
|
+
function effectiveDescription(finding) {
|
|
10
|
+
return finding.llmResolution?.resolvedDescription ?? finding.description;
|
|
11
|
+
}
|
|
12
|
+
function groupByCategory(findings) {
|
|
13
|
+
const byCategory = new Map();
|
|
14
|
+
for (const finding of findings) {
|
|
15
|
+
for (const category of effectiveCategories(finding)) {
|
|
16
|
+
const existing = byCategory.get(category) ?? [];
|
|
17
|
+
existing.push(finding);
|
|
18
|
+
byCategory.set(category, existing);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return byCategory;
|
|
22
|
+
}
|
|
23
|
+
function renderCategorySections(confidentFindings) {
|
|
24
|
+
const byCategory = groupByCategory(confidentFindings);
|
|
25
|
+
const sections = [];
|
|
26
|
+
for (const [category, findings] of [...byCategory.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
27
|
+
const lines = [`### ${DATA_CATEGORY_LABELS[category]}`, ""];
|
|
28
|
+
for (const finding of findings) {
|
|
29
|
+
const attribution = finding.processor ? ` (via ${finding.processor})` : "";
|
|
30
|
+
const aiNote = finding.llmResolution?.outcome === "resolved" ? " _(AI-assisted)_" : "";
|
|
31
|
+
lines.push(`- ${effectiveDescription(finding)}${attribution}${aiNote} — \`${locationRef(finding)}\``);
|
|
32
|
+
}
|
|
33
|
+
sections.push(lines.join("\n"));
|
|
34
|
+
}
|
|
35
|
+
return sections;
|
|
36
|
+
}
|
|
37
|
+
function renderProcessorSection(confidentFindings) {
|
|
38
|
+
const processors = new Map();
|
|
39
|
+
for (const finding of confidentFindings) {
|
|
40
|
+
if (!finding.processor)
|
|
41
|
+
continue;
|
|
42
|
+
const existing = processors.get(finding.processor) ?? { description: effectiveDescription(finding), locations: [] };
|
|
43
|
+
existing.locations.push(locationRef(finding));
|
|
44
|
+
processors.set(finding.processor, existing);
|
|
45
|
+
}
|
|
46
|
+
if (processors.size === 0)
|
|
47
|
+
return null;
|
|
48
|
+
const lines = ["## Third-Party Data Processors", "", "This codebase integrates with the following third-party services, detected via import/require analysis:", ""];
|
|
49
|
+
for (const [name, { description, locations }] of processors) {
|
|
50
|
+
lines.push(`- **${name}** — ${description} (\`${locations.join("`, `")}\`)`);
|
|
51
|
+
}
|
|
52
|
+
return lines.join("\n");
|
|
53
|
+
}
|
|
54
|
+
function renderReviewSection(reviewFindings) {
|
|
55
|
+
if (reviewFindings.length === 0)
|
|
56
|
+
return null;
|
|
57
|
+
const lines = [
|
|
58
|
+
"## Items Requiring Manual Review",
|
|
59
|
+
"",
|
|
60
|
+
"Automated analysis could not confidently classify the following items. They are **not** included in the sections above and must be reviewed manually before this policy is considered complete:",
|
|
61
|
+
"",
|
|
62
|
+
];
|
|
63
|
+
for (const finding of reviewFindings) {
|
|
64
|
+
const attemptNote = finding.llmResolution?.reason ? ` (AI resolution attempt failed: ${finding.llmResolution.reason})` : "";
|
|
65
|
+
lines.push(`- \`${finding.evidence}\` — ${finding.description} — \`${locationRef(finding)}\`${attemptNote}`);
|
|
66
|
+
}
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
69
|
+
function renderDeclinedSection(declinedFindings) {
|
|
70
|
+
if (declinedFindings.length === 0)
|
|
71
|
+
return null;
|
|
72
|
+
const lines = [
|
|
73
|
+
"## Items Declined by AI-Assisted Review",
|
|
74
|
+
"",
|
|
75
|
+
"These items were reviewed by the AI-assisted resolution layer, which explicitly declined to classify them rather than guess. They are **not** included in the sections above and must be reviewed manually:",
|
|
76
|
+
"",
|
|
77
|
+
];
|
|
78
|
+
for (const finding of declinedFindings) {
|
|
79
|
+
lines.push(`- \`${finding.evidence}\` — ${finding.description} — declined: "${finding.llmResolution?.reason}" — \`${locationRef(finding)}\``);
|
|
80
|
+
}
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
83
|
+
function renderLlmErrorWarning(llmError) {
|
|
84
|
+
return ["> **WARNING: AI-ASSISTED RESOLUTION DID NOT RUN**", ">", `> ${llmError}`].join("\n");
|
|
85
|
+
}
|
|
86
|
+
function renderContextSection(config) {
|
|
87
|
+
if (!config) {
|
|
88
|
+
return [
|
|
89
|
+
"## Scope",
|
|
90
|
+
"",
|
|
91
|
+
"No `.privacypolicy.yml` configuration was found, so this policy was generated without jurisdiction, target-market, or industry context. Run `srclawyer init` and re-run `srclawyer scan` to include that context.",
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
const lines = ["## Scope", ""];
|
|
95
|
+
lines.push(`- **Entity location:** ${config.entityLocation ?? "not specified"}`);
|
|
96
|
+
lines.push(`- **Target markets:** ${config.targetMarkets.length > 0 ? config.targetMarkets.join(", ") : "not specified"}`);
|
|
97
|
+
lines.push(`- **Industry:** ${config.industry ?? "not specified"}`);
|
|
98
|
+
lines.push(`- **Collects children's data:** ${config.collectsChildrensData ? "yes" : "no"}`);
|
|
99
|
+
return lines.join("\n");
|
|
100
|
+
}
|
|
101
|
+
function renderUnsupportedStackWarning(warning) {
|
|
102
|
+
return ["> **WARNING: INCOMPLETE SCAN — DO NOT PUBLISH WITHOUT MANUAL REVIEW**", ">", `> ${warning}`].join("\n");
|
|
103
|
+
}
|
|
104
|
+
function outcomeOf(finding) {
|
|
105
|
+
if (!finding.requiresReview)
|
|
106
|
+
return "confident";
|
|
107
|
+
if (finding.llmResolution?.outcome === "resolved")
|
|
108
|
+
return "resolved";
|
|
109
|
+
if (finding.llmResolution?.outcome === "declined")
|
|
110
|
+
return "declined";
|
|
111
|
+
return "unresolved";
|
|
112
|
+
}
|
|
113
|
+
export function generatePolicyMarkdown(result, config, llmError) {
|
|
114
|
+
const confidentFindings = result.findings.filter((f) => {
|
|
115
|
+
const outcome = outcomeOf(f);
|
|
116
|
+
return outcome === "confident" || outcome === "resolved";
|
|
117
|
+
});
|
|
118
|
+
const declinedFindings = result.findings.filter((f) => outcomeOf(f) === "declined");
|
|
119
|
+
const reviewFindings = result.findings.filter((f) => outcomeOf(f) === "unresolved");
|
|
120
|
+
const parts = ["# Privacy Policy", ""];
|
|
121
|
+
if (result.unsupportedStackWarning) {
|
|
122
|
+
parts.push(renderUnsupportedStackWarning(result.unsupportedStackWarning), "");
|
|
123
|
+
}
|
|
124
|
+
if (llmError) {
|
|
125
|
+
parts.push(renderLlmErrorWarning(llmError), "");
|
|
126
|
+
}
|
|
127
|
+
parts.push(`_Generated by SrcLawyer from static analysis of this codebase (${result.filesScanned} file(s) scanned)._`, "", renderContextSection(config), "", "## Data We Collect", "");
|
|
128
|
+
const categorySections = renderCategorySections(confidentFindings);
|
|
129
|
+
if (categorySections.length > 0) {
|
|
130
|
+
parts.push(categorySections.join("\n\n"));
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
parts.push("No data-collection patterns were confidently identified in this codebase.");
|
|
134
|
+
}
|
|
135
|
+
const processorSection = renderProcessorSection(confidentFindings);
|
|
136
|
+
if (processorSection) {
|
|
137
|
+
parts.push("", processorSection);
|
|
138
|
+
}
|
|
139
|
+
const declinedSection = renderDeclinedSection(declinedFindings);
|
|
140
|
+
if (declinedSection) {
|
|
141
|
+
parts.push("", declinedSection);
|
|
142
|
+
}
|
|
143
|
+
const reviewSection = renderReviewSection(reviewFindings);
|
|
144
|
+
if (reviewSection) {
|
|
145
|
+
parts.push("", reviewSection);
|
|
146
|
+
}
|
|
147
|
+
parts.push("", "## Disclaimer", "", MANDATORY_DISCLAIMER);
|
|
148
|
+
return parts.join("\n") + "\n";
|
|
149
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const BANNER_RULE = "=".repeat(72);
|
|
2
|
+
export function formatText(result) {
|
|
3
|
+
const lines = [];
|
|
4
|
+
if (result.unsupportedStackWarning) {
|
|
5
|
+
lines.push(BANNER_RULE);
|
|
6
|
+
lines.push("WARNING:");
|
|
7
|
+
lines.push(result.unsupportedStackWarning);
|
|
8
|
+
lines.push(BANNER_RULE);
|
|
9
|
+
lines.push("");
|
|
10
|
+
}
|
|
11
|
+
lines.push(`Scanned ${result.filesScanned} file(s) across ${result.projects.length} project(s).`);
|
|
12
|
+
lines.push(`Found ${result.findings.length} finding(s), ${result.ambiguousCount} requiring review.`);
|
|
13
|
+
lines.push("");
|
|
14
|
+
const byProcessor = result.findings.filter((f) => f.processor);
|
|
15
|
+
const byField = result.findings.filter((f) => !f.processor);
|
|
16
|
+
if (byProcessor.length > 0) {
|
|
17
|
+
lines.push("Third-party processors detected:");
|
|
18
|
+
for (const f of byProcessor) {
|
|
19
|
+
lines.push(` [${f.confidence}] ${f.processor} — ${f.location.file}:${f.location.line}`);
|
|
20
|
+
lines.push(` ${f.description}`);
|
|
21
|
+
}
|
|
22
|
+
lines.push("");
|
|
23
|
+
}
|
|
24
|
+
if (byField.length > 0) {
|
|
25
|
+
lines.push("Data fields collected:");
|
|
26
|
+
for (const f of byField) {
|
|
27
|
+
const displayCategories = f.llmResolution?.resolvedDataCategories ?? f.dataCategories;
|
|
28
|
+
lines.push(` [${f.confidence}] ${displayCategories.join(", ")}${reviewFlag(f)} — ${f.location.file}:${f.location.line}`);
|
|
29
|
+
lines.push(` ${f.evidence}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const resolvedCount = result.findings.filter((f) => f.llmResolution?.outcome === "resolved").length;
|
|
33
|
+
const declinedCount = result.findings.filter((f) => f.llmResolution?.outcome === "declined").length;
|
|
34
|
+
const stillUnresolvedCount = result.findings.filter((f) => f.requiresReview && f.llmResolution?.outcome !== "resolved" && f.llmResolution?.outcome !== "declined").length;
|
|
35
|
+
if (resolvedCount > 0 || declinedCount > 0) {
|
|
36
|
+
lines.push("");
|
|
37
|
+
lines.push(`AI-assisted resolution: ${resolvedCount} resolved, ${declinedCount} declined (with a reason), ${stillUnresolvedCount} still unresolved.`);
|
|
38
|
+
}
|
|
39
|
+
else if (result.ambiguousCount > 0) {
|
|
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.`);
|
|
42
|
+
}
|
|
43
|
+
return lines.join("\n");
|
|
44
|
+
}
|
|
45
|
+
function reviewFlag(finding) {
|
|
46
|
+
if (!finding.requiresReview)
|
|
47
|
+
return "";
|
|
48
|
+
if (finding.llmResolution?.outcome === "resolved")
|
|
49
|
+
return " (resolved by AI)";
|
|
50
|
+
if (finding.llmResolution?.outcome === "declined")
|
|
51
|
+
return ` (declined by AI: ${finding.llmResolution.reason})`;
|
|
52
|
+
return " (needs review)";
|
|
53
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DataCategory } from "../engine/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Computing the confidence FACTORS for a field is cheap, generic, local work — the same lexicon
|
|
4
|
+
* lookups every rule already does. Turning those factors into an actual confidence/requiresReview
|
|
5
|
+
* verdict is the tuned, corpus-validated part, and that formula now lives server-side (see
|
|
6
|
+
* src/cloud/protectedLogicClient.ts). This module only ever produces factors, never a verdict.
|
|
7
|
+
*/
|
|
8
|
+
export interface ConfidenceFactors {
|
|
9
|
+
lexiconCategory: DataCategory | null;
|
|
10
|
+
ambiguousName: boolean;
|
|
11
|
+
runtimeVerified: boolean;
|
|
12
|
+
siblingConfidentCount?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function countConfidentSiblings(allFieldNames: string[], selfName: string): number;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { classifyFieldName, isAmbiguousFieldName } from "./dataCategoryLexicon.js";
|
|
2
|
+
// Shared by every rule module that has a pre-materialized list of sibling field names available at
|
|
3
|
+
// the point it emits a finding — counts how many OTHER names in the group independently classify as
|
|
4
|
+
// confident PII, excluding `selfName` itself.
|
|
5
|
+
export function countConfidentSiblings(allFieldNames, selfName) {
|
|
6
|
+
let count = 0;
|
|
7
|
+
for (const name of allFieldNames) {
|
|
8
|
+
if (name === selfName)
|
|
9
|
+
continue;
|
|
10
|
+
if (classifyFieldName(name) && !isAmbiguousFieldName(name))
|
|
11
|
+
count += 1;
|
|
12
|
+
}
|
|
13
|
+
return count;
|
|
14
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const LEXICON = [
|
|
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
|
+
{ pattern: /bank[ _-]?name|routing[ _-]?number|account[ _-]?holder|iban|swift[ _-]?code/i, category: "payment_info" },
|
|
6
|
+
{ pattern: /(full)?name$|firstname|lastname|surname/i, category: "name" },
|
|
7
|
+
{ pattern: /phone|mobile|telephone/i, category: "phone" },
|
|
8
|
+
{ pattern: /address|street|city|zip ?code|postal ?code/i, category: "physical_address" },
|
|
9
|
+
{ pattern: /card ?number|creditcard|cvv|cvc|cardholder/i, category: "payment_info" },
|
|
10
|
+
{ pattern: /ssn|social ?security|passport|national ?id|drivers?license/i, category: "government_id" },
|
|
11
|
+
{ pattern: /password|passwd|passphrase|api ?key|access ?token|refresh ?token|secret/i, category: "authentication_credentials" },
|
|
12
|
+
{ pattern: /latitude|longitude|^lat$|^lng$|geo ?location|coordinates/i, category: "location" },
|
|
13
|
+
{ pattern: /device ?id|udid|advertising ?id|imei/i, category: "device_id" },
|
|
14
|
+
{ pattern: /ip ?address|remoteaddr/i, category: "ip_address" },
|
|
15
|
+
];
|
|
16
|
+
const AMBIGUOUS_NAMES = new Set(["data", "payload", "value", "body", "info", "params", "metadata", "details", "fields", "input", "record", "item", "obj"]);
|
|
17
|
+
export function classifyFieldName(fieldName) {
|
|
18
|
+
for (const entry of LEXICON) {
|
|
19
|
+
if (entry.pattern.test(fieldName))
|
|
20
|
+
return entry.category;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
export function isAmbiguousFieldName(fieldName) {
|
|
25
|
+
return AMBIGUOUS_NAMES.has(fieldName.toLowerCase());
|
|
26
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { File } from "@babel/types";
|
|
2
|
+
import type { Finding } from "../engine/types.js";
|
|
3
|
+
export declare const REQUEST_OBJECT_NAMES: RegExp;
|
|
4
|
+
export declare const REQUEST_BODY_PROPS: RegExp;
|
|
5
|
+
export declare function runFrameworkRules(ast: File, filePath: string, source: string, root: string): Finding[];
|
|
@@ -0,0 +1,98 @@
|
|
|
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 { countConfidentSiblings } from "./confidenceFactors.js";
|
|
7
|
+
const traverse = (_traverse.default ?? _traverse);
|
|
8
|
+
// Exported so other rule modules anchoring detection to "this value came from an incoming request"
|
|
9
|
+
// (e.g. zodPreFilter.ts) can match the exact same shapes, rather than redefining them independently.
|
|
10
|
+
export const REQUEST_OBJECT_NAMES = /^(req|request)$/;
|
|
11
|
+
export const REQUEST_BODY_PROPS = /^(body|query|params)$/;
|
|
12
|
+
function fieldsFromObjectPattern(pattern) {
|
|
13
|
+
const names = [];
|
|
14
|
+
for (const prop of pattern.properties) {
|
|
15
|
+
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier") {
|
|
16
|
+
names.push(prop.key.name);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return names;
|
|
20
|
+
}
|
|
21
|
+
export function runFrameworkRules(ast, filePath, source, root) {
|
|
22
|
+
const findings = [];
|
|
23
|
+
const lines = source.split("\n");
|
|
24
|
+
const relPath = relative(root, filePath);
|
|
25
|
+
const emitted = new Set();
|
|
26
|
+
function emit(fieldName, line, node, siblingFieldNames = []) {
|
|
27
|
+
if (isSafeFieldName(fieldName))
|
|
28
|
+
return;
|
|
29
|
+
const dedupeKey = `${fieldName}:${line}`;
|
|
30
|
+
if (emitted.has(dedupeKey))
|
|
31
|
+
return;
|
|
32
|
+
emitted.add(dedupeKey);
|
|
33
|
+
const lexiconCategory = classifyFieldName(fieldName);
|
|
34
|
+
const ambiguousName = isAmbiguousFieldName(fieldName);
|
|
35
|
+
const siblingConfidentCount = countConfidentSiblings(siblingFieldNames, fieldName);
|
|
36
|
+
const evidenceLine = lines[line - 1] ?? "";
|
|
37
|
+
// Confidence/requiresReview are provisional here — the safe fallback until the batched
|
|
38
|
+
// protected-logic scoring call (src/cloud/protectedLogicClient.ts) patches them in scanEngine.ts.
|
|
39
|
+
// dataCategories uses the local lexicon result directly since categorization itself is local,
|
|
40
|
+
// generic, and unprotected; only the CONFIDENCE verdict is cloud-scored.
|
|
41
|
+
findings.push({
|
|
42
|
+
id: `field:${relPath}:${line}:${fieldName}`,
|
|
43
|
+
dataCategories: lexiconCategory ? [lexiconCategory] : ["generic_pii"],
|
|
44
|
+
processor: null,
|
|
45
|
+
description: lexiconCategory
|
|
46
|
+
? `Request field "${fieldName}" collected, classified as ${lexiconCategory}.`
|
|
47
|
+
: `Request field "${fieldName}" collected; could not confidently classify — needs review.`,
|
|
48
|
+
confidence: "low",
|
|
49
|
+
source: "framework-rule",
|
|
50
|
+
location: { file: relPath, line, column: node.loc?.start.column ?? 0 },
|
|
51
|
+
evidence: redactSecrets(evidenceLine.trim()).slice(0, 200),
|
|
52
|
+
requiresReview: true,
|
|
53
|
+
confidenceFactors: { lexiconCategory, ambiguousName, runtimeVerified: true, siblingConfidentCount },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
traverse(ast, {
|
|
57
|
+
MemberExpression(path) {
|
|
58
|
+
const { object, property } = path.node;
|
|
59
|
+
if (object.type !== "Identifier" || !REQUEST_OBJECT_NAMES.test(object.name))
|
|
60
|
+
return;
|
|
61
|
+
if (property.type !== "Identifier" || !REQUEST_BODY_PROPS.test(property.name))
|
|
62
|
+
return;
|
|
63
|
+
const parent = path.parent;
|
|
64
|
+
if (parent.type === "MemberExpression" && parent.property.type === "Identifier") {
|
|
65
|
+
emit(parent.property.name, parent.loc?.start.line ?? path.node.loc?.start.line ?? 1, parent);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (parent.type === "VariableDeclarator" && parent.id.type === "ObjectPattern") {
|
|
69
|
+
const line = parent.loc?.start.line ?? 1;
|
|
70
|
+
const siblingFieldNames = fieldsFromObjectPattern(parent.id);
|
|
71
|
+
for (const fieldName of siblingFieldNames) {
|
|
72
|
+
emit(fieldName, line, parent, siblingFieldNames);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
AwaitExpression(path) {
|
|
77
|
+
const argument = path.node.argument;
|
|
78
|
+
if (argument.type !== "CallExpression")
|
|
79
|
+
return;
|
|
80
|
+
const callee = argument.callee;
|
|
81
|
+
if (callee.type !== "MemberExpression")
|
|
82
|
+
return;
|
|
83
|
+
if (callee.object.type !== "Identifier" || !REQUEST_OBJECT_NAMES.test(callee.object.name))
|
|
84
|
+
return;
|
|
85
|
+
if (callee.property.type !== "Identifier" || callee.property.name !== "json")
|
|
86
|
+
return;
|
|
87
|
+
const parent = path.parent;
|
|
88
|
+
if (parent.type === "VariableDeclarator" && parent.id.type === "ObjectPattern") {
|
|
89
|
+
const line = parent.loc?.start.line ?? 1;
|
|
90
|
+
const siblingFieldNames = fieldsFromObjectPattern(parent.id);
|
|
91
|
+
for (const fieldName of siblingFieldNames) {
|
|
92
|
+
emit(fieldName, line, parent, siblingFieldNames);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
return findings;
|
|
98
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Finding } from "../engine/types.js";
|
|
2
|
+
export declare function runHtmlInputRules(html: string, filePath: string, root: string): Finding[];
|
|
3
|
+
export interface ExtractedScript {
|
|
4
|
+
code: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function extractInlineScripts(html: string): ExtractedScript[];
|