Skip to content
Open
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
26 changes: 23 additions & 3 deletions docs/supported-tech.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,21 @@ that single signal.
strong-params bypasses, `raw`/`html_safe` XSS, raw SQL, open redirect.

### Other Ruby detected
`sinatra`, `grape`, `hanami`, `roda`. Roadmap.
`sinatra`, `grape`, `hanami`, `roda`.

### Ruby gRPC / async (`grpc-ruby`, `async-grpc`, `falcon-ruby`, `async-websocket`)
- **Sentinel detection:** root `Gemfile` / `Gemfile.lock` exact gems:
`grpc`, `async-grpc`, `falcon`, `async-websocket`. `async-grpc`
also emits the shared `grpc` tag.
- **Matchers:** `rb-grpc-service`, `rb-async-websocket-handler`,
`rb-falcon-rack-app` (gated). Their gates also use recursive
`Gemfile` / `Gemfile.lock` / `.ru` sentinels so nested Ruby services
can still produce `.rb` candidates.
- **Prompt highlights:** per-RPC interceptor auth, `call.metadata`
trust, Falcon/Rack async service boundaries, WebSocket handshake and
per-message authorization.
- **Proto files:** `proto-rpc-surface` activates via the shared `grpc`
tag when root detection sees Ruby gRPC.

## Go

Expand Down Expand Up @@ -152,11 +166,17 @@ that single signal.
### Generic Go (`go`)
Always-on Go matchers regardless of framework: `go-http-handler`,
`go-ssrf`, `go-command-injection`, `go-embed-asset`,
`connectrpc-handler-impl`, `proto-rpc-surface`, `unix-socket-listener`.
`connectrpc-handler-impl`, `unix-socket-listener`.

### Protobuf / gRPC (`grpc`)
`proto-rpc-surface` is cross-language and activates for projects tagged
`grpc` or `connectrpc`. It flags `.proto` service/message definitions as
wire-format trust boundaries.

### Other Go detected
`gorilla`, `buffalo`, `grpc`, `connectrpc`, `cobra`. Roadmap for
dedicated matchers (gRPC service impl already partially covered).
dedicated Go matchers (gRPC wire formats are covered by
`proto-rpc-surface`).

## Rust

Expand Down
22 changes: 21 additions & 1 deletion packages/processor/src/__tests__/prompt-assemble.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { assemblePrompt, CORE_PROMPT, TECH_HIGHLIGHTS } from "../prompt/index.js";
import { languagesForBatch } from "../prompt/file-language.js";
import { assemblePrompt, CORE_PROMPT, noteForSlug, TECH_HIGHLIGHTS } from "../prompt/index.js";

describe("assemblePrompt", () => {
it("returns just the core prompt when no tech is detected and no batch slugs", () => {
Expand Down Expand Up @@ -43,6 +44,25 @@ describe("assemblePrompt", () => {
expect(meta.slugsWithNotes).toBe(2);
});

it("maps Rack config files to Ruby for batch-scoped highlights", () => {
expect(languagesForBatch(["config.ru"])).toEqual(["ruby"]);
});

it("uses Ruby-specific gRPC guidance without Go implementation terms", () => {
const { prompt } = assemblePrompt({
detectedTags: ["grpc-ruby"],
batchSlugs: ["rb-grpc-service", "proto-rpc-surface"],
batchLanguages: ["ruby"],
});

expect(prompt).toContain("GRPC::ServerInterceptor");
expect(prompt).toContain("call.metadata");
expect(prompt).toContain("::Service");
expect(prompt).not.toMatch(/context\.Context|connect\.Request|net\/http/);
expect(noteForSlug("proto-rpc-surface")).toMatch(/Wire-format boundary/);
expect(noteForSlug("proto-rpc-surface")).not.toMatch(/Go|Ruby|context\.Context/);
});

it("appends INFO.md and promptAppend at the end, both after the framework section", () => {
const { prompt } = assemblePrompt({
detectedTags: ["nextjs"],
Expand Down
1 change: 1 addition & 0 deletions packages/processor/src/prompt/file-language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const EXT_TO_LANGUAGE: Record<string, string> = {
".mjs": "javascript",
".py": "python",
".rb": "ruby",
".ru": "ruby",
".php": "php",
".go": "go",
".rs": "rust",
Expand Down
35 changes: 35 additions & 0 deletions packages/processor/src/prompt/highlights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,41 @@ export const TECH_HIGHLIGHTS: TechHighlight[] = [
"`redirect_to params[:return_to]` is an open redirect; check for an allowlist",
],
},
{
tag: "grpc-ruby",
title: "Ruby gRPC",
languages: ["ruby"],
bullets: [
"`class X < Some::Service` and `GRPC::GenericService` define public RPC methods; each method needs explicit auth + authorization",
"`GRPC::ServerInterceptor` is the per-RPC gate — confirm it wraps every method and streaming mode",
"`call.metadata` carries attacker-controlled headers/tokens; verify JWT/API keys before using request fields",
"`request` message fields are untrusted even when protobuf-typed — check SQL, subprocess, filesystem, and HTTP sinks",
"`add_http2_port`, `server.handle`, and `run_till_terminated` mark the exposed server boundary",
"`async-grpc` runs work in fibers; avoid shared mutable auth/session state across concurrent RPCs",
],
},
{
tag: "falcon-ruby",
title: "Ruby Falcon",
languages: ["ruby"],
bullets: [
"Falcon exposes Rack/async-http apps; `config.ru` is the deployment boundary but auth still belongs in the app path",
"`Async::HTTP`, `Async::Container`, and `Async::Service` bootstrap long-lived services — verify what is externally reachable",
"Per-route or per-RPC auth must run inside the app, not only in a front proxy or service manager",
"Fiber concurrency makes globals/class vars risky for tenant, user, or request-scoped state",
],
},
{
tag: "async-websocket",
title: "Ruby async-websocket",
languages: ["ruby"],
bullets: [
"`Async::WebSocket::Adapters::Rack.open` upgrades to a long-lived public connection; authenticate during the handshake",
"`connection.read` / `message.buffer` are attacker-controlled per-message inputs — validate every message shape",
"Authorization must be checked for each privileged message/action, not just once at connection open",
"WebSocket loops need rate limits and close/error handling to avoid unbounded work or leaked exceptions",
],
},

// --- Go frameworks ---
{
Expand Down
8 changes: 8 additions & 0 deletions packages/processor/src/prompt/slug-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ const SLUG_NOTES: Record<string, string> = {
"Confirm the endpoint truly has no auth (not just a permissive guard) and that it returns sensitive data.",
"service-entry-point":
"Coarse flag — verify there's an actual auth gap, not just an internal-only handler reachable via service mesh.",
"proto-rpc-surface":
"Wire-format boundary — treat RPC request fields as untrusted and trace sensitive fields to auth, validation, and sink use.",
"object-injection":
"User-controlled keys into `obj[x] = v` without an allowlist enable prototype-pollution / overwriting safe defaults.",
"spread-operator-injection":
Expand Down Expand Up @@ -184,6 +186,12 @@ const SLUG_NOTES: Record<string, string> = {
"Weak entry-point candidate — confirm a `before` callback or middleware enforces auth on this Action class.",
"rb-roda-route":
"Weak entry-point candidate — auth must wrap the tree node, not just the leaf; confirm scope.",
"rb-grpc-service":
"Ruby gRPC entry-point candidate — confirm an interceptor or method-level check authenticates `call.metadata` and authorizes each RPC.",
"rb-async-websocket-handler":
"Async WebSocket entry-point candidate — authenticate the handshake and validate/authorize every `connection.read` message.",
"rb-falcon-rack-app":
"Falcon/Rack bootstrap candidate — confirm exposed async services route requests through app-level auth, not only deployment config.",

"go-gorilla-route":
"Weak entry-point candidate — confirm `router.Use(auth)` covers this subrouter; `PathPrefix(...).Handler(other)` doesn't inherit.",
Expand Down
48 changes: 48 additions & 0 deletions packages/scanner/src/__tests__/detect-tech.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,54 @@ describe("detectTech", () => {
expect(detectTech(tmpRoot).tags).toContain("rails");
});

it("detects async-grpc Ruby stacks from Gemfile", () => {
write("Gemfile", `source "https://rubygems.org"\ngem "async-grpc", "~> 0.3"\n`);
const tags = detectTech(tmpRoot).tags;
expect(tags).toEqual(expect.arrayContaining(["ruby", "async-grpc", "grpc-ruby", "grpc"]));
});

it("detects async-grpc Ruby stacks from Gemfile.lock", () => {
// Bundler spec entries are indented with exactly four spaces.
write("Gemfile.lock", `GEM\n specs:\n async-grpc (1.2.3)\n`);
const tags = detectTech(tmpRoot).tags;
expect(tags).toEqual(expect.arrayContaining(["ruby", "async-grpc", "grpc-ruby", "grpc"]));
});

it("detects plain Ruby grpc without async-grpc", () => {
write("Gemfile", `source "https://rubygems.org"\ngem "grpc", "~> 1.60"\n`);
const tags = detectTech(tmpRoot).tags;
expect(tags).toEqual(expect.arrayContaining(["ruby", "grpc-ruby", "grpc"]));
expect(tags).not.toContain("async-grpc");
});

it("detects Ruby async-websocket from Gemfile", () => {
write("Gemfile", `source "https://rubygems.org"\ngem "async-websocket", "~> 0.30"\n`);
const tags = detectTech(tmpRoot).tags;
expect(tags).toEqual(expect.arrayContaining(["ruby", "async-websocket"]));
});

it("does not detect gRPC from commented gems or similarly named gems", () => {
write("Gemfile", `source "https://rubygems.org"\n# gem "grpc"\ngem "grpc-tools", "~> 1.60"\n`);
const tags = detectTech(tmpRoot).tags;
expect(tags).toContain("ruby");
expect(tags).not.toContain("grpc");
expect(tags).not.toContain("grpc-ruby");
});

it("keeps Python Falcon and Ruby Falcon as distinct tags", () => {
write("pyproject.toml", `[project]\nname = "x"\ndependencies = ["falcon"]\n`);
let tags = detectTech(tmpRoot).tags;
expect(tags).toContain("falcon");
expect(tags).not.toContain("falcon-ruby");

fs.rmSync(tmpRoot, { recursive: true, force: true });
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-detect-tech-"));
write("Gemfile", `source "https://rubygems.org"\ngem "falcon", "~> 0.50"\n`);
tags = detectTech(tmpRoot).tags;
expect(tags).toContain("falcon-ruby");
expect(tags).not.toContain("falcon");
});

it("detects Gin from go.mod", () => {
write("go.mod", `module example.com/x\n\nrequire (\n github.com/gin-gonic/gin v1.10.0\n)\n`);
const tags = detectTech(tmpRoot).tags;
Expand Down
61 changes: 61 additions & 0 deletions packages/scanner/src/__tests__/framework-matchers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { phpLaravelRouteMatcher } from "../matchers/php-laravel-route.js";
import { pyDjangoViewMatcher } from "../matchers/py-django-view.js";
import { pyFastapiRouteMatcher } from "../matchers/py-fastapi-route.js";
import { pyFlaskRouteMatcher } from "../matchers/py-flask-route.js";
import { rbAsyncWebSocketHandlerMatcher } from "../matchers/rb-async-websocket-handler.js";
import { rbFalconRackAppMatcher } from "../matchers/rb-falcon-rack-app.js";
import { rbGrpcServiceMatcher } from "../matchers/rb-grpc-service.js";
import { rbRailsControllerMatcher } from "../matchers/rb-rails-controller.js";

describe("framework entry-point matchers", () => {
Expand Down Expand Up @@ -135,6 +138,64 @@ end
expect(matches.length).toBeGreaterThan(0);
});

it("rb-grpc-service detects service classes, methods, metadata, interceptors, and server bootstrap", () => {
const src = `
class GreeterServer < Helloworld::Greeter::Service
def lookup(request, call)
token = call.metadata["authorization"]
end
end

class AuthInterceptor < GRPC::ServerInterceptor
def request_response(request: nil, call: nil, method: nil)
end
end

server.handle(GreeterServer)
server.add_http2_port("0.0.0.0:50051", :this_port_is_insecure)
server.run_till_terminated
`;
const matches = rbGrpcServiceMatcher.match(src, "lib/greeter_server.rb");
expect(matches.length).toBeGreaterThanOrEqual(5);
});

it("rb-grpc-service ignores generated and vendored Ruby files", () => {
const src = `
# Generated by the protocol buffer compiler. DO NOT EDIT!
class Greeter < Helloworld::Greeter::Service
end
`;
expect(rbGrpcServiceMatcher.match(src, "lib/helloworld_services_pb.rb")).toEqual([]);
expect(rbGrpcServiceMatcher.match(src, "vendor/bundle/ruby/greeter.rb")).toEqual([]);
expect(rbGrpcServiceMatcher.match(src, "spec/greeter_server.rb")).toEqual([]);
expect(rbGrpcServiceMatcher.match(src, "lib/greeter_server.rb")).toEqual([]);
});

it("rb-async-websocket-handler detects async websocket receive loops", () => {
const src = `
Async::WebSocket::Adapters::Rack.open(env) do |connection|
while message = connection.read
handle_message(message.buffer)
end
end
`;
const matches = rbAsyncWebSocketHandlerMatcher.match(src, "lib/stream_handler.rb");
expect(matches.length).toBeGreaterThanOrEqual(3);
});

it("rb-falcon-rack-app detects strong Falcon and Async service signals", () => {
const src = `
require "falcon"
service = Falcon::Service.new
endpoint = Async::HTTP::Endpoint.parse("https://example.com")
`;
const matches = rbFalconRackAppMatcher.match(src, "lib/server.rb");
expect(matches.length).toBeGreaterThanOrEqual(3);
expect(rbFalconRackAppMatcher.match(`run App.new\nmap "/api" do\nend\n`, "config.ru")).toEqual(
[],
);
});

it("php-laravel-route detects Route::get and DB::raw", () => {
const src = `<?php
use Illuminate\\Support\\Facades\\Route;
Expand Down
Loading