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
1 change: 1 addition & 0 deletions .benchmark-fixtures-sha
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
a8d77b3b4fefc54e1f2f5e88ee2c5224956891ca
53 changes: 53 additions & 0 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: benchmark

on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: benchmark-${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true

jobs:
benchmark:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout extension
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"

- name: Install deps
run: npm ci

- name: Build extension
run: npm run build:ext

- name: Read fixtures SHA
id: sha
run: echo "value=$(cat .benchmark-fixtures-sha)" >> "$GITHUB_OUTPUT"

- name: Clone fixtures repo at pinned SHA
run: |
git clone --depth 1 https://github.com/recost-dev/extension_benchmark.git benchmark-fixtures
cd benchmark-fixtures
git fetch --depth 1 origin ${{ steps.sha.outputs.value }}
git checkout ${{ steps.sha.outputs.value }}
Comment on lines +33 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

git ls-files | grep -E "(benchmark\.yml|\.benchmark-fixtures-sha)" | head -20

Repository: recost-dev/extension

Length of output: 120


🏁 Script executed:

fd -t f "benchmark.yml" -o "\.benchmark-fixtures-sha"

Repository: recost-dev/extension

Length of output: 232


🏁 Script executed:

cat -n .github/workflows/benchmark.yml | sed -n '30,45p'

Repository: recost-dev/extension

Length of output: 810


🏁 Script executed:

cat -n .github/workflows/benchmark.yml | head -20

Repository: recost-dev/extension

Length of output: 561


🏁 Script executed:

cat .benchmark-fixtures-sha

Repository: recost-dev/extension

Length of output: 105


🏁 Script executed:

git log --oneline -n 5 -- .benchmark-fixtures-sha

Repository: recost-dev/extension

Length of output: 110


🏁 Script executed:

cat -n .github/workflows/benchmark.yml | sed -n '37,42p'

Repository: recost-dev/extension

Length of output: 426


🌐 Web query:

GitHub Actions variables shell injection escaping security

💡 Result:

GitHub Actions “variables” (contexts/expressions) can become a shell-injection risk when they’re interpolated directly into a run: (shell) script, because the ${{ ... }} expressions are evaluated by GitHub Actions first and substituted into the generated temporary shell script; untrusted input can then break out of quoting and execute attacker-controlled commands. [1][2] What to watch for 1) Inline ${{ ... }} inside run: (or script:) blocks Example pattern: run: some-command ${{ github.event.issue.title }}. The GitHub Docs explicitly show that a vulnerable workflow can be exploited when ${{ ... }} output is substituted into an inline shell script before execution. [1] 2) Untrusted sources GitHub advises treating attacker-influenced values such as issue/PR titles and bodies as untrusted input, and notes that expression evaluation can lead to script injections when used inside blocks like run. [2][3] Is escaping/quoting enough? - Naive escaping is error-prone because ${{ ... }} is replaced before the shell runs; the substituted value may still contain characters that break the script’s structure (e.g., quotes). GitHub recommends not using ${{ ... }} in run for this reason, and instead using safer patterns. [2][1] Defensive patterns that are recommended (with grounding) 1) Don’t use ${{ ... }} directly in run:; use environment variables instead GitHub’s blog recommends that ${{ }} in the run section is almost always dangerous because it macro-expands to arbitrary text in the shell command body. It recommends using ${{ }} in env: instead, then referencing the variable from the shell (example: "$TITLE" bash syntax). [2] 2) Prefer the “intermediate environment variable” approach (inline scripts) GitHub’s Secure use reference (security hardening) states that for inline scripts, the preferred approach is to set the expression result into an intermediate environment variable, so it “doesn’t interact with the script generation process,” and also advises double-quoting shell variables to avoid word-splitting. [3] 3) Consider using an action instead of inline shell script for parsing/processing GitHub states that using an action (e.g., JavaScript) that receives the context value as an argument is not vulnerable to the injection attack described, because the value is passed as an argument rather than used to generate a shell script. [3] 4) If you must serialize to JSON (to preserve structure), be careful with quoting boundaries Community guidance shows that placing ${{ toJSON(...) }} inside shell quoting can break parsing if the JSON contains characters that terminate or conflict with the surrounding quotes (e.g., single quotes or backticks/quotes depending on the quoting style). One workaround is to pass the serialized output via an environment variable, and then read/echo it from the shell using shell-safe quoting. [4] Practical “safe” template - Use env: to bring the untrusted value in, then reference it in run: as a normal shell variable (and quote it): - Set env: TITLE: ${{ github.event.pull_request.title }} - In run:, use echo "$TITLE" (double quotes) [2][3] - If you want, you can also validate/sanitize the value before using it as part of a command or argument. (GitHub’s hardening guidance emphasizes that you should ensure untrusted values do not flow into places where they could be interpreted as executable code.) [1][3] Bottom line - GitHub Actions “expression variables” are not automatically shell-escaped when interpolated into run:; they’re substituted into the shell script before execution, so untrusted content can lead to command/script injection. [1][2] - The most reliable mitigation is to move ${{ ... }} out of run: into env: (or into an action argument) and then handle the value as a quoted shell variable (double-quoted) rather than raw substituted text. [2][3]

