Skip to content
Merged

Dev #74

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
22 changes: 11 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'

- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v6
with:
version: 9

Expand All @@ -32,7 +32,7 @@ jobs:
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT

- name: Setup pnpm cache
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
Expand All @@ -58,15 +58,15 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'

- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v6
with:
version: 9

Expand All @@ -77,7 +77,7 @@ jobs:
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT

- name: Setup pnpm cache
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
Expand All @@ -92,7 +92,7 @@ jobs:
run: pnpm test:ci

- name: Upload coverage to artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: coverage-report
Expand All @@ -106,15 +106,15 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'

- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v6
with:
version: 9

Expand All @@ -125,7 +125,7 @@ jobs:
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT

- name: Setup pnpm cache
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
Expand All @@ -141,7 +141,7 @@ jobs:
NODE_ENV: production

- name: Upload build artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: build-output
path: .next/
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

Expand All @@ -32,7 +32,7 @@ jobs:
publish_results: false

- name: Upload results
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: scorecard-results
path: scorecard-results.json
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,7 @@ We value all contributions and will:

- **General Questions**: Open a [GitHub Discussion](https://github.com/csupenn/topflow/discussions)
- **Bug Reports**: Open a [GitHub Issue](https://github.com/csupenn/topflow/issues)
- **Security Issues**: Email charlie@charliesu.com
- **Security Issues**: Report privately; see [SECURITY.md](SECURITY.md)
- **Feature Proposals**: Open an issue with the "feature request" label

---
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ database and explains what to upgrade.
([why](https://www.topflow.dev/blog/untrusted-reasoning-worker-llm-security)).
- **Runs show sample results by default.** Turn on **Run a real scan** in the run dialog for live data. Public
repos work without a key; a GitHub token raises GitHub's rate limit and allows private repos.
- **No AI spending unless you ask.** The report is built from the scan data. An LLM report (your own key and
quota) runs only when you switch on **Write the report with my AI key** for that run; saved keys never turn it on.

**[Scan a repo →](https://www.topflow.dev/builder?template=github-security-scanner)**

Expand Down
200 changes: 200 additions & 0 deletions app/api/execute-workflow/__tests__/scanner-real-scan-and-cost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* @jest-environment node
*/

/**
* Real-scan design revision 2 (docs/architecture/osv-real-scan-design.md §15), acceptance criteria 1, 2, 5.
*
* Runs the REAL route + engine with the GitHub Scanner template and mocks only the edges:
* - `ai` (every AI-provider call goes through generateText / generateObject / embed) → counted,
* - `lib/osv/scanner` (no network) → observable in-process calls,
* - global fetch → serves api.github.com metadata, and the app's own routes the way local development
* would (so the old HTTP-to-self path can run far enough to reach the LLM and fail for the right reason).
*/

import { GITHUB_SCANNER_NODES, GITHUB_SCANNER_EDGES } from "@/lib/templates/github-scanner"
import { getRepoAnalysis } from "@/lib/demo-data/github-repos"

jest.mock("@/lib/security/upstash-rate-limit-store", () => ({ createUpstashStore: () => null }))
// Full scanner runs include the template's simulated step delays (~5 s each).
jest.setTimeout(30_000)

jest.mock("ai", () => {
const actual = jest.requireActual("ai")
return {
...actual,
generateText: jest.fn(async () => ({ text: "generated", files: [] })),
generateObject: jest.fn(async () => ({
object: { prioritizedFindingIds: [], recommendations: [], summaryLabel: "MINOR_ISSUES" },
})),
embed: jest.fn(async () => ({ embedding: [0] })),
}
})

const FAKE_SCAN = {
repository: "facebook/react",
stars: 1,
forks: 1,
language: "JavaScript",
lastAnalyzed: "2026-09-27T00:00:00.000Z",
scanMode: "real-osv",
securityScore: 72,
grade: "B",
vulnerabilities: {
critical: 0,
high: 1,
medium: 0,
low: 0,
details: [
{
id: "CVE-2026-0001",
osvId: "GHSA-test-0001",
severity: "HIGH",
component: "lodash@4.17.21",
description: "test advisory",
fix: "Upgrade to 4.18.0",
effort: "30+ minutes",
},
],
},
dependencyAudit: {
total: 10,
vulnerable: 1,
outdated: null,
licenses: [],
riskBreakdown: { high: 1, medium: 0, low: 0 },
ecosystemsScanned: ["npm"],
},
securityPractices: {
has_security_policy: true,
dependabot_enabled: false,
code_scanning: null,
secret_scanning: null,
branch_protection: null,
signed_commits: null,
two_factor_required: null,
},
owaspCompliance: { A06_vulnerable_components: "FAIL" },
_meta: { manifestSources: ["package-lock.json (10)"], dataSources: ["GitHub REST API", "OSV.dev"], byok: false },
}

jest.mock("@/lib/osv/scanner", () => ({ scanRepository: jest.fn() }))

import { generateText, generateObject, embed } from "ai"
import { scanRepository } from "@/lib/osv/scanner"
import { POST } from "../route"

const ownOrigin = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/|^\/api\//
let fetchCalls: string[] = []
let ip = 0

function mockFetch() {
fetchCalls = []
jest.spyOn(global, "fetch").mockImplementation(async (input: any) => {
const url = typeof input === "string" ? input : input.url
fetchCalls.push(url)
const json = (v: unknown) => new Response(JSON.stringify(v), { headers: { "Content-Type": "application/json" } })
if (url.startsWith("https://api.github.com/repos/")) {
return json({ full_name: "facebook/react", stargazers_count: 1, forks_count: 1, language: "JavaScript" })
}
// What the app's own routes would answer if the engine could reach them (local development).
if (/\/api\/demo\/github-scan\//.test(url)) return json(getRepoAnalysis("facebook/react"))
if (/\/api\/scan\/github\//.test(url)) return json(FAKE_SCAN)
throw new Error(`unexpected fetch in test: ${url}`)
})
}

async function runScanner(body: Record<string, unknown>) {
const req = new Request("http://localhost:3000/api/execute-workflow", {
method: "POST",
headers: { "Content-Type": "application/json", "x-forwarded-for": `198.51.100.${++ip}` },
body: JSON.stringify({
workflowId: "github-security-scanner",
nodes: GITHUB_SCANNER_NODES,
edges: GITHUB_SCANNER_EDGES,
userInputs: { start: "https://github.com/facebook/react" },
apiKeys: {},
...body,
}),
})
const res = await POST(req)
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let text = ""
for (;;) {
const { done, value } = await reader.read()
if (done) break
text += decoder.decode(value, { stream: true })
}
return text
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l))
}

const aiCalls = () =>
(generateText as jest.Mock).mock.calls.length +
(generateObject as jest.Mock).mock.calls.length +
(embed as jest.Mock).mock.calls.length

describe("Scanner revision 2: real scans in process, AI spending only on request", () => {
beforeEach(() => {
jest.spyOn(console, "log").mockImplementation(() => {})
jest.spyOn(console, "warn").mockImplementation(() => {})
jest.spyOn(console, "error").mockImplementation(() => {})
;(generateText as jest.Mock).mockClear()
;(generateObject as jest.Mock).mockClear()
;(embed as jest.Mock).mockClear()
;(scanRepository as jest.Mock).mockReset().mockResolvedValue(FAKE_SCAN)
mockFetch()
})
afterEach(() => jest.restoreAllMocks())

// Acceptance criterion 2
test("a real scan calls the scanner in process and never fetches the app's own origin", async () => {
const updates = await runScanner({ scanMode: "real" })
expect(fetchCalls.filter((u) => ownOrigin.test(u))).toEqual([])
expect(scanRepository).toHaveBeenCalledWith("facebook", "react", { githubToken: undefined })
expect(updates.filter((u) => u.type === "node_error" || u.type === "error")).toEqual([])
// Score 72: the grade check's "input1.score >= 80" survives input sanitizing and evaluates to false.
// (Branch skipping itself is tracked separately: the base engine runs both branches, S14.)
const gradeCheck = updates.find((u) => u.type === "node_complete" && u.nodeId === "grade-check")
expect(gradeCheck?.output).toBe(false)
})

test("the visitor's GitHub token goes to the in-process scan, not over HTTP", async () => {
await runScanner({ scanMode: "real", githubToken: "ghp_visitor_token" })
expect(scanRepository).toHaveBeenCalledWith("facebook", "react", { githubToken: "ghp_visitor_token" })
expect(fetchCalls.filter((u) => ownOrigin.test(u))).toEqual([])
})

// Acceptance criterion 1
test("sample data + saved AI keys, AI report off → zero AI-provider calls", async () => {
await runScanner({ scanMode: "demo", apiKeys: { openai: "sk-test", google: "g-test" } })
expect(aiCalls()).toBe(0)
})

test("real scan + saved AI keys, AI report off → zero AI-provider calls", async () => {
await runScanner({ scanMode: "real", apiKeys: { openai: "sk-test", google: "g-test" } })
expect(aiCalls()).toBe(0)
})

test("AI report switched on → the URW-constrained model call runs", async () => {
await runScanner({ scanMode: "real", apiKeys: { openai: "sk-test" }, aiReport: true })
expect((generateObject as jest.Mock).mock.calls.length).toBeGreaterThan(0)
})

// Acceptance criterion 5
test("AI report on + Google key → the image step runs; without the switch it doesn't", async () => {
await runScanner({ scanMode: "real", apiKeys: { openai: "sk-test", google: "g-test" }, aiReport: true })
expect((generateText as jest.Mock).mock.calls.length).toBeGreaterThan(0)
;(generateText as jest.Mock).mockClear()
await runScanner({ scanMode: "real", apiKeys: { openai: "sk-test", google: "g-test" } })
expect((generateText as jest.Mock).mock.calls.length).toBe(0)
})

test("AI report on without a Google key → no image-model call", async () => {
await runScanner({ scanMode: "real", apiKeys: { openai: "sk-test" }, aiReport: true })
expect((generateText as jest.Mock).mock.calls.length).toBe(0)
})
})
Loading
Loading