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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Every entry says what changed as a caller sees it, why, and what to watch for

### Fixed

- When pinned Semgrep cannot parse a complete TypeScript file, scan its runtime JavaScript with pinned esbuild, retaining original errors, source maps and explicit type-syntax coverage limits. Compilation or missing runtime coverage fails the run.
- The scanner helper requires Node 22.16 or newer.
- **Agent fixtures support private dialogs with different addresses and message IDs per account.**
Set the sender's dialog and enable per-account IDs in the private cast; each case needs a unique
synthetic label to prove receipt and cleanup on the owner's side.
Expand Down
14 changes: 10 additions & 4 deletions docs/security/METHOD.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ hosted scanner.
tool can produce it, so two runs can be diffed.
5. Do the manual review (section "Checklist"). Every item gets a verdict in `runs/<date>/REPORT.md`:
safe, finding, or not applicable — each with the evidence that supports it.
6. Before a candidate finding gets an id, run the Trail of Bits `fp-check` skill on it, through its
helper agents (its deep route requires them). Keep the plugin disabled between audits: its Stop
hook fires on every session in every project ([`LESSONS.md`](LESSONS.md)). A confirmed finding is
journaled, then added to the private register.
6. Before a candidate finding gets an id, verify it against the current source and a bounded
synthetic regression. Record the evidence and limitations in the private report. Run scanners
separately; keep plugin hooks disabled during continuation work so a Stop hook cannot interrupt
the session ([`LESSONS.md`](LESSONS.md)). A confirmed finding is journaled, then added to the
private register.
7. Compare with the previous run: new findings, findings fixed, findings that came back. The
comparison is the last section of `REPORT.md`.