Citations:


Validate and quote the pinned SHA before using it in shell commands.

At line 35, the SHA is read from .benchmark-fixtures-sha and interpolated into shell commands at lines 41–42 without validation or quoting. Since this workflow runs on pull requests, a malicious PR can modify the file to inject shell commands. GitHub Actions ${{ ... }} expressions are macro-expanded before shell execution, so untrusted input can break out and execute arbitrary code.

Restrict the SHA to the expected 40-character hex format and either quote the interpolation or move it to an env: block (GitHub's recommended pattern). The suggested patch below adds validation and quotes; consider alternatively using env: for stronger isolation.

Suggested hardening patch
       - name: Read fixtures SHA
         id: sha
-        run: echo "value=$(cat .benchmark-fixtures-sha)" >> "$GITHUB_OUTPUT"
+        run: |
+          SHA="$(tr -d '\n\r' < .benchmark-fixtures-sha)"
+          if ! [[ "$SHA" =~ ^[0-9a-f]{40}$ ]]; then
+            echo "Invalid fixtures SHA: '$SHA'" >&2
+            exit 1
+          fi
+          echo "value=$SHA" >> "$GITHUB_OUTPUT"
@@
       - name: Clone fixtures repo at pinned SHA
         run: |
           git clone --depth 1 https://github.com/recost-dev/extension_benchmark.git benchmark-fixtures
           cd benchmark-fixtures
-          git fetch --depth 1 origin ${{ steps.sha.outputs.value }}
-          git checkout ${{ steps.sha.outputs.value }}
+          git fetch --depth 1 origin "${{ steps.sha.outputs.value }}"
+          git checkout "${{ steps.sha.outputs.value }}"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Read fixtures SHA
id: sha
run: echo "value=$(cat .benchmark-fixtures-sha)" >> "$GITHUB_OUTPUT"
- name: Clone fixtures repo at pinned SHA
run: |
git clone --depth 1 https://github.com/recost-dev/extension_benchmark.git benchmark-fixtures
cd benchmark-fixtures
git fetch --depth 1 origin ${{ steps.sha.outputs.value }}
git checkout ${{ steps.sha.outputs.value }}
- name: Read fixtures SHA
id: sha
run: |
SHA="$(tr -d '\n\r' < .benchmark-fixtures-sha)"
if ! [[ "$SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "Invalid fixtures SHA: '$SHA'" >&2
exit 1
fi
echo "value=$SHA" >> "$GITHUB_OUTPUT"
- name: Clone fixtures repo at pinned SHA
run: |
git clone --depth 1 https://github.com/recost-dev/extension_benchmark.git benchmark-fixtures
cd benchmark-fixtures
git fetch --depth 1 origin "${{ steps.sha.outputs.value }}"
git checkout "${{ steps.sha.outputs.value }}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/benchmark.yml around lines 33 - 42, The workflow reads an
unvalidated SHA from .benchmark-fixtures-sha and injects it directly into shell
git commands in the steps "Read fixtures SHA" and "Clone fixtures repo at pinned
SHA", which allows command injection; fix by validating the value before use
(ensure it matches /^[0-9a-fA-F]{40}$/) and then use a safe interpolation method
(either export the validated value into an env: variable and reference
$PINNED_SHA in the shell, or quote the parameter when using ${{
steps.sha.outputs.value }}), and fail the job if validation fails so only a
proper 40‑char hex SHA is ever passed to git fetch/checkout.


- name: Run benchmark
run: npm run benchmark -- --fixtures ./benchmark-fixtures --report benchmark/report.json

- name: Upload report artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: benchmark-report
path: benchmark/report.json
if-no-files-found: ignore
46 changes: 46 additions & 0 deletions benchmark/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Benchmark

Hand-labeled accuracy gate for the scanner. Fails CI when precision or recall drops > 1pp.

## Quick start

```bash
# Smoke (no clone, fast):
npm run benchmark:smoke

# Full (requires extension_benchmark cloned as sibling dir):
git clone https://github.com/recost-dev/extension_benchmark.git ../extension_benchmark
npm run benchmark
```

## Layout

- `runner.ts` — orchestrates per-fixture scan + metric computation. Reads `--fixtures <dir>` (default `../extension_benchmark`).
- `metrics.ts` — pure precision/recall math.
- `schema.ts` — `expected.json` types + validator.
- `report.ts` — console + markdown report formatting.
- `baseline.json` — committed metric baseline. Gate compares current run vs this.
- `_smoke/` — tiny in-repo fixture for runner development.

## CI

`.github/workflows/benchmark.yml` runs on every PR. It reads `.benchmark-fixtures-sha` (repo root), clones `extension_benchmark` at that SHA, then runs `npm run benchmark`.

## Adding a fixture

Fixtures live in `extension_benchmark`, not here. To add one:

1. Open a PR in `recost-dev/extension_benchmark` with a new `<slug>/src/...`, `<slug>/expected.json`, `<slug>/FIXTURE.md`.
2. Once merged, bump `.benchmark-fixtures-sha` in `extension`.
3. Run `npm run benchmark -- --update-baseline` locally and commit `baseline.json` if the new fixture changed it.

## Updating the baseline

When a PR legitimately improves accuracy:

```bash
npm run benchmark -- --update-baseline
git add benchmark/baseline.json
```

Commit the new baseline in the same PR as the code change. Explain why in the PR description.
7 changes: 7 additions & 0 deletions benchmark/_smoke/FIXTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# _smoke fixture

Hand-crafted minimal fixture for runner unit tests. Not vendored from any upstream.

**Scope:** 2 OpenAI calls (chat completion + embeddings in a loop) + 1 expected `batch` finding (embeddings in a `for` loop should be a single batch request).

**Why this fixture exists:** The benchmark runner needs something to iterate against during development without cloning `extension_benchmark`. This fixture is deliberately small and stable.
32 changes: 32 additions & 0 deletions benchmark/_smoke/expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"schemaVersion": 1,
"fixtureSlug": "_smoke",
"endpoints": [
{
"file": "src/openai-helper.ts",
"function": "complete",
"line": 6,
"provider": "openai",
"method": "chat.completions.create",
"must_detect": true
},
{
"file": "src/openai-helper.ts",
"function": "embedBatch",
"line": 16,
"provider": "openai",
"method": "embeddings.create",
"must_detect": true
}
],
"findings": [
{
"file": "src/openai-helper.ts",
"function": "embedBatch",
"line": 16,
"type": "batch",
"is_true_positive": true,
"notes": "Embedding call inside a for loop over items — should be batched."
}
]
}
20 changes: 20 additions & 0 deletions benchmark/_smoke/src/openai-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import OpenAI from "openai";

const client = new OpenAI();

export async function complete(prompt: string): Promise<string> {
const result = await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
});
return result.choices[0]?.message.content ?? "";
}

