Skip to content

Privacy Score — TypeScript source

One number for your privacy health: your browser fingerprint, a test password's strength and breach exposure, and a site's security headers — four checks, one score, concrete fixes. Runs in your browser; only a 5-character hash prefix ever leaves it.

This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Privacy Score (FEAT-030) — the composite scoring engine. Pure and
// null-safe: every category of PrivacyScoreInput is optional; run
// categories score out of 25 each and the overall percent is normalized
// over the categories that actually ran (skipping a check never lowers
// your score). Mirrored in Go at cli/privacy-score (lock-step vectors).

export type HeaderGrade = 'A' | 'B' | 'C' | 'F';

export interface PrivacyScoreInput {
  /** What browser-fingerprint actually produces: signal counts by risk. */
  fingerprint?: { signalCount: number; highRiskCount: number; mediumRiskCount: number };
  password?: { score: number; breached: boolean };
  headers?: { grades: HeaderGrade[] };
  breach?: { pwned: boolean };
}

export type CategoryStatus = 'ok' | 'warn' | 'bad';

export interface Recommendation {
  text: string;
  toolSlug: string;
}

export interface CategoryResult {
  id: 'fingerprint' | 'password' | 'headers' | 'breach';
  label: string;
  points: number;
  max: 25;
  status: CategoryStatus;
  recommendation?: Recommendation;
  toolSlug: string;
}

export interface PrivacyScoreResult {
  total: number;
  max: number;
  percent: number;
  letter: 'A' | 'B' | 'C' | 'D' | '—';
  categories: CategoryResult[];
}

const TOOLS = {
  fingerprint: 'browser-fingerprint',
  password: 'password-strength-analyser',
  headers: 'security-headers',
  breach: 'breach-checker',
} as const;

function finalize(
  id: CategoryResult['id'],
  label: string,
  points: number,
  okText: string,
  warnText: string,
  badText: string
): CategoryResult {
  const clamped = Math.max(0, Math.min(25, Math.round(points)));
  const status: CategoryStatus = clamped >= 20 ? 'ok' : clamped >= 10 ? 'warn' : 'bad';
  const text = status === 'ok' ? okText : status === 'warn' ? warnText : badText;
  return {
    id,
    label,
    points: clamped,
    max: 25,
    status,
    recommendation: status === 'ok' ? undefined : { text, toolSlug: TOOLS[id] },
    toolSlug: TOOLS[id],
  };
}

function fingerprintPoints(signalCount: number, highRiskCount: number, mediumRiskCount: number): number {
  // Honesty note: the tool measures WHICH signals exist, not real entropy.
  // More distinguishable signals — especially high-risk ones (GPU string,
  // fonts list) — make a browser more unique, so the score degrades with
  // each: high-risk signals cost 4 each, medium 1.5, and a large total
  // surface costs a little more.
  const surface = Math.max(0, signalCount - 12) * 0.5;
  return Math.max(0, 25 - highRiskCount * 4 - mediumRiskCount * 1.5 - surface);
}

function passwordPoints(score: number, breached: boolean): number {
  const base = (Math.max(0, Math.min(4, score)) / 4) * 25;
  // Breached is urgent regardless of strength: the ×0.32 floor puts even a
  // score-4 password at 8 points → 'bad' status → the change-it recommendation.
  return breached ? base * 0.32 : base;
}

function headersPoints(grades: HeaderGrade[]): number {
  const counts = { A: 0, B: 0, C: 0, F: 0 } as Record<HeaderGrade, number>;
  for (const g of grades) if (g in counts) counts[g]++;
  if (counts.F > 0) return 0;
  if (counts.C > 0) return 10;
  if (counts.B > 0) return 18;
  return grades.length > 0 ? 25 : 0;
}

function letterFor(percent: number, max: number): PrivacyScoreResult['letter'] {
  if (max === 0) return '—';
  if (percent >= 85) return 'A';
  if (percent >= 70) return 'B';
  if (percent >= 50) return 'C';
  return 'D';
}

/** Score the checks that ran. Order is stable: fingerprint, password, headers, breach. */
export function scorePrivacy(input: PrivacyScoreInput): PrivacyScoreResult {
  const categories: CategoryResult[] = [];
  if (input.fingerprint) {
    categories.push(
      finalize(
        'fingerprint',
        'Browser fingerprint',
        fingerprintPoints(
          input.fingerprint.signalCount,
          input.fingerprint.highRiskCount,
          input.fingerprint.mediumRiskCount
        ),
        'Your browser blends into the crowd.',
        'Some signals make your browser fairly unique.',
        'Your browser is highly identifiable — consider hardening.'
      )
    );
  }
  if (input.password) {
    categories.push(
      finalize(
        'password',
        'Password strength',
        passwordPoints(input.password.score, input.password.breached),
        'Strong test password.',
        'The test password could be stronger.',
        'Weak or breached — change it everywhere it was reused.'
      )
    );
  }
  if (input.headers) {
    categories.push(
      finalize(
        'headers',
        'Site security headers',
        headersPoints(input.headers.grades),
        'The site sends best-practice headers.',
        'Some headers are weak or missing.',
        'A dangerous or missing header undermines the site.'
      )
    );
  }
  if (input.breach) {
    categories.push(
      finalize(
        'breach',
        'Breach exposure',
        input.breach.pwned ? 0 : 25,
        'The tested password is not in known breaches.',
        'The tested password appears in breaches.',
        'That password is exposed in known breaches — stop using it now.'
      )
    );
  }
  const total = categories.reduce((a, c) => a + c.points, 0);
  const max = categories.length * 25;
  const percent = max === 0 ? 0 : Math.round((total / max) * 100);
  return { total, max, percent, letter: letterFor(percent, max), categories };
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →