pqcheck 0.7.8 → 0.7.9
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 +4 -0
- package/bin/pqcheck.js +137 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,6 +12,10 @@ The same scanner that powers [quantapact.com](https://quantapact.com), the brows
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
+
## What's new in 0.7.9
|
|
16
|
+
|
|
17
|
+
**CSP verdict + vendor labels on `pqcheck deps`.** The supply-chain table now shows a friendly vendor label (`New Relic · errors` / `Cloudflare · cdn` / `Adobe Fonts · fonts`) per host instead of raw `bam.nr-data.net`-style hostnames, plus a one-line site-wide CSP verdict above the table (`✗ No CSP enforcement` / `⚠ CSP is permissive` / `✓ Strict CSP enforced`). Same data shape ships on `/r/<domain>` and in the browser extension — cross-surface parity for the supply-chain story. See [CHANGELOG.md](./CHANGELOG.md).
|
|
18
|
+
|
|
15
19
|
## What's new in 0.7.8
|
|
16
20
|
|
|
17
21
|
**Supply-chain change detection in CI** — `pqcheck deps <domain> --baseline file.json` compares the current third-party host list to a stored baseline. New hosts since the last accepted state are flagged `*NEW*` in the pretty table and `"isNew": true` in JSON. Add `--fail-on-new` to exit `4` if anything new appeared — the Polyfill.io-style CI gate that fails PRs introducing third-party scripts until you deliberately accept them with `--write-baseline`. Each row also shows an `SRI` column (on/off/n/a) so you can see which scripts allow silent vendor-side content swaps. See [CHANGELOG.md](./CHANGELOG.md).
|
package/bin/pqcheck.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// =============================================================================
|
|
8
8
|
|
|
9
9
|
const API_BASE = process.env.PQCHECK_API_BASE || "https://quantapact.com";
|
|
10
|
-
const VERSION = "0.7.
|
|
10
|
+
const VERSION = "0.7.9";
|
|
11
11
|
|
|
12
12
|
const ANSI = {
|
|
13
13
|
reset: "\x1b[0m",
|
|
@@ -932,12 +932,15 @@ async function runDepsCommand(args) {
|
|
|
932
932
|
}
|
|
933
933
|
|
|
934
934
|
if (!json) process.stderr.write(color("dim", `Fetching ${domain} HTML...`));
|
|
935
|
-
const
|
|
935
|
+
const fetched = await fetchPageHTML(domain);
|
|
936
936
|
if (!json) process.stderr.write("\r\x1b[K");
|
|
937
|
-
if (!
|
|
937
|
+
if (!fetched) {
|
|
938
938
|
console.error(color("red", `error: could not fetch https://${domain}/`));
|
|
939
939
|
process.exit(1);
|
|
940
940
|
}
|
|
941
|
+
const { html, headerCsp } = fetched;
|
|
942
|
+
const metaCsp = extractMetaCsp(html);
|
|
943
|
+
const cspVerdict = classifyCsp(headerCsp, metaCsp);
|
|
941
944
|
|
|
942
945
|
const refs = extractThirdPartyRefs(html, domain);
|
|
943
946
|
if (refs.length === 0) {
|
|
@@ -1032,12 +1035,16 @@ async function runDepsCommand(args) {
|
|
|
1032
1035
|
// Build manifest
|
|
1033
1036
|
const manifest = {
|
|
1034
1037
|
$schema: "https://quantapact.com/schemas/deps/v1",
|
|
1035
|
-
schemaVersion: "1.
|
|
1038
|
+
schemaVersion: "1.2", // bumped for CSP + vendor classification fields
|
|
1036
1039
|
domain,
|
|
1037
1040
|
scannedAt: new Date().toISOString(),
|
|
1038
1041
|
tool: "pqcheck-cli",
|
|
1039
1042
|
toolVersion: VERSION,
|
|
1040
1043
|
summary,
|
|
1044
|
+
csp: {
|
|
1045
|
+
quality: cspVerdict.quality, // "absent" | "weak" | "strict"
|
|
1046
|
+
source: cspVerdict.source, // "header" | "meta" | null
|
|
1047
|
+
},
|
|
1041
1048
|
baseline: baselineHosts ? {
|
|
1042
1049
|
file: baselinePath,
|
|
1043
1050
|
newHosts,
|
|
@@ -1049,6 +1056,7 @@ async function runDepsCommand(args) {
|
|
|
1049
1056
|
types: r.types,
|
|
1050
1057
|
occurrences: r.occurrences,
|
|
1051
1058
|
sri: { allScriptsHaveSri: !r.anyMissingSri, allHttps: r.allHttps },
|
|
1059
|
+
vendor: classifyVendor(r.host), // { name, category } or null
|
|
1052
1060
|
isNew: r.isNew || false,
|
|
1053
1061
|
scan: r.scan,
|
|
1054
1062
|
error: r.error,
|
|
@@ -1101,6 +1109,15 @@ async function runDepsCommand(args) {
|
|
|
1101
1109
|
console.log("");
|
|
1102
1110
|
console.log(` ${color("bold", "Supply-chain HNDL exposure")} for ${color("violet", domain)}`);
|
|
1103
1111
|
console.log(` ${color("dim", `${summary.uniqueOrigins} unique third-party origins · ${summary.totalReferences} references · weakest: ${summary.weakestLink?.host ?? "—"} (${summary.weakestLink?.grade ?? "—"})`)}`);
|
|
1112
|
+
// CSP-quality summary line — single line, site-wide. Mirrors the banner the
|
|
1113
|
+
// extension shows on the Supply Chain tab.
|
|
1114
|
+
if (cspVerdict.quality === "absent") {
|
|
1115
|
+
console.log(` ${color("red", "✗ No CSP enforcement")} ${color("dim", "— vendor swaps go undetected by the browser")}`);
|
|
1116
|
+
} else if (cspVerdict.quality === "weak") {
|
|
1117
|
+
console.log(` ${color("yellow", "⚠ CSP is permissive")} ${color("dim", `— uses unsafe-inline / wildcards / data: (${cspVerdict.source})`)}`);
|
|
1118
|
+
} else if (cspVerdict.quality === "strict") {
|
|
1119
|
+
console.log(` ${color("green", "✓ Strict CSP enforced")} ${color("dim", `(${cspVerdict.source})`)}`);
|
|
1120
|
+
}
|
|
1104
1121
|
if (baselineHosts) {
|
|
1105
1122
|
const baselineSize = baselineHosts.size;
|
|
1106
1123
|
if (baselineSize === 0) {
|
|
@@ -1113,19 +1130,24 @@ async function runDepsCommand(args) {
|
|
|
1113
1130
|
}
|
|
1114
1131
|
}
|
|
1115
1132
|
console.log("");
|
|
1116
|
-
console.log(` ${color("dim", "GRADE HOST PQC SRI TYPES")}`);
|
|
1117
|
-
console.log(` ${color("dim", "───── ───────────────────────────────────────── ─── ─── ─────")}`);
|
|
1133
|
+
console.log(` ${color("dim", "GRADE HOST VENDOR (CATEGORY) PQC SRI TYPES")}`);
|
|
1134
|
+
console.log(` ${color("dim", "───── ───────────────────────────────────────── ────────────────────── ─── ─── ─────")}`);
|
|
1118
1135
|
for (const r of results) {
|
|
1119
1136
|
const gradeStr = r.scan?.grade ?? "?";
|
|
1120
1137
|
const gradeColored = gradeStr === "A" ? color("green", gradeStr) : gradeStr === "F" || gradeStr === "D" ? color("red", gradeStr) : color("yellow", gradeStr);
|
|
1121
|
-
const isNewMark = r.isNew ? color("yellow", " *NEW*") : "";
|
|
1122
1138
|
const hostRaw = r.host + (r.isNew ? " *NEW*" : "");
|
|
1123
1139
|
const host = hostRaw.length > 41 ? hostRaw.slice(0, 40) + "…" : (r.host.padEnd(r.isNew ? 35 : 41, " ") + (r.isNew ? color("yellow", " *NEW*") : ""));
|
|
1124
1140
|
const pqc = r.scan?.hybridPQC ? color("green", "yes") : color("dim", "no ");
|
|
1125
1141
|
const hasScript = (r.types || []).includes("script");
|
|
1126
1142
|
const sriCell = !hasScript ? color("dim", "n/a") : r.anyMissingSri ? color("yellow", "off") : color("green", "on ");
|
|
1127
1143
|
const types = r.types.join(",");
|
|
1128
|
-
|
|
1144
|
+
// Vendor classification — "(New Relic · errors)" beats "bam.nr-data.net"
|
|
1145
|
+
// for comprehension. Padded to 22 chars so the table columns stay aligned.
|
|
1146
|
+
const vendor = classifyVendor(r.host);
|
|
1147
|
+
const vendorStrRaw = vendor ? `${vendor.name} (${vendor.category})` : "—";
|
|
1148
|
+
const vendorTruncated = vendorStrRaw.length > 22 ? vendorStrRaw.slice(0, 21) + "…" : vendorStrRaw.padEnd(22, " ");
|
|
1149
|
+
const vendorColored = vendor ? color("dim", vendorTruncated) : color("dim", vendorTruncated);
|
|
1150
|
+
console.log(` ${gradeColored.padEnd(8, " ")} ${host} ${vendorColored} ${pqc} ${sriCell} ${color("dim", types)}`);
|
|
1129
1151
|
}
|
|
1130
1152
|
console.log("");
|
|
1131
1153
|
if (baselineHosts && baselineHosts.size > 0 && newHosts.length > 0) {
|
|
@@ -1181,12 +1203,118 @@ async function fetchPageHTML(domain) {
|
|
|
1181
1203
|
});
|
|
1182
1204
|
clearTimeout(t);
|
|
1183
1205
|
if (!resp.ok) return null;
|
|
1184
|
-
|
|
1206
|
+
const html = await resp.text();
|
|
1207
|
+
// Also capture the CSP header so the deps view can show the site-wide
|
|
1208
|
+
// enforcement level. Report-only header is ignored (not enforced).
|
|
1209
|
+
const headerCsp = resp.headers.get("content-security-policy") || "";
|
|
1210
|
+
return { html, headerCsp };
|
|
1185
1211
|
} catch {
|
|
1186
1212
|
return null;
|
|
1187
1213
|
}
|
|
1188
1214
|
}
|
|
1189
1215
|
|
|
1216
|
+
// Scrape <meta http-equiv="Content-Security-Policy" content="..."> from HTML
|
|
1217
|
+
// as a fallback when the response header is absent.
|
|
1218
|
+
function extractMetaCsp(html) {
|
|
1219
|
+
const re = /<meta[^>]*\bhttp-equiv\s*=\s*["']?content-security-policy["']?[^>]*\bcontent\s*=\s*["']([^"']+)["']/i;
|
|
1220
|
+
const m = html.match(re);
|
|
1221
|
+
return m ? m[1] : "";
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Site-level CSP-quality classifier. Mirrors lib/serviceCatalog.ts and the
|
|
1225
|
+
// extension's classifyCsp(). Three buckets: absent / weak / strict.
|
|
1226
|
+
function classifyCsp(headerCsp, metaCsp) {
|
|
1227
|
+
const policy = ((headerCsp || "").trim() || (metaCsp || "").trim()).toLowerCase();
|
|
1228
|
+
if (!policy) return { quality: "absent", source: null, raw: "" };
|
|
1229
|
+
const weakSignals = ["'unsafe-inline'", "'unsafe-eval'", "'unsafe-hashes'", " * ", " *;", "data:", "blob:"];
|
|
1230
|
+
const weak = weakSignals.some((sig) => policy.includes(sig));
|
|
1231
|
+
return {
|
|
1232
|
+
quality: weak ? "weak" : "strict",
|
|
1233
|
+
source: (headerCsp || "").trim() ? "header" : "meta",
|
|
1234
|
+
raw: policy,
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// Compact vendor catalog — covers ~50 most common third-party origins so the
|
|
1239
|
+
// pretty table can show "(New Relic · errors)" instead of just "bam.nr-data.net".
|
|
1240
|
+
// Mirror of lib/serviceCatalog.ts + extension/popup.js SERVICE_CATALOG; keep
|
|
1241
|
+
// the three in sync when adding vendors.
|
|
1242
|
+
const SERVICE_CATALOG = {
|
|
1243
|
+
"googletagmanager.com": { name: "Google Tag Manager", category: "analytics" },
|
|
1244
|
+
"google-analytics.com": { name: "Google Analytics", category: "analytics" },
|
|
1245
|
+
"googleadservices.com": { name: "Google Ads", category: "ads" },
|
|
1246
|
+
"doubleclick.net": { name: "Google DoubleClick", category: "ads" },
|
|
1247
|
+
"googleapis.com": { name: "Google APIs", category: "cdn" },
|
|
1248
|
+
"gstatic.com": { name: "Google Static", category: "cdn" },
|
|
1249
|
+
"recaptcha.net": { name: "Google reCAPTCHA", category: "captcha" },
|
|
1250
|
+
"youtube.com": { name: "YouTube", category: "social" },
|
|
1251
|
+
"cloudflare.com": { name: "Cloudflare", category: "cdn" },
|
|
1252
|
+
"cdnjs.cloudflare.com": { name: "Cloudflare cdnjs", category: "cdn" },
|
|
1253
|
+
"cloudflareinsights.com": { name: "Cloudflare Web Analytics", category: "analytics" },
|
|
1254
|
+
"challenges.cloudflare.com": { name: "Cloudflare Turnstile", category: "captcha" },
|
|
1255
|
+
"stripe.com": { name: "Stripe", category: "payments" },
|
|
1256
|
+
"js.stripe.com": { name: "Stripe Checkout", category: "payments" },
|
|
1257
|
+
"amazonaws.com": { name: "AWS S3", category: "cdn" },
|
|
1258
|
+
"cloudfront.net": { name: "AWS CloudFront", category: "cdn" },
|
|
1259
|
+
"clarity.ms": { name: "Microsoft Clarity", category: "analytics" },
|
|
1260
|
+
"azureedge.net": { name: "Azure CDN", category: "cdn" },
|
|
1261
|
+
"facebook.com": { name: "Facebook", category: "social" },
|
|
1262
|
+
"facebook.net": { name: "Facebook Pixel", category: "ads" },
|
|
1263
|
+
"fbcdn.net": { name: "Facebook CDN", category: "cdn" },
|
|
1264
|
+
"connect.facebook.net": { name: "Facebook Pixel", category: "ads" },
|
|
1265
|
+
"twitter.com": { name: "Twitter (X)", category: "social" },
|
|
1266
|
+
"linkedin.com": { name: "LinkedIn", category: "social" },
|
|
1267
|
+
"snap.licdn.com": { name: "LinkedIn Tracking", category: "ads" },
|
|
1268
|
+
"use.typekit.net": { name: "Adobe Fonts", category: "fonts" },
|
|
1269
|
+
"typekit.net": { name: "Adobe Fonts", category: "fonts" },
|
|
1270
|
+
"paypal.com": { name: "PayPal", category: "payments" },
|
|
1271
|
+
"auth0.com": { name: "Auth0", category: "auth" },
|
|
1272
|
+
"okta.com": { name: "Okta", category: "auth" },
|
|
1273
|
+
"intercomcdn.com": { name: "Intercom", category: "support" },
|
|
1274
|
+
"zendesk.com": { name: "Zendesk", category: "support" },
|
|
1275
|
+
"sentry.io": { name: "Sentry", category: "errors" },
|
|
1276
|
+
"browser.sentry-cdn.com": { name: "Sentry", category: "errors" },
|
|
1277
|
+
"js-agent.newrelic.com": { name: "New Relic", category: "errors" },
|
|
1278
|
+
"bam.nr-data.net": { name: "New Relic", category: "errors" },
|
|
1279
|
+
"datadoghq.com": { name: "Datadog", category: "errors" },
|
|
1280
|
+
"browser-intake-datadoghq.com": { name: "Datadog RUM", category: "errors" },
|
|
1281
|
+
"mailchimp.com": { name: "Mailchimp", category: "analytics" },
|
|
1282
|
+
"hubspot.com": { name: "HubSpot", category: "analytics" },
|
|
1283
|
+
"js.hubspot.com": { name: "HubSpot Tracking", category: "analytics" },
|
|
1284
|
+
"segment.com": { name: "Segment", category: "analytics" },
|
|
1285
|
+
"amplitude.com": { name: "Amplitude", category: "analytics" },
|
|
1286
|
+
"mxpnl.com": { name: "Mixpanel", category: "analytics" },
|
|
1287
|
+
"hotjar.com": { name: "Hotjar", category: "analytics" },
|
|
1288
|
+
"plausible.io": { name: "Plausible Analytics", category: "analytics" },
|
|
1289
|
+
"js.hcaptcha.com": { name: "hCaptcha", category: "captcha" },
|
|
1290
|
+
"cdn.jsdelivr.net": { name: "jsDelivr CDN", category: "cdn" },
|
|
1291
|
+
"unpkg.com": { name: "unpkg CDN", category: "cdn" },
|
|
1292
|
+
"code.jquery.com": { name: "jQuery CDN", category: "cdn" },
|
|
1293
|
+
"akamaihd.net": { name: "Akamai CDN", category: "cdn" },
|
|
1294
|
+
"akamaized.net": { name: "Akamai CDN", category: "cdn" },
|
|
1295
|
+
"fastly.net": { name: "Fastly CDN", category: "cdn" },
|
|
1296
|
+
"bootstrapcdn.com": { name: "Bootstrap CDN", category: "cdn" },
|
|
1297
|
+
"maxcdn.bootstrapcdn.com": { name: "Bootstrap CDN", category: "cdn" },
|
|
1298
|
+
"cookielaw.org": { name: "OneTrust Cookie Consent", category: "consent" },
|
|
1299
|
+
"cookiebot.com": { name: "Cookiebot", category: "consent" },
|
|
1300
|
+
"vimeo.com": { name: "Vimeo", category: "social" },
|
|
1301
|
+
"shopify.com": { name: "Shopify", category: "ecommerce" },
|
|
1302
|
+
"cdn.shopify.com": { name: "Shopify CDN", category: "ecommerce" },
|
|
1303
|
+
"tiktok.com": { name: "TikTok", category: "social" },
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
function classifyVendor(host) {
|
|
1307
|
+
if (!host) return null;
|
|
1308
|
+
const lower = host.toLowerCase();
|
|
1309
|
+
if (SERVICE_CATALOG[lower]) return SERVICE_CATALOG[lower];
|
|
1310
|
+
for (const pattern of Object.keys(SERVICE_CATALOG)) {
|
|
1311
|
+
if (lower === pattern || lower.endsWith("." + pattern)) {
|
|
1312
|
+
return SERVICE_CATALOG[pattern];
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
return null;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1190
1318
|
function extractThirdPartyRefs(html, targetDomain) {
|
|
1191
1319
|
const out = [];
|
|
1192
1320
|
const targetRoot = registeredDomain(targetDomain);
|