export async function embedBatch(items: string[]): Promise<number[][]> {
const out: number[][] = [];
for (const item of items) {
const res = await client.embeddings.create({ model: "text-embedding-3-small", input: item });
out.push(res.data[0]?.embedding ?? []);
}
return out;
}
7 changes: 7 additions & 0 deletions benchmark/baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"detectionPrecision": 0.2988505747126437,
"detectionRecall": 0.4262295081967213,
"providerAttributionAccuracy": 0.7959183673469388,
"findingPrecision": 0.07142857142857142,
"findingRecall": 0.3333333333333333
}
182 changes: 182 additions & 0 deletions benchmark/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import type { ExpectedJson } from "./schema";

const LINE_TOLERANCE = 2;

export interface DetectedEndpoint {
file: string;
line: number;
provider: string;
method: string;
}

export interface DetectedFinding {
file: string;
line: number;
type: string;
}

export interface MetricsReport {
detectionPrecision: number;
detectionRecall: number;
providerAttributionAccuracy: number;
findingPrecision: number;
findingRecall: number;
/** Per-fixture breakdown, useful for diagnosing where a regression landed. */
perFixture: PerFixtureMetrics[];
}

export interface PerFixtureMetrics {
fixtureSlug: string;
detectionPrecision: number;
detectionRecall: number;
providerAttributionAccuracy: number;
findingPrecision: number;
findingRecall: number;
truePositiveEndpoints: number;
falsePositiveEndpoints: number;
falseNegativeEndpoints: number;
truePositiveFindings: number;
falsePositiveFindings: number;
falseNegativeFindings: number;
/** Number of expected endpoints with a same-file-line detection whose provider was not "unknown". */
providerAttributionTotal: number;
/** Of those, how many had the detected provider matching the expected provider. */
providerAttributionCorrect: number;
}

