@etalon/cli 1.0.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/README.md +61 -0
- package/dist/chunk-Z6ZQZ5HI.js +189 -0
- package/dist/consent-checker-B6J7GESG.js +199 -0
- package/dist/index.js +1190 -0
- package/dist/policy-checker-J2WPHGU3.js +294 -0
- package/package.json +43 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
scanSite
|
|
4
|
+
} from "./chunk-Z6ZQZ5HI.js";
|
|
5
|
+
|
|
6
|
+
// src/index.ts
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import ora from "ora";
|
|
9
|
+
import chalk4 from "chalk";
|
|
10
|
+
import { writeFileSync as writeFileSync2 } from "fs";
|
|
11
|
+
import {
|
|
12
|
+
normalizeUrl,
|
|
13
|
+
VendorRegistry,
|
|
14
|
+
auditProject,
|
|
15
|
+
formatAuditSarif,
|
|
16
|
+
generateBadgeSvg,
|
|
17
|
+
calculateScore,
|
|
18
|
+
generatePatches,
|
|
19
|
+
applyPatches,
|
|
20
|
+
analyzeDataFlow,
|
|
21
|
+
toMermaid,
|
|
22
|
+
toTextSummary,
|
|
23
|
+
generatePolicy
|
|
24
|
+
} from "@etalon/core";
|
|
25
|
+
|
|
26
|
+
// src/formatters/text.ts
|
|
27
|
+
import chalk from "chalk";
|
|
28
|
+
function formatText(report) {
|
|
29
|
+
const lines = [];
|
|
30
|
+
lines.push("");
|
|
31
|
+
lines.push(chalk.bold.cyan("ETALON Privacy Audit"));
|
|
32
|
+
lines.push(chalk.dim("\u2550".repeat(55)));
|
|
33
|
+
lines.push(`${chalk.dim("Site:")} ${report.meta.url}`);
|
|
34
|
+
lines.push(`${chalk.dim("Scanned:")} ${new Date(report.meta.scanDate).toLocaleString()}`);
|
|
35
|
+
lines.push(`${chalk.dim("Duration:")} ${(report.meta.scanDurationMs / 1e3).toFixed(1)} seconds`);
|
|
36
|
+
if (report.meta.deep) {
|
|
37
|
+
lines.push(`${chalk.dim("Mode:")} ${chalk.yellow("Deep scan")}`);
|
|
38
|
+
}
|
|
39
|
+
lines.push("");
|
|
40
|
+
lines.push(chalk.bold("\u{1F4CA} Summary"));
|
|
41
|
+
lines.push(chalk.dim("\u2500".repeat(55)));
|
|
42
|
+
const { summary } = report;
|
|
43
|
+
lines.push(`${chalk.green("\u2713")} ${summary.thirdPartyRequests} third-party requests`);
|
|
44
|
+
lines.push(`${chalk.green("\u2713")} ${summary.knownVendors} matched to known vendors`);
|
|
45
|
+
if (summary.unknownDomains > 0) {
|
|
46
|
+
lines.push(`${chalk.yellow("\u26A0")} ${summary.unknownDomains} unknown domain${summary.unknownDomains !== 1 ? "s" : ""}`);
|
|
47
|
+
}
|
|
48
|
+
if (summary.highRisk > 0) {
|
|
49
|
+
lines.push(`${chalk.red("\u2717")} ${summary.highRisk} high-risk tracker${summary.highRisk !== 1 ? "s" : ""} detected`);
|
|
50
|
+
}
|
|
51
|
+
const highRisk = report.vendors.filter((v) => v.vendor.risk_score >= 6);
|
|
52
|
+
if (highRisk.length > 0) {
|
|
53
|
+
lines.push("");
|
|
54
|
+
lines.push(chalk.bold.red(`\u{1F534} High Risk (${highRisk.length})`));
|
|
55
|
+
lines.push(chalk.dim("\u2500".repeat(55)));
|
|
56
|
+
for (const dv of highRisk) {
|
|
57
|
+
lines.push(...formatVendorEntry(dv));
|
|
58
|
+
lines.push("");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const mediumRisk = report.vendors.filter((v) => v.vendor.risk_score >= 3 && v.vendor.risk_score < 6);
|
|
62
|
+
if (mediumRisk.length > 0) {
|
|
63
|
+
lines.push("");
|
|
64
|
+
lines.push(chalk.bold.yellow(`\u{1F7E1} Medium Risk (${mediumRisk.length})`));
|
|
65
|
+
lines.push(chalk.dim("\u2500".repeat(55)));
|
|
66
|
+
for (const dv of mediumRisk) {
|
|
67
|
+
lines.push(...formatVendorEntry(dv));
|
|
68
|
+
lines.push("");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const lowRisk = report.vendors.filter((v) => v.vendor.risk_score < 3);
|
|
72
|
+
if (lowRisk.length > 0) {
|
|
73
|
+
lines.push("");
|
|
74
|
+
lines.push(chalk.bold.green(`\u{1F7E2} Low Risk (${lowRisk.length})`));
|
|
75
|
+
lines.push(chalk.dim("\u2500".repeat(55)));
|
|
76
|
+
for (const dv of lowRisk) {
|
|
77
|
+
lines.push(...formatVendorEntry(dv, true));
|
|
78
|
+
lines.push("");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (report.unknown.length > 0) {
|
|
82
|
+
lines.push("");
|
|
83
|
+
lines.push(chalk.bold.gray(`\u2753 Unknown (${report.unknown.length})`));
|
|
84
|
+
lines.push(chalk.dim("\u2500".repeat(55)));
|
|
85
|
+
for (const u of report.unknown) {
|
|
86
|
+
lines.push(...formatUnknownEntry(u));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (report.recommendations.length > 0) {
|
|
90
|
+
lines.push("");
|
|
91
|
+
lines.push(chalk.bold("\u{1F4A1} Recommendations"));
|
|
92
|
+
lines.push(chalk.dim("\u2500".repeat(55)));
|
|
93
|
+
report.recommendations.forEach((rec, i) => {
|
|
94
|
+
lines.push(`${i + 1}. ${rec.message}`);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
lines.push("");
|
|
98
|
+
lines.push(chalk.dim("Run with --format json for machine-readable output"));
|
|
99
|
+
lines.push(chalk.dim("Report issues: github.com/NMA-vc/etalon/issues"));
|
|
100
|
+
lines.push("");
|
|
101
|
+
return lines.join("\n");
|
|
102
|
+
}
|
|
103
|
+
function formatVendorEntry(dv, compact = false) {
|
|
104
|
+
const { vendor } = dv;
|
|
105
|
+
const lines = [];
|
|
106
|
+
const riskColor = vendor.risk_score >= 6 ? chalk.red : vendor.risk_score >= 3 ? chalk.yellow : chalk.green;
|
|
107
|
+
lines.push(
|
|
108
|
+
`${riskColor(vendor.domains[0].padEnd(35))} ${chalk.bold(vendor.name)}`
|
|
109
|
+
);
|
|
110
|
+
lines.push(`\u251C\u2500 ${chalk.dim("Category:")} ${vendor.category}`);
|
|
111
|
+
if (!compact) {
|
|
112
|
+
const gdprStatus = vendor.gdpr_compliant ? chalk.green("Compliant") + (vendor.dpa_url ? chalk.dim(" (with DPA)") : "") : chalk.red("Non-compliant");
|
|
113
|
+
lines.push(`\u251C\u2500 ${chalk.dim("GDPR:")} ${gdprStatus}`);
|
|
114
|
+
if (vendor.data_collected.length > 0) {
|
|
115
|
+
lines.push(`\u251C\u2500 ${chalk.dim("Data:")} ${vendor.data_collected.join(", ")}`);
|
|
116
|
+
}
|
|
117
|
+
if (vendor.dpa_url) {
|
|
118
|
+
lines.push(`\u251C\u2500 ${chalk.dim("DPA:")} ${chalk.underline(vendor.dpa_url)}`);
|
|
119
|
+
}
|
|
120
|
+
if (vendor.alternatives?.length) {
|
|
121
|
+
lines.push(`\u2514\u2500 ${chalk.dim("Alt:")} Consider ${vendor.alternatives.join(", ")}`);
|
|
122
|
+
} else {
|
|
123
|
+
lines.push(`\u2514\u2500 ${chalk.dim("Requests:")} ${dv.requests.length}`);
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
lines.push(`\u2514\u2500 ${chalk.dim("Requests:")} ${dv.requests.length}`);
|
|
127
|
+
}
|
|
128
|
+
return lines;
|
|
129
|
+
}
|
|
130
|
+
function formatUnknownEntry(u) {
|
|
131
|
+
return [
|
|
132
|
+
`${chalk.gray(u.domain)}`,
|
|
133
|
+
`\u251C\u2500 ${chalk.dim("Requests:")} ${u.requests.length}`,
|
|
134
|
+
`\u2514\u2500 ${chalk.dim("Action:")} Submit to ETALON registry`,
|
|
135
|
+
""
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/formatters/json.ts
|
|
140
|
+
function formatJson(report) {
|
|
141
|
+
const output = {
|
|
142
|
+
meta: {
|
|
143
|
+
etalon_version: report.meta.etalonVersion,
|
|
144
|
+
scan_date: report.meta.scanDate,
|
|
145
|
+
scan_duration_ms: report.meta.scanDurationMs,
|
|
146
|
+
url: report.meta.url
|
|
147
|
+
},
|
|
148
|
+
summary: {
|
|
149
|
+
total_requests: report.summary.totalRequests,
|
|
150
|
+
third_party_requests: report.summary.thirdPartyRequests,
|
|
151
|
+
known_vendors: report.summary.knownVendors,
|
|
152
|
+
unknown_domains: report.summary.unknownDomains,
|
|
153
|
+
high_risk: report.summary.highRisk,
|
|
154
|
+
medium_risk: report.summary.mediumRisk,
|
|
155
|
+
low_risk: report.summary.lowRisk
|
|
156
|
+
},
|
|
157
|
+
vendors: report.vendors.map((dv) => ({
|
|
158
|
+
domain: dv.vendor.domains[0],
|
|
159
|
+
vendor_id: dv.vendor.id,
|
|
160
|
+
name: dv.vendor.name,
|
|
161
|
+
category: dv.vendor.category,
|
|
162
|
+
risk_level: dv.vendor.risk_score >= 6 ? "high" : dv.vendor.risk_score >= 3 ? "medium" : "low",
|
|
163
|
+
risk_score: dv.vendor.risk_score,
|
|
164
|
+
gdpr_compliant: dv.vendor.gdpr_compliant,
|
|
165
|
+
dpa_url: dv.vendor.dpa_url,
|
|
166
|
+
data_collected: dv.vendor.data_collected,
|
|
167
|
+
requests: dv.requests.map((r) => ({
|
|
168
|
+
url: r.url,
|
|
169
|
+
method: r.method,
|
|
170
|
+
type: r.type,
|
|
171
|
+
timestamp: r.timestamp
|
|
172
|
+
}))
|
|
173
|
+
})),
|
|
174
|
+
unknown: report.unknown.map((u) => ({
|
|
175
|
+
domain: u.domain,
|
|
176
|
+
requests: u.requests.map((r) => ({
|
|
177
|
+
url: r.url,
|
|
178
|
+
method: r.method,
|
|
179
|
+
type: r.type
|
|
180
|
+
})),
|
|
181
|
+
suggested_action: u.suggestedAction
|
|
182
|
+
})),
|
|
183
|
+
recommendations: report.recommendations.map((r) => ({
|
|
184
|
+
type: r.type,
|
|
185
|
+
vendor_id: r.vendorId,
|
|
186
|
+
message: r.message
|
|
187
|
+
}))
|
|
188
|
+
};
|
|
189
|
+
return JSON.stringify(output, null, 2);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/formatters/sarif.ts
|
|
193
|
+
function formatSarif(report) {
|
|
194
|
+
const results = [];
|
|
195
|
+
for (const dv of report.vendors) {
|
|
196
|
+
if (dv.vendor.risk_score >= 6) {
|
|
197
|
+
results.push({
|
|
198
|
+
ruleId: "high-risk-tracker",
|
|
199
|
+
level: "warning",
|
|
200
|
+
message: {
|
|
201
|
+
text: `${dv.vendor.name} detected (risk score: ${dv.vendor.risk_score}/10). Category: ${dv.vendor.category}. ${dv.vendor.gdpr_compliant ? "GDPR compliant" : "Not GDPR compliant"}.`
|
|
202
|
+
},
|
|
203
|
+
locations: [
|
|
204
|
+
{
|
|
205
|
+
physicalLocation: {
|
|
206
|
+
artifactLocation: { uri: report.meta.url }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
],
|
|
210
|
+
properties: {
|
|
211
|
+
vendorId: dv.vendor.id,
|
|
212
|
+
category: dv.vendor.category,
|
|
213
|
+
riskScore: dv.vendor.risk_score,
|
|
214
|
+
requestCount: dv.requests.length
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const dv of report.vendors) {
|
|
220
|
+
if (dv.vendor.risk_score >= 3 && dv.vendor.risk_score < 6) {
|
|
221
|
+
results.push({
|
|
222
|
+
ruleId: "medium-risk-tracker",
|
|
223
|
+
level: "note",
|
|
224
|
+
message: {
|
|
225
|
+
text: `${dv.vendor.name} detected (risk score: ${dv.vendor.risk_score}/10). Category: ${dv.vendor.category}.`
|
|
226
|
+
},
|
|
227
|
+
locations: [
|
|
228
|
+
{
|
|
229
|
+
physicalLocation: {
|
|
230
|
+
artifactLocation: { uri: report.meta.url }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
],
|
|
234
|
+
properties: {
|
|
235
|
+
vendorId: dv.vendor.id,
|
|
236
|
+
category: dv.vendor.category,
|
|
237
|
+
riskScore: dv.vendor.risk_score
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for (const u of report.unknown) {
|
|
243
|
+
results.push({
|
|
244
|
+
ruleId: "unknown-tracker",
|
|
245
|
+
level: "note",
|
|
246
|
+
message: {
|
|
247
|
+
text: `Unknown third-party domain: ${u.domain} (${u.requests.length} request${u.requests.length !== 1 ? "s" : ""})`
|
|
248
|
+
},
|
|
249
|
+
locations: [
|
|
250
|
+
{
|
|
251
|
+
physicalLocation: {
|
|
252
|
+
artifactLocation: { uri: report.meta.url }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
]
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
const sarif = {
|
|
259
|
+
$schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
260
|
+
version: "2.1.0",
|
|
261
|
+
runs: [
|
|
262
|
+
{
|
|
263
|
+
tool: {
|
|
264
|
+
driver: {
|
|
265
|
+
name: "ETALON",
|
|
266
|
+
version: report.meta.etalonVersion,
|
|
267
|
+
informationUri: "https://github.com/NMA-vc/etalon",
|
|
268
|
+
rules: [
|
|
269
|
+
{
|
|
270
|
+
id: "high-risk-tracker",
|
|
271
|
+
shortDescription: { text: "High-risk tracker detected" },
|
|
272
|
+
helpUri: "https://github.com/NMA-vc/etalon#risk-levels"
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
id: "medium-risk-tracker",
|
|
276
|
+
shortDescription: { text: "Medium-risk tracker detected" },
|
|
277
|
+
helpUri: "https://github.com/NMA-vc/etalon#risk-levels"
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
id: "unknown-tracker",
|
|
281
|
+
shortDescription: { text: "Unknown third-party domain" },
|
|
282
|
+
helpUri: "https://github.com/NMA-vc/etalon#unknown-domains"
|
|
283
|
+
}
|
|
284
|
+
]
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
results
|
|
288
|
+
}
|
|
289
|
+
]
|
|
290
|
+
};
|
|
291
|
+
return JSON.stringify(sarif, null, 2);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// src/formatters/audit-text.ts
|
|
295
|
+
import chalk2 from "chalk";
|
|
296
|
+
var SEVERITY_ICONS = {
|
|
297
|
+
critical: "\u{1F534}",
|
|
298
|
+
high: "\u{1F534}",
|
|
299
|
+
medium: "\u{1F7E1}",
|
|
300
|
+
low: "\u{1F7E2}",
|
|
301
|
+
info: "\u2139\uFE0F "
|
|
302
|
+
};
|
|
303
|
+
var SEVERITY_COLORS = {
|
|
304
|
+
critical: chalk2.red.bold,
|
|
305
|
+
high: chalk2.red,
|
|
306
|
+
medium: chalk2.yellow,
|
|
307
|
+
low: chalk2.green,
|
|
308
|
+
info: chalk2.gray
|
|
309
|
+
};
|
|
310
|
+
var CATEGORY_LABELS = {
|
|
311
|
+
code: "\u{1F4E6} Code",
|
|
312
|
+
schema: "\u{1F5C4}\uFE0F Schema",
|
|
313
|
+
config: "\u2699\uFE0F Config"
|
|
314
|
+
};
|
|
315
|
+
function formatAuditText(report) {
|
|
316
|
+
const lines = [];
|
|
317
|
+
lines.push("");
|
|
318
|
+
lines.push(chalk2.bold("ETALON Code Audit"));
|
|
319
|
+
lines.push(chalk2.dim("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"));
|
|
320
|
+
lines.push(`Directory: ${chalk2.cyan(report.meta.directory)}`);
|
|
321
|
+
lines.push(`Scanned: ${new Date(report.meta.auditDate).toLocaleString()}`);
|
|
322
|
+
lines.push(`Duration: ${(report.meta.auditDurationMs / 1e3).toFixed(1)} seconds`);
|
|
323
|
+
lines.push("");
|
|
324
|
+
lines.push(chalk2.bold("\u{1F50D} Detected Stack"));
|
|
325
|
+
lines.push(chalk2.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
326
|
+
lines.push(`Languages: ${report.meta.stack.languages.join(", ")}`);
|
|
327
|
+
lines.push(`Framework: ${report.meta.stack.framework}`);
|
|
328
|
+
lines.push(`ORM: ${report.meta.stack.orm}`);
|
|
329
|
+
lines.push(`Pkg Mgr: ${report.meta.stack.packageManager}`);
|
|
330
|
+
lines.push("");
|
|
331
|
+
lines.push(chalk2.bold("\u{1F4CA} Summary"));
|
|
332
|
+
lines.push(chalk2.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
333
|
+
const s = report.summary;
|
|
334
|
+
if (s.totalFindings === 0) {
|
|
335
|
+
lines.push(chalk2.green("\u2713 No findings!"));
|
|
336
|
+
} else {
|
|
337
|
+
if (s.critical > 0) lines.push(chalk2.red.bold(` \u{1F534} ${s.critical} critical`));
|
|
338
|
+
if (s.high > 0) lines.push(chalk2.red(` \u{1F534} ${s.high} high`));
|
|
339
|
+
if (s.medium > 0) lines.push(chalk2.yellow(` \u{1F7E1} ${s.medium} medium`));
|
|
340
|
+
if (s.low > 0) lines.push(chalk2.green(` \u{1F7E2} ${s.low} low`));
|
|
341
|
+
if (s.info > 0) lines.push(chalk2.gray(` \u2139\uFE0F ${s.info} info`));
|
|
342
|
+
lines.push("");
|
|
343
|
+
lines.push(` Tracker SDKs: ${s.trackerSdksFound}`);
|
|
344
|
+
lines.push(` PII columns: ${s.piiColumnsFound}`);
|
|
345
|
+
lines.push(` Config issues: ${s.configIssues}`);
|
|
346
|
+
}
|
|
347
|
+
lines.push("");
|
|
348
|
+
if (report.score) {
|
|
349
|
+
const gradeColors = {
|
|
350
|
+
A: chalk2.green.bold,
|
|
351
|
+
B: chalk2.green,
|
|
352
|
+
C: chalk2.yellow,
|
|
353
|
+
D: chalk2.red,
|
|
354
|
+
F: chalk2.red.bold
|
|
355
|
+
};
|
|
356
|
+
const colorFn = gradeColors[report.score.grade];
|
|
357
|
+
lines.push(chalk2.bold("\u{1F6E1}\uFE0F Compliance Score"));
|
|
358
|
+
lines.push(chalk2.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
359
|
+
lines.push(` Grade: ${colorFn(report.score.grade)} Score: ${colorFn(String(report.score.score))}/100`);
|
|
360
|
+
lines.push("");
|
|
361
|
+
}
|
|
362
|
+
if (report.findings.length > 0) {
|
|
363
|
+
const grouped = groupByCategory(report.findings);
|
|
364
|
+
for (const [category, findings] of Object.entries(grouped)) {
|
|
365
|
+
lines.push(chalk2.bold(`${CATEGORY_LABELS[category] ?? category} Findings (${findings.length})`));
|
|
366
|
+
lines.push(chalk2.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
367
|
+
for (const finding of findings) {
|
|
368
|
+
const icon = SEVERITY_ICONS[finding.severity] ?? "?";
|
|
369
|
+
const colorFn = SEVERITY_COLORS[finding.severity] ?? chalk2.white;
|
|
370
|
+
lines.push(`${icon} ${colorFn(finding.title)}`);
|
|
371
|
+
lines.push(`\u251C\u2500 ${chalk2.dim("File:")} ${finding.file}${finding.line ? `:${finding.line}` : ""}`);
|
|
372
|
+
if (finding.blame) {
|
|
373
|
+
const date = new Date(finding.blame.date).toLocaleDateString();
|
|
374
|
+
lines.push(`\u251C\u2500 ${chalk2.dim("Blame:")} ${finding.blame.author} on ${date} (${finding.blame.commit.substring(0, 8)})`);
|
|
375
|
+
}
|
|
376
|
+
lines.push(`\u251C\u2500 ${chalk2.dim("Rule:")} ${finding.rule}`);
|
|
377
|
+
lines.push(`\u251C\u2500 ${chalk2.dim("Message:")} ${finding.message}`);
|
|
378
|
+
if (finding.gdprArticles && finding.gdprArticles.length > 0) {
|
|
379
|
+
const arts = finding.gdprArticles.map((a) => `Art. ${a.article}`).join(", ");
|
|
380
|
+
lines.push(`\u251C\u2500 ${chalk2.dim("GDPR:")} \u2696\uFE0F ${chalk2.magenta(arts)}`);
|
|
381
|
+
}
|
|
382
|
+
if (finding.fix) {
|
|
383
|
+
lines.push(`\u2514\u2500 ${chalk2.dim("Fix:")} ${chalk2.cyan(finding.fix)}`);
|
|
384
|
+
} else {
|
|
385
|
+
lines.push("");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
lines.push("");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (report.recommendations.length > 0) {
|
|
392
|
+
lines.push(chalk2.bold("\u{1F4A1} Recommendations"));
|
|
393
|
+
lines.push(chalk2.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
394
|
+
for (let i = 0; i < report.recommendations.length; i++) {
|
|
395
|
+
lines.push(`${i + 1}. ${report.recommendations[i]}`);
|
|
396
|
+
}
|
|
397
|
+
lines.push("");
|
|
398
|
+
}
|
|
399
|
+
lines.push(chalk2.dim("Run with --format json for machine-readable output"));
|
|
400
|
+
lines.push(chalk2.dim("Report issues: github.com/NMA-vc/etalon/issues"));
|
|
401
|
+
return lines.join("\n");
|
|
402
|
+
}
|
|
403
|
+
function groupByCategory(findings) {
|
|
404
|
+
const groups = {};
|
|
405
|
+
for (const finding of findings) {
|
|
406
|
+
if (!groups[finding.category]) groups[finding.category] = [];
|
|
407
|
+
groups[finding.category].push(finding);
|
|
408
|
+
}
|
|
409
|
+
return groups;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// src/formatters/html-report.ts
|
|
413
|
+
var GRADE_COLORS = {
|
|
414
|
+
A: "#4caf50",
|
|
415
|
+
B: "#8bc34a",
|
|
416
|
+
C: "#ff9800",
|
|
417
|
+
D: "#ff5722",
|
|
418
|
+
F: "#f44336"
|
|
419
|
+
};
|
|
420
|
+
var SEV_COLORS = {
|
|
421
|
+
critical: "#f44336",
|
|
422
|
+
high: "#ff5722",
|
|
423
|
+
medium: "#ff9800",
|
|
424
|
+
low: "#4caf50",
|
|
425
|
+
info: "#90a4ae"
|
|
426
|
+
};
|
|
427
|
+
function generateHtmlReport(report) {
|
|
428
|
+
const score = report.score;
|
|
429
|
+
const grade = score?.grade ?? "F";
|
|
430
|
+
const gradeColor = GRADE_COLORS[grade];
|
|
431
|
+
const scoreNum = score?.score ?? 0;
|
|
432
|
+
const findingRows = report.findings.map((f) => {
|
|
433
|
+
const gdpr = f.gdprArticles?.map((a) => `<a href="${a.url}" target="_blank" title="${a.title}">Art. ${a.article}</a>`).join(", ") ?? "";
|
|
434
|
+
return `<tr data-severity="${f.severity}" data-category="${f.category}">
|
|
435
|
+
<td><span class="sev-badge" style="background:${SEV_COLORS[f.severity]}">${f.severity}</span></td>
|
|
436
|
+
<td>${esc(f.title)}</td>
|
|
437
|
+
<td><code>${esc(f.file)}${f.line ? `:${f.line}` : ""}</code></td>
|
|
438
|
+
<td>${esc(f.rule)}</td>
|
|
439
|
+
<td class="gdpr-col">${gdpr}</td>
|
|
440
|
+
<td>${f.fix ? esc(f.fix) : ""}</td>
|
|
441
|
+
</tr>`;
|
|
442
|
+
}).join("\n");
|
|
443
|
+
const sevChart = ["critical", "high", "medium", "low", "info"].map((s) => {
|
|
444
|
+
const count = report.summary[s];
|
|
445
|
+
return count > 0 ? `<div class="bar" style="flex:${count};background:${SEV_COLORS[s]}" title="${s}: ${count}">${count}</div>` : "";
|
|
446
|
+
}).join("");
|
|
447
|
+
return `<!DOCTYPE html>
|
|
448
|
+
<html lang="en" data-theme="dark">
|
|
449
|
+
<head>
|
|
450
|
+
<meta charset="utf-8">
|
|
451
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
452
|
+
<title>ETALON Privacy Audit Report</title>
|
|
453
|
+
<style>
|
|
454
|
+
:root { --bg: #0d1117; --card: #161b22; --border: #30363d; --text: #c9d1d9; --text-dim: #8b949e; }
|
|
455
|
+
* { margin:0; padding:0; box-sizing:border-box; }
|
|
456
|
+
body { font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif; background:var(--bg); color:var(--text); padding:2rem; line-height:1.6; }
|
|
457
|
+
.container { max-width:1200px; margin:0 auto; }
|
|
458
|
+
h1 { font-size:1.8rem; margin-bottom:.5rem; }
|
|
459
|
+
h2 { font-size:1.2rem; margin:1.5rem 0 .75rem; color:var(--text); }
|
|
460
|
+
.subtitle { color:var(--text-dim); margin-bottom:2rem; }
|
|
461
|
+
.grid { display:grid; grid-template-columns:200px 1fr; gap:1.5rem; margin-bottom:2rem; }
|
|
462
|
+
.score-ring { width:180px; height:180px; position:relative; margin:0 auto; }
|
|
463
|
+
.score-ring svg { transform:rotate(-90deg); }
|
|
464
|
+
.score-ring .label { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); text-align:center; }
|
|
465
|
+
.score-ring .grade { font-size:3rem; font-weight:700; color:${gradeColor}; }
|
|
466
|
+
.score-ring .num { font-size:1rem; color:var(--text-dim); }
|
|
467
|
+
.card { background:var(--card); border:1px solid var(--border); border-radius:8px; padding:1.25rem; }
|
|
468
|
+
.meta-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:.75rem; }
|
|
469
|
+
.meta-item { font-size:.85rem; }
|
|
470
|
+
.meta-item span { display:block; color:var(--text-dim); font-size:.75rem; }
|
|
471
|
+
.bar-chart { display:flex; border-radius:4px; overflow:hidden; height:28px; margin:1rem 0; }
|
|
472
|
+
.bar { color:#fff; font-size:.75rem; display:flex; align-items:center; justify-content:center; font-weight:600; min-width:28px; }
|
|
473
|
+
table { width:100%; border-collapse:collapse; font-size:.85rem; }
|
|
474
|
+
th { text-align:left; padding:.6rem .75rem; border-bottom:2px solid var(--border); color:var(--text-dim); font-weight:600; position:sticky; top:0; background:var(--card); }
|
|
475
|
+
td { padding:.5rem .75rem; border-bottom:1px solid var(--border); vertical-align:top; }
|
|
476
|
+
tr:hover { background:#1c2128; }
|
|
477
|
+
code { background:#1c2128; padding:.15rem .4rem; border-radius:3px; font-size:.8rem; }
|
|
478
|
+
a { color:#58a6ff; text-decoration:none; }
|
|
479
|
+
a:hover { text-decoration:underline; }
|
|
480
|
+
.sev-badge { color:#fff; padding:2px 8px; border-radius:3px; font-size:.75rem; font-weight:600; text-transform:uppercase; }
|
|
481
|
+
.filters { display:flex; gap:.5rem; margin-bottom:1rem; flex-wrap:wrap; }
|
|
482
|
+
.filters button { background:var(--card); border:1px solid var(--border); color:var(--text); padding:.35rem .75rem; border-radius:4px; cursor:pointer; font-size:.8rem; }
|
|
483
|
+
.filters button.active { background:#1f6feb; border-color:#1f6feb; color:#fff; }
|
|
484
|
+
.gdpr-col { font-size:.8rem; }
|
|
485
|
+
.footer { text-align:center; color:var(--text-dim); font-size:.8rem; margin-top:2rem; padding-top:1rem; border-top:1px solid var(--border); }
|
|
486
|
+
@media print { body { background:#fff; color:#000; } .card { border-color:#ddd; } }
|
|
487
|
+
@media (max-width:768px) { .grid { grid-template-columns:1fr; } }
|
|
488
|
+
</style>
|
|
489
|
+
</head>
|
|
490
|
+
<body>
|
|
491
|
+
<div class="container">
|
|
492
|
+
<h1>\u{1F6E1}\uFE0F ETALON Privacy Audit Report</h1>
|
|
493
|
+
<p class="subtitle">Generated ${new Date(report.meta.auditDate).toLocaleString()} \u2022 ${report.meta.directory}</p>
|
|
494
|
+
|
|
495
|
+
<div class="grid">
|
|
496
|
+
<div class="card" style="display:flex;align-items:center;justify-content:center">
|
|
497
|
+
<div class="score-ring">
|
|
498
|
+
<svg width="180" height="180" viewBox="0 0 180 180">
|
|
499
|
+
<circle cx="90" cy="90" r="78" stroke="var(--border)" stroke-width="12" fill="none"/>
|
|
500
|
+
<circle cx="90" cy="90" r="78" stroke="${gradeColor}" stroke-width="12" fill="none"
|
|
501
|
+
stroke-dasharray="${scoreNum / 100 * 490} 490" stroke-linecap="round"/>
|
|
502
|
+
</svg>
|
|
503
|
+
<div class="label">
|
|
504
|
+
<div class="grade">${grade}</div>
|
|
505
|
+
<div class="num">${scoreNum}/100</div>
|
|
506
|
+
</div>
|
|
507
|
+
</div>
|
|
508
|
+
</div>
|
|
509
|
+
|
|
510
|
+
<div class="card">
|
|
511
|
+
<h2 style="margin-top:0">Summary</h2>
|
|
512
|
+
<div class="meta-grid">
|
|
513
|
+
<div class="meta-item"><span>Total Findings</span>${report.summary.totalFindings}</div>
|
|
514
|
+
<div class="meta-item"><span>Tracker SDKs</span>${report.summary.trackerSdksFound}</div>
|
|
515
|
+
<div class="meta-item"><span>PII Columns</span>${report.summary.piiColumnsFound}</div>
|
|
516
|
+
<div class="meta-item"><span>Config Issues</span>${report.summary.configIssues}</div>
|
|
517
|
+
<div class="meta-item"><span>Stack</span>${report.meta.stack.languages.join(", ")} / ${report.meta.stack.framework}</div>
|
|
518
|
+
<div class="meta-item"><span>Duration</span>${(report.meta.auditDurationMs / 1e3).toFixed(1)}s</div>
|
|
519
|
+
</div>
|
|
520
|
+
<div class="bar-chart">${sevChart || '<div style="flex:1;background:var(--border);display:flex;align-items:center;justify-content:center;color:var(--text-dim);font-size:.8rem">No findings</div>'}</div>
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
|
|
524
|
+
${report.findings.length > 0 ? `
|
|
525
|
+
<div class="card">
|
|
526
|
+
<h2 style="margin-top:0">Findings (${report.findings.length})</h2>
|
|
527
|
+
<div class="filters">
|
|
528
|
+
<button class="active" onclick="filterTable('all')">All</button>
|
|
529
|
+
<button onclick="filterTable('critical')">Critical</button>
|
|
530
|
+
<button onclick="filterTable('high')">High</button>
|
|
531
|
+
<button onclick="filterTable('medium')">Medium</button>
|
|
532
|
+
<button onclick="filterTable('low')">Low</button>
|
|
533
|
+
</div>
|
|
534
|
+
<div style="overflow-x:auto">
|
|
535
|
+
<table id="findings-table">
|
|
536
|
+
<thead><tr><th>Severity</th><th>Title</th><th>File</th><th>Rule</th><th>GDPR</th><th>Fix</th></tr></thead>
|
|
537
|
+
<tbody>${findingRows}</tbody>
|
|
538
|
+
</table>
|
|
539
|
+
</div>
|
|
540
|
+
</div>` : ""}
|
|
541
|
+
|
|
542
|
+
${report.recommendations.length > 0 ? `
|
|
543
|
+
<div class="card" style="margin-top:1.5rem">
|
|
544
|
+
<h2 style="margin-top:0">\u{1F4A1} Recommendations</h2>
|
|
545
|
+
<ol style="padding-left:1.25rem">${report.recommendations.map((r) => `<li>${esc(r)}</li>`).join("")}</ol>
|
|
546
|
+
</div>` : ""}
|
|
547
|
+
|
|
548
|
+
<div class="footer">
|
|
549
|
+
Generated by <a href="https://etalon.nma.vc">ETALON</a> v${report.meta.etalonVersion} \u2022
|
|
550
|
+
<a href="https://etalon.nma.vc/docs">Documentation</a>
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
|
|
554
|
+
<script>
|
|
555
|
+
function filterTable(sev) {
|
|
556
|
+
document.querySelectorAll('.filters button').forEach(b => b.classList.remove('active'));
|
|
557
|
+
event.target.classList.add('active');
|
|
558
|
+
document.querySelectorAll('#findings-table tbody tr').forEach(row => {
|
|
559
|
+
row.style.display = (sev === 'all' || row.dataset.severity === sev) ? '' : 'none';
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
</script>
|
|
563
|
+
</body>
|
|
564
|
+
</html>`;
|
|
565
|
+
}
|
|
566
|
+
function esc(s) {
|
|
567
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// src/config.ts
|
|
571
|
+
import { readFileSync, existsSync } from "fs";
|
|
572
|
+
import { join } from "path";
|
|
573
|
+
import { parse as parseYaml } from "yaml";
|
|
574
|
+
var CONFIG_FILENAMES = ["etalon.yaml", "etalon.yml", ".etalon.yaml", ".etalon.yml"];
|
|
575
|
+
function loadConfig(startDir) {
|
|
576
|
+
const dir = startDir ?? process.cwd();
|
|
577
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
578
|
+
const filePath = join(dir, filename);
|
|
579
|
+
if (existsSync(filePath)) {
|
|
580
|
+
try {
|
|
581
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
582
|
+
return parseYaml(raw);
|
|
583
|
+
} catch (err) {
|
|
584
|
+
console.error(`Warning: Failed to parse ${filePath}: ${err}`);
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// src/commands/init.ts
|
|
593
|
+
import { writeFileSync, existsSync as existsSync2, mkdirSync } from "fs";
|
|
594
|
+
import { join as join2 } from "path";
|
|
595
|
+
import chalk3 from "chalk";
|
|
596
|
+
var ETALON_YAML = `# ETALON Configuration
|
|
597
|
+
# https://etalon.nma.vc/docs/config
|
|
598
|
+
version: "1.0"
|
|
599
|
+
|
|
600
|
+
# Vendors/domains you've reviewed and approved
|
|
601
|
+
allowlist: []
|
|
602
|
+
# - vendor_id: google-analytics
|
|
603
|
+
# reason: "Required for analytics \u2014 consent collected via CMP"
|
|
604
|
+
# approved_by: "privacy@company.com"
|
|
605
|
+
# approved_date: "2025-01-15"
|
|
606
|
+
|
|
607
|
+
# Audit settings
|
|
608
|
+
audit:
|
|
609
|
+
severity: low # minimum severity to report
|
|
610
|
+
include_blame: false # enrich with git blame info
|
|
611
|
+
|
|
612
|
+
# Scan settings (runtime)
|
|
613
|
+
scan:
|
|
614
|
+
timeout: 30000
|
|
615
|
+
wait_for_network_idle: true
|
|
616
|
+
`;
|
|
617
|
+
var GITHUB_ACTION = `name: ETALON Privacy Audit
|
|
618
|
+
on:
|
|
619
|
+
pull_request:
|
|
620
|
+
branches: [main, master]
|
|
621
|
+
|
|
622
|
+
permissions:
|
|
623
|
+
contents: read
|
|
624
|
+
pull-requests: write
|
|
625
|
+
security-events: write
|
|
626
|
+
|
|
627
|
+
jobs:
|
|
628
|
+
etalon:
|
|
629
|
+
name: Privacy Audit
|
|
630
|
+
runs-on: ubuntu-latest
|
|
631
|
+
steps:
|
|
632
|
+
- uses: actions/checkout@v4
|
|
633
|
+
|
|
634
|
+
- uses: actions/setup-node@v4
|
|
635
|
+
with:
|
|
636
|
+
node-version: '20'
|
|
637
|
+
|
|
638
|
+
- name: Install ETALON
|
|
639
|
+
run: npm install -g etalon
|
|
640
|
+
|
|
641
|
+
- name: Run audit
|
|
642
|
+
run: etalon audit ./ --format sarif > etalon-results.sarif
|
|
643
|
+
|
|
644
|
+
- name: Upload SARIF
|
|
645
|
+
if: always()
|
|
646
|
+
uses: github/codeql-action/upload-sarif@v3
|
|
647
|
+
with:
|
|
648
|
+
sarif_file: etalon-results.sarif
|
|
649
|
+
category: etalon-privacy
|
|
650
|
+
`;
|
|
651
|
+
var PRECOMMIT_HOOK = `#!/bin/sh
|
|
652
|
+
# ETALON pre-commit hook \u2014 blocks commits with critical/high privacy issues
|
|
653
|
+
# Installed by 'etalon init'
|
|
654
|
+
|
|
655
|
+
# Get staged files
|
|
656
|
+
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
|
|
657
|
+
|
|
658
|
+
if [ -z "$STAGED_FILES" ]; then
|
|
659
|
+
exit 0
|
|
660
|
+
fi
|
|
661
|
+
|
|
662
|
+
# Run audit silently
|
|
663
|
+
echo "\u{1F50D} ETALON: checking for privacy issues..."
|
|
664
|
+
npx etalon audit ./ --format json --severity high 2>/dev/null | node -e "
|
|
665
|
+
const data = require('fs').readFileSync('/dev/stdin', 'utf8');
|
|
666
|
+
try {
|
|
667
|
+
const report = JSON.parse(data);
|
|
668
|
+
const staged = process.argv.slice(1);
|
|
669
|
+
const issues = report.findings.filter(f => staged.some(s => f.file.includes(s)));
|
|
670
|
+
if (issues.length > 0) {
|
|
671
|
+
console.error('\\n\u{1F534} ETALON: Found ' + issues.length + ' high/critical privacy issue(s):');
|
|
672
|
+
issues.forEach(f => console.error(' \u2022 ' + f.severity.toUpperCase() + ': ' + f.title + ' (' + f.file + ')'));
|
|
673
|
+
console.error('\\nFix these issues or use --no-verify to skip.\\n');
|
|
674
|
+
process.exit(1);
|
|
675
|
+
}
|
|
676
|
+
} catch(e) { /* audit not available, allow commit */ }
|
|
677
|
+
" $STAGED_FILES
|
|
678
|
+
`;
|
|
679
|
+
var GITIGNORE_ADDITIONS = `
|
|
680
|
+
# ETALON
|
|
681
|
+
etalon-report.html
|
|
682
|
+
etalon-badge.svg
|
|
683
|
+
etalon-results.sarif
|
|
684
|
+
`;
|
|
685
|
+
async function runInit(dir, options = {}) {
|
|
686
|
+
const ci = options.ci ?? "github";
|
|
687
|
+
const precommit = options.precommit ?? true;
|
|
688
|
+
const force = options.force ?? false;
|
|
689
|
+
console.log("");
|
|
690
|
+
console.log(chalk3.bold("\u{1F527} ETALON Project Setup"));
|
|
691
|
+
console.log(chalk3.dim("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"));
|
|
692
|
+
console.log("");
|
|
693
|
+
const yamlPath = join2(dir, "etalon.yaml");
|
|
694
|
+
if (!existsSync2(yamlPath) || force) {
|
|
695
|
+
writeFileSync(yamlPath, ETALON_YAML, "utf-8");
|
|
696
|
+
console.log(chalk3.green("\u2713") + ` Created ${chalk3.cyan("etalon.yaml")}`);
|
|
697
|
+
} else {
|
|
698
|
+
console.log(chalk3.yellow("\u2298") + ` ${chalk3.cyan("etalon.yaml")} already exists (use --force to overwrite)`);
|
|
699
|
+
}
|
|
700
|
+
if (ci === "github") {
|
|
701
|
+
const workflowDir = join2(dir, ".github", "workflows");
|
|
702
|
+
const workflowPath = join2(workflowDir, "etalon.yml");
|
|
703
|
+
if (!existsSync2(workflowPath) || force) {
|
|
704
|
+
mkdirSync(workflowDir, { recursive: true });
|
|
705
|
+
writeFileSync(workflowPath, GITHUB_ACTION, "utf-8");
|
|
706
|
+
console.log(chalk3.green("\u2713") + ` Created ${chalk3.cyan(".github/workflows/etalon.yml")}`);
|
|
707
|
+
} else {
|
|
708
|
+
console.log(chalk3.yellow("\u2298") + ` ${chalk3.cyan(".github/workflows/etalon.yml")} already exists`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (precommit) {
|
|
712
|
+
const hooksDir = join2(dir, ".git", "hooks");
|
|
713
|
+
const hookPath = join2(hooksDir, "pre-commit");
|
|
714
|
+
const etalonDir = join2(dir, ".etalon");
|
|
715
|
+
const standalonePath = join2(etalonDir, "pre-commit.sh");
|
|
716
|
+
mkdirSync(etalonDir, { recursive: true });
|
|
717
|
+
if (!existsSync2(standalonePath) || force) {
|
|
718
|
+
writeFileSync(standalonePath, PRECOMMIT_HOOK, { mode: 493 });
|
|
719
|
+
console.log(chalk3.green("\u2713") + ` Created ${chalk3.cyan(".etalon/pre-commit.sh")}`);
|
|
720
|
+
}
|
|
721
|
+
if (existsSync2(join2(dir, ".git"))) {
|
|
722
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
723
|
+
if (!existsSync2(hookPath) || force) {
|
|
724
|
+
writeFileSync(hookPath, PRECOMMIT_HOOK, { mode: 493 });
|
|
725
|
+
console.log(chalk3.green("\u2713") + ` Installed ${chalk3.cyan("pre-commit hook")}`);
|
|
726
|
+
} else {
|
|
727
|
+
console.log(chalk3.yellow("\u2298") + ` pre-commit hook already exists (see ${chalk3.cyan(".etalon/pre-commit.sh")})`);
|
|
728
|
+
}
|
|
729
|
+
} else {
|
|
730
|
+
console.log(chalk3.dim(" (no .git directory \u2014 hook saved to .etalon/pre-commit.sh)"));
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
const rulesDir = join2(dir, ".etalon", "rules");
|
|
734
|
+
if (!existsSync2(rulesDir)) {
|
|
735
|
+
mkdirSync(rulesDir, { recursive: true });
|
|
736
|
+
writeFileSync(join2(rulesDir, ".gitkeep"), "", "utf-8");
|
|
737
|
+
console.log(chalk3.green("\u2713") + ` Created ${chalk3.cyan(".etalon/rules/")} for custom rules`);
|
|
738
|
+
}
|
|
739
|
+
const gitignorePath = join2(dir, ".gitignore");
|
|
740
|
+
if (existsSync2(gitignorePath)) {
|
|
741
|
+
const content = await import("fs").then((fs) => fs.readFileSync(gitignorePath, "utf-8"));
|
|
742
|
+
if (!content.includes("etalon-report.html")) {
|
|
743
|
+
writeFileSync(gitignorePath, content + GITIGNORE_ADDITIONS, "utf-8");
|
|
744
|
+
console.log(chalk3.green("\u2713") + ` Updated ${chalk3.cyan(".gitignore")}`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
console.log("");
|
|
748
|
+
console.log(chalk3.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
749
|
+
console.log(chalk3.bold("Next steps:"));
|
|
750
|
+
console.log(` 1. Review ${chalk3.cyan("etalon.yaml")} and add approved vendors`);
|
|
751
|
+
console.log(` 2. Run ${chalk3.cyan("etalon audit ./")} to scan your codebase`);
|
|
752
|
+
if (ci === "github") {
|
|
753
|
+
console.log(` 3. Push to trigger the GitHub Action`);
|
|
754
|
+
}
|
|
755
|
+
console.log(` 4. Visit ${chalk3.cyan("https://etalon.nma.vc/docs")} for full documentation`);
|
|
756
|
+
console.log("");
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// src/index.ts
|
|
760
|
+
var VERSION = "1.0.0";
|
|
761
|
+
function showBanner() {
|
|
762
|
+
const blue = chalk4.hex("#3B82F6");
|
|
763
|
+
const dim = chalk4.hex("#64748B");
|
|
764
|
+
const cyan = chalk4.hex("#06B6D4");
|
|
765
|
+
console.log("");
|
|
766
|
+
console.log(blue.bold(" \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2557"));
|
|
767
|
+
console.log(blue.bold(" \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551"));
|
|
768
|
+
console.log(cyan.bold(" \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551"));
|
|
769
|
+
console.log(cyan.bold(" \u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551"));
|
|
770
|
+
console.log(blue.bold(" \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551"));
|
|
771
|
+
console.log(blue.bold(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D"));
|
|
772
|
+
console.log("");
|
|
773
|
+
console.log(dim(` v${VERSION} `) + chalk4.white("Privacy audit tool for AI coding agents"));
|
|
774
|
+
console.log(dim(" Open-source GDPR compliance scanner ") + cyan("etalon.nma.vc"));
|
|
775
|
+
console.log("");
|
|
776
|
+
}
|
|
777
|
+
var program = new Command();
|
|
778
|
+
program.name("etalon").description("ETALON \u2014 Open-source privacy auditor. Scan websites for trackers and GDPR compliance.").version(VERSION).hook("preAction", () => {
|
|
779
|
+
showBanner();
|
|
780
|
+
});
|
|
781
|
+
program.command("scan").description("Scan a website for third-party trackers").argument("<url>", "URL to scan").option("-f, --format <format>", "Output format: text, json, sarif", "text").option("-d, --deep", "Deep scan: scroll page, interact with consent dialogs", false).option("-t, --timeout <ms>", "Navigation timeout in milliseconds", "30000").option("--no-idle", "Do not wait for network idle").option("--config <path>", "Path to etalon.yaml config file").action(async (url, options) => {
|
|
782
|
+
const normalizedUrl = normalizeUrl(url);
|
|
783
|
+
const format = options.format ?? "text";
|
|
784
|
+
const config = loadConfig(options.config);
|
|
785
|
+
const scanOptions = {
|
|
786
|
+
deep: options.deep,
|
|
787
|
+
timeout: parseInt(options.timeout, 10),
|
|
788
|
+
waitForNetworkIdle: options.idle !== false
|
|
789
|
+
};
|
|
790
|
+
if (config?.scan) {
|
|
791
|
+
if (config.scan.timeout && !options.timeout) scanOptions.timeout = config.scan.timeout;
|
|
792
|
+
if (config.scan.user_agent) scanOptions.userAgent = config.scan.user_agent;
|
|
793
|
+
if (config.scan.viewport) scanOptions.viewport = config.scan.viewport;
|
|
794
|
+
if (config.scan.wait_for_network_idle !== void 0 && options.idle === void 0) {
|
|
795
|
+
scanOptions.waitForNetworkIdle = config.scan.wait_for_network_idle;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
const showSpinner = format === "text";
|
|
799
|
+
const spinner = showSpinner ? ora(`Scanning ${normalizedUrl}...`).start() : null;
|
|
800
|
+
try {
|
|
801
|
+
const report = await scanSite(normalizedUrl, scanOptions);
|
|
802
|
+
spinner?.stop();
|
|
803
|
+
switch (format) {
|
|
804
|
+
case "json":
|
|
805
|
+
console.log(formatJson(report));
|
|
806
|
+
break;
|
|
807
|
+
case "sarif":
|
|
808
|
+
console.log(formatSarif(report));
|
|
809
|
+
break;
|
|
810
|
+
case "text":
|
|
811
|
+
default:
|
|
812
|
+
console.log(formatText(report));
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
if (report.summary.highRisk > 0) {
|
|
816
|
+
process.exit(1);
|
|
817
|
+
}
|
|
818
|
+
} catch (error) {
|
|
819
|
+
spinner?.fail("Scan failed");
|
|
820
|
+
if (error instanceof Error) {
|
|
821
|
+
console.error(`
|
|
822
|
+
Error: ${error.message}`);
|
|
823
|
+
if (error.message.includes("Executable doesn't exist")) {
|
|
824
|
+
console.error("\nPlaywright browsers not installed. Run:");
|
|
825
|
+
console.error(" npx playwright install chromium");
|
|
826
|
+
}
|
|
827
|
+
} else {
|
|
828
|
+
console.error("\nAn unexpected error occurred.");
|
|
829
|
+
}
|
|
830
|
+
process.exit(2);
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
program.command("audit").description("Scan a codebase for GDPR compliance (tracker SDKs, PII in schemas, config issues)").argument("[dir]", "Directory to audit", "./").option("-f, --format <format>", "Output format: text, json, sarif, html", "text").option("-s, --severity <level>", "Minimum severity: info, low, medium, high, critical").option("--include-blame", "Include git blame information for each finding", false).option("--fix", "Auto-fix simple issues (preview before applying)", false).action(async (dir, options) => {
|
|
834
|
+
const format = options.format ?? "text";
|
|
835
|
+
const includeBlame = options.includeBlame;
|
|
836
|
+
const autoFix = options.fix;
|
|
837
|
+
const showSpinner = format === "text";
|
|
838
|
+
const spinner = showSpinner ? ora(`Auditing ${dir}...`).start() : null;
|
|
839
|
+
try {
|
|
840
|
+
const report = await auditProject(dir, {
|
|
841
|
+
severity: options.severity,
|
|
842
|
+
includeBlame
|
|
843
|
+
});
|
|
844
|
+
spinner?.stop();
|
|
845
|
+
if (autoFix) {
|
|
846
|
+
const patches = generatePatches(report.findings, dir);
|
|
847
|
+
if (patches.length === 0) {
|
|
848
|
+
console.log(chalk4.yellow("\nNo auto-fixable issues found."));
|
|
849
|
+
} else {
|
|
850
|
+
console.log(chalk4.bold(`
|
|
851
|
+
\u{1F527} ${patches.length} auto-fixable issue(s):`));
|
|
852
|
+
for (const p of patches) {
|
|
853
|
+
console.log(` ${chalk4.dim(p.file)}:${p.line} \u2014 ${p.description}`);
|
|
854
|
+
console.log(` ${chalk4.red("- " + p.oldContent.trim())}`);
|
|
855
|
+
console.log(` ${chalk4.green("+ " + p.newContent.trim())}`);
|
|
856
|
+
}
|
|
857
|
+
const applied = applyPatches(patches, dir);
|
|
858
|
+
console.log(chalk4.green(`
|
|
859
|
+
\u2713 Applied ${applied} fix(es).`));
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
switch (format) {
|
|
863
|
+
case "json":
|
|
864
|
+
console.log(JSON.stringify(report, null, 2));
|
|
865
|
+
break;
|
|
866
|
+
case "sarif":
|
|
867
|
+
console.log(formatAuditSarif(report));
|
|
868
|
+
break;
|
|
869
|
+
case "html": {
|
|
870
|
+
const html = generateHtmlReport(report);
|
|
871
|
+
const outPath = "etalon-report.html";
|
|
872
|
+
writeFileSync2(outPath, html, "utf-8");
|
|
873
|
+
console.log(chalk4.green(`\u2713 Report written to ${chalk4.cyan(outPath)}`));
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
case "text":
|
|
877
|
+
default:
|
|
878
|
+
console.log(formatAuditText(report));
|
|
879
|
+
break;
|
|
880
|
+
}
|
|
881
|
+
if (report.summary.critical > 0 || report.summary.high > 0) {
|
|
882
|
+
process.exit(1);
|
|
883
|
+
}
|
|
884
|
+
} catch (error) {
|
|
885
|
+
spinner?.fail("Audit failed");
|
|
886
|
+
if (error instanceof Error) {
|
|
887
|
+
console.error(`
|
|
888
|
+
Error: ${error.message}`);
|
|
889
|
+
} else {
|
|
890
|
+
console.error("\nAn unexpected error occurred.");
|
|
891
|
+
}
|
|
892
|
+
process.exit(2);
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
program.command("lookup").description("Look up a domain in the vendor registry").argument("<domain>", "Domain to look up").action((domain) => {
|
|
896
|
+
const registry = VendorRegistry.load();
|
|
897
|
+
const vendor = registry.lookupDomain(domain);
|
|
898
|
+
if (vendor) {
|
|
899
|
+
console.log(JSON.stringify(vendor, null, 2));
|
|
900
|
+
} else {
|
|
901
|
+
console.log(`Domain "${domain}" is not in the ETALON vendor registry.`);
|
|
902
|
+
process.exit(1);
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
program.command("info").description("Show ETALON registry information").action(() => {
|
|
906
|
+
const registry = VendorRegistry.load();
|
|
907
|
+
const meta = registry.getMetadata();
|
|
908
|
+
console.log(`ETALON Registry v${meta.version}`);
|
|
909
|
+
console.log(`Last updated: ${meta.lastUpdated}`);
|
|
910
|
+
console.log(`Vendors: ${meta.vendorCount}`);
|
|
911
|
+
console.log(`Domains tracked: ${meta.domainCount}`);
|
|
912
|
+
console.log(`Categories: ${meta.categoryCount}`);
|
|
913
|
+
});
|
|
914
|
+
program.command("init").description("Set up ETALON in your project (config, CI, pre-commit hook)").argument("[dir]", "Project directory", "./").option("--ci <provider>", "CI provider: github, gitlab, none", "github").option("--no-precommit", "Skip pre-commit hook installation").option("--force", "Overwrite existing files", false).action(async (dir, options) => {
|
|
915
|
+
await runInit(dir, {
|
|
916
|
+
ci: options.ci,
|
|
917
|
+
precommit: options.precommit !== false,
|
|
918
|
+
force: options.force
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
program.command("consent-check").description("Test if trackers fire before/after rejecting cookies on a website").argument("<url>", "URL to check").option("-f, --format <format>", "Output format: text, json", "text").option("-t, --timeout <ms>", "Navigation timeout", "15000").action(async (url, options) => {
|
|
922
|
+
const normalizedUrl = normalizeUrl(url);
|
|
923
|
+
const format = options.format ?? "text";
|
|
924
|
+
const spinner = format === "text" ? ora(`Checking consent on ${normalizedUrl}...`).start() : null;
|
|
925
|
+
try {
|
|
926
|
+
const { checkConsent } = await import("./consent-checker-B6J7GESG.js");
|
|
927
|
+
const result = await checkConsent(normalizedUrl, {
|
|
928
|
+
timeout: parseInt(options.timeout ?? "15000", 10)
|
|
929
|
+
});
|
|
930
|
+
spinner?.stop();
|
|
931
|
+
if (format === "json") {
|
|
932
|
+
console.log(JSON.stringify(result, null, 2));
|
|
933
|
+
} else {
|
|
934
|
+
console.log("");
|
|
935
|
+
console.log(chalk4.bold("ETALON Consent Verification"));
|
|
936
|
+
console.log(chalk4.dim("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"));
|
|
937
|
+
console.log(`URL: ${chalk4.cyan(result.url)}`);
|
|
938
|
+
console.log(`Banner: ${result.bannerDetected ? chalk4.green("\u2713 Detected") : chalk4.red("\u2717 Not found")}${result.bannerType ? ` (${result.bannerType})` : ""}`);
|
|
939
|
+
console.log(`Reject: ${result.rejectClicked ? chalk4.green("\u2713 Clicked") : chalk4.yellow("\u2717 Could not reject")}`);
|
|
940
|
+
console.log("");
|
|
941
|
+
if (result.preConsentTrackers.length > 0) {
|
|
942
|
+
console.log(chalk4.bold("\u{1F50D} Trackers before consent:"));
|
|
943
|
+
for (const t of result.preConsentTrackers) {
|
|
944
|
+
console.log(` \u2022 ${t.name} (${chalk4.dim(t.matchedDomain)})`);
|
|
945
|
+
}
|
|
946
|
+
console.log("");
|
|
947
|
+
}
|
|
948
|
+
if (result.postRejectTrackers.length > 0) {
|
|
949
|
+
console.log(chalk4.bold("\u26A0\uFE0F Trackers after rejection:"));
|
|
950
|
+
for (const t of result.postRejectTrackers) {
|
|
951
|
+
console.log(` \u2022 ${chalk4.red(t.name)} (${chalk4.dim(t.matchedDomain)})`);
|
|
952
|
+
}
|
|
953
|
+
console.log("");
|
|
954
|
+
}
|
|
955
|
+
if (result.violations.length > 0) {
|
|
956
|
+
console.log(chalk4.red.bold(`\u{1F534} ${result.violations.length} consent violation(s)`));
|
|
957
|
+
for (const v of result.violations) {
|
|
958
|
+
console.log(` ${v.phase === "before-interaction" ? "\u23F1" : "\u{1F534}"} ${v.message}`);
|
|
959
|
+
}
|
|
960
|
+
} else {
|
|
961
|
+
console.log(chalk4.green.bold("\u2713 No consent violations detected"));
|
|
962
|
+
}
|
|
963
|
+
console.log("");
|
|
964
|
+
}
|
|
965
|
+
if (!result.pass) process.exit(1);
|
|
966
|
+
} catch (error) {
|
|
967
|
+
spinner?.fail("Consent check failed");
|
|
968
|
+
if (error instanceof Error) {
|
|
969
|
+
console.error(`
|
|
970
|
+
Error: ${error.message}`);
|
|
971
|
+
}
|
|
972
|
+
process.exit(2);
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
program.command("policy-check").description("Cross-reference privacy policy text against actual detected trackers").argument("<url>", "URL to check").option("-f, --format <format>", "Output format: text, json", "text").option("-t, --timeout <ms>", "Navigation timeout", "30000").option("--policy-url <url>", "Directly specify the privacy policy URL").action(async (url, options) => {
|
|
976
|
+
const normalizedUrl = normalizeUrl(url);
|
|
977
|
+
const format = options.format ?? "text";
|
|
978
|
+
const spinner = format === "text" ? ora(`Analyzing privacy policy for ${normalizedUrl}...`).start() : null;
|
|
979
|
+
try {
|
|
980
|
+
const { checkPolicy } = await import("./policy-checker-J2WPHGU3.js");
|
|
981
|
+
const result = await checkPolicy(normalizedUrl, {
|
|
982
|
+
timeout: parseInt(options.timeout ?? "30000", 10),
|
|
983
|
+
policyUrl: options.policyUrl
|
|
984
|
+
});
|
|
985
|
+
spinner?.stop();
|
|
986
|
+
if (format === "json") {
|
|
987
|
+
console.log(JSON.stringify(result, null, 2));
|
|
988
|
+
} else {
|
|
989
|
+
console.log("");
|
|
990
|
+
console.log(chalk4.bold("ETALON Policy vs. Reality Audit"));
|
|
991
|
+
console.log(chalk4.dim("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"));
|
|
992
|
+
console.log(`URL: ${chalk4.cyan(result.url)}`);
|
|
993
|
+
console.log(`Policy page: ${result.policyFound ? chalk4.green(result.policyUrl) : chalk4.red("\u2717 Not found")}`);
|
|
994
|
+
console.log("");
|
|
995
|
+
console.log(`\u{1F4CB} Vendors mentioned in policy: ${chalk4.bold(String(result.mentionedVendors.length))}`);
|
|
996
|
+
console.log(`\u{1F50D} Vendors detected by scan: ${chalk4.bold(String(result.detectedVendors.length))}`);
|
|
997
|
+
console.log("");
|
|
998
|
+
if (!result.policyFound) {
|
|
999
|
+
console.log(chalk4.red.bold("\u26A0 No privacy policy page found \u2014 all detected trackers are undisclosed"));
|
|
1000
|
+
console.log("");
|
|
1001
|
+
}
|
|
1002
|
+
if (result.undisclosed.length > 0) {
|
|
1003
|
+
console.log(chalk4.red.bold(`\u{1F534} ${result.undisclosed.length} UNDISCLOSED (detected on site, not in policy):`));
|
|
1004
|
+
for (const m of result.undisclosed) {
|
|
1005
|
+
const icon = m.severity === "critical" ? chalk4.red("\u2717 CRITICAL") : m.severity === "high" ? chalk4.yellow("\u2717 HIGH") : m.severity === "medium" ? chalk4.yellow("\u2717 MEDIUM") : chalk4.dim("\u2717 LOW");
|
|
1006
|
+
console.log(` ${icon} ${m.vendorName} \u2014 ${m.message}`);
|
|
1007
|
+
}
|
|
1008
|
+
console.log("");
|
|
1009
|
+
}
|
|
1010
|
+
if (result.disclosed.length > 0) {
|
|
1011
|
+
console.log(chalk4.green.bold(`\u2713 ${result.disclosed.length} DISCLOSED (in both policy and scan):`));
|
|
1012
|
+
for (const m of result.disclosed) {
|
|
1013
|
+
console.log(` ${chalk4.green("\u2713")} ${m.vendorName}`);
|
|
1014
|
+
}
|
|
1015
|
+
console.log("");
|
|
1016
|
+
}
|
|
1017
|
+
if (result.overclaimed.length > 0) {
|
|
1018
|
+
console.log(chalk4.dim(`\u2139 ${result.overclaimed.length} OVERCLAIMED (in policy, not detected):`));
|
|
1019
|
+
for (const m of result.overclaimed) {
|
|
1020
|
+
console.log(` ${chalk4.dim("\u2013")} ${m.vendorName}`);
|
|
1021
|
+
}
|
|
1022
|
+
console.log("");
|
|
1023
|
+
}
|
|
1024
|
+
if (result.pass) {
|
|
1025
|
+
console.log(chalk4.green.bold("\u2713 All detected trackers are disclosed in the privacy policy"));
|
|
1026
|
+
} else {
|
|
1027
|
+
console.log(chalk4.red.bold(`\u2717 ${result.undisclosed.length} tracker(s) not disclosed in privacy policy`));
|
|
1028
|
+
}
|
|
1029
|
+
console.log("");
|
|
1030
|
+
if (result.disclosures.length > 0) {
|
|
1031
|
+
console.log(chalk4.bold.cyan("\u{1F4DD} Add this to your privacy policy:"));
|
|
1032
|
+
console.log(chalk4.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
1033
|
+
for (const d of result.disclosures) {
|
|
1034
|
+
console.log("");
|
|
1035
|
+
console.log(chalk4.bold(` ${d.vendorName}`));
|
|
1036
|
+
console.log(` ${d.snippet}`);
|
|
1037
|
+
if (d.dpaUrl) {
|
|
1038
|
+
console.log(chalk4.dim(` DPA: ${d.dpaUrl}`));
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
console.log("");
|
|
1042
|
+
console.log(chalk4.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
1043
|
+
console.log(chalk4.dim("Copy the text above into your privacy policy or CMS."));
|
|
1044
|
+
console.log("");
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
if (!result.pass) process.exit(1);
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
spinner?.fail("Policy check failed");
|
|
1050
|
+
if (error instanceof Error) {
|
|
1051
|
+
console.error(`
|
|
1052
|
+
Error: ${error.message}`);
|
|
1053
|
+
}
|
|
1054
|
+
process.exit(2);
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
program.command("generate-policy").description("Generate a GDPR privacy policy from code audit + network scan").argument("[dir]", "Project directory to audit", "./").requiredOption("--company <name>", "Your company/organization name").requiredOption("--email <email>", "Privacy contact / DPO email").option("--url <url>", "Also scan a live URL for network trackers").option("--country <country>", 'Jurisdiction (e.g. "EU", "Germany")').option("-o, --output <file>", "Output file", "privacy-policy.md").option("-f, --format <format>", "Output format: md, html, txt", "md").action(async (dir, options) => {
|
|
1058
|
+
const spinner = ora("Generating privacy policy...").start();
|
|
1059
|
+
try {
|
|
1060
|
+
spinner.text = "Running code audit...";
|
|
1061
|
+
const audit = await auditProject(dir);
|
|
1062
|
+
spinner.text = "Analyzing data flows...";
|
|
1063
|
+
const { collectFiles } = await import("@etalon/core");
|
|
1064
|
+
let dataFlow;
|
|
1065
|
+
try {
|
|
1066
|
+
const files = collectFiles?.(dir) ?? [];
|
|
1067
|
+
if (files.length > 0) {
|
|
1068
|
+
dataFlow = analyzeDataFlow(files, dir);
|
|
1069
|
+
}
|
|
1070
|
+
} catch {
|
|
1071
|
+
}
|
|
1072
|
+
let networkVendorIds;
|
|
1073
|
+
if (options.url) {
|
|
1074
|
+
spinner.text = `Scanning ${options.url} for trackers...`;
|
|
1075
|
+
const scanResult = await scanSite(normalizeUrl(options.url), { timeout: 3e4, deep: false });
|
|
1076
|
+
networkVendorIds = new Set(scanResult.vendors.map((v) => v.vendor.id));
|
|
1077
|
+
}
|
|
1078
|
+
spinner.text = "Assembling privacy policy...";
|
|
1079
|
+
const policy = generatePolicy({
|
|
1080
|
+
input: {
|
|
1081
|
+
companyName: options.company,
|
|
1082
|
+
companyEmail: options.email,
|
|
1083
|
+
companyCountry: options.country,
|
|
1084
|
+
siteUrl: options.url,
|
|
1085
|
+
projectDir: dir
|
|
1086
|
+
},
|
|
1087
|
+
audit,
|
|
1088
|
+
networkVendorIds,
|
|
1089
|
+
dataFlow
|
|
1090
|
+
});
|
|
1091
|
+
const outputFile = options.output ?? "privacy-policy.md";
|
|
1092
|
+
writeFileSync2(outputFile, policy.fullText, "utf-8");
|
|
1093
|
+
spinner.succeed(`Privacy policy generated!`);
|
|
1094
|
+
console.log("");
|
|
1095
|
+
console.log(chalk4.bold("ETALON Privacy Policy Generator"));
|
|
1096
|
+
console.log(chalk4.dim("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"));
|
|
1097
|
+
console.log(`Company: ${chalk4.cyan(options.company)}`);
|
|
1098
|
+
console.log(`Contact: ${chalk4.cyan(options.email)}`);
|
|
1099
|
+
if (options.url) {
|
|
1100
|
+
console.log(`Site scanned: ${chalk4.cyan(options.url)}`);
|
|
1101
|
+
}
|
|
1102
|
+
console.log(`Sources: ${chalk4.dim(policy.meta.sources.join(", "))}`);
|
|
1103
|
+
console.log("");
|
|
1104
|
+
console.log(`\u{1F4CB} Sections: ${chalk4.bold(String(policy.sections.length))}`);
|
|
1105
|
+
console.log(`\u{1F50D} Vendors: ${chalk4.bold(String(policy.vendors.length))}`);
|
|
1106
|
+
console.log(`\u{1F6E1}\uFE0F PII types: ${chalk4.bold(String(policy.piiTypes.length))}`);
|
|
1107
|
+
console.log("");
|
|
1108
|
+
if (policy.vendors.length > 0) {
|
|
1109
|
+
console.log(chalk4.dim("Third-party vendors included:"));
|
|
1110
|
+
for (const v of policy.vendors) {
|
|
1111
|
+
const src = v.source === "both" ? chalk4.magenta("code+network") : v.source === "code" ? chalk4.blue("code") : chalk4.green("network");
|
|
1112
|
+
console.log(` ${chalk4.bold(v.vendorName)} ${chalk4.dim(`(${v.category})`)} \u2014 ${src}`);
|
|
1113
|
+
}
|
|
1114
|
+
console.log("");
|
|
1115
|
+
}
|
|
1116
|
+
console.log(chalk4.green(`\u2713 Written to ${chalk4.bold(outputFile)}`));
|
|
1117
|
+
console.log(chalk4.dim("\u26A0 Review with a legal professional before publishing."));
|
|
1118
|
+
console.log("");
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
spinner.fail("Policy generation failed");
|
|
1121
|
+
if (error instanceof Error) {
|
|
1122
|
+
console.error(`
|
|
1123
|
+
Error: ${error.message}`);
|
|
1124
|
+
}
|
|
1125
|
+
process.exit(2);
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
program.command("badge").description("Generate a compliance badge SVG for your README").argument("[dir]", "Directory to audit", "./").option("-o, --output <file>", "Output file", "etalon-badge.svg").action(async (dir, options) => {
|
|
1129
|
+
const spinner = ora("Generating badge...").start();
|
|
1130
|
+
try {
|
|
1131
|
+
const report = await auditProject(dir);
|
|
1132
|
+
const score = report.score ?? calculateScore(report);
|
|
1133
|
+
const svg = generateBadgeSvg(score);
|
|
1134
|
+
writeFileSync2(options.output, svg, "utf-8");
|
|
1135
|
+
spinner.succeed(`Badge written to ${chalk4.cyan(options.output)} \u2014 Grade: ${score.grade} (${score.score}/100)`);
|
|
1136
|
+
console.log("");
|
|
1137
|
+
console.log(chalk4.bold("Shields.io badge for your README:"));
|
|
1138
|
+
console.log(chalk4.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
1139
|
+
const { badgeMarkdown: toBadgeMd } = await import("@etalon/core");
|
|
1140
|
+
console.log(toBadgeMd(score.grade, score.score));
|
|
1141
|
+
console.log("");
|
|
1142
|
+
} catch (error) {
|
|
1143
|
+
spinner.fail("Badge generation failed");
|
|
1144
|
+
if (error instanceof Error) console.error(`
|
|
1145
|
+
Error: ${error.message}`);
|
|
1146
|
+
process.exit(2);
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
program.command("data-flow").description("Map PII data flows through your codebase").argument("[dir]", "Directory to analyze", "./").option("-f, --format <format>", "Output format: text, mermaid, json", "text").action(async (dir, options) => {
|
|
1150
|
+
const spinner = ora("Analyzing data flows...").start();
|
|
1151
|
+
try {
|
|
1152
|
+
let walk2 = function(d) {
|
|
1153
|
+
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
1154
|
+
const full = join3(d, entry.name);
|
|
1155
|
+
if (entry.isDirectory()) {
|
|
1156
|
+
if (!["node_modules", ".git", "dist", "build", ".next", "__pycache__"].includes(entry.name)) {
|
|
1157
|
+
walk2(full);
|
|
1158
|
+
}
|
|
1159
|
+
} else {
|
|
1160
|
+
files.push(relative(dir, full));
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
var walk = walk2;
|
|
1165
|
+
const { readdirSync, statSync } = await import("fs");
|
|
1166
|
+
const { join: join3, relative } = await import("path");
|
|
1167
|
+
const files = [];
|
|
1168
|
+
walk2(dir);
|
|
1169
|
+
const flow = analyzeDataFlow(files, dir);
|
|
1170
|
+
spinner.stop();
|
|
1171
|
+
switch (options.format) {
|
|
1172
|
+
case "json":
|
|
1173
|
+
console.log(JSON.stringify(flow, null, 2));
|
|
1174
|
+
break;
|
|
1175
|
+
case "mermaid":
|
|
1176
|
+
console.log(toMermaid(flow));
|
|
1177
|
+
break;
|
|
1178
|
+
case "text":
|
|
1179
|
+
default:
|
|
1180
|
+
console.log(toTextSummary(flow));
|
|
1181
|
+
break;
|
|
1182
|
+
}
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
spinner.fail("Data flow analysis failed");
|
|
1185
|
+
if (error instanceof Error) console.error(`
|
|
1186
|
+
Error: ${error.message}`);
|
|
1187
|
+
process.exit(2);
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
program.parse();
|