scorm-kit 0.2.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 +21 -0
- package/README.md +109 -0
- package/bin/scorm-kit.js +62 -0
- package/package.json +32 -0
- package/src/a11y/a11y.js +401 -0
- package/src/cmi5/cmi5.js +561 -0
- package/src/confine.js +20 -0
- package/src/diff/diff.js +277 -0
- package/src/i18n/i18n-cli.js +222 -0
- package/src/i18n/runtime/i18n.js +168 -0
- package/src/lint/lint.js +323 -0
- package/src/mock/mock.js +197 -0
- package/src/mock/web/mock-lms.css +57 -0
- package/src/mock/web/mock-lms.html +53 -0
- package/src/mock/web/mock-lms.js +328 -0
- package/src/privacy/privacy.js +535 -0
- package/src/report/report.js +148 -0
- package/src/rum/rum-cli.js +144 -0
- package/src/rum/runtime/rum.js +156 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* scorm-kit privacy — PII / data-leak static auditor for SCORM 1.2 packages.
|
|
4
|
+
*
|
|
5
|
+
* scorm-kit privacy path/to/package.zip
|
|
6
|
+
* scorm-kit privacy path/to/unzipped-dir [--json] [--allow domain1,domain2]
|
|
7
|
+
*
|
|
8
|
+
* Scans every text-like file in a SCORM package (HTML, JS, JSON, CSS, XML) for
|
|
9
|
+
* the data-handling patterns that recur across the GDPR/HIPAA/COPPA audits
|
|
10
|
+
* I've sat through. The goal is not to be a substitute for legal review; it
|
|
11
|
+
* is to catch the boring class of leak before legal review:
|
|
12
|
+
*
|
|
13
|
+
* - hard-coded learner emails or phone numbers shipped inside the package
|
|
14
|
+
* - xAPI actor objects that send plaintext mbox (instead of mbox_sha1sum)
|
|
15
|
+
* - third-party analytics / tracking pixels (Google Analytics, Hotjar,
|
|
16
|
+
* Mixpanel, Segment, Facebook Pixel, LinkedIn Insight, Clarity, etc.)
|
|
17
|
+
* - external form actions that exfiltrate POST bodies offsite
|
|
18
|
+
* - iframes pointing at non-allowlisted domains
|
|
19
|
+
* - accidental API keys / Bearer tokens / signed S3 URLs
|
|
20
|
+
* - PII-shaped sample data left in production (SSN, DOB patterns)
|
|
21
|
+
* - direct echoing of cmi.core.student_name into innerHTML (XSS + PII)
|
|
22
|
+
* - localStorage writes of learner-name / id without consent flag nearby
|
|
23
|
+
*
|
|
24
|
+
* The output is grouped by severity (error / warn / info) and is suitable
|
|
25
|
+
* for CI gating. Exit codes follow the rest of the kit:
|
|
26
|
+
*
|
|
27
|
+
* 0 — clean
|
|
28
|
+
* 1 — warnings only
|
|
29
|
+
* 2 — errors present
|
|
30
|
+
*
|
|
31
|
+
* Allowlist hosts you've already cleared with legal via:
|
|
32
|
+
*
|
|
33
|
+
* --allow scorm.kidvento.com,assets.example.org
|
|
34
|
+
*
|
|
35
|
+
* Rules (id → severity → one-line message):
|
|
36
|
+
*
|
|
37
|
+
* pii-email-literal error email address hard-coded in package
|
|
38
|
+
* pii-phone-literal warn phone-number pattern hard-coded
|
|
39
|
+
* pii-ssn-pattern error US SSN-shaped digit pattern in content
|
|
40
|
+
* pii-dob-pattern warn DOB-shaped pattern (sample data left in?)
|
|
41
|
+
*
|
|
42
|
+
* xapi-actor-mbox-plain error xAPI actor uses mbox: instead of mbox_sha1sum
|
|
43
|
+
* xapi-actor-student-id warn xAPI actor name = cmi.core.student_id (review)
|
|
44
|
+
*
|
|
45
|
+
* scorm-name-into-innerhtml error cmi.core.student_name written into innerHTML
|
|
46
|
+
*
|
|
47
|
+
* tracker-third-party error third-party tracker / pixel detected
|
|
48
|
+
* font-cookie-bearing warn CDN font/script that sets a tracking cookie
|
|
49
|
+
* iframe-external warn iframe src to a non-allowlisted host
|
|
50
|
+
* form-action-external error form action POSTs to a non-allowlisted host
|
|
51
|
+
*
|
|
52
|
+
* secret-bearer-token error "Bearer <token>" literal in source
|
|
53
|
+
* secret-api-key error api_key= or apikey: literal with token-shaped value
|
|
54
|
+
* secret-s3-signed-url warn pre-signed S3/GCS URL embedded as a static asset
|
|
55
|
+
*
|
|
56
|
+
* storage-learner-key warn localStorage write of learner-shaped key
|
|
57
|
+
*
|
|
58
|
+
* Notes:
|
|
59
|
+
* - Pure Node. No deps. Uses the system `unzip` for zip inputs.
|
|
60
|
+
* - Heuristic by design: regex + DOM-light parsing. False positives are
|
|
61
|
+
* possible. False negatives are possible. This is a gate, not a guarantee.
|
|
62
|
+
* - Treat a clean run as "no obvious leaks found" — not "audited".
|
|
63
|
+
*/
|
|
64
|
+
"use strict";
|
|
65
|
+
|
|
66
|
+
var fs = require("fs");
|
|
67
|
+
var path = require("path");
|
|
68
|
+
var os = require("os");
|
|
69
|
+
var { spawnSync } = require("child_process");
|
|
70
|
+
var verifyConfinement = require("../confine");
|
|
71
|
+
|
|
72
|
+
// ---------- rules ----------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
var RULES = {
|
|
75
|
+
"pii-email-literal": { sev: "error", msg: "email address hard-coded inside the package" },
|
|
76
|
+
"pii-phone-literal": { sev: "warn", msg: "phone-number pattern hard-coded" },
|
|
77
|
+
"pii-ssn-pattern": { sev: "error", msg: "US SSN-shaped pattern (###-##-####) in content" },
|
|
78
|
+
"pii-dob-pattern": { sev: "warn", msg: "DOB-shaped pattern (sample data left in production?)" },
|
|
79
|
+
|
|
80
|
+
"xapi-actor-mbox-plain": { sev: "error", msg: "xAPI actor uses plaintext mbox: — prefer mbox_sha1sum:" },
|
|
81
|
+
"xapi-actor-student-id": { sev: "warn", msg: "xAPI actor name set from cmi.core.student_id — confirm pseudonymisation policy" },
|
|
82
|
+
|
|
83
|
+
"scorm-name-into-innerhtml": { sev: "error", msg: "cmi.core.student_name flows into innerHTML — XSS + PII display risk" },
|
|
84
|
+
|
|
85
|
+
"tracker-third-party": { sev: "error", msg: "third-party tracker / analytics pixel detected" },
|
|
86
|
+
"font-cookie-bearing": { sev: "warn", msg: "CDN font or script that sets a tracking cookie (Google Fonts, etc.)" },
|
|
87
|
+
"iframe-external": { sev: "warn", msg: "iframe sources from a non-allowlisted host" },
|
|
88
|
+
"form-action-external": { sev: "error", msg: "<form action=...> POSTs to a non-allowlisted host" },
|
|
89
|
+
|
|
90
|
+
"secret-bearer-token": { sev: "error", msg: "literal 'Bearer <token>' embedded in source" },
|
|
91
|
+
"secret-api-key": { sev: "error", msg: "api_key / apikey literal with token-shaped value" },
|
|
92
|
+
"secret-s3-signed-url": { sev: "warn", msg: "pre-signed S3/GCS URL embedded as a static asset" },
|
|
93
|
+
|
|
94
|
+
"storage-learner-key": { sev: "warn", msg: "localStorage write of learner-shaped key — confirm consent flow" },
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// ---------- pattern tables -------------------------------------------------
|
|
98
|
+
|
|
99
|
+
// Trackers that send identifiable user data offsite. The list is the union
|
|
100
|
+
// of what shows up across compliance audits in 2024-2026; not exhaustive.
|
|
101
|
+
var TRACKER_HOSTS = [
|
|
102
|
+
"google-analytics.com", "googletagmanager.com", "ssl.google-analytics.com",
|
|
103
|
+
"doubleclick.net", "googleadservices.com",
|
|
104
|
+
"facebook.com/tr", "connect.facebook.net",
|
|
105
|
+
"linkedin.com/li/track", "px.ads.linkedin.com",
|
|
106
|
+
"clarity.ms", "c.clarity.ms",
|
|
107
|
+
"hotjar.com", "static.hotjar.com",
|
|
108
|
+
"fullstory.com",
|
|
109
|
+
"mixpanel.com", "cdn.mxpnl.com",
|
|
110
|
+
"segment.io", "api.segment.io", "cdn.segment.com",
|
|
111
|
+
"amplitude.com", "api.amplitude.com",
|
|
112
|
+
"intercom.io", "widget.intercom.io",
|
|
113
|
+
"heap.io", "cdn.heapanalytics.com",
|
|
114
|
+
"matomo.org", "cdn.matomo.cloud",
|
|
115
|
+
"datadoghq-browser-agent.com",
|
|
116
|
+
"newrelic.com/nr-",
|
|
117
|
+
"pendo.io",
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
// CDNs that set cookies for the parent domain (font/script CDNs that
|
|
121
|
+
// commonly trip GDPR even though they "feel" like infrastructure).
|
|
122
|
+
var COOKIE_BEARING_CDNS = [
|
|
123
|
+
"fonts.googleapis.com", "fonts.gstatic.com",
|
|
124
|
+
"ajax.googleapis.com",
|
|
125
|
+
"code.jquery.com",
|
|
126
|
+
"cdnjs.cloudflare.com",
|
|
127
|
+
"stackpath.bootstrapcdn.com",
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
// Email — RFC-5322 lite. Excludes obvious noise (uuid@ patterns, foo@bar
|
|
131
|
+
// where the LHS is all digits, etc.).
|
|
132
|
+
var EMAIL_RE = /\b[A-Za-z][A-Za-z0-9._%+\-]{0,63}@[A-Za-z0-9][A-Za-z0-9.\-]{0,253}\.[A-Za-z]{2,24}\b/g;
|
|
133
|
+
|
|
134
|
+
// Common placeholders that should NOT trigger a finding — example.com,
|
|
135
|
+
// localhost-ish things, schema URIs.
|
|
136
|
+
var EMAIL_PLACEHOLDER_DOMAINS = new Set([
|
|
137
|
+
"example.com", "example.org", "example.net",
|
|
138
|
+
"test.com", "domain.com", "email.com",
|
|
139
|
+
"localhost", "yourdomain.com",
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
// Phones — keep narrow to avoid false-positives on version numbers, IDs.
|
|
143
|
+
// Matches E.164-style ( +CC followed by 7-15 digits ) or NANP "(NXX) NXX-XXXX".
|
|
144
|
+
var PHONE_RE = /(?:\+\d{1,3}[\s\-]?)?(?:\(\d{3}\)[\s\-]?|\d{3}[\s\-])\d{3}[\s\-]\d{4}\b/g;
|
|
145
|
+
var SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/g;
|
|
146
|
+
var DOB_RE = /\b(?:0?[1-9]|1[0-2])\/(?:0?[1-9]|[12]\d|3[01])\/(?:19|20)\d{2}\b/g;
|
|
147
|
+
|
|
148
|
+
// xAPI actor.mbox plaintext — common shapes:
|
|
149
|
+
// "actor": { ..., "mbox": "mailto:..." }
|
|
150
|
+
// actor: { mbox: "mailto:..." }
|
|
151
|
+
var XAPI_MBOX_RE = /["']mbox["']\s*:\s*["']mailto:/g;
|
|
152
|
+
var XAPI_ACTOR_STUDENT_ID_RE = /(["']name["']\s*:\s*[^,}]*cmi\.core\.student_id|cmi\.core\.student_id[^;\n]{0,40}["']name["'])/g;
|
|
153
|
+
|
|
154
|
+
// student_name → innerHTML — same slide, same line ish.
|
|
155
|
+
var STUDENT_NAME_INNERHTML_RE = /(?:cmi\.core\.student_name|SCORM\.get\(["']cmi\.core\.student_name["']\))[^;\n]{0,120}\.innerHTML\s*=|\.innerHTML\s*=[^;\n]{0,120}(?:cmi\.core\.student_name|SCORM\.get\(["']cmi\.core\.student_name["']\))/g;
|
|
156
|
+
|
|
157
|
+
// Bearer token (RFC 6750) — match the token shape, not the prefix alone.
|
|
158
|
+
var BEARER_RE = /\bBearer\s+([A-Za-z0-9_\-\.=]{20,})\b/g;
|
|
159
|
+
var APIKEY_RE = /\b(?:api[_-]?key|apikey|x[_-]?api[_-]?key)["'\s:=]+["']?([A-Za-z0-9_\-]{16,})["']?/gi;
|
|
160
|
+
|
|
161
|
+
// Pre-signed URLs — AWS S3 and GCS shapes. Specific enough that false
|
|
162
|
+
// positives on plain S3 URLs (without signed query) are rare.
|
|
163
|
+
var S3_SIGNED_RE = /https?:\/\/[A-Za-z0-9.\-]+\.amazonaws\.com\/[^\s"'<>]+[?&](?:X-Amz-Signature|AWSAccessKeyId|Signature)=/g;
|
|
164
|
+
var GCS_SIGNED_RE = /https?:\/\/storage\.googleapis\.com\/[^\s"'<>]+[?&](?:GoogleAccessId|Signature|X-Goog-Signature)=/g;
|
|
165
|
+
|
|
166
|
+
// localStorage / sessionStorage writes of learner-shaped keys.
|
|
167
|
+
var STORAGE_LEARNER_RE = /(?:local|session)Storage\.setItem\(\s*["'][^"']*(?:learner|student|user)[^"']*["']/gi;
|
|
168
|
+
|
|
169
|
+
// ---------- args -----------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
function parseArgs(argv) {
|
|
172
|
+
var a = { input: "", json: false, noColor: false, infoOff: false, allow: new Set(), self: null };
|
|
173
|
+
for (var i = 0; i < argv.length; i++) {
|
|
174
|
+
var k = argv[i];
|
|
175
|
+
if (k === "--json") a.json = true;
|
|
176
|
+
else if (k === "--no-color") a.noColor = true;
|
|
177
|
+
else if (k === "--no-info") a.infoOff = true;
|
|
178
|
+
else if (k === "--allow") {
|
|
179
|
+
var raw = argv[++i] || "";
|
|
180
|
+
raw.split(",").map(function (s) { return s.trim().toLowerCase(); }).filter(Boolean).forEach(function (s) { a.allow.add(s); });
|
|
181
|
+
}
|
|
182
|
+
else if (k === "--self") a.self = argv[++i];
|
|
183
|
+
else if (k === "-h" || k === "--help") { usage(); process.exit(0); }
|
|
184
|
+
else if (k[0] === "-") { console.error("Unknown flag: " + k); process.exit(2); }
|
|
185
|
+
else if (!a.input) a.input = k;
|
|
186
|
+
else { console.error("Unexpected arg: " + k); process.exit(2); }
|
|
187
|
+
}
|
|
188
|
+
if (!a.input) { usage(); process.exit(2); }
|
|
189
|
+
return a;
|
|
190
|
+
}
|
|
191
|
+
function usage() {
|
|
192
|
+
console.error([
|
|
193
|
+
"Usage: scorm-kit privacy <package.zip | dir> [options]",
|
|
194
|
+
"",
|
|
195
|
+
"Options:",
|
|
196
|
+
" --json emit findings as JSON",
|
|
197
|
+
" --allow host1,host2,.. allowlist hosts (skip external-iframe/form/tracker checks for them)",
|
|
198
|
+
" --self host treat this host as 'self' (own LMS / CDN) — same as --allow",
|
|
199
|
+
" --no-info suppress info-level findings",
|
|
200
|
+
" --no-color plain output",
|
|
201
|
+
"",
|
|
202
|
+
"Exit codes: 0 = clean, 1 = warnings only, 2 = errors.",
|
|
203
|
+
].join("\n"));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------- zip ------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
function unzipToTemp(zipPath) {
|
|
209
|
+
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), "scorm-privacy-"));
|
|
210
|
+
var r = spawnSync("unzip", ["-q", "-o", zipPath, "-d", tmp]);
|
|
211
|
+
if (r.status !== 0) throw new Error("unzip: " + r.stderr.toString());
|
|
212
|
+
verifyConfinement(tmp);
|
|
213
|
+
return tmp;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isZip(p) {
|
|
217
|
+
if (!fs.existsSync(p)) return false;
|
|
218
|
+
var s = fs.statSync(p);
|
|
219
|
+
if (!s.isFile()) return false;
|
|
220
|
+
if (s.size < 4) return false;
|
|
221
|
+
var fd = fs.openSync(p, "r");
|
|
222
|
+
var buf = Buffer.alloc(4);
|
|
223
|
+
fs.readSync(fd, buf, 0, 4, 0);
|
|
224
|
+
fs.closeSync(fd);
|
|
225
|
+
return buf[0] === 0x50 && buf[1] === 0x4B;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------- walk -----------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
var TEXT_EXTS = new Set([".html", ".htm", ".js", ".mjs", ".cjs", ".json", ".css", ".xml", ".svg", ".txt"]);
|
|
231
|
+
|
|
232
|
+
function walk(dir, acc) {
|
|
233
|
+
acc = acc || [];
|
|
234
|
+
for (var name of fs.readdirSync(dir)) {
|
|
235
|
+
var p = path.join(dir, name);
|
|
236
|
+
var st = fs.statSync(p);
|
|
237
|
+
if (st.isDirectory()) walk(p, acc);
|
|
238
|
+
else acc.push(p);
|
|
239
|
+
}
|
|
240
|
+
return acc;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------- finding -------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
function find(rule, file, line, detail) {
|
|
246
|
+
return { rule: rule, sev: RULES[rule].sev, msg: RULES[rule].msg, file: file, line: line, detail: detail || "" };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function lineOf(text, idx) {
|
|
250
|
+
var n = 1;
|
|
251
|
+
for (var i = 0; i < idx && i < text.length; i++) if (text[i] === "\n") n++;
|
|
252
|
+
return n;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------- host helpers ---------------------------------------------------
|
|
256
|
+
|
|
257
|
+
function hostFromUrl(url) {
|
|
258
|
+
var m = /^(?:https?:)?\/\/([^\/\?\#"']+)/i.exec(url);
|
|
259
|
+
if (!m) return null;
|
|
260
|
+
return m[1].toLowerCase().split(":")[0];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isAllowlisted(host, allow) {
|
|
264
|
+
if (!host) return true;
|
|
265
|
+
host = host.toLowerCase();
|
|
266
|
+
for (var h of allow) {
|
|
267
|
+
if (host === h || host.endsWith("." + h)) return true;
|
|
268
|
+
}
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function hostIsTracker(host) {
|
|
273
|
+
if (!host) return null;
|
|
274
|
+
host = host.toLowerCase();
|
|
275
|
+
for (var t of TRACKER_HOSTS) {
|
|
276
|
+
// tracker entries may include path segments — treat substring match as OK
|
|
277
|
+
if (t.indexOf("/") >= 0) {
|
|
278
|
+
// path-bearing entry: just check the host portion
|
|
279
|
+
var th = t.split("/")[0];
|
|
280
|
+
if (host === th || host.endsWith("." + th)) return t;
|
|
281
|
+
} else {
|
|
282
|
+
if (host === t || host.endsWith("." + t)) return t;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function hostIsCookieBearing(host) {
|
|
289
|
+
if (!host) return null;
|
|
290
|
+
host = host.toLowerCase();
|
|
291
|
+
for (var c of COOKIE_BEARING_CDNS) {
|
|
292
|
+
if (host === c || host.endsWith("." + c)) return c;
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------- audit one file -------------------------------------------------
|
|
298
|
+
|
|
299
|
+
function auditFile(absPath, relPath, text, args) {
|
|
300
|
+
var findings = [];
|
|
301
|
+
var ext = path.extname(absPath).toLowerCase();
|
|
302
|
+
var isHtml = ext === ".html" || ext === ".htm" || ext === ".svg";
|
|
303
|
+
var isManifest = path.basename(absPath).toLowerCase() === "imsmanifest.xml";
|
|
304
|
+
|
|
305
|
+
// 1. email literals — skip placeholder domains and obvious noise
|
|
306
|
+
var m;
|
|
307
|
+
EMAIL_RE.lastIndex = 0;
|
|
308
|
+
while ((m = EMAIL_RE.exec(text)) !== null) {
|
|
309
|
+
var addr = m[0];
|
|
310
|
+
var domain = addr.split("@")[1].toLowerCase();
|
|
311
|
+
if (EMAIL_PLACEHOLDER_DOMAINS.has(domain)) continue;
|
|
312
|
+
// skip the package author block in imsmanifest / metadata files —
|
|
313
|
+
// a contact email there is expected, not a leak.
|
|
314
|
+
if (isManifest) continue;
|
|
315
|
+
findings.push(find("pii-email-literal", relPath, lineOf(text, m.index), addr));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 2. phone literals
|
|
319
|
+
PHONE_RE.lastIndex = 0;
|
|
320
|
+
while ((m = PHONE_RE.exec(text)) !== null) {
|
|
321
|
+
findings.push(find("pii-phone-literal", relPath, lineOf(text, m.index), m[0]));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 3. SSN-shape
|
|
325
|
+
SSN_RE.lastIndex = 0;
|
|
326
|
+
while ((m = SSN_RE.exec(text)) !== null) {
|
|
327
|
+
findings.push(find("pii-ssn-pattern", relPath, lineOf(text, m.index), m[0]));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 4. DOB-shape — heuristic. Skip JSON copyright years and obvious dates
|
|
331
|
+
// in CSS (background-position: 12/04/1999). Keep this warn-level.
|
|
332
|
+
DOB_RE.lastIndex = 0;
|
|
333
|
+
while ((m = DOB_RE.exec(text)) !== null) {
|
|
334
|
+
findings.push(find("pii-dob-pattern", relPath, lineOf(text, m.index), m[0]));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 5. xAPI actor mbox plaintext
|
|
338
|
+
XAPI_MBOX_RE.lastIndex = 0;
|
|
339
|
+
while ((m = XAPI_MBOX_RE.exec(text)) !== null) {
|
|
340
|
+
findings.push(find("xapi-actor-mbox-plain", relPath, lineOf(text, m.index)));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 6. xAPI actor name = student_id
|
|
344
|
+
XAPI_ACTOR_STUDENT_ID_RE.lastIndex = 0;
|
|
345
|
+
while ((m = XAPI_ACTOR_STUDENT_ID_RE.exec(text)) !== null) {
|
|
346
|
+
findings.push(find("xapi-actor-student-id", relPath, lineOf(text, m.index)));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 7. student_name → innerHTML
|
|
350
|
+
STUDENT_NAME_INNERHTML_RE.lastIndex = 0;
|
|
351
|
+
while ((m = STUDENT_NAME_INNERHTML_RE.exec(text)) !== null) {
|
|
352
|
+
findings.push(find("scorm-name-into-innerhtml", relPath, lineOf(text, m.index)));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 8 + 9. Third-party trackers / cookie-bearing CDNs (URLs across all file types)
|
|
356
|
+
var urlRe = /https?:\/\/[A-Za-z0-9.\-]+(?:\/[^\s"'<>]*)?/g;
|
|
357
|
+
urlRe.lastIndex = 0;
|
|
358
|
+
while ((m = urlRe.exec(text)) !== null) {
|
|
359
|
+
var url = m[0];
|
|
360
|
+
var host = hostFromUrl(url);
|
|
361
|
+
if (!host) continue;
|
|
362
|
+
if (isAllowlisted(host, args.allow)) continue;
|
|
363
|
+
|
|
364
|
+
var tracker = hostIsTracker(host);
|
|
365
|
+
if (tracker) {
|
|
366
|
+
findings.push(find("tracker-third-party", relPath, lineOf(text, m.index), host));
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
var cookieCdn = hostIsCookieBearing(host);
|
|
370
|
+
if (cookieCdn) {
|
|
371
|
+
findings.push(find("font-cookie-bearing", relPath, lineOf(text, m.index), host));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 10. iframe external — HTML only
|
|
376
|
+
if (isHtml) {
|
|
377
|
+
var iframeRe = /<iframe\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi;
|
|
378
|
+
iframeRe.lastIndex = 0;
|
|
379
|
+
while ((m = iframeRe.exec(text)) !== null) {
|
|
380
|
+
var ihost = hostFromUrl(m[1]);
|
|
381
|
+
if (ihost && !isAllowlisted(ihost, args.allow)) {
|
|
382
|
+
findings.push(find("iframe-external", relPath, lineOf(text, m.index), ihost));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// 11. form action external — HTML only
|
|
388
|
+
if (isHtml) {
|
|
389
|
+
var formRe = /<form\b[^>]*\baction\s*=\s*["']([^"']+)["']/gi;
|
|
390
|
+
formRe.lastIndex = 0;
|
|
391
|
+
while ((m = formRe.exec(text)) !== null) {
|
|
392
|
+
var fhost = hostFromUrl(m[1]);
|
|
393
|
+
if (fhost && !isAllowlisted(fhost, args.allow)) {
|
|
394
|
+
findings.push(find("form-action-external", relPath, lineOf(text, m.index), fhost));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// 12. Bearer tokens
|
|
400
|
+
BEARER_RE.lastIndex = 0;
|
|
401
|
+
while ((m = BEARER_RE.exec(text)) !== null) {
|
|
402
|
+
findings.push(find("secret-bearer-token", relPath, lineOf(text, m.index), "Bearer " + m[1].slice(0, 8) + "…"));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// 13. api_key= literals — skip obvious template placeholders
|
|
406
|
+
APIKEY_RE.lastIndex = 0;
|
|
407
|
+
while ((m = APIKEY_RE.exec(text)) !== null) {
|
|
408
|
+
var val = m[1];
|
|
409
|
+
if (/^(YOUR|REPLACE|XXX|PLACEHOLDER|\$\{)/i.test(val)) continue;
|
|
410
|
+
if (val === val.toLowerCase().replace(/[^a-z]/g, "")) continue; // all-letters → likely a variable name
|
|
411
|
+
findings.push(find("secret-api-key", relPath, lineOf(text, m.index), val.slice(0, 8) + "…"));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 14. signed S3/GCS URLs
|
|
415
|
+
S3_SIGNED_RE.lastIndex = 0;
|
|
416
|
+
while ((m = S3_SIGNED_RE.exec(text)) !== null) {
|
|
417
|
+
findings.push(find("secret-s3-signed-url", relPath, lineOf(text, m.index), hostFromUrl(m[0])));
|
|
418
|
+
}
|
|
419
|
+
GCS_SIGNED_RE.lastIndex = 0;
|
|
420
|
+
while ((m = GCS_SIGNED_RE.exec(text)) !== null) {
|
|
421
|
+
findings.push(find("secret-s3-signed-url", relPath, lineOf(text, m.index), hostFromUrl(m[0])));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// 15. learner-shaped localStorage writes
|
|
425
|
+
STORAGE_LEARNER_RE.lastIndex = 0;
|
|
426
|
+
while ((m = STORAGE_LEARNER_RE.exec(text)) !== null) {
|
|
427
|
+
findings.push(find("storage-learner-key", relPath, lineOf(text, m.index)));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return findings;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------- audit a package -----------------------------------------------
|
|
434
|
+
|
|
435
|
+
function auditPackage(root, args) {
|
|
436
|
+
var files = walk(root);
|
|
437
|
+
var all = [];
|
|
438
|
+
for (var f of files) {
|
|
439
|
+
var ext = path.extname(f).toLowerCase();
|
|
440
|
+
if (!TEXT_EXTS.has(ext)) continue;
|
|
441
|
+
var rel = path.relative(root, f);
|
|
442
|
+
var text;
|
|
443
|
+
try { text = fs.readFileSync(f, "utf8"); } catch (e) { continue; }
|
|
444
|
+
var found = auditFile(f, rel, text, args);
|
|
445
|
+
if (found.length) all = all.concat(found);
|
|
446
|
+
}
|
|
447
|
+
return all;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---------- output ---------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
function colorize(s, code, on) {
|
|
453
|
+
return on ? "\x1b[" + code + "m" + s + "\x1b[0m" : s;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function reportText(findings, args) {
|
|
457
|
+
var on = !args.noColor && process.stdout.isTTY;
|
|
458
|
+
var counts = { error: 0, warn: 0, info: 0 };
|
|
459
|
+
var byFile = {};
|
|
460
|
+
for (var f of findings) {
|
|
461
|
+
if (args.infoOff && f.sev === "info") continue;
|
|
462
|
+
counts[f.sev] = (counts[f.sev] || 0) + 1;
|
|
463
|
+
(byFile[f.file] = byFile[f.file] || []).push(f);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (findings.length === 0) {
|
|
467
|
+
console.log(colorize("✓ no privacy findings", "32", on));
|
|
468
|
+
return 0;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
Object.keys(byFile).sort().forEach(function (file) {
|
|
472
|
+
console.log("");
|
|
473
|
+
console.log(colorize(file, "1", on));
|
|
474
|
+
byFile[file].sort(function (a, b) { return a.line - b.line; }).forEach(function (f) {
|
|
475
|
+
var tag = f.sev === "error" ? colorize("error", "31", on)
|
|
476
|
+
: f.sev === "warn" ? colorize("warn", "33", on)
|
|
477
|
+
: colorize("info", "36", on);
|
|
478
|
+
var line = " " + tag + " " + f.rule.padEnd(28) + " line " + f.line + " " + f.msg;
|
|
479
|
+
if (f.detail) line += " " + colorize("[" + f.detail + "]", "2", on);
|
|
480
|
+
console.log(line);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
console.log("");
|
|
485
|
+
console.log(
|
|
486
|
+
counts.error + " error" + (counts.error === 1 ? "" : "s") + ", " +
|
|
487
|
+
counts.warn + " warning" + (counts.warn === 1 ? "" : "s") +
|
|
488
|
+
(args.infoOff ? "" : ", " + (counts.info || 0) + " info")
|
|
489
|
+
);
|
|
490
|
+
return counts.error > 0 ? 2 : (counts.warn > 0 ? 1 : 0);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function reportJson(findings, args) {
|
|
494
|
+
var filtered = args.infoOff ? findings.filter(function (f) { return f.sev !== "info"; }) : findings;
|
|
495
|
+
var errors = filtered.filter(function (f) { return f.sev === "error"; }).length;
|
|
496
|
+
var warns = filtered.filter(function (f) { return f.sev === "warn"; }).length;
|
|
497
|
+
process.stdout.write(JSON.stringify({
|
|
498
|
+
ok: errors === 0 && warns === 0,
|
|
499
|
+
counts: { error: errors, warn: warns, info: filtered.length - errors - warns },
|
|
500
|
+
findings: filtered,
|
|
501
|
+
}, null, 2) + "\n");
|
|
502
|
+
return errors > 0 ? 2 : (warns > 0 ? 1 : 0);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ---------- main -----------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
function main(argv) {
|
|
508
|
+
var args = parseArgs(argv.slice(2));
|
|
509
|
+
if (args.self) args.allow.add(args.self.toLowerCase());
|
|
510
|
+
|
|
511
|
+
var root, cleanup = null;
|
|
512
|
+
if (isZip(args.input)) {
|
|
513
|
+
root = unzipToTemp(args.input);
|
|
514
|
+
cleanup = function () { spawnSync("rm", ["-rf", root]); };
|
|
515
|
+
} else if (fs.existsSync(args.input) && fs.statSync(args.input).isDirectory()) {
|
|
516
|
+
root = args.input;
|
|
517
|
+
} else {
|
|
518
|
+
console.error("Not a zip file or directory: " + args.input);
|
|
519
|
+
process.exit(2);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
var findings = auditPackage(root, args);
|
|
524
|
+
var code = args.json ? reportJson(findings, args) : reportText(findings, args);
|
|
525
|
+
if (cleanup) cleanup();
|
|
526
|
+
process.exit(code);
|
|
527
|
+
} catch (e) {
|
|
528
|
+
if (cleanup) cleanup();
|
|
529
|
+
console.error("scorm-kit privacy: " + (e && e.message ? e.message : String(e)));
|
|
530
|
+
process.exit(2);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (require.main === module) main(process.argv);
|
|
535
|
+
module.exports = { auditFile: auditFile, auditPackage: auditPackage, RULES: RULES };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* scorm-kit report — one pre-upload health gate.
|
|
6
|
+
*
|
|
7
|
+
* scorm-kit report course.zip [--json]
|
|
8
|
+
*
|
|
9
|
+
* Runs the three static gates (lint, a11y, privacy) in --json mode, aggregates
|
|
10
|
+
* their findings into a single Build Health score, and frames the result the
|
|
11
|
+
* way it actually matters: every issue caught here is one a learner would
|
|
12
|
+
* otherwise hit in production two weeks later. Pure composition — it shells out
|
|
13
|
+
* to the existing subcommands and adds no new analysis of its own.
|
|
14
|
+
*
|
|
15
|
+
* Exit codes follow the suite convention: 0 clean, 1 warnings only, 2 errors.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
var path = require("path");
|
|
19
|
+
var { spawnSync } = require("child_process");
|
|
20
|
+
|
|
21
|
+
// Each gate is run as its own process, exactly like the dispatcher does.
|
|
22
|
+
var GATES = [
|
|
23
|
+
{ name: "lint", script: "../lint/lint.js" },
|
|
24
|
+
{ name: "a11y", script: "../a11y/a11y.js" },
|
|
25
|
+
{ name: "privacy", script: "../privacy/privacy.js" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// Score model — deliberately simple and explainable:
|
|
29
|
+
// each error costs 10, each warning costs 3, floored at 0.
|
|
30
|
+
var ERROR_COST = 10;
|
|
31
|
+
var WARN_COST = 3;
|
|
32
|
+
|
|
33
|
+
function verdict(score) {
|
|
34
|
+
if (score >= 90) return "ship-ready";
|
|
35
|
+
if (score >= 70) return "minor issues";
|
|
36
|
+
if (score >= 40) return "needs work";
|
|
37
|
+
return "blocked";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Run one gate, return { error, warn, info, failed } counts.
|
|
41
|
+
function runGate(gate, pkg) {
|
|
42
|
+
var script = path.resolve(__dirname, gate.script);
|
|
43
|
+
var res = spawnSync(process.execPath, [script, pkg, "--json"], { encoding: "utf8", timeout: 30000 });
|
|
44
|
+
var counts = { error: 0, warn: 0, info: 0, failed: false };
|
|
45
|
+
var data;
|
|
46
|
+
try {
|
|
47
|
+
data = JSON.parse(res.stdout);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
// Gate crashed or printed non-JSON — surface it instead of silently passing.
|
|
50
|
+
counts.failed = true;
|
|
51
|
+
counts.error = 1;
|
|
52
|
+
counts.message = (res.stderr || "").trim().split("\n").pop() || "gate did not return JSON";
|
|
53
|
+
return counts;
|
|
54
|
+
}
|
|
55
|
+
if (data.counts) {
|
|
56
|
+
counts.error = data.counts.error || 0;
|
|
57
|
+
counts.warn = data.counts.warn || 0;
|
|
58
|
+
counts.info = data.counts.info || 0;
|
|
59
|
+
} else {
|
|
60
|
+
// a11y emits findings without a counts block — derive from finding severities.
|
|
61
|
+
(data.findings || []).forEach(function (f) {
|
|
62
|
+
if (f.sev === "error") counts.error++;
|
|
63
|
+
else if (f.sev === "warn") counts.warn++;
|
|
64
|
+
else counts.info++;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return counts;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseArgs(argv) {
|
|
71
|
+
var args = { json: false, input: null, help: false };
|
|
72
|
+
argv.forEach(function (a) {
|
|
73
|
+
if (a === "--json") args.json = true;
|
|
74
|
+
else if (a === "-h" || a === "--help") args.help = true;
|
|
75
|
+
else if (!args.input) args.input = a;
|
|
76
|
+
});
|
|
77
|
+
return args;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
var HELP = [
|
|
81
|
+
"scorm-kit report — one pre-upload health gate (lint + a11y + privacy)",
|
|
82
|
+
"",
|
|
83
|
+
"Usage: scorm-kit report <package.zip|dir> [--json]",
|
|
84
|
+
"",
|
|
85
|
+
"Aggregates the three static gates into a single Build Health score (0-100)",
|
|
86
|
+
"and reports how many issues were caught before upload.",
|
|
87
|
+
"",
|
|
88
|
+
"Exit codes: 0 clean, 1 warnings only, 2 errors.",
|
|
89
|
+
].join("\n");
|
|
90
|
+
|
|
91
|
+
function main(argv) {
|
|
92
|
+
var args = parseArgs(argv.slice(2));
|
|
93
|
+
if (args.help) { console.log(HELP); process.exit(0); }
|
|
94
|
+
if (!args.input) { console.error("scorm-kit report: missing package.\nRun `scorm-kit report --help`."); process.exit(2); }
|
|
95
|
+
|
|
96
|
+
var passes = {};
|
|
97
|
+
var total = { error: 0, warn: 0, info: 0 };
|
|
98
|
+
GATES.forEach(function (gate) {
|
|
99
|
+
var c = runGate(gate, args.input);
|
|
100
|
+
passes[gate.name] = c;
|
|
101
|
+
total.error += c.error;
|
|
102
|
+
total.warn += c.warn;
|
|
103
|
+
total.info += c.info;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
var score = Math.max(0, 100 - ERROR_COST * total.error - WARN_COST * total.warn);
|
|
107
|
+
var caught = total.error + total.warn;
|
|
108
|
+
var code = total.error > 0 ? 2 : (total.warn > 0 ? 1 : 0);
|
|
109
|
+
|
|
110
|
+
if (args.json) {
|
|
111
|
+
process.stdout.write(JSON.stringify({
|
|
112
|
+
package: args.input,
|
|
113
|
+
score: score,
|
|
114
|
+
verdict: verdict(score),
|
|
115
|
+
caught: caught,
|
|
116
|
+
totals: total,
|
|
117
|
+
passes: passes,
|
|
118
|
+
}, null, 2) + "\n");
|
|
119
|
+
process.exit(code);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log("");
|
|
123
|
+
console.log("scorm-kit report — " + path.basename(args.input));
|
|
124
|
+
console.log("");
|
|
125
|
+
GATES.forEach(function (gate) {
|
|
126
|
+
var c = passes[gate.name];
|
|
127
|
+
var mark = c.error > 0 ? "✗" : (c.warn > 0 ? "!" : "✓");
|
|
128
|
+
var line = " " + (gate.name + " ").slice(0, 8) + mark + " " +
|
|
129
|
+
c.error + " error" + (c.error === 1 ? "" : "s") + " " +
|
|
130
|
+
c.warn + " warning" + (c.warn === 1 ? "" : "s");
|
|
131
|
+
if (c.failed) line += " (gate failed: " + (c.message || "unknown") + ")";
|
|
132
|
+
console.log(line);
|
|
133
|
+
});
|
|
134
|
+
console.log("");
|
|
135
|
+
console.log(" Build health: " + score + "/100 (" + verdict(score) + ")");
|
|
136
|
+
if (caught > 0) {
|
|
137
|
+
console.log(" " + caught + " issue" + (caught === 1 ? "" : "s") +
|
|
138
|
+
" caught before upload — each one a learner ticket you won't get in two weeks.");
|
|
139
|
+
console.log(" Run a gate directly (e.g. `scorm-kit a11y " + path.basename(args.input) + "`) for line-level detail.");
|
|
140
|
+
} else {
|
|
141
|
+
console.log(" No issues across lint, a11y, or privacy. Ship it.");
|
|
142
|
+
}
|
|
143
|
+
console.log("");
|
|
144
|
+
process.exit(code);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (require.main === module) main(process.argv);
|
|
148
|
+
module.exports = { verdict: verdict, runGate: runGate };
|