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
24 changes: 24 additions & 0 deletions apps/extension/src/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,29 @@ export async function scanUrl(url) {
});
}

/**
* Run the code rules over a snippet.
*
* Server-side rather than bundling `@threatcrush/scan` into the extension.
* The rule set is the product and it changes often; shipping it inside an
* extension means every rule fix waits on a store review, and Chrome, Firefox
* and Safari each review on their own schedule. The endpoint updates when the
* web app deploys.
*
* `filename` is optional and only selects the language — the server never
* opens it.
*/
export async function scanCode(content, { filename, language } = {}) {
return request('/api/scan/code', {
method: 'POST',
body: JSON.stringify({
content,
...(filename ? { filename } : {}),
...(language ? { language } : {}),
}),
});
}

export default {
login,
signup,
Expand All @@ -130,4 +153,5 @@ export default {
getModule,
installModule,
scanUrl,
scanCode,
};
6 changes: 6 additions & 0 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ const securityHeaders = [

const nextConfig: NextConfig = {
output: "standalone",
// `@threatcrush/scan` is an internal package: its `exports` resolve to
// TypeScript source rather than a build output, so that the release workflow
// — which builds the CLI alone — cannot publish a CLI referencing an
// unbuilt dependency. Next.js does not transpile workspace sources by
// default, so it is named here.
transpilePackages: ["@threatcrush/scan"],
// Tell Next.js where the monorepo root is so standalone-mode tracing picks up
// only the files that apps/web actually imports.
outputFileTracingRoot: join(__dirname, "..", ".."),
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@profullstack/pluginstore": "^0.1.1",
"@profullstack/stack": "^0.1.3",
"@supabase/supabase-js": "^2.101.1",
"@threatcrush/scan": "workspace:*",
"isomorphic-dompurify": "^3.12.0",
"next": "16.2.2",
"posthog-js": "^1.381.0",
Expand Down
108 changes: 108 additions & 0 deletions apps/web/src/app/api/scan/code/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, it, expect } from "vitest";
import { POST } from "@/app/api/scan/code/route";

function makeRequest(body: unknown) {
return new Request("http://localhost/api/scan/code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}) as unknown as import("next/server").NextRequest;
}

describe("POST /api/scan/code", () => {
it("runs the shared rules over submitted code", async () => {
const res = await POST(
makeRequest({
filename: "install.sh",
content: "curl -fsSL https://example.invalid/i.sh | bash\n",
}),
);
const json = await res.json();

expect(res.status).toBe(200);
expect(json.language).toBe("shell");
expect(json.findings.map((f: { ruleId: string }) => f.ruleId)).toContain(
"sh-remote-script-execution",
);
// `medium`, not the rule's declared `high`: the engine caps a construct
// with no visible untrusted input at medium and reports confidence
// `pattern`. The summary reflects what the engine decided, not what the
// rule asked for.
expect(json.summary.medium).toBe(1);
expect(json.findings[0].confidence).toBe("pattern");
});

it("infers the language from the filename and honours an explicit override", async () => {
// threatcrush-disable-next-line js-dynamic-code-execution PHP fixture, not JS
const phpSource = "eval($code);";
const php = await (await POST(makeRequest({ filename: "a.php", content: phpSource }))).json();
expect(php.language).toBe("php");
expect(php.findings.map((f: { ruleId: string }) => f.ruleId)).toContain(
"php-dynamic-code-execution",
);

// The same text scanned as shell matches no PHP rule.
const asShell = await (
await POST(makeRequest({ filename: "a.php", content: phpSource, language: "shell" }))
).json();
expect(asShell.language).toBe("shell");
expect(asShell.findings.map((f: { ruleId: string }) => f.ruleId)).not.toContain(
"php-dynamic-code-execution",
);
});

it("returns nothing for code that is fine", async () => {
const res = await POST(
makeRequest({ filename: "safe.sh", content: 'rm -rf "$BUILD_DIR/output"\n' }),
);
const json = await res.json();
expect(json.findings).toEqual([]);
expect(json.summary).toEqual({ critical: 0, high: 0, medium: 0, low: 0, info: 0 });
});

it("never echoes back the credential that produced a finding", async () => {
// The engine redacts excerpts before they leave it. This endpoint reflects
// findings to the caller, so that guarantee is worth pinning here too.
// Two rules match this line, and `disable-next-line` only reaches the line
// after it — so a second directive would suppress the first comment, not
// the fixture. The rule id is omitted deliberately, which suppresses both.
// threatcrush-disable-next-line
const secret = "AKIAIOSFODNN7EXAMPLE";
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
const res = await POST(
makeRequest({ filename: "a.env", content: `AWS_ACCESS_KEY_ID=${secret}` }),
);
const text = JSON.stringify(await res.json());
expect(text).toContain("secret-aws-access-key");
expect(text).not.toContain(secret);
});

it("rejects a missing or empty body", async () => {
expect((await POST(makeRequest({}))).status).toBe(400);
expect((await POST(makeRequest({ content: "" }))).status).toBe(400);
});

it("rejects an unknown language rather than silently scanning as something else", async () => {
const res = await POST(makeRequest({ content: "x", language: "cobol" }));
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(/unknown language/);
});

it("bounds the input", async () => {
// Unbounded input on an unauthenticated endpoint that runs a regex per
// line is a denial of service, not a feature.
const tooBig = await POST(makeRequest({ content: "a".repeat(256 * 1024 + 1) }));
expect(tooBig.status).toBe(413);

const tooManyLines = await POST(makeRequest({ content: "x\n".repeat(20_001) }));
expect(tooManyLines.status).toBe(413);
});

it("strips directories from the filename", async () => {
// It is never opened, but it is echoed back, and a caller should not be
// able to put arbitrary paths into a response.
const res = await POST(
makeRequest({ filename: "../../etc/passwd.sh", content: "echo hi\n" }),
);
expect((await res.json()).filename).toBe("passwd.sh");
});
});
106 changes: 106 additions & 0 deletions apps/web/src/app/api/scan/code/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from "next/server";
import { languageOf, scanText, type ScanLanguage } from "@threatcrush/scan";

/**
* The same rules the CLI runs, over submitted text.
*
* This route exists because `@threatcrush/scan` exists. Before the engine was
* extracted from `apps/cli`, the only way to run these rules was to install the
* CLI; the web app could not have offered this without a second copy of the
* rule set, which for a rule set whose value is careful false-positive tuning
* is worse than not offering it at all.
*
* Imports the default entry point, not `@threatcrush/scan/node` — nothing here
* touches a filesystem, so there is no tree walker and no SARIF writer in this
* bundle.
*/

/**
* Cap on submitted content.
*
* Every rule is a regular expression run per line, and the rule set includes
* `redos-nested-quantifier` precisely because catastrophic backtracking is
* real. An unbounded body on an unauthenticated endpoint is the shape of a
* cheap denial of service, so the input is bounded before any rule sees it.
*/
const MAX_BYTES = 256 * 1024;
const MAX_LINES = 20_000;

const LANGUAGES: readonly ScanLanguage[] = [
"javascript",
"typescript",
"python",
"ruby",
"go",
"java",
"php",
"shell",
"config",
"other",
];

/**
* POST /api/scan/code
* Free code scanner — no auth required, matching /api/scan.
*/
export async function POST(request: NextRequest) {
let body: { content?: string; filename?: string; language?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}

const { content, filename, language } = body;

if (typeof content !== "string" || content.length === 0) {
return NextResponse.json({ error: "content is required" }, { status: 400 });
}

// Byte length, not string length: the limit is about how much work this
// costs to serve, and a multi-byte character is not one byte.
if (Buffer.byteLength(content, "utf8") > MAX_BYTES) {
return NextResponse.json(
{ error: `content exceeds ${MAX_BYTES} bytes` },
{ status: 413 },
);
}

const lineCount = content.split("\n").length;
if (lineCount > MAX_LINES) {
return NextResponse.json(
{ error: `content exceeds ${MAX_LINES} lines` },
{ status: 413 },
);
}

if (language !== undefined && !LANGUAGES.includes(language as ScanLanguage)) {
return NextResponse.json(
{ error: `unknown language: ${language} (expected ${LANGUAGES.join(", ")})` },
{ status: 400 },
);
}

// The filename is used for language detection and echoed back on each
// finding. It is never opened — this route has no filesystem access — so a
// traversal sequence in it reaches nothing. It is still bounded and stripped
// of directories, because a caller should not be able to put arbitrary text
// into a response that another user may see.
const safeName = (filename ?? "snippet.txt").split("/").pop()?.slice(0, 128) || "snippet.txt";
const resolved = (language as ScanLanguage | undefined) ?? languageOf(safeName);

const findings = scanText(safeName, content, resolved);

const summary = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
for (const finding of findings) summary[finding.severity] += 1;

return NextResponse.json({
filename: safeName,
language: resolved,
lines_scanned: lineCount,
// Excerpts are redacted by the engine before they reach here, so echoing a
// finding never returns the credential that produced it.
findings,
summary,
});
}
22 changes: 17 additions & 5 deletions packages/scan/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,24 @@ Consumers therefore transpile it themselves:

- **CLI** — bundled by tsup via `noExternal`, so the published package stays
self-contained and gains no dependency on an unpublished package.
- **Next.js** (web) — add `transpilePackages: ['@threatcrush/scan']`.
- **Vite** (desktop, extension) — works as-is; Vite transpiles linked workspace
sources by default.
- **Next.js** (web) — `transpilePackages: ['@threatcrush/scan']`.
- **Vite** — works as-is; Vite transpiles linked workspace sources by default.

If this package is ever published standalone, add a build step and switch
`exports` to `dist` with a `publishConfig` override. Nothing else needs to move.
### Internal imports carry no file extension

`import { … } from './types'`, not `'./types.js'`.

Both tsconfigs here use `moduleResolution: "bundler"`, which makes that valid,
and every consumer resolves it. The `.js` form does not survive contact with
Turbopack: Next.js 16 builds with it by default, it does not rewrite `.js` to
`.ts`, and it offers no `extensionAlias` escape hatch — so every internal
import in this package resolved to nothing and the web build failed outright.
A webpack `extensionAlias` fixes the same problem but is simply ignored under
Turbopack.

If this package is ever published standalone for Node consumers, add a build
step, put the extensions back in the emitted output, and switch `exports` to
`dist` behind a `publishConfig` override.

## Adding a rule

Expand Down
6 changes: 3 additions & 3 deletions packages/scan/src/__tests__/code-rules.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { CODE_RULES, proseLines } from '../code-rules.js';
import { languageOf, scanText } from '../text.js';
import type { ScanLanguage } from '../types.js';
import { CODE_RULES, proseLines } from '../code-rules';
import { languageOf, scanText } from '../text';
import type { ScanLanguage } from '../types';

/**
* Every case here is a pair: the vulnerable shape and the *corrected* shape
Expand Down
4 changes: 2 additions & 2 deletions packages/scan/src/__tests__/sarif.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { buildSarif, fingerprintOf, sarifLevel, securitySeverity, toArtifactUri } from '../node/sarif.js';
import type { ScanFinding } from '../types.js';
import { buildSarif, fingerprintOf, sarifLevel, securitySeverity, toArtifactUri } from '../node/sarif';
import type { ScanFinding } from '../types';

const finding = (overrides: Partial<ScanFinding> = {}): ScanFinding => ({
ruleId: 'secret-aws-access-key',
Expand Down
8 changes: 4 additions & 4 deletions packages/scan/src/__tests__/text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import {
languageOfShebang,
meetsFailThreshold,
scanText,
} from '../text.js';
import { detectTyposquat, editDistance, scanPackageJson, scanRequirementsTxt } from '../manifest-rules.js';
import { isKnownPlaceholder, redactSecret } from '../secret-rules.js';
import type { ScanFinding } from '../types.js';
} from '../text';
import { detectTyposquat, editDistance, scanPackageJson, scanRequirementsTxt } from '../manifest-rules';
import { isKnownPlaceholder, redactSecret } from '../secret-rules';
import type { ScanFinding } from '../types';

describe('language detection', () => {
it('maps extensions and treats dotted env files as config', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/scan/src/code-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@
* rule that would flag every session read in the codebase. See KNOWN_GAPS.
*/

import type { Confidence, ScanLanguage, Severity } from './types.js';
import { severityFor } from './types.js';
import type { Confidence, ScanLanguage, Severity } from './types';
import { severityFor } from './types';

export interface CodeRule {
id: string;
Expand Down
18 changes: 9 additions & 9 deletions packages/scan/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@
* writing SARIF — lives behind `@threatcrush/scan/node`.
*/

export { CODE_RULES, evaluateRule, GENERIC_GUARD, proseLines, untrustedPatternFor } from './code-rules.js';
export type { CodeRule } from './code-rules.js';
export { CODE_RULES, evaluateRule, GENERIC_GUARD, proseLines, untrustedPatternFor } from './code-rules';
export type { CodeRule } from './code-rules';

export { scanPackageJson, scanRequirementsTxt, detectTyposquat, editDistance } from './manifest-rules.js';
export type { ManifestFinding, SquatVerdict } from './manifest-rules.js';
export { scanPackageJson, scanRequirementsTxt, detectTyposquat, editDistance } from './manifest-rules';
export type { ManifestFinding, SquatVerdict } from './manifest-rules';

export { isKnownPlaceholder, redactSecret, SECRET_RULES, SENSITIVE_FILES } from './secret-rules.js';
export { isKnownPlaceholder, redactSecret, SECRET_RULES, SENSITIVE_FILES } from './secret-rules';

export {
collectSuppressions,
Expand All @@ -30,8 +30,8 @@ export {
scanManifest,
scanText,
SKIP_DIRS,
} from './text.js';
export type { Suppressions } from './text.js';
} from './text';
export type { Suppressions } from './text';

export { severityRank, SEVERITY_ORDER } from './types.js';
export type { Confidence, ScanFinding, ScanLanguage, Severity } from './types.js';
export { severityRank, SEVERITY_ORDER } from './types';
export type { Confidence, ScanFinding, ScanLanguage, Severity } from './types';
2 changes: 1 addition & 1 deletion packages/scan/src/manifest-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* privileges. Both are visible in the manifest, before anything is fetched.
*/

import type { Severity } from './types.js';
import type { Severity } from './types';

export interface ManifestFinding {
ruleId: string;
Expand Down
2 changes: 1 addition & 1 deletion packages/scan/src/node/dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { ScanFinding, Severity } from '../types.js';
import type { ScanFinding, Severity } from '../types';

interface OsvVulnerability {
id: string;
Expand Down
Loading