{
  "exported_at": "2026-09-30T14:03:48.634833Z",
  "kind": "repo",
  "target": "ivjames/artificial-atheist@main",
  "run_id": "0d9f4dca5ca24497a2669cec932b30d4",
  "status": "done",
  "stats": {
    "pages": 334,
    "files": 335,
    "changed_files": 0,
    "findings_by_severity": {
      "moderate": 16,
      "info": 2,
      "minor": 3
    },
    "duration_secs": 56.36,
    "tokens": {
      "input": 33527,
      "output": 3380,
      "cache_read": 9457,
      "cache_write": 1351
    },
    "tokens_by_model": {
      "claude-haiku-4-5": {
        "input": 9292,
        "output": 573,
        "cache_read": 0,
        "cache_write": 0
      },
      "claude-sonnet-5": {
        "input": 24235,
        "output": 2807,
        "cache_read": 9457,
        "cache_write": 1351
      }
    },
    "estimated_cost_usd": 0.1349
  },
  "findings": [
    {
      "type": "finding",
      "id": 2897,
      "url": "app/(app)/review/prophecy/evaluations/page.tsx",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "app/(app)/review/prophecy/evaluations/page.tsx",
        "line": 430,
        "snippet": "dangerouslySetInnerHTML={{"
      }
    },
    {
      "type": "finding",
      "id": 2898,
      "url": "app/layout.tsx",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "app/layout.tsx",
        "line": 116,
        "snippet": "<script dangerouslySetInnerHTML={{ __html: themeScript }} />"
      }
    },
    {
      "type": "finding",
      "id": 2899,
      "url": "app/layout.tsx",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "app/layout.tsx",
        "line": 129,
        "snippet": "dangerouslySetInnerHTML={{ __html: gtagStub(site.analytics.gaId) }}"
      }
    },
    {
      "type": "finding",
      "id": 2900,
      "url": "app/posts/[slug]/page.tsx",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "app/posts/[slug]/page.tsx",
        "line": 96,
        "snippet": "dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}"
      }
    },
    {
      "type": "finding",
      "id": 2901,
      "url": "app/posts/[slug]/page.tsx",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "app/posts/[slug]/page.tsx",
        "line": 117,
        "snippet": "<div className=\"prose\" dangerouslySetInnerHTML={{ __html: post.html }} />"
      }
    },
    {
      "type": "finding",
      "id": 2902,
      "url": "components/site/pub.tsx",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "components/site/pub.tsx",
        "line": 22,
        "snippet": "dangerouslySetInnerHTML={{ __html: topicPattern(t, seed) }}"
      }
    },
    {
      "type": "finding",
      "id": 2903,
      "url": "tests/preview.test.ts",
      "pipeline": "security",
      "tier": 0,
      "rule": "generic-credential",
      "severity": "moderate",
      "title": "Possible hard-coded credential exposed",
      "detail": "A key/value pair matching common secret naming conventions (api_key, secret, password, token) was found in tests/preview.test.ts; verify this is not a live credential.",
      "evidence": {
        "source": "tests/preview.test.ts",
        "rule": "generic-credential",
        "match": "TOKE\u2026ink\"",
        "offset": 808
      }
    },
    {
      "type": "finding",
      "id": 2904,
      "url": "tools/admin/index.html",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "tools/admin/index.html",
        "line": 176,
        "snippet": "$(\"cstats\").innerHTML ="
      }
    },
    {
      "type": "finding",
      "id": 2905,
      "url": "tools/admin/index.html",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "tools/admin/index.html",
        "line": 181,
        "snippet": "if(!q.length){ $(\"queue\").innerHTML='<div class=\"hint\">Nothing pending.</div>'; return; }"
      }
    },
    {
      "type": "finding",
      "id": 2906,
      "url": "tools/admin/index.html",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "tools/admin/index.html",
        "line": 182,
        "snippet": "$(\"queue\").innerHTML = q.map(c=>`"
      }
    },
    {
      "type": "finding",
      "id": 2907,
      "url": "tools/studio/index.html",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "tools/studio/index.html",
        "line": 122,
        "snippet": "function renderPreview(){ $(\"preview\").innerHTML = md.render($(\"body\").value || \"*Nothing yet.*\"); }"
      }
    },
    {
      "type": "finding",
      "id": 2908,
      "url": "tools/studio/index.html",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "tools/studio/index.html",
        "line": 139,
        "snippet": "$(\"titles\").innerHTML = c.titles.map(t=>\"\u2022 \"+t).join(\"<br>\") || \"none yet\";"
      }
    },
    {
      "type": "finding",
      "id": 2909,
      "url": "tools/studio/index.html",
      "pipeline": "code",
      "tier": 0,
      "rule": "code/inner-html",
      "severity": "moderate",
      "title": "Raw HTML injection surface",
      "detail": "Assigning markup strings into the DOM is an XSS surface; verify every interpolated value is escaped.",
      "evidence": {
        "source": "tools/studio/index.html",
        "line": 140,
        "snippet": "$(\"status\").innerHTML = `provider <b>${c.provider.provider}</b> \u00b7 ${c.provider.model}` +"
      }
    },
    {
      "type": "finding",
      "id": 2910,
      "url": "app/(app)/review/prophecy/evaluations/page.tsx",
      "pipeline": "security",
      "tier": 2,
      "rule": "security/xss",
      "severity": "info",
      "title": "dangerouslySetInnerHTML use is a static, hardcoded style string (not user input)",
      "detail": "The only innerHTML usage in this file is a <style> block whose content is a fixed string of CSS custom-property declarations, containing no interpolated request/user data. This is not an XSS vector as written; the flagged pattern is benign here. No fix needed unless the string is ever built from dynamic input.",
      "evidence": {
        "selector": "app/(app)/review/prophecy/evaluations/page.tsx",
        "snippet": "dangerouslySetInnerHTML={{ __html: \".eval-charts{--s0:#7d251f;...}\" }}",
        "note": "Static literal, no template interpolation of external data."
      }
    },
    {
      "type": "finding",
      "id": 2911,
      "url": "app/layout.tsx",
      "pipeline": "security",
      "tier": 2,
      "rule": "security/xss",
      "severity": "info",
      "title": "innerHTML usage confined to build-time constant scripts",
      "detail": "Both dangerouslySetInnerHTML calls (themeScript and gtagStub(site.analytics.gaId)) use values from lib/inline-scripts.ts, not user-controlled request/query data. gaId comes from site config, not request input. As long as themeScript/gtagStub never interpolate untrusted input, this is not exploitable XSS; verify gtagStub sanitizes/encodes gaId if it is ever sourced from anything other than a trusted config value, since it's inlined into a script that middleware CSP-hashes by exact string.",
      "evidence": {
        "selector": "app/layout.tsx:120",
        "snippet": "<script dangerouslySetInnerHTML={{ __html: themeScript }} />",
        "note": "no diff available; reviewed as static file"
      }
    },
    {
      "type": "finding",
      "id": 2912,
      "url": "app/posts/[slug]/page.tsx",
      "pipeline": "security",
      "tier": 2,
      "rule": "security/xss",
      "severity": "moderate",
      "title": "Unsanitized HTML rendered via dangerouslySetInnerHTML",
      "detail": "post.html (rendered markdown) is injected directly into the DOM without any sanitization step (e.g. DOMPurify/rehype-sanitize) visible in this file or confirmed upstream. If post content ever originates from or passes through less-trusted input (contributor markdown, CMS, user-submitted content), this is a stored XSS vector. Even for trusted static markdown files, this pattern is fragile: any future move to accept external post/markdown submissions would immediately introduce XSS. Verify that lib/posts.ts sanitizes the HTML output of the markdown renderer; if not, add sanitization (e.g. rehype-sanitize or DOMPurify) before rendering.",
      "evidence": {
        "selector": "app/posts/[slug]/page.tsx:120",
        "snippet": "<div className=\"prose\" dangerouslySetInnerHTML={{ __html: post.html }} />"
      }
    },
    {
      "type": "finding",
      "id": 2913,
      "url": "components/site/pub.tsx",
      "pipeline": "security",
      "tier": 2,
      "rule": "security/xss",
      "severity": "minor",
      "title": "dangerouslySetInnerHTML fed by topic/seed derived from data",
      "detail": "ArtField calls dangerouslySetInnerHTML with topicPattern(t, seed), where seed is post.slug and topic comes from post.topic. If these values ever originate from user-controllable or externally-sourced content (e.g. CMS input, slugs derived from titles) rather than solely from trusted build-time data, this could allow HTML/SVG injection since topicPattern's output is not shown to be escaped here. Confirm topicPattern always generates a fixed, sanitized SVG/pattern string with seed only used numerically (e.g. as a PRNG seed) and never interpolated raw into HTML output; if any string interpolation of topic/seed occurs unescaped inside topicPattern, this is exploitable. Recommend auditing lib/art.ts's topicPattern implementation directly.",
      "evidence": {
        "selector": "components/site/pub.tsx:22-25",
        "snippet": "dangerouslySetInnerHTML={{ __html: topicPattern(t, seed) }}"
      }
    },
    {
      "type": "finding",
      "id": 2914,
      "url": "lib/auth/session.ts",
      "pipeline": "code",
      "tier": 2,
      "rule": "code/logic",
      "severity": "minor",
      "title": "Session age check does not reject negative age",
      "detail": "getSessionUserId() only rejects sessions where age > MAX_AGE_SECONDS, but does not validate age >= 0. A cookie with a manipulated future issuedAt (if an attacker could influence it, e.g. via clock skew or a forged-but-unsigned value bypassing HMAC is not possible here, but any future date) would compute a negative age and always pass the expiry check, effectively creating a non-expiring session. Add a check that age is within [0, MAX_AGE_SECONDS].",
      "evidence": {
        "selector": "lib/auth/session.ts:69",
        "snippet": "if (!Number.isFinite(age) || age > MAX_AGE_SECONDS) return null;"
      }
    },
    {
      "type": "finding",
      "id": 2915,
      "url": "lib/auth/tokens.ts",
      "pipeline": "code",
      "tier": 2,
      "rule": "code/logic",
      "severity": "moderate",
      "title": "TOCTOU race allows a token to be redeemed twice concurrently",
      "detail": "consumeMagicLink reads the token with findUnique, checks usedAt/expiresAt, then issues a separate update marking it used. Two concurrent requests with the same raw token can both pass the usedAt check before either update commits, both proceeding to authenticate as the user. Fix by making the check-and-mark atomic, e.g. `update` with a `where` clause requiring `usedAt: null` (and checking the returned row), wrapped in a transaction, or a conditional update that fails if already used.",
      "evidence": {
        "selector": "lib/auth/tokens.ts:99-105",
        "snippet": "const token = await prisma.loginToken.findUnique({ where: { tokenHash } });\n  if (!token || token.usedAt || token.expiresAt < new Date()) return null;\n\n  await prisma.loginToken.update({\n    where: { id: token.id },\n    data: { usedAt: new Date() },\n  });"
      }
    },
    {
      "type": "finding",
      "id": 2916,
      "url": "app/api/payments/webhook/route.ts",
      "pipeline": "code",
      "tier": 2,
      "rule": "code/logic",
      "severity": "moderate",
      "title": "No idempotency check on fulfillment",
      "detail": "fulfillByRef is called on every event with paid=true without checking if this specific event id/ref was already processed. Stripe can deliver the same webhook event multiple times (retries), which could cause duplicate fulfillment unless fulfillByRef itself is idempotent internally. This should be verified or explicit dedup on event.id should be added.",
      "evidence": {
        "selector": "app/api/payments/webhook/route.ts:29-31",
        "snippet": "if (event?.paid) {\n    await fulfillByRef(event.ref);\n  }"
      }
    },
    {
      "type": "finding",
      "id": 2917,
      "url": "app/api/payments/webhook/route.ts",
      "pipeline": "code",
      "tier": 2,
      "rule": "code/logic",
      "severity": "minor",
      "title": "Errors from fulfillByRef are unhandled",
      "detail": "If fulfillByRef throws (e.g., DB error), the exception propagates unhandled, potentially causing Stripe to retry indefinitely or the response to fail unexpectedly with no logging distinguishing fulfillment failures from signature failures.",
      "evidence": {
        "selector": "app/api/payments/webhook/route.ts:29-31",
        "snippet": "await fulfillByRef(event.ref);"
      }
    }
  ],
  "errors": []
}