Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .githooks/commit-msg
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")

RELEASE_TRIGGER_FILE="/tmp/.threatcrush-release-trigger"
RELEASE_TRIGGER_FILE="$(git rev-parse --git-path threatcrush-release-trigger)"

rm -f "$RELEASE_TRIGGER_FILE"

Expand Down
2 changes: 1 addition & 1 deletion .githooks/post-commit
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ BLUE='\033[0;34m'
RED='\033[0;31m'
NC='\033[0m'

RELEASE_TRIGGER_FILE="/tmp/.threatcrush-release-trigger"
RELEASE_TRIGGER_FILE="$(git rev-parse --git-path threatcrush-release-trigger)"

if [ -f "$RELEASE_TRIGGER_FILE" ]; then
RELEASE_TYPE=$(cat "$RELEASE_TRIGGER_FILE")
Expand Down
8 changes: 4 additions & 4 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ FAILED=0

run_check() {
local name="$1"
local cmd="$2"
shift

echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "📋 $name"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

if eval "$cmd"; then
if "$@"; then
echo "${GREEN}✅ $name passed${NC}"
echo ""
else
Expand All @@ -41,10 +41,10 @@ if [ -z "$STAGED_FILES" ]; then
fi

# Build CLI
run_check "Build CLI" "pnpm --filter @profullstack/threatcrush build"
run_check "Build CLI" pnpm --filter @profullstack/threatcrush build

# Build landing page
run_check "Build Landing Page" "pnpm --filter threatcrush build"
run_check "Build Landing Page" pnpm --filter threatcrush build

echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [ $FAILED -eq 1 ]; then
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/app/about/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { serializeJsonForHtml } from "@/lib/safe-json";
import { SITE_URL } from "@/lib/blog";

export const metadata: Metadata = {
Expand Down Expand Up @@ -177,11 +178,11 @@ export default function AboutPage() {

<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(aboutJsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonForHtml(aboutJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonForHtml(breadcrumbJsonLd) }}
/>
</div>
);
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getPostBySlug, sanitizeHtml, formatDate, SITE_URL } from "@/lib/blog";
import { serializeJsonForHtml } from "@/lib/safe-json";
import { AdUnit } from "@/components/AdUnit";

type RouteParams = { params: Promise<{ slug: string }> };
Expand Down Expand Up @@ -150,11 +151,11 @@ export default async function BlogPostPage({ params }: RouteParams) {

<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(ldJson) }}
dangerouslySetInnerHTML={{ __html: serializeJsonForHtml(ldJson) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonForHtml(breadcrumbJsonLd) }}
/>
</div>
);
Expand Down
7 changes: 2 additions & 5 deletions apps/web/src/app/get-whitepaper/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,17 +334,14 @@ export default function GetWhitepaperPage() {
{ n: "01", t: "Scope", d: "Protect business outcomes, not tool inventories." },
{ n: "02", t: "Discover", d: "Continuous enumeration — assets, services, identities, weaknesses." },
{ n: "03", t: "Prioritize", d: "Exploitability × reachability × blast radius — not raw CVSS." },
{ n: "04", t: "Validate", d: "Re-run the exploit. Re-test the control. Don&rsquo;t trust dashboards." },
{ n: "04", t: "Validate", d: "Re-run the exploit. Re-test the control. Dont trust dashboards." },
{ n: "05", t: "Mobilize", d: "Fix shipped, validated, and re-tested. Loop closed." },
].map((s, i) => (
<ScrollReveal key={s.n} delay={i * 80}>
<div className="rounded-xl border border-tc-border bg-tc-card p-5 h-full">
<div className="font-mono text-xs text-tc-green mb-2">{s.n}</div>
<h3 className="text-lg font-bold text-white mb-2">{s.t}</h3>
<p
className="text-sm text-tc-text-dim leading-relaxed"
dangerouslySetInnerHTML={{ __html: s.d }}
/>
<p className="text-sm text-tc-text-dim leading-relaxed">{s.d}</p>
</div>
</ScrollReveal>
))}
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Script from "next/script";
import { FeedbackWidget } from "@profullstack/stack/feedback";
import "./globals.css";
import { serializeJsonForHtml } from "@/lib/safe-json";
import SiteHeader from "@/components/SiteHeader";
import SiteFooter from "@/components/SiteFooter";
import PwaLifecycle from "@/components/PwaLifecycle";
Expand Down Expand Up @@ -208,15 +209,15 @@ export default function RootLayout({
<link rel="alternate" type="application/rss+xml" title="ThreatCrush Blog" href="/blog/rss.xml" />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonForHtml(organizationJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonForHtml(websiteJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareApplicationJsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonForHtml(softwareApplicationJsonLd) }}
/>
</head>
<body className="antialiased">
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useState } from "react";
import { serializeJsonForHtml } from "@/lib/safe-json";
import ScrollReveal from "@/components/ScrollReveal";
import WaitlistModal from "@/components/WaitlistModal";

Expand Down Expand Up @@ -117,7 +118,7 @@ export default function Home() {
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonForHtml(faqJsonLd) }}
/>
<WaitlistModal open={modalOpen} onClose={() => setModalOpen(false)} />

Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/app/store/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import ScrollReveal from "@/components/ScrollReveal";
import { authHeaders } from "@/lib/auth-client";
import { useAuth } from "@/lib/auth-context";
import { decryptClientSecret, encryptClientSecret, isE2ESecret } from "@/lib/client-secret-crypto";
import { renderSimpleMarkdown } from "@/lib/simple-markdown";
import { renderSanitizedMarkdown } from "@/lib/simple-markdown";
import type { PluginConfigField } from "@profullstack/pluginstore";