/**
* Compute metrics for a single fixture against its expected.json + scanner output.
* Pure function — no I/O.
*
* Matching rules:
* - An expected endpoint matches a detected endpoint when file matches AND provider matches AND
* (method OR methodSignature equivalent) matches AND |detected.line - expected.line| <= 2.
* - When file+line match but provider differs, the detection is BOTH a false positive (wrong provider)
* AND a false negative (missed the expected entry).
* - Provider attribution accuracy: of the detected endpoints with provider !== "unknown" that matched
* something on file+line, what fraction got the provider right?
* - Findings: file + type + |line| <= 2 are matched. Same FP/FN logic.
*/
export function computeMetrics(
expected: ExpectedJson,
detected: DetectedEndpoint[],
detectedFindings: DetectedFinding[]
): PerFixtureMetrics {
const endpointMatch = matchPairs(
expected.endpoints,
detected,
(e, d) => e.file === d.file && e.provider === d.provider && methodsEquivalent(e.method, d.method) && Math.abs(e.line - d.line) <= LINE_TOLERANCE,
);

// Provider attribution: any detected entry on the right file+line, regardless of provider, counts toward the denominator.
let attributionCorrect = 0;
let attributionTotal = 0;
for (const e of expected.endpoints) {
const sameFileLine = detected.find(d => d.file === e.file && Math.abs(d.line - e.line) <= LINE_TOLERANCE);
if (sameFileLine && sameFileLine.provider !== "unknown") {
attributionTotal += 1;
if (sameFileLine.provider === e.provider) attributionCorrect += 1;
}
}

const findingMatch = matchPairs(
expected.findings,
detectedFindings,
(e, d) => e.file === d.file && e.type === d.type && Math.abs(e.line - d.line) <= LINE_TOLERANCE,
);

return {
fixtureSlug: expected.fixtureSlug,
detectionPrecision: safeRatio(endpointMatch.truePositives, endpointMatch.truePositives + endpointMatch.falsePositives),
detectionRecall: safeRatio(endpointMatch.truePositives, endpointMatch.truePositives + endpointMatch.falseNegatives),
providerAttributionAccuracy: attributionTotal === 0 ? 1 : attributionCorrect / attributionTotal,
findingPrecision: safeRatio(findingMatch.truePositives, findingMatch.truePositives + findingMatch.falsePositives),
findingRecall: safeRatio(findingMatch.truePositives, findingMatch.truePositives + findingMatch.falseNegatives),
truePositiveEndpoints: endpointMatch.truePositives,
falsePositiveEndpoints: endpointMatch.falsePositives,
falseNegativeEndpoints: endpointMatch.falseNegatives,
truePositiveFindings: findingMatch.truePositives,
falsePositiveFindings: findingMatch.falsePositives,
falseNegativeFindings: findingMatch.falseNegatives,
providerAttributionTotal: attributionTotal,
providerAttributionCorrect: attributionCorrect,
};
}