Expand Down Expand Up @@ -59,6 +60,11 @@ Command-line scanners, each pinned to a version recorded in `SCOPE.md`:
- `semgrep` CE, pinned to a release at least two weeks old (1.178.0 on 2026-10-09; run 1 used
1.180.0, which was too new) — rule packs `p/javascript`, `p/typescript`, `p/nodejs`, `p/secrets`,
`p/github-actions`.
When its pinned TypeScript parser fails on a whole file, the runner scans that file’s runtime
JavaScript compiled with `esbuild@0.25.12` targeting ES2019. It maps findings back to source and
keeps the original report, emitted code and source maps under `supplement/`. Compilation, runtime
parsing or missing coverage fails the run. Type-only syntax remains outside this fallback;
partial parsing warnings remain visible.
- `zizmor` — GitHub Actions workflows.
- CodeQL — through GitHub default setup; alerts read with `gh api`.

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@
},
"type": "module",
"engines": {
"node": ">=22"
"node": "^22.16.0 || >=24"
},
"packageManager": "pnpm@11.20.0",
"files": [
"dist",
"README.md",
"LICENSE",
"scripts/security/scan",
"scripts/security/semgrep.ts",
"scripts/security/socket",
"scripts/agent/run-agent",
"scripts/agent/run-payload",
Expand Down
87 changes: 87 additions & 0 deletions scripts/security-semgrep.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { describe, expect, it } from "vitest"
import { scanSemgrep } from "./security/semgrep.ts"

const fixture = () => {
const directory = mkdtempSync(join(tmpdir(), "scanner-runtime-"))
const root = join(directory, "source")
mkdirSync(join(root, "src"), { recursive: true })
writeFileSync(join(root, "src/bad.ts"), "const value = request.input\nexec(value)\n")
return { root, output: join(directory, "raw/semgrep.json"), supplement: join(directory, "supplement") }
}
const result = (errors: unknown[] = [], results: unknown[] = [], scanned = ["src/bad.ts"]) =>
JSON.stringify({ results, errors, paths: { scanned } })
const error = { type: "Syntax error", path: "src/bad.ts" }

describe("Semgrep runtime fallback", () => {
it("retains source failures and maps a positive runtime control to its original source", () => {
const { root, output, supplement } = fixture()
let scans = 0
const done = scanSemgrep(root, output, supplement, (command, args) => {
if (command === "pnpm") {
expect(args).toContain("esbuild@0.25.12")
expect(args).toContain("./src/bad.ts")
const runtime = join(supplement, "runtime/src")
mkdirSync(runtime, { recursive: true })
writeFileSync(join(runtime, "bad.js"), "exec(value);\n")
writeFileSync(
join(runtime, "bad.js.map"),
JSON.stringify({
version: 3,
sources: ["bad.ts"],
names: [],
mappings: "AACA",
sourcesContent: ["const value = request.input\nexec(value)\n"],
}),
)
return { code: 0, stdout: "" }
}
return {
code: 0,
stdout:
++scans === 1
? result([error])
: result(
[],
[
{
check_id: "synthetic.unsafe-exec",
path: "src/bad.js",
start: { line: 1, col: 1 },
end: { line: 1, col: 5 },
},
],
["src/bad.js"],
),
}
})
expect(done.errors).toEqual([])
expect(done.results[0]).toMatchObject({ path: "src/bad.ts", start: { line: 2, col: 1 } })
expect(JSON.parse(readFileSync(join(supplement, "source.json"), "utf8")).errors).toEqual([error])
expect(done.runtimeCoverage).toMatchObject({ sources: ["src/bad.ts"], typeSyntaxAnalyzed: false })
})

it.each(["compile", "parse", "missing"])("does not write a successful result after a %s failure", (failure) => {
const { root, output, supplement } = fixture()
let scans = 0
expect(() =>
scanSemgrep(root, output, supplement, (command) => {
if (command === "pnpm") return { code: failure === "compile" ? 1 : 0, stdout: "" }
return { code: 0, stdout: ++scans === 1 ? result([error]) : result(failure === "parse" ? [error] : [], [], []) }
}),
).toThrow()
expect(existsSync(output)).toBe(false)
})

it("rejects a scanner error path outside the export before starting a compiler", () => {
const { root, output, supplement } = fixture()
expect(() =>
scanSemgrep(root, output, supplement, (command) => {
expect(command).toBe("semgrep")
return { code: 0, stdout: result([{ type: "Syntax error", path: "../outside.ts" }]) }
}),
).toThrow("outside the source export")
})
})
11 changes: 4 additions & 7 deletions scripts/security/scan
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# scripts/security/scan ~/Projects/AI/cli-private/security/runs/2026-11-01 ~/Projects/AI/max-cli ~/Projects/AI/tg-cli
# Writes <run-dir>/SCOPE.md and <run-dir>/raw/<repo>/{osv,semgrep,zizmor,codeql}.json.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"

run="$1"; shift
[ "$#" -gt 0 ] || { echo "usage: scan <run-dir> <checkout>..." >&2; exit 2; }
Expand Down Expand Up @@ -40,6 +41,7 @@ done
echo "- osv-scanner $(osv-scanner --version | head -1 | awk '{print $3}')"
echo "- semgrep $(semgrep --version)"
echo "- $(zizmor --version)"
echo "- esbuild 0.25.12 (runtime parse fallback only)"
echo
echo "## GitHub settings"
echo
Expand Down Expand Up @@ -74,13 +76,8 @@ for checkout in "$@"; do
if [ -f pnpm-lock.yaml ]; then
run_tool osv-scanner "0 1" "$out/osv.json" osv-scanner scan source -r --format json .
fi
run_tool semgrep "0" "$out/semgrep.json" semgrep scan --metrics=off --quiet --json \
--config p/javascript --config p/typescript --config p/nodejs --config p/secrets \
--config p/github-actions .
if jq -e '[.errors[] | select(.type[0]? != "PartialParsing")] | length > 0' "$out/semgrep.json" > /dev/null; then
echo "$name: semgrep reported errors other than PartialParsing" >&2
exit 1
fi
run_tool semgrep "0" /dev/null node --experimental-strip-types "$here/semgrep.ts" \
"$work/$name" "$out/semgrep.json" "$run/supplement/$name"
if [ -d .github ]; then
run_tool zizmor "0 11 12 13 14" "$out/zizmor.json" zizmor --offline --format json .github
fi
Expand Down
154 changes: 154 additions & 0 deletions scripts/security/semgrep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { spawnSync } from "node:child_process"
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { SourceMap } from "node:module"
import { dirname, extname, isAbsolute, relative, resolve } from "node:path"
import { fileURLToPath } from "node:url"

const PACKS = ["p/javascript", "p/typescript", "p/nodejs", "p/secrets", "p/github-actions"]
const ESBUILD = "0.25.12"
type Failure = { type: string | unknown[]; path?: string }
type Match = {
check_id: string
path: string
start: { line: number; col: number }
end: { line: number; col: number }
}
type Result = { results: Match[]; errors: Failure[]; paths: { scanned: string[] }; [key: string]: unknown }
type Run = (command: string, args: string[], cwd: string) => { code: number; stdout: string }
const run: Run = (command, args, cwd) => {
const result = spawnSync(command, args, {
cwd,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
timeout: 600_000,
stdio: ["ignore", "pipe", "inherit"],
})
return { code: result.status ?? 2, stdout: result.stdout ?? "" }
}
const partial = (error: Failure) => Array.isArray(error.type) && error.type[0] === "PartialParsing"
const within = (path: string, root: string) => {
const candidate = relative(root, resolve(root, path))
if (!candidate || candidate.startsWith("..") || isAbsolute(candidate))
throw new Error("scanner path is outside the source export")
return candidate
}

export const scanSemgrep = (root: string, output: string, supplement: string, invoke: Run = run): Result => {
const args = [
"scan",
"--metrics=off",
"--quiet",
"--json",
"--timeout",
"120",
"--timeout-threshold",
"0",
"--jobs",
"2",
...PACKS.flatMap((pack) => ["--config", pack]),
]
const scan = (directory: string, targets = ["."]): Result => {
const result = invoke("semgrep", [...args, ...targets], directory)
if (result.code !== 0) throw new Error(`semgrep failed with exit ${result.code}`)
const value = JSON.parse(result.stdout) as Result
if (!Array.isArray(value.results) || !Array.isArray(value.errors) || !Array.isArray(value.paths?.scanned))
throw new Error("invalid Semgrep result")
return value
}
const source = scan(root)
const failures = source.errors.filter((error) => !partial(error))
mkdirSync(supplement, { recursive: true, mode: 0o700 })
writeFileSync(resolve(supplement, "source.json"), `${JSON.stringify(source)}\n`, { mode: 0o600 })
if (failures.length) {
if (
failures.some(
(error) => error.type !== "Syntax error" || !error.path || ![".ts", ".tsx"].includes(extname(error.path)),
)
)
throw new Error("Semgrep reported an unrecoverable error")
const files = [...new Set(failures.map((error) => within(error.path as string, root)))]
const compiled = resolve(supplement, "runtime")
// ES2019 lowers the syntax rejected by the pinned parser; imports are never bundled or executed.
const build = invoke(
"pnpm",
[
"dlx",
`esbuild@${ESBUILD}`,
...files.map((file) => `./${file}`),
`--outdir=${compiled}`,
"--outbase=.",
"--platform=node",
"--target=es2019",
"--format=esm",
"--charset=ascii",
"--sourcemap=external",
"--sources-content=true",
"--log-level=error",
],
root,
)
if (build.code !== 0) throw new Error(`runtime coverage compilation failed with exit ${build.code}`)
const expected = files.map((file) => file.replace(/\.(?:tsx|ts)$/, ".js"))
const fallback = scan(
compiled,
expected.map((file) => `./${file}`),
)
writeFileSync(resolve(supplement, "runtime.json"), `${JSON.stringify(fallback)}\n`, { mode: 0o600 })
if (fallback.errors.length) throw new Error("Semgrep runtime fallback contains parsing or analysis errors")
const scanned = new Set(fallback.paths.scanned.map((file) => within(file, compiled)))
if (expected.some((file) => !scanned.has(file)))
throw new Error("runtime fallback did not scan every failed source")
const original = new Map(expected.map((file, index) => [file, files[index] as string]))
for (const match of fallback.results) {
const file = within(match.path, compiled),
sourcePath = original.get(file)
if (!sourcePath) throw new Error("runtime match has no original source")
const map = new SourceMap(JSON.parse(readFileSync(resolve(compiled, `${file}.map`), "utf8")))
const mapped = (point: { line: number; col: number }) => {
const entry = map.findEntry(point.line - 1, point.col - 1)
if (
!("originalLine" in entry) ||
!("originalColumn" in entry) ||
typeof entry.originalLine !== "number" ||
typeof entry.originalColumn !== "number"
)
throw new Error("runtime match cannot be mapped to original source")
return { ...point, line: entry.originalLine + 1, col: entry.originalColumn + 1 }
}
source.results.push({ ...match, path: sourcePath, start: mapped(match.start), end: mapped(match.end) })
}
source.errors = source.errors.filter(partial)
source.runtimeCoverage = {
compiler: `esbuild ${ESBUILD}`,
target: "es2019",
sources: files,
typeSyntaxAnalyzed: false,
originalErrors: relative(dirname(output), resolve(supplement, "source.json")),
runtimeResults: relative(dirname(output), resolve(supplement, "runtime.json")),
}
}
source.results = [
...new Map(
source.results.map((match) => [
JSON.stringify([match.check_id, match.path, match.start.line, match.start.col]),
match,
]),
).values(),
]
mkdirSync(dirname(output), { recursive: true })
writeFileSync(output, `${JSON.stringify(source)}\n`, { mode: 0o600 })
return source
}

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
scanSemgrep(
resolve(process.argv[2] as string),
resolve(process.argv[3] as string),
resolve(process.argv[4] as string),
)
} catch (error) {
console.error(error instanceof Error ? error.message : "Semgrep coverage failed")
process.exitCode = 1
}
}
Loading