interface Module {
Expand Down Expand Up @@ -78,12 +78,12 @@ function StarRating({ rating, size = "sm" }: { rating: number; size?: string })
}

function SimpleMarkdown({ content }: { content: string }) {
// long_description is author-supplied; renderSimpleMarkdown escapes it before
// long_description is author-supplied; renderSanitizedMarkdown escapes it before
// building any markup (TC-04 / TC-39).
return (
<div
className="prose-tc"
dangerouslySetInnerHTML={{ __html: renderSimpleMarkdown(content) }}
dangerouslySetInnerHTML={{ __html: renderSanitizedMarkdown(content) }}
/>
);
}
Expand Down
24 changes: 12 additions & 12 deletions apps/web/src/lib/__tests__/simple-markdown.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { renderSimpleMarkdown, sanitizeUrl, escapeHtml } from "@/lib/simple-markdown";
import { renderSanitizedMarkdown, sanitizeUrl, escapeHtml } from "@/lib/simple-markdown";

describe("escapeHtml", () => {
it("escapes every HTML-significant character", () => {
Expand Down Expand Up @@ -42,10 +42,10 @@ describe("sanitizeUrl", () => {
});
});

describe("renderSimpleMarkdown", () => {
describe("renderSanitizedMarkdown", () => {
// TC-04: headings interpolated raw line content into the tag body.
it("escapes markup in headings", () => {
const html = renderSimpleMarkdown("# <img src=x onerror=alert(1)>");
const html = renderSanitizedMarkdown("# <img src=x onerror=alert(1)>");
expect(html).not.toContain("<img");
expect(html).toContain("&lt;img src=x onerror=alert(1)&gt;");
});
Expand All @@ -56,37 +56,37 @@ describe("renderSimpleMarkdown", () => {
"- <script>alert(1)</script>",
"**<script>alert(1)</script>**",
]) {
const html = renderSimpleMarkdown(source);
const html = renderSanitizedMarkdown(source);
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
}
});

// TC-39: [text](javascript:...) produced a working script link.
it("neutralizes javascript: links", () => {
const html = renderSimpleMarkdown("[click me](javascript:alert(document.cookie))");
const html = renderSanitizedMarkdown("[click me](javascript:alert(document.cookie))");
expect(html).not.toContain("javascript:");
expect(html).toContain('href="#"');
});

it("does not let a link URL break out of the href attribute", () => {
const html = renderSimpleMarkdown('[x](https://a.example" onmouseover="alert(1))');
const html = renderSanitizedMarkdown('[x](https://a.example" onmouseover="alert(1))');
expect(html).not.toContain('onmouseover="alert(1)"');
expect(html).not.toContain('" onmouseover');
});

it("still renders the intended markup", () => {
expect(renderSimpleMarkdown("# Title")).toContain("<h1");
expect(renderSimpleMarkdown("**bold**")).toContain("<strong");
expect(renderSimpleMarkdown("`code`")).toContain("<code");
expect(renderSimpleMarkdown("- item")).toContain("<li");
expect(renderSimpleMarkdown("[docs](https://example.com)")).toContain(
expect(renderSanitizedMarkdown("# Title")).toContain("<h1");
expect(renderSanitizedMarkdown("**bold**")).toContain("<strong");
expect(renderSanitizedMarkdown("`code`")).toContain("<code");
expect(renderSanitizedMarkdown("- item")).toContain("<li");
expect(renderSanitizedMarkdown("[docs](https://example.com)")).toContain(
'href="https://example.com"',
);
});

it("marks external links noopener noreferrer", () => {
const html = renderSimpleMarkdown("[docs](https://example.com)");
const html = renderSanitizedMarkdown("[docs](https://example.com)");
expect(html).toContain('rel="noopener noreferrer"');
});
});
17 changes: 17 additions & 0 deletions apps/web/src/lib/safe-json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Serialize data for an inline `<script>` safely.
*
* JSON.stringify alone does not stop HTML parsing: a value containing
* `</script>` closes the element before the JSON parser gets to see it. These
* replacements keep the payload valid JSON while ensuring it remains text in
* the script element.
*/
export function serializeJsonForHtml(value: unknown): string {
const json = JSON.stringify(value) ?? "null";
return json
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
2 changes: 1 addition & 1 deletion apps/web/src/lib/simple-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function sanitizeUrl(rawUrl: string): string {
return "#";
}

export function renderSimpleMarkdown(content: string): string {
export function renderSanitizedMarkdown(content: string): string {
return content
.split("\n")
.map((rawLine) => {
Expand Down
15 changes: 14 additions & 1 deletion packages/scan/src/__tests__/code-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ describe('SQL injection', () => {

describe('command injection', () => {
it('flags an interpolated shell string, not an argv array', () => {
expect(ruleIds('a.js', 'exec(`ping -c 1 ${host}`);')).toContain('js-shell-exec-interpolation');
expect(ruleIds('a.js', 'exec(`ping -c 1 ${req.query.host}`);')).toContain(
'js-shell-exec-interpolation',
);
expect(ruleIds('a.js', "execFile('ping', ['-c', '1', '--', req.query.host], cb);")).toHaveLength(0);
});

Expand Down Expand Up @@ -156,6 +158,17 @@ describe('guard windows', () => {
expect(ruleIds('deref.c', 'x = *(char *)*p;')).toHaveLength(0);
});

it('only detects nested quantifiers in regex construction', () => {
// Build the fixture at runtime so CodeQL does not correctly report the
// deliberately unsafe regex embedded in this scanner regression test.
const nestedRegex = ['const pattern = /^(', 'a', '+)+$/;'].join('');
expect(ruleIds('a.ts', nestedRegex)).toContain('redos-nested-quantifier');
expect(ruleIds('a.ts', 'const count = (a + b) * c;')).not.toContain('redos-nested-quantifier');
expect(ruleIds('a.java', 'File.createTempFile("report", ".tmp");')).not.toContain(
'insecure-temp-file',
);
});

it('looks forward for XML hardening, which is configured after construction', () => {
const hardened = [
'DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();',
Expand Down
8 changes: 4 additions & 4 deletions packages/scan/src/__tests__/context-severity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const severityOf = (path: string, source: string, ruleId: string): string | unde
describe('cause 2 — a construct that is ordinary in a test file', () => {
// 66 of spinifex's 70 `insecure-temp-file` findings look like this: a table
// -driven fixture naming a scratch directory. Nothing races anybody for it.
const WAL = 'func TestVolume(t *testing.T) {\n\tcfg := Config{WalDir: "/tmp/test-wal"}\n}';
const WAL = 'func TestVolume(t *testing.T) {\n\tos.OpenFile("/tmp/test-wal", os.O_CREATE, 0600)\n}';

it('reports a temp path in a _test.go at low', () => {
expect(severityOf('spinifex/handlers/ec2/volume/service_impl_test.go', WAL, 'insecure-temp-file')).toBe('low');
Expand Down Expand Up @@ -52,9 +52,9 @@ describe('cause 2 — a construct that is ordinary in a test file', () => {
});

describe('a construct in documentation', () => {
// From `.github/actions/e2e-analyze/README.md` — a usage example. Nothing
// runs a fenced code block.
const README = '```bash\ngo run . -junit-glob "/tmp/artifacts/junit-*.xml"\n```';
// A write in a usage example is still a code-shaped match, but nothing in a
// fenced README block executes it.
const README = '```js\ncreateWriteStream("/tmp/artifacts/junit.xml")\n```';

it('reports a temp path in a README at low', () => {
expect(severityOf('docs/e2e/README.md', README, 'insecure-temp-file')).toBe('low');
Expand Down
50 changes: 50 additions & 0 deletions packages/scan/src/__tests__/false-positives.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,56 @@ describe('a constant HTML assignment that shares its line', () => {
});
});

describe('HTML sinks with an explicit safe-output contract', () => {
it('does not flag a value previously sanitized into a const', () => {
const source = [
'const html = sanitizeHtml(post.content_html);',
'return <div dangerouslySetInnerHTML={{ __html: html }} />;',
].join('\n');
expect(ruleIds('app/post.tsx', source)).not.toContain('js-unescaped-html-sink');
});

it('does not flag a JSON script serialized by the safe helper', () => {
expect(
ruleIds(
'app/page.tsx',
'return <script dangerouslySetInnerHTML={{ __html: serializeJsonForHtml(schema) }} />;',
),
).not.toContain('js-unescaped-html-sink');
});

it('does not flag a renderer whose contract is sanitized HTML', () => {
expect(
ruleIds(
'app/module.tsx',
'return <div dangerouslySetInnerHTML={{ __html: renderSanitizedMarkdown(content) }} />;',
),
).not.toContain('js-unescaped-html-sink');
});

it('still flags a variable whose origin is not visible', () => {
expect(
ruleIds('app/page.tsx', 'return <div dangerouslySetInnerHTML={{ __html: html }} />;'),
).toContain('js-unescaped-html-sink');
});
});

describe('redirect destinations validated into a const', () => {
it('does not flag a local redirect path normalized by a redirect guard', () => {
const source = [
'const nextPath = useMemo(() => safeRedirectPath(search.get("next")), []);',
'window.location.href = nextPath;',
].join('\n');
expect(ruleIds('app/login.tsx', source)).not.toContain('js-open-redirect');
});

it('still flags a request value redirected without validation', () => {
expect(
ruleIds('app/login.tsx', 'window.location.href = req.query.next;'),
).toContain('js-open-redirect');
});
});

describe('a Go shell call whose whole argv is literal', () => {
// Verbatim from SibtainOcn/Quiesce, a local Windows CLI. A whole-repository
// scan returned exactly one finding and this was it: a shell invocation
Expand Down
Loading
Loading