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 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.8";
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 html = await fetchPageHTML(domain);
935
+ const fetched = await fetchPageHTML(domain);
936
936
  if (!json) process.stderr.write("\r\x1b[K");
937
- if (!html) {
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.1", // bumped for SRI + baseline-diff fields
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
- console.log(` ${gradeColored.padEnd(8, " ")} ${host} ${pqc} ${sriCell} ${color("dim", types)}`);
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
- return await resp.text();
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.7.8",
3
+ "version": "0.7.9",
4
4
  "description": "Decryption Blast Radius scanner — find out how much of your data unlocks when quantum decryption arrives.",
5
5
  "keywords": [
6
6
  "post-quantum",