/** Aggregate per-fixture metrics into a global report. */
export function aggregate(perFixture: PerFixtureMetrics[]): MetricsReport {
const sum = perFixture.reduce(
(acc, m) => ({
tpE: acc.tpE + m.truePositiveEndpoints,
fpE: acc.fpE + m.falsePositiveEndpoints,
fnE: acc.fnE + m.falseNegativeEndpoints,
tpF: acc.tpF + m.truePositiveFindings,
fpF: acc.fpF + m.falsePositiveFindings,
fnF: acc.fnF + m.falseNegativeFindings,
attCorrect: acc.attCorrect + m.providerAttributionCorrect,
attTotal: acc.attTotal + m.providerAttributionTotal,
}),
{ tpE: 0, fpE: 0, fnE: 0, tpF: 0, fpF: 0, fnF: 0, attCorrect: 0, attTotal: 0 },
);

return {
detectionPrecision: safeRatio(sum.tpE, sum.tpE + sum.fpE),
detectionRecall: safeRatio(sum.tpE, sum.tpE + sum.fnE),
providerAttributionAccuracy: sum.attTotal === 0 ? 1 : sum.attCorrect / sum.attTotal,
findingPrecision: safeRatio(sum.tpF, sum.tpF + sum.fpF),
findingRecall: safeRatio(sum.tpF, sum.tpF + sum.fnF),
perFixture,
};
}

interface MatchResult { truePositives: number; falsePositives: number; falseNegatives: number; }

function matchPairs<E, D>(expected: E[], detected: D[], match: (e: E, d: D) => boolean): MatchResult {
const expectedMatched = new Array<boolean>(expected.length).fill(false);
const detectedMatched = new Array<boolean>(detected.length).fill(false);

for (let ei = 0; ei < expected.length; ei++) {
for (let di = 0; di < detected.length; di++) {
if (detectedMatched[di]) continue;
if (match(expected[ei], detected[di])) {
expectedMatched[ei] = true;
detectedMatched[di] = true;
break;
}
}
}

const truePositives = expectedMatched.filter(Boolean).length;
const falsePositives = detectedMatched.filter(m => !m).length;
const falseNegatives = expectedMatched.filter(m => !m).length;
return { truePositives, falsePositives, falseNegatives };
}

function safeRatio(numerator: number, denominator: number): number {
if (denominator === 0) return 1;
return numerator / denominator;
}

/**
* Methods are considered equivalent if either:
* - exact string match
* - both refer to the same SDK chain regardless of separator (defensive — fixture authors might use "." or " ")
* - one is a dot-suffix of the other (e.g. fixture: "chat.completions.create",
* scanner output: "client.chat.completions.create"). The AST scanner emits the
* full receiver chain ("client.foo.bar.baz"); fixtures typically omit the receiver.
*/
function methodsEquivalent(a: string, b: string): boolean {
if (a === b) return true;
const na = normalize(a);
const nb = normalize(b);
if (na === nb) return true;
if (na.length > 0 && nb.length > 0) {
if (na.endsWith("." + nb)) return true;
if (nb.endsWith("." + na)) return true;
}
return false;
}

function normalize(s: string): string {
return s.replace(/\s+/g, "").toLowerCase();
}
Loading
Loading