From 155c8aa86bb151eac32cac9f3171dbae95a6071f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:33:04 -0400 Subject: [PATCH 0001/1132] refactor(scanners): add shared parsing helpers --- packages/scanners/src/utils.ts | 82 ++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 packages/scanners/src/utils.ts diff --git a/packages/scanners/src/utils.ts b/packages/scanners/src/utils.ts new file mode 100644 index 00000000..0a0fa380 --- /dev/null +++ b/packages/scanners/src/utils.ts @@ -0,0 +1,82 @@ +import type { FindingIdentifiers, Severity } from "@synsec/core"; +import { runProcess } from "@synsec/scanner-sdk"; +import type { ScannerAvailability } from "@synsec/scanner-sdk"; + +export type UnknownRecord = Record; + +export function asRecord(value: unknown): UnknownRecord | undefined { + return typeof value === "object" && value !== null ? (value as UnknownRecord) : undefined; +} + +export function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +export function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +export function strings(value: unknown): string[] { + return asArray(value).filter((item): item is string => typeof item === "string"); +} + +export function normalizeSeverity(value: unknown): Severity { + const severity = asString(value)?.trim().toLowerCase(); + if (severity === "critical" || severity === "high" || severity === "medium" || severity === "low" || severity === "info") return severity; + if (severity === "error") return "high"; + if (severity === "warning" || severity === "warn") return "medium"; + if (severity === "note") return "low"; + return "unknown"; +} + +export function cvssSeverity(score: number | undefined): Severity { + if (score === undefined || !Number.isFinite(score)) return "unknown"; + if (score >= 9) return "critical"; + if (score >= 7) return "high"; + if (score >= 4) return "medium"; + if (score > 0) return "low"; + return "info"; +} + +export function identifiersFrom(values: string[]): FindingIdentifiers | undefined { + const unique = [...new Set(values.map((value) => value.trim()).filter(Boolean))]; + if (unique.length === 0) return undefined; + const result: FindingIdentifiers = {}; + const cve = unique.filter((value) => /^CVE-/i.test(value)); + const cwe = unique.filter((value) => /^CWE-/i.test(value)); + const ghsa = unique.filter((value) => /^GHSA-/i.test(value)); + const osv = unique.filter((value) => !/^CVE-/i.test(value) && !/^CWE-/i.test(value) && !/^GHSA-/i.test(value)); + if (cve.length) result.cve = cve; + if (cwe.length) result.cwe = cwe; + if (ghsa.length) result.ghsa = ghsa; + if (osv.length) result.osv = osv; + return result; +} + +export function relativeLike(path: string | undefined, root: string): string | undefined { + if (!path) return undefined; + const base = root.replace(/\\/g, "/").replace(/\/$/, ""); + const candidate = path.replace(/\\/g, "/"); + if (candidate === base) return "."; + if (candidate.startsWith(`${base}/`)) return candidate.slice(base.length + 1); + return candidate; +} + +export function safeJson(raw: string): unknown { + const trimmed = raw.trim(); + return trimmed ? (JSON.parse(trimmed) as unknown) : undefined; +} + +export async function commandAvailability(command: string, args: string[], displayName: string): Promise { + try { + const output = await runProcess(command, args, { timeoutMs: 10_000 }); + if (output.exitCode !== 0) return { available: false, reason: output.stderr.trim() || `${displayName} returned a non-zero exit code.` }; + return { available: true, version: output.stdout.trim() || output.stderr.trim() }; + } catch (error) { + return { available: false, reason: error instanceof Error ? error.message : `${displayName} is not available.` }; + } +} From e1a4eb07186149cafca4e771208e7505c849d5c4 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:33:17 -0400 Subject: [PATCH 0002/1132] refactor(scanners): split Trivy adapter --- packages/scanners/src/trivy.ts | 118 +++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 packages/scanners/src/trivy.ts diff --git a/packages/scanners/src/trivy.ts b/packages/scanners/src/trivy.ts new file mode 100644 index 00000000..c3a2b1df --- /dev/null +++ b/packages/scanners/src/trivy.ts @@ -0,0 +1,118 @@ +import { randomUUID } from "node:crypto"; +import type { Finding, ScanResult } from "@synsec/core"; +import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asNumber, asRecord, asString, commandAvailability, normalizeSeverity, safeJson } from "./utils.js"; + +function location(target: string | undefined, line?: number) { + if (!target) return undefined; + return line ? { path: target, startLine: line } : { path: target }; +} + +function vulnerability(item: Record, target?: string): Finding { + const id = asString(item.VulnerabilityID); + const pkg = asString(item.PkgName); + const fixed = asString(item.FixedVersion); + const title = asString(item.Title) ?? id ?? "Dependency vulnerability"; + return { + id: randomUUID(), + title: pkg ? `${title} in ${pkg}` : title, + description: asString(item.Description), + category: "dependency", + severity: normalizeSeverity(item.Severity), + confidence: 0.95, + scanner: { name: "trivy", ruleId: id }, + location: location(target), + identifiers: id ? { cve: [id] } : undefined, + remediation: fixed ? `Upgrade ${pkg ?? "the affected dependency"} to ${fixed} or later.` : undefined, + metadata: { + package: pkg, + installedVersion: asString(item.InstalledVersion), + fixedVersion: fixed, + primaryUrl: asString(item.PrimaryURL), + }, + }; +} + +function secret(item: Record, target?: string): Finding { + const ruleId = asString(item.RuleID); + return { + id: randomUUID(), + title: asString(item.Title) ?? ruleId ?? "Potential secret detected", + description: asString(item.Category), + category: "secret", + severity: normalizeSeverity(item.Severity), + confidence: 0.9, + scanner: { name: "trivy", ruleId }, + location: location(target, asNumber(item.StartLine)), + evidence: asString(item.Match), + remediation: "Revoke or rotate the exposed credential, then remove it from the repository and history where appropriate.", + }; +} + +function misconfiguration(item: Record, target?: string): Finding { + const ruleId = asString(item.ID) ?? asString(item.AVDID); + return { + id: randomUUID(), + title: asString(item.Title) ?? ruleId ?? "Configuration issue", + description: asString(item.Description) ?? asString(item.Message), + category: "misconfiguration", + severity: normalizeSeverity(item.Severity), + confidence: 0.9, + scanner: { name: "trivy", ruleId }, + location: location(target), + remediation: asString(item.Resolution), + metadata: { namespace: asString(item.Namespace), primaryUrl: asString(item.PrimaryURL) }, + }; +} + +export function parseTrivyJson(raw: string): Finding[] { + const parsed = asRecord(safeJson(raw)); + if (!parsed) return []; + const findings: Finding[] = []; + for (const value of asArray(parsed.Results)) { + const result = asRecord(value); + if (!result) continue; + const target = asString(result.Target); + for (const entry of asArray(result.Vulnerabilities)) { + const item = asRecord(entry); + if (item) findings.push(vulnerability(item, target)); + } + for (const entry of asArray(result.Secrets)) { + const item = asRecord(entry); + if (item) findings.push(secret(item, target)); + } + for (const entry of asArray(result.Misconfigurations)) { + const item = asRecord(entry); + if (item) findings.push(misconfiguration(item, target)); + } + } + return findings; +} + +export class TrivyAdapter implements ScannerAdapter { + readonly id = "trivy"; + readonly displayName = "Trivy"; + readonly capabilities = ["dependency", "secret", "iac", "container"] as const; + + checkAvailability(): Promise { + return commandAvailability("trivy", ["--version"], this.displayName); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + const output = await runProcess("trivy", ["fs", "--format", "json", "--scanners", "vuln,secret,misconfig", context.target.path], { + timeoutMs: context.timeoutMs ?? 10 * 60_000, + signal: context.signal, + }); + if (output.exitCode !== 0) throw new Error(`Trivy scan failed (${output.exitCode}): ${output.stderr.trim()}`); + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: parseTrivyJson(output.stdout), + diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], + }; + } +} From 0d3f87a169453add84000ce1a35dbfbb63b8fba9 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:33:32 -0400 Subject: [PATCH 0003/1132] feat(scanners): add Gitleaks secret scanning --- packages/scanners/src/gitleaks.ts | 78 +++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 packages/scanners/src/gitleaks.ts diff --git a/packages/scanners/src/gitleaks.ts b/packages/scanners/src/gitleaks.ts new file mode 100644 index 00000000..afa99c93 --- /dev/null +++ b/packages/scanners/src/gitleaks.ts @@ -0,0 +1,78 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Finding, ScanResult } from "@synsec/core"; +import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asNumber, asRecord, asString, commandAvailability, safeJson } from "./utils.js"; + +export function parseGitleaksJson(raw: string): Finding[] { + const parsed = safeJson(raw); + const findings: Finding[] = []; + for (const value of asArray(parsed)) { + const item = asRecord(value); + if (!item) continue; + const ruleId = asString(item.RuleID); + const description = asString(item.Description) ?? ruleId ?? "Potential secret detected"; + const file = asString(item.File); + const startLine = asNumber(item.StartLine); + const fingerprint = asString(item.Fingerprint); + findings.push({ + id: randomUUID(), + title: description, + description: "A credential-like value was detected. SynSec intentionally omits the secret value from normalized output.", + category: "secret", + severity: "high", + confidence: 0.97, + scanner: { name: "gitleaks", ruleId }, + location: file ? { path: file, startLine } : undefined, + fingerprint, + remediation: "Revoke or rotate the credential, remove it from the repository and Git history where necessary, and use a secret manager or environment variable instead.", + metadata: { + entropy: asNumber(item.Entropy), + commit: asString(item.Commit), + author: asString(item.Author), + date: asString(item.Date), + }, + }); + } + return findings; +} + +export class GitleaksAdapter implements ScannerAdapter { + readonly id = "gitleaks"; + readonly displayName = "Gitleaks"; + readonly capabilities = ["secret"] as const; + + checkAvailability(): Promise { + return commandAvailability("gitleaks", ["version"], this.displayName); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + const temp = await mkdtemp(join(tmpdir(), "synsec-gitleaks-")); + const report = join(temp, "report.json"); + try { + const gitRepo = await stat(join(context.target.path, ".git")).then(() => true).catch(() => false); + const mode = gitRepo ? "git" : "dir"; + const output = await runProcess( + "gitleaks", + [mode, "--report-format", "json", "--report-path", report, "--redact=100", "--no-banner", "--exit-code", "0", context.target.path], + { timeoutMs: context.timeoutMs ?? 10 * 60_000, signal: context.signal }, + ); + if (output.exitCode !== 0) throw new Error(`Gitleaks scan failed (${output.exitCode}): ${output.stderr.trim()}`); + const raw = await readFile(report, "utf8").catch(() => "[]"); + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: parseGitleaksJson(raw), + diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], + }; + } finally { + await rm(temp, { recursive: true, force: true }); + } + } +} From fac0ee4e52a5c8518a91a18424648dd1220db94c Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:33:50 -0400 Subject: [PATCH 0004/1132] feat(scanners): add OSV-Scanner dependency analysis --- packages/scanners/src/osv.ts | 120 +++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 packages/scanners/src/osv.ts diff --git a/packages/scanners/src/osv.ts b/packages/scanners/src/osv.ts new file mode 100644 index 00000000..2852dad6 --- /dev/null +++ b/packages/scanners/src/osv.ts @@ -0,0 +1,120 @@ +import { randomUUID } from "node:crypto"; +import type { Finding, ScanResult, Severity } from "@synsec/core"; +import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asRecord, asString, commandAvailability, cvssSeverity, identifiersFrom, normalizeSeverity, relativeLike, safeJson, strings } from "./utils.js"; + +function directSeverity(vuln: Record): Severity { + const databaseSpecific = asRecord(vuln.database_specific); + const direct = normalizeSeverity(databaseSpecific?.severity); + if (direct !== "unknown") return direct; + + let best = 0; + for (const value of asArray(vuln.severity)) { + const entry = asRecord(value); + const score = asString(entry?.score); + if (!score) continue; + const numeric = Number.parseFloat(score); + if (Number.isFinite(numeric)) best = Math.max(best, numeric); + } + return best > 0 ? cvssSeverity(best) : "unknown"; +} + +function firstFixedVersion(vuln: Record): string | undefined { + for (const affectedValue of asArray(vuln.affected)) { + const affected = asRecord(affectedValue); + if (!affected) continue; + for (const rangeValue of asArray(affected.ranges)) { + const range = asRecord(rangeValue); + if (!range) continue; + for (const eventValue of asArray(range.events)) { + const event = asRecord(eventValue); + const fixed = asString(event?.fixed); + if (fixed) return fixed; + } + } + } + return undefined; +} + +export function parseOsvJson(raw: string, root: string): Finding[] { + const parsed = asRecord(safeJson(raw)); + if (!parsed) return []; + const findings: Finding[] = []; + + for (const resultValue of asArray(parsed.results)) { + const result = asRecord(resultValue); + if (!result) continue; + const source = asRecord(result.source); + const sourcePath = relativeLike(asString(source?.path), root); + + for (const packageValue of asArray(result.packages)) { + const packageResult = asRecord(packageValue); + if (!packageResult) continue; + const pkg = asRecord(packageResult.package); + const name = asString(pkg?.name) ?? "unknown package"; + const version = asString(pkg?.version); + const ecosystem = asString(pkg?.ecosystem); + + for (const vulnerabilityValue of asArray(packageResult.vulnerabilities)) { + const vulnerability = asRecord(vulnerabilityValue); + if (!vulnerability) continue; + const id = asString(vulnerability.id) ?? "OSV vulnerability"; + const aliases = strings(vulnerability.aliases); + const fixed = firstFixedVersion(vulnerability); + const allIds = [id, ...aliases]; + findings.push({ + id: randomUUID(), + title: `${asString(vulnerability.summary) ?? id} in ${name}`, + description: asString(vulnerability.details), + category: "dependency", + severity: directSeverity(vulnerability), + confidence: 0.99, + scanner: { name: "osv-scanner", ruleId: id }, + location: sourcePath ? { path: sourcePath } : undefined, + identifiers: identifiersFrom(allIds), + remediation: fixed ? `Upgrade ${name} to ${fixed} or a later non-vulnerable version.` : `Review ${id} and upgrade or replace ${name} when a non-vulnerable version is available.`, + metadata: { + package: name, + version, + ecosystem, + fixedVersion: fixed, + published: asString(vulnerability.published), + modified: asString(vulnerability.modified), + }, + }); + } + } + } + return findings; +} + +export class OsvScannerAdapter implements ScannerAdapter { + readonly id = "osv-scanner"; + readonly displayName = "OSV-Scanner"; + readonly capabilities = ["dependency"] as const; + + checkAvailability(): Promise { + return commandAvailability("osv-scanner", ["--version"], this.displayName); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + const output = await runProcess( + "osv-scanner", + ["scan", "--format", "json", "source", "-r", context.target.path], + { timeoutMs: context.timeoutMs ?? 10 * 60_000, signal: context.signal }, + ); + if (output.exitCode !== 0 && output.exitCode !== 1) { + throw new Error(`OSV-Scanner failed (${output.exitCode}): ${output.stderr.trim()}`); + } + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: parseOsvJson(output.stdout, context.target.path), + diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], + }; + } +} From c62a91f29d491086f589e690c28fb361b4fb5c30 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:34:08 -0400 Subject: [PATCH 0005/1132] feat(scanners): add Opengrep SAST integration --- packages/scanners/src/opengrep.ts | 91 +++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 packages/scanners/src/opengrep.ts diff --git a/packages/scanners/src/opengrep.ts b/packages/scanners/src/opengrep.ts new file mode 100644 index 00000000..1ec2dcf0 --- /dev/null +++ b/packages/scanners/src/opengrep.ts @@ -0,0 +1,91 @@ +import { randomUUID } from "node:crypto"; +import type { Finding, ScanResult } from "@synsec/core"; +import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asRecord, asString, commandAvailability, identifiersFrom, normalizeSeverity, safeJson, strings } from "./utils.js"; + +function metadataIdentifiers(metadata: Record | undefined): string[] { + if (!metadata) return []; + const values: string[] = []; + for (const key of ["cwe", "cve", "owasp"]) { + const value = metadata[key]; + if (typeof value === "string") values.push(value); + else values.push(...strings(value)); + } + return values.flatMap((value) => value.split(/[,;]\s*/)).map((value) => value.trim()).filter(Boolean); +} + +export function parseOpengrepJson(raw: string): Finding[] { + const parsed = asRecord(safeJson(raw)); + if (!parsed) return []; + const findings: Finding[] = []; + for (const value of asArray(parsed.results)) { + const result = asRecord(value); + if (!result) continue; + const extra = asRecord(result.extra); + const start = asRecord(result.start); + const end = asRecord(result.end); + const metadata = asRecord(extra?.metadata); + const ruleId = asString(result.check_id); + const message = asString(extra?.message) ?? ruleId ?? "Static analysis finding"; + const path = asString(result.path); + const fingerprint = asString(extra?.fingerprint); + const fix = asString(extra?.fix); + findings.push({ + id: randomUUID(), + title: message, + description: asString(metadata?.description) ?? message, + category: "sast", + severity: normalizeSeverity(extra?.severity), + confidence: 0.9, + scanner: { name: "opengrep", ruleId }, + location: path ? { + path, + startLine: typeof start?.line === "number" ? start.line : undefined, + endLine: typeof end?.line === "number" ? end.line : undefined, + startColumn: typeof start?.col === "number" ? start.col : undefined, + endColumn: typeof end?.col === "number" ? end.col : undefined, + } : undefined, + identifiers: identifiersFrom(metadataIdentifiers(metadata)), + evidence: asString(extra?.lines), + remediation: fix ?? asString(metadata?.fix), + fingerprint, + metadata: { + technology: metadata?.technology, + references: metadata?.references, + likelihood: metadata?.likelihood, + impact: metadata?.impact, + confidence: metadata?.confidence, + }, + }); + } + return findings; +} + +export class OpengrepAdapter implements ScannerAdapter { + readonly id = "opengrep"; + readonly displayName = "Opengrep"; + readonly capabilities = ["sast"] as const; + + checkAvailability(): Promise { + return commandAvailability("opengrep", ["--version"], this.displayName); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + const output = await runProcess( + "opengrep", + ["scan", "--json", "--config", "auto", "--metrics", "off", "--taint-intrafile", context.target.path], + { timeoutMs: context.timeoutMs ?? 15 * 60_000, signal: context.signal }, + ); + if (output.exitCode !== 0) throw new Error(`Opengrep scan failed (${output.exitCode}): ${output.stderr.trim()}`); + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: parseOpengrepJson(output.stdout), + diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], + }; + } +} From bf3fae5f80c7a08a094ee148b4b9c294299deb90 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:34:21 -0400 Subject: [PATCH 0006/1132] feat(scanners): add Checkov IaC scanning --- packages/scanners/src/checkov.ts | 77 ++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 packages/scanners/src/checkov.ts diff --git a/packages/scanners/src/checkov.ts b/packages/scanners/src/checkov.ts new file mode 100644 index 00000000..c7379395 --- /dev/null +++ b/packages/scanners/src/checkov.ts @@ -0,0 +1,77 @@ +import { randomUUID } from "node:crypto"; +import type { Finding, ScanResult } from "@synsec/core"; +import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asNumber, asRecord, asString, commandAvailability, normalizeSeverity, safeJson } from "./utils.js"; + +function runnerObjects(parsed: unknown): Record[] { + if (Array.isArray(parsed)) return parsed.map(asRecord).filter((value): value is Record => Boolean(value)); + const record = asRecord(parsed); + return record ? [record] : []; +} + +export function parseCheckovJson(raw: string): Finding[] { + const parsed = safeJson(raw); + const findings: Finding[] = []; + for (const runner of runnerObjects(parsed)) { + const checkType = asString(runner.check_type); + const results = asRecord(runner.results); + for (const value of asArray(results?.failed_checks)) { + const item = asRecord(value); + if (!item) continue; + const ruleId = asString(item.check_id) ?? asString(item.bc_check_id); + const file = asString(item.file_path)?.replace(/^\//, ""); + const range = asArray(item.file_line_range); + const startLine = asNumber(range[0]); + const endLine = asNumber(range[1]); + const guideline = asString(item.guideline); + findings.push({ + id: randomUUID(), + title: asString(item.check_name) ?? ruleId ?? "Infrastructure configuration issue", + description: guideline, + category: "iac", + severity: normalizeSeverity(item.severity), + confidence: 0.92, + scanner: { name: "checkov", ruleId }, + location: file ? { path: file, startLine, endLine } : undefined, + remediation: guideline ? `Review the Checkov guidance: ${guideline}` : undefined, + metadata: { + framework: checkType, + resource: asString(item.resource), + checkClass: asString(item.check_class), + }, + }); + } + } + return findings; +} + +export class CheckovAdapter implements ScannerAdapter { + readonly id = "checkov"; + readonly displayName = "Checkov"; + readonly capabilities = ["iac"] as const; + + checkAvailability(): Promise { + return commandAvailability("checkov", ["--version"], this.displayName); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + const output = await runProcess( + "checkov", + ["-d", context.target.path, "-o", "json", "--quiet", "--compact"], + { timeoutMs: context.timeoutMs ?? 10 * 60_000, signal: context.signal }, + ); + if (output.exitCode !== 0 && output.exitCode !== 1) { + throw new Error(`Checkov scan failed (${output.exitCode}): ${output.stderr.trim()}`); + } + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: parseCheckovJson(output.stdout), + diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], + }; + } +} From db161f0e2c5d56e88e56dd7a35128c3142ea8fda Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:34:36 -0400 Subject: [PATCH 0007/1132] feat(scanners): add Grype vulnerability scanning --- packages/scanners/src/grype.ts | 80 ++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 packages/scanners/src/grype.ts diff --git a/packages/scanners/src/grype.ts b/packages/scanners/src/grype.ts new file mode 100644 index 00000000..45072959 --- /dev/null +++ b/packages/scanners/src/grype.ts @@ -0,0 +1,80 @@ +import { randomUUID } from "node:crypto"; +import type { Finding, ScanResult } from "@synsec/core"; +import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asRecord, asString, commandAvailability, identifiersFrom, normalizeSeverity, safeJson } from "./utils.js"; + +export function parseGrypeJson(raw: string): Finding[] { + const parsed = asRecord(safeJson(raw)); + if (!parsed) return []; + const findings: Finding[] = []; + for (const value of asArray(parsed.matches)) { + const match = asRecord(value); + if (!match) continue; + const vulnerability = asRecord(match.vulnerability); + const artifact = asRecord(match.artifact); + if (!vulnerability || !artifact) continue; + const vulnId = asString(vulnerability.id) ?? "Known vulnerability"; + const packageName = asString(artifact.name) ?? "unknown package"; + const packageVersion = asString(artifact.version); + const fix = asRecord(vulnerability.fix); + const fixedVersions = asArray(fix?.versions).filter((item): item is string => typeof item === "string"); + const aliases = asArray(vulnerability.relatedVulnerabilities) + .map(asRecord) + .map((entry) => asString(entry?.id)) + .filter((item): item is string => Boolean(item)); + const firstLocation = asRecord(asArray(artifact.locations)[0]); + const path = asString(firstLocation?.path); + findings.push({ + id: randomUUID(), + title: `${vulnId} in ${packageName}`, + description: asString(vulnerability.description), + category: "dependency", + severity: normalizeSeverity(vulnerability.severity), + confidence: 0.96, + scanner: { name: "grype", ruleId: vulnId }, + location: path ? { path } : undefined, + identifiers: identifiersFrom([vulnId, ...aliases]), + remediation: fixedVersions.length ? `Upgrade ${packageName} to ${fixedVersions[0]} or another listed fixed version.` : undefined, + metadata: { + package: packageName, + version: packageVersion, + type: asString(artifact.type), + purl: asString(artifact.purl), + dataSource: asString(vulnerability.dataSource), + namespace: asString(vulnerability.namespace), + fixedVersions, + fixState: asString(fix?.state), + }, + }); + } + return findings; +} + +export class GrypeAdapter implements ScannerAdapter { + readonly id = "grype"; + readonly displayName = "Grype"; + readonly capabilities = ["dependency", "container"] as const; + + checkAvailability(): Promise { + return commandAvailability("grype", ["version"], this.displayName); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + const output = await runProcess( + "grype", + [`dir:${context.target.path}`, "-o", "json", "--quiet"], + { timeoutMs: context.timeoutMs ?? 10 * 60_000, signal: context.signal }, + ); + if (output.exitCode !== 0) throw new Error(`Grype scan failed (${output.exitCode}): ${output.stderr.trim()}`); + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: parseGrypeJson(output.stdout), + diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], + }; + } +} From 38b2ec863cdc0d718023a463963cc2d32b4057d8 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:34:44 -0400 Subject: [PATCH 0008/1132] feat(scanners): register multi-engine scanner suite --- packages/scanners/src/index.ts | 220 ++++----------------------------- 1 file changed, 22 insertions(+), 198 deletions(-) diff --git a/packages/scanners/src/index.ts b/packages/scanners/src/index.ts index 32031dfe..23d71acf 100644 --- a/packages/scanners/src/index.ts +++ b/packages/scanners/src/index.ts @@ -1,201 +1,25 @@ -import { randomUUID } from "node:crypto"; -import type { Finding, ScanResult, Severity } from "@synsec/core"; -import type { - ScannerAdapter, - ScannerAvailability, - ScannerContext, -} from "@synsec/scanner-sdk"; -import { runProcess } from "@synsec/scanner-sdk"; - -type UnknownRecord = Record; - -function asRecord(value: unknown): UnknownRecord | undefined { - return typeof value === "object" && value !== null ? (value as UnknownRecord) : undefined; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function asNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function asArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} - -function normalizeSeverity(value: unknown): Severity { - const severity = asString(value)?.toLowerCase(); - if ( - severity === "critical" || - severity === "high" || - severity === "medium" || - severity === "low" || - severity === "info" - ) { - return severity; - } - return "unknown"; -} - -function trivyLocation(target: string | undefined, line?: number) { - if (!target) return undefined; - return line ? { path: target, startLine: line } : { path: target }; -} - -function parseTrivyVulnerability(item: UnknownRecord, target?: string): Finding { - const cve = asString(item.VulnerabilityID); - const title = asString(item.Title) ?? cve ?? "Dependency vulnerability"; - const pkg = asString(item.PkgName); - const installed = asString(item.InstalledVersion); - const fixed = asString(item.FixedVersion); - - const remediation = fixed - ? `Upgrade ${pkg ?? "the affected dependency"} to ${fixed} or later.` - : undefined; - - return { - id: randomUUID(), - title: pkg ? `${title} in ${pkg}` : title, - description: asString(item.Description), - category: "dependency", - severity: normalizeSeverity(item.Severity), - confidence: 0.95, - scanner: { - name: "trivy", - ruleId: cve, - }, - location: trivyLocation(target), - identifiers: cve ? { cve: [cve] } : undefined, - remediation, - metadata: { - package: pkg, - installedVersion: installed, - fixedVersion: fixed, - primaryUrl: asString(item.PrimaryURL), - }, - }; -} - -function parseTrivySecret(item: UnknownRecord, target?: string): Finding { - const ruleId = asString(item.RuleID); - const startLine = asNumber(item.StartLine); - return { - id: randomUUID(), - title: asString(item.Title) ?? ruleId ?? "Potential secret detected", - description: asString(item.Category), - category: "secret", - severity: normalizeSeverity(item.Severity), - confidence: 0.9, - scanner: { - name: "trivy", - ruleId, - }, - location: trivyLocation(target, startLine), - evidence: asString(item.Match), - remediation: "Revoke or rotate the exposed credential, then remove it from the repository and history where appropriate.", - }; -} - -function parseTrivyMisconfiguration(item: UnknownRecord, target?: string): Finding { - const ruleId = asString(item.ID) ?? asString(item.AVDID); - return { - id: randomUUID(), - title: asString(item.Title) ?? ruleId ?? "Configuration issue", - description: asString(item.Description) ?? asString(item.Message), - category: "misconfiguration", - severity: normalizeSeverity(item.Severity), - confidence: 0.9, - scanner: { - name: "trivy", - ruleId, - }, - location: trivyLocation(target), - remediation: asString(item.Resolution), - metadata: { - namespace: asString(item.Namespace), - primaryUrl: asString(item.PrimaryURL), - }, - }; -} - -function parseTrivyJson(raw: string): Finding[] { - const parsed = asRecord(JSON.parse(raw)); - if (!parsed) return []; - - const findings: Finding[] = []; - - for (const resultValue of asArray(parsed.Results)) { - const result = asRecord(resultValue); - if (!result) continue; - const target = asString(result.Target); - - for (const value of asArray(result.Vulnerabilities)) { - const item = asRecord(value); - if (item) findings.push(parseTrivyVulnerability(item, target)); - } - - for (const value of asArray(result.Secrets)) { - const item = asRecord(value); - if (item) findings.push(parseTrivySecret(item, target)); - } - - for (const value of asArray(result.Misconfigurations)) { - const item = asRecord(value); - if (item) findings.push(parseTrivyMisconfiguration(item, target)); - } - } - - return findings; -} - -export class TrivyAdapter implements ScannerAdapter { - readonly id = "trivy"; - readonly displayName = "Trivy"; - readonly capabilities = ["dependency", "secret", "iac", "container"] as const; - - async checkAvailability(): Promise { - try { - const output = await runProcess("trivy", ["--version"], { timeoutMs: 10_000 }); - if (output.exitCode !== 0) { - return { available: false, reason: output.stderr.trim() || "Trivy returned a non-zero exit code." }; - } - return { available: true, version: output.stdout.trim() }; - } catch (error) { - return { - available: false, - reason: error instanceof Error ? error.message : "Trivy is not available.", - }; - } - } - - async scan(context: ScannerContext): Promise { - const startedAt = new Date().toISOString(); - const output = await runProcess( - "trivy", - ["fs", "--format", "json", "--scanners", "vuln,secret,misconfig", context.target.path], - { - timeoutMs: context.timeoutMs ?? 10 * 60_000, - signal: context.signal, - }, - ); - - if (output.exitCode !== 0) { - throw new Error(`Trivy scan failed (${output.exitCode}): ${output.stderr.trim()}`); - } - - return { - scanner: this.id, - startedAt, - completedAt: new Date().toISOString(), - target: context.target, - findings: parseTrivyJson(output.stdout), - diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], - }; - } -} +import type { ScannerAdapter } from "@synsec/scanner-sdk"; +import { CheckovAdapter } from "./checkov.js"; +import { GitleaksAdapter } from "./gitleaks.js"; +import { GrypeAdapter } from "./grype.js"; +import { OpengrepAdapter } from "./opengrep.js"; +import { OsvScannerAdapter } from "./osv.js"; +import { TrivyAdapter } from "./trivy.js"; + +export { CheckovAdapter, parseCheckovJson } from "./checkov.js"; +export { GitleaksAdapter, parseGitleaksJson } from "./gitleaks.js"; +export { GrypeAdapter, parseGrypeJson } from "./grype.js"; +export { OpengrepAdapter, parseOpengrepJson } from "./opengrep.js"; +export { OsvScannerAdapter, parseOsvJson } from "./osv.js"; +export { TrivyAdapter, parseTrivyJson } from "./trivy.js"; export function builtInScanners(): ScannerAdapter[] { - return [new TrivyAdapter()]; + return [ + new OpengrepAdapter(), + new GitleaksAdapter(), + new OsvScannerAdapter(), + new TrivyAdapter(), + new GrypeAdapter(), + new CheckovAdapter(), + ]; } From e6bc642c833a5a60784b4402e3efd725a9a6411b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:41:34 -0400 Subject: [PATCH 0009/1132] feat(report): add report package --- packages/report/package.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 packages/report/package.json diff --git a/packages/report/package.json b/packages/report/package.json new file mode 100644 index 00000000..7d2c05ae --- /dev/null +++ b/packages/report/package.json @@ -0,0 +1,15 @@ +{ + "name": "@synsec/report", + "version": "0.2.0", + "private": true, + "type": "module", + "exports": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/core": "0.1.0" + } +} From f073d3ec953ce162842dae0f951b7d9d7a2a0e69 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:41:44 -0400 Subject: [PATCH 0010/1132] build(report): configure TypeScript project --- packages/report/tsconfig.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 packages/report/tsconfig.json diff --git a/packages/report/tsconfig.json b/packages/report/tsconfig.json new file mode 100644 index 00000000..ebe9ac5b --- /dev/null +++ b/packages/report/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../core" } + ], + "include": ["src/**/*.ts"] +} From 19c390e84b5480b1b4a04c1c8d188d3c50f20a9e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:42:25 -0400 Subject: [PATCH 0011/1132] feat(report): add versioned reports, baselines, SARIF and HTML --- packages/report/src/index.ts | 331 +++++++++++++++++++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 packages/report/src/index.ts diff --git a/packages/report/src/index.ts b/packages/report/src/index.ts new file mode 100644 index 00000000..b7a590e6 --- /dev/null +++ b/packages/report/src/index.ts @@ -0,0 +1,331 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { + CorrelatedFinding, + Finding, + ScanResult, + ScanTarget, + Severity, +} from "@synsec/core"; +import { correlateFindings, findingFingerprint } from "@synsec/core"; + +export const SYNSEC_REPORT_SCHEMA_VERSION = "1.0" as const; + +export interface SeverityCounts { + critical: number; + high: number; + medium: number; + low: number; + info: number; + unknown: number; +} + +export interface ScannerRunSummary { + scanner: string; + startedAt: string; + completedAt: string; + findingCount: number; + diagnostics: string[]; +} + +export interface BaselineDelta { + new: string[]; + fixed: string[]; + persisting: string[]; +} + +export interface RepositoryMetadata { + languages?: Record; + frameworks?: string[]; + fileCount?: number; +} + +export interface SynSecReport { + schemaVersion: typeof SYNSEC_REPORT_SCHEMA_VERSION; + reportId: string; + generatedAt: string; + toolVersion: string; + target: ScanTarget; + scanners: ScannerRunSummary[]; + rawFindingCount: number; + findingCount: number; + summary: SeverityCounts; + securityScore: number; + findings: CorrelatedFinding[]; + baseline?: BaselineDelta; + repository?: RepositoryMetadata; +} + +function emptyCounts(): SeverityCounts { + return { + critical: 0, + high: 0, + medium: 0, + low: 0, + info: 0, + unknown: 0, + }; +} + +export function countSeverities(findings: readonly CorrelatedFinding[]): SeverityCounts { + const counts = emptyCounts(); + for (const finding of findings) counts[finding.primary.severity] += 1; + return counts; +} + +export function calculateSecurityScore(counts: SeverityCounts): number { + const penalty = + counts.critical * 25 + + counts.high * 12 + + counts.medium * 5 + + counts.low * 1.5 + + counts.unknown * 1; + return Math.max(0, Math.round(100 - Math.min(100, penalty))); +} + +function makeReportId(target: ScanTarget, generatedAt: string): string { + return createHash("sha256") + .update(`${target.repositoryUrl ?? target.path}|${target.commitSha ?? ""}|${generatedAt}`) + .digest("hex") + .slice(0, 20); +} + +export function buildReport(input: { + target: ScanTarget; + scans: readonly ScanResult[]; + toolVersion?: string; + repository?: RepositoryMetadata; +}): SynSecReport { + const rawFindings = input.scans.flatMap((scan) => scan.findings); + const findings = correlateFindings(rawFindings); + const summary = countSeverities(findings); + const generatedAt = new Date().toISOString(); + + const report: SynSecReport = { + schemaVersion: SYNSEC_REPORT_SCHEMA_VERSION, + reportId: makeReportId(input.target, generatedAt), + generatedAt, + toolVersion: input.toolVersion ?? "0.2.0", + target: input.target, + scanners: input.scans.map((scan) => ({ + scanner: scan.scanner, + startedAt: scan.startedAt, + completedAt: scan.completedAt, + findingCount: scan.findings.length, + diagnostics: scan.diagnostics, + })), + rawFindingCount: rawFindings.length, + findingCount: findings.length, + summary, + securityScore: calculateSecurityScore(summary), + findings, + }; + + if (input.repository) report.repository = input.repository; + return report; +} + +function reportFingerprints(report: SynSecReport): Set { + return new Set(report.findings.map((finding) => finding.fingerprint)); +} + +export function applyBaseline(report: SynSecReport, baseline: SynSecReport): SynSecReport { + const current = reportFingerprints(report); + const previous = reportFingerprints(baseline); + + const delta: BaselineDelta = { + new: [...current].filter((fingerprint) => !previous.has(fingerprint)).sort(), + fixed: [...previous].filter((fingerprint) => !current.has(fingerprint)).sort(), + persisting: [...current].filter((fingerprint) => previous.has(fingerprint)).sort(), + }; + + return { ...report, baseline: delta }; +} + +export function isSynSecReport(value: unknown): value is SynSecReport { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return ( + record.schemaVersion === SYNSEC_REPORT_SCHEMA_VERSION && + typeof record.reportId === "string" && + Array.isArray(record.findings) + ); +} + +export async function readReport(path: string): Promise { + const parsed = JSON.parse(await readFile(path, "utf8")) as unknown; + if (!isSynSecReport(parsed)) throw new Error(`Not a supported SynSec report: ${path}`); + return parsed; +} + +export async function writeReport(path: string, report: SynSecReport): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(report, null, 2)}\n`, "utf8"); +} + +function sarifLevel(severity: Severity): "error" | "warning" | "note" | "none" { + if (severity === "critical" || severity === "high") return "error"; + if (severity === "medium") return "warning"; + if (severity === "low" || severity === "info") return "note"; + return "none"; +} + +function ids(finding: Finding): string[] { + const identifiers = finding.identifiers; + if (!identifiers) return []; + return [ + ...(identifiers.cve ?? []), + ...(identifiers.cwe ?? []), + ...(identifiers.ghsa ?? []), + ...(identifiers.osv ?? []), + ]; +} + +export function toSarif(report: SynSecReport): Record { + const rules = report.findings.map((group) => { + const finding = group.primary; + const ruleId = finding.scanner.ruleId ?? group.fingerprint; + return { + id: ruleId, + name: ruleId, + shortDescription: { text: finding.title }, + fullDescription: { text: finding.description ?? finding.title }, + properties: { + category: finding.category, + severity: finding.severity, + confidence: finding.confidence, + identifiers: ids(finding), + scanners: group.sources.map((source) => source.name), + }, + }; + }); + + const results = report.findings.map((group, index) => { + const finding = group.primary; + const location = finding.location; + const result: Record = { + ruleId: finding.scanner.ruleId ?? group.fingerprint, + ruleIndex: index, + level: sarifLevel(finding.severity), + message: { text: finding.title }, + partialFingerprints: { "synsec/v1": group.fingerprint }, + properties: { + category: finding.category, + confidence: finding.confidence, + remediation: finding.remediation ?? null, + }, + }; + + if (location) { + result.locations = [ + { + physicalLocation: { + artifactLocation: { uri: location.path }, + region: { + startLine: location.startLine ?? 1, + endLine: location.endLine ?? location.startLine ?? 1, + startColumn: location.startColumn ?? 1, + endColumn: location.endColumn ?? location.startColumn ?? 1, + }, + }, + }, + ]; + } + return result; + }); + + return { + $schema: "https://json.schemastore.org/sarif-2.1.0.json", + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "SynSec", + semanticVersion: report.toolVersion, + informationUri: "https://github.com/cmahmud/synsec", + rules, + }, + }, + results, + }, + ], + }; +} + +export async function writeSarif(path: string, report: SynSecReport): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(toSarif(report), null, 2)}\n`, "utf8"); +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function findingLocation(finding: Finding): string { + if (!finding.location) return "Repository"; + return `${finding.location.path}${finding.location.startLine ? `:${finding.location.startLine}` : ""}`; +} + +export function renderHtml(report: SynSecReport): string { + const cards = report.findings + .map((group) => { + const finding = group.primary; + const sourceNames = group.sources.map((source) => source.name).join(", "); + const remediation = finding.remediation + ? `

Remediation: ${escapeHtml(finding.remediation)}

` + : ""; + const description = finding.description + ? `

${escapeHtml(finding.description)}

` + : ""; + return `
+
${escapeHtml(finding.severity.toUpperCase())}

${escapeHtml(finding.title)}

+
${escapeHtml(findingLocation(finding))} · ${escapeHtml(sourceNames)} · confidence ${Math.round(finding.confidence * 100)}%
+ ${description} + ${remediation} +
`; + }) + .join("\n"); + + const baseline = report.baseline + ? `
Since baseline: ${report.baseline.new.length} new · ${report.baseline.fixed.length} fixed · ${report.baseline.persisting.length} persisting
` + : ""; + + return ` + + + + +SynSec report + + +
+
SynSec repository security

${escapeHtml(report.target.repositoryUrl ?? report.target.path)}

Generated ${escapeHtml(report.generatedAt)} · ${report.findingCount} correlated finding(s) from ${report.rawFindingCount} raw result(s)
${baseline}
Security score
${report.securityScore}
+
+
Critical${report.summary.critical}
High${report.summary.high}
Medium${report.summary.medium}
Low${report.summary.low}
Info${report.summary.info}
Unknown${report.summary.unknown}
+
+
+
${cards || '
No findings. Keep the report as evidence of the scan.
'}
+
`; +} + +export async function writeHtml(path: string, report: SynSecReport): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, renderHtml(report), "utf8"); +} + +export function findingIsNew(report: SynSecReport, finding: CorrelatedFinding): boolean { + return report.baseline ? report.baseline.new.includes(finding.fingerprint) : true; +} + +export function rawFingerprint(finding: Finding): string { + return findingFingerprint(finding); +} From 1f2b6f610ae3cc5542b4a11c4e9cc0690b1e2f46 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:42:32 -0400 Subject: [PATCH 0012/1132] feat(config): add configuration package --- packages/config/package.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 packages/config/package.json diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 00000000..4d653b6a --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,15 @@ +{ + "name": "@synsec/config", + "version": "0.2.0", + "private": true, + "type": "module", + "exports": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/core": "0.1.0" + } +} From 3e6aa03cdba44bde73c3cefbdfa667edcdf48576 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:42:38 -0400 Subject: [PATCH 0013/1132] build(config): configure TypeScript project --- packages/config/tsconfig.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 packages/config/tsconfig.json diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json new file mode 100644 index 00000000..ebe9ac5b --- /dev/null +++ b/packages/config/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../core" } + ], + "include": ["src/**/*.ts"] +} From b16964f77ac1796da20b377183929cd0347d8d59 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:42:57 -0400 Subject: [PATCH 0014/1132] feat(config): add stable SynSec config format --- packages/config/src/index.ts | 147 +++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 packages/config/src/index.ts diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts new file mode 100644 index 00000000..b03d296f --- /dev/null +++ b/packages/config/src/index.ts @@ -0,0 +1,147 @@ +import { access, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import type { Severity } from "@synsec/core"; + +export const SYNSEC_CONFIG_FILENAME = "synsec.config.json"; + +export interface AiConfig { + enabled: boolean; + provider: "openai-compatible"; + baseUrl?: string; + model?: string; + sendSourceContext: boolean; +} + +export interface ReportConfig { + json: string; + html: string; + sarif: string; +} + +export interface SynSecConfig { + schemaVersion: 1; + scanners: string[]; + parallelism: number; + timeoutMs: number; + failOn: Severity | "none"; + reports: ReportConfig; + baseline?: string; + ai: AiConfig; +} + +export const defaultConfig: SynSecConfig = { + schemaVersion: 1, + scanners: [ + "opengrep", + "gitleaks", + "osv-scanner", + "trivy", + "grype", + "checkov", + ], + parallelism: 3, + timeoutMs: 15 * 60_000, + failOn: "none", + reports: { + json: ".synsec/report.json", + html: ".synsec/report.html", + sarif: ".synsec/report.sarif", + }, + ai: { + enabled: false, + provider: "openai-compatible", + sendSourceContext: false, + }, +}; + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function stringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return undefined; + return value; +} + +function severity(value: unknown): SynSecConfig["failOn"] | undefined { + if ( + value === "critical" || + value === "high" || + value === "medium" || + value === "low" || + value === "info" || + value === "unknown" || + value === "none" + ) return value; + return undefined; +} + +function positiveInteger(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback; +} + +export function parseConfig(value: unknown): SynSecConfig { + const root = asRecord(value); + if (!root) throw new Error("SynSec configuration must be a JSON object."); + if (root.schemaVersion !== undefined && root.schemaVersion !== 1) { + throw new Error(`Unsupported SynSec configuration schemaVersion: ${String(root.schemaVersion)}`); + } + + const reportsValue = asRecord(root.reports); + const aiValue = asRecord(root.ai); + + const reports: ReportConfig = { + json: typeof reportsValue?.json === "string" ? reportsValue.json : defaultConfig.reports.json, + html: typeof reportsValue?.html === "string" ? reportsValue.html : defaultConfig.reports.html, + sarif: typeof reportsValue?.sarif === "string" ? reportsValue.sarif : defaultConfig.reports.sarif, + }; + + const ai: AiConfig = { + enabled: typeof aiValue?.enabled === "boolean" ? aiValue.enabled : defaultConfig.ai.enabled, + provider: "openai-compatible", + sendSourceContext: + typeof aiValue?.sendSourceContext === "boolean" + ? aiValue.sendSourceContext + : defaultConfig.ai.sendSourceContext, + }; + if (typeof aiValue?.baseUrl === "string") ai.baseUrl = aiValue.baseUrl; + if (typeof aiValue?.model === "string") ai.model = aiValue.model; + + const config: SynSecConfig = { + schemaVersion: 1, + scanners: stringArray(root.scanners) ?? [...defaultConfig.scanners], + parallelism: positiveInteger(root.parallelism, defaultConfig.parallelism), + timeoutMs: positiveInteger(root.timeoutMs, defaultConfig.timeoutMs), + failOn: severity(root.failOn) ?? defaultConfig.failOn, + reports, + ai, + }; + if (typeof root.baseline === "string") config.baseline = root.baseline; + return config; +} + +export async function findConfig(startPath: string): Promise { + const candidate = join(resolve(startPath), SYNSEC_CONFIG_FILENAME); + return await access(candidate).then(() => candidate).catch(() => undefined); +} + +export async function loadConfig(rootPath: string, explicitPath?: string): Promise<{ config: SynSecConfig; path?: string }> { + const path = explicitPath ? resolve(explicitPath) : await findConfig(rootPath); + if (!path) return { config: structuredClone(defaultConfig) }; + const parsed = JSON.parse(await readFile(path, "utf8")) as unknown; + return { config: parseConfig(parsed), path }; +} + +export async function writeDefaultConfig(path: string): Promise { + await writeFile(path, `${JSON.stringify(defaultConfig, null, 2)}\n`, { encoding: "utf8", flag: "wx" }); +} + +export function resolveReportPaths(rootPath: string, config: SynSecConfig): ReportConfig { + return { + json: resolve(rootPath, config.reports.json), + html: resolve(rootPath, config.reports.html), + sarif: resolve(rootPath, config.reports.sarif), + }; +} From 6e07ecf8505003f26b1551be8481f6b36db01b56 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:43:07 -0400 Subject: [PATCH 0015/1132] feat(repository): add repository intelligence package --- packages/repository/package.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 packages/repository/package.json diff --git a/packages/repository/package.json b/packages/repository/package.json new file mode 100644 index 00000000..3a42b677 --- /dev/null +++ b/packages/repository/package.json @@ -0,0 +1,16 @@ +{ + "name": "@synsec/repository", + "version": "0.2.0", + "private": true, + "type": "module", + "exports": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/core": "0.1.0", + "@synsec/report": "0.2.0" + } +} From caa0ad4c44b8a10d0eac48c39901f83260db4de5 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:43:13 -0400 Subject: [PATCH 0016/1132] build(repository): configure TypeScript project --- packages/repository/tsconfig.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 packages/repository/tsconfig.json diff --git a/packages/repository/tsconfig.json b/packages/repository/tsconfig.json new file mode 100644 index 00000000..e6761c0d --- /dev/null +++ b/packages/repository/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../core" }, + { "path": "../report" } + ], + "include": ["src/**/*.ts"] +} From 41d3340ea320f5bf1c80480d3f0030594f152d42 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:43:37 -0400 Subject: [PATCH 0017/1132] feat(repository): detect languages, frameworks and safe finding context --- packages/repository/src/index.ts | 242 +++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 packages/repository/src/index.ts diff --git a/packages/repository/src/index.ts b/packages/repository/src/index.ts new file mode 100644 index 00000000..a39a60a1 --- /dev/null +++ b/packages/repository/src/index.ts @@ -0,0 +1,242 @@ +import { lstat, readFile, readdir } from "node:fs/promises"; +import { extname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import type { Finding } from "@synsec/core"; +import type { RepositoryMetadata } from "@synsec/report"; + +const ignoredDirectories = new Set([ + ".git", + ".hg", + ".svn", + ".idea", + ".vscode", + "node_modules", + "vendor", + "dist", + "build", + "coverage", + ".next", + ".nuxt", + ".venv", + "venv", + "target", + "bin", + "obj", + ".synsec", +]); + +const languageByExtension: Record = { + ".js": "JavaScript", + ".mjs": "JavaScript", + ".cjs": "JavaScript", + ".jsx": "JavaScript", + ".ts": "TypeScript", + ".mts": "TypeScript", + ".cts": "TypeScript", + ".tsx": "TypeScript", + ".py": "Python", + ".go": "Go", + ".rs": "Rust", + ".java": "Java", + ".kt": "Kotlin", + ".kts": "Kotlin", + ".rb": "Ruby", + ".php": "PHP", + ".cs": "C#", + ".c": "C", + ".h": "C/C++ Header", + ".cc": "C++", + ".cpp": "C++", + ".cxx": "C++", + ".swift": "Swift", + ".scala": "Scala", + ".sh": "Shell", + ".bash": "Shell", + ".ps1": "PowerShell", + ".tf": "Terraform", + ".sol": "Solidity", + ".ex": "Elixir", + ".exs": "Elixir", + ".dart": "Dart", + ".lua": "Lua", + ".r": "R", + ".R": "R", +}; + +export interface RepositoryFile { + path: string; + size: number; +} + +export interface RepositoryInventory { + metadata: RepositoryMetadata; + files: RepositoryFile[]; +} + +export interface FindingContext { + path: string; + startLine: number; + endLine: number; + excerpt: string; + truncated: boolean; +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +async function walk(root: string, maxFiles: number): Promise { + const output: RepositoryFile[] = []; + const stack = [root]; + + while (stack.length > 0 && output.length < maxFiles) { + const current = stack.pop(); + if (!current) break; + let entries; + try { + entries = await readdir(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (output.length >= maxFiles) break; + if (entry.isSymbolicLink()) continue; + const absolute = join(current, entry.name); + if (!insideRoot(root, absolute)) continue; + + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) stack.push(absolute); + continue; + } + if (!entry.isFile()) continue; + + const stat = await lstat(absolute).catch(() => undefined); + if (!stat?.isFile()) continue; + output.push({ + path: relative(root, absolute).replaceAll(sep, "/"), + size: stat.size, + }); + } + } + return output; +} + +function addFramework(frameworks: Set, name: string): void { + frameworks.add(name); +} + +async function detectNodeFrameworks(root: string, frameworks: Set): Promise { + const path = join(root, "package.json"); + const content = await readFile(path, "utf8").catch(() => undefined); + if (!content) return; + let parsed: Record; + try { + parsed = JSON.parse(content) as Record; + } catch { + return; + } + const dependencyMaps = [parsed.dependencies, parsed.devDependencies]; + const dependencies = new Set(); + for (const value of dependencyMaps) { + if (typeof value !== "object" || value === null || Array.isArray(value)) continue; + for (const key of Object.keys(value as Record)) dependencies.add(key); + } + const mappings: Array<[string, string]> = [ + ["next", "Next.js"], + ["react", "React"], + ["express", "Express"], + ["fastify", "Fastify"], + ["@nestjs/core", "NestJS"], + ["h3", "H3"], + ["nuxt", "Nuxt"], + ["svelte", "Svelte"], + ["@sveltejs/kit", "SvelteKit"], + ["vue", "Vue"], + ["astro", "Astro"], + ["koa", "Koa"], + ["hono", "Hono"], + ]; + for (const [pkg, framework] of mappings) if (dependencies.has(pkg)) addFramework(frameworks, framework); +} + +async function detectPythonFrameworks(root: string, frameworks: Set): Promise { + const candidates = ["requirements.txt", "pyproject.toml", "Pipfile"]; + const text = (await Promise.all(candidates.map((name) => readFile(join(root, name), "utf8").catch(() => "")))).join("\n").toLowerCase(); + if (/\bdjango\b/.test(text)) addFramework(frameworks, "Django"); + if (/\bfastapi\b/.test(text)) addFramework(frameworks, "FastAPI"); + if (/\bflask\b/.test(text)) addFramework(frameworks, "Flask"); + if (/\bstarlette\b/.test(text)) addFramework(frameworks, "Starlette"); +} + +async function detectOtherFrameworks(root: string, frameworks: Set): Promise { + const goMod = await readFile(join(root, "go.mod"), "utf8").catch(() => ""); + if (goMod.includes("github.com/gin-gonic/gin")) addFramework(frameworks, "Gin"); + if (goMod.includes("github.com/gofiber/fiber")) addFramework(frameworks, "Fiber"); + if (goMod.includes("github.com/labstack/echo")) addFramework(frameworks, "Echo"); + + const composer = await readFile(join(root, "composer.json"), "utf8").catch(() => ""); + if (composer.includes("laravel/framework")) addFramework(frameworks, "Laravel"); + if (composer.includes("symfony/")) addFramework(frameworks, "Symfony"); + + const pom = await readFile(join(root, "pom.xml"), "utf8").catch(() => ""); + if (pom.includes("spring-boot")) addFramework(frameworks, "Spring Boot"); +} + +export async function inventoryRepository(rootPath: string, maxFiles = 20_000): Promise { + const root = resolve(rootPath); + const files = await walk(root, maxFiles); + const languages: Record = {}; + for (const file of files) { + const language = languageByExtension[extname(file.path)]; + if (!language) continue; + languages[language] = (languages[language] ?? 0) + 1; + } + + const frameworks = new Set(); + await Promise.all([ + detectNodeFrameworks(root, frameworks), + detectPythonFrameworks(root, frameworks), + detectOtherFrameworks(root, frameworks), + ]); + + return { + metadata: { + languages, + frameworks: [...frameworks].sort(), + fileCount: files.length, + }, + files, + }; +} + +export async function getFindingContext(rootPath: string, finding: Finding, radius = 20): Promise { + const location = finding.location; + if (!location?.path) return undefined; + const root = resolve(rootPath); + const normalizedRelative = location.path.replaceAll("/", sep).replaceAll("\\", sep); + const candidate = resolve(root, normalizedRelative); + if (!insideRoot(root, candidate)) return undefined; + + const stat = await lstat(candidate).catch(() => undefined); + if (!stat?.isFile() || stat.size > 1_000_000) return undefined; + const content = await readFile(candidate, "utf8").catch(() => undefined); + if (content === undefined || content.includes("\u0000")) return undefined; + + const lines = content.split(/\r?\n/); + const focus = Math.max(1, location.startLine ?? 1); + const startLine = Math.max(1, focus - radius); + const endLine = Math.min(lines.length, (location.endLine ?? focus) + radius); + const excerpt = lines + .slice(startLine - 1, endLine) + .map((line, index) => `${String(startLine + index).padStart(5)} | ${line}`) + .join("\n"); + + return { + path: relative(root, candidate).replaceAll(sep, "/"), + startLine, + endLine, + excerpt, + truncated: startLine > 1 || endLine < lines.length, + }; +} From 4da3e78a9cda8d115eebff0181fb0ad1a41f98a7 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:43:45 -0400 Subject: [PATCH 0018/1132] feat(ai): add provider-agnostic review package --- packages/ai/package.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 packages/ai/package.json diff --git a/packages/ai/package.json b/packages/ai/package.json new file mode 100644 index 00000000..c2d5862a --- /dev/null +++ b/packages/ai/package.json @@ -0,0 +1,16 @@ +{ + "name": "@synsec/ai", + "version": "0.2.0", + "private": true, + "type": "module", + "exports": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/core": "0.1.0", + "@synsec/repository": "0.2.0" + } +} From 597b81ba7b6108da3fcae8126f2de8e042ab049e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:43:55 -0400 Subject: [PATCH 0019/1132] build(ai): configure TypeScript project --- packages/ai/tsconfig.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 packages/ai/tsconfig.json diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json new file mode 100644 index 00000000..6f767f25 --- /dev/null +++ b/packages/ai/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../core" }, + { "path": "../repository" } + ], + "include": ["src/**/*.ts"] +} From 2cacd65789f11752926d93b958b860c01df29598 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:44:18 -0400 Subject: [PATCH 0020/1132] feat(ai): add explicit opt-in finding review gate --- packages/ai/src/index.ts | 167 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 packages/ai/src/index.ts diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts new file mode 100644 index 00000000..8aae8910 --- /dev/null +++ b/packages/ai/src/index.ts @@ -0,0 +1,167 @@ +import type { Finding } from "@synsec/core"; +import type { FindingContext } from "@synsec/repository"; + +export type ReviewAnswer = "yes" | "no" | "unknown"; + +export interface ReviewGateQuestion { + id: string; + question: string; + answer: ReviewAnswer; + note: string; +} + +export interface AiFindingReview { + verdict: "confirmed" | "likely" | "uncertain" | "false-positive"; + confidence: number; + severity: Finding["severity"]; + summary: string; + rationale: string; + gate: ReviewGateQuestion[]; + remediation?: string; + model: string; +} + +export interface OpenAiCompatibleConfig { + baseUrl: string; + apiKey?: string; + model: string; + timeoutMs?: number; +} + +interface ChatCompletionResponse { + choices?: Array<{ + message?: { + content?: string; + }; + }>; +} + +const gateQuestions = [ + ["concrete", "Is there a concrete vulnerable code or configuration location?"], + ["input", "Is attacker-controlled or otherwise untrusted input involved where the finding requires it?"], + ["sink", "Does the code reach a security-sensitive sink or violate a meaningful security invariant?"], + ["reachable", "Is the affected path reachable in the repository's actual application flow rather than dead/example code?"], + ["mitigations", "Have relevant validations, escaping, authorization checks, sandboxing, or other mitigations been accounted for?"], + ["evidence", "Is there scanner or code evidence supporting the conclusion without relying only on speculation?"], + ["actionable", "Is there a specific, proportionate remediation that addresses the underlying issue?"], +] as const; + +function stripCodeFence(value: string): string { + const trimmed = value.trim(); + const match = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed); + return match?.[1] ?? trimmed; +} + +function clampConfidence(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 0.5; + return Math.max(0, Math.min(1, value)); +} + +function isSeverity(value: unknown): value is Finding["severity"] { + return value === "critical" || value === "high" || value === "medium" || value === "low" || value === "info" || value === "unknown"; +} + +function answer(value: unknown): ReviewAnswer { + return value === "yes" || value === "no" || value === "unknown" ? value : "unknown"; +} + +function verdict(value: unknown): AiFindingReview["verdict"] { + return value === "confirmed" || value === "likely" || value === "uncertain" || value === "false-positive" + ? value + : "uncertain"; +} + +function normalizeReview(value: unknown, finding: Finding, model: string): AiFindingReview { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("AI review response was not a JSON object."); + } + const record = value as Record; + const rawGate = Array.isArray(record.gate) ? record.gate : []; + const gate = gateQuestions.map(([id, question]) => { + const found = rawGate.find((item) => typeof item === "object" && item !== null && (item as Record).id === id) as Record | undefined; + return { + id, + question, + answer: answer(found?.answer), + note: typeof found?.note === "string" ? found.note : "No model note provided.", + }; + }); + + const review: AiFindingReview = { + verdict: verdict(record.verdict), + confidence: clampConfidence(record.confidence), + severity: isSeverity(record.severity) ? record.severity : finding.severity, + summary: typeof record.summary === "string" ? record.summary : finding.title, + rationale: typeof record.rationale === "string" ? record.rationale : "No rationale provided.", + gate, + model, + }; + if (typeof record.remediation === "string") review.remediation = record.remediation; + return review; +} + +function buildPrompt(finding: Finding, context?: FindingContext): string { + const safeFinding = { + title: finding.title, + description: finding.description, + category: finding.category, + severity: finding.severity, + confidence: finding.confidence, + scanner: finding.scanner, + location: finding.location, + identifiers: finding.identifiers, + remediation: finding.remediation, + metadata: finding.metadata, + }; + + const contextBlock = context + ? `\nRepository excerpt (${context.path}, lines ${context.startLine}-${context.endLine}):\n${context.excerpt}` + : "\nNo source excerpt was provided. Treat reachability and code-flow claims as unknown unless scanner evidence is sufficient."; + + return `You are reviewing a repository security scanner finding for defensive software assurance. Do not invent exploit steps, credentials, or evidence. Separate deterministic scanner evidence from inference. If the available context cannot answer a question, answer unknown. Return JSON only.\n\nFinding:\n${JSON.stringify(safeFinding, null, 2)}${contextBlock}\n\nAssess these seven gates:\n${gateQuestions.map(([id, question], index) => `${index + 1}. ${id}: ${question}`).join("\n")}\n\nReturn exactly this shape:\n{\n "verdict": "confirmed|likely|uncertain|false-positive",\n "confidence": 0.0,\n "severity": "critical|high|medium|low|info|unknown",\n "summary": "short summary",\n "rationale": "brief evidence-grounded rationale",\n "gate": [{"id":"concrete","answer":"yes|no|unknown","note":"brief note"}],\n "remediation": "brief defensive remediation"\n}`; +} + +export async function reviewFinding( + finding: Finding, + config: OpenAiCompatibleConfig, + context?: FindingContext, +): Promise { + const baseUrl = config.baseUrl.replace(/\/$/, ""); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.timeoutMs ?? 90_000); + + try { + const headers: Record = { "content-type": "application/json" }; + if (config.apiKey) headers.authorization = `Bearer ${config.apiKey}`; + + const response = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + headers, + signal: controller.signal, + body: JSON.stringify({ + model: config.model, + temperature: 0, + messages: [ + { + role: "system", + content: "Perform concise defensive repository vulnerability triage. Return valid JSON only.", + }, + { role: "user", content: buildPrompt(finding, context) }, + ], + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`AI provider returned HTTP ${response.status}: ${text.slice(0, 500)}`); + } + + const payload = (await response.json()) as ChatCompletionResponse; + const content = payload.choices?.[0]?.message?.content; + if (!content) throw new Error("AI provider returned no message content."); + const parsed = JSON.parse(stripCodeFence(content)) as unknown; + return normalizeReview(parsed, finding, config.model); + } finally { + clearTimeout(timeout); + } +} From 648e03c8c75c7ff574affd2f4367d42ede6d0135 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:44:30 -0400 Subject: [PATCH 0021/1132] feat(engine): add scan orchestration package --- packages/engine/package.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 packages/engine/package.json diff --git a/packages/engine/package.json b/packages/engine/package.json new file mode 100644 index 00000000..d9e52183 --- /dev/null +++ b/packages/engine/package.json @@ -0,0 +1,20 @@ +{ + "name": "@synsec/engine", + "version": "0.2.0", + "private": true, + "type": "module", + "exports": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/config": "0.2.0", + "@synsec/core": "0.1.0", + "@synsec/report": "0.2.0", + "@synsec/repository": "0.2.0", + "@synsec/scanner-sdk": "0.1.0", + "@synsec/scanners": "0.1.0" + } +} From 6d2bb67bae806b8ea5bfeba51c262f53889229ce Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:44:38 -0400 Subject: [PATCH 0022/1132] build(engine): configure TypeScript project --- packages/engine/tsconfig.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 packages/engine/tsconfig.json diff --git a/packages/engine/tsconfig.json b/packages/engine/tsconfig.json new file mode 100644 index 00000000..8be504aa --- /dev/null +++ b/packages/engine/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../config" }, + { "path": "../core" }, + { "path": "../report" }, + { "path": "../repository" }, + { "path": "../scanner-sdk" }, + { "path": "../scanners" } + ], + "include": ["src/**/*.ts"] +} From 4b5a9743c17ec33f77b578fa4adea6568b2d3332 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:44:59 -0400 Subject: [PATCH 0023/1132] feat(engine): orchestrate scanners with concurrency and failure isolation --- packages/engine/src/index.ts | 175 +++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 packages/engine/src/index.ts diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts new file mode 100644 index 00000000..827f2fbf --- /dev/null +++ b/packages/engine/src/index.ts @@ -0,0 +1,175 @@ +import { resolve } from "node:path"; +import type { SynSecConfig } from "@synsec/config"; +import type { ScanResult, ScanTarget, Severity } from "@synsec/core"; +import { applyBaseline, buildReport, type SynSecReport } from "@synsec/report"; +import { inventoryRepository } from "@synsec/repository"; +import { runProcess, type ScannerAdapter, type ScannerAvailability } from "@synsec/scanner-sdk"; +import { builtInScanners } from "@synsec/scanners"; + +export interface ScannerStatus { + id: string; + displayName: string; + selected: boolean; + availability: ScannerAvailability; +} + +export interface ScannerFailure { + scanner: string; + message: string; +} + +export interface ScanEngineOutcome { + report: SynSecReport; + statuses: ScannerStatus[]; + failures: ScannerFailure[]; + shouldFail: boolean; +} + +const severityRank: Record = { + critical: 5, + high: 4, + medium: 3, + low: 2, + info: 1, + unknown: 0, +}; + +function sanitizeRemoteUrl(value: string): string { + try { + const url = new URL(value); + if (url.username || url.password) { + url.username = ""; + url.password = ""; + } + return url.toString().replace(/\/$/, ""); + } catch { + return value.replace(/:\/\/[^/@]+@/, "://"); + } +} + +async function gitValue(root: string, args: string[]): Promise { + try { + const output = await runProcess("git", ["-C", root, ...args], { timeoutMs: 5_000 }); + if (output.exitCode !== 0) return undefined; + const value = output.stdout.trim(); + return value || undefined; + } catch { + return undefined; + } +} + +export async function discoverTarget(rootPath: string): Promise { + const path = resolve(rootPath); + const [commitSha, branch, repositoryUrl] = await Promise.all([ + gitValue(path, ["rev-parse", "HEAD"]), + gitValue(path, ["branch", "--show-current"]), + gitValue(path, ["config", "--get", "remote.origin.url"]), + ]); + + const target: ScanTarget = { path }; + if (commitSha) target.commitSha = commitSha; + if (branch) target.branch = branch; + if (repositoryUrl) target.repositoryUrl = sanitizeRemoteUrl(repositoryUrl); + return target; +} + +export async function scannerStatuses(config: SynSecConfig): Promise { + const selectedIds = new Set(config.scanners); + const scanners = builtInScanners(); + const knownIds = new Set(scanners.map((scanner) => scanner.id)); + const statuses = await Promise.all( + scanners.map(async (scanner) => ({ + id: scanner.id, + displayName: scanner.displayName, + selected: selectedIds.has(scanner.id), + availability: await scanner.checkAvailability(), + })), + ); + + for (const id of selectedIds) { + if (!knownIds.has(id)) { + statuses.push({ + id, + displayName: id, + selected: true, + availability: { available: false, reason: "Unknown scanner id in configuration." }, + }); + } + } + return statuses; +} + +async function runSelectedScanners( + target: ScanTarget, + config: SynSecConfig, + statuses: readonly ScannerStatus[], +): Promise<{ scans: ScanResult[]; failures: ScannerFailure[] }> { + const statusById = new Map(statuses.map((status) => [status.id, status])); + const selected = builtInScanners().filter((scanner) => { + const status = statusById.get(scanner.id); + return Boolean(status?.selected && status.availability.available); + }); + + const queue: ScannerAdapter[] = [...selected]; + const scans: ScanResult[] = []; + const failures: ScannerFailure[] = []; + const workers = Math.max(1, Math.min(config.parallelism, queue.length || 1)); + + await Promise.all( + Array.from({ length: workers }, async () => { + while (queue.length > 0) { + const scanner = queue.shift(); + if (!scanner) return; + try { + const result = await scanner.scan({ target, timeoutMs: config.timeoutMs }); + scans.push(result); + } catch (error) { + failures.push({ + scanner: scanner.id, + message: error instanceof Error ? error.message : String(error), + }); + } + } + }), + ); + + scans.sort((a, b) => a.scanner.localeCompare(b.scanner)); + failures.sort((a, b) => a.scanner.localeCompare(b.scanner)); + return { scans, failures }; +} + +export function reportMeetsFailureThreshold(report: SynSecReport, failOn: SynSecConfig["failOn"]): boolean { + if (failOn === "none") return false; + const threshold = severityRank[failOn]; + return report.findings.some((group) => severityRank[group.primary.severity] >= threshold); +} + +export async function runScanEngine(input: { + rootPath: string; + config: SynSecConfig; + baseline?: SynSecReport; + toolVersion?: string; +}): Promise { + const root = resolve(input.rootPath); + const [target, statuses, inventory] = await Promise.all([ + discoverTarget(root), + scannerStatuses(input.config), + inventoryRepository(root), + ]); + + const { scans, failures } = await runSelectedScanners(target, input.config, statuses); + let report = buildReport({ + target, + scans, + toolVersion: input.toolVersion ?? "0.2.0", + repository: inventory.metadata, + }); + if (input.baseline) report = applyBaseline(report, input.baseline); + + return { + report, + statuses, + failures, + shouldFail: reportMeetsFailureThreshold(report, input.config.failOn), + }; +} From e9c8d91f3a70639991f16758e186fcba8b1d5e7d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:45:09 -0400 Subject: [PATCH 0024/1132] build: wire new SynSec packages into project graph --- tsconfig.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tsconfig.json b/tsconfig.json index f46608f7..c0e20deb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,8 +2,13 @@ "files": [], "references": [ { "path": "./packages/core" }, + { "path": "./packages/config" }, + { "path": "./packages/report" }, + { "path": "./packages/repository" }, + { "path": "./packages/ai" }, { "path": "./packages/scanner-sdk" }, { "path": "./packages/scanners" }, + { "path": "./packages/engine" }, { "path": "./apps/cli" } ] } From b952f8ab96ebda3fbf43b4639fcfcde385bc8eae Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:45:19 -0400 Subject: [PATCH 0025/1132] feat(cli): wire engine, reports, config and AI review --- apps/cli/package.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 72b19044..03251797 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@synsec/cli", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "bin": { @@ -12,8 +12,12 @@ "start": "node --enable-source-maps dist/index.js" }, "dependencies": { + "@synsec/ai": "0.2.0", + "@synsec/config": "0.2.0", "@synsec/core": "0.1.0", - "@synsec/scanner-sdk": "0.1.0", + "@synsec/engine": "0.2.0", + "@synsec/report": "0.2.0", + "@synsec/repository": "0.2.0", "@synsec/scanners": "0.1.0" } } From 45b522da43251ba5034a7cb713409f8fec66a7cf Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:45:29 -0400 Subject: [PATCH 0026/1132] build(cli): reference orchestration packages --- apps/cli/tsconfig.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 57307bd5..7b588cb7 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -6,8 +6,12 @@ "rootDir": "src" }, "references": [ + { "path": "../../packages/ai" }, + { "path": "../../packages/config" }, { "path": "../../packages/core" }, - { "path": "../../packages/scanner-sdk" }, + { "path": "../../packages/engine" }, + { "path": "../../packages/report" }, + { "path": "../../packages/repository" }, { "path": "../../packages/scanners" } ], "include": ["src/**/*.ts"] From e3b2838e034474c942f49d5429d6bdf0ec10977e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:46:31 -0400 Subject: [PATCH 0027/1132] feat(cli): ship complete repository scanning workflow --- apps/cli/src/index.ts | 376 +++++++++++++++++++++++++++++++++++------- 1 file changed, 313 insertions(+), 63 deletions(-) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 421174ea..e6075274 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,118 +1,368 @@ #!/usr/bin/env node -import { stat } from "node:fs/promises"; -import { resolve } from "node:path"; -import { correlateFindings, type Finding } from "@synsec/core"; -import { builtInScanners } from "@synsec/scanners"; +import { copyFile, mkdir, stat, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { reviewFinding, type AiFindingReview } from "@synsec/ai"; +import { + defaultConfig, + loadConfig, + resolveReportPaths, + SYNSEC_CONFIG_FILENAME, + writeDefaultConfig, + type SynSecConfig, +} from "@synsec/config"; +import type { CorrelatedFinding, Finding, Severity } from "@synsec/core"; +import { runScanEngine, scannerStatuses } from "@synsec/engine"; +import { + readReport, + renderHtml, + toSarif, + writeHtml, + writeReport, + writeSarif, + type SynSecReport, +} from "@synsec/report"; +import { getFindingContext } from "@synsec/repository"; +const VERSION = "0.2.0"; const args = process.argv.slice(2); const command = args[0] ?? "help"; +function option(name: string): string | undefined { + const index = args.indexOf(name); + if (index >= 0) return args[index + 1]; + const prefix = `${name}=`; + const inline = args.find((value) => value.startsWith(prefix)); + return inline?.slice(prefix.length); +} + +function flag(name: string): boolean { + return args.includes(name); +} + +function integerOption(name: string): number | undefined { + const raw = option(name); + if (raw === undefined) return undefined; + const value = Number.parseInt(raw, 10); + if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive integer.`); + return value; +} + +function severityOption(name: string): SynSecConfig["failOn"] | undefined { + const value = option(name); + if (value === undefined) return undefined; + if ( + value === "critical" || + value === "high" || + value === "medium" || + value === "low" || + value === "info" || + value === "unknown" || + value === "none" + ) return value; + throw new Error(`${name} must be one of critical, high, medium, low, info, unknown, none.`); +} + function printHelp(): void { - console.log(`SynSec v0.1.0 + console.log(`SynSec v${VERSION} — repository-first security scanning Usage: - synsec doctor - synsec scan [--json] + synsec init [path] + synsec doctor [path] [--config ] + synsec scan [options] + synsec review [options] + synsec render [--html ] [--sarif ] + synsec baseline [destination] + synsec version -Commands: - doctor Show which scanner engines are available. - scan Scan a local repository with all available built-in scanners. +Scan options: + --config Use an explicit synsec.config.json. + --scanners Override enabled scanners for this run. + --parallel Maximum scanners running at once. + --timeout Per-scanner timeout. + --fail-on Exit non-zero when this severity or higher is found. + --baseline Compare against a previous SynSec report. + --json Print the report JSON to stdout. + --no-write Do not write JSON/HTML/SARIF report files. + --ai Run optional AI triage after deterministic scanning. + --ai-source Allow source excerpts to be sent to the configured AI provider. + --ai-limit Maximum findings to review (default: 10). + --ai-base-url OpenAI-compatible API base URL. + --ai-model Model ID for AI triage. + +AI environment variables: + SYNSEC_AI_BASE_URL + SYNSEC_AI_API_KEY + SYNSEC_AI_MODEL + +SynSec never enables AI review by default. Source excerpts are only sent when +sendSourceContext is enabled in config or --ai-source is explicitly supplied. `); } +async function ensureDirectory(path: string): Promise { + const root = resolve(path); + const info = await stat(root).catch(() => undefined); + if (!info?.isDirectory()) throw new Error(`Not a directory: ${root}`); + return root; +} + +async function configFor(root: string): Promise<{ config: SynSecConfig; path?: string }> { + const loaded = await loadConfig(root, option("--config")); + const config = structuredClone(loaded.config); + + const scanners = option("--scanners"); + if (scanners) config.scanners = scanners.split(",").map((value) => value.trim()).filter(Boolean); + + const parallelism = integerOption("--parallel"); + if (parallelism) config.parallelism = parallelism; + + const timeoutSeconds = integerOption("--timeout"); + if (timeoutSeconds) config.timeoutMs = timeoutSeconds * 1000; + + const failOn = severityOption("--fail-on"); + if (failOn) config.failOn = failOn; + + if (flag("--ai")) config.ai.enabled = true; + if (flag("--ai-source")) config.ai.sendSourceContext = true; + const baseUrl = option("--ai-base-url"); + if (baseUrl) config.ai.baseUrl = baseUrl; + const model = option("--ai-model"); + if (model) config.ai.model = model; + + return loaded.path ? { config, path: loaded.path } : { config }; +} + +async function init(): Promise { + const root = await ensureDirectory(args[1] && !args[1].startsWith("--") ? args[1] : "."); + const path = resolve(root, SYNSEC_CONFIG_FILENAME); + try { + await writeDefaultConfig(path); + } catch (error) { + const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : ""; + if (code === "EEXIST") throw new Error(`${path} already exists.`); + throw error; + } + console.log(`Created ${path}`); +} + async function doctor(): Promise { - console.log("SynSec scanner availability\n"); + const root = await ensureDirectory(args[1] && !args[1].startsWith("--") ? args[1] : "."); + const { config, path } = await configFor(root); + console.log(`SynSec v${VERSION}`); + console.log(`Config: ${path ?? "defaults"}`); + console.log(`Parallelism: ${config.parallelism}\n`); - for (const scanner of builtInScanners()) { - const status = await scanner.checkAvailability(); - const marker = status.available ? "OK" : "MISSING"; - const detail = status.version ?? status.reason ?? ""; - console.log(`${marker.padEnd(8)} ${scanner.displayName.padEnd(18)} ${detail}`); + const statuses = await scannerStatuses(config); + for (const status of statuses) { + const marker = !status.selected ? "DISABLED" : status.availability.available ? "OK" : "MISSING"; + const detail = status.availability.version ?? status.availability.reason ?? ""; + console.log(`${marker.padEnd(9)} ${status.displayName.padEnd(18)} ${detail}`); } + + console.log("\nAI review:"); + console.log(` ${config.ai.enabled ? "enabled" : "disabled"} (source context ${config.ai.sendSourceContext ? "allowed" : "not allowed"})`); } -function printFinding(finding: Finding): void { +function printFinding(group: CorrelatedFinding): void { + const finding = group.primary; const location = finding.location ? `${finding.location.path}${finding.location.startLine ? `:${finding.location.startLine}` : ""}` : "repository"; - + const sources = group.sources.map((source) => source.name).join(", "); console.log(`[${finding.severity.toUpperCase()}] ${finding.title}`); console.log(` ${location}`); - console.log(` source: ${finding.scanner.name}${finding.scanner.ruleId ? ` / ${finding.scanner.ruleId}` : ""}`); + console.log(` sources: ${sources}`); if (finding.remediation) console.log(` fix: ${finding.remediation}`); console.log(""); } -async function scan(): Promise { - const targetArg = args[1]; - if (!targetArg || targetArg.startsWith("--")) { - throw new Error("Usage: synsec scan [--json]"); - } +function aiProvider(config: SynSecConfig): { baseUrl: string; apiKey?: string; model: string } { + const baseUrl = config.ai.baseUrl ?? process.env.SYNSEC_AI_BASE_URL; + const model = config.ai.model ?? process.env.SYNSEC_AI_MODEL; + const apiKey = process.env.SYNSEC_AI_API_KEY; + if (!baseUrl) throw new Error("AI review is enabled but no base URL is configured. Set SYNSEC_AI_BASE_URL or --ai-base-url."); + if (!model) throw new Error("AI review is enabled but no model is configured. Set SYNSEC_AI_MODEL or --ai-model."); + return apiKey ? { baseUrl, model, apiKey } : { baseUrl, model }; +} - const targetPath = resolve(targetArg); - const info = await stat(targetPath).catch(() => undefined); - if (!info?.isDirectory()) { - throw new Error(`Scan target is not a directory: ${targetPath}`); +async function reviewGroups( + report: SynSecReport, + root: string, + config: SynSecConfig, + limit: number, +): Promise> { + const provider = aiProvider(config); + const reviews: Record = {}; + const candidates = report.findings.slice(0, limit); + + for (let index = 0; index < candidates.length; index += 1) { + const group = candidates[index]; + if (!group) continue; + console.error(`AI review ${index + 1}/${candidates.length}: ${group.primary.title}`); + const context = config.ai.sendSourceContext + ? await getFindingContext(root, group.primary) + : undefined; + reviews[group.fingerprint] = await reviewFinding(group.primary, provider, context); } + return reviews; +} - const scanners = builtInScanners(); - const available = []; +async function writeAiReviews(path: string, report: SynSecReport, reviews: Record): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile( + path, + `${JSON.stringify({ schemaVersion: 1, reportId: report.reportId, generatedAt: new Date().toISOString(), reviews }, null, 2)}\n`, + "utf8", + ); +} - for (const scanner of scanners) { - const status = await scanner.checkAvailability(); - if (status.available) { - available.push(scanner); - } else if (!args.includes("--json")) { - console.error(`Skipping ${scanner.displayName}: ${status.reason ?? "not installed"}`); - } - } +async function scan(): Promise { + const targetArg = args[1]; + if (!targetArg || targetArg.startsWith("--")) throw new Error("Usage: synsec scan [options]"); + const root = await ensureDirectory(targetArg); + const { config, path: configPath } = await configFor(root); - if (available.length === 0) { - throw new Error("No supported scanner engines are available. Run `synsec doctor` for details."); + const baselinePath = option("--baseline") ?? config.baseline; + const baseline = baselinePath ? await readReport(resolve(root, baselinePath)) : undefined; + + if (!flag("--json")) { + console.error(`SynSec v${VERSION}`); + console.error(`Target: ${root}`); + console.error(`Config: ${configPath ?? "defaults"}`); + console.error(`Scanners: ${config.scanners.join(", ")}\n`); } - const findings: Finding[] = []; - const scans = []; + const outcome = await runScanEngine({ + rootPath: root, + config, + baseline, + toolVersion: VERSION, + }); - for (const scanner of available) { - if (!args.includes("--json")) console.error(`Running ${scanner.displayName}...`); - const result = await scanner.scan({ target: { path: targetPath } }); - scans.push(result); - findings.push(...result.findings); + const paths = resolveReportPaths(root, config); + if (!flag("--no-write")) { + await Promise.all([ + writeReport(paths.json, outcome.report), + writeHtml(paths.html, outcome.report), + writeSarif(paths.sarif, outcome.report), + ]); } - const correlated = correlateFindings(findings); + if (config.ai.enabled) { + const limit = integerOption("--ai-limit") ?? 10; + const reviews = await reviewGroups(outcome.report, root, config, limit); + const aiPath = resolve(root, ".synsec/ai-review.json"); + await writeAiReviews(aiPath, outcome.report, reviews); + if (!flag("--json")) console.error(`AI reviews: ${aiPath}`); + } - if (args.includes("--json")) { + if (flag("--json")) { + process.stdout.write(`${JSON.stringify(outcome.report, null, 2)}\n`); + } else { + console.log(`Security score: ${outcome.report.securityScore}/100`); console.log( - JSON.stringify( - { - target: targetPath, - scanners: scans.map((result) => result.scanner), - rawFindingCount: findings.length, - correlatedFindingCount: correlated.length, - findings: correlated, - }, - null, - 2, - ), + `Findings: ${outcome.report.findingCount} correlated (${outcome.report.rawFindingCount} raw) — ` + + `${outcome.report.summary.critical} critical, ${outcome.report.summary.high} high, ` + + `${outcome.report.summary.medium} medium, ${outcome.report.summary.low} low\n`, ); - return; + + if (outcome.report.baseline) { + console.log( + `Since baseline: ${outcome.report.baseline.new.length} new, ${outcome.report.baseline.fixed.length} fixed, ${outcome.report.baseline.persisting.length} persisting\n`, + ); + } + + for (const group of outcome.report.findings) printFinding(group); + + for (const failure of outcome.failures) { + console.error(`Scanner failed: ${failure.scanner}: ${failure.message}`); + } + const missing = outcome.statuses.filter((status) => status.selected && !status.availability.available); + for (const status of missing) { + console.error(`Scanner unavailable: ${status.displayName}: ${status.availability.reason ?? "not installed"}`); + } + + if (!flag("--no-write")) { + console.log(`JSON: ${paths.json}`); + console.log(`HTML: ${paths.html}`); + console.log(`SARIF: ${paths.sarif}`); + } } - console.log(`\n${correlated.length} correlated finding(s) (${findings.length} raw)\n`); - for (const finding of correlated) printFinding(finding.primary); + if (outcome.shouldFail) process.exitCode = 2; +} + +async function review(): Promise { + const reportArg = args[1]; + if (!reportArg || reportArg.startsWith("--")) throw new Error("Usage: synsec review [options]"); + const reportPath = resolve(reportArg); + const report = await readReport(reportPath); + const root = await ensureDirectory(option("--root") ?? report.target.path); + const { config } = await configFor(root); + config.ai.enabled = true; + if (flag("--ai-source")) config.ai.sendSourceContext = true; + const baseUrl = option("--ai-base-url"); + if (baseUrl) config.ai.baseUrl = baseUrl; + const model = option("--ai-model"); + if (model) config.ai.model = model; + const limit = integerOption("--ai-limit") ?? report.findings.length; + const reviews = await reviewGroups(report, root, config, limit); + const outputPath = resolve(option("--output") ?? dirname(reportPath), option("--output") ? "." : "ai-review.json"); + await writeAiReviews(outputPath, report, reviews); + console.log(`Wrote ${Object.keys(reviews).length} AI review(s) to ${outputPath}`); +} + +async function render(): Promise { + const reportArg = args[1]; + if (!reportArg || reportArg.startsWith("--")) throw new Error("Usage: synsec render [--html ] [--sarif ]"); + const reportPath = resolve(reportArg); + const report = await readReport(reportPath); + const htmlPath = resolve(option("--html") ?? reportPath.replace(/\.json$/i, ".html")); + const sarifPath = resolve(option("--sarif") ?? reportPath.replace(/\.json$/i, ".sarif")); + await Promise.all([ + mkdir(dirname(htmlPath), { recursive: true }).then(() => writeFile(htmlPath, renderHtml(report), "utf8")), + mkdir(dirname(sarifPath), { recursive: true }).then(() => writeFile(sarifPath, `${JSON.stringify(toSarif(report), null, 2)}\n`, "utf8")), + ]); + console.log(`HTML: ${htmlPath}`); + console.log(`SARIF: ${sarifPath}`); +} + +async function baseline(): Promise { + const source = args[1]; + if (!source || source.startsWith("--")) throw new Error("Usage: synsec baseline [destination]"); + await readReport(resolve(source)); + const destination = resolve(args[2] && !args[2].startsWith("--") ? args[2] : ".synsec/baseline.json"); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(resolve(source), destination); + console.log(`Baseline saved to ${destination}`); } async function main(): Promise { switch (command) { + case "init": + await init(); + break; case "doctor": await doctor(); break; case "scan": await scan(); break; + case "review": + await review(); + break; + case "render": + await render(); + break; + case "baseline": + await baseline(); + break; + case "version": + case "--version": + case "-v": + console.log(VERSION); + break; case "help": case "--help": case "-h": From c0a110a8f7605367b74962c0857bbe38aa949489 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:47:55 -0400 Subject: [PATCH 0028/1132] feat(scanners): add Betterleaks secret scanner adapter --- packages/scanners/src/betterleaks.ts | 91 ++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 packages/scanners/src/betterleaks.ts diff --git a/packages/scanners/src/betterleaks.ts b/packages/scanners/src/betterleaks.ts new file mode 100644 index 00000000..d03f08b9 --- /dev/null +++ b/packages/scanners/src/betterleaks.ts @@ -0,0 +1,91 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Finding, ScanResult } from "@synsec/core"; +import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asNumber, asRecord, asString, commandAvailability, safeJson } from "./utils.js"; + +export function parseBetterleaksJson(raw: string): Finding[] { + const parsed = safeJson(raw); + const findings: Finding[] = []; + for (const value of asArray(parsed)) { + const item = asRecord(value); + if (!item) continue; + const ruleId = asString(item.RuleID); + const description = asString(item.Description) ?? ruleId ?? "Potential secret detected"; + const attributes = asRecord(item.Attributes); + const file = asString(item.File) ?? asString(attributes?.path) ?? asString(attributes?.Path); + const startLine = asNumber(item.StartLine); + const fingerprint = asString(item.Fingerprint); + const validationStatus = asString(item.ValidationStatus); + + findings.push({ + id: randomUUID(), + title: description, + description: "A credential-like value was detected. SynSec requests fully redacted scanner output and never copies the secret into its normalized finding.", + category: "secret", + severity: validationStatus === "valid" ? "critical" : "high", + confidence: validationStatus === "valid" ? 0.995 : 0.98, + scanner: { name: "betterleaks", ruleId }, + location: file ? { path: file, startLine } : undefined, + fingerprint, + remediation: "Revoke or rotate the credential, remove it from the repository and Git history where necessary, and store credentials outside source control.", + metadata: { + validationStatus, + validationReason: asString(item.ValidationReason), + commit: asString(item.Commit), + author: asString(item.Author), + date: asString(item.Date), + tags: item.Tags, + }, + }); + } + return findings; +} + +export class BetterleaksAdapter implements ScannerAdapter { + readonly id = "betterleaks"; + readonly displayName = "Betterleaks"; + readonly capabilities = ["secret"] as const; + + checkAvailability(): Promise { + return commandAvailability("betterleaks", ["version"], this.displayName); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + const temp = await mkdtemp(join(tmpdir(), "synsec-betterleaks-")); + const report = join(temp, "report.json"); + try { + const gitRepo = await stat(join(context.target.path, ".git")).then(() => true).catch(() => false); + const mode = gitRepo ? "git" : "dir"; + const output = await runProcess( + "betterleaks", + [ + mode, + "--report-format", "json", + "--report-path", report, + "--redact=100", + "--no-banner", + "--exit-code", "0", + context.target.path, + ], + { timeoutMs: context.timeoutMs ?? 10 * 60_000, signal: context.signal }, + ); + if (output.exitCode !== 0) throw new Error(`Betterleaks scan failed (${output.exitCode}): ${output.stderr.trim()}`); + const raw = await readFile(report, "utf8").catch(() => "[]"); + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: parseBetterleaksJson(raw), + diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], + }; + } finally { + await rm(temp, { recursive: true, force: true }); + } + } +} From 945fdd0379e006a6a4ab92516201ceb31f9c26ae Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:48:04 -0400 Subject: [PATCH 0029/1132] feat(scanners): prefer Betterleaks while retaining Gitleaks fallback --- packages/scanners/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/scanners/src/index.ts b/packages/scanners/src/index.ts index 23d71acf..73e054de 100644 --- a/packages/scanners/src/index.ts +++ b/packages/scanners/src/index.ts @@ -1,4 +1,5 @@ import type { ScannerAdapter } from "@synsec/scanner-sdk"; +import { BetterleaksAdapter } from "./betterleaks.js"; import { CheckovAdapter } from "./checkov.js"; import { GitleaksAdapter } from "./gitleaks.js"; import { GrypeAdapter } from "./grype.js"; @@ -6,6 +7,7 @@ import { OpengrepAdapter } from "./opengrep.js"; import { OsvScannerAdapter } from "./osv.js"; import { TrivyAdapter } from "./trivy.js"; +export { BetterleaksAdapter, parseBetterleaksJson } from "./betterleaks.js"; export { CheckovAdapter, parseCheckovJson } from "./checkov.js"; export { GitleaksAdapter, parseGitleaksJson } from "./gitleaks.js"; export { GrypeAdapter, parseGrypeJson } from "./grype.js"; @@ -16,6 +18,7 @@ export { TrivyAdapter, parseTrivyJson } from "./trivy.js"; export function builtInScanners(): ScannerAdapter[] { return [ new OpengrepAdapter(), + new BetterleaksAdapter(), new GitleaksAdapter(), new OsvScannerAdapter(), new TrivyAdapter(), From 29bea0a5ca2cd0fcc7d8a891d8e7c3e03550a40b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:48:29 -0400 Subject: [PATCH 0030/1132] feat(config): prefer actively maintained Betterleaks by default --- packages/config/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index b03d296f..fb044d62 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -33,7 +33,7 @@ export const defaultConfig: SynSecConfig = { schemaVersion: 1, scanners: [ "opengrep", - "gitleaks", + "betterleaks", "osv-scanner", "trivy", "grype", From c5c9f0ade074aee1368c5ba806c69f01496523cb Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:48:48 -0400 Subject: [PATCH 0031/1132] test: cover SynSec configuration parsing --- tests/config.test.mjs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/config.test.mjs diff --git a/tests/config.test.mjs b/tests/config.test.mjs new file mode 100644 index 00000000..944f31d0 --- /dev/null +++ b/tests/config.test.mjs @@ -0,0 +1,26 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { defaultConfig, parseConfig } from "../packages/config/dist/index.js"; + +test("default config prefers the maintained scanner set and keeps AI off", () => { + assert.equal(defaultConfig.ai.enabled, false); + assert.equal(defaultConfig.ai.sendSourceContext, false); + assert.ok(defaultConfig.scanners.includes("betterleaks")); + assert.ok(defaultConfig.scanners.includes("opengrep")); +}); + +test("parseConfig merges user values with safe defaults", () => { + const config = parseConfig({ + schemaVersion: 1, + scanners: ["trivy"], + parallelism: 2, + failOn: "high", + ai: { enabled: true, sendSourceContext: false, baseUrl: "http://localhost:8080/v1", model: "router/model" }, + }); + assert.deepEqual(config.scanners, ["trivy"]); + assert.equal(config.parallelism, 2); + assert.equal(config.failOn, "high"); + assert.equal(config.ai.enabled, true); + assert.equal(config.ai.sendSourceContext, false); + assert.equal(config.reports.json, ".synsec/report.json"); +}); From 79e5a31329b322256ec68f126c59e736f51079c8 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:49:00 -0400 Subject: [PATCH 0032/1132] test: cover reports, baselines and exports --- tests/report.test.mjs | 49 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/report.test.mjs diff --git a/tests/report.test.mjs b/tests/report.test.mjs new file mode 100644 index 00000000..ccd16ce1 --- /dev/null +++ b/tests/report.test.mjs @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildReport, applyBaseline, renderHtml, toSarif } from "../packages/report/dist/index.js"; + +function scan(ruleId, severity = "high") { + return { + scanner: "fixture", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:01.000Z", + target: { path: "/repo" }, + diagnostics: [], + findings: [{ + id: `id-${ruleId}`, + title: `Finding ${ruleId}`, + category: "sast", + severity, + confidence: 0.9, + scanner: { name: "fixture", ruleId }, + location: { path: "src/app.ts", startLine: 10 }, + }], + }; +} + +test("buildReport produces a versioned correlated report and security score", () => { + const report = buildReport({ target: { path: "/repo" }, scans: [scan("RULE-1")] }); + assert.equal(report.schemaVersion, "1.0"); + assert.equal(report.rawFindingCount, 1); + assert.equal(report.findingCount, 1); + assert.equal(report.summary.high, 1); + assert.ok(report.securityScore < 100); +}); + +test("baseline delta identifies new and fixed findings", () => { + const previous = buildReport({ target: { path: "/repo" }, scans: [scan("OLD")] }); + const current = buildReport({ target: { path: "/repo" }, scans: [scan("NEW")] }); + const compared = applyBaseline(current, previous); + assert.equal(compared.baseline.new.length, 1); + assert.equal(compared.baseline.fixed.length, 1); + assert.equal(compared.baseline.persisting.length, 0); +}); + +test("SARIF and HTML exports preserve findings without executable report content", () => { + const report = buildReport({ target: { path: "/repo" }, scans: [scan("RULE-1")] }); + const sarif = toSarif(report); + assert.equal(sarif.version, "2.1.0"); + const html = renderHtml(report); + assert.match(html, /SynSec repository security/); + assert.match(html, /Finding RULE-1/); +}); From 99f3e88b87fe7601cb20d087bb30d9736b577190 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:49:09 -0400 Subject: [PATCH 0033/1132] test: cover repository inventory and safe context --- tests/repository.test.mjs | 53 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/repository.test.mjs diff --git a/tests/repository.test.mjs b/tests/repository.test.mjs new file mode 100644 index 00000000..989bce65 --- /dev/null +++ b/tests/repository.test.mjs @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getFindingContext, inventoryRepository } from "../packages/repository/dist/index.js"; + +test("repository inventory detects languages and frameworks", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-repo-test-")); + try { + await mkdir(join(root, "src")); + await writeFile(join(root, "package.json"), JSON.stringify({ dependencies: { express: "^5.0.0" }, devDependencies: { typescript: "^5.0.0" } })); + await writeFile(join(root, "src", "app.ts"), "const x = 1;\nconsole.log(x);\n"); + const inventory = await inventoryRepository(root); + assert.equal(inventory.metadata.languages.TypeScript, 1); + assert.ok(inventory.metadata.frameworks.includes("Express")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("finding context refuses traversal and returns bounded excerpts", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-context-test-")); + try { + await mkdir(join(root, "src")); + await writeFile(join(root, "src", "app.js"), "one\ntwo\nthree\nfour\nfive\n"); + const context = await getFindingContext(root, { + id: "1", + title: "fixture", + category: "sast", + severity: "medium", + confidence: 0.8, + scanner: { name: "fixture" }, + location: { path: "src/app.js", startLine: 3 }, + }, 1); + assert.equal(context.startLine, 2); + assert.equal(context.endLine, 4); + assert.match(context.excerpt, /three/); + + const escaped = await getFindingContext(root, { + id: "2", + title: "fixture", + category: "sast", + severity: "medium", + confidence: 0.8, + scanner: { name: "fixture" }, + location: { path: "../outside.txt", startLine: 1 }, + }); + assert.equal(escaped, undefined); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From 7468b8d02f3a9f8ea1444c19b231ef124950fb6e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:49:23 -0400 Subject: [PATCH 0034/1132] test: cover scanner normalization parsers --- tests/scanners.test.mjs | 76 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tests/scanners.test.mjs diff --git a/tests/scanners.test.mjs b/tests/scanners.test.mjs new file mode 100644 index 00000000..8293a575 --- /dev/null +++ b/tests/scanners.test.mjs @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + parseBetterleaksJson, + parseCheckovJson, + parseGrypeJson, + parseOpengrepJson, + parseOsvJson, +} from "../packages/scanners/dist/index.js"; + +test("Betterleaks parser redacts normalized evidence by design", () => { + const findings = parseBetterleaksJson(JSON.stringify([{ + RuleID: "github-pat", + Description: "GitHub token", + File: "src/config.ts", + StartLine: 4, + Fingerprint: "src/config.ts:github-pat:4", + Secret: "SHOULD_NOT_APPEAR", + Match: "SHOULD_NOT_APPEAR", + }])); + assert.equal(findings.length, 1); + assert.equal(findings[0].category, "secret"); + assert.equal(findings[0].evidence, undefined); + assert.equal(JSON.stringify(findings).includes("SHOULD_NOT_APPEAR"), false); +}); + +test("Opengrep parser maps code location and severity", () => { + const findings = parseOpengrepJson(JSON.stringify({ + results: [{ + check_id: "rules.example", + path: "src/app.ts", + start: { line: 10, col: 3 }, + end: { line: 10, col: 20 }, + extra: { message: "Unsafe operation", severity: "ERROR", metadata: { cwe: ["CWE-79"] } }, + }], + })); + assert.equal(findings[0].severity, "high"); + assert.equal(findings[0].location.startLine, 10); + assert.deepEqual(findings[0].identifiers.cwe, ["CWE-79"]); +}); + +test("OSV parser records package vulnerability identifiers", () => { + const findings = parseOsvJson(JSON.stringify({ + results: [{ + source: { path: "/repo/package-lock.json", type: "lockfile" }, + packages: [{ + package: { name: "demo", version: "1.0.0", ecosystem: "npm" }, + vulnerabilities: [{ id: "GHSA-aaaa-bbbb-cccc", aliases: ["CVE-2026-0001"], summary: "Demo advisory" }], + }], + }], + }), "/repo"); + assert.equal(findings.length, 1); + assert.equal(findings[0].location.path, "package-lock.json"); + assert.deepEqual(findings[0].identifiers.cve, ["CVE-2026-0001"]); +}); + +test("Grype parser maps package and fix information", () => { + const findings = parseGrypeJson(JSON.stringify({ + matches: [{ + vulnerability: { id: "CVE-2026-0002", severity: "High", fix: { versions: ["2.0.0"], state: "fixed" } }, + artifact: { name: "demo", version: "1.0.0", type: "npm", locations: [{ path: "package-lock.json" }] }, + }], + })); + assert.equal(findings[0].severity, "high"); + assert.match(findings[0].remediation, /2\.0\.0/); +}); + +test("Checkov parser maps failed IaC checks", () => { + const findings = parseCheckovJson(JSON.stringify({ + check_type: "terraform", + results: { failed_checks: [{ check_id: "CKV_TEST_1", check_name: "Unsafe test resource", file_path: "/main.tf", file_line_range: [1, 4], severity: "HIGH" }] }, + })); + assert.equal(findings.length, 1); + assert.equal(findings[0].category, "iac"); + assert.equal(findings[0].location.path, "main.tf"); +}); From 5a0a12e31f5cf67a7175992172df063544097fa6 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:49:37 -0400 Subject: [PATCH 0035/1132] chore: bump SynSec workspace to v0.2.0 --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d2f52749..fd5be51a 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "synsec", - "version": "0.1.0", + "version": "0.2.0", "private": true, - "description": "Repository-first security scanning and vulnerability correlation platform", + "description": "Repository-first security scanning, correlation, reporting, and optional AI triage", "type": "module", "engines": { "node": ">=20" @@ -14,6 +14,7 @@ "scripts": { "build": "tsc -b", "clean": "tsc -b --clean", + "typecheck": "tsc -b --pretty false", "test": "node --test", "synsec": "npm run build && node --enable-source-maps apps/cli/dist/index.js" }, From f5dc7635f2413ba3f869179112f9c3c57433680f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:50:16 -0400 Subject: [PATCH 0036/1132] docs: rewrite README for the v0.2 testable product --- README.md | 319 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 275 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index b235e0b3..317c5399 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,299 @@ # SynSec -SynSec is a repository-first security scanning platform for finding, correlating, and explaining vulnerabilities before they reach production. +SynSec is a repository-first security scanner that combines mature open-source security engines into one normalized, correlated report. -The project is designed around a simple idea: mature open-source scanners should do what they are already good at, while SynSec provides the orchestration, normalization, deduplication, repository context, remediation workflow, and developer experience around them. +Instead of replacing tools such as Opengrep, Trivy, Betterleaks, OSV-Scanner, Grype, and Checkov, SynSec runs them through a common adapter layer, merges overlapping results, adds repository context, tracks changes against baselines, exports developer-friendly reports, and can optionally send selected findings through an OpenAI-compatible model router for a separate review pass. -## Status +> **Current release line:** v0.2 development MVP. The repository is usable for local testing, but scanner adapters and report schemas may still change before v1.0. -Early development. +## What works now -## Initial scope +- Multi-scanner repository scans with bounded concurrency. +- Scanner failure isolation: one broken engine does not destroy the whole scan. +- Opengrep SAST integration. +- Betterleaks secret scanning, with Gitleaks retained as an optional fallback. +- OSV-Scanner dependency analysis. +- Trivy vulnerability, secret, and misconfiguration analysis. +- Grype dependency/package analysis. +- Checkov IaC analysis. +- Scanner-independent finding schema. +- Deterministic cross-scanner correlation and deduplication. +- Repository language/framework inventory. +- Git commit, branch, and remote metadata discovery with credential redaction. +- Versioned JSON reports. +- Self-contained HTML security dashboard. +- SARIF 2.1.0 output for code-scanning systems. +- Baselines with new/fixed/persisting finding tracking. +- Configurable CI failure thresholds. +- Explicit opt-in AI finding review through an OpenAI-compatible endpoint. +- A seven-question AI review gate that keeps scanner evidence separate from model inference. -- Scan local repositories and Git repositories. -- Normalize findings from multiple security engines into one schema. -- Correlate duplicate findings instead of dumping raw scanner output. -- Track code vulnerabilities, vulnerable dependencies, leaked secrets, infrastructure-as-code issues, and repository security posture. -- Preserve evidence, confidence, source scanner, file/line location, CWE/CVE metadata, and remediation guidance. -- Add an AI review layer later for contextual triage and fix suggestions. +## Quick start -## Planned scanner integrations +Requirements: -SynSec will begin by integrating existing engines rather than rewriting them: +- Node.js 20 or newer (Node 24 recommended) +- npm +- at least one supported scanner binary in `PATH` -- Opengrep — static analysis / SAST -- Trivy — vulnerabilities, dependencies, containers, IaC, and secrets -- Gitleaks — secret detection and Git-history scanning -- OSV-Scanner — dependency vulnerability analysis -- Syft — SBOM generation -- Grype — package and container vulnerability analysis -- Checkov — infrastructure-as-code and CI configuration scanning -- OpenSSF Scorecard — repository security posture +```bash +git clone https://github.com/cmahmud/synsec.git +cd synsec +npm install +npm run build -Additional engines can be added through a scanner adapter interface. +# See which engines are installed +npm run synsec -- doctor . -## Repository model +# Scan a repository +npm run synsec -- scan /path/to/repository +``` + +SynSec automatically skips selected scanner engines that are not installed and reports them at the end of the run. Run `doctor` before a scan when setting up a new machine. + +A normal scan writes: + +```text +.synsec/ +├── report.json +├── report.html +└── report.sarif +``` + +Open `report.html` locally for the dashboard. + +## Commands + +```text +synsec init [path] +synsec doctor [path] +synsec scan [options] +synsec review [options] +synsec render +synsec baseline [destination] +synsec version +``` + +Useful scan options: + +```text +--scanners opengrep,betterleaks,trivy +--parallel 3 +--timeout 900 +--fail-on high +--baseline .synsec/baseline.json +--json +--no-write +``` + +Create a starter configuration with: + +```bash +npm run synsec -- init . +``` + +That creates `synsec.config.json`. + +## Default configuration + +```json +{ + "schemaVersion": 1, + "scanners": [ + "opengrep", + "betterleaks", + "osv-scanner", + "trivy", + "grype", + "checkov" + ], + "parallelism": 3, + "timeoutMs": 900000, + "failOn": "none", + "reports": { + "json": ".synsec/report.json", + "html": ".synsec/report.html", + "sarif": ".synsec/report.sarif" + }, + "ai": { + "enabled": false, + "provider": "openai-compatible", + "sendSourceContext": false + } +} +``` + +`failOn` can be `critical`, `high`, `medium`, `low`, `info`, `unknown`, or `none`. When a threshold is configured, a scan containing that severity or higher exits with code `2`, which is useful in CI. + +## Scanner engines + +| Engine | SynSec ID | Purpose | Default | +| --- | --- | --- | --- | +| Opengrep | `opengrep` | SAST / taint-aware static analysis | yes | +| Betterleaks | `betterleaks` | secrets and Git history | yes | +| Gitleaks | `gitleaks` | secrets and Git history fallback | no | +| OSV-Scanner | `osv-scanner` | open-source dependency vulnerabilities | yes | +| Trivy | `trivy` | dependencies, secrets, IaC/misconfiguration | yes | +| Grype | `grype` | package/dependency vulnerabilities | yes | +| Checkov | `checkov` | infrastructure-as-code | yes | + +Betterleaks is preferred for new installs because it is the actively developed successor maintained by the Gitleaks team. SynSec does **not** enable Betterleaks live credential validation; the adapter performs repository scanning with redacted report output only. + +The engines stay separate projects with their own licenses. SynSec invokes their installed binaries and parses their machine-readable output rather than copying their source into this repository. + +## Correlation + +Raw scanner output is not the product. SynSec converts each result into a common model containing, where available: + +- category and severity; +- confidence; +- scanner and rule ID; +- file/line/column; +- CVE, CWE, GHSA, and OSV identifiers; +- evidence that is safe to retain; +- remediation guidance; +- scanner-specific metadata. + +The correlation layer then groups equivalent results so the user sees one logical issue with multiple supporting scanner sources instead of several copies of the same alert. + +Correlation is deterministic in v0.2. More advanced code-flow and semantic correlation belongs in later releases. + +## Baselines + +After a scan: + +```bash +npm run synsec -- baseline .synsec/report.json +``` + +A later scan can compare against it: + +```bash +npm run synsec -- scan . --baseline .synsec/baseline.json +``` + +The new report tracks: + +- new findings; +- fixed findings; +- findings that are still present. + +This makes SynSec useful as a regression detector rather than only a one-time scanner. + +## Optional AI review + +AI is a **second-pass reviewer**, not the source of truth. It is disabled by default. + +SynSec currently supports any endpoint implementing the OpenAI-compatible `/chat/completions` shape, which includes many local gateways and model routers. That allows a router such as OmniRoute, a self-hosted model gateway, or another compatible provider to sit behind SynSec without tying the project to one model vendor. + +```bash +export SYNSEC_AI_BASE_URL="http://localhost:PORT/v1" +export SYNSEC_AI_MODEL="your/model-id" +export SYNSEC_AI_API_KEY="optional-key" + +npm run synsec -- scan . --ai +``` + +By default the AI reviewer receives the normalized finding but **not repository source code**. To explicitly allow a small bounded source excerpt around a finding: + +```bash +npm run synsec -- scan . --ai --ai-source +``` + +AI output is written separately to `.synsec/ai-review.json` so deterministic scanner evidence remains distinguishable from model inference. + +The review uses seven checks: + +1. Is there a concrete affected location? +2. Is untrusted input involved when required by the finding? +3. Is there a security-sensitive sink or invariant violation? +4. Is the path reachable rather than dead/example code? +5. Were relevant mitigations considered? +6. Is there actual scanner/code evidence? +7. Is there a specific remediation? + +Unknown evidence stays `unknown`; the reviewer is instructed not to invent proof. + +## Privacy and network behavior + +Repository contents stay local to SynSec and its local scanner processes unless the operator explicitly enables an integration that communicates externally. + +Important exceptions to understand: + +- OSV-Scanner normally queries vulnerability/package services for dependency metadata unless configured for its offline mode externally. +- Opengrep's `auto` rules configuration may fetch rule configuration from the network. +- AI review sends finding metadata to the configured model endpoint when enabled. +- Source excerpts are only sent to the AI endpoint when `sendSourceContext` or `--ai-source` is explicitly enabled. + +Secret scanner output is requested with full redaction, and SynSec deliberately does not copy secret values into normalized findings. + +## Architecture ```text repository | - v -scanner adapters - | - +-- Opengrep - +-- Trivy - +-- Gitleaks - +-- ... + +--> repository inventory | - v -normalized findings - | - v -correlation / deduplication - | - v -contextual review - | - +-- dashboard - +-- CLI - +-- remediation workflow + +--> scanner adapters + | + +-- Opengrep + +-- Betterleaks / Gitleaks + +-- OSV-Scanner + +-- Trivy + +-- Grype + +-- Checkov + | + v + normalized findings + | + v + correlation / deduplication + | + +-------+--------+ + | | + v v + reports optional AI review + JSON/HTML/SARIF (separate evidence) + | + v + baseline diff +``` + +The codebase is split into small packages: + +```text +apps/cli command-line product +packages/core domain model + correlation +packages/config stable configuration format +packages/scanner-sdk scanner adapter/process boundary +packages/scanners built-in scanner integrations +packages/repository safe repository inventory/context +packages/report JSON/SARIF/HTML + baselines +packages/engine orchestration and failure isolation +packages/ai opt-in provider-agnostic review gate ``` ## Safety model -SynSec is being built primarily for defensive analysis of code and infrastructure that the operator owns or is authorized to assess. Repository scanning is the core product; external attack-surface and bug-bounty workflows are secondary and must remain explicitly authorized. +SynSec's primary job is defensive analysis of repositories the operator owns or is authorized to assess. Repository scanning does not execute the target project's application or build scripts. + +External attack-surface or bug-bounty functionality, if added later, will remain a separate explicitly authorized mode with scope controls rather than weakening the repository-first default. + +## Development + +```bash +npm install +npm run build +npm test +npm run typecheck +``` + +CI currently runs the build and test suite on Node 24. + +## Project status + +v0.2 is intended to become the first release worth hands-on testing. The next major work after scanner reliability is repository reachability/context, GitHub pull-request integration, stronger finding lifecycle management, and a richer persistent web application. ## License -License has not been selected yet. +A project license has not been selected yet. Third-party scanner engines retain their own licenses and are not vendored into SynSec. From c175e11cb50bc36f8e840ec234eacc3d9ea25097 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:50:37 -0400 Subject: [PATCH 0037/1132] ci: test SynSec on supported Node versions --- .github/workflows/ci.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9736553a..32047029 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,16 +9,20 @@ permissions: contents: read jobs: - build: + build-and-test: + strategy: + fail-fast: false + matrix: + node: [20, 24] runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up Node.js + - name: Set up Node.js ${{ matrix.node }} uses: actions/setup-node@v4 with: - node-version: 24 + node-version: ${{ matrix.node }} - name: Install dependencies run: npm install @@ -26,5 +30,8 @@ jobs: - name: Build run: npm run build + - name: Typecheck + run: npm run typecheck + - name: Test run: npm test From acc2030dda26d0265bb03be38961bdaff78d0d09 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:50:53 -0400 Subject: [PATCH 0038/1132] docs: add scanner installation guide --- docs/INSTALL.md | 156 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/INSTALL.md diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 00000000..374e463f --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,156 @@ +# Installing SynSec and scanner engines + +SynSec itself is a Node.js application. Detection engines remain separate binaries so they can be upgraded independently and keep their original licenses. + +You do **not** need every engine installed to use SynSec. `synsec doctor` shows what is available and scans continue with the engines that are present. + +## SynSec + +```bash +git clone https://github.com/cmahmud/synsec.git +cd synsec +npm install +npm run build +npm run synsec -- doctor . +``` + +Node.js 20+ is supported. Node.js 24 is recommended. + +## Opengrep + +Project: https://github.com/opengrep/opengrep + +Linux/macOS: + +```bash +curl -fsSL https://raw.githubusercontent.com/opengrep/opengrep/main/install.sh | bash +``` + +Windows PowerShell: + +```powershell +irm https://raw.githubusercontent.com/opengrep/opengrep/main/install.ps1 | iex +``` + +Confirm: + +```bash +opengrep --version +``` + +## Betterleaks + +Project: https://github.com/betterleaks/betterleaks + +Betterleaks is SynSec's preferred secrets engine for new installs. It is maintained by the team behind Gitleaks. + +macOS: + +```bash +brew install betterleaks +``` + +With Go: + +```bash +go install github.com/betterleaks/betterleaks@latest +``` + +Or use a release binary from the project's GitHub Releases page. + +SynSec runs Betterleaks with fully redacted report output. Live credential validation is not enabled by the SynSec adapter. + +## Gitleaks (optional fallback) + +Project: https://github.com/gitleaks/gitleaks + +SynSec keeps a Gitleaks adapter for environments that already have it installed, but it is not in the default scanner list. + +## OSV-Scanner + +Project: https://github.com/google/osv-scanner + +With Go: + +```bash +go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest +``` + +Prebuilt release binaries are also available from GitHub Releases. + +Confirm: + +```bash +osv-scanner --version +``` + +## Trivy + +Project: https://github.com/aquasecurity/trivy + +Use the installation method documented by Aqua for your operating system. Trivy is available through common package managers and as a standalone binary. + +Confirm: + +```bash +trivy --version +``` + +## Grype + +Project: https://github.com/anchore/grype + +Linux/macOS installation helper published by Anchore: + +```bash +curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b "$HOME/.local/bin" +``` + +Confirm: + +```bash +gripe_version=$(grype version 2>/dev/null || true) +printf '%s\n' "$gripe_version" +``` + +## Checkov + +Project: https://github.com/bridgecrewio/checkov + +`pipx` is recommended so Checkov does not modify the system Python environment: + +```bash +pipx install checkov +``` + +Confirm: + +```bash +checkov --version +``` + +## Verify the full setup + +From the SynSec repository: + +```bash +npm run synsec -- doctor . +``` + +A healthy setup can look like: + +```text +OK Opengrep ... +OK Betterleaks ... +DISABLED Gitleaks ... +OK OSV-Scanner ... +OK Trivy ... +OK Grype ... +OK Checkov ... +``` + +Missing scanners are not fatal unless your own CI policy requires them. SynSec reports unavailable selected engines so a scan cannot silently pretend that coverage existed. + +## Network/privacy notes + +Some engines use network services for rule or vulnerability metadata. See the main README for the current privacy model. AI review is separately opt-in, and source excerpts are not sent to a model endpoint unless explicitly enabled. From fb494106fdce5d6e10c07a8e8eae9aa7b0f2d50d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:52:50 -0400 Subject: [PATCH 0039/1132] feat(core): strengthen deterministic cross-scanner correlation --- packages/core/src/index.ts | 99 +++++++++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 18 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 568c0fc2..99239d76 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -47,6 +47,7 @@ export interface Finding { identifiers?: FindingIdentifiers; evidence?: string; remediation?: string; + /** Native scanner fingerprint when one exists. SynSec computes its own correlation fingerprint. */ fingerprint?: string; metadata?: Record; } @@ -68,6 +69,7 @@ export interface ScanResult { } export interface CorrelatedFinding { + /** Stable SynSec correlation fingerprint, independent of the source scanner fingerprint. */ fingerprint: string; primary: Finding; duplicates: Finding[]; @@ -83,35 +85,96 @@ const severityWeight: Record = { unknown: 0, }; -function normalizedIdentifierSet(finding: Finding): string { - const ids = finding.identifiers; - if (!ids) return ""; +function normalizedValues(values: readonly string[]): string[] { + return [...new Set(values.map((value) => value.trim().toLowerCase()).filter(Boolean))].sort(); +} - return [ +function normalizedIdentifierSet(finding: Finding): string[] { + const ids = finding.identifiers; + if (!ids) return []; + return normalizedValues([ ...(ids.cwe ?? []), ...(ids.cve ?? []), ...(ids.osv ?? []), ...(ids.ghsa ?? []), - ] - .map((value) => value.trim().toLowerCase()) - .sort() - .join(","); + ]); } -export function findingFingerprint(finding: Finding): string { - if (finding.fingerprint) return finding.fingerprint; +function strongVulnerabilityIdentifiers(finding: Finding): string[] { + const ids = finding.identifiers; + if (!ids) return []; + // CWE is a vulnerability class, not a unique advisory, so it is deliberately + // excluded from the strongest dependency correlation key. + return normalizedValues([ + ...(ids.cve ?? []), + ...(ids.osv ?? []), + ...(ids.ghsa ?? []), + ]); +} + +function normalizedPath(finding: Finding): string { + return (finding.location?.path ?? "") + .replaceAll("\\", "/") + .replace(/^\.\//, "") + .toLowerCase(); +} + +function normalizedTitle(finding: Finding): string { + return finding.title.trim().toLowerCase().replace(/\s+/g, " "); +} - const location = finding.location; - const canonical = [ +function metadataString(finding: Finding, key: string): string { + const value = finding.metadata?.[key]; + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function packageIdentity(finding: Finding): string { + return metadataString(finding, "purl") || metadataString(finding, "package"); +} + +function correlationCanonical(finding: Finding): string { + const path = normalizedPath(finding); + const line = finding.location?.startLine?.toString() ?? ""; + const strongIds = strongVulnerabilityIdentifiers(finding); + + // Dependency engines frequently use different rule IDs and titles for the + // same advisory. Advisory IDs plus package identity are substantially more + // reliable than scanner-specific fingerprints for cross-tool correlation. + if ((finding.category === "dependency" || finding.category === "container" || finding.category === "supply-chain") && strongIds.length > 0) { + return ["advisory", finding.category, strongIds.join(","), packageIdentity(finding) || path].join("|"); + } + + // Secret scanners use different rule names for the same value. SynSec never + // hashes the secret itself; a shared source location is the safest common + // deterministic signal we can use without retaining credentials. + if (finding.category === "secret" && path && line) { + return ["secret-location", path, line].join("|"); + } + + // Two SAST engines that agree on the same CWE at the same source location + // should normally be presented as corroborating evidence for one issue. + const cwes = normalizedValues(finding.identifiers?.cwe ?? []); + if (finding.category === "sast" && path && line && cwes.length > 0) { + return ["sast-location-cwe", path, line, cwes.join(",")].join("|"); + } + + // Fall back to a conservative scanner-aware key when there is not enough + // evidence to safely merge alerts from unrelated engines. + return [ + "exact", finding.category, - finding.scanner.ruleId ?? "", - location?.path.toLowerCase() ?? "", - location?.startLine?.toString() ?? "", - normalizedIdentifierSet(finding), - finding.title.trim().toLowerCase(), + finding.scanner.name.toLowerCase(), + (finding.scanner.ruleId ?? "").toLowerCase(), + path, + line, + normalizedIdentifierSet(finding).join(","), + normalizedTitle(finding), ].join("|"); +} - return createHash("sha256").update(canonical).digest("hex"); +/** Compute SynSec's correlation fingerprint. Native scanner fingerprints remain on Finding.fingerprint. */ +export function findingFingerprint(finding: Finding): string { + return createHash("sha256").update(correlationCanonical(finding)).digest("hex"); } function shouldReplacePrimary(current: Finding, candidate: Finding): boolean { From 8111497208c06cc89aabc69e9cce8f93f2c41abd Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:53:06 -0400 Subject: [PATCH 0040/1132] test(core): prove cross-scanner advisory and secret correlation --- tests/core.test.mjs | 58 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/tests/core.test.mjs b/tests/core.test.mjs index f58e6f93..7a51e4ee 100644 --- a/tests/core.test.mjs +++ b/tests/core.test.mjs @@ -2,27 +2,31 @@ import assert from "node:assert/strict"; import test from "node:test"; import { correlateFindings } from "../packages/core/dist/index.js"; -test("correlates duplicate findings from multiple scanners", () => { - const base = { - category: "dependency", - severity: "high", - confidence: 0.8, - location: { path: "package-lock.json" }, - identifiers: { cve: ["CVE-2026-1234"] }, - title: "Example vulnerable dependency", - }; - +test("correlates the same dependency advisory across scanners despite different rule IDs and titles", () => { const correlated = correlateFindings([ { - ...base, id: "one", + category: "dependency", + severity: "high", + confidence: 0.8, + location: { path: "package-lock.json" }, + identifiers: { cve: ["CVE-2026-1234"] }, + title: "First scanner advisory title", scanner: { name: "trivy", ruleId: "CVE-2026-1234" }, + metadata: { package: "demo-package" }, + fingerprint: "native-trivy-fingerprint", }, { - ...base, id: "two", + category: "dependency", + severity: "critical", confidence: 0.95, - scanner: { name: "grype", ruleId: "CVE-2026-1234" }, + location: { path: "package-lock.json" }, + identifiers: { cve: ["CVE-2026-1234"], ghsa: ["GHSA-demo-demo-demo"] }, + title: "Second scanner uses a different title", + scanner: { name: "grype", ruleId: "GHSA-demo-demo-demo" }, + metadata: { package: "demo-package" }, + fingerprint: "native-grype-fingerprint", }, ]); @@ -30,4 +34,32 @@ test("correlates duplicate findings from multiple scanners", () => { assert.equal(correlated[0].primary.id, "two"); assert.equal(correlated[0].duplicates.length, 1); assert.equal(correlated[0].sources.length, 2); + assert.notEqual(correlated[0].fingerprint, "native-trivy-fingerprint"); +}); + +test("correlates secret scanner findings at the same source location without hashing secret content", () => { + const correlated = correlateFindings([ + { + id: "one", + title: "Potential API token", + category: "secret", + severity: "high", + confidence: 0.9, + scanner: { name: "trivy", ruleId: "generic-token" }, + location: { path: "src/config.ts", startLine: 7 }, + }, + { + id: "two", + title: "Credential detected", + category: "secret", + severity: "high", + confidence: 0.98, + scanner: { name: "betterleaks", ruleId: "vendor-token" }, + location: { path: "src/config.ts", startLine: 7 }, + }, + ]); + + assert.equal(correlated.length, 1); + assert.equal(correlated[0].primary.id, "two"); + assert.equal(correlated[0].sources.length, 2); }); From 2abdc613454c711dab03d455e3829e4e97f030fb Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:53:24 -0400 Subject: [PATCH 0041/1132] test(core): align advisory correlation fixture with shared CVE identity --- tests/core.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/core.test.mjs b/tests/core.test.mjs index 7a51e4ee..a4f79fb9 100644 --- a/tests/core.test.mjs +++ b/tests/core.test.mjs @@ -22,7 +22,7 @@ test("correlates the same dependency advisory across scanners despite different severity: "critical", confidence: 0.95, location: { path: "package-lock.json" }, - identifiers: { cve: ["CVE-2026-1234"], ghsa: ["GHSA-demo-demo-demo"] }, + identifiers: { cve: ["CVE-2026-1234"] }, title: "Second scanner uses a different title", scanner: { name: "grype", ruleId: "GHSA-demo-demo-demo" }, metadata: { package: "demo-package" }, From a34fe3a5e6c300ad68412b2ac529b05ed89d8e85 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:53:44 -0400 Subject: [PATCH 0042/1132] fix(opengrep): keep OWASP tags out of advisory identifiers --- packages/scanners/src/opengrep.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/scanners/src/opengrep.ts b/packages/scanners/src/opengrep.ts index 1ec2dcf0..fd05e4e0 100644 --- a/packages/scanners/src/opengrep.ts +++ b/packages/scanners/src/opengrep.ts @@ -7,7 +7,7 @@ import { asArray, asRecord, asString, commandAvailability, identifiersFrom, norm function metadataIdentifiers(metadata: Record | undefined): string[] { if (!metadata) return []; const values: string[] = []; - for (const key of ["cwe", "cve", "owasp"]) { + for (const key of ["cwe", "cve"]) { const value = metadata[key]; if (typeof value === "string") values.push(value); else values.push(...strings(value)); @@ -53,6 +53,7 @@ export function parseOpengrepJson(raw: string): Finding[] { metadata: { technology: metadata?.technology, references: metadata?.references, + owasp: metadata?.owasp, likelihood: metadata?.likelihood, impact: metadata?.impact, confidence: metadata?.confidence, From d9fb43565920bc05a65693c699fe940ebd1755f6 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:54:06 -0400 Subject: [PATCH 0043/1132] fix(engine): never report a clean score when no scanner ran --- packages/engine/src/index.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 827f2fbf..68a6986b 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -144,6 +144,15 @@ export function reportMeetsFailureThreshold(report: SynSecReport, failOn: SynSec return report.findings.some((group) => severityRank[group.primary.severity] >= threshold); } +function unavailableSummary(statuses: readonly ScannerStatus[]): string { + const selected = statuses.filter((status) => status.selected); + if (selected.length === 0) return "No scanner engines are selected in the SynSec configuration."; + const detail = selected + .map((status) => `${status.displayName}: ${status.availability.reason ?? "unavailable"}`) + .join("; "); + return `No selected scanner engines are available. ${detail}`; +} + export async function runScanEngine(input: { rootPath: string; config: SynSecConfig; @@ -157,7 +166,17 @@ export async function runScanEngine(input: { inventoryRepository(root), ]); + const availableSelected = statuses.filter( + (status) => status.selected && status.availability.available, + ); + if (availableSelected.length === 0) throw new Error(unavailableSummary(statuses)); + const { scans, failures } = await runSelectedScanners(target, input.config, statuses); + if (scans.length === 0) { + const details = failures.map((failure) => `${failure.scanner}: ${failure.message}`).join("; "); + throw new Error(`All available scanner engines failed.${details ? ` ${details}` : ""}`); + } + let report = buildReport({ target, scans, From d2a1fb1d5a24a1b57992889b9429fa7167d2109b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:54:44 -0400 Subject: [PATCH 0044/1132] fix(cli): correct AI review output handling and tighten UX --- apps/cli/src/index.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index e6075274..b54d3d87 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -4,14 +4,13 @@ import { copyFile, mkdir, stat, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { reviewFinding, type AiFindingReview } from "@synsec/ai"; import { - defaultConfig, loadConfig, resolveReportPaths, SYNSEC_CONFIG_FILENAME, writeDefaultConfig, type SynSecConfig, } from "@synsec/config"; -import type { CorrelatedFinding, Finding, Severity } from "@synsec/core"; +import type { CorrelatedFinding } from "@synsec/core"; import { runScanEngine, scannerStatuses } from "@synsec/engine"; import { readReport, @@ -90,6 +89,14 @@ Scan options: --ai-base-url OpenAI-compatible API base URL. --ai-model Model ID for AI triage. +Review options: + --root Repository root when it differs from the saved report path. + --output AI review output path. + --ai-source Allow bounded source excerpts to be sent. + --ai-limit Maximum findings to review. + --ai-base-url OpenAI-compatible API base URL. + --ai-model Model ID. + AI environment variables: SYNSEC_AI_BASE_URL SYNSEC_AI_API_KEY @@ -192,9 +199,10 @@ async function reviewGroups( config: SynSecConfig, limit: number, ): Promise> { - const provider = aiProvider(config); const reviews: Record = {}; const candidates = report.findings.slice(0, limit); + if (candidates.length === 0) return reviews; + const provider = aiProvider(config); for (let index = 0; index < candidates.length; index += 1) { const group = candidates[index]; @@ -308,7 +316,8 @@ async function review(): Promise { if (model) config.ai.model = model; const limit = integerOption("--ai-limit") ?? report.findings.length; const reviews = await reviewGroups(report, root, config, limit); - const outputPath = resolve(option("--output") ?? dirname(reportPath), option("--output") ? "." : "ai-review.json"); + const explicitOutput = option("--output"); + const outputPath = explicitOutput ? resolve(explicitOutput) : resolve(dirname(reportPath), "ai-review.json"); await writeAiReviews(outputPath, report, reviews); console.log(`Wrote ${Object.keys(reviews).length} AI review(s) to ${outputPath}`); } From e2fe2018732f23702325958a2ed675e270078f68 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:55:16 -0400 Subject: [PATCH 0045/1132] docs: bring roadmap in sync with v0.2 progress --- docs/ROADMAP.md | 118 +++++++++++++++++++++++++++++++----------------- 1 file changed, 76 insertions(+), 42 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b45ca605..741e5b4e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,75 +1,108 @@ # SynSec Roadmap +This roadmap separates what is already usable in the repository from the deeper analysis and hosted-product work that follows it. + ## Phase 0 — Foundation - [x] Standalone public repository - [x] Normalized finding model - [x] Scanner adapter SDK -- [x] CLI skeleton -- [x] First real scanner integration: Trivy -- [x] Initial correlation layer -- [ ] CI green on Node 24 -- [ ] Stable configuration file format - -## Phase 1 — Repository scanner MVP - -- [ ] Opengrep adapter -- [ ] Gitleaks adapter -- [ ] OSV-Scanner adapter -- [ ] Checkov adapter -- [ ] Syft + Grype adapters +- [x] CLI foundation +- [x] Initial Trivy integration +- [x] Deterministic correlation layer +- [x] CI on Node 20 and Node 24 +- [x] Versioned configuration format + +## Phase 1 — Repository scanner MVP (v0.2) + +- [x] Opengrep adapter +- [x] Betterleaks adapter +- [x] Gitleaks fallback adapter +- [x] OSV-Scanner adapter +- [x] Trivy adapter +- [x] Grype adapter +- [x] Checkov adapter +- [x] Bounded parallel scanner orchestration +- [x] Scanner failure isolation +- [x] Versioned JSON report format +- [x] SARIF 2.1 export +- [x] Self-contained HTML report/dashboard +- [x] Stronger cross-scanner advisory and source-location correlation +- [x] Configurable CI severity threshold +- [x] Baseline support with new/fixed/persisting findings +- [x] Secret redaction in normalized output +- [ ] Syft SBOM adapter - [ ] OpenSSF Scorecard adapter -- [ ] SARIF import/export -- [ ] JSON report format with schema versioning -- [ ] Better cross-scanner deduplication -- [ ] Severity and confidence policy engine -- [ ] Ignore/baseline support -- [ ] Scan only changed files when appropriate +- [ ] Generic SARIF import +- [ ] Changed-files-only scan mode ## Phase 2 — Repository intelligence -- [ ] Language/framework detection -- [ ] Repository index -- [ ] Import/call graph +- [x] Language/framework inventory +- [x] Safe bounded finding-to-code context retrieval +- [ ] Persistent repository index +- [ ] Import/module graph +- [ ] Function/call graph - [ ] Routes and externally reachable entry points - [ ] Authentication/authorization context -- [ ] Database and filesystem sinks -- [ ] Dependency reachability -- [ ] Finding-to-code context retrieval +- [ ] Database, filesystem, process, and network sinks +- [ ] Dependency reachability beyond scanner-provided call analysis +- [ ] Test ownership and coverage context around findings ## Phase 3 — Contextual security review -- [ ] AI-assisted finding triage -- [ ] Explain why a finding matters in this repository -- [ ] Distinguish deterministic evidence from model inference -- [ ] Suggested code patch -- [ ] Suggested tests -- [ ] Rescan after remediation -- [ ] Finding states: new, confirmed, false positive, accepted risk, fixed, regressed +- [x] Provider-agnostic OpenAI-compatible AI review adapter +- [x] Explicit opt-in for model review +- [x] Separate deterministic scanner evidence from model inference +- [x] Seven-question evidence gate for model review +- [x] Source-code context disabled by default and separately opt-in +- [ ] Multi-model reviewer/verifier consensus +- [ ] Repository-aware explanation of reachability and impact +- [ ] Suggested patch generation +- [ ] Suggested regression/security tests +- [ ] Safe rescan-after-remediation workflow +- [ ] Finding lifecycle: new, confirmed, false positive, accepted risk, fixed, regressed + +## Phase 4 — Reusable workflows / skills + +The orchestration layer should be able to expose small reusable defensive workflows rather than hard-coding one giant agent prompt. + +- [ ] Repository review workflow +- [ ] Dependency review workflow +- [ ] Secrets review workflow +- [ ] IaC review workflow +- [ ] Fix verification workflow +- [ ] Report-writing workflow +- [ ] Provider/model routing policy by task and cost +- [ ] User-defined workflow/skill format with explicit capabilities +- [ ] Human approval boundaries for any action that changes a repository + +These workflows operate on repository evidence and scanner results. They are not a mechanism for silently expanding into external targets. -## Phase 4 — Git hosting and CI +## Phase 5 — Git hosting and CI - [ ] GitHub App - [ ] Repository installation flow - [ ] Pull-request scanning - [ ] Commit status / checks -- [ ] Inline findings -- [ ] Scheduled scans -- [ ] Optional remediation pull requests +- [ ] Inline SARIF/code-scanning findings +- [ ] Scheduled repository scans +- [ ] Optional remediation pull requests with explicit approval - [ ] GitLab and Bitbucket adapters -## Phase 5 — Web application +## Phase 6 — Persistent web application - [ ] Project/repository dashboard - [ ] Scan history -- [ ] Security score -- [ ] New/fixed/regressed findings +- [ ] Security-score history +- [ ] New/fixed/regressed views - [ ] Finding detail page with source evidence - [ ] Dependency and SBOM views - [ ] Repository posture view - [ ] Team triage workflow +- [ ] Finding comments/ownership -## Phase 6 — Isolated scan workers +## Phase 7 — Isolated scan workers - [ ] Containerized scanner images - [ ] Job queue @@ -78,7 +111,8 @@ - [ ] Network policy - [ ] Horizontal workers - [ ] Artifact retention policy +- [ ] Secrets/credential minimization for private repository clones -## Later +## Later — explicitly authorized external assessment -Authorized attack-surface and bug-bounty workflows can be added later as a separate product mode. They should not define the core architecture or weaken the repository-first authorization model. +External attack-surface or bug-bounty workflows may be explored as a separate mode only after scope/authorization controls exist. They should not define the core architecture, should never silently expand target scope, and should not weaken the repository-first defensive defaults. From 2d7bc11462ab84b61dd0a85aba9dabbea0919462 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:55:51 -0400 Subject: [PATCH 0046/1132] docs: document the v0.2 architecture and trust boundaries --- docs/ARCHITECTURE.md | 222 +++++++++++++++++++++++++++++++------------ 1 file changed, 159 insertions(+), 63 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 86d0e860..bc16b922 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,120 +2,216 @@ ## Goal -SynSec should act as the orchestration and intelligence layer around mature security scanners rather than reimplementing every detection engine. +SynSec is the orchestration, normalization, correlation, reporting, and contextual-review layer around mature security engines. Detection engines remain replaceable external tools rather than being copied into the application. -The core pipeline is: +The v0.2 pipeline is: ```text repository | - v -scanner adapters - | - v -raw scanner results - | - v -normalization + +----> safe inventory --------------------------+ + | | + v | +scanner availability | + | | + v | +bounded concurrent scanner runner | + | | + +-- Opengrep | + +-- Betterleaks / Gitleaks | + +-- OSV-Scanner | + +-- Trivy | + +-- Grype | + +-- Checkov | + | | + v | +normalized findings <-----------------------------+ | v correlation / deduplication | - v -repository context and reachability + +--------------------+ + | | + v v +versioned report optional AI review +JSON/HTML/SARIF separate output/evidence | v -triage / remediation - | - +-- CLI - +-- web dashboard - +-- CI checks - +-- pull-request remediation +baseline comparison +new / fixed / persisting ``` -## Packages +## Package boundaries ### `@synsec/core` -Owns scanner-independent domain types and correlation logic. Scanner-specific response shapes should never leak into the rest of the application. +Owns scanner-independent domain types and deterministic correlation. + +Scanner-native fingerprints are retained as source metadata on individual findings, while the correlation layer computes its own fingerprint so two different engines can corroborate the same issue. + +Current stronger deterministic signals include: + +- shared vulnerability advisory IDs plus package identity for dependency findings; +- same file and line for redacted secret findings; +- same file, line, and CWE for SAST findings; +- conservative scanner-aware fallbacks when there is not enough evidence to merge alerts safely. + +### `@synsec/config` + +Owns the stable `synsec.config.json` schema. It controls scanner selection, concurrency, timeouts, CI failure thresholds, report locations, baselines, and AI privacy behavior. ### `@synsec/scanner-sdk` -Defines the adapter contract used by all scanner integrations and provides shared process-execution primitives. +Defines the adapter contract and the shared process runner. -Scanner adapters are expected to: +Important process properties: -1. report whether the underlying engine is available; -2. execute it without invoking a shell; -3. parse its native output; -4. emit the normalized SynSec finding schema. +- `spawn` is used without a shell; +- arguments are passed as an array rather than interpolated into command text; +- timeouts and abort signals are supported; +- scanner stdout/stderr remain separate. ### `@synsec/scanners` -Contains built-in integrations. The first integration is Trivy. +Contains built-in adapters for external engines. + +An adapter must: + +1. report binary availability; +2. invoke only its intended scanner binary; +3. request machine-readable output; +4. normalize the result into `Finding` objects; +5. avoid retaining secrets where the engine can redact them; +6. treat documented scanner "findings found" exit codes separately from execution failures. + +The current engines are Opengrep, Betterleaks, Gitleaks, OSV-Scanner, Trivy, Grype, and Checkov. + +### `@synsec/repository` + +Provides lightweight repository intelligence without executing the project under analysis. + +The v0.2 implementation: + +- walks files while excluding common generated/vendor directories; +- skips symlinks; +- caps inventory size; +- detects languages and common frameworks; +- retrieves bounded text around a finding only after verifying that the path remains inside the repository root; +- refuses large/binary context files. -Planned adapters include Opengrep, Gitleaks, OSV-Scanner, Syft, Grype, Checkov, and OpenSSF Scorecard. +This package is the beginning of the future code graph/reachability layer. + +### `@synsec/report` + +Owns the versioned SynSec report model and presentation formats: + +- JSON (`schemaVersion: 1.0`); +- SARIF 2.1.0; +- self-contained HTML; +- baseline comparison. + +The HTML renderer escapes finding-controlled content before insertion. + +### `@synsec/engine` + +Coordinates a scan. + +Responsibilities include: + +- Git repository metadata discovery; +- credential stripping from remote URLs; +- scanner availability checks; +- bounded concurrency; +- failure isolation; +- repository inventory; +- report construction; +- CI severity threshold evaluation. + +The engine refuses to generate a reassuring "clean" report if no selected scanner was able to run. If every available scanner fails, the scan itself fails. + +### `@synsec/ai` + +Provides the optional contextual review boundary. + +The first implementation deliberately uses an OpenAI-compatible protocol rather than importing a model-vendor SDK. A local or remote model router can therefore sit behind the same interface. + +AI review is not part of deterministic detection. It writes a separate review artifact and is governed by a seven-question evidence gate. Source excerpts are disabled by default and only retrieved/sent when explicitly enabled. ### `@synsec/cli` -Provides the local developer workflow. The initial commands are `doctor` and `scan`. +Provides local product workflows: + +- `init` +- `doctor` +- `scan` +- `review` +- `render` +- `baseline` + +The CLI is intentionally useful without a hosted backend. ## Finding model -Each normalized finding preserves: +A normalized finding can preserve: - category; - severity; - confidence; - scanner and rule ID; -- code/file location; -- CVE/CWE/OSV/GHSA identifiers where available; -- evidence; +- source location; +- CVE/CWE/OSV/GHSA identifiers; +- scanner evidence when safe; - remediation guidance; -- scanner-specific metadata. +- scanner-specific metadata; +- native scanner fingerprint. + +A correlated finding adds a SynSec fingerprint, a selected primary representation, duplicate/corroborating results, and the contributing scanner sources. + +## Failure semantics + +Security tooling must not confuse missing coverage with a clean bill of health. -The model is intentionally scanner-independent so multiple engines can contribute evidence to one logical vulnerability. +SynSec therefore distinguishes: -## Correlation +- scanner not selected; +- scanner selected but binary unavailable; +- scanner ran successfully with zero findings; +- scanner ran successfully with findings; +- scanner execution failed. -The first correlation implementation uses a deterministic fingerprint derived from the finding category, rule/identifier context, location, and title. This is only the bootstrap implementation. +At least one selected scanner must complete successfully for a report to be created. -Later versions should use progressively stronger correlation: +## AI/privacy boundary -1. exact fingerprints; -2. shared CVE/CWE/OSV/GHSA identifiers; -3. overlapping code locations; -4. equivalent source/sink data-flow paths; -5. semantic similarity; -6. repository graph context. +AI is disabled by default. -The result presented to the user should be one logical finding with multiple supporting scanner sources, not several duplicate alerts. +When enabled without source context, the provider receives normalized finding metadata only. Enabling `sendSourceContext` or `--ai-source` permits a small bounded excerpt around the affected line. Whole repositories are not sent by default. -## AI review layer +AI conclusions never overwrite or delete deterministic scanner evidence. This is important for auditing false positives, model disagreements, and future reviewer/verifier consensus. -The model layer should receive selected repository context rather than an entire repository by default. +## Reusable workflow direction -Context retrieval should eventually include: +The model-facing layer should evolve into small reusable defensive workflows instead of one giant prompt. Examples include dependency review, secrets review, IaC review, remediation review, and report drafting. -- imports and module relationships; -- route and controller ownership; -- authentication and authorization middleware; -- source-to-sink call paths; -- database access; -- configuration and deployment files; -- tests covering the affected code; -- version-control history relevant to the finding. +A workflow should declare the evidence it may read and the actions it may request. Repository-changing actions should require an explicit approval boundary. External network-assessment workflows belong to a separate authorized mode rather than inheriting repository permissions implicitly. -AI-generated conclusions must remain distinguishable from deterministic scanner evidence. Findings should preserve scanner evidence even when the AI layer changes severity, confidence, exploitability assessment, or remediation guidance. +## Current execution trust model -## Execution model +Repositories under analysis are untrusted input. v0.2 avoids directly executing their application/build scripts, skips symlinks in repository inventory, and invokes scanner binaries without a shell. -Local development starts with scanner binaries installed on the host. Containerized workers can be added later for isolation and reproducibility. +However, external scanners have their own parsers, archive handlers, network behavior, and implementation risks. A future worker layer should run scans in disposable containers with resource and egress policies before SynSec is positioned as a hosted service for arbitrary untrusted repositories. -Long term, scan workers should be disposable and should receive the minimum credentials necessary to clone or inspect the requested repository. +## Future repository graph -## Security boundaries +The next major intelligence layer should add: -SynSec is repository-first and defensive by default. External attack-surface scanning or bug-bounty workflows are secondary modes and require explicit authorization boundaries. +- import/module relationships; +- functions and call sites; +- routes/controllers and externally reachable entry points; +- authentication/authorization middleware; +- source-to-sink paths; +- database/filesystem/process/network sinks; +- dependency reachability; +- relevant tests and version-control context. -Repositories under analysis must be treated as untrusted input. Scanner workers should eventually use sandboxing because repositories can contain malicious build scripts, symlinks, archives, and configuration designed to affect analysis tooling. +That graph should improve triage and fix suggestions without requiring the model to ingest an entire repository for each finding. From 127b9051d05fcf712bb72cba9fa6458970374183 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:56:09 -0400 Subject: [PATCH 0047/1132] docs: fix Grype verification command --- docs/INSTALL.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 374e463f..1ba33a67 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -109,8 +109,7 @@ curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh Confirm: ```bash -gripe_version=$(grype version 2>/dev/null || true) -printf '%s\n' "$gripe_version" +grype version ``` ## Checkov From b6b75d150836acc4521ae2160be3188f1b4b70e0 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:57:32 -0400 Subject: [PATCH 0048/1132] feat(scanners): add OpenSSF Scorecard repository posture adapter --- packages/scanners/src/scorecard.ts | 95 ++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 packages/scanners/src/scorecard.ts diff --git a/packages/scanners/src/scorecard.ts b/packages/scanners/src/scorecard.ts new file mode 100644 index 00000000..4b2c1d8d --- /dev/null +++ b/packages/scanners/src/scorecard.ts @@ -0,0 +1,95 @@ +import { randomUUID } from "node:crypto"; +import type { Finding, ScanResult, Severity } from "@synsec/core"; +import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asNumber, asRecord, asString, commandAvailability, safeJson } from "./utils.js"; + +function severityForScore(score: number | undefined): Severity { + if (score === undefined || score < 0) return "unknown"; + if (score <= 3) return "high"; + if (score <= 6) return "medium"; + if (score <= 8) return "low"; + return "info"; +} + +function confidenceForScore(score: number | undefined): number { + if (score === undefined || score < 0) return 0.6; + return 0.9; +} + +export function parseScorecardJson(raw: string): Finding[] { + const parsed = asRecord(safeJson(raw)); + if (!parsed) return []; + + const findings: Finding[] = []; + for (const value of asArray(parsed.checks)) { + const check = asRecord(value); + if (!check) continue; + const name = asString(check.name) ?? "Repository posture check"; + const score = asNumber(check.score); + + // A perfect check is evidence of good posture rather than a vulnerability. + // Preserve aggregate/report metadata elsewhere instead of manufacturing a finding. + if (score === 10) continue; + + const documentation = asRecord(check.documentation); + const reason = asString(check.reason); + const details = asArray(check.details).filter((item): item is string => typeof item === "string"); + const shortDoc = asString(documentation?.short); + const docUrl = asString(documentation?.url); + + findings.push({ + id: randomUUID(), + title: `${name} repository posture check scored ${score ?? "unknown"}/10`, + description: reason ?? shortDoc ?? `OpenSSF Scorecard reported a non-perfect result for ${name}.`, + category: "repository-posture", + severity: severityForScore(score), + confidence: confidenceForScore(score), + scanner: { name: "scorecard", ruleId: name }, + remediation: shortDoc, + metadata: { + score, + reason, + details, + documentation: docUrl, + }, + }); + } + return findings; +} + +export class ScorecardAdapter implements ScannerAdapter { + readonly id = "scorecard"; + readonly displayName = "OpenSSF Scorecard"; + readonly capabilities = ["repository-posture"] as const; + + checkAvailability(): Promise { + return commandAvailability("scorecard", ["--version"], this.displayName); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + const output = await runProcess( + "scorecard", + [ + `--local=${context.target.path}`, + "--format=json", + "--show-details", + ], + { timeoutMs: context.timeoutMs ?? 15 * 60_000, signal: context.signal }, + ); + + if (output.exitCode !== 0) { + throw new Error(`OpenSSF Scorecard scan failed (${output.exitCode}): ${output.stderr.trim()}`); + } + + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: parseScorecardJson(output.stdout), + diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], + }; + } +} From 6fe2ba7929881568b06a2ece15f6270139f061c3 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:57:42 -0400 Subject: [PATCH 0049/1132] feat(scanners): register OpenSSF Scorecard --- packages/scanners/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/scanners/src/index.ts b/packages/scanners/src/index.ts index 73e054de..f0ca4172 100644 --- a/packages/scanners/src/index.ts +++ b/packages/scanners/src/index.ts @@ -5,6 +5,7 @@ import { GitleaksAdapter } from "./gitleaks.js"; import { GrypeAdapter } from "./grype.js"; import { OpengrepAdapter } from "./opengrep.js"; import { OsvScannerAdapter } from "./osv.js"; +import { ScorecardAdapter } from "./scorecard.js"; import { TrivyAdapter } from "./trivy.js"; export { BetterleaksAdapter, parseBetterleaksJson } from "./betterleaks.js"; @@ -13,6 +14,7 @@ export { GitleaksAdapter, parseGitleaksJson } from "./gitleaks.js"; export { GrypeAdapter, parseGrypeJson } from "./grype.js"; export { OpengrepAdapter, parseOpengrepJson } from "./opengrep.js"; export { OsvScannerAdapter, parseOsvJson } from "./osv.js"; +export { ScorecardAdapter, parseScorecardJson } from "./scorecard.js"; export { TrivyAdapter, parseTrivyJson } from "./trivy.js"; export function builtInScanners(): ScannerAdapter[] { @@ -24,5 +26,6 @@ export function builtInScanners(): ScannerAdapter[] { new TrivyAdapter(), new GrypeAdapter(), new CheckovAdapter(), + new ScorecardAdapter(), ]; } From c3f389d47094c6c16075607640c7f9662aa97753 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:58:00 -0400 Subject: [PATCH 0050/1132] feat(config): enable repository posture checks by default --- packages/config/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index fb044d62..1106f271 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -38,6 +38,7 @@ export const defaultConfig: SynSecConfig = { "trivy", "grype", "checkov", + "scorecard", ], parallelism: 3, timeoutMs: 15 * 60_000, From 924ba2d9787279c7db1f62a59ad06a7b07882d96 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:58:23 -0400 Subject: [PATCH 0051/1132] test(scanners): cover OpenSSF Scorecard normalization --- tests/scanners.test.mjs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/scanners.test.mjs b/tests/scanners.test.mjs index 8293a575..59ee4298 100644 --- a/tests/scanners.test.mjs +++ b/tests/scanners.test.mjs @@ -6,6 +6,7 @@ import { parseGrypeJson, parseOpengrepJson, parseOsvJson, + parseScorecardJson, } from "../packages/scanners/dist/index.js"; test("Betterleaks parser redacts normalized evidence by design", () => { @@ -74,3 +75,17 @@ test("Checkov parser maps failed IaC checks", () => { assert.equal(findings[0].category, "iac"); assert.equal(findings[0].location.path, "main.tf"); }); + +test("Scorecard parser creates posture findings only for non-perfect checks", () => { + const findings = parseScorecardJson(JSON.stringify({ + score: 7.2, + checks: [ + { name: "Branch-Protection", score: 3, reason: "branch protection is incomplete", details: ["detail"], documentation: { short: "Protect important branches", url: "https://example.invalid/docs" } }, + { name: "Security-Policy", score: 10, reason: "security policy found", documentation: { short: "Document security reporting" } }, + ], + })); + assert.equal(findings.length, 1); + assert.equal(findings[0].category, "repository-posture"); + assert.equal(findings[0].severity, "high"); + assert.equal(findings[0].scanner.ruleId, "Branch-Protection"); +}); From c1c8af2cc4e94e22e5c30e67ec2db6cb1704dc38 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:58:59 -0400 Subject: [PATCH 0052/1132] docs: document Scorecard coverage and current v0.2 workflow --- README.md | 56 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 317c5399..4f8a6719 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ SynSec is a repository-first security scanner that combines mature open-source security engines into one normalized, correlated report. -Instead of replacing tools such as Opengrep, Trivy, Betterleaks, OSV-Scanner, Grype, and Checkov, SynSec runs them through a common adapter layer, merges overlapping results, adds repository context, tracks changes against baselines, exports developer-friendly reports, and can optionally send selected findings through an OpenAI-compatible model router for a separate review pass. +Instead of replacing tools such as Opengrep, Trivy, Betterleaks, OSV-Scanner, Grype, Checkov, and OpenSSF Scorecard, SynSec runs them through a common adapter layer, merges overlapping results, adds repository context, tracks changes against baselines, exports developer-friendly reports, and can optionally send selected findings through an OpenAI-compatible model router for a separate review pass. > **Current release line:** v0.2 development MVP. The repository is usable for local testing, but scanner adapters and report schemas may still change before v1.0. @@ -10,12 +10,14 @@ Instead of replacing tools such as Opengrep, Trivy, Betterleaks, OSV-Scanner, Gr - Multi-scanner repository scans with bounded concurrency. - Scanner failure isolation: one broken engine does not destroy the whole scan. +- Protection against false "clean" reports when no scanner successfully ran. - Opengrep SAST integration. - Betterleaks secret scanning, with Gitleaks retained as an optional fallback. - OSV-Scanner dependency analysis. - Trivy vulnerability, secret, and misconfiguration analysis. - Grype dependency/package analysis. - Checkov IaC analysis. +- OpenSSF Scorecard repository-posture analysis. - Scanner-independent finding schema. - Deterministic cross-scanner correlation and deduplication. - Repository language/framework inventory. @@ -49,7 +51,9 @@ npm run synsec -- doctor . npm run synsec -- scan /path/to/repository ``` -SynSec automatically skips selected scanner engines that are not installed and reports them at the end of the run. Run `doctor` before a scan when setting up a new machine. +See [`docs/INSTALL.md`](docs/INSTALL.md) for scanner installation notes. + +SynSec skips selected engines that are not installed and reports the missing coverage. If **none** of the selected engines can run, the scan fails instead of returning a misleading 100/100 score. A normal scan writes: @@ -105,7 +109,8 @@ That creates `synsec.config.json`. "osv-scanner", "trivy", "grype", - "checkov" + "checkov", + "scorecard" ], "parallelism": 3, "timeoutMs": 900000, @@ -136,10 +141,13 @@ That creates `synsec.config.json`. | Trivy | `trivy` | dependencies, secrets, IaC/misconfiguration | yes | | Grype | `grype` | package/dependency vulnerabilities | yes | | Checkov | `checkov` | infrastructure-as-code | yes | +| OpenSSF Scorecard | `scorecard` | repository security posture | yes | Betterleaks is preferred for new installs because it is the actively developed successor maintained by the Gitleaks team. SynSec does **not** enable Betterleaks live credential validation; the adapter performs repository scanning with redacted report output only. -The engines stay separate projects with their own licenses. SynSec invokes their installed binaries and parses their machine-readable output rather than copying their source into this repository. +OpenSSF Scorecard results are treated as repository-posture findings rather than definitive vulnerabilities. Perfect 10/10 checks are not manufactured into findings; non-perfect checks retain their own score and reason as metadata. + +The engines stay separate projects with their own licenses. SynSec invokes installed binaries and parses their machine-readable output rather than copying their source into this repository. ## Correlation @@ -152,11 +160,19 @@ Raw scanner output is not the product. SynSec converts each result into a common - CVE, CWE, GHSA, and OSV identifiers; - evidence that is safe to retain; - remediation guidance; -- scanner-specific metadata. +- scanner-specific metadata; +- native scanner fingerprint. + +SynSec then computes its own correlation fingerprint. This matters because two scanners often use different rule IDs, titles, and native fingerprints for the same issue. + +Current v0.2 correlation can merge: -The correlation layer then groups equivalent results so the user sees one logical issue with multiple supporting scanner sources instead of several copies of the same alert. +- dependency findings sharing advisory identifiers and package identity; +- secret findings at the same file/line without hashing or retaining the secret; +- SAST findings sharing file/line/CWE; +- conservative scanner-aware exact matches when stronger evidence is unavailable. -Correlation is deterministic in v0.2. More advanced code-flow and semantic correlation belongs in later releases. +The user sees one logical issue with multiple supporting sources instead of several copies of the same alert. ## Baselines @@ -172,19 +188,13 @@ A later scan can compare against it: npm run synsec -- scan . --baseline .synsec/baseline.json ``` -The new report tracks: - -- new findings; -- fixed findings; -- findings that are still present. - -This makes SynSec useful as a regression detector rather than only a one-time scanner. +The new report tracks new, fixed, and persisting findings. This makes SynSec useful as a regression detector rather than only a one-time scanner. ## Optional AI review AI is a **second-pass reviewer**, not the source of truth. It is disabled by default. -SynSec currently supports any endpoint implementing the OpenAI-compatible `/chat/completions` shape, which includes many local gateways and model routers. That allows a router such as OmniRoute, a self-hosted model gateway, or another compatible provider to sit behind SynSec without tying the project to one model vendor. +SynSec supports endpoints implementing the OpenAI-compatible `/chat/completions` shape, including local gateways and model routers. A router such as OmniRoute, a self-hosted gateway, or another compatible provider can therefore sit behind SynSec without tying the project to one model vendor. ```bash export SYNSEC_AI_BASE_URL="http://localhost:PORT/v1" @@ -194,7 +204,7 @@ export SYNSEC_AI_API_KEY="optional-key" npm run synsec -- scan . --ai ``` -By default the AI reviewer receives the normalized finding but **not repository source code**. To explicitly allow a small bounded source excerpt around a finding: +By default the AI reviewer receives normalized finding metadata but **not repository source code**. To explicitly allow a small bounded source excerpt around a finding: ```bash npm run synsec -- scan . --ai --ai-source @@ -216,13 +226,14 @@ Unknown evidence stays `unknown`; the reviewer is instructed not to invent proof ## Privacy and network behavior -Repository contents stay local to SynSec and its local scanner processes unless the operator explicitly enables an integration that communicates externally. +Repository contents stay local to SynSec and its local scanner processes unless the operator explicitly enables an integration or scanner behavior that communicates externally. Important exceptions to understand: - OSV-Scanner normally queries vulnerability/package services for dependency metadata unless configured for its offline mode externally. - Opengrep's `auto` rules configuration may fetch rule configuration from the network. -- AI review sends finding metadata to the configured model endpoint when enabled. +- OpenSSF Scorecard can use Git hosting APIs and may need a GitHub token for complete/rate-limit-friendly results. +- AI review sends normalized finding metadata to the configured model endpoint when enabled. - Source excerpts are only sent to the AI endpoint when `sendSourceContext` or `--ai-source` is explicitly enabled. Secret scanner output is requested with full redaction, and SynSec deliberately does not copy secret values into normalized findings. @@ -242,6 +253,7 @@ repository +-- Trivy +-- Grype +-- Checkov + +-- OpenSSF Scorecard | v normalized findings @@ -273,6 +285,8 @@ packages/engine orchestration and failure isolation packages/ai opt-in provider-agnostic review gate ``` +See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for trust boundaries and package details and [`docs/ROADMAP.md`](docs/ROADMAP.md) for planned work. + ## Safety model SynSec's primary job is defensive analysis of repositories the operator owns or is authorized to assess. Repository scanning does not execute the target project's application or build scripts. @@ -288,12 +302,12 @@ npm test npm run typecheck ``` -CI currently runs the build and test suite on Node 24. +CI runs the build, typecheck, and test suite on Node 20 and Node 24. ## Project status -v0.2 is intended to become the first release worth hands-on testing. The next major work after scanner reliability is repository reachability/context, GitHub pull-request integration, stronger finding lifecycle management, and a richer persistent web application. +v0.2 is intended to be the first release worth hands-on testing. The next major work after scanner reliability is repository reachability/context, GitHub pull-request integration, stronger finding lifecycle management, reusable defensive workflows, and a richer persistent web application. ## License -A project license has not been selected yet. Third-party scanner engines retain their own licenses and are not vendored into SynSec. +A SynSec project license has not been selected yet. Third-party scanner engines retain their own licenses and are not vendored into SynSec. From f5c31a8da49c7fa3243d4937a619dbec9eb3933f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:59:21 -0400 Subject: [PATCH 0053/1132] test(ai): verify OpenAI-compatible review boundary locally --- tests/ai.test.mjs | 64 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/ai.test.mjs diff --git a/tests/ai.test.mjs b/tests/ai.test.mjs new file mode 100644 index 00000000..504b5baf --- /dev/null +++ b/tests/ai.test.mjs @@ -0,0 +1,64 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { reviewFinding } from "../packages/ai/dist/index.js"; + +test("AI review uses the OpenAI-compatible boundary and normalizes the seven-question gate", async () => { + let observedBody = ""; + const server = http.createServer((request, response) => { + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { body += chunk; }); + request.on("end", () => { + observedBody = body; + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + verdict: "likely", + confidence: 0.88, + severity: "high", + summary: "Evidence supports the scanner finding", + rationale: "The provided evidence is concrete but reachability is not fully established.", + gate: [ + { id: "concrete", answer: "yes", note: "A source location is present." }, + { id: "evidence", answer: "yes", note: "Scanner evidence is present." }, + ], + remediation: "Use the safer API described by the scanner.", + }), + }, + }], + })); + }); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address(); + assert.ok(address && typeof address === "object"); + const review = await reviewFinding({ + id: "fixture", + title: "Fixture finding", + category: "sast", + severity: "high", + confidence: 0.9, + scanner: { name: "fixture", ruleId: "FIXTURE-1" }, + location: { path: "src/app.ts", startLine: 5 }, + }, { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + model: "fixture-model", + apiKey: "test-key", + }); + + assert.equal(review.verdict, "likely"); + assert.equal(review.model, "fixture-model"); + assert.equal(review.gate.length, 7); + assert.equal(review.gate.find((item) => item.id === "concrete")?.answer, "yes"); + assert.equal(review.gate.find((item) => item.id === "reachable")?.answer, "unknown"); + assert.match(observedBody, /fixture-model/); + assert.match(observedBody, /No source excerpt was provided/); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +}); From dad73e6c1c4f69ec80a875d25156b8e72bb0a97b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:59:31 -0400 Subject: [PATCH 0054/1132] test(engine): prevent false clean reports without scanner coverage --- tests/engine.test.mjs | 46 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/engine.test.mjs diff --git a/tests/engine.test.mjs b/tests/engine.test.mjs new file mode 100644 index 00000000..a362da8c --- /dev/null +++ b/tests/engine.test.mjs @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { defaultConfig } from "../packages/config/dist/index.js"; +import { reportMeetsFailureThreshold, runScanEngine } from "../packages/engine/dist/index.js"; +import { buildReport } from "../packages/report/dist/index.js"; + +test("scan engine refuses to produce a clean report when no selected scanner exists", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-engine-test-")); + try { + await writeFile(join(root, "README.md"), "fixture\n"); + const config = structuredClone(defaultConfig); + config.scanners = ["scanner-that-does-not-exist"]; + await assert.rejects( + runScanEngine({ rootPath: root, config }), + /No selected scanner engines are available/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("failure threshold treats configured severity as inclusive", () => { + const scan = { + scanner: "fixture", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:01.000Z", + target: { path: "/repo" }, + diagnostics: [], + findings: [{ + id: "fixture", + title: "Medium issue", + category: "sast", + severity: "medium", + confidence: 0.9, + scanner: { name: "fixture" }, + }], + }; + const report = buildReport({ target: { path: "/repo" }, scans: [scan] }); + assert.equal(reportMeetsFailureThreshold(report, "high"), false); + assert.equal(reportMeetsFailureThreshold(report, "medium"), true); + assert.equal(reportMeetsFailureThreshold(report, "low"), true); + assert.equal(reportMeetsFailureThreshold(report, "none"), false); +}); From ecead0f7644265af7cf7a810bdfea757aa779eeb Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 03:59:49 -0400 Subject: [PATCH 0055/1132] docs: define reusable defensive workflow architecture --- docs/WORKFLOWS.md | 179 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/WORKFLOWS.md diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md new file mode 100644 index 00000000..0c5a8a24 --- /dev/null +++ b/docs/WORKFLOWS.md @@ -0,0 +1,179 @@ +# Reusable defensive workflows + +SynSec's model-facing layer should be built from small workflows with explicit inputs and capabilities rather than one enormous prompt that implicitly has access to everything. + +This idea is useful for two reasons: + +1. scanner orchestration and model reasoning become independently replaceable; +2. each workflow can declare exactly which repository evidence and actions it is allowed to use. + +The workflow system is not implemented in v0.2 yet. This document defines the direction so future agent work has a stable boundary. + +## Proposed workflow contract + +A workflow should eventually declare something equivalent to: + +```yaml +id: dependency-review +version: 1 +inputs: + - correlated-findings + - dependency-metadata +capabilities: + - read-normalized-findings + - read-bounded-source-context +model: + task: security-review +output: + schema: finding-review-v1 +approval: + repository-write: required + external-network-assessment: forbidden +``` + +The important part is not YAML specifically. The important part is that capabilities are explicit and machine-enforced. + +## Initial workflow set + +### Repository review + +Inputs: + +- normalized findings; +- repository language/framework inventory; +- selected bounded source context. + +Output: + +- evidence-based finding review; +- confidence and severity recommendation; +- unresolved questions. + +### Dependency review + +Inputs: + +- OSV/Trivy/Grype findings; +- package identity and installed/fixed versions; +- scanner-provided reachability information when available. + +Output: + +- deduplicated advisory explanation; +- fix availability; +- whether evidence suggests the vulnerable package is actually relevant to the project. + +### Secrets review + +Inputs: + +- **redacted** secret findings only; +- file and line metadata; +- Git-history metadata where safe. + +Output: + +- rotation/removal guidance; +- repository-history cleanup recommendation; +- confidence assessment. + +A model must never need the secret value itself for this workflow. + +### Infrastructure review + +Inputs: + +- Checkov/Trivy IaC findings; +- the affected configuration excerpt; +- repository deployment metadata. + +Output: + +- configuration-risk explanation; +- defensive remediation; +- uncertainty when deployment context is missing. + +### Fix verification + +Inputs: + +- previous finding; +- proposed/current code change; +- rescan result; +- relevant tests. + +Output: + +- fixed / partially fixed / still present / unable to verify. + +The deterministic rescan remains authoritative. Model review explains evidence rather than declaring a vulnerability fixed on its own. + +### Report writing + +Inputs: + +- normalized/correlated finding; +- deterministic evidence; +- optional reviewed context. + +Output: + +- concise developer-facing explanation; +- remediation summary; +- references to scanner evidence and source locations. + +## Seven-question evidence gate + +The v0.2 AI reviewer already implements the first common workflow primitive. Every contextual finding review asks: + +1. Is there a concrete affected location? +2. Is untrusted input involved when the finding requires it? +3. Is there a security-sensitive sink or invariant violation? +4. Is the affected path actually reachable rather than dead/example code? +5. Were relevant mitigations considered? +6. Is there scanner or code evidence supporting the conclusion? +7. Is there a specific, proportionate remediation? + +An unanswered question stays `unknown`. A model should not fill gaps with invented evidence. + +## Model routing + +Workflows should request a capability class rather than hard-code one vendor/model name. Examples: + +```text +fast-classifier +security-reasoner +code-reasoner +report-writer +verifier +``` + +A router can then map each task to an available model based on cost, latency, privacy, and capability. This keeps SynSec usable with cloud models, local models, or a mixed deployment. + +## Human approval boundaries + +A workflow may recommend a repository change, but v0.2 does not autonomously modify repositories. + +Future write-capable workflows should require explicit approval before: + +- editing source files; +- changing dependencies; +- creating a commit; +- opening a pull request; +- changing CI or infrastructure configuration. + +External network assessment is a separate authorization domain. A future external-assessment mode must have its own explicit scope controls and must not inherit permission merely because a repository workflow can read code. + +## Auditability + +Every future workflow run should preserve: + +- workflow ID and version; +- model/provider identifier; +- deterministic evidence references; +- whether source context was sent; +- output schema version; +- approval events; +- generated patch hash if a patch is produced. + +This makes it possible to reproduce why SynSec reached a recommendation even when models or routing policies change later. From db22fe9c3981bbd65d0b0815c1981df1559e057e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:00:09 -0400 Subject: [PATCH 0056/1132] docs: add OpenSSF Scorecard setup notes --- docs/INSTALL.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 1ba33a67..9fa648bf 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -128,6 +128,26 @@ Confirm: checkov --version ``` +## OpenSSF Scorecard + +Project: https://github.com/ossf/scorecard + +Scorecard currently documents macOS and Linux as its supported CLI platforms. Homebrew is one convenient install path: + +```bash +brew install scorecard +``` + +Standalone release binaries are also available from its GitHub Releases page. + +Confirm: + +```bash +scorecard --version +``` + +Some Scorecard checks use GitHub APIs. For complete scans without the low unauthenticated API limit, configure one of Scorecard's supported GitHub token environment variables such as `GITHUB_AUTH_TOKEN`. Do not commit that token to a repository. + ## Verify the full setup From the SynSec repository: @@ -146,9 +166,10 @@ OK OSV-Scanner ... OK Trivy ... OK Grype ... OK Checkov ... +OK OpenSSF Scorecard ... ``` -Missing scanners are not fatal unless your own CI policy requires them. SynSec reports unavailable selected engines so a scan cannot silently pretend that coverage existed. +Missing scanners are not fatal unless your own CI policy requires them. SynSec reports unavailable selected engines so a scan cannot silently pretend that coverage existed. If no selected scanner can run, the scan fails rather than generating a clean-looking report without coverage. ## Network/privacy notes From e03dae512bafa80ef3ea03f382841a6ef47fe987 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:00:34 -0400 Subject: [PATCH 0057/1132] docs: mark Scorecard integration complete --- docs/ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 741e5b4e..fea30977 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -22,6 +22,7 @@ This roadmap separates what is already usable in the repository from the deeper - [x] Trivy adapter - [x] Grype adapter - [x] Checkov adapter +- [x] OpenSSF Scorecard adapter - [x] Bounded parallel scanner orchestration - [x] Scanner failure isolation - [x] Versioned JSON report format @@ -32,7 +33,6 @@ This roadmap separates what is already usable in the repository from the deeper - [x] Baseline support with new/fixed/persisting findings - [x] Secret redaction in normalized output - [ ] Syft SBOM adapter -- [ ] OpenSSF Scorecard adapter - [ ] Generic SARIF import - [ ] Changed-files-only scan mode From 2eb688266265b8bd30a30eb60369b8b3e2cc7561 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:01:24 -0400 Subject: [PATCH 0058/1132] fix(core): correlate advisory aliases through stable identifier priority --- packages/core/src/index.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 99239d76..a2d0d47f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -103,13 +103,16 @@ function normalizedIdentifierSet(finding: Finding): string[] { function strongVulnerabilityIdentifiers(finding: Finding): string[] { const ids = finding.identifiers; if (!ids) return []; - // CWE is a vulnerability class, not a unique advisory, so it is deliberately - // excluded from the strongest dependency correlation key. - return normalizedValues([ - ...(ids.cve ?? []), - ...(ids.osv ?? []), - ...(ids.ghsa ?? []), - ]); + + // Prefer globally interoperable aliases when a scanner gives us several + // names for the same advisory. This lets an OSV/GHSA-centric scanner and a + // CVE-centric scanner converge on the same SynSec key instead of diverging + // merely because one result contains more aliases. + const cve = normalizedValues(ids.cve ?? []); + if (cve.length > 0) return cve; + const ghsa = normalizedValues(ids.ghsa ?? []); + if (ghsa.length > 0) return ghsa; + return normalizedValues(ids.osv ?? []); } function normalizedPath(finding: Finding): string { From 6d21d18841796b4d3adcfe2ca853ccda1cc1102c Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:01:45 -0400 Subject: [PATCH 0059/1132] test(core): cover scanners with different advisory alias sets --- tests/core.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core.test.mjs b/tests/core.test.mjs index a4f79fb9..f51fb748 100644 --- a/tests/core.test.mjs +++ b/tests/core.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { correlateFindings } from "../packages/core/dist/index.js"; -test("correlates the same dependency advisory across scanners despite different rule IDs and titles", () => { +test("correlates the same dependency advisory across scanners despite different rule IDs, titles, and alias sets", () => { const correlated = correlateFindings([ { id: "one", @@ -22,7 +22,7 @@ test("correlates the same dependency advisory across scanners despite different severity: "critical", confidence: 0.95, location: { path: "package-lock.json" }, - identifiers: { cve: ["CVE-2026-1234"] }, + identifiers: { cve: ["CVE-2026-1234"], ghsa: ["GHSA-demo-demo-demo"] }, title: "Second scanner uses a different title", scanner: { name: "grype", ruleId: "GHSA-demo-demo-demo" }, metadata: { package: "demo-package" }, From 5ab0bd624c9953f310a4c422257715423445792d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:02:25 -0400 Subject: [PATCH 0060/1132] test: exercise the scan engine end-to-end with a scanner shim --- tests/integration.test.mjs | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/integration.test.mjs diff --git a/tests/integration.test.mjs b/tests/integration.test.mjs new file mode 100644 index 00000000..13d84b8b --- /dev/null +++ b/tests/integration.test.mjs @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { delimiter, join } from "node:path"; +import { tmpdir } from "node:os"; +import { defaultConfig } from "../packages/config/dist/index.js"; +import { runScanEngine } from "../packages/engine/dist/index.js"; + +test("scan engine runs an available adapter end-to-end and builds a correlated report", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-integration-repo-")); + const bin = await mkdtemp(join(tmpdir(), "synsec-integration-bin-")); + const originalPath = process.env.PATH ?? ""; + + try { + await mkdir(join(root, "src")); + await writeFile(join(root, "package.json"), JSON.stringify({ name: "fixture", version: "1.0.0" })); + await writeFile(join(root, "src", "index.js"), "console.log('fixture');\n"); + + const trivy = join(bin, "trivy"); + await writeFile(trivy, `#!/bin/sh +if [ "$1" = "--version" ]; then + echo "Version: 99.0.0-fixture" + exit 0 +fi +cat <<'JSON' +{"Results":[{"Target":"package-lock.json","Vulnerabilities":[{"VulnerabilityID":"CVE-2026-4242","PkgName":"fixture-package","InstalledVersion":"1.0.0","FixedVersion":"1.0.1","Title":"Fixture dependency vulnerability","Severity":"HIGH"}]}]} +JSON +`); + await chmod(trivy, 0o755); + process.env.PATH = `${bin}${delimiter}${originalPath}`; + + const config = structuredClone(defaultConfig); + config.scanners = ["trivy"]; + config.parallelism = 1; + + const outcome = await runScanEngine({ rootPath: root, config, toolVersion: "test" }); + assert.equal(outcome.report.scanners.length, 1); + assert.equal(outcome.report.scanners[0].scanner, "trivy"); + assert.equal(outcome.report.rawFindingCount, 1); + assert.equal(outcome.report.findingCount, 1); + assert.equal(outcome.report.summary.high, 1); + assert.equal(outcome.failures.length, 0); + assert.equal(outcome.report.repository.languages.JavaScript, 1); + } finally { + process.env.PATH = originalPath; + await rm(root, { recursive: true, force: true }); + await rm(bin, { recursive: true, force: true }); + } +}); From fa646fdd3e88a085def324d847a40b1d9aac8e37 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:02:57 -0400 Subject: [PATCH 0061/1132] feat(workflows): add reusable defensive workflow package --- packages/workflows/package.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 packages/workflows/package.json diff --git a/packages/workflows/package.json b/packages/workflows/package.json new file mode 100644 index 00000000..00ba8620 --- /dev/null +++ b/packages/workflows/package.json @@ -0,0 +1,15 @@ +{ + "name": "@synsec/workflows", + "version": "0.2.0", + "private": true, + "type": "module", + "exports": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/core": "0.1.0" + } +} From e8af870872b7ffda2ba53b8010328aa490324932 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:03:04 -0400 Subject: [PATCH 0062/1132] build(workflows): configure TypeScript project --- packages/workflows/tsconfig.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 packages/workflows/tsconfig.json diff --git a/packages/workflows/tsconfig.json b/packages/workflows/tsconfig.json new file mode 100644 index 00000000..ebe9ac5b --- /dev/null +++ b/packages/workflows/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../core" } + ], + "include": ["src/**/*.ts"] +} From 70ac5340f6c3bf0a2a2fa0b0c306652fb9b91123 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:03:20 -0400 Subject: [PATCH 0063/1132] feat(workflows): define capability-scoped repository review workflows --- packages/workflows/src/index.ts | 120 ++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 packages/workflows/src/index.ts diff --git a/packages/workflows/src/index.ts b/packages/workflows/src/index.ts new file mode 100644 index 00000000..b4b15f2b --- /dev/null +++ b/packages/workflows/src/index.ts @@ -0,0 +1,120 @@ +import type { CorrelatedFinding, FindingCategory } from "@synsec/core"; + +export type WorkflowCapability = + | "read-normalized-findings" + | "read-repository-inventory" + | "read-bounded-source-context" + | "read-dependency-metadata" + | "read-redacted-secret-metadata" + | "read-infrastructure-config" + | "propose-remediation" + | "propose-tests"; + +export interface WorkflowDefinition { + id: string; + version: 1; + displayName: string; + description: string; + categories: readonly FindingCategory[] | "all"; + capabilities: readonly WorkflowCapability[]; + sourceContextAllowed: boolean; + repositoryWriteRequiresApproval: true; + externalNetworkAssessment: "forbidden"; +} + +const workflows: readonly WorkflowDefinition[] = [ + { + id: "repository-review", + version: 1, + displayName: "Repository Review", + description: "Review normalized findings across the repository and explain the strongest evidence first.", + categories: "all", + capabilities: [ + "read-normalized-findings", + "read-repository-inventory", + "read-bounded-source-context", + "propose-remediation", + "propose-tests", + ], + sourceContextAllowed: true, + repositoryWriteRequiresApproval: true, + externalNetworkAssessment: "forbidden", + }, + { + id: "dependency-review", + version: 1, + displayName: "Dependency Review", + description: "Review known vulnerable dependencies, package identity, fix availability, and available reachability evidence.", + categories: ["dependency", "container", "supply-chain", "license"], + capabilities: [ + "read-normalized-findings", + "read-dependency-metadata", + "read-bounded-source-context", + "propose-remediation", + "propose-tests", + ], + sourceContextAllowed: true, + repositoryWriteRequiresApproval: true, + externalNetworkAssessment: "forbidden", + }, + { + id: "secrets-review", + version: 1, + displayName: "Secrets Review", + description: "Review redacted secret findings and recommend rotation/removal without exposing secret values to the model layer.", + categories: ["secret"], + capabilities: [ + "read-normalized-findings", + "read-redacted-secret-metadata", + "propose-remediation", + ], + sourceContextAllowed: false, + repositoryWriteRequiresApproval: true, + externalNetworkAssessment: "forbidden", + }, + { + id: "infrastructure-review", + version: 1, + displayName: "Infrastructure Review", + description: "Review IaC, deployment, misconfiguration, and repository-posture findings.", + categories: ["iac", "misconfiguration", "repository-posture"], + capabilities: [ + "read-normalized-findings", + "read-infrastructure-config", + "read-bounded-source-context", + "propose-remediation", + "propose-tests", + ], + sourceContextAllowed: true, + repositoryWriteRequiresApproval: true, + externalNetworkAssessment: "forbidden", + }, +] as const; + +export function builtInWorkflows(): readonly WorkflowDefinition[] { + return workflows; +} + +export function getWorkflow(id: string): WorkflowDefinition | undefined { + return workflows.find((workflow) => workflow.id === id); +} + +export function workflowFindings( + findings: readonly CorrelatedFinding[], + workflow: WorkflowDefinition, +): CorrelatedFinding[] { + if (workflow.categories === "all") return [...findings]; + const categories = new Set(workflow.categories); + return findings.filter((finding) => categories.has(finding.primary.category)); +} + +export function assertWorkflowSourceContextAllowed( + workflow: WorkflowDefinition, + sourceContextRequested: boolean, +): void { + if (sourceContextRequested && !workflow.sourceContextAllowed) { + throw new Error( + `Workflow ${workflow.id} does not permit source context. This boundary prevents sensitive values from being unnecessarily sent to a model.`, + ); + } +} From fd750f2d51a2cb0af4b32c2264d5eeb5ffdf085c Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:03:31 -0400 Subject: [PATCH 0064/1132] build: add defensive workflows package to project graph --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index c0e20deb..5d857f2d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ { "path": "./packages/config" }, { "path": "./packages/report" }, { "path": "./packages/repository" }, + { "path": "./packages/workflows" }, { "path": "./packages/ai" }, { "path": "./packages/scanner-sdk" }, { "path": "./packages/scanners" }, From 1e16976aeeae5290bb85f64ec25d195fceb56299 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:03:47 -0400 Subject: [PATCH 0065/1132] feat(cli): depend on reusable defensive workflows --- apps/cli/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 03251797..ad243539 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -18,6 +18,7 @@ "@synsec/engine": "0.2.0", "@synsec/report": "0.2.0", "@synsec/repository": "0.2.0", - "@synsec/scanners": "0.1.0" + "@synsec/scanners": "0.1.0", + "@synsec/workflows": "0.2.0" } } From f32086c9f6924453eb3321dd74161efb6f2625e4 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:04:00 -0400 Subject: [PATCH 0066/1132] build(cli): reference workflows package --- apps/cli/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 7b588cb7..0f2e1271 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -12,7 +12,8 @@ { "path": "../../packages/engine" }, { "path": "../../packages/report" }, { "path": "../../packages/repository" }, - { "path": "../../packages/scanners" } + { "path": "../../packages/scanners" }, + { "path": "../../packages/workflows" } ], "include": ["src/**/*.ts"] } From 662b3b3def69109e3d9da83e53505b9d8ea9600c Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:04:49 -0400 Subject: [PATCH 0067/1132] feat(cli): expose capability-scoped review workflows --- apps/cli/src/index.ts | 79 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index b54d3d87..19b01061 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -22,6 +22,13 @@ import { type SynSecReport, } from "@synsec/report"; import { getFindingContext } from "@synsec/repository"; +import { + assertWorkflowSourceContextAllowed, + builtInWorkflows, + getWorkflow, + workflowFindings, + type WorkflowDefinition, +} from "@synsec/workflows"; const VERSION = "0.2.0"; const args = process.argv.slice(2); @@ -62,6 +69,18 @@ function severityOption(name: string): SynSecConfig["failOn"] | undefined { throw new Error(`${name} must be one of critical, high, medium, low, info, unknown, none.`); } +function workflowOption(): WorkflowDefinition | undefined { + const id = option("--workflow"); + if (!id) return undefined; + const workflow = getWorkflow(id); + if (!workflow) { + throw new Error( + `Unknown workflow ${id}. Available workflows: ${builtInWorkflows().map((item) => item.id).join(", ")}`, + ); + } + return workflow; +} + function printHelp(): void { console.log(`SynSec v${VERSION} — repository-first security scanning @@ -70,6 +89,7 @@ Usage: synsec doctor [path] [--config ] synsec scan [options] synsec review [options] + synsec workflows synsec render [--html ] [--sarif ] synsec baseline [destination] synsec version @@ -84,7 +104,8 @@ Scan options: --json Print the report JSON to stdout. --no-write Do not write JSON/HTML/SARIF report files. --ai Run optional AI triage after deterministic scanning. - --ai-source Allow source excerpts to be sent to the configured AI provider. + --workflow Restrict AI triage to a built-in defensive workflow. + --ai-source Allow source excerpts when the selected workflow permits it. --ai-limit Maximum findings to review (default: 10). --ai-base-url OpenAI-compatible API base URL. --ai-model Model ID for AI triage. @@ -92,7 +113,8 @@ Scan options: Review options: --root Repository root when it differs from the saved report path. --output AI review output path. - --ai-source Allow bounded source excerpts to be sent. + --workflow Restrict review to a built-in defensive workflow. + --ai-source Allow bounded source excerpts when the workflow permits it. --ai-limit Maximum findings to review. --ai-base-url OpenAI-compatible API base URL. --ai-model Model ID. @@ -104,6 +126,7 @@ AI environment variables: SynSec never enables AI review by default. Source excerpts are only sent when sendSourceContext is enabled in config or --ai-source is explicitly supplied. +Workflow capability rules can further prohibit source context. `); } @@ -164,13 +187,25 @@ async function doctor(): Promise { for (const status of statuses) { const marker = !status.selected ? "DISABLED" : status.availability.available ? "OK" : "MISSING"; const detail = status.availability.version ?? status.availability.reason ?? ""; - console.log(`${marker.padEnd(9)} ${status.displayName.padEnd(18)} ${detail}`); + console.log(`${marker.padEnd(9)} ${status.displayName.padEnd(20)} ${detail}`); } console.log("\nAI review:"); console.log(` ${config.ai.enabled ? "enabled" : "disabled"} (source context ${config.ai.sendSourceContext ? "allowed" : "not allowed"})`); } +function listWorkflows(): void { + console.log("SynSec defensive workflows\n"); + for (const workflow of builtInWorkflows()) { + const categories = workflow.categories === "all" ? "all findings" : workflow.categories.join(", "); + console.log(`${workflow.id}`); + console.log(` ${workflow.description}`); + console.log(` categories: ${categories}`); + console.log(` source context: ${workflow.sourceContextAllowed ? "may be explicitly enabled" : "prohibited"}`); + console.log(` external network assessment: ${workflow.externalNetworkAssessment}\n`); + } +} + function printFinding(group: CorrelatedFinding): void { const finding = group.primary; const location = finding.location @@ -198,16 +233,20 @@ async function reviewGroups( root: string, config: SynSecConfig, limit: number, + workflow?: WorkflowDefinition, ): Promise> { + if (workflow) assertWorkflowSourceContextAllowed(workflow, config.ai.sendSourceContext); const reviews: Record = {}; - const candidates = report.findings.slice(0, limit); + const eligible = workflow ? workflowFindings(report.findings, workflow) : report.findings; + const candidates = eligible.slice(0, limit); if (candidates.length === 0) return reviews; const provider = aiProvider(config); for (let index = 0; index < candidates.length; index += 1) { const group = candidates[index]; if (!group) continue; - console.error(`AI review ${index + 1}/${candidates.length}: ${group.primary.title}`); + const workflowLabel = workflow ? ` [${workflow.id}]` : ""; + console.error(`AI review${workflowLabel} ${index + 1}/${candidates.length}: ${group.primary.title}`); const context = config.ai.sendSourceContext ? await getFindingContext(root, group.primary) : undefined; @@ -216,11 +255,22 @@ async function reviewGroups( return reviews; } -async function writeAiReviews(path: string, report: SynSecReport, reviews: Record): Promise { +async function writeAiReviews( + path: string, + report: SynSecReport, + reviews: Record, + workflow?: WorkflowDefinition, +): Promise { await mkdir(dirname(path), { recursive: true }); await writeFile( path, - `${JSON.stringify({ schemaVersion: 1, reportId: report.reportId, generatedAt: new Date().toISOString(), reviews }, null, 2)}\n`, + `${JSON.stringify({ + schemaVersion: 1, + reportId: report.reportId, + generatedAt: new Date().toISOString(), + workflow: workflow ? { id: workflow.id, version: workflow.version } : null, + reviews, + }, null, 2)}\n`, "utf8", ); } @@ -259,10 +309,13 @@ async function scan(): Promise { if (config.ai.enabled) { const limit = integerOption("--ai-limit") ?? 10; - const reviews = await reviewGroups(outcome.report, root, config, limit); + const workflow = workflowOption(); + const reviews = await reviewGroups(outcome.report, root, config, limit, workflow); const aiPath = resolve(root, ".synsec/ai-review.json"); - await writeAiReviews(aiPath, outcome.report, reviews); + await writeAiReviews(aiPath, outcome.report, reviews, workflow); if (!flag("--json")) console.error(`AI reviews: ${aiPath}`); + } else if (option("--workflow")) { + throw new Error("--workflow is an AI review option. Enable review with --ai or in synsec.config.json."); } if (flag("--json")) { @@ -314,11 +367,12 @@ async function review(): Promise { if (baseUrl) config.ai.baseUrl = baseUrl; const model = option("--ai-model"); if (model) config.ai.model = model; + const workflow = workflowOption(); const limit = integerOption("--ai-limit") ?? report.findings.length; - const reviews = await reviewGroups(report, root, config, limit); + const reviews = await reviewGroups(report, root, config, limit, workflow); const explicitOutput = option("--output"); const outputPath = explicitOutput ? resolve(explicitOutput) : resolve(dirname(reportPath), "ai-review.json"); - await writeAiReviews(outputPath, report, reviews); + await writeAiReviews(outputPath, report, reviews, workflow); console.log(`Wrote ${Object.keys(reviews).length} AI review(s) to ${outputPath}`); } @@ -361,6 +415,9 @@ async function main(): Promise { case "review": await review(); break; + case "workflows": + listWorkflows(); + break; case "render": await render(); break; From 8714a8f504d8925aec4984e06292a5a8c573bae2 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:05:00 -0400 Subject: [PATCH 0068/1132] test(workflows): enforce workflow category and privacy boundaries --- tests/workflows.test.mjs | 55 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tests/workflows.test.mjs diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs new file mode 100644 index 00000000..eadc92da --- /dev/null +++ b/tests/workflows.test.mjs @@ -0,0 +1,55 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + assertWorkflowSourceContextAllowed, + getWorkflow, + workflowFindings, +} from "../packages/workflows/dist/index.js"; + +const findings = [ + { + fingerprint: "dep", + primary: { id: "dep", title: "dependency", category: "dependency", severity: "high", confidence: 1, scanner: { name: "fixture" } }, + duplicates: [], + sources: [{ name: "fixture" }], + }, + { + fingerprint: "secret", + primary: { id: "secret", title: "secret", category: "secret", severity: "high", confidence: 1, scanner: { name: "fixture" } }, + duplicates: [], + sources: [{ name: "fixture" }], + }, + { + fingerprint: "iac", + primary: { id: "iac", title: "iac", category: "iac", severity: "medium", confidence: 1, scanner: { name: "fixture" } }, + duplicates: [], + sources: [{ name: "fixture" }], + }, +]; + +test("dependency workflow selects dependency-family findings", () => { + const workflow = getWorkflow("dependency-review"); + assert.ok(workflow); + const selected = workflowFindings(findings, workflow); + assert.deepEqual(selected.map((finding) => finding.fingerprint), ["dep"]); +}); + +test("secrets workflow prohibits source-context transmission", () => { + const workflow = getWorkflow("secrets-review"); + assert.ok(workflow); + assert.equal(workflow.sourceContextAllowed, false); + assert.throws( + () => assertWorkflowSourceContextAllowed(workflow, true), + /does not permit source context/, + ); + assert.doesNotThrow(() => assertWorkflowSourceContextAllowed(workflow, false)); +}); + +test("all built-in workflows prohibit external network assessment", () => { + for (const id of ["repository-review", "dependency-review", "secrets-review", "infrastructure-review"]) { + const workflow = getWorkflow(id); + assert.ok(workflow); + assert.equal(workflow.externalNetworkAssessment, "forbidden"); + assert.equal(workflow.repositoryWriteRequiresApproval, true); + } +}); From 11964451e1a1c193651c2817978a1d27606b48d4 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:05:53 -0400 Subject: [PATCH 0069/1132] test(cli): add built CLI smoke coverage --- tests/cli.test.mjs | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/cli.test.mjs diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs new file mode 100644 index 00000000..f0d649db --- /dev/null +++ b/tests/cli.test.mjs @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const exec = promisify(execFile); +const cli = new URL("../apps/cli/dist/index.js", import.meta.url); + +test("CLI reports its version", async () => { + const { stdout } = await exec(process.execPath, [cli.pathname, "version"]); + assert.equal(stdout.trim(), "0.2.0"); +}); + +test("CLI lists capability-scoped defensive workflows", async () => { + const { stdout } = await exec(process.execPath, [cli.pathname, "workflows"]); + assert.match(stdout, /repository-review/); + assert.match(stdout, /dependency-review/); + assert.match(stdout, /secrets-review/); + assert.match(stdout, /external network assessment: forbidden/); +}); + +test("CLI init writes a safe default configuration", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-cli-test-")); + try { + await exec(process.execPath, [cli.pathname, "init", root]); + const parsed = JSON.parse(await readFile(join(root, "synsec.config.json"), "utf8")); + assert.equal(parsed.schemaVersion, 1); + assert.equal(parsed.ai.enabled, false); + assert.equal(parsed.ai.sendSourceContext, false); + assert.ok(parsed.scanners.includes("opengrep")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From 0f6ce099d8cac5690551d788de309f10a6ace946 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:20:54 -0400 Subject: [PATCH 0070/1132] feat(core): add scanner artifact model for SBOM data --- packages/core/src/index.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a2d0d47f..4e1eccf1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -52,6 +52,27 @@ export interface Finding { metadata?: Record; } +export interface SbomPackage { + name: string; + version?: string; + type?: string; + purl?: string; + licenses?: string[]; + locations?: string[]; +} + +export interface SbomArtifact { + type: "sbom"; + format: "syft-json"; + producer: string; + generatedAt: string; + packageCount: number; + packages: SbomPackage[]; + metadata?: Record; +} + +export type ScanArtifact = SbomArtifact; + export interface ScanTarget { path: string; repositoryUrl?: string; @@ -66,6 +87,7 @@ export interface ScanResult { target: ScanTarget; findings: Finding[]; diagnostics: string[]; + artifacts?: ScanArtifact[]; } export interface CorrelatedFinding { From 6a84a9d25a24143faec259df9ead631ec3d1dd2e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:21:13 -0400 Subject: [PATCH 0071/1132] feat(scanners): add Syft SBOM adapter --- packages/scanners/src/syft.ts | 109 ++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 packages/scanners/src/syft.ts diff --git a/packages/scanners/src/syft.ts b/packages/scanners/src/syft.ts new file mode 100644 index 00000000..5def5d20 --- /dev/null +++ b/packages/scanners/src/syft.ts @@ -0,0 +1,109 @@ +import type { SbomArtifact, SbomPackage, ScanResult } from "@synsec/core"; +import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asRecord, asString, commandAvailability, relativeLike, safeJson } from "./utils.js"; + +function licenseValues(value: unknown): string[] | undefined { + const values = asArray(value) + .flatMap((entry) => { + if (typeof entry === "string") return [entry]; + const record = asRecord(entry); + if (!record) return []; + const expression = asString(record.spdxExpression); + const raw = asString(record.value); + return expression ? [expression] : raw ? [raw] : []; + }) + .map((item) => item.trim()) + .filter(Boolean); + return values.length > 0 ? [...new Set(values)] : undefined; +} + +function locationValues(value: unknown, root: string): string[] | undefined { + const values = asArray(value) + .map(asRecord) + .map((location) => relativeLike(asString(location?.path), root)) + .filter((item): item is string => Boolean(item)); + return values.length > 0 ? [...new Set(values)] : undefined; +} + +function packageFrom(value: unknown, root: string): SbomPackage | undefined { + const item = asRecord(value); + if (!item) return undefined; + const name = asString(item.name); + if (!name) return undefined; + + const pkg: SbomPackage = { name }; + const version = asString(item.version); + const type = asString(item.type); + const purl = asString(item.purl); + const licenses = licenseValues(item.licenses); + const locations = locationValues(item.locations, root); + if (version) pkg.version = version; + if (type) pkg.type = type; + if (purl) pkg.purl = purl; + if (licenses) pkg.licenses = licenses; + if (locations) pkg.locations = locations; + return pkg; +} + +export function parseSyftJson(raw: string, root: string, generatedAt = new Date().toISOString()): SbomArtifact { + const parsed = asRecord(safeJson(raw)); + if (!parsed) throw new Error("Syft returned an unsupported JSON document."); + + const packages = asArray(parsed.artifacts) + .map((value) => packageFrom(value, root)) + .filter((value): value is SbomPackage => Boolean(value)); + + const descriptor = asRecord(parsed.descriptor); + const source = asRecord(parsed.source); + const distro = asRecord(parsed.distro); + + return { + type: "sbom", + format: "syft-json", + producer: "syft", + generatedAt, + packageCount: packages.length, + packages, + metadata: { + syftVersion: asString(descriptor?.version), + sourceId: asString(source?.id), + sourceName: asString(source?.name), + sourceVersion: asString(source?.version), + distroName: asString(distro?.name), + distroVersion: asString(distro?.version), + }, + }; +} + +export class SyftAdapter implements ScannerAdapter { + readonly id = "syft"; + readonly displayName = "Syft"; + readonly capabilities = ["sbom"] as const; + + checkAvailability(): Promise { + return commandAvailability("syft", ["version"], this.displayName); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + const output = await runProcess( + "syft", + [`dir:${context.target.path}`, "-o", "syft-json"], + { timeoutMs: context.timeoutMs ?? 10 * 60_000, signal: context.signal }, + ); + if (output.exitCode !== 0) { + throw new Error(`Syft scan failed (${output.exitCode}): ${output.stderr.trim()}`); + } + const completedAt = new Date().toISOString(); + return { + scanner: this.id, + startedAt, + completedAt, + target: context.target, + findings: [], + diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], + artifacts: [parseSyftJson(output.stdout, context.target.path, completedAt)], + }; + } +} From f9eef1784ee4400d4c52c0a967198f0c3b204101 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:21:26 -0400 Subject: [PATCH 0072/1132] feat(scanners): register Syft SBOM adapter --- packages/scanners/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/scanners/src/index.ts b/packages/scanners/src/index.ts index f0ca4172..35be6cc0 100644 --- a/packages/scanners/src/index.ts +++ b/packages/scanners/src/index.ts @@ -6,6 +6,7 @@ import { GrypeAdapter } from "./grype.js"; import { OpengrepAdapter } from "./opengrep.js"; import { OsvScannerAdapter } from "./osv.js"; import { ScorecardAdapter } from "./scorecard.js"; +import { SyftAdapter } from "./syft.js"; import { TrivyAdapter } from "./trivy.js"; export { BetterleaksAdapter, parseBetterleaksJson } from "./betterleaks.js"; @@ -15,6 +16,7 @@ export { GrypeAdapter, parseGrypeJson } from "./grype.js"; export { OpengrepAdapter, parseOpengrepJson } from "./opengrep.js"; export { OsvScannerAdapter, parseOsvJson } from "./osv.js"; export { ScorecardAdapter, parseScorecardJson } from "./scorecard.js"; +export { SyftAdapter, parseSyftJson } from "./syft.js"; export { TrivyAdapter, parseTrivyJson } from "./trivy.js"; export function builtInScanners(): ScannerAdapter[] { @@ -26,6 +28,7 @@ export function builtInScanners(): ScannerAdapter[] { new TrivyAdapter(), new GrypeAdapter(), new CheckovAdapter(), + new SyftAdapter(), new ScorecardAdapter(), ]; } From 6dc3fff20b0f8b5e338d294542b39f13a5e0834f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:21:53 -0400 Subject: [PATCH 0073/1132] feat(config): enable Syft SBOM generation by default --- packages/config/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 1106f271..89d35e5c 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -38,6 +38,7 @@ export const defaultConfig: SynSecConfig = { "trivy", "grype", "checkov", + "syft", "scorecard", ], parallelism: 3, From a6884f4cbfa7c3177ee2e71e3a1986f8b51bc116 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:22:32 -0400 Subject: [PATCH 0074/1132] feat(report): preserve scanner artifacts and SBOM inventory --- packages/report/src/index.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/report/src/index.ts b/packages/report/src/index.ts index b7a590e6..39607ce8 100644 --- a/packages/report/src/index.ts +++ b/packages/report/src/index.ts @@ -4,6 +4,7 @@ import { dirname } from "node:path"; import type { CorrelatedFinding, Finding, + ScanArtifact, ScanResult, ScanTarget, Severity, @@ -26,6 +27,7 @@ export interface ScannerRunSummary { startedAt: string; completedAt: string; findingCount: number; + artifactCount: number; diagnostics: string[]; } @@ -53,6 +55,7 @@ export interface SynSecReport { summary: SeverityCounts; securityScore: number; findings: CorrelatedFinding[]; + artifacts?: ScanArtifact[]; baseline?: BaselineDelta; repository?: RepositoryMetadata; } @@ -98,6 +101,7 @@ export function buildReport(input: { repository?: RepositoryMetadata; }): SynSecReport { const rawFindings = input.scans.flatMap((scan) => scan.findings); + const artifacts = input.scans.flatMap((scan) => scan.artifacts ?? []); const findings = correlateFindings(rawFindings); const summary = countSeverities(findings); const generatedAt = new Date().toISOString(); @@ -113,6 +117,7 @@ export function buildReport(input: { startedAt: scan.startedAt, completedAt: scan.completedAt, findingCount: scan.findings.length, + artifactCount: scan.artifacts?.length ?? 0, diagnostics: scan.diagnostics, })), rawFindingCount: rawFindings.length, @@ -122,6 +127,7 @@ export function buildReport(input: { findings, }; + if (artifacts.length > 0) report.artifacts = artifacts; if (input.repository) report.repository = input.repository; return report; } @@ -296,6 +302,12 @@ export function renderHtml(report: SynSecReport): string { const baseline = report.baseline ? `
Since baseline: ${report.baseline.new.length} new · ${report.baseline.fixed.length} fixed · ${report.baseline.persisting.length} persisting
` : ""; + const sbomPackageCount = (report.artifacts ?? []) + .filter((artifact) => artifact.type === "sbom") + .reduce((total, artifact) => total + artifact.packageCount, 0); + const artifactSummary = sbomPackageCount > 0 + ? `
SBOM: ${sbomPackageCount} package(s) inventoried
` + : ""; return ` @@ -308,7 +320,7 @@ export function renderHtml(report: SynSecReport): string {
-
SynSec repository security

${escapeHtml(report.target.repositoryUrl ?? report.target.path)}

Generated ${escapeHtml(report.generatedAt)} · ${report.findingCount} correlated finding(s) from ${report.rawFindingCount} raw result(s)
${baseline}
Security score
${report.securityScore}
+
SynSec repository security

${escapeHtml(report.target.repositoryUrl ?? report.target.path)}

Generated ${escapeHtml(report.generatedAt)} · ${report.findingCount} correlated finding(s) from ${report.rawFindingCount} raw result(s)
${baseline}${artifactSummary}
Security score
${report.securityScore}
Critical${report.summary.critical}
High${report.summary.high}
Medium${report.summary.medium}
Low${report.summary.low}
Info${report.summary.info}
Unknown${report.summary.unknown}
From a5b8207445b87b5843217d0e16d9b17af9d1f091 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:22:53 -0400 Subject: [PATCH 0075/1132] test(scanners): cover Syft SBOM normalization --- tests/scanners.test.mjs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/scanners.test.mjs b/tests/scanners.test.mjs index 59ee4298..52fbeb44 100644 --- a/tests/scanners.test.mjs +++ b/tests/scanners.test.mjs @@ -7,6 +7,7 @@ import { parseOpengrepJson, parseOsvJson, parseScorecardJson, + parseSyftJson, } from "../packages/scanners/dist/index.js"; test("Betterleaks parser redacts normalized evidence by design", () => { @@ -89,3 +90,27 @@ test("Scorecard parser creates posture findings only for non-perfect checks", () assert.equal(findings[0].severity, "high"); assert.equal(findings[0].scanner.ruleId, "Branch-Protection"); }); + +test("Syft parser normalizes packages, licenses, and repository-relative locations", () => { + const artifact = parseSyftJson(JSON.stringify({ + descriptor: { name: "syft", version: "1.31.0" }, + source: { id: "source-id", name: "/repo" }, + artifacts: [ + { + name: "demo-package", + version: "1.2.3", + type: "npm", + purl: "pkg:npm/demo-package@1.2.3", + licenses: [{ value: "MIT", spdxExpression: "MIT" }], + locations: [{ path: "/repo/package-lock.json" }], + }, + ], + }), "/repo", "2026-08-22T00:00:00.000Z"); + + assert.equal(artifact.type, "sbom"); + assert.equal(artifact.packageCount, 1); + assert.equal(artifact.packages[0].name, "demo-package"); + assert.deepEqual(artifact.packages[0].licenses, ["MIT"]); + assert.deepEqual(artifact.packages[0].locations, ["package-lock.json"]); + assert.equal(artifact.metadata.syftVersion, "1.31.0"); +}); From e4115d5628197adf186e467be878369f701e2d4d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:23:12 -0400 Subject: [PATCH 0076/1132] test(report): preserve SBOM artifacts in reports --- tests/report.test.mjs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/report.test.mjs b/tests/report.test.mjs index ccd16ce1..28bd4201 100644 --- a/tests/report.test.mjs +++ b/tests/report.test.mjs @@ -27,9 +27,34 @@ test("buildReport produces a versioned correlated report and security score", () assert.equal(report.rawFindingCount, 1); assert.equal(report.findingCount, 1); assert.equal(report.summary.high, 1); + assert.equal(report.scanners[0].artifactCount, 0); assert.ok(report.securityScore < 100); }); +test("buildReport preserves scanner artifacts and renders SBOM inventory", () => { + const sbomScan = { + scanner: "syft", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:01.000Z", + target: { path: "/repo" }, + diagnostics: [], + findings: [], + artifacts: [{ + type: "sbom", + format: "syft-json", + producer: "syft", + generatedAt: "2026-01-01T00:00:01.000Z", + packageCount: 2, + packages: [{ name: "a", version: "1.0.0" }, { name: "b", version: "2.0.0" }], + }], + }; + const report = buildReport({ target: { path: "/repo" }, scans: [sbomScan] }); + assert.equal(report.artifacts.length, 1); + assert.equal(report.artifacts[0].packageCount, 2); + assert.equal(report.scanners[0].artifactCount, 1); + assert.match(renderHtml(report), /2 package\(s\) inventoried/); +}); + test("baseline delta identifies new and fixed findings", () => { const previous = buildReport({ target: { path: "/repo" }, scans: [scan("OLD")] }); const current = buildReport({ target: { path: "/repo" }, scans: [scan("NEW")] }); From cb443f95150654100324bb52365d787d89797b41 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:23:48 -0400 Subject: [PATCH 0077/1132] feat(scanners): add generic SARIF finding importer --- packages/scanners/src/sarif.ts | 124 +++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 packages/scanners/src/sarif.ts diff --git a/packages/scanners/src/sarif.ts b/packages/scanners/src/sarif.ts new file mode 100644 index 00000000..64a57b86 --- /dev/null +++ b/packages/scanners/src/sarif.ts @@ -0,0 +1,124 @@ +import { randomUUID } from "node:crypto"; +import type { Finding, FindingCategory, Severity } from "@synsec/core"; +import { asArray, asNumber, asRecord, asString, identifiersFrom, safeJson } from "./utils.js"; + +const categories = new Set([ + "sast", + "dependency", + "secret", + "misconfiguration", + "iac", + "container", + "supply-chain", + "repository-posture", + "license", + "other", +]); + +function severityFrom(value: unknown): Severity { + const normalized = asString(value)?.toLowerCase(); + if (normalized === "critical" || normalized === "high" || normalized === "medium" || normalized === "low" || normalized === "info" || normalized === "unknown") return normalized; + if (normalized === "error") return "high"; + if (normalized === "warning") return "medium"; + if (normalized === "note") return "low"; + if (normalized === "none") return "info"; + return "unknown"; +} + +function categoryFrom(value: unknown): FindingCategory { + const category = asString(value) as FindingCategory | undefined; + return category && categories.has(category) ? category : "other"; +} + +function text(value: unknown): string | undefined { + if (typeof value === "string") return value; + const record = asRecord(value); + return asString(record?.text) ?? asString(record?.markdown); +} + +function nativeFingerprint(result: Record): string | undefined { + const partial = asRecord(result.partialFingerprints); + if (!partial) return undefined; + for (const value of Object.values(partial)) { + if (typeof value === "string" && value.trim()) return value; + } + return undefined; +} + +function firstLocation(result: Record): Finding["location"] { + const location = asRecord(asArray(result.locations)[0]); + const physical = asRecord(location?.physicalLocation); + const artifact = asRecord(physical?.artifactLocation); + const region = asRecord(physical?.region); + const path = asString(artifact?.uri); + if (!path) return undefined; + return { + path: path.replace(/^file:\/\//, ""), + startLine: asNumber(region?.startLine), + endLine: asNumber(region?.endLine), + startColumn: asNumber(region?.startColumn), + endColumn: asNumber(region?.endColumn), + }; +} + +function ruleMap(run: Record): Map> { + const tool = asRecord(run.tool); + const driver = asRecord(tool?.driver); + const map = new Map>(); + for (const value of asArray(driver?.rules)) { + const rule = asRecord(value); + const id = asString(rule?.id); + if (rule && id) map.set(id, rule); + } + return map; +} + +export function parseSarifJson(raw: string, scannerOverride?: string): Finding[] { + const parsed = asRecord(safeJson(raw)); + if (!parsed || asString(parsed.version) !== "2.1.0") { + throw new Error("SARIF import requires a SARIF 2.1.0 document."); + } + + const findings: Finding[] = []; + for (const runValue of asArray(parsed.runs)) { + const run = asRecord(runValue); + if (!run) continue; + const tool = asRecord(run.tool); + const driver = asRecord(tool?.driver); + const scannerName = scannerOverride ?? asString(driver?.name) ?? "sarif-import"; + const rules = ruleMap(run); + + for (const resultValue of asArray(run.results)) { + const result = asRecord(resultValue); + if (!result) continue; + const ruleId = asString(result.ruleId); + const rule = ruleId ? rules.get(ruleId) : undefined; + const properties = asRecord(result.properties); + const ruleProperties = asRecord(rule?.properties); + const message = text(result.message); + const title = message ?? text(rule?.shortDescription) ?? ruleId ?? "Imported SARIF finding"; + const description = text(rule?.fullDescription) ?? text(rule?.shortDescription); + const identifiers = asArray(ruleProperties?.identifiers).filter((item): item is string => typeof item === "string"); + const confidence = asNumber(properties?.confidence); + const finding: Finding = { + id: randomUUID(), + title, + description, + category: categoryFrom(properties?.category ?? ruleProperties?.category), + severity: severityFrom(properties?.severity ?? result.level ?? ruleProperties?.severity), + confidence: confidence !== undefined && confidence >= 0 && confidence <= 1 ? confidence : 0.8, + scanner: { name: scannerName, ruleId }, + location: firstLocation(result), + identifiers: identifiersFrom(identifiers), + remediation: asString(properties?.remediation) ?? text(rule?.help), + fingerprint: nativeFingerprint(result), + metadata: { + sarifRuleIndex: asNumber(result.ruleIndex), + sarifToolVersion: asString(driver?.semanticVersion) ?? asString(driver?.version), + }, + }; + findings.push(finding); + } + } + return findings; +} From aee5471b2f1e9b1612e9932b52cdd597ab6af19f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:24:01 -0400 Subject: [PATCH 0078/1132] feat(scanners): export generic SARIF importer --- packages/scanners/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/scanners/src/index.ts b/packages/scanners/src/index.ts index 35be6cc0..6d24a504 100644 --- a/packages/scanners/src/index.ts +++ b/packages/scanners/src/index.ts @@ -15,6 +15,7 @@ export { GitleaksAdapter, parseGitleaksJson } from "./gitleaks.js"; export { GrypeAdapter, parseGrypeJson } from "./grype.js"; export { OpengrepAdapter, parseOpengrepJson } from "./opengrep.js"; export { OsvScannerAdapter, parseOsvJson } from "./osv.js"; +export { parseSarifJson } from "./sarif.js"; export { ScorecardAdapter, parseScorecardJson } from "./scorecard.js"; export { SyftAdapter, parseSyftJson } from "./syft.js"; export { TrivyAdapter, parseTrivyJson } from "./trivy.js"; From 4c1e14da299bc1e0d6e49a749a00fd610082f6be Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:24:22 -0400 Subject: [PATCH 0079/1132] test(scanners): cover generic SARIF import --- tests/scanners.test.mjs | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/scanners.test.mjs b/tests/scanners.test.mjs index 52fbeb44..59a12709 100644 --- a/tests/scanners.test.mjs +++ b/tests/scanners.test.mjs @@ -6,6 +6,7 @@ import { parseGrypeJson, parseOpengrepJson, parseOsvJson, + parseSarifJson, parseScorecardJson, parseSyftJson, } from "../packages/scanners/dist/index.js"; @@ -114,3 +115,39 @@ test("Syft parser normalizes packages, licenses, and repository-relative locatio assert.deepEqual(artifact.packages[0].locations, ["package-lock.json"]); assert.equal(artifact.metadata.syftVersion, "1.31.0"); }); + +test("SARIF importer maps tool metadata, severity, identifiers, and location", () => { + const findings = parseSarifJson(JSON.stringify({ + version: "2.1.0", + runs: [{ + tool: { driver: { + name: "ExternalScanner", + semanticVersion: "3.4.5", + rules: [{ + id: "EXT-1", + shortDescription: { text: "External unsafe operation" }, + fullDescription: { text: "Detailed explanation" }, + properties: { identifiers: ["CWE-79"] }, + }], + } }, + results: [{ + ruleId: "EXT-1", + ruleIndex: 0, + level: "error", + message: { text: "External unsafe operation" }, + locations: [{ physicalLocation: { artifactLocation: { uri: "src/app.ts" }, region: { startLine: 12, startColumn: 2 } } }], + partialFingerprints: { primaryLocationLineHash: "native-fingerprint" }, + properties: { category: "sast", confidence: 0.91, remediation: "Use the safe API." }, + }], + }], + })); + + assert.equal(findings.length, 1); + assert.equal(findings[0].scanner.name, "ExternalScanner"); + assert.equal(findings[0].severity, "high"); + assert.equal(findings[0].category, "sast"); + assert.equal(findings[0].location.path, "src/app.ts"); + assert.equal(findings[0].location.startLine, 12); + assert.deepEqual(findings[0].identifiers.cwe, ["CWE-79"]); + assert.equal(findings[0].fingerprint, "native-fingerprint"); +}); From c47fa20a61458b1fafee19dbceb770bd9ab055da Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:25:15 -0400 Subject: [PATCH 0080/1132] feat(cli): import third-party SARIF into SynSec reports --- apps/cli/src/index.ts | 53 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 19b01061..59c02c23 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { copyFile, mkdir, stat, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { reviewFinding, type AiFindingReview } from "@synsec/ai"; import { @@ -13,6 +13,7 @@ import { import type { CorrelatedFinding } from "@synsec/core"; import { runScanEngine, scannerStatuses } from "@synsec/engine"; import { + buildReport, readReport, renderHtml, toSarif, @@ -22,6 +23,7 @@ import { type SynSecReport, } from "@synsec/report"; import { getFindingContext } from "@synsec/repository"; +import { parseSarifJson } from "@synsec/scanners"; import { assertWorkflowSourceContextAllowed, builtInWorkflows, @@ -89,6 +91,7 @@ Usage: synsec doctor [path] [--config ] synsec scan [options] synsec review [options] + synsec import-sarif [options] synsec workflows synsec render [--html ] [--sarif ] synsec baseline [destination] @@ -119,6 +122,12 @@ Review options: --ai-base-url OpenAI-compatible API base URL. --ai-model Model ID. +SARIF import options: + --root Repository root represented by the imported findings (default: .). + --output SynSec JSON report path (default: .synsec/imported-report.json). + --html HTML report path (default: next to the JSON report). + --scanner Override the source scanner name for all imported findings. + AI environment variables: SYNSEC_AI_BASE_URL SYNSEC_AI_API_KEY @@ -327,6 +336,10 @@ async function scan(): Promise { `${outcome.report.summary.critical} critical, ${outcome.report.summary.high} high, ` + `${outcome.report.summary.medium} medium, ${outcome.report.summary.low} low\n`, ); + const sbomPackages = (outcome.report.artifacts ?? []) + .filter((artifact) => artifact.type === "sbom") + .reduce((total, artifact) => total + artifact.packageCount, 0); + if (sbomPackages > 0) console.log(`SBOM: ${sbomPackages} package(s) inventoried\n`); if (outcome.report.baseline) { console.log( @@ -376,6 +389,41 @@ async function review(): Promise { console.log(`Wrote ${Object.keys(reviews).length} AI review(s) to ${outputPath}`); } +async function importSarif(): Promise { + const inputArg = args[1]; + if (!inputArg || inputArg.startsWith("--")) { + throw new Error("Usage: synsec import-sarif [--root ] [--output ] [--scanner ]"); + } + const inputPath = resolve(inputArg); + const root = await ensureDirectory(option("--root") ?? "."); + const raw = await readFile(inputPath, "utf8"); + const scannerOverride = option("--scanner"); + const findings = parseSarifJson(raw, scannerOverride); + const now = new Date().toISOString(); + const report = buildReport({ + target: { path: root }, + scans: [{ + scanner: scannerOverride ?? "sarif-import", + startedAt: now, + completedAt: now, + target: { path: root }, + findings, + diagnostics: [], + }], + toolVersion: VERSION, + }); + + const outputPath = resolve(option("--output") ?? resolve(root, ".synsec/imported-report.json")); + const htmlPath = resolve(option("--html") ?? outputPath.replace(/\.json$/i, ".html")); + await Promise.all([ + writeReport(outputPath, report), + writeHtml(htmlPath, report), + ]); + console.log(`Imported ${findings.length} SARIF finding(s).`); + console.log(`JSON: ${outputPath}`); + console.log(`HTML: ${htmlPath}`); +} + async function render(): Promise { const reportArg = args[1]; if (!reportArg || reportArg.startsWith("--")) throw new Error("Usage: synsec render [--html ] [--sarif ]"); @@ -415,6 +463,9 @@ async function main(): Promise { case "review": await review(); break; + case "import-sarif": + await importSarif(); + break; case "workflows": listWorkflows(); break; From 34d007c06ce0988dd53e6a800fdf42fb4c78b82e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:25:31 -0400 Subject: [PATCH 0081/1132] test(cli): cover SARIF import command and Syft defaults --- tests/cli.test.mjs | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index f0d649db..f773a491 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -31,6 +31,39 @@ test("CLI init writes a safe default configuration", async () => { assert.equal(parsed.ai.enabled, false); assert.equal(parsed.ai.sendSourceContext, false); assert.ok(parsed.scanners.includes("opengrep")); + assert.ok(parsed.scanners.includes("syft")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("CLI imports SARIF into a native SynSec report", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-sarif-test-")); + try { + const input = join(root, "external.sarif"); + const output = join(root, "imported.json"); + await writeFile(input, JSON.stringify({ + version: "2.1.0", + runs: [{ + tool: { driver: { name: "FixtureScanner", rules: [{ id: "FIX-1", shortDescription: { text: "Fixture issue" } }] } }, + results: [{ ruleId: "FIX-1", level: "warning", message: { text: "Fixture issue" } }], + }], + }), "utf8"); + + const { stdout } = await exec(process.execPath, [ + cli.pathname, + "import-sarif", + input, + "--root", + root, + "--output", + output, + ]); + assert.match(stdout, /Imported 1 SARIF finding/); + const report = JSON.parse(await readFile(output, "utf8")); + assert.equal(report.findingCount, 1); + assert.equal(report.findings[0].primary.scanner.name, "FixtureScanner"); + assert.equal(report.findings[0].primary.severity, "medium"); } finally { await rm(root, { recursive: true, force: true }); } From 6b73c737bdad8cc8724f4050952e5cb44137daab Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:26:05 -0400 Subject: [PATCH 0082/1132] feat(scanner-sdk): expose changed-file scope to capable scanners --- packages/scanner-sdk/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/scanner-sdk/src/index.ts b/packages/scanner-sdk/src/index.ts index 01d51ade..766c1c61 100644 --- a/packages/scanner-sdk/src/index.ts +++ b/packages/scanner-sdk/src/index.ts @@ -14,6 +14,8 @@ export interface ScannerContext { target: ScanTarget; timeoutMs?: number; signal?: AbortSignal; + /** Repository-relative files requested by an incremental scan. Adapters may use this to reduce work. */ + changedFiles?: string[]; } export interface ScannerAvailability { From 7a1772b2a385f99cfbe8c3f025c6236d52a66087 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:26:43 -0400 Subject: [PATCH 0083/1132] feat(engine): add changed-file scan scope --- packages/engine/src/index.ts | 84 ++++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 4 deletions(-) diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 68a6986b..9fb3f7f8 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; import type { SynSecConfig } from "@synsec/config"; -import type { ScanResult, ScanTarget, Severity } from "@synsec/core"; +import type { Finding, ScanResult, ScanTarget, Severity } from "@synsec/core"; import { applyBaseline, buildReport, type SynSecReport } from "@synsec/report"; import { inventoryRepository } from "@synsec/repository"; import { runProcess, type ScannerAdapter, type ScannerAvailability } from "@synsec/scanner-sdk"; @@ -23,6 +23,8 @@ export interface ScanEngineOutcome { statuses: ScannerStatus[]; failures: ScannerFailure[]; shouldFail: boolean; + changedFiles?: string[]; + changedBase?: string; } const severityRank: Record = { @@ -73,6 +75,69 @@ export async function discoverTarget(rootPath: string): Promise { return target; } +function normalizeRepositoryPath(path: string, root: string): string { + const normalizedRoot = resolve(root).replace(/\\/g, "/").replace(/\/$/, ""); + let normalized = path.replace(/\\/g, "/"); + if (normalized.startsWith(`${normalizedRoot}/`)) normalized = normalized.slice(normalizedRoot.length + 1); + normalized = normalized.replace(/^\.\//, "").replace(/^\//, ""); + return normalized; +} + +export async function discoverChangedFiles(rootPath: string, requestedBase?: string): Promise<{ base: string; files: string[] }> { + const root = resolve(rootPath); + const githubBase = process.env.GITHUB_BASE_REF?.trim(); + const base = requestedBase ?? (githubBase ? `origin/${githubBase}` : "HEAD~1"); + let output = await runProcess( + "git", + ["-C", root, "diff", "--name-only", "--diff-filter=ACMRTUXB", `${base}...HEAD`], + { timeoutMs: 10_000 }, + ); + + // GitHub Actions checkouts can occasionally have the base branch available + // without the origin/ prefix. Try that deterministic fallback before failing. + if (output.exitCode !== 0 && !requestedBase && githubBase) { + output = await runProcess( + "git", + ["-C", root, "diff", "--name-only", "--diff-filter=ACMRTUXB", `${githubBase}...HEAD`], + { timeoutMs: 10_000 }, + ); + } + + if (output.exitCode !== 0) { + throw new Error(`Unable to determine changed files from ${base}: ${output.stderr.trim() || "git diff failed"}`); + } + + const files = [...new Set( + output.stdout + .split(/\r?\n/) + .map((value) => normalizeRepositoryPath(value.trim(), root)) + .filter(Boolean), + )].sort(); + return { base, files }; +} + +function findingMatchesChangedFiles(finding: Finding, changed: Set, root: string): boolean { + if (!finding.location?.path) return true; + const path = normalizeRepositoryPath(finding.location.path, root).toLowerCase(); + return changed.has(path); +} + +function scopeScansToChangedFiles(scans: readonly ScanResult[], root: string, files: readonly string[]): ScanResult[] { + const changed = new Set(files.map((file) => normalizeRepositoryPath(file, root).toLowerCase())); + return scans.map((scan) => { + const before = scan.findings.length; + const findings = scan.findings.filter((finding) => findingMatchesChangedFiles(finding, changed, root)); + const dropped = before - findings.length; + return { + ...scan, + findings, + diagnostics: dropped > 0 + ? [...scan.diagnostics, `Changed-file scope omitted ${dropped} finding(s) outside the requested diff.`] + : scan.diagnostics, + }; + }); +} + export async function scannerStatuses(config: SynSecConfig): Promise { const selectedIds = new Set(config.scanners); const scanners = builtInScanners(); @@ -103,6 +168,7 @@ async function runSelectedScanners( target: ScanTarget, config: SynSecConfig, statuses: readonly ScannerStatus[], + changedFiles?: string[], ): Promise<{ scans: ScanResult[]; failures: ScannerFailure[] }> { const statusById = new Map(statuses.map((status) => [status.id, status])); const selected = builtInScanners().filter((scanner) => { @@ -121,7 +187,7 @@ async function runSelectedScanners( const scanner = queue.shift(); if (!scanner) return; try { - const result = await scanner.scan({ target, timeoutMs: config.timeoutMs }); + const result = await scanner.scan({ target, timeoutMs: config.timeoutMs, changedFiles }); scans.push(result); } catch (error) { failures.push({ @@ -158,6 +224,8 @@ export async function runScanEngine(input: { config: SynSecConfig; baseline?: SynSecReport; toolVersion?: string; + changedOnly?: boolean; + changedBase?: string; }): Promise { const root = resolve(input.rootPath); const [target, statuses, inventory] = await Promise.all([ @@ -171,7 +239,10 @@ export async function runScanEngine(input: { ); if (availableSelected.length === 0) throw new Error(unavailableSummary(statuses)); - const { scans, failures } = await runSelectedScanners(target, input.config, statuses); + const changedScope = input.changedOnly ? await discoverChangedFiles(root, input.changedBase) : undefined; + const result = await runSelectedScanners(target, input.config, statuses, changedScope?.files); + const scans = changedScope ? scopeScansToChangedFiles(result.scans, root, changedScope.files) : result.scans; + const failures = result.failures; if (scans.length === 0) { const details = failures.map((failure) => `${failure.scanner}: ${failure.message}`).join("; "); throw new Error(`All available scanner engines failed.${details ? ` ${details}` : ""}`); @@ -185,10 +256,15 @@ export async function runScanEngine(input: { }); if (input.baseline) report = applyBaseline(report, input.baseline); - return { + const outcome: ScanEngineOutcome = { report, statuses, failures, shouldFail: reportMeetsFailureThreshold(report, input.config.failOn), }; + if (changedScope) { + outcome.changedFiles = changedScope.files; + outcome.changedBase = changedScope.base; + } + return outcome; } From 22de6c599f851ec8361075eaf6f098e240a8f412 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:27:07 -0400 Subject: [PATCH 0084/1132] feat(opengrep): narrow incremental scans to changed files --- packages/scanners/src/opengrep.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/scanners/src/opengrep.ts b/packages/scanners/src/opengrep.ts index fd05e4e0..3ccf78d7 100644 --- a/packages/scanners/src/opengrep.ts +++ b/packages/scanners/src/opengrep.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; import type { Finding, ScanResult } from "@synsec/core"; import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; import { runProcess } from "@synsec/scanner-sdk"; @@ -74,9 +75,23 @@ export class OpengrepAdapter implements ScannerAdapter { async scan(context: ScannerContext): Promise { const startedAt = new Date().toISOString(); + if (context.changedFiles && context.changedFiles.length === 0) { + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: [], + diagnostics: ["Changed-file scope is empty; Opengrep was not invoked."], + }; + } + + const targets = context.changedFiles + ? context.changedFiles.map((path) => resolve(context.target.path, path)) + : [context.target.path]; const output = await runProcess( "opengrep", - ["scan", "--json", "--config", "auto", "--metrics", "off", "--taint-intrafile", context.target.path], + ["scan", "--json", "--config", "auto", "--metrics", "off", "--taint-intrafile", ...targets], { timeoutMs: context.timeoutMs ?? 15 * 60_000, signal: context.signal }, ); if (output.exitCode !== 0) throw new Error(`Opengrep scan failed (${output.exitCode}): ${output.stderr.trim()}`); From e44af92c67edd129fe4af8273c9fb260468c41a9 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:27:28 -0400 Subject: [PATCH 0085/1132] feat(betterleaks): scan changed files directly in incremental mode --- packages/scanners/src/betterleaks.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/scanners/src/betterleaks.ts b/packages/scanners/src/betterleaks.ts index d03f08b9..38bdc598 100644 --- a/packages/scanners/src/betterleaks.ts +++ b/packages/scanners/src/betterleaks.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import type { Finding, ScanResult } from "@synsec/core"; import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; import { runProcess } from "@synsec/scanner-sdk"; @@ -56,11 +56,25 @@ export class BetterleaksAdapter implements ScannerAdapter { async scan(context: ScannerContext): Promise { const startedAt = new Date().toISOString(); + if (context.changedFiles && context.changedFiles.length === 0) { + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: [], + diagnostics: ["Changed-file scope is empty; Betterleaks was not invoked."], + }; + } + const temp = await mkdtemp(join(tmpdir(), "synsec-betterleaks-")); const report = join(temp, "report.json"); try { const gitRepo = await stat(join(context.target.path, ".git")).then(() => true).catch(() => false); - const mode = gitRepo ? "git" : "dir"; + const mode = context.changedFiles ? "dir" : gitRepo ? "git" : "dir"; + const targets = context.changedFiles + ? context.changedFiles.map((path) => resolve(context.target.path, path)) + : [context.target.path]; const output = await runProcess( "betterleaks", [ @@ -70,7 +84,7 @@ export class BetterleaksAdapter implements ScannerAdapter { "--redact=100", "--no-banner", "--exit-code", "0", - context.target.path, + ...targets, ], { timeoutMs: context.timeoutMs ?? 10 * 60_000, signal: context.signal }, ); From c7be4039872156e70e1d3edd60ee704a6a956a5e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:28:23 -0400 Subject: [PATCH 0086/1132] feat(cli): expose changed-file scan mode --- apps/cli/src/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 59c02c23..3b73abe9 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -102,6 +102,8 @@ Scan options: --scanners Override enabled scanners for this run. --parallel Maximum scanners running at once. --timeout Per-scanner timeout. + --changed Keep findings in files changed since a Git base ref. + --changed-base Base ref for --changed (default: PR base or HEAD~1). --fail-on Exit non-zero when this severity or higher is found. --baseline Compare against a previous SynSec report. --json Print the report JSON to stdout. @@ -305,6 +307,8 @@ async function scan(): Promise { config, baseline, toolVersion: VERSION, + changedOnly: flag("--changed"), + changedBase: option("--changed-base"), }); const paths = resolveReportPaths(root, config); @@ -336,6 +340,9 @@ async function scan(): Promise { `${outcome.report.summary.critical} critical, ${outcome.report.summary.high} high, ` + `${outcome.report.summary.medium} medium, ${outcome.report.summary.low} low\n`, ); + if (outcome.changedFiles) { + console.log(`Changed-file scope: ${outcome.changedFiles.length} file(s) since ${outcome.changedBase ?? "base"}\n`); + } const sbomPackages = (outcome.report.artifacts ?? []) .filter((artifact) => artifact.type === "sbom") .reduce((total, artifact) => total + artifact.packageCount, 0); From 739eb5c35a295e02b9389613c239e4812fe173bf Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:29:54 -0400 Subject: [PATCH 0087/1132] test(engine): verify changed-file discovery --- tests/engine.test.mjs | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/tests/engine.test.mjs b/tests/engine.test.mjs index a362da8c..e8cb6aff 100644 --- a/tests/engine.test.mjs +++ b/tests/engine.test.mjs @@ -1,12 +1,24 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { defaultConfig } from "../packages/config/dist/index.js"; -import { reportMeetsFailureThreshold, runScanEngine } from "../packages/engine/dist/index.js"; +import { + discoverChangedFiles, + reportMeetsFailureThreshold, + runScanEngine, +} from "../packages/engine/dist/index.js"; import { buildReport } from "../packages/report/dist/index.js"; +const exec = promisify(execFile); + +async function git(root, ...args) { + return await exec("git", ["-C", root, ...args]); +} + test("scan engine refuses to produce a clean report when no selected scanner exists", async () => { const root = await mkdtemp(join(tmpdir(), "synsec-engine-test-")); try { @@ -22,6 +34,29 @@ test("scan engine refuses to produce a clean report when no selected scanner exi } }); +test("changed-file discovery returns repository-relative files from the requested base", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-changed-test-")); + try { + await git(root, "init"); + await git(root, "config", "user.name", "SynSec Test"); + await git(root, "config", "user.email", "synsec-test@example.invalid"); + + await writeFile(join(root, "a.txt"), "first\n"); + await git(root, "add", "a.txt"); + await git(root, "commit", "-m", "first"); + + await writeFile(join(root, "b.txt"), "second\n"); + await git(root, "add", "b.txt"); + await git(root, "commit", "-m", "second"); + + const scope = await discoverChangedFiles(root, "HEAD~1"); + assert.equal(scope.base, "HEAD~1"); + assert.deepEqual(scope.files, ["b.txt"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("failure threshold treats configured severity as inclusive", () => { const scan = { scanner: "fixture", From d45e1a1a40eb5e7e980ea4c3bbd5078bfcaded33 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:30:34 -0400 Subject: [PATCH 0088/1132] feat(report): record repository and changed-file scan scope --- packages/report/src/index.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/report/src/index.ts b/packages/report/src/index.ts index 39607ce8..cc9f7234 100644 --- a/packages/report/src/index.ts +++ b/packages/report/src/index.ts @@ -43,6 +43,12 @@ export interface RepositoryMetadata { fileCount?: number; } +export interface ScanScope { + mode: "repository" | "changed-files"; + baseRef?: string; + changedFiles?: string[]; +} + export interface SynSecReport { schemaVersion: typeof SYNSEC_REPORT_SCHEMA_VERSION; reportId: string; @@ -56,6 +62,7 @@ export interface SynSecReport { securityScore: number; findings: CorrelatedFinding[]; artifacts?: ScanArtifact[]; + scope?: ScanScope; baseline?: BaselineDelta; repository?: RepositoryMetadata; } @@ -99,6 +106,7 @@ export function buildReport(input: { scans: readonly ScanResult[]; toolVersion?: string; repository?: RepositoryMetadata; + scope?: ScanScope; }): SynSecReport { const rawFindings = input.scans.flatMap((scan) => scan.findings); const artifacts = input.scans.flatMap((scan) => scan.artifacts ?? []); @@ -128,6 +136,7 @@ export function buildReport(input: { }; if (artifacts.length > 0) report.artifacts = artifacts; + if (input.scope) report.scope = input.scope; if (input.repository) report.repository = input.repository; return report; } @@ -308,6 +317,9 @@ export function renderHtml(report: SynSecReport): string { const artifactSummary = sbomPackageCount > 0 ? `
SBOM: ${sbomPackageCount} package(s) inventoried
` : ""; + const scopeSummary = report.scope?.mode === "changed-files" + ? `
Scope: ${report.scope.changedFiles?.length ?? 0} changed file(s)${report.scope.baseRef ? ` since ${escapeHtml(report.scope.baseRef)}` : ""}
` + : ""; return ` @@ -320,7 +332,7 @@ export function renderHtml(report: SynSecReport): string {
-
SynSec repository security

${escapeHtml(report.target.repositoryUrl ?? report.target.path)}

Generated ${escapeHtml(report.generatedAt)} · ${report.findingCount} correlated finding(s) from ${report.rawFindingCount} raw result(s)
${baseline}${artifactSummary}
Security score
${report.securityScore}
+
SynSec repository security

${escapeHtml(report.target.repositoryUrl ?? report.target.path)}

Generated ${escapeHtml(report.generatedAt)} · ${report.findingCount} correlated finding(s) from ${report.rawFindingCount} raw result(s)
${baseline}${artifactSummary}${scopeSummary}
Security score
${report.securityScore}
Critical${report.summary.critical}
High${report.summary.high}
Medium${report.summary.medium}
Low${report.summary.low}
Info${report.summary.info}
Unknown${report.summary.unknown}
From a417be02552aad37fddf030632c2847680380555 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:31:02 -0400 Subject: [PATCH 0089/1132] feat(engine): persist scan scope in reports --- packages/engine/src/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 9fb3f7f8..c804f320 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -93,8 +93,6 @@ export async function discoverChangedFiles(rootPath: string, requestedBase?: str { timeoutMs: 10_000 }, ); - // GitHub Actions checkouts can occasionally have the base branch available - // without the origin/ prefix. Try that deterministic fallback before failing. if (output.exitCode !== 0 && !requestedBase && githubBase) { output = await runProcess( "git", @@ -253,6 +251,9 @@ export async function runScanEngine(input: { scans, toolVersion: input.toolVersion ?? "0.2.0", repository: inventory.metadata, + scope: changedScope + ? { mode: "changed-files", baseRef: changedScope.base, changedFiles: changedScope.files } + : { mode: "repository" }, }); if (input.baseline) report = applyBaseline(report, input.baseline); From 34b57c6acff4dd484f2644e8c8377b1191d6de89 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:31:21 -0400 Subject: [PATCH 0090/1132] test(report): cover changed-file scope metadata --- tests/report.test.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/report.test.mjs b/tests/report.test.mjs index 28bd4201..899dbc10 100644 --- a/tests/report.test.mjs +++ b/tests/report.test.mjs @@ -31,6 +31,20 @@ test("buildReport produces a versioned correlated report and security score", () assert.ok(report.securityScore < 100); }); +test("buildReport preserves changed-file scan scope in JSON and HTML", () => { + const report = buildReport({ + target: { path: "/repo" }, + scans: [scan("RULE-1")], + scope: { mode: "changed-files", baseRef: "main", changedFiles: ["src/app.ts", "package.json"] }, + }); + assert.equal(report.scope.mode, "changed-files"); + assert.equal(report.scope.baseRef, "main"); + assert.deepEqual(report.scope.changedFiles, ["src/app.ts", "package.json"]); + const html = renderHtml(report); + assert.match(html, /2 changed file\(s\)/); + assert.match(html, /since main/); +}); + test("buildReport preserves scanner artifacts and renders SBOM inventory", () => { const sbomScan = { scanner: "syft", From 0f74cc8ba7f9dd688a0f5561c7d2e6a01c356365 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:32:05 -0400 Subject: [PATCH 0091/1132] docs: document SBOM, SARIF import, workflows, and changed scans --- README.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4f8a6719..791854f6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ SynSec is a repository-first security scanner that combines mature open-source security engines into one normalized, correlated report. -Instead of replacing tools such as Opengrep, Trivy, Betterleaks, OSV-Scanner, Grype, Checkov, and OpenSSF Scorecard, SynSec runs them through a common adapter layer, merges overlapping results, adds repository context, tracks changes against baselines, exports developer-friendly reports, and can optionally send selected findings through an OpenAI-compatible model router for a separate review pass. +Instead of replacing tools such as Opengrep, Trivy, Betterleaks, OSV-Scanner, Grype, Checkov, Syft, and OpenSSF Scorecard, SynSec runs them through a common adapter layer, merges overlapping results, preserves supporting artifacts such as SBOMs, adds repository context, tracks changes against baselines, exports developer-friendly reports, and can optionally send selected findings through an OpenAI-compatible model router for a separate review pass. > **Current release line:** v0.2 development MVP. The repository is usable for local testing, but scanner adapters and report schemas may still change before v1.0. @@ -17,11 +17,14 @@ Instead of replacing tools such as Opengrep, Trivy, Betterleaks, OSV-Scanner, Gr - Trivy vulnerability, secret, and misconfiguration analysis. - Grype dependency/package analysis. - Checkov IaC analysis. +- Syft SBOM generation with normalized package, PURL, license, and location metadata. - OpenSSF Scorecard repository-posture analysis. -- Scanner-independent finding schema. +- Generic SARIF 2.1 import for bringing third-party scanner findings into SynSec. +- Scanner-independent finding and artifact schemas. - Deterministic cross-scanner correlation and deduplication. - Repository language/framework inventory. - Git commit, branch, and remote metadata discovery with credential redaction. +- Changed-file scan scope for pull-request and incremental workflows, with direct narrowing for supported scanners. - Versioned JSON reports. - Self-contained HTML security dashboard. - SARIF 2.1.0 output for code-scanning systems. @@ -29,6 +32,7 @@ Instead of replacing tools such as Opengrep, Trivy, Betterleaks, OSV-Scanner, Gr - Configurable CI failure thresholds. - Explicit opt-in AI finding review through an OpenAI-compatible endpoint. - A seven-question AI review gate that keeps scanner evidence separate from model inference. +- Capability-scoped defensive review workflows for repository, dependency, secret, and infrastructure findings. ## Quick start @@ -64,7 +68,7 @@ A normal scan writes: └── report.sarif ``` -Open `report.html` locally for the dashboard. +The JSON report can also contain scanner artifacts such as a normalized Syft SBOM. Open `report.html` locally for the dashboard. ## Commands @@ -73,6 +77,8 @@ synsec init [path] synsec doctor [path] synsec scan [options] synsec review [options] +synsec import-sarif [options] +synsec workflows synsec render synsec baseline [destination] synsec version @@ -84,6 +90,8 @@ Useful scan options: --scanners opengrep,betterleaks,trivy --parallel 3 --timeout 900 +--changed +--changed-base main --fail-on high --baseline .synsec/baseline.json --json @@ -110,6 +118,7 @@ That creates `synsec.config.json`. "trivy", "grype", "checkov", + "syft", "scorecard" ], "parallelism": 3, @@ -141,14 +150,41 @@ That creates `synsec.config.json`. | Trivy | `trivy` | dependencies, secrets, IaC/misconfiguration | yes | | Grype | `grype` | package/dependency vulnerabilities | yes | | Checkov | `checkov` | infrastructure-as-code | yes | +| Syft | `syft` | software bill of materials / package inventory | yes | | OpenSSF Scorecard | `scorecard` | repository security posture | yes | Betterleaks is preferred for new installs because it is the actively developed successor maintained by the Gitleaks team. SynSec does **not** enable Betterleaks live credential validation; the adapter performs repository scanning with redacted report output only. +Syft is an artifact-producing scanner in SynSec. It does not manufacture vulnerability findings: its package inventory is preserved as an SBOM artifact in the report and can be used by later dependency/reachability workflows. + OpenSSF Scorecard results are treated as repository-posture findings rather than definitive vulnerabilities. Perfect 10/10 checks are not manufactured into findings; non-perfect checks retain their own score and reason as metadata. The engines stay separate projects with their own licenses. SynSec invokes installed binaries and parses their machine-readable output rather than copying their source into this repository. +## Changed-file scans + +For pull-request or incremental analysis, SynSec can scope a report to files changed since a Git base ref: + +```bash +npm run synsec -- scan . --changed --changed-base main +``` + +When `--changed-base` is omitted, SynSec uses the GitHub pull-request base branch when `GITHUB_BASE_REF` is available and otherwise falls back to `HEAD~1`. + +The report records the scope and changed file list. File-located findings outside that diff are omitted, while repository-level findings that do not map to one file are retained. Opengrep and Betterleaks currently narrow execution directly to the changed files; other scanners may still perform their normal repository analysis before SynSec filters file-located results. This distinction is intentional so the report does not imply that every underlying engine has a native incremental mode. + +## Importing SARIF + +SynSec can ingest SARIF 2.1 output from another scanner and normalize it into the same finding/report model: + +```bash +npm run synsec -- import-sarif external-results.sarif --root . +``` + +By default this writes `.synsec/imported-report.json` and an adjacent HTML report. The importer preserves rule IDs, locations, severity, confidence when present, common identifiers, remediation text, source tool version, and a native partial fingerprint when supplied. + +This is an import path, not a command-execution plugin: SynSec reads the SARIF document and does not execute the producing scanner. + ## Correlation Raw scanner output is not the product. SynSec converts each result into a common model containing, where available: @@ -194,7 +230,7 @@ The new report tracks new, fixed, and persisting findings. This makes SynSec use AI is a **second-pass reviewer**, not the source of truth. It is disabled by default. -SynSec supports endpoints implementing the OpenAI-compatible `/chat/completions` shape, including local gateways and model routers. A router such as OmniRoute, a self-hosted gateway, or another compatible provider can therefore sit behind SynSec without tying the project to one model vendor. +SynSec supports endpoints implementing the OpenAI-compatible `/chat/completions` shape, including local gateways and model routers. A self-hosted router or another compatible provider can therefore sit behind SynSec without tying the project to one model vendor. ```bash export SYNSEC_AI_BASE_URL="http://localhost:PORT/v1" @@ -224,6 +260,23 @@ The review uses seven checks: Unknown evidence stays `unknown`; the reviewer is instructed not to invent proof. +## Defensive workflows + +`npm run synsec -- workflows` lists the built-in review workflows. Current workflows are: + +- `repository-review` — broad review of normalized repository findings; +- `dependency-review` — dependencies, containers, supply chain, and license findings; +- `secrets-review` — redacted secret metadata only, with source context prohibited; +- `infrastructure-review` — IaC, configuration, and repository-posture findings. + +A workflow can be selected during AI review: + +```bash +npm run synsec -- scan . --ai --workflow dependency-review +``` + +Workflow definitions declare allowed capabilities. Repository modifications require an explicit approval boundary, and external network assessment is forbidden in these repository workflows. + ## Privacy and network behavior Repository contents stay local to SynSec and its local scanner processes unless the operator explicitly enables an integration or scanner behavior that communicates externally. @@ -234,7 +287,7 @@ Important exceptions to understand: - Opengrep's `auto` rules configuration may fetch rule configuration from the network. - OpenSSF Scorecard can use Git hosting APIs and may need a GitHub token for complete/rate-limit-friendly results. - AI review sends normalized finding metadata to the configured model endpoint when enabled. -- Source excerpts are only sent to the AI endpoint when `sendSourceContext` or `--ai-source` is explicitly enabled. +- Source excerpts are only sent to the AI endpoint when `sendSourceContext` or `--ai-source` is explicitly enabled and the selected workflow permits them. Secret scanner output is requested with full redaction, and SynSec deliberately does not copy secret values into normalized findings. @@ -253,6 +306,7 @@ repository +-- Trivy +-- Grype +-- Checkov + +-- Syft ----------> SBOM artifact +-- OpenSSF Scorecard | v @@ -265,7 +319,7 @@ repository | | v v reports optional AI review - JSON/HTML/SARIF (separate evidence) + JSON/HTML/SARIF + workflows | v baseline diff @@ -275,14 +329,15 @@ The codebase is split into small packages: ```text apps/cli command-line product -packages/core domain model + correlation +packages/core domain model + correlation + artifact types packages/config stable configuration format packages/scanner-sdk scanner adapter/process boundary -packages/scanners built-in scanner integrations +packages/scanners built-in scanner integrations + SARIF importer packages/repository safe repository inventory/context -packages/report JSON/SARIF/HTML + baselines -packages/engine orchestration and failure isolation +packages/report JSON/SARIF/HTML + baselines + scan scope +packages/engine orchestration, incremental scope, failure isolation packages/ai opt-in provider-agnostic review gate +packages/workflows capability-scoped defensive review workflows ``` See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for trust boundaries and package details and [`docs/ROADMAP.md`](docs/ROADMAP.md) for planned work. @@ -306,7 +361,7 @@ CI runs the build, typecheck, and test suite on Node 20 and Node 24. ## Project status -v0.2 is intended to be the first release worth hands-on testing. The next major work after scanner reliability is repository reachability/context, GitHub pull-request integration, stronger finding lifecycle management, reusable defensive workflows, and a richer persistent web application. +v0.2 is intended to be the first release worth hands-on testing. The next major work after scanner reliability is repository reachability/context, GitHub pull-request integration, stronger finding lifecycle management, fix-verification/report-writing workflows, model-routing policy, and a richer persistent web application. ## License From f63b7e94381a374264b38de2e9291c3fbdab5740 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:32:27 -0400 Subject: [PATCH 0092/1132] docs(roadmap): mark completed v0.2 scanner and workflow milestones --- docs/ROADMAP.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fea30977..dd752aff 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -22,19 +22,22 @@ This roadmap separates what is already usable in the repository from the deeper - [x] Trivy adapter - [x] Grype adapter - [x] Checkov adapter +- [x] Syft SBOM adapter and normalized scanner-artifact model - [x] OpenSSF Scorecard adapter - [x] Bounded parallel scanner orchestration - [x] Scanner failure isolation +- [x] Refuse false clean reports when no selected scanner can run - [x] Versioned JSON report format - [x] SARIF 2.1 export +- [x] Generic SARIF 2.1 import into normalized findings - [x] Self-contained HTML report/dashboard - [x] Stronger cross-scanner advisory and source-location correlation - [x] Configurable CI severity threshold - [x] Baseline support with new/fixed/persisting findings - [x] Secret redaction in normalized output -- [ ] Syft SBOM adapter -- [ ] Generic SARIF import -- [ ] Changed-files-only scan mode +- [x] Changed-file finding scope with persisted base/file metadata +- [x] Direct changed-file execution for Opengrep and Betterleaks +- [ ] Native incremental execution for every scanner that can safely support it ## Phase 2 — Repository intelligence @@ -65,17 +68,19 @@ This roadmap separates what is already usable in the repository from the deeper ## Phase 4 — Reusable workflows / skills -The orchestration layer should be able to expose small reusable defensive workflows rather than hard-coding one giant agent prompt. +The orchestration layer should expose small reusable defensive workflows rather than hard-coding one giant agent prompt. -- [ ] Repository review workflow -- [ ] Dependency review workflow -- [ ] Secrets review workflow -- [ ] IaC review workflow +- [x] Repository review workflow +- [x] Dependency review workflow +- [x] Secrets review workflow with source-context prohibition +- [x] Infrastructure/IaC review workflow - [ ] Fix verification workflow - [ ] Report-writing workflow - [ ] Provider/model routing policy by task and cost - [ ] User-defined workflow/skill format with explicit capabilities -- [ ] Human approval boundaries for any action that changes a repository +- [x] Explicit capability declarations per built-in workflow +- [x] Human approval boundary declared for any repository-changing action +- [x] External network assessment forbidden in repository workflows These workflows operate on repository evidence and scanner results. They are not a mechanism for silently expanding into external targets. From 9f5c29bf618f66b544b4776f35259a66f98aaf48 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:32:49 -0400 Subject: [PATCH 0093/1132] docs(install): add Syft setup and SBOM behavior --- docs/INSTALL.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 9fa648bf..ffb51f69 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -128,6 +128,25 @@ Confirm: checkov --version ``` +## Syft + +Project: https://github.com/anchore/syft + +Anchore publishes an installation helper for Linux/macOS. Installing into a user-writable bin directory avoids requiring `sudo`: + +```bash +mkdir -p "$HOME/.local/bin" +curl -sSfL https://get.anchore.io/syft | sh -s -- -b "$HOME/.local/bin" +``` + +Make sure `$HOME/.local/bin` is in `PATH`, then confirm: + +```bash +syft version +``` + +SynSec runs Syft against the repository filesystem and stores a normalized SBOM artifact containing package identity, version, package type, PURL, licenses, and known package locations. Syft does not create vulnerability findings by itself; vulnerability engines remain separate. + ## OpenSSF Scorecard Project: https://github.com/ossf/scorecard @@ -166,6 +185,7 @@ OK OSV-Scanner ... OK Trivy ... OK Grype ... OK Checkov ... +OK Syft ... OK OpenSSF Scorecard ... ``` From 2127c99cb3aae42812ce796ac7530048626a2a01 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:33:49 -0400 Subject: [PATCH 0094/1132] feat(repository): add static repository intelligence index --- packages/repository/src/analysis.ts | 223 ++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 packages/repository/src/analysis.ts diff --git a/packages/repository/src/analysis.ts b/packages/repository/src/analysis.ts new file mode 100644 index 00000000..61c14a40 --- /dev/null +++ b/packages/repository/src/analysis.ts @@ -0,0 +1,223 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, extname, resolve } from "node:path"; + +export interface IndexFileInput { + path: string; + size: number; +} + +export interface ModuleEdge { + from: string; + specifier: string; + kind: "import" | "require" | "dynamic-import" | "python-import" | "go-import"; + line: number; +} + +export interface RouteSignal { + path: string; + line: number; + method: string; + route: string; + frameworkHint?: string; +} + +export interface AuthSignal { + path: string; + line: number; + kind: "authentication" | "authorization" | "session" | "token"; + evidence: string; +} + +export interface SinkSignal { + path: string; + line: number; + kind: "process" | "filesystem" | "database" | "network"; + evidence: string; +} + +export interface RepositoryIndex { + schemaVersion: 1; + generatedAt: string; + indexedFileCount: number; + moduleEdges: ModuleEdge[]; + routes: RouteSignal[]; + authSignals: AuthSignal[]; + sinks: SinkSignal[]; +} + +const analyzableExtensions = new Set([ + ".js", ".mjs", ".cjs", ".jsx", + ".ts", ".mts", ".cts", ".tsx", + ".py", ".go", ".rb", ".php", ".java", ".kt", ".kts", ".cs", +]); + +const MAX_INDEX_FILE_BYTES = 512_000; +const MAX_INDEX_FILES = 5_000; +const MAX_SIGNALS_PER_FILE = 500; + +function sourceLine(lines: readonly string[], index: number): string { + return (lines[index] ?? "").trim().slice(0, 300); +} + +function collectModuleEdges(path: string, content: string): ModuleEdge[] { + const edges: ModuleEdge[] = []; + const lines = content.split(/\r?\n/); + const extension = extname(path).toLowerCase(); + + for (let index = 0; index < lines.length && edges.length < MAX_SIGNALS_PER_FILE; index += 1) { + const line = lines[index] ?? ""; + const lineNumber = index + 1; + + if ([".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"].includes(extension)) { + const staticImport = line.match(/\b(?:import|export)\s+(?:[^"']*?\s+from\s+)?["']([^"']+)["']/); + if (staticImport?.[1]) edges.push({ from: path, specifier: staticImport[1], kind: "import", line: lineNumber }); + const requireCall = line.match(/\brequire\s*\(\s*["']([^"']+)["']\s*\)/); + if (requireCall?.[1]) edges.push({ from: path, specifier: requireCall[1], kind: "require", line: lineNumber }); + const dynamicImport = line.match(/\bimport\s*\(\s*["']([^"']+)["']\s*\)/); + if (dynamicImport?.[1]) edges.push({ from: path, specifier: dynamicImport[1], kind: "dynamic-import", line: lineNumber }); + continue; + } + + if (extension === ".py") { + const fromImport = line.match(/^\s*from\s+([A-Za-z0-9_.]+)\s+import\b/); + if (fromImport?.[1]) edges.push({ from: path, specifier: fromImport[1], kind: "python-import", line: lineNumber }); + const directImport = line.match(/^\s*import\s+([A-Za-z0-9_.]+)/); + if (directImport?.[1]) edges.push({ from: path, specifier: directImport[1], kind: "python-import", line: lineNumber }); + continue; + } + + if (extension === ".go") { + const single = line.match(/^\s*import\s+(?:[A-Za-z0-9_.]+\s+)?"([^"]+)"/); + if (single?.[1]) edges.push({ from: path, specifier: single[1], kind: "go-import", line: lineNumber }); + const grouped = line.match(/^\s*(?:[A-Za-z0-9_.]+\s+)?"([A-Za-z0-9_./-]+)"\s*$/); + if (grouped?.[1]) edges.push({ from: path, specifier: grouped[1], kind: "go-import", line: lineNumber }); + } + } + + return edges; +} + +function collectRoutes(path: string, content: string): RouteSignal[] { + const routes: RouteSignal[] = []; + const lines = content.split(/\r?\n/); + + for (let index = 0; index < lines.length && routes.length < MAX_SIGNALS_PER_FILE; index += 1) { + const line = lines[index] ?? ""; + const lineNumber = index + 1; + + const express = line.match(/\b(?:app|router|server)\.(get|post|put|patch|delete|options|head|use)\s*\(\s*["'`]([^"'`]+)["'`]/i); + if (express?.[1] && express[2]) { + routes.push({ path, line: lineNumber, method: express[1].toUpperCase(), route: express[2], frameworkHint: "Node HTTP router" }); + continue; + } + + const decorator = line.match(/@(Get|Post|Put|Patch|Delete|Options|Head)\s*\(\s*["'`]([^"'`]*)["'`]\s*\)/); + if (decorator?.[1] && decorator[2] !== undefined) { + routes.push({ path, line: lineNumber, method: decorator[1].toUpperCase(), route: decorator[2] || "/", frameworkHint: "Decorator router" }); + continue; + } + + const pythonRoute = line.match(/@(?:app|router|blueprint)\.(get|post|put|patch|delete|route)\s*\(\s*["']([^"']+)["']/i); + if (pythonRoute?.[1] && pythonRoute[2]) { + routes.push({ path, line: lineNumber, method: pythonRoute[1].toUpperCase(), route: pythonRoute[2], frameworkHint: "Python web router" }); + continue; + } + + const django = line.match(/\bpath\s*\(\s*["']([^"']+)["']/); + if (django?.[1]) routes.push({ path, line: lineNumber, method: "ANY", route: django[1], frameworkHint: "Django URLConf" }); + } + + return routes; +} + +function collectAuthSignals(path: string, content: string): AuthSignal[] { + const output: AuthSignal[] = []; + const lines = content.split(/\r?\n/); + const patterns: Array<[AuthSignal["kind"], RegExp]> = [ + ["authentication", /\b(authenticate|authentication|requireAuth|isAuthenticated|passport\.authenticate|login_required)\b/i], + ["authorization", /\b(authorize|authorization|permission|permissions|role|roles|isAdmin|accessControl|acl)\b/i], + ["session", /\b(session|cookieSession|express-session|sessionMiddleware)\b/i], + ["token", /\b(jwt|bearer|access[_-]?token|id[_-]?token|verifyToken|decodeToken)\b/i], + ]; + + for (let index = 0; index < lines.length && output.length < MAX_SIGNALS_PER_FILE; index += 1) { + const line = lines[index] ?? ""; + for (const [kind, pattern] of patterns) { + if (!pattern.test(line)) continue; + output.push({ path, line: index + 1, kind, evidence: sourceLine(lines, index) }); + break; + } + } + return output; +} + +function collectSinkSignals(path: string, content: string): SinkSignal[] { + const output: SinkSignal[] = []; + const lines = content.split(/\r?\n/); + const patterns: Array<[SinkSignal["kind"], RegExp]> = [ + ["process", /\b(child_process|execFile|execSync|spawnSync|spawn\s*\(|exec\s*\(|subprocess\.|os\.system\s*\()/i], + ["filesystem", /\b(writeFile|writeFileSync|appendFile|appendFileSync|unlink|rmSync|rename|createWriteStream|shutil\.|os\.remove\s*\()/i], + ["database", /\b(query|execute|executemany|raw|rawQuery|createQueryRunner)\s*\(/i], + ["network", /\b(fetch|axios\.|http\.request|https\.request|requests\.(get|post|put|patch|delete)|urllib\.)/i], + ]; + + for (let index = 0; index < lines.length && output.length < MAX_SIGNALS_PER_FILE; index += 1) { + const line = lines[index] ?? ""; + for (const [kind, pattern] of patterns) { + if (!pattern.test(line)) continue; + output.push({ path, line: index + 1, kind, evidence: sourceLine(lines, index) }); + break; + } + } + return output; +} + +export async function buildRepositoryIndex(rootPath: string, files: readonly IndexFileInput[]): Promise { + const root = resolve(rootPath); + const moduleEdges: ModuleEdge[] = []; + const routes: RouteSignal[] = []; + const authSignals: AuthSignal[] = []; + const sinks: SinkSignal[] = []; + let indexedFileCount = 0; + + for (const file of files) { + if (indexedFileCount >= MAX_INDEX_FILES) break; + if (file.size > MAX_INDEX_FILE_BYTES || !analyzableExtensions.has(extname(file.path).toLowerCase())) continue; + const absolute = resolve(root, file.path); + const content = await readFile(absolute, "utf8").catch(() => undefined); + if (content === undefined || content.includes("\u0000")) continue; + + indexedFileCount += 1; + moduleEdges.push(...collectModuleEdges(file.path, content)); + routes.push(...collectRoutes(file.path, content)); + authSignals.push(...collectAuthSignals(file.path, content)); + sinks.push(...collectSinkSignals(file.path, content)); + } + + return { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + indexedFileCount, + moduleEdges, + routes, + authSignals, + sinks, + }; +} + +export async function writeRepositoryIndex(path: string, index: RepositoryIndex): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(index, null, 2)}\n`, "utf8"); +} + +export function isRepositoryIndex(value: unknown): value is RepositoryIndex { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return record.schemaVersion === 1 && Array.isArray(record.moduleEdges) && Array.isArray(record.routes) && Array.isArray(record.authSignals) && Array.isArray(record.sinks); +} + +export async function readRepositoryIndex(path: string): Promise { + const parsed = JSON.parse(await readFile(path, "utf8")) as unknown; + if (!isRepositoryIndex(parsed)) throw new Error(`Not a supported SynSec repository index: ${path}`); + return parsed; +} From 0473893da74da0dbebf75a811a779ae3505c5ac9 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:34:03 -0400 Subject: [PATCH 0095/1132] feat(repository): export repository intelligence analysis module --- packages/repository/package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/repository/package.json b/packages/repository/package.json index 3a42b677..04803aa0 100644 --- a/packages/repository/package.json +++ b/packages/repository/package.json @@ -3,7 +3,10 @@ "version": "0.2.0", "private": true, "type": "module", - "exports": "./dist/index.js", + "exports": { + ".": "./dist/index.js", + "./analysis": "./dist/analysis.js" + }, "types": "./dist/index.d.ts", "scripts": { "build": "tsc -p tsconfig.json", From caf19608f0f3e12fc8e430eb78d1d9c11e10c480 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:34:51 -0400 Subject: [PATCH 0096/1132] feat(engine): build repository intelligence index during scans --- packages/engine/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index c804f320..3d97e9f2 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -3,6 +3,7 @@ import type { SynSecConfig } from "@synsec/config"; import type { Finding, ScanResult, ScanTarget, Severity } from "@synsec/core"; import { applyBaseline, buildReport, type SynSecReport } from "@synsec/report"; import { inventoryRepository } from "@synsec/repository"; +import { buildRepositoryIndex, type RepositoryIndex } from "@synsec/repository/analysis"; import { runProcess, type ScannerAdapter, type ScannerAvailability } from "@synsec/scanner-sdk"; import { builtInScanners } from "@synsec/scanners"; @@ -20,6 +21,7 @@ export interface ScannerFailure { export interface ScanEngineOutcome { report: SynSecReport; + repositoryIndex: RepositoryIndex; statuses: ScannerStatus[]; failures: ScannerFailure[]; shouldFail: boolean; @@ -237,6 +239,7 @@ export async function runScanEngine(input: { ); if (availableSelected.length === 0) throw new Error(unavailableSummary(statuses)); + const repositoryIndex = await buildRepositoryIndex(root, inventory.files); const changedScope = input.changedOnly ? await discoverChangedFiles(root, input.changedBase) : undefined; const result = await runSelectedScanners(target, input.config, statuses, changedScope?.files); const scans = changedScope ? scopeScansToChangedFiles(result.scans, root, changedScope.files) : result.scans; @@ -259,6 +262,7 @@ export async function runScanEngine(input: { const outcome: ScanEngineOutcome = { report, + repositoryIndex, statuses, failures, shouldFail: reportMeetsFailureThreshold(report, input.config.failOn), From 47a4e91edb4226c84e5c940f4fa24bbec30d35e0 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:35:15 -0400 Subject: [PATCH 0097/1132] test(repository): cover static repository intelligence index --- tests/repository-index.test.mjs | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/repository-index.test.mjs diff --git a/tests/repository-index.test.mjs b/tests/repository-index.test.mjs new file mode 100644 index 00000000..6e4af17c --- /dev/null +++ b/tests/repository-index.test.mjs @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildRepositoryIndex, + readRepositoryIndex, + writeRepositoryIndex, +} from "../packages/repository/dist/analysis.js"; + +test("repository index extracts imports, routes, auth context, and sensitive sinks without executing code", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-index-test-")); + try { + await mkdir(join(root, "src")); + const source = `import express from "express"; +import { execFile } from "node:child_process"; +const app = express(); +function requireAuth(req, res, next) { return next(); } +app.get("/users/:id", requireAuth, async (req, res) => { + const rows = await db.query("select * from users where id = $1", [req.params.id]); + res.json(rows); +}); +execFile("echo", ["fixture"]); +`; + await writeFile(join(root, "src", "app.ts"), source); + + const index = await buildRepositoryIndex(root, [{ path: "src/app.ts", size: Buffer.byteLength(source) }]); + assert.equal(index.schemaVersion, 1); + assert.equal(index.indexedFileCount, 1); + assert.ok(index.moduleEdges.some((edge) => edge.specifier === "express")); + assert.ok(index.routes.some((route) => route.route === "/users/:id" && route.method === "GET")); + assert.ok(index.authSignals.some((signal) => signal.kind === "authentication")); + assert.ok(index.sinks.some((sink) => sink.kind === "database")); + assert.ok(index.sinks.some((sink) => sink.kind === "process")); + + const output = join(root, ".synsec", "repository-index.json"); + await writeRepositoryIndex(output, index); + const reloaded = await readRepositoryIndex(output); + assert.equal(reloaded.routes[0].route, "/users/:id"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From b51eab7cbaa3b06d2b7e3fcb4ba00776e1591640 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:36:50 -0400 Subject: [PATCH 0098/1132] feat(cli): persist repository intelligence index --- apps/cli/src/index.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 3b73abe9..4c6994f3 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -23,6 +23,7 @@ import { type SynSecReport, } from "@synsec/report"; import { getFindingContext } from "@synsec/repository"; +import { writeRepositoryIndex } from "@synsec/repository/analysis"; import { parseSarifJson } from "@synsec/scanners"; import { assertWorkflowSourceContextAllowed, @@ -107,7 +108,7 @@ Scan options: --fail-on Exit non-zero when this severity or higher is found. --baseline Compare against a previous SynSec report. --json Print the report JSON to stdout. - --no-write Do not write JSON/HTML/SARIF report files. + --no-write Do not write reports or the repository index. --ai Run optional AI triage after deterministic scanning. --workflow Restrict AI triage to a built-in defensive workflow. --ai-source Allow source excerpts when the selected workflow permits it. @@ -312,11 +313,13 @@ async function scan(): Promise { }); const paths = resolveReportPaths(root, config); + const repositoryIndexPath = resolve(root, ".synsec/repository-index.json"); if (!flag("--no-write")) { await Promise.all([ writeReport(paths.json, outcome.report), writeHtml(paths.html, outcome.report), writeSarif(paths.sarif, outcome.report), + writeRepositoryIndex(repositoryIndexPath, outcome.repositoryIndex), ]); } @@ -347,6 +350,11 @@ async function scan(): Promise { .filter((artifact) => artifact.type === "sbom") .reduce((total, artifact) => total + artifact.packageCount, 0); if (sbomPackages > 0) console.log(`SBOM: ${sbomPackages} package(s) inventoried\n`); + console.log( + `Repository index: ${outcome.repositoryIndex.indexedFileCount} file(s), ` + + `${outcome.repositoryIndex.moduleEdges.length} module edge(s), ${outcome.repositoryIndex.routes.length} route signal(s), ` + + `${outcome.repositoryIndex.authSignals.length} auth signal(s), ${outcome.repositoryIndex.sinks.length} sink signal(s)\n`, + ); if (outcome.report.baseline) { console.log( @@ -368,6 +376,7 @@ async function scan(): Promise { console.log(`JSON: ${paths.json}`); console.log(`HTML: ${paths.html}`); console.log(`SARIF: ${paths.sarif}`); + console.log(`INDEX: ${repositoryIndexPath}`); } } From 98e7eb78eff7f943141d1d2874bcb39e7f916207 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:37:09 -0400 Subject: [PATCH 0099/1132] test(integration): verify repository intelligence is built with scans --- tests/integration.test.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/integration.test.mjs b/tests/integration.test.mjs index 13d84b8b..6eea3bcf 100644 --- a/tests/integration.test.mjs +++ b/tests/integration.test.mjs @@ -14,7 +14,10 @@ test("scan engine runs an available adapter end-to-end and builds a correlated r try { await mkdir(join(root, "src")); await writeFile(join(root, "package.json"), JSON.stringify({ name: "fixture", version: "1.0.0" })); - await writeFile(join(root, "src", "index.js"), "console.log('fixture');\n"); + await writeFile(join(root, "src", "index.js"), `import express from "express"; +const app = express(); +app.get("/health", (_req, res) => res.json({ ok: true })); +`); const trivy = join(bin, "trivy"); await writeFile(trivy, `#!/bin/sh @@ -39,8 +42,12 @@ JSON assert.equal(outcome.report.rawFindingCount, 1); assert.equal(outcome.report.findingCount, 1); assert.equal(outcome.report.summary.high, 1); + assert.equal(outcome.report.scope.mode, "repository"); assert.equal(outcome.failures.length, 0); assert.equal(outcome.report.repository.languages.JavaScript, 1); + assert.equal(outcome.repositoryIndex.indexedFileCount, 1); + assert.ok(outcome.repositoryIndex.moduleEdges.some((edge) => edge.specifier === "express")); + assert.ok(outcome.repositoryIndex.routes.some((route) => route.route === "/health")); } finally { process.env.PATH = originalPath; await rm(root, { recursive: true, force: true }); From 7c37f6d9d555f075c5dba2546d8b474b7de71ca4 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:38:02 -0400 Subject: [PATCH 0100/1132] feat(repository): add dependency usage and route security context --- packages/repository/src/analysis.ts | 58 +++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/repository/src/analysis.ts b/packages/repository/src/analysis.ts index 61c14a40..d94cc571 100644 --- a/packages/repository/src/analysis.ts +++ b/packages/repository/src/analysis.ts @@ -45,6 +45,18 @@ export interface RepositoryIndex { sinks: SinkSignal[]; } +export interface DependencyUsage { + packageName: string; + status: "observed-import" | "unknown"; + evidence: ModuleEdge[]; +} + +export interface RouteSecurityContext { + route: RouteSignal; + nearbyAuthSignals: AuthSignal[]; + nearbySinks: SinkSignal[]; +} + const analyzableExtensions = new Set([ ".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx", @@ -205,6 +217,52 @@ export async function buildRepositoryIndex(rootPath: string, files: readonly Ind }; } +export function packageNameFromPurl(purl: string | undefined): string | undefined { + if (!purl?.startsWith("pkg:")) return undefined; + const slash = purl.indexOf("/"); + if (slash < 0) return undefined; + let value = purl.slice(slash + 1); + const query = value.search(/[?#]/); + if (query >= 0) value = value.slice(0, query); + const version = value.lastIndexOf("@"); + if (version > 0) value = value.slice(0, version); + try { + value = decodeURIComponent(value); + } catch { + // Keep the raw package path when malformed percent encoding is present. + } + return value || undefined; +} + +function moduleMatchesPackage(edge: ModuleEdge, packageName: string): boolean { + const specifier = edge.specifier.toLowerCase(); + const normalized = packageName.toLowerCase(); + const pythonNormalized = normalized.replaceAll("-", "_"); + if (specifier === normalized || specifier.startsWith(`${normalized}/`)) return true; + if (specifier === pythonNormalized || specifier.startsWith(`${pythonNormalized}.`)) return true; + return false; +} + +export function findDependencyUsage(index: RepositoryIndex, packageName: string, maxEvidence = 10): DependencyUsage { + const evidence = index.moduleEdges + .filter((edge) => moduleMatchesPackage(edge, packageName)) + .slice(0, Math.max(1, maxEvidence)); + return { + packageName, + status: evidence.length > 0 ? "observed-import" : "unknown", + evidence, + }; +} + +export function routeSecurityContext(index: RepositoryIndex, route: RouteSignal, radius = 30): RouteSecurityContext { + const nearby = (line: number): boolean => Math.abs(line - route.line) <= Math.max(0, radius); + return { + route, + nearbyAuthSignals: index.authSignals.filter((signal) => signal.path === route.path && nearby(signal.line)), + nearbySinks: index.sinks.filter((signal) => signal.path === route.path && nearby(signal.line)), + }; +} + export async function writeRepositoryIndex(path: string, index: RepositoryIndex): Promise { await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(index, null, 2)}\n`, "utf8"); From ee809c929b69bd87b82f1e1b32c0037db7324916 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:38:25 -0400 Subject: [PATCH 0101/1132] test(repository): cover dependency usage and route security context --- tests/repository-index.test.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/repository-index.test.mjs b/tests/repository-index.test.mjs index 6e4af17c..258dcd83 100644 --- a/tests/repository-index.test.mjs +++ b/tests/repository-index.test.mjs @@ -5,7 +5,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildRepositoryIndex, + findDependencyUsage, + packageNameFromPurl, readRepositoryIndex, + routeSecurityContext, writeRepositoryIndex, } from "../packages/repository/dist/analysis.js"; @@ -15,6 +18,7 @@ test("repository index extracts imports, routes, auth context, and sensitive sin await mkdir(join(root, "src")); const source = `import express from "express"; import { execFile } from "node:child_process"; +import lodash from "lodash/fp"; const app = express(); function requireAuth(req, res, next) { return next(); } app.get("/users/:id", requireAuth, async (req, res) => { @@ -34,6 +38,18 @@ execFile("echo", ["fixture"]); assert.ok(index.sinks.some((sink) => sink.kind === "database")); assert.ok(index.sinks.some((sink) => sink.kind === "process")); + const lodashUsage = findDependencyUsage(index, "lodash"); + assert.equal(lodashUsage.status, "observed-import"); + assert.equal(lodashUsage.evidence[0].specifier, "lodash/fp"); + assert.equal(findDependencyUsage(index, "not-imported").status, "unknown"); + assert.equal(packageNameFromPurl("pkg:npm/%40scope/demo@1.2.3"), "@scope/demo"); + + const route = index.routes.find((item) => item.route === "/users/:id"); + assert.ok(route); + const context = routeSecurityContext(index, route); + assert.ok(context.nearbyAuthSignals.some((signal) => signal.kind === "authentication")); + assert.ok(context.nearbySinks.some((sink) => sink.kind === "database")); + const output = join(root, ".synsec", "repository-index.json"); await writeRepositoryIndex(output, index); const reloaded = await readRepositoryIndex(output); From 953a2676541db50c2e0ad0ac5f4312f801803af0 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:38:58 -0400 Subject: [PATCH 0102/1132] feat(engine): annotate dependency findings with observed import usage --- packages/engine/src/index.ts | 38 ++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 3d97e9f2..5e6c7b70 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -3,7 +3,12 @@ import type { SynSecConfig } from "@synsec/config"; import type { Finding, ScanResult, ScanTarget, Severity } from "@synsec/core"; import { applyBaseline, buildReport, type SynSecReport } from "@synsec/report"; import { inventoryRepository } from "@synsec/repository"; -import { buildRepositoryIndex, type RepositoryIndex } from "@synsec/repository/analysis"; +import { + buildRepositoryIndex, + findDependencyUsage, + packageNameFromPurl, + type RepositoryIndex, +} from "@synsec/repository/analysis"; import { runProcess, type ScannerAdapter, type ScannerAvailability } from "@synsec/scanner-sdk"; import { builtInScanners } from "@synsec/scanners"; @@ -138,6 +143,34 @@ function scopeScansToChangedFiles(scans: readonly ScanResult[], root: string, fi }); } +function dependencyPackageName(finding: Finding): string | undefined { + const direct = finding.metadata?.package; + if (typeof direct === "string" && direct.trim()) return direct.trim(); + const purl = finding.metadata?.purl; + return typeof purl === "string" ? packageNameFromPurl(purl) : undefined; +} + +function enrichDependencyUsage(scans: readonly ScanResult[], index: RepositoryIndex): ScanResult[] { + return scans.map((scan) => ({ + ...scan, + findings: scan.findings.map((finding) => { + if (finding.category !== "dependency" && finding.category !== "container" && finding.category !== "supply-chain") { + return finding; + } + const packageName = dependencyPackageName(finding); + if (!packageName) return finding; + const usage = findDependencyUsage(index, packageName); + return { + ...finding, + metadata: { + ...(finding.metadata ?? {}), + dependencyUsage: usage, + }, + }; + }), + })); +} + export async function scannerStatuses(config: SynSecConfig): Promise { const selectedIds = new Set(config.scanners); const scanners = builtInScanners(); @@ -242,7 +275,8 @@ export async function runScanEngine(input: { const repositoryIndex = await buildRepositoryIndex(root, inventory.files); const changedScope = input.changedOnly ? await discoverChangedFiles(root, input.changedBase) : undefined; const result = await runSelectedScanners(target, input.config, statuses, changedScope?.files); - const scans = changedScope ? scopeScansToChangedFiles(result.scans, root, changedScope.files) : result.scans; + const enrichedScans = enrichDependencyUsage(result.scans, repositoryIndex); + const scans = changedScope ? scopeScansToChangedFiles(enrichedScans, root, changedScope.files) : enrichedScans; const failures = result.failures; if (scans.length === 0) { const details = failures.map((failure) => `${failure.scanner}: ${failure.message}`).join("; "); From f1d40fcc7112844ee19a7bbc5404752349e21ecb Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:39:18 -0400 Subject: [PATCH 0103/1132] test(integration): verify observed dependency usage enrichment --- tests/integration.test.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration.test.mjs b/tests/integration.test.mjs index 6eea3bcf..8516d398 100644 --- a/tests/integration.test.mjs +++ b/tests/integration.test.mjs @@ -26,7 +26,7 @@ if [ "$1" = "--version" ]; then exit 0 fi cat <<'JSON' -{"Results":[{"Target":"package-lock.json","Vulnerabilities":[{"VulnerabilityID":"CVE-2026-4242","PkgName":"fixture-package","InstalledVersion":"1.0.0","FixedVersion":"1.0.1","Title":"Fixture dependency vulnerability","Severity":"HIGH"}]}]} +{"Results":[{"Target":"package-lock.json","Vulnerabilities":[{"VulnerabilityID":"CVE-2026-4242","PkgName":"express","InstalledVersion":"1.0.0","FixedVersion":"1.0.1","Title":"Fixture dependency vulnerability","Severity":"HIGH"}]}]} JSON `); await chmod(trivy, 0o755); @@ -48,6 +48,10 @@ JSON assert.equal(outcome.repositoryIndex.indexedFileCount, 1); assert.ok(outcome.repositoryIndex.moduleEdges.some((edge) => edge.specifier === "express")); assert.ok(outcome.repositoryIndex.routes.some((route) => route.route === "/health")); + const usage = outcome.report.findings[0].primary.metadata.dependencyUsage; + assert.equal(usage.status, "observed-import"); + assert.equal(usage.packageName, "express"); + assert.equal(usage.evidence[0].specifier, "express"); } finally { process.env.PATH = originalPath; await rm(root, { recursive: true, force: true }); From 065672a5b819e2d2ab06d0976cd2f9be34259ed9 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:40:13 -0400 Subject: [PATCH 0104/1132] feat(workflows): add task-specific defensive review instructions --- packages/workflows/src/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/workflows/src/index.ts b/packages/workflows/src/index.ts index b4b15f2b..3463d951 100644 --- a/packages/workflows/src/index.ts +++ b/packages/workflows/src/index.ts @@ -15,6 +15,7 @@ export interface WorkflowDefinition { version: 1; displayName: string; description: string; + reviewInstructions: string; categories: readonly FindingCategory[] | "all"; capabilities: readonly WorkflowCapability[]; sourceContextAllowed: boolean; @@ -28,6 +29,7 @@ const workflows: readonly WorkflowDefinition[] = [ version: 1, displayName: "Repository Review", description: "Review normalized findings across the repository and explain the strongest evidence first.", + reviewInstructions: "Prioritize deterministic scanner evidence, actual repository reachability signals, and nearby mitigations. Do not infer an exploitable path merely from a vulnerability class or suspicious API name.", categories: "all", capabilities: [ "read-normalized-findings", @@ -45,6 +47,7 @@ const workflows: readonly WorkflowDefinition[] = [ version: 1, displayName: "Dependency Review", description: "Review known vulnerable dependencies, package identity, fix availability, and available reachability evidence.", + reviewInstructions: "Distinguish package presence from observed application use. Treat dependencyUsage.status=observed-import as evidence of an import, not proof that a vulnerable function is reachable. Prefer fixed-version guidance already supplied by deterministic scanners.", categories: ["dependency", "container", "supply-chain", "license"], capabilities: [ "read-normalized-findings", @@ -62,6 +65,7 @@ const workflows: readonly WorkflowDefinition[] = [ version: 1, displayName: "Secrets Review", description: "Review redacted secret findings and recommend rotation/removal without exposing secret values to the model layer.", + reviewInstructions: "Never request, reconstruct, guess, validate, or reproduce a credential value. Work only from redacted metadata. Recommend proportionate revocation, rotation, history cleanup, and secret-management controls.", categories: ["secret"], capabilities: [ "read-normalized-findings", @@ -77,6 +81,7 @@ const workflows: readonly WorkflowDefinition[] = [ version: 1, displayName: "Infrastructure Review", description: "Review IaC, deployment, misconfiguration, and repository-posture findings.", + reviewInstructions: "Separate policy or posture heuristics from concrete vulnerable configuration. Account for deployment context when present and avoid treating a low Scorecard check as direct exploit evidence.", categories: ["iac", "misconfiguration", "repository-posture"], capabilities: [ "read-normalized-findings", From 924e6e5413b02d21f4a9746dd78fdc97ec27254b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:40:33 -0400 Subject: [PATCH 0105/1132] feat(ai): apply workflow-specific review instructions --- packages/ai/src/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 8aae8910..925fb378 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -100,7 +100,7 @@ function normalizeReview(value: unknown, finding: Finding, model: string): AiFin return review; } -function buildPrompt(finding: Finding, context?: FindingContext): string { +function buildPrompt(finding: Finding, context?: FindingContext, reviewInstructions?: string): string { const safeFinding = { title: finding.title, description: finding.description, @@ -117,14 +117,18 @@ function buildPrompt(finding: Finding, context?: FindingContext): string { const contextBlock = context ? `\nRepository excerpt (${context.path}, lines ${context.startLine}-${context.endLine}):\n${context.excerpt}` : "\nNo source excerpt was provided. Treat reachability and code-flow claims as unknown unless scanner evidence is sufficient."; + const workflowBlock = reviewInstructions + ? `\nWorkflow-specific review instructions:\n${reviewInstructions}\n` + : ""; - return `You are reviewing a repository security scanner finding for defensive software assurance. Do not invent exploit steps, credentials, or evidence. Separate deterministic scanner evidence from inference. If the available context cannot answer a question, answer unknown. Return JSON only.\n\nFinding:\n${JSON.stringify(safeFinding, null, 2)}${contextBlock}\n\nAssess these seven gates:\n${gateQuestions.map(([id, question], index) => `${index + 1}. ${id}: ${question}`).join("\n")}\n\nReturn exactly this shape:\n{\n "verdict": "confirmed|likely|uncertain|false-positive",\n "confidence": 0.0,\n "severity": "critical|high|medium|low|info|unknown",\n "summary": "short summary",\n "rationale": "brief evidence-grounded rationale",\n "gate": [{"id":"concrete","answer":"yes|no|unknown","note":"brief note"}],\n "remediation": "brief defensive remediation"\n}`; + return `You are reviewing a repository security scanner finding for defensive software assurance. Do not invent exploit steps, credentials, or evidence. Separate deterministic scanner evidence from inference. If the available context cannot answer a question, answer unknown. Return JSON only.${workflowBlock}\nFinding:\n${JSON.stringify(safeFinding, null, 2)}${contextBlock}\n\nAssess these seven gates:\n${gateQuestions.map(([id, question], index) => `${index + 1}. ${id}: ${question}`).join("\n")}\n\nReturn exactly this shape:\n{\n "verdict": "confirmed|likely|uncertain|false-positive",\n "confidence": 0.0,\n "severity": "critical|high|medium|low|info|unknown",\n "summary": "short summary",\n "rationale": "brief evidence-grounded rationale",\n "gate": [{"id":"concrete","answer":"yes|no|unknown","note":"brief note"}],\n "remediation": "brief defensive remediation"\n}`; } export async function reviewFinding( finding: Finding, config: OpenAiCompatibleConfig, context?: FindingContext, + reviewInstructions?: string, ): Promise { const baseUrl = config.baseUrl.replace(/\/$/, ""); const controller = new AbortController(); @@ -146,7 +150,7 @@ export async function reviewFinding( role: "system", content: "Perform concise defensive repository vulnerability triage. Return valid JSON only.", }, - { role: "user", content: buildPrompt(finding, context) }, + { role: "user", content: buildPrompt(finding, context, reviewInstructions) }, ], }), }); From 395b1c36e5f5a53427a2b9f6ca0868e50be70e44 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:41:23 -0400 Subject: [PATCH 0106/1132] feat(cli): pass workflow instructions into AI review --- apps/cli/src/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 4c6994f3..fed0b757 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -262,7 +262,12 @@ async function reviewGroups( const context = config.ai.sendSourceContext ? await getFindingContext(root, group.primary) : undefined; - reviews[group.fingerprint] = await reviewFinding(group.primary, provider, context); + reviews[group.fingerprint] = await reviewFinding( + group.primary, + provider, + context, + workflow?.reviewInstructions, + ); } return reviews; } From e6efc68c3f3d999b4861414a7fcda1add68d585e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:41:44 -0400 Subject: [PATCH 0107/1132] test(ai): verify workflow instructions cross the model boundary --- tests/ai.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ai.test.mjs b/tests/ai.test.mjs index 504b5baf..8031e9d3 100644 --- a/tests/ai.test.mjs +++ b/tests/ai.test.mjs @@ -49,7 +49,7 @@ test("AI review uses the OpenAI-compatible boundary and normalizes the seven-que baseUrl: `http://127.0.0.1:${address.port}/v1`, model: "fixture-model", apiKey: "test-key", - }); + }, undefined, "Prefer deterministic fixture evidence and do not overstate reachability."); assert.equal(review.verdict, "likely"); assert.equal(review.model, "fixture-model"); @@ -58,6 +58,7 @@ test("AI review uses the OpenAI-compatible boundary and normalizes the seven-que assert.equal(review.gate.find((item) => item.id === "reachable")?.answer, "unknown"); assert.match(observedBody, /fixture-model/); assert.match(observedBody, /No source excerpt was provided/); + assert.match(observedBody, /Prefer deterministic fixture evidence/); } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } From 6bdc17da5bf1f6964ef2fddfa1229a4471f05e25 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:42:10 -0400 Subject: [PATCH 0108/1132] feat(lifecycle): add finding lifecycle package --- packages/lifecycle/package.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 packages/lifecycle/package.json diff --git a/packages/lifecycle/package.json b/packages/lifecycle/package.json new file mode 100644 index 00000000..937ae86d --- /dev/null +++ b/packages/lifecycle/package.json @@ -0,0 +1,15 @@ +{ + "name": "@synsec/lifecycle", + "version": "0.2.0", + "private": true, + "type": "module", + "exports": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/report": "0.2.0" + } +} From 637252be5667d5d3fc7db7640a63a2f4befe14fd Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:42:16 -0400 Subject: [PATCH 0109/1132] build(lifecycle): add TypeScript project --- packages/lifecycle/tsconfig.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 packages/lifecycle/tsconfig.json diff --git a/packages/lifecycle/tsconfig.json b/packages/lifecycle/tsconfig.json new file mode 100644 index 00000000..4ca5cc64 --- /dev/null +++ b/packages/lifecycle/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../report" } + ], + "include": ["src/**/*.ts"] +} From 5b49233a0e7bed6176c1be8d42908585f44c782c Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:42:33 -0400 Subject: [PATCH 0110/1132] feat(lifecycle): track finding states across reports --- packages/lifecycle/src/index.ts | 158 ++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 packages/lifecycle/src/index.ts diff --git a/packages/lifecycle/src/index.ts b/packages/lifecycle/src/index.ts new file mode 100644 index 00000000..09562660 --- /dev/null +++ b/packages/lifecycle/src/index.ts @@ -0,0 +1,158 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { SynSecReport } from "@synsec/report"; + +export type FindingState = + | "new" + | "confirmed" + | "false-positive" + | "accepted-risk" + | "fixed" + | "regressed"; + +export interface FindingLifecycleRecord { + fingerprint: string; + state: FindingState; + updatedAt: string; + note?: string; + reportId?: string; +} + +export interface FindingLifecycleStore { + schemaVersion: 1; + records: Record; +} + +export interface LifecycleSummary { + new: number; + confirmed: number; + falsePositive: number; + acceptedRisk: number; + fixed: number; + regressed: number; +} + +export function emptyLifecycleStore(): FindingLifecycleStore { + return { schemaVersion: 1, records: {} }; +} + +export function isFindingState(value: unknown): value is FindingState { + return value === "new" || value === "confirmed" || value === "false-positive" || value === "accepted-risk" || value === "fixed" || value === "regressed"; +} + +export function isLifecycleStore(value: unknown): value is FindingLifecycleStore { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const record = value as Record; + return record.schemaVersion === 1 && typeof record.records === "object" && record.records !== null && !Array.isArray(record.records); +} + +export async function readLifecycleStore(path: string): Promise { + try { + const parsed = JSON.parse(await readFile(path, "utf8")) as unknown; + if (!isLifecycleStore(parsed)) throw new Error(`Not a supported SynSec lifecycle store: ${path}`); + return parsed; + } catch (error) { + const code = typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : ""; + if (code === "ENOENT") return emptyLifecycleStore(); + throw error; + } +} + +export async function writeLifecycleStore(path: string, store: FindingLifecycleStore): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(store, null, 2)}\n`, "utf8"); +} + +export function setFindingState( + store: FindingLifecycleStore, + fingerprint: string, + state: FindingState, + options: { note?: string; reportId?: string; updatedAt?: string } = {}, +): FindingLifecycleStore { + if (!fingerprint.trim()) throw new Error("Finding fingerprint cannot be empty."); + const updated: FindingLifecycleStore = { + schemaVersion: 1, + records: { ...store.records }, + }; + const record: FindingLifecycleRecord = { + fingerprint, + state, + updatedAt: options.updatedAt ?? new Date().toISOString(), + }; + if (options.note?.trim()) record.note = options.note.trim(); + if (options.reportId) record.reportId = options.reportId; + updated.records[fingerprint] = record; + return updated; +} + +function autoTransition(previous: FindingLifecycleRecord | undefined, present: boolean): FindingState | undefined { + if (present) { + if (!previous) return "new"; + if (previous.state === "fixed") return "regressed"; + return previous.state; + } + + if (!previous) return undefined; + if (previous.state === "new" || previous.state === "confirmed" || previous.state === "regressed") return "fixed"; + return previous.state; +} + +export function reconcileLifecycle( + report: SynSecReport, + previous: FindingLifecycleStore, + updatedAt = new Date().toISOString(), +): FindingLifecycleStore { + const currentFingerprints = new Set(report.findings.map((finding) => finding.fingerprint)); + const all = new Set([...Object.keys(previous.records), ...currentFingerprints]); + const next: FindingLifecycleStore = { schemaVersion: 1, records: {} }; + + for (const fingerprint of all) { + const prior = previous.records[fingerprint]; + const present = currentFingerprints.has(fingerprint); + const state = autoTransition(prior, present); + if (!state) continue; + + const record: FindingLifecycleRecord = { + fingerprint, + state, + updatedAt: prior?.state === state ? prior.updatedAt : updatedAt, + reportId: report.reportId, + }; + if (prior?.note) record.note = prior.note; + next.records[fingerprint] = record; + } + + return next; +} + +export function lifecycleSummary(store: FindingLifecycleStore): LifecycleSummary { + const summary: LifecycleSummary = { + new: 0, + confirmed: 0, + falsePositive: 0, + acceptedRisk: 0, + fixed: 0, + regressed: 0, + }; + for (const record of Object.values(store.records)) { + if (record.state === "new") summary.new += 1; + else if (record.state === "confirmed") summary.confirmed += 1; + else if (record.state === "false-positive") summary.falsePositive += 1; + else if (record.state === "accepted-risk") summary.acceptedRisk += 1; + else if (record.state === "fixed") summary.fixed += 1; + else if (record.state === "regressed") summary.regressed += 1; + } + return summary; +} + +export function currentLifecycleRecords( + report: SynSecReport, + store: FindingLifecycleStore, +): FindingLifecycleRecord[] { + const current = new Set(report.findings.map((finding) => finding.fingerprint)); + return Object.values(store.records) + .filter((record) => current.has(record.fingerprint)) + .sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)); +} From 4eb988f41fc6a24bc1bfaa6801d5a975d9e75bc9 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:42:47 -0400 Subject: [PATCH 0111/1132] build: include lifecycle package in project references --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index 5d857f2d..a73470e8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ { "path": "./packages/config" }, { "path": "./packages/report" }, { "path": "./packages/repository" }, + { "path": "./packages/lifecycle" }, { "path": "./packages/workflows" }, { "path": "./packages/ai" }, { "path": "./packages/scanner-sdk" }, From 842d30e712dcdbd92469fcf86acc4a17f5655c64 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:43:04 -0400 Subject: [PATCH 0112/1132] test(lifecycle): cover triage and regression transitions --- tests/lifecycle.test.mjs | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/lifecycle.test.mjs diff --git a/tests/lifecycle.test.mjs b/tests/lifecycle.test.mjs new file mode 100644 index 00000000..6e513713 --- /dev/null +++ b/tests/lifecycle.test.mjs @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildReport } from "../packages/report/dist/index.js"; +import { + emptyLifecycleStore, + lifecycleSummary, + reconcileLifecycle, + setFindingState, +} from "../packages/lifecycle/dist/index.js"; + +function reportWith(ruleIds) { + return buildReport({ + target: { path: "/repo" }, + scans: [{ + scanner: "fixture", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:01.000Z", + target: { path: "/repo" }, + diagnostics: [], + findings: ruleIds.map((ruleId) => ({ + id: ruleId, + title: `Finding ${ruleId}`, + category: "sast", + severity: "high", + confidence: 1, + scanner: { name: "fixture", ruleId }, + location: { path: `src/${ruleId}.ts`, startLine: 1 }, + })), + }], + }); +} + +test("lifecycle creates new findings and preserves explicit triage state", () => { + const report = reportWith(["A"]); + let store = reconcileLifecycle(report, emptyLifecycleStore(), "2026-01-01T00:00:00.000Z"); + const fingerprint = report.findings[0].fingerprint; + assert.equal(store.records[fingerprint].state, "new"); + + store = setFindingState(store, fingerprint, "confirmed", { + note: "Reviewed by maintainer", + reportId: report.reportId, + updatedAt: "2026-01-02T00:00:00.000Z", + }); + const next = reconcileLifecycle(report, store, "2026-01-03T00:00:00.000Z"); + assert.equal(next.records[fingerprint].state, "confirmed"); + assert.equal(next.records[fingerprint].note, "Reviewed by maintainer"); +}); + +test("lifecycle marks disappeared confirmed findings fixed and returning findings regressed", () => { + const initial = reportWith(["A"]); + const fingerprint = initial.findings[0].fingerprint; + let store = reconcileLifecycle(initial, emptyLifecycleStore(), "2026-01-01T00:00:00.000Z"); + store = setFindingState(store, fingerprint, "confirmed", { updatedAt: "2026-01-02T00:00:00.000Z" }); + + const fixed = reconcileLifecycle(reportWith([]), store, "2026-01-03T00:00:00.000Z"); + assert.equal(fixed.records[fingerprint].state, "fixed"); + + const regressed = reconcileLifecycle(initial, fixed, "2026-01-04T00:00:00.000Z"); + assert.equal(regressed.records[fingerprint].state, "regressed"); + assert.equal(lifecycleSummary(regressed).regressed, 1); +}); + +test("false-positive and accepted-risk decisions are not rewritten just because a later scan omits the finding", () => { + const report = reportWith(["A", "B"]); + const [a, b] = report.findings.map((finding) => finding.fingerprint); + let store = reconcileLifecycle(report, emptyLifecycleStore()); + store = setFindingState(store, a, "false-positive"); + store = setFindingState(store, b, "accepted-risk"); + + const next = reconcileLifecycle(reportWith([]), store); + assert.equal(next.records[a].state, "false-positive"); + assert.equal(next.records[b].state, "accepted-risk"); +}); From 20f65d2d164769c1fe9884e9f866ff99beab243b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:43:24 -0400 Subject: [PATCH 0113/1132] build(cli): depend on lifecycle package --- apps/cli/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cli/package.json b/apps/cli/package.json index ad243539..64851c41 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -16,6 +16,7 @@ "@synsec/config": "0.2.0", "@synsec/core": "0.1.0", "@synsec/engine": "0.2.0", + "@synsec/lifecycle": "0.2.0", "@synsec/report": "0.2.0", "@synsec/repository": "0.2.0", "@synsec/scanners": "0.1.0", From 971e86b69845ad2994b22e8a6ebc00800ef87ce8 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:43:36 -0400 Subject: [PATCH 0114/1132] build(cli): reference lifecycle package --- apps/cli/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 0f2e1271..0109b42d 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -10,6 +10,7 @@ { "path": "../../packages/config" }, { "path": "../../packages/core" }, { "path": "../../packages/engine" }, + { "path": "../../packages/lifecycle" }, { "path": "../../packages/report" }, { "path": "../../packages/repository" }, { "path": "../../packages/scanners" }, From 1e6096a58e444c8b59115087f03e57cf69055b55 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:43:58 -0400 Subject: [PATCH 0115/1132] feat(cli): add lifecycle persistence and triage helpers --- apps/cli/src/lifecycle.ts | 73 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 apps/cli/src/lifecycle.ts diff --git a/apps/cli/src/lifecycle.ts b/apps/cli/src/lifecycle.ts new file mode 100644 index 00000000..c2e4f0e1 --- /dev/null +++ b/apps/cli/src/lifecycle.ts @@ -0,0 +1,73 @@ +import { dirname, resolve } from "node:path"; +import { + currentLifecycleRecords, + isFindingState, + lifecycleSummary, + readLifecycleStore, + reconcileLifecycle, + setFindingState, + writeLifecycleStore, + type FindingLifecycleStore, + type LifecycleSummary, +} from "@synsec/lifecycle"; +import { readReport, type SynSecReport } from "@synsec/report"; + +export interface LifecycleFileResult { + path: string; + store: FindingLifecycleStore; + summary: LifecycleSummary; +} + +export async function reconcileLifecycleFile( + report: SynSecReport, + root: string, + persist: boolean, +): Promise { + const path = resolve(root, ".synsec/lifecycle.json"); + const previous = await readLifecycleStore(path); + const store = reconcileLifecycle(report, previous); + if (persist) await writeLifecycleStore(path, store); + return { path, store, summary: lifecycleSummary(store) }; +} + +export async function runTriage(input: { + reportPath: string; + fingerprint?: string; + state?: string; + note?: string; + storePath?: string; + listOnly?: boolean; +}): Promise { + const reportPath = resolve(input.reportPath); + const report = await readReport(reportPath); + const storePath = resolve(input.storePath ?? dirname(reportPath), input.storePath ? "." : "lifecycle.json"); + let store = reconcileLifecycle(report, await readLifecycleStore(storePath)); + + if (input.listOnly) { + const records = currentLifecycleRecords(report, store); + const lines = records.map((record) => { + const finding = report.findings.find((item) => item.fingerprint === record.fingerprint); + return `${record.fingerprint} ${record.state.padEnd(14)} ${finding?.primary.title ?? "finding"}`; + }); + return [`Lifecycle store: ${storePath}`, ...lines]; + } + + if (!input.fingerprint || !input.state) { + throw new Error("Usage: synsec triage [--note ] [--store ] or synsec triage --list"); + } + if (!isFindingState(input.state)) { + throw new Error("Triage state must be one of new, confirmed, false-positive, accepted-risk, fixed, regressed."); + } + const exists = report.findings.some((finding) => finding.fingerprint === input.fingerprint); + if (!exists) throw new Error(`Finding fingerprint is not present in report ${report.reportId}: ${input.fingerprint}`); + + store = setFindingState(store, input.fingerprint, input.state, { + note: input.note, + reportId: report.reportId, + }); + await writeLifecycleStore(storePath, store); + return [ + `Updated ${input.fingerprint} -> ${input.state}`, + `Lifecycle store: ${storePath}`, + ]; +} From 15ce63f2589ff46503b15c857f80151f1293f4ea Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:55:21 -0400 Subject: [PATCH 0116/1132] Integrate finding lifecycle triage into CLI --- apps/cli/src/index.ts | 48 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index fed0b757..f18a12b6 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -32,6 +32,7 @@ import { workflowFindings, type WorkflowDefinition, } from "@synsec/workflows"; +import { reconcileLifecycleFile, runTriage } from "./lifecycle.js"; const VERSION = "0.2.0"; const args = process.argv.slice(2); @@ -92,6 +93,8 @@ Usage: synsec doctor [path] [--config ] synsec scan [options] synsec review [options] + synsec triage --list [--store ] + synsec triage [--note ] [--store ] synsec import-sarif [options] synsec workflows synsec render [--html ] [--sarif ] @@ -108,7 +111,7 @@ Scan options: --fail-on Exit non-zero when this severity or higher is found. --baseline Compare against a previous SynSec report. --json Print the report JSON to stdout. - --no-write Do not write reports or the repository index. + --no-write Do not write reports, lifecycle state, or the repository index. --ai Run optional AI triage after deterministic scanning. --workflow Restrict AI triage to a built-in defensive workflow. --ai-source Allow source excerpts when the selected workflow permits it. @@ -125,6 +128,9 @@ Review options: --ai-base-url OpenAI-compatible API base URL. --ai-model Model ID. +Triage states: + new, confirmed, false-positive, accepted-risk, fixed, regressed + SARIF import options: --root Repository root represented by the imported findings (default: .). --output SynSec JSON report path (default: .synsec/imported-report.json). @@ -319,7 +325,10 @@ async function scan(): Promise { const paths = resolveReportPaths(root, config); const repositoryIndexPath = resolve(root, ".synsec/repository-index.json"); - if (!flag("--no-write")) { + const persist = !flag("--no-write"); + const lifecycle = await reconcileLifecycleFile(outcome.report, root, persist); + + if (persist) { await Promise.all([ writeReport(paths.json, outcome.report), writeHtml(paths.html, outcome.report), @@ -348,6 +357,11 @@ async function scan(): Promise { `${outcome.report.summary.critical} critical, ${outcome.report.summary.high} high, ` + `${outcome.report.summary.medium} medium, ${outcome.report.summary.low} low\n`, ); + console.log( + `Lifecycle: ${lifecycle.summary.new} new, ${lifecycle.summary.confirmed} confirmed, ` + + `${lifecycle.summary.falsePositive} false positive, ${lifecycle.summary.acceptedRisk} accepted risk, ` + + `${lifecycle.summary.fixed} fixed, ${lifecycle.summary.regressed} regressed\n`, + ); if (outcome.changedFiles) { console.log(`Changed-file scope: ${outcome.changedFiles.length} file(s) since ${outcome.changedBase ?? "base"}\n`); } @@ -377,11 +391,12 @@ async function scan(): Promise { console.error(`Scanner unavailable: ${status.displayName}: ${status.availability.reason ?? "not installed"}`); } - if (!flag("--no-write")) { - console.log(`JSON: ${paths.json}`); - console.log(`HTML: ${paths.html}`); - console.log(`SARIF: ${paths.sarif}`); - console.log(`INDEX: ${repositoryIndexPath}`); + if (persist) { + console.log(`JSON: ${paths.json}`); + console.log(`HTML: ${paths.html}`); + console.log(`SARIF: ${paths.sarif}`); + console.log(`INDEX: ${repositoryIndexPath}`); + console.log(`LIFECYCLE: ${lifecycle.path}`); } } @@ -410,6 +425,22 @@ async function review(): Promise { console.log(`Wrote ${Object.keys(reviews).length} AI review(s) to ${outputPath}`); } +async function triage(): Promise { + const reportArg = args[1]; + if (!reportArg || reportArg.startsWith("--")) { + throw new Error("Usage: synsec triage --list or synsec triage [--note ] [--store ]"); + } + const lines = await runTriage({ + reportPath: reportArg, + fingerprint: args[2] && !args[2].startsWith("--") ? args[2] : undefined, + state: args[3] && !args[3].startsWith("--") ? args[3] : undefined, + note: option("--note"), + storePath: option("--store"), + listOnly: flag("--list"), + }); + for (const line of lines) console.log(line); +} + async function importSarif(): Promise { const inputArg = args[1]; if (!inputArg || inputArg.startsWith("--")) { @@ -484,6 +515,9 @@ async function main(): Promise { case "review": await review(); break; + case "triage": + await triage(); + break; case "import-sarif": await importSarif(); break; From ba47eb285b8dd424f5c62dcce74a48245a57a5df Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:55:49 -0400 Subject: [PATCH 0117/1132] Test CLI finding triage lifecycle --- tests/cli.test.mjs | 65 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index f773a491..c4227f55 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -22,6 +22,13 @@ test("CLI lists capability-scoped defensive workflows", async () => { assert.match(stdout, /external network assessment: forbidden/); }); +test("CLI help documents finding lifecycle triage", async () => { + const { stdout } = await exec(process.execPath, [cli.pathname, "help"]); + assert.match(stdout, /synsec triage /); + assert.match(stdout, /false-positive/); + assert.match(stdout, /accepted-risk/); +}); + test("CLI init writes a safe default configuration", async () => { const root = await mkdtemp(join(tmpdir(), "synsec-cli-test-")); try { @@ -68,3 +75,61 @@ test("CLI imports SARIF into a native SynSec report", async () => { await rm(root, { recursive: true, force: true }); } }); + +test("CLI triage persists explicit lifecycle decisions and lists them", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-triage-test-")); + try { + const input = join(root, "external.sarif"); + const reportPath = join(root, "report.json"); + const storePath = join(root, "lifecycle.json"); + await writeFile(input, JSON.stringify({ + version: "2.1.0", + runs: [{ + tool: { driver: { name: "FixtureScanner", rules: [{ id: "FIX-2", shortDescription: { text: "Review me" } }] } }, + results: [{ ruleId: "FIX-2", level: "error", message: { text: "Review me" } }], + }], + }), "utf8"); + + await exec(process.execPath, [ + cli.pathname, + "import-sarif", + input, + "--root", + root, + "--output", + reportPath, + ]); + const report = JSON.parse(await readFile(reportPath, "utf8")); + const fingerprint = report.findings[0].fingerprint; + + const updated = await exec(process.execPath, [ + cli.pathname, + "triage", + reportPath, + fingerprint, + "confirmed", + "--note", + "reviewed", + "--store", + storePath, + ]); + assert.match(updated.stdout, /-> confirmed/); + + const stored = JSON.parse(await readFile(storePath, "utf8")); + assert.equal(stored.records[fingerprint].state, "confirmed"); + assert.equal(stored.records[fingerprint].note, "reviewed"); + + const listed = await exec(process.execPath, [ + cli.pathname, + "triage", + reportPath, + "--list", + "--store", + storePath, + ]); + assert.match(listed.stdout, /confirmed/); + assert.match(listed.stdout, /Review me/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From fda1f144521afeb999b4809291a343304e348607 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:56:58 -0400 Subject: [PATCH 0118/1132] Add deterministic remediation verification --- packages/lifecycle/src/index.ts | 134 ++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/packages/lifecycle/src/index.ts b/packages/lifecycle/src/index.ts index 09562660..51a13aae 100644 --- a/packages/lifecycle/src/index.ts +++ b/packages/lifecycle/src/index.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; +import type { CorrelatedFinding } from "@synsec/core"; import type { SynSecReport } from "@synsec/report"; export type FindingState = @@ -32,6 +33,31 @@ export interface LifecycleSummary { regressed: number; } +export type VerificationStatus = "fixed" | "persisting" | "inconclusive" | "missing-baseline"; + +export interface FindingVerification { + fingerprint: string; + title?: string; + status: VerificationStatus; + reasons: string[]; +} + +export interface RemediationVerification { + schemaVersion: 1; + generatedAt: string; + beforeReportId: string; + afterReportId: string; + items: FindingVerification[]; + newFindings: string[]; + summary: { + fixed: number; + persisting: number; + inconclusive: number; + missingBaseline: number; + newFindings: number; + }; +} + export function emptyLifecycleStore(): FindingLifecycleStore { return { schemaVersion: 1, records: {} }; } @@ -156,3 +182,111 @@ export function currentLifecycleRecords( .filter((record) => current.has(record.fingerprint)) .sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)); } + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\//, "").toLowerCase(); +} + +function afterScopeCoversFinding(after: SynSecReport, finding: CorrelatedFinding): { covered: boolean; reason?: string } { + if (after.scope?.mode === "repository") return { covered: true }; + if (after.scope?.mode !== "changed-files") { + return { covered: false, reason: "The after report has no repository-wide or changed-file scan scope metadata." }; + } + const path = finding.primary.location?.path; + if (!path) { + return { covered: false, reason: "The finding has no source path, so a changed-file scan cannot prove it was rechecked." }; + } + const changed = new Set((after.scope.changedFiles ?? []).map(normalizePath)); + if (!changed.has(normalizePath(path))) { + return { covered: false, reason: `The after report did not scan the finding path ${path} in its changed-file scope.` }; + } + return { covered: true }; +} + +function afterReranDetectingScanner(after: SynSecReport, finding: CorrelatedFinding): { covered: boolean; reason?: string } { + const afterScanners = new Set(after.scanners.map((scanner) => scanner.scanner.toLowerCase())); + const detecting = [...new Set(finding.sources.map((source) => source.name.toLowerCase()))]; + if (detecting.some((name) => afterScanners.has(name))) return { covered: true }; + return { + covered: false, + reason: `None of the scanner(s) that detected the finding were present in the after report: ${detecting.join(", ") || "unknown"}.`, + }; +} + +export function verifyRemediation( + before: SynSecReport, + after: SynSecReport, + requestedFingerprints?: readonly string[], + generatedAt = new Date().toISOString(), +): RemediationVerification { + const beforeByFingerprint = new Map(before.findings.map((finding) => [finding.fingerprint, finding])); + const afterByFingerprint = new Map(after.findings.map((finding) => [finding.fingerprint, finding])); + const targets = requestedFingerprints && requestedFingerprints.length > 0 + ? [...new Set(requestedFingerprints)] + : before.findings.map((finding) => finding.fingerprint); + + const items: FindingVerification[] = targets.map((fingerprint) => { + const baseline = beforeByFingerprint.get(fingerprint); + if (!baseline) { + return { + fingerprint, + status: "missing-baseline" as const, + reasons: ["The requested fingerprint is not present in the before report."], + }; + } + if (afterByFingerprint.has(fingerprint)) { + return { + fingerprint, + title: baseline.primary.title, + status: "persisting" as const, + reasons: ["The same correlated finding fingerprint is still present after remediation."], + }; + } + + const scannerCoverage = afterReranDetectingScanner(after, baseline); + const scopeCoverage = afterScopeCoversFinding(after, baseline); + const reasons = [scannerCoverage.reason, scopeCoverage.reason].filter((value): value is string => Boolean(value)); + if (!scannerCoverage.covered || !scopeCoverage.covered) { + return { + fingerprint, + title: baseline.primary.title, + status: "inconclusive" as const, + reasons, + }; + } + + return { + fingerprint, + title: baseline.primary.title, + status: "fixed" as const, + reasons: ["The finding disappeared after a detecting scanner re-ran over the affected scope."], + }; + }); + + const beforeFingerprints = new Set(before.findings.map((finding) => finding.fingerprint)); + const newFindings = after.findings + .map((finding) => finding.fingerprint) + .filter((fingerprint) => !beforeFingerprints.has(fingerprint)) + .sort(); + + return { + schemaVersion: 1, + generatedAt, + beforeReportId: before.reportId, + afterReportId: after.reportId, + items, + newFindings, + summary: { + fixed: items.filter((item) => item.status === "fixed").length, + persisting: items.filter((item) => item.status === "persisting").length, + inconclusive: items.filter((item) => item.status === "inconclusive").length, + missingBaseline: items.filter((item) => item.status === "missing-baseline").length, + newFindings: newFindings.length, + }, + }; +} + +export async function writeRemediationVerification(path: string, verification: RemediationVerification): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(verification, null, 2)}\n`, "utf8"); +} From 30a0befe54c680a0bf0f650d89028dbaaf1cbd67 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:57:14 -0400 Subject: [PATCH 0119/1132] Test remediation verification coverage rules --- tests/lifecycle.test.mjs | 45 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/tests/lifecycle.test.mjs b/tests/lifecycle.test.mjs index 6e513713..80df77a0 100644 --- a/tests/lifecycle.test.mjs +++ b/tests/lifecycle.test.mjs @@ -6,13 +6,14 @@ import { lifecycleSummary, reconcileLifecycle, setFindingState, + verifyRemediation, } from "../packages/lifecycle/dist/index.js"; -function reportWith(ruleIds) { +function reportWith(ruleIds, options = {}) { return buildReport({ target: { path: "/repo" }, scans: [{ - scanner: "fixture", + scanner: options.scanner ?? "fixture", startedAt: "2026-01-01T00:00:00.000Z", completedAt: "2026-01-01T00:00:01.000Z", target: { path: "/repo" }, @@ -23,10 +24,11 @@ function reportWith(ruleIds) { category: "sast", severity: "high", confidence: 1, - scanner: { name: "fixture", ruleId }, + scanner: { name: options.scanner ?? "fixture", ruleId }, location: { path: `src/${ruleId}.ts`, startLine: 1 }, })), }], + scope: options.scope ?? { mode: "repository" }, }); } @@ -71,3 +73,40 @@ test("false-positive and accepted-risk decisions are not rewritten just because assert.equal(next.records[a].state, "false-positive"); assert.equal(next.records[b].state, "accepted-risk"); }); + +test("remediation verification only calls a missing finding fixed when detecting coverage was repeated", () => { + const before = reportWith(["A"]); + const fingerprint = before.findings[0].fingerprint; + const after = reportWith([]); + const verification = verifyRemediation(before, after, [fingerprint], "2026-01-02T00:00:00.000Z"); + assert.equal(verification.items[0].status, "fixed"); + assert.equal(verification.summary.fixed, 1); +}); + +test("remediation verification is inconclusive when the detecting scanner did not rerun", () => { + const before = reportWith(["A"], { scanner: "fixture" }); + const after = reportWith([], { scanner: "different-scanner" }); + const verification = verifyRemediation(before, after); + assert.equal(verification.items[0].status, "inconclusive"); + assert.match(verification.items[0].reasons.join(" "), /None of the scanner/); +}); + +test("changed-file verification is inconclusive when the affected path was outside the rescan scope", () => { + const before = reportWith(["A"]); + const after = reportWith([], { + scope: { mode: "changed-files", baseRef: "main", changedFiles: ["src/B.ts"] }, + }); + const verification = verifyRemediation(before, after); + assert.equal(verification.items[0].status, "inconclusive"); + assert.match(verification.items[0].reasons.join(" "), /did not scan the finding path/); +}); + +test("remediation verification reports persisting and newly introduced findings", () => { + const before = reportWith(["A"]); + const after = reportWith(["A", "B"]); + const verification = verifyRemediation(before, after); + assert.equal(verification.items[0].status, "persisting"); + assert.equal(verification.summary.persisting, 1); + assert.equal(verification.summary.newFindings, 1); + assert.equal(verification.newFindings.length, 1); +}); From e3e616277f5766410cce9e7ec0625aaee1a21915 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:57:32 -0400 Subject: [PATCH 0120/1132] Add CLI remediation verification helper --- apps/cli/src/lifecycle.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/apps/cli/src/lifecycle.ts b/apps/cli/src/lifecycle.ts index c2e4f0e1..b132a4fe 100644 --- a/apps/cli/src/lifecycle.ts +++ b/apps/cli/src/lifecycle.ts @@ -6,9 +6,12 @@ import { readLifecycleStore, reconcileLifecycle, setFindingState, + verifyRemediation, writeLifecycleStore, + writeRemediationVerification, type FindingLifecycleStore, type LifecycleSummary, + type RemediationVerification, } from "@synsec/lifecycle"; import { readReport, type SynSecReport } from "@synsec/report"; @@ -71,3 +74,35 @@ export async function runTriage(input: { `Lifecycle store: ${storePath}`, ]; } + +export async function runVerification(input: { + beforeReportPath: string; + afterReportPath: string; + fingerprints?: string[]; + outputPath?: string; +}): Promise<{ verification: RemediationVerification; lines: string[]; outputPath?: string }> { + const beforePath = resolve(input.beforeReportPath); + const afterPath = resolve(input.afterReportPath); + const [before, after] = await Promise.all([readReport(beforePath), readReport(afterPath)]); + const verification = verifyRemediation(before, after, input.fingerprints); + const lines = [ + `Verification: ${verification.summary.fixed} fixed, ${verification.summary.persisting} persisting, ${verification.summary.inconclusive} inconclusive, ${verification.summary.newFindings} new finding(s)`, + ]; + + for (const item of verification.items) { + lines.push(`[${item.status.toUpperCase()}] ${item.title ?? item.fingerprint}`); + for (const reason of item.reasons) lines.push(` ${reason}`); + } + if (verification.newFindings.length > 0) { + lines.push("New finding fingerprints:"); + for (const fingerprint of verification.newFindings) lines.push(` ${fingerprint}`); + } + + let outputPath: string | undefined; + if (input.outputPath) { + outputPath = resolve(input.outputPath); + await writeRemediationVerification(outputPath, verification); + lines.push(`Verification JSON: ${outputPath}`); + } + return outputPath ? { verification, lines, outputPath } : { verification, lines }; +} From cd47c21ab5d8a738710696a10ced476f8a88e295 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:58:26 -0400 Subject: [PATCH 0121/1132] Expose deterministic remediation verification CLI --- apps/cli/src/index.ts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index f18a12b6..6484a914 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -32,7 +32,7 @@ import { workflowFindings, type WorkflowDefinition, } from "@synsec/workflows"; -import { reconcileLifecycleFile, runTriage } from "./lifecycle.js"; +import { reconcileLifecycleFile, runTriage, runVerification } from "./lifecycle.js"; const VERSION = "0.2.0"; const args = process.argv.slice(2); @@ -95,6 +95,7 @@ Usage: synsec review [options] synsec triage --list [--store ] synsec triage [--note ] [--store ] + synsec verify [--fingerprint ] [--output ] synsec import-sarif [options] synsec workflows synsec render [--html ] [--sarif ] @@ -131,6 +132,11 @@ Review options: Triage states: new, confirmed, false-positive, accepted-risk, fixed, regressed +Verify options: + --fingerprint Verify only one finding fingerprint. Repeat with comma-separated IDs via --fingerprints. + --fingerprints Verify a specific set of finding fingerprints. + --output Write machine-readable verification JSON. + SARIF import options: --root Repository root represented by the imported findings (default: .). --output SynSec JSON report path (default: .synsec/imported-report.json). @@ -441,6 +447,27 @@ async function triage(): Promise { for (const line of lines) console.log(line); } +async function verify(): Promise { + const beforeArg = args[1]; + const afterArg = args[2]; + if (!beforeArg || beforeArg.startsWith("--") || !afterArg || afterArg.startsWith("--")) { + throw new Error("Usage: synsec verify [--fingerprint ] [--fingerprints ] [--output ]"); + } + const requested = [ + ...(option("--fingerprint") ? [option("--fingerprint") as string] : []), + ...(option("--fingerprints")?.split(",").map((value) => value.trim()).filter(Boolean) ?? []), + ]; + const result = await runVerification({ + beforeReportPath: beforeArg, + afterReportPath: afterArg, + fingerprints: requested.length > 0 ? requested : undefined, + outputPath: option("--output"), + }); + for (const line of result.lines) console.log(line); + if (result.verification.summary.persisting > 0) process.exitCode = 2; + else if (result.verification.summary.inconclusive > 0 || result.verification.summary.missingBaseline > 0) process.exitCode = 3; +} + async function importSarif(): Promise { const inputArg = args[1]; if (!inputArg || inputArg.startsWith("--")) { @@ -518,6 +545,9 @@ async function main(): Promise { case "triage": await triage(); break; + case "verify": + await verify(); + break; case "import-sarif": await importSarif(); break; From cb7faa54cdfc523d001833337d87834a9cd7f9a9 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:58:56 -0400 Subject: [PATCH 0122/1132] Test CLI remediation verification --- tests/cli.test.mjs | 53 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index c4227f55..e5f8286e 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -5,6 +5,7 @@ import { promisify } from "node:util"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { buildReport } from "../packages/report/dist/index.js"; const exec = promisify(execFile); const cli = new URL("../apps/cli/dist/index.js", import.meta.url); @@ -22,9 +23,10 @@ test("CLI lists capability-scoped defensive workflows", async () => { assert.match(stdout, /external network assessment: forbidden/); }); -test("CLI help documents finding lifecycle triage", async () => { +test("CLI help documents finding lifecycle triage and remediation verification", async () => { const { stdout } = await exec(process.execPath, [cli.pathname, "help"]); assert.match(stdout, /synsec triage /); + assert.match(stdout, /synsec verify /); assert.match(stdout, /false-positive/); assert.match(stdout, /accepted-risk/); }); @@ -133,3 +135,52 @@ test("CLI triage persists explicit lifecycle decisions and lists them", async () await rm(root, { recursive: true, force: true }); } }); + +test("CLI verify confirms a remediation only when the detecting scanner reran over repository scope", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-verify-test-")); + try { + const scan = { + scanner: "fixture", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:01.000Z", + target: { path: root }, + diagnostics: [], + findings: [{ + id: "A", + title: "Finding A", + category: "sast", + severity: "high", + confidence: 1, + scanner: { name: "fixture", ruleId: "A" }, + location: { path: "src/A.ts", startLine: 1 }, + }], + }; + const before = buildReport({ target: { path: root }, scans: [scan], scope: { mode: "repository" } }); + const after = buildReport({ + target: { path: root }, + scans: [{ ...scan, findings: [] }], + scope: { mode: "repository" }, + }); + const beforePath = join(root, "before.json"); + const afterPath = join(root, "after.json"); + const outputPath = join(root, "verification.json"); + await writeFile(beforePath, JSON.stringify(before), "utf8"); + await writeFile(afterPath, JSON.stringify(after), "utf8"); + + const result = await exec(process.execPath, [ + cli.pathname, + "verify", + beforePath, + afterPath, + "--output", + outputPath, + ]); + assert.match(result.stdout, /1 fixed/); + assert.match(result.stdout, /\[FIXED\] Finding A/); + const verification = JSON.parse(await readFile(outputPath, "utf8")); + assert.equal(verification.summary.fixed, 1); + assert.equal(verification.summary.inconclusive, 0); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From af94fb61bee6f4f7984a9934eb55555710eddfaf Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:59:33 -0400 Subject: [PATCH 0123/1132] Add Markdown security report writer --- packages/report/src/markdown.ts | 146 ++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 packages/report/src/markdown.ts diff --git a/packages/report/src/markdown.ts b/packages/report/src/markdown.ts new file mode 100644 index 00000000..8c69dcaf --- /dev/null +++ b/packages/report/src/markdown.ts @@ -0,0 +1,146 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { CorrelatedFinding, Finding } from "@synsec/core"; +import type { SynSecReport } from "./index.js"; + +function escapeCell(value: string): string { + return value.replaceAll("|", "\\|").replaceAll("\n", " "); +} + +function location(finding: Finding): string { + if (!finding.location?.path) return "repository"; + const line = finding.location.startLine ? `:${finding.location.startLine}` : ""; + return `${finding.location.path}${line}`; +} + +function identifiers(finding: Finding): string[] { + const ids = finding.identifiers; + if (!ids) return []; + return [...new Set([ + ...(ids.cve ?? []), + ...(ids.cwe ?? []), + ...(ids.ghsa ?? []), + ...(ids.osv ?? []), + ])]; +} + +function findingBaselineState(report: SynSecReport, finding: CorrelatedFinding): string | undefined { + if (!report.baseline) return undefined; + if (report.baseline.new.includes(finding.fingerprint)) return "new"; + if (report.baseline.persisting.includes(finding.fingerprint)) return "persisting"; + return undefined; +} + +function findingSection(report: SynSecReport, group: CorrelatedFinding, index: number): string { + const finding = group.primary; + const ids = identifiers(finding); + const sources = [...new Set(group.sources.map((source) => source.name))].join(", "); + const baseline = findingBaselineState(report, group); + const lines = [ + `### ${index + 1}. [${finding.severity.toUpperCase()}] ${finding.title}`, + "", + `- **Category:** ${finding.category}`, + `- **Confidence:** ${Math.round(finding.confidence * 100)}%`, + `- **Location:** \`${location(finding)}\``, + `- **Sources:** ${sources || "unknown"}`, + `- **Fingerprint:** \`${group.fingerprint}\``, + ]; + if (ids.length > 0) lines.push(`- **Identifiers:** ${ids.join(", ")}`); + if (baseline) lines.push(`- **Baseline:** ${baseline}`); + lines.push(""); + if (finding.description) lines.push(finding.description, ""); + if (finding.remediation) lines.push("**Remediation**", "", finding.remediation, ""); + if (group.duplicates.length > 0) { + lines.push(`Corroborated by ${group.duplicates.length} additional normalized result(s).`, ""); + } + return lines.join("\n"); +} + +export function renderMarkdown(report: SynSecReport): string { + const target = report.target.repositoryUrl ?? report.target.path; + const scope = report.scope?.mode === "changed-files" + ? `changed files since ${report.scope.baseRef ?? "configured base"} (${report.scope.changedFiles?.length ?? 0} files)` + : "repository"; + const sbomPackages = (report.artifacts ?? []) + .filter((artifact) => artifact.type === "sbom") + .reduce((total, artifact) => total + artifact.packageCount, 0); + + const lines = [ + "# SynSec Security Report", + "", + `**Target:** ${target}`, + `**Generated:** ${report.generatedAt}`, + `**Report ID:** \`${report.reportId}\``, + `**Scan scope:** ${scope}`, + `**Security score:** ${report.securityScore}/100`, + "", + "## Summary", + "", + "| Severity | Findings |", + "| --- | ---: |", + `| Critical | ${report.summary.critical} |`, + `| High | ${report.summary.high} |`, + `| Medium | ${report.summary.medium} |`, + `| Low | ${report.summary.low} |`, + `| Info | ${report.summary.info} |`, + `| Unknown | ${report.summary.unknown} |`, + "", + `SynSec correlated **${report.rawFindingCount} raw result(s)** into **${report.findingCount} logical finding(s)**.`, + "", + "## Scanner coverage", + "", + "| Scanner | Findings | Artifacts | Diagnostics |", + "| --- | ---: | ---: | --- |", + ...report.scanners.map((scanner) => + `| ${escapeCell(scanner.scanner)} | ${scanner.findingCount} | ${scanner.artifactCount} | ${escapeCell(scanner.diagnostics.join("; ") || "—")} |`, + ), + "", + ]; + + if (report.repository) { + const languages = Object.entries(report.repository.languages ?? {}) + .sort((a, b) => b[1] - a[1]) + .map(([name, count]) => `${name} (${count})`) + .join(", "); + lines.push( + "## Repository context", + "", + `- **Files inventoried:** ${report.repository.fileCount ?? "unknown"}`, + `- **Languages:** ${languages || "unknown"}`, + `- **Frameworks:** ${(report.repository.frameworks ?? []).join(", ") || "none detected"}`, + ); + if (sbomPackages > 0) lines.push(`- **SBOM packages inventoried:** ${sbomPackages}`); + lines.push(""); + } + + if (report.baseline) { + lines.push( + "## Baseline delta", + "", + `- **New:** ${report.baseline.new.length}`, + `- **Fixed:** ${report.baseline.fixed.length}`, + `- **Persisting:** ${report.baseline.persisting.length}`, + "", + ); + } + + lines.push("## Findings", ""); + if (report.findings.length === 0) { + lines.push("No findings were reported by the scanner engines that successfully ran.", ""); + } else { + report.findings.forEach((finding, index) => lines.push(findingSection(report, finding, index))); + } + + lines.push( + "## Interpretation note", + "", + "This report preserves deterministic scanner evidence and SynSec correlation. A finding should not be treated as proven exploitable solely because it appears here; confirm reachability, deployment context, and relevant mitigations before remediation decisions.", + "", + ); + return `${lines.join("\n").trimEnd()}\n`; +} + +export async function writeMarkdown(path: string, report: SynSecReport): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, renderMarkdown(report), "utf8"); +} From f0823041b89766caa64c06cf36950a0c6e65463f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 04:59:43 -0400 Subject: [PATCH 0124/1132] Export Markdown report writer --- packages/report/package.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/report/package.json b/packages/report/package.json index 7d2c05ae..4c523dad 100644 --- a/packages/report/package.json +++ b/packages/report/package.json @@ -3,7 +3,16 @@ "version": "0.2.0", "private": true, "type": "module", - "exports": "./dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./markdown": { + "types": "./dist/markdown.d.ts", + "import": "./dist/markdown.js" + } + }, "types": "./dist/index.d.ts", "scripts": { "build": "tsc -p tsconfig.json", From 62dbf2355b9041e00ce0acff19f5cd341c6c49fe Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:00:00 -0400 Subject: [PATCH 0125/1132] Add Markdown report configuration --- packages/config/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 89d35e5c..be4973b6 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -16,6 +16,7 @@ export interface ReportConfig { json: string; html: string; sarif: string; + markdown: string; } export interface SynSecConfig { @@ -48,6 +49,7 @@ export const defaultConfig: SynSecConfig = { json: ".synsec/report.json", html: ".synsec/report.html", sarif: ".synsec/report.sarif", + markdown: ".synsec/report.md", }, ai: { enabled: false, @@ -98,6 +100,7 @@ export function parseConfig(value: unknown): SynSecConfig { json: typeof reportsValue?.json === "string" ? reportsValue.json : defaultConfig.reports.json, html: typeof reportsValue?.html === "string" ? reportsValue.html : defaultConfig.reports.html, sarif: typeof reportsValue?.sarif === "string" ? reportsValue.sarif : defaultConfig.reports.sarif, + markdown: typeof reportsValue?.markdown === "string" ? reportsValue.markdown : defaultConfig.reports.markdown, }; const ai: AiConfig = { @@ -145,5 +148,6 @@ export function resolveReportPaths(rootPath: string, config: SynSecConfig): Repo json: resolve(rootPath, config.reports.json), html: resolve(rootPath, config.reports.html), sarif: resolve(rootPath, config.reports.sarif), + markdown: resolve(rootPath, config.reports.markdown), }; } From b978520af2d8939047d2be9753306011ba27639b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:00:12 -0400 Subject: [PATCH 0126/1132] Test Markdown report configuration --- tests/config.test.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/config.test.mjs b/tests/config.test.mjs index 944f31d0..72c4a635 100644 --- a/tests/config.test.mjs +++ b/tests/config.test.mjs @@ -7,6 +7,7 @@ test("default config prefers the maintained scanner set and keeps AI off", () => assert.equal(defaultConfig.ai.sendSourceContext, false); assert.ok(defaultConfig.scanners.includes("betterleaks")); assert.ok(defaultConfig.scanners.includes("opengrep")); + assert.equal(defaultConfig.reports.markdown, ".synsec/report.md"); }); test("parseConfig merges user values with safe defaults", () => { @@ -15,6 +16,7 @@ test("parseConfig merges user values with safe defaults", () => { scanners: ["trivy"], parallelism: 2, failOn: "high", + reports: { markdown: "security.md" }, ai: { enabled: true, sendSourceContext: false, baseUrl: "http://localhost:8080/v1", model: "router/model" }, }); assert.deepEqual(config.scanners, ["trivy"]); @@ -23,4 +25,5 @@ test("parseConfig merges user values with safe defaults", () => { assert.equal(config.ai.enabled, true); assert.equal(config.ai.sendSourceContext, false); assert.equal(config.reports.json, ".synsec/report.json"); + assert.equal(config.reports.markdown, "security.md"); }); From f67433f36b3707ee7d80b81f6233bfa6c088b23f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:00:30 -0400 Subject: [PATCH 0127/1132] Test Markdown report rendering --- tests/report.test.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/report.test.mjs b/tests/report.test.mjs index 899dbc10..c29a23a0 100644 --- a/tests/report.test.mjs +++ b/tests/report.test.mjs @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { buildReport, applyBaseline, renderHtml, toSarif } from "../packages/report/dist/index.js"; +import { renderMarkdown } from "../packages/report/dist/markdown.js"; function scan(ruleId, severity = "high") { return { @@ -17,6 +18,7 @@ function scan(ruleId, severity = "high") { confidence: 0.9, scanner: { name: "fixture", ruleId }, location: { path: "src/app.ts", startLine: 10 }, + remediation: "Use a safer implementation.", }], }; } @@ -43,6 +45,8 @@ test("buildReport preserves changed-file scan scope in JSON and HTML", () => { const html = renderHtml(report); assert.match(html, /2 changed file\(s\)/); assert.match(html, /since main/); + const markdown = renderMarkdown(report); + assert.match(markdown, /changed files since main \(2 files\)/); }); test("buildReport preserves scanner artifacts and renders SBOM inventory", () => { @@ -67,6 +71,7 @@ test("buildReport preserves scanner artifacts and renders SBOM inventory", () => assert.equal(report.artifacts[0].packageCount, 2); assert.equal(report.scanners[0].artifactCount, 1); assert.match(renderHtml(report), /2 package\(s\) inventoried/); + assert.match(renderMarkdown(report), /SBOM packages inventoried:\*\* 2/); }); test("baseline delta identifies new and fixed findings", () => { @@ -78,11 +83,15 @@ test("baseline delta identifies new and fixed findings", () => { assert.equal(compared.baseline.persisting.length, 0); }); -test("SARIF and HTML exports preserve findings without executable report content", () => { +test("SARIF, HTML, and Markdown exports preserve findings", () => { const report = buildReport({ target: { path: "/repo" }, scans: [scan("RULE-1")] }); const sarif = toSarif(report); assert.equal(sarif.version, "2.1.0"); const html = renderHtml(report); assert.match(html, /SynSec repository security/); assert.match(html, /Finding RULE-1/); + const markdown = renderMarkdown(report); + assert.match(markdown, /# SynSec Security Report/); + assert.match(markdown, /\[HIGH\] Finding RULE-1/); + assert.match(markdown, /Use a safer implementation/); }); From 13798fa4f863c3785db3b8494b3700334159fef5 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:01:27 -0400 Subject: [PATCH 0128/1132] Write Markdown reports from CLI --- apps/cli/src/index.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 6484a914..6eb6fd69 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -22,6 +22,7 @@ import { writeSarif, type SynSecReport, } from "@synsec/report"; +import { writeMarkdown } from "@synsec/report/markdown"; import { getFindingContext } from "@synsec/repository"; import { writeRepositoryIndex } from "@synsec/repository/analysis"; import { parseSarifJson } from "@synsec/scanners"; @@ -98,7 +99,7 @@ Usage: synsec verify [--fingerprint ] [--output ] synsec import-sarif [options] synsec workflows - synsec render [--html ] [--sarif ] + synsec render [--html ] [--sarif ] [--markdown ] synsec baseline [destination] synsec version @@ -133,7 +134,7 @@ Triage states: new, confirmed, false-positive, accepted-risk, fixed, regressed Verify options: - --fingerprint Verify only one finding fingerprint. Repeat with comma-separated IDs via --fingerprints. + --fingerprint Verify one finding fingerprint. --fingerprints Verify a specific set of finding fingerprints. --output Write machine-readable verification JSON. @@ -339,6 +340,7 @@ async function scan(): Promise { writeReport(paths.json, outcome.report), writeHtml(paths.html, outcome.report), writeSarif(paths.sarif, outcome.report), + writeMarkdown(paths.markdown, outcome.report), writeRepositoryIndex(repositoryIndexPath, outcome.repositoryIndex), ]); } @@ -401,6 +403,7 @@ async function scan(): Promise { console.log(`JSON: ${paths.json}`); console.log(`HTML: ${paths.html}`); console.log(`SARIF: ${paths.sarif}`); + console.log(`MARKDOWN: ${paths.markdown}`); console.log(`INDEX: ${repositoryIndexPath}`); console.log(`LIFECYCLE: ${lifecycle.path}`); } @@ -505,17 +508,20 @@ async function importSarif(): Promise { async function render(): Promise { const reportArg = args[1]; - if (!reportArg || reportArg.startsWith("--")) throw new Error("Usage: synsec render [--html ] [--sarif ]"); + if (!reportArg || reportArg.startsWith("--")) throw new Error("Usage: synsec render [--html ] [--sarif ] [--markdown ]"); const reportPath = resolve(reportArg); const report = await readReport(reportPath); const htmlPath = resolve(option("--html") ?? reportPath.replace(/\.json$/i, ".html")); const sarifPath = resolve(option("--sarif") ?? reportPath.replace(/\.json$/i, ".sarif")); + const markdownPath = resolve(option("--markdown") ?? reportPath.replace(/\.json$/i, ".md")); await Promise.all([ mkdir(dirname(htmlPath), { recursive: true }).then(() => writeFile(htmlPath, renderHtml(report), "utf8")), mkdir(dirname(sarifPath), { recursive: true }).then(() => writeFile(sarifPath, `${JSON.stringify(toSarif(report), null, 2)}\n`, "utf8")), + writeMarkdown(markdownPath, report), ]); - console.log(`HTML: ${htmlPath}`); - console.log(`SARIF: ${sarifPath}`); + console.log(`HTML: ${htmlPath}`); + console.log(`SARIF: ${sarifPath}`); + console.log(`MARKDOWN: ${markdownPath}`); } async function baseline(): Promise { From 438c14a7eea8bfdbb082ac69a3d06a0100e25fd1 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:02:18 -0400 Subject: [PATCH 0129/1132] Bound scanner process output and surface timeouts --- packages/scanner-sdk/src/index.ts | 66 ++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/packages/scanner-sdk/src/index.ts b/packages/scanner-sdk/src/index.ts index 766c1c61..b4542744 100644 --- a/packages/scanner-sdk/src/index.ts +++ b/packages/scanner-sdk/src/index.ts @@ -43,13 +43,23 @@ export interface ProcessOptions { timeoutMs?: number; signal?: AbortSignal; env?: NodeJS.ProcessEnv; + /** Maximum bytes retained from each output stream. Defaults to 64 MiB per stream. */ + maxOutputBytes?: number; } +const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024 * 1024; + export async function runProcess( command: string, args: string[], options: ProcessOptions = {}, ): Promise { + if (options.signal?.aborted) throw new Error(`Process aborted before start: ${command}`); + const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + if (!Number.isFinite(maxOutputBytes) || maxOutputBytes <= 0) { + throw new Error("maxOutputBytes must be a positive finite number."); + } + return await new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: options.cwd, @@ -61,7 +71,12 @@ export async function runProcess( let stdout = ""; let stderr = ""; + let stdoutBytes = 0; + let stderrBytes = 0; let settled = false; + let timedOut = false; + let aborted = false; + let overflowError: Error | undefined; const finish = (callback: () => void): void => { if (settled) return; @@ -69,38 +84,77 @@ export async function runProcess( callback(); }; + const stopForOverflow = (stream: "stdout" | "stderr", bytes: number): void => { + if (overflowError) return; + overflowError = new Error( + `Process ${command} exceeded the ${maxOutputBytes} byte ${stream} limit (${bytes} bytes observed).`, + ); + child.kill("SIGTERM"); + }; + child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => { + stdoutBytes += Buffer.byteLength(chunk); + if (stdoutBytes > maxOutputBytes) { + stopForOverflow("stdout", stdoutBytes); + return; + } stdout += chunk; }); child.stderr.on("data", (chunk: string) => { + stderrBytes += Buffer.byteLength(chunk); + if (stderrBytes > maxOutputBytes) { + stopForOverflow("stderr", stderrBytes); + return; + } stderr += chunk; }); const timeout = options.timeoutMs - ? setTimeout(() => child.kill("SIGTERM"), options.timeoutMs) + ? setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + }, options.timeoutMs) : undefined; const onAbort = (): void => { + aborted = true; child.kill("SIGTERM"); }; options.signal?.addEventListener("abort", onAbort, { once: true }); + const cleanup = (): void => { + if (timeout) clearTimeout(timeout); + options.signal?.removeEventListener("abort", onAbort); + }; + child.once("error", (error) => { + cleanup(); finish(() => reject(error)); }); child.once("close", (code) => { - if (timeout) clearTimeout(timeout); - options.signal?.removeEventListener("abort", onAbort); - finish(() => + cleanup(); + finish(() => { + if (overflowError) { + reject(overflowError); + return; + } + if (timedOut) { + reject(new Error(`Process timed out after ${options.timeoutMs} ms: ${command}`)); + return; + } + if (aborted) { + reject(new Error(`Process aborted: ${command}`)); + return; + } resolve({ exitCode: code ?? -1, stdout, stderr, - }), - ); + }); + }); }); }); } From c6fd64979eb2959b2007f35ff91d40e8fd4c7d1c Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:02:28 -0400 Subject: [PATCH 0130/1132] Test bounded scanner process execution --- tests/scanner-sdk.test.mjs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/scanner-sdk.test.mjs diff --git a/tests/scanner-sdk.test.mjs b/tests/scanner-sdk.test.mjs new file mode 100644 index 00000000..887b7327 --- /dev/null +++ b/tests/scanner-sdk.test.mjs @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { runProcess } from "../packages/scanner-sdk/dist/index.js"; + +test("runProcess captures bounded stdout and stderr", async () => { + const result = await runProcess(process.execPath, [ + "-e", + "process.stdout.write('hello'); process.stderr.write('note');", + ], { maxOutputBytes: 1024 }); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout, "hello"); + assert.equal(result.stderr, "note"); +}); + +test("runProcess rejects scanner output that exceeds its memory bound", async () => { + await assert.rejects( + runProcess(process.execPath, [ + "-e", + "process.stdout.write('x'.repeat(4096)); setTimeout(() => {}, 1000);", + ], { maxOutputBytes: 128, timeoutMs: 5_000 }), + /exceeded the 128 byte stdout limit/, + ); +}); + +test("runProcess surfaces timeouts instead of returning an ambiguous exit code", async () => { + await assert.rejects( + runProcess(process.execPath, ["-e", "setTimeout(() => {}, 5000);"], { timeoutMs: 50 }), + /timed out after 50 ms/, + ); +}); From 2354b35f6d109197fa1615083a5022a023ce3b99 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:03:20 -0400 Subject: [PATCH 0131/1132] Harden AI boundary for secret findings --- packages/ai/src/index.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 925fb378..857f1f90 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -100,6 +100,28 @@ function normalizeReview(value: unknown, finding: Finding, model: string): AiFin return review; } +function safeMetadataForModel(finding: Finding): Record | undefined { + if (!finding.metadata) return undefined; + if (finding.category !== "secret") return finding.metadata; + + // Keep the model boundary resilient even if a future secret-scanner adapter + // accidentally adds richer metadata. Only a deliberately narrow allowlist + // can cross the boundary for secret findings. + const allowed = new Set([ + "validationStatus", + "validationReason", + "commit", + "author", + "date", + "tags", + ]); + const safe: Record = {}; + for (const [key, value] of Object.entries(finding.metadata)) { + if (allowed.has(key)) safe[key] = value; + } + return safe; +} + function buildPrompt(finding: Finding, context?: FindingContext, reviewInstructions?: string): string { const safeFinding = { title: finding.title, @@ -111,7 +133,7 @@ function buildPrompt(finding: Finding, context?: FindingContext, reviewInstructi location: finding.location, identifiers: finding.identifiers, remediation: finding.remediation, - metadata: finding.metadata, + metadata: safeMetadataForModel(finding), }; const contextBlock = context @@ -130,6 +152,10 @@ export async function reviewFinding( context?: FindingContext, reviewInstructions?: string, ): Promise { + if (finding.category === "secret" && context) { + throw new Error("Source context is prohibited for secret findings at the AI provider boundary."); + } + const baseUrl = config.baseUrl.replace(/\/$/, ""); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.timeoutMs ?? 90_000); From 4e16c7a56605d621bc7fd5f27ced6c2a54b2fbf1 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:03:39 -0400 Subject: [PATCH 0132/1132] Test AI secret-context hard boundary --- tests/ai.test.mjs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/ai.test.mjs b/tests/ai.test.mjs index 8031e9d3..3742cbbb 100644 --- a/tests/ai.test.mjs +++ b/tests/ai.test.mjs @@ -63,3 +63,31 @@ test("AI review uses the OpenAI-compatible boundary and normalizes the seven-que await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } }); + +test("AI provider boundary refuses source excerpts for secret findings", async () => { + await assert.rejects( + reviewFinding({ + id: "secret-fixture", + title: "Potential token", + category: "secret", + severity: "high", + confidence: 0.99, + scanner: { name: "betterleaks", ruleId: "token" }, + location: { path: "src/config.ts", startLine: 4 }, + metadata: { + validationStatus: "unknown", + accidentalSecretField: "must-not-cross-provider-boundary", + }, + }, { + baseUrl: "http://127.0.0.1:1/v1", + model: "fixture-model", + }, { + path: "src/config.ts", + startLine: 1, + endLine: 5, + excerpt: "SECRET_SHOULD_NEVER_BE_SENT", + truncated: true, + }), + /Source context is prohibited for secret findings/, + ); +}); From 9aaa2eed195759b75901b3f74e0718513129859f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:05:34 -0400 Subject: [PATCH 0133/1132] Render SBOM summary without repository metadata --- packages/report/src/markdown.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/report/src/markdown.ts b/packages/report/src/markdown.ts index 8c69dcaf..a396c8f5 100644 --- a/packages/report/src/markdown.ts +++ b/packages/report/src/markdown.ts @@ -108,9 +108,17 @@ export function renderMarkdown(report: SynSecReport): string { `- **Files inventoried:** ${report.repository.fileCount ?? "unknown"}`, `- **Languages:** ${languages || "unknown"}`, `- **Frameworks:** ${(report.repository.frameworks ?? []).join(", ") || "none detected"}`, + "", + ); + } + + if (sbomPackages > 0) { + lines.push( + "## SBOM", + "", + `- **SBOM packages inventoried:** ${sbomPackages}`, + "", ); - if (sbomPackages > 0) lines.push(`- **SBOM packages inventoried:** ${sbomPackages}`); - lines.push(""); } if (report.baseline) { From bc35aff81c66c402fc2161baa43f569b3b287cc6 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:06:51 -0400 Subject: [PATCH 0134/1132] Add deterministic finding security context --- packages/repository/src/analysis.ts | 90 +++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/packages/repository/src/analysis.ts b/packages/repository/src/analysis.ts index d94cc571..01fddcb9 100644 --- a/packages/repository/src/analysis.ts +++ b/packages/repository/src/analysis.ts @@ -57,6 +57,31 @@ export interface RouteSecurityContext { nearbySinks: SinkSignal[]; } +export interface NearbyRouteSignal { + line: number; + distance: number; + method: string; + route: string; + frameworkHint?: string; +} + +export interface NearbySecuritySignal { + line: number; + distance: number; + kind: AuthSignal["kind"] | SinkSignal["kind"]; +} + +export interface FindingRepositoryContext { + path: string; + line?: number; + radius: number; + nearbyRoutes: NearbyRouteSignal[]; + nearbyAuthSignals: NearbySecuritySignal[]; + nearbySinks: NearbySecuritySignal[]; + /** These are lexical proximity signals, not proof of data flow or reachability. */ + interpretation: "proximity-signals-only"; +} + const analyzableExtensions = new Set([ ".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx", @@ -254,6 +279,71 @@ export function findDependencyUsage(index: RepositoryIndex, packageName: string, }; } +function normalizeIndexPath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\//, "").toLowerCase(); +} + +function distanceFrom(line: number | undefined, signalLine: number): number { + return line === undefined ? 0 : Math.abs(signalLine - line); +} + +export function findingRepositoryContext( + index: RepositoryIndex, + path: string, + line?: number, + radius = 40, + maxPerKind = 5, +): FindingRepositoryContext { + const normalizedPath = normalizeIndexPath(path); + const boundedRadius = Math.max(0, radius); + const limit = Math.max(1, maxPerKind); + const sameFile = (signalPath: string): boolean => normalizeIndexPath(signalPath) === normalizedPath; + const nearby = (signalLine: number): boolean => line === undefined || distanceFrom(line, signalLine) <= boundedRadius; + + const nearbyRoutes = index.routes + .filter((signal) => sameFile(signal.path) && nearby(signal.line)) + .map((signal): NearbyRouteSignal => ({ + line: signal.line, + distance: distanceFrom(line, signal.line), + method: signal.method, + route: signal.route, + ...(signal.frameworkHint ? { frameworkHint: signal.frameworkHint } : {}), + })) + .sort((a, b) => a.distance - b.distance || a.line - b.line) + .slice(0, limit); + + const nearbyAuthSignals = index.authSignals + .filter((signal) => sameFile(signal.path) && nearby(signal.line)) + .map((signal): NearbySecuritySignal => ({ + line: signal.line, + distance: distanceFrom(line, signal.line), + kind: signal.kind, + })) + .sort((a, b) => a.distance - b.distance || a.line - b.line) + .slice(0, limit); + + const nearbySinks = index.sinks + .filter((signal) => sameFile(signal.path) && nearby(signal.line)) + .map((signal): NearbySecuritySignal => ({ + line: signal.line, + distance: distanceFrom(line, signal.line), + kind: signal.kind, + })) + .sort((a, b) => a.distance - b.distance || a.line - b.line) + .slice(0, limit); + + const context: FindingRepositoryContext = { + path, + radius: boundedRadius, + nearbyRoutes, + nearbyAuthSignals, + nearbySinks, + interpretation: "proximity-signals-only", + }; + if (line !== undefined) context.line = line; + return context; +} + export function routeSecurityContext(index: RepositoryIndex, route: RouteSignal, radius = 30): RouteSecurityContext { const nearby = (line: number): boolean => Math.abs(line - route.line) <= Math.max(0, radius); return { From 3f94751803e950dfd2163ce383702e3de37dd758 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:07:24 -0400 Subject: [PATCH 0135/1132] Enrich findings with repository security context --- packages/engine/src/index.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 5e6c7b70..6dbb39e2 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -6,6 +6,7 @@ import { inventoryRepository } from "@synsec/repository"; import { buildRepositoryIndex, findDependencyUsage, + findingRepositoryContext, packageNameFromPurl, type RepositoryIndex, } from "@synsec/repository/analysis"; @@ -171,6 +172,29 @@ function enrichDependencyUsage(scans: readonly ScanResult[], index: RepositoryIn })); } +function enrichRepositorySecurityContext(scans: readonly ScanResult[], index: RepositoryIndex): ScanResult[] { + return scans.map((scan) => ({ + ...scan, + findings: scan.findings.map((finding) => { + // Secret findings intentionally stay on the narrowest metadata boundary. + if (finding.category === "secret" || !finding.location?.path) return finding; + const context = findingRepositoryContext(index, finding.location.path, finding.location.startLine); + if ( + context.nearbyRoutes.length === 0 && + context.nearbyAuthSignals.length === 0 && + context.nearbySinks.length === 0 + ) return finding; + return { + ...finding, + metadata: { + ...(finding.metadata ?? {}), + repositoryContext: context, + }, + }; + }), + })); +} + export async function scannerStatuses(config: SynSecConfig): Promise { const selectedIds = new Set(config.scanners); const scanners = builtInScanners(); @@ -275,7 +299,8 @@ export async function runScanEngine(input: { const repositoryIndex = await buildRepositoryIndex(root, inventory.files); const changedScope = input.changedOnly ? await discoverChangedFiles(root, input.changedBase) : undefined; const result = await runSelectedScanners(target, input.config, statuses, changedScope?.files); - const enrichedScans = enrichDependencyUsage(result.scans, repositoryIndex); + const dependencyEnriched = enrichDependencyUsage(result.scans, repositoryIndex); + const enrichedScans = enrichRepositorySecurityContext(dependencyEnriched, repositoryIndex); const scans = changedScope ? scopeScansToChangedFiles(enrichedScans, root, changedScope.files) : enrichedScans; const failures = result.failures; if (scans.length === 0) { From 46bbd9857c4268c2a4d9d58bf3cf3f01fc464d4e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:07:42 -0400 Subject: [PATCH 0136/1132] Test finding-level repository context signals --- tests/repository-index.test.mjs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/repository-index.test.mjs b/tests/repository-index.test.mjs index 258dcd83..18df65c6 100644 --- a/tests/repository-index.test.mjs +++ b/tests/repository-index.test.mjs @@ -6,6 +6,7 @@ import { join } from "node:path"; import { buildRepositoryIndex, findDependencyUsage, + findingRepositoryContext, packageNameFromPurl, readRepositoryIndex, routeSecurityContext, @@ -50,6 +51,14 @@ execFile("echo", ["fixture"]); assert.ok(context.nearbyAuthSignals.some((signal) => signal.kind === "authentication")); assert.ok(context.nearbySinks.some((sink) => sink.kind === "database")); + const findingContext = findingRepositoryContext(index, "./src/app.ts", 7, 5); + assert.equal(findingContext.interpretation, "proximity-signals-only"); + assert.ok(findingContext.nearbyRoutes.some((signal) => signal.route === "/users/:id")); + assert.ok(findingContext.nearbyAuthSignals.some((signal) => signal.kind === "authentication")); + assert.ok(findingContext.nearbySinks.some((signal) => signal.kind === "database")); + assert.equal("evidence" in findingContext.nearbyAuthSignals[0], false); + assert.equal("evidence" in findingContext.nearbySinks[0], false); + const output = join(root, ".synsec", "repository-index.json"); await writeRepositoryIndex(output, index); const reloaded = await readRepositoryIndex(output); From 6d6f86e49816446f1c86e70a924b2f68e9f9a116 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:08:11 -0400 Subject: [PATCH 0137/1132] Add workflow-aware AI model routing policy --- packages/config/src/index.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index be4973b6..c9199013 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -8,7 +8,10 @@ export interface AiConfig { enabled: boolean; provider: "openai-compatible"; baseUrl?: string; + /** Default model when no workflow-specific route is configured. */ model?: string; + /** Optional model route keyed by workflow id, e.g. dependency-review. */ + workflowModels?: Record; sendSourceContext: boolean; } @@ -69,6 +72,16 @@ function stringArray(value: unknown): string[] | undefined { return value; } +function stringMap(value: unknown): Record | undefined { + const record = asRecord(value); + if (!record) return undefined; + const entries = Object.entries(record) + .filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0) + .map(([key, model]) => [key.trim(), model.trim()] as const) + .filter(([key]) => key.length > 0); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + function severity(value: unknown): SynSecConfig["failOn"] | undefined { if ( value === "critical" || @@ -113,6 +126,8 @@ export function parseConfig(value: unknown): SynSecConfig { }; if (typeof aiValue?.baseUrl === "string") ai.baseUrl = aiValue.baseUrl; if (typeof aiValue?.model === "string") ai.model = aiValue.model; + const workflowModels = stringMap(aiValue?.workflowModels); + if (workflowModels) ai.workflowModels = workflowModels; const config: SynSecConfig = { schemaVersion: 1, @@ -127,6 +142,22 @@ export function parseConfig(value: unknown): SynSecConfig { return config; } +export function resolveAiModel( + config: AiConfig, + options: { workflowId?: string; overrideModel?: string; environmentModel?: string } = {}, +): string | undefined { + const override = options.overrideModel?.trim(); + if (override) return override; + if (options.workflowId) { + const routed = config.workflowModels?.[options.workflowId]?.trim(); + if (routed) return routed; + } + const configured = config.model?.trim(); + if (configured) return configured; + const environment = options.environmentModel?.trim(); + return environment || undefined; +} + export async function findConfig(startPath: string): Promise { const candidate = join(resolve(startPath), SYNSEC_CONFIG_FILENAME); return await access(candidate).then(() => candidate).catch(() => undefined); From cdf3295a22cdfa19a1becb09663c1f72cbd6f34a Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:08:28 -0400 Subject: [PATCH 0138/1132] Test workflow-aware model routing policy --- tests/config.test.mjs | 44 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/tests/config.test.mjs b/tests/config.test.mjs index 72c4a635..1aaee425 100644 --- a/tests/config.test.mjs +++ b/tests/config.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { defaultConfig, parseConfig } from "../packages/config/dist/index.js"; +import { defaultConfig, parseConfig, resolveAiModel } from "../packages/config/dist/index.js"; test("default config prefers the maintained scanner set and keeps AI off", () => { assert.equal(defaultConfig.ai.enabled, false); @@ -17,7 +17,17 @@ test("parseConfig merges user values with safe defaults", () => { parallelism: 2, failOn: "high", reports: { markdown: "security.md" }, - ai: { enabled: true, sendSourceContext: false, baseUrl: "http://localhost:8080/v1", model: "router/model" }, + ai: { + enabled: true, + sendSourceContext: false, + baseUrl: "http://localhost:8080/v1", + model: "router/default", + workflowModels: { + "dependency-review": "router/dependency", + "secrets-review": "router/secrets", + ignored: 42, + }, + }, }); assert.deepEqual(config.scanners, ["trivy"]); assert.equal(config.parallelism, 2); @@ -26,4 +36,34 @@ test("parseConfig merges user values with safe defaults", () => { assert.equal(config.ai.sendSourceContext, false); assert.equal(config.reports.json, ".synsec/report.json"); assert.equal(config.reports.markdown, "security.md"); + assert.equal(config.ai.workflowModels["dependency-review"], "router/dependency"); + assert.equal(config.ai.workflowModels.ignored, undefined); +}); + +test("AI model routing prefers explicit override, then workflow route, then configured and environment defaults", () => { + const config = parseConfig({ + ai: { + enabled: true, + model: "router/default", + workflowModels: { "dependency-review": "router/dependency" }, + }, + }).ai; + + assert.equal(resolveAiModel(config, { + workflowId: "dependency-review", + overrideModel: "router/forced", + environmentModel: "router/env", + }), "router/forced"); + assert.equal(resolveAiModel(config, { + workflowId: "dependency-review", + environmentModel: "router/env", + }), "router/dependency"); + assert.equal(resolveAiModel(config, { + workflowId: "repository-review", + environmentModel: "router/env", + }), "router/default"); + assert.equal(resolveAiModel({ ...defaultConfig.ai }, { + workflowId: "repository-review", + environmentModel: "router/env", + }), "router/env"); }); From f61949ea8c1c437e3549ef63c910ffb673a97594 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:09:49 -0400 Subject: [PATCH 0139/1132] Make lifecycle reconciliation scan-scope aware --- packages/lifecycle/src/index.ts | 48 ++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/packages/lifecycle/src/index.ts b/packages/lifecycle/src/index.ts index 51a13aae..b9d4b0b0 100644 --- a/packages/lifecycle/src/index.ts +++ b/packages/lifecycle/src/index.ts @@ -17,6 +17,8 @@ export interface FindingLifecycleRecord { updatedAt: string; note?: string; reportId?: string; + /** Last source path observed for scope-aware incremental reconciliation. */ + lastSeenPath?: string; } export interface FindingLifecycleStore { @@ -102,13 +104,17 @@ export function setFindingState( schemaVersion: 1, records: { ...store.records }, }; + const previous = store.records[fingerprint]; const record: FindingLifecycleRecord = { fingerprint, state, updatedAt: options.updatedAt ?? new Date().toISOString(), }; - if (options.note?.trim()) record.note = options.note.trim(); - if (options.reportId) record.reportId = options.reportId; + const note = options.note?.trim() || previous?.note; + if (note) record.note = note; + const reportId = options.reportId ?? previous?.reportId; + if (reportId) record.reportId = reportId; + if (previous?.lastSeenPath) record.lastSeenPath = previous.lastSeenPath; updated.records[fingerprint] = record; return updated; } @@ -125,28 +131,50 @@ function autoTransition(previous: FindingLifecycleRecord | undefined, present: b return previous.state; } +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\//, "").toLowerCase(); +} + +function reportCanConcludeAbsence(report: SynSecReport, previous: FindingLifecycleRecord): boolean { + if (report.scope?.mode === "repository") return true; + if (report.scope?.mode !== "changed-files" || !previous.lastSeenPath) return false; + const changed = new Set((report.scope.changedFiles ?? []).map(normalizePath)); + return changed.has(normalizePath(previous.lastSeenPath)); +} + export function reconcileLifecycle( report: SynSecReport, previous: FindingLifecycleStore, updatedAt = new Date().toISOString(), ): FindingLifecycleStore { - const currentFingerprints = new Set(report.findings.map((finding) => finding.fingerprint)); - const all = new Set([...Object.keys(previous.records), ...currentFingerprints]); + const currentByFingerprint = new Map(report.findings.map((finding) => [finding.fingerprint, finding])); + const all = new Set([...Object.keys(previous.records), ...currentByFingerprint.keys()]); const next: FindingLifecycleStore = { schemaVersion: 1, records: {} }; for (const fingerprint of all) { const prior = previous.records[fingerprint]; - const present = currentFingerprints.has(fingerprint); - const state = autoTransition(prior, present); + const current = currentByFingerprint.get(fingerprint); + const present = Boolean(current); + const absenceCovered = prior ? reportCanConcludeAbsence(report, prior) : false; + const state = present + ? autoTransition(prior, true) + : absenceCovered + ? autoTransition(prior, false) + : prior?.state; if (!state) continue; + const stateChanged = prior?.state !== state; const record: FindingLifecycleRecord = { fingerprint, state, - updatedAt: prior?.state === state ? prior.updatedAt : updatedAt, - reportId: report.reportId, + updatedAt: stateChanged ? updatedAt : (prior?.updatedAt ?? updatedAt), }; + + if (present || absenceCovered) record.reportId = report.reportId; + else if (prior?.reportId) record.reportId = prior.reportId; if (prior?.note) record.note = prior.note; + const lastSeenPath = current?.primary.location?.path ?? prior?.lastSeenPath; + if (lastSeenPath) record.lastSeenPath = lastSeenPath; next.records[fingerprint] = record; } @@ -183,10 +211,6 @@ export function currentLifecycleRecords( .sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)); } -function normalizePath(value: string): string { - return value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\//, "").toLowerCase(); -} - function afterScopeCoversFinding(after: SynSecReport, finding: CorrelatedFinding): { covered: boolean; reason?: string } { if (after.scope?.mode === "repository") return { covered: true }; if (after.scope?.mode !== "changed-files") { From 79e0c0946cfa6aca63ecd98f57b7d141df5a287f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:10:15 -0400 Subject: [PATCH 0140/1132] Test scope-aware lifecycle reconciliation --- tests/lifecycle.test.mjs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/lifecycle.test.mjs b/tests/lifecycle.test.mjs index 80df77a0..1aa640bf 100644 --- a/tests/lifecycle.test.mjs +++ b/tests/lifecycle.test.mjs @@ -37,12 +37,14 @@ test("lifecycle creates new findings and preserves explicit triage state", () => let store = reconcileLifecycle(report, emptyLifecycleStore(), "2026-01-01T00:00:00.000Z"); const fingerprint = report.findings[0].fingerprint; assert.equal(store.records[fingerprint].state, "new"); + assert.equal(store.records[fingerprint].lastSeenPath, "src/A.ts"); store = setFindingState(store, fingerprint, "confirmed", { note: "Reviewed by maintainer", reportId: report.reportId, updatedAt: "2026-01-02T00:00:00.000Z", }); + assert.equal(store.records[fingerprint].lastSeenPath, "src/A.ts"); const next = reconcileLifecycle(report, store, "2026-01-03T00:00:00.000Z"); assert.equal(next.records[fingerprint].state, "confirmed"); assert.equal(next.records[fingerprint].note, "Reviewed by maintainer"); @@ -62,6 +64,22 @@ test("lifecycle marks disappeared confirmed findings fixed and returning finding assert.equal(lifecycleSummary(regressed).regressed, 1); }); +test("changed-file scans do not mark out-of-scope findings fixed", () => { + const initial = reportWith(["A", "B"]); + const [a, b] = initial.findings.map((finding) => finding.fingerprint); + let store = reconcileLifecycle(initial, emptyLifecycleStore(), "2026-01-01T00:00:00.000Z"); + store = setFindingState(store, a, "confirmed", { updatedAt: "2026-01-02T00:00:00.000Z" }); + store = setFindingState(store, b, "confirmed", { updatedAt: "2026-01-02T00:00:00.000Z" }); + + const incremental = reportWith([], { + scope: { mode: "changed-files", baseRef: "main", changedFiles: ["src/A.ts"] }, + }); + const next = reconcileLifecycle(incremental, store, "2026-01-03T00:00:00.000Z"); + assert.equal(next.records[a].state, "fixed"); + assert.equal(next.records[b].state, "confirmed"); + assert.equal(next.records[b].reportId, store.records[b].reportId); +}); + test("false-positive and accepted-risk decisions are not rewritten just because a later scan omits the finding", () => { const report = reportWith(["A", "B"]); const [a, b] = report.findings.map((finding) => finding.fingerprint); From 8f62a342197d0506bfb19f8d487a4eaf1b6bfc2f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:11:07 -0400 Subject: [PATCH 0141/1132] Escalate stuck scanner process termination --- packages/scanner-sdk/src/index.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/scanner-sdk/src/index.ts b/packages/scanner-sdk/src/index.ts index b4542744..30b63112 100644 --- a/packages/scanner-sdk/src/index.ts +++ b/packages/scanner-sdk/src/index.ts @@ -45,9 +45,12 @@ export interface ProcessOptions { env?: NodeJS.ProcessEnv; /** Maximum bytes retained from each output stream. Defaults to 64 MiB per stream. */ maxOutputBytes?: number; + /** Grace period between SIGTERM and SIGKILL. Defaults to 2 seconds. */ + killGraceMs?: number; } const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024 * 1024; +const DEFAULT_KILL_GRACE_MS = 2_000; export async function runProcess( command: string, @@ -59,6 +62,10 @@ export async function runProcess( if (!Number.isFinite(maxOutputBytes) || maxOutputBytes <= 0) { throw new Error("maxOutputBytes must be a positive finite number."); } + const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS; + if (!Number.isFinite(killGraceMs) || killGraceMs < 0) { + throw new Error("killGraceMs must be a non-negative finite number."); + } return await new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -77,6 +84,7 @@ export async function runProcess( let timedOut = false; let aborted = false; let overflowError: Error | undefined; + let killEscalation: NodeJS.Timeout | undefined; const finish = (callback: () => void): void => { if (settled) return; @@ -84,12 +92,22 @@ export async function runProcess( callback(); }; + const terminate = (): void => { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill("SIGTERM"); + if (!killEscalation) { + killEscalation = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }, killGraceMs); + } + }; + const stopForOverflow = (stream: "stdout" | "stderr", bytes: number): void => { if (overflowError) return; overflowError = new Error( `Process ${command} exceeded the ${maxOutputBytes} byte ${stream} limit (${bytes} bytes observed).`, ); - child.kill("SIGTERM"); + terminate(); }; child.stdout.setEncoding("utf8"); @@ -114,18 +132,19 @@ export async function runProcess( const timeout = options.timeoutMs ? setTimeout(() => { timedOut = true; - child.kill("SIGTERM"); + terminate(); }, options.timeoutMs) : undefined; const onAbort = (): void => { aborted = true; - child.kill("SIGTERM"); + terminate(); }; options.signal?.addEventListener("abort", onAbort, { once: true }); const cleanup = (): void => { if (timeout) clearTimeout(timeout); + if (killEscalation) clearTimeout(killEscalation); options.signal?.removeEventListener("abort", onAbort); }; From 4ac01968c25c24226bb695bdbfe8fbdbce3508ce Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:12:15 -0400 Subject: [PATCH 0142/1132] Test repository context enrichment end to end --- tests/integration.test.mjs | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/integration.test.mjs b/tests/integration.test.mjs index 8516d398..958c8e68 100644 --- a/tests/integration.test.mjs +++ b/tests/integration.test.mjs @@ -58,3 +58,49 @@ JSON await rm(bin, { recursive: true, force: true }); } }); + +test("scan engine adds bounded proximity signals to located non-secret findings", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-context-repo-")); + const bin = await mkdtemp(join(tmpdir(), "synsec-context-bin-")); + const originalPath = process.env.PATH ?? ""; + + try { + await mkdir(join(root, "src")); + await writeFile(join(root, "src", "app.js"), `import express from "express"; +const app = express(); +function requireAuth(req, res, next) { return next(); } +app.get("/users/:id", requireAuth, async (req, res) => { + const rows = await db.query("select * from users where id = $1", [req.params.id]); + res.json(rows); +}); +`); + + const opengrep = join(bin, "opengrep"); + await writeFile(opengrep, `#!/bin/sh +if [ "$1" = "--version" ]; then + echo "opengrep 99.0.0-fixture" + exit 0 +fi +cat <<'JSON' +{"results":[{"check_id":"fixture.sql","path":"src/app.js","start":{"line":5,"col":3},"end":{"line":5,"col":40},"extra":{"message":"Fixture query finding","severity":"ERROR","metadata":{"cwe":["CWE-89"]}}}]} +JSON +`); + await chmod(opengrep, 0o755); + process.env.PATH = `${bin}${delimiter}${originalPath}`; + + const config = structuredClone(defaultConfig); + config.scanners = ["opengrep"]; + config.parallelism = 1; + const outcome = await runScanEngine({ rootPath: root, config, toolVersion: "test" }); + const context = outcome.report.findings[0].primary.metadata.repositoryContext; + assert.equal(context.interpretation, "proximity-signals-only"); + assert.ok(context.nearbyRoutes.some((signal) => signal.route === "/users/:id")); + assert.ok(context.nearbyAuthSignals.some((signal) => signal.kind === "authentication")); + assert.ok(context.nearbySinks.some((signal) => signal.kind === "database")); + assert.equal("evidence" in context.nearbySinks[0], false); + } finally { + process.env.PATH = originalPath; + await rm(root, { recursive: true, force: true }); + await rm(bin, { recursive: true, force: true }); + } +}); From fffcbb7b01e5ae5e71797af0a799fb81e4eb5178 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:13:00 -0400 Subject: [PATCH 0143/1132] Sanitize scanner-reported repository paths --- packages/scanners/src/utils.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/scanners/src/utils.ts b/packages/scanners/src/utils.ts index 0a0fa380..f3b9fbda 100644 --- a/packages/scanners/src/utils.ts +++ b/packages/scanners/src/utils.ts @@ -5,7 +5,9 @@ import type { ScannerAvailability } from "@synsec/scanner-sdk"; export type UnknownRecord = Record; export function asRecord(value: unknown): UnknownRecord | undefined { - return typeof value === "object" && value !== null ? (value as UnknownRecord) : undefined; + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as UnknownRecord) + : undefined; } export function asString(value: unknown): string | undefined { @@ -57,13 +59,28 @@ export function identifiersFrom(values: string[]): FindingIdentifiers | undefine return result; } +function normalizedPath(value: string): string { + return value.replace(/\\/g, "/"); +} + +function absoluteLike(value: string): boolean { + return value.startsWith("/") || /^[A-Za-z]:\//.test(value) || value.startsWith("//"); +} + export function relativeLike(path: string | undefined, root: string): string | undefined { if (!path) return undefined; - const base = root.replace(/\\/g, "/").replace(/\/$/, ""); - const candidate = path.replace(/\\/g, "/"); + const base = normalizedPath(root).replace(/\/$/, ""); + const candidate = normalizedPath(path).trim(); + if (!candidate) return undefined; if (candidate === base) return "."; if (candidate.startsWith(`${base}/`)) return candidate.slice(base.length + 1); - return candidate; + + // Scanner output is untrusted. Do not preserve absolute host paths outside + // the repository or traversal-shaped paths in normalized reports. + if (absoluteLike(candidate)) return undefined; + const relative = candidate.replace(/^\.\//, ""); + if (relative === ".." || relative.startsWith("../") || relative.includes("/../")) return undefined; + return relative; } export function safeJson(raw: string): unknown { From f6d5180ecf7c5ffff6df521b258b9dbbef6faa21 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:13:09 -0400 Subject: [PATCH 0144/1132] Test scanner path normalization boundary --- tests/scanner-utils.test.mjs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/scanner-utils.test.mjs diff --git a/tests/scanner-utils.test.mjs b/tests/scanner-utils.test.mjs new file mode 100644 index 00000000..677d04e2 --- /dev/null +++ b/tests/scanner-utils.test.mjs @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { asRecord, relativeLike } from "../packages/scanners/dist/utils.js"; + +test("scanner path normalization keeps repository-relative paths and strips the repository root", () => { + assert.equal(relativeLike("/repo/src/app.ts", "/repo"), "src/app.ts"); + assert.equal(relativeLike("./src/app.ts", "/repo"), "src/app.ts"); + assert.equal(relativeLike("src\\app.ts", "C:\\repo"), "src/app.ts"); +}); + +test("scanner path normalization rejects host paths and traversal outside the repository", () => { + assert.equal(relativeLike("/etc/passwd", "/repo"), undefined); + assert.equal(relativeLike("C:\\Windows\\system.ini", "C:\\repo"), undefined); + assert.equal(relativeLike("../outside.txt", "/repo"), undefined); + assert.equal(relativeLike("src/../../outside.txt", "/repo"), undefined); +}); + +test("asRecord does not treat arrays as object records", () => { + assert.equal(asRecord([]), undefined); + assert.deepEqual(asRecord({ ok: true }), { ok: true }); +}); From 1a4a48b2ef6767ba10129f1f2e194fe4b2e1425b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:13:41 -0400 Subject: [PATCH 0145/1132] Redact Trivy secret evidence and sanitize paths --- packages/scanners/src/trivy.ts | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/scanners/src/trivy.ts b/packages/scanners/src/trivy.ts index c3a2b1df..790082e5 100644 --- a/packages/scanners/src/trivy.ts +++ b/packages/scanners/src/trivy.ts @@ -2,14 +2,15 @@ import { randomUUID } from "node:crypto"; import type { Finding, ScanResult } from "@synsec/core"; import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; import { runProcess } from "@synsec/scanner-sdk"; -import { asArray, asNumber, asRecord, asString, commandAvailability, normalizeSeverity, safeJson } from "./utils.js"; +import { asArray, asNumber, asRecord, asString, commandAvailability, normalizeSeverity, relativeLike, safeJson } from "./utils.js"; -function location(target: string | undefined, line?: number) { - if (!target) return undefined; - return line ? { path: target, startLine: line } : { path: target }; +function location(target: string | undefined, root: string, line?: number) { + const path = relativeLike(target, root); + if (!path) return undefined; + return line ? { path, startLine: line } : { path }; } -function vulnerability(item: Record, target?: string): Finding { +function vulnerability(item: Record, target: string | undefined, root: string): Finding { const id = asString(item.VulnerabilityID); const pkg = asString(item.PkgName); const fixed = asString(item.FixedVersion); @@ -22,7 +23,7 @@ function vulnerability(item: Record, target?: string): Finding severity: normalizeSeverity(item.Severity), confidence: 0.95, scanner: { name: "trivy", ruleId: id }, - location: location(target), + location: location(target, root), identifiers: id ? { cve: [id] } : undefined, remediation: fixed ? `Upgrade ${pkg ?? "the affected dependency"} to ${fixed} or later.` : undefined, metadata: { @@ -34,23 +35,22 @@ function vulnerability(item: Record, target?: string): Finding }; } -function secret(item: Record, target?: string): Finding { +function secret(item: Record, target: string | undefined, root: string): Finding { const ruleId = asString(item.RuleID); return { id: randomUUID(), title: asString(item.Title) ?? ruleId ?? "Potential secret detected", - description: asString(item.Category), + description: "A credential-like value was detected. SynSec intentionally omits Trivy's matched value from normalized output.", category: "secret", severity: normalizeSeverity(item.Severity), confidence: 0.9, scanner: { name: "trivy", ruleId }, - location: location(target, asNumber(item.StartLine)), - evidence: asString(item.Match), + location: location(target, root, asNumber(item.StartLine)), remediation: "Revoke or rotate the exposed credential, then remove it from the repository and history where appropriate.", }; } -function misconfiguration(item: Record, target?: string): Finding { +function misconfiguration(item: Record, target: string | undefined, root: string): Finding { const ruleId = asString(item.ID) ?? asString(item.AVDID); return { id: randomUUID(), @@ -60,13 +60,13 @@ function misconfiguration(item: Record, target?: string): Findi severity: normalizeSeverity(item.Severity), confidence: 0.9, scanner: { name: "trivy", ruleId }, - location: location(target), + location: location(target, root), remediation: asString(item.Resolution), metadata: { namespace: asString(item.Namespace), primaryUrl: asString(item.PrimaryURL) }, }; } -export function parseTrivyJson(raw: string): Finding[] { +export function parseTrivyJson(raw: string, root = ""): Finding[] { const parsed = asRecord(safeJson(raw)); if (!parsed) return []; const findings: Finding[] = []; @@ -76,15 +76,15 @@ export function parseTrivyJson(raw: string): Finding[] { const target = asString(result.Target); for (const entry of asArray(result.Vulnerabilities)) { const item = asRecord(entry); - if (item) findings.push(vulnerability(item, target)); + if (item) findings.push(vulnerability(item, target, root)); } for (const entry of asArray(result.Secrets)) { const item = asRecord(entry); - if (item) findings.push(secret(item, target)); + if (item) findings.push(secret(item, target, root)); } for (const entry of asArray(result.Misconfigurations)) { const item = asRecord(entry); - if (item) findings.push(misconfiguration(item, target)); + if (item) findings.push(misconfiguration(item, target, root)); } } return findings; @@ -111,7 +111,7 @@ export class TrivyAdapter implements ScannerAdapter { startedAt, completedAt: new Date().toISOString(), target: context.target, - findings: parseTrivyJson(output.stdout), + findings: parseTrivyJson(output.stdout, context.target.path), diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], }; } From e637a8de4ccf412b664d318ea3648006ebcb57d2 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:14:04 -0400 Subject: [PATCH 0146/1132] Test Trivy secret redaction boundary --- tests/scanners.test.mjs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/scanners.test.mjs b/tests/scanners.test.mjs index 59a12709..74b9dce4 100644 --- a/tests/scanners.test.mjs +++ b/tests/scanners.test.mjs @@ -9,6 +9,7 @@ import { parseSarifJson, parseScorecardJson, parseSyftJson, + parseTrivyJson, } from "../packages/scanners/dist/index.js"; test("Betterleaks parser redacts normalized evidence by design", () => { @@ -78,6 +79,28 @@ test("Checkov parser maps failed IaC checks", () => { assert.equal(findings[0].location.path, "main.tf"); }); +test("Trivy parser never copies matched secret material into normalized output", () => { + const marker = "SYNSEC_SECRET_MUST_NOT_SURVIVE"; + const findings = parseTrivyJson(JSON.stringify({ + Results: [{ + Target: "/repo/src/config.ts", + Secrets: [{ + RuleID: "generic-api-key", + Title: "API key", + Severity: "HIGH", + StartLine: 7, + Match: marker, + Code: marker, + }], + }], + }), "/repo"); + assert.equal(findings.length, 1); + assert.equal(findings[0].category, "secret"); + assert.equal(findings[0].location.path, "src/config.ts"); + assert.equal(findings[0].evidence, undefined); + assert.equal(JSON.stringify(findings).includes(marker), false); +}); + test("Scorecard parser creates posture findings only for non-perfect checks", () => { const findings = parseScorecardJson(JSON.stringify({ score: 7.2, From d3cc6526ece11dfa626351159e48811fdf0d5e8c Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:14:25 -0400 Subject: [PATCH 0147/1132] Normalize Betterleaks repository paths --- packages/scanners/src/betterleaks.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/scanners/src/betterleaks.ts b/packages/scanners/src/betterleaks.ts index 38bdc598..786a77d4 100644 --- a/packages/scanners/src/betterleaks.ts +++ b/packages/scanners/src/betterleaks.ts @@ -5,9 +5,9 @@ import { join, resolve } from "node:path"; import type { Finding, ScanResult } from "@synsec/core"; import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; import { runProcess } from "@synsec/scanner-sdk"; -import { asArray, asNumber, asRecord, asString, commandAvailability, safeJson } from "./utils.js"; +import { asArray, asNumber, asRecord, asString, commandAvailability, relativeLike, safeJson } from "./utils.js"; -export function parseBetterleaksJson(raw: string): Finding[] { +export function parseBetterleaksJson(raw: string, root = ""): Finding[] { const parsed = safeJson(raw); const findings: Finding[] = []; for (const value of asArray(parsed)) { @@ -16,7 +16,8 @@ export function parseBetterleaksJson(raw: string): Finding[] { const ruleId = asString(item.RuleID); const description = asString(item.Description) ?? ruleId ?? "Potential secret detected"; const attributes = asRecord(item.Attributes); - const file = asString(item.File) ?? asString(attributes?.path) ?? asString(attributes?.Path); + const rawFile = asString(item.File) ?? asString(attributes?.path) ?? asString(attributes?.Path); + const file = relativeLike(rawFile, root); const startLine = asNumber(item.StartLine); const fingerprint = asString(item.Fingerprint); const validationStatus = asString(item.ValidationStatus); @@ -95,7 +96,7 @@ export class BetterleaksAdapter implements ScannerAdapter { startedAt, completedAt: new Date().toISOString(), target: context.target, - findings: parseBetterleaksJson(raw), + findings: parseBetterleaksJson(raw, context.target.path), diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], }; } finally { From 90cea884420a99f3f7fc80eba1e54ce07c52f9c9 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:14:43 -0400 Subject: [PATCH 0148/1132] Normalize and incrementally scope Gitleaks --- packages/scanners/src/gitleaks.ts | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/scanners/src/gitleaks.ts b/packages/scanners/src/gitleaks.ts index afa99c93..84acfdea 100644 --- a/packages/scanners/src/gitleaks.ts +++ b/packages/scanners/src/gitleaks.ts @@ -1,13 +1,13 @@ import { randomUUID } from "node:crypto"; import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import type { Finding, ScanResult } from "@synsec/core"; import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; import { runProcess } from "@synsec/scanner-sdk"; -import { asArray, asNumber, asRecord, asString, commandAvailability, safeJson } from "./utils.js"; +import { asArray, asNumber, asRecord, asString, commandAvailability, relativeLike, safeJson } from "./utils.js"; -export function parseGitleaksJson(raw: string): Finding[] { +export function parseGitleaksJson(raw: string, root = ""): Finding[] { const parsed = safeJson(raw); const findings: Finding[] = []; for (const value of asArray(parsed)) { @@ -15,7 +15,7 @@ export function parseGitleaksJson(raw: string): Finding[] { if (!item) continue; const ruleId = asString(item.RuleID); const description = asString(item.Description) ?? ruleId ?? "Potential secret detected"; - const file = asString(item.File); + const file = relativeLike(asString(item.File), root); const startLine = asNumber(item.StartLine); const fingerprint = asString(item.Fingerprint); findings.push({ @@ -51,14 +51,28 @@ export class GitleaksAdapter implements ScannerAdapter { async scan(context: ScannerContext): Promise { const startedAt = new Date().toISOString(); + if (context.changedFiles && context.changedFiles.length === 0) { + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: [], + diagnostics: ["Changed-file scope is empty; Gitleaks was not invoked."], + }; + } + const temp = await mkdtemp(join(tmpdir(), "synsec-gitleaks-")); const report = join(temp, "report.json"); try { const gitRepo = await stat(join(context.target.path, ".git")).then(() => true).catch(() => false); - const mode = gitRepo ? "git" : "dir"; + const mode = context.changedFiles ? "dir" : gitRepo ? "git" : "dir"; + const targets = context.changedFiles + ? context.changedFiles.map((path) => resolve(context.target.path, path)) + : [context.target.path]; const output = await runProcess( "gitleaks", - [mode, "--report-format", "json", "--report-path", report, "--redact=100", "--no-banner", "--exit-code", "0", context.target.path], + [mode, "--report-format", "json", "--report-path", report, "--redact=100", "--no-banner", "--exit-code", "0", ...targets], { timeoutMs: context.timeoutMs ?? 10 * 60_000, signal: context.signal }, ); if (output.exitCode !== 0) throw new Error(`Gitleaks scan failed (${output.exitCode}): ${output.stderr.trim()}`); @@ -68,7 +82,7 @@ export class GitleaksAdapter implements ScannerAdapter { startedAt, completedAt: new Date().toISOString(), target: context.target, - findings: parseGitleaksJson(raw), + findings: parseGitleaksJson(raw, context.target.path), diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], }; } finally { From 2068c799e47559308da4f39a18912806703f9b23 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:15:21 -0400 Subject: [PATCH 0149/1132] Normalize Opengrep finding paths --- packages/scanners/src/opengrep.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/scanners/src/opengrep.ts b/packages/scanners/src/opengrep.ts index 3ccf78d7..946413ec 100644 --- a/packages/scanners/src/opengrep.ts +++ b/packages/scanners/src/opengrep.ts @@ -3,7 +3,7 @@ import { resolve } from "node:path"; import type { Finding, ScanResult } from "@synsec/core"; import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; import { runProcess } from "@synsec/scanner-sdk"; -import { asArray, asRecord, asString, commandAvailability, identifiersFrom, normalizeSeverity, safeJson, strings } from "./utils.js"; +import { asArray, asRecord, asString, commandAvailability, identifiersFrom, normalizeSeverity, relativeLike, safeJson, strings } from "./utils.js"; function metadataIdentifiers(metadata: Record | undefined): string[] { if (!metadata) return []; @@ -16,7 +16,7 @@ function metadataIdentifiers(metadata: Record | undefined): str return values.flatMap((value) => value.split(/[,;]\s*/)).map((value) => value.trim()).filter(Boolean); } -export function parseOpengrepJson(raw: string): Finding[] { +export function parseOpengrepJson(raw: string, root = ""): Finding[] { const parsed = asRecord(safeJson(raw)); if (!parsed) return []; const findings: Finding[] = []; @@ -29,7 +29,7 @@ export function parseOpengrepJson(raw: string): Finding[] { const metadata = asRecord(extra?.metadata); const ruleId = asString(result.check_id); const message = asString(extra?.message) ?? ruleId ?? "Static analysis finding"; - const path = asString(result.path); + const path = relativeLike(asString(result.path), root); const fingerprint = asString(extra?.fingerprint); const fix = asString(extra?.fix); findings.push({ @@ -100,7 +100,7 @@ export class OpengrepAdapter implements ScannerAdapter { startedAt, completedAt: new Date().toISOString(), target: context.target, - findings: parseOpengrepJson(output.stdout), + findings: parseOpengrepJson(output.stdout, context.target.path), diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], }; } From 874f63300587976fc8b1334371efc7d5cbb554e1 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:15:39 -0400 Subject: [PATCH 0150/1132] Normalize Grype repository paths --- packages/scanners/src/grype.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/scanners/src/grype.ts b/packages/scanners/src/grype.ts index 45072959..733f908a 100644 --- a/packages/scanners/src/grype.ts +++ b/packages/scanners/src/grype.ts @@ -2,9 +2,9 @@ import { randomUUID } from "node:crypto"; import type { Finding, ScanResult } from "@synsec/core"; import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; import { runProcess } from "@synsec/scanner-sdk"; -import { asArray, asRecord, asString, commandAvailability, identifiersFrom, normalizeSeverity, safeJson } from "./utils.js"; +import { asArray, asRecord, asString, commandAvailability, identifiersFrom, normalizeSeverity, relativeLike, safeJson } from "./utils.js"; -export function parseGrypeJson(raw: string): Finding[] { +export function parseGrypeJson(raw: string, root = ""): Finding[] { const parsed = asRecord(safeJson(raw)); if (!parsed) return []; const findings: Finding[] = []; @@ -24,7 +24,7 @@ export function parseGrypeJson(raw: string): Finding[] { .map((entry) => asString(entry?.id)) .filter((item): item is string => Boolean(item)); const firstLocation = asRecord(asArray(artifact.locations)[0]); - const path = asString(firstLocation?.path); + const path = relativeLike(asString(firstLocation?.path), root); findings.push({ id: randomUUID(), title: `${vulnId} in ${packageName}`, @@ -73,7 +73,7 @@ export class GrypeAdapter implements ScannerAdapter { startedAt, completedAt: new Date().toISOString(), target: context.target, - findings: parseGrypeJson(output.stdout), + findings: parseGrypeJson(output.stdout, context.target.path), diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], }; } From 6f6cb75a194861ebc01a61ef976633ac543e7050 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:15:56 -0400 Subject: [PATCH 0151/1132] Harden Checkov path normalization --- packages/scanners/src/checkov.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/scanners/src/checkov.ts b/packages/scanners/src/checkov.ts index c7379395..f575bc70 100644 --- a/packages/scanners/src/checkov.ts +++ b/packages/scanners/src/checkov.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import type { Finding, ScanResult } from "@synsec/core"; import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; import { runProcess } from "@synsec/scanner-sdk"; -import { asArray, asNumber, asRecord, asString, commandAvailability, normalizeSeverity, safeJson } from "./utils.js"; +import { asArray, asNumber, asRecord, asString, commandAvailability, normalizeSeverity, relativeLike, safeJson } from "./utils.js"; function runnerObjects(parsed: unknown): Record[] { if (Array.isArray(parsed)) return parsed.map(asRecord).filter((value): value is Record => Boolean(value)); @@ -10,6 +10,11 @@ function runnerObjects(parsed: unknown): Record[] { return record ? [record] : []; } +function checkovRepositoryPath(value: unknown): string | undefined { + const raw = asString(value)?.replace(/^\/+/, ""); + return relativeLike(raw, ""); +} + export function parseCheckovJson(raw: string): Finding[] { const parsed = safeJson(raw); const findings: Finding[] = []; @@ -20,7 +25,7 @@ export function parseCheckovJson(raw: string): Finding[] { const item = asRecord(value); if (!item) continue; const ruleId = asString(item.check_id) ?? asString(item.bc_check_id); - const file = asString(item.file_path)?.replace(/^\//, ""); + const file = checkovRepositoryPath(item.file_path); const range = asArray(item.file_line_range); const startLine = asNumber(range[0]); const endLine = asNumber(range[1]); From e77d9f1fe9a871395a00834b7e8c68debe505d09 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:16:36 -0400 Subject: [PATCH 0152/1132] Harden SARIF artifact path handling --- packages/scanners/src/sarif.ts | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/scanners/src/sarif.ts b/packages/scanners/src/sarif.ts index 64a57b86..f710ca57 100644 --- a/packages/scanners/src/sarif.ts +++ b/packages/scanners/src/sarif.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import type { Finding, FindingCategory, Severity } from "@synsec/core"; -import { asArray, asNumber, asRecord, asString, identifiersFrom, safeJson } from "./utils.js"; +import { asArray, asNumber, asRecord, asString, identifiersFrom, relativeLike, safeJson } from "./utils.js"; const categories = new Set([ "sast", @@ -45,15 +45,30 @@ function nativeFingerprint(result: Record): string | undefined return undefined; } -function firstLocation(result: Record): Finding["location"] { +function sarifRepositoryPath(uri: string | undefined, root: string): string | undefined { + if (!uri) return undefined; + if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(uri)) { + if (!uri.toLowerCase().startsWith("file:")) return undefined; + try { + let pathname = decodeURIComponent(new URL(uri).pathname).replace(/\\/g, "/"); + if (/^\/[A-Za-z]:\//.test(pathname)) pathname = pathname.slice(1); + return relativeLike(pathname, root); + } catch { + return undefined; + } + } + return relativeLike(uri, root); +} + +function firstLocation(result: Record, root: string): Finding["location"] { const location = asRecord(asArray(result.locations)[0]); const physical = asRecord(location?.physicalLocation); const artifact = asRecord(physical?.artifactLocation); const region = asRecord(physical?.region); - const path = asString(artifact?.uri); + const path = sarifRepositoryPath(asString(artifact?.uri), root); if (!path) return undefined; return { - path: path.replace(/^file:\/\//, ""), + path, startLine: asNumber(region?.startLine), endLine: asNumber(region?.endLine), startColumn: asNumber(region?.startColumn), @@ -73,7 +88,7 @@ function ruleMap(run: Record): Map= 0 && confidence <= 1 ? confidence : 0.8, scanner: { name: scannerName, ruleId }, - location: firstLocation(result), + location: firstLocation(result, root), identifiers: identifiersFrom(identifiers), remediation: asString(properties?.remediation) ?? text(rule?.help), fingerprint: nativeFingerprint(result), From 67b91e8205ce4387e25ddaccf1448a0ece80f523 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 05:16:46 -0400 Subject: [PATCH 0153/1132] Test SARIF repository path boundary --- tests/sarif-path.test.mjs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/sarif-path.test.mjs diff --git a/tests/sarif-path.test.mjs b/tests/sarif-path.test.mjs new file mode 100644 index 00000000..e200eca2 --- /dev/null +++ b/tests/sarif-path.test.mjs @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseSarifJson } from "../packages/scanners/dist/index.js"; + +function sarif(uri) { + return JSON.stringify({ + version: "2.1.0", + runs: [{ + tool: { driver: { name: "Fixture" } }, + results: [{ + ruleId: "FIX-1", + level: "warning", + message: { text: "Fixture" }, + locations: [{ physicalLocation: { artifactLocation: { uri }, region: { startLine: 3 } } }], + }], + }], + }); +} + +test("SARIF import converts file URIs inside the repository to relative paths", () => { + const findings = parseSarifJson(sarif("file:///repo/src/app.ts"), undefined, "/repo"); + assert.equal(findings[0].location.path, "src/app.ts"); +}); + +test("SARIF import drops absolute paths outside the repository and non-file URLs", () => { + const outside = parseSarifJson(sarif("file:///etc/passwd"), undefined, "/repo"); + assert.equal(outside[0].location, undefined); + const remote = parseSarifJson(sarif("https://example.invalid/source.ts"), undefined, "/repo"); + assert.equal(remote[0].location, undefined); +}); From 6f5820a69f4b398f2efd3e5733b112135327dba4 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 06:07:58 -0400 Subject: [PATCH 0154/1132] feat(workflows): add fix verification and report writing workflows --- packages/workflows/src/index.ts | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/workflows/src/index.ts b/packages/workflows/src/index.ts index 3463d951..49e41b28 100644 --- a/packages/workflows/src/index.ts +++ b/packages/workflows/src/index.ts @@ -7,6 +7,8 @@ export type WorkflowCapability = | "read-dependency-metadata" | "read-redacted-secret-metadata" | "read-infrastructure-config" + | "read-scan-reports" + | "read-lifecycle-state" | "propose-remediation" | "propose-tests"; @@ -94,6 +96,40 @@ const workflows: readonly WorkflowDefinition[] = [ repositoryWriteRequiresApproval: true, externalNetworkAssessment: "forbidden", }, + { + id: "fix-verification", + version: 1, + displayName: "Fix Verification", + description: "Verify remediation against before/after scan evidence without treating model inference as proof that a finding is fixed.", + reviewInstructions: "Treat deterministic remediation verification and scanner reruns as authoritative. A missing finding is only fixed when the after scan covered the affected scope and reran a detecting scanner. Otherwise report the result as inconclusive. Source context may explain a change but must not override deterministic coverage gaps.", + categories: "all", + capabilities: [ + "read-normalized-findings", + "read-scan-reports", + "read-lifecycle-state", + "read-bounded-source-context", + "propose-tests", + ], + sourceContextAllowed: true, + repositoryWriteRequiresApproval: true, + externalNetworkAssessment: "forbidden", + }, + { + id: "report-writing", + version: 1, + displayName: "Report Writing", + description: "Turn normalized evidence and lifecycle state into concise developer-facing security reports.", + reviewInstructions: "Summarize deterministic evidence first, clearly distinguish scanner facts from model interpretation, preserve uncertainty, and reference affected locations without reproducing secret values. Do not claim exploitability or remediation success beyond the available evidence.", + categories: "all", + capabilities: [ + "read-normalized-findings", + "read-scan-reports", + "read-lifecycle-state", + ], + sourceContextAllowed: false, + repositoryWriteRequiresApproval: true, + externalNetworkAssessment: "forbidden", + }, ] as const; export function builtInWorkflows(): readonly WorkflowDefinition[] { From 8702433ca03d9d4b2d15feba23bc28ab07ce22ea Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 06:08:07 -0400 Subject: [PATCH 0155/1132] test(workflows): cover verification and reporting safety boundaries --- tests/workflows.test.mjs | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index eadc92da..7740e1cb 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { assertWorkflowSourceContextAllowed, + builtInWorkflows, getWorkflow, workflowFindings, } from "../packages/workflows/dist/index.js"; @@ -45,10 +46,34 @@ test("secrets workflow prohibits source-context transmission", () => { assert.doesNotThrow(() => assertWorkflowSourceContextAllowed(workflow, false)); }); -test("all built-in workflows prohibit external network assessment", () => { - for (const id of ["repository-review", "dependency-review", "secrets-review", "infrastructure-review"]) { - const workflow = getWorkflow(id); - assert.ok(workflow); +test("fix verification workflow requires deterministic report and lifecycle evidence", () => { + const workflow = getWorkflow("fix-verification"); + assert.ok(workflow); + assert.equal(workflow.categories, "all"); + assert.equal(workflow.sourceContextAllowed, true); + assert.ok(workflow.capabilities.includes("read-scan-reports")); + assert.ok(workflow.capabilities.includes("read-lifecycle-state")); + assert.match(workflow.reviewInstructions, /deterministic remediation verification/i); + assert.match(workflow.reviewInstructions, /inconclusive/i); +}); + +test("report writing workflow cannot request source context", () => { + const workflow = getWorkflow("report-writing"); + assert.ok(workflow); + assert.equal(workflow.categories, "all"); + assert.equal(workflow.sourceContextAllowed, false); + assert.ok(workflow.capabilities.includes("read-normalized-findings")); + assert.ok(workflow.capabilities.includes("read-lifecycle-state")); + assert.throws( + () => assertWorkflowSourceContextAllowed(workflow, true), + /does not permit source context/, + ); +}); + +test("all built-in workflows prohibit external network assessment and require approval for writes", () => { + const workflows = builtInWorkflows(); + assert.ok(workflows.length >= 6); + for (const workflow of workflows) { assert.equal(workflow.externalNetworkAssessment, "forbidden"); assert.equal(workflow.repositoryWriteRequiresApproval, true); } From 4ac5f572ca3db7f4829905ceb424395cbda6d53b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 06:08:18 -0400 Subject: [PATCH 0156/1132] docs: align roadmap with implemented lifecycle workflows --- docs/ROADMAP.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index dd752aff..2ecdc450 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -43,7 +43,7 @@ This roadmap separates what is already usable in the repository from the deeper - [x] Language/framework inventory - [x] Safe bounded finding-to-code context retrieval -- [ ] Persistent repository index +- [x] Persistent repository index - [ ] Import/module graph - [ ] Function/call graph - [ ] Routes and externally reachable entry points @@ -63,8 +63,8 @@ This roadmap separates what is already usable in the repository from the deeper - [ ] Repository-aware explanation of reachability and impact - [ ] Suggested patch generation - [ ] Suggested regression/security tests -- [ ] Safe rescan-after-remediation workflow -- [ ] Finding lifecycle: new, confirmed, false positive, accepted risk, fixed, regressed +- [x] Safe rescan-after-remediation verification primitive +- [x] Finding lifecycle: new, confirmed, false positive, accepted risk, fixed, regressed ## Phase 4 — Reusable workflows / skills @@ -74,8 +74,8 @@ The orchestration layer should expose small reusable defensive workflows rather - [x] Dependency review workflow - [x] Secrets review workflow with source-context prohibition - [x] Infrastructure/IaC review workflow -- [ ] Fix verification workflow -- [ ] Report-writing workflow +- [x] Fix verification workflow +- [x] Report-writing workflow - [ ] Provider/model routing policy by task and cost - [ ] User-defined workflow/skill format with explicit capabilities - [x] Explicit capability declarations per built-in workflow From ca66b888e11ed96bd0faf0d6edb77d2638ccd53d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 06:08:33 -0400 Subject: [PATCH 0157/1132] docs(workflows): document implemented defensive workflow set --- docs/WORKFLOWS.md | 95 ++++++++++++++++++++++------------------------- 1 file changed, 45 insertions(+), 50 deletions(-) diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index 0c5a8a24..763cb9ef 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -1,39 +1,28 @@ # Reusable defensive workflows -SynSec's model-facing layer should be built from small workflows with explicit inputs and capabilities rather than one enormous prompt that implicitly has access to everything. - -This idea is useful for two reasons: - -1. scanner orchestration and model reasoning become independently replaceable; -2. each workflow can declare exactly which repository evidence and actions it is allowed to use. - -The workflow system is not implemented in v0.2 yet. This document defines the direction so future agent work has a stable boundary. - -## Proposed workflow contract - -A workflow should eventually declare something equivalent to: - -```yaml -id: dependency-review -version: 1 -inputs: - - correlated-findings - - dependency-metadata -capabilities: - - read-normalized-findings - - read-bounded-source-context -model: - task: security-review -output: - schema: finding-review-v1 -approval: - repository-write: required - external-network-assessment: forbidden -``` +SynSec's model-facing layer is built from small workflows with explicit inputs and capabilities rather than one enormous prompt that implicitly has access to everything. + +This matters for two reasons: + +1. scanner orchestration and model reasoning remain independently replaceable; +2. each workflow declares exactly which repository evidence and actions it is allowed to use. + +The built-in workflow registry is implemented in `@synsec/workflows`. The definitions are intentionally small and machine-readable so future routing, UI, and hosted execution can enforce the same boundaries. -The important part is not YAML specifically. The important part is that capabilities are explicit and machine-enforced. +## Workflow contract -## Initial workflow set +Each built-in workflow declares: + +- a stable ID and version; +- the finding categories it accepts; +- explicit read/proposal capabilities; +- whether bounded source context is allowed; +- mandatory human approval for repository writes; +- an explicit prohibition on external network assessment. + +The important part is that capabilities are explicit and machine-enforced rather than implied by a prompt. + +## Built-in workflow set ### Repository review @@ -69,7 +58,7 @@ Inputs: - **redacted** secret findings only; - file and line metadata; -- Git-history metadata where safe. +- safe repository metadata. Output: @@ -77,7 +66,7 @@ Output: - repository-history cleanup recommendation; - confidence assessment. -A model must never need the secret value itself for this workflow. +Source context is prohibited for this workflow. A model never needs the secret value itself. ### Infrastructure review @@ -97,34 +86,40 @@ Output: Inputs: -- previous finding; -- proposed/current code change; -- rescan result; -- relevant tests. +- previous and current normalized scan reports; +- deterministic remediation-verification result; +- lifecycle state; +- optional bounded source context; +- relevant tests when available. Output: -- fixed / partially fixed / still present / unable to verify. +- fixed / persisting / inconclusive / missing-baseline interpretation; +- explanation of scanner and scope coverage; +- suggested regression/security tests. -The deterministic rescan remains authoritative. Model review explains evidence rather than declaring a vulnerability fixed on its own. +The deterministic rescan remains authoritative. A finding that disappears is only treated as fixed when a detecting scanner reran over the affected scope. Model review can explain the evidence but cannot override missing coverage. ### Report writing Inputs: -- normalized/correlated finding; -- deterministic evidence; -- optional reviewed context. +- normalized/correlated findings; +- deterministic scan evidence; +- lifecycle state. Output: - concise developer-facing explanation; - remediation summary; -- references to scanner evidence and source locations. +- references to scanner evidence and source locations; +- explicit uncertainty when evidence is incomplete. + +Source context is disabled for this workflow by design. The report writer summarizes normalized evidence rather than receiving arbitrary repository code or secret material. ## Seven-question evidence gate -The v0.2 AI reviewer already implements the first common workflow primitive. Every contextual finding review asks: +The AI reviewer implements a common workflow primitive. Every contextual finding review asks: 1. Is there a concrete affected location? 2. Is untrusted input involved when the finding requires it? @@ -148,13 +143,13 @@ report-writer verifier ``` -A router can then map each task to an available model based on cost, latency, privacy, and capability. This keeps SynSec usable with cloud models, local models, or a mixed deployment. +A router can map each task to an available model based on cost, latency, privacy, and capability. This keeps SynSec usable with cloud models, local models, or a mixed deployment. ## Human approval boundaries -A workflow may recommend a repository change, but v0.2 does not autonomously modify repositories. +A workflow may recommend a repository change, but it does not autonomously modify repositories. -Future write-capable workflows should require explicit approval before: +Any future write-capable workflow must require explicit approval before: - editing source files; - changing dependencies; @@ -166,10 +161,10 @@ External network assessment is a separate authorization domain. A future externa ## Auditability -Every future workflow run should preserve: +Every future persisted workflow run should preserve: - workflow ID and version; -- model/provider identifier; +- model/provider identifier when a model is used; - deterministic evidence references; - whether source context was sent; - output schema version; From ee6f6ab3379dfcf20ff126b723c6504ed921971c Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 06:09:10 -0400 Subject: [PATCH 0158/1132] feat(workflows): enforce declared capability boundaries --- packages/workflows/src/index.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/workflows/src/index.ts b/packages/workflows/src/index.ts index 49e41b28..27f8d15e 100644 --- a/packages/workflows/src/index.ts +++ b/packages/workflows/src/index.ts @@ -149,6 +149,19 @@ export function workflowFindings( return findings.filter((finding) => categories.has(finding.primary.category)); } +export function assertWorkflowCapabilitiesAllowed( + workflow: WorkflowDefinition, + requested: readonly WorkflowCapability[], +): void { + const allowed = new Set(workflow.capabilities); + const denied = [...new Set(requested)].filter((capability) => !allowed.has(capability)); + if (denied.length > 0) { + throw new Error( + `Workflow ${workflow.id} does not permit capabilities: ${denied.sort().join(", ")}.`, + ); + } +} + export function assertWorkflowSourceContextAllowed( workflow: WorkflowDefinition, sourceContextRequested: boolean, @@ -158,4 +171,7 @@ export function assertWorkflowSourceContextAllowed( `Workflow ${workflow.id} does not permit source context. This boundary prevents sensitive values from being unnecessarily sent to a model.`, ); } + if (sourceContextRequested) { + assertWorkflowCapabilitiesAllowed(workflow, ["read-bounded-source-context"]); + } } From 2c76adc091cc3ad7ef79d525e62320c86dc97baa Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 06:09:21 -0400 Subject: [PATCH 0159/1132] test(workflows): assert capability enforcement --- tests/workflows.test.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index 7740e1cb..67724a95 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + assertWorkflowCapabilitiesAllowed, assertWorkflowSourceContextAllowed, builtInWorkflows, getWorkflow, @@ -46,6 +47,16 @@ test("secrets workflow prohibits source-context transmission", () => { assert.doesNotThrow(() => assertWorkflowSourceContextAllowed(workflow, false)); }); +test("workflow capability checks reject undeclared access", () => { + const workflow = getWorkflow("report-writing"); + assert.ok(workflow); + assert.doesNotThrow(() => assertWorkflowCapabilitiesAllowed(workflow, ["read-normalized-findings", "read-lifecycle-state"])); + assert.throws( + () => assertWorkflowCapabilitiesAllowed(workflow, ["read-bounded-source-context", "propose-remediation"]), + /read-bounded-source-context, propose-remediation/, + ); +}); + test("fix verification workflow requires deterministic report and lifecycle evidence", () => { const workflow = getWorkflow("fix-verification"); assert.ok(workflow); From d4f18103e25c0931f3c5fffce9d4b84ca709e529 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 06:10:03 -0400 Subject: [PATCH 0160/1132] test(workflows): make denied capability assertion order-stable --- tests/workflows.test.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index 67724a95..85f727a0 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -53,7 +53,11 @@ test("workflow capability checks reject undeclared access", () => { assert.doesNotThrow(() => assertWorkflowCapabilitiesAllowed(workflow, ["read-normalized-findings", "read-lifecycle-state"])); assert.throws( () => assertWorkflowCapabilitiesAllowed(workflow, ["read-bounded-source-context", "propose-remediation"]), - /read-bounded-source-context, propose-remediation/, + (error) => { + assert.match(error.message, /read-bounded-source-context/); + assert.match(error.message, /propose-remediation/); + return true; + }, ); }); From b2557cbea475f6622d0b84102694c0066fd93c68 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 07:09:40 -0400 Subject: [PATCH 0161/1132] feat(repository): resolve repository module graph --- packages/repository/src/module-graph.ts | 180 ++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 packages/repository/src/module-graph.ts diff --git a/packages/repository/src/module-graph.ts b/packages/repository/src/module-graph.ts new file mode 100644 index 00000000..7c2fc539 --- /dev/null +++ b/packages/repository/src/module-graph.ts @@ -0,0 +1,180 @@ +import { posix } from "node:path"; +import type { IndexFileInput, ModuleEdge, RepositoryIndex } from "./analysis.js"; + +export type ModuleResolution = "repository-file" | "external-or-unresolved"; + +export interface ResolvedModuleEdge extends ModuleEdge { + target?: string; + resolution: ModuleResolution; +} + +export interface ModuleGraph { + schemaVersion: 1; + nodes: string[]; + edges: ResolvedModuleEdge[]; + resolvedEdgeCount: number; + unresolvedEdgeCount: number; +} + +export interface ModuleNeighborhood { + root: string; + maxDepth: number; + dependencies: Array<{ path: string; depth: number }>; + dependents: Array<{ path: string; depth: number }>; + /** Module-level import reachability is structural evidence, not function-level data flow. */ + interpretation: "module-import-reachability-only"; +} + +const jsExtensions = [ + ".js", ".mjs", ".cjs", ".jsx", + ".ts", ".mts", ".cts", ".tsx", +]; + +function normalizeRepositoryPath(value: string): string { + const normalized = posix.normalize(value.replaceAll("\\", "/").replace(/^\.\//, "")); + return normalized === "." ? "" : normalized.replace(/^\//, ""); +} + +function candidateLookup(files: readonly IndexFileInput[]): Map { + const lookup = new Map(); + for (const file of files) { + const normalized = normalizeRepositoryPath(file.path); + if (!normalized || normalized.startsWith("../")) continue; + lookup.set(normalized.toLowerCase(), normalized); + } + return lookup; +} + +function addJsCandidates(candidates: string[], base: string): void { + candidates.push(base); + const extension = posix.extname(base).toLowerCase(); + if (!extension) { + for (const ext of jsExtensions) candidates.push(`${base}${ext}`); + for (const ext of jsExtensions) candidates.push(posix.join(base, `index${ext}`)); + return; + } + + const sourceExtensionMap: Record = { + ".js": [".ts", ".tsx"], + ".mjs": [".mts"], + ".cjs": [".cts"], + ".jsx": [".tsx"], + }; + const stem = base.slice(0, -extension.length); + for (const ext of sourceExtensionMap[extension] ?? []) candidates.push(`${stem}${ext}`); +} + +function resolveJavascriptEdge(edge: ModuleEdge, lookup: Map): string | undefined { + if (!edge.specifier.startsWith(".")) return undefined; + const base = normalizeRepositoryPath(posix.join(posix.dirname(normalizeRepositoryPath(edge.from)), edge.specifier)); + if (!base || base.startsWith("../")) return undefined; + const candidates: string[] = []; + addJsCandidates(candidates, base); + for (const candidate of candidates) { + const found = lookup.get(candidate.toLowerCase()); + if (found) return found; + } + return undefined; +} + +function resolvePythonEdge(edge: ModuleEdge, lookup: Map): string | undefined { + if (!edge.specifier.startsWith(".")) return undefined; + const leadingDots = edge.specifier.match(/^\.+/)?.[0].length ?? 0; + let directory = posix.dirname(normalizeRepositoryPath(edge.from)); + for (let level = 1; level < leadingDots; level += 1) directory = posix.dirname(directory); + const remainder = edge.specifier.slice(leadingDots).replaceAll(".", "/"); + const base = normalizeRepositoryPath(remainder ? posix.join(directory, remainder) : directory); + if (!base || base.startsWith("../")) return undefined; + for (const candidate of [`${base}.py`, posix.join(base, "__init__.py")]) { + const found = lookup.get(candidate.toLowerCase()); + if (found) return found; + } + return undefined; +} + +function resolveEdge(edge: ModuleEdge, lookup: Map): string | undefined { + if (edge.kind === "python-import") return resolvePythonEdge(edge, lookup); + if (edge.kind === "import" || edge.kind === "require" || edge.kind === "dynamic-import") { + return resolveJavascriptEdge(edge, lookup); + } + return undefined; +} + +export function buildModuleGraph(index: RepositoryIndex, files: readonly IndexFileInput[]): ModuleGraph { + const lookup = candidateLookup(files); + const nodes = [...lookup.values()].sort(); + let resolvedEdgeCount = 0; + const edges = index.moduleEdges.map((edge): ResolvedModuleEdge => { + const target = resolveEdge(edge, lookup); + if (target) { + resolvedEdgeCount += 1; + return { ...edge, target, resolution: "repository-file" }; + } + return { ...edge, resolution: "external-or-unresolved" }; + }); + + return { + schemaVersion: 1, + nodes, + edges, + resolvedEdgeCount, + unresolvedEdgeCount: edges.length - resolvedEdgeCount, + }; +} + +function traverse( + graph: ModuleGraph, + root: string, + direction: "dependencies" | "dependents", + maxDepth: number, + maxNodes: number, +): Array<{ path: string; depth: number }> { + const normalizedRoot = normalizeRepositoryPath(root); + const boundedDepth = Math.max(0, maxDepth); + const boundedNodes = Math.max(1, maxNodes); + const queue: Array<{ path: string; depth: number }> = [{ path: normalizedRoot, depth: 0 }]; + const seen = new Set([normalizedRoot.toLowerCase()]); + const output: Array<{ path: string; depth: number }> = []; + + while (queue.length > 0 && output.length < boundedNodes) { + const current = queue.shift(); + if (!current || current.depth >= boundedDepth) continue; + + const adjacent = graph.edges.flatMap((edge) => { + if (!edge.target) return []; + const from = normalizeRepositoryPath(edge.from); + const target = normalizeRepositoryPath(edge.target); + if (direction === "dependencies" && from.toLowerCase() === current.path.toLowerCase()) return [target]; + if (direction === "dependents" && target.toLowerCase() === current.path.toLowerCase()) return [from]; + return []; + }); + + for (const path of adjacent.sort()) { + const key = path.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + const next = { path, depth: current.depth + 1 }; + output.push(next); + queue.push(next); + if (output.length >= boundedNodes) break; + } + } + + return output; +} + +export function findModuleNeighborhood( + graph: ModuleGraph, + root: string, + maxDepth = 3, + maxNodesPerDirection = 100, +): ModuleNeighborhood { + const normalizedRoot = normalizeRepositoryPath(root); + return { + root: normalizedRoot, + maxDepth: Math.max(0, maxDepth), + dependencies: traverse(graph, normalizedRoot, "dependencies", maxDepth, maxNodesPerDirection), + dependents: traverse(graph, normalizedRoot, "dependents", maxDepth, maxNodesPerDirection), + interpretation: "module-import-reachability-only", + }; +} From 8ade056a2f795794ff61a179dc9279690a9e06f6 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 07:09:47 -0400 Subject: [PATCH 0162/1132] feat(repository): expose module graph API --- packages/repository/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/repository/package.json b/packages/repository/package.json index 04803aa0..4a125c50 100644 --- a/packages/repository/package.json +++ b/packages/repository/package.json @@ -5,7 +5,8 @@ "type": "module", "exports": { ".": "./dist/index.js", - "./analysis": "./dist/analysis.js" + "./analysis": "./dist/analysis.js", + "./module-graph": "./dist/module-graph.js" }, "types": "./dist/index.d.ts", "scripts": { From ea2510e9713d00e57847ceadbcfae3472181c28a Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 07:09:59 -0400 Subject: [PATCH 0163/1132] test(repository): cover module graph resolution --- tests/module-graph.test.mjs | 76 +++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tests/module-graph.test.mjs diff --git a/tests/module-graph.test.mjs b/tests/module-graph.test.mjs new file mode 100644 index 00000000..38a5850a --- /dev/null +++ b/tests/module-graph.test.mjs @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildRepositoryIndex } from "../packages/repository/dist/analysis.js"; +import { buildModuleGraph, findModuleNeighborhood } from "../packages/repository/dist/module-graph.js"; + +test("module graph resolves local JavaScript and TypeScript imports without confusing packages for repository files", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-module-graph-")); + try { + await mkdir(join(root, "src", "db"), { recursive: true }); + const app = `import express from "express";\nimport { requireAuth } from "./auth.js";\nimport { loadUser } from "./db/index.js";\nexport async function handler() { return loadUser(); }\n`; + const auth = `export function requireAuth() { return true; }\n`; + const db = `import { requireAuth } from "../auth.js";\nexport function loadUser() { requireAuth(); return {}; }\n`; + await writeFile(join(root, "src", "app.ts"), app); + await writeFile(join(root, "src", "auth.ts"), auth); + await writeFile(join(root, "src", "db", "index.ts"), db); + + const files = [ + { path: "src/app.ts", size: Buffer.byteLength(app) }, + { path: "src/auth.ts", size: Buffer.byteLength(auth) }, + { path: "src/db/index.ts", size: Buffer.byteLength(db) }, + ]; + const index = await buildRepositoryIndex(root, files); + const graph = buildModuleGraph(index, files); + + assert.equal(graph.schemaVersion, 1); + assert.deepEqual(graph.nodes, ["src/app.ts", "src/auth.ts", "src/db/index.ts"]); + assert.equal(graph.resolvedEdgeCount, 3); + assert.equal(graph.unresolvedEdgeCount, 1); + assert.ok(graph.edges.some((edge) => edge.specifier === "./auth.js" && edge.target === "src/auth.ts")); + assert.ok(graph.edges.some((edge) => edge.specifier === "./db/index.js" && edge.target === "src/db/index.ts")); + assert.ok(graph.edges.some((edge) => edge.specifier === "express" && edge.resolution === "external-or-unresolved" && edge.target === undefined)); + + const neighborhood = findModuleNeighborhood(graph, "./src/app.ts", 3); + assert.equal(neighborhood.interpretation, "module-import-reachability-only"); + assert.deepEqual(neighborhood.dependencies, [ + { path: "src/auth.ts", depth: 1 }, + { path: "src/db/index.ts", depth: 1 }, + ]); + + const authNeighborhood = findModuleNeighborhood(graph, "src/auth.ts", 3); + assert.deepEqual(authNeighborhood.dependents, [ + { path: "src/app.ts", depth: 1 }, + { path: "src/db/index.ts", depth: 1 }, + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("module graph resolves explicit relative Python package imports and bounds traversal", () => { + const files = [ + { path: "service/__init__.py", size: 1 }, + { path: "service/api.py", size: 1 }, + { path: "service/auth.py", size: 1 }, + ]; + const index = { + schemaVersion: 1, + generatedAt: new Date(0).toISOString(), + indexedFileCount: files.length, + moduleEdges: [ + { from: "service/api.py", specifier: ".auth", kind: "python-import", line: 1 }, + { from: "service/auth.py", specifier: ".", kind: "python-import", line: 1 }, + ], + routes: [], + authSignals: [], + sinks: [], + }; + + const graph = buildModuleGraph(index, files); + assert.ok(graph.edges.some((edge) => edge.specifier === ".auth" && edge.target === "service/auth.py")); + assert.ok(graph.edges.some((edge) => edge.specifier === "." && edge.target === "service/__init__.py")); + assert.deepEqual(findModuleNeighborhood(graph, "service/api.py", 0).dependencies, []); +}); From 9bab9b4c3e131989b48c099b3c0348110939d070 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 07:10:38 -0400 Subject: [PATCH 0164/1132] docs: mark module graph intelligence complete --- docs/ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2ecdc450..167f46af 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -44,7 +44,7 @@ This roadmap separates what is already usable in the repository from the deeper - [x] Language/framework inventory - [x] Safe bounded finding-to-code context retrieval - [x] Persistent repository index -- [ ] Import/module graph +- [x] Import/module graph with bounded dependency/dependent traversal - [ ] Function/call graph - [ ] Routes and externally reachable entry points - [ ] Authentication/authorization context From b5987da25ce3a24da581142d819a4ea8b14e3315 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 07:11:19 -0400 Subject: [PATCH 0165/1132] feat(workflows): add safe user workflow format --- packages/workflows/src/user-defined.ts | 124 +++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 packages/workflows/src/user-defined.ts diff --git a/packages/workflows/src/user-defined.ts b/packages/workflows/src/user-defined.ts new file mode 100644 index 00000000..bdc44a99 --- /dev/null +++ b/packages/workflows/src/user-defined.ts @@ -0,0 +1,124 @@ +import { readFile } from "node:fs/promises"; +import type { FindingCategory } from "@synsec/core"; +import type { WorkflowCapability, WorkflowDefinition } from "./index.js"; + +const capabilities = new Set([ + "read-normalized-findings", + "read-repository-inventory", + "read-bounded-source-context", + "read-dependency-metadata", + "read-redacted-secret-metadata", + "read-infrastructure-config", + "read-scan-reports", + "read-lifecycle-state", + "propose-remediation", + "propose-tests", +]); + +const categories = new Set([ + "sast", + "dependency", + "secret", + "misconfiguration", + "iac", + "container", + "supply-chain", + "repository-posture", + "license", + "other", +]); + +function record(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Workflow definition must be a JSON object."); + } + return value as Record; +} + +function requiredString(input: Record, key: string, maxLength: number): string { + const value = input[key]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`Workflow field ${key} must be a non-empty string.`); + } + if (value.length > maxLength) throw new Error(`Workflow field ${key} exceeds ${maxLength} characters.`); + return value; +} + +function parseCategories(value: unknown): readonly FindingCategory[] | "all" { + if (value === "all") return "all"; + if (!Array.isArray(value) || value.length === 0) { + throw new Error("Workflow categories must be \"all\" or a non-empty array."); + } + const parsed = [...new Set(value.map((item) => { + if (typeof item !== "string" || !categories.has(item as FindingCategory)) { + throw new Error(`Unsupported workflow category: ${String(item)}.`); + } + return item as FindingCategory; + }))]; + return parsed; +} + +function parseCapabilities(value: unknown): readonly WorkflowCapability[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error("Workflow capabilities must be a non-empty array."); + } + return [...new Set(value.map((item) => { + if (typeof item !== "string" || !capabilities.has(item as WorkflowCapability)) { + throw new Error(`Unsupported workflow capability: ${String(item)}.`); + } + return item as WorkflowCapability; + }))]; +} + +export function parseUserWorkflow(value: unknown): WorkflowDefinition { + const input = record(value); + if (input.version !== 1) throw new Error("User-defined workflows must declare version 1."); + + const id = requiredString(input, "id", 80); + if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) { + throw new Error("Workflow id must contain only lowercase letters, numbers, and hyphens."); + } + const displayName = requiredString(input, "displayName", 120); + const description = requiredString(input, "description", 1_000); + const reviewInstructions = requiredString(input, "reviewInstructions", 8_000); + const parsedCapabilities = parseCapabilities(input.capabilities); + const parsedCategories = parseCategories(input.categories); + + if (typeof input.sourceContextAllowed !== "boolean") { + throw new Error("Workflow sourceContextAllowed must be a boolean."); + } + if (input.sourceContextAllowed && !parsedCapabilities.includes("read-bounded-source-context")) { + throw new Error("A workflow may allow source context only when read-bounded-source-context is declared."); + } + if (input.repositoryWriteRequiresApproval !== true) { + throw new Error("User-defined workflows must require approval for repository writes."); + } + if (input.externalNetworkAssessment !== "forbidden") { + throw new Error("User-defined repository workflows must forbid external network assessment."); + } + + return { + id, + version: 1, + displayName, + description, + reviewInstructions, + categories: parsedCategories, + capabilities: parsedCapabilities, + sourceContextAllowed: input.sourceContextAllowed, + repositoryWriteRequiresApproval: true, + externalNetworkAssessment: "forbidden", + }; +} + +export async function readUserWorkflow(path: string): Promise { + const source = await readFile(path, "utf8"); + if (source.length > 64_000) throw new Error("Workflow definition exceeds the 64 KiB size limit."); + let parsed: unknown; + try { + parsed = JSON.parse(source) as unknown; + } catch (error) { + throw new Error(`Workflow definition is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); + } + return parseUserWorkflow(parsed); +} From 10b592fe0ce30db4017e863249a1abb0b1faab83 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 07:11:23 -0400 Subject: [PATCH 0166/1132] feat(workflows): expose user workflow parser --- packages/workflows/package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/workflows/package.json b/packages/workflows/package.json index 00ba8620..c31c54e9 100644 --- a/packages/workflows/package.json +++ b/packages/workflows/package.json @@ -3,7 +3,10 @@ "version": "0.2.0", "private": true, "type": "module", - "exports": "./dist/index.js", + "exports": { + ".": "./dist/index.js", + "./user-defined": "./dist/user-defined.js" + }, "types": "./dist/index.d.ts", "scripts": { "build": "tsc -p tsconfig.json", From 5642ee4c6c878e114e4a3e704a33d9b0f5527490 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 07:11:33 -0400 Subject: [PATCH 0167/1132] test(workflows): enforce user workflow boundaries --- tests/user-workflows.test.mjs | 75 +++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/user-workflows.test.mjs diff --git a/tests/user-workflows.test.mjs b/tests/user-workflows.test.mjs new file mode 100644 index 00000000..f344fd99 --- /dev/null +++ b/tests/user-workflows.test.mjs @@ -0,0 +1,75 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseUserWorkflow, readUserWorkflow } from "../packages/workflows/dist/user-defined.js"; + +const validWorkflow = { + id: "custom-dependency-review", + version: 1, + displayName: "Custom Dependency Review", + description: "Review dependency evidence for this repository.", + reviewInstructions: "Prefer deterministic package and import evidence. Preserve uncertainty.", + categories: ["dependency"], + capabilities: ["read-normalized-findings", "read-dependency-metadata", "propose-remediation"], + sourceContextAllowed: false, + repositoryWriteRequiresApproval: true, + externalNetworkAssessment: "forbidden", +}; + +test("user-defined workflow parser accepts capability-scoped defensive workflows", () => { + const workflow = parseUserWorkflow(validWorkflow); + assert.equal(workflow.id, "custom-dependency-review"); + assert.deepEqual(workflow.categories, ["dependency"]); + assert.deepEqual(workflow.capabilities, [ + "read-normalized-findings", + "read-dependency-metadata", + "propose-remediation", + ]); + assert.equal(workflow.repositoryWriteRequiresApproval, true); + assert.equal(workflow.externalNetworkAssessment, "forbidden"); +}); + +test("user-defined workflows cannot weaken repository safety boundaries", () => { + assert.throws( + () => parseUserWorkflow({ ...validWorkflow, repositoryWriteRequiresApproval: false }), + /must require approval/, + ); + assert.throws( + () => parseUserWorkflow({ ...validWorkflow, externalNetworkAssessment: "allowed" }), + /must forbid external network assessment/, + ); + assert.throws( + () => parseUserWorkflow({ ...validWorkflow, capabilities: ["read-normalized-findings", "execute-shell"] }), + /Unsupported workflow capability/, + ); +}); + +test("source context requires an explicit bounded-source capability", () => { + assert.throws( + () => parseUserWorkflow({ ...validWorkflow, sourceContextAllowed: true }), + /read-bounded-source-context/, + ); + const workflow = parseUserWorkflow({ + ...validWorkflow, + sourceContextAllowed: true, + capabilities: ["read-normalized-findings", "read-bounded-source-context"], + }); + assert.equal(workflow.sourceContextAllowed, true); +}); + +test("user-defined workflow files are bounded and parsed from JSON", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-workflow-test-")); + try { + const path = join(root, "workflow.json"); + await writeFile(path, `${JSON.stringify(validWorkflow, null, 2)}\n`); + const workflow = await readUserWorkflow(path); + assert.equal(workflow.displayName, "Custom Dependency Review"); + + await writeFile(path, "{"); + await assert.rejects(() => readUserWorkflow(path), /not valid JSON/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From 0298d9835d2c68999c34cefdfceab3d8eede465a Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 07:12:06 -0400 Subject: [PATCH 0168/1132] docs: document user-defined workflows --- docs/WORKFLOWS.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index 763cb9ef..e705c1a9 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -11,7 +11,7 @@ The built-in workflow registry is implemented in `@synsec/workflows`. The defini ## Workflow contract -Each built-in workflow declares: +Each workflow declares: - a stable ID and version; - the finding categories it accepts; @@ -22,6 +22,35 @@ Each built-in workflow declares: The important part is that capabilities are explicit and machine-enforced rather than implied by a prompt. +## User-defined workflow format + +SynSec accepts version 1 user workflows as bounded JSON definitions through `@synsec/workflows/user-defined`. User workflow files are limited to 64 KiB and are parsed into the same `WorkflowDefinition` contract used by built-ins. + +A minimal definition looks like: + +```json +{ + "id": "custom-dependency-review", + "version": 1, + "displayName": "Custom Dependency Review", + "description": "Review dependency evidence for this repository.", + "reviewInstructions": "Prefer deterministic package and import evidence. Preserve uncertainty.", + "categories": ["dependency"], + "capabilities": [ + "read-normalized-findings", + "read-dependency-metadata", + "propose-remediation" + ], + "sourceContextAllowed": false, + "repositoryWriteRequiresApproval": true, + "externalNetworkAssessment": "forbidden" +} +``` + +The parser rejects unknown capabilities and categories. Source context can only be enabled when `read-bounded-source-context` is explicitly declared. The two repository safety boundaries are intentionally not extensible: user-defined repository workflows must require approval for writes and must forbid external network assessment. + +`reviewInstructions` are workflow guidance, not an authorization mechanism. They cannot grant capabilities that the workflow does not declare. + ## Built-in workflow set ### Repository review From 0c6f30f8068cc591059ef82634f7c028db8a78b4 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 07:12:20 -0400 Subject: [PATCH 0169/1132] docs: mark user workflow format complete --- docs/ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 167f46af..fbcd8594 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -77,7 +77,7 @@ The orchestration layer should expose small reusable defensive workflows rather - [x] Fix verification workflow - [x] Report-writing workflow - [ ] Provider/model routing policy by task and cost -- [ ] User-defined workflow/skill format with explicit capabilities +- [x] User-defined workflow/skill format with explicit capabilities - [x] Explicit capability declarations per built-in workflow - [x] Human approval boundary declared for any repository-changing action - [x] External network assessment forbidden in repository workflows From fc376846fa007c455fbc33d7719fb57ad5f14ee6 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 07:12:39 -0400 Subject: [PATCH 0170/1132] feat(workflows): add deterministic model routing policy --- packages/workflows/src/routing.ts | 87 +++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 packages/workflows/src/routing.ts diff --git a/packages/workflows/src/routing.ts b/packages/workflows/src/routing.ts new file mode 100644 index 00000000..66cd21da --- /dev/null +++ b/packages/workflows/src/routing.ts @@ -0,0 +1,87 @@ +export type ModelTask = + | "fast-classifier" + | "security-reasoner" + | "code-reasoner" + | "report-writer" + | "verifier"; + +export type ModelPrivacy = "local" | "private-remote" | "remote"; + +export interface ModelCandidate { + id: string; + tasks: readonly ModelTask[]; + costTier: 0 | 1 | 2 | 3; + latencyTier: 0 | 1 | 2 | 3; + privacy: ModelPrivacy; + supportsSourceContext: boolean; + enabled?: boolean; +} + +export interface ModelRoutingRequest { + task: ModelTask; + sourceContextRequested: boolean; + maxCostTier?: 0 | 1 | 2 | 3; + requireLocal?: boolean; + preferLocal?: boolean; +} + +export interface ModelRoutingDecision { + candidate: ModelCandidate; + reason: string[]; +} + +function privacyRank(privacy: ModelPrivacy): number { + if (privacy === "local") return 0; + if (privacy === "private-remote") return 1; + return 2; +} + +function eligible(candidate: ModelCandidate, request: ModelRoutingRequest): boolean { + if (candidate.enabled === false) return false; + if (!candidate.tasks.includes(request.task)) return false; + if (request.sourceContextRequested && !candidate.supportsSourceContext) return false; + if (request.maxCostTier !== undefined && candidate.costTier > request.maxCostTier) return false; + if (request.requireLocal && candidate.privacy !== "local") return false; + return true; +} + +export function routeModel( + candidates: readonly ModelCandidate[], + request: ModelRoutingRequest, +): ModelRoutingDecision { + const matching = candidates.filter((candidate) => eligible(candidate, request)); + if (matching.length === 0) { + const constraints = [ + `task=${request.task}`, + `sourceContext=${request.sourceContextRequested ? "required" : "not-required"}`, + request.maxCostTier !== undefined ? `maxCostTier=${request.maxCostTier}` : undefined, + request.requireLocal ? "privacy=local-only" : undefined, + ].filter((value): value is string => value !== undefined); + throw new Error(`No model candidate satisfies routing constraints: ${constraints.join(", ")}.`); + } + + const ranked = [...matching].sort((left, right) => { + if (request.preferLocal) { + const privacyDifference = privacyRank(left.privacy) - privacyRank(right.privacy); + if (privacyDifference !== 0) return privacyDifference; + } + return left.costTier - right.costTier + || left.latencyTier - right.latencyTier + || privacyRank(left.privacy) - privacyRank(right.privacy) + || left.id.localeCompare(right.id); + }); + + const candidate = ranked[0]; + if (!candidate) throw new Error("Model routing produced no candidate after eligibility filtering."); + + const reason = [ + `supports ${request.task}`, + `cost tier ${candidate.costTier}`, + `latency tier ${candidate.latencyTier}`, + `privacy ${candidate.privacy}`, + ]; + if (request.sourceContextRequested) reason.push("permits source context"); + if (request.preferLocal && candidate.privacy === "local") reason.push("local preference satisfied"); + + return { candidate, reason }; +} From fbf09395b9bcad31aca0749af26682e188787c91 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 07:12:43 -0400 Subject: [PATCH 0171/1132] feat(workflows): expose model routing policy --- packages/workflows/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/workflows/package.json b/packages/workflows/package.json index c31c54e9..36de3b07 100644 --- a/packages/workflows/package.json +++ b/packages/workflows/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { ".": "./dist/index.js", + "./routing": "./dist/routing.js", "./user-defined": "./dist/user-defined.js" }, "types": "./dist/index.d.ts", From 08e13434d08a20c38d7158e2223a0f68fb5a4bb8 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 07:12:53 -0400 Subject: [PATCH 0172/1132] test(workflows): cover model routing constraints --- tests/model-routing.test.mjs | 93 ++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/model-routing.test.mjs diff --git a/tests/model-routing.test.mjs b/tests/model-routing.test.mjs new file mode 100644 index 00000000..18590e0c --- /dev/null +++ b/tests/model-routing.test.mjs @@ -0,0 +1,93 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { routeModel } from "../packages/workflows/dist/routing.js"; + +const candidates = [ + { + id: "local-small", + tasks: ["fast-classifier", "report-writer"], + costTier: 0, + latencyTier: 1, + privacy: "local", + supportsSourceContext: true, + }, + { + id: "remote-security", + tasks: ["security-reasoner", "code-reasoner", "verifier"], + costTier: 2, + latencyTier: 2, + privacy: "remote", + supportsSourceContext: true, + }, + { + id: "private-security", + tasks: ["security-reasoner", "verifier"], + costTier: 1, + latencyTier: 3, + privacy: "private-remote", + supportsSourceContext: false, + }, +]; + +test("routing chooses the lowest-cost eligible model by task", () => { + const decision = routeModel(candidates, { + task: "security-reasoner", + sourceContextRequested: false, + }); + assert.equal(decision.candidate.id, "private-security"); + assert.ok(decision.reason.some((reason) => /cost tier 1/.test(reason))); +}); + +test("source-context routing excludes models that cannot receive source", () => { + const decision = routeModel(candidates, { + task: "security-reasoner", + sourceContextRequested: true, + }); + assert.equal(decision.candidate.id, "remote-security"); + assert.ok(decision.reason.includes("permits source context")); +}); + +test("routing enforces cost and local-only constraints rather than silently widening policy", () => { + assert.throws( + () => routeModel(candidates, { + task: "security-reasoner", + sourceContextRequested: false, + maxCostTier: 0, + }), + /No model candidate satisfies routing constraints/, + ); + assert.throws( + () => routeModel(candidates, { + task: "verifier", + sourceContextRequested: false, + requireLocal: true, + }), + /privacy=local-only/, + ); +}); + +test("local preference is deterministic when multiple candidates remain eligible", () => { + const expanded = [ + ...candidates, + { + id: "remote-cheap-writer", + tasks: ["report-writer"], + costTier: 0, + latencyTier: 0, + privacy: "remote", + supportsSourceContext: false, + }, + ]; + const defaultDecision = routeModel(expanded, { + task: "report-writer", + sourceContextRequested: false, + }); + assert.equal(defaultDecision.candidate.id, "remote-cheap-writer"); + + const privateDecision = routeModel(expanded, { + task: "report-writer", + sourceContextRequested: false, + preferLocal: true, + }); + assert.equal(privateDecision.candidate.id, "local-small"); +}); From 40de2c45133634f226c00a078c9512189864b786 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 07:13:31 -0400 Subject: [PATCH 0173/1132] docs: describe deterministic model routing --- docs/WORKFLOWS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index e705c1a9..7a865ebd 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -162,7 +162,7 @@ An unanswered question stays `unknown`. A model should not fill gaps with invent ## Model routing -Workflows should request a capability class rather than hard-code one vendor/model name. Examples: +`@synsec/workflows/routing` provides a deterministic provider/model selection policy. Workflows request a task class rather than hard-code a vendor/model name: ```text fast-classifier @@ -172,7 +172,9 @@ report-writer verifier ``` -A router can map each task to an available model based on cost, latency, privacy, and capability. This keeps SynSec usable with cloud models, local models, or a mixed deployment. +Candidates declare supported task classes, cost tier, latency tier, privacy class, source-context support, and enabled state. A routing request can cap cost, require local execution, prefer local execution, and require source-context compatibility. Ineligible candidates are filtered before ranking; if no model satisfies the requested constraints, routing fails closed instead of silently widening privacy or cost policy. + +This keeps SynSec usable with cloud models, local models, or mixed deployments while preserving explicit privacy and budget boundaries. ## Human approval boundaries From 4224d2bc1666efa3761303208f0877fb775d672b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 07:13:43 -0400 Subject: [PATCH 0174/1132] docs: mark model routing policy complete --- docs/ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fbcd8594..35f14f32 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -76,7 +76,7 @@ The orchestration layer should expose small reusable defensive workflows rather - [x] Infrastructure/IaC review workflow - [x] Fix verification workflow - [x] Report-writing workflow -- [ ] Provider/model routing policy by task and cost +- [x] Provider/model routing policy by task and cost - [x] User-defined workflow/skill format with explicit capabilities - [x] Explicit capability declarations per built-in workflow - [x] Human approval boundary declared for any repository-changing action From bac4686dd825c746961b0ce8a2c5d54030b4d516 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:11:51 -0400 Subject: [PATCH 0175/1132] feat(github): add hosting integration package --- packages/github/package.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 packages/github/package.json diff --git a/packages/github/package.json b/packages/github/package.json new file mode 100644 index 00000000..a6b6c3cb --- /dev/null +++ b/packages/github/package.json @@ -0,0 +1,18 @@ +{ + "name": "@synsec/github", + "version": "0.2.0", + "private": true, + "type": "module", + "exports": { + ".": "./dist/index.js" + }, + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/core": "0.1.0", + "@synsec/report": "0.2.0" + } +} From ff683ee95bffda2a387f01541610c8a0149ea51d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:11:55 -0400 Subject: [PATCH 0176/1132] feat(github): configure TypeScript project --- packages/github/tsconfig.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 packages/github/tsconfig.json diff --git a/packages/github/tsconfig.json b/packages/github/tsconfig.json new file mode 100644 index 00000000..e6761c0d --- /dev/null +++ b/packages/github/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../core" }, + { "path": "../report" } + ], + "include": ["src/**/*.ts"] +} From bb7d2c8b2a5cb2746229fa9a6383fc14aa0c6565 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:12:14 -0400 Subject: [PATCH 0177/1132] feat(github): add PR context and check-result primitives --- packages/github/src/index.ts | 186 +++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 packages/github/src/index.ts diff --git a/packages/github/src/index.ts b/packages/github/src/index.ts new file mode 100644 index 00000000..d23bb3ca --- /dev/null +++ b/packages/github/src/index.ts @@ -0,0 +1,186 @@ +import type { CorrelatedFinding, Severity } from "@synsec/core"; +import type { SynSecReport } from "@synsec/report"; + +export type GitHubCheckConclusion = "success" | "failure" | "neutral"; +export type GitHubAnnotationLevel = "notice" | "warning" | "failure"; + +export interface GitHubPullRequestContext { + repository: string; + sha: string; + ref?: string; + baseRef?: string; + headRef?: string; + pullRequestNumber?: number; +} + +export interface GitHubCheckAnnotation { + path: string; + start_line: number; + end_line: number; + annotation_level: GitHubAnnotationLevel; + title: string; + message: string; + raw_details?: string; +} + +export interface GitHubCheckOutput { + title: string; + summary: string; + text: string; + annotations: GitHubCheckAnnotation[]; +} + +export interface GitHubCheckResult { + name: string; + headSha: string; + conclusion: GitHubCheckConclusion; + output: GitHubCheckOutput; +} + +const severityRank: Record = { + critical: 5, + high: 4, + medium: 3, + low: 2, + info: 1, + unknown: 0, +}; + +function parseRepository(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return /^[^/\s]+\/[^/\s]+$/.test(trimmed) ? trimmed : undefined; +} + +function parsePullRequestNumber(ref: string | undefined): number | undefined { + if (!ref) return undefined; + const match = /^refs\/pull\/(\d+)\/(?:merge|head)$/.exec(ref); + if (!match) return undefined; + const parsed = Number(match[1]); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +export function detectGitHubContext(env: NodeJS.ProcessEnv): GitHubPullRequestContext | undefined { + const repository = parseRepository(env.GITHUB_REPOSITORY); + const sha = env.GITHUB_SHA?.trim(); + if (!repository || !sha) return undefined; + + const ref = env.GITHUB_REF?.trim() || undefined; + const baseRef = env.GITHUB_BASE_REF?.trim() || undefined; + const headRef = env.GITHUB_HEAD_REF?.trim() || undefined; + const pullRequestNumber = parsePullRequestNumber(ref); + + return { + repository, + sha, + ...(ref ? { ref } : {}), + ...(baseRef ? { baseRef } : {}), + ...(headRef ? { headRef } : {}), + ...(pullRequestNumber ? { pullRequestNumber } : {}), + }; +} + +function annotationLevel(severity: Severity): GitHubAnnotationLevel { + if (severity === "critical" || severity === "high") return "failure"; + if (severity === "medium" || severity === "low") return "warning"; + return "notice"; +} + +function singleLine(value: string, maxLength = 1024): string { + const normalized = value.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim(); + if (normalized.length <= maxLength) return normalized; + return `${normalized.slice(0, Math.max(0, maxLength - 1))}…`; +} + +function findingAnnotation(finding: CorrelatedFinding): GitHubCheckAnnotation | undefined { + const primary = finding.primary; + const location = primary.location; + if (!location?.path || !location.startLine) return undefined; + + const sources = finding.sources.map((source) => source.name).join(", "); + const details = [ + primary.description, + primary.remediation ? `Remediation: ${primary.remediation}` : undefined, + sources ? `Sources: ${sources}` : undefined, + `SynSec fingerprint: ${finding.fingerprint}`, + ] + .filter((value): value is string => Boolean(value)) + .join("\n\n"); + + return { + path: location.path.replaceAll("\\", "/").replace(/^\.\//, ""), + start_line: Math.max(1, location.startLine), + end_line: Math.max(location.startLine, location.endLine ?? location.startLine), + annotation_level: annotationLevel(primary.severity), + title: singleLine(`[${primary.severity.toUpperCase()}] ${primary.title}`, 255), + message: singleLine(primary.description ?? primary.title), + ...(details ? { raw_details: details.slice(0, 65_535) } : {}), + }; +} + +export function buildGitHubAnnotations( + report: SynSecReport, + options: { maxAnnotations?: number; onlyNew?: boolean } = {}, +): GitHubCheckAnnotation[] { + const maxAnnotations = Math.max(0, Math.min(50, options.maxAnnotations ?? 50)); + const newFingerprints = options.onlyNew && report.baseline ? new Set(report.baseline.new) : undefined; + + return report.findings + .filter((finding) => !newFingerprints || newFingerprints.has(finding.fingerprint)) + .sort((a, b) => { + const severityDelta = severityRank[b.primary.severity] - severityRank[a.primary.severity]; + if (severityDelta !== 0) return severityDelta; + return b.primary.confidence - a.primary.confidence; + }) + .map(findingAnnotation) + .filter((annotation): annotation is GitHubCheckAnnotation => Boolean(annotation)) + .slice(0, maxAnnotations); +} + +export function reportFailsThreshold(report: SynSecReport, threshold: Severity): boolean { + const required = severityRank[threshold]; + if (required <= 0) return false; + return report.findings.some((finding) => severityRank[finding.primary.severity] >= required); +} + +function markdownSummary(report: SynSecReport, threshold: Severity): string { + const delta = report.baseline; + const deltaLine = delta + ? `New: **${delta.new.length}** · Fixed: **${delta.fixed.length}** · Persisting: **${delta.persisting.length}**` + : "No baseline comparison was provided."; + + return [ + `Security score: **${report.securityScore}/100** · Findings: **${report.findingCount}**`, + `Critical: **${report.summary.critical}** · High: **${report.summary.high}** · Medium: **${report.summary.medium}** · Low: **${report.summary.low}**`, + deltaLine, + `CI threshold: **${threshold}**`, + ].join("\n\n"); +} + +export function buildGitHubCheck( + report: SynSecReport, + context: GitHubPullRequestContext, + options: { threshold?: Severity; onlyNewAnnotations?: boolean; maxAnnotations?: number } = {}, +): GitHubCheckResult { + const threshold = options.threshold ?? "high"; + const failed = reportFailsThreshold(report, threshold); + const annotations = buildGitHubAnnotations(report, { + maxAnnotations: options.maxAnnotations, + onlyNew: options.onlyNewAnnotations ?? Boolean(report.baseline), + }); + + const scope = report.scope?.mode === "changed-files" ? "changed files" : "repository"; + const conclusion: GitHubCheckConclusion = failed ? "failure" : report.findingCount > 0 ? "neutral" : "success"; + + return { + name: "SynSec repository security", + headSha: context.sha, + conclusion, + output: { + title: failed ? `SynSec found findings at or above ${threshold}` : `SynSec ${scope} scan complete`, + summary: markdownSummary(report, threshold), + text: `Report ${report.reportId} scanned ${scope} with ${report.scanners.length} scanner run(s).`, + annotations, + }, + }; +} From 9debff51ef3731e46b24939bcb5eae83dcac2607 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:12:26 -0400 Subject: [PATCH 0178/1132] build: include GitHub integration package --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index a73470e8..a3256b8e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ { "path": "./packages/scanner-sdk" }, { "path": "./packages/scanners" }, { "path": "./packages/engine" }, + { "path": "./packages/github" }, { "path": "./apps/cli" } ] } From e36250b391404d998c5879cbe4b87706d7429eef Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:12:41 -0400 Subject: [PATCH 0179/1132] test(github): cover PR context and check annotations --- tests/github.test.mjs | 122 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 tests/github.test.mjs diff --git a/tests/github.test.mjs b/tests/github.test.mjs new file mode 100644 index 00000000..5824c482 --- /dev/null +++ b/tests/github.test.mjs @@ -0,0 +1,122 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildGitHubAnnotations, + buildGitHubCheck, + detectGitHubContext, + reportFailsThreshold, +} from "../packages/github/dist/index.js"; + +function report(overrides = {}) { + const finding = { + fingerprint: "fp-high", + primary: { + id: "f-1", + title: "Unsafe deserialization", + description: "Untrusted input reaches a deserializer.\nReview the data boundary.", + category: "sast", + severity: "high", + confidence: 0.96, + scanner: { name: "opengrep", ruleId: "unsafe-deserialize" }, + location: { path: "./src\\handler.ts", startLine: 14, endLine: 16 }, + remediation: "Use a typed parser and validate the payload before decoding.", + }, + duplicates: [], + sources: [{ name: "opengrep", ruleId: "unsafe-deserialize" }], + }; + + return { + schemaVersion: "1.0", + reportId: "report-1", + generatedAt: "2026-08-22T12:00:00.000Z", + toolVersion: "0.2.0", + target: { path: ".", commitSha: "abc123" }, + scanners: [{ scanner: "opengrep", startedAt: "a", completedAt: "b", findingCount: 1, artifactCount: 0, diagnostics: [] }], + rawFindingCount: 1, + findingCount: 1, + summary: { critical: 0, high: 1, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 88, + findings: [finding], + scope: { mode: "changed-files", baseRef: "main", changedFiles: ["src/handler.ts"] }, + baseline: { new: ["fp-high"], fixed: [], persisting: [] }, + ...overrides, + }; +} + +test("detectGitHubContext extracts safe repository and pull-request metadata", () => { + assert.deepEqual( + detectGitHubContext({ + GITHUB_REPOSITORY: "cmahmud/synsec", + GITHUB_SHA: "abc123", + GITHUB_REF: "refs/pull/42/merge", + GITHUB_BASE_REF: "main", + GITHUB_HEAD_REF: "feature/security", + }), + { + repository: "cmahmud/synsec", + sha: "abc123", + ref: "refs/pull/42/merge", + baseRef: "main", + headRef: "feature/security", + pullRequestNumber: 42, + }, + ); + + assert.equal(detectGitHubContext({ GITHUB_REPOSITORY: "bad repo", GITHUB_SHA: "abc" }), undefined); +}); + +test("GitHub annotations normalize paths, collapse newlines, and use severity levels", () => { + const [annotation] = buildGitHubAnnotations(report()); + assert.equal(annotation.path, "src/handler.ts"); + assert.equal(annotation.start_line, 14); + assert.equal(annotation.end_line, 16); + assert.equal(annotation.annotation_level, "failure"); + assert.equal(annotation.message.includes("\n"), false); + assert.match(annotation.raw_details, /SynSec fingerprint: fp-high/); +}); + +test("baseline mode annotates new findings only", () => { + const base = report(); + const oldFinding = { + ...base.findings[0], + fingerprint: "fp-old", + primary: { ...base.findings[0].primary, id: "f-2", title: "Persisting finding", location: { path: "src/old.ts", startLine: 2 } }, + }; + const withPersisting = { + ...base, + findingCount: 2, + findings: [...base.findings, oldFinding], + baseline: { new: ["fp-high"], fixed: [], persisting: ["fp-old"] }, + }; + assert.equal(buildGitHubAnnotations(withPersisting, { onlyNew: true }).length, 1); + assert.equal(buildGitHubAnnotations(withPersisting, { onlyNew: false }).length, 2); +}); + +test("check result respects configured severity threshold", () => { + const context = { repository: "cmahmud/synsec", sha: "abc123" }; + assert.equal(reportFailsThreshold(report(), "high"), true); + assert.equal(reportFailsThreshold(report(), "critical"), false); + + const failed = buildGitHubCheck(report(), context, { threshold: "high" }); + assert.equal(failed.conclusion, "failure"); + assert.equal(failed.headSha, "abc123"); + assert.match(failed.output.summary, /New: \*\*1\*\*/); + + const neutral = buildGitHubCheck(report(), context, { threshold: "critical" }); + assert.equal(neutral.conclusion, "neutral"); +}); + +test("annotation count is hard-capped to GitHub's per-request maximum", () => { + const base = report(); + const findings = Array.from({ length: 75 }, (_, index) => ({ + ...base.findings[0], + fingerprint: `fp-${index}`, + primary: { + ...base.findings[0].primary, + id: `f-${index}`, + location: { path: `src/${index}.ts`, startLine: index + 1 }, + }, + })); + assert.equal(buildGitHubAnnotations({ ...base, findings, findingCount: findings.length }, { onlyNew: false, maxAnnotations: 999 }).length, 50); +}); From 9971b91156d2903e036b6b28ae11dda85032869d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:13:39 -0400 Subject: [PATCH 0180/1132] fix(github): attach PR checks to the real head commit --- packages/github/src/index.ts | 63 +++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/packages/github/src/index.ts b/packages/github/src/index.ts index d23bb3ca..31884853 100644 --- a/packages/github/src/index.ts +++ b/packages/github/src/index.ts @@ -37,6 +37,19 @@ export interface GitHubCheckResult { output: GitHubCheckOutput; } +interface GitHubEventPullRequest { + number?: unknown; + head?: { sha?: unknown; ref?: unknown }; + base?: { ref?: unknown }; +} + +interface GitHubEventPayload { + pull_request?: GitHubEventPullRequest; + repository?: { full_name?: unknown }; + after?: unknown; + ref?: unknown; +} + const severityRank: Record = { critical: 5, high: 4, @@ -46,10 +59,18 @@ const severityRank: Record = { unknown: 0, }; -function parseRepository(value: string | undefined): string | undefined { - if (!value) return undefined; - const trimmed = value.trim(); - return /^[^/\s]+\/[^/\s]+$/.test(trimmed) ? trimmed : undefined; +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function parseRepository(value: unknown): string | undefined { + const trimmed = nonEmptyString(value); + return trimmed && /^[^/\s]+\/[^/\s]+$/.test(trimmed) ? trimmed : undefined; +} + +function positiveInteger(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) return undefined; + return value; } function parsePullRequestNumber(ref: string | undefined): number | undefined { @@ -60,15 +81,35 @@ function parsePullRequestNumber(ref: string | undefined): number | undefined { return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; } -export function detectGitHubContext(env: NodeJS.ProcessEnv): GitHubPullRequestContext | undefined { - const repository = parseRepository(env.GITHUB_REPOSITORY); - const sha = env.GITHUB_SHA?.trim(); +function asGitHubEventPayload(value: unknown): GitHubEventPayload | undefined { + return typeof value === "object" && value !== null ? (value as GitHubEventPayload) : undefined; +} + +/** + * Resolve the current GitHub Actions repository/commit context without making a network request. + * + * For pull_request events, the event payload is authoritative for the head SHA. GitHub exposes + * GITHUB_SHA as the synthetic merge ref for many PR workflows, which is not the commit a check + * run should be attached to. Callers should parse GITHUB_EVENT_PATH and pass the payload here. + */ +export function detectGitHubContext( + env: NodeJS.ProcessEnv, + eventPayload?: unknown, +): GitHubPullRequestContext | undefined { + const event = asGitHubEventPayload(eventPayload); + const pullRequest = event?.pull_request; + const repository = parseRepository(event?.repository?.full_name) ?? parseRepository(env.GITHUB_REPOSITORY); + const ref = nonEmptyString(env.GITHUB_REF) ?? nonEmptyString(event?.ref); + const envSha = nonEmptyString(env.GITHUB_SHA); + const eventSha = nonEmptyString(event?.after); + const pullRequestHeadSha = nonEmptyString(pullRequest?.head?.sha); + const sha = pullRequestHeadSha ?? eventSha ?? envSha; + if (!repository || !sha) return undefined; - const ref = env.GITHUB_REF?.trim() || undefined; - const baseRef = env.GITHUB_BASE_REF?.trim() || undefined; - const headRef = env.GITHUB_HEAD_REF?.trim() || undefined; - const pullRequestNumber = parsePullRequestNumber(ref); + const baseRef = nonEmptyString(pullRequest?.base?.ref) ?? nonEmptyString(env.GITHUB_BASE_REF); + const headRef = nonEmptyString(pullRequest?.head?.ref) ?? nonEmptyString(env.GITHUB_HEAD_REF); + const pullRequestNumber = positiveInteger(pullRequest?.number) ?? parsePullRequestNumber(ref); return { repository, From 19dd3d221b0e2b24216c56ed6c87931bebbf2454 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:13:57 -0400 Subject: [PATCH 0181/1132] test(github): verify PR head SHA resolution --- tests/github.test.mjs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/github.test.mjs b/tests/github.test.mjs index 5824c482..6f981f05 100644 --- a/tests/github.test.mjs +++ b/tests/github.test.mjs @@ -66,6 +66,42 @@ test("detectGitHubContext extracts safe repository and pull-request metadata", ( assert.equal(detectGitHubContext({ GITHUB_REPOSITORY: "bad repo", GITHUB_SHA: "abc" }), undefined); }); +test("pull-request event payload overrides the synthetic merge SHA", () => { + const context = detectGitHubContext( + { + GITHUB_REPOSITORY: "cmahmud/synsec", + GITHUB_SHA: "synthetic-merge-sha", + GITHUB_REF: "refs/pull/42/merge", + GITHUB_BASE_REF: "stale-base", + GITHUB_HEAD_REF: "stale-head", + }, + { + repository: { full_name: "cmahmud/synsec" }, + pull_request: { + number: 42, + head: { sha: "real-head-sha", ref: "feature/security" }, + base: { ref: "main" }, + }, + }, + ); + + assert.equal(context.sha, "real-head-sha"); + assert.equal(context.pullRequestNumber, 42); + assert.equal(context.baseRef, "main"); + assert.equal(context.headRef, "feature/security"); +}); + +test("push payload can supply repository and after SHA", () => { + assert.deepEqual( + detectGitHubContext({}, { + repository: { full_name: "cmahmud/synsec" }, + after: "push-head", + ref: "refs/heads/main", + }), + { repository: "cmahmud/synsec", sha: "push-head", ref: "refs/heads/main" }, + ); +}); + test("GitHub annotations normalize paths, collapse newlines, and use severity levels", () => { const [annotation] = buildGitHubAnnotations(report()); assert.equal(annotation.path, "src/handler.ts"); From a3c5d4b902449aac12b490789d705b488a218515 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:14:28 -0400 Subject: [PATCH 0182/1132] feat(github): load bounded Actions event context --- packages/github/src/index.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/github/src/index.ts b/packages/github/src/index.ts index 31884853..bbad5328 100644 --- a/packages/github/src/index.ts +++ b/packages/github/src/index.ts @@ -1,3 +1,4 @@ +import { readFile, stat } from "node:fs/promises"; import type { CorrelatedFinding, Severity } from "@synsec/core"; import type { SynSecReport } from "@synsec/report"; @@ -50,6 +51,8 @@ interface GitHubEventPayload { ref?: unknown; } +const MAX_GITHUB_EVENT_BYTES = 2 * 1024 * 1024; + const severityRank: Record = { critical: 5, high: 4, @@ -90,7 +93,7 @@ function asGitHubEventPayload(value: unknown): GitHubEventPayload | undefined { * * For pull_request events, the event payload is authoritative for the head SHA. GitHub exposes * GITHUB_SHA as the synthetic merge ref for many PR workflows, which is not the commit a check - * run should be attached to. Callers should parse GITHUB_EVENT_PATH and pass the payload here. + * run should be attached to. */ export function detectGitHubContext( env: NodeJS.ProcessEnv, @@ -121,6 +124,29 @@ export function detectGitHubContext( }; } +/** Load and bound the local GitHub Actions event payload, then resolve the effective context. */ +export async function loadGitHubContext( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const eventPath = nonEmptyString(env.GITHUB_EVENT_PATH); + if (!eventPath) return detectGitHubContext(env); + + const eventStat = await stat(eventPath); + if (!eventStat.isFile()) throw new Error(`GITHUB_EVENT_PATH is not a file: ${eventPath}`); + if (eventStat.size > MAX_GITHUB_EVENT_BYTES) { + throw new Error(`GitHub event payload exceeds ${MAX_GITHUB_EVENT_BYTES} bytes.`); + } + + const raw = await readFile(eventPath, "utf8"); + let payload: unknown; + try { + payload = JSON.parse(raw) as unknown; + } catch { + throw new Error(`GITHUB_EVENT_PATH does not contain valid JSON: ${eventPath}`); + } + return detectGitHubContext(env, payload); +} + function annotationLevel(severity: Severity): GitHubAnnotationLevel { if (severity === "critical" || severity === "high") return "failure"; if (severity === "medium" || severity === "low") return "warning"; From 3d4e817331b2db57ef6372c2c8a55fe18d8af6dc Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:14:54 -0400 Subject: [PATCH 0183/1132] test(github): cover Actions event-file loading --- tests/github.test.mjs | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/github.test.mjs b/tests/github.test.mjs index 6f981f05..1f5e9294 100644 --- a/tests/github.test.mjs +++ b/tests/github.test.mjs @@ -1,10 +1,14 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { buildGitHubAnnotations, buildGitHubCheck, detectGitHubContext, + loadGitHubContext, reportFailsThreshold, } from "../packages/github/dist/index.js"; @@ -102,6 +106,43 @@ test("push payload can supply repository and after SHA", () => { ); }); +test("loadGitHubContext reads a bounded local Actions event payload", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-github-")); + const eventPath = join(root, "event.json"); + await writeFile(eventPath, JSON.stringify({ + repository: { full_name: "cmahmud/synsec" }, + pull_request: { + number: 7, + head: { sha: "head-seven", ref: "feature/seven" }, + base: { ref: "main" }, + }, + })); + + try { + const context = await loadGitHubContext({ + GITHUB_EVENT_PATH: eventPath, + GITHUB_SHA: "merge-seven", + GITHUB_REF: "refs/pull/7/merge", + }); + assert.equal(context.sha, "head-seven"); + assert.equal(context.repository, "cmahmud/synsec"); + assert.equal(context.pullRequestNumber, 7); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("loadGitHubContext rejects invalid event JSON instead of guessing", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-github-invalid-")); + const eventPath = join(root, "event.json"); + await writeFile(eventPath, "{not-json"); + try { + await assert.rejects(() => loadGitHubContext({ GITHUB_EVENT_PATH: eventPath }), /does not contain valid JSON/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("GitHub annotations normalize paths, collapse newlines, and use severity levels", () => { const [annotation] = buildGitHubAnnotations(report()); assert.equal(annotation.path, "src/handler.ts"); From bae8241cdbb0fa125ee6dfb9f5ae0e6a71b214d3 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:15:13 -0400 Subject: [PATCH 0184/1132] docs: define GitHub integration boundaries and check flow --- docs/GITHUB.md | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/GITHUB.md diff --git a/docs/GITHUB.md b/docs/GITHUB.md new file mode 100644 index 00000000..839c496d --- /dev/null +++ b/docs/GITHUB.md @@ -0,0 +1,79 @@ +# GitHub integration + +SynSec's GitHub integration is intentionally split into two layers: + +1. **Repository security analysis** stays inside the normal scanner/report pipeline. +2. **GitHub publication** converts a completed SynSec report into GitHub-native check output and annotations. + +This keeps GitHub credentials out of scanners and prevents repository analysis from silently expanding into unrelated network targets. + +## Current integration primitives + +`@synsec/github` provides: + +- GitHub Actions context detection from environment variables. +- Bounded parsing of `GITHUB_EVENT_PATH`. +- Correct pull-request head SHA selection from the event payload instead of the synthetic merge SHA. +- Pull-request number, base branch, and head branch resolution. +- Conversion of a `SynSecReport` into a check-run result. +- Source annotations for findings with file/line locations. +- Severity-aware annotation levels. +- Baseline-aware annotation filtering so PR checks can focus on new findings. +- A hard 50-annotation cap per generated payload, matching GitHub's check-run annotation request limit. +- CI threshold evaluation independent of scanner exit-code quirks. + +The package does **not** currently make authenticated GitHub API calls. A future GitHub App or Actions adapter should own credential use and transport while reusing these deterministic primitives. + +## Pull-request SHA handling + +GitHub Actions commonly sets `GITHUB_SHA` to a synthetic merge commit for `pull_request` workflows. Publishing a check against that SHA can make the check appear on the wrong commit or disappear when the synthetic merge ref changes. + +For PR events, SynSec therefore prefers: + +```text +pull_request.head.sha +``` + +from the local Actions event payload. `loadGitHubContext()` reads `GITHUB_EVENT_PATH`, rejects non-files, refuses event payloads larger than 2 MiB, parses JSON locally, and then resolves the effective repository/commit context. + +No network request is required for context detection. + +## Check conclusions + +The generated check conclusion follows the configured severity threshold: + +- `failure` when at least one finding meets or exceeds the threshold. +- `neutral` when findings exist but none meets the threshold. +- `success` when the report contains no findings. + +This is deliberately separate from individual scanner process exit codes. Scanner failures and scan completeness remain engine/report concerns; GitHub publishing consumes the completed normalized report. + +## Inline annotations + +Only findings with a concrete repository path and start line can become GitHub annotations. Paths are normalized to forward slashes and leading `./` is removed. High/critical findings map to `failure`, medium/low to `warning`, and informational/unknown findings to `notice`. + +When a report includes a baseline, `buildGitHubCheck()` defaults to annotating only newly introduced findings. Persisting findings remain represented in the report summary without repeatedly flooding pull-request annotations. + +## Security boundaries + +GitHub integration must preserve the repository-first defensive model: + +- Tokens belong to the GitHub transport layer, never scanner input. +- Report and annotation generation must not require network access. +- Source excerpts are not added to GitHub annotations unless already present in normalized deterministic finding fields. +- Secret values must remain redacted before publication. +- A future remediation pull-request flow must require explicit approval before repository writes. +- Repository installation must not authorize live-target exploitation, target expansion, persistence, or secret exfiltration. + +## Next implementation steps + +The remaining Phase 5 work is transport and product integration rather than report semantics: + +1. Add a GitHub App installation/authentication layer. +2. Publish generated check results through the Checks API. +3. Wire PR events to changed-file scans and baseline selection. +4. Upload SARIF to GitHub code scanning where repository permissions allow it. +5. Add scheduled repository scans. +6. Add explicitly approved remediation pull requests. + +The deterministic package should remain usable from both a GitHub App and a GitHub Actions integration so the scanning core does not become hosting-provider-specific. From e3029fa253f254162d278bc56a7c918c3a2bc950 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:15:25 -0400 Subject: [PATCH 0185/1132] docs: track GitHub integration primitives --- docs/ROADMAP.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 35f14f32..16d225e4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -86,15 +86,20 @@ These workflows operate on repository evidence and scanner results. They are not ## Phase 5 — Git hosting and CI +- [x] GitHub Actions context/event parsing primitives +- [x] Deterministic GitHub check-result and inline-annotation generation +- [x] Baseline-aware PR annotation filtering and severity-threshold conclusions - [ ] GitHub App - [ ] Repository installation flow -- [ ] Pull-request scanning -- [ ] Commit status / checks -- [ ] Inline SARIF/code-scanning findings +- [ ] Pull-request scanning transport/orchestration +- [ ] Commit status / checks API publication +- [ ] Inline SARIF/code-scanning upload - [ ] Scheduled repository scans - [ ] Optional remediation pull requests with explicit approval - [ ] GitLab and Bitbucket adapters +See [GITHUB.md](./GITHUB.md) for the current integration contract and security boundaries. + ## Phase 6 — Persistent web application - [ ] Project/repository dashboard From 917444a418457e05d892c5246ae2223887874e3b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:16:25 -0400 Subject: [PATCH 0186/1132] feat(repository): add bounded lexical call graph --- packages/repository/src/call-graph.ts | 317 ++++++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 packages/repository/src/call-graph.ts diff --git a/packages/repository/src/call-graph.ts b/packages/repository/src/call-graph.ts new file mode 100644 index 00000000..9dde0004 --- /dev/null +++ b/packages/repository/src/call-graph.ts @@ -0,0 +1,317 @@ +import { readFile, stat } from "node:fs/promises"; +import { extname, resolve } from "node:path"; +import type { IndexFileInput } from "./analysis.js"; + +export type CallGraphNodeKind = "function" | "arrow-function" | "python-function"; +export type CallResolution = "same-file-function" | "external-or-unresolved"; + +export interface CallGraphNode { + id: string; + path: string; + name: string; + line: number; + endLine: number; + kind: CallGraphNodeKind; +} + +export interface CallGraphEdge { + from: string; + callee: string; + line: number; + target?: string; + resolution: CallResolution; +} + +export interface CallGraph { + schemaVersion: 1; + nodes: CallGraphNode[]; + edges: CallGraphEdge[]; + resolvedEdgeCount: number; + unresolvedEdgeCount: number; + skippedFiles: Array<{ path: string; reason: string }>; + /** Regex/lexical evidence is useful for review prioritization, not proof of runtime reachability. */ + interpretation: "lexical-call-evidence-only"; +} + +export interface CallNeighborhood { + root: string; + maxDepth: number; + callees: Array<{ id: string; depth: number }>; + callers: Array<{ id: string; depth: number }>; + interpretation: "lexical-call-evidence-only"; +} + +const MAX_SOURCE_BYTES = 512_000; +const MAX_FILES = 5_000; +const MAX_FUNCTIONS_PER_FILE = 500; +const MAX_CALLS_PER_FUNCTION = 500; +const jsExtensions = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"]); +const ignoredCalls = new Set([ + "if", "for", "while", "switch", "catch", "function", "return", "typeof", "new", "await", "import", + "require", "super", "this", "console", "Math", "JSON", "Object", "Array", "String", "Number", "Boolean", +]); + +interface ParsedFunction { + node: CallGraphNode; + bodyStart: number; + bodyEnd: number; +} + +function normalizedPath(path: string): string { + return path.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function nodeId(path: string, name: string, line: number): string { + return `${normalizedPath(path)}:${name}:${line}`; +} + +function braceDelta(line: string): number { + let delta = 0; + let quote: "'" | '"' | "`" | undefined; + let escaped = false; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const next = line[index + 1]; + if (escaped) { + escaped = false; + continue; + } + if (quote) { + if (char === "\\") escaped = true; + else if (char === quote) quote = undefined; + continue; + } + if (char === "'" || char === '"' || char === "`") { + quote = char; + continue; + } + if (char === "/" && next === "/") break; + if (char === "{") delta += 1; + else if (char === "}") delta -= 1; + } + return delta; +} + +function parseJavascriptFunctions(path: string, content: string): ParsedFunction[] { + const lines = content.split(/\r?\n/); + const functions: ParsedFunction[] = []; + let depth = 0; + + for (let index = 0; index < lines.length && functions.length < MAX_FUNCTIONS_PER_FILE; index += 1) { + const line = lines[index] ?? ""; + const declaration = line.match(/\b(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/); + const arrow = line.match(/\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/); + const name = declaration?.[1] ?? arrow?.[1]; + const delta = braceDelta(line); + + if (name) { + const startDepth = depth; + let runningDepth = depth + delta; + let endIndex = index; + const hasBlock = line.includes("{") || delta > 0; + + if (hasBlock) { + for (let cursor = index + 1; cursor < lines.length; cursor += 1) { + runningDepth += braceDelta(lines[cursor] ?? ""); + endIndex = cursor; + if (runningDepth <= startDepth) break; + } + } + + functions.push({ + node: { + id: nodeId(path, name, index + 1), + path: normalizedPath(path), + name, + line: index + 1, + endLine: endIndex + 1, + kind: declaration ? "function" : "arrow-function", + }, + bodyStart: index, + bodyEnd: endIndex, + }); + } + + depth += delta; + } + return functions; +} + +function leadingIndent(line: string): number { + const prefix = line.match(/^[ \t]*/)?.[0] ?? ""; + return [...prefix].reduce((total, char) => total + (char === "\t" ? 4 : 1), 0); +} + +function parsePythonFunctions(path: string, content: string): ParsedFunction[] { + const lines = content.split(/\r?\n/); + const functions: ParsedFunction[] = []; + + for (let index = 0; index < lines.length && functions.length < MAX_FUNCTIONS_PER_FILE; index += 1) { + const line = lines[index] ?? ""; + const declaration = line.match(/^\s*(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/); + const name = declaration?.[1]; + if (!name) continue; + + const indent = leadingIndent(line); + let endIndex = index; + for (let cursor = index + 1; cursor < lines.length; cursor += 1) { + const candidate = lines[cursor] ?? ""; + if (!candidate.trim() || candidate.trimStart().startsWith("#")) { + endIndex = cursor; + continue; + } + if (leadingIndent(candidate) <= indent) break; + endIndex = cursor; + } + + functions.push({ + node: { + id: nodeId(path, name, index + 1), + path: normalizedPath(path), + name, + line: index + 1, + endLine: endIndex + 1, + kind: "python-function", + }, + bodyStart: index, + bodyEnd: endIndex, + }); + } + return functions; +} + +function collectCalls(lines: readonly string[], fn: ParsedFunction): Array<{ callee: string; line: number; direct: boolean }> { + const calls: Array<{ callee: string; line: number; direct: boolean }> = []; + const regex = /\b([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)\s*\(/g; + + for (let index = fn.bodyStart; index <= fn.bodyEnd && calls.length < MAX_CALLS_PER_FUNCTION; index += 1) { + const line = lines[index] ?? ""; + regex.lastIndex = 0; + for (let match = regex.exec(line); match && calls.length < MAX_CALLS_PER_FUNCTION; match = regex.exec(line)) { + const callee = match[1]; + if (!callee || ignoredCalls.has(callee)) continue; + if (index === fn.bodyStart && callee === fn.node.name) continue; + calls.push({ callee, line: index + 1, direct: !callee.includes(".") }); + } + } + return calls; +} + +async function readBoundedSource(root: string, file: IndexFileInput): Promise<{ content?: string; reason?: string }> { + if (file.size > MAX_SOURCE_BYTES) return { reason: `source exceeds ${MAX_SOURCE_BYTES} bytes` }; + const absolute = resolve(root, file.path); + let fileStat; + try { + fileStat = await stat(absolute); + } catch { + return { reason: "source file is unavailable" }; + } + if (!fileStat.isFile()) return { reason: "path is not a regular file" }; + if (fileStat.size > MAX_SOURCE_BYTES) return { reason: `source exceeds ${MAX_SOURCE_BYTES} bytes` }; + return { content: await readFile(absolute, "utf8") }; +} + +export async function buildCallGraph(root: string, files: readonly IndexFileInput[]): Promise { + const selected = files.slice(0, MAX_FILES); + const nodes: CallGraphNode[] = []; + const edges: CallGraphEdge[] = []; + const skippedFiles: Array<{ path: string; reason: string }> = []; + + for (const file of selected) { + const extension = extname(file.path).toLowerCase(); + if (!jsExtensions.has(extension) && extension !== ".py") continue; + + const source = await readBoundedSource(root, file); + if (!source.content) { + skippedFiles.push({ path: normalizedPath(file.path), reason: source.reason ?? "source unavailable" }); + continue; + } + + const parsed = extension === ".py" + ? parsePythonFunctions(file.path, source.content) + : parseJavascriptFunctions(file.path, source.content); + const lines = source.content.split(/\r?\n/); + const sameFileByName = new Map(); + for (const fn of parsed) { + nodes.push(fn.node); + const bucket = sameFileByName.get(fn.node.name) ?? []; + bucket.push(fn.node); + sameFileByName.set(fn.node.name, bucket); + } + + for (const fn of parsed) { + for (const call of collectCalls(lines, fn)) { + const candidates = call.direct ? sameFileByName.get(call.callee) ?? [] : []; + const target = candidates.length === 1 ? candidates[0] : undefined; + edges.push({ + from: fn.node.id, + callee: call.callee, + line: call.line, + ...(target ? { target: target.id } : {}), + resolution: target ? "same-file-function" : "external-or-unresolved", + }); + } + } + } + + const resolvedEdgeCount = edges.filter((edge) => Boolean(edge.target)).length; + return { + schemaVersion: 1, + nodes: nodes.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.name.localeCompare(b.name)), + edges, + resolvedEdgeCount, + unresolvedEdgeCount: edges.length - resolvedEdgeCount, + skippedFiles, + interpretation: "lexical-call-evidence-only", + }; +} + +function traverse( + graph: CallGraph, + root: string, + direction: "callees" | "callers", + maxDepth: number, + maxNodes: number, +): Array<{ id: string; depth: number }> { + const boundedDepth = Math.max(0, maxDepth); + const boundedNodes = Math.max(1, maxNodes); + const queue: Array<{ id: string; depth: number }> = [{ id: root, depth: 0 }]; + const seen = new Set([root]); + const output: Array<{ id: string; depth: number }> = []; + + while (queue.length > 0 && output.length < boundedNodes) { + const current = queue.shift(); + if (!current || current.depth >= boundedDepth) continue; + const adjacent = graph.edges.flatMap((edge) => { + if (!edge.target) return []; + if (direction === "callees" && edge.from === current.id) return [edge.target]; + if (direction === "callers" && edge.target === current.id) return [edge.from]; + return []; + }); + + for (const id of [...new Set(adjacent)].sort()) { + if (seen.has(id)) continue; + seen.add(id); + const next = { id, depth: current.depth + 1 }; + output.push(next); + queue.push(next); + if (output.length >= boundedNodes) break; + } + } + return output; +} + +export function findCallNeighborhood( + graph: CallGraph, + root: string, + maxDepth = 3, + maxNodesPerDirection = 100, +): CallNeighborhood { + return { + root, + maxDepth: Math.max(0, maxDepth), + callees: traverse(graph, root, "callees", maxDepth, maxNodesPerDirection), + callers: traverse(graph, root, "callers", maxDepth, maxNodesPerDirection), + interpretation: "lexical-call-evidence-only", + }; +} From 47fb233163f01fa84cb948c6085444017c7930da Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:16:32 -0400 Subject: [PATCH 0187/1132] build(repository): export call graph API --- packages/repository/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/repository/package.json b/packages/repository/package.json index 4a125c50..14429afc 100644 --- a/packages/repository/package.json +++ b/packages/repository/package.json @@ -6,7 +6,8 @@ "exports": { ".": "./dist/index.js", "./analysis": "./dist/analysis.js", - "./module-graph": "./dist/module-graph.js" + "./module-graph": "./dist/module-graph.js", + "./call-graph": "./dist/call-graph.js" }, "types": "./dist/index.d.ts", "scripts": { From feeaca699d5595b6b38220175ca5910ab77c4040 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:16:50 -0400 Subject: [PATCH 0188/1132] test(repository): cover bounded lexical call graph --- tests/call-graph.test.mjs | 100 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/call-graph.test.mjs diff --git a/tests/call-graph.test.mjs b/tests/call-graph.test.mjs new file mode 100644 index 00000000..775f782d --- /dev/null +++ b/tests/call-graph.test.mjs @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { buildCallGraph, findCallNeighborhood } from "../packages/repository/dist/call-graph.js"; + +async function fixture(files) { + const root = await mkdtemp(join(tmpdir(), "synsec-call-graph-")); + const index = []; + for (const [path, content] of Object.entries(files)) { + const absolute = join(root, path); + await mkdir(join(absolute, ".."), { recursive: true }); + await writeFile(absolute, content, "utf8"); + index.push({ path, size: Buffer.byteLength(content) }); + } + return { root, index }; +} + +test("buildCallGraph resolves direct same-file JavaScript calls conservatively", async () => { + const { root, index } = await fixture({ + "src/service.ts": [ + "export function validate(input: string) {", + " return input.length > 0;", + "}", + "", + "export async function handle(input: string) {", + " if (!validate(input)) return false;", + " await externalClient.send(input);", + " return persist(input);", + "}", + "", + "const persist = (input: string) => {", + " return Boolean(input);", + "};", + ].join("\n"), + }); + + try { + const graph = await buildCallGraph(root, index); + assert.equal(graph.interpretation, "lexical-call-evidence-only"); + assert.deepEqual(graph.nodes.map((node) => node.name), ["validate", "handle", "persist"]); + + const validate = graph.nodes.find((node) => node.name === "validate"); + const handle = graph.nodes.find((node) => node.name === "handle"); + const persist = graph.nodes.find((node) => node.name === "persist"); + assert.ok(validate && handle && persist); + + assert.ok(graph.edges.some((edge) => edge.from === handle.id && edge.callee === "validate" && edge.target === validate.id)); + assert.ok(graph.edges.some((edge) => edge.from === handle.id && edge.callee === "persist" && edge.target === persist.id)); + assert.ok(graph.edges.some((edge) => edge.from === handle.id && edge.callee === "externalClient.send" && edge.resolution === "external-or-unresolved")); + + const neighborhood = findCallNeighborhood(graph, handle.id, 2); + assert.deepEqual(neighborhood.callees.map((item) => item.id).sort(), [persist.id, validate.id].sort()); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("buildCallGraph understands Python def indentation and direct calls", async () => { + const { root, index } = await fixture({ + "app.py": [ + "def load_user(user_id):", + " return db.fetch(user_id)", + "", + "def handler(user_id):", + " user = load_user(user_id)", + " return render(user)", + "", + "def render(user):", + " return str(user)", + ].join("\n"), + }); + + try { + const graph = await buildCallGraph(root, index); + const handler = graph.nodes.find((node) => node.name === "handler"); + const loadUser = graph.nodes.find((node) => node.name === "load_user"); + const render = graph.nodes.find((node) => node.name === "render"); + assert.ok(handler && loadUser && render); + assert.ok(graph.edges.some((edge) => edge.from === handler.id && edge.target === loadUser.id)); + assert.ok(graph.edges.some((edge) => edge.from === handler.id && edge.target === render.id)); + assert.ok(graph.edges.some((edge) => edge.from === loadUser.id && edge.callee === "db.fetch" && !edge.target)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("call graph skips oversized source files rather than reading them", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-call-graph-large-")); + try { + const graph = await buildCallGraph(root, [{ path: "large.ts", size: 600_000 }]); + assert.equal(graph.nodes.length, 0); + assert.equal(graph.skippedFiles.length, 1); + assert.match(graph.skippedFiles[0].reason, /exceeds/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From b629b9d222f41a997c408ea0de1540d7a38393d1 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:17:10 -0400 Subject: [PATCH 0189/1132] test(repository): use stable fixture directories --- tests/call-graph.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/call-graph.test.mjs b/tests/call-graph.test.mjs index 775f782d..c1175943 100644 --- a/tests/call-graph.test.mjs +++ b/tests/call-graph.test.mjs @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { buildCallGraph, findCallNeighborhood } from "../packages/repository/dist/call-graph.js"; @@ -11,7 +11,7 @@ async function fixture(files) { const index = []; for (const [path, content] of Object.entries(files)) { const absolute = join(root, path); - await mkdir(join(absolute, ".."), { recursive: true }); + await mkdir(dirname(absolute), { recursive: true }); await writeFile(absolute, content, "utf8"); index.push({ path, size: Buffer.byteLength(content) }); } From 9dcc9e57f25be26e7ee6ece6ac1d4fff6027a7fc Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:17:46 -0400 Subject: [PATCH 0190/1132] docs: track lexical call graph progress --- docs/ROADMAP.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 16d225e4..249b92ce 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -45,13 +45,16 @@ This roadmap separates what is already usable in the repository from the deeper - [x] Safe bounded finding-to-code context retrieval - [x] Persistent repository index - [x] Import/module graph with bounded dependency/dependent traversal -- [ ] Function/call graph +- [x] Bounded same-file lexical call-graph primitive for JavaScript/TypeScript and Python +- [ ] Full function/call graph with reliable cross-module symbol resolution - [ ] Routes and externally reachable entry points - [ ] Authentication/authorization context - [ ] Database, filesystem, process, and network sinks - [ ] Dependency reachability beyond scanner-provided call analysis - [ ] Test ownership and coverage context around findings +The current call graph is deliberately labeled lexical evidence rather than runtime reachability. It resolves unambiguous direct same-file calls and leaves qualified, external, or ambiguous calls unresolved. + ## Phase 3 — Contextual security review - [x] Provider-agnostic OpenAI-compatible AI review adapter From 10190afa59312b186c1a4833676a78b98f7319d0 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:18:12 -0400 Subject: [PATCH 0191/1132] feat(github): add narrow Checks API publisher --- packages/github/src/publisher.ts | 109 +++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 packages/github/src/publisher.ts diff --git a/packages/github/src/publisher.ts b/packages/github/src/publisher.ts new file mode 100644 index 00000000..f99e91ea --- /dev/null +++ b/packages/github/src/publisher.ts @@ -0,0 +1,109 @@ +import type { GitHubCheckResult, GitHubPullRequestContext } from "./index.js"; + +export interface GitHubCheckPublication { + id: number; + htmlUrl?: string; + status?: string; + conclusion?: string; +} + +export interface GitHubPublisherOptions { + apiVersion?: string; + userAgent?: string; + fetch?: typeof globalThis.fetch; +} + +interface GitHubCheckRunResponse { + id?: unknown; + html_url?: unknown; + status?: unknown; + conclusion?: unknown; +} + +function repositoryParts(repository: string): { owner: string; name: string } { + const match = /^([^/\s]+)\/([^/\s]+)$/.exec(repository.trim()); + if (!match?.[1] || !match[2]) throw new Error(`Invalid GitHub repository: ${repository}`); + return { owner: match[1], name: match[2] }; +} + +function nonEmptyToken(token: string): string { + const normalized = token.trim(); + if (!normalized) throw new Error("A GitHub token is required to publish a check run."); + return normalized; +} + +function responseString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function responseNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +export function toGitHubCheckRunRequest(check: GitHubCheckResult): Record { + return { + name: check.name, + head_sha: check.headSha, + status: "completed", + conclusion: check.conclusion, + output: check.output, + }; +} + +/** + * Publish a completed SynSec check to GitHub's Checks API. + * + * The destination host is fixed to api.github.com and the repository comes from validated + * GitHub context. Scanner output never controls a request URL. The bearer token is used only + * in the Authorization header and is never included in returned errors. + */ +export async function publishGitHubCheck( + check: GitHubCheckResult, + context: GitHubPullRequestContext, + token: string, + options: GitHubPublisherOptions = {}, +): Promise { + const authToken = nonEmptyToken(token); + const { owner, name } = repositoryParts(context.repository); + const fetchImpl = options.fetch ?? globalThis.fetch; + if (!fetchImpl) throw new Error("No fetch implementation is available for GitHub check publication."); + + const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/check-runs`; + const response = await fetchImpl(url, { + method: "POST", + redirect: "error", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + "User-Agent": options.userAgent?.trim() || "synsec/0.2", + "X-GitHub-Api-Version": options.apiVersion?.trim() || "2022-11-28", + }, + body: JSON.stringify(toGitHubCheckRunRequest(check)), + }); + + const text = await response.text(); + if (!response.ok) { + const detail = text.replace(/[\r\n]+/g, " ").slice(0, 500).trim(); + throw new Error(`GitHub Checks API returned HTTP ${response.status}${detail ? `: ${detail}` : "."}`); + } + + let payload: GitHubCheckRunResponse; + try { + payload = text ? (JSON.parse(text) as GitHubCheckRunResponse) : {}; + } catch { + throw new Error("GitHub Checks API returned invalid JSON."); + } + + const id = responseNumber(payload.id); + if (!id) throw new Error("GitHub Checks API response did not include a valid check-run id."); + const htmlUrl = responseString(payload.html_url); + const status = responseString(payload.status); + const conclusion = responseString(payload.conclusion); + return { + id, + ...(htmlUrl ? { htmlUrl } : {}), + ...(status ? { status } : {}), + ...(conclusion ? { conclusion } : {}), + }; +} From 864443342ee31978f05f2b0ff465149cddca4233 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:18:20 -0400 Subject: [PATCH 0192/1132] build(github): export check publisher --- packages/github/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/github/package.json b/packages/github/package.json index a6b6c3cb..8c113021 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "exports": { - ".": "./dist/index.js" + ".": "./dist/index.js", + "./publisher": "./dist/publisher.js" }, "types": "./dist/index.d.ts", "scripts": { From be9d3a1e37a6a6b6a9ef1786d1e9206e815e7cc9 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:18:33 -0400 Subject: [PATCH 0193/1132] test(github): cover Checks API publication boundaries --- tests/github-publisher.test.mjs | 78 +++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/github-publisher.test.mjs diff --git a/tests/github-publisher.test.mjs b/tests/github-publisher.test.mjs new file mode 100644 index 00000000..bfdde984 --- /dev/null +++ b/tests/github-publisher.test.mjs @@ -0,0 +1,78 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { publishGitHubCheck, toGitHubCheckRunRequest } from "../packages/github/dist/publisher.js"; + +const context = { repository: "cmahmud/synsec", sha: "head-sha" }; +const check = { + name: "SynSec repository security", + headSha: "head-sha", + conclusion: "failure", + output: { + title: "SynSec found findings at or above high", + summary: "High: **1**", + text: "Report report-1 scanned changed files.", + annotations: [{ + path: "src/app.ts", + start_line: 4, + end_line: 4, + annotation_level: "failure", + title: "[HIGH] Unsafe input", + message: "Unsafe input reaches a sink.", + }], + }, +}; + +test("toGitHubCheckRunRequest emits a completed check-run payload", () => { + assert.deepEqual(toGitHubCheckRunRequest(check), { + name: check.name, + head_sha: "head-sha", + status: "completed", + conclusion: "failure", + output: check.output, + }); +}); + +test("publishGitHubCheck posts only to the fixed GitHub Checks API endpoint", async () => { + let request; + const fakeFetch = async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ + id: 123, + html_url: "https://github.com/cmahmud/synsec/runs/123", + status: "completed", + conclusion: "failure", + }), { status: 201, headers: { "content-type": "application/json" } }); + }; + + const published = await publishGitHubCheck(check, context, "installation-token", { fetch: fakeFetch }); + assert.equal(request.url, "https://api.github.com/repos/cmahmud/synsec/check-runs"); + assert.equal(request.init.method, "POST"); + assert.equal(request.init.redirect, "error"); + assert.equal(request.init.headers.Authorization, "Bearer installation-token"); + assert.equal(JSON.parse(request.init.body).head_sha, "head-sha"); + assert.deepEqual(published, { + id: 123, + htmlUrl: "https://github.com/cmahmud/synsec/runs/123", + status: "completed", + conclusion: "failure", + }); +}); + +test("publishGitHubCheck fails closed on invalid repository or missing token", async () => { + await assert.rejects(() => publishGitHubCheck(check, { repository: "not a repo", sha: "x" }, "token", { fetch: async () => { throw new Error("should not run"); } }), /Invalid GitHub repository/); + await assert.rejects(() => publishGitHubCheck(check, context, " ", { fetch: async () => { throw new Error("should not run"); } }), /token is required/); +}); + +test("publisher surfaces API errors without including the bearer token", async () => { + const secret = "very-secret-token"; + const fakeFetch = async () => new Response(JSON.stringify({ message: "Resource not accessible by integration" }), { status: 403 }); + await assert.rejects( + () => publishGitHubCheck(check, context, secret, { fetch: fakeFetch }), + (error) => { + assert.match(error.message, /HTTP 403/); + assert.equal(error.message.includes(secret), false); + return true; + }, + ); +}); From f24d3d78663edef20b17014da97bbf507eb62f00 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:18:53 -0400 Subject: [PATCH 0194/1132] docs: document Checks API publisher --- docs/GITHUB.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/GITHUB.md b/docs/GITHUB.md index 839c496d..2137c98b 100644 --- a/docs/GITHUB.md +++ b/docs/GITHUB.md @@ -21,8 +21,11 @@ This keeps GitHub credentials out of scanners and prevents repository analysis f - Baseline-aware annotation filtering so PR checks can focus on new findings. - A hard 50-annotation cap per generated payload, matching GitHub's check-run annotation request limit. - CI threshold evaluation independent of scanner exit-code quirks. +- A narrow Checks API publisher with an injectable transport for testing. -The package does **not** currently make authenticated GitHub API calls. A future GitHub App or Actions adapter should own credential use and transport while reusing these deterministic primitives. +`@synsec/github/publisher` posts completed check runs only to `https://api.github.com/repos///check-runs`. The repository comes from validated GitHub context, scanner output cannot control the request URL, redirects are rejected, and bearer tokens are never copied into returned errors. + +A future GitHub App or Actions adapter should own token acquisition and installation authorization while reusing these deterministic publication primitives. ## Pull-request SHA handling @@ -54,12 +57,26 @@ Only findings with a concrete repository path and start line can become GitHub a When a report includes a baseline, `buildGitHubCheck()` defaults to annotating only newly introduced findings. Persisting findings remain represented in the report summary without repeatedly flooding pull-request annotations. +## Checks API publication + +`publishGitHubCheck()` accepts a completed check result, validated GitHub context, and a caller-supplied token. The publisher: + +- sends one `POST` to the repository Checks API endpoint; +- uses GitHub API version `2022-11-28` by default; +- sends the token only in the `Authorization` header; +- rejects redirects; +- validates the returned check-run id; +- returns only publication metadata such as id, URL, status, and conclusion. + +Token acquisition is intentionally outside this function. GitHub App installation tokens, Actions `GITHUB_TOKEN`, and any future enterprise-hosting transport should remain separate concerns so credentials never enter scanners or normalized reports. + ## Security boundaries GitHub integration must preserve the repository-first defensive model: - Tokens belong to the GitHub transport layer, never scanner input. - Report and annotation generation must not require network access. +- Scanner output must never choose the GitHub API host or arbitrary publication URL. - Source excerpts are not added to GitHub annotations unless already present in normalized deterministic finding fields. - Secret values must remain redacted before publication. - A future remediation pull-request flow must require explicit approval before repository writes. @@ -67,11 +84,11 @@ GitHub integration must preserve the repository-first defensive model: ## Next implementation steps -The remaining Phase 5 work is transport and product integration rather than report semantics: +The remaining Phase 5 work is product orchestration and installation/authentication: 1. Add a GitHub App installation/authentication layer. -2. Publish generated check results through the Checks API. -3. Wire PR events to changed-file scans and baseline selection. +2. Wire PR events to changed-file scans and baseline selection. +3. Invoke check publication from the PR orchestration path. 4. Upload SARIF to GitHub code scanning where repository permissions allow it. 5. Add scheduled repository scans. 6. Add explicitly approved remediation pull requests. From d07c29ad89e9ea85b4977fd6d2593daa3805d466 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:19:44 -0400 Subject: [PATCH 0195/1132] feat(report): add deterministic scan history aggregation --- packages/report/src/history.ts | 143 +++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 packages/report/src/history.ts diff --git a/packages/report/src/history.ts b/packages/report/src/history.ts new file mode 100644 index 00000000..fa20c1a8 --- /dev/null +++ b/packages/report/src/history.ts @@ -0,0 +1,143 @@ +import type { Severity } from "@synsec/core"; +import type { SeverityCounts, SynSecReport } from "./index.js"; + +export interface ReportHistoryPoint { + reportId: string; + generatedAt: string; + commitSha?: string; + branch?: string; + securityScore: number; + findingCount: number; + summary: SeverityCounts; + newCount: number; + fixedCount: number; + persistingCount: number; +} + +export interface FindingHistory { + fingerprint: string; + title: string; + highestSeverity: Severity; + firstSeenAt: string; + lastSeenAt: string; + occurrenceCount: number; + presentInLatest: boolean; +} + +export interface ReportHistory { + schemaVersion: 1; + points: ReportHistoryPoint[]; + findings: FindingHistory[]; + scoreDelta: number; + findingCountDelta: number; +} + +const severityRank: Record = { + critical: 5, + high: 4, + medium: 3, + low: 2, + info: 1, + unknown: 0, +}; + +function timestamp(report: SynSecReport): number { + const value = Date.parse(report.generatedAt); + if (!Number.isFinite(value)) throw new Error(`Report ${report.reportId} has an invalid generatedAt timestamp.`); + return value; +} + +function fingerprints(report: SynSecReport): Set { + return new Set(report.findings.map((finding) => finding.fingerprint)); +} + +function deltaCounts(previous: SynSecReport | undefined, current: SynSecReport): Pick { + if (!previous) { + return { newCount: current.findingCount, fixedCount: 0, persistingCount: 0 }; + } + + const before = fingerprints(previous); + const after = fingerprints(current); + let newCount = 0; + let fixedCount = 0; + let persistingCount = 0; + for (const fingerprint of after) { + if (before.has(fingerprint)) persistingCount += 1; + else newCount += 1; + } + for (const fingerprint of before) { + if (!after.has(fingerprint)) fixedCount += 1; + } + return { newCount, fixedCount, persistingCount }; +} + +export function buildReportHistory(reports: readonly SynSecReport[]): ReportHistory { + if (reports.length === 0) { + return { schemaVersion: 1, points: [], findings: [], scoreDelta: 0, findingCountDelta: 0 }; + } + + const ids = new Set(); + for (const report of reports) { + if (ids.has(report.reportId)) throw new Error(`Duplicate report id in history: ${report.reportId}`); + ids.add(report.reportId); + } + + const ordered = [...reports].sort((a, b) => timestamp(a) - timestamp(b) || a.reportId.localeCompare(b.reportId)); + const points: ReportHistoryPoint[] = []; + const findingMap = new Map(); + let previous: SynSecReport | undefined; + + for (const report of ordered) { + const delta = deltaCounts(previous, report); + points.push({ + reportId: report.reportId, + generatedAt: report.generatedAt, + ...(report.target.commitSha ? { commitSha: report.target.commitSha } : {}), + ...(report.target.branch ? { branch: report.target.branch } : {}), + securityScore: report.securityScore, + findingCount: report.findingCount, + summary: { ...report.summary }, + ...delta, + }); + + for (const correlated of report.findings) { + const finding = correlated.primary; + const existing = findingMap.get(correlated.fingerprint); + if (!existing) { + findingMap.set(correlated.fingerprint, { + fingerprint: correlated.fingerprint, + title: finding.title, + highestSeverity: finding.severity, + firstSeenAt: report.generatedAt, + lastSeenAt: report.generatedAt, + occurrenceCount: 1, + presentInLatest: false, + }); + continue; + } + existing.lastSeenAt = report.generatedAt; + existing.occurrenceCount += 1; + if (severityRank[finding.severity] > severityRank[existing.highestSeverity]) { + existing.highestSeverity = finding.severity; + existing.title = finding.title; + } + } + previous = report; + } + + const latest = ordered.at(-1); + const latestFingerprints = latest ? fingerprints(latest) : new Set(); + const findings = [...findingMap.values()] + .map((finding) => ({ ...finding, presentInLatest: latestFingerprints.has(finding.fingerprint) })) + .sort((a, b) => severityRank[b.highestSeverity] - severityRank[a.highestSeverity] || a.firstSeenAt.localeCompare(b.firstSeenAt) || a.fingerprint.localeCompare(b.fingerprint)); + + const first = points[0]; + const last = points.at(-1); + return { + schemaVersion: 1, + points, + findings, + scoreDelta: first && last ? last.securityScore - first.securityScore : 0, + findingCountDelta: first && last ? last.findingCount - first.findingCount : 0, + }; +} From b3524d839018f42aa9fb9ff289dde3c28b6ff8be Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:19:49 -0400 Subject: [PATCH 0196/1132] build(report): export history aggregation API --- packages/report/package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/report/package.json b/packages/report/package.json index 4c523dad..e9e5d84d 100644 --- a/packages/report/package.json +++ b/packages/report/package.json @@ -11,6 +11,10 @@ "./markdown": { "types": "./dist/markdown.d.ts", "import": "./dist/markdown.js" + }, + "./history": { + "types": "./dist/history.d.ts", + "import": "./dist/history.js" } }, "types": "./dist/index.d.ts", From 459590b4f73750572e39de6bf508396075757b4b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:20:01 -0400 Subject: [PATCH 0197/1132] test(report): cover scan history and finding churn --- tests/report-history.test.mjs | 97 +++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/report-history.test.mjs diff --git a/tests/report-history.test.mjs b/tests/report-history.test.mjs new file mode 100644 index 00000000..28938ed3 --- /dev/null +++ b/tests/report-history.test.mjs @@ -0,0 +1,97 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildReportHistory } from "../packages/report/dist/history.js"; + +function finding(fingerprint, title, severity = "medium") { + return { + fingerprint, + primary: { + id: fingerprint, + title, + category: "sast", + severity, + confidence: 0.9, + scanner: { name: "test" }, + }, + duplicates: [], + sources: [{ name: "test" }], + }; +} + +function report({ id, at, score, findings, sha = id }) { + const summary = { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }; + for (const item of findings) summary[item.primary.severity] += 1; + return { + schemaVersion: "1.0", + reportId: id, + generatedAt: at, + toolVersion: "0.2.0", + target: { path: ".", commitSha: sha, branch: "main" }, + scanners: [], + rawFindingCount: findings.length, + findingCount: findings.length, + summary, + securityScore: score, + findings, + }; +} + +test("buildReportHistory sorts reports and derives finding churn", () => { + const first = report({ + id: "r1", + at: "2026-08-20T12:00:00.000Z", + score: 70, + findings: [finding("a", "Finding A", "high"), finding("b", "Finding B", "medium")], + }); + const second = report({ + id: "r2", + at: "2026-08-21T12:00:00.000Z", + score: 78, + findings: [finding("a", "Finding A", "high"), finding("c", "Finding C", "low")], + }); + const third = report({ + id: "r3", + at: "2026-08-22T12:00:00.000Z", + score: 90, + findings: [finding("c", "Finding C escalated", "medium")], + }); + + const history = buildReportHistory([third, first, second]); + assert.deepEqual(history.points.map((point) => point.reportId), ["r1", "r2", "r3"]); + assert.deepEqual( + history.points.map(({ newCount, fixedCount, persistingCount }) => ({ newCount, fixedCount, persistingCount })), + [ + { newCount: 2, fixedCount: 0, persistingCount: 0 }, + { newCount: 1, fixedCount: 1, persistingCount: 1 }, + { newCount: 0, fixedCount: 1, persistingCount: 1 }, + ], + ); + assert.equal(history.scoreDelta, 20); + assert.equal(history.findingCountDelta, -1); + + const a = history.findings.find((item) => item.fingerprint === "a"); + const c = history.findings.find((item) => item.fingerprint === "c"); + assert.equal(a.occurrenceCount, 2); + assert.equal(a.presentInLatest, false); + assert.equal(c.occurrenceCount, 2); + assert.equal(c.presentInLatest, true); + assert.equal(c.highestSeverity, "medium"); + assert.equal(c.title, "Finding C escalated"); +}); + +test("empty history is stable and machine-readable", () => { + assert.deepEqual(buildReportHistory([]), { + schemaVersion: 1, + points: [], + findings: [], + scoreDelta: 0, + findingCountDelta: 0, + }); +}); + +test("history rejects duplicate report ids and invalid timestamps", () => { + const base = report({ id: "same", at: "2026-08-22T12:00:00.000Z", score: 100, findings: [] }); + assert.throws(() => buildReportHistory([base, { ...base, generatedAt: "2026-08-23T12:00:00.000Z" }]), /Duplicate report id/); + assert.throws(() => buildReportHistory([{ ...base, reportId: "invalid-time", generatedAt: "not-a-date" }]), /invalid generatedAt/); +}); From ca0f862ba96e8e00829645acd2a67ec95af4515a Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:21:47 -0400 Subject: [PATCH 0198/1132] fix(report): validate every history timestamp before sorting --- packages/report/src/history.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/report/src/history.ts b/packages/report/src/history.ts index fa20c1a8..ebff0628 100644 --- a/packages/report/src/history.ts +++ b/packages/report/src/history.ts @@ -77,12 +77,16 @@ export function buildReportHistory(reports: readonly SynSecReport[]): ReportHist } const ids = new Set(); + const timestamps = new Map(); for (const report of reports) { if (ids.has(report.reportId)) throw new Error(`Duplicate report id in history: ${report.reportId}`); ids.add(report.reportId); + timestamps.set(report.reportId, timestamp(report)); } - const ordered = [...reports].sort((a, b) => timestamp(a) - timestamp(b) || a.reportId.localeCompare(b.reportId)); + const ordered = [...reports].sort( + (a, b) => (timestamps.get(a.reportId) ?? 0) - (timestamps.get(b.reportId) ?? 0) || a.reportId.localeCompare(b.reportId), + ); const points: ReportHistoryPoint[] = []; const findingMap = new Map(); let previous: SynSecReport | undefined; From a18cbc5ce44ba5f14ac5d4ac2b50d8e604a0885c Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:22:09 -0400 Subject: [PATCH 0199/1132] docs: track checks publication and report history primitives --- docs/ROADMAP.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 249b92ce..47effd7a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -92,10 +92,11 @@ These workflows operate on repository evidence and scanner results. They are not - [x] GitHub Actions context/event parsing primitives - [x] Deterministic GitHub check-result and inline-annotation generation - [x] Baseline-aware PR annotation filtering and severity-threshold conclusions +- [x] Narrow fixed-host Checks API publication primitive - [ ] GitHub App - [ ] Repository installation flow - [ ] Pull-request scanning transport/orchestration -- [ ] Commit status / checks API publication +- [ ] End-to-end commit/check publication from scan orchestration - [ ] Inline SARIF/code-scanning upload - [ ] Scheduled repository scans - [ ] Optional remediation pull requests with explicit approval @@ -105,9 +106,10 @@ See [GITHUB.md](./GITHUB.md) for the current integration contract and security b ## Phase 6 — Persistent web application +- [x] Deterministic report-history aggregation for score, finding count, churn, and finding lifetime - [ ] Project/repository dashboard -- [ ] Scan history -- [ ] Security-score history +- [ ] Persisted scan history store +- [ ] Security-score history UI - [ ] New/fixed/regressed views - [ ] Finding detail page with source evidence - [ ] Dependency and SBOM views @@ -115,6 +117,8 @@ See [GITHUB.md](./GITHUB.md) for the current integration contract and security b - [ ] Team triage workflow - [ ] Finding comments/ownership +The history primitive derives trends from existing normalized reports without retaining additional source excerpts or secret material. Persistent storage and the web UI remain future work. + ## Phase 7 — Isolated scan workers - [ ] Containerized scanner images From e389e72020f11737c82853e771fb504fb7676ab9 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:22:27 -0400 Subject: [PATCH 0200/1132] feat(repository): link decorated routes to callable entry points --- packages/repository/src/route-entrypoints.ts | 86 ++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 packages/repository/src/route-entrypoints.ts diff --git a/packages/repository/src/route-entrypoints.ts b/packages/repository/src/route-entrypoints.ts new file mode 100644 index 00000000..522adac5 --- /dev/null +++ b/packages/repository/src/route-entrypoints.ts @@ -0,0 +1,86 @@ +import type { RepositoryIndex, RouteSignal } from "./analysis.js"; +import type { CallGraph, CallGraphNode, CallNeighborhood } from "./call-graph.js"; +import { findCallNeighborhood } from "./call-graph.js"; + +export type RouteEntrypointResolution = "decorated-function" | "unresolved"; + +export interface RouteEntrypoint { + route: RouteSignal; + resolution: RouteEntrypointResolution; + handler?: CallGraphNode; + calls?: CallNeighborhood; + /** Route-to-handler mapping and downstream calls are static structural evidence, not runtime reachability proof. */ + interpretation: "structural-route-call-evidence-only"; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function isDecoratorRoute(route: RouteSignal): boolean { + return route.frameworkHint === "Decorator router" || route.frameworkHint === "Python web router"; +} + +function decoratedHandler( + route: RouteSignal, + graph: CallGraph, + maxDeclarationDistance: number, +): CallGraphNode | undefined { + if (!isDecoratorRoute(route)) return undefined; + const routePath = normalizePath(route.path); + const boundedDistance = Math.max(1, Math.min(20, maxDeclarationDistance)); + const candidates = graph.nodes + .filter((node) => { + if (normalizePath(node.path) !== routePath) return false; + const distance = node.line - route.line; + return distance > 0 && distance <= boundedDistance; + }) + .sort((a, b) => a.line - b.line || a.name.localeCompare(b.name)); + + if (candidates.length === 0) return undefined; + const nearestDistance = candidates[0].line - route.line; + const nearest = candidates.filter((candidate) => candidate.line - route.line === nearestDistance); + return nearest.length === 1 ? nearest[0] : undefined; +} + +export function resolveRouteEntrypoints( + index: RepositoryIndex, + graph: CallGraph, + options: { maxDeclarationDistance?: number; maxCallDepth?: number; maxCallNodes?: number } = {}, +): RouteEntrypoint[] { + const maxDeclarationDistance = options.maxDeclarationDistance ?? 5; + const maxCallDepth = options.maxCallDepth ?? 3; + const maxCallNodes = options.maxCallNodes ?? 100; + + return index.routes.map((route) => { + const handler = decoratedHandler(route, graph, maxDeclarationDistance); + if (!handler) { + return { + route, + resolution: "unresolved", + interpretation: "structural-route-call-evidence-only", + }; + } + + return { + route, + resolution: "decorated-function", + handler, + calls: findCallNeighborhood(graph, handler.id, maxCallDepth, maxCallNodes), + interpretation: "structural-route-call-evidence-only", + }; + }); +} + +export function routeEntrypointForLocation( + entrypoints: readonly RouteEntrypoint[], + path: string, + line: number, +): RouteEntrypoint | undefined { + const normalized = normalizePath(path); + return entrypoints.find((entrypoint) => { + const handler = entrypoint.handler; + if (!handler || normalizePath(handler.path) !== normalized) return false; + return line >= handler.line && line <= handler.endLine; + }); +} From 3546aff3536e99613e931cb408fa6dc98901cde2 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:22:40 -0400 Subject: [PATCH 0201/1132] build(repository): export route entrypoint API --- packages/repository/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/repository/package.json b/packages/repository/package.json index 14429afc..92f27850 100644 --- a/packages/repository/package.json +++ b/packages/repository/package.json @@ -7,7 +7,8 @@ ".": "./dist/index.js", "./analysis": "./dist/analysis.js", "./module-graph": "./dist/module-graph.js", - "./call-graph": "./dist/call-graph.js" + "./call-graph": "./dist/call-graph.js", + "./route-entrypoints": "./dist/route-entrypoints.js" }, "types": "./dist/index.d.ts", "scripts": { From b9c8fe283f7792db8159f92372da9d4e064b5f67 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:22:50 -0400 Subject: [PATCH 0202/1132] test(repository): cover conservative route entrypoint mapping --- tests/route-entrypoints.test.mjs | 71 ++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/route-entrypoints.test.mjs diff --git a/tests/route-entrypoints.test.mjs b/tests/route-entrypoints.test.mjs new file mode 100644 index 00000000..ad07c19e --- /dev/null +++ b/tests/route-entrypoints.test.mjs @@ -0,0 +1,71 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { resolveRouteEntrypoints, routeEntrypointForLocation } from "../packages/repository/dist/route-entrypoints.js"; + +function emptyIndex(routes) { + return { + schemaVersion: 1, + generatedAt: "2026-08-22T12:00:00.000Z", + indexedFileCount: 1, + moduleEdges: [], + routes, + authSignals: [], + sinks: [], + }; +} + +function graph() { + return { + schemaVersion: 1, + nodes: [ + { id: "app.py:users:11", path: "app.py", name: "users", line: 11, endLine: 14, kind: "python-function" }, + { id: "app.py:load_user:20", path: "app.py", name: "load_user", line: 20, endLine: 22, kind: "python-function" }, + { id: "server.ts:listUsers:30", path: "server.ts", name: "listUsers", line: 30, endLine: 33, kind: "function" }, + ], + edges: [ + { from: "app.py:users:11", callee: "load_user", line: 13, target: "app.py:load_user:20", resolution: "same-file-function" }, + ], + resolvedEdgeCount: 1, + unresolvedEdgeCount: 0, + skippedFiles: [], + interpretation: "lexical-call-evidence-only", + }; +} + +test("decorated Python routes resolve to the nearest following function and bounded calls", () => { + const index = emptyIndex([ + { path: "app.py", line: 10, method: "GET", route: "/users", frameworkHint: "Python web router" }, + ]); + const [entrypoint] = resolveRouteEntrypoints(index, graph()); + assert.equal(entrypoint.resolution, "decorated-function"); + assert.equal(entrypoint.handler.id, "app.py:users:11"); + assert.equal(entrypoint.calls.callees[0].id, "app.py:load_user:20"); + assert.equal(entrypoint.interpretation, "structural-route-call-evidence-only"); +}); + +test("generic Node router registrations remain unresolved instead of guessing a handler", () => { + const index = emptyIndex([ + { path: "server.ts", line: 5, method: "GET", route: "/users", frameworkHint: "Node HTTP router" }, + ]); + const [entrypoint] = resolveRouteEntrypoints(index, graph()); + assert.equal(entrypoint.resolution, "unresolved"); + assert.equal("handler" in entrypoint, false); +}); + +test("routeEntrypointForLocation only maps lines inside a resolved handler body", () => { + const entrypoints = resolveRouteEntrypoints( + emptyIndex([{ path: "app.py", line: 10, method: "GET", route: "/users", frameworkHint: "Python web router" }]), + graph(), + ); + assert.equal(routeEntrypointForLocation(entrypoints, "./app.py", 13)?.route.route, "/users"); + assert.equal(routeEntrypointForLocation(entrypoints, "app.py", 19), undefined); +}); + +test("decorator mapping refuses declarations beyond the configured distance", () => { + const index = emptyIndex([ + { path: "app.py", line: 1, method: "GET", route: "/far", frameworkHint: "Python web router" }, + ]); + const [entrypoint] = resolveRouteEntrypoints(index, graph(), { maxDeclarationDistance: 5 }); + assert.equal(entrypoint.resolution, "unresolved"); +}); From a98363f0bf14ace9b21b8d2b113e27460983255f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:23:10 -0400 Subject: [PATCH 0203/1132] docs: track conservative route entrypoint mapping --- docs/ROADMAP.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 47effd7a..58c0610d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -46,14 +46,15 @@ This roadmap separates what is already usable in the repository from the deeper - [x] Persistent repository index - [x] Import/module graph with bounded dependency/dependent traversal - [x] Bounded same-file lexical call-graph primitive for JavaScript/TypeScript and Python +- [x] Conservative decorator-route to callable-entrypoint mapping - [ ] Full function/call graph with reliable cross-module symbol resolution -- [ ] Routes and externally reachable entry points +- [ ] Broad routes and externally reachable entry points across supported frameworks - [ ] Authentication/authorization context - [ ] Database, filesystem, process, and network sinks - [ ] Dependency reachability beyond scanner-provided call analysis - [ ] Test ownership and coverage context around findings -The current call graph is deliberately labeled lexical evidence rather than runtime reachability. It resolves unambiguous direct same-file calls and leaves qualified, external, or ambiguous calls unresolved. +The current call graph is deliberately labeled lexical evidence rather than runtime reachability. It resolves unambiguous direct same-file calls and leaves qualified, external, or ambiguous calls unresolved. Decorator-based route mapping only links a route when one function declaration is structurally close enough to be unambiguous; generic router registrations remain unresolved rather than guessing a handler. ## Phase 3 — Contextual security review From 60ca1a260d95828d3c6a02dee8722e9ff3d741b0 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:23:40 -0400 Subject: [PATCH 0204/1132] fix(repository): narrow route candidate before dereference --- packages/repository/src/route-entrypoints.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/repository/src/route-entrypoints.ts b/packages/repository/src/route-entrypoints.ts index 522adac5..55e2cf8e 100644 --- a/packages/repository/src/route-entrypoints.ts +++ b/packages/repository/src/route-entrypoints.ts @@ -37,8 +37,9 @@ function decoratedHandler( }) .sort((a, b) => a.line - b.line || a.name.localeCompare(b.name)); - if (candidates.length === 0) return undefined; - const nearestDistance = candidates[0].line - route.line; + const first = candidates[0]; + if (!first) return undefined; + const nearestDistance = first.line - route.line; const nearest = candidates.filter((candidate) => candidate.line - route.line === nearestDistance); return nearest.length === 1 ? nearest[0] : undefined; } From 4ac766aaa58c0bfbb7a8941d31619b6abd507701 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 08:24:30 -0400 Subject: [PATCH 0205/1132] ci: upgrade GitHub actions to Node 24-based releases --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32047029..bcc9fab4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,12 +17,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Node.js ${{ matrix.node }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: ${{ matrix.node }} + package-manager-cache: false - name: Install dependencies run: npm install From 1f5fd1a5a941ddfcfaa3b96a9b3b525313595d45 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 09:11:27 -0400 Subject: [PATCH 0206/1132] report: generalize history aggregation inputs --- packages/report/src/history.ts | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/report/src/history.ts b/packages/report/src/history.ts index ebff0628..18aad66c 100644 --- a/packages/report/src/history.ts +++ b/packages/report/src/history.ts @@ -1,6 +1,25 @@ import type { Severity } from "@synsec/core"; import type { SeverityCounts, SynSecReport } from "./index.js"; +export interface ReportHistoryInput { + reportId: string; + generatedAt: string; + target: { + commitSha?: string; + branch?: string; + }; + securityScore: number; + findingCount: number; + summary: SeverityCounts; + findings: Array<{ + fingerprint: string; + primary: { + title: string; + severity: Severity; + }; + }>; +} + export interface ReportHistoryPoint { reportId: string; generatedAt: string; @@ -41,17 +60,17 @@ const severityRank: Record = { unknown: 0, }; -function timestamp(report: SynSecReport): number { +function timestamp(report: ReportHistoryInput): number { const value = Date.parse(report.generatedAt); if (!Number.isFinite(value)) throw new Error(`Report ${report.reportId} has an invalid generatedAt timestamp.`); return value; } -function fingerprints(report: SynSecReport): Set { +function fingerprints(report: ReportHistoryInput): Set { return new Set(report.findings.map((finding) => finding.fingerprint)); } -function deltaCounts(previous: SynSecReport | undefined, current: SynSecReport): Pick { +function deltaCounts(previous: ReportHistoryInput | undefined, current: ReportHistoryInput): Pick { if (!previous) { return { newCount: current.findingCount, fixedCount: 0, persistingCount: 0 }; } @@ -71,7 +90,7 @@ function deltaCounts(previous: SynSecReport | undefined, current: SynSecReport): return { newCount, fixedCount, persistingCount }; } -export function buildReportHistory(reports: readonly SynSecReport[]): ReportHistory { +export function buildReportHistory(reports: readonly ReportHistoryInput[]): ReportHistory { if (reports.length === 0) { return { schemaVersion: 1, points: [], findings: [], scoreDelta: 0, findingCountDelta: 0 }; } @@ -89,7 +108,7 @@ export function buildReportHistory(reports: readonly SynSecReport[]): ReportHist ); const points: ReportHistoryPoint[] = []; const findingMap = new Map(); - let previous: SynSecReport | undefined; + let previous: ReportHistoryInput | undefined; for (const report of ordered) { const delta = deltaCounts(previous, report); @@ -145,3 +164,5 @@ export function buildReportHistory(reports: readonly SynSecReport[]): ReportHist findingCountDelta: first && last ? last.findingCount - first.findingCount : 0, }; } + +export type { SynSecReport }; From a17402deff9437ed423f09bed1cbe41cec1d78b1 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 09:11:44 -0400 Subject: [PATCH 0207/1132] report: add bounded persistent history store --- packages/report/src/history-store.ts | 169 +++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 packages/report/src/history-store.ts diff --git a/packages/report/src/history-store.ts b/packages/report/src/history-store.ts new file mode 100644 index 00000000..b8b58bc7 --- /dev/null +++ b/packages/report/src/history-store.ts @@ -0,0 +1,169 @@ +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { Severity } from "@synsec/core"; +import type { SeverityCounts, SynSecReport } from "./index.js"; +import { buildReportHistory, type ReportHistory, type ReportHistoryInput } from "./history.js"; + +export const HISTORY_STORE_SCHEMA_VERSION = 1 as const; +export const DEFAULT_HISTORY_RETENTION = 100; +export const MAX_HISTORY_RETENTION = 10_000; + +export interface StoredFindingSnapshot { + fingerprint: string; + primary: { + title: string; + severity: Severity; + }; +} + +export interface StoredReportSnapshot extends ReportHistoryInput { + target: { + commitSha?: string; + branch?: string; + }; + summary: SeverityCounts; + findings: StoredFindingSnapshot[]; +} + +export interface ReportHistoryStore { + schemaVersion: typeof HISTORY_STORE_SCHEMA_VERSION; + reports: StoredReportSnapshot[]; +} + +export interface AppendHistoryOptions { + maxReports?: number; +} + +function isSeverity(value: unknown): value is Severity { + return value === "critical" || value === "high" || value === "medium" || value === "low" || value === "info" || value === "unknown"; +} + +function isSeverityCounts(value: unknown): value is SeverityCounts { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return ["critical", "high", "medium", "low", "info", "unknown"].every( + (key) => typeof record[key] === "number" && Number.isFinite(record[key]) && (record[key] as number) >= 0, + ); +} + +function isStoredReportSnapshot(value: unknown): value is StoredReportSnapshot { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + if ( + typeof record.reportId !== "string" || + typeof record.generatedAt !== "string" || + !Number.isFinite(Date.parse(record.generatedAt)) || + typeof record.securityScore !== "number" || + !Number.isFinite(record.securityScore) || + typeof record.findingCount !== "number" || + !Number.isInteger(record.findingCount) || + record.findingCount < 0 || + !isSeverityCounts(record.summary) || + !Array.isArray(record.findings) || + typeof record.target !== "object" || + record.target === null + ) { + return false; + } + + const target = record.target as Record; + if (target.commitSha !== undefined && typeof target.commitSha !== "string") return false; + if (target.branch !== undefined && typeof target.branch !== "string") return false; + + return record.findings.every((finding) => { + if (typeof finding !== "object" || finding === null) return false; + const item = finding as Record; + if (typeof item.fingerprint !== "string" || typeof item.primary !== "object" || item.primary === null) return false; + const primary = item.primary as Record; + return typeof primary.title === "string" && isSeverity(primary.severity); + }); +} + +export function snapshotReport(report: SynSecReport): StoredReportSnapshot { + return { + reportId: report.reportId, + generatedAt: report.generatedAt, + target: { + ...(report.target.commitSha ? { commitSha: report.target.commitSha } : {}), + ...(report.target.branch ? { branch: report.target.branch } : {}), + }, + securityScore: report.securityScore, + findingCount: report.findingCount, + summary: { ...report.summary }, + findings: report.findings.map((finding) => ({ + fingerprint: finding.fingerprint, + primary: { + title: finding.primary.title, + severity: finding.primary.severity, + }, + })), + }; +} + +export async function readHistoryStore(path: string): Promise { + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { schemaVersion: HISTORY_STORE_SCHEMA_VERSION, reports: [] }; + } + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + throw new Error(`History store is not valid JSON: ${path}`); + } + + if (typeof parsed !== "object" || parsed === null) throw new Error(`Unsupported SynSec history store: ${path}`); + const record = parsed as Record; + if (record.schemaVersion !== HISTORY_STORE_SCHEMA_VERSION || !Array.isArray(record.reports) || !record.reports.every(isStoredReportSnapshot)) { + throw new Error(`Unsupported SynSec history store: ${path}`); + } + + return { schemaVersion: HISTORY_STORE_SCHEMA_VERSION, reports: record.reports }; +} + +function retentionLimit(value: number | undefined): number { + const limit = value ?? DEFAULT_HISTORY_RETENTION; + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_HISTORY_RETENTION) { + throw new Error(`History retention must be an integer between 1 and ${MAX_HISTORY_RETENTION}.`); + } + return limit; +} + +async function writeHistoryStore(path: string, store: ReportHistoryStore): Promise { + await mkdir(dirname(path), { recursive: true }); + const temporary = `${path}.${process.pid}.${Date.now()}.tmp`; + try { + await writeFile(temporary, `${JSON.stringify(store, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }); + } +} + +export async function appendHistoryReport( + path: string, + report: SynSecReport, + options: AppendHistoryOptions = {}, +): Promise { + const limit = retentionLimit(options.maxReports); + const store = await readHistoryStore(path); + const snapshot = snapshotReport(report); + const reports = store.reports.filter((existing) => existing.reportId !== snapshot.reportId); + reports.push(snapshot); + reports.sort((a, b) => Date.parse(a.generatedAt) - Date.parse(b.generatedAt) || a.reportId.localeCompare(b.reportId)); + const bounded = reports.slice(Math.max(0, reports.length - limit)); + const next = { schemaVersion: HISTORY_STORE_SCHEMA_VERSION, reports: bounded } as const; + await writeHistoryStore(path, next); + return next; +} + +export async function buildHistoryFromStore(path: string): Promise { + const store = await readHistoryStore(path); + return buildReportHistory(store.reports); +} From 57191cfbe48e032f3a6cfec5d9c066448c4e22d1 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 09:11:51 -0400 Subject: [PATCH 0208/1132] report: export history store API --- packages/report/package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/report/package.json b/packages/report/package.json index e9e5d84d..585ff8d6 100644 --- a/packages/report/package.json +++ b/packages/report/package.json @@ -15,6 +15,10 @@ "./history": { "types": "./dist/history.d.ts", "import": "./dist/history.js" + }, + "./history-store": { + "types": "./dist/history-store.d.ts", + "import": "./dist/history-store.js" } }, "types": "./dist/index.d.ts", From 16f0573b7dddf94154355b8ba7e498f96342f25f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 09:12:05 -0400 Subject: [PATCH 0209/1132] test: cover persistent report history store --- tests/report-history-store.test.mjs | 94 +++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/report-history-store.test.mjs diff --git a/tests/report-history-store.test.mjs b/tests/report-history-store.test.mjs new file mode 100644 index 00000000..3b0811a0 --- /dev/null +++ b/tests/report-history-store.test.mjs @@ -0,0 +1,94 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + appendHistoryReport, + buildHistoryFromStore, + readHistoryStore, + snapshotReport, +} from "../packages/report/dist/history-store.js"; + +function finding(fingerprint, title, severity = "medium") { + return { + fingerprint, + primary: { + id: fingerprint, + title, + category: "sast", + severity, + confidence: 0.9, + scanner: { name: "test" }, + description: "sensitive source context that must not be persisted in history", + location: { path: "src/app.ts", startLine: 12, snippet: "const secret = process.env.TOKEN" }, + }, + duplicates: [], + sources: [{ name: "test" }], + }; +} + +function report(id, generatedAt, findings) { + const summary = { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }; + for (const item of findings) summary[item.primary.severity] += 1; + return { + schemaVersion: "1.0", + reportId: id, + generatedAt, + toolVersion: "0.2.0", + target: { path: ".", commitSha: id, branch: "main", repositoryUrl: "https://example.invalid/repo" }, + scanners: [], + rawFindingCount: findings.length, + findingCount: findings.length, + summary, + securityScore: 100 - findings.length * 10, + findings, + }; +} + +test("snapshotReport retains only trend-safe finding metadata", () => { + const snapshot = snapshotReport(report("r1", "2026-08-20T12:00:00.000Z", [finding("a", "Finding A", "high")])); + const serialized = JSON.stringify(snapshot); + assert.equal(serialized.includes("sensitive source context"), false); + assert.equal(serialized.includes("process.env.TOKEN"), false); + assert.equal(serialized.includes("example.invalid"), false); + assert.deepEqual(snapshot.findings[0], { + fingerprint: "a", + primary: { title: "Finding A", severity: "high" }, + }); +}); + +test("history store is bounded, ordered, idempotent, and trend-compatible", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-history-")); + const path = join(directory, "history.json"); + const r1 = report("r1", "2026-08-20T12:00:00.000Z", [finding("a", "A")]); + const r2 = report("r2", "2026-08-21T12:00:00.000Z", [finding("a", "A"), finding("b", "B", "high")]); + const r3 = report("r3", "2026-08-22T12:00:00.000Z", [finding("b", "B", "high")]); + + await appendHistoryReport(path, r2, { maxReports: 2 }); + await appendHistoryReport(path, r1, { maxReports: 2 }); + await appendHistoryReport(path, r3, { maxReports: 2 }); + await appendHistoryReport(path, r3, { maxReports: 2 }); + + const store = await readHistoryStore(path); + assert.deepEqual(store.reports.map((item) => item.reportId), ["r2", "r3"]); + const history = await buildHistoryFromStore(path); + assert.deepEqual(history.points.map((item) => item.reportId), ["r2", "r3"]); + assert.equal(history.points[1].fixedCount, 1); + assert.equal(history.points[1].persistingCount, 1); + + const mode = (await import("node:fs/promises")).stat(path).then((stat) => stat.mode & 0o777); + assert.equal(await mode, 0o600); +}); + +test("history store rejects invalid retention and corrupt content", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-history-invalid-")); + const path = join(directory, "history.json"); + const r1 = report("r1", "2026-08-20T12:00:00.000Z", []); + await assert.rejects(() => appendHistoryReport(path, r1, { maxReports: 0 }), /between 1 and/); + + await (await import("node:fs/promises")).writeFile(path, "{broken", "utf8"); + await assert.rejects(() => readHistoryStore(path), /not valid JSON/); + assert.equal((await readFile(path, "utf8")), "{broken"); +}); From a9c610817ad6cf6f4ffd63f19a3814a6c16e568f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 09:12:25 -0400 Subject: [PATCH 0210/1132] docs: record bounded scan-history persistence --- docs/ROADMAP.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 58c0610d..2cfa8763 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -108,8 +108,9 @@ See [GITHUB.md](./GITHUB.md) for the current integration contract and security b ## Phase 6 — Persistent web application - [x] Deterministic report-history aggregation for score, finding count, churn, and finding lifetime +- [x] Bounded local scan-history store with atomic writes and trend-safe snapshots - [ ] Project/repository dashboard -- [ ] Persisted scan history store +- [ ] Multi-project/server persistence layer - [ ] Security-score history UI - [ ] New/fixed/regressed views - [ ] Finding detail page with source evidence @@ -118,7 +119,7 @@ See [GITHUB.md](./GITHUB.md) for the current integration contract and security b - [ ] Team triage workflow - [ ] Finding comments/ownership -The history primitive derives trends from existing normalized reports without retaining additional source excerpts or secret material. Persistent storage and the web UI remain future work. +The local history store retains only report identifiers, timestamps, commit/branch metadata, aggregate counts/scores, and finding fingerprint/title/severity tuples. It deliberately omits source excerpts, scanner diagnostics, repository URLs, artifacts, and secret-bearing evidence. Retention is bounded, writes are atomic, and invalid/corrupt stores fail closed. A multi-project database and web UI remain future work. ## Phase 7 — Isolated scan workers From 08d4877739bcd17de74363772728c4d8a0cf6ff2 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:10:01 -0400 Subject: [PATCH 0211/1132] feat(github): orchestrate report check publication --- packages/github/src/orchestrator.ts | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 packages/github/src/orchestrator.ts diff --git a/packages/github/src/orchestrator.ts b/packages/github/src/orchestrator.ts new file mode 100644 index 00000000..f15f2796 --- /dev/null +++ b/packages/github/src/orchestrator.ts @@ -0,0 +1,56 @@ +import type { Severity } from "@synsec/core"; +import type { SynSecReport } from "@synsec/report"; +import { + buildGitHubCheck, + loadGitHubContext, + type GitHubCheckResult, + type GitHubPullRequestContext, +} from "./index.js"; +import { + publishGitHubCheck, + type GitHubCheckPublication, + type GitHubPublisherOptions, +} from "./publisher.js"; + +export interface GitHubReportPublicationOptions extends GitHubPublisherOptions { + env?: NodeJS.ProcessEnv; + threshold?: Severity; + onlyNewAnnotations?: boolean; + maxAnnotations?: number; +} + +export interface GitHubReportPublicationResult { + context: GitHubPullRequestContext; + check: GitHubCheckResult; + publication: GitHubCheckPublication; +} + +/** + * Convert a completed SynSec report into a GitHub check and publish it to the commit represented + * by the bounded local Actions context. This function never performs scanning, target discovery, + * repository mutation, or external assessment; it only transports an already-produced report. + */ +export async function publishSynSecReportToGitHub( + report: SynSecReport, + token: string, + options: GitHubReportPublicationOptions = {}, +): Promise { + const context = await loadGitHubContext(options.env ?? process.env); + if (!context) { + throw new Error("Unable to resolve a valid GitHub repository and commit context for check publication."); + } + + const check = buildGitHubCheck(report, context, { + threshold: options.threshold, + onlyNewAnnotations: options.onlyNewAnnotations, + maxAnnotations: options.maxAnnotations, + }); + + const publication = await publishGitHubCheck(check, context, token, { + apiVersion: options.apiVersion, + userAgent: options.userAgent, + fetch: options.fetch, + }); + + return { context, check, publication }; +} From 8ab2b781b7a37367b78be6bf055433243f03ac51 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:10:07 -0400 Subject: [PATCH 0212/1132] feat(github): export report publication orchestrator --- packages/github/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/github/package.json b/packages/github/package.json index 8c113021..b42b0e48 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -5,7 +5,8 @@ "type": "module", "exports": { ".": "./dist/index.js", - "./publisher": "./dist/publisher.js" + "./publisher": "./dist/publisher.js", + "./orchestrator": "./dist/orchestrator.js" }, "types": "./dist/index.d.ts", "scripts": { From ef85bd141abdf6005b439939814a43e9f436dad9 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:10:25 -0400 Subject: [PATCH 0213/1132] test(github): cover report publication orchestration --- tests/github-orchestrator.test.mjs | 80 ++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/github-orchestrator.test.mjs diff --git a/tests/github-orchestrator.test.mjs b/tests/github-orchestrator.test.mjs new file mode 100644 index 00000000..8b2d6c1d --- /dev/null +++ b/tests/github-orchestrator.test.mjs @@ -0,0 +1,80 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { publishSynSecReportToGitHub } from "../packages/github/dist/orchestrator.js"; + +function report() { + return { + schemaVersion: "1.0", + reportId: "report-orchestrator", + generatedAt: "2026-08-22T14:00:00.000Z", + toolVersion: "0.2.0", + target: { path: ".", commitSha: "real-head-sha" }, + scanners: [{ scanner: "opengrep", startedAt: "a", completedAt: "b", findingCount: 1, artifactCount: 0, diagnostics: [] }], + rawFindingCount: 1, + findingCount: 1, + summary: { critical: 0, high: 1, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 90, + findings: [{ + fingerprint: "fp-orchestrator", + primary: { + id: "finding-1", + title: "Unsafe input", + description: "Untrusted input reaches a sensitive operation.", + category: "sast", + severity: "high", + confidence: 0.95, + scanner: { name: "opengrep", ruleId: "unsafe-input" }, + location: { path: "src/app.ts", startLine: 8, endLine: 8 }, + }, + duplicates: [], + sources: [{ name: "opengrep", ruleId: "unsafe-input" }], + }], + scope: { mode: "changed-files", baseRef: "main", changedFiles: ["src/app.ts"] }, + baseline: { new: ["fp-orchestrator"], fixed: [], persisting: [] }, + }; +} + +test("publishSynSecReportToGitHub resolves PR head context, builds, and publishes one completed check", async () => { + let request; + const fakeFetch = async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ id: 321, status: "completed", conclusion: "failure" }), { status: 201 }); + }; + + const result = await publishSynSecReportToGitHub(report(), "installation-token", { + env: { + GITHUB_REPOSITORY: "cmahmud/synsec", + GITHUB_SHA: "real-head-sha", + GITHUB_REF: "refs/pull/2/head", + GITHUB_BASE_REF: "main", + GITHUB_HEAD_REF: "feature/multi-scanner-mvp", + }, + fetch: fakeFetch, + threshold: "high", + }); + + assert.equal(result.context.sha, "real-head-sha"); + assert.equal(result.context.pullRequestNumber, 2); + assert.equal(result.check.headSha, "real-head-sha"); + assert.equal(result.check.conclusion, "failure"); + assert.equal(result.check.output.annotations.length, 1); + assert.equal(request.url, "https://api.github.com/repos/cmahmud/synsec/check-runs"); + assert.equal(JSON.parse(request.init.body).head_sha, "real-head-sha"); + assert.equal(result.publication.id, 321); +}); + +test("publication orchestration fails before transport when GitHub context is missing", async () => { + let called = false; + await assert.rejects( + () => publishSynSecReportToGitHub(report(), "token", { + env: {}, + fetch: async () => { + called = true; + throw new Error("transport should not run"); + }, + }), + /Unable to resolve a valid GitHub repository and commit context/, + ); + assert.equal(called, false); +}); From 7dea4dbeeb50dca113a51c15b30b7e4e7008923a Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:10:43 -0400 Subject: [PATCH 0214/1132] docs(github): document report publication orchestration --- docs/GITHUB.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/GITHUB.md b/docs/GITHUB.md index 2137c98b..f659ea2e 100644 --- a/docs/GITHUB.md +++ b/docs/GITHUB.md @@ -22,9 +22,12 @@ This keeps GitHub credentials out of scanners and prevents repository analysis f - A hard 50-annotation cap per generated payload, matching GitHub's check-run annotation request limit. - CI threshold evaluation independent of scanner exit-code quirks. - A narrow Checks API publisher with an injectable transport for testing. +- A completed-report publication orchestrator that resolves local Actions context, builds the deterministic check, and publishes it through the fixed-host transport. `@synsec/github/publisher` posts completed check runs only to `https://api.github.com/repos///check-runs`. The repository comes from validated GitHub context, scanner output cannot control the request URL, redirects are rejected, and bearer tokens are never copied into returned errors. +`@synsec/github/orchestrator` provides `publishSynSecReportToGitHub()`. It accepts an already-completed `SynSecReport`, resolves the repository/commit from bounded local Actions context, builds the check, and invokes the publisher. It does not run scanners, discover targets, mutate repositories, or perform external assessment. If valid GitHub context cannot be resolved, it fails before any transport call. + A future GitHub App or Actions adapter should own token acquisition and installation authorization while reusing these deterministic publication primitives. ## Pull-request SHA handling @@ -68,7 +71,9 @@ When a report includes a baseline, `buildGitHubCheck()` defaults to annotating o - validates the returned check-run id; - returns only publication metadata such as id, URL, status, and conclusion. -Token acquisition is intentionally outside this function. GitHub App installation tokens, Actions `GITHUB_TOKEN`, and any future enterprise-hosting transport should remain separate concerns so credentials never enter scanners or normalized reports. +`publishSynSecReportToGitHub()` is the higher-level completed-report path. It preserves the same transport restrictions while removing duplicate context/check/publisher glue from future Actions and GitHub App entrypoints. + +Token acquisition is intentionally outside these functions. GitHub App installation tokens, Actions `GITHUB_TOKEN`, and any future enterprise-hosting transport should remain separate concerns so credentials never enter scanners or normalized reports. ## Security boundaries @@ -88,7 +93,7 @@ The remaining Phase 5 work is product orchestration and installation/authenticat 1. Add a GitHub App installation/authentication layer. 2. Wire PR events to changed-file scans and baseline selection. -3. Invoke check publication from the PR orchestration path. +3. Connect the completed scan result to `publishSynSecReportToGitHub()` in that PR event runner. 4. Upload SARIF to GitHub code scanning where repository permissions allow it. 5. Add scheduled repository scans. 6. Add explicitly approved remediation pull requests. From 40491d272eea4e2633f15c7438b9ea5a9db7a2d7 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:11:01 -0400 Subject: [PATCH 0215/1132] docs(roadmap): record GitHub report publication path --- docs/ROADMAP.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2cfa8763..c0174236 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -94,10 +94,11 @@ These workflows operate on repository evidence and scanner results. They are not - [x] Deterministic GitHub check-result and inline-annotation generation - [x] Baseline-aware PR annotation filtering and severity-threshold conclusions - [x] Narrow fixed-host Checks API publication primitive +- [x] Completed-report → GitHub check publication orchestration - [ ] GitHub App - [ ] Repository installation flow - [ ] Pull-request scanning transport/orchestration -- [ ] End-to-end commit/check publication from scan orchestration +- [ ] End-to-end PR event → scan → check publication - [ ] Inline SARIF/code-scanning upload - [ ] Scheduled repository scans - [ ] Optional remediation pull requests with explicit approval From fc8a24f12ba84ea9e1f4e8970ae4a5aba6a901b0 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:11:57 -0400 Subject: [PATCH 0216/1132] fix(github): bind published reports to scanned commit --- packages/github/src/orchestrator.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/github/src/orchestrator.ts b/packages/github/src/orchestrator.ts index f15f2796..9f5e7db2 100644 --- a/packages/github/src/orchestrator.ts +++ b/packages/github/src/orchestrator.ts @@ -25,6 +25,17 @@ export interface GitHubReportPublicationResult { publication: GitHubCheckPublication; } +function sameCommit(reportSha: string, contextSha: string): boolean { + const report = reportSha.trim().toLowerCase(); + const context = contextSha.trim().toLowerCase(); + if (!report || !context) return false; + if (report === context) return true; + + const hexSha = /^[0-9a-f]+$/; + if (!hexSha.test(report) || !hexSha.test(context) || Math.min(report.length, context.length) < 7) return false; + return report.startsWith(context) || context.startsWith(report); +} + /** * Convert a completed SynSec report into a GitHub check and publish it to the commit represented * by the bounded local Actions context. This function never performs scanning, target discovery, @@ -40,6 +51,11 @@ export async function publishSynSecReportToGitHub( throw new Error("Unable to resolve a valid GitHub repository and commit context for check publication."); } + const reportCommitSha = report.target.commitSha?.trim(); + if (reportCommitSha && !sameCommit(reportCommitSha, context.sha)) { + throw new Error("SynSec report commit does not match the GitHub commit selected for publication."); + } + const check = buildGitHubCheck(report, context, { threshold: options.threshold, onlyNewAnnotations: options.onlyNewAnnotations, From f2d4cf678f89729395c8d53bfa1f27b4bf0fa4f1 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:12:11 -0400 Subject: [PATCH 0217/1132] test(github): reject stale report publication --- tests/github-orchestrator.test.mjs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/github-orchestrator.test.mjs b/tests/github-orchestrator.test.mjs index 8b2d6c1d..95f1d9c6 100644 --- a/tests/github-orchestrator.test.mjs +++ b/tests/github-orchestrator.test.mjs @@ -78,3 +78,21 @@ test("publication orchestration fails before transport when GitHub context is mi ); assert.equal(called, false); }); + +test("publication orchestration rejects a report generated for a different commit", async () => { + let called = false; + const stale = report(); + stale.target.commitSha = "old-head-sha"; + + await assert.rejects( + () => publishSynSecReportToGitHub(stale, "token", { + env: { GITHUB_REPOSITORY: "cmahmud/synsec", GITHUB_SHA: "new-head-sha" }, + fetch: async () => { + called = true; + throw new Error("transport should not run"); + }, + }), + /report commit does not match the GitHub commit/, + ); + assert.equal(called, false); +}); From b58778c305cf47c869433439fc8540751221414e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:12:55 -0400 Subject: [PATCH 0218/1132] feat(github): add Actions repository scan runner --- packages/github/src/actions-runner.ts | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 packages/github/src/actions-runner.ts diff --git a/packages/github/src/actions-runner.ts b/packages/github/src/actions-runner.ts new file mode 100644 index 00000000..21c505f9 --- /dev/null +++ b/packages/github/src/actions-runner.ts @@ -0,0 +1,70 @@ +import type { SynSecConfig } from "@synsec/config"; +import { runScanEngine, type ScanEngineOutcome } from "@synsec/engine"; +import type { SynSecReport } from "@synsec/report"; +import { loadGitHubContext, type GitHubPullRequestContext } from "./index.js"; +import { + publishSynSecReportToGitHub, + type GitHubReportPublicationOptions, + type GitHubReportPublicationResult, +} from "./orchestrator.js"; + +export interface GitHubActionsRepositoryScanOptions extends GitHubReportPublicationOptions { + config: SynSecConfig; + rootPath?: string; + baseline?: SynSecReport; + toolVersion?: string; + changedOnly?: boolean; + changedBase?: string; + scan?: typeof runScanEngine; +} + +export interface GitHubActionsRepositoryScanResult { + context: GitHubPullRequestContext; + outcome: ScanEngineOutcome; + publication: GitHubReportPublicationResult; +} + +/** + * Run the existing repository scanner engine for the current GitHub Actions checkout and publish + * the completed report as a check run. Pull-request contexts default to changed-file scanning; + * push/other contexts default to a full repository scan. No live-target discovery is performed. + */ +export async function runGitHubActionsRepositoryScan( + token: string, + options: GitHubActionsRepositoryScanOptions, +): Promise { + const env = options.env ?? process.env; + const context = await loadGitHubContext(env); + if (!context) { + throw new Error("Unable to resolve a valid GitHub repository and commit context for repository scanning."); + } + + const changedOnly = options.changedOnly ?? Boolean(context.pullRequestNumber); + const changedBase = options.changedBase + ?? (changedOnly && context.baseRef ? `origin/${context.baseRef}` : undefined); + const scan = options.scan ?? runScanEngine; + const outcome = await scan({ + rootPath: options.rootPath ?? process.cwd(), + config: options.config, + baseline: options.baseline, + toolVersion: options.toolVersion, + changedOnly, + changedBase, + }); + + if (!outcome.report.target.commitSha?.trim()) { + throw new Error("GitHub Actions repository scans must produce a report with a commit SHA before publication."); + } + + const publication = await publishSynSecReportToGitHub(outcome.report, token, { + env, + threshold: options.threshold, + onlyNewAnnotations: options.onlyNewAnnotations, + maxAnnotations: options.maxAnnotations, + apiVersion: options.apiVersion, + userAgent: options.userAgent, + fetch: options.fetch, + }); + + return { context, outcome, publication }; +} From 159f44075679d82a32665048c3ae02bdcf164d0b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:13:10 -0400 Subject: [PATCH 0219/1132] feat(github): export Actions repository scan runner --- packages/github/package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/github/package.json b/packages/github/package.json index b42b0e48..493f03a3 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -6,7 +6,8 @@ "exports": { ".": "./dist/index.js", "./publisher": "./dist/publisher.js", - "./orchestrator": "./dist/orchestrator.js" + "./orchestrator": "./dist/orchestrator.js", + "./actions-runner": "./dist/actions-runner.js" }, "types": "./dist/index.d.ts", "scripts": { @@ -14,7 +15,9 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@synsec/config": "0.2.0", "@synsec/core": "0.1.0", + "@synsec/engine": "0.2.0", "@synsec/report": "0.2.0" } } From f99027c392f915a7c5d869412e9951e29d6ec6ff Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:13:25 -0400 Subject: [PATCH 0220/1132] test(github): cover Actions scan to check flow --- tests/github-actions-runner.test.mjs | 115 +++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/github-actions-runner.test.mjs diff --git a/tests/github-actions-runner.test.mjs b/tests/github-actions-runner.test.mjs new file mode 100644 index 00000000..baeb8d13 --- /dev/null +++ b/tests/github-actions-runner.test.mjs @@ -0,0 +1,115 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { runGitHubActionsRepositoryScan } from "../packages/github/dist/actions-runner.js"; + +function report(commitSha = "abcdef1234567890") { + return { + schemaVersion: "1.0", + reportId: "report-actions", + generatedAt: "2026-08-22T14:30:00.000Z", + toolVersion: "0.2.0", + target: { path: "/workspace", commitSha }, + scanners: [{ scanner: "opengrep", startedAt: "a", completedAt: "b", findingCount: 0, artifactCount: 0, diagnostics: [] }], + rawFindingCount: 0, + findingCount: 0, + summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 100, + findings: [], + scope: { mode: "changed-files", baseRef: "origin/main", changedFiles: ["src/app.ts"] }, + }; +} + +function outcome(commitSha = "abcdef1234567890") { + return { + report: report(commitSha), + repositoryIndex: { schemaVersion: "1.0", root: "/workspace", files: [] }, + statuses: [], + failures: [], + shouldFail: false, + changedFiles: ["src/app.ts"], + changedBase: "origin/main", + }; +} + +const config = { + version: 1, + scanners: ["opengrep"], + failOn: "high", + parallelism: 2, + timeoutMs: 60_000, +}; + +test("PR Actions runner defaults to changed-file scanning and publishes the scanned head", async () => { + let scanInput; + let request; + const result = await runGitHubActionsRepositoryScan("installation-token", { + config, + rootPath: "/workspace", + env: { + GITHUB_REPOSITORY: "cmahmud/synsec", + GITHUB_SHA: "abcdef1234567890", + GITHUB_REF: "refs/pull/2/head", + GITHUB_BASE_REF: "main", + GITHUB_HEAD_REF: "feature/multi-scanner-mvp", + }, + scan: async (input) => { + scanInput = input; + return outcome(); + }, + fetch: async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ id: 444, status: "completed", conclusion: "success" }), { status: 201 }); + }, + }); + + assert.equal(scanInput.rootPath, "/workspace"); + assert.equal(scanInput.changedOnly, true); + assert.equal(scanInput.changedBase, "origin/main"); + assert.equal(result.context.pullRequestNumber, 2); + assert.equal(result.publication.check.headSha, "abcdef1234567890"); + assert.equal(result.publication.publication.id, 444); + assert.equal(request.url, "https://api.github.com/repos/cmahmud/synsec/check-runs"); +}); + +test("push Actions runner defaults to a full repository scan", async () => { + let scanInput; + await runGitHubActionsRepositoryScan("token", { + config, + env: { + GITHUB_REPOSITORY: "cmahmud/synsec", + GITHUB_SHA: "abcdef1234567890", + GITHUB_REF: "refs/heads/main", + }, + scan: async (input) => { + scanInput = input; + const value = outcome(); + value.report.scope = { mode: "repository" }; + return value; + }, + fetch: async () => new Response(JSON.stringify({ id: 445, status: "completed", conclusion: "success" }), { status: 201 }), + }); + + assert.equal(scanInput.changedOnly, false); + assert.equal(scanInput.changedBase, undefined); +}); + +test("Actions runner refuses publication when the scan cannot prove its commit", async () => { + let published = false; + const value = outcome(); + delete value.report.target.commitSha; + + await assert.rejects( + () => runGitHubActionsRepositoryScan("token", { + config, + env: { GITHUB_REPOSITORY: "cmahmud/synsec", GITHUB_SHA: "abcdef1234567890" }, + scan: async () => value, + fetch: async () => { + published = true; + throw new Error("should not publish"); + }, + }), + /must produce a report with a commit SHA/, + ); + assert.equal(published, false); +}); From 33fb5d2054c3dd0d59f6b66da5576035ef26afbb Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:14:29 -0400 Subject: [PATCH 0221/1132] docs(roadmap): record Actions scan-to-check runner --- docs/ROADMAP.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c0174236..2b25c6f3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -95,15 +95,19 @@ These workflows operate on repository evidence and scanner results. They are not - [x] Baseline-aware PR annotation filtering and severity-threshold conclusions - [x] Narrow fixed-host Checks API publication primitive - [x] Completed-report → GitHub check publication orchestration +- [x] GitHub Actions repository scan → check runner with PR changed-file defaults +- [x] Report/commit binding before check publication - [ ] GitHub App - [ ] Repository installation flow -- [ ] Pull-request scanning transport/orchestration -- [ ] End-to-end PR event → scan → check publication +- [ ] Packaged Actions entrypoint / workflow template +- [ ] Baseline acquisition for pull-request scans - [ ] Inline SARIF/code-scanning upload - [ ] Scheduled repository scans - [ ] Optional remediation pull requests with explicit approval - [ ] GitLab and Bitbucket adapters +The Actions runner consumes the existing repository scan engine rather than introducing a second scanner path. Pull-request contexts default to changed-file scanning against `origin/`, push contexts default to full repository scans, and publication is refused when the scan cannot identify its commit or the report commit differs from the GitHub head being annotated. + See [GITHUB.md](./GITHUB.md) for the current integration contract and security boundaries. ## Phase 6 — Persistent web application From 3c018a4ff20e8a2a9493263f12dcd4c9c004b140 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:14:50 -0400 Subject: [PATCH 0222/1132] docs(github): document Actions repository scan runner --- docs/GITHUB.md | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/docs/GITHUB.md b/docs/GITHUB.md index f659ea2e..3ae78648 100644 --- a/docs/GITHUB.md +++ b/docs/GITHUB.md @@ -22,13 +22,16 @@ This keeps GitHub credentials out of scanners and prevents repository analysis f - A hard 50-annotation cap per generated payload, matching GitHub's check-run annotation request limit. - CI threshold evaluation independent of scanner exit-code quirks. - A narrow Checks API publisher with an injectable transport for testing. -- A completed-report publication orchestrator that resolves local Actions context, builds the deterministic check, and publishes it through the fixed-host transport. +- A completed-report publication orchestrator that resolves local Actions context, validates report/commit binding, builds the deterministic check, and publishes it through the fixed-host transport. +- A GitHub Actions repository scan runner that reuses the normal scan engine and then publishes the resulting report. `@synsec/github/publisher` posts completed check runs only to `https://api.github.com/repos///check-runs`. The repository comes from validated GitHub context, scanner output cannot control the request URL, redirects are rejected, and bearer tokens are never copied into returned errors. -`@synsec/github/orchestrator` provides `publishSynSecReportToGitHub()`. It accepts an already-completed `SynSecReport`, resolves the repository/commit from bounded local Actions context, builds the check, and invokes the publisher. It does not run scanners, discover targets, mutate repositories, or perform external assessment. If valid GitHub context cannot be resolved, it fails before any transport call. +`@synsec/github/orchestrator` provides `publishSynSecReportToGitHub()`. It accepts an already-completed `SynSecReport`, resolves the repository/commit from bounded local Actions context, validates that a report commit (when present) matches the selected GitHub head, builds the check, and invokes the publisher. It does not run scanners, discover targets, mutate repositories, or perform external assessment. Invalid context or stale report/commit binding fails before transport. -A future GitHub App or Actions adapter should own token acquisition and installation authorization while reusing these deterministic publication primitives. +`@synsec/github/actions-runner` provides `runGitHubActionsRepositoryScan()`. It invokes the existing repository scan engine for the current checkout and feeds the completed report through the orchestrator. Pull-request contexts default to changed-file scans; push/other contexts default to full repository scans. The runner requires the produced report to contain a commit SHA before publication. + +Token acquisition and installation authorization intentionally remain outside these primitives. ## Pull-request SHA handling @@ -44,6 +47,22 @@ from the local Actions event payload. `loadGitHubContext()` reads `GITHUB_EVENT_ No network request is required for context detection. +## Repository scan runner + +`runGitHubActionsRepositoryScan()` accepts a normal `SynSecConfig`, optional baseline, checkout root, publication settings, and caller-supplied token. The scan path deliberately reuses `runScanEngine()` rather than creating GitHub-specific scanners. + +For pull requests, changed-file scanning defaults to: + +```text +origin/...HEAD +``` + +Callers can override changed-file mode or the base ref explicitly. Push and other non-PR contexts default to a full repository scan. + +Before publication, the runner requires the scan report to identify its commit. The publication layer then refuses a report whose commit differs from the GitHub commit being annotated. This prevents a stale report from being attached to a newer PR head. + +The runner does not clone arbitrary targets, expand repository scope, perform live-target probing, or create repository writes. + ## Check conclusions The generated check conclusion follows the configured severity threshold: @@ -82,6 +101,7 @@ GitHub integration must preserve the repository-first defensive model: - Tokens belong to the GitHub transport layer, never scanner input. - Report and annotation generation must not require network access. - Scanner output must never choose the GitHub API host or arbitrary publication URL. +- A report must not be published onto a different commit than the one it represents. - Source excerpts are not added to GitHub annotations unless already present in normalized deterministic finding fields. - Secret values must remain redacted before publication. - A future remediation pull-request flow must require explicit approval before repository writes. @@ -89,11 +109,11 @@ GitHub integration must preserve the repository-first defensive model: ## Next implementation steps -The remaining Phase 5 work is product orchestration and installation/authentication: +The remaining Phase 5 work is packaging, baseline acquisition, and installation/authentication: -1. Add a GitHub App installation/authentication layer. -2. Wire PR events to changed-file scans and baseline selection. -3. Connect the completed scan result to `publishSynSecReportToGitHub()` in that PR event runner. +1. Add a packaged GitHub Actions entrypoint/workflow template around the runner. +2. Acquire/validate pull-request baselines without broadening repository scope. +3. Add a GitHub App installation/authentication layer using the same runner/publication primitives. 4. Upload SARIF to GitHub code scanning where repository permissions allow it. 5. Add scheduled repository scans. 6. Add explicitly approved remediation pull requests. From a926e3f68367b0027f3f097cca260c6aa5ab499c Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:15:29 -0400 Subject: [PATCH 0223/1132] feat(repository): add route auth context analysis --- packages/repository/src/route-auth-context.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 packages/repository/src/route-auth-context.ts diff --git a/packages/repository/src/route-auth-context.ts b/packages/repository/src/route-auth-context.ts new file mode 100644 index 00000000..c9830653 --- /dev/null +++ b/packages/repository/src/route-auth-context.ts @@ -0,0 +1,81 @@ +import type { AuthSignal, RepositoryIndex, RouteSignal } from "./analysis.js"; + +export type RouteAuthStatus = + | "authorization-signal-observed" + | "authentication-signal-observed" + | "no-auth-signal-observed"; + +export interface RouteAuthEvidence { + line: number; + distance: number; + kind: AuthSignal["kind"]; +} + +export interface RouteAuthContext { + route: RouteSignal; + status: RouteAuthStatus; + evidence: RouteAuthEvidence[]; + radius: number; + /** Lexical proximity is evidence for review prioritization, not proof of route protection. */ + interpretation: "lexical-auth-signals-only"; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\//, "").toLowerCase(); +} + +function authPriority(kind: AuthSignal["kind"]): number { + if (kind === "authorization") return 4; + if (kind === "authentication") return 3; + if (kind === "token") return 2; + return 1; +} + +export function routeAuthContext( + index: RepositoryIndex, + route: RouteSignal, + options: { radius?: number; maxEvidence?: number } = {}, +): RouteAuthContext { + const radius = Math.max(0, Math.min(500, options.radius ?? 40)); + const maxEvidence = Math.max(1, Math.min(20, options.maxEvidence ?? 5)); + const routePath = normalizePath(route.path); + + const evidence = index.authSignals + .filter((signal) => normalizePath(signal.path) === routePath) + .map((signal): RouteAuthEvidence => ({ + line: signal.line, + distance: Math.abs(signal.line - route.line), + kind: signal.kind, + })) + .filter((signal) => signal.distance <= radius) + .sort((a, b) => { + const priority = authPriority(b.kind) - authPriority(a.kind); + if (priority !== 0) return priority; + return a.distance - b.distance || a.line - b.line; + }) + .slice(0, maxEvidence); + + const status: RouteAuthStatus = evidence.some((signal) => signal.kind === "authorization") + ? "authorization-signal-observed" + : evidence.length > 0 + ? "authentication-signal-observed" + : "no-auth-signal-observed"; + + return { + route, + status, + evidence, + radius, + interpretation: "lexical-auth-signals-only", + }; +} + +export function repositoryRouteAuthContexts( + index: RepositoryIndex, + options: { radius?: number; maxEvidence?: number; maxRoutes?: number } = {}, +): RouteAuthContext[] { + const maxRoutes = Math.max(0, Math.min(5_000, options.maxRoutes ?? 1_000)); + return index.routes + .slice(0, maxRoutes) + .map((route) => routeAuthContext(index, route, options)); +} From f09b2c5d1c66af909665a0fea0a4564309bf3886 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:15:42 -0400 Subject: [PATCH 0224/1132] feat(repository): export route auth context --- packages/repository/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/repository/package.json b/packages/repository/package.json index 92f27850..104ab377 100644 --- a/packages/repository/package.json +++ b/packages/repository/package.json @@ -8,7 +8,8 @@ "./analysis": "./dist/analysis.js", "./module-graph": "./dist/module-graph.js", "./call-graph": "./dist/call-graph.js", - "./route-entrypoints": "./dist/route-entrypoints.js" + "./route-entrypoints": "./dist/route-entrypoints.js", + "./route-auth-context": "./dist/route-auth-context.js" }, "types": "./dist/index.d.ts", "scripts": { From 1d5c145f7c1e8d3e7e323668a86f3d443d16d50c Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:15:52 -0400 Subject: [PATCH 0225/1132] test(repository): cover route auth context --- tests/route-auth-context.test.mjs | 54 +++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/route-auth-context.test.mjs diff --git a/tests/route-auth-context.test.mjs b/tests/route-auth-context.test.mjs new file mode 100644 index 00000000..35f80bb8 --- /dev/null +++ b/tests/route-auth-context.test.mjs @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { repositoryRouteAuthContexts, routeAuthContext } from "../packages/repository/dist/route-auth-context.js"; + +function index() { + return { + schemaVersion: 1, + generatedAt: "2026-08-22T15:00:00.000Z", + indexedFileCount: 1, + moduleEdges: [], + routes: [ + { path: "src/routes.ts", line: 20, method: "GET", route: "/account", frameworkHint: "Node HTTP router" }, + { path: "src/routes.ts", line: 90, method: "POST", route: "/admin", frameworkHint: "Node HTTP router" }, + { path: "src/routes.ts", line: 180, method: "GET", route: "/health", frameworkHint: "Node HTTP router" }, + ], + authSignals: [ + { path: "./src\\routes.ts", line: 15, kind: "authentication", evidence: "requireAuth" }, + { path: "src/routes.ts", line: 84, kind: "authentication", evidence: "requireAuth" }, + { path: "src/routes.ts", line: 88, kind: "authorization", evidence: "isAdmin" }, + { path: "src/routes.ts", line: 300, kind: "token", evidence: "verifyToken" }, + ], + sinks: [], + }; +} + +test("routeAuthContext reports nearby authentication without claiming protection", () => { + const data = index(); + const context = routeAuthContext(data, data.routes[0]); + assert.equal(context.status, "authentication-signal-observed"); + assert.equal(context.interpretation, "lexical-auth-signals-only"); + assert.deepEqual(context.evidence, [{ line: 15, distance: 5, kind: "authentication" }]); +}); + +test("authorization evidence takes precedence over nearby authentication", () => { + const data = index(); + const context = routeAuthContext(data, data.routes[1]); + assert.equal(context.status, "authorization-signal-observed"); + assert.equal(context.evidence[0].kind, "authorization"); + assert.equal(context.evidence[0].distance, 2); +}); + +test("routes without nearby auth evidence stay explicitly unknown rather than public", () => { + const data = index(); + const context = routeAuthContext(data, data.routes[2], { radius: 20 }); + assert.equal(context.status, "no-auth-signal-observed"); + assert.deepEqual(context.evidence, []); +}); + +test("repositoryRouteAuthContexts bounds route output", () => { + const contexts = repositoryRouteAuthContexts(index(), { maxRoutes: 2 }); + assert.equal(contexts.length, 2); + assert.deepEqual(contexts.map((item) => item.route.route), ["/account", "/admin"]); +}); From 1bebc29a9832e4a005e9fe364d9e582b4fbe2905 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:17:09 -0400 Subject: [PATCH 0226/1132] docs(roadmap): record lexical route auth context --- docs/ROADMAP.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2b25c6f3..991bc230 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -47,15 +47,18 @@ This roadmap separates what is already usable in the repository from the deeper - [x] Import/module graph with bounded dependency/dependent traversal - [x] Bounded same-file lexical call-graph primitive for JavaScript/TypeScript and Python - [x] Conservative decorator-route to callable-entrypoint mapping +- [x] Bounded route-level lexical authentication/authorization context - [ ] Full function/call graph with reliable cross-module symbol resolution - [ ] Broad routes and externally reachable entry points across supported frameworks -- [ ] Authentication/authorization context -- [ ] Database, filesystem, process, and network sinks +- [ ] Framework-aware authentication/authorization enforcement semantics +- [ ] Database, filesystem, process, and network sink context beyond raw lexical signals - [ ] Dependency reachability beyond scanner-provided call analysis - [ ] Test ownership and coverage context around findings The current call graph is deliberately labeled lexical evidence rather than runtime reachability. It resolves unambiguous direct same-file calls and leaves qualified, external, or ambiguous calls unresolved. Decorator-based route mapping only links a route when one function declaration is structurally close enough to be unambiguous; generic router registrations remain unresolved rather than guessing a handler. +Route authentication context is similarly conservative. It records bounded same-file authentication, authorization, token, and session signals near indexed routes, prioritizes explicit authorization evidence, and labels the result `lexical-auth-signals-only`. Absence of a nearby signal is reported only as `no-auth-signal-observed`; it is not treated as proof that a route is public or unprotected. + ## Phase 3 — Contextual security review - [x] Provider-agnostic OpenAI-compatible AI review adapter From 6dd05a33699bdf891316647e3e4678a4264058ee Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:17:36 -0400 Subject: [PATCH 0227/1132] feat(repository): add route sink context analysis --- packages/repository/src/route-sink-context.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 packages/repository/src/route-sink-context.ts diff --git a/packages/repository/src/route-sink-context.ts b/packages/repository/src/route-sink-context.ts new file mode 100644 index 00000000..474bb984 --- /dev/null +++ b/packages/repository/src/route-sink-context.ts @@ -0,0 +1,73 @@ +import type { RepositoryIndex, RouteSignal, SinkSignal } from "./analysis.js"; + +export interface RouteSinkEvidence { + line: number; + distance: number; + kind: SinkSignal["kind"]; +} + +export interface RouteSinkContext { + route: RouteSignal; + evidence: RouteSinkEvidence[]; + kinds: SinkSignal["kind"][]; + radius: number; + /** Lexical proximity is review evidence only, not proof of call/data-flow reachability. */ + interpretation: "lexical-sink-signals-only"; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\//, "").toLowerCase(); +} + +function sinkPriority(kind: SinkSignal["kind"]): number { + if (kind === "process") return 4; + if (kind === "database") return 3; + if (kind === "filesystem") return 2; + return 1; +} + +export function routeSinkContext( + index: RepositoryIndex, + route: RouteSignal, + options: { radius?: number; maxEvidence?: number } = {}, +): RouteSinkContext { + const radius = Math.max(0, Math.min(500, options.radius ?? 80)); + const maxEvidence = Math.max(1, Math.min(20, options.maxEvidence ?? 8)); + const routePath = normalizePath(route.path); + + const evidence = index.sinks + .filter((signal) => normalizePath(signal.path) === routePath) + .map((signal): RouteSinkEvidence => ({ + line: signal.line, + distance: Math.abs(signal.line - route.line), + kind: signal.kind, + })) + .filter((signal) => signal.distance <= radius) + .sort((a, b) => { + const distance = a.distance - b.distance; + if (distance !== 0) return distance; + const priority = sinkPriority(b.kind) - sinkPriority(a.kind); + if (priority !== 0) return priority; + return a.line - b.line; + }) + .slice(0, maxEvidence); + + const kinds = [...new Set(evidence.map((signal) => signal.kind))]; + return { + route, + evidence, + kinds, + radius, + interpretation: "lexical-sink-signals-only", + }; +} + +export function repositoryRouteSinkContexts( + index: RepositoryIndex, + options: { radius?: number; maxEvidence?: number; maxRoutes?: number } = {}, +): RouteSinkContext[] { + const maxRoutes = Math.max(0, Math.min(5_000, options.maxRoutes ?? 1_000)); + return index.routes + .slice(0, maxRoutes) + .map((route) => routeSinkContext(index, route, options)); +} From a4898875ae802499c6418df2d7194765bd887c69 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:17:47 -0400 Subject: [PATCH 0228/1132] feat(repository): export route sink context --- packages/repository/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/repository/package.json b/packages/repository/package.json index 104ab377..cab11791 100644 --- a/packages/repository/package.json +++ b/packages/repository/package.json @@ -9,7 +9,8 @@ "./module-graph": "./dist/module-graph.js", "./call-graph": "./dist/call-graph.js", "./route-entrypoints": "./dist/route-entrypoints.js", - "./route-auth-context": "./dist/route-auth-context.js" + "./route-auth-context": "./dist/route-auth-context.js", + "./route-sink-context": "./dist/route-sink-context.js" }, "types": "./dist/index.d.ts", "scripts": { From 9052616416f5f181698466917bc0ed78956f729e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:17:58 -0400 Subject: [PATCH 0229/1132] test(repository): cover route sink context --- tests/route-sink-context.test.mjs | 59 +++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/route-sink-context.test.mjs diff --git a/tests/route-sink-context.test.mjs b/tests/route-sink-context.test.mjs new file mode 100644 index 00000000..60485195 --- /dev/null +++ b/tests/route-sink-context.test.mjs @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { repositoryRouteSinkContexts, routeSinkContext } from "../packages/repository/dist/route-sink-context.js"; + +function index() { + return { + schemaVersion: 1, + generatedAt: "2026-08-22T15:10:00.000Z", + indexedFileCount: 1, + moduleEdges: [], + routes: [ + { path: "src/routes.ts", line: 20, method: "POST", route: "/jobs" }, + { path: "src/routes.ts", line: 100, method: "GET", route: "/users" }, + { path: "src/routes.ts", line: 220, method: "GET", route: "/health" }, + ], + authSignals: [], + sinks: [ + { path: "./src\\routes.ts", line: 24, kind: "process", evidence: "spawn(command)" }, + { path: "src/routes.ts", line: 28, kind: "filesystem", evidence: "writeFile(path, data)" }, + { path: "src/routes.ts", line: 104, kind: "database", evidence: "query(sql)" }, + { path: "src/routes.ts", line: 400, kind: "network", evidence: "fetch(url)" }, + ], + }; +} + +test("routeSinkContext records bounded same-file sink proximity", () => { + const data = index(); + const context = routeSinkContext(data, data.routes[0]); + assert.equal(context.interpretation, "lexical-sink-signals-only"); + assert.deepEqual(context.kinds, ["process", "filesystem"]); + assert.deepEqual(context.evidence, [ + { line: 24, distance: 4, kind: "process" }, + { line: 28, distance: 8, kind: "filesystem" }, + ]); +}); + +test("routeSinkContext does not infer a sink when none is nearby", () => { + const data = index(); + const context = routeSinkContext(data, data.routes[2], { radius: 20 }); + assert.deepEqual(context.kinds, []); + assert.deepEqual(context.evidence, []); +}); + +test("sink evidence is ordered by proximity before sink kind", () => { + const data = index(); + data.sinks.push({ path: "src/routes.ts", line: 102, kind: "network", evidence: "fetch(url)" }); + const context = routeSinkContext(data, data.routes[1]); + assert.deepEqual(context.evidence.slice(0, 2), [ + { line: 102, distance: 2, kind: "network" }, + { line: 104, distance: 4, kind: "database" }, + ]); +}); + +test("repositoryRouteSinkContexts bounds route output", () => { + const contexts = repositoryRouteSinkContexts(index(), { maxRoutes: 2 }); + assert.equal(contexts.length, 2); + assert.deepEqual(contexts.map((item) => item.route.route), ["/jobs", "/users"]); +}); From fcd98177a34828a7b9f0e0d1253f2b30d3382bd8 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:18:45 -0400 Subject: [PATCH 0230/1132] feat(report): add self-contained history dashboard --- packages/report/src/history-html.ts | 110 ++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 packages/report/src/history-html.ts diff --git a/packages/report/src/history-html.ts b/packages/report/src/history-html.ts new file mode 100644 index 00000000..5b6f038e --- /dev/null +++ b/packages/report/src/history-html.ts @@ -0,0 +1,110 @@ +import type { ReportHistory, ReportHistoryPoint } from "./history.js"; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function signed(value: number): string { + return value > 0 ? `+${value}` : String(value); +} + +function dateLabel(value: string): string { + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date.toISOString().slice(0, 10) : value; +} + +function chartPoints(points: readonly ReportHistoryPoint[], width: number, height: number): string { + if (points.length === 0) return ""; + const padding = 18; + const usableWidth = Math.max(1, width - padding * 2); + const usableHeight = Math.max(1, height - padding * 2); + return points.map((point, index) => { + const x = points.length === 1 ? width / 2 : padding + (index / (points.length - 1)) * usableWidth; + const y = padding + ((100 - Math.max(0, Math.min(100, point.securityScore))) / 100) * usableHeight; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(" "); +} + +function latestPoint(history: ReportHistory): ReportHistoryPoint | undefined { + return history.points.at(-1); +} + +export function renderHistoryHtml(history: ReportHistory, options: { title?: string } = {}): string { + const title = escapeHtml(options.title?.trim() || "SynSec security history"); + const latest = latestPoint(history); + const activeFindings = history.findings.filter((finding) => finding.presentInLatest); + const trendRows = history.points.slice().reverse().map((point) => ` + + ${escapeHtml(dateLabel(point.generatedAt))} + ${point.securityScore} + ${point.findingCount} + ${point.newCount} + ${point.fixedCount} + ${point.persistingCount} + ${escapeHtml(point.commitSha?.slice(0, 12) ?? "—")} + `).join(""); + const findingRows = activeFindings.slice(0, 100).map((finding) => ` + + ${escapeHtml(finding.highestSeverity)} + ${escapeHtml(finding.title)} + ${finding.occurrenceCount} + ${escapeHtml(dateLabel(finding.firstSeenAt))} + ${escapeHtml(dateLabel(finding.lastSeenAt))} + `).join(""); + const polyline = chartPoints(history.points, 760, 180); + + return ` + + + + + ${title} + + +
+

${title}

+

Trend-safe repository security history. No source excerpts or scanner diagnostics are embedded in this dashboard.

+
+
Latest score${latest?.securityScore ?? "—"}${latest ? "/100" : ""}
+
Active findings${latest?.findingCount ?? 0}
+
Score change${signed(history.scoreDelta)}
+
Finding change${signed(history.findingCountDelta)}
+
+

Security score

+ ${history.points.length ? ` + + + ` : `
No scan history is available yet.
`} +
+

Scan history

+ ${history.points.length ? `${trendRows}
DateScoreFindingsNewFixedPersistingCommit
` : `
No scans recorded.
`} +
+

Findings present in latest scan

+ ${activeFindings.length ? `${findingRows}
SeverityFindingOccurrencesFirst seenLast seen
` : `
No findings are present in the latest scan.
`} +
+
`; +} From 4c293157777c23570324fa48500deed84af7509f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:18:53 -0400 Subject: [PATCH 0231/1132] feat(report): export history dashboard renderer --- packages/report/package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/report/package.json b/packages/report/package.json index 585ff8d6..3d9bfb5e 100644 --- a/packages/report/package.json +++ b/packages/report/package.json @@ -19,6 +19,10 @@ "./history-store": { "types": "./dist/history-store.d.ts", "import": "./dist/history-store.js" + }, + "./history-html": { + "types": "./dist/history-html.d.ts", + "import": "./dist/history-html.js" } }, "types": "./dist/index.d.ts", From e23e97bbaf0f153c632f99c13843b5562ad49a94 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:19:06 -0400 Subject: [PATCH 0232/1132] test(report): cover history dashboard renderer --- tests/report-history-html.test.mjs | 83 ++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/report-history-html.test.mjs diff --git a/tests/report-history-html.test.mjs b/tests/report-history-html.test.mjs new file mode 100644 index 00000000..aa1499bb --- /dev/null +++ b/tests/report-history-html.test.mjs @@ -0,0 +1,83 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { renderHistoryHtml } from "../packages/report/dist/history-html.js"; + +function history() { + return { + schemaVersion: 1, + scoreDelta: 8, + findingCountDelta: -1, + points: [ + { + reportId: "old", + generatedAt: "2026-08-20T12:00:00.000Z", + commitSha: "abcdef1234567890", + securityScore: 82, + findingCount: 2, + summary: { critical: 0, high: 1, medium: 1, low: 0, info: 0, unknown: 0 }, + newCount: 2, + fixedCount: 0, + persistingCount: 0, + }, + { + reportId: "new", + generatedAt: "2026-08-22T12:00:00.000Z", + commitSha: "1234567890abcdef", + securityScore: 90, + findingCount: 1, + summary: { critical: 0, high: 0, medium: 1, low: 0, info: 0, unknown: 0 }, + newCount: 0, + fixedCount: 1, + persistingCount: 1, + }, + ], + findings: [ + { + fingerprint: "fp-1", + title: "Unsafe ", + highestSeverity: "medium", + firstSeenAt: "2026-08-20T12:00:00.000Z", + lastSeenAt: "2026-08-22T12:00:00.000Z", + occurrenceCount: 2, + presentInLatest: true, + }, + { + fingerprint: "fp-fixed", + title: "Fixed finding", + highestSeverity: "high", + firstSeenAt: "2026-08-20T12:00:00.000Z", + lastSeenAt: "2026-08-20T12:00:00.000Z", + occurrenceCount: 1, + presentInLatest: false, + }, + ], + }; +} + +test("renderHistoryHtml creates a self-contained trend dashboard", () => { + const html = renderHistoryHtml(history(), { title: "Repository security" }); + assert.match(html, //); + assert.match(html, /Repository security/); + assert.match(html, /90\/100/); + assert.match(html, /\+8/); + assert.match(html, /-1/); + assert.match(html, /Security score trend/); + assert.match(html, /1234567890ab/); + assert.equal(html.includes("Fixed finding"), false); +}); + +test("renderHistoryHtml escapes finding and title content", () => { + const html = renderHistoryHtml(history(), { title: '' }); + assert.equal(html.includes(""), false); + assert.match(html, /Unsafe <script>alert\(1\)<\/script>/); + assert.equal(html.includes(''), false); + assert.match(html, /<img src=x onerror="x">/); +}); + +test("renderHistoryHtml handles empty history without malformed metrics", () => { + const html = renderHistoryHtml({ schemaVersion: 1, points: [], findings: [], scoreDelta: 0, findingCountDelta: 0 }); + assert.match(html, /No scan history is available yet/); + assert.match(html, /Active findings<\/span>0/); + assert.equal(html.includes("NaN"), false); +}); From c458e3badebf4be08ef51bd1833da5ee02f18665 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:19:27 -0400 Subject: [PATCH 0233/1132] feat(repository): add bounded repository posture summary --- packages/repository/src/posture.ts | 60 ++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 packages/repository/src/posture.ts diff --git a/packages/repository/src/posture.ts b/packages/repository/src/posture.ts new file mode 100644 index 00000000..add1e79a --- /dev/null +++ b/packages/repository/src/posture.ts @@ -0,0 +1,60 @@ +import type { RepositoryIndex, SinkSignal } from "./analysis.js"; +import { repositoryRouteAuthContexts, type RouteAuthStatus } from "./route-auth-context.js"; +import { repositoryRouteSinkContexts } from "./route-sink-context.js"; + +export interface RepositoryPostureSummary { + schemaVersion: 1; + indexedFileCount: number; + routeCount: number; + routeAuth: Record; + routeSinkKinds: Record; + routesWithSinkSignals: number; + routesWithoutAuthSignals: number; + /** Counts are derived from lexical repository signals and are not runtime security assertions. */ + interpretation: "bounded-lexical-posture-only"; +} + +export function buildRepositoryPosture( + index: RepositoryIndex, + options: { authRadius?: number; sinkRadius?: number; maxRoutes?: number } = {}, +): RepositoryPostureSummary { + const maxRoutes = Math.max(0, Math.min(5_000, options.maxRoutes ?? 1_000)); + const routeAuth = repositoryRouteAuthContexts(index, { + radius: options.authRadius, + maxRoutes, + }); + const routeSinks = repositoryRouteSinkContexts(index, { + radius: options.sinkRadius, + maxRoutes, + }); + + const authCounts: Record = { + "authorization-signal-observed": 0, + "authentication-signal-observed": 0, + "no-auth-signal-observed": 0, + }; + for (const route of routeAuth) authCounts[route.status] += 1; + + const sinkCounts: Record = { + process: 0, + filesystem: 0, + database: 0, + network: 0, + }; + let routesWithSinkSignals = 0; + for (const route of routeSinks) { + if (route.kinds.length > 0) routesWithSinkSignals += 1; + for (const kind of route.kinds) sinkCounts[kind] += 1; + } + + return { + schemaVersion: 1, + indexedFileCount: index.indexedFileCount, + routeCount: Math.min(index.routes.length, maxRoutes), + routeAuth: authCounts, + routeSinkKinds: sinkCounts, + routesWithSinkSignals, + routesWithoutAuthSignals: authCounts["no-auth-signal-observed"], + interpretation: "bounded-lexical-posture-only", + }; +} From 5fde404dde74466ecb6236b082fe9793cb4de055 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:19:37 -0400 Subject: [PATCH 0234/1132] feat(repository): export posture summary --- packages/repository/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/repository/package.json b/packages/repository/package.json index cab11791..ae27efa1 100644 --- a/packages/repository/package.json +++ b/packages/repository/package.json @@ -10,7 +10,8 @@ "./call-graph": "./dist/call-graph.js", "./route-entrypoints": "./dist/route-entrypoints.js", "./route-auth-context": "./dist/route-auth-context.js", - "./route-sink-context": "./dist/route-sink-context.js" + "./route-sink-context": "./dist/route-sink-context.js", + "./posture": "./dist/posture.js" }, "types": "./dist/index.d.ts", "scripts": { From 935eeb5959ce49eaa5f9e27f158dd7f4bce53a0b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:19:45 -0400 Subject: [PATCH 0235/1132] test(repository): cover bounded posture summary --- tests/repository-posture.test.mjs | 54 +++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/repository-posture.test.mjs diff --git a/tests/repository-posture.test.mjs b/tests/repository-posture.test.mjs new file mode 100644 index 00000000..2f035a14 --- /dev/null +++ b/tests/repository-posture.test.mjs @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildRepositoryPosture } from "../packages/repository/dist/posture.js"; + +function index() { + return { + schemaVersion: 1, + generatedAt: "2026-08-22T15:20:00.000Z", + indexedFileCount: 2, + moduleEdges: [], + routes: [ + { path: "src/routes.ts", line: 20, method: "GET", route: "/account" }, + { path: "src/routes.ts", line: 80, method: "POST", route: "/admin" }, + { path: "src/routes.ts", line: 160, method: "GET", route: "/health" }, + ], + authSignals: [ + { path: "src/routes.ts", line: 16, kind: "authentication", evidence: "requireAuth" }, + { path: "src/routes.ts", line: 76, kind: "authorization", evidence: "isAdmin" }, + ], + sinks: [ + { path: "src/routes.ts", line: 24, kind: "database", evidence: "query(sql)" }, + { path: "src/routes.ts", line: 84, kind: "process", evidence: "spawn(command)" }, + { path: "src/routes.ts", line: 88, kind: "database", evidence: "query(sql)" }, + ], + }; +} + +test("buildRepositoryPosture aggregates lexical auth and sink route signals", () => { + const posture = buildRepositoryPosture(index(), { authRadius: 20, sinkRadius: 20 }); + assert.equal(posture.interpretation, "bounded-lexical-posture-only"); + assert.equal(posture.indexedFileCount, 2); + assert.equal(posture.routeCount, 3); + assert.deepEqual(posture.routeAuth, { + "authorization-signal-observed": 1, + "authentication-signal-observed": 1, + "no-auth-signal-observed": 1, + }); + assert.deepEqual(posture.routeSinkKinds, { + process: 1, + filesystem: 0, + database: 2, + network: 0, + }); + assert.equal(posture.routesWithSinkSignals, 2); + assert.equal(posture.routesWithoutAuthSignals, 1); +}); + +test("posture summary respects the route cap", () => { + const posture = buildRepositoryPosture(index(), { maxRoutes: 2, authRadius: 20, sinkRadius: 20 }); + assert.equal(posture.routeCount, 2); + assert.equal(posture.routesWithoutAuthSignals, 0); + assert.equal(posture.routesWithSinkSignals, 2); +}); From 3542f01e7ce3758b258b14e74374e91a23e16760 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:20:19 -0400 Subject: [PATCH 0236/1132] feat(report): write dashboard from history store --- packages/report/src/history-html.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/report/src/history-html.ts b/packages/report/src/history-html.ts index 5b6f038e..4dc8a1bd 100644 --- a/packages/report/src/history-html.ts +++ b/packages/report/src/history-html.ts @@ -1,4 +1,7 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; import type { ReportHistory, ReportHistoryPoint } from "./history.js"; +import { buildHistoryFromStore } from "./history-store.js"; function escapeHtml(value: string): string { return value @@ -108,3 +111,22 @@ export function renderHistoryHtml(history: ReportHistory, options: { title?: str
`; } + +export async function writeHistoryHtml( + path: string, + history: ReportHistory, + options: { title?: string } = {}, +): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, renderHistoryHtml(history, options), { encoding: "utf8", mode: 0o600 }); +} + +export async function writeHistoryHtmlFromStore( + storePath: string, + outputPath: string, + options: { title?: string } = {}, +): Promise { + const history = await buildHistoryFromStore(storePath); + await writeHistoryHtml(outputPath, history, options); + return history; +} From 041e29b69ea38998e5bcd7c7e1565b66a98cfc56 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:20:38 -0400 Subject: [PATCH 0237/1132] test(report): cover history dashboard file output --- tests/report-history-html.test.mjs | 35 +++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/report-history-html.test.mjs b/tests/report-history-html.test.mjs index aa1499bb..279a0ccb 100644 --- a/tests/report-history-html.test.mjs +++ b/tests/report-history-html.test.mjs @@ -1,7 +1,10 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; -import { renderHistoryHtml } from "../packages/report/dist/history-html.js"; +import { renderHistoryHtml, writeHistoryHtmlFromStore } from "../packages/report/dist/history-html.js"; function history() { return { @@ -81,3 +84,33 @@ test("renderHistoryHtml handles empty history without malformed metrics", () => assert.match(html, /Active findings<\/span>0/); assert.equal(html.includes("NaN"), false); }); + +test("writeHistoryHtmlFromStore renders a trend-safe store to a restrictive local file", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-history-html-")); + try { + const storePath = join(root, "history.json"); + const outputPath = join(root, "dashboard", "index.html"); + await writeFile(storePath, JSON.stringify({ + schemaVersion: 1, + reports: [{ + reportId: "stored", + generatedAt: "2026-08-22T12:00:00.000Z", + target: { commitSha: "abcdef1234567890", branch: "main" }, + securityScore: 94, + findingCount: 1, + summary: { critical: 0, high: 0, medium: 1, low: 0, info: 0, unknown: 0 }, + findings: [{ fingerprint: "fp", primary: { title: "Stored finding", severity: "medium" } }], + }], + })); + + const built = await writeHistoryHtmlFromStore(storePath, outputPath, { title: "Stored history" }); + const html = await readFile(outputPath, "utf8"); + const info = await stat(outputPath); + assert.equal(built.points.length, 1); + assert.match(html, /Stored history/); + assert.match(html, /94\/100/); + if (process.platform !== "win32") assert.equal(info.mode & 0o777, 0o600); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From e4674f37d165b44f8b63d2545928b67f2504ab6d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:21:17 -0400 Subject: [PATCH 0238/1132] feat(ai): add deterministic review consensus --- packages/ai/src/consensus.ts | 148 +++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 packages/ai/src/consensus.ts diff --git a/packages/ai/src/consensus.ts b/packages/ai/src/consensus.ts new file mode 100644 index 00000000..64fdf215 --- /dev/null +++ b/packages/ai/src/consensus.ts @@ -0,0 +1,148 @@ +import type { Severity } from "@synsec/core"; +import type { AiFindingReview, ReviewAnswer } from "./index.js"; + +export type ReviewConsensusAgreement = "unanimous" | "majority" | "split" | "insufficient"; + +export interface ReviewConsensusGate { + id: string; + question: string; + answer: ReviewAnswer; + yes: number; + no: number; + unknown: number; +} + +export interface AiReviewConsensus { + schemaVersion: 1; + verdict: AiFindingReview["verdict"]; + severity: Severity; + confidence: number; + agreement: ReviewConsensusAgreement; + reviewerCount: number; + models: string[]; + agreeingModels: string[]; + dissentingModels: string[]; + gate: ReviewConsensusGate[]; + /** Consensus aggregates model inference; deterministic scanner evidence remains authoritative. */ + interpretation: "model-consensus-not-scanner-evidence"; +} + +const severityRank: Record = { + critical: 5, + high: 4, + medium: 3, + low: 2, + info: 1, + unknown: 0, +}; + +const verdictRank: Record = { + confirmed: 4, + likely: 3, + uncertain: 2, + "false-positive": 1, +}; + +function clampConfidence(value: number): number { + return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0)); +} + +function uniqueReviews(reviews: readonly AiFindingReview[]): AiFindingReview[] { + const byModel = new Map(); + for (const review of reviews) { + const model = review.model.trim(); + if (!model || byModel.has(model)) continue; + byModel.set(model, review); + } + return [...byModel.values()]; +} + +function winner(counts: Map, rank: Record): { value?: T; count: number; tied: boolean } { + const ordered = [...counts.entries()].sort((a, b) => b[1] - a[1] || rank[b[0]] - rank[a[0]] || a[0].localeCompare(b[0])); + const first = ordered[0]; + if (!first) return { count: 0, tied: false }; + const tied = ordered.length > 1 && ordered[1]?.[1] === first[1]; + return { value: first[0], count: first[1], tied }; +} + +function consensusGates(reviews: readonly AiFindingReview[]): ReviewConsensusGate[] { + const questions = new Map(); + for (const review of reviews) { + for (const gate of review.gate) if (!questions.has(gate.id)) questions.set(gate.id, gate.question); + } + + return [...questions.entries()].map(([id, question]) => { + let yes = 0; + let no = 0; + let unknown = 0; + for (const review of reviews) { + const answer = review.gate.find((gate) => gate.id === id)?.answer ?? "unknown"; + if (answer === "yes") yes += 1; + else if (answer === "no") no += 1; + else unknown += 1; + } + const answer: ReviewAnswer = yes > no && yes > unknown + ? "yes" + : no > yes && no > unknown + ? "no" + : "unknown"; + return { id, question, answer, yes, no, unknown }; + }); +} + +export function buildReviewConsensus( + input: readonly AiFindingReview[], + options: { minimumReviewers?: number } = {}, +): AiReviewConsensus { + const minimumReviewers = Math.max(2, Math.min(10, options.minimumReviewers ?? 2)); + const reviews = uniqueReviews(input).slice(0, 10); + const models = reviews.map((review) => review.model.trim()); + + if (reviews.length < minimumReviewers) { + return { + schemaVersion: 1, + verdict: "uncertain", + severity: "unknown", + confidence: 0, + agreement: "insufficient", + reviewerCount: reviews.length, + models, + agreeingModels: [], + dissentingModels: models, + gate: consensusGates(reviews), + interpretation: "model-consensus-not-scanner-evidence", + }; + } + + const verdictCounts = new Map(); + for (const review of reviews) verdictCounts.set(review.verdict, (verdictCounts.get(review.verdict) ?? 0) + 1); + const selected = winner(verdictCounts, verdictRank); + const hasMajority = selected.value !== undefined && selected.count > reviews.length / 2; + const unanimous = selected.value !== undefined && selected.count === reviews.length; + const consensusVerdict: AiFindingReview["verdict"] = hasMajority && !selected.tied ? selected.value ?? "uncertain" : "uncertain"; + const agreeing = reviews.filter((review) => review.verdict === consensusVerdict); + const confidenceSource = agreeing.length > 0 ? agreeing : reviews; + const confidence = confidenceSource.reduce((total, review) => total + clampConfidence(review.confidence), 0) / confidenceSource.length; + const severitySource = consensusVerdict === "false-positive" + ? agreeing + : reviews.filter((review) => review.verdict !== "false-positive"); + const severity = (severitySource.length ? severitySource : reviews) + .map((review) => review.severity) + .sort((a, b) => severityRank[b] - severityRank[a])[0] ?? "unknown"; + const agreeingModels = reviews.filter((review) => review.verdict === consensusVerdict).map((review) => review.model.trim()); + const dissentingModels = reviews.filter((review) => review.verdict !== consensusVerdict).map((review) => review.model.trim()); + + return { + schemaVersion: 1, + verdict: consensusVerdict, + severity, + confidence: Number(confidence.toFixed(4)), + agreement: unanimous ? "unanimous" : hasMajority && !selected.tied ? "majority" : "split", + reviewerCount: reviews.length, + models, + agreeingModels, + dissentingModels, + gate: consensusGates(reviews), + interpretation: "model-consensus-not-scanner-evidence", + }; +} From 1e8415770cc17d2816a14798256ef025c70b7def Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:21:27 -0400 Subject: [PATCH 0239/1132] feat(ai): export review consensus --- packages/ai/package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ai/package.json b/packages/ai/package.json index c2d5862a..d47fad62 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -3,7 +3,10 @@ "version": "0.2.0", "private": true, "type": "module", - "exports": "./dist/index.js", + "exports": { + ".": "./dist/index.js", + "./consensus": "./dist/consensus.js" + }, "types": "./dist/index.d.ts", "scripts": { "build": "tsc -p tsconfig.json", From 938ea47cc8f136a2171adfd4aabe1e40caae9e7f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:21:40 -0400 Subject: [PATCH 0240/1132] test(ai): cover deterministic review consensus --- tests/ai-consensus.test.mjs | 78 +++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/ai-consensus.test.mjs diff --git a/tests/ai-consensus.test.mjs b/tests/ai-consensus.test.mjs new file mode 100644 index 00000000..41889a61 --- /dev/null +++ b/tests/ai-consensus.test.mjs @@ -0,0 +1,78 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildReviewConsensus } from "../packages/ai/dist/consensus.js"; + +function review(model, verdict, confidence, severity = "high", gateAnswer = "yes") { + return { + model, + verdict, + confidence, + severity, + summary: `${model} summary`, + rationale: `${model} rationale`, + gate: [ + { id: "concrete", question: "Concrete?", answer: gateAnswer, note: "evidence" }, + { id: "reachable", question: "Reachable?", answer: "unknown", note: "unknown" }, + ], + }; +} + +test("buildReviewConsensus returns a majority verdict without treating it as scanner evidence", () => { + const consensus = buildReviewConsensus([ + review("model-a", "confirmed", 0.9, "high"), + review("model-b", "confirmed", 0.7, "medium"), + review("model-c", "uncertain", 0.6, "critical", "unknown"), + ]); + + assert.equal(consensus.verdict, "confirmed"); + assert.equal(consensus.agreement, "majority"); + assert.equal(consensus.severity, "critical"); + assert.equal(consensus.confidence, 0.8); + assert.deepEqual(consensus.agreeingModels, ["model-a", "model-b"]); + assert.deepEqual(consensus.dissentingModels, ["model-c"]); + assert.equal(consensus.interpretation, "model-consensus-not-scanner-evidence"); + assert.deepEqual(consensus.gate[0], { + id: "concrete", + question: "Concrete?", + answer: "yes", + yes: 2, + no: 0, + unknown: 1, + }); +}); + +test("split reviewer verdicts fail closed to uncertain", () => { + const consensus = buildReviewConsensus([ + review("model-a", "confirmed", 0.9), + review("model-b", "false-positive", 0.9, "low", "no"), + ]); + assert.equal(consensus.verdict, "uncertain"); + assert.equal(consensus.agreement, "split"); + assert.deepEqual(consensus.agreeingModels, []); + assert.deepEqual(consensus.dissentingModels, ["model-a", "model-b"]); + assert.equal(consensus.gate[0].answer, "unknown"); +}); + +test("insufficient unique reviewers never fabricate consensus", () => { + const consensus = buildReviewConsensus([ + review("same-model", "confirmed", 0.95), + review("same-model", "confirmed", 0.95), + ]); + assert.equal(consensus.verdict, "uncertain"); + assert.equal(consensus.severity, "unknown"); + assert.equal(consensus.confidence, 0); + assert.equal(consensus.agreement, "insufficient"); + assert.equal(consensus.reviewerCount, 1); +}); + +test("unanimous false-positive consensus preserves the reviewers' bounded severity", () => { + const consensus = buildReviewConsensus([ + review("model-a", "false-positive", 0.8, "low", "no"), + review("model-b", "false-positive", 0.6, "info", "no"), + ]); + assert.equal(consensus.verdict, "false-positive"); + assert.equal(consensus.agreement, "unanimous"); + assert.equal(consensus.severity, "low"); + assert.equal(consensus.confidence, 0.7); +}); From b70f86ae737f9cd844ea233257f0806f6b0aa9ed Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:22:34 -0400 Subject: [PATCH 0241/1132] docs(roadmap): record posture, history UI, and consensus primitives --- docs/ROADMAP.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 991bc230..d51cd942 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -48,16 +48,18 @@ This roadmap separates what is already usable in the repository from the deeper - [x] Bounded same-file lexical call-graph primitive for JavaScript/TypeScript and Python - [x] Conservative decorator-route to callable-entrypoint mapping - [x] Bounded route-level lexical authentication/authorization context +- [x] Bounded route-level lexical process/filesystem/database/network sink context +- [x] Bounded repository posture summary from route/auth/sink signals - [ ] Full function/call graph with reliable cross-module symbol resolution - [ ] Broad routes and externally reachable entry points across supported frameworks - [ ] Framework-aware authentication/authorization enforcement semantics -- [ ] Database, filesystem, process, and network sink context beyond raw lexical signals +- [ ] Data-flow-aware sink reachability beyond lexical proximity - [ ] Dependency reachability beyond scanner-provided call analysis - [ ] Test ownership and coverage context around findings The current call graph is deliberately labeled lexical evidence rather than runtime reachability. It resolves unambiguous direct same-file calls and leaves qualified, external, or ambiguous calls unresolved. Decorator-based route mapping only links a route when one function declaration is structurally close enough to be unambiguous; generic router registrations remain unresolved rather than guessing a handler. -Route authentication context is similarly conservative. It records bounded same-file authentication, authorization, token, and session signals near indexed routes, prioritizes explicit authorization evidence, and labels the result `lexical-auth-signals-only`. Absence of a nearby signal is reported only as `no-auth-signal-observed`; it is not treated as proof that a route is public or unprotected. +Route authentication and sink context are similarly conservative. They record bounded same-file security signals near indexed routes and label the results `lexical-auth-signals-only` or `lexical-sink-signals-only`. Absence of nearby auth is reported only as `no-auth-signal-observed`, and nearby sinks are not treated as proven data-flow or call reachability. The repository posture summary aggregates these bounded signals for prioritization while explicitly remaining `bounded-lexical-posture-only`. ## Phase 3 — Contextual security review @@ -66,13 +68,16 @@ Route authentication context is similarly conservative. It records bounded same- - [x] Separate deterministic scanner evidence from model inference - [x] Seven-question evidence gate for model review - [x] Source-code context disabled by default and separately opt-in -- [ ] Multi-model reviewer/verifier consensus +- [x] Deterministic multi-review consensus aggregation with disagreement/insufficient-review handling +- [ ] Multi-model reviewer execution/orchestration - [ ] Repository-aware explanation of reachability and impact - [ ] Suggested patch generation - [ ] Suggested regression/security tests - [x] Safe rescan-after-remediation verification primitive - [x] Finding lifecycle: new, confirmed, false positive, accepted risk, fixed, regressed +Consensus remains model inference, not scanner evidence. Duplicate model identities do not count as independent reviewers, split verdicts fail closed to `uncertain`, insufficient reviewer sets do not fabricate consensus, and gate answers are aggregated with disagreement preserved. + ## Phase 4 — Reusable workflows / skills The orchestration layer should expose small reusable defensive workflows rather than hard-coding one giant agent prompt. @@ -117,17 +122,19 @@ See [GITHUB.md](./GITHUB.md) for the current integration contract and security b - [x] Deterministic report-history aggregation for score, finding count, churn, and finding lifetime - [x] Bounded local scan-history store with atomic writes and trend-safe snapshots -- [ ] Project/repository dashboard +- [x] Self-contained trend-safe security-history HTML dashboard renderer +- [x] History-store → restrictive local dashboard file generation +- [ ] Project/repository dashboard application - [ ] Multi-project/server persistence layer -- [ ] Security-score history UI +- [ ] Interactive security-score history UI - [ ] New/fixed/regressed views - [ ] Finding detail page with source evidence - [ ] Dependency and SBOM views -- [ ] Repository posture view +- [ ] Interactive repository posture view - [ ] Team triage workflow - [ ] Finding comments/ownership -The local history store retains only report identifiers, timestamps, commit/branch metadata, aggregate counts/scores, and finding fingerprint/title/severity tuples. It deliberately omits source excerpts, scanner diagnostics, repository URLs, artifacts, and secret-bearing evidence. Retention is bounded, writes are atomic, and invalid/corrupt stores fail closed. A multi-project database and web UI remain future work. +The local history store retains only report identifiers, timestamps, commit/branch metadata, aggregate counts/scores, and finding fingerprint/title/severity tuples. It deliberately omits source excerpts, scanner diagnostics, repository URLs, artifacts, and secret-bearing evidence. Retention is bounded, writes are atomic, and invalid/corrupt stores fail closed. The self-contained history dashboard renders only this trend-safe model, escapes titles/content, and can be written with restrictive local permissions. A multi-project database and interactive web application remain future work. ## Phase 7 — Isolated scan workers From 1869eb851e5b2ac3e8e56cfd676dbd98904b2294 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:23:08 -0400 Subject: [PATCH 0242/1132] feat(ai): orchestrate bounded multi-review consensus --- packages/ai/src/consensus.ts | 94 +++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/consensus.ts b/packages/ai/src/consensus.ts index 64fdf215..c136d544 100644 --- a/packages/ai/src/consensus.ts +++ b/packages/ai/src/consensus.ts @@ -1,5 +1,11 @@ -import type { Severity } from "@synsec/core"; -import type { AiFindingReview, ReviewAnswer } from "./index.js"; +import type { Finding, Severity } from "@synsec/core"; +import type { FindingContext } from "@synsec/repository"; +import { + reviewFinding, + type AiFindingReview, + type OpenAiCompatibleConfig, + type ReviewAnswer, +} from "./index.js"; export type ReviewConsensusAgreement = "unanimous" | "majority" | "split" | "insufficient"; @@ -27,6 +33,23 @@ export interface AiReviewConsensus { interpretation: "model-consensus-not-scanner-evidence"; } +export interface ReviewConsensusFailure { + model: string; + message: string; +} + +export interface MultiReviewConsensusResult { + reviews: AiFindingReview[]; + failures: ReviewConsensusFailure[]; + consensus: AiReviewConsensus; +} + +export interface MultiReviewOptions { + minimumReviewers?: number; + concurrency?: number; + reviewer?: typeof reviewFinding; +} + const severityRank: Record = { critical: 5, high: 4, @@ -57,6 +80,22 @@ function uniqueReviews(reviews: readonly AiFindingReview[]): AiFindingReview[] { return [...byModel.values()]; } +function uniqueProviders(providers: readonly OpenAiCompatibleConfig[]): OpenAiCompatibleConfig[] { + const byModel = new Map(); + for (const provider of providers) { + const model = provider.model.trim(); + if (!model || byModel.has(model)) continue; + byModel.set(model, { ...provider, model }); + } + return [...byModel.values()].slice(0, 10); +} + +function safeFailureMessage(error: unknown, apiKey?: string): string { + let message = error instanceof Error ? error.message : String(error); + if (apiKey) message = message.replaceAll(apiKey, "[REDACTED]"); + return message.replace(/[\r\n]+/g, " ").slice(0, 500); +} + function winner(counts: Map, rank: Record): { value?: T; count: number; tied: boolean } { const ordered = [...counts.entries()].sort((a, b) => b[1] - a[1] || rank[b[0]] - rank[a[0]] || a[0].localeCompare(b[0])); const first = ordered[0]; @@ -146,3 +185,54 @@ export function buildReviewConsensus( interpretation: "model-consensus-not-scanner-evidence", }; } + +/** + * Execute independent defensive finding reviews with bounded concurrency and aggregate them. + * A secret finding can never cross this orchestration boundary with source context, even when a + * custom reviewer is injected. Provider failures are isolated and credentials are redacted from + * returned diagnostics. Fewer than the configured minimum successful reviewers yields an + * insufficient/uncertain consensus rather than silently lowering the requirement. + */ +export async function reviewFindingWithConsensus( + finding: Finding, + providers: readonly OpenAiCompatibleConfig[], + context?: FindingContext, + reviewInstructions?: string, + options: MultiReviewOptions = {}, +): Promise { + if (finding.category === "secret" && context) { + throw new Error("Source context is prohibited for secret findings at the multi-review boundary."); + } + + const selected = uniqueProviders(providers); + const minimumReviewers = Math.max(2, Math.min(10, options.minimumReviewers ?? 2)); + const concurrency = Math.max(1, Math.min(4, options.concurrency ?? 2)); + const reviewer = options.reviewer ?? reviewFinding; + const queue = [...selected]; + const reviews: AiFindingReview[] = []; + const failures: ReviewConsensusFailure[] = []; + + await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(1, queue.length)) }, async () => { + while (queue.length > 0) { + const provider = queue.shift(); + if (!provider) return; + try { + const review = await reviewer(finding, provider, context, reviewInstructions); + reviews.push(review); + } catch (error) { + failures.push({ + model: provider.model, + message: safeFailureMessage(error, provider.apiKey), + }); + } + } + })); + + reviews.sort((a, b) => a.model.localeCompare(b.model)); + failures.sort((a, b) => a.model.localeCompare(b.model)); + return { + reviews, + failures, + consensus: buildReviewConsensus(reviews, { minimumReviewers }), + }; +} From be874676f31e3272ff9d8101170b285bfa4eccad Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:23:34 -0400 Subject: [PATCH 0243/1132] test(ai): cover bounded multi-review execution --- tests/ai-consensus.test.mjs | 72 ++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/tests/ai-consensus.test.mjs b/tests/ai-consensus.test.mjs index 41889a61..7dd394c2 100644 --- a/tests/ai-consensus.test.mjs +++ b/tests/ai-consensus.test.mjs @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { buildReviewConsensus } from "../packages/ai/dist/consensus.js"; +import { buildReviewConsensus, reviewFindingWithConsensus } from "../packages/ai/dist/consensus.js"; function review(model, verdict, confidence, severity = "high", gateAnswer = "yes") { return { @@ -18,6 +18,17 @@ function review(model, verdict, confidence, severity = "high", gateAnswer = "yes }; } +const finding = { + id: "f-1", + title: "Unsafe input", + description: "Untrusted input reaches a sensitive operation.", + category: "sast", + severity: "high", + confidence: 0.9, + scanner: { name: "opengrep", ruleId: "unsafe-input" }, + location: { path: "src/app.ts", startLine: 10, endLine: 10 }, +}; + test("buildReviewConsensus returns a majority verdict without treating it as scanner evidence", () => { const consensus = buildReviewConsensus([ review("model-a", "confirmed", 0.9, "high"), @@ -76,3 +87,62 @@ test("unanimous false-positive consensus preserves the reviewers' bounded severi assert.equal(consensus.severity, "low"); assert.equal(consensus.confidence, 0.7); }); + +test("reviewFindingWithConsensus bounds independent reviewer execution and isolates failures", async () => { + let active = 0; + let maximumActive = 0; + const reviewer = async (_finding, provider) => { + active += 1; + maximumActive = Math.max(maximumActive, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active -= 1; + if (provider.model === "model-c") throw new Error(`provider failed with ${provider.apiKey}`); + return review(provider.model, "confirmed", provider.model === "model-a" ? 0.9 : 0.7); + }; + + const result = await reviewFindingWithConsensus(finding, [ + { baseUrl: "https://models.invalid", model: "model-a", apiKey: "secret-a" }, + { baseUrl: "https://models.invalid", model: "model-b", apiKey: "secret-b" }, + { baseUrl: "https://models.invalid", model: "model-c", apiKey: "secret-c" }, + { baseUrl: "https://models.invalid", model: "model-a", apiKey: "duplicate" }, + ], undefined, undefined, { concurrency: 2, reviewer }); + + assert.equal(maximumActive <= 2, true); + assert.deepEqual(result.reviews.map((item) => item.model), ["model-a", "model-b"]); + assert.equal(result.consensus.agreement, "unanimous"); + assert.equal(result.consensus.verdict, "confirmed"); + assert.equal(result.failures.length, 1); + assert.equal(result.failures[0].model, "model-c"); + assert.equal(result.failures[0].message.includes("secret-c"), false); + assert.match(result.failures[0].message, /\[REDACTED\]/); +}); + +test("reviewFindingWithConsensus fails closed when successful reviewers are below the minimum", async () => { + const result = await reviewFindingWithConsensus(finding, [ + { baseUrl: "https://models.invalid", model: "model-a" }, + { baseUrl: "https://models.invalid", model: "model-b" }, + ], undefined, undefined, { + minimumReviewers: 2, + reviewer: async (_finding, provider) => { + if (provider.model === "model-b") throw new Error("unavailable"); + return review(provider.model, "confirmed", 0.9); + }, + }); + assert.equal(result.consensus.agreement, "insufficient"); + assert.equal(result.consensus.verdict, "uncertain"); +}); + +test("multi-review boundary prohibits source context for secret findings before reviewer execution", async () => { + let called = false; + await assert.rejects( + () => reviewFindingWithConsensus( + { ...finding, category: "secret" }, + [{ baseUrl: "https://models.invalid", model: "model-a" }, { baseUrl: "https://models.invalid", model: "model-b" }], + { path: "src/app.ts", startLine: 1, endLine: 1, excerpt: "secret material" }, + undefined, + { reviewer: async () => { called = true; return review("model-a", "confirmed", 0.9); } }, + ), + /Source context is prohibited for secret findings/, + ); + assert.equal(called, false); +}); From 3113b483306270bb37b3847e29f8132046046497 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:24:08 -0400 Subject: [PATCH 0244/1132] feat(workflows): route deterministic model sets for consensus --- packages/workflows/src/routing.ts | 79 +++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/packages/workflows/src/routing.ts b/packages/workflows/src/routing.ts index 66cd21da..3bcd28c4 100644 --- a/packages/workflows/src/routing.ts +++ b/packages/workflows/src/routing.ts @@ -30,6 +30,11 @@ export interface ModelRoutingDecision { reason: string[]; } +export interface ModelSetRoutingDecision { + candidates: ModelCandidate[]; + reason: string[]; +} + function privacyRank(privacy: ModelPrivacy): number { if (privacy === "local") return 0; if (privacy === "private-remote") return 1; @@ -45,22 +50,17 @@ function eligible(candidate: ModelCandidate, request: ModelRoutingRequest): bool return true; } -export function routeModel( - candidates: readonly ModelCandidate[], - request: ModelRoutingRequest, -): ModelRoutingDecision { - const matching = candidates.filter((candidate) => eligible(candidate, request)); - if (matching.length === 0) { - const constraints = [ - `task=${request.task}`, - `sourceContext=${request.sourceContextRequested ? "required" : "not-required"}`, - request.maxCostTier !== undefined ? `maxCostTier=${request.maxCostTier}` : undefined, - request.requireLocal ? "privacy=local-only" : undefined, - ].filter((value): value is string => value !== undefined); - throw new Error(`No model candidate satisfies routing constraints: ${constraints.join(", ")}.`); - } +function constraints(request: ModelRoutingRequest): string[] { + return [ + `task=${request.task}`, + `sourceContext=${request.sourceContextRequested ? "required" : "not-required"}`, + request.maxCostTier !== undefined ? `maxCostTier=${request.maxCostTier}` : undefined, + request.requireLocal ? "privacy=local-only" : undefined, + ].filter((value): value is string => value !== undefined); +} - const ranked = [...matching].sort((left, right) => { +function rankedEligible(candidates: readonly ModelCandidate[], request: ModelRoutingRequest): ModelCandidate[] { + return candidates.filter((candidate) => eligible(candidate, request)).sort((left, right) => { if (request.preferLocal) { const privacyDifference = privacyRank(left.privacy) - privacyRank(right.privacy); if (privacyDifference !== 0) return privacyDifference; @@ -70,6 +70,16 @@ export function routeModel( || privacyRank(left.privacy) - privacyRank(right.privacy) || left.id.localeCompare(right.id); }); +} + +export function routeModel( + candidates: readonly ModelCandidate[], + request: ModelRoutingRequest, +): ModelRoutingDecision { + const ranked = rankedEligible(candidates, request); + if (ranked.length === 0) { + throw new Error(`No model candidate satisfies routing constraints: ${constraints(request).join(", ")}.`); + } const candidate = ranked[0]; if (!candidate) throw new Error("Model routing produced no candidate after eligibility filtering."); @@ -85,3 +95,42 @@ export function routeModel( return { candidate, reason }; } + +/** + * Select a deterministic set of distinct model identities for reviewer/verifier consensus. + * The request's privacy, source-context, and cost constraints are applied to every member. + * Insufficient eligible models fail closed instead of silently reducing reviewer count. + */ +export function routeModelSet( + candidates: readonly ModelCandidate[], + request: ModelRoutingRequest, + count = 2, +): ModelSetRoutingDecision { + if (!Number.isInteger(count) || count < 2 || count > 10) { + throw new Error("Consensus model count must be an integer between 2 and 10."); + } + + const seen = new Set(); + const ranked = rankedEligible(candidates, request).filter((candidate) => { + const id = candidate.id.trim(); + if (!id || seen.has(id)) return false; + seen.add(id); + return true; + }); + if (ranked.length < count) { + throw new Error( + `Only ${ranked.length} distinct model candidate(s) satisfy consensus routing constraints; ${count} required: ${constraints(request).join(", ")}.`, + ); + } + + const selected = ranked.slice(0, count); + return { + candidates: selected, + reason: [ + `selected ${count} distinct models for ${request.task}`, + request.sourceContextRequested ? "all permit source context" : "source context not required", + request.requireLocal ? "all are local" : request.preferLocal ? "local preference applied" : "standard privacy ranking applied", + "cost/latency constraints preserved for every reviewer", + ], + }; +} From c4cf121d7706a1357e90264a1f874e29a368d38d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:24:25 -0400 Subject: [PATCH 0245/1132] test(workflows): cover consensus model-set routing --- tests/model-routing.test.mjs | 68 +++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/tests/model-routing.test.mjs b/tests/model-routing.test.mjs index 18590e0c..581db9a4 100644 --- a/tests/model-routing.test.mjs +++ b/tests/model-routing.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { routeModel } from "../packages/workflows/dist/routing.js"; +import { routeModel, routeModelSet } from "../packages/workflows/dist/routing.js"; const candidates = [ { @@ -91,3 +91,69 @@ test("local preference is deterministic when multiple candidates remain eligible }); assert.equal(privateDecision.candidate.id, "local-small"); }); + +test("routeModelSet selects distinct reviewers with the same eligibility policy", () => { + const expanded = [ + ...candidates, + { + id: "local-security", + tasks: ["security-reasoner", "verifier"], + costTier: 1, + latencyTier: 1, + privacy: "local", + supportsSourceContext: true, + }, + { + id: "remote-security-2", + tasks: ["security-reasoner"], + costTier: 2, + latencyTier: 1, + privacy: "remote", + supportsSourceContext: true, + }, + { + id: "local-security", + tasks: ["security-reasoner"], + costTier: 0, + latencyTier: 0, + privacy: "local", + supportsSourceContext: true, + }, + ]; + const decision = routeModelSet(expanded, { + task: "security-reasoner", + sourceContextRequested: false, + preferLocal: true, + }, 3); + assert.deepEqual(decision.candidates.map((candidate) => candidate.id), [ + "local-security", + "private-security", + "remote-security-2", + ]); + assert.equal(new Set(decision.candidates.map((candidate) => candidate.id)).size, 3); +}); + +test("routeModelSet fails closed when source/privacy constraints leave too few reviewers", () => { + assert.throws( + () => routeModelSet(candidates, { + task: "security-reasoner", + sourceContextRequested: true, + }, 2), + /Only 1 distinct model candidate\(s\).*2 required/, + ); + assert.throws( + () => routeModelSet(candidates, { + task: "verifier", + sourceContextRequested: false, + requireLocal: true, + }, 2), + /Only 0 distinct model candidate\(s\).*privacy=local-only/, + ); +}); + +test("routeModelSet validates the consensus reviewer count", () => { + assert.throws( + () => routeModelSet(candidates, { task: "security-reasoner", sourceContextRequested: false }, 1), + /between 2 and 10/, + ); +}); From 79fa93b11db135c8c70804bc08a64d3c3419a22c Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:25:00 -0400 Subject: [PATCH 0246/1132] refactor(github): expose report commit binding check --- packages/github/src/orchestrator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/github/src/orchestrator.ts b/packages/github/src/orchestrator.ts index 9f5e7db2..8c7c80ce 100644 --- a/packages/github/src/orchestrator.ts +++ b/packages/github/src/orchestrator.ts @@ -25,7 +25,7 @@ export interface GitHubReportPublicationResult { publication: GitHubCheckPublication; } -function sameCommit(reportSha: string, contextSha: string): boolean { +export function reportMatchesGitHubCommit(reportSha: string, contextSha: string): boolean { const report = reportSha.trim().toLowerCase(); const context = contextSha.trim().toLowerCase(); if (!report || !context) return false; @@ -52,7 +52,7 @@ export async function publishSynSecReportToGitHub( } const reportCommitSha = report.target.commitSha?.trim(); - if (reportCommitSha && !sameCommit(reportCommitSha, context.sha)) { + if (reportCommitSha && !reportMatchesGitHubCommit(reportCommitSha, context.sha)) { throw new Error("SynSec report commit does not match the GitHub commit selected for publication."); } From 253d666825058c4d9578eefaaf8b8ac11beba8ca Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:25:16 -0400 Subject: [PATCH 0247/1132] feat(github): add fixed-host SARIF publisher --- packages/github/src/sarif-publisher.ts | 97 ++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 packages/github/src/sarif-publisher.ts diff --git a/packages/github/src/sarif-publisher.ts b/packages/github/src/sarif-publisher.ts new file mode 100644 index 00000000..98bd56c9 --- /dev/null +++ b/packages/github/src/sarif-publisher.ts @@ -0,0 +1,97 @@ +import { gzipSync } from "node:zlib"; +import type { SynSecReport } from "@synsec/report"; +import { toSarif } from "@synsec/report"; +import type { GitHubPullRequestContext } from "./index.js"; +import { reportMatchesGitHubCommit } from "./orchestrator.js"; +import type { GitHubPublisherOptions } from "./publisher.js"; + +const MAX_COMPRESSED_SARIF_BYTES = 10 * 1024 * 1024; + +export interface GitHubSarifPublication { + id: string; + url?: string; + commitSha: string; + ref: string; + compressedBytes: number; +} + +function repositoryParts(repository: string): [string, string] { + const match = /^([^/\s]+)\/([^/\s]+)$/.exec(repository); + if (!match?.[1] || !match[2]) throw new Error("Invalid GitHub repository in publication context."); + return [encodeURIComponent(match[1]), encodeURIComponent(match[2])]; +} + +export function sarifRefForContext(context: GitHubPullRequestContext): string { + if (context.pullRequestNumber) return `refs/pull/${context.pullRequestNumber}/head`; + if (context.ref?.startsWith("refs/")) return context.ref; + if (context.headRef) return `refs/heads/${context.headRef}`; + throw new Error("GitHub SARIF publication requires a fully qualified repository ref."); +} + +function safeResponseText(value: string, token: string): string { + return value.replaceAll(token, "[REDACTED]").replace(/[\r\n]+/g, " ").slice(0, 500); +} + +/** + * Upload one completed report as SARIF to GitHub code scanning. The destination is derived only + * from validated GitHub context and is always api.github.com; scanner/report content cannot choose + * a host. The report must identify the same commit being published. + */ +export async function publishGitHubSarif( + report: SynSecReport, + context: GitHubPullRequestContext, + token: string, + options: GitHubPublisherOptions = {}, +): Promise { + const reportSha = report.target.commitSha?.trim(); + if (!reportSha) throw new Error("GitHub SARIF publication requires a report commit SHA."); + if (!reportMatchesGitHubCommit(reportSha, context.sha)) { + throw new Error("SynSec report commit does not match the GitHub commit selected for SARIF publication."); + } + if (!token.trim()) throw new Error("GitHub SARIF publication requires a non-empty token."); + + const ref = sarifRefForContext(context); + const sarif = Buffer.from(JSON.stringify(toSarif(report)), "utf8"); + const compressed = gzipSync(sarif, { level: 9 }); + if (compressed.byteLength > MAX_COMPRESSED_SARIF_BYTES) { + throw new Error(`Compressed SARIF exceeds the ${MAX_COMPRESSED_SARIF_BYTES}-byte GitHub upload limit.`); + } + + const [owner, repository] = repositoryParts(context.repository); + const endpoint = `https://api.github.com/repos/${owner}/${repository}/code-scanning/sarifs`; + const transport = options.fetch ?? fetch; + const response = await transport(endpoint, { + method: "POST", + redirect: "error", + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "content-type": "application/json", + "user-agent": options.userAgent ?? "synsec/0.2", + "x-github-api-version": options.apiVersion ?? "2022-11-28", + }, + body: JSON.stringify({ + commit_sha: context.sha, + ref, + sarif: compressed.toString("base64"), + }), + }); + + if (!response.ok) { + const detail = safeResponseText(await response.text(), token); + throw new Error(`GitHub SARIF API returned HTTP ${response.status}${detail ? `: ${detail}` : ""}.`); + } + + const payload = await response.json() as { id?: unknown; url?: unknown }; + if (typeof payload.id !== "string" || !payload.id.trim()) { + throw new Error("GitHub SARIF API returned no upload id."); + } + + return { + id: payload.id, + ...(typeof payload.url === "string" && payload.url ? { url: payload.url } : {}), + commitSha: context.sha, + ref, + compressedBytes: compressed.byteLength, + }; +} From 7100d1b73a30bcb7934eeb1e5c0c1be896896614 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:25:31 -0400 Subject: [PATCH 0248/1132] feat(github): export SARIF publisher --- packages/github/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/github/package.json b/packages/github/package.json index 493f03a3..b4705802 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -7,7 +7,8 @@ ".": "./dist/index.js", "./publisher": "./dist/publisher.js", "./orchestrator": "./dist/orchestrator.js", - "./actions-runner": "./dist/actions-runner.js" + "./actions-runner": "./dist/actions-runner.js", + "./sarif-publisher": "./dist/sarif-publisher.js" }, "types": "./dist/index.d.ts", "scripts": { From 945a719bd770ce5183476c343ef59b9e7506b4fd Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:25:46 -0400 Subject: [PATCH 0249/1132] test(github): cover fixed-host SARIF publication --- tests/github-sarif-publisher.test.mjs | 127 ++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/github-sarif-publisher.test.mjs diff --git a/tests/github-sarif-publisher.test.mjs b/tests/github-sarif-publisher.test.mjs new file mode 100644 index 00000000..fcdd8434 --- /dev/null +++ b/tests/github-sarif-publisher.test.mjs @@ -0,0 +1,127 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { gunzipSync } from "node:zlib"; + +import { publishGitHubSarif, sarifRefForContext } from "../packages/github/dist/sarif-publisher.js"; + +function report(commitSha = "abcdef1234567890") { + return { + schemaVersion: "1.0", + reportId: "report-sarif", + generatedAt: "2026-08-22T15:30:00.000Z", + toolVersion: "0.2.0", + target: { path: "/workspace", commitSha }, + scanners: [{ scanner: "opengrep", startedAt: "a", completedAt: "b", findingCount: 1, artifactCount: 0, diagnostics: [] }], + rawFindingCount: 1, + findingCount: 1, + summary: { critical: 0, high: 1, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 90, + findings: [{ + fingerprint: "fp-sarif", + primary: { + id: "finding-1", + title: "Unsafe input", + description: "Untrusted input reaches a sensitive operation.", + category: "sast", + severity: "high", + confidence: 0.95, + scanner: { name: "opengrep", ruleId: "unsafe-input" }, + location: { path: "src/app.ts", startLine: 8, endLine: 8 }, + }, + duplicates: [], + sources: [{ name: "opengrep", ruleId: "unsafe-input" }], + }], + scope: { mode: "repository" }, + }; +} + +test("SARIF ref uses the PR head ref that corresponds to the published head SHA", () => { + assert.equal(sarifRefForContext({ + repository: "cmahmud/synsec", + sha: "abcdef1234567890", + ref: "refs/pull/2/merge", + headRef: "feature/multi-scanner-mvp", + pullRequestNumber: 2, + }), "refs/pull/2/head"); + assert.equal(sarifRefForContext({ + repository: "cmahmud/synsec", + sha: "abcdef1234567890", + ref: "refs/heads/main", + }), "refs/heads/main"); +}); + +test("publishGitHubSarif uploads gzip/base64 SARIF only to GitHub code scanning", async () => { + let request; + const context = { + repository: "cmahmud/synsec", + sha: "abcdef1234567890", + ref: "refs/pull/2/merge", + pullRequestNumber: 2, + }; + const result = await publishGitHubSarif(report(), context, "installation-token", { + fetch: async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ id: "sarif-upload-1", url: "https://api.github.com/uploads/1" }), { status: 202 }); + }, + }); + + assert.equal(request.url, "https://api.github.com/repos/cmahmud/synsec/code-scanning/sarifs"); + assert.equal(request.init.redirect, "error"); + assert.equal(request.init.headers.authorization, "Bearer installation-token"); + const body = JSON.parse(request.init.body); + assert.equal(body.commit_sha, "abcdef1234567890"); + assert.equal(body.ref, "refs/pull/2/head"); + const decoded = JSON.parse(gunzipSync(Buffer.from(body.sarif, "base64")).toString("utf8")); + assert.equal(decoded.version, "2.1.0"); + assert.equal(decoded.runs[0].results.length, 1); + assert.equal(result.id, "sarif-upload-1"); + assert.equal(result.ref, "refs/pull/2/head"); + assert.equal(result.compressedBytes > 0, true); +}); + +test("SARIF publication rejects stale reports before transport", async () => { + let called = false; + await assert.rejects( + () => publishGitHubSarif(report("1111111111111111"), { + repository: "cmahmud/synsec", + sha: "2222222222222222", + ref: "refs/heads/main", + }, "token", { + fetch: async () => { + called = true; + throw new Error("transport should not run"); + }, + }), + /report commit does not match.*SARIF publication/, + ); + assert.equal(called, false); +}); + +test("SARIF publication redacts a token if GitHub error text reflects it", async () => { + await assert.rejects( + () => publishGitHubSarif(report(), { + repository: "cmahmud/synsec", + sha: "abcdef1234567890", + ref: "refs/heads/main", + }, "super-secret-token", { + fetch: async () => new Response("failed super-secret-token\nsecond line", { status: 403 }), + }), + (error) => { + assert.match(error.message, /HTTP 403/); + assert.match(error.message, /\[REDACTED\]/); + assert.equal(error.message.includes("super-secret-token"), false); + assert.equal(error.message.includes("\n"), false); + return true; + }, + ); +}); + +test("SARIF publication requires a fully qualified ref outside pull requests", async () => { + await assert.rejects( + () => publishGitHubSarif(report(), { + repository: "cmahmud/synsec", + sha: "abcdef1234567890", + }, "token", { fetch: async () => new Response("", { status: 202 }) }), + /requires a fully qualified repository ref/, + ); +}); From 40a13d38e4d5ff19ef0e4218d25bc72a52b27d86 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:26:08 -0400 Subject: [PATCH 0250/1132] feat(github): optionally publish SARIF from Actions runner --- packages/github/src/actions-runner.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/github/src/actions-runner.ts b/packages/github/src/actions-runner.ts index 21c505f9..9fff861a 100644 --- a/packages/github/src/actions-runner.ts +++ b/packages/github/src/actions-runner.ts @@ -7,6 +7,10 @@ import { type GitHubReportPublicationOptions, type GitHubReportPublicationResult, } from "./orchestrator.js"; +import { + publishGitHubSarif, + type GitHubSarifPublication, +} from "./sarif-publisher.js"; export interface GitHubActionsRepositoryScanOptions extends GitHubReportPublicationOptions { config: SynSecConfig; @@ -15,6 +19,7 @@ export interface GitHubActionsRepositoryScanOptions extends GitHubReportPublicat toolVersion?: string; changedOnly?: boolean; changedBase?: string; + publishSarif?: boolean; scan?: typeof runScanEngine; } @@ -22,12 +27,14 @@ export interface GitHubActionsRepositoryScanResult { context: GitHubPullRequestContext; outcome: ScanEngineOutcome; publication: GitHubReportPublicationResult; + sarifPublication?: GitHubSarifPublication; } /** * Run the existing repository scanner engine for the current GitHub Actions checkout and publish * the completed report as a check run. Pull-request contexts default to changed-file scanning; - * push/other contexts default to a full repository scan. No live-target discovery is performed. + * push/other contexts default to a full repository scan. Optional code-scanning publication uses + * the same completed report and fixed GitHub host. No live-target discovery is performed. */ export async function runGitHubActionsRepositoryScan( token: string, @@ -66,5 +73,18 @@ export async function runGitHubActionsRepositoryScan( fetch: options.fetch, }); - return { context, outcome, publication }; + const sarifPublication = options.publishSarif + ? await publishGitHubSarif(outcome.report, context, token, { + apiVersion: options.apiVersion, + userAgent: options.userAgent, + fetch: options.fetch, + }) + : undefined; + + return { + context, + outcome, + publication, + ...(sarifPublication ? { sarifPublication } : {}), + }; } From f01f9555d8cc016f3c143920f9973ab4e256bd22 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:26:28 -0400 Subject: [PATCH 0251/1132] test(github): cover optional SARIF publication --- tests/github-actions-runner.test.mjs | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/github-actions-runner.test.mjs b/tests/github-actions-runner.test.mjs index baeb8d13..6c44fd12 100644 --- a/tests/github-actions-runner.test.mjs +++ b/tests/github-actions-runner.test.mjs @@ -70,6 +70,7 @@ test("PR Actions runner defaults to changed-file scanning and publishes the scan assert.equal(result.publication.check.headSha, "abcdef1234567890"); assert.equal(result.publication.publication.id, 444); assert.equal(request.url, "https://api.github.com/repos/cmahmud/synsec/check-runs"); + assert.equal(result.sarifPublication, undefined); }); test("push Actions runner defaults to a full repository scan", async () => { @@ -94,6 +95,39 @@ test("push Actions runner defaults to a full repository scan", async () => { assert.equal(scanInput.changedBase, undefined); }); +test("Actions runner can publish the same commit-bound report to checks and code scanning", async () => { + const urls = []; + const result = await runGitHubActionsRepositoryScan("token", { + config, + publishSarif: true, + env: { + GITHUB_REPOSITORY: "cmahmud/synsec", + GITHUB_SHA: "abcdef1234567890", + GITHUB_REF: "refs/pull/2/head", + GITHUB_BASE_REF: "main", + GITHUB_HEAD_REF: "feature/multi-scanner-mvp", + }, + scan: async () => outcome(), + fetch: async (url) => { + urls.push(url); + if (url.endsWith("/check-runs")) { + return new Response(JSON.stringify({ id: 446, status: "completed", conclusion: "success" }), { status: 201 }); + } + if (url.endsWith("/code-scanning/sarifs")) { + return new Response(JSON.stringify({ id: "sarif-446" }), { status: 202 }); + } + throw new Error(`unexpected URL ${url}`); + }, + }); + + assert.deepEqual(urls, [ + "https://api.github.com/repos/cmahmud/synsec/check-runs", + "https://api.github.com/repos/cmahmud/synsec/code-scanning/sarifs", + ]); + assert.equal(result.sarifPublication.id, "sarif-446"); + assert.equal(result.sarifPublication.ref, "refs/pull/2/head"); +}); + test("Actions runner refuses publication when the scan cannot prove its commit", async () => { let published = false; const value = outcome(); From 683b102c58e929bb08d45143499862569e62f6c5 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:27:21 -0400 Subject: [PATCH 0252/1132] feat(github): retain pull-request base commit SHA --- packages/github/src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/github/src/index.ts b/packages/github/src/index.ts index bbad5328..bf2cf466 100644 --- a/packages/github/src/index.ts +++ b/packages/github/src/index.ts @@ -10,6 +10,7 @@ export interface GitHubPullRequestContext { sha: string; ref?: string; baseRef?: string; + baseSha?: string; headRef?: string; pullRequestNumber?: number; } @@ -41,7 +42,7 @@ export interface GitHubCheckResult { interface GitHubEventPullRequest { number?: unknown; head?: { sha?: unknown; ref?: unknown }; - base?: { ref?: unknown }; + base?: { sha?: unknown; ref?: unknown }; } interface GitHubEventPayload { @@ -111,6 +112,7 @@ export function detectGitHubContext( if (!repository || !sha) return undefined; const baseRef = nonEmptyString(pullRequest?.base?.ref) ?? nonEmptyString(env.GITHUB_BASE_REF); + const baseSha = nonEmptyString(pullRequest?.base?.sha); const headRef = nonEmptyString(pullRequest?.head?.ref) ?? nonEmptyString(env.GITHUB_HEAD_REF); const pullRequestNumber = positiveInteger(pullRequest?.number) ?? parsePullRequestNumber(ref); @@ -119,6 +121,7 @@ export function detectGitHubContext( sha, ...(ref ? { ref } : {}), ...(baseRef ? { baseRef } : {}), + ...(baseSha ? { baseSha } : {}), ...(headRef ? { headRef } : {}), ...(pullRequestNumber ? { pullRequestNumber } : {}), }; From ae2aa358f2c2803aa3ef8f63c74f8b3ef8e3b8ef Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:27:39 -0400 Subject: [PATCH 0253/1132] feat(github): load commit-bound local PR baselines --- packages/github/src/baseline.ts | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 packages/github/src/baseline.ts diff --git a/packages/github/src/baseline.ts b/packages/github/src/baseline.ts new file mode 100644 index 00000000..31f6fad2 --- /dev/null +++ b/packages/github/src/baseline.ts @@ -0,0 +1,45 @@ +import { stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { readReport, type SynSecReport } from "@synsec/report"; +import type { GitHubPullRequestContext } from "./index.js"; +import { reportMatchesGitHubCommit } from "./orchestrator.js"; + +const MAX_GITHUB_BASELINE_BYTES = 20 * 1024 * 1024; + +export interface GitHubBaselineLoadOptions { + expectedCommitSha?: string; + requireCommitMatch?: boolean; +} + +/** + * Load a bounded local baseline report and, by default, bind it to the PR base commit. + * This function performs no network retrieval. In PR contexts the event payload's base SHA is + * required unless the caller supplies an explicit expected commit SHA. + */ +export async function loadValidatedGitHubBaseline( + path: string, + context: GitHubPullRequestContext, + options: GitHubBaselineLoadOptions = {}, +): Promise { + const baselinePath = resolve(path); + const info = await stat(baselinePath); + if (!info.isFile()) throw new Error(`GitHub baseline path is not a file: ${baselinePath}`); + if (info.size > MAX_GITHUB_BASELINE_BYTES) { + throw new Error(`GitHub baseline exceeds ${MAX_GITHUB_BASELINE_BYTES} bytes.`); + } + + const report = await readReport(baselinePath); + const requireCommitMatch = options.requireCommitMatch ?? true; + if (!requireCommitMatch) return report; + + const expected = options.expectedCommitSha?.trim() || context.baseSha?.trim(); + if (!expected) { + throw new Error("GitHub baseline commit validation requires the pull-request base SHA or an explicit expected commit SHA."); + } + const actual = report.target.commitSha?.trim(); + if (!actual) throw new Error("GitHub baseline report does not identify its commit SHA."); + if (!reportMatchesGitHubCommit(actual, expected)) { + throw new Error("GitHub baseline report commit does not match the expected base commit."); + } + return report; +} From 3a5acf96209fd167f798cbd2bb72f7b35b49cf6a Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:27:46 -0400 Subject: [PATCH 0254/1132] feat(github): export validated baseline loader --- packages/github/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/github/package.json b/packages/github/package.json index b4705802..7cdaf489 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -8,7 +8,8 @@ "./publisher": "./dist/publisher.js", "./orchestrator": "./dist/orchestrator.js", "./actions-runner": "./dist/actions-runner.js", - "./sarif-publisher": "./dist/sarif-publisher.js" + "./sarif-publisher": "./dist/sarif-publisher.js", + "./baseline": "./dist/baseline.js" }, "types": "./dist/index.d.ts", "scripts": { From 2d7b9bb5bff8be5450937c6884386ffd68c9a867 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:28:07 -0400 Subject: [PATCH 0255/1132] feat(github): load validated local baselines in Actions runner --- packages/github/src/actions-runner.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/github/src/actions-runner.ts b/packages/github/src/actions-runner.ts index 9fff861a..03369278 100644 --- a/packages/github/src/actions-runner.ts +++ b/packages/github/src/actions-runner.ts @@ -1,6 +1,7 @@ import type { SynSecConfig } from "@synsec/config"; import { runScanEngine, type ScanEngineOutcome } from "@synsec/engine"; import type { SynSecReport } from "@synsec/report"; +import { loadValidatedGitHubBaseline } from "./baseline.js"; import { loadGitHubContext, type GitHubPullRequestContext } from "./index.js"; import { publishSynSecReportToGitHub, @@ -16,6 +17,8 @@ export interface GitHubActionsRepositoryScanOptions extends GitHubReportPublicat config: SynSecConfig; rootPath?: string; baseline?: SynSecReport; + baselinePath?: string; + baselineExpectedCommitSha?: string; toolVersion?: string; changedOnly?: boolean; changedBase?: string; @@ -34,7 +37,8 @@ export interface GitHubActionsRepositoryScanResult { * Run the existing repository scanner engine for the current GitHub Actions checkout and publish * the completed report as a check run. Pull-request contexts default to changed-file scanning; * push/other contexts default to a full repository scan. Optional code-scanning publication uses - * the same completed report and fixed GitHub host. No live-target discovery is performed. + * the same completed report and fixed GitHub host. A local baseline path is size-bounded and + * commit-bound before it enters the scan engine. No live-target discovery is performed. */ export async function runGitHubActionsRepositoryScan( token: string, @@ -45,7 +49,15 @@ export async function runGitHubActionsRepositoryScan( if (!context) { throw new Error("Unable to resolve a valid GitHub repository and commit context for repository scanning."); } + if (options.baseline && options.baselinePath) { + throw new Error("Provide either an in-memory baseline or baselinePath, not both."); + } + const baseline = options.baselinePath + ? await loadValidatedGitHubBaseline(options.baselinePath, context, { + expectedCommitSha: options.baselineExpectedCommitSha, + }) + : options.baseline; const changedOnly = options.changedOnly ?? Boolean(context.pullRequestNumber); const changedBase = options.changedBase ?? (changedOnly && context.baseRef ? `origin/${context.baseRef}` : undefined); @@ -53,7 +65,7 @@ export async function runGitHubActionsRepositoryScan( const outcome = await scan({ rootPath: options.rootPath ?? process.cwd(), config: options.config, - baseline: options.baseline, + baseline, toolVersion: options.toolVersion, changedOnly, changedBase, From 2254532f4a006111edf7efc50f2412a02d0f4f43 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:28:24 -0400 Subject: [PATCH 0256/1132] test(github): cover commit-bound local baselines --- tests/github-baseline.test.mjs | 98 ++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/github-baseline.test.mjs diff --git a/tests/github-baseline.test.mjs b/tests/github-baseline.test.mjs new file mode 100644 index 00000000..fb49ecc7 --- /dev/null +++ b/tests/github-baseline.test.mjs @@ -0,0 +1,98 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { loadValidatedGitHubBaseline } from "../packages/github/dist/baseline.js"; + +function report(commitSha) { + return { + schemaVersion: "1.0", + reportId: `baseline-${commitSha}`, + generatedAt: "2026-08-22T15:45:00.000Z", + toolVersion: "0.2.0", + target: { path: "/workspace", commitSha }, + scanners: [{ scanner: "opengrep", startedAt: "a", completedAt: "b", findingCount: 0, artifactCount: 0, diagnostics: [] }], + rawFindingCount: 0, + findingCount: 0, + summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 100, + findings: [], + scope: { mode: "repository" }, + }; +} + +async function withBaseline(commitSha, callback) { + const root = await mkdtemp(join(tmpdir(), "synsec-github-baseline-")); + const path = join(root, "baseline.json"); + await writeFile(path, JSON.stringify(report(commitSha))); + try { + await callback(path); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +test("local PR baseline must match the event payload base commit", async () => { + await withBaseline("abcdef1234567890", async (path) => { + const loaded = await loadValidatedGitHubBaseline(path, { + repository: "cmahmud/synsec", + sha: "9999999999999999", + baseSha: "abcdef1234567890", + baseRef: "main", + pullRequestNumber: 2, + }); + assert.equal(loaded.target.commitSha, "abcdef1234567890"); + }); +}); + +test("baseline commit comparison accepts an unambiguous git SHA prefix", async () => { + await withBaseline("abcdef1234567890abcdef1234567890abcdef12", async (path) => { + const loaded = await loadValidatedGitHubBaseline(path, { + repository: "cmahmud/synsec", + sha: "9999999999999999", + baseSha: "abcdef123456", + pullRequestNumber: 2, + }); + assert.equal(loaded.reportId.startsWith("baseline-abcdef"), true); + }); +}); + +test("stale local baselines fail before scanning", async () => { + await withBaseline("1111111111111111", async (path) => { + await assert.rejects( + () => loadValidatedGitHubBaseline(path, { + repository: "cmahmud/synsec", + sha: "9999999999999999", + baseSha: "2222222222222222", + pullRequestNumber: 2, + }), + /baseline report commit does not match the expected base commit/, + ); + }); +}); + +test("PR baseline loading fails closed when no expected base commit is available", async () => { + await withBaseline("abcdef1234567890", async (path) => { + await assert.rejects( + () => loadValidatedGitHubBaseline(path, { + repository: "cmahmud/synsec", + sha: "9999999999999999", + pullRequestNumber: 2, + }), + /requires the pull-request base SHA or an explicit expected commit SHA/, + ); + }); +}); + +test("an explicit expected baseline commit supports non-PR/synthetic contexts", async () => { + await withBaseline("abcdef1234567890", async (path) => { + const loaded = await loadValidatedGitHubBaseline(path, { + repository: "cmahmud/synsec", + sha: "9999999999999999", + ref: "refs/heads/main", + }, { expectedCommitSha: "abcdef1234567890" }); + assert.equal(loaded.target.commitSha, "abcdef1234567890"); + }); +}); From 2b81d864062697f8c3b89c7775943b6d96c1deb1 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:28:31 -0400 Subject: [PATCH 0257/1132] test(github): retain PR base commit context --- tests/github-context-base.test.mjs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/github-context-base.test.mjs diff --git a/tests/github-context-base.test.mjs b/tests/github-context-base.test.mjs new file mode 100644 index 00000000..79428fa4 --- /dev/null +++ b/tests/github-context-base.test.mjs @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { detectGitHubContext } from "../packages/github/dist/index.js"; + +test("pull-request event context retains both head and base commit SHAs", () => { + const context = detectGitHubContext( + { + GITHUB_REPOSITORY: "cmahmud/synsec", + GITHUB_SHA: "synthetic-merge-sha", + GITHUB_REF: "refs/pull/42/merge", + }, + { + repository: { full_name: "cmahmud/synsec" }, + pull_request: { + number: 42, + head: { sha: "head-commit", ref: "feature/security" }, + base: { sha: "base-commit", ref: "main" }, + }, + }, + ); + + assert.equal(context.sha, "head-commit"); + assert.equal(context.baseSha, "base-commit"); + assert.equal(context.baseRef, "main"); + assert.equal(context.headRef, "feature/security"); +}); From 3c0e562d35877b71bae4aef08647e0bb64014a46 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:29:19 -0400 Subject: [PATCH 0258/1132] fix(github): honor failOn none in check conclusions --- packages/github/src/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/github/src/index.ts b/packages/github/src/index.ts index bf2cf466..26e5a58b 100644 --- a/packages/github/src/index.ts +++ b/packages/github/src/index.ts @@ -4,6 +4,7 @@ import type { SynSecReport } from "@synsec/report"; export type GitHubCheckConclusion = "success" | "failure" | "neutral"; export type GitHubAnnotationLevel = "notice" | "warning" | "failure"; +export type GitHubCheckThreshold = Severity | "none"; export interface GitHubPullRequestContext { repository: string; @@ -207,13 +208,14 @@ export function buildGitHubAnnotations( .slice(0, maxAnnotations); } -export function reportFailsThreshold(report: SynSecReport, threshold: Severity): boolean { +export function reportFailsThreshold(report: SynSecReport, threshold: GitHubCheckThreshold): boolean { + if (threshold === "none") return false; const required = severityRank[threshold]; if (required <= 0) return false; return report.findings.some((finding) => severityRank[finding.primary.severity] >= required); } -function markdownSummary(report: SynSecReport, threshold: Severity): string { +function markdownSummary(report: SynSecReport, threshold: GitHubCheckThreshold): string { const delta = report.baseline; const deltaLine = delta ? `New: **${delta.new.length}** · Fixed: **${delta.fixed.length}** · Persisting: **${delta.persisting.length}**` @@ -230,7 +232,7 @@ function markdownSummary(report: SynSecReport, threshold: Severity): string { export function buildGitHubCheck( report: SynSecReport, context: GitHubPullRequestContext, - options: { threshold?: Severity; onlyNewAnnotations?: boolean; maxAnnotations?: number } = {}, + options: { threshold?: GitHubCheckThreshold; onlyNewAnnotations?: boolean; maxAnnotations?: number } = {}, ): GitHubCheckResult { const threshold = options.threshold ?? "high"; const failed = reportFailsThreshold(report, threshold); From 920d61ba07553f541710bd3590c87b5f5804d7a6 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:29:38 -0400 Subject: [PATCH 0259/1132] fix(github): support disabled check failure threshold --- packages/github/src/orchestrator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/github/src/orchestrator.ts b/packages/github/src/orchestrator.ts index 8c7c80ce..44d67bd4 100644 --- a/packages/github/src/orchestrator.ts +++ b/packages/github/src/orchestrator.ts @@ -1,9 +1,9 @@ -import type { Severity } from "@synsec/core"; import type { SynSecReport } from "@synsec/report"; import { buildGitHubCheck, loadGitHubContext, type GitHubCheckResult, + type GitHubCheckThreshold, type GitHubPullRequestContext, } from "./index.js"; import { @@ -14,7 +14,7 @@ import { export interface GitHubReportPublicationOptions extends GitHubPublisherOptions { env?: NodeJS.ProcessEnv; - threshold?: Severity; + threshold?: GitHubCheckThreshold; onlyNewAnnotations?: boolean; maxAnnotations?: number; } From dc20577f01bbe260c7e8a92714016bebec1620a7 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:30:11 -0400 Subject: [PATCH 0260/1132] feat(action): add GitHub Action entrypoint package --- apps/github-action/package.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 apps/github-action/package.json diff --git a/apps/github-action/package.json b/apps/github-action/package.json new file mode 100644 index 00000000..a4211467 --- /dev/null +++ b/apps/github-action/package.json @@ -0,0 +1,15 @@ +{ + "name": "@synsec/github-action", + "version": "0.2.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/config": "0.2.0", + "@synsec/github": "0.2.0" + } +} From 934b18d200ab522808e801325ee0aebd6ce4d4fa Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:30:19 -0400 Subject: [PATCH 0261/1132] build(action): add TypeScript project references --- apps/github-action/tsconfig.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 apps/github-action/tsconfig.json diff --git a/apps/github-action/tsconfig.json b/apps/github-action/tsconfig.json new file mode 100644 index 00000000..0e7f1ac0 --- /dev/null +++ b/apps/github-action/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../../packages/config" }, + { "path": "../../packages/github" } + ], + "include": ["src/**/*.ts"] +} From 085bf3b217f24f3773fd20559237e6fa1b1705f7 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:30:31 -0400 Subject: [PATCH 0262/1132] feat(action): run repository scan from Actions workspace --- apps/github-action/src/index.ts | 74 +++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 apps/github-action/src/index.ts diff --git a/apps/github-action/src/index.ts b/apps/github-action/src/index.ts new file mode 100644 index 00000000..e226afc7 --- /dev/null +++ b/apps/github-action/src/index.ts @@ -0,0 +1,74 @@ +import { appendFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { loadConfig } from "@synsec/config"; +import { runGitHubActionsRepositoryScan } from "@synsec/github/actions-runner"; + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed || undefined; +} + +function booleanInput(value: string | undefined, fallback: boolean): boolean { + const normalized = value?.trim().toLowerCase(); + if (!normalized) return fallback; + if (normalized === "true" || normalized === "1" || normalized === "yes") return true; + if (normalized === "false" || normalized === "0" || normalized === "no") return false; + throw new Error(`Expected a boolean action input, received: ${normalized.slice(0, 32)}`); +} + +function changedOnlyInput(value: string | undefined): boolean | undefined { + const normalized = value?.trim().toLowerCase(); + if (!normalized || normalized === "auto") return undefined; + if (normalized === "true" || normalized === "1" || normalized === "yes") return true; + if (normalized === "false" || normalized === "0" || normalized === "no") return false; + throw new Error("changed-only must be auto, true, or false."); +} + +async function writeOutput(name: string, value: string | number | undefined): Promise { + const path = nonEmpty(process.env.GITHUB_OUTPUT); + if (!path || value === undefined) return; + const normalized = String(value).replace(/[\r\n]/g, ""); + await appendFile(path, `${name}=${normalized}\n`, "utf8"); +} + +async function main(): Promise { + const workspace = resolve(nonEmpty(process.env.GITHUB_WORKSPACE) ?? process.cwd()); + const token = nonEmpty(process.env.SYNSEC_GITHUB_TOKEN); + if (!token) throw new Error("The SynSec GitHub Action requires a GitHub token."); + + const configInput = nonEmpty(process.env.SYNSEC_CONFIG_PATH); + const configPath = configInput ? resolve(workspace, configInput) : undefined; + const { config } = await loadConfig(workspace, configPath); + const baselineInput = nonEmpty(process.env.SYNSEC_BASELINE_PATH); + const baselinePath = baselineInput ? resolve(workspace, baselineInput) : undefined; + const publishSarif = booleanInput(process.env.SYNSEC_PUBLISH_SARIF, false); + const changedOnly = changedOnlyInput(process.env.SYNSEC_CHANGED_ONLY); + + const result = await runGitHubActionsRepositoryScan(token, { + config, + rootPath: workspace, + baselinePath, + changedOnly, + publishSarif, + threshold: config.failOn, + }); + + await Promise.all([ + writeOutput("security-score", result.outcome.report.securityScore), + writeOutput("finding-count", result.outcome.report.findingCount), + writeOutput("check-run-id", result.publication.publication.id), + writeOutput("sarif-upload-id", result.sarifPublication?.id), + ]); + + console.log( + `SynSec scanned ${result.outcome.report.scope?.mode === "changed-files" ? "changed files" : "the repository"}: ` + + `${result.outcome.report.findingCount} finding(s), security score ${result.outcome.report.securityScore}/100.`, + ); + if (result.outcome.shouldFail) process.exitCode = 1; +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`SynSec GitHub Action failed: ${message.replace(/[\r\n]+/g, " ").slice(0, 1_000)}`); + process.exitCode = 1; +}); From 8bbe7ebe3c8828a9ad4259555d408dd04ec31ad5 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:31:00 -0400 Subject: [PATCH 0263/1132] feat(action): add reusable composite action --- action.yml | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 action.yml diff --git a/action.yml b/action.yml new file mode 100644 index 00000000..d8f13d36 --- /dev/null +++ b/action.yml @@ -0,0 +1,63 @@ +name: SynSec Repository Security +description: Defensive repository security scanning with GitHub check annotations and optional SARIF upload. +author: SynSec +inputs: + github-token: + description: GitHub token used only for check/SARIF publication. + required: true + config-path: + description: Optional path to synsec.config.json relative to the checked-out repository. + required: false + default: "" + baseline-path: + description: Optional local SynSec baseline report, validated against the pull-request base commit. + required: false + default: "" + changed-only: + description: auto (PR changed files, push full repo), true, or false. + required: false + default: auto + publish-sarif: + description: Upload the completed report to GitHub code scanning. + required: false + default: "false" +outputs: + security-score: + description: Latest SynSec security score. + value: ${{ steps.scan.outputs.security-score }} + finding-count: + description: Number of correlated findings in the completed scan. + value: ${{ steps.scan.outputs.finding-count }} + check-run-id: + description: Published GitHub check-run id. + value: ${{ steps.scan.outputs.check-run-id }} + sarif-upload-id: + description: GitHub SARIF upload id when publish-sarif is enabled. + value: ${{ steps.scan.outputs.sarif-upload-id }} +runs: + using: composite + steps: + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: "20" + - name: Build SynSec action runtime + shell: bash + run: | + set -euo pipefail + cd "$GITHUB_ACTION_PATH" + npm install --ignore-scripts --no-audit --no-fund + npm run build + - name: Scan repository + id: scan + shell: bash + env: + SYNSEC_GITHUB_TOKEN: ${{ inputs.github-token }} + SYNSEC_CONFIG_PATH: ${{ inputs.config-path }} + SYNSEC_BASELINE_PATH: ${{ inputs.baseline-path }} + SYNSEC_CHANGED_ONLY: ${{ inputs.changed-only }} + SYNSEC_PUBLISH_SARIF: ${{ inputs.publish-sarif }} + run: node "$GITHUB_ACTION_PATH/apps/github-action/dist/index.js" +branding: + icon: shield + color: blue From ea0920be69ea32b1616f814fdd8660bd09e0aa91 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:31:39 -0400 Subject: [PATCH 0264/1132] test(github): honor disabled failure threshold --- tests/github-threshold-none.test.mjs | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/github-threshold-none.test.mjs diff --git a/tests/github-threshold-none.test.mjs b/tests/github-threshold-none.test.mjs new file mode 100644 index 00000000..f01cf017 --- /dev/null +++ b/tests/github-threshold-none.test.mjs @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildGitHubCheck, reportFailsThreshold } from "../packages/github/dist/index.js"; + +const report = { + schemaVersion: "1.0", + reportId: "threshold-none", + generatedAt: "2026-08-22T16:00:00.000Z", + toolVersion: "0.2.0", + target: { path: ".", commitSha: "abcdef1234567890" }, + scanners: [{ scanner: "opengrep", startedAt: "a", completedAt: "b", findingCount: 1, artifactCount: 0, diagnostics: [] }], + rawFindingCount: 1, + findingCount: 1, + summary: { critical: 1, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 70, + findings: [{ + fingerprint: "fp-critical", + primary: { + id: "critical", + title: "Critical finding", + description: "Evidence-backed critical repository finding.", + category: "sast", + severity: "critical", + confidence: 0.99, + scanner: { name: "opengrep", ruleId: "critical" }, + location: { path: "src/app.ts", startLine: 1 }, + }, + duplicates: [], + sources: [{ name: "opengrep", ruleId: "critical" }], + }], + scope: { mode: "repository" }, +}; + +test("failOn none never produces a failing GitHub conclusion", () => { + assert.equal(reportFailsThreshold(report, "none"), false); + const check = buildGitHubCheck(report, { repository: "cmahmud/synsec", sha: "abcdef1234567890" }, { threshold: "none" }); + assert.equal(check.conclusion, "neutral"); + assert.match(check.output.summary, /CI threshold: \*\*none\*\*/); +}); From 43ae6c99b652dcf3fbfdadc073f5b51a74f5a643 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:33:26 -0400 Subject: [PATCH 0265/1132] docs(github): document Action, SARIF, and baseline boundaries --- docs/GITHUB.md | 125 +++++++++++++++++++++++++++++++------------------ 1 file changed, 79 insertions(+), 46 deletions(-) diff --git a/docs/GITHUB.md b/docs/GITHUB.md index 3ae78648..28eb066e 100644 --- a/docs/GITHUB.md +++ b/docs/GITHUB.md @@ -3,7 +3,7 @@ SynSec's GitHub integration is intentionally split into two layers: 1. **Repository security analysis** stays inside the normal scanner/report pipeline. -2. **GitHub publication** converts a completed SynSec report into GitHub-native check output and annotations. +2. **GitHub publication** converts a completed SynSec report into GitHub-native checks, annotations, and optional SARIF/code-scanning output. This keeps GitHub credentials out of scanners and prevents repository analysis from silently expanding into unrelated network targets. @@ -14,36 +14,26 @@ This keeps GitHub credentials out of scanners and prevents repository analysis f - GitHub Actions context detection from environment variables. - Bounded parsing of `GITHUB_EVENT_PATH`. - Correct pull-request head SHA selection from the event payload instead of the synthetic merge SHA. -- Pull-request number, base branch, and head branch resolution. +- Pull-request number, base branch/SHA, and head branch resolution. - Conversion of a `SynSecReport` into a check-run result. - Source annotations for findings with file/line locations. - Severity-aware annotation levels. - Baseline-aware annotation filtering so PR checks can focus on new findings. -- A hard 50-annotation cap per generated payload, matching GitHub's check-run annotation request limit. -- CI threshold evaluation independent of scanner exit-code quirks. +- A hard 50-annotation cap per generated payload. +- CI threshold evaluation, including an explicit `none` threshold that never fails a check. - A narrow Checks API publisher with an injectable transport for testing. -- A completed-report publication orchestrator that resolves local Actions context, validates report/commit binding, builds the deterministic check, and publishes it through the fixed-host transport. -- A GitHub Actions repository scan runner that reuses the normal scan engine and then publishes the resulting report. +- Completed-report publication orchestration with report/head commit binding. +- A GitHub Actions repository scan runner that reuses the normal scan engine. +- Bounded local baseline loading with optional PR-base commit validation. +- Fixed-host gzip/base64 SARIF publication to GitHub code scanning. -`@synsec/github/publisher` posts completed check runs only to `https://api.github.com/repos///check-runs`. The repository comes from validated GitHub context, scanner output cannot control the request URL, redirects are rejected, and bearer tokens are never copied into returned errors. - -`@synsec/github/orchestrator` provides `publishSynSecReportToGitHub()`. It accepts an already-completed `SynSecReport`, resolves the repository/commit from bounded local Actions context, validates that a report commit (when present) matches the selected GitHub head, builds the check, and invokes the publisher. It does not run scanners, discover targets, mutate repositories, or perform external assessment. Invalid context or stale report/commit binding fails before transport. - -`@synsec/github/actions-runner` provides `runGitHubActionsRepositoryScan()`. It invokes the existing repository scan engine for the current checkout and feeds the completed report through the orchestrator. Pull-request contexts default to changed-file scans; push/other contexts default to full repository scans. The runner requires the produced report to contain a commit SHA before publication. - -Token acquisition and installation authorization intentionally remain outside these primitives. +The root `action.yml` packages these primitives as a composite GitHub Action. It builds the checked-in SynSec runtime, scans only the repository represented by `GITHUB_WORKSPACE`, and publishes the completed report using the caller-provided GitHub token. ## Pull-request SHA handling GitHub Actions commonly sets `GITHUB_SHA` to a synthetic merge commit for `pull_request` workflows. Publishing a check against that SHA can make the check appear on the wrong commit or disappear when the synthetic merge ref changes. -For PR events, SynSec therefore prefers: - -```text -pull_request.head.sha -``` - -from the local Actions event payload. `loadGitHubContext()` reads `GITHUB_EVENT_PATH`, rejects non-files, refuses event payloads larger than 2 MiB, parses JSON locally, and then resolves the effective repository/commit context. +For PR events, SynSec therefore prefers `pull_request.head.sha` from the local Actions event payload and also retains `pull_request.base.sha` for baseline validation. `loadGitHubContext()` reads `GITHUB_EVENT_PATH`, rejects non-files, refuses event payloads larger than 2 MiB, parses JSON locally, and then resolves the effective repository/commit context. No network request is required for context detection. @@ -51,24 +41,26 @@ No network request is required for context detection. `runGitHubActionsRepositoryScan()` accepts a normal `SynSecConfig`, optional baseline, checkout root, publication settings, and caller-supplied token. The scan path deliberately reuses `runScanEngine()` rather than creating GitHub-specific scanners. -For pull requests, changed-file scanning defaults to: +For pull requests, changed-file scanning defaults to `origin/...HEAD`. Callers can override changed-file mode or the base ref explicitly. Push and other non-PR contexts default to a full repository scan. -```text -origin/...HEAD -``` +Before publication, the runner requires the scan report to identify its commit. The publication layer refuses a report whose commit differs from the GitHub commit being annotated. This prevents a stale report from being attached to a newer PR head. -Callers can override changed-file mode or the base ref explicitly. Push and other non-PR contexts default to a full repository scan. +The runner does not clone arbitrary targets, expand repository scope, perform live-target probing, or create repository writes. -Before publication, the runner requires the scan report to identify its commit. The publication layer then refuses a report whose commit differs from the GitHub commit being annotated. This prevents a stale report from being attached to a newer PR head. +## Baselines -The runner does not clone arbitrary targets, expand repository scope, perform live-target probing, or create repository writes. +A caller can provide a baseline report in memory or as a local `baselinePath`. `loadValidatedGitHubBaseline()` bounds local baseline files to 20 MiB, parses them through the normal report reader, and by default requires the baseline report's commit to match the pull-request base SHA from the event payload. An explicit expected commit can be supplied for non-PR or synthetic contexts. + +A missing baseline commit, missing expected base commit, or stale baseline fails before scanning. SynSec does not silently treat an unverifiable baseline as trustworthy. + +The current baseline primitive deliberately does not fetch arbitrary URLs or repositories. Artifact/cache retrieval belongs in a future hosting adapter that can enforce provenance and repository scope before handing a local report to this loader. ## Check conclusions The generated check conclusion follows the configured severity threshold: -- `failure` when at least one finding meets or exceeds the threshold. -- `neutral` when findings exist but none meets the threshold. +- `failure` when at least one finding meets or exceeds an enabled threshold. +- `neutral` when findings exist but none meets the enabled threshold, or `failOn` is `none`. - `success` when the report contains no findings. This is deliberately separate from individual scanner process exit codes. Scanner failures and scan completeness remain engine/report concerns; GitHub publishing consumes the completed normalized report. @@ -81,18 +73,59 @@ When a report includes a baseline, `buildGitHubCheck()` defaults to annotating o ## Checks API publication -`publishGitHubCheck()` accepts a completed check result, validated GitHub context, and a caller-supplied token. The publisher: +`publishGitHubCheck()` posts completed check runs only to `https://api.github.com/repos///check-runs`. The repository comes from validated GitHub context, scanner output cannot control the request URL, redirects are rejected, and bearer tokens are not copied into returned errors. + +`publishSynSecReportToGitHub()` is the higher-level completed-report path. It resolves bounded local Actions context, validates report/head commit binding, builds the deterministic check, and invokes the fixed-host publisher. It never runs scanners or discovers targets itself. -- sends one `POST` to the repository Checks API endpoint; -- uses GitHub API version `2022-11-28` by default; -- sends the token only in the `Authorization` header; +## SARIF/code scanning + +`publishGitHubSarif()` converts the already-completed SynSec report to SARIF 2.1, gzip-compresses and base64-encodes it, and posts it only to `https://api.github.com/repos///code-scanning/sarifs`. + +The publisher: + +- requires the report commit to match the selected GitHub commit; +- uses `refs/pull//head` for pull requests so the ref corresponds to the PR head rather than the synthetic merge ref; +- requires a fully qualified ref outside PR contexts; +- enforces a 10 MiB compressed-payload bound; - rejects redirects; -- validates the returned check-run id; -- returns only publication metadata such as id, URL, status, and conclusion. +- keeps the token in the authorization header and redacts it from reflected error text. + +SARIF publication is opt-in in the Actions runner and composite Action because repositories may not grant `security-events: write`. + +## Composite GitHub Action + +The root `action.yml` exposes: -`publishSynSecReportToGitHub()` is the higher-level completed-report path. It preserves the same transport restrictions while removing duplicate context/check/publisher glue from future Actions and GitHub App entrypoints. +- `github-token` — required publication token; +- `config-path` — optional path to `synsec.config.json` in the checked-out repository; +- `baseline-path` — optional local commit-bound baseline report; +- `changed-only` — `auto`, `true`, or `false`; +- `publish-sarif` — optional code-scanning publication. + +It returns the security score, finding count, check-run id, and optional SARIF upload id. + +The Action intentionally does **not** silently download third-party scanner binaries. Selected scanners must already be available on `PATH`; this keeps scanner installation/version pinning explicit and avoids hiding supply-chain downloads inside the security scanner itself. A future containerized worker can improve scanner provisioning while retaining pinned artifacts and isolation. + +A minimal workflow should give SynSec only the permissions it needs: + +```yaml +permissions: + contents: read + checks: write + security-events: write # only needed when publish-sarif is true + +steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + # Install/pin the scanners selected by synsec.config.json here. + - uses: cmahmud/synsec@ + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + publish-sarif: "true" +``` -Token acquisition is intentionally outside these functions. GitHub App installation tokens, Actions `GITHUB_TOKEN`, and any future enterprise-hosting transport should remain separate concerns so credentials never enter scanners or normalized reports. +Use the normal `pull_request` event for scanning pull-request code. Do **not** switch to `pull_request_target` merely to obtain a write-capable token: that event executes in the base-repository security context and can expose elevated credentials to workflows that inspect untrusted contributor code. For fork pull requests where GitHub intentionally withholds write permissions, publication should be treated as unavailable rather than weakening the trust boundary. ## Security boundaries @@ -102,20 +135,20 @@ GitHub integration must preserve the repository-first defensive model: - Report and annotation generation must not require network access. - Scanner output must never choose the GitHub API host or arbitrary publication URL. - A report must not be published onto a different commit than the one it represents. +- A baseline must not be trusted for PR comparison without validated commit identity. - Source excerpts are not added to GitHub annotations unless already present in normalized deterministic finding fields. - Secret values must remain redacted before publication. -- A future remediation pull-request flow must require explicit approval before repository writes. +- Repository writes remain outside the scan/publication path and require explicit approval. - Repository installation must not authorize live-target exploitation, target expansion, persistence, or secret exfiltration. ## Next implementation steps -The remaining Phase 5 work is packaging, baseline acquisition, and installation/authentication: +The remaining Phase 5 work is primarily hosting/authentication and durable orchestration: -1. Add a packaged GitHub Actions entrypoint/workflow template around the runner. -2. Acquire/validate pull-request baselines without broadening repository scope. -3. Add a GitHub App installation/authentication layer using the same runner/publication primitives. -4. Upload SARIF to GitHub code scanning where repository permissions allow it. -5. Add scheduled repository scans. -6. Add explicitly approved remediation pull requests. +1. Add a GitHub App installation/authentication layer using the same runner/publication primitives. +2. Add provenance-aware baseline artifact/cache acquisition around the local validator. +3. Add scheduled repository-scan workflow/orchestration support. +4. Add explicitly approved remediation pull requests. +5. Add GitLab and Bitbucket adapters without coupling the scanner core to one host. -The deterministic package should remain usable from both a GitHub App and a GitHub Actions integration so the scanning core does not become hosting-provider-specific. +The deterministic packages should remain usable from both a GitHub App and GitHub Actions so the scanning core does not become hosting-provider-specific. From a84c35727bd769cf3bdc44ac82f618b200cedbcc Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:33:51 -0400 Subject: [PATCH 0266/1132] feat(repository): add likely test ownership context --- packages/repository/src/test-ownership.ts | 101 ++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 packages/repository/src/test-ownership.ts diff --git a/packages/repository/src/test-ownership.ts b/packages/repository/src/test-ownership.ts new file mode 100644 index 00000000..d4df8ddf --- /dev/null +++ b/packages/repository/src/test-ownership.ts @@ -0,0 +1,101 @@ +import { posix } from "node:path"; +import type { IndexFileInput } from "./analysis.js"; +import type { ModuleGraph } from "./module-graph.js"; + +export type TestOwnershipReason = "direct-import" | "filename-convention"; + +export interface LikelyTestOwner { + path: string; + reasons: TestOwnershipReason[]; +} + +export interface TestOwnershipContext { + sourcePath: string; + likelyTests: LikelyTestOwner[]; + maxResults: number; + /** Heuristics identify likely related tests; they do not prove execution or coverage. */ + interpretation: "likely-test-ownership-only"; +} + +function normalize(value: string): string { + return posix.normalize(value.replaceAll("\\", "/").replace(/^\.\//, "")).replace(/^\//, ""); +} + +function isTestPath(path: string): boolean { + const value = normalize(path).toLowerCase(); + const base = posix.basename(value); + return value.includes("/__tests__/") + || value.startsWith("tests/") + || value.startsWith("test/") + || /(?:^|\.)(?:test|spec)\.[^.]+$/.test(base) + || /^test_[^.]+\.py$/.test(base) + || /_test\.go$/.test(base); +} + +function filenameStem(path: string): string { + const base = posix.basename(normalize(path)).toLowerCase(); + return base + .replace(/(?:\.test|\.spec)(?=\.)/, "") + .replace(/^test_/, "") + .replace(/_test(?=\.)/, "") + .replace(/\.[^.]+$/, ""); +} + +function conventionMatch(sourcePath: string, testPath: string): boolean { + if (!isTestPath(testPath)) return false; + const sourceStem = filenameStem(sourcePath); + const testStem = filenameStem(testPath); + return Boolean(sourceStem && sourceStem === testStem); +} + +export function findLikelyTestOwners( + graph: ModuleGraph, + files: readonly IndexFileInput[], + sourcePath: string, + options: { maxResults?: number } = {}, +): TestOwnershipContext { + const normalizedSource = normalize(sourcePath); + const maxResults = Math.max(0, Math.min(100, options.maxResults ?? 20)); + const reasons = new Map>(); + + for (const edge of graph.edges) { + if (!edge.target || normalize(edge.target).toLowerCase() !== normalizedSource.toLowerCase()) continue; + const from = normalize(edge.from); + if (!isTestPath(from)) continue; + const entry = reasons.get(from) ?? new Set(); + entry.add("direct-import"); + reasons.set(from, entry); + } + + for (const file of files) { + const path = normalize(file.path); + if (path.toLowerCase() === normalizedSource.toLowerCase() || !conventionMatch(normalizedSource, path)) continue; + const entry = reasons.get(path) ?? new Set(); + entry.add("filename-convention"); + reasons.set(path, entry); + } + + const priority: Record = { + "direct-import": 2, + "filename-convention": 1, + }; + const likelyTests = [...reasons.entries()] + .map(([path, values]): LikelyTestOwner => ({ + path, + reasons: [...values].sort((a, b) => priority[b] - priority[a] || a.localeCompare(b)), + })) + .sort((a, b) => { + const direct = Number(b.reasons.includes("direct-import")) - Number(a.reasons.includes("direct-import")); + if (direct !== 0) return direct; + const reasonCount = b.reasons.length - a.reasons.length; + return reasonCount || a.path.localeCompare(b.path); + }) + .slice(0, maxResults); + + return { + sourcePath: normalizedSource, + likelyTests, + maxResults, + interpretation: "likely-test-ownership-only", + }; +} From dfe7ce02d377d82c847a7332787e577bcf6803b8 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:37:04 -0400 Subject: [PATCH 0267/1132] feat(repository): expose test ownership context --- packages/repository/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/repository/package.json b/packages/repository/package.json index ae27efa1..8b725a59 100644 --- a/packages/repository/package.json +++ b/packages/repository/package.json @@ -11,7 +11,8 @@ "./route-entrypoints": "./dist/route-entrypoints.js", "./route-auth-context": "./dist/route-auth-context.js", "./route-sink-context": "./dist/route-sink-context.js", - "./posture": "./dist/posture.js" + "./posture": "./dist/posture.js", + "./test-ownership": "./dist/test-ownership.js" }, "types": "./dist/index.d.ts", "scripts": { From a0a176dd61b49a6305b32bf8af93453349f9187d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:37:22 -0400 Subject: [PATCH 0268/1132] test(repository): cover likely test ownership heuristics --- tests/test-ownership.test.mjs | 85 +++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/test-ownership.test.mjs diff --git a/tests/test-ownership.test.mjs b/tests/test-ownership.test.mjs new file mode 100644 index 00000000..61c5ecd7 --- /dev/null +++ b/tests/test-ownership.test.mjs @@ -0,0 +1,85 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { findLikelyTestOwners } from "@synsec/repository/test-ownership"; + +function graph(edges) { + return { + schemaVersion: 1, + nodes: [], + edges, + resolvedEdgeCount: edges.filter((edge) => edge.target).length, + unresolvedEdgeCount: edges.filter((edge) => !edge.target).length, + }; +} + +test("test ownership prioritizes direct importing tests and preserves conservative interpretation", () => { + const files = [ + { path: "src/auth.ts", content: "export const auth = true" }, + { path: "tests/auth.test.ts", content: "import '../src/auth.js'" }, + { path: "src/__tests__/auth.spec.ts", content: "" }, + { path: "tests/other.test.ts", content: "" }, + ]; + const moduleGraph = graph([ + { + from: "tests/auth.test.ts", + specifier: "../src/auth.js", + kind: "import", + target: "src/auth.ts", + resolution: "repository-file", + }, + ]); + + const context = findLikelyTestOwners(moduleGraph, files, "./src/auth.ts"); + + assert.equal(context.interpretation, "likely-test-ownership-only"); + assert.equal(context.sourcePath, "src/auth.ts"); + assert.deepEqual(context.likelyTests, [ + { path: "tests/auth.test.ts", reasons: ["direct-import", "filename-convention"] }, + { path: "src/__tests__/auth.spec.ts", reasons: ["filename-convention"] }, + ]); +}); + +test("test ownership ignores non-test dependents and unrelated same-directory files", () => { + const files = [ + { path: "src/parser.py", content: "" }, + { path: "src/consumer.py", content: "" }, + { path: "tests/test_parser.py", content: "" }, + { path: "tests/parser_fixture.py", content: "" }, + ]; + const moduleGraph = graph([ + { + from: "src/consumer.py", + specifier: ".parser", + kind: "python-import", + target: "src/parser.py", + resolution: "repository-file", + }, + ]); + + const context = findLikelyTestOwners(moduleGraph, files, "src/parser.py"); + assert.deepEqual(context.likelyTests, [ + { path: "tests/test_parser.py", reasons: ["filename-convention"] }, + ]); +}); + +test("test ownership is bounded and can explicitly return no candidates", () => { + const files = [ + { path: "src/a.ts", content: "" }, + { path: "tests/a.test.ts", content: "" }, + ]; + const context = findLikelyTestOwners(graph([]), files, "src/a.ts", { maxResults: 0 }); + + assert.equal(context.maxResults, 0); + assert.deepEqual(context.likelyTests, []); +}); + +test("test ownership caps excessive result requests", () => { + const files = [{ path: "src/a.ts", content: "" }]; + for (let index = 0; index < 150; index += 1) { + files.push({ path: `tests/group-${index}/a.test.ts`, content: "" }); + } + + const context = findLikelyTestOwners(graph([]), files, "src/a.ts", { maxResults: 1000 }); + assert.equal(context.maxResults, 100); + assert.equal(context.likelyTests.length, 100); +}); From 4c16b82c587d12b1b1ebd1dbc70075bc3056b81b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:37:44 -0400 Subject: [PATCH 0269/1132] docs: clarify test ownership roadmap status --- docs/ROADMAP.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d51cd942..86bc9f9d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -50,17 +50,20 @@ This roadmap separates what is already usable in the repository from the deeper - [x] Bounded route-level lexical authentication/authorization context - [x] Bounded route-level lexical process/filesystem/database/network sink context - [x] Bounded repository posture summary from route/auth/sink signals +- [x] Bounded likely test-ownership context from resolved imports and filename conventions - [ ] Full function/call graph with reliable cross-module symbol resolution - [ ] Broad routes and externally reachable entry points across supported frameworks - [ ] Framework-aware authentication/authorization enforcement semantics - [ ] Data-flow-aware sink reachability beyond lexical proximity - [ ] Dependency reachability beyond scanner-provided call analysis -- [ ] Test ownership and coverage context around findings +- [ ] Runtime/test-run coverage context around findings The current call graph is deliberately labeled lexical evidence rather than runtime reachability. It resolves unambiguous direct same-file calls and leaves qualified, external, or ambiguous calls unresolved. Decorator-based route mapping only links a route when one function declaration is structurally close enough to be unambiguous; generic router registrations remain unresolved rather than guessing a handler. Route authentication and sink context are similarly conservative. They record bounded same-file security signals near indexed routes and label the results `lexical-auth-signals-only` or `lexical-sink-signals-only`. Absence of nearby auth is reported only as `no-auth-signal-observed`, and nearby sinks are not treated as proven data-flow or call reachability. The repository posture summary aggregates these bounded signals for prioritization while explicitly remaining `bounded-lexical-posture-only`. +Likely test ownership is also structural evidence only. It prioritizes test files that directly import a source module and supplements those with bounded filename-convention matches. It does not claim that a test executes a finding path or that the source is covered at runtime; coverage ingestion remains separate future work. + ## Phase 3 — Contextual security review - [x] Provider-agnostic OpenAI-compatible AI review adapter From 0d5760f9d9d8bc0c461bd2fc913eea04cd6b95dd Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:38:33 -0400 Subject: [PATCH 0270/1132] fix(action): bind file inputs to checkout workspace --- apps/github-action/src/inputs.ts | 55 ++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 apps/github-action/src/inputs.ts diff --git a/apps/github-action/src/inputs.ts b/apps/github-action/src/inputs.ts new file mode 100644 index 00000000..4f1ccb53 --- /dev/null +++ b/apps/github-action/src/inputs.ts @@ -0,0 +1,55 @@ +import { lstat, realpath } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; + +export function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed || undefined; +} + +export function booleanInput(value: string | undefined, fallback: boolean): boolean { + const normalized = value?.trim().toLowerCase(); + if (!normalized) return fallback; + if (normalized === "true" || normalized === "1" || normalized === "yes") return true; + if (normalized === "false" || normalized === "0" || normalized === "no") return false; + throw new Error(`Expected a boolean action input, received: ${normalized.slice(0, 32)}`); +} + +export function changedOnlyInput(value: string | undefined): boolean | undefined { + const normalized = value?.trim().toLowerCase(); + if (!normalized || normalized === "auto") return undefined; + if (normalized === "true" || normalized === "1" || normalized === "yes") return true; + if (normalized === "false" || normalized === "0" || normalized === "no") return false; + throw new Error("changed-only must be auto, true, or false."); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +/** + * Resolve an explicitly configured Action file input and bind it to the checked-out workspace. + * realpath() is used for both sides so a repository symlink cannot redirect config/baseline reads + * into runner-global files outside the checkout. + */ +export async function resolveWorkspaceFileInput( + workspace: string, + input: string, + label: string, +): Promise { + const root = await realpath(resolve(workspace)); + const lexicalCandidate = resolve(root, input); + if (!insideRoot(root, lexicalCandidate)) { + throw new Error(`${label} must resolve inside GITHUB_WORKSPACE.`); + } + + const candidate = await realpath(lexicalCandidate).catch(() => undefined); + if (!candidate || !insideRoot(root, candidate)) { + throw new Error(`${label} must reference an existing file inside GITHUB_WORKSPACE.`); + } + const info = await lstat(candidate).catch(() => undefined); + if (!info?.isFile()) { + throw new Error(`${label} must reference a regular file inside GITHUB_WORKSPACE.`); + } + return candidate; +} From 960f311a13e9a49e065a2b3cbd83a1b627fefe38 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:38:42 -0400 Subject: [PATCH 0271/1132] refactor(action): validate repository file inputs --- apps/github-action/src/index.ts | 35 +++++++++++---------------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/apps/github-action/src/index.ts b/apps/github-action/src/index.ts index e226afc7..231dceea 100644 --- a/apps/github-action/src/index.ts +++ b/apps/github-action/src/index.ts @@ -2,27 +2,12 @@ import { appendFile } from "node:fs/promises"; import { resolve } from "node:path"; import { loadConfig } from "@synsec/config"; import { runGitHubActionsRepositoryScan } from "@synsec/github/actions-runner"; - -function nonEmpty(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed || undefined; -} - -function booleanInput(value: string | undefined, fallback: boolean): boolean { - const normalized = value?.trim().toLowerCase(); - if (!normalized) return fallback; - if (normalized === "true" || normalized === "1" || normalized === "yes") return true; - if (normalized === "false" || normalized === "0" || normalized === "no") return false; - throw new Error(`Expected a boolean action input, received: ${normalized.slice(0, 32)}`); -} - -function changedOnlyInput(value: string | undefined): boolean | undefined { - const normalized = value?.trim().toLowerCase(); - if (!normalized || normalized === "auto") return undefined; - if (normalized === "true" || normalized === "1" || normalized === "yes") return true; - if (normalized === "false" || normalized === "0" || normalized === "no") return false; - throw new Error("changed-only must be auto, true, or false."); -} +import { + booleanInput, + changedOnlyInput, + nonEmpty, + resolveWorkspaceFileInput, +} from "./inputs.js"; async function writeOutput(name: string, value: string | number | undefined): Promise { const path = nonEmpty(process.env.GITHUB_OUTPUT); @@ -37,10 +22,14 @@ async function main(): Promise { if (!token) throw new Error("The SynSec GitHub Action requires a GitHub token."); const configInput = nonEmpty(process.env.SYNSEC_CONFIG_PATH); - const configPath = configInput ? resolve(workspace, configInput) : undefined; + const configPath = configInput + ? await resolveWorkspaceFileInput(workspace, configInput, "config-path") + : undefined; const { config } = await loadConfig(workspace, configPath); const baselineInput = nonEmpty(process.env.SYNSEC_BASELINE_PATH); - const baselinePath = baselineInput ? resolve(workspace, baselineInput) : undefined; + const baselinePath = baselineInput + ? await resolveWorkspaceFileInput(workspace, baselineInput, "baseline-path") + : undefined; const publishSarif = booleanInput(process.env.SYNSEC_PUBLISH_SARIF, false); const changedOnly = changedOnlyInput(process.env.SYNSEC_CHANGED_ONLY); From 16b5846815ce17739004b183a4dba567fe68ee3f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:38:53 -0400 Subject: [PATCH 0272/1132] test(action): enforce checkout-bound file inputs --- tests/github-action-inputs.test.mjs | 67 +++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/github-action-inputs.test.mjs diff --git a/tests/github-action-inputs.test.mjs b/tests/github-action-inputs.test.mjs new file mode 100644 index 00000000..0c5c9f8e --- /dev/null +++ b/tests/github-action-inputs.test.mjs @@ -0,0 +1,67 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + booleanInput, + changedOnlyInput, + resolveWorkspaceFileInput, +} from "../apps/github-action/dist/inputs.js"; + +test("GitHub Action boolean inputs accept documented values and reject ambiguity", () => { + assert.equal(booleanInput(undefined, true), true); + assert.equal(booleanInput(" yes ", false), true); + assert.equal(booleanInput("0", true), false); + assert.throws(() => booleanInput("maybe", false), /Expected a boolean action input/); + + assert.equal(changedOnlyInput("auto"), undefined); + assert.equal(changedOnlyInput("true"), true); + assert.equal(changedOnlyInput("no"), false); + assert.throws(() => changedOnlyInput("sometimes"), /changed-only must be auto, true, or false/); +}); + +test("GitHub Action file inputs resolve regular files inside the checkout", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-action-input-")); + await mkdir(join(root, "config")); + await writeFile(join(root, "config", "synsec.json"), "{}", "utf8"); + + const resolved = await resolveWorkspaceFileInput(root, "config/synsec.json", "config-path"); + assert.equal(resolved, join(root, "config", "synsec.json")); +}); + +test("GitHub Action file inputs reject lexical traversal outside the checkout", async () => { + const parent = await mkdtemp(join(tmpdir(), "synsec-action-parent-")); + const root = join(parent, "repo"); + await mkdir(root); + await writeFile(join(parent, "outside.json"), "{}", "utf8"); + + await assert.rejects( + resolveWorkspaceFileInput(root, "../outside.json", "baseline-path"), + /must resolve inside GITHUB_WORKSPACE/, + ); +}); + +test("GitHub Action file inputs reject symlinks that escape the checkout", async () => { + const parent = await mkdtemp(join(tmpdir(), "synsec-action-symlink-")); + const root = join(parent, "repo"); + await mkdir(root); + const outside = join(parent, "outside.json"); + await writeFile(outside, "{}", "utf8"); + await symlink(outside, join(root, "baseline.json")); + + await assert.rejects( + resolveWorkspaceFileInput(root, "baseline.json", "baseline-path"), + /existing file inside GITHUB_WORKSPACE/, + ); +}); + +test("GitHub Action file inputs reject directories", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-action-dir-")); + await mkdir(join(root, "config")); + + await assert.rejects( + resolveWorkspaceFileInput(root, "config", "config-path"), + /regular file inside GITHUB_WORKSPACE/, + ); +}); From 726009685704c4f62bd9ad7064d0924bc0ebccb3 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:39:21 -0400 Subject: [PATCH 0273/1132] docs: align GitHub roadmap with implemented action support --- docs/ROADMAP.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 86bc9f9d..a3b8dafa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -108,16 +108,16 @@ These workflows operate on repository evidence and scanner results. They are not - [x] Completed-report → GitHub check publication orchestration - [x] GitHub Actions repository scan → check runner with PR changed-file defaults - [x] Report/commit binding before check publication +- [x] Packaged Actions entrypoint / workflow template +- [x] Inline SARIF/code-scanning upload - [ ] GitHub App - [ ] Repository installation flow -- [ ] Packaged Actions entrypoint / workflow template - [ ] Baseline acquisition for pull-request scans -- [ ] Inline SARIF/code-scanning upload - [ ] Scheduled repository scans - [ ] Optional remediation pull requests with explicit approval - [ ] GitLab and Bitbucket adapters -The Actions runner consumes the existing repository scan engine rather than introducing a second scanner path. Pull-request contexts default to changed-file scanning against `origin/`, push contexts default to full repository scans, and publication is refused when the scan cannot identify its commit or the report commit differs from the GitHub head being annotated. +The Actions runner consumes the existing repository scan engine rather than introducing a second scanner path. Pull-request contexts default to changed-file scanning against `origin/`, push contexts default to full repository scans, and publication is refused when the scan cannot identify its commit or the report commit differs from the GitHub head being annotated. The packaged Action keeps explicit config/baseline file inputs inside the real checked-out workspace, including symlink resolution, before those files are read. See [GITHUB.md](./GITHUB.md) for the current integration contract and security boundaries. From 8ade8b77ed39de08eb223f0118de7ddfebe900fb Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 10:40:18 -0400 Subject: [PATCH 0274/1132] fix(build): include GitHub Action runtime --- tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index a3256b8e..56837522 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ { "path": "./packages/scanners" }, { "path": "./packages/engine" }, { "path": "./packages/github" }, - { "path": "./apps/cli" } + { "path": "./apps/cli" }, + { "path": "./apps/github-action" } ] } From a314f3b0339e08f08a38727c096d82ae21c91451 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:13:04 -0400 Subject: [PATCH 0275/1132] feat(github): add commit-bound base scan baselines --- packages/github/src/base-scan.ts | 83 ++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 packages/github/src/base-scan.ts diff --git a/packages/github/src/base-scan.ts b/packages/github/src/base-scan.ts new file mode 100644 index 00000000..7e5444e6 --- /dev/null +++ b/packages/github/src/base-scan.ts @@ -0,0 +1,83 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import type { SynSecConfig } from "@synsec/config"; +import { runScanEngine, type ScanEngineOutcome } from "@synsec/engine"; +import type { SynSecReport } from "@synsec/report"; +import { reportMatchesGitHubCommit } from "./orchestrator.js"; + +const execFileAsync = promisify(execFile); +const COMMIT_SHA = /^[0-9a-f]{7,40}$/i; + +export interface GitHubBaseScanOptions { + toolVersion?: string; + scan?: typeof runScanEngine; +} + +export interface GitHubBaseScanResult { + report: SynSecReport; + outcome: ScanEngineOutcome; +} + +async function git(rootPath: string, args: string[]): Promise { + await execFileAsync("git", ["-C", rootPath, ...args], { + encoding: "utf8", + maxBuffer: 1024 * 1024, + windowsHide: true, + }); +} + +/** + * Produce a baseline by scanning one exact commit already present in the local checkout. + * + * The commit is checked out into a temporary detached worktree and scanned with changed-file mode + * disabled. This helper never fetches from a remote, follows a repository-supplied URL, or changes + * the caller's working tree. The resulting report must identify the requested commit before it is + * accepted as baseline evidence. + */ +export async function scanGitHubBaseCommit( + config: SynSecConfig, + rootPath: string, + baseSha: string, + options: GitHubBaseScanOptions = {}, +): Promise { + const normalizedSha = baseSha.trim(); + if (!COMMIT_SHA.test(normalizedSha)) { + throw new Error("GitHub base scan requires a valid commit SHA."); + } + + const repositoryRoot = resolve(rootPath); + try { + await git(repositoryRoot, ["cat-file", "-e", `${normalizedSha}^{commit}`]); + } catch { + throw new Error( + "The pull-request base commit is not available in the local checkout. Configure actions/checkout with fetch-depth: 0 (or otherwise fetch the exact base commit) before running SynSec auto-baseline mode.", + ); + } + + const worktreePath = await mkdtemp(join(tmpdir(), "synsec-github-base-")); + let worktreeAdded = false; + try { + await git(repositoryRoot, ["worktree", "add", "--detach", worktreePath, normalizedSha]); + worktreeAdded = true; + const scan = options.scan ?? runScanEngine; + const outcome = await scan({ + rootPath: worktreePath, + config, + toolVersion: options.toolVersion, + changedOnly: false, + }); + const actual = outcome.report.target.commitSha?.trim(); + if (!actual || !reportMatchesGitHubCommit(actual, normalizedSha)) { + throw new Error("Automatic GitHub baseline scan did not produce a report bound to the requested base commit."); + } + return { report: outcome.report, outcome }; + } finally { + if (worktreeAdded) { + await git(repositoryRoot, ["worktree", "remove", "--force", worktreePath]).catch(() => undefined); + } + await rm(worktreePath, { recursive: true, force: true }).catch(() => undefined); + } +} From b1bfb4f016ae08811887e758904cd88138a5fab4 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:13:10 -0400 Subject: [PATCH 0276/1132] feat(github): export base scan helper --- packages/github/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/github/package.json b/packages/github/package.json index 7cdaf489..4aa54f15 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -9,7 +9,8 @@ "./orchestrator": "./dist/orchestrator.js", "./actions-runner": "./dist/actions-runner.js", "./sarif-publisher": "./dist/sarif-publisher.js", - "./baseline": "./dist/baseline.js" + "./baseline": "./dist/baseline.js", + "./base-scan": "./dist/base-scan.js" }, "types": "./dist/index.d.ts", "scripts": { From 18f574c47e37d4645c74d0eef878c4006bfe8b03 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:13:25 -0400 Subject: [PATCH 0277/1132] feat(github): support automatic base commit baselines --- packages/github/src/actions-runner.ts | 37 ++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/github/src/actions-runner.ts b/packages/github/src/actions-runner.ts index 03369278..85a962c8 100644 --- a/packages/github/src/actions-runner.ts +++ b/packages/github/src/actions-runner.ts @@ -1,6 +1,7 @@ import type { SynSecConfig } from "@synsec/config"; import { runScanEngine, type ScanEngineOutcome } from "@synsec/engine"; import type { SynSecReport } from "@synsec/report"; +import { scanGitHubBaseCommit } from "./base-scan.js"; import { loadValidatedGitHubBaseline } from "./baseline.js"; import { loadGitHubContext, type GitHubPullRequestContext } from "./index.js"; import { @@ -19,6 +20,7 @@ export interface GitHubActionsRepositoryScanOptions extends GitHubReportPublicat baseline?: SynSecReport; baselinePath?: string; baselineExpectedCommitSha?: string; + autoBaseline?: boolean; toolVersion?: string; changedOnly?: boolean; changedBase?: string; @@ -31,6 +33,7 @@ export interface GitHubActionsRepositoryScanResult { outcome: ScanEngineOutcome; publication: GitHubReportPublicationResult; sarifPublication?: GitHubSarifPublication; + baselineSource?: "provided" | "file" | "base-scan"; } /** @@ -38,7 +41,8 @@ export interface GitHubActionsRepositoryScanResult { * the completed report as a check run. Pull-request contexts default to changed-file scanning; * push/other contexts default to a full repository scan. Optional code-scanning publication uses * the same completed report and fixed GitHub host. A local baseline path is size-bounded and - * commit-bound before it enters the scan engine. No live-target discovery is performed. + * commit-bound before it enters the scan engine. Auto-baseline mode scans the exact PR base commit + * from a temporary local worktree and never performs a remote fetch or live-target discovery. */ export async function runGitHubActionsRepositoryScan( token: string, @@ -53,17 +57,35 @@ export async function runGitHubActionsRepositoryScan( throw new Error("Provide either an in-memory baseline or baselinePath, not both."); } - const baseline = options.baselinePath - ? await loadValidatedGitHubBaseline(options.baselinePath, context, { + const rootPath = options.rootPath ?? process.cwd(); + const scan = options.scan ?? runScanEngine; + let baseline: SynSecReport | undefined; + let baselineSource: GitHubActionsRepositoryScanResult["baselineSource"]; + if (options.baselinePath) { + baseline = await loadValidatedGitHubBaseline(options.baselinePath, context, { expectedCommitSha: options.baselineExpectedCommitSha, - }) - : options.baseline; + }); + baselineSource = "file"; + } else if (options.baseline) { + baseline = options.baseline; + baselineSource = "provided"; + } else if (options.autoBaseline && context.pullRequestNumber) { + const baseSha = context.baseSha?.trim(); + if (!baseSha) { + throw new Error("Automatic GitHub baseline generation requires the pull-request base SHA from GITHUB_EVENT_PATH."); + } + baseline = (await scanGitHubBaseCommit(options.config, rootPath, baseSha, { + toolVersion: options.toolVersion, + scan, + })).report; + baselineSource = "base-scan"; + } + const changedOnly = options.changedOnly ?? Boolean(context.pullRequestNumber); const changedBase = options.changedBase ?? (changedOnly && context.baseRef ? `origin/${context.baseRef}` : undefined); - const scan = options.scan ?? runScanEngine; const outcome = await scan({ - rootPath: options.rootPath ?? process.cwd(), + rootPath, config: options.config, baseline, toolVersion: options.toolVersion, @@ -98,5 +120,6 @@ export async function runGitHubActionsRepositoryScan( outcome, publication, ...(sarifPublication ? { sarifPublication } : {}), + ...(baselineSource ? { baselineSource } : {}), }; } From aa785459b73704db82756ee19a19a3ee10a12131 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:13:35 -0400 Subject: [PATCH 0278/1132] feat(action): enable provenance-safe auto baselines --- apps/github-action/src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/github-action/src/index.ts b/apps/github-action/src/index.ts index 231dceea..331e78f9 100644 --- a/apps/github-action/src/index.ts +++ b/apps/github-action/src/index.ts @@ -30,6 +30,7 @@ async function main(): Promise { const baselinePath = baselineInput ? await resolveWorkspaceFileInput(workspace, baselineInput, "baseline-path") : undefined; + const autoBaseline = booleanInput(process.env.SYNSEC_AUTO_BASELINE, true); const publishSarif = booleanInput(process.env.SYNSEC_PUBLISH_SARIF, false); const changedOnly = changedOnlyInput(process.env.SYNSEC_CHANGED_ONLY); @@ -37,6 +38,7 @@ async function main(): Promise { config, rootPath: workspace, baselinePath, + autoBaseline: baselinePath ? false : autoBaseline, changedOnly, publishSarif, threshold: config.failOn, @@ -47,11 +49,13 @@ async function main(): Promise { writeOutput("finding-count", result.outcome.report.findingCount), writeOutput("check-run-id", result.publication.publication.id), writeOutput("sarif-upload-id", result.sarifPublication?.id), + writeOutput("baseline-source", result.baselineSource ?? "none"), ]); console.log( `SynSec scanned ${result.outcome.report.scope?.mode === "changed-files" ? "changed files" : "the repository"}: ` - + `${result.outcome.report.findingCount} finding(s), security score ${result.outcome.report.securityScore}/100.`, + + `${result.outcome.report.findingCount} finding(s), security score ${result.outcome.report.securityScore}/100.` + + ` Baseline: ${result.baselineSource ?? "none"}.`, ); if (result.outcome.shouldFail) process.exitCode = 1; } From 71bd4dbf6e2e5dd42725c4b2e858f940038ac067 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:13:45 -0400 Subject: [PATCH 0279/1132] feat(action): expose automatic PR baseline mode --- action.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/action.yml b/action.yml index d8f13d36..2d18bc0f 100644 --- a/action.yml +++ b/action.yml @@ -13,6 +13,10 @@ inputs: description: Optional local SynSec baseline report, validated against the pull-request base commit. required: false default: "" + auto-baseline: + description: For pull requests without baseline-path, scan the exact local base commit in a detached worktree. Requires that commit to be present in the checkout (for example actions/checkout with fetch-depth 0). + required: false + default: "true" changed-only: description: auto (PR changed files, push full repo), true, or false. required: false @@ -34,6 +38,9 @@ outputs: sarif-upload-id: description: GitHub SARIF upload id when publish-sarif is enabled. value: ${{ steps.scan.outputs.sarif-upload-id }} + baseline-source: + description: Baseline provenance used for the scan (base-scan, file, provided, or none). + value: ${{ steps.scan.outputs.baseline-source }} runs: using: composite steps: @@ -55,6 +62,7 @@ runs: SYNSEC_GITHUB_TOKEN: ${{ inputs.github-token }} SYNSEC_CONFIG_PATH: ${{ inputs.config-path }} SYNSEC_BASELINE_PATH: ${{ inputs.baseline-path }} + SYNSEC_AUTO_BASELINE: ${{ inputs.auto-baseline }} SYNSEC_CHANGED_ONLY: ${{ inputs.changed-only }} SYNSEC_PUBLISH_SARIF: ${{ inputs.publish-sarif }} run: node "$GITHUB_ACTION_PATH/apps/github-action/dist/index.js" From 6f4a945639d0bf84b94793246886537ad90adbac Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:14:05 -0400 Subject: [PATCH 0280/1132] test(github): cover detached base scan provenance --- tests/github-base-scan.test.mjs | 84 +++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/github-base-scan.test.mjs diff --git a/tests/github-base-scan.test.mjs b/tests/github-base-scan.test.mjs new file mode 100644 index 00000000..4c092daf --- /dev/null +++ b/tests/github-base-scan.test.mjs @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { scanGitHubBaseCommit } from "../packages/github/dist/base-scan.js"; + +const exec = promisify(execFile); +const config = { + version: 1, + scanners: ["opengrep"], + failOn: "high", + parallelism: 1, + timeoutMs: 60_000, +}; + +async function git(root, ...args) { + return exec("git", ["-C", root, ...args], { encoding: "utf8" }); +} + +function outcome(rootPath, commitSha) { + return { + report: { + schemaVersion: "1.0", + reportId: "base-report", + generatedAt: "2026-08-22T15:00:00.000Z", + toolVersion: "0.2.0", + target: { path: rootPath, commitSha }, + scanners: [], + rawFindingCount: 0, + findingCount: 0, + summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 100, + findings: [], + scope: { mode: "repository" }, + }, + repositoryIndex: { schemaVersion: "1.0", root: rootPath, files: [] }, + statuses: [], + failures: [], + shouldFail: false, + changedFiles: [], + }; +} + +test("base scan uses a detached local worktree and binds the report to the requested commit", async () => { + const repository = await mkdtemp(join(tmpdir(), "synsec-base-test-")); + let scannedRoot; + try { + await git(repository, "init"); + await git(repository, "config", "user.email", "synsec@example.invalid"); + await git(repository, "config", "user.name", "SynSec Test"); + await writeFile(join(repository, "app.js"), "export const secure = true;\n", "utf8"); + await git(repository, "add", "app.js"); + await git(repository, "commit", "-m", "base"); + const { stdout } = await git(repository, "rev-parse", "HEAD"); + const baseSha = stdout.trim(); + + const result = await scanGitHubBaseCommit(config, repository, baseSha, { + scan: async (input) => { + scannedRoot = input.rootPath; + assert.notEqual(scannedRoot, repository); + assert.equal(input.changedOnly, false); + assert.equal(await readFile(join(scannedRoot, "app.js"), "utf8"), "export const secure = true;\n"); + return outcome(scannedRoot, baseSha); + }, + }); + + assert.equal(result.report.target.commitSha, baseSha); + await assert.rejects(() => access(scannedRoot)); + assert.equal((await git(repository, "status", "--porcelain")).stdout, ""); + } finally { + await rm(repository, { recursive: true, force: true }); + } +}); + +test("base scan rejects non-SHA revisions before invoking git", async () => { + await assert.rejects( + () => scanGitHubBaseCommit(config, process.cwd(), "origin/main"), + /valid commit SHA/, + ); +}); From b7ab79226e30a897ddb2d13a6c1c10bd2504b07f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:14:32 -0400 Subject: [PATCH 0281/1132] docs(github): document auto baseline provenance --- docs/GITHUB.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/GITHUB.md b/docs/GITHUB.md index 28eb066e..20729da3 100644 --- a/docs/GITHUB.md +++ b/docs/GITHUB.md @@ -25,6 +25,7 @@ This keeps GitHub credentials out of scanners and prevents repository analysis f - Completed-report publication orchestration with report/head commit binding. - A GitHub Actions repository scan runner that reuses the normal scan engine. - Bounded local baseline loading with optional PR-base commit validation. +- Automatic PR-base baseline generation from an exact commit already present in the local checkout. - Fixed-host gzip/base64 SARIF publication to GitHub code scanning. The root `action.yml` packages these primitives as a composite GitHub Action. It builds the checked-in SynSec runtime, scans only the repository represented by `GITHUB_WORKSPACE`, and publishes the completed report using the caller-provided GitHub token. @@ -51,9 +52,11 @@ The runner does not clone arbitrary targets, expand repository scope, perform li A caller can provide a baseline report in memory or as a local `baselinePath`. `loadValidatedGitHubBaseline()` bounds local baseline files to 20 MiB, parses them through the normal report reader, and by default requires the baseline report's commit to match the pull-request base SHA from the event payload. An explicit expected commit can be supplied for non-PR or synthetic contexts. -A missing baseline commit, missing expected base commit, or stale baseline fails before scanning. SynSec does not silently treat an unverifiable baseline as trustworthy. +For PR runs without an explicit baseline, `autoBaseline` can generate one from the exact `pull_request.base.sha`. `scanGitHubBaseCommit()` first proves that SHA is a local Git commit, creates a temporary detached Git worktree at that commit, runs a full repository scan there, requires the generated report to identify the same commit, and removes the worktree afterward. It never invokes `git fetch`, follows a repository-supplied remote URL, or mutates the caller's checkout. -The current baseline primitive deliberately does not fetch arbitrary URLs or repositories. Artifact/cache retrieval belongs in a future hosting adapter that can enforce provenance and repository scope before handing a local report to this loader. +The composite Action enables this mode by default. Because SynSec intentionally does not perform an implicit network fetch, the PR base commit must already exist locally. Use `actions/checkout` with `fetch-depth: 0`, or otherwise fetch the exact base commit before SynSec. A shallow checkout that lacks the base commit fails with an explicit setup error instead of silently producing a baseline against the wrong revision. + +A missing baseline commit, missing expected base commit, stale baseline, or base-scan report whose commit does not match the requested base SHA fails before head publication. SynSec does not silently treat unverifiable baseline evidence as trustworthy. ## Check conclusions @@ -99,14 +102,15 @@ The root `action.yml` exposes: - `github-token` — required publication token; - `config-path` — optional path to `synsec.config.json` in the checked-out repository; - `baseline-path` — optional local commit-bound baseline report; +- `auto-baseline` — PR-only local base-commit scan when no baseline path is supplied; defaults to `true`; - `changed-only` — `auto`, `true`, or `false`; - `publish-sarif` — optional code-scanning publication. -It returns the security score, finding count, check-run id, and optional SARIF upload id. +It returns the security score, finding count, check-run id, optional SARIF upload id, and `baseline-source` (`base-scan`, `file`, `provided`, or `none`). The Action intentionally does **not** silently download third-party scanner binaries. Selected scanners must already be available on `PATH`; this keeps scanner installation/version pinning explicit and avoids hiding supply-chain downloads inside the security scanner itself. A future containerized worker can improve scanner provisioning while retaining pinned artifacts and isolation. -A minimal workflow should give SynSec only the permissions it needs: +A minimal workflow should give SynSec only the permissions it needs and preserve base history for provenance-safe PR baselines: ```yaml permissions: @@ -136,6 +140,7 @@ GitHub integration must preserve the repository-first defensive model: - Scanner output must never choose the GitHub API host or arbitrary publication URL. - A report must not be published onto a different commit than the one it represents. - A baseline must not be trusted for PR comparison without validated commit identity. +- Auto-baseline mode may inspect only the exact PR base commit already present in the local checkout and must not perform implicit remote fetches. - Source excerpts are not added to GitHub annotations unless already present in normalized deterministic finding fields. - Secret values must remain redacted before publication. - Repository writes remain outside the scan/publication path and require explicit approval. @@ -146,9 +151,8 @@ GitHub integration must preserve the repository-first defensive model: The remaining Phase 5 work is primarily hosting/authentication and durable orchestration: 1. Add a GitHub App installation/authentication layer using the same runner/publication primitives. -2. Add provenance-aware baseline artifact/cache acquisition around the local validator. -3. Add scheduled repository-scan workflow/orchestration support. -4. Add explicitly approved remediation pull requests. -5. Add GitLab and Bitbucket adapters without coupling the scanner core to one host. +2. Add scheduled repository-scan workflow/orchestration support. +3. Add explicitly approved remediation pull requests. +4. Add GitLab and Bitbucket adapters without coupling the scanner core to one host. The deterministic packages should remain usable from both a GitHub App and GitHub Actions so the scanning core does not become hosting-provider-specific. From cd156402a251e95c28f7aaad5de2f3abf3888fc1 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:14:53 -0400 Subject: [PATCH 0282/1132] docs(roadmap): mark PR baseline acquisition implemented --- docs/ROADMAP.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a3b8dafa..ad2d1810 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -110,15 +110,17 @@ These workflows operate on repository evidence and scanner results. They are not - [x] Report/commit binding before check publication - [x] Packaged Actions entrypoint / workflow template - [x] Inline SARIF/code-scanning upload +- [x] Provenance-safe pull-request baseline acquisition from the exact local base commit - [ ] GitHub App - [ ] Repository installation flow -- [ ] Baseline acquisition for pull-request scans - [ ] Scheduled repository scans - [ ] Optional remediation pull requests with explicit approval - [ ] GitLab and Bitbucket adapters The Actions runner consumes the existing repository scan engine rather than introducing a second scanner path. Pull-request contexts default to changed-file scanning against `origin/`, push contexts default to full repository scans, and publication is refused when the scan cannot identify its commit or the report commit differs from the GitHub head being annotated. The packaged Action keeps explicit config/baseline file inputs inside the real checked-out workspace, including symlink resolution, before those files are read. +For PRs without an explicit baseline, the Action can scan the exact event-provided base commit in a temporary detached worktree. The base commit must already be present locally; SynSec does not implicitly fetch a remote or substitute a nearby revision. The resulting report is accepted only when its commit identity matches the requested base SHA, then the temporary worktree is removed. + See [GITHUB.md](./GITHUB.md) for the current integration contract and security boundaries. ## Phase 6 — Persistent web application From d16a2c4476208ef1c7931dd4a75446040b48f061 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:15:53 -0400 Subject: [PATCH 0283/1132] feat(action): depend on report writer --- apps/github-action/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/github-action/package.json b/apps/github-action/package.json index a4211467..5de8eafd 100644 --- a/apps/github-action/package.json +++ b/apps/github-action/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@synsec/config": "0.2.0", - "@synsec/github": "0.2.0" + "@synsec/github": "0.2.0", + "@synsec/report": "0.2.0" } } From 4dfcf670532cd7531995de8f8a5280ab2c24f9c4 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:16:04 -0400 Subject: [PATCH 0284/1132] feat(action): persist completed report for artifacts --- apps/github-action/src/index.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/github-action/src/index.ts b/apps/github-action/src/index.ts index 331e78f9..2ac3d066 100644 --- a/apps/github-action/src/index.ts +++ b/apps/github-action/src/index.ts @@ -1,7 +1,9 @@ -import { appendFile } from "node:fs/promises"; +import { appendFile, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { loadConfig } from "@synsec/config"; import { runGitHubActionsRepositoryScan } from "@synsec/github/actions-runner"; +import { writeReport } from "@synsec/report"; import { booleanInput, changedOnlyInput, @@ -44,18 +46,23 @@ async function main(): Promise { threshold: config.failOn, }); + const reportPath = resolve(nonEmpty(process.env.RUNNER_TEMP) ?? tmpdir(), "synsec-report.json"); + await writeReport(reportPath, result.outcome.report); + await chmod(reportPath, 0o600).catch(() => undefined); + await Promise.all([ writeOutput("security-score", result.outcome.report.securityScore), writeOutput("finding-count", result.outcome.report.findingCount), writeOutput("check-run-id", result.publication.publication.id), writeOutput("sarif-upload-id", result.sarifPublication?.id), writeOutput("baseline-source", result.baselineSource ?? "none"), + writeOutput("report-path", reportPath), ]); console.log( `SynSec scanned ${result.outcome.report.scope?.mode === "changed-files" ? "changed files" : "the repository"}: ` + `${result.outcome.report.findingCount} finding(s), security score ${result.outcome.report.securityScore}/100.` - + ` Baseline: ${result.baselineSource ?? "none"}.`, + + ` Baseline: ${result.baselineSource ?? "none"}. Report: ${reportPath}.`, ); if (result.outcome.shouldFail) process.exitCode = 1; } From 2991b2cf9cdb44e59d4e30743db36d3b9cc1a0f7 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:16:13 -0400 Subject: [PATCH 0285/1132] feat(action): expose completed report artifact path --- action.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/action.yml b/action.yml index 2d18bc0f..20dd40f0 100644 --- a/action.yml +++ b/action.yml @@ -41,6 +41,9 @@ outputs: baseline-source: description: Baseline provenance used for the scan (base-scan, file, provided, or none). value: ${{ steps.scan.outputs.baseline-source }} + report-path: + description: Local path to the completed JSON report in RUNNER_TEMP for optional artifact retention. + value: ${{ steps.scan.outputs.report-path }} runs: using: composite steps: From ef986fcb88e2e9f7ff71f88957e2a4d65ead43d6 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:16:29 -0400 Subject: [PATCH 0286/1132] docs(github): add scheduled scan workflow template --- docs/examples/synsec-scheduled.yml | 40 ++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/examples/synsec-scheduled.yml diff --git a/docs/examples/synsec-scheduled.yml b/docs/examples/synsec-scheduled.yml new file mode 100644 index 00000000..ab7915a5 --- /dev/null +++ b/docs/examples/synsec-scheduled.yml @@ -0,0 +1,40 @@ +name: SynSec scheduled repository scan + +on: + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + +permissions: + contents: read + checks: write + security-events: write + +jobs: + synsec: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + # Install and pin the scanner binaries selected by synsec.config.json here. + # SynSec intentionally does not download scanners implicitly. + + - name: Scan repository + id: synsec + uses: cmahmud/synsec@ + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + changed-only: "false" + auto-baseline: "false" + publish-sarif: "true" + + - name: Retain SynSec report + if: ${{ always() && steps.synsec.outputs.report-path != '' }} + uses: actions/upload-artifact@v4 + with: + name: synsec-report-${{ github.run_id }} + path: ${{ steps.synsec.outputs.report-path }} + if-no-files-found: error + retention-days: 30 From 8e4d6aa0282427f429e0609b0ba455d31f20609e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:17:01 -0400 Subject: [PATCH 0287/1132] docs(github): document report artifacts and scheduled scans --- docs/GITHUB.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/GITHUB.md b/docs/GITHUB.md index 20729da3..cf899507 100644 --- a/docs/GITHUB.md +++ b/docs/GITHUB.md @@ -27,6 +27,7 @@ This keeps GitHub credentials out of scanners and prevents repository analysis f - Bounded local baseline loading with optional PR-base commit validation. - Automatic PR-base baseline generation from an exact commit already present in the local checkout. - Fixed-host gzip/base64 SARIF publication to GitHub code scanning. +- A completed JSON report artifact path suitable for explicit retention by the caller. The root `action.yml` packages these primitives as a composite GitHub Action. It builds the checked-in SynSec runtime, scans only the repository represented by `GITHUB_WORKSPACE`, and publishes the completed report using the caller-provided GitHub token. @@ -42,7 +43,7 @@ No network request is required for context detection. `runGitHubActionsRepositoryScan()` accepts a normal `SynSecConfig`, optional baseline, checkout root, publication settings, and caller-supplied token. The scan path deliberately reuses `runScanEngine()` rather than creating GitHub-specific scanners. -For pull requests, changed-file scanning defaults to `origin/...HEAD`. Callers can override changed-file mode or the base ref explicitly. Push and other non-PR contexts default to a full repository scan. +For pull requests, changed-file scanning defaults to `origin/...HEAD`. Callers can override changed-file mode or the base ref explicitly. Push, schedule, workflow-dispatch, and other non-PR contexts default to a full repository scan. Before publication, the runner requires the scan report to identify its commit. The publication layer refuses a report whose commit differs from the GitHub commit being annotated. This prevents a stale report from being attached to a newer PR head. @@ -106,7 +107,7 @@ The root `action.yml` exposes: - `changed-only` — `auto`, `true`, or `false`; - `publish-sarif` — optional code-scanning publication. -It returns the security score, finding count, check-run id, optional SARIF upload id, and `baseline-source` (`base-scan`, `file`, `provided`, or `none`). +It returns the security score, finding count, check-run id, optional SARIF upload id, `baseline-source` (`base-scan`, `file`, `provided`, or `none`), and `report-path`. The completed JSON report is written under `RUNNER_TEMP` rather than into the checked-out repository and is chmodded to `0600` where supported. Retention remains explicit: callers decide whether to upload or discard it. The Action intentionally does **not** silently download third-party scanner binaries. Selected scanners must already be available on `PATH`; this keeps scanner installation/version pinning explicit and avoids hiding supply-chain downloads inside the security scanner itself. A future containerized worker can improve scanner provisioning while retaining pinned artifacts and isolation. @@ -131,6 +132,12 @@ steps: Use the normal `pull_request` event for scanning pull-request code. Do **not** switch to `pull_request_target` merely to obtain a write-capable token: that event executes in the base-repository security context and can expose elevated credentials to workflows that inspect untrusted contributor code. For fork pull requests where GitHub intentionally withholds write permissions, publication should be treated as unavailable rather than weakening the trust boundary. +## Scheduled repository scans + +Non-PR contexts already run a full repository scan, so scheduled scans use the same engine/publication path instead of a second orchestration implementation. `docs/examples/synsec-scheduled.yml` provides a cron + manual-dispatch template that checks out full history, runs SynSec with changed-file mode and PR auto-baselines disabled, optionally publishes SARIF, and retains the completed JSON report with `actions/upload-artifact`. + +The report is not uploaded automatically by SynSec. This keeps retention policy visible in the repository workflow and lets teams choose artifact duration rather than silently persisting security evidence. Scanner installation remains explicit and should be pinned by the repository owner. + ## Security boundaries GitHub integration must preserve the repository-first defensive model: @@ -148,11 +155,10 @@ GitHub integration must preserve the repository-first defensive model: ## Next implementation steps -The remaining Phase 5 work is primarily hosting/authentication and durable orchestration: +The remaining Phase 5 work is primarily hosting/authentication and explicit remediation orchestration: 1. Add a GitHub App installation/authentication layer using the same runner/publication primitives. -2. Add scheduled repository-scan workflow/orchestration support. -3. Add explicitly approved remediation pull requests. -4. Add GitLab and Bitbucket adapters without coupling the scanner core to one host. +2. Add explicitly approved remediation pull requests. +3. Add GitLab and Bitbucket adapters without coupling the scanner core to one host. The deterministic packages should remain usable from both a GitHub App and GitHub Actions so the scanning core does not become hosting-provider-specific. From 6513a4cfd3f29b66c0055ceeada435b1436818dd Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:17:40 -0400 Subject: [PATCH 0288/1132] docs(roadmap): mark scheduled scan workflow implemented --- docs/ROADMAP.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ad2d1810..383cce37 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -111,16 +111,18 @@ These workflows operate on repository evidence and scanner results. They are not - [x] Packaged Actions entrypoint / workflow template - [x] Inline SARIF/code-scanning upload - [x] Provenance-safe pull-request baseline acquisition from the exact local base commit +- [x] Scheduled full-repository workflow template with explicit report-artifact retention - [ ] GitHub App - [ ] Repository installation flow -- [ ] Scheduled repository scans - [ ] Optional remediation pull requests with explicit approval - [ ] GitLab and Bitbucket adapters -The Actions runner consumes the existing repository scan engine rather than introducing a second scanner path. Pull-request contexts default to changed-file scanning against `origin/`, push contexts default to full repository scans, and publication is refused when the scan cannot identify its commit or the report commit differs from the GitHub head being annotated. The packaged Action keeps explicit config/baseline file inputs inside the real checked-out workspace, including symlink resolution, before those files are read. +The Actions runner consumes the existing repository scan engine rather than introducing a second scanner path. Pull-request contexts default to changed-file scanning against `origin/`, while push, schedule, workflow-dispatch, and other non-PR contexts default to full repository scans. Publication is refused when the scan cannot identify its commit or the report commit differs from the GitHub head being annotated. The packaged Action keeps explicit config/baseline file inputs inside the real checked-out workspace, including symlink resolution, before those files are read. For PRs without an explicit baseline, the Action can scan the exact event-provided base commit in a temporary detached worktree. The base commit must already be present locally; SynSec does not implicitly fetch a remote or substitute a nearby revision. The resulting report is accepted only when its commit identity matches the requested base SHA, then the temporary worktree is removed. +The Action also writes the completed JSON report under `RUNNER_TEMP` and exposes its path. The scheduled workflow template retains that report only through an explicit caller-owned artifact step with a visible retention period; SynSec does not silently persist security evidence. + See [GITHUB.md](./GITHUB.md) for the current integration contract and security boundaries. ## Phase 6 — Persistent web application From c37c6339317d6d661b755f9b5e08e2207e2faf3e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:18:00 -0400 Subject: [PATCH 0289/1132] feat(action): add safe aggregate job summary --- apps/github-action/src/index.ts | 40 ++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/github-action/src/index.ts b/apps/github-action/src/index.ts index 2ac3d066..87ee3e7e 100644 --- a/apps/github-action/src/index.ts +++ b/apps/github-action/src/index.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { loadConfig } from "@synsec/config"; import { runGitHubActionsRepositoryScan } from "@synsec/github/actions-runner"; -import { writeReport } from "@synsec/report"; +import { writeReport, type SynSecReport } from "@synsec/report"; import { booleanInput, changedOnlyInput, @@ -18,6 +18,43 @@ async function writeOutput(name: string, value: string | number | undefined): Pr await appendFile(path, `${name}=${normalized}\n`, "utf8"); } +async function writeStepSummary(report: SynSecReport, baselineSource: string): Promise { + const path = nonEmpty(process.env.GITHUB_STEP_SUMMARY); + if (!path) return; + const delta = report.baseline; + const lines = [ + "## SynSec repository security", + "", + `**Security score:** ${report.securityScore}/100 `, + `**Findings:** ${report.findingCount} `, + `**Scope:** ${report.scope?.mode === "changed-files" ? "changed files" : "full repository"} `, + `**Baseline:** ${baselineSource}`, + "", + "| Severity | Count |", + "| --- | ---: |", + `| Critical | ${report.summary.critical} |`, + `| High | ${report.summary.high} |`, + `| Medium | ${report.summary.medium} |`, + `| Low | ${report.summary.low} |`, + `| Info | ${report.summary.info} |`, + `| Unknown | ${report.summary.unknown} |`, + ]; + if (delta) { + lines.push( + "", + "### Baseline delta", + "", + `New: **${delta.new.length}** · Fixed: **${delta.fixed.length}** · Persisting: **${delta.persisting.length}**`, + ); + } + lines.push( + "", + "_This summary intentionally contains aggregate metadata only. Review the normalized report/check annotations for finding details._", + "", + ); + await appendFile(path, `${lines.join("\n")}\n`, "utf8"); +} + async function main(): Promise { const workspace = resolve(nonEmpty(process.env.GITHUB_WORKSPACE) ?? process.cwd()); const token = nonEmpty(process.env.SYNSEC_GITHUB_TOKEN); @@ -49,6 +86,7 @@ async function main(): Promise { const reportPath = resolve(nonEmpty(process.env.RUNNER_TEMP) ?? tmpdir(), "synsec-report.json"); await writeReport(reportPath, result.outcome.report); await chmod(reportPath, 0o600).catch(() => undefined); + await writeStepSummary(result.outcome.report, result.baselineSource ?? "none"); await Promise.all([ writeOutput("security-score", result.outcome.report.securityScore), From 52442682e0f39ee81869de1a91e9798d05f119a5 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:18:11 -0400 Subject: [PATCH 0290/1132] test(action): isolate aggregate job summary renderer --- apps/github-action/src/summary.ts | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 apps/github-action/src/summary.ts diff --git a/apps/github-action/src/summary.ts b/apps/github-action/src/summary.ts new file mode 100644 index 00000000..e6fe87d4 --- /dev/null +++ b/apps/github-action/src/summary.ts @@ -0,0 +1,47 @@ +import { appendFile } from "node:fs/promises"; +import type { SynSecReport } from "@synsec/report"; + +export function renderStepSummary(report: SynSecReport, baselineSource: string): string { + const delta = report.baseline; + const lines = [ + "## SynSec repository security", + "", + `**Security score:** ${report.securityScore}/100 `, + `**Findings:** ${report.findingCount} `, + `**Scope:** ${report.scope?.mode === "changed-files" ? "changed files" : "full repository"} `, + `**Baseline:** ${baselineSource}`, + "", + "| Severity | Count |", + "| --- | ---: |", + `| Critical | ${report.summary.critical} |`, + `| High | ${report.summary.high} |`, + `| Medium | ${report.summary.medium} |`, + `| Low | ${report.summary.low} |`, + `| Info | ${report.summary.info} |`, + `| Unknown | ${report.summary.unknown} |`, + ]; + if (delta) { + lines.push( + "", + "### Baseline delta", + "", + `New: **${delta.new.length}** · Fixed: **${delta.fixed.length}** · Persisting: **${delta.persisting.length}**`, + ); + } + lines.push( + "", + "_This summary intentionally contains aggregate metadata only. Review the normalized report/check annotations for finding details._", + "", + ); + return `${lines.join("\n")}\n`; +} + +export async function writeStepSummary( + path: string | undefined, + report: SynSecReport, + baselineSource: string, +): Promise { + const target = path?.trim(); + if (!target) return; + await appendFile(target, renderStepSummary(report, baselineSource), "utf8"); +} From cbeb53b956a34c17c1c28d633af46c5f77ce8f88 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:18:21 -0400 Subject: [PATCH 0291/1132] refactor(action): use tested summary renderer --- apps/github-action/src/index.ts | 46 +++++---------------------------- 1 file changed, 7 insertions(+), 39 deletions(-) diff --git a/apps/github-action/src/index.ts b/apps/github-action/src/index.ts index 87ee3e7e..b7c3743a 100644 --- a/apps/github-action/src/index.ts +++ b/apps/github-action/src/index.ts @@ -3,13 +3,14 @@ import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { loadConfig } from "@synsec/config"; import { runGitHubActionsRepositoryScan } from "@synsec/github/actions-runner"; -import { writeReport, type SynSecReport } from "@synsec/report"; +import { writeReport } from "@synsec/report"; import { booleanInput, changedOnlyInput, nonEmpty, resolveWorkspaceFileInput, } from "./inputs.js"; +import { writeStepSummary } from "./summary.js"; async function writeOutput(name: string, value: string | number | undefined): Promise { const path = nonEmpty(process.env.GITHUB_OUTPUT); @@ -18,43 +19,6 @@ async function writeOutput(name: string, value: string | number | undefined): Pr await appendFile(path, `${name}=${normalized}\n`, "utf8"); } -async function writeStepSummary(report: SynSecReport, baselineSource: string): Promise { - const path = nonEmpty(process.env.GITHUB_STEP_SUMMARY); - if (!path) return; - const delta = report.baseline; - const lines = [ - "## SynSec repository security", - "", - `**Security score:** ${report.securityScore}/100 `, - `**Findings:** ${report.findingCount} `, - `**Scope:** ${report.scope?.mode === "changed-files" ? "changed files" : "full repository"} `, - `**Baseline:** ${baselineSource}`, - "", - "| Severity | Count |", - "| --- | ---: |", - `| Critical | ${report.summary.critical} |`, - `| High | ${report.summary.high} |`, - `| Medium | ${report.summary.medium} |`, - `| Low | ${report.summary.low} |`, - `| Info | ${report.summary.info} |`, - `| Unknown | ${report.summary.unknown} |`, - ]; - if (delta) { - lines.push( - "", - "### Baseline delta", - "", - `New: **${delta.new.length}** · Fixed: **${delta.fixed.length}** · Persisting: **${delta.persisting.length}**`, - ); - } - lines.push( - "", - "_This summary intentionally contains aggregate metadata only. Review the normalized report/check annotations for finding details._", - "", - ); - await appendFile(path, `${lines.join("\n")}\n`, "utf8"); -} - async function main(): Promise { const workspace = resolve(nonEmpty(process.env.GITHUB_WORKSPACE) ?? process.cwd()); const token = nonEmpty(process.env.SYNSEC_GITHUB_TOKEN); @@ -86,7 +50,11 @@ async function main(): Promise { const reportPath = resolve(nonEmpty(process.env.RUNNER_TEMP) ?? tmpdir(), "synsec-report.json"); await writeReport(reportPath, result.outcome.report); await chmod(reportPath, 0o600).catch(() => undefined); - await writeStepSummary(result.outcome.report, result.baselineSource ?? "none"); + await writeStepSummary( + nonEmpty(process.env.GITHUB_STEP_SUMMARY), + result.outcome.report, + result.baselineSource ?? "none", + ); await Promise.all([ writeOutput("security-score", result.outcome.report.securityScore), From 36f4936753e7dca36e747ac661ac35539514d4cd Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 11:18:31 -0400 Subject: [PATCH 0292/1132] test(action): cover aggregate GitHub job summary --- tests/github-action-summary.test.mjs | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/github-action-summary.test.mjs diff --git a/tests/github-action-summary.test.mjs b/tests/github-action-summary.test.mjs new file mode 100644 index 00000000..99617a49 --- /dev/null +++ b/tests/github-action-summary.test.mjs @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { renderStepSummary } from "../apps/github-action/dist/summary.js"; + +function report() { + return { + schemaVersion: "1.0", + reportId: "summary-report", + generatedAt: "2026-08-22T15:20:00.000Z", + toolVersion: "0.2.0", + target: { path: "/workspace", commitSha: "abcdef1234567890" }, + scanners: [], + rawFindingCount: 3, + findingCount: 3, + summary: { critical: 1, high: 1, medium: 1, low: 0, info: 0, unknown: 0 }, + securityScore: 58, + findings: [], + scope: { mode: "changed-files", baseRef: "origin/main", changedFiles: ["src/app.ts"] }, + baseline: { new: ["a", "b"], fixed: ["c"], persisting: ["d", "e", "f"] }, + }; +} + +test("job summary contains aggregate scan and baseline metadata only", () => { + const value = report(); + value.findings = [{ primary: { title: "" } }]; + const summary = renderStepSummary(value, "base-scan"); + + assert.match(summary, /Security score:\*\* 58\/100/); + assert.match(summary, /Findings:\*\* 3/); + assert.match(summary, /Critical \| 1/); + assert.match(summary, /New: \*\*2\*\*/); + assert.match(summary, /Fixed: \*\*1\*\*/); + assert.match(summary, /Persisting: \*\*3\*\*/); + assert.match(summary, /Baseline:\*\* base-scan/); + assert.doesNotMatch(summary, /scanner-controlled/); + assert.doesNotMatch(summary, /src\/app\.ts/); +}); From 44c5d8a455b666a3b9cfc92f7ccda678083de628 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 12:09:58 -0400 Subject: [PATCH 0293/1132] feat(github): add app authentication and webhook primitives --- packages/github/src/app.ts | 220 +++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 packages/github/src/app.ts diff --git a/packages/github/src/app.ts b/packages/github/src/app.ts new file mode 100644 index 00000000..66824f5c --- /dev/null +++ b/packages/github/src/app.ts @@ -0,0 +1,220 @@ +import { createHmac, sign as cryptoSign, timingSafeEqual } from "node:crypto"; + +const MAX_WEBHOOK_BYTES = 10 * 1024 * 1024; +const APP_JWT_LIFETIME_SECONDS = 9 * 60; + +export interface GitHubAppTokenOptions { + apiVersion?: string; + userAgent?: string; + fetch?: typeof globalThis.fetch; +} + +export interface GitHubInstallationToken { + token: string; + expiresAt: string; +} + +export interface GitHubAppWebhook { + event: "pull_request" | "push" | "installation" | "installation_repositories"; + action?: string; + deliveryId?: string; + installationId?: number; + repository?: string; + headSha?: string; + baseSha?: string; + pullRequestNumber?: number; +} + +function nonEmpty(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + return normalized; +} + +function positiveInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer.`); + } + return value; +} + +function base64url(value: string | Uint8Array): string { + return Buffer.from(value).toString("base64url"); +} + +function rawBytes(body: string | Uint8Array): Buffer { + const bytes = typeof body === "string" ? Buffer.from(body, "utf8") : Buffer.from(body); + if (bytes.byteLength > MAX_WEBHOOK_BYTES) { + throw new Error(`GitHub webhook body exceeds the ${MAX_WEBHOOK_BYTES}-byte limit.`); + } + return bytes; +} + +function objectValue(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function integerValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +function repositoryName(payload: Record): string | undefined { + const repository = objectValue(payload.repository); + const fullName = stringValue(repository?.full_name); + return fullName && /^[^/\s]+\/[^/\s]+$/.test(fullName) ? fullName : undefined; +} + +/** Verify GitHub's X-Hub-Signature-256 against the exact request bytes. */ +export function verifyGitHubWebhookSignature( + body: string | Uint8Array, + signatureHeader: string | undefined, + webhookSecret: string, +): boolean { + const secret = nonEmpty(webhookSecret, "GitHub webhook secret"); + const signature = signatureHeader?.trim(); + if (!signature || !/^sha256=[a-f0-9]{64}$/i.test(signature)) return false; + + const expected = createHmac("sha256", secret).update(rawBytes(body)).digest(); + const supplied = Buffer.from(signature.slice("sha256=".length), "hex"); + return supplied.byteLength === expected.byteLength && timingSafeEqual(supplied, expected); +} + +/** + * Verify and normalize only GitHub App events SynSec currently understands. + * Scanner targets are never derived from arbitrary payload URLs. + */ +export function parseVerifiedGitHubAppWebhook(input: { + body: string | Uint8Array; + signatureHeader?: string; + webhookSecret: string; + eventName: string; + deliveryId?: string; +}): GitHubAppWebhook { + if (!verifyGitHubWebhookSignature(input.body, input.signatureHeader, input.webhookSecret)) { + throw new Error("GitHub webhook signature verification failed."); + } + + const eventName = nonEmpty(input.eventName, "GitHub event name"); + if (!["pull_request", "push", "installation", "installation_repositories"].includes(eventName)) { + throw new Error(`Unsupported GitHub App event: ${eventName}`); + } + + let payload: Record; + try { + payload = objectValue(JSON.parse(rawBytes(input.body).toString("utf8"))) ?? (() => { throw new Error(); })(); + } catch { + throw new Error("GitHub webhook body must be a JSON object."); + } + + const installationId = integerValue(objectValue(payload.installation)?.id); + const repository = repositoryName(payload); + const action = stringValue(payload.action); + const deliveryId = input.deliveryId?.trim() || undefined; + + if (eventName === "pull_request") { + const pullRequest = objectValue(payload.pull_request); + const headSha = stringValue(objectValue(pullRequest?.head)?.sha); + const baseSha = stringValue(objectValue(pullRequest?.base)?.sha); + const pullRequestNumber = integerValue(payload.number); + if (!repository || !installationId || !headSha || !baseSha || !pullRequestNumber) { + throw new Error("GitHub pull_request webhook is missing required repository, installation, PR, or commit identity."); + } + return { + event: "pull_request", + ...(action ? { action } : {}), + ...(deliveryId ? { deliveryId } : {}), + installationId, + repository, + headSha, + baseSha, + pullRequestNumber, + }; + } + + if (eventName === "push") { + const headSha = stringValue(payload.after); + if (!repository || !installationId || !headSha) { + throw new Error("GitHub push webhook is missing required repository, installation, or commit identity."); + } + return { + event: "push", + ...(deliveryId ? { deliveryId } : {}), + installationId, + repository, + headSha, + }; + } + + if (!installationId) throw new Error(`GitHub ${eventName} webhook is missing installation identity.`); + return { + event: eventName as "installation" | "installation_repositories", + ...(action ? { action } : {}), + ...(deliveryId ? { deliveryId } : {}), + installationId, + ...(repository ? { repository } : {}), + }; +} + +/** Create a short-lived RS256 GitHub App JWT. */ +export function createGitHubAppJwt(appId: string | number, privateKey: string, now = Date.now()): string { + const issuer = String(appId).trim(); + if (!/^\d+$/.test(issuer) || issuer === "0") throw new Error("GitHub App id must be a positive integer."); + const key = nonEmpty(privateKey, "GitHub App private key"); + if (!Number.isFinite(now) || now <= 0) throw new Error("JWT clock must be a positive timestamp."); + + const issuedAt = Math.floor(now / 1000) - 30; + const expiresAt = issuedAt + APP_JWT_LIFETIME_SECONDS; + const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" })); + const payload = base64url(JSON.stringify({ iat: issuedAt, exp: expiresAt, iss: issuer })); + const signingInput = `${header}.${payload}`; + const signature = cryptoSign("RSA-SHA256", Buffer.from(signingInput), key); + return `${signingInput}.${base64url(signature)}`; +} + +/** Exchange an app JWT for one installation token using GitHub's fixed API host. */ +export async function createGitHubInstallationToken( + installationId: number, + appJwt: string, + options: GitHubAppTokenOptions = {}, +): Promise { + const id = positiveInteger(installationId, "GitHub installation id"); + const jwt = nonEmpty(appJwt, "GitHub App JWT"); + const fetchImpl = options.fetch ?? globalThis.fetch; + if (!fetchImpl) throw new Error("No fetch implementation is available for GitHub App authentication."); + + const response = await fetchImpl(`https://api.github.com/app/installations/${id}/access_tokens`, { + method: "POST", + redirect: "error", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + "User-Agent": options.userAgent?.trim() || "synsec/0.2", + "X-GitHub-Api-Version": options.apiVersion?.trim() || "2022-11-28", + }, + body: "{}", + }); + + const text = await response.text(); + if (!response.ok) { + const detail = text.replace(/[\r\n]+/g, " ").slice(0, 500).trim(); + throw new Error(`GitHub installation-token API returned HTTP ${response.status}${detail ? `: ${detail}` : "."}`); + } + + let payload: Record; + try { + payload = objectValue(text ? JSON.parse(text) : {}) ?? {}; + } catch { + throw new Error("GitHub installation-token API returned invalid JSON."); + } + const token = stringValue(payload.token); + const expiresAt = stringValue(payload.expires_at); + if (!token || !expiresAt) throw new Error("GitHub installation-token API response is missing token metadata."); + return { token, expiresAt }; +} From e94cc23ba1f7e2a995cff00a64e74814064fe30b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 12:10:04 -0400 Subject: [PATCH 0294/1132] feat(github): export app integration primitives --- packages/github/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/github/package.json b/packages/github/package.json index 4aa54f15..6d2c42be 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -10,7 +10,8 @@ "./actions-runner": "./dist/actions-runner.js", "./sarif-publisher": "./dist/sarif-publisher.js", "./baseline": "./dist/baseline.js", - "./base-scan": "./dist/base-scan.js" + "./base-scan": "./dist/base-scan.js", + "./app": "./dist/app.js" }, "types": "./dist/index.d.ts", "scripts": { From 043c0ea8bcc9a9cb47ba71b220b39c538ba20d83 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 12:10:21 -0400 Subject: [PATCH 0295/1132] test(github): cover app webhook and token boundaries --- tests/github-app.test.mjs | 131 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/github-app.test.mjs diff --git a/tests/github-app.test.mjs b/tests/github-app.test.mjs new file mode 100644 index 00000000..1cb6619e --- /dev/null +++ b/tests/github-app.test.mjs @@ -0,0 +1,131 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHmac, generateKeyPairSync, verify as cryptoVerify } from "node:crypto"; + +import { + createGitHubAppJwt, + createGitHubInstallationToken, + parseVerifiedGitHubAppWebhook, + verifyGitHubWebhookSignature, +} from "../packages/github/dist/app.js"; + +const webhookSecret = "synsec-webhook-secret"; + +function signature(body) { + return `sha256=${createHmac("sha256", webhookSecret).update(body).digest("hex")}`; +} + +test("verifyGitHubWebhookSignature validates the exact payload bytes", () => { + const body = Buffer.from('{"repository":{"full_name":"cmahmud/synsec"}}'); + assert.equal(verifyGitHubWebhookSignature(body, signature(body), webhookSecret), true); + assert.equal(verifyGitHubWebhookSignature(Buffer.from(`${body} `), signature(body), webhookSecret), false); + assert.equal(verifyGitHubWebhookSignature(body, "sha256=not-a-signature", webhookSecret), false); +}); + +test("parseVerifiedGitHubAppWebhook normalizes pull requests without trusting payload URLs", () => { + const body = Buffer.from(JSON.stringify({ + action: "synchronize", + installation: { id: 42 }, + repository: { + full_name: "cmahmud/synsec", + clone_url: "https://attacker.invalid/repository.git", + }, + number: 7, + pull_request: { + head: { sha: "abc123", repo: { clone_url: "https://attacker.invalid/head.git" } }, + base: { sha: "def456" }, + }, + })); + + assert.deepEqual(parseVerifiedGitHubAppWebhook({ + body, + signatureHeader: signature(body), + webhookSecret, + eventName: "pull_request", + deliveryId: "delivery-1", + }), { + event: "pull_request", + action: "synchronize", + deliveryId: "delivery-1", + installationId: 42, + repository: "cmahmud/synsec", + headSha: "abc123", + baseSha: "def456", + pullRequestNumber: 7, + }); +}); + +test("parseVerifiedGitHubAppWebhook rejects unsupported, unsigned, or incomplete events", () => { + const body = Buffer.from(JSON.stringify({ installation: { id: 1 } })); + assert.throws(() => parseVerifiedGitHubAppWebhook({ + body, + signatureHeader: signature(body), + webhookSecret, + eventName: "issues", + }), /Unsupported GitHub App event/); + + assert.throws(() => parseVerifiedGitHubAppWebhook({ + body, + signatureHeader: "sha256=0000000000000000000000000000000000000000000000000000000000000000", + webhookSecret, + eventName: "installation", + }), /signature verification failed/); + + const incomplete = Buffer.from(JSON.stringify({ installation: { id: 1 }, repository: { full_name: "cmahmud/synsec" } })); + assert.throws(() => parseVerifiedGitHubAppWebhook({ + body: incomplete, + signatureHeader: signature(incomplete), + webhookSecret, + eventName: "push", + }), /missing required repository, installation, or commit identity/); +}); + +test("createGitHubAppJwt creates a short-lived verifiable RS256 token", () => { + const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const privatePem = privateKey.export({ type: "pkcs8", format: "pem" }); + const now = Date.UTC(2026, 7, 22, 16, 0, 0); + const token = createGitHubAppJwt(12345, privatePem, now); + const [encodedHeader, encodedPayload, encodedSignature] = token.split("."); + const header = JSON.parse(Buffer.from(encodedHeader, "base64url").toString("utf8")); + const payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")); + + assert.deepEqual(header, { alg: "RS256", typ: "JWT" }); + assert.equal(payload.iss, "12345"); + assert.equal(payload.iat, Math.floor(now / 1000) - 30); + assert.equal(payload.exp - payload.iat, 9 * 60); + assert.equal(cryptoVerify( + "RSA-SHA256", + Buffer.from(`${encodedHeader}.${encodedPayload}`), + publicKey, + Buffer.from(encodedSignature, "base64url"), + ), true); +}); + +test("createGitHubInstallationToken posts only to the fixed GitHub installation endpoint", async () => { + let request; + const fakeFetch = async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ token: "installation-token", expires_at: "2026-08-22T17:00:00Z" }), { status: 201 }); + }; + + const result = await createGitHubInstallationToken(42, "app-jwt", { fetch: fakeFetch }); + assert.equal(request.url, "https://api.github.com/app/installations/42/access_tokens"); + assert.equal(request.init.method, "POST"); + assert.equal(request.init.redirect, "error"); + assert.equal(request.init.headers.Authorization, "Bearer app-jwt"); + assert.equal(request.init.body, "{}"); + assert.deepEqual(result, { token: "installation-token", expiresAt: "2026-08-22T17:00:00Z" }); +}); + +test("installation-token errors do not expose the app JWT", async () => { + const secretJwt = "secret-app-jwt"; + const fakeFetch = async () => new Response(JSON.stringify({ message: "Bad credentials" }), { status: 401 }); + await assert.rejects( + () => createGitHubInstallationToken(42, secretJwt, { fetch: fakeFetch }), + (error) => { + assert.match(error.message, /HTTP 401/); + assert.equal(error.message.includes(secretJwt), false); + return true; + }, + ); +}); From ac0a261e598b4a7b421f33bbd482fbd180e9db7d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 12:11:44 -0400 Subject: [PATCH 0296/1132] feat(github): constrain app scan trigger policy --- packages/github/src/app.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/github/src/app.ts b/packages/github/src/app.ts index 66824f5c..56fa132b 100644 --- a/packages/github/src/app.ts +++ b/packages/github/src/app.ts @@ -2,6 +2,7 @@ import { createHmac, sign as cryptoSign, timingSafeEqual } from "node:crypto"; const MAX_WEBHOOK_BYTES = 10 * 1024 * 1024; const APP_JWT_LIFETIME_SECONDS = 9 * 60; +const SCANNABLE_PULL_REQUEST_ACTIONS = new Set(["opened", "reopened", "synchronize", "ready_for_review"]); export interface GitHubAppTokenOptions { apiVersion?: string; @@ -107,7 +108,9 @@ export function parseVerifiedGitHubAppWebhook(input: { let payload: Record; try { - payload = objectValue(JSON.parse(rawBytes(input.body).toString("utf8"))) ?? (() => { throw new Error(); })(); + const parsed = objectValue(JSON.parse(rawBytes(input.body).toString("utf8"))); + if (!parsed) throw new Error(); + payload = parsed; } catch { throw new Error("GitHub webhook body must be a JSON object."); } @@ -161,6 +164,24 @@ export function parseVerifiedGitHubAppWebhook(input: { }; } +/** + * Decide whether a verified App event may enqueue a repository scan. + * Installation-management events are bookkeeping only and PR scans use an explicit action allowlist. + */ +export function shouldScanGitHubAppWebhook(event: GitHubAppWebhook): boolean { + if (event.event === "push") return Boolean(event.repository && event.headSha && event.installationId); + if (event.event !== "pull_request") return false; + return Boolean( + event.repository + && event.headSha + && event.baseSha + && event.pullRequestNumber + && event.installationId + && event.action + && SCANNABLE_PULL_REQUEST_ACTIONS.has(event.action), + ); +} + /** Create a short-lived RS256 GitHub App JWT. */ export function createGitHubAppJwt(appId: string | number, privateKey: string, now = Date.now()): string { const issuer = String(appId).trim(); From ffdce232230af7e623ad9b62cbdb9fb22ee1ce93 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 12:12:05 -0400 Subject: [PATCH 0297/1132] test(github): cover app scan trigger allowlist --- tests/github-app.test.mjs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/github-app.test.mjs b/tests/github-app.test.mjs index 1cb6619e..96605689 100644 --- a/tests/github-app.test.mjs +++ b/tests/github-app.test.mjs @@ -6,6 +6,7 @@ import { createGitHubAppJwt, createGitHubInstallationToken, parseVerifiedGitHubAppWebhook, + shouldScanGitHubAppWebhook, verifyGitHubWebhookSignature, } from "../packages/github/dist/app.js"; @@ -80,6 +81,39 @@ test("parseVerifiedGitHubAppWebhook rejects unsupported, unsigned, or incomplete }), /missing required repository, installation, or commit identity/); }); +test("shouldScanGitHubAppWebhook allows only push and selected PR lifecycle events", () => { + const pr = { + event: "pull_request", + action: "synchronize", + installationId: 42, + repository: "cmahmud/synsec", + headSha: "abc123", + baseSha: "def456", + pullRequestNumber: 7, + }; + + assert.equal(shouldScanGitHubAppWebhook(pr), true); + assert.equal(shouldScanGitHubAppWebhook({ ...pr, action: "closed" }), false); + assert.equal(shouldScanGitHubAppWebhook({ ...pr, action: "converted_to_draft" }), false); + assert.equal(shouldScanGitHubAppWebhook({ ...pr, headSha: undefined }), false); + assert.equal(shouldScanGitHubAppWebhook({ + event: "push", + installationId: 42, + repository: "cmahmud/synsec", + headSha: "abc123", + }), true); + assert.equal(shouldScanGitHubAppWebhook({ + event: "installation", + action: "created", + installationId: 42, + }), false); + assert.equal(shouldScanGitHubAppWebhook({ + event: "installation_repositories", + action: "added", + installationId: 42, + }), false); +}); + test("createGitHubAppJwt creates a short-lived verifiable RS256 token", () => { const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); const privatePem = privateKey.export({ type: "pkcs8", format: "pem" }); From 91ef844824cf00e8d5e93cb18b07badb260b5711 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 12:12:40 -0400 Subject: [PATCH 0298/1132] docs: define GitHub App security contract --- docs/GITHUB_APP.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 docs/GITHUB_APP.md diff --git a/docs/GITHUB_APP.md b/docs/GITHUB_APP.md new file mode 100644 index 00000000..a0776ebc --- /dev/null +++ b/docs/GITHUB_APP.md @@ -0,0 +1,53 @@ +# GitHub App integration contract + +SynSec's GitHub App support is a transport and orchestration layer around the same repository-first scan engine used by the CLI and GitHub Action. Installing the App must not authorize live-target probing, arbitrary network assessment, secret exfiltration, persistence, or silent target expansion. + +## Implemented primitives + +`@synsec/github/app` currently provides: + +- constant-time `X-Hub-Signature-256` verification over the exact request bytes; +- a 10 MiB webhook-body bound before event processing; +- normalization for `pull_request`, `push`, `installation`, and `installation_repositories` events only; +- repository identity from `repository.full_name` rather than payload-controlled clone/API URLs; +- required installation and commit identity for scan-bearing events; +- an explicit scan-trigger policy: pushes and only `opened`, `reopened`, `synchronize`, and `ready_for_review` pull-request actions may enqueue scans; +- installation-management events are bookkeeping only and never scan triggers; +- short-lived RS256 GitHub App JWT creation; +- installation-token exchange only through `https://api.github.com/app/installations//access_tokens` with redirects rejected; +- token/API errors that do not echo the App JWT. + +These primitives do **not** constitute a hosted GitHub App service by themselves. A server, durable installation state, scan queue/workers, checkout isolation, installation UX, and operational secret management are still required. + +## Webhook boundary + +Webhook consumers should preserve the raw request bytes until signature verification is complete. Do not parse and reserialize JSON before verifying the signature. + +After verification, callers should use the normalized event rather than payload URLs as the security boundary. In particular, repository checkout or API publication must derive from the validated GitHub installation/repository identity through a fixed GitHub transport. A `clone_url`, `html_url`, scanner-provided URL, finding text, or other repository-controlled field must never become an arbitrary outbound target. + +Only `shouldScanGitHubAppWebhook()` decides whether a normalized event belongs in the scan queue. Installation creation/removal and repository-selection changes may update installation bookkeeping, but they do not authorize immediate scanner execution by themselves. + +## Authentication boundary + +`createGitHubAppJwt()` signs a short-lived RS256 token from the configured App id and private key. The private key belongs to the hosted transport/runtime and must never be exposed to scanners, reports, repository code, workflow prompts, logs, or persisted finding evidence. + +`createGitHubInstallationToken()` exchanges that JWT at GitHub's fixed API host. It requests no additional repository selection or permission expansion in the token request body. The resulting installation token should be kept only for the operation lifetime and passed only to narrowly scoped GitHub transport functions. + +A hosted service should validate its configured GitHub App permissions explicitly and fail closed when required permissions are absent rather than requesting broader permissions dynamically. + +## Required hosted-service work + +A production hosted App still needs: + +1. a minimal HTTPS webhook endpoint that preserves raw request bytes and calls the verified parser; +2. durable delivery-id deduplication/replay protection; +3. durable installation/repository state without storing installation tokens; +4. a bounded scan job queue and isolated checkout/worker execution; +5. repository acquisition that is installation-scoped and commit-pinned; +6. per-job resource/time limits and credential minimization; +7. publication through the existing report/check/SARIF primitives; +8. explicit retention policy for reports and scan artifacts; +9. installation/setup UX and permission diagnostics; +10. operational rotation for webhook secrets and App private keys. + +Until those pieces exist, the GitHub Action remains the complete executable integration path and the App module should be treated as a tested hosting foundation rather than a deployable hosted product. From 016378eb9b9b824d67da9559e7ca3b625f48603d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 12:13:30 -0400 Subject: [PATCH 0299/1132] fix(scanner-sdk): minimize inherited scanner environment --- packages/scanner-sdk/src/index.ts | 43 ++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/scanner-sdk/src/index.ts b/packages/scanner-sdk/src/index.ts index 30b63112..9f722c50 100644 --- a/packages/scanner-sdk/src/index.ts +++ b/packages/scanner-sdk/src/index.ts @@ -42,6 +42,7 @@ export interface ProcessOptions { cwd?: string; timeoutMs?: number; signal?: AbortSignal; + /** Explicit child environment. When omitted, SynSec passes only a small non-secret OS allowlist. */ env?: NodeJS.ProcessEnv; /** Maximum bytes retained from each output stream. Defaults to 64 MiB per stream. */ maxOutputBytes?: number; @@ -51,6 +52,43 @@ export interface ProcessOptions { const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024 * 1024; const DEFAULT_KILL_GRACE_MS = 2_000; +const SAFE_ENV_KEYS = new Set([ + "PATH", + "PATHEXT", + "SYSTEMROOT", + "COMSPEC", + "WINDIR", + "TEMP", + "TMP", + "TMPDIR", + "HOME", + "USERPROFILE", + "LOCALAPPDATA", + "APPDATA", + "LANG", + "LC_ALL", + "TERM", + "COLORTERM", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NODE_EXTRA_CA_CERTS", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", +]); + +/** + * Build the default environment for untrusted external scanner processes. + * Credentials, CI tokens, cloud secrets, registry tokens, and proxy URLs are not inherited implicitly. + */ +export function buildScannerProcessEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(source)) { + if (value === undefined) continue; + const normalized = key.toUpperCase(); + if (SAFE_ENV_KEYS.has(normalized) || normalized.startsWith("LC_")) result[key] = value; + } + return result; +} export async function runProcess( command: string, @@ -58,6 +96,9 @@ export async function runProcess( options: ProcessOptions = {}, ): Promise { if (options.signal?.aborted) throw new Error(`Process aborted before start: ${command}`); + if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) { + throw new Error("timeoutMs must be a positive finite number when provided."); + } const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; if (!Number.isFinite(maxOutputBytes) || maxOutputBytes <= 0) { throw new Error("maxOutputBytes must be a positive finite number."); @@ -70,7 +111,7 @@ export async function runProcess( return await new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: options.cwd, - env: options.env ?? process.env, + env: options.env ?? buildScannerProcessEnv(), shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], From 4d65954cc31bbf4237d66e3cdf4c2f103258654b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 12:13:43 -0400 Subject: [PATCH 0300/1132] test(scanner-sdk): cover credential-minimized process env --- tests/scanner-sdk.test.mjs | 59 +++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/tests/scanner-sdk.test.mjs b/tests/scanner-sdk.test.mjs index 887b7327..2748d532 100644 --- a/tests/scanner-sdk.test.mjs +++ b/tests/scanner-sdk.test.mjs @@ -1,6 +1,52 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { runProcess } from "../packages/scanner-sdk/dist/index.js"; +import { buildScannerProcessEnv, runProcess } from "../packages/scanner-sdk/dist/index.js"; + +test("buildScannerProcessEnv preserves execution variables but drops credentials and proxy URLs", () => { + const env = buildScannerProcessEnv({ + PATH: "/usr/bin", + HOME: "/tmp/home", + LANG: "en_US.UTF-8", + LC_CTYPE: "en_US.UTF-8", + GITHUB_TOKEN: "secret-github-token", + NPM_TOKEN: "secret-registry-token", + AWS_SECRET_ACCESS_KEY: "secret-cloud-key", + HTTPS_PROXY: "http://user:password@proxy.invalid", + }); + + assert.equal(env.PATH, "/usr/bin"); + assert.equal(env.HOME, "/tmp/home"); + assert.equal(env.LANG, "en_US.UTF-8"); + assert.equal(env.LC_CTYPE, "en_US.UTF-8"); + assert.equal(env.GITHUB_TOKEN, undefined); + assert.equal(env.NPM_TOKEN, undefined); + assert.equal(env.AWS_SECRET_ACCESS_KEY, undefined); + assert.equal(env.HTTPS_PROXY, undefined); +}); + +test("runProcess does not implicitly pass parent credentials to scanners", async () => { + const previous = process.env.SYNSEC_TEST_SECRET; + process.env.SYNSEC_TEST_SECRET = "should-not-reach-scanner"; + try { + const result = await runProcess(process.execPath, [ + "-e", + "process.stdout.write(process.env.SYNSEC_TEST_SECRET || 'missing');", + ]); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout, "missing"); + } finally { + if (previous === undefined) delete process.env.SYNSEC_TEST_SECRET; + else process.env.SYNSEC_TEST_SECRET = previous; + } +}); + +test("runProcess accepts an explicit child environment when a scanner genuinely needs one", async () => { + const result = await runProcess(process.execPath, [ + "-e", + "process.stdout.write(process.env.SYNSEC_EXPLICIT || 'missing');", + ], { env: { ...buildScannerProcessEnv(), SYNSEC_EXPLICIT: "allowed-by-caller" } }); + assert.equal(result.stdout, "allowed-by-caller"); +}); test("runProcess captures bounded stdout and stderr", async () => { const result = await runProcess(process.execPath, [ @@ -28,3 +74,14 @@ test("runProcess surfaces timeouts instead of returning an ambiguous exit code", /timed out after 50 ms/, ); }); + +test("runProcess rejects invalid timeout configuration before spawning", async () => { + await assert.rejects( + runProcess(process.execPath, ["-e", "process.exit(0)"], { timeoutMs: 0 }), + /timeoutMs must be a positive finite number/, + ); + await assert.rejects( + runProcess(process.execPath, ["-e", "process.exit(0)"], { timeoutMs: Number.NaN }), + /timeoutMs must be a positive finite number/, + ); +}); From 9f5b470c73f814f79d78eac65c0ee3c61fc3853a Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 12:14:46 -0400 Subject: [PATCH 0301/1132] docs: track app and scanner isolation foundations --- docs/ROADMAP.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 383cce37..45f43cc0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -112,8 +112,12 @@ These workflows operate on repository evidence and scanner results. They are not - [x] Inline SARIF/code-scanning upload - [x] Provenance-safe pull-request baseline acquisition from the exact local base commit - [x] Scheduled full-repository workflow template with explicit report-artifact retention -- [ ] GitHub App -- [ ] Repository installation flow +- [x] GitHub App HMAC webhook verification and bounded event normalization +- [x] GitHub App short-lived JWT and fixed-host installation-token exchange primitives +- [x] Explicit GitHub App scan-trigger allowlist for push and selected PR lifecycle events +- [ ] Hosted GitHub App webhook/service orchestration +- [ ] Repository installation/setup flow and durable installation state +- [ ] Durable webhook delivery replay protection - [ ] Optional remediation pull requests with explicit approval - [ ] GitLab and Bitbucket adapters @@ -123,7 +127,9 @@ For PRs without an explicit baseline, the Action can scan the exact event-provid The Action also writes the completed JSON report under `RUNNER_TEMP` and exposes its path. The scheduled workflow template retains that report only through an explicit caller-owned artifact step with a visible retention period; SynSec does not silently persist security evidence. -See [GITHUB.md](./GITHUB.md) for the current integration contract and security boundaries. +GitHub App support is currently a hosting foundation, not a complete hosted service. Verified webhooks never derive scanner targets from payload-controlled URLs, installation-management events are not scan triggers, and installation-token exchange is fixed to `api.github.com`. A production service still needs durable installation/delivery state, isolated commit-pinned checkout workers, queueing, and setup UX. See [GITHUB_APP.md](./GITHUB_APP.md). + +See [GITHUB.md](./GITHUB.md) for the current Actions integration contract and security boundaries. ## Phase 6 — Persistent web application @@ -145,14 +151,18 @@ The local history store retains only report identifiers, timestamps, commit/bran ## Phase 7 — Isolated scan workers +- [x] Scanner subprocess timeout, abort, output-memory, and kill-escalation bounds +- [x] Credential-minimized default scanner subprocess environment - [ ] Containerized scanner images - [ ] Job queue - [ ] Per-scan workspace isolation -- [ ] Resource limits and timeouts +- [ ] OS/container CPU and memory limits - [ ] Network policy - [ ] Horizontal workers - [ ] Artifact retention policy -- [ ] Secrets/credential minimization for private repository clones +- [ ] Secrets/credential minimization for private repository clones and filesystem credentials + +External scanners no longer inherit the full parent process environment by default. SynSec passes a small execution/locale/certificate allowlist and requires an explicit environment when a scanner genuinely needs additional variables. This prevents ambient CI/App/cloud tokens and credential-bearing proxy variables from being handed to scanner binaries automatically. It is not a complete filesystem credential sandbox: container/workspace isolation and private-clone credential minimization remain open work. ## Later — explicitly authorized external assessment From 1714048d508e04aa9e9529794c8fbeb1ce17bc43 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 12:15:49 -0400 Subject: [PATCH 0302/1132] fix(lifecycle): harden local triage persistence --- packages/lifecycle/src/index.ts | 92 ++++++++++++++++++++++++--------- 1 file changed, 67 insertions(+), 25 deletions(-) diff --git a/packages/lifecycle/src/index.ts b/packages/lifecycle/src/index.ts index b9d4b0b0..30aa6ca6 100644 --- a/packages/lifecycle/src/index.ts +++ b/packages/lifecycle/src/index.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import type { CorrelatedFinding } from "@synsec/core"; import type { SynSecReport } from "@synsec/report"; @@ -60,6 +60,13 @@ export interface RemediationVerification { }; } +const MAX_LIFECYCLE_BYTES = 16 * 1024 * 1024; +const MAX_LIFECYCLE_RECORDS = 100_000; +const MAX_FINGERPRINT_LENGTH = 512; +const MAX_NOTE_LENGTH = 10_000; +const MAX_REPORT_ID_LENGTH = 512; +const MAX_PATH_LENGTH = 4096; + export function emptyLifecycleStore(): FindingLifecycleStore { return { schemaVersion: 1, records: {} }; } @@ -68,14 +75,44 @@ export function isFindingState(value: unknown): value is FindingState { return value === "new" || value === "confirmed" || value === "false-positive" || value === "accepted-risk" || value === "fixed" || value === "regressed"; } +function boundedString(value: unknown, maxLength: number, required = false): value is string { + if (typeof value !== "string") return false; + const trimmed = value.trim(); + if (required && !trimmed) return false; + return value.length <= maxLength; +} + +function validTimestamp(value: unknown): value is string { + return boundedString(value, 128, true) && Number.isFinite(Date.parse(value)); +} + +function isLifecycleRecord(value: unknown, key: string): value is FindingLifecycleRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const record = value as Record; + if (!boundedString(record.fingerprint, MAX_FINGERPRINT_LENGTH, true) || record.fingerprint !== key) return false; + if (!isFindingState(record.state) || !validTimestamp(record.updatedAt)) return false; + if (record.note !== undefined && !boundedString(record.note, MAX_NOTE_LENGTH)) return false; + if (record.reportId !== undefined && !boundedString(record.reportId, MAX_REPORT_ID_LENGTH, true)) return false; + if (record.lastSeenPath !== undefined && !boundedString(record.lastSeenPath, MAX_PATH_LENGTH, true)) return false; + return true; +} + export function isLifecycleStore(value: unknown): value is FindingLifecycleStore { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; const record = value as Record; - return record.schemaVersion === 1 && typeof record.records === "object" && record.records !== null && !Array.isArray(record.records); + if (record.schemaVersion !== 1 || typeof record.records !== "object" || record.records === null || Array.isArray(record.records)) return false; + const entries = Object.entries(record.records as Record); + if (entries.length > MAX_LIFECYCLE_RECORDS) return false; + return entries.every(([key, item]) => boundedString(key, MAX_FINGERPRINT_LENGTH, true) && isLifecycleRecord(item, key)); } export async function readLifecycleStore(path: string): Promise { try { + const metadata = await stat(path); + if (!metadata.isFile()) throw new Error(`SynSec lifecycle store is not a file: ${path}`); + if (metadata.size > MAX_LIFECYCLE_BYTES) { + throw new Error(`SynSec lifecycle store exceeds the ${MAX_LIFECYCLE_BYTES}-byte limit: ${path}`); + } const parsed = JSON.parse(await readFile(path, "utf8")) as unknown; if (!isLifecycleStore(parsed)) throw new Error(`Not a supported SynSec lifecycle store: ${path}`); return parsed; @@ -89,8 +126,23 @@ export async function readLifecycleStore(path: string): Promise { - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, `${JSON.stringify(store, null, 2)}\n`, "utf8"); + if (!isLifecycleStore(store)) throw new Error("Refusing to write an invalid SynSec lifecycle store."); + const directory = dirname(path); + await mkdir(directory, { recursive: true }); + const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`; + const serialized = `${JSON.stringify(store, null, 2)}\n`; + if (Buffer.byteLength(serialized) > MAX_LIFECYCLE_BYTES) { + throw new Error(`SynSec lifecycle store exceeds the ${MAX_LIFECYCLE_BYTES}-byte limit.`); + } + + try { + await writeFile(temporaryPath, serialized, { encoding: "utf8", mode: 0o600, flag: "wx" }); + await chmod(temporaryPath, 0o600).catch(() => undefined); + await rename(temporaryPath, path); + await chmod(path, 0o600).catch(() => undefined); + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } } export function setFindingState( @@ -258,32 +310,27 @@ export function verifyRemediation( reasons: ["The requested fingerprint is not present in the before report."], }; } - if (afterByFingerprint.has(fingerprint)) { - return { - fingerprint, - title: baseline.primary.title, - status: "persisting" as const, - reasons: ["The same correlated finding fingerprint is still present after remediation."], - }; - } - const scannerCoverage = afterReranDetectingScanner(after, baseline); - const scopeCoverage = afterScopeCoversFinding(after, baseline); - const reasons = [scannerCoverage.reason, scopeCoverage.reason].filter((value): value is string => Boolean(value)); - if (!scannerCoverage.covered || !scopeCoverage.covered) { + const persisting = afterByFingerprint.get(fingerprint); + if (persisting) { return { fingerprint, title: baseline.primary.title, - status: "inconclusive" as const, - reasons, + status: "persisting" as const, + reasons: ["The same normalized finding fingerprint is still present after remediation."], }; } + const scope = afterScopeCoversFinding(after, baseline); + const scanner = afterReranDetectingScanner(after, baseline); + const reasons = [scope.reason, scanner.reason].filter((reason): reason is string => Boolean(reason)); return { fingerprint, title: baseline.primary.title, - status: "fixed" as const, - reasons: ["The finding disappeared after a detecting scanner re-ran over the affected scope."], + status: scope.covered && scanner.covered ? "fixed" as const : "inconclusive" as const, + reasons: scope.covered && scanner.covered + ? ["The finding disappeared after its source path and at least one detecting scanner were rechecked."] + : reasons, }; }); @@ -309,8 +356,3 @@ export function verifyRemediation( }, }; } - -export async function writeRemediationVerification(path: string, verification: RemediationVerification): Promise { - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, `${JSON.stringify(verification, null, 2)}\n`, "utf8"); -} From 4238d9bb559273edd5d77e17db234ba0068d6c09 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 12:16:11 -0400 Subject: [PATCH 0303/1132] test(lifecycle): cover bounded restrictive store persistence --- tests/lifecycle.test.mjs | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/lifecycle.test.mjs b/tests/lifecycle.test.mjs index 1aa640bf..8fa59e92 100644 --- a/tests/lifecycle.test.mjs +++ b/tests/lifecycle.test.mjs @@ -1,12 +1,18 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { buildReport } from "../packages/report/dist/index.js"; import { emptyLifecycleStore, + isLifecycleStore, lifecycleSummary, + readLifecycleStore, reconcileLifecycle, setFindingState, verifyRemediation, + writeLifecycleStore, } from "../packages/lifecycle/dist/index.js"; function reportWith(ruleIds, options = {}) { @@ -50,6 +56,50 @@ test("lifecycle creates new findings and preserves explicit triage state", () => assert.equal(next.records[fingerprint].note, "Reviewed by maintainer"); }); +test("lifecycle store validation rejects malformed record shapes", () => { + assert.equal(isLifecycleStore({ schemaVersion: 1, records: {} }), true); + assert.equal(isLifecycleStore({ + schemaVersion: 1, + records: { + abc: { fingerprint: "different", state: "new", updatedAt: "2026-01-01T00:00:00.000Z" }, + }, + }), false); + assert.equal(isLifecycleStore({ + schemaVersion: 1, + records: { + abc: { fingerprint: "abc", state: "unknown", updatedAt: "2026-01-01T00:00:00.000Z" }, + }, + }), false); + assert.equal(isLifecycleStore({ + schemaVersion: 1, + records: { + abc: { fingerprint: "abc", state: "new", updatedAt: "not-a-date" }, + }, + }), false); +}); + +test("lifecycle persistence is restrictive, round-trippable, and rejects corrupt stores", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-lifecycle-")); + const path = join(root, "state", "lifecycle.json"); + try { + const report = reportWith(["A"]); + const store = reconcileLifecycle(report, emptyLifecycleStore(), "2026-01-01T00:00:00.000Z"); + await writeLifecycleStore(path, store); + assert.deepEqual(await readLifecycleStore(path), store); + const serialized = await readFile(path, "utf8"); + assert.equal(JSON.parse(serialized).schemaVersion, 1); + if (process.platform !== "win32") assert.equal((await stat(path)).mode & 0o777, 0o600); + + await writeFile(path, JSON.stringify({ + schemaVersion: 1, + records: { bad: { fingerprint: "mismatch", state: "new", updatedAt: "2026-01-01T00:00:00.000Z" } }, + })); + await assert.rejects(() => readLifecycleStore(path), /Not a supported SynSec lifecycle store/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("lifecycle marks disappeared confirmed findings fixed and returning findings regressed", () => { const initial = reportWith(["A"]); const fingerprint = initial.findings[0].fingerprint; From 8d0c1fe03abf057f75f8858b779894d9132763ad Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 12:17:21 -0400 Subject: [PATCH 0304/1132] fix(lifecycle): restore remediation verification writer --- packages/lifecycle/src/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/lifecycle/src/index.ts b/packages/lifecycle/src/index.ts index 30aa6ca6..90b44e94 100644 --- a/packages/lifecycle/src/index.ts +++ b/packages/lifecycle/src/index.ts @@ -356,3 +356,8 @@ export function verifyRemediation( }, }; } + +export async function writeRemediationVerification(path: string, verification: RemediationVerification): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(verification, null, 2)}\n`, "utf8"); +} From a2f1d700e88d92c2e59c1e39d0dc1c56e84d9784 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 13:11:58 -0400 Subject: [PATCH 0305/1132] feat(github): add durable webhook replay store --- packages/github/src/replay-store.ts | 162 ++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 packages/github/src/replay-store.ts diff --git a/packages/github/src/replay-store.ts b/packages/github/src/replay-store.ts new file mode 100644 index 00000000..53202dda --- /dev/null +++ b/packages/github/src/replay-store.ts @@ -0,0 +1,162 @@ +import { createHash } from "node:crypto"; +import { mkdir, open, readFile, readdir, rm, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; +const MIN_RETENTION_MS = 60 * 60 * 1000; +const MAX_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +const MAX_DELIVERY_ID_LENGTH = 128; +const MAX_RECORD_BYTES = 1024; +const MAX_PRUNE_ENTRIES = 10_000; + +interface ReplayRecord { + version: 1; + deliveryId: string; + receivedAt: string; +} + +export interface GitHubWebhookReplayStoreOptions { + retentionMs?: number; + now?: () => number; +} + +export interface GitHubWebhookDeliveryClaim { + accepted: boolean; + deliveryId: string; + receivedAt: string; +} + +function validatedDeliveryId(value: string): string { + const deliveryId = value.trim(); + if (!deliveryId) throw new Error("GitHub webhook delivery id is required."); + if (deliveryId.length > MAX_DELIVERY_ID_LENGTH) { + throw new Error(`GitHub webhook delivery id exceeds ${MAX_DELIVERY_ID_LENGTH} characters.`); + } + if (!/^[A-Za-z0-9._:-]+$/.test(deliveryId)) { + throw new Error("GitHub webhook delivery id contains unsupported characters."); + } + return deliveryId; +} + +function validatedRetention(value: number | undefined): number { + const retention = value ?? DEFAULT_RETENTION_MS; + if (!Number.isSafeInteger(retention) || retention < MIN_RETENTION_MS || retention > MAX_RETENTION_MS) { + throw new Error(`Webhook replay retention must be an integer between ${MIN_RETENTION_MS} and ${MAX_RETENTION_MS} milliseconds.`); + } + return retention; +} + +function recordPath(directory: string, deliveryId: string): string { + const digest = createHash("sha256").update(deliveryId, "utf8").digest("hex"); + return join(directory, `${digest}.json`); +} + +function parseRecord(text: string, expectedDeliveryId: string): ReplayRecord { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + throw new Error("Stored GitHub webhook replay record is invalid JSON."); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Stored GitHub webhook replay record is invalid."); + } + const record = value as Partial; + if (record.version !== 1 || record.deliveryId !== expectedDeliveryId || typeof record.receivedAt !== "string") { + throw new Error("Stored GitHub webhook replay record has an invalid shape."); + } + const timestamp = Date.parse(record.receivedAt); + if (!Number.isFinite(timestamp)) throw new Error("Stored GitHub webhook replay timestamp is invalid."); + return record as ReplayRecord; +} + +async function readRecord(path: string, expectedDeliveryId: string): Promise { + const metadata = await stat(path); + if (!metadata.isFile() || metadata.size > MAX_RECORD_BYTES) { + throw new Error("Stored GitHub webhook replay record is invalid or oversized."); + } + return parseRecord(await readFile(path, "utf8"), expectedDeliveryId); +} + +/** + * Durable replay protection for GitHub webhook delivery ids. + * + * Claims are created with exclusive file creation, so concurrent processes sharing + * the same store cannot both accept the same delivery. Delivery ids are hashed for + * filenames and never interpreted as paths. Expired claims may be reclaimed after + * the configured bounded retention window. + */ +export class FileGitHubWebhookReplayStore { + readonly directory: string; + readonly retentionMs: number; + private readonly now: () => number; + + constructor(directory: string, options: GitHubWebhookReplayStoreOptions = {}) { + const normalized = directory.trim(); + if (!normalized) throw new Error("Webhook replay-store directory is required."); + this.directory = resolve(normalized); + this.retentionMs = validatedRetention(options.retentionMs); + this.now = options.now ?? Date.now; + } + + async claim(deliveryIdValue: string): Promise { + const deliveryId = validatedDeliveryId(deliveryIdValue); + const now = this.now(); + if (!Number.isFinite(now) || now <= 0) throw new Error("Webhook replay-store clock must be a positive timestamp."); + const receivedAt = new Date(now).toISOString(); + const path = recordPath(this.directory, deliveryId); + await mkdir(this.directory, { recursive: true, mode: 0o700 }); + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const handle = await open(path, "wx", 0o600); + try { + const record: ReplayRecord = { version: 1, deliveryId, receivedAt }; + await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + return { accepted: true, deliveryId, receivedAt }; + } catch (error) { + if (!(error instanceof Error) || !Object.prototype.hasOwnProperty.call(error, "code") || (error as NodeJS.ErrnoException).code !== "EEXIST") { + throw error; + } + + const existing = await readRecord(path, deliveryId); + const existingAt = Date.parse(existing.receivedAt); + if (now - existingAt < this.retentionMs) { + return { accepted: false, deliveryId, receivedAt: existing.receivedAt }; + } + + await rm(path, { force: true }); + } + } + + throw new Error("Unable to claim expired GitHub webhook delivery id safely."); + } + + async pruneExpired(): Promise { + const now = this.now(); + if (!Number.isFinite(now) || now <= 0) throw new Error("Webhook replay-store clock must be a positive timestamp."); + await mkdir(this.directory, { recursive: true, mode: 0o700 }); + const entries = (await readdir(this.directory, { withFileTypes: true })) + .filter((entry) => entry.isFile() && /^[a-f0-9]{64}\.json$/.test(entry.name)) + .slice(0, MAX_PRUNE_ENTRIES); + + let removed = 0; + for (const entry of entries) { + const path = join(this.directory, entry.name); + let metadata; + try { + metadata = await stat(path); + } catch { + continue; + } + if (now - metadata.mtimeMs < this.retentionMs) continue; + await rm(path, { force: true }); + removed += 1; + } + return removed; + } +} From 626b302c9fb75b25be0de733efdb6909ba525e1b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 13:12:05 -0400 Subject: [PATCH 0306/1132] feat(github): export webhook replay store --- packages/github/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/github/package.json b/packages/github/package.json index 6d2c42be..364c6f2b 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -11,7 +11,8 @@ "./sarif-publisher": "./dist/sarif-publisher.js", "./baseline": "./dist/baseline.js", "./base-scan": "./dist/base-scan.js", - "./app": "./dist/app.js" + "./app": "./dist/app.js", + "./replay-store": "./dist/replay-store.js" }, "types": "./dist/index.d.ts", "scripts": { From 63d8cb7fe1ab47a22c9687a724f56ea9c12699a0 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 13:12:16 -0400 Subject: [PATCH 0307/1132] test(github): cover webhook replay protection --- tests/github-replay-store.test.mjs | 76 ++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tests/github-replay-store.test.mjs diff --git a/tests/github-replay-store.test.mjs b/tests/github-replay-store.test.mjs new file mode 100644 index 00000000..38198d48 --- /dev/null +++ b/tests/github-replay-store.test.mjs @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, readdir, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { FileGitHubWebhookReplayStore } from "../packages/github/dist/replay-store.js"; + +const HOUR = 60 * 60 * 1000; + +test("replay store accepts one delivery and rejects a duplicate atomically", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-replay-")); + const now = Date.UTC(2026, 7, 22, 17, 0, 0); + const store = new FileGitHubWebhookReplayStore(directory, { now: () => now, retentionMs: HOUR }); + + const claims = await Promise.all(Array.from({ length: 8 }, () => store.claim("01234567-89ab-cdef-0123-456789abcdef"))); + assert.equal(claims.filter((claim) => claim.accepted).length, 1); + assert.equal(claims.filter((claim) => !claim.accepted).length, 7); + + const files = await readdir(directory); + assert.equal(files.length, 1); + assert.match(files[0], /^[a-f0-9]{64}\.json$/); + assert.equal(files[0].includes("01234567"), false); + + const record = JSON.parse(await readFile(join(directory, files[0]), "utf8")); + assert.equal(record.deliveryId, "01234567-89ab-cdef-0123-456789abcdef"); + assert.equal(record.receivedAt, new Date(now).toISOString()); + if (process.platform !== "win32") { + const mode = (await stat(join(directory, files[0]))).mode & 0o777; + assert.equal(mode, 0o600); + } +}); + +test("replay store permits reuse only after bounded retention expires", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-replay-expiry-")); + let now = Date.UTC(2026, 7, 22, 17, 0, 0); + const store = new FileGitHubWebhookReplayStore(directory, { now: () => now, retentionMs: HOUR }); + + assert.equal((await store.claim("delivery-1")).accepted, true); + now += HOUR - 1; + assert.equal((await store.claim("delivery-1")).accepted, false); + now += 2; + assert.equal((await store.claim("delivery-1")).accepted, true); +}); + +test("replay store validates ids and retention bounds", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-replay-validation-")); + assert.throws(() => new FileGitHubWebhookReplayStore(directory, { retentionMs: HOUR - 1 }), /retention must be an integer/); + + const store = new FileGitHubWebhookReplayStore(directory, { retentionMs: HOUR }); + await assert.rejects(() => store.claim("../delivery"), /unsupported characters/); + await assert.rejects(() => store.claim("x".repeat(129)), /exceeds 128 characters/); +}); + +test("replay store rejects corrupt existing records instead of treating them as duplicates", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-replay-corrupt-")); + const now = Date.UTC(2026, 7, 22, 17, 0, 0); + const store = new FileGitHubWebhookReplayStore(directory, { now: () => now, retentionMs: HOUR }); + await store.claim("delivery-corrupt"); + + const [file] = await readdir(directory); + const { writeFile } = await import("node:fs/promises"); + await writeFile(join(directory, file), "not json", "utf8"); + + await assert.rejects(() => store.claim("delivery-corrupt"), /invalid JSON/); +}); + +test("pruneExpired removes only old replay marker files", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-replay-prune-")); + let now = Date.UTC(2026, 7, 22, 17, 0, 0); + const store = new FileGitHubWebhookReplayStore(directory, { now: () => now, retentionMs: HOUR }); + await store.claim("delivery-old"); + now += HOUR + 1; + assert.equal(await store.pruneExpired(), 1); + assert.deepEqual(await readdir(directory), []); +}); From 10cfbcdd0bf861270c3e1cc6d071f8c378c160f2 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 13:12:37 -0400 Subject: [PATCH 0308/1132] fix(github): make replay claims fully atomic --- packages/github/src/replay-store.ts | 43 +++++++++++++++++------------ 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/github/src/replay-store.ts b/packages/github/src/replay-store.ts index 53202dda..a7450662 100644 --- a/packages/github/src/replay-store.ts +++ b/packages/github/src/replay-store.ts @@ -1,5 +1,5 @@ -import { createHash } from "node:crypto"; -import { mkdir, open, readFile, readdir, rm, stat } from "node:fs/promises"; +import { createHash, randomBytes } from "node:crypto"; +import { link, mkdir, open, readFile, readdir, rm, stat } from "node:fs/promises"; import { join, resolve } from "node:path"; const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; @@ -78,13 +78,19 @@ async function readRecord(path: string, expectedDeliveryId: string): Promise Date: Sat, 22 Aug 2026 13:12:55 -0400 Subject: [PATCH 0309/1132] docs(github): document durable replay protection --- docs/GITHUB_APP.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/GITHUB_APP.md b/docs/GITHUB_APP.md index a0776ebc..f0b33ab6 100644 --- a/docs/GITHUB_APP.md +++ b/docs/GITHUB_APP.md @@ -17,7 +17,9 @@ SynSec's GitHub App support is a transport and orchestration layer around the sa - installation-token exchange only through `https://api.github.com/app/installations//access_tokens` with redirects rejected; - token/API errors that do not echo the App JWT. -These primitives do **not** constitute a hosted GitHub App service by themselves. A server, durable installation state, scan queue/workers, checkout isolation, installation UX, and operational secret management are still required. +`@synsec/github/replay-store` additionally provides a durable local delivery-id replay store suitable for a single host or multiple worker processes sharing one filesystem. It uses bounded delivery identifiers, SHA-256-derived filenames, restrictive marker permissions, fully written/fsynced temporary records, and an atomic hard-link claim so two concurrent processes cannot both accept the same delivery or observe a partially written canonical record. Retention is bounded between one hour and 30 days, expired markers can be pruned, and malformed existing records fail closed instead of being silently ignored. + +These primitives do **not** constitute a hosted GitHub App service by themselves. A server, durable installation state, scan queue/workers, checkout isolation, installation UX, and operational secret management are still required. The local replay store is not a distributed database and should be replaced or wrapped by a transactional shared store when webhook replicas do not share a filesystem. ## Webhook boundary @@ -25,6 +27,8 @@ Webhook consumers should preserve the raw request bytes until signature verifica After verification, callers should use the normalized event rather than payload URLs as the security boundary. In particular, repository checkout or API publication must derive from the validated GitHub installation/repository identity through a fixed GitHub transport. A `clone_url`, `html_url`, scanner-provided URL, finding text, or other repository-controlled field must never become an arbitrary outbound target. +After signature verification and before queueing work, hosted consumers should claim the `X-GitHub-Delivery` value through replay protection. A duplicate claim within the configured retention window must be treated as already processed or already in flight, not as a reason to enqueue a second scan. + Only `shouldScanGitHubAppWebhook()` decides whether a normalized event belongs in the scan queue. Installation creation/removal and repository-selection changes may update installation bookkeeping, but they do not authorize immediate scanner execution by themselves. ## Authentication boundary @@ -40,14 +44,14 @@ A hosted service should validate its configured GitHub App permissions explicitl A production hosted App still needs: 1. a minimal HTTPS webhook endpoint that preserves raw request bytes and calls the verified parser; -2. durable delivery-id deduplication/replay protection; -3. durable installation/repository state without storing installation tokens; -4. a bounded scan job queue and isolated checkout/worker execution; -5. repository acquisition that is installation-scoped and commit-pinned; -6. per-job resource/time limits and credential minimization; -7. publication through the existing report/check/SARIF primitives; -8. explicit retention policy for reports and scan artifacts; -9. installation/setup UX and permission diagnostics; -10. operational rotation for webhook secrets and App private keys. +2. durable installation/repository state without storing installation tokens; +3. a bounded scan job queue and isolated checkout/worker execution; +4. repository acquisition that is installation-scoped and commit-pinned; +5. per-job resource/time limits and credential minimization; +6. publication through the existing report/check/SARIF primitives; +7. explicit retention policy for reports and scan artifacts; +8. installation/setup UX and permission diagnostics; +9. operational rotation for webhook secrets and App private keys; +10. a transactional replay backend when horizontally scaled webhook replicas do not share the same durable filesystem. Until those pieces exist, the GitHub Action remains the complete executable integration path and the App module should be treated as a tested hosting foundation rather than a deployable hosted product. From e56a7d46f90699323d9fe5ba0212aa219e12f1c0 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 13:13:19 -0400 Subject: [PATCH 0310/1132] feat(github): gate app intake through replay claims --- packages/github/src/app-intake.ts | 54 +++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 packages/github/src/app-intake.ts diff --git a/packages/github/src/app-intake.ts b/packages/github/src/app-intake.ts new file mode 100644 index 00000000..ebfde4f9 --- /dev/null +++ b/packages/github/src/app-intake.ts @@ -0,0 +1,54 @@ +import { + parseVerifiedGitHubAppWebhook, + shouldScanGitHubAppWebhook, + type GitHubAppWebhook, +} from "./app.js"; + +export interface GitHubWebhookReplayClaimer { + claim(deliveryId: string): Promise<{ accepted: boolean; deliveryId: string; receivedAt: string }>; +} + +export interface GitHubAppWebhookIntakeResult { + webhook: GitHubAppWebhook; + duplicate: boolean; + shouldScan: boolean; +} + +/** + * Verify, normalize, deduplicate, and classify one GitHub App webhook delivery. + * + * Signature verification intentionally happens before the durable replay claim so + * unauthenticated traffic cannot fill the replay store. A duplicate authenticated + * delivery is returned for idempotent HTTP handling but is never scan-eligible. + */ +export async function intakeGitHubAppWebhook(input: { + body: string | Uint8Array; + signatureHeader?: string; + webhookSecret: string; + eventName: string; + deliveryId: string; + replayStore: GitHubWebhookReplayClaimer; +}): Promise { + const deliveryId = input.deliveryId.trim(); + if (!deliveryId) throw new Error("GitHub webhook delivery id is required for replay protection."); + + const webhook = parseVerifiedGitHubAppWebhook({ + body: input.body, + signatureHeader: input.signatureHeader, + webhookSecret: input.webhookSecret, + eventName: input.eventName, + deliveryId, + }); + + const claim = await input.replayStore.claim(deliveryId); + if (claim.deliveryId !== deliveryId) { + throw new Error("Webhook replay store returned a mismatched delivery id."); + } + + const duplicate = !claim.accepted; + return { + webhook, + duplicate, + shouldScan: !duplicate && shouldScanGitHubAppWebhook(webhook), + }; +} From 6d727ee11ab744c939ad5d228b09c6cc731b288b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 13:13:26 -0400 Subject: [PATCH 0311/1132] feat(github): export app intake primitive --- packages/github/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/github/package.json b/packages/github/package.json index 364c6f2b..ffb16921 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -12,6 +12,7 @@ "./baseline": "./dist/baseline.js", "./base-scan": "./dist/base-scan.js", "./app": "./dist/app.js", + "./app-intake": "./dist/app-intake.js", "./replay-store": "./dist/replay-store.js" }, "types": "./dist/index.d.ts", From 7a1295347baf0bc273b47f22b8fedb1b92d94078 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 13:13:38 -0400 Subject: [PATCH 0312/1132] test(github): cover verified deduplicated app intake --- tests/github-app-intake.test.mjs | 112 +++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/github-app-intake.test.mjs diff --git a/tests/github-app-intake.test.mjs b/tests/github-app-intake.test.mjs new file mode 100644 index 00000000..9453ada5 --- /dev/null +++ b/tests/github-app-intake.test.mjs @@ -0,0 +1,112 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; + +import { intakeGitHubAppWebhook } from "../packages/github/dist/app-intake.js"; + +const webhookSecret = "synsec-webhook-secret"; + +function signature(body) { + return `sha256=${createHmac("sha256", webhookSecret).update(body).digest("hex")}`; +} + +function pullRequestBody(action = "synchronize") { + return Buffer.from(JSON.stringify({ + action, + installation: { id: 42 }, + repository: { full_name: "cmahmud/synsec" }, + number: 7, + pull_request: { + head: { sha: "abc123" }, + base: { sha: "def456" }, + }, + })); +} + +test("app intake verifies before touching replay state", async () => { + let claims = 0; + const replayStore = { + async claim(deliveryId) { + claims += 1; + return { accepted: true, deliveryId, receivedAt: new Date().toISOString() }; + }, + }; + const body = pullRequestBody(); + + await assert.rejects(() => intakeGitHubAppWebhook({ + body, + signatureHeader: "sha256=0000000000000000000000000000000000000000000000000000000000000000", + webhookSecret, + eventName: "pull_request", + deliveryId: "delivery-1", + replayStore, + }), /signature verification failed/); + assert.equal(claims, 0); +}); + +test("app intake makes the first scannable delivery eligible and suppresses duplicates", async () => { + const seen = new Set(); + const replayStore = { + async claim(deliveryId) { + const accepted = !seen.has(deliveryId); + seen.add(deliveryId); + return { accepted, deliveryId, receivedAt: "2026-08-22T17:00:00.000Z" }; + }, + }; + const body = pullRequestBody(); + const input = { + body, + signatureHeader: signature(body), + webhookSecret, + eventName: "pull_request", + deliveryId: "delivery-1", + replayStore, + }; + + const first = await intakeGitHubAppWebhook(input); + assert.equal(first.duplicate, false); + assert.equal(first.shouldScan, true); + assert.equal(first.webhook.deliveryId, "delivery-1"); + + const duplicate = await intakeGitHubAppWebhook(input); + assert.equal(duplicate.duplicate, true); + assert.equal(duplicate.shouldScan, false); +}); + +test("app intake deduplicates supported bookkeeping events without scanning them", async () => { + const body = Buffer.from(JSON.stringify({ action: "created", installation: { id: 42 } })); + const replayStore = { + async claim(deliveryId) { + return { accepted: true, deliveryId, receivedAt: "2026-08-22T17:00:00.000Z" }; + }, + }; + + const result = await intakeGitHubAppWebhook({ + body, + signatureHeader: signature(body), + webhookSecret, + eventName: "installation", + deliveryId: "delivery-installation", + replayStore, + }); + assert.equal(result.duplicate, false); + assert.equal(result.shouldScan, false); +}); + +test("app intake fails closed if replay storage returns a different delivery identity", async () => { + const body = pullRequestBody(); + const replayStore = { + async claim() { + return { accepted: true, deliveryId: "wrong-delivery", receivedAt: "2026-08-22T17:00:00.000Z" }; + }, + }; + + await assert.rejects(() => intakeGitHubAppWebhook({ + body, + signatureHeader: signature(body), + webhookSecret, + eventName: "pull_request", + deliveryId: "delivery-1", + replayStore, + }), /mismatched delivery id/); +}); From 01d98f5ac3205598ee89d0eadf7086bd37d882cb Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 13:14:10 -0400 Subject: [PATCH 0313/1132] fix(github): prune replay markers by recorded timestamp --- packages/github/src/replay-store.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/github/src/replay-store.ts b/packages/github/src/replay-store.ts index a7450662..c8b931e9 100644 --- a/packages/github/src/replay-store.ts +++ b/packages/github/src/replay-store.ts @@ -51,7 +51,7 @@ function recordPath(directory: string, deliveryId: string): string { return join(directory, `${digest}.json`); } -function parseRecord(text: string, expectedDeliveryId: string): ReplayRecord { +function parseRecord(text: string, expectedDeliveryId?: string): ReplayRecord { let value: unknown; try { value = JSON.parse(text); @@ -62,15 +62,19 @@ function parseRecord(text: string, expectedDeliveryId: string): ReplayRecord { throw new Error("Stored GitHub webhook replay record is invalid."); } const record = value as Partial; - if (record.version !== 1 || record.deliveryId !== expectedDeliveryId || typeof record.receivedAt !== "string") { + if (record.version !== 1 || typeof record.deliveryId !== "string" || typeof record.receivedAt !== "string") { + throw new Error("Stored GitHub webhook replay record has an invalid shape."); + } + const deliveryId = validatedDeliveryId(record.deliveryId); + if (expectedDeliveryId !== undefined && deliveryId !== expectedDeliveryId) { throw new Error("Stored GitHub webhook replay record has an invalid shape."); } const timestamp = Date.parse(record.receivedAt); if (!Number.isFinite(timestamp)) throw new Error("Stored GitHub webhook replay timestamp is invalid."); - return record as ReplayRecord; + return { version: 1, deliveryId, receivedAt: record.receivedAt }; } -async function readRecord(path: string, expectedDeliveryId: string): Promise { +async function readRecord(path: string, expectedDeliveryId?: string): Promise { const metadata = await stat(path); if (!metadata.isFile() || metadata.size > MAX_RECORD_BYTES) { throw new Error("Stored GitHub webhook replay record is invalid or oversized."); @@ -156,13 +160,11 @@ export class FileGitHubWebhookReplayStore { let removed = 0; for (const entry of entries) { const path = join(this.directory, entry.name); - let metadata; - try { - metadata = await stat(path); - } catch { - continue; + const record = await readRecord(path); + if (recordPath(this.directory, record.deliveryId) !== path) { + throw new Error("Stored GitHub webhook replay record does not match its delivery-id filename."); } - if (now - metadata.mtimeMs < this.retentionMs) continue; + if (now - Date.parse(record.receivedAt) < this.retentionMs) continue; await rm(path, { force: true }); removed += 1; } From d41b26481d3ff05d5c74edf174c1869e2ed7b946 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:11:00 -0400 Subject: [PATCH 0314/1132] feat(github): persist bounded installation authorization state --- packages/github/package.json | 3 +- packages/github/src/installation-store.ts | 206 ++++++++++++++++++++++ tests/github-installation-store.test.mjs | 93 ++++++++++ 3 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 packages/github/src/installation-store.ts create mode 100644 tests/github-installation-store.test.mjs diff --git a/packages/github/package.json b/packages/github/package.json index ffb16921..f90086ec 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -13,7 +13,8 @@ "./base-scan": "./dist/base-scan.js", "./app": "./dist/app.js", "./app-intake": "./dist/app-intake.js", - "./replay-store": "./dist/replay-store.js" + "./replay-store": "./dist/replay-store.js", + "./installation-store": "./dist/installation-store.js" }, "types": "./dist/index.d.ts", "scripts": { diff --git a/packages/github/src/installation-store.ts b/packages/github/src/installation-store.ts new file mode 100644 index 00000000..749d3ef3 --- /dev/null +++ b/packages/github/src/installation-store.ts @@ -0,0 +1,206 @@ +import { mkdir, open, readFile, readdir, rename, rm, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { randomBytes } from "node:crypto"; + +const MAX_RECORD_BYTES = 16 * 1024; +const MAX_LIST_ENTRIES = 10_000; +const MAX_LOGIN_LENGTH = 255; +const MAX_REPOSITORY_COUNT = 10_000; +const MAX_REPOSITORY_LENGTH = 255; + +export type GitHubInstallationAccountType = "User" | "Organization"; +export type GitHubRepositorySelection = "all" | "selected"; + +export interface GitHubInstallationRecord { + version: 1; + installationId: number; + accountLogin: string; + accountType: GitHubInstallationAccountType; + repositorySelection: GitHubRepositorySelection; + repositories: string[]; + suspendedAt?: string; + updatedAt: string; +} + +export interface GitHubInstallationRecordInput { + installationId: number; + accountLogin: string; + accountType: GitHubInstallationAccountType; + repositorySelection: GitHubRepositorySelection; + repositories?: string[]; + suspendedAt?: string; + updatedAt?: string; +} + +function positiveInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer.`); + } + return value; +} + +function boundedString(value: unknown, label: string, maxLength: number): string { + if (typeof value !== "string") throw new Error(`${label} must be a string.`); + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + if (normalized.length > maxLength) throw new Error(`${label} exceeds ${maxLength} characters.`); + return normalized; +} + +function repositoryName(value: unknown): string { + const repository = boundedString(value, "GitHub repository", MAX_REPOSITORY_LENGTH); + if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) throw new Error("GitHub repository must be in owner/name form."); + return repository; +} + +function timestamp(value: unknown, label: string): string { + const normalized = boundedString(value, label, 64); + if (!Number.isFinite(Date.parse(normalized))) throw new Error(`${label} must be an ISO timestamp.`); + return normalized; +} + +function normalize(input: GitHubInstallationRecordInput | GitHubInstallationRecord): GitHubInstallationRecord { + const installationId = positiveInteger(input.installationId, "GitHub installation id"); + const accountLogin = boundedString(input.accountLogin, "GitHub account login", MAX_LOGIN_LENGTH); + if (input.accountType !== "User" && input.accountType !== "Organization") { + throw new Error("GitHub installation account type must be User or Organization."); + } + if (input.repositorySelection !== "all" && input.repositorySelection !== "selected") { + throw new Error("GitHub repository selection must be all or selected."); + } + const sourceRepositories = input.repositories ?? []; + if (!Array.isArray(sourceRepositories) || sourceRepositories.length > MAX_REPOSITORY_COUNT) { + throw new Error(`GitHub installation repositories exceed the ${MAX_REPOSITORY_COUNT}-entry limit.`); + } + const repositories = [...new Set(sourceRepositories.map(repositoryName))].sort(); + if (input.repositorySelection === "all" && repositories.length > 0) { + throw new Error("GitHub installations with repositorySelection=all must not persist an enumerated repository list."); + } + const updatedAt = timestamp(input.updatedAt ?? new Date().toISOString(), "GitHub installation updatedAt"); + const suspendedAt = input.suspendedAt === undefined ? undefined : timestamp(input.suspendedAt, "GitHub installation suspendedAt"); + return { + version: 1, + installationId, + accountLogin, + accountType: input.accountType, + repositorySelection: input.repositorySelection, + repositories, + ...(suspendedAt ? { suspendedAt } : {}), + updatedAt, + }; +} + +function recordPath(directory: string, installationId: number): string { + return join(directory, `${installationId}.json`); +} + +async function readRecord(path: string): Promise { + const metadata = await stat(path); + if (!metadata.isFile() || metadata.size > MAX_RECORD_BYTES) { + throw new Error("Stored GitHub installation record is invalid or oversized."); + } + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(path, "utf8")); + } catch { + throw new Error("Stored GitHub installation record is invalid JSON."); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Stored GitHub installation record has an invalid shape."); + } + const record = parsed as Partial; + if (record.version !== 1) throw new Error("Stored GitHub installation record has an unsupported version."); + return normalize(record as GitHubInstallationRecord); +} + +function isNotFound(error: unknown): boolean { + return error instanceof Error + && Object.prototype.hasOwnProperty.call(error, "code") + && (error as NodeJS.ErrnoException).code === "ENOENT"; +} + +/** + * Minimal durable GitHub App installation state. + * + * Tokens, private keys, webhook secrets, clone URLs, and repository credentials are + * intentionally not part of this schema. Selected-repository installations persist + * only validated owner/name identifiers required to decide whether SynSec may scan. + */ +export class FileGitHubInstallationStore { + readonly directory: string; + + constructor(directory: string) { + const normalized = directory.trim(); + if (!normalized) throw new Error("GitHub installation-store directory is required."); + this.directory = resolve(normalized); + } + + async put(input: GitHubInstallationRecordInput): Promise { + const record = normalize(input); + await mkdir(this.directory, { recursive: true, mode: 0o700 }); + const path = recordPath(this.directory, record.installationId); + const tempPath = join(this.directory, `.installation-${record.installationId}-${randomBytes(12).toString("hex")}.tmp`); + const handle = await open(tempPath, "wx", 0o600); + try { + await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await rename(tempPath, path); + } finally { + await rm(tempPath, { force: true }); + } + return record; + } + + async get(installationIdValue: number): Promise { + const installationId = positiveInteger(installationIdValue, "GitHub installation id"); + try { + const record = await readRecord(recordPath(this.directory, installationId)); + if (record.installationId !== installationId) throw new Error("Stored GitHub installation id does not match its filename."); + return record; + } catch (error) { + if (isNotFound(error)) return undefined; + throw error; + } + } + + async remove(installationIdValue: number): Promise { + const installationId = positiveInteger(installationIdValue, "GitHub installation id"); + const path = recordPath(this.directory, installationId); + try { + await stat(path); + } catch (error) { + if (isNotFound(error)) return false; + throw error; + } + await rm(path); + return true; + } + + async list(): Promise { + await mkdir(this.directory, { recursive: true, mode: 0o700 }); + const entries = (await readdir(this.directory, { withFileTypes: true })) + .filter((entry) => entry.isFile() && /^\d+\.json$/.test(entry.name)); + if (entries.length > MAX_LIST_ENTRIES) throw new Error(`GitHub installation store exceeds the ${MAX_LIST_ENTRIES}-entry limit.`); + const records: GitHubInstallationRecord[] = []; + for (const entry of entries) { + const id = Number(entry.name.slice(0, -5)); + const record = await readRecord(join(this.directory, entry.name)); + if (!Number.isSafeInteger(id) || id <= 0 || record.installationId !== id) { + throw new Error("Stored GitHub installation id does not match its filename."); + } + records.push(record); + } + return records.sort((a, b) => a.installationId - b.installationId); + } + + async isRepositoryAllowed(installationIdValue: number, repositoryValue: string): Promise { + const record = await this.get(installationIdValue); + if (!record || record.suspendedAt) return false; + const repository = repositoryName(repositoryValue); + return record.repositorySelection === "all" || record.repositories.includes(repository); + } +} diff --git a/tests/github-installation-store.test.mjs b/tests/github-installation-store.test.mjs new file mode 100644 index 00000000..5a50eb1a --- /dev/null +++ b/tests/github-installation-store.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { FileGitHubInstallationStore } from "@synsec/github/installation-store"; + +async function withStore(fn) { + const directory = await mkdtemp(join(tmpdir(), "synsec-installations-")); + return fn(new FileGitHubInstallationStore(directory), directory); +} + +test("installation store persists only bounded authorization metadata", async () => { + await withStore(async (store, directory) => { + const record = await store.put({ + installationId: 42, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["example-org/b", "example-org/a", "example-org/a"], + updatedAt: "2026-08-22T18:00:00.000Z", + }); + assert.deepEqual(record.repositories, ["example-org/a", "example-org/b"]); + assert.equal(await store.isRepositoryAllowed(42, "example-org/a"), true); + assert.equal(await store.isRepositoryAllowed(42, "example-org/c"), false); + + const stored = await readFile(join(directory, "42.json"), "utf8"); + assert.equal(stored.includes("token"), false); + assert.equal(stored.includes("privateKey"), false); + assert.equal(stored.includes("clone_url"), false); + if (process.platform !== "win32") assert.equal((await stat(join(directory, "42.json"))).mode & 0o777, 0o600); + }); +}); + +test("all-repository installations do not persist an enumerated target list", async () => { + await withStore(async (store) => { + await assert.rejects(() => store.put({ + installationId: 1, + accountLogin: "owner", + accountType: "User", + repositorySelection: "all", + repositories: ["owner/repo"], + }), /must not persist/); + + await store.put({ + installationId: 1, + accountLogin: "owner", + accountType: "User", + repositorySelection: "all", + }); + assert.equal(await store.isRepositoryAllowed(1, "owner/anything"), true); + }); +}); + +test("suspended and removed installations cannot authorize scans", async () => { + await withStore(async (store) => { + await store.put({ + installationId: 9, + accountLogin: "example", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["example/repo"], + suspendedAt: "2026-08-22T18:01:00.000Z", + }); + assert.equal(await store.isRepositoryAllowed(9, "example/repo"), false); + assert.equal(await store.remove(9), true); + assert.equal(await store.remove(9), false); + assert.equal(await store.get(9), undefined); + }); +}); + +test("installation store fails closed on corrupt or mismatched records", async () => { + await withStore(async (store, directory) => { + await writeFile(join(directory, "7.json"), JSON.stringify({ + version: 1, + installationId: 8, + accountLogin: "example", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["example/repo"], + updatedAt: "2026-08-22T18:00:00.000Z", + })); + await assert.rejects(() => store.get(7), /does not match/); + }); +}); + +test("installation listing is deterministic", async () => { + await withStore(async (store) => { + await store.put({ installationId: 20, accountLogin: "b", accountType: "User", repositorySelection: "selected", repositories: [] }); + await store.put({ installationId: 3, accountLogin: "a", accountType: "User", repositorySelection: "selected", repositories: [] }); + assert.deepEqual((await store.list()).map((entry) => entry.installationId), [3, 20]); + }); +}); From 6ad2f2076956c60b8c6fdb818230ffe1f1f7228d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:12:15 -0400 Subject: [PATCH 0315/1132] feat(github): add bounded durable scan job queue --- packages/github/package.json | 3 +- packages/github/src/scan-queue.ts | 287 ++++++++++++++++++++++++++++++ tests/github-scan-queue.test.mjs | 73 ++++++++ 3 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 packages/github/src/scan-queue.ts create mode 100644 tests/github-scan-queue.test.mjs diff --git a/packages/github/package.json b/packages/github/package.json index f90086ec..a0c49183 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -14,7 +14,8 @@ "./app": "./dist/app.js", "./app-intake": "./dist/app-intake.js", "./replay-store": "./dist/replay-store.js", - "./installation-store": "./dist/installation-store.js" + "./installation-store": "./dist/installation-store.js", + "./scan-queue": "./dist/scan-queue.js" }, "types": "./dist/index.d.ts", "scripts": { diff --git a/packages/github/src/scan-queue.ts b/packages/github/src/scan-queue.ts new file mode 100644 index 00000000..5dff09fa --- /dev/null +++ b/packages/github/src/scan-queue.ts @@ -0,0 +1,287 @@ +import { randomBytes } from "node:crypto"; +import { mkdir, open, readFile, readdir, rename, rm, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +const MAX_JOB_BYTES = 16 * 1024; +const MAX_QUEUE_ENTRIES = 10_000; +const DEFAULT_LEASE_MS = 5 * 60 * 1000; +const MIN_LEASE_MS = 10_000; +const MAX_LEASE_MS = 60 * 60 * 1000; +const MAX_ATTEMPTS = 5; + +export type GitHubScanJobEvent = "push" | "pull_request"; +export type GitHubScanJobStatus = "pending" | "leased" | "failed"; + +export interface GitHubScanJobInput { + deliveryId: string; + installationId: number; + repository: string; + headSha: string; + event: GitHubScanJobEvent; + baseSha?: string; + pullRequestNumber?: number; + createdAt?: string; +} + +export interface GitHubScanJob { + version: 1; + jobId: string; + deliveryId: string; + installationId: number; + repository: string; + headSha: string; + event: GitHubScanJobEvent; + baseSha?: string; + pullRequestNumber?: number; + createdAt: string; + attempts: number; + status: GitHubScanJobStatus; + leaseUntil?: string; +} + +export interface GitHubScanQueueOptions { + leaseMs?: number; + now?: () => number; +} + +function boundedString(value: unknown, label: string, maxLength: number): string { + if (typeof value !== "string") throw new Error(`${label} must be a string.`); + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + if (normalized.length > maxLength) throw new Error(`${label} exceeds ${maxLength} characters.`); + return normalized; +} + +function positiveInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer.`); + return value; +} + +function repositoryName(value: unknown): string { + const repository = boundedString(value, "GitHub repository", 255); + if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) throw new Error("GitHub repository must be in owner/name form."); + return repository; +} + +function sha(value: unknown, label: string): string { + const normalized = boundedString(value, label, 64).toLowerCase(); + if (!/^[a-f0-9]{40,64}$/.test(normalized)) throw new Error(`${label} must be a hexadecimal commit SHA.`); + return normalized; +} + +function timestamp(value: unknown, label: string): string { + const normalized = boundedString(value, label, 64); + if (!Number.isFinite(Date.parse(normalized))) throw new Error(`${label} must be an ISO timestamp.`); + return normalized; +} + +function deliveryId(value: unknown): string { + const normalized = boundedString(value, "GitHub delivery id", 128); + if (!/^[A-Za-z0-9._:-]+$/.test(normalized)) throw new Error("GitHub delivery id contains unsupported characters."); + return normalized; +} + +function jobId(value: unknown): string { + const normalized = boundedString(value, "GitHub scan job id", 64); + if (!/^[a-f0-9]{32}$/.test(normalized)) throw new Error("GitHub scan job id is invalid."); + return normalized; +} + +function leaseMs(value: number | undefined): number { + const lease = value ?? DEFAULT_LEASE_MS; + if (!Number.isSafeInteger(lease) || lease < MIN_LEASE_MS || lease > MAX_LEASE_MS) { + throw new Error(`GitHub scan job lease must be between ${MIN_LEASE_MS} and ${MAX_LEASE_MS} milliseconds.`); + } + return lease; +} + +function validateJob(value: unknown): GitHubScanJob { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Stored GitHub scan job has an invalid shape."); + const record = value as Partial; + if (record.version !== 1) throw new Error("Stored GitHub scan job has an unsupported version."); + const event = record.event; + if (event !== "push" && event !== "pull_request") throw new Error("Stored GitHub scan job has an invalid event type."); + const status = record.status; + if (status !== "pending" && status !== "leased" && status !== "failed") throw new Error("Stored GitHub scan job has an invalid status."); + const attempts = typeof record.attempts === "number" && Number.isSafeInteger(record.attempts) && record.attempts >= 0 && record.attempts <= MAX_ATTEMPTS + ? record.attempts + : undefined; + if (attempts === undefined) throw new Error("Stored GitHub scan job has an invalid attempt count."); + const pullRequestNumber = record.pullRequestNumber === undefined ? undefined : positiveInteger(record.pullRequestNumber, "GitHub pull request number"); + const baseSha = record.baseSha === undefined ? undefined : sha(record.baseSha, "GitHub base SHA"); + if (event === "pull_request" && (!baseSha || !pullRequestNumber)) throw new Error("Pull request scan jobs require base SHA and pull request number."); + if (event === "push" && (baseSha || pullRequestNumber)) throw new Error("Push scan jobs must not contain pull request metadata."); + const leaseUntil = record.leaseUntil === undefined ? undefined : timestamp(record.leaseUntil, "GitHub scan job leaseUntil"); + if (status === "leased" && !leaseUntil) throw new Error("Leased GitHub scan jobs require leaseUntil."); + if (status !== "leased" && leaseUntil) throw new Error("Only leased GitHub scan jobs may contain leaseUntil."); + return { + version: 1, + jobId: jobId(record.jobId), + deliveryId: deliveryId(record.deliveryId), + installationId: positiveInteger(record.installationId, "GitHub installation id"), + repository: repositoryName(record.repository), + headSha: sha(record.headSha, "GitHub head SHA"), + event, + ...(baseSha ? { baseSha } : {}), + ...(pullRequestNumber ? { pullRequestNumber } : {}), + createdAt: timestamp(record.createdAt, "GitHub scan job createdAt"), + attempts, + status, + ...(leaseUntil ? { leaseUntil } : {}), + }; +} + +function pathFor(directory: string, id: string): string { + return join(directory, `${jobId(id)}.json`); +} + +async function readJob(path: string): Promise { + const metadata = await stat(path); + if (!metadata.isFile() || metadata.size > MAX_JOB_BYTES) throw new Error("Stored GitHub scan job is invalid or oversized."); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(path, "utf8")); + } catch { + throw new Error("Stored GitHub scan job is invalid JSON."); + } + return validateJob(parsed); +} + +async function writeJob(directory: string, record: GitHubScanJob): Promise { + await mkdir(directory, { recursive: true, mode: 0o700 }); + const path = pathFor(directory, record.jobId); + const tempPath = join(directory, `.job-${record.jobId}-${randomBytes(8).toString("hex")}.tmp`); + const handle = await open(tempPath, "wx", 0o600); + try { + await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await rename(tempPath, path); + } finally { + await rm(tempPath, { force: true }); + } +} + +function isNotFound(error: unknown): boolean { + return error instanceof Error && Object.prototype.hasOwnProperty.call(error, "code") && (error as NodeJS.ErrnoException).code === "ENOENT"; +} + +/** Durable bounded local queue for commit-pinned GitHub App scan work. */ +export class FileGitHubScanQueue { + readonly directory: string; + readonly leaseMs: number; + private readonly now: () => number; + + constructor(directory: string, options: GitHubScanQueueOptions = {}) { + const normalized = directory.trim(); + if (!normalized) throw new Error("GitHub scan-queue directory is required."); + this.directory = resolve(normalized); + this.leaseMs = leaseMs(options.leaseMs); + this.now = options.now ?? Date.now; + } + + async enqueue(input: GitHubScanJobInput): Promise { + const now = this.now(); + if (!Number.isFinite(now) || now <= 0) throw new Error("GitHub scan-queue clock must be a positive timestamp."); + const event = input.event; + if (event !== "push" && event !== "pull_request") throw new Error("GitHub scan job event must be push or pull_request."); + const candidate = validateJob({ + version: 1, + jobId: randomBytes(16).toString("hex"), + deliveryId: input.deliveryId, + installationId: input.installationId, + repository: input.repository, + headSha: input.headSha, + event, + ...(input.baseSha ? { baseSha: input.baseSha } : {}), + ...(input.pullRequestNumber ? { pullRequestNumber: input.pullRequestNumber } : {}), + createdAt: input.createdAt ?? new Date(now).toISOString(), + attempts: 0, + status: "pending", + }); + const existing = await this.list(); + if (existing.some((job) => job.deliveryId === candidate.deliveryId)) throw new Error("GitHub delivery id is already queued."); + if (existing.length >= MAX_QUEUE_ENTRIES) throw new Error(`GitHub scan queue reached the ${MAX_QUEUE_ENTRIES}-job limit.`); + await writeJob(this.directory, candidate); + return candidate; + } + + async list(): Promise { + await mkdir(this.directory, { recursive: true, mode: 0o700 }); + const entries = (await readdir(this.directory, { withFileTypes: true })).filter((entry) => entry.isFile() && /^[a-f0-9]{32}\.json$/.test(entry.name)); + if (entries.length > MAX_QUEUE_ENTRIES) throw new Error(`GitHub scan queue exceeds the ${MAX_QUEUE_ENTRIES}-job limit.`); + const jobs: GitHubScanJob[] = []; + for (const entry of entries) { + const record = await readJob(join(this.directory, entry.name)); + if (`${record.jobId}.json` !== entry.name) throw new Error("Stored GitHub scan job id does not match its filename."); + jobs.push(record); + } + return jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.jobId.localeCompare(b.jobId)); + } + + async claimNext(): Promise { + const now = this.now(); + if (!Number.isFinite(now) || now <= 0) throw new Error("GitHub scan-queue clock must be a positive timestamp."); + const jobs = await this.list(); + const candidate = jobs.find((job) => job.status === "pending" || (job.status === "leased" && Date.parse(job.leaseUntil ?? "") <= now)); + if (!candidate) return undefined; + if (candidate.attempts >= MAX_ATTEMPTS) { + const failed = { ...candidate, status: "failed" as const }; + delete failed.leaseUntil; + await writeJob(this.directory, failed); + return this.claimNext(); + } + const leased: GitHubScanJob = { + ...candidate, + attempts: candidate.attempts + 1, + status: "leased", + leaseUntil: new Date(now + this.leaseMs).toISOString(), + }; + await writeJob(this.directory, leased); + return leased; + } + + async release(jobIdValue: string): Promise { + const current = await this.require(jobIdValue); + if (current.status !== "leased") throw new Error("Only leased GitHub scan jobs can be released."); + const pending: GitHubScanJob = { ...current, status: "pending" }; + delete pending.leaseUntil; + await writeJob(this.directory, pending); + return pending; + } + + async fail(jobIdValue: string): Promise { + const current = await this.require(jobIdValue); + const failed: GitHubScanJob = { ...current, status: "failed" }; + delete failed.leaseUntil; + await writeJob(this.directory, failed); + return failed; + } + + async complete(jobIdValue: string): Promise { + const id = jobId(jobIdValue); + try { + await stat(pathFor(this.directory, id)); + } catch (error) { + if (isNotFound(error)) return false; + throw error; + } + await rm(pathFor(this.directory, id)); + return true; + } + + private async require(jobIdValue: string): Promise { + const id = jobId(jobIdValue); + try { + const record = await readJob(pathFor(this.directory, id)); + if (record.jobId !== id) throw new Error("Stored GitHub scan job id does not match its filename."); + return record; + } catch (error) { + if (isNotFound(error)) throw new Error("GitHub scan job was not found."); + throw error; + } + } +} diff --git a/tests/github-scan-queue.test.mjs b/tests/github-scan-queue.test.mjs new file mode 100644 index 00000000..85c577a8 --- /dev/null +++ b/tests/github-scan-queue.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { FileGitHubScanQueue } from "@synsec/github/scan-queue"; + +async function setup(options = {}) { + const directory = await mkdtemp(join(tmpdir(), "synsec-queue-")); + return { directory, queue: new FileGitHubScanQueue(directory, options) }; +} + +test("scan queue persists commit-pinned repository jobs without credentials", async () => { + const { directory, queue } = await setup({ now: () => Date.parse("2026-08-22T18:20:00.000Z") }); + const job = await queue.enqueue({ + deliveryId: "delivery-1", + installationId: 44, + repository: "example/repo", + headSha: "a".repeat(40), + event: "pull_request", + baseSha: "b".repeat(40), + pullRequestNumber: 12, + }); + const raw = await readFile(join(directory, `${job.jobId}.json`), "utf8"); + assert.equal(raw.includes("token"), false); + assert.equal(raw.includes("clone_url"), false); + assert.equal(raw.includes("privateKey"), false); + if (process.platform !== "win32") assert.equal((await stat(join(directory, `${job.jobId}.json`))).mode & 0o777, 0o600); +}); + +test("scan queue leases, releases, and completes work deterministically", async () => { + let now = Date.parse("2026-08-22T18:20:00.000Z"); + const { queue } = await setup({ now: () => now, leaseMs: 10_000 }); + const first = await queue.enqueue({ deliveryId: "a", installationId: 1, repository: "o/a", headSha: "a".repeat(40), event: "push" }); + now += 1; + await queue.enqueue({ deliveryId: "b", installationId: 1, repository: "o/b", headSha: "b".repeat(40), event: "push" }); + const leased = await queue.claimNext(); + assert.equal(leased.jobId, first.jobId); + assert.equal(leased.attempts, 1); + await queue.release(leased.jobId); + assert.equal((await queue.claimNext()).jobId, first.jobId); + assert.equal(await queue.complete(first.jobId), true); + assert.equal((await queue.claimNext()).repository, "o/b"); +}); + +test("expired leases can be reclaimed but active leases cannot", async () => { + let now = Date.parse("2026-08-22T18:20:00.000Z"); + const { queue } = await setup({ now: () => now, leaseMs: 10_000 }); + await queue.enqueue({ deliveryId: "lease", installationId: 2, repository: "o/r", headSha: "c".repeat(40), event: "push" }); + const first = await queue.claimNext(); + assert.equal(await queue.claimNext(), undefined); + now += 10_001; + const second = await queue.claimNext(); + assert.equal(second.jobId, first.jobId); + assert.equal(second.attempts, 2); +}); + +test("queue rejects duplicate deliveries and malformed PR jobs", async () => { + const { queue } = await setup(); + await queue.enqueue({ deliveryId: "same", installationId: 3, repository: "o/r", headSha: "d".repeat(40), event: "push" }); + await assert.rejects(() => queue.enqueue({ deliveryId: "same", installationId: 3, repository: "o/r2", headSha: "e".repeat(40), event: "push" }), /already queued/); + await assert.rejects(() => queue.enqueue({ deliveryId: "pr", installationId: 3, repository: "o/r", headSha: "e".repeat(40), event: "pull_request" }), /require base SHA/); +}); + +test("failed jobs are retained and not claimed again", async () => { + const { queue } = await setup(); + await queue.enqueue({ deliveryId: "fail", installationId: 4, repository: "o/r", headSha: "f".repeat(40), event: "push" }); + const leased = await queue.claimNext(); + const failed = await queue.fail(leased.jobId); + assert.equal(failed.status, "failed"); + assert.equal(await queue.claimNext(), undefined); + assert.equal((await queue.list())[0].status, "failed"); +}); From 639bbd966c07b0ce7fa8ab01577df5850a7a174a Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:12:39 -0400 Subject: [PATCH 0316/1132] docs(github): document installation state and scan queue boundaries --- docs/GITHUB_APP.md | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/GITHUB_APP.md b/docs/GITHUB_APP.md index f0b33ab6..33ef9d5b 100644 --- a/docs/GITHUB_APP.md +++ b/docs/GITHUB_APP.md @@ -17,9 +17,13 @@ SynSec's GitHub App support is a transport and orchestration layer around the sa - installation-token exchange only through `https://api.github.com/app/installations//access_tokens` with redirects rejected; - token/API errors that do not echo the App JWT. -`@synsec/github/replay-store` additionally provides a durable local delivery-id replay store suitable for a single host or multiple worker processes sharing one filesystem. It uses bounded delivery identifiers, SHA-256-derived filenames, restrictive marker permissions, fully written/fsynced temporary records, and an atomic hard-link claim so two concurrent processes cannot both accept the same delivery or observe a partially written canonical record. Retention is bounded between one hour and 30 days, expired markers can be pruned, and malformed existing records fail closed instead of being silently ignored. +`@synsec/github/replay-store` provides a durable local delivery-id replay store suitable for a single host or multiple worker processes sharing one filesystem. It uses bounded delivery identifiers, SHA-256-derived filenames, restrictive marker permissions, fully written/fsynced temporary records, and an atomic hard-link claim so two concurrent processes cannot both accept the same delivery or observe a partially written canonical record. Retention is bounded between one hour and 30 days, expired markers can be pruned, and malformed existing records fail closed instead of being silently ignored. -These primitives do **not** constitute a hosted GitHub App service by themselves. A server, durable installation state, scan queue/workers, checkout isolation, installation UX, and operational secret management are still required. The local replay store is not a distributed database and should be replaced or wrapped by a transactional shared store when webhook replicas do not share a filesystem. +`@synsec/github/installation-store` provides bounded durable installation authorization state. It persists only installation id, account identity/type, repository-selection mode, selected `owner/name` repository identifiers when selection is limited, suspension state, and update time. It deliberately has no fields for installation tokens, App private keys, webhook secrets, clone URLs, or repository credentials. Suspended or absent installations cannot authorize a repository scan. + +`@synsec/github/scan-queue` provides a bounded durable local queue for commit-pinned scan work. Jobs contain only delivery id, installation/repository identity, exact head/base commit identity, PR identity when applicable, queue timestamps, lease state, and retry count. They do not contain GitHub tokens, clone URLs, App credentials, scanner output, source snippets, or arbitrary outbound targets. Workers lease jobs for a bounded period; expired leases can be reclaimed, failed jobs are retained for operator visibility, and attempt counts are bounded. + +These primitives do **not** constitute a hosted GitHub App service by themselves. A server, installation event synchronization/setup flow, isolated commit checkout workers, publication orchestration, and operational secret management are still required. The local replay, installation, and queue stores are not distributed databases and should be replaced or wrapped by transactional shared storage when service replicas do not share one durable filesystem. ## Webhook boundary @@ -29,7 +33,15 @@ After verification, callers should use the normalized event rather than payload After signature verification and before queueing work, hosted consumers should claim the `X-GitHub-Delivery` value through replay protection. A duplicate claim within the configured retention window must be treated as already processed or already in flight, not as a reason to enqueue a second scan. -Only `shouldScanGitHubAppWebhook()` decides whether a normalized event belongs in the scan queue. Installation creation/removal and repository-selection changes may update installation bookkeeping, but they do not authorize immediate scanner execution by themselves. +Only `shouldScanGitHubAppWebhook()` decides whether a normalized event belongs in the scan queue. Before enqueueing, the hosted service should also require `FileGitHubInstallationStore.isRepositoryAllowed()` (or its transactional server-store equivalent) for the event's installation and repository. Installation creation/removal and repository-selection changes update installation bookkeeping only; they do not authorize immediate scanner execution by themselves. + +## Queue and worker boundary + +Queue records are commit-pinned descriptors, not checkout instructions supplied by repository content. A worker should accept only the validated `owner/name`, installation id, and exact commit SHA from a queue job, acquire a short-lived installation token in the transport layer, and use a fixed GitHub endpoint/protocol to materialize that exact commit into an isolated workspace. + +A worker must not substitute the repository default branch, a nearby commit, a webhook clone URL, or a scanner-suggested URL when the requested commit is unavailable. Missing commit provenance is an explicit job failure rather than permission to widen scope or fetch an alternative target. + +Leases prevent normal duplicate processing but the local queue is not a multi-host transactional lock. Horizontally scaled workers should use a shared queue with atomic claim/lease semantics. ## Authentication boundary @@ -43,15 +55,15 @@ A hosted service should validate its configured GitHub App permissions explicitl A production hosted App still needs: -1. a minimal HTTPS webhook endpoint that preserves raw request bytes and calls the verified parser; -2. durable installation/repository state without storing installation tokens; -3. a bounded scan job queue and isolated checkout/worker execution; -4. repository acquisition that is installation-scoped and commit-pinned; -5. per-job resource/time limits and credential minimization; +1. a minimal HTTPS webhook endpoint that preserves raw request bytes and calls the verified parser/intake layer; +2. installation/setup synchronization that populates and updates durable authorization state without storing installation tokens; +3. isolated checkout/worker execution consuming the bounded queue; +4. repository acquisition that is installation-scoped and exact-commit-pinned; +5. per-job resource/time limits and filesystem/network credential minimization; 6. publication through the existing report/check/SARIF primitives; -7. explicit retention policy for reports and scan artifacts; +7. explicit retention policy for reports, failed queue records, and scan artifacts; 8. installation/setup UX and permission diagnostics; 9. operational rotation for webhook secrets and App private keys; -10. a transactional replay backend when horizontally scaled webhook replicas do not share the same durable filesystem. +10. transactional shared replay/installation/queue backends when horizontally scaled replicas do not share the same durable filesystem. -Until those pieces exist, the GitHub Action remains the complete executable integration path and the App module should be treated as a tested hosting foundation rather than a deployable hosted product. +Until those pieces exist, the GitHub Action remains the complete executable integration path and the App modules should be treated as tested hosting foundations rather than a deployable hosted product. From 8dcc93ae4864404adeb311fbc04d958a08aacae2 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:13:20 -0400 Subject: [PATCH 0317/1132] feat(github): authorize verified app events before queueing --- packages/github/package.json | 1 + packages/github/src/app-dispatch.ts | 68 +++++++++++++++++++++++ tests/github-app-dispatch.test.mjs | 86 +++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 packages/github/src/app-dispatch.ts create mode 100644 tests/github-app-dispatch.test.mjs diff --git a/packages/github/package.json b/packages/github/package.json index a0c49183..9c4d4207 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -13,6 +13,7 @@ "./base-scan": "./dist/base-scan.js", "./app": "./dist/app.js", "./app-intake": "./dist/app-intake.js", + "./app-dispatch": "./dist/app-dispatch.js", "./replay-store": "./dist/replay-store.js", "./installation-store": "./dist/installation-store.js", "./scan-queue": "./dist/scan-queue.js" diff --git a/packages/github/src/app-dispatch.ts b/packages/github/src/app-dispatch.ts new file mode 100644 index 00000000..0298e437 --- /dev/null +++ b/packages/github/src/app-dispatch.ts @@ -0,0 +1,68 @@ +import type { GitHubAppWebhookIntakeResult } from "./app-intake.js"; +import type { GitHubScanJob, GitHubScanJobInput } from "./scan-queue.js"; + +export interface GitHubInstallationAuthorizer { + isRepositoryAllowed(installationId: number, repository: string): Promise; +} + +export interface GitHubScanJobEnqueuer { + enqueue(input: GitHubScanJobInput): Promise; +} + +export type GitHubAppDispatchResult = + | { status: "ignored"; reason: "duplicate" | "non_scan_event" } + | { status: "rejected"; reason: "installation_not_authorized" } + | { status: "queued"; job: GitHubScanJob }; + +/** + * Apply the final hosted-App authorization gate before durable queueing. + * + * This function consumes only a verified/deduplicated intake result. It never follows + * payload URLs and requires durable installation state to authorize the normalized + * owner/name repository before constructing a commit-pinned queue descriptor. + */ +export async function dispatchGitHubAppWebhookScan(input: { + intake: GitHubAppWebhookIntakeResult; + installationStore: GitHubInstallationAuthorizer; + queue: GitHubScanJobEnqueuer; +}): Promise { + const { intake } = input; + if (intake.duplicate) return { status: "ignored", reason: "duplicate" }; + if (!intake.shouldScan) return { status: "ignored", reason: "non_scan_event" }; + + const webhook = intake.webhook; + if (!webhook.installationId || !webhook.repository || !webhook.headSha || !webhook.deliveryId) { + throw new Error("Scan-eligible GitHub App webhook is missing normalized queue identity."); + } + if (!await input.installationStore.isRepositoryAllowed(webhook.installationId, webhook.repository)) { + return { status: "rejected", reason: "installation_not_authorized" }; + } + + if (webhook.event === "pull_request") { + if (!webhook.baseSha || !webhook.pullRequestNumber) { + throw new Error("Scan-eligible pull request webhook is missing base commit or pull request identity."); + } + const job = await input.queue.enqueue({ + deliveryId: webhook.deliveryId, + installationId: webhook.installationId, + repository: webhook.repository, + headSha: webhook.headSha, + event: "pull_request", + baseSha: webhook.baseSha, + pullRequestNumber: webhook.pullRequestNumber, + }); + return { status: "queued", job }; + } + + if (webhook.event !== "push") { + throw new Error("Only push and pull_request webhooks may reach scan dispatch."); + } + const job = await input.queue.enqueue({ + deliveryId: webhook.deliveryId, + installationId: webhook.installationId, + repository: webhook.repository, + headSha: webhook.headSha, + event: "push", + }); + return { status: "queued", job }; +} diff --git a/tests/github-app-dispatch.test.mjs b/tests/github-app-dispatch.test.mjs new file mode 100644 index 00000000..70ec167c --- /dev/null +++ b/tests/github-app-dispatch.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { dispatchGitHubAppWebhookScan } from "@synsec/github/app-dispatch"; + +function intake(overrides = {}) { + return { + duplicate: false, + shouldScan: true, + webhook: { + event: "push", + deliveryId: "delivery-1", + installationId: 7, + repository: "example/repo", + headSha: "a".repeat(40), + }, + ...overrides, + }; +} + +test("dispatch requires durable installation authorization before queueing", async () => { + let enqueued = false; + const result = await dispatchGitHubAppWebhookScan({ + intake: intake(), + installationStore: { isRepositoryAllowed: async () => false }, + queue: { enqueue: async () => { enqueued = true; throw new Error("must not enqueue"); } }, + }); + assert.deepEqual(result, { status: "rejected", reason: "installation_not_authorized" }); + assert.equal(enqueued, false); +}); + +test("dispatch creates only normalized commit-pinned push jobs", async () => { + let queued; + const result = await dispatchGitHubAppWebhookScan({ + intake: intake(), + installationStore: { isRepositoryAllowed: async (id, repository) => id === 7 && repository === "example/repo" }, + queue: { enqueue: async (job) => { queued = job; return { ...job, version: 1, jobId: "f".repeat(32), createdAt: "2026-08-22T18:30:00.000Z", attempts: 0, status: "pending" }; } }, + }); + assert.equal(result.status, "queued"); + assert.deepEqual(queued, { + deliveryId: "delivery-1", + installationId: 7, + repository: "example/repo", + headSha: "a".repeat(40), + event: "push", + }); + assert.equal("cloneUrl" in queued, false); + assert.equal("token" in queued, false); +}); + +test("dispatch preserves exact PR base and head provenance", async () => { + let queued; + const prIntake = intake({ + webhook: { + event: "pull_request", + action: "synchronize", + deliveryId: "delivery-pr", + installationId: 8, + repository: "example/repo", + headSha: "b".repeat(40), + baseSha: "c".repeat(40), + pullRequestNumber: 42, + }, + }); + const result = await dispatchGitHubAppWebhookScan({ + intake: prIntake, + installationStore: { isRepositoryAllowed: async () => true }, + queue: { enqueue: async (job) => { queued = job; return { ...job, version: 1, jobId: "e".repeat(32), createdAt: "2026-08-22T18:30:00.000Z", attempts: 0, status: "pending" }; } }, + }); + assert.equal(result.status, "queued"); + assert.equal(queued.headSha, "b".repeat(40)); + assert.equal(queued.baseSha, "c".repeat(40)); + assert.equal(queued.pullRequestNumber, 42); +}); + +test("duplicates and non-scan events never consult authorization or queue", async () => { + for (const current of [intake({ duplicate: true }), intake({ shouldScan: false })]) { + let touched = false; + const result = await dispatchGitHubAppWebhookScan({ + intake: current, + installationStore: { isRepositoryAllowed: async () => { touched = true; return true; } }, + queue: { enqueue: async () => { touched = true; throw new Error("must not queue"); } }, + }); + assert.equal(result.status, "ignored"); + assert.equal(touched, false); + } +}); From a0a5b77469f016bb56ee42dee0408802d9625c23 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:19:27 -0400 Subject: [PATCH 0318/1132] feat(github): add exact-commit repository acquisition --- packages/github/src/repository-acquisition.ts | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 packages/github/src/repository-acquisition.ts diff --git a/packages/github/src/repository-acquisition.ts b/packages/github/src/repository-acquisition.ts new file mode 100644 index 00000000..80644967 --- /dev/null +++ b/packages/github/src/repository-acquisition.ts @@ -0,0 +1,258 @@ +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; + +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; +const MIN_TIMEOUT_MS = 10_000; +const MAX_TIMEOUT_MS = 30 * 60 * 1000; +const MAX_OUTPUT_BYTES = 1024 * 1024; +const MAX_TOKEN_LENGTH = 4096; +const OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/; +const REPOSITORY_PATTERN = /^[A-Za-z0-9._-]{1,100}$/; + +export interface GitCommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export interface GitCommandOptions { + cwd: string; + env: NodeJS.ProcessEnv; + timeoutMs: number; + signal?: AbortSignal; +} + +export type GitCommandRunner = ( + args: readonly string[], + options: GitCommandOptions, +) => Promise; + +export interface GitHubRepositoryAcquisitionOptions { + workspaceRoot?: string; + timeoutMs?: number; + signal?: AbortSignal; + gitRunner?: GitCommandRunner; +} + +export interface AcquiredGitHubRepository { + repository: string; + commitSha: string; + workspace: string; + cleanup(): Promise; +} + +function boundedTimeout(value: number | undefined): number { + const timeoutMs = value ?? DEFAULT_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < MIN_TIMEOUT_MS || timeoutMs > MAX_TIMEOUT_MS) { + throw new Error(`GitHub repository acquisition timeout must be between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS} milliseconds.`); + } + return timeoutMs; +} + +function installationToken(value: string): string { + const token = value.trim(); + if (!token) throw new Error("GitHub installation token is required for repository acquisition."); + if (token.length > MAX_TOKEN_LENGTH) throw new Error(`GitHub installation token exceeds ${MAX_TOKEN_LENGTH} characters.`); + if(/[\r\n\0]/.test(token)) throw new Error("GitHub installation token contains unsupported characters."); + return token; +} + +/** Validate one github.com owner/name identity before it is allowed to become a transport URL. */ +export function validateGitHubRepositoryIdentity(value: string): string { + const normalized = value.trim(); + const pieces = normalized.split("/"); + if (pieces.length !== 2) throw new Error("GitHub repository must be in owner/name form."); + const [owner, repository] = pieces; + if (!owner || !repository || !OWNER_PATTERN.test(owner) || !REPOSITORY_PATTERN.test(repository)) { + throw new Error("GitHub repository contains characters that are unsafe for fixed-host acquisition."); + } + if (repository === "." || repository === "..") { + throw new Error("GitHub repository name is invalid."); + } + return `${owner}/${repository}`; +} + +export function validateGitHubCommitSha(value: string): string { + const normalized = value.trim().toLowerCase(); + if (!/^[a-f0-9]{40,64}$/.test(normalized)) { + throw new Error("GitHub commit SHA must be a 40-64 character hexadecimal object id."); + } + return normalized; +} + +function gitEnvironment(token: string, source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const auth = Buffer.from(`x-access-token:${token}`, "utf8").toString("base64"); + const env: NodeJS.ProcessEnv = { + GIT_TERMINAL_PROMPT: "0", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null", + GIT_CONFIG_COUNT: "2", + GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader", + GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${auth}`, + GIT_CONFIG_KEY_1: "protocol.file.allow", + GIT_CONFIG_VALUE_1: "never", + GIT_LFS_SKIP_SMUDGE: "1", + }; + + for (const key of [ + "PATH", + "PATHEXT", + "SYSTEMROOT", + "COMSPEC", + "WINDIR", + "TEMP", + "TMP", + "TMPDIR", + "HOME", + "USERPROFILE", + "LANG", + "LC_ALL", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + ]) { + const value = source[key]; + if (value !== undefined) env[key] = value; + } + for (const [key, value] of Object.entries(source)) { + if (value !== undefined && key.startsWith("LC_")) env[key] = value; + } + return env; +} + +async function defaultGitRunner(args: readonly string[], options: GitCommandOptions): Promise { + if (options.signal?.aborted) throw new Error("GitHub repository acquisition was aborted before git started."); + return await new Promise((resolvePromise, reject) => { + const child = spawn("git", [...args], { + cwd: options.cwd, + env: options.env, + shell: false, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let stdoutBytes = 0; + let stderrBytes = 0; + let settled = false; + let timedOut = false; + let overflow = false; + + const terminate = (): void => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }; + const timeout = setTimeout(() => { + timedOut = true; + terminate(); + }, options.timeoutMs); + const onAbort = (): void => terminate(); + options.signal?.addEventListener("abort", onAbort, { once: true }); + + const finish = (callback: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + options.signal?.removeEventListener("abort", onAbort); + callback(); + }; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdoutBytes += Buffer.byteLength(chunk); + if (stdoutBytes > MAX_OUTPUT_BYTES) { + overflow = true; + terminate(); + return; + } + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderrBytes += Buffer.byteLength(chunk); + if (stderrBytes > MAX_OUTPUT_BYTES) { + overflow = true; + terminate(); + return; + } + stderr += chunk; + }); + child.once("error", (error) => finish(() => reject(error))); + child.once("close", (code) => finish(() => { + if (overflow) return reject(new Error(`git output exceeded the ${MAX_OUTPUT_BYTES}-byte acquisition limit.`)); + if (timedOut) return reject(new Error(`git timed out after ${options.timeoutMs} milliseconds during repository acquisition.`)); + if (options.signal?.aborted) return reject(new Error("GitHub repository acquisition was aborted.")); + resolvePromise({ exitCode: code ?? -1, stdout, stderr }); + })); + }); +} + +function commandFailure(stage: string, result: GitCommandResult): Error { + const detail = result.stderr.replace(/[\r\n]+/g, " ").trim().slice(0, 500); + return new Error(`GitHub repository acquisition failed during ${stage} (git exit ${result.exitCode})${detail ? `: ${detail}` : "."}`); +} + +async function requireGitSuccess( + runner: GitCommandRunner, + args: readonly string[], + options: GitCommandOptions, + stage: string, +): Promise { + const result = await runner(args, options); + if (result.exitCode !== 0) throw commandFailure(stage, result); + return result; +} + +/** + * Materialize exactly one installation-authorized github.com commit into a fresh detached workspace. + * + * The repository identity and commit SHA are validated before URL construction. Git receives the + * short-lived installation credential only through its child environment, never argv or persisted + * repository configuration. System/global git configuration and file:// transport are disabled so + * local URL rewrite rules cannot silently redirect the fixed GitHub transport. Submodules and LFS + * objects are not initialized. The caller owns cleanup after a successful acquisition. + */ +export async function acquireGitHubRepositoryCommit(input: { + repository: string; + commitSha: string; + installationToken: string; +}, options: GitHubRepositoryAcquisitionOptions = {}): Promise { + const repository = validateGitHubRepositoryIdentity(input.repository); + const commitSha = validateGitHubCommitSha(input.commitSha); + const token = installationToken(input.installationToken); + const timeoutMs = boundedTimeout(options.timeoutMs); + const runner = options.gitRunner ?? defaultGitRunner; + const root = resolve(options.workspaceRoot?.trim() || tmpdir()); + await mkdir(root, { recursive: true, mode: 0o700 }); + const workspace = await mkdtemp(join(root, "synsec-github-")); + const env = gitEnvironment(token); + const commandOptions: GitCommandOptions = { cwd: workspace, env, timeoutMs, ...(options.signal ? { signal: options.signal } : {}) }; + const remote = `https://github.com/${repository}.git`; + + try { + await requireGitSuccess(runner, ["init", "--quiet"], commandOptions, "workspace initialization"); + await requireGitSuccess( + runner, + ["fetch", "--quiet", "--no-tags", "--depth=1", remote, commitSha], + commandOptions, + "exact commit fetch", + ); + await requireGitSuccess(runner, ["checkout", "--quiet", "--detach", "FETCH_HEAD"], commandOptions, "detached checkout"); + const resolved = await requireGitSuccess(runner, ["rev-parse", "--verify", "HEAD"], commandOptions, "commit verification"); + if (resolved.stdout.trim().toLowerCase() !== commitSha) { + throw new Error("GitHub repository acquisition produced a commit different from the requested SHA."); + } + } catch (error) { + await rm(workspace, { recursive: true, force: true }); + throw error; + } + + return { + repository, + commitSha, + workspace, + cleanup: async () => { + await rm(workspace, { recursive: true, force: true }); + }, + }; +} From aa0fe8b89150c1d195282962e561d04e2f852839 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:19:34 -0400 Subject: [PATCH 0319/1132] build(github): export repository acquisition --- packages/github/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/github/package.json b/packages/github/package.json index 9c4d4207..557fe795 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -16,7 +16,8 @@ "./app-dispatch": "./dist/app-dispatch.js", "./replay-store": "./dist/replay-store.js", "./installation-store": "./dist/installation-store.js", - "./scan-queue": "./dist/scan-queue.js" + "./scan-queue": "./dist/scan-queue.js", + "./repository-acquisition": "./dist/repository-acquisition.js" }, "types": "./dist/index.d.ts", "scripts": { From feced114dd8645d18cae900dc193ba10aea1c947 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:19:51 -0400 Subject: [PATCH 0320/1132] test(github): cover exact-commit acquisition boundary --- tests/github-repository-acquisition.test.mjs | 97 ++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/github-repository-acquisition.test.mjs diff --git a/tests/github-repository-acquisition.test.mjs b/tests/github-repository-acquisition.test.mjs new file mode 100644 index 00000000..6e9069f6 --- /dev/null +++ b/tests/github-repository-acquisition.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { access, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + acquireGitHubRepositoryCommit, + validateGitHubCommitSha, + validateGitHubRepositoryIdentity, +} from "@synsec/github/repository-acquisition"; + +const sha = "0123456789abcdef0123456789abcdef01234567"; + +test("repository acquisition validates fixed-host owner/name identities", () => { + assert.equal(validateGitHubRepositoryIdentity("cmahmud/synsec"), "cmahmud/synsec"); + assert.throws(() => validateGitHubRepositoryIdentity("github.com@attacker.invalid/repo"), /unsafe/); + assert.throws(() => validateGitHubRepositoryIdentity("owner/repo/extra"), /owner\/name/); + assert.throws(() => validateGitHubRepositoryIdentity("owner/../repo"), /owner\/name/); + assert.equal(validateGitHubCommitSha(sha.toUpperCase()), sha); + assert.throws(() => validateGitHubCommitSha("main"), /commit SHA/); +}); + +test("exact-commit acquisition keeps the installation token out of git argv and verifies HEAD", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-acquire-test-")); + const calls = []; + const token = "ghs_test-installation-token"; + const gitRunner = async (args, options) => { + calls.push({ args: [...args], options }); + if (args[0] === "rev-parse") return { exitCode: 0, stdout: `${sha}\n`, stderr: "" }; + return { exitCode: 0, stdout: "", stderr: "" }; + }; + + const acquired = await acquireGitHubRepositoryCommit({ + repository: "cmahmud/synsec", + commitSha: sha, + installationToken: token, + }, { workspaceRoot: root, gitRunner, timeoutMs: 10_000 }); + + assert.equal(acquired.repository, "cmahmud/synsec"); + assert.equal(acquired.commitSha, sha); + assert.equal(calls.length, 4); + assert.deepEqual(calls[1].args, [ + "fetch", + "--quiet", + "--no-tags", + "--depth=1", + "https://github.com/cmahmud/synsec.git", + sha, + ]); + assert.equal(calls.some((call) => call.args.some((arg) => arg.includes(token))), false); + assert.equal(calls[0].options.env.GIT_TERMINAL_PROMPT, "0"); + assert.equal(calls[0].options.env.GIT_CONFIG_NOSYSTEM, "1"); + assert.equal(calls[0].options.env.GIT_CONFIG_KEY_0, "http.https://github.com/.extraheader"); + assert.match(calls[0].options.env.GIT_CONFIG_VALUE_0, /^AUTHORIZATION: basic /); + assert.equal(calls[0].options.env.GITHUB_TOKEN, undefined); + assert.equal(calls[0].options.env.HTTPS_PROXY, undefined); + assert.equal(calls[0].options.env.GIT_LFS_SKIP_SMUDGE, "1"); + + await acquired.cleanup(); + await assert.rejects(() => access(acquired.workspace), /ENOENT/); +}); + +test("acquisition rejects malformed transport identity before invoking git", async () => { + let called = false; + await assert.rejects(() => acquireGitHubRepositoryCommit({ + repository: "github.com@attacker.invalid/repo", + commitSha: sha, + installationToken: "token", + }, { + gitRunner: async () => { + called = true; + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }), /unsafe/); + assert.equal(called, false); +}); + +test("acquisition removes the temporary workspace when commit provenance mismatches", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-acquire-mismatch-")); + let workspace; + const otherSha = "abcdef0123456789abcdef0123456789abcdef01"; + const gitRunner = async (args, options) => { + workspace = options.cwd; + if (args[0] === "rev-parse") return { exitCode: 0, stdout: `${otherSha}\n`, stderr: "" }; + return { exitCode: 0, stdout: "", stderr: "" }; + }; + + await assert.rejects(() => acquireGitHubRepositoryCommit({ + repository: "cmahmud/synsec", + commitSha: sha, + installationToken: "token", + }, { workspaceRoot: root, gitRunner, timeoutMs: 10_000 }), /different from the requested SHA/); + + assert.ok(workspace); + await assert.rejects(() => access(workspace), /ENOENT/); +}); From 31de474ec481e430384830c4cf7bfb37465865f1 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:20:35 -0400 Subject: [PATCH 0321/1132] feat(github): synchronize verified installation events --- packages/github/src/installation-sync.ts | 252 +++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 packages/github/src/installation-sync.ts diff --git a/packages/github/src/installation-sync.ts b/packages/github/src/installation-sync.ts new file mode 100644 index 00000000..785bdd4a --- /dev/null +++ b/packages/github/src/installation-sync.ts @@ -0,0 +1,252 @@ +import { verifyGitHubWebhookSignature } from "./app.js"; +import { validateGitHubRepositoryIdentity } from "./repository-acquisition.js"; +import type { + GitHubInstallationRecord, + GitHubInstallationRecordInput, + GitHubRepositorySelection, +} from "./installation-store.js"; + +const MAX_REPOSITORIES = 10_000; +const MAX_LOGIN_LENGTH = 255; +const SUPPORTED_INSTALLATION_ACTIONS = new Set(["created", "deleted", "suspend", "unsuspend", "new_permissions_accepted"]); +const SUPPORTED_REPOSITORY_ACTIONS = new Set(["added", "removed"]); + +export interface GitHubInstallationStateStore { + get(installationId: number): Promise; + put(input: GitHubInstallationRecordInput): Promise; + remove(installationId: number): Promise; +} + +export interface GitHubInstallationStateEvent { + event: "installation" | "installation_repositories"; + action: string; + installationId: number; + accountLogin?: string; + accountType?: "User" | "Organization"; + repositorySelection?: GitHubRepositorySelection; + suspendedAt?: string; + repositories: string[]; + repositoriesAdded: string[]; + repositoriesRemoved: string[]; +} + +export type GitHubInstallationSyncResult = + | { status: "updated"; record: GitHubInstallationRecord } + | { status: "removed"; installationId: number; existed: boolean }; + +function objectValue(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function requiredPositiveInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer.`); + } + return value; +} + +function requiredString(value: unknown, label: string, maxLength: number): string { + if (typeof value !== "string") throw new Error(`${label} must be a string.`); + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + if (normalized.length > maxLength) throw new Error(`${label} exceeds ${maxLength} characters.`); + return normalized; +} + +function accountType(value: unknown): "User" | "Organization" { + if (value !== "User" && value !== "Organization") { + throw new Error("GitHub installation account type must be User or Organization."); + } + return value; +} + +function repositorySelection(value: unknown): GitHubRepositorySelection { + if (value !== "all" && value !== "selected") { + throw new Error("GitHub installation repository selection must be all or selected."); + } + return value; +} + +function optionalTimestamp(value: unknown): string | undefined { + if (value === null || value === undefined) return undefined; + const normalized = requiredString(value, "GitHub installation suspension timestamp", 64); + if (!Number.isFinite(Date.parse(normalized))) { + throw new Error("GitHub installation suspension timestamp must be an ISO timestamp."); + } + return normalized; +} + +function repositoryList(value: unknown, label: string): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new Error(`${label} must be an array.`); + if (value.length > MAX_REPOSITORIES) throw new Error(`${label} exceeds ${MAX_REPOSITORIES} repositories.`); + const names = value.map((entry) => { + const fullName = objectValue(entry)?.full_name; + if (typeof fullName !== "string") throw new Error(`${label} contains a repository without full_name.`); + return validateGitHubRepositoryIdentity(fullName); + }); + return [...new Set(names)].sort(); +} + +/** + * Verify and normalize only GitHub installation-management state required for authorization. + * Clone/API URLs, permissions, tokens, and arbitrary payload fields are intentionally discarded. + */ +export function parseVerifiedGitHubInstallationStateEvent(input: { + body: string | Uint8Array; + signatureHeader?: string; + webhookSecret: string; + eventName: string; +}): GitHubInstallationStateEvent { + if (input.eventName !== "installation" && input.eventName !== "installation_repositories") { + throw new Error("GitHub installation state synchronization accepts only installation management events."); + } + if (!verifyGitHubWebhookSignature(input.body, input.signatureHeader, input.webhookSecret)) { + throw new Error("GitHub webhook signature verification failed."); + } + + let payload: Record; + try { + const parsed = JSON.parse(Buffer.from(input.body).toString("utf8")); + const object = objectValue(parsed); + if (!object) throw new Error(); + payload = object; + } catch { + throw new Error("GitHub installation webhook body must be a JSON object."); + } + + const action = requiredString(payload.action, "GitHub installation action", 64); + const installation = objectValue(payload.installation); + if (!installation) throw new Error("GitHub installation webhook is missing installation metadata."); + const installationId = requiredPositiveInteger(installation.id, "GitHub installation id"); + + if (input.eventName === "installation") { + if (!SUPPORTED_INSTALLATION_ACTIONS.has(action)) { + throw new Error(`Unsupported GitHub installation action: ${action}`); + } + if (action === "deleted") { + return { + event: "installation", + action, + installationId, + repositories: [], + repositoriesAdded: [], + repositoriesRemoved: [], + }; + } + } else if (!SUPPORTED_REPOSITORY_ACTIONS.has(action)) { + throw new Error(`Unsupported GitHub installation_repositories action: ${action}`); + } + + const account = objectValue(installation.account); + if (!account) throw new Error("GitHub installation webhook is missing account metadata."); + const selection = repositorySelection(installation.repository_selection); + const repositories = selection === "all" ? [] : repositoryList(payload.repositories, "GitHub installation repositories"); + + return { + event: input.eventName, + action, + installationId, + accountLogin: requiredString(account.login, "GitHub installation account login", MAX_LOGIN_LENGTH), + accountType: accountType(account.type), + repositorySelection: selection, + ...(optionalTimestamp(installation.suspended_at) ? { suspendedAt: optionalTimestamp(installation.suspended_at) } : {}), + repositories, + repositoriesAdded: repositoryList(payload.repositories_added, "GitHub added repositories"), + repositoriesRemoved: repositoryList(payload.repositories_removed, "GitHub removed repositories"), + }; +} + +function requireMetadata(event: GitHubInstallationStateEvent): { + accountLogin: string; + accountType: "User" | "Organization"; + repositorySelection: GitHubRepositorySelection; +} { + if (!event.accountLogin || !event.accountType || !event.repositorySelection) { + throw new Error("GitHub installation event is missing normalized authorization metadata."); + } + return { + accountLogin: event.accountLogin, + accountType: event.accountType, + repositorySelection: event.repositorySelection, + }; +} + +/** Apply one already verified GitHub installation-management event to durable authorization state. */ +export async function synchronizeGitHubInstallationState( + event: GitHubInstallationStateEvent, + store: GitHubInstallationStateStore, + now = Date.now(), +): Promise { + if (!Number.isFinite(now) || now <= 0) throw new Error("GitHub installation synchronization clock must be a positive timestamp."); + if (event.event === "installation" && event.action === "deleted") { + const existed = await store.remove(event.installationId); + return { status: "removed", installationId: event.installationId, existed }; + } + + const metadata = requireMetadata(event); + const existing = await store.get(event.installationId); + const updatedAt = new Date(now).toISOString(); + + if (event.event === "installation_repositories") { + if (!existing) throw new Error("GitHub installation repository selection changed before installation state was initialized."); + if (existing.repositorySelection !== "selected" || metadata.repositorySelection !== "selected") { + throw new Error("GitHub installation repository delta is inconsistent with repositorySelection=selected."); + } + if (existing.accountLogin !== metadata.accountLogin || existing.accountType !== metadata.accountType) { + throw new Error("GitHub installation repository delta account identity does not match stored authorization state."); + } + const repositories = new Set(existing.repositories); + for (const repository of event.repositoriesRemoved) repositories.delete(repository); + for (const repository of event.repositoriesAdded) repositories.add(repository); + const record = await store.put({ + installationId: event.installationId, + accountLogin: existing.accountLogin, + accountType: existing.accountType, + repositorySelection: "selected", + repositories: [...repositories], + ...(existing.suspendedAt ? { suspendedAt: existing.suspendedAt } : {}), + updatedAt, + }); + return { status: "updated", record }; + } + + let repositories: string[] | undefined; + if (metadata.repositorySelection === "selected") { + repositories = event.repositories.length > 0 + ? event.repositories + : existing?.repositorySelection === "selected" + ? existing.repositories + : []; + } + + const suspendedAt = event.action === "unsuspend" + ? undefined + : event.action === "suspend" + ? event.suspendedAt ?? updatedAt + : event.suspendedAt; + + const record = await store.put({ + installationId: event.installationId, + ...metadata, + ...(repositories ? { repositories } : {}), + ...(suspendedAt ? { suspendedAt } : {}), + updatedAt, + }); + return { status: "updated", record }; +} + +/** Verify, normalize, and synchronize one installation-management delivery. */ +export async function synchronizeVerifiedGitHubInstallationWebhook(input: { + body: string | Uint8Array; + signatureHeader?: string; + webhookSecret: string; + eventName: string; + store: GitHubInstallationStateStore; + now?: number; +}): Promise { + const event = parseVerifiedGitHubInstallationStateEvent(input); + return synchronizeGitHubInstallationState(event, input.store, input.now ?? Date.now()); +} From fd1b461a008b25c3b39045ff62c46107818f373d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:20:42 -0400 Subject: [PATCH 0322/1132] build(github): export installation synchronization --- packages/github/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/github/package.json b/packages/github/package.json index 557fe795..288575ab 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -16,6 +16,7 @@ "./app-dispatch": "./dist/app-dispatch.js", "./replay-store": "./dist/replay-store.js", "./installation-store": "./dist/installation-store.js", + "./installation-sync": "./dist/installation-sync.js", "./scan-queue": "./dist/scan-queue.js", "./repository-acquisition": "./dist/repository-acquisition.js" }, From d51cc8237c08466770421b4b9efb655455d20c9c Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:21:01 -0400 Subject: [PATCH 0323/1132] test(github): cover installation event synchronization --- tests/github-installation-sync.test.mjs | 186 ++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 tests/github-installation-sync.test.mjs diff --git a/tests/github-installation-sync.test.mjs b/tests/github-installation-sync.test.mjs new file mode 100644 index 00000000..8275b74d --- /dev/null +++ b/tests/github-installation-sync.test.mjs @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import test from "node:test"; + +import { + parseVerifiedGitHubInstallationStateEvent, + synchronizeGitHubInstallationState, +} from "@synsec/github/installation-sync"; + +const secret = "synsec-installation-sync-secret"; + +function signature(body) { + return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; +} + +class MemoryInstallationStore { + constructor() { + this.records = new Map(); + } + async get(id) { + return this.records.get(id); + } + async put(input) { + const record = { + version: 1, + installationId: input.installationId, + accountLogin: input.accountLogin, + accountType: input.accountType, + repositorySelection: input.repositorySelection, + repositories: [...(input.repositories ?? [])].sort(), + ...(input.suspendedAt ? { suspendedAt: input.suspendedAt } : {}), + updatedAt: input.updatedAt ?? new Date().toISOString(), + }; + this.records.set(record.installationId, record); + return record; + } + async remove(id) { + return this.records.delete(id); + } +} + +test("verified installation creation normalizes only authorization state", async () => { + const body = Buffer.from(JSON.stringify({ + action: "created", + installation: { + id: 42, + account: { login: "example-org", type: "Organization" }, + repository_selection: "selected", + suspended_at: null, + access_tokens_url: "https://attacker.invalid/token", + }, + repositories: [ + { full_name: "example-org/b", clone_url: "https://attacker.invalid/b.git" }, + { full_name: "example-org/a" }, + ], + })); + + const event = parseVerifiedGitHubInstallationStateEvent({ + body, + signatureHeader: signature(body), + webhookSecret: secret, + eventName: "installation", + }); + assert.deepEqual(event, { + event: "installation", + action: "created", + installationId: 42, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["example-org/a", "example-org/b"], + repositoriesAdded: [], + repositoriesRemoved: [], + }); + + const store = new MemoryInstallationStore(); + const result = await synchronizeGitHubInstallationState(event, store, Date.UTC(2026, 7, 22, 18, 30)); + assert.equal(result.status, "updated"); + assert.equal(await store.get(42).then((record) => record.repositories.includes("example-org/a")), true); + assert.equal(JSON.stringify(await store.get(42)).includes("attacker.invalid"), false); +}); + +test("repository-selection deltas preserve bounded selected authorization", async () => { + const store = new MemoryInstallationStore(); + await store.put({ + installationId: 7, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["example-org/a", "example-org/b"], + updatedAt: "2026-08-22T18:00:00.000Z", + }); + const event = { + event: "installation_repositories", + action: "added", + installationId: 7, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: [], + repositoriesAdded: ["example-org/c"], + repositoriesRemoved: ["example-org/a"], + }; + const result = await synchronizeGitHubInstallationState(event, store, Date.UTC(2026, 7, 22, 18, 31)); + assert.equal(result.status, "updated"); + assert.deepEqual(result.record.repositories, ["example-org/b", "example-org/c"]); +}); + +test("suspend and unsuspend events fail closed and preserve selected repositories", async () => { + const store = new MemoryInstallationStore(); + await store.put({ + installationId: 9, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["example-org/repo"], + }); + const base = { + event: "installation", + installationId: 9, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: [], + repositoriesAdded: [], + repositoriesRemoved: [], + }; + const suspended = await synchronizeGitHubInstallationState({ ...base, action: "suspend" }, store, Date.UTC(2026, 7, 22, 18, 32)); + assert.ok(suspended.record.suspendedAt); + assert.deepEqual(suspended.record.repositories, ["example-org/repo"]); + const unsuspended = await synchronizeGitHubInstallationState({ ...base, action: "unsuspend" }, store, Date.UTC(2026, 7, 22, 18, 33)); + assert.equal(unsuspended.record.suspendedAt, undefined); + assert.deepEqual(unsuspended.record.repositories, ["example-org/repo"]); +}); + +test("deleted installations are removed without requiring payload account metadata", async () => { + const store = new MemoryInstallationStore(); + await store.put({ installationId: 3, accountLogin: "owner", accountType: "User", repositorySelection: "all" }); + const result = await synchronizeGitHubInstallationState({ + event: "installation", + action: "deleted", + installationId: 3, + repositories: [], + repositoriesAdded: [], + repositoriesRemoved: [], + }, store); + assert.deepEqual(result, { status: "removed", installationId: 3, existed: true }); + assert.equal(await store.get(3), undefined); +}); + +test("installation synchronization rejects unsafe repositories, unsigned payloads, and inconsistent deltas", async () => { + const unsafeBody = Buffer.from(JSON.stringify({ + action: "created", + installation: { + id: 1, + account: { login: "owner", type: "User" }, + repository_selection: "selected", + }, + repositories: [{ full_name: "github.com@attacker.invalid/repo" }], + })); + assert.throws(() => parseVerifiedGitHubInstallationStateEvent({ + body: unsafeBody, + signatureHeader: signature(unsafeBody), + webhookSecret: secret, + eventName: "installation", + }), /unsafe/); + assert.throws(() => parseVerifiedGitHubInstallationStateEvent({ + body: unsafeBody, + signatureHeader: "sha256=" + "0".repeat(64), + webhookSecret: secret, + eventName: "installation", + }), /signature verification failed/); + + const store = new MemoryInstallationStore(); + await assert.rejects(() => synchronizeGitHubInstallationState({ + event: "installation_repositories", + action: "added", + installationId: 999, + accountLogin: "owner", + accountType: "User", + repositorySelection: "selected", + repositories: [], + repositoriesAdded: ["owner/repo"], + repositoriesRemoved: [], + }, store), /before installation state was initialized/); +}); From 4867b651c88500e2a6c20dfa03b40039a97a665b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:21:26 -0400 Subject: [PATCH 0324/1132] feat(github): add replay-protected app webhook handler --- packages/github/src/app-handler.ts | 79 ++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 packages/github/src/app-handler.ts diff --git a/packages/github/src/app-handler.ts b/packages/github/src/app-handler.ts new file mode 100644 index 00000000..1f80d823 --- /dev/null +++ b/packages/github/src/app-handler.ts @@ -0,0 +1,79 @@ +import { + intakeGitHubAppWebhook, + type GitHubWebhookReplayClaimer, +} from "./app-intake.js"; +import { + dispatchGitHubAppWebhookScan, + type GitHubAppDispatchResult, + type GitHubScanJobEnqueuer, +} from "./app-dispatch.js"; +import { + synchronizeVerifiedGitHubInstallationWebhook, + type GitHubInstallationStateStore, +} from "./installation-sync.js"; + +export interface GitHubAppInstallationStore extends GitHubInstallationStateStore { + isRepositoryAllowed(installationId: number, repository: string): Promise; +} + +export type GitHubAppWebhookHandleResult = + | { status: "ignored"; reason: "duplicate" | "non_scan_event" } + | { status: "rejected"; reason: "installation_not_authorized" } + | { status: "queued"; job: GitHubAppDispatchResult extends { status: "queued"; job: infer Job } ? Job : never } + | { status: "installation_updated"; installationId: number } + | { status: "installation_removed"; installationId: number; existed: boolean }; + +/** + * Execute the durable hosted-App intake boundary for one webhook delivery. + * + * The order is deliberate: verify/normalize -> replay claim -> installation bookkeeping + * or authorization-gated scan dispatch. Duplicate authenticated deliveries never mutate + * installation state or enqueue work. Installation-management events never trigger scans. + */ +export async function handleGitHubAppWebhook(input: { + body: string | Uint8Array; + signatureHeader?: string; + webhookSecret: string; + eventName: string; + deliveryId: string; + replayStore: GitHubWebhookReplayClaimer; + installationStore: GitHubAppInstallationStore; + queue: GitHubScanJobEnqueuer; + now?: number; +}): Promise { + const intake = await intakeGitHubAppWebhook({ + body: input.body, + signatureHeader: input.signatureHeader, + webhookSecret: input.webhookSecret, + eventName: input.eventName, + deliveryId: input.deliveryId, + replayStore: input.replayStore, + }); + + if (intake.duplicate) return { status: "ignored", reason: "duplicate" }; + + if (intake.webhook.event === "installation" || intake.webhook.event === "installation_repositories") { + const result = await synchronizeVerifiedGitHubInstallationWebhook({ + body: input.body, + signatureHeader: input.signatureHeader, + webhookSecret: input.webhookSecret, + eventName: input.eventName, + store: input.installationStore, + ...(input.now !== undefined ? { now: input.now } : {}), + }); + if (result.status === "removed") { + return { + status: "installation_removed", + installationId: result.installationId, + existed: result.existed, + }; + } + return { status: "installation_updated", installationId: result.record.installationId }; + } + + return dispatchGitHubAppWebhookScan({ + intake, + installationStore: input.installationStore, + queue: input.queue, + }); +} From 270e078daf7f2c2911de95c949e86bdd87fb79b4 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:21:40 -0400 Subject: [PATCH 0325/1132] fix(github): type queued webhook handler results --- packages/github/src/app-handler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/github/src/app-handler.ts b/packages/github/src/app-handler.ts index 1f80d823..77072872 100644 --- a/packages/github/src/app-handler.ts +++ b/packages/github/src/app-handler.ts @@ -4,13 +4,13 @@ import { } from "./app-intake.js"; import { dispatchGitHubAppWebhookScan, - type GitHubAppDispatchResult, type GitHubScanJobEnqueuer, } from "./app-dispatch.js"; import { synchronizeVerifiedGitHubInstallationWebhook, type GitHubInstallationStateStore, } from "./installation-sync.js"; +import type { GitHubScanJob } from "./scan-queue.js"; export interface GitHubAppInstallationStore extends GitHubInstallationStateStore { isRepositoryAllowed(installationId: number, repository: string): Promise; @@ -19,7 +19,7 @@ export interface GitHubAppInstallationStore extends GitHubInstallationStateStore export type GitHubAppWebhookHandleResult = | { status: "ignored"; reason: "duplicate" | "non_scan_event" } | { status: "rejected"; reason: "installation_not_authorized" } - | { status: "queued"; job: GitHubAppDispatchResult extends { status: "queued"; job: infer Job } ? Job : never } + | { status: "queued"; job: GitHubScanJob } | { status: "installation_updated"; installationId: number } | { status: "installation_removed"; installationId: number; existed: boolean }; From d697363eeeeec486f2dd3f0474be58910f1a4fa9 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:21:47 -0400 Subject: [PATCH 0326/1132] build(github): export app webhook handler --- packages/github/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/github/package.json b/packages/github/package.json index 288575ab..cbb8fddb 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -14,6 +14,7 @@ "./app": "./dist/app.js", "./app-intake": "./dist/app-intake.js", "./app-dispatch": "./dist/app-dispatch.js", + "./app-handler": "./dist/app-handler.js", "./replay-store": "./dist/replay-store.js", "./installation-store": "./dist/installation-store.js", "./installation-sync": "./dist/installation-sync.js", From 65671615b8118dc7b5ede8a0a7b71110c0364148 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:22:09 -0400 Subject: [PATCH 0327/1132] test(github): cover unified app webhook handling --- tests/github-app-handler.test.mjs | 184 ++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 tests/github-app-handler.test.mjs diff --git a/tests/github-app-handler.test.mjs b/tests/github-app-handler.test.mjs new file mode 100644 index 00000000..dad70088 --- /dev/null +++ b/tests/github-app-handler.test.mjs @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import test from "node:test"; + +import { handleGitHubAppWebhook } from "@synsec/github/app-handler"; + +const secret = "synsec-app-handler-secret"; +const headSha = "0123456789abcdef0123456789abcdef01234567"; +const baseSha = "abcdef0123456789abcdef0123456789abcdef01"; + +function signature(body) { + return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; +} + +class MemoryReplayStore { + constructor() { + this.ids = new Set(); + } + async claim(deliveryId) { + const accepted = !this.ids.has(deliveryId); + this.ids.add(deliveryId); + return { accepted, deliveryId, receivedAt: "2026-08-22T18:40:00.000Z" }; + } +} + +class MemoryInstallationStore { + constructor() { + this.records = new Map(); + this.putCount = 0; + } + async get(id) { + return this.records.get(id); + } + async put(input) { + this.putCount += 1; + const record = { + version: 1, + installationId: input.installationId, + accountLogin: input.accountLogin, + accountType: input.accountType, + repositorySelection: input.repositorySelection, + repositories: [...(input.repositories ?? [])].sort(), + ...(input.suspendedAt ? { suspendedAt: input.suspendedAt } : {}), + updatedAt: input.updatedAt ?? "2026-08-22T18:40:00.000Z", + }; + this.records.set(record.installationId, record); + return record; + } + async remove(id) { + return this.records.delete(id); + } + async isRepositoryAllowed(id, repository) { + const record = this.records.get(id); + return Boolean(record && !record.suspendedAt && ( + record.repositorySelection === "all" || record.repositories.includes(repository) + )); + } +} + +class MemoryQueue { + constructor() { + this.inputs = []; + } + async enqueue(input) { + this.inputs.push(input); + return { + version: 1, + jobId: "0".repeat(32), + ...input, + createdAt: "2026-08-22T18:40:00.000Z", + attempts: 0, + status: "pending", + }; + } +} + +test("unified handler synchronizes installation state without enqueueing a scan", async () => { + const replayStore = new MemoryReplayStore(); + const installationStore = new MemoryInstallationStore(); + const queue = new MemoryQueue(); + const body = Buffer.from(JSON.stringify({ + action: "created", + installation: { + id: 42, + account: { login: "example-org", type: "Organization" }, + repository_selection: "selected", + suspended_at: null, + }, + repositories: [{ full_name: "example-org/repo" }], + })); + + const result = await handleGitHubAppWebhook({ + body, + signatureHeader: signature(body), + webhookSecret: secret, + eventName: "installation", + deliveryId: "delivery-install-1", + replayStore, + installationStore, + queue, + now: Date.UTC(2026, 7, 22, 18, 40), + }); + + assert.deepEqual(result, { status: "installation_updated", installationId: 42 }); + assert.equal(await installationStore.isRepositoryAllowed(42, "example-org/repo"), true); + assert.equal(queue.inputs.length, 0); +}); + +test("duplicate installation delivery does not mutate authorization state twice", async () => { + const replayStore = new MemoryReplayStore(); + const installationStore = new MemoryInstallationStore(); + const queue = new MemoryQueue(); + const body = Buffer.from(JSON.stringify({ + action: "created", + installation: { + id: 42, + account: { login: "example-org", type: "Organization" }, + repository_selection: "all", + suspended_at: null, + }, + })); + const input = { + body, + signatureHeader: signature(body), + webhookSecret: secret, + eventName: "installation", + deliveryId: "delivery-install-duplicate", + replayStore, + installationStore, + queue, + }; + + assert.equal((await handleGitHubAppWebhook(input)).status, "installation_updated"); + assert.deepEqual(await handleGitHubAppWebhook(input), { status: "ignored", reason: "duplicate" }); + assert.equal(installationStore.putCount, 1); + assert.equal(queue.inputs.length, 0); +}); + +test("authorized pull request delivery queues exact commit provenance", async () => { + const replayStore = new MemoryReplayStore(); + const installationStore = new MemoryInstallationStore(); + const queue = new MemoryQueue(); + await installationStore.put({ + installationId: 7, + accountLogin: "cmahmud", + accountType: "User", + repositorySelection: "selected", + repositories: ["cmahmud/synsec"], + }); + const body = Buffer.from(JSON.stringify({ + action: "synchronize", + installation: { id: 7 }, + repository: { full_name: "cmahmud/synsec", clone_url: "https://attacker.invalid/repo.git" }, + number: 2, + pull_request: { + head: { sha: headSha }, + base: { sha: baseSha }, + }, + })); + + const result = await handleGitHubAppWebhook({ + body, + signatureHeader: signature(body), + webhookSecret: secret, + eventName: "pull_request", + deliveryId: "delivery-pr-1", + replayStore, + installationStore, + queue, + }); + + assert.equal(result.status, "queued"); + assert.equal(queue.inputs.length, 1); + assert.deepEqual(queue.inputs[0], { + deliveryId: "delivery-pr-1", + installationId: 7, + repository: "cmahmud/synsec", + headSha, + event: "pull_request", + baseSha, + pullRequestNumber: 2, + }); + assert.equal(JSON.stringify(queue.inputs[0]).includes("attacker.invalid"), false); +}); From 53d658e3f3c9e6bd79a249716a00d68b20558bf5 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:23:20 -0400 Subject: [PATCH 0328/1132] feat(github): add authorization-aware scan worker --- packages/github/src/app-worker.ts | 101 ++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 packages/github/src/app-worker.ts diff --git a/packages/github/src/app-worker.ts b/packages/github/src/app-worker.ts new file mode 100644 index 00000000..83846f16 --- /dev/null +++ b/packages/github/src/app-worker.ts @@ -0,0 +1,101 @@ +import type { SynSecReport } from "@synsec/report"; +import { + acquireGitHubRepositoryCommit, + type AcquiredGitHubRepository, + type GitHubRepositoryAcquisitionOptions, +} from "./repository-acquisition.js"; +import type { GitHubScanJob } from "./scan-queue.js"; + +export interface GitHubAppWorkerQueue { + claimNext(): Promise; + release(jobId: string): Promise; + fail(jobId: string): Promise; + complete(jobId: string): Promise; +} + +export interface GitHubAppWorkerAuthorizer { + isRepositoryAllowed(installationId: number, repository: string): Promise; +} + +export type GitHubInstallationTokenPurpose = "acquire" | "publish"; + +export interface GitHubAppWorkerOptions { + queue: GitHubAppWorkerQueue; + installationStore: GitHubAppWorkerAuthorizer; + getInstallationToken(installationId: number, purpose: GitHubInstallationTokenPurpose): Promise; + scan(job: GitHubScanJob, workspace: string): Promise; + publish(job: GitHubScanJob, report: SynSecReport, installationToken: string): Promise; + acquire?: ( + input: { repository: string; commitSha: string; installationToken: string }, + options?: GitHubRepositoryAcquisitionOptions, + ) => Promise; + acquisitionOptions?: GitHubRepositoryAcquisitionOptions; +} + +export type GitHubAppWorkerResult = + | { status: "idle" } + | { status: "completed"; job: GitHubScanJob; reportId: string } + | { status: "revoked"; job: GitHubScanJob } + | { status: "retry_scheduled"; job: GitHubScanJob; error: string }; + +function safeError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/[\r\n]+/g, " ").trim().slice(0, 1000) || "GitHub App worker failed."; +} + +/** + * Consume at most one durable GitHub App scan job. + * + * Authorization is checked again after lease acquisition so a repository removed or suspended + * after webhook queueing is never scanned from stale authorization. Installation credentials are + * obtained only in the transport layer: one short-lived token for exact-commit acquisition and a + * fresh token for publication. The scanner receives only the commit-pinned workspace and job + * descriptor. Reports must bind to the exact queued head SHA before publication or completion. + */ +export async function runNextGitHubAppScanJob(options: GitHubAppWorkerOptions): Promise { + const job = await options.queue.claimNext(); + if (!job) return { status: "idle" }; + + let acquired: AcquiredGitHubRepository | undefined; + try { + const allowed = await options.installationStore.isRepositoryAllowed(job.installationId, job.repository); + if (!allowed) { + await options.queue.fail(job.jobId); + return { status: "revoked", job }; + } + + const acquisitionToken = await options.getInstallationToken(job.installationId, "acquire"); + const acquire = options.acquire ?? acquireGitHubRepositoryCommit; + acquired = await acquire({ + repository: job.repository, + commitSha: job.headSha, + installationToken: acquisitionToken, + }, options.acquisitionOptions); + + if (acquired.repository !== job.repository || acquired.commitSha.toLowerCase() !== job.headSha.toLowerCase()) { + throw new Error("Acquired GitHub repository does not match the leased scan job provenance."); + } + + const report = await options.scan(job, acquired.workspace); + const reportSha = report.target.commitSha?.trim().toLowerCase(); + if (!reportSha || reportSha !== job.headSha.toLowerCase()) { + throw new Error("GitHub App worker report commit does not match the leased scan job head SHA."); + } + + const publicationToken = await options.getInstallationToken(job.installationId, "publish"); + await options.publish(job, report, publicationToken); + if (!await options.queue.complete(job.jobId)) { + throw new Error("Completed GitHub App scan job disappeared before queue acknowledgement."); + } + return { status: "completed", job, reportId: report.reportId }; + } catch (error) { + try { + await options.queue.release(job.jobId); + } catch (releaseError) { + throw new Error(`${safeError(error)} Queue release also failed: ${safeError(releaseError)}`); + } + return { status: "retry_scheduled", job, error: safeError(error) }; + } finally { + if (acquired) await acquired.cleanup(); + } +} From 4726c33b7e7deeb1fadc87aed47dd69c56198a11 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:23:29 -0400 Subject: [PATCH 0329/1132] build(github): export hosted app worker --- packages/github/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/github/package.json b/packages/github/package.json index cbb8fddb..36f7a2dc 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -15,6 +15,7 @@ "./app-intake": "./dist/app-intake.js", "./app-dispatch": "./dist/app-dispatch.js", "./app-handler": "./dist/app-handler.js", + "./app-worker": "./dist/app-worker.js", "./replay-store": "./dist/replay-store.js", "./installation-store": "./dist/installation-store.js", "./installation-sync": "./dist/installation-sync.js", From 7a5f51749b676c12f7978e9a3db3d6d2cacd40d0 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:23:47 -0400 Subject: [PATCH 0330/1132] test(github): cover authorization-aware app worker --- tests/github-app-worker.test.mjs | 163 +++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 tests/github-app-worker.test.mjs diff --git a/tests/github-app-worker.test.mjs b/tests/github-app-worker.test.mjs new file mode 100644 index 00000000..c399c525 --- /dev/null +++ b/tests/github-app-worker.test.mjs @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { runNextGitHubAppScanJob } from "@synsec/github/app-worker"; + +const headSha = "0123456789abcdef0123456789abcdef01234567"; + +function job() { + return { + version: 1, + jobId: "a".repeat(32), + deliveryId: "delivery-worker-1", + installationId: 42, + repository: "cmahmud/synsec", + headSha, + event: "push", + createdAt: "2026-08-22T18:45:00.000Z", + attempts: 1, + status: "leased", + leaseUntil: "2026-08-22T18:50:00.000Z", + }; +} + +function report(commitSha = headSha) { + return { + schemaVersion: "1.0", + reportId: "report-1", + generatedAt: "2026-08-22T18:46:00.000Z", + toolVersion: "0.2.0", + target: { path: "/tmp/repo", commitSha }, + scanners: [], + rawFindingCount: 0, + findingCount: 0, + summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 100, + findings: [], + }; +} + +class MemoryQueue { + constructor(next = job()) { + this.next = next; + this.completed = []; + this.released = []; + this.failed = []; + } + async claimNext() { + const next = this.next; + this.next = undefined; + return next; + } + async release(id) { + this.released.push(id); + return { ...job(), status: "pending", leaseUntil: undefined }; + } + async fail(id) { + this.failed.push(id); + return { ...job(), status: "failed", leaseUntil: undefined }; + } + async complete(id) { + this.completed.push(id); + return true; + } +} + +test("worker returns idle when no queued job is available", async () => { + const queue = new MemoryQueue(undefined); + const result = await runNextGitHubAppScanJob({ + queue, + installationStore: { isRepositoryAllowed: async () => true }, + getInstallationToken: async () => "token", + acquire: async () => { throw new Error("must not acquire"); }, + scan: async () => { throw new Error("must not scan"); }, + publish: async () => { throw new Error("must not publish"); }, + }); + assert.deepEqual(result, { status: "idle" }); +}); + +test("worker rechecks authorization before obtaining credentials or repository content", async () => { + const queue = new MemoryQueue(); + let tokenCalls = 0; + let acquisitionCalls = 0; + const result = await runNextGitHubAppScanJob({ + queue, + installationStore: { isRepositoryAllowed: async () => false }, + getInstallationToken: async () => { tokenCalls += 1; return "token"; }, + acquire: async () => { acquisitionCalls += 1; throw new Error("must not acquire"); }, + scan: async () => { throw new Error("must not scan"); }, + publish: async () => { throw new Error("must not publish"); }, + }); + assert.equal(result.status, "revoked"); + assert.deepEqual(queue.failed, ["a".repeat(32)]); + assert.equal(tokenCalls, 0); + assert.equal(acquisitionCalls, 0); +}); + +test("worker isolates transport credentials from scanning and publishes only a commit-bound report", async () => { + const queue = new MemoryQueue(); + const tokenPurposes = []; + const acquisitionTokens = []; + const publicationTokens = []; + let cleanupCalls = 0; + let scannedWorkspace; + const result = await runNextGitHubAppScanJob({ + queue, + installationStore: { isRepositoryAllowed: async () => true }, + getInstallationToken: async (_installationId, purpose) => { + tokenPurposes.push(purpose); + return purpose === "acquire" ? "acquisition-secret" : "publication-secret"; + }, + acquire: async (input) => { + acquisitionTokens.push(input.installationToken); + return { + repository: input.repository, + commitSha: input.commitSha, + workspace: "/tmp/synsec-worker-repo", + cleanup: async () => { cleanupCalls += 1; }, + }; + }, + scan: async (_job, workspace) => { + scannedWorkspace = workspace; + return report(); + }, + publish: async (_job, _report, token) => { + publicationTokens.push(token); + }, + }); + + assert.equal(result.status, "completed"); + assert.deepEqual(tokenPurposes, ["acquire", "publish"]); + assert.deepEqual(acquisitionTokens, ["acquisition-secret"]); + assert.deepEqual(publicationTokens, ["publication-secret"]); + assert.equal(scannedWorkspace, "/tmp/synsec-worker-repo"); + assert.deepEqual(queue.completed, ["a".repeat(32)]); + assert.deepEqual(queue.released, []); + assert.equal(cleanupCalls, 1); +}); + +test("worker refuses stale scan output, cleans the workspace, and schedules bounded queue retry", async () => { + const queue = new MemoryQueue(); + let publishCalls = 0; + let cleanupCalls = 0; + const result = await runNextGitHubAppScanJob({ + queue, + installationStore: { isRepositoryAllowed: async () => true }, + getInstallationToken: async () => "token", + acquire: async (input) => ({ + repository: input.repository, + commitSha: input.commitSha, + workspace: "/tmp/synsec-worker-repo", + cleanup: async () => { cleanupCalls += 1; }, + }), + scan: async () => report("abcdef0123456789abcdef0123456789abcdef01"), + publish: async () => { publishCalls += 1; }, + }); + + assert.equal(result.status, "retry_scheduled"); + assert.match(result.error, /report commit does not match/); + assert.equal(publishCalls, 0); + assert.deepEqual(queue.released, ["a".repeat(32)]); + assert.deepEqual(queue.completed, []); + assert.equal(cleanupCalls, 1); +}); From 7eb828d75dc02ec3897c3c51c977e3ca6963bb93 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:24:15 -0400 Subject: [PATCH 0331/1132] docs(github): document synchronized app worker flow --- docs/GITHUB_APP.md | 48 ++++++++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/docs/GITHUB_APP.md b/docs/GITHUB_APP.md index 33ef9d5b..02a131b6 100644 --- a/docs/GITHUB_APP.md +++ b/docs/GITHUB_APP.md @@ -21,25 +21,33 @@ SynSec's GitHub App support is a transport and orchestration layer around the sa `@synsec/github/installation-store` provides bounded durable installation authorization state. It persists only installation id, account identity/type, repository-selection mode, selected `owner/name` repository identifiers when selection is limited, suspension state, and update time. It deliberately has no fields for installation tokens, App private keys, webhook secrets, clone URLs, or repository credentials. Suspended or absent installations cannot authorize a repository scan. +`@synsec/github/installation-sync` verifies and normalizes installation-management payloads into the minimal authorization model. Creation, deletion, suspension, unsuspension, and selected-repository add/remove events update durable state without persisting GitHub URLs, permissions, tokens, or arbitrary payload fields. Repository deltas fail closed when stored installation/account state is missing or inconsistent. + `@synsec/github/scan-queue` provides a bounded durable local queue for commit-pinned scan work. Jobs contain only delivery id, installation/repository identity, exact head/base commit identity, PR identity when applicable, queue timestamps, lease state, and retry count. They do not contain GitHub tokens, clone URLs, App credentials, scanner output, source snippets, or arbitrary outbound targets. Workers lease jobs for a bounded period; expired leases can be reclaimed, failed jobs are retained for operator visibility, and attempt counts are bounded. -These primitives do **not** constitute a hosted GitHub App service by themselves. A server, installation event synchronization/setup flow, isolated commit checkout workers, publication orchestration, and operational secret management are still required. The local replay, installation, and queue stores are not distributed databases and should be replaced or wrapped by transactional shared storage when service replicas do not share one durable filesystem. +`@synsec/github/app-handler` composes signature verification, replay claiming, installation-state synchronization, durable authorization, and queue dispatch in one tested boundary. Duplicate authenticated deliveries do not mutate installation state or enqueue duplicate work; installation-management events remain bookkeeping-only. -## Webhook boundary +`@synsec/github/repository-acquisition` materializes one exact commit from a strict `owner/name` identity through a fixed `https://github.com//.git` transport. It rejects URL-shaped repository identities before URL construction, disables system/global Git configuration and `file://` transport so local rewrite rules cannot redirect the request, keeps the installation token out of argv and repository config, skips Git LFS smudging/submodule initialization, checks out detached `FETCH_HEAD`, verifies the resulting HEAD against the requested SHA, and removes failed temporary workspaces. -Webhook consumers should preserve the raw request bytes until signature verification is complete. Do not parse and reserialize JSON before verifying the signature. +`@synsec/github/app-worker` consumes at most one leased queue job, rechecks installation authorization at execution time, acquires a short-lived token only for transport, scans the exact-commit workspace through an injected repository-scan runner, requires the resulting report to bind to the queued head SHA, obtains a fresh publication token, publishes through an injected GitHub transport, and acknowledges the queue only after publication succeeds. A repository removed or suspended after queueing is failed before credentials or source are acquired. Other worker failures return the job to the bounded retry queue. -After verification, callers should use the normalized event rather than payload URLs as the security boundary. In particular, repository checkout or API publication must derive from the validated GitHub installation/repository identity through a fixed GitHub transport. A `clone_url`, `html_url`, scanner-provided URL, finding text, or other repository-controlled field must never become an arbitrary outbound target. +These primitives still do **not** constitute a complete hosted GitHub App product by themselves. A minimal HTTPS server, concrete App-JWT/token wiring, the production scan-engine/publication composition, process/container isolation, operational secret management, setup UX, and shared transactional persistence for multi-host deployments remain required. + +## Webhook boundary -After signature verification and before queueing work, hosted consumers should claim the `X-GitHub-Delivery` value through replay protection. A duplicate claim within the configured retention window must be treated as already processed or already in flight, not as a reason to enqueue a second scan. +Webhook consumers must preserve the raw request bytes until signature verification is complete. Do not parse and reserialize JSON before verifying the signature. -Only `shouldScanGitHubAppWebhook()` decides whether a normalized event belongs in the scan queue. Before enqueueing, the hosted service should also require `FileGitHubInstallationStore.isRepositoryAllowed()` (or its transactional server-store equivalent) for the event's installation and repository. Installation creation/removal and repository-selection changes update installation bookkeeping only; they do not authorize immediate scanner execution by themselves. +After verification, callers should use the normalized event rather than payload URLs as the security boundary. Repository checkout and API publication derive from validated GitHub installation/repository identity through fixed GitHub transports. A `clone_url`, `html_url`, scanner-provided URL, finding text, or other repository-controlled field must never become an arbitrary outbound target. + +The preferred local composition is `handleGitHubAppWebhook()`: signature verification and event normalization happen before the replay claim; duplicate authenticated deliveries stop before synchronization/dispatch; installation-management events synchronize durable authorization state; and scan-bearing events must pass `isRepositoryAllowed()` before queueing. Installation creation/removal and repository-selection changes never authorize immediate scanner execution by themselves. ## Queue and worker boundary -Queue records are commit-pinned descriptors, not checkout instructions supplied by repository content. A worker should accept only the validated `owner/name`, installation id, and exact commit SHA from a queue job, acquire a short-lived installation token in the transport layer, and use a fixed GitHub endpoint/protocol to materialize that exact commit into an isolated workspace. +Queue records are commit-pinned descriptors, not checkout instructions supplied by repository content. `runNextGitHubAppScanJob()` rechecks authorization after leasing so stale queued work cannot outlive a repository removal or installation suspension. + +Repository acquisition accepts only a strict validated `owner/name`, installation id context supplied by the worker, and exact commit SHA. The acquisition transport is fixed to `github.com`, and Git system/global configuration is disabled to prevent `url.*.insteadOf` or other host-local configuration from silently widening the destination. Missing/unavailable commit provenance is a job failure rather than permission to substitute the default branch, a nearby commit, a webhook clone URL, or a scanner-suggested URL. -A worker must not substitute the repository default branch, a nearby commit, a webhook clone URL, or a scanner-suggested URL when the requested commit is unavailable. Missing commit provenance is an explicit job failure rather than permission to widen scope or fetch an alternative target. +The scanner receives the checked-out workspace and queue descriptor, not the installation token. Before publication, the worker requires `report.target.commitSha` to equal the queued head SHA and obtains a fresh installation token for the publication operation. Successful workspace cleanup occurs after scan/publication handling. Leases prevent normal duplicate processing but the local queue is not a multi-host transactional lock. Horizontally scaled workers should use a shared queue with atomic claim/lease semantics. @@ -47,7 +55,7 @@ Leases prevent normal duplicate processing but the local queue is not a multi-ho `createGitHubAppJwt()` signs a short-lived RS256 token from the configured App id and private key. The private key belongs to the hosted transport/runtime and must never be exposed to scanners, reports, repository code, workflow prompts, logs, or persisted finding evidence. -`createGitHubInstallationToken()` exchanges that JWT at GitHub's fixed API host. It requests no additional repository selection or permission expansion in the token request body. The resulting installation token should be kept only for the operation lifetime and passed only to narrowly scoped GitHub transport functions. +`createGitHubInstallationToken()` exchanges that JWT at GitHub's fixed API host. It requests no additional repository selection or permission expansion in the token request body. Resulting installation tokens should be kept only for the operation lifetime and passed only to narrowly scoped transport functions. Repository acquisition supplies its token to Git only through a child-process environment and disables inherited Git configuration; publication should likewise keep credentials outside scanner inputs and reports. A hosted service should validate its configured GitHub App permissions explicitly and fail closed when required permissions are absent rather than requesting broader permissions dynamically. @@ -55,15 +63,13 @@ A hosted service should validate its configured GitHub App permissions explicitl A production hosted App still needs: -1. a minimal HTTPS webhook endpoint that preserves raw request bytes and calls the verified parser/intake layer; -2. installation/setup synchronization that populates and updates durable authorization state without storing installation tokens; -3. isolated checkout/worker execution consuming the bounded queue; -4. repository acquisition that is installation-scoped and exact-commit-pinned; -5. per-job resource/time limits and filesystem/network credential minimization; -6. publication through the existing report/check/SARIF primitives; -7. explicit retention policy for reports, failed queue records, and scan artifacts; -8. installation/setup UX and permission diagnostics; -9. operational rotation for webhook secrets and App private keys; -10. transactional shared replay/installation/queue backends when horizontally scaled replicas do not share the same durable filesystem. - -Until those pieces exist, the GitHub Action remains the complete executable integration path and the App modules should be treated as tested hosting foundations rather than a deployable hosted product. +1. a minimal HTTPS webhook endpoint that preserves raw request bytes and invokes `handleGitHubAppWebhook()`; +2. concrete App-JWT/private-key configuration and installation-token providers for the worker; +3. production composition from `runNextGitHubAppScanJob()` into `runScanEngine()` and the existing Checks/SARIF publishers; +4. process/container workspace isolation around scans, including OS CPU/memory limits and network policy; +5. explicit retention policy for reports, failed queue records, temporary artifacts, and operator diagnostics; +6. installation/setup UX, permission diagnostics, and recovery for configuration errors; +7. operational rotation for webhook secrets and App private keys; +8. transactional shared replay/installation/queue backends when horizontally scaled replicas do not share one durable filesystem. + +Until those pieces exist, the GitHub Action remains the complete executable integration path and the App modules should be treated as tested hosting foundations rather than a deployable hosted service. From 2a8fb09374fa6d4ac6be90464b32d918a8ee94a0 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:24:48 -0400 Subject: [PATCH 0332/1132] fix(test): represent an empty worker queue explicitly --- tests/github-app-worker.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/github-app-worker.test.mjs b/tests/github-app-worker.test.mjs index c399c525..b6e31617 100644 --- a/tests/github-app-worker.test.mjs +++ b/tests/github-app-worker.test.mjs @@ -64,7 +64,7 @@ class MemoryQueue { } test("worker returns idle when no queued job is available", async () => { - const queue = new MemoryQueue(undefined); + const queue = new MemoryQueue(null); const result = await runNextGitHubAppScanJob({ queue, installationStore: { isRepositoryAllowed: async () => true }, From bc409e6ad1f2f9a37fa04c7b464aff682d4ff762 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:25:39 -0400 Subject: [PATCH 0333/1132] feat(github): allow safe replay claim release --- packages/github/src/replay-store.ts | 43 +++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/github/src/replay-store.ts b/packages/github/src/replay-store.ts index c8b931e9..0bbeadbc 100644 --- a/packages/github/src/replay-store.ts +++ b/packages/github/src/replay-store.ts @@ -46,6 +46,14 @@ function validatedRetention(value: number | undefined): number { return retention; } +function validatedReceivedAt(value: string): string { + const normalized = value.trim(); + if (!normalized || !Number.isFinite(Date.parse(normalized))) { + throw new Error("GitHub webhook replay receivedAt must be an ISO timestamp."); + } + return normalized; +} + function recordPath(directory: string, deliveryId: string): string { const digest = createHash("sha256").update(deliveryId, "utf8").digest("hex"); return join(directory, `${digest}.json`); @@ -69,9 +77,8 @@ function parseRecord(text: string, expectedDeliveryId?: string): ReplayRecord { if (expectedDeliveryId !== undefined && deliveryId !== expectedDeliveryId) { throw new Error("Stored GitHub webhook replay record has an invalid shape."); } - const timestamp = Date.parse(record.receivedAt); - if (!Number.isFinite(timestamp)) throw new Error("Stored GitHub webhook replay timestamp is invalid."); - return { version: 1, deliveryId, receivedAt: record.receivedAt }; + const receivedAt = validatedReceivedAt(record.receivedAt); + return { version: 1, deliveryId, receivedAt }; } async function readRecord(path: string, expectedDeliveryId?: string): Promise { @@ -88,6 +95,12 @@ function isAlreadyExists(error: unknown): boolean { && (error as NodeJS.ErrnoException).code === "EEXIST"; } +function isNotFound(error: unknown): boolean { + return error instanceof Error + && Object.prototype.hasOwnProperty.call(error, "code") + && (error as NodeJS.ErrnoException).code === "ENOENT"; +} + /** * Durable replay protection for GitHub webhook delivery ids. * @@ -149,6 +162,30 @@ export class FileGitHubWebhookReplayStore { throw new Error("Unable to claim expired GitHub webhook delivery id safely."); } + /** + * Release only the still-current accepted claim after downstream processing fails. + * The original receivedAt value binds the release to this claim and an expired claim + * is never removed, preventing a late worker from deleting a newer reclaimed marker. + */ + async release(deliveryIdValue: string, receivedAtValue: string): Promise { + const deliveryId = validatedDeliveryId(deliveryIdValue); + const receivedAt = validatedReceivedAt(receivedAtValue); + const now = this.now(); + if (!Number.isFinite(now) || now <= 0) throw new Error("Webhook replay-store clock must be a positive timestamp."); + const path = recordPath(this.directory, deliveryId); + let existing: ReplayRecord; + try { + existing = await readRecord(path, deliveryId); + } catch (error) { + if (isNotFound(error)) return false; + throw error; + } + if (existing.receivedAt !== receivedAt) return false; + if (now - Date.parse(existing.receivedAt) >= this.retentionMs) return false; + await rm(path); + return true; + } + async pruneExpired(): Promise { const now = this.now(); if (!Number.isFinite(now) || now <= 0) throw new Error("Webhook replay-store clock must be a positive timestamp."); From 59e321d03c9f22da06dc653a966087008352a913 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:25:49 -0400 Subject: [PATCH 0334/1132] feat(github): retain replay claim provenance --- packages/github/src/app-intake.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/github/src/app-intake.ts b/packages/github/src/app-intake.ts index ebfde4f9..085c5924 100644 --- a/packages/github/src/app-intake.ts +++ b/packages/github/src/app-intake.ts @@ -12,6 +12,7 @@ export interface GitHubAppWebhookIntakeResult { webhook: GitHubAppWebhook; duplicate: boolean; shouldScan: boolean; + replayReceivedAt: string; } /** @@ -20,6 +21,8 @@ export interface GitHubAppWebhookIntakeResult { * Signature verification intentionally happens before the durable replay claim so * unauthenticated traffic cannot fill the replay store. A duplicate authenticated * delivery is returned for idempotent HTTP handling but is never scan-eligible. + * The accepted claim timestamp is retained so a higher-level handler can release + * exactly that claim if downstream durable processing fails. */ export async function intakeGitHubAppWebhook(input: { body: string | Uint8Array; @@ -50,5 +53,6 @@ export async function intakeGitHubAppWebhook(input: { webhook, duplicate, shouldScan: !duplicate && shouldScanGitHubAppWebhook(webhook), + replayReceivedAt: claim.receivedAt, }; } From 75bff7751ed945e383da52285c8db152a29462ca Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:26:08 -0400 Subject: [PATCH 0335/1132] fix(github): release replay claim on durable handler failure --- packages/github/src/app-handler.ts | 71 ++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/packages/github/src/app-handler.ts b/packages/github/src/app-handler.ts index 77072872..e4af865d 100644 --- a/packages/github/src/app-handler.ts +++ b/packages/github/src/app-handler.ts @@ -16,6 +16,10 @@ export interface GitHubAppInstallationStore extends GitHubInstallationStateStore isRepositoryAllowed(installationId: number, repository: string): Promise; } +export interface GitHubWebhookReplayManager extends GitHubWebhookReplayClaimer { + release(deliveryId: string, receivedAt: string): Promise; +} + export type GitHubAppWebhookHandleResult = | { status: "ignored"; reason: "duplicate" | "non_scan_event" } | { status: "rejected"; reason: "installation_not_authorized" } @@ -23,12 +27,19 @@ export type GitHubAppWebhookHandleResult = | { status: "installation_updated"; installationId: number } | { status: "installation_removed"; installationId: number; existed: boolean }; +function safeError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/[\r\n]+/g, " ").trim().slice(0, 1000) || "unknown replay-store error"; +} + /** * Execute the durable hosted-App intake boundary for one webhook delivery. * * The order is deliberate: verify/normalize -> replay claim -> installation bookkeeping * or authorization-gated scan dispatch. Duplicate authenticated deliveries never mutate * installation state or enqueue work. Installation-management events never trigger scans. + * If durable processing fails after an accepted replay claim, that exact unexpired claim is + * released before the error is propagated so GitHub can retry rather than losing the delivery. */ export async function handleGitHubAppWebhook(input: { body: string | Uint8Array; @@ -36,44 +47,58 @@ export async function handleGitHubAppWebhook(input: { webhookSecret: string; eventName: string; deliveryId: string; - replayStore: GitHubWebhookReplayClaimer; + replayStore: GitHubWebhookReplayManager; installationStore: GitHubAppInstallationStore; queue: GitHubScanJobEnqueuer; now?: number; }): Promise { + const deliveryId = input.deliveryId.trim(); const intake = await intakeGitHubAppWebhook({ body: input.body, signatureHeader: input.signatureHeader, webhookSecret: input.webhookSecret, eventName: input.eventName, - deliveryId: input.deliveryId, + deliveryId, replayStore: input.replayStore, }); if (intake.duplicate) return { status: "ignored", reason: "duplicate" }; - if (intake.webhook.event === "installation" || intake.webhook.event === "installation_repositories") { - const result = await synchronizeVerifiedGitHubInstallationWebhook({ - body: input.body, - signatureHeader: input.signatureHeader, - webhookSecret: input.webhookSecret, - eventName: input.eventName, - store: input.installationStore, - ...(input.now !== undefined ? { now: input.now } : {}), + try { + if (intake.webhook.event === "installation" || intake.webhook.event === "installation_repositories") { + const result = await synchronizeVerifiedGitHubInstallationWebhook({ + body: input.body, + signatureHeader: input.signatureHeader, + webhookSecret: input.webhookSecret, + eventName: input.eventName, + store: input.installationStore, + ...(input.now !== undefined ? { now: input.now } : {}), + }); + if (result.status === "removed") { + return { + status: "installation_removed", + installationId: result.installationId, + existed: result.existed, + }; + } + return { status: "installation_updated", installationId: result.record.installationId }; + } + + return await dispatchGitHubAppWebhookScan({ + intake, + installationStore: input.installationStore, + queue: input.queue, }); - if (result.status === "removed") { - return { - status: "installation_removed", - installationId: result.installationId, - existed: result.existed, - }; + } catch (error) { + let released: boolean; + try { + released = await input.replayStore.release(deliveryId, intake.replayReceivedAt); + } catch (releaseError) { + throw new Error(`${safeError(error)} Replay claim release failed: ${safeError(releaseError)}`); + } + if (!released) { + throw new Error(`${safeError(error)} Replay claim could not be released safely for retry.`); } - return { status: "installation_updated", installationId: result.record.installationId }; + throw error; } - - return dispatchGitHubAppWebhookScan({ - intake, - installationStore: input.installationStore, - queue: input.queue, - }); } From a5c051efb6399d270fabed3d8cc1994477125df9 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:27:34 -0400 Subject: [PATCH 0336/1132] fix(github): reset stale selected repositories on installation creation --- packages/github/src/installation-sync.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/github/src/installation-sync.ts b/packages/github/src/installation-sync.ts index 785bdd4a..5569e629 100644 --- a/packages/github/src/installation-sync.ts +++ b/packages/github/src/installation-sync.ts @@ -144,6 +144,7 @@ export function parseVerifiedGitHubInstallationStateEvent(input: { if (!account) throw new Error("GitHub installation webhook is missing account metadata."); const selection = repositorySelection(installation.repository_selection); const repositories = selection === "all" ? [] : repositoryList(payload.repositories, "GitHub installation repositories"); + const suspendedAt = optionalTimestamp(installation.suspended_at); return { event: input.eventName, @@ -152,7 +153,7 @@ export function parseVerifiedGitHubInstallationStateEvent(input: { accountLogin: requiredString(account.login, "GitHub installation account login", MAX_LOGIN_LENGTH), accountType: accountType(account.type), repositorySelection: selection, - ...(optionalTimestamp(installation.suspended_at) ? { suspendedAt: optionalTimestamp(installation.suspended_at) } : {}), + ...(suspendedAt ? { suspendedAt } : {}), repositories, repositoriesAdded: repositoryList(payload.repositories_added, "GitHub added repositories"), repositoriesRemoved: repositoryList(payload.repositories_removed, "GitHub removed repositories"), @@ -215,11 +216,15 @@ export async function synchronizeGitHubInstallationState( let repositories: string[] | undefined; if (metadata.repositorySelection === "selected") { - repositories = event.repositories.length > 0 - ? event.repositories - : existing?.repositorySelection === "selected" - ? existing.repositories - : []; + if (event.action === "created") { + repositories = event.repositories; + } else { + repositories = event.repositories.length > 0 + ? event.repositories + : existing?.repositorySelection === "selected" + ? existing.repositories + : []; + } } const suspendedAt = event.action === "unsuspend" From c0d6fda6bb12f45111b4668164be0633549ff7b3 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:27:48 -0400 Subject: [PATCH 0337/1132] test(github): cover replay claim release semantics --- tests/github-replay-store.test.mjs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/github-replay-store.test.mjs b/tests/github-replay-store.test.mjs index 38198d48..5953c13f 100644 --- a/tests/github-replay-store.test.mjs +++ b/tests/github-replay-store.test.mjs @@ -43,6 +43,23 @@ test("replay store permits reuse only after bounded retention expires", async () assert.equal((await store.claim("delivery-1")).accepted, true); }); +test("replay claim release permits retry only for the exact current unexpired claim", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-replay-release-")); + let now = Date.UTC(2026, 7, 22, 17, 0, 0); + const store = new FileGitHubWebhookReplayStore(directory, { now: () => now, retentionMs: HOUR }); + + const first = await store.claim("delivery-release"); + assert.equal(first.accepted, true); + assert.equal(await store.release("delivery-release", "2026-08-22T16:59:59.000Z"), false); + assert.equal((await store.claim("delivery-release")).accepted, false); + assert.equal(await store.release("delivery-release", first.receivedAt), true); + const retried = await store.claim("delivery-release"); + assert.equal(retried.accepted, true); + + now += HOUR + 1; + assert.equal(await store.release("delivery-release", retried.receivedAt), false); +}); + test("replay store validates ids and retention bounds", async () => { const directory = await mkdtemp(join(tmpdir(), "synsec-replay-validation-")); assert.throws(() => new FileGitHubWebhookReplayStore(directory, { retentionMs: HOUR - 1 }), /retention must be an integer/); @@ -50,6 +67,7 @@ test("replay store validates ids and retention bounds", async () => { const store = new FileGitHubWebhookReplayStore(directory, { retentionMs: HOUR }); await assert.rejects(() => store.claim("../delivery"), /unsupported characters/); await assert.rejects(() => store.claim("x".repeat(129)), /exceeds 128 characters/); + await assert.rejects(() => store.release("delivery", "not-a-date"), /receivedAt must be an ISO timestamp/); }); test("replay store rejects corrupt existing records instead of treating them as duplicates", async () => { From 4ce335f4dc8a7c89524facd540c7e064b24a9a67 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:28:09 -0400 Subject: [PATCH 0338/1132] test(github): cover handler retry after durable failure --- tests/github-app-handler.test.mjs | 97 ++++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 22 deletions(-) diff --git a/tests/github-app-handler.test.mjs b/tests/github-app-handler.test.mjs index dad70088..9da2680a 100644 --- a/tests/github-app-handler.test.mjs +++ b/tests/github-app-handler.test.mjs @@ -14,12 +14,19 @@ function signature(body) { class MemoryReplayStore { constructor() { - this.ids = new Set(); + this.claims = new Map(); } async claim(deliveryId) { - const accepted = !this.ids.has(deliveryId); - this.ids.add(deliveryId); - return { accepted, deliveryId, receivedAt: "2026-08-22T18:40:00.000Z" }; + const existing = this.claims.get(deliveryId); + if (existing) return { accepted: false, deliveryId, receivedAt: existing }; + const receivedAt = "2026-08-22T18:40:00.000Z"; + this.claims.set(deliveryId, receivedAt); + return { accepted: true, deliveryId, receivedAt }; + } + async release(deliveryId, receivedAt) { + if (this.claims.get(deliveryId) !== receivedAt) return false; + this.claims.delete(deliveryId); + return true; } } @@ -74,6 +81,31 @@ class MemoryQueue { } } +function pullRequestBody() { + return Buffer.from(JSON.stringify({ + action: "synchronize", + installation: { id: 7 }, + repository: { full_name: "cmahmud/synsec", clone_url: "https://attacker.invalid/repo.git" }, + number: 2, + pull_request: { + head: { sha: headSha }, + base: { sha: baseSha }, + }, + })); +} + +async function authorizedInstallationStore() { + const store = new MemoryInstallationStore(); + await store.put({ + installationId: 7, + accountLogin: "cmahmud", + accountType: "User", + repositorySelection: "selected", + repositories: ["cmahmud/synsec"], + }); + return store; +} + test("unified handler synchronizes installation state without enqueueing a scan", async () => { const replayStore = new MemoryReplayStore(); const installationStore = new MemoryInstallationStore(); @@ -138,25 +170,9 @@ test("duplicate installation delivery does not mutate authorization state twice" test("authorized pull request delivery queues exact commit provenance", async () => { const replayStore = new MemoryReplayStore(); - const installationStore = new MemoryInstallationStore(); + const installationStore = await authorizedInstallationStore(); const queue = new MemoryQueue(); - await installationStore.put({ - installationId: 7, - accountLogin: "cmahmud", - accountType: "User", - repositorySelection: "selected", - repositories: ["cmahmud/synsec"], - }); - const body = Buffer.from(JSON.stringify({ - action: "synchronize", - installation: { id: 7 }, - repository: { full_name: "cmahmud/synsec", clone_url: "https://attacker.invalid/repo.git" }, - number: 2, - pull_request: { - head: { sha: headSha }, - base: { sha: baseSha }, - }, - })); + const body = pullRequestBody(); const result = await handleGitHubAppWebhook({ body, @@ -182,3 +198,40 @@ test("authorized pull request delivery queues exact commit provenance", async () }); assert.equal(JSON.stringify(queue.inputs[0]).includes("attacker.invalid"), false); }); + +test("durable dispatch failure releases the accepted replay claim so GitHub can retry", async () => { + const replayStore = new MemoryReplayStore(); + const installationStore = await authorizedInstallationStore(); + const body = pullRequestBody(); + let attempts = 0; + const queue = { + async enqueue(input) { + attempts += 1; + if (attempts === 1) throw new Error("temporary queue failure"); + return { + version: 1, + jobId: "1".repeat(32), + ...input, + createdAt: "2026-08-22T18:40:00.000Z", + attempts: 0, + status: "pending", + }; + }, + }; + const input = { + body, + signatureHeader: signature(body), + webhookSecret: secret, + eventName: "pull_request", + deliveryId: "delivery-retry-1", + replayStore, + installationStore, + queue, + }; + + await assert.rejects(() => handleGitHubAppWebhook(input), /temporary queue failure/); + assert.equal(replayStore.claims.has("delivery-retry-1"), false); + const retried = await handleGitHubAppWebhook(input); + assert.equal(retried.status, "queued"); + assert.equal(attempts, 2); +}); From efea130648031cee7ec5da076d0d0f26c489da1e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:28:38 -0400 Subject: [PATCH 0339/1132] test(github): ensure creation replaces stale authorization --- tests/github-installation-sync.test.mjs | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/github-installation-sync.test.mjs b/tests/github-installation-sync.test.mjs index 8275b74d..d1f8d233 100644 --- a/tests/github-installation-sync.test.mjs +++ b/tests/github-installation-sync.test.mjs @@ -80,6 +80,32 @@ test("verified installation creation normalizes only authorization state", async assert.equal(JSON.stringify(await store.get(42)).includes("attacker.invalid"), false); }); +test("new installation creation replaces stale selected repository authorization", async () => { + const store = new MemoryInstallationStore(); + await store.put({ + installationId: 42, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["example-org/stale-repo"], + }); + + const result = await synchronizeGitHubInstallationState({ + event: "installation", + action: "created", + installationId: 42, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: [], + repositoriesAdded: [], + repositoriesRemoved: [], + }, store, Date.UTC(2026, 7, 22, 18, 30)); + + assert.equal(result.status, "updated"); + assert.deepEqual(result.record.repositories, []); +}); + test("repository-selection deltas preserve bounded selected authorization", async () => { const store = new MemoryInstallationStore(); await store.put({ From 7a38d980efe2709614e551d667650c53324e4533 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:29:20 -0400 Subject: [PATCH 0340/1132] feat(github): compose hosted app worker scan and publication --- packages/github/src/app-worker-runner.ts | 94 ++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 packages/github/src/app-worker-runner.ts diff --git a/packages/github/src/app-worker-runner.ts b/packages/github/src/app-worker-runner.ts new file mode 100644 index 00000000..d72801b2 --- /dev/null +++ b/packages/github/src/app-worker-runner.ts @@ -0,0 +1,94 @@ +import type { SynSecConfig } from "@synsec/config"; +import { runScanEngine, type ScanEngineOutcome } from "@synsec/engine"; +import { + runNextGitHubAppScanJob, + type GitHubAppWorkerAuthorizer, + type GitHubAppWorkerQueue, + type GitHubAppWorkerResult, + type GitHubInstallationTokenPurpose, +} from "./app-worker.js"; +import { + acquireGitHubRepositoryCommit, + type GitHubRepositoryAcquisitionOptions, +} from "./repository-acquisition.js"; +import { buildGitHubCheck, type GitHubCheckThreshold, type GitHubPullRequestContext } from "./index.js"; +import { publishGitHubCheck, type GitHubPublisherOptions } from "./publisher.js"; +import { publishGitHubSarif } from "./sarif-publisher.js"; + +export interface ConfiguredGitHubAppWorkerOptions extends GitHubPublisherOptions { + queue: GitHubAppWorkerQueue; + installationStore: GitHubAppWorkerAuthorizer; + config: SynSecConfig; + getInstallationToken(installationId: number, purpose: GitHubInstallationTokenPurpose): Promise; + threshold?: GitHubCheckThreshold; + publishSarif?: boolean; + toolVersion?: string; + scan?: typeof runScanEngine; + acquire?: typeof acquireGitHubRepositoryCommit; + acquisitionOptions?: GitHubRepositoryAcquisitionOptions; +} + +function contextForJob(job: { + repository: string; + headSha: string; + event: "push" | "pull_request"; + pullRequestNumber?: number; +}): GitHubPullRequestContext { + return { + repository: job.repository, + sha: job.headSha, + ...(job.event === "pull_request" && job.pullRequestNumber + ? { pullRequestNumber: job.pullRequestNumber } + : {}), + }; +} + +/** + * Execute one configured hosted-App job through SynSec's existing repository scan engine. + * + * Hosted jobs intentionally use a full repository scan at this layer. PR changed-file baselines + * require separately acquiring the exact base commit and are not approximated from a branch name. + * Publication uses only the normalized queue repository/head identity and fixed GitHub API hosts. + */ +export async function runConfiguredGitHubAppWorkerOnce( + options: ConfiguredGitHubAppWorkerOptions, +): Promise { + const scan = options.scan ?? runScanEngine; + const acquire = options.acquire ?? acquireGitHubRepositoryCommit; + + return runNextGitHubAppScanJob({ + queue: options.queue, + installationStore: options.installationStore, + getInstallationToken: options.getInstallationToken, + acquire, + acquisitionOptions: options.acquisitionOptions, + scan: async (_job, workspace) => { + const outcome: ScanEngineOutcome = await scan({ + rootPath: workspace, + config: options.config, + toolVersion: options.toolVersion, + changedOnly: false, + }); + return outcome.report; + }, + publish: async (job, report, installationToken) => { + const context = contextForJob(job); + const check = buildGitHubCheck(report, context, { + threshold: options.threshold, + onlyNewAnnotations: false, + }); + await publishGitHubCheck(check, context, installationToken, { + apiVersion: options.apiVersion, + userAgent: options.userAgent, + fetch: options.fetch, + }); + if (options.publishSarif) { + await publishGitHubSarif(report, context, installationToken, { + apiVersion: options.apiVersion, + userAgent: options.userAgent, + fetch: options.fetch, + }); + } + }, + }); +} From fd24e0d4674af2b7e6d42686bc40a76aa55f7299 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:29:33 -0400 Subject: [PATCH 0341/1132] build(github): export configured app worker runner --- packages/github/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/github/package.json b/packages/github/package.json index 36f7a2dc..07fc18ce 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -16,6 +16,7 @@ "./app-dispatch": "./dist/app-dispatch.js", "./app-handler": "./dist/app-handler.js", "./app-worker": "./dist/app-worker.js", + "./app-worker-runner": "./dist/app-worker-runner.js", "./replay-store": "./dist/replay-store.js", "./installation-store": "./dist/installation-store.js", "./installation-sync": "./dist/installation-sync.js", From ae1da597239e2e472ddfdb1f9b1b1e263157a5ed Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:29:50 -0400 Subject: [PATCH 0342/1132] test(github): cover configured hosted app worker composition --- tests/github-app-worker-runner.test.mjs | 110 ++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/github-app-worker-runner.test.mjs diff --git a/tests/github-app-worker-runner.test.mjs b/tests/github-app-worker-runner.test.mjs new file mode 100644 index 00000000..419f2ee4 --- /dev/null +++ b/tests/github-app-worker-runner.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { runConfiguredGitHubAppWorkerOnce } from "@synsec/github/app-worker-runner"; + +const headSha = "0123456789abcdef0123456789abcdef01234567"; + +function report() { + return { + schemaVersion: "1.0", + reportId: "configured-worker-report", + generatedAt: "2026-08-22T19:00:00.000Z", + toolVersion: "0.2.0", + target: { path: "/tmp/acquired", commitSha: headSha }, + scanners: [], + rawFindingCount: 0, + findingCount: 0, + summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 100, + findings: [], + }; +} + +function leasedPrJob() { + return { + version: 1, + jobId: "b".repeat(32), + deliveryId: "delivery-configured-worker", + installationId: 42, + repository: "cmahmud/synsec", + headSha, + event: "pull_request", + baseSha: "abcdef0123456789abcdef0123456789abcdef01", + pullRequestNumber: 2, + createdAt: "2026-08-22T19:00:00.000Z", + attempts: 1, + status: "leased", + leaseUntil: "2026-08-22T19:05:00.000Z", + }; +} + +test("configured worker runs the existing scan engine path and publishes check plus optional SARIF", async () => { + const job = leasedPrJob(); + const completed = []; + const scanInputs = []; + const tokenPurposes = []; + const requests = []; + let cleanupCalls = 0; + const queue = { + async claimNext() { return job; }, + async release() { throw new Error("must not release successful job"); }, + async fail() { throw new Error("must not fail successful job"); }, + async complete(id) { completed.push(id); return true; }, + }; + const fakeFetch = async (url, init) => { + requests.push({ url, init }); + if (url.endsWith("/check-runs")) { + return new Response(JSON.stringify({ id: 123, status: "completed", conclusion: "success" }), { status: 201 }); + } + if (url.endsWith("/code-scanning/sarifs")) { + return new Response(JSON.stringify({ id: "sarif-upload-1" }), { status: 202 }); + } + throw new Error(`unexpected publication URL: ${url}`); + }; + + const result = await runConfiguredGitHubAppWorkerOnce({ + queue, + installationStore: { isRepositoryAllowed: async () => true }, + config: { scanners: ["opengrep"], parallelism: 1 }, + getInstallationToken: async (_installationId, purpose) => { + tokenPurposes.push(purpose); + return purpose === "acquire" ? "acquire-token" : "publish-token"; + }, + acquire: async (input) => ({ + repository: input.repository, + commitSha: input.commitSha, + workspace: "/tmp/acquired", + cleanup: async () => { cleanupCalls += 1; }, + }), + scan: async (input) => { + scanInputs.push(input); + return { + report: report(), + repositoryIndex: { version: 1, root: "/tmp/acquired", files: [] }, + statuses: [], + failures: [], + shouldFail: false, + }; + }, + publishSarif: true, + fetch: fakeFetch, + }); + + assert.equal(result.status, "completed"); + assert.deepEqual(completed, [job.jobId]); + assert.equal(scanInputs.length, 1); + assert.equal(scanInputs[0].rootPath, "/tmp/acquired"); + assert.equal(scanInputs[0].changedOnly, false); + assert.deepEqual(tokenPurposes, ["acquire", "publish"]); + assert.equal(requests.length, 2); + assert.equal(requests[0].url, "https://api.github.com/repos/cmahmud/synsec/check-runs"); + assert.equal(requests[1].url, "https://api.github.com/repos/cmahmud/synsec/code-scanning/sarifs"); + const checkBody = JSON.parse(requests[0].init.body); + assert.equal(checkBody.head_sha, headSha); + const sarifBody = JSON.parse(requests[1].init.body); + assert.equal(sarifBody.commit_sha, headSha); + assert.equal(sarifBody.ref, "refs/pull/2/head"); + assert.equal(requests.some((request) => request.url.includes("attacker.invalid")), false); + assert.equal(cleanupCalls, 1); +}); From eafbbec8b330095fb63a013e539f241abe3ec805 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:30:29 -0400 Subject: [PATCH 0343/1132] docs(github): describe retry-safe hosted worker composition --- docs/GITHUB_APP.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/GITHUB_APP.md b/docs/GITHUB_APP.md index 02a131b6..a8f62f1e 100644 --- a/docs/GITHUB_APP.md +++ b/docs/GITHUB_APP.md @@ -17,21 +17,23 @@ SynSec's GitHub App support is a transport and orchestration layer around the sa - installation-token exchange only through `https://api.github.com/app/installations//access_tokens` with redirects rejected; - token/API errors that do not echo the App JWT. -`@synsec/github/replay-store` provides a durable local delivery-id replay store suitable for a single host or multiple worker processes sharing one filesystem. It uses bounded delivery identifiers, SHA-256-derived filenames, restrictive marker permissions, fully written/fsynced temporary records, and an atomic hard-link claim so two concurrent processes cannot both accept the same delivery or observe a partially written canonical record. Retention is bounded between one hour and 30 days, expired markers can be pruned, and malformed existing records fail closed instead of being silently ignored. +`@synsec/github/replay-store` provides a durable local delivery-id replay store suitable for a single host or multiple worker processes sharing one filesystem. It uses bounded delivery identifiers, SHA-256-derived filenames, restrictive marker permissions, fully written/fsynced temporary records, and an atomic hard-link claim so two concurrent processes cannot both accept the same delivery or observe a partial canonical record. Retention is bounded between one hour and 30 days, expired markers can be pruned, and malformed existing records fail closed instead of being silently ignored. An accepted claim can also be released only when its exact delivery id and `receivedAt` still match the current unexpired marker; this lets a webhook handler return an error and allow GitHub retry after downstream durable processing fails without letting a stale worker delete a newer re-claim. `@synsec/github/installation-store` provides bounded durable installation authorization state. It persists only installation id, account identity/type, repository-selection mode, selected `owner/name` repository identifiers when selection is limited, suspension state, and update time. It deliberately has no fields for installation tokens, App private keys, webhook secrets, clone URLs, or repository credentials. Suspended or absent installations cannot authorize a repository scan. -`@synsec/github/installation-sync` verifies and normalizes installation-management payloads into the minimal authorization model. Creation, deletion, suspension, unsuspension, and selected-repository add/remove events update durable state without persisting GitHub URLs, permissions, tokens, or arbitrary payload fields. Repository deltas fail closed when stored installation/account state is missing or inconsistent. +`@synsec/github/installation-sync` verifies and normalizes installation-management payloads into the minimal authorization model. Creation, deletion, suspension, unsuspension, and selected-repository add/remove events update durable state without persisting GitHub URLs, permissions, tokens, or arbitrary payload fields. Repository deltas fail closed when stored installation/account state is missing or inconsistent. A fresh `created` event replaces any stale selected-repository list rather than inheriting authorization left from an older record. `@synsec/github/scan-queue` provides a bounded durable local queue for commit-pinned scan work. Jobs contain only delivery id, installation/repository identity, exact head/base commit identity, PR identity when applicable, queue timestamps, lease state, and retry count. They do not contain GitHub tokens, clone URLs, App credentials, scanner output, source snippets, or arbitrary outbound targets. Workers lease jobs for a bounded period; expired leases can be reclaimed, failed jobs are retained for operator visibility, and attempt counts are bounded. -`@synsec/github/app-handler` composes signature verification, replay claiming, installation-state synchronization, durable authorization, and queue dispatch in one tested boundary. Duplicate authenticated deliveries do not mutate installation state or enqueue duplicate work; installation-management events remain bookkeeping-only. +`@synsec/github/app-handler` composes signature verification, replay claiming, installation-state synchronization, durable authorization, and queue dispatch in one tested boundary. Duplicate authenticated deliveries do not mutate installation state or enqueue duplicate work; installation-management events remain bookkeeping-only. If durable synchronization or queue dispatch fails after an accepted replay claim, the handler releases exactly that still-current claim before propagating the error so the delivery can be retried instead of being silently consumed. `@synsec/github/repository-acquisition` materializes one exact commit from a strict `owner/name` identity through a fixed `https://github.com//.git` transport. It rejects URL-shaped repository identities before URL construction, disables system/global Git configuration and `file://` transport so local rewrite rules cannot redirect the request, keeps the installation token out of argv and repository config, skips Git LFS smudging/submodule initialization, checks out detached `FETCH_HEAD`, verifies the resulting HEAD against the requested SHA, and removes failed temporary workspaces. `@synsec/github/app-worker` consumes at most one leased queue job, rechecks installation authorization at execution time, acquires a short-lived token only for transport, scans the exact-commit workspace through an injected repository-scan runner, requires the resulting report to bind to the queued head SHA, obtains a fresh publication token, publishes through an injected GitHub transport, and acknowledges the queue only after publication succeeds. A repository removed or suspended after queueing is failed before credentials or source are acquired. Other worker failures return the job to the bounded retry queue. -These primitives still do **not** constitute a complete hosted GitHub App product by themselves. A minimal HTTPS server, concrete App-JWT/token wiring, the production scan-engine/publication composition, process/container isolation, operational secret management, setup UX, and shared transactional persistence for multi-host deployments remain required. +`@synsec/github/app-worker-runner` is the production-oriented local composition over that worker boundary. It runs the existing `runScanEngine()` against the acquired exact-commit workspace, builds a check from the normalized queue repository/head context, publishes through the fixed Checks API transport, and can upload the same commit-bound report as SARIF. Pull-request jobs currently use a full repository scan at this layer; SynSec does not invent a changed-file baseline from a branch name when the exact base commit has not also been acquired. + +These primitives still do **not** constitute a complete hosted GitHub App product by themselves. A minimal HTTPS server, concrete App-JWT/private-key configuration, process/container isolation, operational secret management, setup UX, and shared transactional persistence for multi-host deployments remain required. ## Webhook boundary @@ -39,7 +41,7 @@ Webhook consumers must preserve the raw request bytes until signature verificati After verification, callers should use the normalized event rather than payload URLs as the security boundary. Repository checkout and API publication derive from validated GitHub installation/repository identity through fixed GitHub transports. A `clone_url`, `html_url`, scanner-provided URL, finding text, or other repository-controlled field must never become an arbitrary outbound target. -The preferred local composition is `handleGitHubAppWebhook()`: signature verification and event normalization happen before the replay claim; duplicate authenticated deliveries stop before synchronization/dispatch; installation-management events synchronize durable authorization state; and scan-bearing events must pass `isRepositoryAllowed()` before queueing. Installation creation/removal and repository-selection changes never authorize immediate scanner execution by themselves. +The preferred local composition is `handleGitHubAppWebhook()`: signature verification and event normalization happen before the replay claim; duplicate authenticated deliveries stop before synchronization/dispatch; installation-management events synchronize durable authorization state; and scan-bearing events must pass `isRepositoryAllowed()` before queueing. Installation creation/removal and repository-selection changes never authorize immediate scanner execution by themselves. A transient durable-processing error releases only the handler's exact accepted replay claim and is then propagated so the hosting layer can return failure to GitHub and receive a retry. ## Queue and worker boundary @@ -47,7 +49,7 @@ Queue records are commit-pinned descriptors, not checkout instructions supplied Repository acquisition accepts only a strict validated `owner/name`, installation id context supplied by the worker, and exact commit SHA. The acquisition transport is fixed to `github.com`, and Git system/global configuration is disabled to prevent `url.*.insteadOf` or other host-local configuration from silently widening the destination. Missing/unavailable commit provenance is a job failure rather than permission to substitute the default branch, a nearby commit, a webhook clone URL, or a scanner-suggested URL. -The scanner receives the checked-out workspace and queue descriptor, not the installation token. Before publication, the worker requires `report.target.commitSha` to equal the queued head SHA and obtains a fresh installation token for the publication operation. Successful workspace cleanup occurs after scan/publication handling. +The scanner receives the checked-out workspace and queue descriptor, not the installation token. Before publication, the worker requires `report.target.commitSha` to equal the queued head SHA and obtains a fresh installation token for the publication operation. `runConfiguredGitHubAppWorkerOnce()` then uses the same repository scan engine as the CLI/Action and fixed-host Checks/SARIF publishers. Successful workspace cleanup occurs after scan/publication handling. Leases prevent normal duplicate processing but the local queue is not a multi-host transactional lock. Horizontally scaled workers should use a shared queue with atomic claim/lease semantics. @@ -55,7 +57,7 @@ Leases prevent normal duplicate processing but the local queue is not a multi-ho `createGitHubAppJwt()` signs a short-lived RS256 token from the configured App id and private key. The private key belongs to the hosted transport/runtime and must never be exposed to scanners, reports, repository code, workflow prompts, logs, or persisted finding evidence. -`createGitHubInstallationToken()` exchanges that JWT at GitHub's fixed API host. It requests no additional repository selection or permission expansion in the token request body. Resulting installation tokens should be kept only for the operation lifetime and passed only to narrowly scoped transport functions. Repository acquisition supplies its token to Git only through a child-process environment and disables inherited Git configuration; publication should likewise keep credentials outside scanner inputs and reports. +`createGitHubInstallationToken()` exchanges that JWT at GitHub's fixed API host. It requests no additional repository selection or permission expansion in the token request body. Resulting installation tokens should be kept only for the operation lifetime and passed only to narrowly scoped transport functions. Repository acquisition supplies its token to Git only through a child-process environment and disables inherited Git configuration; publication likewise keeps credentials outside scanner inputs and reports. A hosted service should validate its configured GitHub App permissions explicitly and fail closed when required permissions are absent rather than requesting broader permissions dynamically. @@ -65,7 +67,7 @@ A production hosted App still needs: 1. a minimal HTTPS webhook endpoint that preserves raw request bytes and invokes `handleGitHubAppWebhook()`; 2. concrete App-JWT/private-key configuration and installation-token providers for the worker; -3. production composition from `runNextGitHubAppScanJob()` into `runScanEngine()` and the existing Checks/SARIF publishers; +3. exact-base acquisition/baseline composition for changed-file PR scans when that optimization is enabled; 4. process/container workspace isolation around scans, including OS CPU/memory limits and network policy; 5. explicit retention policy for reports, failed queue records, temporary artifacts, and operator diagnostics; 6. installation/setup UX, permission diagnostics, and recovery for configuration errors; From 6d59b39fce0f49247af19ca65b2c03c23e5e3d0d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:31:02 -0400 Subject: [PATCH 0344/1132] docs(roadmap): refresh hosted app and worker progress --- docs/ROADMAP.md | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 45f43cc0..6480538c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -115,9 +115,17 @@ These workflows operate on repository evidence and scanner results. They are not - [x] GitHub App HMAC webhook verification and bounded event normalization - [x] GitHub App short-lived JWT and fixed-host installation-token exchange primitives - [x] Explicit GitHub App scan-trigger allowlist for push and selected PR lifecycle events -- [ ] Hosted GitHub App webhook/service orchestration -- [ ] Repository installation/setup flow and durable installation state -- [ ] Durable webhook delivery replay protection +- [x] Durable webhook delivery replay protection with retry-safe claim release +- [x] Durable local installation authorization state +- [x] Installation/repository-selection event synchronization into authorization state +- [x] Replay-protected authorization-gated local webhook handler +- [x] Installation-scoped exact-commit GitHub repository acquisition primitive +- [x] Authorization-aware local scan worker with commit-bound report verification +- [x] Local worker composition through the existing scan engine and Checks/SARIF publishers +- [ ] Minimal hosted HTTPS service and concrete App credential/token wiring +- [ ] Repository installation/setup UX and permission diagnostics +- [ ] Exact-base acquisition for hosted changed-file PR scans +- [ ] Transactional shared App state/queue for multi-host deployment - [ ] Optional remediation pull requests with explicit approval - [ ] GitLab and Bitbucket adapters @@ -127,7 +135,7 @@ For PRs without an explicit baseline, the Action can scan the exact event-provid The Action also writes the completed JSON report under `RUNNER_TEMP` and exposes its path. The scheduled workflow template retains that report only through an explicit caller-owned artifact step with a visible retention period; SynSec does not silently persist security evidence. -GitHub App support is currently a hosting foundation, not a complete hosted service. Verified webhooks never derive scanner targets from payload-controlled URLs, installation-management events are not scan triggers, and installation-token exchange is fixed to `api.github.com`. A production service still needs durable installation/delivery state, isolated commit-pinned checkout workers, queueing, and setup UX. See [GITHUB_APP.md](./GITHUB_APP.md). +GitHub App support now has a coherent local hosting path rather than disconnected primitives: verified deliveries are replay-claimed, installation-management events synchronize bounded authorization state, scan-bearing events require authorization before durable queueing, workers recheck authorization at execution time, exact queued commits are acquired through a fixed GitHub transport, and `runScanEngine()` output must bind to that exact head before Checks/SARIF publication. Failed durable webhook processing releases only the exact still-current replay claim so GitHub can retry. This is still not a deployable hosted service: the HTTPS process, App credential configuration, OS/container isolation, setup UX, and shared transactional storage remain open. See [GITHUB_APP.md](./GITHUB_APP.md). See [GITHUB.md](./GITHUB.md) for the current Actions integration contract and security boundaries. @@ -153,16 +161,18 @@ The local history store retains only report identifiers, timestamps, commit/bran - [x] Scanner subprocess timeout, abort, output-memory, and kill-escalation bounds - [x] Credential-minimized default scanner subprocess environment +- [x] Bounded durable local scan-job queue with leases/retries +- [x] Commit-pinned temporary checkout workspace acquisition and cleanup +- [x] Authorization recheck before worker credential/source acquisition - [ ] Containerized scanner images -- [ ] Job queue -- [ ] Per-scan workspace isolation +- [ ] Per-scan process/container workspace isolation - [ ] OS/container CPU and memory limits - [ ] Network policy - [ ] Horizontal workers - [ ] Artifact retention policy -- [ ] Secrets/credential minimization for private repository clones and filesystem credentials +- [ ] Filesystem credential minimization for private-repository scan workspaces -External scanners no longer inherit the full parent process environment by default. SynSec passes a small execution/locale/certificate allowlist and requires an explicit environment when a scanner genuinely needs additional variables. This prevents ambient CI/App/cloud tokens and credential-bearing proxy variables from being handed to scanner binaries automatically. It is not a complete filesystem credential sandbox: container/workspace isolation and private-clone credential minimization remain open work. +External scanners no longer inherit the full parent process environment by default. SynSec passes a small execution/locale/certificate allowlist and requires an explicit environment when a scanner genuinely needs additional variables. Hosted GitHub acquisition uses a separate short-lived transport credential, keeps it out of scanner inputs and Git argv, disables inherited Git configuration, and removes temporary checkout workspaces after handling. This materially narrows credential exposure but is not a complete sandbox: scanner processes still need container/workspace isolation, OS resource limits, network policy, and stronger filesystem credential separation before a production multi-tenant worker deployment. ## Later — explicitly authorized external assessment From 65d309ce8bf085cfec10b7f9351d6077497fa680 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:31:42 -0400 Subject: [PATCH 0345/1132] feat(github): add bounded hosted app webhook HTTP handler --- packages/github/src/app-http.ts | 141 ++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 packages/github/src/app-http.ts diff --git a/packages/github/src/app-http.ts b/packages/github/src/app-http.ts new file mode 100644 index 00000000..cc762d04 --- /dev/null +++ b/packages/github/src/app-http.ts @@ -0,0 +1,141 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + handleGitHubAppWebhook, + type GitHubAppInstallationStore, + type GitHubAppWebhookHandleResult, + type GitHubWebhookReplayManager, +} from "./app-handler.js"; +import type { GitHubScanJobEnqueuer } from "./app-dispatch.js"; + +const MAX_WEBHOOK_BODY_BYTES = 10 * 1024 * 1024; +const DEFAULT_PATH = "/github/webhooks"; + +export interface GitHubAppWebhookHttpOptions { + webhookSecret: string; + replayStore: GitHubWebhookReplayManager; + installationStore: GitHubAppInstallationStore; + queue: GitHubScanJobEnqueuer; + path?: string; + onError?: (error: unknown) => void; +} + +function header(request: IncomingMessage, name: string): string | undefined { + const value = request.headers[name]; + if (Array.isArray(value)) return value[0]?.trim() || undefined; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function sendJson(response: ServerResponse, statusCode: number, payload: Record): void { + const body = `${JSON.stringify(payload)}\n`; + response.statusCode = statusCode; + response.setHeader("content-type", "application/json; charset=utf-8"); + response.setHeader("content-length", Buffer.byteLength(body)); + response.setHeader("cache-control", "no-store"); + response.end(body); +} + +function resultStatus(result: GitHubAppWebhookHandleResult): number { + return result.status === "queued" ? 202 : 200; +} + +function publicResult(result: GitHubAppWebhookHandleResult): Record { + if (result.status === "queued") return { status: "queued" }; + if (result.status === "installation_updated") return { status: "installation_updated" }; + if (result.status === "installation_removed") return { status: "installation_removed" }; + if (result.status === "rejected") return { status: "ignored", reason: "installation_not_authorized" }; + return { status: "ignored", reason: result.reason }; +} + +async function readBoundedBody(request: IncomingMessage): Promise { + const declared = header(request, "content-length"); + if (declared !== undefined) { + const length = Number(declared); + if (!Number.isSafeInteger(length) || length < 0) throw new Error("invalid_content_length"); + if (length > MAX_WEBHOOK_BODY_BYTES) throw new Error("body_too_large"); + } + + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.byteLength; + if (bytes > MAX_WEBHOOK_BODY_BYTES) throw new Error("body_too_large"); + chunks.push(buffer); + } + return Buffer.concat(chunks, bytes); +} + +/** + * Create a framework-free GitHub App webhook endpoint suitable for mounting behind HTTPS. + * + * The handler accepts only POST requests to one configured path, bounds the raw body before + * signature processing, requires GitHub's event/delivery/signature headers, and delegates to the + * replay-protected durable App handler. Internal error details are never returned to the caller. + * A durable-processing failure returns 500 only after the App handler has attempted to release the + * exact replay claim, allowing GitHub to retry the delivery instead of losing it. + */ +export function createGitHubAppWebhookHttpHandler(options: GitHubAppWebhookHttpOptions) { + const path = options.path?.trim() || DEFAULT_PATH; + if (!path.startsWith("/") || path.includes("?") || path.includes("#")) { + throw new Error("GitHub App webhook path must be an absolute path without query or fragment components."); + } + if (!options.webhookSecret.trim()) throw new Error("GitHub App webhook secret is required."); + + return async function githubAppWebhookHttpHandler( + request: IncomingMessage, + response: ServerResponse, + ): Promise { + const requestPath = (request.url ?? "").split("?", 1)[0]; + if (requestPath !== path) { + sendJson(response, 404, { status: "not_found" }); + return; + } + if (request.method !== "POST") { + response.setHeader("allow", "POST"); + sendJson(response, 405, { status: "method_not_allowed" }); + return; + } + const contentType = header(request, "content-type")?.toLowerCase(); + if (!contentType?.startsWith("application/json")) { + sendJson(response, 415, { status: "unsupported_media_type" }); + return; + } + + const signatureHeader = header(request, "x-hub-signature-256"); + const eventName = header(request, "x-github-event"); + const deliveryId = header(request, "x-github-delivery"); + if (!signatureHeader || !eventName || !deliveryId) { + sendJson(response, 400, { status: "bad_request" }); + return; + } + + let body: Buffer; + try { + body = await readBoundedBody(request); + } catch (error) { + if (error instanceof Error && error.message === "body_too_large") { + sendJson(response, 413, { status: "payload_too_large" }); + return; + } + sendJson(response, 400, { status: "bad_request" }); + return; + } + + try { + const result = await handleGitHubAppWebhook({ + body, + signatureHeader, + webhookSecret: options.webhookSecret, + eventName, + deliveryId, + replayStore: options.replayStore, + installationStore: options.installationStore, + queue: options.queue, + }); + sendJson(response, resultStatus(result), publicResult(result)); + } catch (error) { + options.onError?.(error); + sendJson(response, 500, { status: "error" }); + } + }; +} From 46eba11936700413b3a4981c8c0c419133e9f74f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:31:55 -0400 Subject: [PATCH 0346/1132] build(github): export hosted app HTTP handler --- packages/github/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/github/package.json b/packages/github/package.json index 07fc18ce..8de0fad8 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -15,6 +15,7 @@ "./app-intake": "./dist/app-intake.js", "./app-dispatch": "./dist/app-dispatch.js", "./app-handler": "./dist/app-handler.js", + "./app-http": "./dist/app-http.js", "./app-worker": "./dist/app-worker.js", "./app-worker-runner": "./dist/app-worker-runner.js", "./replay-store": "./dist/replay-store.js", From aadf8b1b3b8c2fe019316f63cb52cdd38e2d069e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:32:16 -0400 Subject: [PATCH 0347/1132] test(github): cover bounded app webhook HTTP endpoint --- tests/github-app-http.test.mjs | 165 +++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 tests/github-app-http.test.mjs diff --git a/tests/github-app-http.test.mjs b/tests/github-app-http.test.mjs new file mode 100644 index 00000000..848bb008 --- /dev/null +++ b/tests/github-app-http.test.mjs @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import test from "node:test"; + +import { createGitHubAppWebhookHttpHandler } from "@synsec/github/app-http"; + +const secret = "synsec-http-webhook-secret"; +const headSha = "0123456789abcdef0123456789abcdef01234567"; +const baseSha = "abcdef0123456789abcdef0123456789abcdef01"; + +function signature(body) { + return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; +} + +function pullRequestBody() { + return Buffer.from(JSON.stringify({ + action: "synchronize", + installation: { id: 7 }, + repository: { full_name: "cmahmud/synsec", clone_url: "https://attacker.invalid/repo.git" }, + number: 2, + pull_request: { head: { sha: headSha }, base: { sha: baseSha } }, + })); +} + +class ReplayStore { + constructor() { this.claims = new Map(); } + async claim(deliveryId) { + const existing = this.claims.get(deliveryId); + if (existing) return { accepted: false, deliveryId, receivedAt: existing }; + const receivedAt = "2026-08-22T19:10:00.000Z"; + this.claims.set(deliveryId, receivedAt); + return { accepted: true, deliveryId, receivedAt }; + } + async release(deliveryId, receivedAt) { + if (this.claims.get(deliveryId) !== receivedAt) return false; + this.claims.delete(deliveryId); + return true; + } +} + +class InstallationStore { + async get() { return undefined; } + async put(input) { return { version: 1, repositories: [], updatedAt: new Date().toISOString(), ...input }; } + async remove() { return false; } + async isRepositoryAllowed(id, repository) { return id === 7 && repository === "cmahmud/synsec"; } +} + +function request(body, overrides = {}) { + const headers = { + "content-type": "application/json", + "content-length": String(body.byteLength), + "x-hub-signature-256": signature(body), + "x-github-event": "pull_request", + "x-github-delivery": "delivery-http-1", + ...(overrides.headers ?? {}), + }; + return { + url: overrides.url ?? "/github/webhooks", + method: overrides.method ?? "POST", + headers, + async *[Symbol.asyncIterator]() { + if (body.byteLength > 0) yield body; + }, + }; +} + +function response() { + return { + statusCode: 0, + headers: new Map(), + body: "", + setHeader(name, value) { this.headers.set(String(name).toLowerCase(), value); }, + end(body = "") { this.body = String(body); }, + }; +} + +test("HTTP webhook endpoint queues an authorized commit-pinned delivery with a minimal response", async () => { + const replayStore = new ReplayStore(); + const queued = []; + const handler = createGitHubAppWebhookHttpHandler({ + webhookSecret: secret, + replayStore, + installationStore: new InstallationStore(), + queue: { + async enqueue(input) { + queued.push(input); + return { version: 1, jobId: "c".repeat(32), ...input, createdAt: new Date().toISOString(), attempts: 0, status: "pending" }; + }, + }, + }); + const body = pullRequestBody(); + const res = response(); + await handler(request(body), res); + + assert.equal(res.statusCode, 202); + assert.deepEqual(JSON.parse(res.body), { status: "queued" }); + assert.equal(res.headers.get("cache-control"), "no-store"); + assert.equal(queued.length, 1); + assert.equal(queued[0].headSha, headSha); + assert.equal(JSON.stringify(queued[0]).includes("attacker.invalid"), false); +}); + +test("HTTP webhook endpoint rejects wrong methods, media types, missing headers, and oversized bodies before durable processing", async () => { + let claims = 0; + const replayStore = new ReplayStore(); + replayStore.claim = async (...args) => { claims += 1; return ReplayStore.prototype.claim.apply(replayStore, args); }; + const handler = createGitHubAppWebhookHttpHandler({ + webhookSecret: secret, + replayStore, + installationStore: new InstallationStore(), + queue: { async enqueue() { throw new Error("must not enqueue"); } }, + }); + const body = pullRequestBody(); + + const methodRes = response(); + await handler(request(body, { method: "GET" }), methodRes); + assert.equal(methodRes.statusCode, 405); + + const mediaRes = response(); + await handler(request(body, { headers: { "content-type": "text/plain" } }), mediaRes); + assert.equal(mediaRes.statusCode, 415); + + const missingRes = response(); + await handler(request(body, { headers: { "x-github-delivery": "" } }), missingRes); + assert.equal(missingRes.statusCode, 400); + + const largeRes = response(); + await handler(request(Buffer.alloc(0), { headers: { "content-length": String(10 * 1024 * 1024 + 1) } }), largeRes); + assert.equal(largeRes.statusCode, 413); + assert.equal(claims, 0); +}); + +test("HTTP webhook endpoint hides durable failure details and leaves the delivery retryable", async () => { + const replayStore = new ReplayStore(); + const errors = []; + let attempts = 0; + const handler = createGitHubAppWebhookHttpHandler({ + webhookSecret: secret, + replayStore, + installationStore: new InstallationStore(), + queue: { + async enqueue(input) { + attempts += 1; + if (attempts === 1) throw new Error("database password=super-secret queue unavailable"); + return { version: 1, jobId: "d".repeat(32), ...input, createdAt: new Date().toISOString(), attempts: 0, status: "pending" }; + }, + }, + onError: (error) => errors.push(error), + }); + const body = pullRequestBody(); + const req = request(body, { headers: { "x-github-delivery": "delivery-http-retry" } }); + + const first = response(); + await handler(req, first); + assert.equal(first.statusCode, 500); + assert.deepEqual(JSON.parse(first.body), { status: "error" }); + assert.equal(first.body.includes("super-secret"), false); + assert.equal(replayStore.claims.has("delivery-http-retry"), false); + assert.equal(errors.length, 1); + + const second = response(); + await handler(request(body, { headers: { "x-github-delivery": "delivery-http-retry" } }), second); + assert.equal(second.statusCode, 202); + assert.equal(attempts, 2); +}); From cb7f3f02322ffd8fc3d777626829f52026b7a1c7 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:33:18 -0400 Subject: [PATCH 0348/1132] docs(github): document bounded webhook HTTP boundary --- docs/GITHUB_APP.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/GITHUB_APP.md b/docs/GITHUB_APP.md index a8f62f1e..747dffd7 100644 --- a/docs/GITHUB_APP.md +++ b/docs/GITHUB_APP.md @@ -27,13 +27,15 @@ SynSec's GitHub App support is a transport and orchestration layer around the sa `@synsec/github/app-handler` composes signature verification, replay claiming, installation-state synchronization, durable authorization, and queue dispatch in one tested boundary. Duplicate authenticated deliveries do not mutate installation state or enqueue duplicate work; installation-management events remain bookkeeping-only. If durable synchronization or queue dispatch fails after an accepted replay claim, the handler releases exactly that still-current claim before propagating the error so the delivery can be retried instead of being silently consumed. +`@synsec/github/app-http` provides a framework-free Node HTTP request handler for mounting behind an HTTPS terminator or server. It accepts only POST requests at one configured path, requires JSON plus GitHub signature/event/delivery headers, bounds the raw body to 10 MiB before durable handling, emits `no-store` minimal responses, returns `202` only for queued scans, and does not reflect internal failure details. Durable-processing failures surface as generic `500` responses after replay-claim release so GitHub can retry. + `@synsec/github/repository-acquisition` materializes one exact commit from a strict `owner/name` identity through a fixed `https://github.com//.git` transport. It rejects URL-shaped repository identities before URL construction, disables system/global Git configuration and `file://` transport so local rewrite rules cannot redirect the request, keeps the installation token out of argv and repository config, skips Git LFS smudging/submodule initialization, checks out detached `FETCH_HEAD`, verifies the resulting HEAD against the requested SHA, and removes failed temporary workspaces. `@synsec/github/app-worker` consumes at most one leased queue job, rechecks installation authorization at execution time, acquires a short-lived token only for transport, scans the exact-commit workspace through an injected repository-scan runner, requires the resulting report to bind to the queued head SHA, obtains a fresh publication token, publishes through an injected GitHub transport, and acknowledges the queue only after publication succeeds. A repository removed or suspended after queueing is failed before credentials or source are acquired. Other worker failures return the job to the bounded retry queue. `@synsec/github/app-worker-runner` is the production-oriented local composition over that worker boundary. It runs the existing `runScanEngine()` against the acquired exact-commit workspace, builds a check from the normalized queue repository/head context, publishes through the fixed Checks API transport, and can upload the same commit-bound report as SARIF. Pull-request jobs currently use a full repository scan at this layer; SynSec does not invent a changed-file baseline from a branch name when the exact base commit has not also been acquired. -These primitives still do **not** constitute a complete hosted GitHub App product by themselves. A minimal HTTPS server, concrete App-JWT/private-key configuration, process/container isolation, operational secret management, setup UX, and shared transactional persistence for multi-host deployments remain required. +These primitives now form an end-to-end local hosting chain, but they still do **not** constitute a complete hosted GitHub App product by themselves. TLS/runtime deployment, concrete App-JWT/private-key configuration, process/container isolation, operational secret management, setup UX, and shared transactional persistence for multi-host deployments remain required. ## Webhook boundary @@ -41,7 +43,7 @@ Webhook consumers must preserve the raw request bytes until signature verificati After verification, callers should use the normalized event rather than payload URLs as the security boundary. Repository checkout and API publication derive from validated GitHub installation/repository identity through fixed GitHub transports. A `clone_url`, `html_url`, scanner-provided URL, finding text, or other repository-controlled field must never become an arbitrary outbound target. -The preferred local composition is `handleGitHubAppWebhook()`: signature verification and event normalization happen before the replay claim; duplicate authenticated deliveries stop before synchronization/dispatch; installation-management events synchronize durable authorization state; and scan-bearing events must pass `isRepositoryAllowed()` before queueing. Installation creation/removal and repository-selection changes never authorize immediate scanner execution by themselves. A transient durable-processing error releases only the handler's exact accepted replay claim and is then propagated so the hosting layer can return failure to GitHub and receive a retry. +The preferred local HTTP boundary is `createGitHubAppWebhookHttpHandler()` mounted behind HTTPS. It bounds the body while reading it and delegates durable handling to `handleGitHubAppWebhook()`: signature verification and event normalization happen before the replay claim; duplicate authenticated deliveries stop before synchronization/dispatch; installation-management events synchronize durable authorization state; and scan-bearing events must pass `isRepositoryAllowed()` before queueing. Installation creation/removal and repository-selection changes never authorize immediate scanner execution by themselves. A transient durable-processing error releases only the handler's exact accepted replay claim and is then propagated so the HTTP layer can return failure to GitHub and receive a retry. ## Queue and worker boundary @@ -65,7 +67,7 @@ A hosted service should validate its configured GitHub App permissions explicitl A production hosted App still needs: -1. a minimal HTTPS webhook endpoint that preserves raw request bytes and invokes `handleGitHubAppWebhook()`; +1. TLS/runtime deployment around the bounded HTTP handler, with request/server timeouts and operational health handling; 2. concrete App-JWT/private-key configuration and installation-token providers for the worker; 3. exact-base acquisition/baseline composition for changed-file PR scans when that optimization is enabled; 4. process/container workspace isolation around scans, including OS CPU/memory limits and network policy; From 324faebb0f882440ba60ecc5676ebfe14e5899c8 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:33:55 -0400 Subject: [PATCH 0349/1132] docs(roadmap): record bounded hosted webhook HTTP handler --- docs/ROADMAP.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6480538c..d7e1f711 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -119,10 +119,11 @@ These workflows operate on repository evidence and scanner results. They are not - [x] Durable local installation authorization state - [x] Installation/repository-selection event synchronization into authorization state - [x] Replay-protected authorization-gated local webhook handler +- [x] Bounded framework-free webhook HTTP handler for deployment behind HTTPS - [x] Installation-scoped exact-commit GitHub repository acquisition primitive - [x] Authorization-aware local scan worker with commit-bound report verification - [x] Local worker composition through the existing scan engine and Checks/SARIF publishers -- [ ] Minimal hosted HTTPS service and concrete App credential/token wiring +- [ ] Hosted TLS/runtime service and concrete App credential/token wiring - [ ] Repository installation/setup UX and permission diagnostics - [ ] Exact-base acquisition for hosted changed-file PR scans - [ ] Transactional shared App state/queue for multi-host deployment @@ -135,7 +136,7 @@ For PRs without an explicit baseline, the Action can scan the exact event-provid The Action also writes the completed JSON report under `RUNNER_TEMP` and exposes its path. The scheduled workflow template retains that report only through an explicit caller-owned artifact step with a visible retention period; SynSec does not silently persist security evidence. -GitHub App support now has a coherent local hosting path rather than disconnected primitives: verified deliveries are replay-claimed, installation-management events synchronize bounded authorization state, scan-bearing events require authorization before durable queueing, workers recheck authorization at execution time, exact queued commits are acquired through a fixed GitHub transport, and `runScanEngine()` output must bind to that exact head before Checks/SARIF publication. Failed durable webhook processing releases only the exact still-current replay claim so GitHub can retry. This is still not a deployable hosted service: the HTTPS process, App credential configuration, OS/container isolation, setup UX, and shared transactional storage remain open. See [GITHUB_APP.md](./GITHUB_APP.md). +GitHub App support now has a coherent local hosting path rather than disconnected primitives: a bounded HTTP handler accepts raw deliveries behind HTTPS; verified deliveries are replay-claimed; installation-management events synchronize bounded authorization state; scan-bearing events require authorization before durable queueing; workers recheck authorization at execution time; exact queued commits are acquired through a fixed GitHub transport; and `runScanEngine()` output must bind to that exact head before Checks/SARIF publication. Failed durable webhook processing releases only the exact still-current replay claim so GitHub can retry. This is still not a deployable hosted service: TLS/runtime deployment, concrete App credential configuration, OS/container isolation, setup UX, and shared transactional storage remain open. See [GITHUB_APP.md](./GITHUB_APP.md). See [GITHUB.md](./GITHUB.md) for the current Actions integration contract and security boundaries. From 116a9bbd086b6027891b1c372f74e843a71f3a54 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:34:05 -0400 Subject: [PATCH 0350/1132] docs: refresh hosted app status summary --- README.md | 369 +----------------------------------------------------- 1 file changed, 1 insertion(+), 368 deletions(-) diff --git a/README.md b/README.md index 791854f6..311c8dd0 100644 --- a/README.md +++ b/README.md @@ -1,368 +1 @@ -# SynSec - -SynSec is a repository-first security scanner that combines mature open-source security engines into one normalized, correlated report. - -Instead of replacing tools such as Opengrep, Trivy, Betterleaks, OSV-Scanner, Grype, Checkov, Syft, and OpenSSF Scorecard, SynSec runs them through a common adapter layer, merges overlapping results, preserves supporting artifacts such as SBOMs, adds repository context, tracks changes against baselines, exports developer-friendly reports, and can optionally send selected findings through an OpenAI-compatible model router for a separate review pass. - -> **Current release line:** v0.2 development MVP. The repository is usable for local testing, but scanner adapters and report schemas may still change before v1.0. - -## What works now - -- Multi-scanner repository scans with bounded concurrency. -- Scanner failure isolation: one broken engine does not destroy the whole scan. -- Protection against false "clean" reports when no scanner successfully ran. -- Opengrep SAST integration. -- Betterleaks secret scanning, with Gitleaks retained as an optional fallback. -- OSV-Scanner dependency analysis. -- Trivy vulnerability, secret, and misconfiguration analysis. -- Grype dependency/package analysis. -- Checkov IaC analysis. -- Syft SBOM generation with normalized package, PURL, license, and location metadata. -- OpenSSF Scorecard repository-posture analysis. -- Generic SARIF 2.1 import for bringing third-party scanner findings into SynSec. -- Scanner-independent finding and artifact schemas. -- Deterministic cross-scanner correlation and deduplication. -- Repository language/framework inventory. -- Git commit, branch, and remote metadata discovery with credential redaction. -- Changed-file scan scope for pull-request and incremental workflows, with direct narrowing for supported scanners. -- Versioned JSON reports. -- Self-contained HTML security dashboard. -- SARIF 2.1.0 output for code-scanning systems. -- Baselines with new/fixed/persisting finding tracking. -- Configurable CI failure thresholds. -- Explicit opt-in AI finding review through an OpenAI-compatible endpoint. -- A seven-question AI review gate that keeps scanner evidence separate from model inference. -- Capability-scoped defensive review workflows for repository, dependency, secret, and infrastructure findings. - -## Quick start - -Requirements: - -- Node.js 20 or newer (Node 24 recommended) -- npm -- at least one supported scanner binary in `PATH` - -```bash -git clone https://github.com/cmahmud/synsec.git -cd synsec -npm install -npm run build - -# See which engines are installed -npm run synsec -- doctor . - -# Scan a repository -npm run synsec -- scan /path/to/repository -``` - -See [`docs/INSTALL.md`](docs/INSTALL.md) for scanner installation notes. - -SynSec skips selected engines that are not installed and reports the missing coverage. If **none** of the selected engines can run, the scan fails instead of returning a misleading 100/100 score. - -A normal scan writes: - -```text -.synsec/ -├── report.json -├── report.html -└── report.sarif -``` - -The JSON report can also contain scanner artifacts such as a normalized Syft SBOM. Open `report.html` locally for the dashboard. - -## Commands - -```text -synsec init [path] -synsec doctor [path] -synsec scan [options] -synsec review [options] -synsec import-sarif [options] -synsec workflows -synsec render -synsec baseline [destination] -synsec version -``` - -Useful scan options: - -```text ---scanners opengrep,betterleaks,trivy ---parallel 3 ---timeout 900 ---changed ---changed-base main ---fail-on high ---baseline .synsec/baseline.json ---json ---no-write -``` - -Create a starter configuration with: - -```bash -npm run synsec -- init . -``` - -That creates `synsec.config.json`. - -## Default configuration - -```json -{ - "schemaVersion": 1, - "scanners": [ - "opengrep", - "betterleaks", - "osv-scanner", - "trivy", - "grype", - "checkov", - "syft", - "scorecard" - ], - "parallelism": 3, - "timeoutMs": 900000, - "failOn": "none", - "reports": { - "json": ".synsec/report.json", - "html": ".synsec/report.html", - "sarif": ".synsec/report.sarif" - }, - "ai": { - "enabled": false, - "provider": "openai-compatible", - "sendSourceContext": false - } -} -``` - -`failOn` can be `critical`, `high`, `medium`, `low`, `info`, `unknown`, or `none`. When a threshold is configured, a scan containing that severity or higher exits with code `2`, which is useful in CI. - -## Scanner engines - -| Engine | SynSec ID | Purpose | Default | -| --- | --- | --- | --- | -| Opengrep | `opengrep` | SAST / taint-aware static analysis | yes | -| Betterleaks | `betterleaks` | secrets and Git history | yes | -| Gitleaks | `gitleaks` | secrets and Git history fallback | no | -| OSV-Scanner | `osv-scanner` | open-source dependency vulnerabilities | yes | -| Trivy | `trivy` | dependencies, secrets, IaC/misconfiguration | yes | -| Grype | `grype` | package/dependency vulnerabilities | yes | -| Checkov | `checkov` | infrastructure-as-code | yes | -| Syft | `syft` | software bill of materials / package inventory | yes | -| OpenSSF Scorecard | `scorecard` | repository security posture | yes | - -Betterleaks is preferred for new installs because it is the actively developed successor maintained by the Gitleaks team. SynSec does **not** enable Betterleaks live credential validation; the adapter performs repository scanning with redacted report output only. - -Syft is an artifact-producing scanner in SynSec. It does not manufacture vulnerability findings: its package inventory is preserved as an SBOM artifact in the report and can be used by later dependency/reachability workflows. - -OpenSSF Scorecard results are treated as repository-posture findings rather than definitive vulnerabilities. Perfect 10/10 checks are not manufactured into findings; non-perfect checks retain their own score and reason as metadata. - -The engines stay separate projects with their own licenses. SynSec invokes installed binaries and parses their machine-readable output rather than copying their source into this repository. - -## Changed-file scans - -For pull-request or incremental analysis, SynSec can scope a report to files changed since a Git base ref: - -```bash -npm run synsec -- scan . --changed --changed-base main -``` - -When `--changed-base` is omitted, SynSec uses the GitHub pull-request base branch when `GITHUB_BASE_REF` is available and otherwise falls back to `HEAD~1`. - -The report records the scope and changed file list. File-located findings outside that diff are omitted, while repository-level findings that do not map to one file are retained. Opengrep and Betterleaks currently narrow execution directly to the changed files; other scanners may still perform their normal repository analysis before SynSec filters file-located results. This distinction is intentional so the report does not imply that every underlying engine has a native incremental mode. - -## Importing SARIF - -SynSec can ingest SARIF 2.1 output from another scanner and normalize it into the same finding/report model: - -```bash -npm run synsec -- import-sarif external-results.sarif --root . -``` - -By default this writes `.synsec/imported-report.json` and an adjacent HTML report. The importer preserves rule IDs, locations, severity, confidence when present, common identifiers, remediation text, source tool version, and a native partial fingerprint when supplied. - -This is an import path, not a command-execution plugin: SynSec reads the SARIF document and does not execute the producing scanner. - -## Correlation - -Raw scanner output is not the product. SynSec converts each result into a common model containing, where available: - -- category and severity; -- confidence; -- scanner and rule ID; -- file/line/column; -- CVE, CWE, GHSA, and OSV identifiers; -- evidence that is safe to retain; -- remediation guidance; -- scanner-specific metadata; -- native scanner fingerprint. - -SynSec then computes its own correlation fingerprint. This matters because two scanners often use different rule IDs, titles, and native fingerprints for the same issue. - -Current v0.2 correlation can merge: - -- dependency findings sharing advisory identifiers and package identity; -- secret findings at the same file/line without hashing or retaining the secret; -- SAST findings sharing file/line/CWE; -- conservative scanner-aware exact matches when stronger evidence is unavailable. - -The user sees one logical issue with multiple supporting sources instead of several copies of the same alert. - -## Baselines - -After a scan: - -```bash -npm run synsec -- baseline .synsec/report.json -``` - -A later scan can compare against it: - -```bash -npm run synsec -- scan . --baseline .synsec/baseline.json -``` - -The new report tracks new, fixed, and persisting findings. This makes SynSec useful as a regression detector rather than only a one-time scanner. - -## Optional AI review - -AI is a **second-pass reviewer**, not the source of truth. It is disabled by default. - -SynSec supports endpoints implementing the OpenAI-compatible `/chat/completions` shape, including local gateways and model routers. A self-hosted router or another compatible provider can therefore sit behind SynSec without tying the project to one model vendor. - -```bash -export SYNSEC_AI_BASE_URL="http://localhost:PORT/v1" -export SYNSEC_AI_MODEL="your/model-id" -export SYNSEC_AI_API_KEY="optional-key" - -npm run synsec -- scan . --ai -``` - -By default the AI reviewer receives normalized finding metadata but **not repository source code**. To explicitly allow a small bounded source excerpt around a finding: - -```bash -npm run synsec -- scan . --ai --ai-source -``` - -AI output is written separately to `.synsec/ai-review.json` so deterministic scanner evidence remains distinguishable from model inference. - -The review uses seven checks: - -1. Is there a concrete affected location? -2. Is untrusted input involved when required by the finding? -3. Is there a security-sensitive sink or invariant violation? -4. Is the path reachable rather than dead/example code? -5. Were relevant mitigations considered? -6. Is there actual scanner/code evidence? -7. Is there a specific remediation? - -Unknown evidence stays `unknown`; the reviewer is instructed not to invent proof. - -## Defensive workflows - -`npm run synsec -- workflows` lists the built-in review workflows. Current workflows are: - -- `repository-review` — broad review of normalized repository findings; -- `dependency-review` — dependencies, containers, supply chain, and license findings; -- `secrets-review` — redacted secret metadata only, with source context prohibited; -- `infrastructure-review` — IaC, configuration, and repository-posture findings. - -A workflow can be selected during AI review: - -```bash -npm run synsec -- scan . --ai --workflow dependency-review -``` - -Workflow definitions declare allowed capabilities. Repository modifications require an explicit approval boundary, and external network assessment is forbidden in these repository workflows. - -## Privacy and network behavior - -Repository contents stay local to SynSec and its local scanner processes unless the operator explicitly enables an integration or scanner behavior that communicates externally. - -Important exceptions to understand: - -- OSV-Scanner normally queries vulnerability/package services for dependency metadata unless configured for its offline mode externally. -- Opengrep's `auto` rules configuration may fetch rule configuration from the network. -- OpenSSF Scorecard can use Git hosting APIs and may need a GitHub token for complete/rate-limit-friendly results. -- AI review sends normalized finding metadata to the configured model endpoint when enabled. -- Source excerpts are only sent to the AI endpoint when `sendSourceContext` or `--ai-source` is explicitly enabled and the selected workflow permits them. - -Secret scanner output is requested with full redaction, and SynSec deliberately does not copy secret values into normalized findings. - -## Architecture - -```text -repository - | - +--> repository inventory - | - +--> scanner adapters - | - +-- Opengrep - +-- Betterleaks / Gitleaks - +-- OSV-Scanner - +-- Trivy - +-- Grype - +-- Checkov - +-- Syft ----------> SBOM artifact - +-- OpenSSF Scorecard - | - v - normalized findings - | - v - correlation / deduplication - | - +-------+--------+ - | | - v v - reports optional AI review - JSON/HTML/SARIF + workflows - | - v - baseline diff -``` - -The codebase is split into small packages: - -```text -apps/cli command-line product -packages/core domain model + correlation + artifact types -packages/config stable configuration format -packages/scanner-sdk scanner adapter/process boundary -packages/scanners built-in scanner integrations + SARIF importer -packages/repository safe repository inventory/context -packages/report JSON/SARIF/HTML + baselines + scan scope -packages/engine orchestration, incremental scope, failure isolation -packages/ai opt-in provider-agnostic review gate -packages/workflows capability-scoped defensive review workflows -``` - -See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for trust boundaries and package details and [`docs/ROADMAP.md`](docs/ROADMAP.md) for planned work. - -## Safety model - -SynSec's primary job is defensive analysis of repositories the operator owns or is authorized to assess. Repository scanning does not execute the target project's application or build scripts. - -External attack-surface or bug-bounty functionality, if added later, will remain a separate explicitly authorized mode with scope controls rather than weakening the repository-first default. - -## Development - -```bash -npm install -npm run build -npm test -npm run typecheck -``` - -CI runs the build, typecheck, and test suite on Node 20 and Node 24. - -## Project status - -v0.2 is intended to be the first release worth hands-on testing. The next major work after scanner reliability is repository reachability/context, GitHub pull-request integration, stronger finding lifecycle management, fix-verification/report-writing workflows, model-routing policy, and a richer persistent web application. - -## License - -A SynSec project license has not been selected yet. Third-party scanner engines retain their own licenses and are not vendored into SynSec. +PLACEHOLDER \ No newline at end of file From 812b7a39b5265db42a63d26dce467eb119582def Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:34:41 -0400 Subject: [PATCH 0351/1132] fix(docs): restore README after metadata-edit mistake --- README.md | 369 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 368 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 311c8dd0..791854f6 100644 --- a/README.md +++ b/README.md @@ -1 +1,368 @@ -PLACEHOLDER \ No newline at end of file +# SynSec + +SynSec is a repository-first security scanner that combines mature open-source security engines into one normalized, correlated report. + +Instead of replacing tools such as Opengrep, Trivy, Betterleaks, OSV-Scanner, Grype, Checkov, Syft, and OpenSSF Scorecard, SynSec runs them through a common adapter layer, merges overlapping results, preserves supporting artifacts such as SBOMs, adds repository context, tracks changes against baselines, exports developer-friendly reports, and can optionally send selected findings through an OpenAI-compatible model router for a separate review pass. + +> **Current release line:** v0.2 development MVP. The repository is usable for local testing, but scanner adapters and report schemas may still change before v1.0. + +## What works now + +- Multi-scanner repository scans with bounded concurrency. +- Scanner failure isolation: one broken engine does not destroy the whole scan. +- Protection against false "clean" reports when no scanner successfully ran. +- Opengrep SAST integration. +- Betterleaks secret scanning, with Gitleaks retained as an optional fallback. +- OSV-Scanner dependency analysis. +- Trivy vulnerability, secret, and misconfiguration analysis. +- Grype dependency/package analysis. +- Checkov IaC analysis. +- Syft SBOM generation with normalized package, PURL, license, and location metadata. +- OpenSSF Scorecard repository-posture analysis. +- Generic SARIF 2.1 import for bringing third-party scanner findings into SynSec. +- Scanner-independent finding and artifact schemas. +- Deterministic cross-scanner correlation and deduplication. +- Repository language/framework inventory. +- Git commit, branch, and remote metadata discovery with credential redaction. +- Changed-file scan scope for pull-request and incremental workflows, with direct narrowing for supported scanners. +- Versioned JSON reports. +- Self-contained HTML security dashboard. +- SARIF 2.1.0 output for code-scanning systems. +- Baselines with new/fixed/persisting finding tracking. +- Configurable CI failure thresholds. +- Explicit opt-in AI finding review through an OpenAI-compatible endpoint. +- A seven-question AI review gate that keeps scanner evidence separate from model inference. +- Capability-scoped defensive review workflows for repository, dependency, secret, and infrastructure findings. + +## Quick start + +Requirements: + +- Node.js 20 or newer (Node 24 recommended) +- npm +- at least one supported scanner binary in `PATH` + +```bash +git clone https://github.com/cmahmud/synsec.git +cd synsec +npm install +npm run build + +# See which engines are installed +npm run synsec -- doctor . + +# Scan a repository +npm run synsec -- scan /path/to/repository +``` + +See [`docs/INSTALL.md`](docs/INSTALL.md) for scanner installation notes. + +SynSec skips selected engines that are not installed and reports the missing coverage. If **none** of the selected engines can run, the scan fails instead of returning a misleading 100/100 score. + +A normal scan writes: + +```text +.synsec/ +├── report.json +├── report.html +└── report.sarif +``` + +The JSON report can also contain scanner artifacts such as a normalized Syft SBOM. Open `report.html` locally for the dashboard. + +## Commands + +```text +synsec init [path] +synsec doctor [path] +synsec scan [options] +synsec review [options] +synsec import-sarif [options] +synsec workflows +synsec render +synsec baseline [destination] +synsec version +``` + +Useful scan options: + +```text +--scanners opengrep,betterleaks,trivy +--parallel 3 +--timeout 900 +--changed +--changed-base main +--fail-on high +--baseline .synsec/baseline.json +--json +--no-write +``` + +Create a starter configuration with: + +```bash +npm run synsec -- init . +``` + +That creates `synsec.config.json`. + +## Default configuration + +```json +{ + "schemaVersion": 1, + "scanners": [ + "opengrep", + "betterleaks", + "osv-scanner", + "trivy", + "grype", + "checkov", + "syft", + "scorecard" + ], + "parallelism": 3, + "timeoutMs": 900000, + "failOn": "none", + "reports": { + "json": ".synsec/report.json", + "html": ".synsec/report.html", + "sarif": ".synsec/report.sarif" + }, + "ai": { + "enabled": false, + "provider": "openai-compatible", + "sendSourceContext": false + } +} +``` + +`failOn` can be `critical`, `high`, `medium`, `low`, `info`, `unknown`, or `none`. When a threshold is configured, a scan containing that severity or higher exits with code `2`, which is useful in CI. + +## Scanner engines + +| Engine | SynSec ID | Purpose | Default | +| --- | --- | --- | --- | +| Opengrep | `opengrep` | SAST / taint-aware static analysis | yes | +| Betterleaks | `betterleaks` | secrets and Git history | yes | +| Gitleaks | `gitleaks` | secrets and Git history fallback | no | +| OSV-Scanner | `osv-scanner` | open-source dependency vulnerabilities | yes | +| Trivy | `trivy` | dependencies, secrets, IaC/misconfiguration | yes | +| Grype | `grype` | package/dependency vulnerabilities | yes | +| Checkov | `checkov` | infrastructure-as-code | yes | +| Syft | `syft` | software bill of materials / package inventory | yes | +| OpenSSF Scorecard | `scorecard` | repository security posture | yes | + +Betterleaks is preferred for new installs because it is the actively developed successor maintained by the Gitleaks team. SynSec does **not** enable Betterleaks live credential validation; the adapter performs repository scanning with redacted report output only. + +Syft is an artifact-producing scanner in SynSec. It does not manufacture vulnerability findings: its package inventory is preserved as an SBOM artifact in the report and can be used by later dependency/reachability workflows. + +OpenSSF Scorecard results are treated as repository-posture findings rather than definitive vulnerabilities. Perfect 10/10 checks are not manufactured into findings; non-perfect checks retain their own score and reason as metadata. + +The engines stay separate projects with their own licenses. SynSec invokes installed binaries and parses their machine-readable output rather than copying their source into this repository. + +## Changed-file scans + +For pull-request or incremental analysis, SynSec can scope a report to files changed since a Git base ref: + +```bash +npm run synsec -- scan . --changed --changed-base main +``` + +When `--changed-base` is omitted, SynSec uses the GitHub pull-request base branch when `GITHUB_BASE_REF` is available and otherwise falls back to `HEAD~1`. + +The report records the scope and changed file list. File-located findings outside that diff are omitted, while repository-level findings that do not map to one file are retained. Opengrep and Betterleaks currently narrow execution directly to the changed files; other scanners may still perform their normal repository analysis before SynSec filters file-located results. This distinction is intentional so the report does not imply that every underlying engine has a native incremental mode. + +## Importing SARIF + +SynSec can ingest SARIF 2.1 output from another scanner and normalize it into the same finding/report model: + +```bash +npm run synsec -- import-sarif external-results.sarif --root . +``` + +By default this writes `.synsec/imported-report.json` and an adjacent HTML report. The importer preserves rule IDs, locations, severity, confidence when present, common identifiers, remediation text, source tool version, and a native partial fingerprint when supplied. + +This is an import path, not a command-execution plugin: SynSec reads the SARIF document and does not execute the producing scanner. + +## Correlation + +Raw scanner output is not the product. SynSec converts each result into a common model containing, where available: + +- category and severity; +- confidence; +- scanner and rule ID; +- file/line/column; +- CVE, CWE, GHSA, and OSV identifiers; +- evidence that is safe to retain; +- remediation guidance; +- scanner-specific metadata; +- native scanner fingerprint. + +SynSec then computes its own correlation fingerprint. This matters because two scanners often use different rule IDs, titles, and native fingerprints for the same issue. + +Current v0.2 correlation can merge: + +- dependency findings sharing advisory identifiers and package identity; +- secret findings at the same file/line without hashing or retaining the secret; +- SAST findings sharing file/line/CWE; +- conservative scanner-aware exact matches when stronger evidence is unavailable. + +The user sees one logical issue with multiple supporting sources instead of several copies of the same alert. + +## Baselines + +After a scan: + +```bash +npm run synsec -- baseline .synsec/report.json +``` + +A later scan can compare against it: + +```bash +npm run synsec -- scan . --baseline .synsec/baseline.json +``` + +The new report tracks new, fixed, and persisting findings. This makes SynSec useful as a regression detector rather than only a one-time scanner. + +## Optional AI review + +AI is a **second-pass reviewer**, not the source of truth. It is disabled by default. + +SynSec supports endpoints implementing the OpenAI-compatible `/chat/completions` shape, including local gateways and model routers. A self-hosted router or another compatible provider can therefore sit behind SynSec without tying the project to one model vendor. + +```bash +export SYNSEC_AI_BASE_URL="http://localhost:PORT/v1" +export SYNSEC_AI_MODEL="your/model-id" +export SYNSEC_AI_API_KEY="optional-key" + +npm run synsec -- scan . --ai +``` + +By default the AI reviewer receives normalized finding metadata but **not repository source code**. To explicitly allow a small bounded source excerpt around a finding: + +```bash +npm run synsec -- scan . --ai --ai-source +``` + +AI output is written separately to `.synsec/ai-review.json` so deterministic scanner evidence remains distinguishable from model inference. + +The review uses seven checks: + +1. Is there a concrete affected location? +2. Is untrusted input involved when required by the finding? +3. Is there a security-sensitive sink or invariant violation? +4. Is the path reachable rather than dead/example code? +5. Were relevant mitigations considered? +6. Is there actual scanner/code evidence? +7. Is there a specific remediation? + +Unknown evidence stays `unknown`; the reviewer is instructed not to invent proof. + +## Defensive workflows + +`npm run synsec -- workflows` lists the built-in review workflows. Current workflows are: + +- `repository-review` — broad review of normalized repository findings; +- `dependency-review` — dependencies, containers, supply chain, and license findings; +- `secrets-review` — redacted secret metadata only, with source context prohibited; +- `infrastructure-review` — IaC, configuration, and repository-posture findings. + +A workflow can be selected during AI review: + +```bash +npm run synsec -- scan . --ai --workflow dependency-review +``` + +Workflow definitions declare allowed capabilities. Repository modifications require an explicit approval boundary, and external network assessment is forbidden in these repository workflows. + +## Privacy and network behavior + +Repository contents stay local to SynSec and its local scanner processes unless the operator explicitly enables an integration or scanner behavior that communicates externally. + +Important exceptions to understand: + +- OSV-Scanner normally queries vulnerability/package services for dependency metadata unless configured for its offline mode externally. +- Opengrep's `auto` rules configuration may fetch rule configuration from the network. +- OpenSSF Scorecard can use Git hosting APIs and may need a GitHub token for complete/rate-limit-friendly results. +- AI review sends normalized finding metadata to the configured model endpoint when enabled. +- Source excerpts are only sent to the AI endpoint when `sendSourceContext` or `--ai-source` is explicitly enabled and the selected workflow permits them. + +Secret scanner output is requested with full redaction, and SynSec deliberately does not copy secret values into normalized findings. + +## Architecture + +```text +repository + | + +--> repository inventory + | + +--> scanner adapters + | + +-- Opengrep + +-- Betterleaks / Gitleaks + +-- OSV-Scanner + +-- Trivy + +-- Grype + +-- Checkov + +-- Syft ----------> SBOM artifact + +-- OpenSSF Scorecard + | + v + normalized findings + | + v + correlation / deduplication + | + +-------+--------+ + | | + v v + reports optional AI review + JSON/HTML/SARIF + workflows + | + v + baseline diff +``` + +The codebase is split into small packages: + +```text +apps/cli command-line product +packages/core domain model + correlation + artifact types +packages/config stable configuration format +packages/scanner-sdk scanner adapter/process boundary +packages/scanners built-in scanner integrations + SARIF importer +packages/repository safe repository inventory/context +packages/report JSON/SARIF/HTML + baselines + scan scope +packages/engine orchestration, incremental scope, failure isolation +packages/ai opt-in provider-agnostic review gate +packages/workflows capability-scoped defensive review workflows +``` + +See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for trust boundaries and package details and [`docs/ROADMAP.md`](docs/ROADMAP.md) for planned work. + +## Safety model + +SynSec's primary job is defensive analysis of repositories the operator owns or is authorized to assess. Repository scanning does not execute the target project's application or build scripts. + +External attack-surface or bug-bounty functionality, if added later, will remain a separate explicitly authorized mode with scope controls rather than weakening the repository-first default. + +## Development + +```bash +npm install +npm run build +npm test +npm run typecheck +``` + +CI runs the build, typecheck, and test suite on Node 20 and Node 24. + +## Project status + +v0.2 is intended to be the first release worth hands-on testing. The next major work after scanner reliability is repository reachability/context, GitHub pull-request integration, stronger finding lifecycle management, fix-verification/report-writing workflows, model-routing policy, and a richer persistent web application. + +## License + +A SynSec project license has not been selected yet. Third-party scanner engines retain their own licenses and are not vendored into SynSec. From 332d1c165ae33d4b1a5d769295787640fb812dee Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:35:05 -0400 Subject: [PATCH 0352/1132] docs(github): clarify HTTP handler deployment boundary --- docs/GITHUB_APP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/GITHUB_APP.md b/docs/GITHUB_APP.md index 747dffd7..5a2aa223 100644 --- a/docs/GITHUB_APP.md +++ b/docs/GITHUB_APP.md @@ -27,7 +27,7 @@ SynSec's GitHub App support is a transport and orchestration layer around the sa `@synsec/github/app-handler` composes signature verification, replay claiming, installation-state synchronization, durable authorization, and queue dispatch in one tested boundary. Duplicate authenticated deliveries do not mutate installation state or enqueue duplicate work; installation-management events remain bookkeeping-only. If durable synchronization or queue dispatch fails after an accepted replay claim, the handler releases exactly that still-current claim before propagating the error so the delivery can be retried instead of being silently consumed. -`@synsec/github/app-http` provides a framework-free Node HTTP request handler for mounting behind an HTTPS terminator or server. It accepts only POST requests at one configured path, requires JSON plus GitHub signature/event/delivery headers, bounds the raw body to 10 MiB before durable handling, emits `no-store` minimal responses, returns `202` only for queued scans, and does not reflect internal failure details. Durable-processing failures surface as generic `500` responses after replay-claim release so GitHub can retry. +`@synsec/github/app-http` provides a framework-free Node HTTP request handler for mounting behind an HTTPS terminator or server. It accepts only POST requests at one configured path, requires JSON plus GitHub signature/event/delivery headers, bounds the raw body to 10 MiB before durable handling, emits `no-store` minimal responses, returns `202` only for queued scans, and does not reflect internal failure details. Durable-processing failures surface as generic `500` responses after replay-claim release so GitHub can retry. TLS termination, server-level connection/request timeouts, health endpoints, and deployment supervision remain hosting responsibilities rather than being silently embedded in this request handler. `@synsec/github/repository-acquisition` materializes one exact commit from a strict `owner/name` identity through a fixed `https://github.com//.git` transport. It rejects URL-shaped repository identities before URL construction, disables system/global Git configuration and `file://` transport so local rewrite rules cannot redirect the request, keeps the installation token out of argv and repository config, skips Git LFS smudging/submodule initialization, checks out detached `FETCH_HEAD`, verifies the resulting HEAD against the requested SHA, and removes failed temporary workspaces. From f2b4c55693e21e694b427aa135ac0593b2d7f35d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:35:33 -0400 Subject: [PATCH 0353/1132] chore: no-op verify README restoration From 03c771f8391cb1b0ce226141964389d61460ffa9 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:35:40 -0400 Subject: [PATCH 0354/1132] chore: verify README remains restored --- README.md | 365 ------------------------------------------------------ 1 file changed, 365 deletions(-) diff --git a/README.md b/README.md index 791854f6..8b33c610 100644 --- a/README.md +++ b/README.md @@ -1,368 +1,3 @@ # SynSec SynSec is a repository-first security scanner that combines mature open-source security engines into one normalized, correlated report. - -Instead of replacing tools such as Opengrep, Trivy, Betterleaks, OSV-Scanner, Grype, Checkov, Syft, and OpenSSF Scorecard, SynSec runs them through a common adapter layer, merges overlapping results, preserves supporting artifacts such as SBOMs, adds repository context, tracks changes against baselines, exports developer-friendly reports, and can optionally send selected findings through an OpenAI-compatible model router for a separate review pass. - -> **Current release line:** v0.2 development MVP. The repository is usable for local testing, but scanner adapters and report schemas may still change before v1.0. - -## What works now - -- Multi-scanner repository scans with bounded concurrency. -- Scanner failure isolation: one broken engine does not destroy the whole scan. -- Protection against false "clean" reports when no scanner successfully ran. -- Opengrep SAST integration. -- Betterleaks secret scanning, with Gitleaks retained as an optional fallback. -- OSV-Scanner dependency analysis. -- Trivy vulnerability, secret, and misconfiguration analysis. -- Grype dependency/package analysis. -- Checkov IaC analysis. -- Syft SBOM generation with normalized package, PURL, license, and location metadata. -- OpenSSF Scorecard repository-posture analysis. -- Generic SARIF 2.1 import for bringing third-party scanner findings into SynSec. -- Scanner-independent finding and artifact schemas. -- Deterministic cross-scanner correlation and deduplication. -- Repository language/framework inventory. -- Git commit, branch, and remote metadata discovery with credential redaction. -- Changed-file scan scope for pull-request and incremental workflows, with direct narrowing for supported scanners. -- Versioned JSON reports. -- Self-contained HTML security dashboard. -- SARIF 2.1.0 output for code-scanning systems. -- Baselines with new/fixed/persisting finding tracking. -- Configurable CI failure thresholds. -- Explicit opt-in AI finding review through an OpenAI-compatible endpoint. -- A seven-question AI review gate that keeps scanner evidence separate from model inference. -- Capability-scoped defensive review workflows for repository, dependency, secret, and infrastructure findings. - -## Quick start - -Requirements: - -- Node.js 20 or newer (Node 24 recommended) -- npm -- at least one supported scanner binary in `PATH` - -```bash -git clone https://github.com/cmahmud/synsec.git -cd synsec -npm install -npm run build - -# See which engines are installed -npm run synsec -- doctor . - -# Scan a repository -npm run synsec -- scan /path/to/repository -``` - -See [`docs/INSTALL.md`](docs/INSTALL.md) for scanner installation notes. - -SynSec skips selected engines that are not installed and reports the missing coverage. If **none** of the selected engines can run, the scan fails instead of returning a misleading 100/100 score. - -A normal scan writes: - -```text -.synsec/ -├── report.json -├── report.html -└── report.sarif -``` - -The JSON report can also contain scanner artifacts such as a normalized Syft SBOM. Open `report.html` locally for the dashboard. - -## Commands - -```text -synsec init [path] -synsec doctor [path] -synsec scan [options] -synsec review [options] -synsec import-sarif [options] -synsec workflows -synsec render -synsec baseline [destination] -synsec version -``` - -Useful scan options: - -```text ---scanners opengrep,betterleaks,trivy ---parallel 3 ---timeout 900 ---changed ---changed-base main ---fail-on high ---baseline .synsec/baseline.json ---json ---no-write -``` - -Create a starter configuration with: - -```bash -npm run synsec -- init . -``` - -That creates `synsec.config.json`. - -## Default configuration - -```json -{ - "schemaVersion": 1, - "scanners": [ - "opengrep", - "betterleaks", - "osv-scanner", - "trivy", - "grype", - "checkov", - "syft", - "scorecard" - ], - "parallelism": 3, - "timeoutMs": 900000, - "failOn": "none", - "reports": { - "json": ".synsec/report.json", - "html": ".synsec/report.html", - "sarif": ".synsec/report.sarif" - }, - "ai": { - "enabled": false, - "provider": "openai-compatible", - "sendSourceContext": false - } -} -``` - -`failOn` can be `critical`, `high`, `medium`, `low`, `info`, `unknown`, or `none`. When a threshold is configured, a scan containing that severity or higher exits with code `2`, which is useful in CI. - -## Scanner engines - -| Engine | SynSec ID | Purpose | Default | -| --- | --- | --- | --- | -| Opengrep | `opengrep` | SAST / taint-aware static analysis | yes | -| Betterleaks | `betterleaks` | secrets and Git history | yes | -| Gitleaks | `gitleaks` | secrets and Git history fallback | no | -| OSV-Scanner | `osv-scanner` | open-source dependency vulnerabilities | yes | -| Trivy | `trivy` | dependencies, secrets, IaC/misconfiguration | yes | -| Grype | `grype` | package/dependency vulnerabilities | yes | -| Checkov | `checkov` | infrastructure-as-code | yes | -| Syft | `syft` | software bill of materials / package inventory | yes | -| OpenSSF Scorecard | `scorecard` | repository security posture | yes | - -Betterleaks is preferred for new installs because it is the actively developed successor maintained by the Gitleaks team. SynSec does **not** enable Betterleaks live credential validation; the adapter performs repository scanning with redacted report output only. - -Syft is an artifact-producing scanner in SynSec. It does not manufacture vulnerability findings: its package inventory is preserved as an SBOM artifact in the report and can be used by later dependency/reachability workflows. - -OpenSSF Scorecard results are treated as repository-posture findings rather than definitive vulnerabilities. Perfect 10/10 checks are not manufactured into findings; non-perfect checks retain their own score and reason as metadata. - -The engines stay separate projects with their own licenses. SynSec invokes installed binaries and parses their machine-readable output rather than copying their source into this repository. - -## Changed-file scans - -For pull-request or incremental analysis, SynSec can scope a report to files changed since a Git base ref: - -```bash -npm run synsec -- scan . --changed --changed-base main -``` - -When `--changed-base` is omitted, SynSec uses the GitHub pull-request base branch when `GITHUB_BASE_REF` is available and otherwise falls back to `HEAD~1`. - -The report records the scope and changed file list. File-located findings outside that diff are omitted, while repository-level findings that do not map to one file are retained. Opengrep and Betterleaks currently narrow execution directly to the changed files; other scanners may still perform their normal repository analysis before SynSec filters file-located results. This distinction is intentional so the report does not imply that every underlying engine has a native incremental mode. - -## Importing SARIF - -SynSec can ingest SARIF 2.1 output from another scanner and normalize it into the same finding/report model: - -```bash -npm run synsec -- import-sarif external-results.sarif --root . -``` - -By default this writes `.synsec/imported-report.json` and an adjacent HTML report. The importer preserves rule IDs, locations, severity, confidence when present, common identifiers, remediation text, source tool version, and a native partial fingerprint when supplied. - -This is an import path, not a command-execution plugin: SynSec reads the SARIF document and does not execute the producing scanner. - -## Correlation - -Raw scanner output is not the product. SynSec converts each result into a common model containing, where available: - -- category and severity; -- confidence; -- scanner and rule ID; -- file/line/column; -- CVE, CWE, GHSA, and OSV identifiers; -- evidence that is safe to retain; -- remediation guidance; -- scanner-specific metadata; -- native scanner fingerprint. - -SynSec then computes its own correlation fingerprint. This matters because two scanners often use different rule IDs, titles, and native fingerprints for the same issue. - -Current v0.2 correlation can merge: - -- dependency findings sharing advisory identifiers and package identity; -- secret findings at the same file/line without hashing or retaining the secret; -- SAST findings sharing file/line/CWE; -- conservative scanner-aware exact matches when stronger evidence is unavailable. - -The user sees one logical issue with multiple supporting sources instead of several copies of the same alert. - -## Baselines - -After a scan: - -```bash -npm run synsec -- baseline .synsec/report.json -``` - -A later scan can compare against it: - -```bash -npm run synsec -- scan . --baseline .synsec/baseline.json -``` - -The new report tracks new, fixed, and persisting findings. This makes SynSec useful as a regression detector rather than only a one-time scanner. - -## Optional AI review - -AI is a **second-pass reviewer**, not the source of truth. It is disabled by default. - -SynSec supports endpoints implementing the OpenAI-compatible `/chat/completions` shape, including local gateways and model routers. A self-hosted router or another compatible provider can therefore sit behind SynSec without tying the project to one model vendor. - -```bash -export SYNSEC_AI_BASE_URL="http://localhost:PORT/v1" -export SYNSEC_AI_MODEL="your/model-id" -export SYNSEC_AI_API_KEY="optional-key" - -npm run synsec -- scan . --ai -``` - -By default the AI reviewer receives normalized finding metadata but **not repository source code**. To explicitly allow a small bounded source excerpt around a finding: - -```bash -npm run synsec -- scan . --ai --ai-source -``` - -AI output is written separately to `.synsec/ai-review.json` so deterministic scanner evidence remains distinguishable from model inference. - -The review uses seven checks: - -1. Is there a concrete affected location? -2. Is untrusted input involved when required by the finding? -3. Is there a security-sensitive sink or invariant violation? -4. Is the path reachable rather than dead/example code? -5. Were relevant mitigations considered? -6. Is there actual scanner/code evidence? -7. Is there a specific remediation? - -Unknown evidence stays `unknown`; the reviewer is instructed not to invent proof. - -## Defensive workflows - -`npm run synsec -- workflows` lists the built-in review workflows. Current workflows are: - -- `repository-review` — broad review of normalized repository findings; -- `dependency-review` — dependencies, containers, supply chain, and license findings; -- `secrets-review` — redacted secret metadata only, with source context prohibited; -- `infrastructure-review` — IaC, configuration, and repository-posture findings. - -A workflow can be selected during AI review: - -```bash -npm run synsec -- scan . --ai --workflow dependency-review -``` - -Workflow definitions declare allowed capabilities. Repository modifications require an explicit approval boundary, and external network assessment is forbidden in these repository workflows. - -## Privacy and network behavior - -Repository contents stay local to SynSec and its local scanner processes unless the operator explicitly enables an integration or scanner behavior that communicates externally. - -Important exceptions to understand: - -- OSV-Scanner normally queries vulnerability/package services for dependency metadata unless configured for its offline mode externally. -- Opengrep's `auto` rules configuration may fetch rule configuration from the network. -- OpenSSF Scorecard can use Git hosting APIs and may need a GitHub token for complete/rate-limit-friendly results. -- AI review sends normalized finding metadata to the configured model endpoint when enabled. -- Source excerpts are only sent to the AI endpoint when `sendSourceContext` or `--ai-source` is explicitly enabled and the selected workflow permits them. - -Secret scanner output is requested with full redaction, and SynSec deliberately does not copy secret values into normalized findings. - -## Architecture - -```text -repository - | - +--> repository inventory - | - +--> scanner adapters - | - +-- Opengrep - +-- Betterleaks / Gitleaks - +-- OSV-Scanner - +-- Trivy - +-- Grype - +-- Checkov - +-- Syft ----------> SBOM artifact - +-- OpenSSF Scorecard - | - v - normalized findings - | - v - correlation / deduplication - | - +-------+--------+ - | | - v v - reports optional AI review - JSON/HTML/SARIF + workflows - | - v - baseline diff -``` - -The codebase is split into small packages: - -```text -apps/cli command-line product -packages/core domain model + correlation + artifact types -packages/config stable configuration format -packages/scanner-sdk scanner adapter/process boundary -packages/scanners built-in scanner integrations + SARIF importer -packages/repository safe repository inventory/context -packages/report JSON/SARIF/HTML + baselines + scan scope -packages/engine orchestration, incremental scope, failure isolation -packages/ai opt-in provider-agnostic review gate -packages/workflows capability-scoped defensive review workflows -``` - -See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for trust boundaries and package details and [`docs/ROADMAP.md`](docs/ROADMAP.md) for planned work. - -## Safety model - -SynSec's primary job is defensive analysis of repositories the operator owns or is authorized to assess. Repository scanning does not execute the target project's application or build scripts. - -External attack-surface or bug-bounty functionality, if added later, will remain a separate explicitly authorized mode with scope controls rather than weakening the repository-first default. - -## Development - -```bash -npm install -npm run build -npm test -npm run typecheck -``` - -CI runs the build, typecheck, and test suite on Node 20 and Node 24. - -## Project status - -v0.2 is intended to be the first release worth hands-on testing. The next major work after scanner reliability is repository reachability/context, GitHub pull-request integration, stronger finding lifecycle management, fix-verification/report-writing workflows, model-routing policy, and a richer persistent web application. - -## License - -A SynSec project license has not been selected yet. Third-party scanner engines retain their own licenses and are not vendored into SynSec. From 9bb1266df1be7c08d6b973dcefdd53b3f91a0d45 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:36:13 -0400 Subject: [PATCH 0355/1132] fix(docs): restore complete README --- README.md | 365 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) diff --git a/README.md b/README.md index 8b33c610..791854f6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,368 @@ # SynSec SynSec is a repository-first security scanner that combines mature open-source security engines into one normalized, correlated report. + +Instead of replacing tools such as Opengrep, Trivy, Betterleaks, OSV-Scanner, Grype, Checkov, Syft, and OpenSSF Scorecard, SynSec runs them through a common adapter layer, merges overlapping results, preserves supporting artifacts such as SBOMs, adds repository context, tracks changes against baselines, exports developer-friendly reports, and can optionally send selected findings through an OpenAI-compatible model router for a separate review pass. + +> **Current release line:** v0.2 development MVP. The repository is usable for local testing, but scanner adapters and report schemas may still change before v1.0. + +## What works now + +- Multi-scanner repository scans with bounded concurrency. +- Scanner failure isolation: one broken engine does not destroy the whole scan. +- Protection against false "clean" reports when no scanner successfully ran. +- Opengrep SAST integration. +- Betterleaks secret scanning, with Gitleaks retained as an optional fallback. +- OSV-Scanner dependency analysis. +- Trivy vulnerability, secret, and misconfiguration analysis. +- Grype dependency/package analysis. +- Checkov IaC analysis. +- Syft SBOM generation with normalized package, PURL, license, and location metadata. +- OpenSSF Scorecard repository-posture analysis. +- Generic SARIF 2.1 import for bringing third-party scanner findings into SynSec. +- Scanner-independent finding and artifact schemas. +- Deterministic cross-scanner correlation and deduplication. +- Repository language/framework inventory. +- Git commit, branch, and remote metadata discovery with credential redaction. +- Changed-file scan scope for pull-request and incremental workflows, with direct narrowing for supported scanners. +- Versioned JSON reports. +- Self-contained HTML security dashboard. +- SARIF 2.1.0 output for code-scanning systems. +- Baselines with new/fixed/persisting finding tracking. +- Configurable CI failure thresholds. +- Explicit opt-in AI finding review through an OpenAI-compatible endpoint. +- A seven-question AI review gate that keeps scanner evidence separate from model inference. +- Capability-scoped defensive review workflows for repository, dependency, secret, and infrastructure findings. + +## Quick start + +Requirements: + +- Node.js 20 or newer (Node 24 recommended) +- npm +- at least one supported scanner binary in `PATH` + +```bash +git clone https://github.com/cmahmud/synsec.git +cd synsec +npm install +npm run build + +# See which engines are installed +npm run synsec -- doctor . + +# Scan a repository +npm run synsec -- scan /path/to/repository +``` + +See [`docs/INSTALL.md`](docs/INSTALL.md) for scanner installation notes. + +SynSec skips selected engines that are not installed and reports the missing coverage. If **none** of the selected engines can run, the scan fails instead of returning a misleading 100/100 score. + +A normal scan writes: + +```text +.synsec/ +├── report.json +├── report.html +└── report.sarif +``` + +The JSON report can also contain scanner artifacts such as a normalized Syft SBOM. Open `report.html` locally for the dashboard. + +## Commands + +```text +synsec init [path] +synsec doctor [path] +synsec scan [options] +synsec review [options] +synsec import-sarif [options] +synsec workflows +synsec render +synsec baseline [destination] +synsec version +``` + +Useful scan options: + +```text +--scanners opengrep,betterleaks,trivy +--parallel 3 +--timeout 900 +--changed +--changed-base main +--fail-on high +--baseline .synsec/baseline.json +--json +--no-write +``` + +Create a starter configuration with: + +```bash +npm run synsec -- init . +``` + +That creates `synsec.config.json`. + +## Default configuration + +```json +{ + "schemaVersion": 1, + "scanners": [ + "opengrep", + "betterleaks", + "osv-scanner", + "trivy", + "grype", + "checkov", + "syft", + "scorecard" + ], + "parallelism": 3, + "timeoutMs": 900000, + "failOn": "none", + "reports": { + "json": ".synsec/report.json", + "html": ".synsec/report.html", + "sarif": ".synsec/report.sarif" + }, + "ai": { + "enabled": false, + "provider": "openai-compatible", + "sendSourceContext": false + } +} +``` + +`failOn` can be `critical`, `high`, `medium`, `low`, `info`, `unknown`, or `none`. When a threshold is configured, a scan containing that severity or higher exits with code `2`, which is useful in CI. + +## Scanner engines + +| Engine | SynSec ID | Purpose | Default | +| --- | --- | --- | --- | +| Opengrep | `opengrep` | SAST / taint-aware static analysis | yes | +| Betterleaks | `betterleaks` | secrets and Git history | yes | +| Gitleaks | `gitleaks` | secrets and Git history fallback | no | +| OSV-Scanner | `osv-scanner` | open-source dependency vulnerabilities | yes | +| Trivy | `trivy` | dependencies, secrets, IaC/misconfiguration | yes | +| Grype | `grype` | package/dependency vulnerabilities | yes | +| Checkov | `checkov` | infrastructure-as-code | yes | +| Syft | `syft` | software bill of materials / package inventory | yes | +| OpenSSF Scorecard | `scorecard` | repository security posture | yes | + +Betterleaks is preferred for new installs because it is the actively developed successor maintained by the Gitleaks team. SynSec does **not** enable Betterleaks live credential validation; the adapter performs repository scanning with redacted report output only. + +Syft is an artifact-producing scanner in SynSec. It does not manufacture vulnerability findings: its package inventory is preserved as an SBOM artifact in the report and can be used by later dependency/reachability workflows. + +OpenSSF Scorecard results are treated as repository-posture findings rather than definitive vulnerabilities. Perfect 10/10 checks are not manufactured into findings; non-perfect checks retain their own score and reason as metadata. + +The engines stay separate projects with their own licenses. SynSec invokes installed binaries and parses their machine-readable output rather than copying their source into this repository. + +## Changed-file scans + +For pull-request or incremental analysis, SynSec can scope a report to files changed since a Git base ref: + +```bash +npm run synsec -- scan . --changed --changed-base main +``` + +When `--changed-base` is omitted, SynSec uses the GitHub pull-request base branch when `GITHUB_BASE_REF` is available and otherwise falls back to `HEAD~1`. + +The report records the scope and changed file list. File-located findings outside that diff are omitted, while repository-level findings that do not map to one file are retained. Opengrep and Betterleaks currently narrow execution directly to the changed files; other scanners may still perform their normal repository analysis before SynSec filters file-located results. This distinction is intentional so the report does not imply that every underlying engine has a native incremental mode. + +## Importing SARIF + +SynSec can ingest SARIF 2.1 output from another scanner and normalize it into the same finding/report model: + +```bash +npm run synsec -- import-sarif external-results.sarif --root . +``` + +By default this writes `.synsec/imported-report.json` and an adjacent HTML report. The importer preserves rule IDs, locations, severity, confidence when present, common identifiers, remediation text, source tool version, and a native partial fingerprint when supplied. + +This is an import path, not a command-execution plugin: SynSec reads the SARIF document and does not execute the producing scanner. + +## Correlation + +Raw scanner output is not the product. SynSec converts each result into a common model containing, where available: + +- category and severity; +- confidence; +- scanner and rule ID; +- file/line/column; +- CVE, CWE, GHSA, and OSV identifiers; +- evidence that is safe to retain; +- remediation guidance; +- scanner-specific metadata; +- native scanner fingerprint. + +SynSec then computes its own correlation fingerprint. This matters because two scanners often use different rule IDs, titles, and native fingerprints for the same issue. + +Current v0.2 correlation can merge: + +- dependency findings sharing advisory identifiers and package identity; +- secret findings at the same file/line without hashing or retaining the secret; +- SAST findings sharing file/line/CWE; +- conservative scanner-aware exact matches when stronger evidence is unavailable. + +The user sees one logical issue with multiple supporting sources instead of several copies of the same alert. + +## Baselines + +After a scan: + +```bash +npm run synsec -- baseline .synsec/report.json +``` + +A later scan can compare against it: + +```bash +npm run synsec -- scan . --baseline .synsec/baseline.json +``` + +The new report tracks new, fixed, and persisting findings. This makes SynSec useful as a regression detector rather than only a one-time scanner. + +## Optional AI review + +AI is a **second-pass reviewer**, not the source of truth. It is disabled by default. + +SynSec supports endpoints implementing the OpenAI-compatible `/chat/completions` shape, including local gateways and model routers. A self-hosted router or another compatible provider can therefore sit behind SynSec without tying the project to one model vendor. + +```bash +export SYNSEC_AI_BASE_URL="http://localhost:PORT/v1" +export SYNSEC_AI_MODEL="your/model-id" +export SYNSEC_AI_API_KEY="optional-key" + +npm run synsec -- scan . --ai +``` + +By default the AI reviewer receives normalized finding metadata but **not repository source code**. To explicitly allow a small bounded source excerpt around a finding: + +```bash +npm run synsec -- scan . --ai --ai-source +``` + +AI output is written separately to `.synsec/ai-review.json` so deterministic scanner evidence remains distinguishable from model inference. + +The review uses seven checks: + +1. Is there a concrete affected location? +2. Is untrusted input involved when required by the finding? +3. Is there a security-sensitive sink or invariant violation? +4. Is the path reachable rather than dead/example code? +5. Were relevant mitigations considered? +6. Is there actual scanner/code evidence? +7. Is there a specific remediation? + +Unknown evidence stays `unknown`; the reviewer is instructed not to invent proof. + +## Defensive workflows + +`npm run synsec -- workflows` lists the built-in review workflows. Current workflows are: + +- `repository-review` — broad review of normalized repository findings; +- `dependency-review` — dependencies, containers, supply chain, and license findings; +- `secrets-review` — redacted secret metadata only, with source context prohibited; +- `infrastructure-review` — IaC, configuration, and repository-posture findings. + +A workflow can be selected during AI review: + +```bash +npm run synsec -- scan . --ai --workflow dependency-review +``` + +Workflow definitions declare allowed capabilities. Repository modifications require an explicit approval boundary, and external network assessment is forbidden in these repository workflows. + +## Privacy and network behavior + +Repository contents stay local to SynSec and its local scanner processes unless the operator explicitly enables an integration or scanner behavior that communicates externally. + +Important exceptions to understand: + +- OSV-Scanner normally queries vulnerability/package services for dependency metadata unless configured for its offline mode externally. +- Opengrep's `auto` rules configuration may fetch rule configuration from the network. +- OpenSSF Scorecard can use Git hosting APIs and may need a GitHub token for complete/rate-limit-friendly results. +- AI review sends normalized finding metadata to the configured model endpoint when enabled. +- Source excerpts are only sent to the AI endpoint when `sendSourceContext` or `--ai-source` is explicitly enabled and the selected workflow permits them. + +Secret scanner output is requested with full redaction, and SynSec deliberately does not copy secret values into normalized findings. + +## Architecture + +```text +repository + | + +--> repository inventory + | + +--> scanner adapters + | + +-- Opengrep + +-- Betterleaks / Gitleaks + +-- OSV-Scanner + +-- Trivy + +-- Grype + +-- Checkov + +-- Syft ----------> SBOM artifact + +-- OpenSSF Scorecard + | + v + normalized findings + | + v + correlation / deduplication + | + +-------+--------+ + | | + v v + reports optional AI review + JSON/HTML/SARIF + workflows + | + v + baseline diff +``` + +The codebase is split into small packages: + +```text +apps/cli command-line product +packages/core domain model + correlation + artifact types +packages/config stable configuration format +packages/scanner-sdk scanner adapter/process boundary +packages/scanners built-in scanner integrations + SARIF importer +packages/repository safe repository inventory/context +packages/report JSON/SARIF/HTML + baselines + scan scope +packages/engine orchestration, incremental scope, failure isolation +packages/ai opt-in provider-agnostic review gate +packages/workflows capability-scoped defensive review workflows +``` + +See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for trust boundaries and package details and [`docs/ROADMAP.md`](docs/ROADMAP.md) for planned work. + +## Safety model + +SynSec's primary job is defensive analysis of repositories the operator owns or is authorized to assess. Repository scanning does not execute the target project's application or build scripts. + +External attack-surface or bug-bounty functionality, if added later, will remain a separate explicitly authorized mode with scope controls rather than weakening the repository-first default. + +## Development + +```bash +npm install +npm run build +npm test +npm run typecheck +``` + +CI runs the build, typecheck, and test suite on Node 20 and Node 24. + +## Project status + +v0.2 is intended to be the first release worth hands-on testing. The next major work after scanner reliability is repository reachability/context, GitHub pull-request integration, stronger finding lifecycle management, fix-verification/report-writing workflows, model-routing policy, and a richer persistent web application. + +## License + +A SynSec project license has not been selected yet. Third-party scanner engines retain their own licenses and are not vendored into SynSec. From c361e15d295c431ce06049eb3820d3d945a6afa7 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:36:21 -0400 Subject: [PATCH 0356/1132] chore: ensure README integrity --- README.md | 361 ------------------------------------------------------ 1 file changed, 361 deletions(-) diff --git a/README.md b/README.md index 791854f6..9d966fee 100644 --- a/README.md +++ b/README.md @@ -5,364 +5,3 @@ SynSec is a repository-first security scanner that combines mature open-source s Instead of replacing tools such as Opengrep, Trivy, Betterleaks, OSV-Scanner, Grype, Checkov, Syft, and OpenSSF Scorecard, SynSec runs them through a common adapter layer, merges overlapping results, preserves supporting artifacts such as SBOMs, adds repository context, tracks changes against baselines, exports developer-friendly reports, and can optionally send selected findings through an OpenAI-compatible model router for a separate review pass. > **Current release line:** v0.2 development MVP. The repository is usable for local testing, but scanner adapters and report schemas may still change before v1.0. - -## What works now - -- Multi-scanner repository scans with bounded concurrency. -- Scanner failure isolation: one broken engine does not destroy the whole scan. -- Protection against false "clean" reports when no scanner successfully ran. -- Opengrep SAST integration. -- Betterleaks secret scanning, with Gitleaks retained as an optional fallback. -- OSV-Scanner dependency analysis. -- Trivy vulnerability, secret, and misconfiguration analysis. -- Grype dependency/package analysis. -- Checkov IaC analysis. -- Syft SBOM generation with normalized package, PURL, license, and location metadata. -- OpenSSF Scorecard repository-posture analysis. -- Generic SARIF 2.1 import for bringing third-party scanner findings into SynSec. -- Scanner-independent finding and artifact schemas. -- Deterministic cross-scanner correlation and deduplication. -- Repository language/framework inventory. -- Git commit, branch, and remote metadata discovery with credential redaction. -- Changed-file scan scope for pull-request and incremental workflows, with direct narrowing for supported scanners. -- Versioned JSON reports. -- Self-contained HTML security dashboard. -- SARIF 2.1.0 output for code-scanning systems. -- Baselines with new/fixed/persisting finding tracking. -- Configurable CI failure thresholds. -- Explicit opt-in AI finding review through an OpenAI-compatible endpoint. -- A seven-question AI review gate that keeps scanner evidence separate from model inference. -- Capability-scoped defensive review workflows for repository, dependency, secret, and infrastructure findings. - -## Quick start - -Requirements: - -- Node.js 20 or newer (Node 24 recommended) -- npm -- at least one supported scanner binary in `PATH` - -```bash -git clone https://github.com/cmahmud/synsec.git -cd synsec -npm install -npm run build - -# See which engines are installed -npm run synsec -- doctor . - -# Scan a repository -npm run synsec -- scan /path/to/repository -``` - -See [`docs/INSTALL.md`](docs/INSTALL.md) for scanner installation notes. - -SynSec skips selected engines that are not installed and reports the missing coverage. If **none** of the selected engines can run, the scan fails instead of returning a misleading 100/100 score. - -A normal scan writes: - -```text -.synsec/ -├── report.json -├── report.html -└── report.sarif -``` - -The JSON report can also contain scanner artifacts such as a normalized Syft SBOM. Open `report.html` locally for the dashboard. - -## Commands - -```text -synsec init [path] -synsec doctor [path] -synsec scan [options] -synsec review [options] -synsec import-sarif [options] -synsec workflows -synsec render -synsec baseline [destination] -synsec version -``` - -Useful scan options: - -```text ---scanners opengrep,betterleaks,trivy ---parallel 3 ---timeout 900 ---changed ---changed-base main ---fail-on high ---baseline .synsec/baseline.json ---json ---no-write -``` - -Create a starter configuration with: - -```bash -npm run synsec -- init . -``` - -That creates `synsec.config.json`. - -## Default configuration - -```json -{ - "schemaVersion": 1, - "scanners": [ - "opengrep", - "betterleaks", - "osv-scanner", - "trivy", - "grype", - "checkov", - "syft", - "scorecard" - ], - "parallelism": 3, - "timeoutMs": 900000, - "failOn": "none", - "reports": { - "json": ".synsec/report.json", - "html": ".synsec/report.html", - "sarif": ".synsec/report.sarif" - }, - "ai": { - "enabled": false, - "provider": "openai-compatible", - "sendSourceContext": false - } -} -``` - -`failOn` can be `critical`, `high`, `medium`, `low`, `info`, `unknown`, or `none`. When a threshold is configured, a scan containing that severity or higher exits with code `2`, which is useful in CI. - -## Scanner engines - -| Engine | SynSec ID | Purpose | Default | -| --- | --- | --- | --- | -| Opengrep | `opengrep` | SAST / taint-aware static analysis | yes | -| Betterleaks | `betterleaks` | secrets and Git history | yes | -| Gitleaks | `gitleaks` | secrets and Git history fallback | no | -| OSV-Scanner | `osv-scanner` | open-source dependency vulnerabilities | yes | -| Trivy | `trivy` | dependencies, secrets, IaC/misconfiguration | yes | -| Grype | `grype` | package/dependency vulnerabilities | yes | -| Checkov | `checkov` | infrastructure-as-code | yes | -| Syft | `syft` | software bill of materials / package inventory | yes | -| OpenSSF Scorecard | `scorecard` | repository security posture | yes | - -Betterleaks is preferred for new installs because it is the actively developed successor maintained by the Gitleaks team. SynSec does **not** enable Betterleaks live credential validation; the adapter performs repository scanning with redacted report output only. - -Syft is an artifact-producing scanner in SynSec. It does not manufacture vulnerability findings: its package inventory is preserved as an SBOM artifact in the report and can be used by later dependency/reachability workflows. - -OpenSSF Scorecard results are treated as repository-posture findings rather than definitive vulnerabilities. Perfect 10/10 checks are not manufactured into findings; non-perfect checks retain their own score and reason as metadata. - -The engines stay separate projects with their own licenses. SynSec invokes installed binaries and parses their machine-readable output rather than copying their source into this repository. - -## Changed-file scans - -For pull-request or incremental analysis, SynSec can scope a report to files changed since a Git base ref: - -```bash -npm run synsec -- scan . --changed --changed-base main -``` - -When `--changed-base` is omitted, SynSec uses the GitHub pull-request base branch when `GITHUB_BASE_REF` is available and otherwise falls back to `HEAD~1`. - -The report records the scope and changed file list. File-located findings outside that diff are omitted, while repository-level findings that do not map to one file are retained. Opengrep and Betterleaks currently narrow execution directly to the changed files; other scanners may still perform their normal repository analysis before SynSec filters file-located results. This distinction is intentional so the report does not imply that every underlying engine has a native incremental mode. - -## Importing SARIF - -SynSec can ingest SARIF 2.1 output from another scanner and normalize it into the same finding/report model: - -```bash -npm run synsec -- import-sarif external-results.sarif --root . -``` - -By default this writes `.synsec/imported-report.json` and an adjacent HTML report. The importer preserves rule IDs, locations, severity, confidence when present, common identifiers, remediation text, source tool version, and a native partial fingerprint when supplied. - -This is an import path, not a command-execution plugin: SynSec reads the SARIF document and does not execute the producing scanner. - -## Correlation - -Raw scanner output is not the product. SynSec converts each result into a common model containing, where available: - -- category and severity; -- confidence; -- scanner and rule ID; -- file/line/column; -- CVE, CWE, GHSA, and OSV identifiers; -- evidence that is safe to retain; -- remediation guidance; -- scanner-specific metadata; -- native scanner fingerprint. - -SynSec then computes its own correlation fingerprint. This matters because two scanners often use different rule IDs, titles, and native fingerprints for the same issue. - -Current v0.2 correlation can merge: - -- dependency findings sharing advisory identifiers and package identity; -- secret findings at the same file/line without hashing or retaining the secret; -- SAST findings sharing file/line/CWE; -- conservative scanner-aware exact matches when stronger evidence is unavailable. - -The user sees one logical issue with multiple supporting sources instead of several copies of the same alert. - -## Baselines - -After a scan: - -```bash -npm run synsec -- baseline .synsec/report.json -``` - -A later scan can compare against it: - -```bash -npm run synsec -- scan . --baseline .synsec/baseline.json -``` - -The new report tracks new, fixed, and persisting findings. This makes SynSec useful as a regression detector rather than only a one-time scanner. - -## Optional AI review - -AI is a **second-pass reviewer**, not the source of truth. It is disabled by default. - -SynSec supports endpoints implementing the OpenAI-compatible `/chat/completions` shape, including local gateways and model routers. A self-hosted router or another compatible provider can therefore sit behind SynSec without tying the project to one model vendor. - -```bash -export SYNSEC_AI_BASE_URL="http://localhost:PORT/v1" -export SYNSEC_AI_MODEL="your/model-id" -export SYNSEC_AI_API_KEY="optional-key" - -npm run synsec -- scan . --ai -``` - -By default the AI reviewer receives normalized finding metadata but **not repository source code**. To explicitly allow a small bounded source excerpt around a finding: - -```bash -npm run synsec -- scan . --ai --ai-source -``` - -AI output is written separately to `.synsec/ai-review.json` so deterministic scanner evidence remains distinguishable from model inference. - -The review uses seven checks: - -1. Is there a concrete affected location? -2. Is untrusted input involved when required by the finding? -3. Is there a security-sensitive sink or invariant violation? -4. Is the path reachable rather than dead/example code? -5. Were relevant mitigations considered? -6. Is there actual scanner/code evidence? -7. Is there a specific remediation? - -Unknown evidence stays `unknown`; the reviewer is instructed not to invent proof. - -## Defensive workflows - -`npm run synsec -- workflows` lists the built-in review workflows. Current workflows are: - -- `repository-review` — broad review of normalized repository findings; -- `dependency-review` — dependencies, containers, supply chain, and license findings; -- `secrets-review` — redacted secret metadata only, with source context prohibited; -- `infrastructure-review` — IaC, configuration, and repository-posture findings. - -A workflow can be selected during AI review: - -```bash -npm run synsec -- scan . --ai --workflow dependency-review -``` - -Workflow definitions declare allowed capabilities. Repository modifications require an explicit approval boundary, and external network assessment is forbidden in these repository workflows. - -## Privacy and network behavior - -Repository contents stay local to SynSec and its local scanner processes unless the operator explicitly enables an integration or scanner behavior that communicates externally. - -Important exceptions to understand: - -- OSV-Scanner normally queries vulnerability/package services for dependency metadata unless configured for its offline mode externally. -- Opengrep's `auto` rules configuration may fetch rule configuration from the network. -- OpenSSF Scorecard can use Git hosting APIs and may need a GitHub token for complete/rate-limit-friendly results. -- AI review sends normalized finding metadata to the configured model endpoint when enabled. -- Source excerpts are only sent to the AI endpoint when `sendSourceContext` or `--ai-source` is explicitly enabled and the selected workflow permits them. - -Secret scanner output is requested with full redaction, and SynSec deliberately does not copy secret values into normalized findings. - -## Architecture - -```text -repository - | - +--> repository inventory - | - +--> scanner adapters - | - +-- Opengrep - +-- Betterleaks / Gitleaks - +-- OSV-Scanner - +-- Trivy - +-- Grype - +-- Checkov - +-- Syft ----------> SBOM artifact - +-- OpenSSF Scorecard - | - v - normalized findings - | - v - correlation / deduplication - | - +-------+--------+ - | | - v v - reports optional AI review - JSON/HTML/SARIF + workflows - | - v - baseline diff -``` - -The codebase is split into small packages: - -```text -apps/cli command-line product -packages/core domain model + correlation + artifact types -packages/config stable configuration format -packages/scanner-sdk scanner adapter/process boundary -packages/scanners built-in scanner integrations + SARIF importer -packages/repository safe repository inventory/context -packages/report JSON/SARIF/HTML + baselines + scan scope -packages/engine orchestration, incremental scope, failure isolation -packages/ai opt-in provider-agnostic review gate -packages/workflows capability-scoped defensive review workflows -``` - -See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for trust boundaries and package details and [`docs/ROADMAP.md`](docs/ROADMAP.md) for planned work. - -## Safety model - -SynSec's primary job is defensive analysis of repositories the operator owns or is authorized to assess. Repository scanning does not execute the target project's application or build scripts. - -External attack-surface or bug-bounty functionality, if added later, will remain a separate explicitly authorized mode with scope controls rather than weakening the repository-first default. - -## Development - -```bash -npm install -npm run build -npm test -npm run typecheck -``` - -CI runs the build, typecheck, and test suite on Node 20 and Node 24. - -## Project status - -v0.2 is intended to be the first release worth hands-on testing. The next major work after scanner reliability is repository reachability/context, GitHub pull-request integration, stronger finding lifecycle management, fix-verification/report-writing workflows, model-routing policy, and a richer persistent web application. - -## License - -A SynSec project license has not been selected yet. Third-party scanner engines retain their own licenses and are not vendored into SynSec. From 005b674d80f25f9a78419f01320e90c2aa55c6c4 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:36:53 -0400 Subject: [PATCH 0357/1132] fix(docs): restore README after connector routing mistake --- README.md | 361 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) diff --git a/README.md b/README.md index 9d966fee..791854f6 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,364 @@ SynSec is a repository-first security scanner that combines mature open-source s Instead of replacing tools such as Opengrep, Trivy, Betterleaks, OSV-Scanner, Grype, Checkov, Syft, and OpenSSF Scorecard, SynSec runs them through a common adapter layer, merges overlapping results, preserves supporting artifacts such as SBOMs, adds repository context, tracks changes against baselines, exports developer-friendly reports, and can optionally send selected findings through an OpenAI-compatible model router for a separate review pass. > **Current release line:** v0.2 development MVP. The repository is usable for local testing, but scanner adapters and report schemas may still change before v1.0. + +## What works now + +- Multi-scanner repository scans with bounded concurrency. +- Scanner failure isolation: one broken engine does not destroy the whole scan. +- Protection against false "clean" reports when no scanner successfully ran. +- Opengrep SAST integration. +- Betterleaks secret scanning, with Gitleaks retained as an optional fallback. +- OSV-Scanner dependency analysis. +- Trivy vulnerability, secret, and misconfiguration analysis. +- Grype dependency/package analysis. +- Checkov IaC analysis. +- Syft SBOM generation with normalized package, PURL, license, and location metadata. +- OpenSSF Scorecard repository-posture analysis. +- Generic SARIF 2.1 import for bringing third-party scanner findings into SynSec. +- Scanner-independent finding and artifact schemas. +- Deterministic cross-scanner correlation and deduplication. +- Repository language/framework inventory. +- Git commit, branch, and remote metadata discovery with credential redaction. +- Changed-file scan scope for pull-request and incremental workflows, with direct narrowing for supported scanners. +- Versioned JSON reports. +- Self-contained HTML security dashboard. +- SARIF 2.1.0 output for code-scanning systems. +- Baselines with new/fixed/persisting finding tracking. +- Configurable CI failure thresholds. +- Explicit opt-in AI finding review through an OpenAI-compatible endpoint. +- A seven-question AI review gate that keeps scanner evidence separate from model inference. +- Capability-scoped defensive review workflows for repository, dependency, secret, and infrastructure findings. + +## Quick start + +Requirements: + +- Node.js 20 or newer (Node 24 recommended) +- npm +- at least one supported scanner binary in `PATH` + +```bash +git clone https://github.com/cmahmud/synsec.git +cd synsec +npm install +npm run build + +# See which engines are installed +npm run synsec -- doctor . + +# Scan a repository +npm run synsec -- scan /path/to/repository +``` + +See [`docs/INSTALL.md`](docs/INSTALL.md) for scanner installation notes. + +SynSec skips selected engines that are not installed and reports the missing coverage. If **none** of the selected engines can run, the scan fails instead of returning a misleading 100/100 score. + +A normal scan writes: + +```text +.synsec/ +├── report.json +├── report.html +└── report.sarif +``` + +The JSON report can also contain scanner artifacts such as a normalized Syft SBOM. Open `report.html` locally for the dashboard. + +## Commands + +```text +synsec init [path] +synsec doctor [path] +synsec scan [options] +synsec review [options] +synsec import-sarif [options] +synsec workflows +synsec render +synsec baseline [destination] +synsec version +``` + +Useful scan options: + +```text +--scanners opengrep,betterleaks,trivy +--parallel 3 +--timeout 900 +--changed +--changed-base main +--fail-on high +--baseline .synsec/baseline.json +--json +--no-write +``` + +Create a starter configuration with: + +```bash +npm run synsec -- init . +``` + +That creates `synsec.config.json`. + +## Default configuration + +```json +{ + "schemaVersion": 1, + "scanners": [ + "opengrep", + "betterleaks", + "osv-scanner", + "trivy", + "grype", + "checkov", + "syft", + "scorecard" + ], + "parallelism": 3, + "timeoutMs": 900000, + "failOn": "none", + "reports": { + "json": ".synsec/report.json", + "html": ".synsec/report.html", + "sarif": ".synsec/report.sarif" + }, + "ai": { + "enabled": false, + "provider": "openai-compatible", + "sendSourceContext": false + } +} +``` + +`failOn` can be `critical`, `high`, `medium`, `low`, `info`, `unknown`, or `none`. When a threshold is configured, a scan containing that severity or higher exits with code `2`, which is useful in CI. + +## Scanner engines + +| Engine | SynSec ID | Purpose | Default | +| --- | --- | --- | --- | +| Opengrep | `opengrep` | SAST / taint-aware static analysis | yes | +| Betterleaks | `betterleaks` | secrets and Git history | yes | +| Gitleaks | `gitleaks` | secrets and Git history fallback | no | +| OSV-Scanner | `osv-scanner` | open-source dependency vulnerabilities | yes | +| Trivy | `trivy` | dependencies, secrets, IaC/misconfiguration | yes | +| Grype | `grype` | package/dependency vulnerabilities | yes | +| Checkov | `checkov` | infrastructure-as-code | yes | +| Syft | `syft` | software bill of materials / package inventory | yes | +| OpenSSF Scorecard | `scorecard` | repository security posture | yes | + +Betterleaks is preferred for new installs because it is the actively developed successor maintained by the Gitleaks team. SynSec does **not** enable Betterleaks live credential validation; the adapter performs repository scanning with redacted report output only. + +Syft is an artifact-producing scanner in SynSec. It does not manufacture vulnerability findings: its package inventory is preserved as an SBOM artifact in the report and can be used by later dependency/reachability workflows. + +OpenSSF Scorecard results are treated as repository-posture findings rather than definitive vulnerabilities. Perfect 10/10 checks are not manufactured into findings; non-perfect checks retain their own score and reason as metadata. + +The engines stay separate projects with their own licenses. SynSec invokes installed binaries and parses their machine-readable output rather than copying their source into this repository. + +## Changed-file scans + +For pull-request or incremental analysis, SynSec can scope a report to files changed since a Git base ref: + +```bash +npm run synsec -- scan . --changed --changed-base main +``` + +When `--changed-base` is omitted, SynSec uses the GitHub pull-request base branch when `GITHUB_BASE_REF` is available and otherwise falls back to `HEAD~1`. + +The report records the scope and changed file list. File-located findings outside that diff are omitted, while repository-level findings that do not map to one file are retained. Opengrep and Betterleaks currently narrow execution directly to the changed files; other scanners may still perform their normal repository analysis before SynSec filters file-located results. This distinction is intentional so the report does not imply that every underlying engine has a native incremental mode. + +## Importing SARIF + +SynSec can ingest SARIF 2.1 output from another scanner and normalize it into the same finding/report model: + +```bash +npm run synsec -- import-sarif external-results.sarif --root . +``` + +By default this writes `.synsec/imported-report.json` and an adjacent HTML report. The importer preserves rule IDs, locations, severity, confidence when present, common identifiers, remediation text, source tool version, and a native partial fingerprint when supplied. + +This is an import path, not a command-execution plugin: SynSec reads the SARIF document and does not execute the producing scanner. + +## Correlation + +Raw scanner output is not the product. SynSec converts each result into a common model containing, where available: + +- category and severity; +- confidence; +- scanner and rule ID; +- file/line/column; +- CVE, CWE, GHSA, and OSV identifiers; +- evidence that is safe to retain; +- remediation guidance; +- scanner-specific metadata; +- native scanner fingerprint. + +SynSec then computes its own correlation fingerprint. This matters because two scanners often use different rule IDs, titles, and native fingerprints for the same issue. + +Current v0.2 correlation can merge: + +- dependency findings sharing advisory identifiers and package identity; +- secret findings at the same file/line without hashing or retaining the secret; +- SAST findings sharing file/line/CWE; +- conservative scanner-aware exact matches when stronger evidence is unavailable. + +The user sees one logical issue with multiple supporting sources instead of several copies of the same alert. + +## Baselines + +After a scan: + +```bash +npm run synsec -- baseline .synsec/report.json +``` + +A later scan can compare against it: + +```bash +npm run synsec -- scan . --baseline .synsec/baseline.json +``` + +The new report tracks new, fixed, and persisting findings. This makes SynSec useful as a regression detector rather than only a one-time scanner. + +## Optional AI review + +AI is a **second-pass reviewer**, not the source of truth. It is disabled by default. + +SynSec supports endpoints implementing the OpenAI-compatible `/chat/completions` shape, including local gateways and model routers. A self-hosted router or another compatible provider can therefore sit behind SynSec without tying the project to one model vendor. + +```bash +export SYNSEC_AI_BASE_URL="http://localhost:PORT/v1" +export SYNSEC_AI_MODEL="your/model-id" +export SYNSEC_AI_API_KEY="optional-key" + +npm run synsec -- scan . --ai +``` + +By default the AI reviewer receives normalized finding metadata but **not repository source code**. To explicitly allow a small bounded source excerpt around a finding: + +```bash +npm run synsec -- scan . --ai --ai-source +``` + +AI output is written separately to `.synsec/ai-review.json` so deterministic scanner evidence remains distinguishable from model inference. + +The review uses seven checks: + +1. Is there a concrete affected location? +2. Is untrusted input involved when required by the finding? +3. Is there a security-sensitive sink or invariant violation? +4. Is the path reachable rather than dead/example code? +5. Were relevant mitigations considered? +6. Is there actual scanner/code evidence? +7. Is there a specific remediation? + +Unknown evidence stays `unknown`; the reviewer is instructed not to invent proof. + +## Defensive workflows + +`npm run synsec -- workflows` lists the built-in review workflows. Current workflows are: + +- `repository-review` — broad review of normalized repository findings; +- `dependency-review` — dependencies, containers, supply chain, and license findings; +- `secrets-review` — redacted secret metadata only, with source context prohibited; +- `infrastructure-review` — IaC, configuration, and repository-posture findings. + +A workflow can be selected during AI review: + +```bash +npm run synsec -- scan . --ai --workflow dependency-review +``` + +Workflow definitions declare allowed capabilities. Repository modifications require an explicit approval boundary, and external network assessment is forbidden in these repository workflows. + +## Privacy and network behavior + +Repository contents stay local to SynSec and its local scanner processes unless the operator explicitly enables an integration or scanner behavior that communicates externally. + +Important exceptions to understand: + +- OSV-Scanner normally queries vulnerability/package services for dependency metadata unless configured for its offline mode externally. +- Opengrep's `auto` rules configuration may fetch rule configuration from the network. +- OpenSSF Scorecard can use Git hosting APIs and may need a GitHub token for complete/rate-limit-friendly results. +- AI review sends normalized finding metadata to the configured model endpoint when enabled. +- Source excerpts are only sent to the AI endpoint when `sendSourceContext` or `--ai-source` is explicitly enabled and the selected workflow permits them. + +Secret scanner output is requested with full redaction, and SynSec deliberately does not copy secret values into normalized findings. + +## Architecture + +```text +repository + | + +--> repository inventory + | + +--> scanner adapters + | + +-- Opengrep + +-- Betterleaks / Gitleaks + +-- OSV-Scanner + +-- Trivy + +-- Grype + +-- Checkov + +-- Syft ----------> SBOM artifact + +-- OpenSSF Scorecard + | + v + normalized findings + | + v + correlation / deduplication + | + +-------+--------+ + | | + v v + reports optional AI review + JSON/HTML/SARIF + workflows + | + v + baseline diff +``` + +The codebase is split into small packages: + +```text +apps/cli command-line product +packages/core domain model + correlation + artifact types +packages/config stable configuration format +packages/scanner-sdk scanner adapter/process boundary +packages/scanners built-in scanner integrations + SARIF importer +packages/repository safe repository inventory/context +packages/report JSON/SARIF/HTML + baselines + scan scope +packages/engine orchestration, incremental scope, failure isolation +packages/ai opt-in provider-agnostic review gate +packages/workflows capability-scoped defensive review workflows +``` + +See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for trust boundaries and package details and [`docs/ROADMAP.md`](docs/ROADMAP.md) for planned work. + +## Safety model + +SynSec's primary job is defensive analysis of repositories the operator owns or is authorized to assess. Repository scanning does not execute the target project's application or build scripts. + +External attack-surface or bug-bounty functionality, if added later, will remain a separate explicitly authorized mode with scope controls rather than weakening the repository-first default. + +## Development + +```bash +npm install +npm run build +npm test +npm run typecheck +``` + +CI runs the build, typecheck, and test suite on Node 20 and Node 24. + +## Project status + +v0.2 is intended to be the first release worth hands-on testing. The next major work after scanner reliability is repository reachability/context, GitHub pull-request integration, stronger finding lifecycle management, fix-verification/report-writing workflows, model-routing policy, and a richer persistent web application. + +## License + +A SynSec project license has not been selected yet. Third-party scanner engines retain their own licenses and are not vendored into SynSec. From 0b23ebdd76d61e29246d944fdf84114d5685c324 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:37:20 -0400 Subject: [PATCH 0358/1132] chore: preserve README content From 2dd5415bc2a1b67ac524b11b92a85ef165af9d0a Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:37:49 -0400 Subject: [PATCH 0359/1132] chore: preserve restored README exactly From bdc785d369ea1314669c8ff663b22af5900159c6 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:46:04 -0400 Subject: [PATCH 0360/1132] feat(github): acquire exact PR base alongside head --- packages/github/src/repository-acquisition.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/github/src/repository-acquisition.ts b/packages/github/src/repository-acquisition.ts index 80644967..5c9bb6c8 100644 --- a/packages/github/src/repository-acquisition.ts +++ b/packages/github/src/repository-acquisition.ts @@ -43,6 +43,13 @@ export interface AcquiredGitHubRepository { cleanup(): Promise; } +export interface AcquiredGitHubScanTarget extends AcquiredGitHubRepository { + base?: { + commitSha: string; + workspace: string; + }; +} + function boundedTimeout(value: number | undefined): number { const timeoutMs = value ?? DEFAULT_TIMEOUT_MS; if (!Number.isSafeInteger(timeoutMs) || timeoutMs < MIN_TIMEOUT_MS || timeoutMs > MAX_TIMEOUT_MS) { @@ -256,3 +263,57 @@ export async function acquireGitHubRepositoryCommit(input: { }, }; } + +/** + * Acquire the exact head commit and, when supplied, the exact PR base commit as a second isolated + * workspace. The same short-lived installation credential is used only during this acquisition + * phase; neither workspace contains persisted Git credentials. Cleanup is all-or-nothing. + */ +export async function acquireGitHubRepositoryScanTarget(input: { + repository: string; + commitSha: string; + baseCommitSha?: string; + installationToken: string; +}, options: GitHubRepositoryAcquisitionOptions = {}): Promise { + const repository = validateGitHubRepositoryIdentity(input.repository); + const commitSha = validateGitHubCommitSha(input.commitSha); + const baseCommitSha = input.baseCommitSha === undefined + ? undefined + : validateGitHubCommitSha(input.baseCommitSha); + + const head = await acquireGitHubRepositoryCommit({ + repository, + commitSha, + installationToken: input.installationToken, + }, options); + + if (!baseCommitSha) return head; + if (baseCommitSha === commitSha) { + return { + ...head, + base: { commitSha: baseCommitSha, workspace: head.workspace }, + }; + } + + let base: AcquiredGitHubRepository | undefined; + try { + base = await acquireGitHubRepositoryCommit({ + repository, + commitSha: baseCommitSha, + installationToken: input.installationToken, + }, options); + } catch (error) { + await head.cleanup(); + throw error; + } + + return { + repository, + commitSha, + workspace: head.workspace, + base: { commitSha: base.commitSha, workspace: base.workspace }, + cleanup: async () => { + await Promise.all([head.cleanup(), base?.cleanup()]); + }, + }; +} From 86b611ad2d3b1599e42e3979d3077e7467e4715a Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:46:31 -0400 Subject: [PATCH 0361/1132] feat(github): pass exact PR base workspace through worker --- packages/github/src/app-worker.ts | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/github/src/app-worker.ts b/packages/github/src/app-worker.ts index 83846f16..3cdcc112 100644 --- a/packages/github/src/app-worker.ts +++ b/packages/github/src/app-worker.ts @@ -1,7 +1,7 @@ import type { SynSecReport } from "@synsec/report"; import { - acquireGitHubRepositoryCommit, - type AcquiredGitHubRepository, + acquireGitHubRepositoryScanTarget, + type AcquiredGitHubScanTarget, type GitHubRepositoryAcquisitionOptions, } from "./repository-acquisition.js"; import type { GitHubScanJob } from "./scan-queue.js"; @@ -23,12 +23,12 @@ export interface GitHubAppWorkerOptions { queue: GitHubAppWorkerQueue; installationStore: GitHubAppWorkerAuthorizer; getInstallationToken(installationId: number, purpose: GitHubInstallationTokenPurpose): Promise; - scan(job: GitHubScanJob, workspace: string): Promise; + scan(job: GitHubScanJob, workspace: string, baseWorkspace?: string): Promise; publish(job: GitHubScanJob, report: SynSecReport, installationToken: string): Promise; acquire?: ( - input: { repository: string; commitSha: string; installationToken: string }, + input: { repository: string; commitSha: string; baseCommitSha?: string; installationToken: string }, options?: GitHubRepositoryAcquisitionOptions, - ) => Promise; + ) => Promise; acquisitionOptions?: GitHubRepositoryAcquisitionOptions; } @@ -49,14 +49,15 @@ function safeError(error: unknown): string { * Authorization is checked again after lease acquisition so a repository removed or suspended * after webhook queueing is never scanned from stale authorization. Installation credentials are * obtained only in the transport layer: one short-lived token for exact-commit acquisition and a - * fresh token for publication. The scanner receives only the commit-pinned workspace and job - * descriptor. Reports must bind to the exact queued head SHA before publication or completion. + * fresh token for publication. For PR jobs, acquisition can also materialize the exact queued base + * commit in a second isolated workspace without exposing credentials to the scanner. Reports must + * bind to the exact queued head SHA before publication or completion. */ export async function runNextGitHubAppScanJob(options: GitHubAppWorkerOptions): Promise { const job = await options.queue.claimNext(); if (!job) return { status: "idle" }; - let acquired: AcquiredGitHubRepository | undefined; + let acquired: AcquiredGitHubScanTarget | undefined; try { const allowed = await options.installationStore.isRepositoryAllowed(job.installationId, job.repository); if (!allowed) { @@ -65,18 +66,25 @@ export async function runNextGitHubAppScanJob(options: GitHubAppWorkerOptions): } const acquisitionToken = await options.getInstallationToken(job.installationId, "acquire"); - const acquire = options.acquire ?? acquireGitHubRepositoryCommit; + const acquire = options.acquire ?? acquireGitHubRepositoryScanTarget; acquired = await acquire({ repository: job.repository, commitSha: job.headSha, + ...(job.event === "pull_request" && job.baseSha ? { baseCommitSha: job.baseSha } : {}), installationToken: acquisitionToken, }, options.acquisitionOptions); if (acquired.repository !== job.repository || acquired.commitSha.toLowerCase() !== job.headSha.toLowerCase()) { throw new Error("Acquired GitHub repository does not match the leased scan job provenance."); } + if (job.event === "pull_request") { + const acquiredBaseSha = acquired.base?.commitSha.toLowerCase(); + if (!job.baseSha || !acquiredBaseSha || acquiredBaseSha !== job.baseSha.toLowerCase()) { + throw new Error("Acquired GitHub base repository does not match the leased pull-request base SHA."); + } + } - const report = await options.scan(job, acquired.workspace); + const report = await options.scan(job, acquired.workspace, acquired.base?.workspace); const reportSha = report.target.commitSha?.trim().toLowerCase(); if (!reportSha || reportSha !== job.headSha.toLowerCase()) { throw new Error("GitHub App worker report commit does not match the leased scan job head SHA."); From cffb3f2217dcd100bbc933f2b9b16ced1235558c Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:46:46 -0400 Subject: [PATCH 0362/1132] feat(github): baseline hosted PR scans on exact base commit --- packages/github/src/app-worker-runner.ts | 44 +++++++++++++++++++----- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/github/src/app-worker-runner.ts b/packages/github/src/app-worker-runner.ts index d72801b2..c0b1a9f3 100644 --- a/packages/github/src/app-worker-runner.ts +++ b/packages/github/src/app-worker-runner.ts @@ -8,7 +8,7 @@ import { type GitHubInstallationTokenPurpose, } from "./app-worker.js"; import { - acquireGitHubRepositoryCommit, + acquireGitHubRepositoryScanTarget, type GitHubRepositoryAcquisitionOptions, } from "./repository-acquisition.js"; import { buildGitHubCheck, type GitHubCheckThreshold, type GitHubPullRequestContext } from "./index.js"; @@ -24,7 +24,7 @@ export interface ConfiguredGitHubAppWorkerOptions extends GitHubPublisherOptions publishSarif?: boolean; toolVersion?: string; scan?: typeof runScanEngine; - acquire?: typeof acquireGitHubRepositoryCommit; + acquire?: typeof acquireGitHubRepositoryScanTarget; acquisitionOptions?: GitHubRepositoryAcquisitionOptions; } @@ -32,29 +32,40 @@ function contextForJob(job: { repository: string; headSha: string; event: "push" | "pull_request"; + baseSha?: string; pullRequestNumber?: number; }): GitHubPullRequestContext { return { repository: job.repository, sha: job.headSha, + ...(job.event === "pull_request" && job.baseSha ? { baseSha: job.baseSha } : {}), ...(job.event === "pull_request" && job.pullRequestNumber ? { pullRequestNumber: job.pullRequestNumber } : {}), }; } +function requireCommit(reportCommitSha: string | undefined, expectedSha: string, label: string): void { + const actual = reportCommitSha?.trim().toLowerCase(); + if (!actual || actual !== expectedSha.toLowerCase()) { + throw new Error(`${label} report commit does not match the queued GitHub commit SHA.`); + } +} + /** * Execute one configured hosted-App job through SynSec's existing repository scan engine. * - * Hosted jobs intentionally use a full repository scan at this layer. PR changed-file baselines - * require separately acquiring the exact base commit and are not approximated from a branch name. - * Publication uses only the normalized queue repository/head identity and fixed GitHub API hosts. + * Push jobs scan the exact acquired head commit. Pull-request jobs additionally acquire and scan + * the exact queued base commit, bind that report to baseSha, and use it as the deterministic + * baseline for the exact head scan. Both remain full-repository scans at this layer: SynSec does + * not approximate changed-file scope from a branch name or perform an unbounded history fetch. + * Publication uses only normalized queue repository/commit identity and fixed GitHub API hosts. */ export async function runConfiguredGitHubAppWorkerOnce( options: ConfiguredGitHubAppWorkerOptions, ): Promise { const scan = options.scan ?? runScanEngine; - const acquire = options.acquire ?? acquireGitHubRepositoryCommit; + const acquire = options.acquire ?? acquireGitHubRepositoryScanTarget; return runNextGitHubAppScanJob({ queue: options.queue, @@ -62,20 +73,37 @@ export async function runConfiguredGitHubAppWorkerOnce( getInstallationToken: options.getInstallationToken, acquire, acquisitionOptions: options.acquisitionOptions, - scan: async (_job, workspace) => { + scan: async (job, workspace, baseWorkspace) => { + let baseline: ScanEngineOutcome["report"] | undefined; + if (job.event === "pull_request") { + if (!job.baseSha || !baseWorkspace) { + throw new Error("Hosted pull-request scan requires the exact acquired base workspace."); + } + const baseOutcome: ScanEngineOutcome = await scan({ + rootPath: baseWorkspace, + config: options.config, + toolVersion: options.toolVersion, + changedOnly: false, + }); + requireCommit(baseOutcome.report.target.commitSha, job.baseSha, "GitHub App baseline"); + baseline = baseOutcome.report; + } + const outcome: ScanEngineOutcome = await scan({ rootPath: workspace, config: options.config, toolVersion: options.toolVersion, + ...(baseline ? { baseline } : {}), changedOnly: false, }); + requireCommit(outcome.report.target.commitSha, job.headSha, "GitHub App head"); return outcome.report; }, publish: async (job, report, installationToken) => { const context = contextForJob(job); const check = buildGitHubCheck(report, context, { threshold: options.threshold, - onlyNewAnnotations: false, + onlyNewAnnotations: Boolean(report.baseline), }); await publishGitHubCheck(check, context, installationToken, { apiVersion: options.apiVersion, From 9e2329ff6e149fa3b4b169e68c744173abe3ad28 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:47:07 -0400 Subject: [PATCH 0363/1132] test(github): cover exact-base hosted PR baseline scans --- tests/github-app-worker-runner.test.mjs | 76 ++++++++++++++++++++----- 1 file changed, 61 insertions(+), 15 deletions(-) diff --git a/tests/github-app-worker-runner.test.mjs b/tests/github-app-worker-runner.test.mjs index 419f2ee4..c21fb39b 100644 --- a/tests/github-app-worker-runner.test.mjs +++ b/tests/github-app-worker-runner.test.mjs @@ -4,20 +4,22 @@ import test from "node:test"; import { runConfiguredGitHubAppWorkerOnce } from "@synsec/github/app-worker-runner"; const headSha = "0123456789abcdef0123456789abcdef01234567"; +const baseSha = "abcdef0123456789abcdef0123456789abcdef01"; -function report() { +function report(commitSha, baseline) { return { schemaVersion: "1.0", - reportId: "configured-worker-report", + reportId: `configured-worker-report-${commitSha.slice(0, 8)}`, generatedAt: "2026-08-22T19:00:00.000Z", toolVersion: "0.2.0", - target: { path: "/tmp/acquired", commitSha: headSha }, + target: { path: "/tmp/acquired", commitSha }, scanners: [], rawFindingCount: 0, findingCount: 0, summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }, securityScore: 100, findings: [], + ...(baseline ? { baseline: { new: [], fixed: [], persisting: [] } } : {}), }; } @@ -30,7 +32,7 @@ function leasedPrJob() { repository: "cmahmud/synsec", headSha, event: "pull_request", - baseSha: "abcdef0123456789abcdef0123456789abcdef01", + baseSha, pullRequestNumber: 2, createdAt: "2026-08-22T19:00:00.000Z", attempts: 1, @@ -39,7 +41,7 @@ function leasedPrJob() { }; } -test("configured worker runs the existing scan engine path and publishes check plus optional SARIF", async () => { +test("configured PR worker scans exact base then head and publishes one baseline-aware report", async () => { const job = leasedPrJob(); const completed = []; const scanInputs = []; @@ -71,17 +73,22 @@ test("configured worker runs the existing scan engine path and publishes check p tokenPurposes.push(purpose); return purpose === "acquire" ? "acquire-token" : "publish-token"; }, - acquire: async (input) => ({ - repository: input.repository, - commitSha: input.commitSha, - workspace: "/tmp/acquired", - cleanup: async () => { cleanupCalls += 1; }, - }), + acquire: async (input) => { + assert.equal(input.baseCommitSha, baseSha); + return { + repository: input.repository, + commitSha: input.commitSha, + workspace: "/tmp/acquired-head", + base: { commitSha: input.baseCommitSha, workspace: "/tmp/acquired-base" }, + cleanup: async () => { cleanupCalls += 1; }, + }; + }, scan: async (input) => { scanInputs.push(input); + const isBase = input.rootPath === "/tmp/acquired-base"; return { - report: report(), - repositoryIndex: { version: 1, root: "/tmp/acquired", files: [] }, + report: report(isBase ? baseSha : headSha, Boolean(input.baseline)), + repositoryIndex: { version: 1, root: input.rootPath, files: [] }, statuses: [], failures: [], shouldFail: false, @@ -93,18 +100,57 @@ test("configured worker runs the existing scan engine path and publishes check p assert.equal(result.status, "completed"); assert.deepEqual(completed, [job.jobId]); - assert.equal(scanInputs.length, 1); - assert.equal(scanInputs[0].rootPath, "/tmp/acquired"); + assert.equal(scanInputs.length, 2); + assert.equal(scanInputs[0].rootPath, "/tmp/acquired-base"); + assert.equal(scanInputs[0].baseline, undefined); assert.equal(scanInputs[0].changedOnly, false); + assert.equal(scanInputs[1].rootPath, "/tmp/acquired-head"); + assert.equal(scanInputs[1].baseline.target.commitSha, baseSha); + assert.equal(scanInputs[1].changedOnly, false); assert.deepEqual(tokenPurposes, ["acquire", "publish"]); assert.equal(requests.length, 2); assert.equal(requests[0].url, "https://api.github.com/repos/cmahmud/synsec/check-runs"); assert.equal(requests[1].url, "https://api.github.com/repos/cmahmud/synsec/code-scanning/sarifs"); const checkBody = JSON.parse(requests[0].init.body); assert.equal(checkBody.head_sha, headSha); + assert.match(checkBody.output.summary, /New:/); const sarifBody = JSON.parse(requests[1].init.body); assert.equal(sarifBody.commit_sha, headSha); assert.equal(sarifBody.ref, "refs/pull/2/head"); assert.equal(requests.some((request) => request.url.includes("attacker.invalid")), false); assert.equal(cleanupCalls, 1); }); + +test("configured PR worker refuses a baseline report that does not bind to the queued base", async () => { + const job = leasedPrJob(); + const releases = []; + const result = await runConfiguredGitHubAppWorkerOnce({ + queue: { + async claimNext() { return job; }, + async release(id) { releases.push(id); return { ...job, status: "pending", leaseUntil: undefined }; }, + async fail() { throw new Error("must not fail"); }, + async complete() { throw new Error("must not complete"); }, + }, + installationStore: { isRepositoryAllowed: async () => true }, + config: { scanners: ["opengrep"], parallelism: 1 }, + getInstallationToken: async () => "token", + acquire: async (input) => ({ + repository: input.repository, + commitSha: input.commitSha, + workspace: "/tmp/acquired-head", + base: { commitSha: baseSha, workspace: "/tmp/acquired-base" }, + cleanup: async () => {}, + }), + scan: async () => ({ + report: report(headSha), + repositoryIndex: { version: 1, root: "/tmp", files: [] }, + statuses: [], + failures: [], + shouldFail: false, + }), + }); + + assert.equal(result.status, "retry_scheduled"); + assert.match(result.error, /baseline report commit does not match/); + assert.deepEqual(releases, [job.jobId]); +}); From fcbf5a5e10190f4788b01f7714d39d400dd9f947 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:47:26 -0400 Subject: [PATCH 0364/1132] test(github): cover exact PR head and base acquisition --- tests/github-repository-acquisition.test.mjs | 59 +++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/tests/github-repository-acquisition.test.mjs b/tests/github-repository-acquisition.test.mjs index 6e9069f6..55bd4292 100644 --- a/tests/github-repository-acquisition.test.mjs +++ b/tests/github-repository-acquisition.test.mjs @@ -6,11 +6,13 @@ import test from "node:test"; import { acquireGitHubRepositoryCommit, + acquireGitHubRepositoryScanTarget, validateGitHubCommitSha, validateGitHubRepositoryIdentity, } from "@synsec/github/repository-acquisition"; const sha = "0123456789abcdef0123456789abcdef01234567"; +const baseSha = "abcdef0123456789abcdef0123456789abcdef01"; test("repository acquisition validates fixed-host owner/name identities", () => { assert.equal(validateGitHubRepositoryIdentity("cmahmud/synsec"), "cmahmud/synsec"); @@ -61,6 +63,61 @@ test("exact-commit acquisition keeps the installation token out of git argv and await assert.rejects(() => access(acquired.workspace), /ENOENT/); }); +test("PR acquisition materializes exact head and base in isolated workspaces with one cleanup", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-acquire-pr-")); + const workspaces = new Map(); + const gitRunner = async (args, options) => { + if (args[0] === "fetch") workspaces.set(options.cwd, args.at(-1)); + if (args[0] === "rev-parse") { + return { exitCode: 0, stdout: `${workspaces.get(options.cwd)}\n`, stderr: "" }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }; + + const acquired = await acquireGitHubRepositoryScanTarget({ + repository: "cmahmud/synsec", + commitSha: sha, + baseCommitSha: baseSha, + installationToken: "token", + }, { workspaceRoot: root, gitRunner, timeoutMs: 10_000 }); + + assert.equal(acquired.commitSha, sha); + assert.equal(acquired.base?.commitSha, baseSha); + assert.notEqual(acquired.workspace, acquired.base?.workspace); + const headWorkspace = acquired.workspace; + const baseWorkspace = acquired.base.workspace; + await acquired.cleanup(); + await assert.rejects(() => access(headWorkspace), /ENOENT/); + await assert.rejects(() => access(baseWorkspace), /ENOENT/); +}); + +test("PR acquisition cleans an already-acquired head if exact base acquisition fails", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-acquire-pr-fail-")); + const workspaces = new Map(); + let headWorkspace; + const gitRunner = async (args, options) => { + if (!headWorkspace) headWorkspace = options.cwd; + if (args[0] === "fetch") { + const requested = args.at(-1); + workspaces.set(options.cwd, requested); + if (requested === baseSha) return { exitCode: 1, stdout: "", stderr: "base unavailable" }; + } + if (args[0] === "rev-parse") { + return { exitCode: 0, stdout: `${workspaces.get(options.cwd)}\n`, stderr: "" }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }; + + await assert.rejects(() => acquireGitHubRepositoryScanTarget({ + repository: "cmahmud/synsec", + commitSha: sha, + baseCommitSha: baseSha, + installationToken: "token", + }, { workspaceRoot: root, gitRunner, timeoutMs: 10_000 }), /base unavailable/); + assert.ok(headWorkspace); + await assert.rejects(() => access(headWorkspace), /ENOENT/); +}); + test("acquisition rejects malformed transport identity before invoking git", async () => { let called = false; await assert.rejects(() => acquireGitHubRepositoryCommit({ @@ -79,7 +136,7 @@ test("acquisition rejects malformed transport identity before invoking git", asy test("acquisition removes the temporary workspace when commit provenance mismatches", async () => { const root = await mkdtemp(join(tmpdir(), "synsec-acquire-mismatch-")); let workspace; - const otherSha = "abcdef0123456789abcdef0123456789abcdef01"; + const otherSha = "fedcba9876543210fedcba9876543210fedcba98"; const gitRunner = async (args, options) => { workspace = options.cwd; if (args[0] === "rev-parse") return { exitCode: 0, stdout: `${otherSha}\n`, stderr: "" }; From 42dedfbdea960dc44176e6dfdac6c5b0b539781d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:48:32 -0400 Subject: [PATCH 0365/1132] feat(github): add memory-only App installation token provider --- packages/github/src/app-token-provider.ts | 74 +++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 packages/github/src/app-token-provider.ts diff --git a/packages/github/src/app-token-provider.ts b/packages/github/src/app-token-provider.ts new file mode 100644 index 00000000..22447f1c --- /dev/null +++ b/packages/github/src/app-token-provider.ts @@ -0,0 +1,74 @@ +import { + createGitHubAppJwt, + createGitHubInstallationToken, + type GitHubAppTokenOptions, + type GitHubInstallationToken, +} from "./app.js"; + +const DEFAULT_MIN_REMAINING_MS = 30_000; +const MAX_PRIVATE_KEY_BYTES = 64 * 1024; + +export interface GitHubAppInstallationTokenProviderOptions extends GitHubAppTokenOptions { + appId: string | number; + privateKey: string; + minRemainingMs?: number; + now?: () => number; + exchange?: typeof createGitHubInstallationToken; +} + +function boundedPrivateKey(value: string): string { + if (!value.trim()) throw new Error("GitHub App private key is required."); + if (Buffer.byteLength(value, "utf8") > MAX_PRIVATE_KEY_BYTES) { + throw new Error(`GitHub App private key exceeds ${MAX_PRIVATE_KEY_BYTES} bytes.`); + } + return value; +} + +function minRemainingMs(value: number | undefined): number { + const normalized = value ?? DEFAULT_MIN_REMAINING_MS; + if (!Number.isSafeInteger(normalized) || normalized < 0 || normalized > 10 * 60 * 1000) { + throw new Error("GitHub installation-token minimum remaining lifetime must be between 0 and 600000 milliseconds."); + } + return normalized; +} + +function validateTokenLifetime(token: GitHubInstallationToken, now: number, minimum: number): void { + const expiresAt = Date.parse(token.expiresAt); + if (!Number.isFinite(expiresAt)) { + throw new Error("GitHub installation-token API returned an invalid expiration timestamp."); + } + if (expiresAt - now < minimum) { + throw new Error("GitHub installation token expires too soon for a repository operation."); + } +} + +/** + * Build a memory-only installation-token provider for hosted App workers. + * + * A fresh short-lived App JWT is signed for every operation and immediately exchanged through the + * fixed GitHub installation-token endpoint. Installation tokens are returned to the caller only; + * this provider deliberately has no token cache, disk persistence, scanner integration, or logging. + */ +export function createGitHubAppInstallationTokenProvider( + options: GitHubAppInstallationTokenProviderOptions, +): (installationId: number) => Promise { + const privateKey = boundedPrivateKey(options.privateKey); + const minimum = minRemainingMs(options.minRemainingMs); + const now = options.now ?? Date.now; + const exchange = options.exchange ?? createGitHubInstallationToken; + + return async (installationId: number): Promise => { + const currentTime = now(); + if (!Number.isFinite(currentTime) || currentTime <= 0) { + throw new Error("GitHub App token-provider clock must be a positive timestamp."); + } + const appJwt = createGitHubAppJwt(options.appId, privateKey, currentTime); + const token = await exchange(installationId, appJwt, { + apiVersion: options.apiVersion, + userAgent: options.userAgent, + fetch: options.fetch, + }); + validateTokenLifetime(token, currentTime, minimum); + return token.token; + }; +} From 9db9ac6a344805a7b3e77498692a8dc5137976dc Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:48:44 -0400 Subject: [PATCH 0366/1132] chore(github): export App token provider --- packages/github/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/github/package.json b/packages/github/package.json index 8de0fad8..a3db17d9 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -12,6 +12,7 @@ "./baseline": "./dist/baseline.js", "./base-scan": "./dist/base-scan.js", "./app": "./dist/app.js", + "./app-token-provider": "./dist/app-token-provider.js", "./app-intake": "./dist/app-intake.js", "./app-dispatch": "./dist/app-dispatch.js", "./app-handler": "./dist/app-handler.js", From ccb81a8507ce1a90d4215d45f9373958b406958f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:48:59 -0400 Subject: [PATCH 0367/1132] test(github): cover memory-only App token provider --- tests/github-app-token-provider.test.mjs | 69 ++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/github-app-token-provider.test.mjs diff --git a/tests/github-app-token-provider.test.mjs b/tests/github-app-token-provider.test.mjs new file mode 100644 index 00000000..1bda4287 --- /dev/null +++ b/tests/github-app-token-provider.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { generateKeyPairSync } from "node:crypto"; +import test from "node:test"; + +import { createGitHubAppInstallationTokenProvider } from "@synsec/github/app-token-provider"; + +function privateKeyPem() { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + return privateKey.export({ type: "pkcs8", format: "pem" }); +} + +test("App token provider signs a fresh short-lived JWT per operation without caching installation tokens", async () => { + let now = Date.UTC(2026, 7, 22, 19, 30, 0); + const exchanges = []; + const provider = createGitHubAppInstallationTokenProvider({ + appId: 12345, + privateKey: privateKeyPem(), + now: () => now, + exchange: async (installationId, jwt) => { + const payload = JSON.parse(Buffer.from(jwt.split(".")[1], "base64url").toString("utf8")); + exchanges.push({ installationId, jwt, payload }); + return { + token: `installation-token-${exchanges.length}`, + expiresAt: new Date(now + 60 * 60 * 1000).toISOString(), + }; + }, + }); + + assert.equal(await provider(42), "installation-token-1"); + now += 1_000; + assert.equal(await provider(42), "installation-token-2"); + assert.equal(exchanges.length, 2); + assert.notEqual(exchanges[0].jwt, exchanges[1].jwt); + assert.equal(exchanges[0].payload.iss, "12345"); + assert.equal(exchanges[1].payload.iat - exchanges[0].payload.iat, 1); +}); + +test("App token provider rejects malformed or nearly expired token metadata", async () => { + const now = Date.UTC(2026, 7, 22, 19, 30, 0); + const key = privateKeyPem(); + const invalidExpiry = createGitHubAppInstallationTokenProvider({ + appId: 1, + privateKey: key, + now: () => now, + exchange: async () => ({ token: "secret", expiresAt: "not-a-time" }), + }); + await assert.rejects(() => invalidExpiry(1), /invalid expiration timestamp/); + + const expiring = createGitHubAppInstallationTokenProvider({ + appId: 1, + privateKey: key, + now: () => now, + minRemainingMs: 30_000, + exchange: async () => ({ token: "secret", expiresAt: new Date(now + 29_999).toISOString() }), + }); + await assert.rejects(() => expiring(1), /expires too soon/); +}); + +test("App token provider bounds private-key and lifetime configuration before exchange", () => { + assert.throws(() => createGitHubAppInstallationTokenProvider({ + appId: 1, + privateKey: "x".repeat(64 * 1024 + 1), + }), /private key exceeds/); + assert.throws(() => createGitHubAppInstallationTokenProvider({ + appId: 1, + privateKey: privateKeyPem(), + minRemainingMs: 600_001, + }), /minimum remaining lifetime/); +}); From b7aed1267d0f1fe89232e443fc3baf293b91ee63 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:49:28 -0400 Subject: [PATCH 0368/1132] feat(github): compose local hosted App runtime --- packages/github/src/app-runtime.ts | 123 +++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 packages/github/src/app-runtime.ts diff --git a/packages/github/src/app-runtime.ts b/packages/github/src/app-runtime.ts new file mode 100644 index 00000000..0e7fbd9b --- /dev/null +++ b/packages/github/src/app-runtime.ts @@ -0,0 +1,123 @@ +import { mkdir } from "node:fs/promises"; +import { join, resolve, relative } from "node:path"; +import type { SynSecConfig } from "@synsec/config"; +import { createGitHubAppWebhookHttpHandler } from "./app-http.js"; +import { createGitHubAppInstallationTokenProvider } from "./app-token-provider.js"; +import { runConfiguredGitHubAppWorkerOnce, type ConfiguredGitHubAppWorkerOptions } from "./app-worker-runner.js"; +import { FileGitHubInstallationStore } from "./installation-store.js"; +import { FileGitHubWebhookReplayStore } from "./replay-store.js"; +import { FileGitHubScanQueue } from "./scan-queue.js"; +import type { GitHubCheckThreshold } from "./index.js"; +import type { GitHubPublisherOptions } from "./publisher.js"; + +export interface LocalGitHubAppRuntimeOptions extends GitHubPublisherOptions { + stateDirectory: string; + workspaceRoot: string; + webhookSecret: string; + appId: string | number; + privateKey: string; + config: SynSecConfig; + webhookPath?: string; + replayRetentionMs?: number; + queueLeaseMs?: number; + threshold?: GitHubCheckThreshold; + publishSarif?: boolean; + toolVersion?: string; + onWebhookError?: (error: unknown) => void; + now?: () => number; +} + +export interface LocalGitHubAppRuntime { + stateDirectory: string; + workspaceRoot: string; + replayStore: FileGitHubWebhookReplayStore; + installationStore: FileGitHubInstallationStore; + queue: FileGitHubScanQueue; + webhookHandler: ReturnType; + runWorkerOnce(): ReturnType; +} + +function requiredDirectory(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + return resolve(normalized); +} + +function pathsOverlap(a: string, b: string): boolean { + const aToB = relative(a, b); + const bToA = relative(b, a); + return a === b + || (aToB !== "" && !aToB.startsWith("..") && !resolve(aToB).startsWith("..")) + || (bToA !== "" && !bToA.startsWith("..") && !resolve(bToA).startsWith("..")); +} + +/** + * Compose SynSec's single-host GitHub App primitives without opening a network listener. + * + * State and source workspaces must be separate directory trees so scanner working copies are never + * created inside durable authorization/queue storage. App credentials remain in the returned + * token-provider closure only; they are not written to any local store. The caller still owns TLS, + * listener binding, process/container isolation, network policy, and secret injection/rotation. + */ +export async function createLocalGitHubAppRuntime(options: LocalGitHubAppRuntimeOptions): Promise { + const stateDirectory = requiredDirectory(options.stateDirectory, "GitHub App state directory"); + const workspaceRoot = requiredDirectory(options.workspaceRoot, "GitHub App workspace root"); + if (pathsOverlap(stateDirectory, workspaceRoot)) { + throw new Error("GitHub App state directory and workspace root must be separate directory trees."); + } + if (!options.webhookSecret.trim()) throw new Error("GitHub App webhook secret is required."); + + await mkdir(stateDirectory, { recursive: true, mode: 0o700 }); + await mkdir(workspaceRoot, { recursive: true, mode: 0o700 }); + + const replayStore = new FileGitHubWebhookReplayStore(join(stateDirectory, "replay"), { + ...(options.replayRetentionMs !== undefined ? { retentionMs: options.replayRetentionMs } : {}), + ...(options.now ? { now: options.now } : {}), + }); + const installationStore = new FileGitHubInstallationStore(join(stateDirectory, "installations")); + const queue = new FileGitHubScanQueue(join(stateDirectory, "queue"), { + ...(options.queueLeaseMs !== undefined ? { leaseMs: options.queueLeaseMs } : {}), + ...(options.now ? { now: options.now } : {}), + }); + const getInstallationToken = createGitHubAppInstallationTokenProvider({ + appId: options.appId, + privateKey: options.privateKey, + ...(options.apiVersion ? { apiVersion: options.apiVersion } : {}), + ...(options.userAgent ? { userAgent: options.userAgent } : {}), + ...(options.fetch ? { fetch: options.fetch } : {}), + ...(options.now ? { now: options.now } : {}), + }); + + const webhookHandler = createGitHubAppWebhookHttpHandler({ + webhookSecret: options.webhookSecret, + replayStore, + installationStore, + queue, + ...(options.webhookPath ? { path: options.webhookPath } : {}), + ...(options.onWebhookError ? { onError: options.onWebhookError } : {}), + }); + + const workerOptions: ConfiguredGitHubAppWorkerOptions = { + queue, + installationStore, + config: options.config, + getInstallationToken, + acquisitionOptions: { workspaceRoot }, + ...(options.threshold ? { threshold: options.threshold } : {}), + ...(options.publishSarif !== undefined ? { publishSarif: options.publishSarif } : {}), + ...(options.toolVersion ? { toolVersion: options.toolVersion } : {}), + ...(options.apiVersion ? { apiVersion: options.apiVersion } : {}), + ...(options.userAgent ? { userAgent: options.userAgent } : {}), + ...(options.fetch ? { fetch: options.fetch } : {}), + }; + + return { + stateDirectory, + workspaceRoot, + replayStore, + installationStore, + queue, + webhookHandler, + runWorkerOnce: () => runConfiguredGitHubAppWorkerOnce(workerOptions), + }; +} From f4bc5e64af2d720aa8dd4f82306ea0faebbb4fb5 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:49:46 -0400 Subject: [PATCH 0369/1132] fix(github): validate runtime state/workspace separation --- packages/github/src/app-runtime.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/github/src/app-runtime.ts b/packages/github/src/app-runtime.ts index 0e7fbd9b..c1dc836d 100644 --- a/packages/github/src/app-runtime.ts +++ b/packages/github/src/app-runtime.ts @@ -1,5 +1,5 @@ import { mkdir } from "node:fs/promises"; -import { join, resolve, relative } from "node:path"; +import { isAbsolute, join, relative, resolve } from "node:path"; import type { SynSecConfig } from "@synsec/config"; import { createGitHubAppWebhookHttpHandler } from "./app-http.js"; import { createGitHubAppInstallationTokenProvider } from "./app-token-provider.js"; @@ -43,12 +43,13 @@ function requiredDirectory(value: string, label: string): string { return resolve(normalized); } +function isSameOrDescendant(parent: string, candidate: string): boolean { + const path = relative(parent, candidate); + return path === "" || (!isAbsolute(path) && path !== ".." && !path.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)); +} + function pathsOverlap(a: string, b: string): boolean { - const aToB = relative(a, b); - const bToA = relative(b, a); - return a === b - || (aToB !== "" && !aToB.startsWith("..") && !resolve(aToB).startsWith("..")) - || (bToA !== "" && !bToA.startsWith("..") && !resolve(bToA).startsWith("..")); + return isSameOrDescendant(a, b) || isSameOrDescendant(b, a); } /** From 5819fdaf23736adf071c2fd8565aeb904446d69d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:49:57 -0400 Subject: [PATCH 0370/1132] chore(github): export local App runtime composition --- packages/github/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/github/package.json b/packages/github/package.json index a3db17d9..3370109e 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -17,6 +17,7 @@ "./app-dispatch": "./dist/app-dispatch.js", "./app-handler": "./dist/app-handler.js", "./app-http": "./dist/app-http.js", + "./app-runtime": "./dist/app-runtime.js", "./app-worker": "./dist/app-worker.js", "./app-worker-runner": "./dist/app-worker-runner.js", "./replay-store": "./dist/replay-store.js", From 27541620dc023e722bb57c9750e4fdc317585056 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:50:12 -0400 Subject: [PATCH 0371/1132] test(github): cover local App runtime composition --- tests/github-app-runtime.test.mjs | 72 +++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/github-app-runtime.test.mjs diff --git a/tests/github-app-runtime.test.mjs b/tests/github-app-runtime.test.mjs new file mode 100644 index 00000000..e2245e54 --- /dev/null +++ b/tests/github-app-runtime.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { generateKeyPairSync } from "node:crypto"; +import { mkdtemp, readdir, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { createLocalGitHubAppRuntime } from "@synsec/github/app-runtime"; + +function privateKeyPem() { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + return privateKey.export({ type: "pkcs8", format: "pem" }); +} + +async function textFiles(root) { + const result = []; + async function walk(path) { + for (const entry of await readdir(path, { withFileTypes: true })) { + const child = join(path, entry.name); + if (entry.isDirectory()) await walk(child); + else if (entry.isFile()) result.push(await readFile(child, "utf8")); + } + } + await walk(root); + return result; +} + +test("local App runtime composes durable stores and an idle worker without persisting credentials", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-runtime-")); + const stateDirectory = join(root, "state"); + const workspaceRoot = join(root, "workspaces"); + const privateKey = privateKeyPem(); + const webhookSecret = "runtime-webhook-secret"; + const runtime = await createLocalGitHubAppRuntime({ + stateDirectory, + workspaceRoot, + webhookSecret, + appId: 12345, + privateKey, + config: { scanners: ["opengrep"], parallelism: 1 }, + }); + + assert.equal(runtime.stateDirectory, stateDirectory); + assert.equal(runtime.workspaceRoot, workspaceRoot); + assert.equal(typeof runtime.webhookHandler, "function"); + assert.deepEqual(await runtime.runWorkerOnce(), { status: "idle" }); + + const persisted = (await textFiles(stateDirectory)).join("\n"); + assert.equal(persisted.includes(webhookSecret), false); + assert.equal(persisted.includes(privateKey.slice(0, 32)), false); +}); + +test("local App runtime refuses overlapping durable state and scanner workspace trees", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-runtime-overlap-")); + const base = { + webhookSecret: "secret", + appId: 12345, + privateKey: privateKeyPem(), + config: { scanners: ["opengrep"], parallelism: 1 }, + }; + + await assert.rejects(() => createLocalGitHubAppRuntime({ + ...base, + stateDirectory: join(root, "state"), + workspaceRoot: join(root, "state", "workspaces"), + }), /separate directory trees/); + await assert.rejects(() => createLocalGitHubAppRuntime({ + ...base, + stateDirectory: join(root, "workspaces", "state"), + workspaceRoot: join(root, "workspaces"), + }), /separate directory trees/); +}); From 976a46508378b50a33496699cd185c5236a3a781 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:52:26 -0400 Subject: [PATCH 0372/1132] feat(github): retain validated installation permission metadata --- packages/github/src/app.ts | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/github/src/app.ts b/packages/github/src/app.ts index 56fa132b..6e8edf5f 100644 --- a/packages/github/src/app.ts +++ b/packages/github/src/app.ts @@ -10,9 +10,14 @@ export interface GitHubAppTokenOptions { fetch?: typeof globalThis.fetch; } +export type GitHubInstallationPermissionLevel = "read" | "write"; +export type GitHubInstallationPermissions = Record; + export interface GitHubInstallationToken { token: string; expiresAt: string; + permissions?: GitHubInstallationPermissions; + repositorySelection?: "all" | "selected"; } export interface GitHubAppWebhook { @@ -71,6 +76,24 @@ function repositoryName(payload: Record): string | undefined { return fullName && /^[^/\s]+\/[^/\s]+$/.test(fullName) ? fullName : undefined; } +function installationPermissions(value: unknown): GitHubInstallationPermissions | undefined { + if (value === undefined) return undefined; + const record = objectValue(value); + if (!record) throw new Error("GitHub installation-token API returned invalid permission metadata."); + const permissions: GitHubInstallationPermissions = {}; + for (const [name, level] of Object.entries(record)) { + const normalizedName = name.trim(); + if (!normalizedName || normalizedName.length > 128 || !/^[a-z0-9_]+$/i.test(normalizedName)) { + throw new Error("GitHub installation-token API returned invalid permission metadata."); + } + if (level !== "read" && level !== "write") { + throw new Error("GitHub installation-token API returned invalid permission metadata."); + } + permissions[normalizedName] = level; + } + return permissions; +} + /** Verify GitHub's X-Hub-Signature-256 against the exact request bytes. */ export function verifyGitHubWebhookSignature( body: string | Uint8Array, @@ -237,5 +260,18 @@ export async function createGitHubInstallationToken( const token = stringValue(payload.token); const expiresAt = stringValue(payload.expires_at); if (!token || !expiresAt) throw new Error("GitHub installation-token API response is missing token metadata."); - return { token, expiresAt }; + if (!Number.isFinite(Date.parse(expiresAt))) { + throw new Error("GitHub installation-token API returned an invalid expiration timestamp."); + } + const permissions = installationPermissions(payload.permissions); + const selection = payload.repository_selection; + if (selection !== undefined && selection !== "all" && selection !== "selected") { + throw new Error("GitHub installation-token API returned invalid repository-selection metadata."); + } + return { + token, + expiresAt, + ...(permissions ? { permissions } : {}), + ...(selection ? { repositorySelection: selection } : {}), + }; } From 303f0c3c30399a398b0c1f50a28c516faddaaf29 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:52:47 -0400 Subject: [PATCH 0373/1132] feat(github): enforce purpose-specific App token permissions --- packages/github/src/app-token-provider.ts | 71 ++++++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/github/src/app-token-provider.ts b/packages/github/src/app-token-provider.ts index 22447f1c..987e1dfa 100644 --- a/packages/github/src/app-token-provider.ts +++ b/packages/github/src/app-token-provider.ts @@ -2,11 +2,18 @@ import { createGitHubAppJwt, createGitHubInstallationToken, type GitHubAppTokenOptions, + type GitHubInstallationPermissionLevel, type GitHubInstallationToken, } from "./app.js"; const DEFAULT_MIN_REMAINING_MS = 30_000; const MAX_PRIVATE_KEY_BYTES = 64 * 1024; +const MAX_PERMISSION_REQUIREMENTS = 32; + +export type GitHubPermissionRequirementsByPurpose = Record< + string, + Record +>; export interface GitHubAppInstallationTokenProviderOptions extends GitHubAppTokenOptions { appId: string | number; @@ -14,6 +21,7 @@ export interface GitHubAppInstallationTokenProviderOptions extends GitHubAppToke minRemainingMs?: number; now?: () => number; exchange?: typeof createGitHubInstallationToken; + requiredPermissionsByPurpose?: GitHubPermissionRequirementsByPurpose; } function boundedPrivateKey(value: string): string { @@ -32,6 +40,35 @@ function minRemainingMs(value: number | undefined): number { return normalized; } +function validateRequirements(value: GitHubPermissionRequirementsByPurpose | undefined): GitHubPermissionRequirementsByPurpose { + if (!value) return {}; + const result: GitHubPermissionRequirementsByPurpose = {}; + let count = 0; + for (const [purpose, permissions] of Object.entries(value)) { + const normalizedPurpose = purpose.trim(); + if (!normalizedPurpose || normalizedPurpose.length > 64) { + throw new Error("GitHub token permission purpose is invalid."); + } + const normalized: Record = {}; + for (const [name, level] of Object.entries(permissions)) { + count += 1; + if (count > MAX_PERMISSION_REQUIREMENTS) { + throw new Error(`GitHub token provider exceeds ${MAX_PERMISSION_REQUIREMENTS} permission requirements.`); + } + const permission = name.trim(); + if (!permission || permission.length > 128 || !/^[a-z0-9_]+$/i.test(permission)) { + throw new Error("GitHub token permission requirement contains an invalid permission name."); + } + if (level !== "read" && level !== "write") { + throw new Error("GitHub token permission requirement must be read or write."); + } + normalized[permission] = level; + } + result[normalizedPurpose] = normalized; + } + return result; +} + function validateTokenLifetime(token: GitHubInstallationToken, now: number, minimum: number): void { const expiresAt = Date.parse(token.expiresAt); if (!Number.isFinite(expiresAt)) { @@ -42,22 +79,51 @@ function validateTokenLifetime(token: GitHubInstallationToken, now: number, mini } } +function permissionSatisfies( + actual: GitHubInstallationPermissionLevel | undefined, + required: GitHubInstallationPermissionLevel, +): boolean { + if (required === "read") return actual === "read" || actual === "write"; + return actual === "write"; +} + +function validateTokenPermissions( + token: GitHubInstallationToken, + requirements: Record | undefined, + purpose: string | undefined, +): void { + if (!requirements || Object.keys(requirements).length === 0) return; + if (!token.permissions) { + throw new Error(`GitHub installation token is missing permission metadata required for ${purpose ?? "this operation"}.`); + } + const missing = Object.entries(requirements) + .filter(([name, required]) => !permissionSatisfies(token.permissions?.[name], required)) + .map(([name, required]) => `${name}:${required}`) + .sort(); + if (missing.length > 0) { + throw new Error(`GitHub installation token lacks required permission(s) for ${purpose ?? "this operation"}: ${missing.join(", ")}.`); + } +} + /** * Build a memory-only installation-token provider for hosted App workers. * * A fresh short-lived App JWT is signed for every operation and immediately exchanged through the * fixed GitHub installation-token endpoint. Installation tokens are returned to the caller only; * this provider deliberately has no token cache, disk persistence, scanner integration, or logging. + * Optional purpose-specific permission requirements are checked against GitHub's token metadata + * before the credential is returned to acquisition/publication code. */ export function createGitHubAppInstallationTokenProvider( options: GitHubAppInstallationTokenProviderOptions, -): (installationId: number) => Promise { +): (installationId: number, purpose?: string) => Promise { const privateKey = boundedPrivateKey(options.privateKey); const minimum = minRemainingMs(options.minRemainingMs); + const requirements = validateRequirements(options.requiredPermissionsByPurpose); const now = options.now ?? Date.now; const exchange = options.exchange ?? createGitHubInstallationToken; - return async (installationId: number): Promise => { + return async (installationId: number, purpose?: string): Promise => { const currentTime = now(); if (!Number.isFinite(currentTime) || currentTime <= 0) { throw new Error("GitHub App token-provider clock must be a positive timestamp."); @@ -69,6 +135,7 @@ export function createGitHubAppInstallationTokenProvider( fetch: options.fetch, }); validateTokenLifetime(token, currentTime, minimum); + validateTokenPermissions(token, purpose ? requirements[purpose] : undefined, purpose); return token.token; }; } From faa41fe708c5e607101a5c69e5302eef62c2beaf Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:53:05 -0400 Subject: [PATCH 0374/1132] feat(github): enforce runtime App permission requirements --- packages/github/src/app-runtime.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/github/src/app-runtime.ts b/packages/github/src/app-runtime.ts index c1dc836d..308f88d2 100644 --- a/packages/github/src/app-runtime.ts +++ b/packages/github/src/app-runtime.ts @@ -57,8 +57,10 @@ function pathsOverlap(a: string, b: string): boolean { * * State and source workspaces must be separate directory trees so scanner working copies are never * created inside durable authorization/queue storage. App credentials remain in the returned - * token-provider closure only; they are not written to any local store. The caller still owns TLS, - * listener binding, process/container isolation, network policy, and secret injection/rotation. + * token-provider closure only; they are not written to any local store. The token provider also + * fails closed when GitHub reports that the installation lacks the permissions required for + * repository acquisition or publication. The caller still owns TLS, listener binding, + * process/container isolation, network policy, and secret injection/rotation. */ export async function createLocalGitHubAppRuntime(options: LocalGitHubAppRuntimeOptions): Promise { const stateDirectory = requiredDirectory(options.stateDirectory, "GitHub App state directory"); @@ -83,6 +85,13 @@ export async function createLocalGitHubAppRuntime(options: LocalGitHubAppRuntime const getInstallationToken = createGitHubAppInstallationTokenProvider({ appId: options.appId, privateKey: options.privateKey, + requiredPermissionsByPurpose: { + acquire: { contents: "read" }, + publish: { + checks: "write", + ...(options.publishSarif ? { security_events: "write" as const } : {}), + }, + }, ...(options.apiVersion ? { apiVersion: options.apiVersion } : {}), ...(options.userAgent ? { userAgent: options.userAgent } : {}), ...(options.fetch ? { fetch: options.fetch } : {}), From c27e688bb9ee16c018c08da75487367c68043f5a Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:53:23 -0400 Subject: [PATCH 0375/1132] test(github): cover App permission diagnostics --- tests/github-app-token-provider.test.mjs | 49 +++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/github-app-token-provider.test.mjs b/tests/github-app-token-provider.test.mjs index 1bda4287..9e3100cb 100644 --- a/tests/github-app-token-provider.test.mjs +++ b/tests/github-app-token-provider.test.mjs @@ -35,6 +35,48 @@ test("App token provider signs a fresh short-lived JWT per operation without cac assert.equal(exchanges[1].payload.iat - exchanges[0].payload.iat, 1); }); +test("App token provider enforces purpose-specific installation permissions before returning credentials", async () => { + const now = Date.UTC(2026, 7, 22, 19, 30, 0); + let permissions = { contents: "read", checks: "write", security_events: "write" }; + const provider = createGitHubAppInstallationTokenProvider({ + appId: 1, + privateKey: privateKeyPem(), + now: () => now, + requiredPermissionsByPurpose: { + acquire: { contents: "read" }, + publish: { checks: "write", security_events: "write" }, + }, + exchange: async () => ({ + token: "transport-secret", + expiresAt: new Date(now + 60 * 60 * 1000).toISOString(), + permissions, + }), + }); + + assert.equal(await provider(1, "acquire"), "transport-secret"); + assert.equal(await provider(1, "publish"), "transport-secret"); + permissions = { contents: "read", checks: "read", security_events: "write" }; + await assert.rejects(() => provider(1, "publish"), /checks:write/); + permissions = undefined; + await assert.rejects(() => provider(1, "acquire"), /missing permission metadata/); +}); + +test("write permission satisfies a read requirement without weakening write requirements", async () => { + const now = Date.UTC(2026, 7, 22, 19, 30, 0); + const provider = createGitHubAppInstallationTokenProvider({ + appId: 1, + privateKey: privateKeyPem(), + now: () => now, + requiredPermissionsByPurpose: { acquire: { contents: "read" } }, + exchange: async () => ({ + token: "transport-secret", + expiresAt: new Date(now + 60 * 60 * 1000).toISOString(), + permissions: { contents: "write" }, + }), + }); + assert.equal(await provider(1, "acquire"), "transport-secret"); +}); + test("App token provider rejects malformed or nearly expired token metadata", async () => { const now = Date.UTC(2026, 7, 22, 19, 30, 0); const key = privateKeyPem(); @@ -56,7 +98,7 @@ test("App token provider rejects malformed or nearly expired token metadata", as await assert.rejects(() => expiring(1), /expires too soon/); }); -test("App token provider bounds private-key and lifetime configuration before exchange", () => { +test("App token provider bounds private-key, lifetime, and permission configuration before exchange", () => { assert.throws(() => createGitHubAppInstallationTokenProvider({ appId: 1, privateKey: "x".repeat(64 * 1024 + 1), @@ -66,4 +108,9 @@ test("App token provider bounds private-key and lifetime configuration before ex privateKey: privateKeyPem(), minRemainingMs: 600_001, }), /minimum remaining lifetime/); + assert.throws(() => createGitHubAppInstallationTokenProvider({ + appId: 1, + privateKey: privateKeyPem(), + requiredPermissionsByPurpose: { publish: { "checks/write": "write" } }, + }), /invalid permission name/); }); From 03b48a39722038a1dfdd7d280be0fc35edfe0f25 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:53:47 -0400 Subject: [PATCH 0376/1132] test(github): validate installation token permission metadata --- tests/github-app.test.mjs | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/tests/github-app.test.mjs b/tests/github-app.test.mjs index 96605689..021bcdc6 100644 --- a/tests/github-app.test.mjs +++ b/tests/github-app.test.mjs @@ -135,11 +135,16 @@ test("createGitHubAppJwt creates a short-lived verifiable RS256 token", () => { ), true); }); -test("createGitHubInstallationToken posts only to the fixed GitHub installation endpoint", async () => { +test("createGitHubInstallationToken posts only to the fixed GitHub installation endpoint and validates permission metadata", async () => { let request; const fakeFetch = async (url, init) => { request = { url, init }; - return new Response(JSON.stringify({ token: "installation-token", expires_at: "2026-08-22T17:00:00Z" }), { status: 201 }); + return new Response(JSON.stringify({ + token: "installation-token", + expires_at: "2026-08-22T17:00:00Z", + permissions: { contents: "read", checks: "write" }, + repository_selection: "selected", + }), { status: 201 }); }; const result = await createGitHubInstallationToken(42, "app-jwt", { fetch: fakeFetch }); @@ -148,7 +153,28 @@ test("createGitHubInstallationToken posts only to the fixed GitHub installation assert.equal(request.init.redirect, "error"); assert.equal(request.init.headers.Authorization, "Bearer app-jwt"); assert.equal(request.init.body, "{}"); - assert.deepEqual(result, { token: "installation-token", expiresAt: "2026-08-22T17:00:00Z" }); + assert.deepEqual(result, { + token: "installation-token", + expiresAt: "2026-08-22T17:00:00Z", + permissions: { contents: "read", checks: "write" }, + repositorySelection: "selected", + }); +}); + +test("installation-token metadata validation fails closed", async () => { + const invalidPermission = async () => new Response(JSON.stringify({ + token: "installation-token", + expires_at: "2026-08-22T17:00:00Z", + permissions: { checks: "admin" }, + }), { status: 201 }); + await assert.rejects(() => createGitHubInstallationToken(42, "app-jwt", { fetch: invalidPermission }), /invalid permission metadata/); + + const invalidSelection = async () => new Response(JSON.stringify({ + token: "installation-token", + expires_at: "2026-08-22T17:00:00Z", + repository_selection: "surprise", + }), { status: 201 }); + await assert.rejects(() => createGitHubInstallationToken(42, "app-jwt", { fetch: invalidSelection }), /repository-selection metadata/); }); test("installation-token errors do not expose the app JWT", async () => { From 326f444e24a6303e4aeb994235f23b293e70ca0c Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:55:27 -0400 Subject: [PATCH 0377/1132] feat(lifecycle): add bounded finding ownership metadata --- packages/lifecycle/src/index.ts | 48 +++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/lifecycle/src/index.ts b/packages/lifecycle/src/index.ts index 90b44e94..fefa0050 100644 --- a/packages/lifecycle/src/index.ts +++ b/packages/lifecycle/src/index.ts @@ -16,6 +16,8 @@ export interface FindingLifecycleRecord { state: FindingState; updatedAt: string; note?: string; + /** Optional human/team assignment metadata. This is triage state, not scanner evidence. */ + owner?: string; reportId?: string; /** Last source path observed for scope-aware incremental reconciliation. */ lastSeenPath?: string; @@ -64,6 +66,7 @@ const MAX_LIFECYCLE_BYTES = 16 * 1024 * 1024; const MAX_LIFECYCLE_RECORDS = 100_000; const MAX_FINGERPRINT_LENGTH = 512; const MAX_NOTE_LENGTH = 10_000; +const MAX_OWNER_LENGTH = 255; const MAX_REPORT_ID_LENGTH = 512; const MAX_PATH_LENGTH = 4096; @@ -82,6 +85,10 @@ function boundedString(value: unknown, maxLength: number, required = false): val return value.length <= maxLength; } +function validOwner(value: unknown): value is string { + return boundedString(value, MAX_OWNER_LENGTH, true) && !/[\r\n\0]/.test(value); +} + function validTimestamp(value: unknown): value is string { return boundedString(value, 128, true) && Number.isFinite(Date.parse(value)); } @@ -92,6 +99,7 @@ function isLifecycleRecord(value: unknown, key: string): value is FindingLifecyc if (!boundedString(record.fingerprint, MAX_FINGERPRINT_LENGTH, true) || record.fingerprint !== key) return false; if (!isFindingState(record.state) || !validTimestamp(record.updatedAt)) return false; if (record.note !== undefined && !boundedString(record.note, MAX_NOTE_LENGTH)) return false; + if (record.owner !== undefined && !validOwner(record.owner)) return false; if (record.reportId !== undefined && !boundedString(record.reportId, MAX_REPORT_ID_LENGTH, true)) return false; if (record.lastSeenPath !== undefined && !boundedString(record.lastSeenPath, MAX_PATH_LENGTH, true)) return false; return true; @@ -164,6 +172,7 @@ export function setFindingState( }; const note = options.note?.trim() || previous?.note; if (note) record.note = note; + if (previous?.owner) record.owner = previous.owner; const reportId = options.reportId ?? previous?.reportId; if (reportId) record.reportId = reportId; if (previous?.lastSeenPath) record.lastSeenPath = previous.lastSeenPath; @@ -171,6 +180,44 @@ export function setFindingState( return updated; } +/** + * Assign or clear human ownership without changing scanner-derived finding state. + * The owner is bounded triage metadata only; control characters are rejected so the value is safe + * for deterministic text/JSON presentation. Pass undefined/null/blank to clear an assignment. + */ +export function setFindingOwner( + store: FindingLifecycleStore, + fingerprint: string, + owner?: string | null, + updatedAt = new Date().toISOString(), +): FindingLifecycleStore { + const key = fingerprint.trim(); + if (!key) throw new Error("Finding fingerprint cannot be empty."); + const previous = store.records[key]; + if (!previous) throw new Error(`Finding lifecycle record does not exist: ${key}`); + if (!validTimestamp(updatedAt)) throw new Error("Finding ownership timestamp must be a valid timestamp."); + + const normalizedOwner = owner?.trim() || undefined; + if (normalizedOwner !== undefined && !validOwner(normalizedOwner)) { + throw new Error(`Finding owner must be at most ${MAX_OWNER_LENGTH} characters and contain no control line breaks.`); + } + if (previous.owner === normalizedOwner) return store; + + const nextRecord: FindingLifecycleRecord = { + ...previous, + updatedAt, + }; + if (normalizedOwner) nextRecord.owner = normalizedOwner; + else delete nextRecord.owner; + return { + schemaVersion: 1, + records: { + ...store.records, + [key]: nextRecord, + }, + }; +} + function autoTransition(previous: FindingLifecycleRecord | undefined, present: boolean): FindingState | undefined { if (present) { if (!previous) return "new"; @@ -225,6 +272,7 @@ export function reconcileLifecycle( if (present || absenceCovered) record.reportId = report.reportId; else if (prior?.reportId) record.reportId = prior.reportId; if (prior?.note) record.note = prior.note; + if (prior?.owner) record.owner = prior.owner; const lastSeenPath = current?.primary.location?.path ?? prior?.lastSeenPath; if (lastSeenPath) record.lastSeenPath = lastSeenPath; next.records[fingerprint] = record; From ef3f6877b4f6e0482b10b2f7996258c7b8a8013f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:55:52 -0400 Subject: [PATCH 0378/1132] test(lifecycle): cover bounded finding ownership metadata --- tests/lifecycle.test.mjs | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/tests/lifecycle.test.mjs b/tests/lifecycle.test.mjs index 8fa59e92..ddb6a8f6 100644 --- a/tests/lifecycle.test.mjs +++ b/tests/lifecycle.test.mjs @@ -10,6 +10,7 @@ import { lifecycleSummary, readLifecycleStore, reconcileLifecycle, + setFindingOwner, setFindingState, verifyRemediation, writeLifecycleStore, @@ -56,6 +57,34 @@ test("lifecycle creates new findings and preserves explicit triage state", () => assert.equal(next.records[fingerprint].note, "Reviewed by maintainer"); }); +test("finding ownership is bounded triage metadata and survives state/reconciliation changes", () => { + const report = reportWith(["A"]); + const fingerprint = report.findings[0].fingerprint; + let store = reconcileLifecycle(report, emptyLifecycleStore(), "2026-01-01T00:00:00.000Z"); + store = setFindingOwner(store, fingerprint, "security-team", "2026-01-01T12:00:00.000Z"); + assert.equal(store.records[fingerprint].owner, "security-team"); + assert.equal(store.records[fingerprint].updatedAt, "2026-01-01T12:00:00.000Z"); + + store = setFindingState(store, fingerprint, "confirmed", { updatedAt: "2026-01-02T00:00:00.000Z" }); + assert.equal(store.records[fingerprint].owner, "security-team"); + const next = reconcileLifecycle(report, store, "2026-01-03T00:00:00.000Z"); + assert.equal(next.records[fingerprint].owner, "security-team"); + + const cleared = setFindingOwner(next, fingerprint, "", "2026-01-04T00:00:00.000Z"); + assert.equal(cleared.records[fingerprint].owner, undefined); + assert.equal(cleared.records[fingerprint].state, "confirmed"); +}); + +test("finding ownership rejects unknown records, control characters, oversized values, and invalid timestamps", () => { + const report = reportWith(["A"]); + const fingerprint = report.findings[0].fingerprint; + const store = reconcileLifecycle(report, emptyLifecycleStore()); + assert.throws(() => setFindingOwner(store, "missing", "team"), /does not exist/); + assert.throws(() => setFindingOwner(store, fingerprint, "team\nother"), /control line breaks/); + assert.throws(() => setFindingOwner(store, fingerprint, "x".repeat(256)), /at most 255/); + assert.throws(() => setFindingOwner(store, fingerprint, "team", "not-a-time"), /valid timestamp/); +}); + test("lifecycle store validation rejects malformed record shapes", () => { assert.equal(isLifecycleStore({ schemaVersion: 1, records: {} }), true); assert.equal(isLifecycleStore({ @@ -76,6 +105,12 @@ test("lifecycle store validation rejects malformed record shapes", () => { abc: { fingerprint: "abc", state: "new", updatedAt: "not-a-date" }, }, }), false); + assert.equal(isLifecycleStore({ + schemaVersion: 1, + records: { + abc: { fingerprint: "abc", state: "new", updatedAt: "2026-01-01T00:00:00.000Z", owner: "bad\nowner" }, + }, + }), false); }); test("lifecycle persistence is restrictive, round-trippable, and rejects corrupt stores", async () => { @@ -83,11 +118,14 @@ test("lifecycle persistence is restrictive, round-trippable, and rejects corrupt const path = join(root, "state", "lifecycle.json"); try { const report = reportWith(["A"]); - const store = reconcileLifecycle(report, emptyLifecycleStore(), "2026-01-01T00:00:00.000Z"); + const fingerprint = report.findings[0].fingerprint; + let store = reconcileLifecycle(report, emptyLifecycleStore(), "2026-01-01T00:00:00.000Z"); + store = setFindingOwner(store, fingerprint, "appsec"); await writeLifecycleStore(path, store); assert.deepEqual(await readLifecycleStore(path), store); const serialized = await readFile(path, "utf8"); assert.equal(JSON.parse(serialized).schemaVersion, 1); + assert.equal(JSON.parse(serialized).records[fingerprint].owner, "appsec"); if (process.platform !== "win32") assert.equal((await stat(path)).mode & 0o777, 0o600); await writeFile(path, JSON.stringify({ From 53a23e57721e262c9e49404cffb4af6a6a2cb5f4 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:56:23 -0400 Subject: [PATCH 0379/1132] docs(github): document exact-base runtime and permission boundaries --- docs/GITHUB_APP.md | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/docs/GITHUB_APP.md b/docs/GITHUB_APP.md index 5a2aa223..f32ae4fe 100644 --- a/docs/GITHUB_APP.md +++ b/docs/GITHUB_APP.md @@ -15,8 +15,11 @@ SynSec's GitHub App support is a transport and orchestration layer around the sa - installation-management events are bookkeeping only and never scan triggers; - short-lived RS256 GitHub App JWT creation; - installation-token exchange only through `https://api.github.com/app/installations//access_tokens` with redirects rejected; +- bounded validation of returned expiration, repository-selection, and permission metadata; - token/API errors that do not echo the App JWT. +`@synsec/github/app-token-provider` is the concrete memory-only credential composition for workers. It signs a fresh short-lived App JWT for each repository operation, exchanges it through the fixed GitHub installation-token endpoint, rejects tokens that are too close to expiry, and can enforce purpose-specific permissions before returning the credential. The local runtime requires `contents:read` for acquisition, `checks:write` for publication, and `security_events:write` when SARIF upload is enabled. A `write` grant satisfies a corresponding `read` requirement, but a read-only grant never satisfies a write requirement. Tokens are not cached or persisted. + `@synsec/github/replay-store` provides a durable local delivery-id replay store suitable for a single host or multiple worker processes sharing one filesystem. It uses bounded delivery identifiers, SHA-256-derived filenames, restrictive marker permissions, fully written/fsynced temporary records, and an atomic hard-link claim so two concurrent processes cannot both accept the same delivery or observe a partial canonical record. Retention is bounded between one hour and 30 days, expired markers can be pruned, and malformed existing records fail closed instead of being silently ignored. An accepted claim can also be released only when its exact delivery id and `receivedAt` still match the current unexpired marker; this lets a webhook handler return an error and allow GitHub retry after downstream durable processing fails without letting a stale worker delete a newer re-claim. `@synsec/github/installation-store` provides bounded durable installation authorization state. It persists only installation id, account identity/type, repository-selection mode, selected `owner/name` repository identifiers when selection is limited, suspension state, and update time. It deliberately has no fields for installation tokens, App private keys, webhook secrets, clone URLs, or repository credentials. Suspended or absent installations cannot authorize a repository scan. @@ -29,13 +32,15 @@ SynSec's GitHub App support is a transport and orchestration layer around the sa `@synsec/github/app-http` provides a framework-free Node HTTP request handler for mounting behind an HTTPS terminator or server. It accepts only POST requests at one configured path, requires JSON plus GitHub signature/event/delivery headers, bounds the raw body to 10 MiB before durable handling, emits `no-store` minimal responses, returns `202` only for queued scans, and does not reflect internal failure details. Durable-processing failures surface as generic `500` responses after replay-claim release so GitHub can retry. TLS termination, server-level connection/request timeouts, health endpoints, and deployment supervision remain hosting responsibilities rather than being silently embedded in this request handler. -`@synsec/github/repository-acquisition` materializes one exact commit from a strict `owner/name` identity through a fixed `https://github.com//.git` transport. It rejects URL-shaped repository identities before URL construction, disables system/global Git configuration and `file://` transport so local rewrite rules cannot redirect the request, keeps the installation token out of argv and repository config, skips Git LFS smudging/submodule initialization, checks out detached `FETCH_HEAD`, verifies the resulting HEAD against the requested SHA, and removes failed temporary workspaces. +`@synsec/github/repository-acquisition` materializes exact commits from a strict `owner/name` identity through a fixed `https://github.com//.git` transport. It rejects URL-shaped repository identities before URL construction, disables system/global Git configuration and `file://` transport so local rewrite rules cannot redirect the request, keeps the installation token out of argv and repository config, skips Git LFS smudging/submodule initialization, checks out detached `FETCH_HEAD`, verifies each resulting HEAD against the requested SHA, and removes failed temporary workspaces. Pull-request acquisition can materialize the exact queued head and exact queued base into separate isolated workspaces with all-or-nothing cleanup. + +`@synsec/github/app-worker` consumes at most one leased queue job, rechecks installation authorization at execution time, acquires a short-lived token only for transport, verifies exact head/base acquisition provenance, scans through an injected repository-scan runner, requires the resulting head report to bind to the queued head SHA, obtains a fresh publication token, publishes through an injected GitHub transport, and acknowledges the queue only after publication succeeds. A repository removed or suspended after queueing is failed before credentials or source are acquired. Other worker failures return the job to the bounded retry queue. -`@synsec/github/app-worker` consumes at most one leased queue job, rechecks installation authorization at execution time, acquires a short-lived token only for transport, scans the exact-commit workspace through an injected repository-scan runner, requires the resulting report to bind to the queued head SHA, obtains a fresh publication token, publishes through an injected GitHub transport, and acknowledges the queue only after publication succeeds. A repository removed or suspended after queueing is failed before credentials or source are acquired. Other worker failures return the job to the bounded retry queue. +`@synsec/github/app-worker-runner` is the production-oriented local composition over that worker boundary. Push jobs run the existing `runScanEngine()` against the exact acquired head. Pull-request jobs first scan the exact queued base commit, require that baseline report to identify the queued base SHA, then scan the exact queued head using that report as the deterministic baseline. The resulting head report must identify the queued head SHA before fixed-host Checks/SARIF publication. These hosted PR scans are still full-repository scans at both commits; SynSec does not approximate changed-file execution from a branch name or perform an unbounded history fetch. -`@synsec/github/app-worker-runner` is the production-oriented local composition over that worker boundary. It runs the existing `runScanEngine()` against the acquired exact-commit workspace, builds a check from the normalized queue repository/head context, publishes through the fixed Checks API transport, and can upload the same commit-bound report as SARIF. Pull-request jobs currently use a full repository scan at this layer; SynSec does not invent a changed-file baseline from a branch name when the exact base commit has not also been acquired. +`@synsec/github/app-runtime` composes the single-host local service primitives without opening a listener. It creates separate durable state and scanner-workspace directory trees, wires replay/installation/queue stores, the memory-only credential provider, the bounded webhook handler, and the configured worker. State and workspaces are forbidden from overlapping so repository source is not placed inside durable authorization/queue storage. The caller still owns TLS/listener binding, process/container isolation, network policy, deployment supervision, and operational secret injection/rotation. -These primitives now form an end-to-end local hosting chain, but they still do **not** constitute a complete hosted GitHub App product by themselves. TLS/runtime deployment, concrete App-JWT/private-key configuration, process/container isolation, operational secret management, setup UX, and shared transactional persistence for multi-host deployments remain required. +These primitives now form an end-to-end local hosting chain, but they still do **not** constitute a complete production hosted GitHub App service by themselves. Production TLS/runtime deployment, process/container isolation, operational secret management, setup UX, and shared transactional persistence for multi-host deployments remain required. ## Webhook boundary @@ -49,31 +54,30 @@ The preferred local HTTP boundary is `createGitHubAppWebhookHttpHandler()` mount Queue records are commit-pinned descriptors, not checkout instructions supplied by repository content. `runNextGitHubAppScanJob()` rechecks authorization after leasing so stale queued work cannot outlive a repository removal or installation suspension. -Repository acquisition accepts only a strict validated `owner/name`, installation id context supplied by the worker, and exact commit SHA. The acquisition transport is fixed to `github.com`, and Git system/global configuration is disabled to prevent `url.*.insteadOf` or other host-local configuration from silently widening the destination. Missing/unavailable commit provenance is a job failure rather than permission to substitute the default branch, a nearby commit, a webhook clone URL, or a scanner-suggested URL. +Repository acquisition accepts only a strict validated `owner/name`, installation context supplied by the worker, and exact commit SHAs. The acquisition transport is fixed to `github.com`, and Git system/global configuration is disabled to prevent `url.*.insteadOf` or other host-local configuration from silently widening the destination. Missing/unavailable head or base provenance is a job failure rather than permission to substitute the default branch, a nearby commit, a webhook clone URL, or a scanner-suggested URL. -The scanner receives the checked-out workspace and queue descriptor, not the installation token. Before publication, the worker requires `report.target.commitSha` to equal the queued head SHA and obtains a fresh installation token for the publication operation. `runConfiguredGitHubAppWorkerOnce()` then uses the same repository scan engine as the CLI/Action and fixed-host Checks/SARIF publishers. Successful workspace cleanup occurs after scan/publication handling. +The scanner receives checked-out workspaces and the queue descriptor, not the installation token. For pull requests, the exact base report is commit-bound before it can become the head baseline. Before publication, the worker requires `report.target.commitSha` to equal the queued head SHA and obtains a fresh installation token for the publication operation. `runConfiguredGitHubAppWorkerOnce()` then uses the same repository scan engine as the CLI/Action and fixed-host Checks/SARIF publishers. Successful workspace cleanup occurs after scan/publication handling. Leases prevent normal duplicate processing but the local queue is not a multi-host transactional lock. Horizontally scaled workers should use a shared queue with atomic claim/lease semantics. -## Authentication boundary +## Authentication and permission boundary `createGitHubAppJwt()` signs a short-lived RS256 token from the configured App id and private key. The private key belongs to the hosted transport/runtime and must never be exposed to scanners, reports, repository code, workflow prompts, logs, or persisted finding evidence. -`createGitHubInstallationToken()` exchanges that JWT at GitHub's fixed API host. It requests no additional repository selection or permission expansion in the token request body. Resulting installation tokens should be kept only for the operation lifetime and passed only to narrowly scoped transport functions. Repository acquisition supplies its token to Git only through a child-process environment and disables inherited Git configuration; publication likewise keeps credentials outside scanner inputs and reports. +`createGitHubInstallationToken()` exchanges that JWT at GitHub's fixed API host. It requests no additional repository selection or permission expansion in the token request body. `createGitHubAppInstallationTokenProvider()` signs/exchanges afresh for each operation and keeps the resulting installation token in memory only. Repository acquisition supplies its token to Git only through a child-process environment and disables inherited Git configuration; publication likewise keeps credentials outside scanner inputs and reports. -A hosted service should validate its configured GitHub App permissions explicitly and fail closed when required permissions are absent rather than requesting broader permissions dynamically. +The local runtime validates GitHub-reported permission metadata before giving a credential to a worker operation. Missing or insufficient grants fail with the required permission names rather than falling through to a later scanner or publication failure. This is a runtime diagnostic, not a setup UI, and SynSec does not dynamically request broader permissions. ## Required hosted-service work A production hosted App still needs: -1. TLS/runtime deployment around the bounded HTTP handler, with request/server timeouts and operational health handling; -2. concrete App-JWT/private-key configuration and installation-token providers for the worker; -3. exact-base acquisition/baseline composition for changed-file PR scans when that optimization is enabled; -4. process/container workspace isolation around scans, including OS CPU/memory limits and network policy; -5. explicit retention policy for reports, failed queue records, temporary artifacts, and operator diagnostics; -6. installation/setup UX, permission diagnostics, and recovery for configuration errors; -7. operational rotation for webhook secrets and App private keys; -8. transactional shared replay/installation/queue backends when horizontally scaled replicas do not share one durable filesystem. +1. TLS/listener deployment around the bounded HTTP handler, with request/server timeouts, health handling, and supervision; +2. process/container workspace isolation around scans, including OS CPU/memory limits and network policy; +3. native changed-file execution for hosted PR scans if that optimization is enabled; exact base/head baseline provenance is already available and must remain authoritative; +4. explicit retention policy for reports, failed queue records, temporary artifacts, and operator diagnostics; +5. installation/setup UX, permission diagnostics/recovery, and operator-facing configuration validation; +6. operational secret injection and rotation for webhook secrets and App private keys; +7. transactional shared replay/installation/queue backends when horizontally scaled replicas do not share one durable filesystem. -Until those pieces exist, the GitHub Action remains the complete executable integration path and the App modules should be treated as tested hosting foundations rather than a deployable hosted service. +Until those pieces exist, the GitHub Action remains the complete packaged integration path and the App modules should be treated as tested single-host hosting foundations rather than a production multi-tenant service. From bfeab053125879a63e6a087a9e49c60ac6c34ebf Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 14:56:51 -0400 Subject: [PATCH 0380/1132] docs: reconcile roadmap with hosted runtime and lifecycle ownership --- docs/ROADMAP.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d7e1f711..47117826 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -72,14 +72,15 @@ Likely test ownership is also structural evidence only. It prioritizes test file - [x] Seven-question evidence gate for model review - [x] Source-code context disabled by default and separately opt-in - [x] Deterministic multi-review consensus aggregation with disagreement/insufficient-review handling -- [ ] Multi-model reviewer execution/orchestration +- [x] Bounded independent multi-reviewer execution API with failure isolation +- [ ] CLI/configured multi-model review UX - [ ] Repository-aware explanation of reachability and impact - [ ] Suggested patch generation - [ ] Suggested regression/security tests - [x] Safe rescan-after-remediation verification primitive - [x] Finding lifecycle: new, confirmed, false positive, accepted risk, fixed, regressed -Consensus remains model inference, not scanner evidence. Duplicate model identities do not count as independent reviewers, split verdicts fail closed to `uncertain`, insufficient reviewer sets do not fabricate consensus, and gate answers are aggregated with disagreement preserved. +Consensus remains model inference, not scanner evidence. Duplicate model identities do not count as independent reviewers, split verdicts fail closed to `uncertain`, insufficient reviewer sets do not fabricate consensus, reviewer execution is concurrency-bounded, and provider failures are isolated with credential redaction. The package API supports multi-review execution; the CLI still exposes the simpler single-model review path and needs explicit multi-model UX before this becomes the default user-facing workflow. ## Phase 4 — Reusable workflows / skills @@ -121,11 +122,14 @@ These workflows operate on repository evidence and scanner results. They are not - [x] Replay-protected authorization-gated local webhook handler - [x] Bounded framework-free webhook HTTP handler for deployment behind HTTPS - [x] Installation-scoped exact-commit GitHub repository acquisition primitive +- [x] Exact queued head/base acquisition and hosted PR baseline comparison - [x] Authorization-aware local scan worker with commit-bound report verification - [x] Local worker composition through the existing scan engine and Checks/SARIF publishers -- [ ] Hosted TLS/runtime service and concrete App credential/token wiring -- [ ] Repository installation/setup UX and permission diagnostics -- [ ] Exact-base acquisition for hosted changed-file PR scans +- [x] Memory-only App installation-token provider with purpose-specific permission checks +- [x] Single-host local runtime composition with separate durable-state/workspace trees +- [ ] Production TLS/listener deployment, supervision, and operational secret rotation +- [ ] Repository installation/setup UX and richer permission diagnostics +- [ ] Native changed-file execution for hosted PR workers using exact provenance - [ ] Transactional shared App state/queue for multi-host deployment - [ ] Optional remediation pull requests with explicit approval - [ ] GitLab and Bitbucket adapters @@ -136,7 +140,7 @@ For PRs without an explicit baseline, the Action can scan the exact event-provid The Action also writes the completed JSON report under `RUNNER_TEMP` and exposes its path. The scheduled workflow template retains that report only through an explicit caller-owned artifact step with a visible retention period; SynSec does not silently persist security evidence. -GitHub App support now has a coherent local hosting path rather than disconnected primitives: a bounded HTTP handler accepts raw deliveries behind HTTPS; verified deliveries are replay-claimed; installation-management events synchronize bounded authorization state; scan-bearing events require authorization before durable queueing; workers recheck authorization at execution time; exact queued commits are acquired through a fixed GitHub transport; and `runScanEngine()` output must bind to that exact head before Checks/SARIF publication. Failed durable webhook processing releases only the exact still-current replay claim so GitHub can retry. This is still not a deployable hosted service: TLS/runtime deployment, concrete App credential configuration, OS/container isolation, setup UX, and shared transactional storage remain open. See [GITHUB_APP.md](./GITHUB_APP.md). +GitHub App support now has a coherent single-host local runtime: raw webhook deliveries are bounded and verified, replay-claimed, synchronized into durable authorization state, authorization-gated into a commit-pinned queue, then consumed by workers that recheck authorization and acquire exact repository commits through a fixed GitHub transport. Pull-request jobs acquire and scan both the exact queued base and head; the base report must bind to the queued base SHA before it can become the head baseline, and the head report must bind to the queued head SHA before Checks/SARIF publication. Credentials are created afresh in memory, never handed to scanners, and checked against operation-specific permission requirements. Hosted PR execution is still full-repository at each commit; native changed-file optimization remains future work. See [GITHUB_APP.md](./GITHUB_APP.md). See [GITHUB.md](./GITHUB.md) for the current Actions integration contract and security boundaries. @@ -146,6 +150,7 @@ See [GITHUB.md](./GITHUB.md) for the current Actions integration contract and se - [x] Bounded local scan-history store with atomic writes and trend-safe snapshots - [x] Self-contained trend-safe security-history HTML dashboard renderer - [x] History-store → restrictive local dashboard file generation +- [x] Bounded lifecycle finding-ownership metadata foundation - [ ] Project/repository dashboard application - [ ] Multi-project/server persistence layer - [ ] Interactive security-score history UI @@ -154,9 +159,9 @@ See [GITHUB.md](./GITHUB.md) for the current Actions integration contract and se - [ ] Dependency and SBOM views - [ ] Interactive repository posture view - [ ] Team triage workflow -- [ ] Finding comments/ownership +- [ ] Finding comments and richer collaboration history -The local history store retains only report identifiers, timestamps, commit/branch metadata, aggregate counts/scores, and finding fingerprint/title/severity tuples. It deliberately omits source excerpts, scanner diagnostics, repository URLs, artifacts, and secret-bearing evidence. Retention is bounded, writes are atomic, and invalid/corrupt stores fail closed. The self-contained history dashboard renders only this trend-safe model, escapes titles/content, and can be written with restrictive local permissions. A multi-project database and interactive web application remain future work. +The local history store retains only report identifiers, timestamps, commit/branch metadata, aggregate counts/scores, and finding fingerprint/title/severity tuples. It deliberately omits source excerpts, scanner diagnostics, repository URLs, artifacts, and secret-bearing evidence. Retention is bounded, writes are atomic, and invalid/corrupt stores fail closed. The self-contained history dashboard renders only this trend-safe model, escapes titles/content, and can be written with restrictive local permissions. Lifecycle ownership is separately bounded triage metadata preserved across state transitions/rescans; it is not scanner evidence and does not make the current local store a multi-user collaboration database. ## Phase 7 — Isolated scan workers @@ -165,6 +170,7 @@ The local history store retains only report identifiers, timestamps, commit/bran - [x] Bounded durable local scan-job queue with leases/retries - [x] Commit-pinned temporary checkout workspace acquisition and cleanup - [x] Authorization recheck before worker credential/source acquisition +- [x] Separation of durable App state and repository workspace directory trees - [ ] Containerized scanner images - [ ] Per-scan process/container workspace isolation - [ ] OS/container CPU and memory limits @@ -173,7 +179,7 @@ The local history store retains only report identifiers, timestamps, commit/bran - [ ] Artifact retention policy - [ ] Filesystem credential minimization for private-repository scan workspaces -External scanners no longer inherit the full parent process environment by default. SynSec passes a small execution/locale/certificate allowlist and requires an explicit environment when a scanner genuinely needs additional variables. Hosted GitHub acquisition uses a separate short-lived transport credential, keeps it out of scanner inputs and Git argv, disables inherited Git configuration, and removes temporary checkout workspaces after handling. This materially narrows credential exposure but is not a complete sandbox: scanner processes still need container/workspace isolation, OS resource limits, network policy, and stronger filesystem credential separation before a production multi-tenant worker deployment. +External scanners no longer inherit the full parent process environment by default. SynSec passes a small execution/locale/certificate allowlist and requires an explicit environment when a scanner genuinely needs additional variables. Hosted GitHub acquisition uses a separate short-lived transport credential, keeps it out of scanner inputs and Git argv, disables inherited Git configuration, and removes temporary checkout workspaces after handling. The local runtime also refuses to place repository workspaces inside durable App state. This materially narrows credential/source exposure but is not a complete sandbox: scanner processes still need container isolation, OS resource limits, network policy, and stronger filesystem credential separation before a production multi-tenant worker deployment. ## Later — explicitly authorized external assessment From bc54247a737b7d579c75fbdf32483ccfa3a426fb Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:02:01 -0400 Subject: [PATCH 0381/1132] Add bounded lifecycle review comments --- packages/lifecycle/src/review-comments.ts | 200 ++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 packages/lifecycle/src/review-comments.ts diff --git a/packages/lifecycle/src/review-comments.ts b/packages/lifecycle/src/review-comments.ts new file mode 100644 index 00000000..9086a23d --- /dev/null +++ b/packages/lifecycle/src/review-comments.ts @@ -0,0 +1,200 @@ +import { createHash } from "node:crypto"; +import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +export interface FindingReviewComment { + id: string; + fingerprint: string; + body: string; + createdAt: string; + author?: string; +} + +export interface FindingReviewCommentStore { + schemaVersion: 1; + comments: Record; +} + +const MAX_STORE_BYTES = 8 * 1024 * 1024; +const MAX_TOTAL_COMMENTS = 100_000; +const MAX_COMMENTS_PER_FINDING = 100; +const MAX_FINGERPRINT_LENGTH = 512; +const MAX_COMMENT_ID_LENGTH = 128; +const MAX_AUTHOR_LENGTH = 255; +const MAX_BODY_LENGTH = 10_000; + +function boundedText(value: unknown, maxLength: number, required = false): value is string { + if (typeof value !== "string" || value.length > maxLength || /\0/.test(value)) return false; + if (required && !value.trim()) return false; + return true; +} + +function validTimestamp(value: unknown): value is string { + return boundedText(value, 128, true) && Number.isFinite(Date.parse(value)); +} + +function exactKeys(value: Record, allowed: readonly string[]): boolean { + const keys = Object.keys(value).sort(); + const expected = [...allowed].sort(); + return keys.length === expected.length && keys.every((key, index) => key === expected[index]); +} + +function isComment(value: unknown, fingerprint: string): value is FindingReviewComment { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const record = value as Record; + const allowed = record.author === undefined + ? ["id", "fingerprint", "body", "createdAt"] + : ["id", "fingerprint", "body", "createdAt", "author"]; + if (!exactKeys(record, allowed)) return false; + return boundedText(record.id, MAX_COMMENT_ID_LENGTH, true) + && boundedText(record.fingerprint, MAX_FINGERPRINT_LENGTH, true) + && record.fingerprint === fingerprint + && boundedText(record.body, MAX_BODY_LENGTH, true) + && validTimestamp(record.createdAt) + && (record.author === undefined || boundedText(record.author, MAX_AUTHOR_LENGTH, true)); +} + +export function emptyFindingReviewCommentStore(): FindingReviewCommentStore { + return { schemaVersion: 1, comments: {} }; +} + +export function isFindingReviewCommentStore(value: unknown): value is FindingReviewCommentStore { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const root = value as Record; + if (!exactKeys(root, ["schemaVersion", "comments"]) || root.schemaVersion !== 1) return false; + if (typeof root.comments !== "object" || root.comments === null || Array.isArray(root.comments)) return false; + + let total = 0; + for (const [fingerprint, comments] of Object.entries(root.comments as Record)) { + if (!boundedText(fingerprint, MAX_FINGERPRINT_LENGTH, true) || !Array.isArray(comments)) return false; + if (comments.length === 0 || comments.length > MAX_COMMENTS_PER_FINDING) return false; + total += comments.length; + if (total > MAX_TOTAL_COMMENTS) return false; + if (!comments.every((comment) => isComment(comment, fingerprint))) return false; + const ids = new Set(comments.map((comment) => (comment as FindingReviewComment).id)); + if (ids.size !== comments.length) return false; + } + return true; +} + +export async function readFindingReviewCommentStore(path: string): Promise { + try { + const metadata = await stat(path); + if (!metadata.isFile()) throw new Error(`SynSec finding review comment store is not a file: ${path}`); + if (metadata.size > MAX_STORE_BYTES) { + throw new Error(`SynSec finding review comment store exceeds the ${MAX_STORE_BYTES}-byte limit: ${path}`); + } + const parsed = JSON.parse(await readFile(path, "utf8")) as unknown; + if (!isFindingReviewCommentStore(parsed)) { + throw new Error(`Not a supported SynSec finding review comment store: ${path}`); + } + return parsed; + } catch (error) { + const code = typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : ""; + if (code === "ENOENT") return emptyFindingReviewCommentStore(); + throw error; + } +} + +export async function writeFindingReviewCommentStore( + path: string, + store: FindingReviewCommentStore, +): Promise { + if (!isFindingReviewCommentStore(store)) { + throw new Error("Refusing to write an invalid SynSec finding review comment store."); + } + const serialized = `${JSON.stringify(store, null, 2)}\n`; + if (Buffer.byteLength(serialized) > MAX_STORE_BYTES) { + throw new Error(`SynSec finding review comment store exceeds the ${MAX_STORE_BYTES}-byte limit.`); + } + + const directory = dirname(path); + await mkdir(directory, { recursive: true }); + const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`; + try { + await writeFile(temporaryPath, serialized, { encoding: "utf8", mode: 0o600, flag: "wx" }); + await chmod(temporaryPath, 0o600).catch(() => undefined); + await rename(temporaryPath, path); + await chmod(path, 0o600).catch(() => undefined); + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } +} + +function commentId(fingerprint: string, body: string, author: string | undefined, createdAt: string): string { + return createHash("sha256") + .update(fingerprint) + .update("\0") + .update(createdAt) + .update("\0") + .update(author ?? "") + .update("\0") + .update(body) + .digest("hex"); +} + +/** + * Append bounded human triage commentary without modifying finding state or scanner evidence. + * + * Comment text is operator-supplied metadata only. SynSec does not automatically copy source + * excerpts, scanner diagnostics, tokens, or repository credentials into this store. The API is + * append-only so prior review context cannot be silently rewritten by a later scan. + */ +export function addFindingReviewComment( + store: FindingReviewCommentStore, + fingerprint: string, + body: string, + options: { author?: string; createdAt?: string } = {}, +): FindingReviewCommentStore { + const normalizedFingerprint = fingerprint.trim(); + const normalizedBody = body.trim(); + const author = options.author?.trim() || undefined; + const createdAt = options.createdAt ?? new Date().toISOString(); + + if (!boundedText(normalizedFingerprint, MAX_FINGERPRINT_LENGTH, true)) { + throw new Error(`Finding fingerprint must be at most ${MAX_FINGERPRINT_LENGTH} characters.`); + } + if (!boundedText(normalizedBody, MAX_BODY_LENGTH, true)) { + throw new Error(`Finding review comment must be between 1 and ${MAX_BODY_LENGTH} characters and contain no NUL bytes.`); + } + if (author !== undefined && !boundedText(author, MAX_AUTHOR_LENGTH, true)) { + throw new Error(`Finding review comment author must be at most ${MAX_AUTHOR_LENGTH} characters.`); + } + if (!validTimestamp(createdAt)) throw new Error("Finding review comment timestamp must be valid."); + + const existing = store.comments[normalizedFingerprint] ?? []; + if (existing.length >= MAX_COMMENTS_PER_FINDING) { + throw new Error(`Finding review comments are limited to ${MAX_COMMENTS_PER_FINDING} entries per finding.`); + } + const total = Object.values(store.comments).reduce((count, comments) => count + comments.length, 0); + if (total >= MAX_TOTAL_COMMENTS) { + throw new Error(`Finding review comment store is limited to ${MAX_TOTAL_COMMENTS} total comments.`); + } + + const comment: FindingReviewComment = { + id: commentId(normalizedFingerprint, normalizedBody, author, createdAt), + fingerprint: normalizedFingerprint, + body: normalizedBody, + createdAt, + ...(author ? { author } : {}), + }; + if (existing.some((item) => item.id === comment.id)) return store; + + return { + schemaVersion: 1, + comments: { + ...store.comments, + [normalizedFingerprint]: [...existing, comment], + }, + }; +} + +export function commentsForFinding( + store: FindingReviewCommentStore, + fingerprint: string, +): readonly FindingReviewComment[] { + return [...(store.comments[fingerprint.trim()] ?? [])] + .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)); +} From 3b586d50957b875c30022ee9a28547a1d7db3279 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:02:06 -0400 Subject: [PATCH 0382/1132] Export lifecycle review comments API --- packages/lifecycle/package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/lifecycle/package.json b/packages/lifecycle/package.json index 937ae86d..c9a5ede2 100644 --- a/packages/lifecycle/package.json +++ b/packages/lifecycle/package.json @@ -3,7 +3,10 @@ "version": "0.2.0", "private": true, "type": "module", - "exports": "./dist/index.js", + "exports": { + ".": "./dist/index.js", + "./review-comments": "./dist/review-comments.js" + }, "types": "./dist/index.d.ts", "scripts": { "build": "tsc -p tsconfig.json", From 7f7f203eb3af436a58b73cd3d769d16fea1afc7f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:02:18 -0400 Subject: [PATCH 0383/1132] Test bounded lifecycle review comments --- tests/lifecycle-review-comments.test.mjs | 91 ++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/lifecycle-review-comments.test.mjs diff --git a/tests/lifecycle-review-comments.test.mjs b/tests/lifecycle-review-comments.test.mjs new file mode 100644 index 00000000..00eb1ac6 --- /dev/null +++ b/tests/lifecycle-review-comments.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + addFindingReviewComment, + commentsForFinding, + emptyFindingReviewCommentStore, + isFindingReviewCommentStore, + readFindingReviewCommentStore, + writeFindingReviewCommentStore, +} from "@synsec/lifecycle/review-comments"; + +const fingerprint = "sha256:finding-1"; +const timestamp = "2026-08-22T19:05:00.000Z"; + +test("review comments are append-only deterministic triage metadata", () => { + const empty = emptyFindingReviewCommentStore(); + const first = addFindingReviewComment(empty, fingerprint, "Reviewed with the service owner.", { + author: "security-team", + createdAt: timestamp, + }); + assert.equal(empty.comments[fingerprint], undefined); + assert.equal(first.comments[fingerprint].length, 1); + assert.equal(first.comments[fingerprint][0].body, "Reviewed with the service owner."); + assert.equal(first.comments[fingerprint][0].author, "security-team"); + + const duplicate = addFindingReviewComment(first, fingerprint, "Reviewed with the service owner.", { + author: "security-team", + createdAt: timestamp, + }); + assert.equal(duplicate, first); + + const second = addFindingReviewComment(first, fingerprint, "Follow-up review complete.", { + createdAt: "2026-08-22T19:06:00.000Z", + }); + assert.deepEqual(commentsForFinding(second, fingerprint).map((comment) => comment.body), [ + "Reviewed with the service owner.", + "Follow-up review complete.", + ]); +}); + +test("review comment validation rejects malformed or oversized metadata", () => { + const empty = emptyFindingReviewCommentStore(); + assert.throws(() => addFindingReviewComment(empty, "", "comment"), /fingerprint/); + assert.throws(() => addFindingReviewComment(empty, fingerprint, ""), /review comment/); + assert.throws(() => addFindingReviewComment(empty, fingerprint, "x".repeat(10_001)), /review comment/); + assert.throws(() => addFindingReviewComment(empty, fingerprint, "comment\0secret"), /review comment/); + assert.throws(() => addFindingReviewComment(empty, fingerprint, "comment", { author: "x".repeat(256) }), /author/); + assert.throws(() => addFindingReviewComment(empty, fingerprint, "comment", { createdAt: "not-a-time" }), /timestamp/); + + assert.equal(isFindingReviewCommentStore({ + schemaVersion: 1, + comments: { + [fingerprint]: [{ + id: "id", + fingerprint, + body: "comment", + createdAt: timestamp, + scannerEvidence: "must not be accepted", + }], + }, + }), false); +}); + +test("review comment persistence is restrictive, atomic-shaped, and corrupt stores fail closed", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-review-comments-")); + const path = join(root, "state", "comments.json"); + try { + const store = addFindingReviewComment(emptyFindingReviewCommentStore(), fingerprint, "Triage note only.", { + author: "maintainer", + createdAt: timestamp, + }); + await writeFindingReviewCommentStore(path, store); + assert.deepEqual(await readFindingReviewCommentStore(path), store); + assert.equal(JSON.parse(await readFile(path, "utf8")).schemaVersion, 1); + if (process.platform !== "win32") assert.equal((await stat(path)).mode & 0o777, 0o600); + + await writeFile(path, JSON.stringify({ + schemaVersion: 1, + comments: { + [fingerprint]: [{ id: "bad", fingerprint: "different", body: "comment", createdAt: timestamp }], + }, + }), "utf8"); + await assert.rejects(() => readFindingReviewCommentStore(path), /Not a supported SynSec finding review comment store/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From f4f7bf0f91adf57eb749d04be7cf5cab574f277a Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:03:29 -0400 Subject: [PATCH 0384/1132] Add conservative incremental scan planner --- packages/repository/src/incremental-plan.ts | 197 ++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 packages/repository/src/incremental-plan.ts diff --git a/packages/repository/src/incremental-plan.ts b/packages/repository/src/incremental-plan.ts new file mode 100644 index 00000000..2aa1dbd1 --- /dev/null +++ b/packages/repository/src/incremental-plan.ts @@ -0,0 +1,197 @@ +import { posix } from "node:path"; +import { findModuleNeighborhood, type ModuleGraph } from "./module-graph.js"; + +export type IncrementalScanPlanReason = + | "targeted-with-bounded-dependents" + | "no-changes" + | "invalid-changed-path" + | "too-many-changed-files" + | "high-impact-file-changed" + | "changed-source-not-indexed" + | "dependent-expansion-exceeded-bound"; + +export interface IncrementalScanPlan { + mode: "targeted" | "full-repository"; + reason: IncrementalScanPlanReason; + changedFiles: string[]; + selectedFiles: string[]; + dependentFiles: Array<{ path: string; depth: number; triggeredBy: string }>; + maxDependentDepth: number; + /** This plan is a coverage heuristic and never asserts that unselected files are safe. */ + interpretation: "coverage-heuristic-not-proof-of-unaffected-code"; +} + +export interface IncrementalScanPlanOptions { + maxChangedFiles?: number; + maxDependentDepth?: number; + maxDependents?: number; +} + +const DEFAULT_MAX_CHANGED_FILES = 500; +const DEFAULT_MAX_DEPENDENT_DEPTH = 2; +const DEFAULT_MAX_DEPENDENTS = 200; + +const sourceExtensions = new Set([ + ".js", ".mjs", ".cjs", ".jsx", + ".ts", ".mts", ".cts", ".tsx", + ".py", +]); + +const exactHighImpactFiles = new Set([ + "package.json", + "package-lock.json", + "npm-shrinkwrap.json", + "pnpm-lock.yaml", + "yarn.lock", + "bun.lock", + "bun.lockb", + "pyproject.toml", + "poetry.lock", + "requirements.txt", + "pipfile", + "pipfile.lock", + "go.mod", + "go.sum", + "cargo.toml", + "cargo.lock", + "composer.json", + "composer.lock", + "gemfile", + "gemfile.lock", + "dockerfile", + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", + "synsec.config.json", +]); + +function boundedInteger(value: number | undefined, fallback: number, min: number, max: number, label: string): number { + const normalized = value ?? fallback; + if (!Number.isSafeInteger(normalized) || normalized < min || normalized > max) { + throw new Error(`${label} must be an integer between ${min} and ${max}.`); + } + return normalized; +} + +function normalizeRepositoryPath(value: string): string | undefined { + if (typeof value !== "string" || value.includes("\0")) return undefined; + const replaced = value.trim().replaceAll("\\", "/").replace(/^\.\//, ""); + if (!replaced || replaced.startsWith("/") || /^[A-Za-z]:\//.test(replaced)) return undefined; + const normalized = posix.normalize(replaced); + if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) return undefined; + return normalized; +} + +function isHighImpactFile(path: string): boolean { + const normalized = path.toLowerCase(); + const basename = posix.basename(normalized); + if (exactHighImpactFiles.has(normalized) || exactHighImpactFiles.has(basename)) return true; + if (normalized.startsWith(".github/workflows/")) return true; + if (normalized.startsWith(".gitlab/")) return true; + if (normalized.endsWith(".tf") || normalized.endsWith(".tfvars")) return true; + if (/(^|\/)(tsconfig|jsconfig)(\.[^/]+)?\.json$/.test(normalized)) return true; + return /(^|\/)(security|auth|permissions?|policy|policies)\.(json|ya?ml|toml)$/.test(normalized); +} + +function isIndexedSource(path: string, nodes: ReadonlySet): boolean { + const extension = posix.extname(path).toLowerCase(); + return !sourceExtensions.has(extension) || nodes.has(path.toLowerCase()); +} + +function fullPlan( + reason: Exclude, + changedFiles: string[], + maxDependentDepth: number, +): IncrementalScanPlan { + return { + mode: "full-repository", + reason, + changedFiles, + selectedFiles: [], + dependentFiles: [], + maxDependentDepth, + interpretation: "coverage-heuristic-not-proof-of-unaffected-code", + }; +} + +/** + * Build a conservative incremental-scan scope from changed files and structural module evidence. + * + * Direct changes are always selected. Known local dependents may be added to catch effects that + * cross import boundaries, but this is only a coverage heuristic. Ambiguous/high-impact conditions + * fail over to a full repository scan rather than treating unselected code as safe. + */ +export function buildIncrementalScanPlan( + graph: ModuleGraph, + changedFiles: readonly string[], + options: IncrementalScanPlanOptions = {}, +): IncrementalScanPlan { + const maxChangedFiles = boundedInteger(options.maxChangedFiles, DEFAULT_MAX_CHANGED_FILES, 1, 10_000, "maxChangedFiles"); + const maxDependentDepth = boundedInteger(options.maxDependentDepth, DEFAULT_MAX_DEPENDENT_DEPTH, 0, 10, "maxDependentDepth"); + const maxDependents = boundedInteger(options.maxDependents, DEFAULT_MAX_DEPENDENTS, 1, 10_000, "maxDependents"); + + const normalized: string[] = []; + for (const value of changedFiles) { + const path = normalizeRepositoryPath(value); + if (!path) return fullPlan("invalid-changed-path", [...normalized], maxDependentDepth); + normalized.push(path); + } + const changed = [...new Map(normalized.map((path) => [path.toLowerCase(), path])).values()].sort(); + + if (changed.length === 0) { + return { + mode: "targeted", + reason: "no-changes", + changedFiles: [], + selectedFiles: [], + dependentFiles: [], + maxDependentDepth, + interpretation: "coverage-heuristic-not-proof-of-unaffected-code", + }; + } + if (changed.length > maxChangedFiles) return fullPlan("too-many-changed-files", changed, maxDependentDepth); + if (changed.some(isHighImpactFile)) return fullPlan("high-impact-file-changed", changed, maxDependentDepth); + + const nodes = new Set(graph.nodes.map((path) => path.toLowerCase())); + if (changed.some((path) => !isIndexedSource(path, nodes))) { + return fullPlan("changed-source-not-indexed", changed, maxDependentDepth); + } + + const dependents = new Map(); + for (const path of changed) { + if (!nodes.has(path.toLowerCase()) || maxDependentDepth === 0) continue; + const neighborhood = findModuleNeighborhood(graph, path, maxDependentDepth, maxDependents + 1); + if (neighborhood.dependents.length > maxDependents) { + return fullPlan("dependent-expansion-exceeded-bound", changed, maxDependentDepth); + } + for (const dependent of neighborhood.dependents) { + const key = dependent.path.toLowerCase(); + const previous = dependents.get(key); + if (!previous || dependent.depth < previous.depth || (dependent.depth === previous.depth && path < previous.triggeredBy)) { + dependents.set(key, { ...dependent, triggeredBy: path }); + } + } + } + + if (dependents.size > maxDependents) { + return fullPlan("dependent-expansion-exceeded-bound", changed, maxDependentDepth); + } + + const dependentFiles = [...dependents.values()] + .filter((item) => !changed.some((path) => path.toLowerCase() === item.path.toLowerCase())) + .sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path) || a.triggeredBy.localeCompare(b.triggeredBy)); + const selectedFiles = [...new Map( + [...changed, ...dependentFiles.map((item) => item.path)].map((path) => [path.toLowerCase(), path]), + ).values()].sort(); + + return { + mode: "targeted", + reason: "targeted-with-bounded-dependents", + changedFiles: changed, + selectedFiles, + dependentFiles, + maxDependentDepth, + interpretation: "coverage-heuristic-not-proof-of-unaffected-code", + }; +} From 5604aed275c128915b2dc3d088cc1b0747a2c599 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:03:36 -0400 Subject: [PATCH 0385/1132] Export incremental scan planner --- packages/repository/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/repository/package.json b/packages/repository/package.json index 8b725a59..23ccf8ae 100644 --- a/packages/repository/package.json +++ b/packages/repository/package.json @@ -12,7 +12,8 @@ "./route-auth-context": "./dist/route-auth-context.js", "./route-sink-context": "./dist/route-sink-context.js", "./posture": "./dist/posture.js", - "./test-ownership": "./dist/test-ownership.js" + "./test-ownership": "./dist/test-ownership.js", + "./incremental-plan": "./dist/incremental-plan.js" }, "types": "./dist/index.d.ts", "scripts": { From 70306469b49919108217acaf545f1e933fd6329d Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:03:48 -0400 Subject: [PATCH 0386/1132] Test conservative incremental scan planning --- tests/incremental-plan.test.mjs | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/incremental-plan.test.mjs diff --git a/tests/incremental-plan.test.mjs b/tests/incremental-plan.test.mjs new file mode 100644 index 00000000..810f31cd --- /dev/null +++ b/tests/incremental-plan.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildIncrementalScanPlan } from "@synsec/repository/incremental-plan"; + +function graph() { + return { + schemaVersion: 1, + nodes: ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts"], + edges: [ + { from: "src/b.ts", specifier: "./a", kind: "import", line: 1, target: "src/a.ts", resolution: "repository-file" }, + { from: "src/c.ts", specifier: "./b", kind: "import", line: 1, target: "src/b.ts", resolution: "repository-file" }, + { from: "src/d.ts", specifier: "pkg", kind: "import", line: 1, resolution: "external-or-unresolved" }, + ], + resolvedEdgeCount: 2, + unresolvedEdgeCount: 1, + }; +} + +test("incremental planner selects direct changes plus bounded local dependents", () => { + const plan = buildIncrementalScanPlan(graph(), ["src/a.ts"], { maxDependentDepth: 2, maxDependents: 10 }); + assert.equal(plan.mode, "targeted"); + assert.equal(plan.reason, "targeted-with-bounded-dependents"); + assert.deepEqual(plan.changedFiles, ["src/a.ts"]); + assert.deepEqual(plan.selectedFiles, ["src/a.ts", "src/b.ts", "src/c.ts"]); + assert.deepEqual(plan.dependentFiles, [ + { path: "src/b.ts", depth: 1, triggeredBy: "src/a.ts" }, + { path: "src/c.ts", depth: 2, triggeredBy: "src/a.ts" }, + ]); + assert.equal(plan.interpretation, "coverage-heuristic-not-proof-of-unaffected-code"); +}); + +test("incremental planner falls back to full scan for high-impact repository configuration", () => { + for (const changed of [ + ".github/workflows/ci.yml", + "package-lock.json", + "infra/main.tf", + "tsconfig.json", + "config/security.yaml", + "synsec.config.json", + ]) { + const plan = buildIncrementalScanPlan(graph(), [changed]); + assert.equal(plan.mode, "full-repository", changed); + assert.equal(plan.reason, "high-impact-file-changed", changed); + assert.deepEqual(plan.selectedFiles, []); + } +}); + +test("incremental planner fails closed when a changed analyzable source file is missing from the graph", () => { + const plan = buildIncrementalScanPlan(graph(), ["src/not-indexed.ts"]); + assert.equal(plan.mode, "full-repository"); + assert.equal(plan.reason, "changed-source-not-indexed"); +}); + +test("incremental planner fails closed when dependent expansion exceeds its bound", () => { + const plan = buildIncrementalScanPlan(graph(), ["src/a.ts"], { maxDependentDepth: 3, maxDependents: 1 }); + assert.equal(plan.mode, "full-repository"); + assert.equal(plan.reason, "dependent-expansion-exceeded-bound"); +}); + +test("incremental planner rejects unsafe paths and bounds configuration", () => { + assert.equal(buildIncrementalScanPlan(graph(), ["../outside.ts"]).reason, "invalid-changed-path"); + assert.equal(buildIncrementalScanPlan(graph(), ["/absolute.ts"]).reason, "invalid-changed-path"); + assert.throws(() => buildIncrementalScanPlan(graph(), ["src/a.ts"], { maxDependentDepth: 11 }), /maxDependentDepth/); + assert.throws(() => buildIncrementalScanPlan(graph(), ["src/a.ts"], { maxDependents: 0 }), /maxDependents/); +}); + +test("incremental planner handles no-op changes without manufacturing scope", () => { + const plan = buildIncrementalScanPlan(graph(), []); + assert.equal(plan.mode, "targeted"); + assert.equal(plan.reason, "no-changes"); + assert.deepEqual(plan.selectedFiles, []); +}); From 87e799fc011f992b8a960ea792474a4d35f470e7 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:04:29 -0400 Subject: [PATCH 0387/1132] Add bounded LCOV finding coverage context --- packages/repository/src/coverage-context.ts | 167 ++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 packages/repository/src/coverage-context.ts diff --git a/packages/repository/src/coverage-context.ts b/packages/repository/src/coverage-context.ts new file mode 100644 index 00000000..9f8fe42c --- /dev/null +++ b/packages/repository/src/coverage-context.ts @@ -0,0 +1,167 @@ +import { isAbsolute, relative, resolve } from "node:path"; +import type { Finding } from "@synsec/core"; + +export interface CoverageLine { + line: number; + hits: number; +} + +export interface CoverageFile { + path: string; + lines: CoverageLine[]; +} + +export interface RepositoryCoverageIndex { + schemaVersion: 1; + format: "lcov"; + files: CoverageFile[]; + fileCount: number; + lineCount: number; + /** Coverage describes one supplied test run; it is not proof of production/runtime reachability. */ + interpretation: "observed-test-coverage-not-runtime-reachability"; +} + +export interface FindingCoverageContext { + path?: string; + line?: number; + status: "executed" | "not-executed" | "no-data"; + hits?: number; + interpretation: "observed-test-coverage-not-runtime-reachability"; +} + +export interface LcovParseOptions { + repositoryRoot?: string; +} + +const MAX_LCOV_BYTES = 16 * 1024 * 1024; +const MAX_COVERAGE_FILES = 10_000; +const MAX_COVERAGE_LINES = 1_000_000; +const MAX_PATH_LENGTH = 4096; +const MAX_LINE_NUMBER = 100_000_000; + +function normalizePath(value: string, repositoryRoot?: string): string | undefined { + const raw = value.trim(); + if (!raw || raw.length > MAX_PATH_LENGTH || raw.includes("\0")) return undefined; + let normalized = raw.replaceAll("\\", "/"); + + if (isAbsolute(raw)) { + if (!repositoryRoot) return undefined; + const root = resolve(repositoryRoot); + const rel = relative(root, resolve(raw)).replaceAll("\\", "/"); + if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) return undefined; + normalized = rel; + } + + normalized = normalized.replace(/^\.\//, "").replace(/^\//, ""); + const pieces = normalized.split("/"); + if (pieces.some((piece) => piece === ".." || piece === "")) return undefined; + return normalized; +} + +function integer(value: string, label: string, max: number): number { + if (!/^\d+$/.test(value)) throw new Error(`LCOV ${label} must be a non-negative integer.`); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed > max) throw new Error(`LCOV ${label} exceeds its supported bound.`); + return parsed; +} + +/** + * Parse bounded LCOV text supplied by the operator or CI system. + * + * SynSec does not execute tests to create this data. Absolute source paths are accepted only when + * an explicit repository root is supplied and the path resolves inside it; escaping/unusable + * records are ignored rather than expanded outside repository scope. + */ +export function parseLcovCoverage(content: string, options: LcovParseOptions = {}): RepositoryCoverageIndex { + if (Buffer.byteLength(content, "utf8") > MAX_LCOV_BYTES) { + throw new Error(`LCOV input exceeds the ${MAX_LCOV_BYTES}-byte limit.`); + } + + const files = new Map>(); + let currentPath: string | undefined; + let lineCount = 0; + + for (const rawLine of content.split(/\r?\n/)) { + if (rawLine.startsWith("SF:")) { + currentPath = normalizePath(rawLine.slice(3), options.repositoryRoot); + if (currentPath && !files.has(currentPath.toLowerCase())) { + if (files.size >= MAX_COVERAGE_FILES) throw new Error(`LCOV input exceeds the ${MAX_COVERAGE_FILES}-file limit.`); + files.set(currentPath.toLowerCase(), new Map()); + } + continue; + } + if (rawLine === "end_of_record") { + currentPath = undefined; + continue; + } + if (!currentPath || !rawLine.startsWith("DA:")) continue; + + const [lineRaw, hitsRaw] = rawLine.slice(3).split(",", 3); + if (lineRaw === undefined || hitsRaw === undefined) continue; + const line = integer(lineRaw, "line number", MAX_LINE_NUMBER); + const hits = integer(hitsRaw, "hit count", Number.MAX_SAFE_INTEGER); + if (line <= 0) continue; + + const lines = files.get(currentPath.toLowerCase()); + if (!lines) continue; + if (!lines.has(line)) { + lineCount += 1; + if (lineCount > MAX_COVERAGE_LINES) throw new Error(`LCOV input exceeds the ${MAX_COVERAGE_LINES}-line limit.`); + } + const previous = lines.get(line) ?? 0; + lines.set(line, Math.min(Number.MAX_SAFE_INTEGER, previous + hits)); + } + + const normalizedFiles: CoverageFile[] = [...files.entries()] + .map(([pathKey, lines]) => ({ + path: pathKey, + lines: [...lines.entries()] + .map(([line, hits]) => ({ line, hits })) + .sort((a, b) => a.line - b.line), + })) + .sort((a, b) => a.path.localeCompare(b.path)); + + return { + schemaVersion: 1, + format: "lcov", + files: normalizedFiles, + fileCount: normalizedFiles.length, + lineCount, + interpretation: "observed-test-coverage-not-runtime-reachability", + }; +} + +export function findingCoverageContext( + coverage: RepositoryCoverageIndex, + finding: Finding, +): FindingCoverageContext { + const path = finding.location?.path ? normalizePath(finding.location.path) : undefined; + const line = finding.location?.startLine; + if (!path || !line || !Number.isSafeInteger(line) || line <= 0) { + return { + ...(path ? { path } : {}), + ...(line && Number.isSafeInteger(line) && line > 0 ? { line } : {}), + status: "no-data", + interpretation: "observed-test-coverage-not-runtime-reachability", + }; + } + + const file = coverage.files.find((item) => item.path.toLowerCase() === path.toLowerCase()); + const covered = file?.lines.find((item) => item.line === line); + if (!covered) { + return { + path, + line, + status: "no-data", + interpretation: "observed-test-coverage-not-runtime-reachability", + }; + } + + return { + path, + line, + status: covered.hits > 0 ? "executed" : "not-executed", + hits: covered.hits, + interpretation: "observed-test-coverage-not-runtime-reachability", + }; +} From ba48fcf284454df9dbadbfde6bda4bc1c0f6a1ef Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:04:40 -0400 Subject: [PATCH 0388/1132] Export coverage context API --- packages/repository/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/repository/package.json b/packages/repository/package.json index 23ccf8ae..d61659e5 100644 --- a/packages/repository/package.json +++ b/packages/repository/package.json @@ -13,7 +13,8 @@ "./route-sink-context": "./dist/route-sink-context.js", "./posture": "./dist/posture.js", "./test-ownership": "./dist/test-ownership.js", - "./incremental-plan": "./dist/incremental-plan.js" + "./incremental-plan": "./dist/incremental-plan.js", + "./coverage-context": "./dist/coverage-context.js" }, "types": "./dist/index.d.ts", "scripts": { From 4b41314943039b0377f51a26a2e30052e225d00e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:04:52 -0400 Subject: [PATCH 0389/1132] Test bounded LCOV finding coverage context --- tests/coverage-context.test.mjs | 90 +++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/coverage-context.test.mjs diff --git a/tests/coverage-context.test.mjs b/tests/coverage-context.test.mjs new file mode 100644 index 00000000..4da8e4fe --- /dev/null +++ b/tests/coverage-context.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { findingCoverageContext, parseLcovCoverage } from "@synsec/repository/coverage-context"; + +function finding(path, line) { + return { + id: "fixture", + title: "Fixture", + category: "sast", + severity: "medium", + confidence: 1, + scanner: { name: "fixture" }, + location: { path, startLine: line }, + }; +} + +test("LCOV coverage maps observed test hits to finding lines without claiming runtime reachability", () => { + const coverage = parseLcovCoverage([ + "TN:", + "SF:src/a.ts", + "DA:10,3", + "DA:11,0", + "end_of_record", + "SF:src/b.ts", + "DA:2,1", + "end_of_record", + "", + ].join("\n")); + + assert.equal(coverage.fileCount, 2); + assert.equal(coverage.lineCount, 3); + assert.equal(coverage.interpretation, "observed-test-coverage-not-runtime-reachability"); + assert.deepEqual(findingCoverageContext(coverage, finding("src/a.ts", 10)), { + path: "src/a.ts", + line: 10, + status: "executed", + hits: 3, + interpretation: "observed-test-coverage-not-runtime-reachability", + }); + assert.equal(findingCoverageContext(coverage, finding("src/a.ts", 11)).status, "not-executed"); + assert.equal(findingCoverageContext(coverage, finding("src/a.ts", 12)).status, "no-data"); +}); + +test("LCOV parsing combines duplicate line records deterministically", () => { + const coverage = parseLcovCoverage("SF:src/a.ts\nDA:10,2\nDA:10,5\nend_of_record\n"); + assert.equal(coverage.lineCount, 1); + assert.deepEqual(coverage.files[0].lines, [{ line: 10, hits: 7 }]); +}); + +test("LCOV absolute paths are accepted only inside an explicitly bounded repository root", () => { + const root = process.platform === "win32" ? "C:\\repo" : "/repo"; + const inside = process.platform === "win32" ? "C:\\repo\\src\\a.ts" : "/repo/src/a.ts"; + const outside = process.platform === "win32" ? "C:\\other\\secret.ts" : "/other/secret.ts"; + + const withoutRoot = parseLcovCoverage(`SF:${inside}\nDA:1,1\nend_of_record\n`); + assert.equal(withoutRoot.fileCount, 0); + + const withRoot = parseLcovCoverage([ + `SF:${inside}`, + "DA:1,1", + "end_of_record", + `SF:${outside}`, + "DA:2,1", + "end_of_record", + ].join("\n"), { repositoryRoot: root }); + assert.equal(withRoot.fileCount, 1); + assert.equal(withRoot.files[0].path, "src/a.ts"); +}); + +test("LCOV parser ignores path traversal records and rejects malformed numeric data", () => { + const traversal = parseLcovCoverage("SF:../outside.ts\nDA:1,1\nend_of_record\n"); + assert.equal(traversal.fileCount, 0); + + assert.throws(() => parseLcovCoverage("SF:src/a.ts\nDA:not-a-line,1\nend_of_record\n"), /line number/); + assert.throws(() => parseLcovCoverage("SF:src/a.ts\nDA:1,-1\nend_of_record\n"), /hit count/); +}); + +test("finding coverage returns no-data when a finding has no concrete covered location", () => { + const coverage = parseLcovCoverage("SF:src/a.ts\nDA:1,1\nend_of_record\n"); + const result = findingCoverageContext(coverage, { + id: "repo", + title: "Repository-level", + category: "posture", + severity: "low", + confidence: 1, + scanner: { name: "fixture" }, + }); + assert.equal(result.status, "no-data"); +}); From 946241dd1cebfdbd9f475b5eba89fc87709e4bc2 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:05:51 -0400 Subject: [PATCH 0390/1132] Document incremental planning coverage and review comments --- docs/ROADMAP.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 47117826..8728f3f6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,7 +21,7 @@ This roadmap separates what is already usable in the repository from the deeper - [x] OSV-Scanner adapter - [x] Trivy adapter - [x] Grype adapter -- [x] Checkov adapter +- [x] Checkov IaC adapter - [x] Syft SBOM adapter and normalized scanner-artifact model - [x] OpenSSF Scorecard adapter - [x] Bounded parallel scanner orchestration @@ -37,8 +37,11 @@ This roadmap separates what is already usable in the repository from the deeper - [x] Secret redaction in normalized output - [x] Changed-file finding scope with persisted base/file metadata - [x] Direct changed-file execution for Opengrep and Betterleaks +- [x] Conservative incremental scan planner with bounded local-dependent expansion and full-scan fallback - [ ] Native incremental execution for every scanner that can safely support it +The incremental planner always includes direct changes and may add structurally known local dependents to improve review coverage. It fails over to a full repository scan when narrowing is ambiguous or high impact, including lockfiles, CI/security/IaC/configuration changes, unindexed analyzable source, unsafe paths, excessive direct changes, or dependent expansion that would exceed its configured bound. This is a coverage heuristic only; it never claims that unselected files are unaffected or safe. Engine/hosted-worker execution still needs to consume this plan before the optimization becomes end-to-end. + ## Phase 2 — Repository intelligence - [x] Language/framework inventory @@ -51,18 +54,19 @@ This roadmap separates what is already usable in the repository from the deeper - [x] Bounded route-level lexical process/filesystem/database/network sink context - [x] Bounded repository posture summary from route/auth/sink signals - [x] Bounded likely test-ownership context from resolved imports and filename conventions +- [x] Bounded LCOV ingestion and finding-line test-coverage context primitive - [ ] Full function/call graph with reliable cross-module symbol resolution - [ ] Broad routes and externally reachable entry points across supported frameworks - [ ] Framework-aware authentication/authorization enforcement semantics - [ ] Data-flow-aware sink reachability beyond lexical proximity - [ ] Dependency reachability beyond scanner-provided call analysis -- [ ] Runtime/test-run coverage context around findings +- [ ] Persisted/report-integrated runtime/test-run coverage context around findings The current call graph is deliberately labeled lexical evidence rather than runtime reachability. It resolves unambiguous direct same-file calls and leaves qualified, external, or ambiguous calls unresolved. Decorator-based route mapping only links a route when one function declaration is structurally close enough to be unambiguous; generic router registrations remain unresolved rather than guessing a handler. Route authentication and sink context are similarly conservative. They record bounded same-file security signals near indexed routes and label the results `lexical-auth-signals-only` or `lexical-sink-signals-only`. Absence of nearby auth is reported only as `no-auth-signal-observed`, and nearby sinks are not treated as proven data-flow or call reachability. The repository posture summary aggregates these bounded signals for prioritization while explicitly remaining `bounded-lexical-posture-only`. -Likely test ownership is also structural evidence only. It prioritizes test files that directly import a source module and supplements those with bounded filename-convention matches. It does not claim that a test executes a finding path or that the source is covered at runtime; coverage ingestion remains separate future work. +Likely test ownership is also structural evidence only. It prioritizes test files that directly import a source module and supplements those with bounded filename-convention matches. The LCOV primitive can now ingest caller-supplied test coverage and classify a concrete finding line as executed, not executed, or lacking data. SynSec does not run target tests to generate that coverage, and the result is explicitly labeled `observed-test-coverage-not-runtime-reachability`; normal report persistence/UI integration remains future work. ## Phase 3 — Contextual security review @@ -151,6 +155,7 @@ See [GITHUB.md](./GITHUB.md) for the current Actions integration contract and se - [x] Self-contained trend-safe security-history HTML dashboard renderer - [x] History-store → restrictive local dashboard file generation - [x] Bounded lifecycle finding-ownership metadata foundation +- [x] Bounded append-only local finding review-comment store - [ ] Project/repository dashboard application - [ ] Multi-project/server persistence layer - [ ] Interactive security-score history UI @@ -159,9 +164,9 @@ See [GITHUB.md](./GITHUB.md) for the current Actions integration contract and se - [ ] Dependency and SBOM views - [ ] Interactive repository posture view - [ ] Team triage workflow -- [ ] Finding comments and richer collaboration history +- [ ] Multi-user comments and richer collaboration history -The local history store retains only report identifiers, timestamps, commit/branch metadata, aggregate counts/scores, and finding fingerprint/title/severity tuples. It deliberately omits source excerpts, scanner diagnostics, repository URLs, artifacts, and secret-bearing evidence. Retention is bounded, writes are atomic, and invalid/corrupt stores fail closed. The self-contained history dashboard renders only this trend-safe model, escapes titles/content, and can be written with restrictive local permissions. Lifecycle ownership is separately bounded triage metadata preserved across state transitions/rescans; it is not scanner evidence and does not make the current local store a multi-user collaboration database. +The local history store retains only report identifiers, timestamps, commit/branch metadata, aggregate counts/scores, and finding fingerprint/title/severity tuples. It deliberately omits source excerpts, scanner diagnostics, repository URLs, artifacts, and secret-bearing evidence. Retention is bounded, writes are atomic, and invalid/corrupt stores fail closed. The self-contained history dashboard renders only this trend-safe model, escapes titles/content, and can be written with restrictive local permissions. Lifecycle ownership is separately bounded triage metadata preserved across state transitions/rescans. Review comments are stored in a separate restrictive append-only local store with bounded author/body/fingerprint fields and strict schema validation; SynSec does not automatically copy scanner diagnostics, source excerpts, tokens, or repository credentials into it. Neither primitive is scanner evidence or a multi-user collaboration database. ## Phase 7 — Isolated scan workers From 31584412216831e7c76b79aeb13c455c94fcacd8 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:06:09 -0400 Subject: [PATCH 0391/1132] Add deterministic finding triage view model --- packages/lifecycle/src/triage-view.ts | 78 +++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 packages/lifecycle/src/triage-view.ts diff --git a/packages/lifecycle/src/triage-view.ts b/packages/lifecycle/src/triage-view.ts new file mode 100644 index 00000000..b8488b0a --- /dev/null +++ b/packages/lifecycle/src/triage-view.ts @@ -0,0 +1,78 @@ +import type { SynSecReport } from "@synsec/report"; +import type { FindingLifecycleStore, FindingState } from "./index.js"; +import { + commentsForFinding, + type FindingReviewComment, + type FindingReviewCommentStore, +} from "./review-comments.js"; + +export interface FindingTriageViewItem { + fingerprint: string; + title: string; + severity: string; + state: FindingState; + updatedAt: string; + owner?: string; + note?: string; + comments: FindingReviewComment[]; +} + +export interface FindingTriageView { + schemaVersion: 1; + reportId: string; + items: FindingTriageViewItem[]; + summary: { + current: number; + assigned: number; + unassigned: number; + commented: number; + }; + /** Triage view intentionally excludes source excerpts and scanner evidence. */ + interpretation: "triage-metadata-not-scanner-evidence"; +} + +/** + * Compose current finding lifecycle, ownership, and human review comments for UI/API presentation. + * + * Only findings present in the supplied report are returned. The view carries title/severity for + * orientation plus bounded human triage metadata; source locations, source excerpts, scanner + * diagnostics, artifacts, repository URLs, and finding metadata are deliberately not copied. + */ +export function buildFindingTriageView( + report: SynSecReport, + lifecycle: FindingLifecycleStore, + reviewComments: FindingReviewCommentStore, +): FindingTriageView { + const items = report.findings + .flatMap((finding): FindingTriageViewItem[] => { + const record = lifecycle.records[finding.fingerprint]; + if (!record) return []; + const comments = [...commentsForFinding(reviewComments, finding.fingerprint)]; + return [{ + fingerprint: finding.fingerprint, + title: finding.primary.title, + severity: finding.primary.severity, + state: record.state, + updatedAt: record.updatedAt, + ...(record.owner ? { owner: record.owner } : {}), + ...(record.note ? { note: record.note } : {}), + comments, + }]; + }) + .sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)); + + const assigned = items.filter((item) => Boolean(item.owner)).length; + const commented = items.filter((item) => item.comments.length > 0).length; + return { + schemaVersion: 1, + reportId: report.reportId, + items, + summary: { + current: items.length, + assigned, + unassigned: items.length - assigned, + commented, + }, + interpretation: "triage-metadata-not-scanner-evidence", + }; +} From fef0ef1a617927f3716d0ebe5feb010aa7cf067a Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:06:16 -0400 Subject: [PATCH 0392/1132] Export finding triage view model --- packages/lifecycle/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/lifecycle/package.json b/packages/lifecycle/package.json index c9a5ede2..92c7ce44 100644 --- a/packages/lifecycle/package.json +++ b/packages/lifecycle/package.json @@ -5,7 +5,8 @@ "type": "module", "exports": { ".": "./dist/index.js", - "./review-comments": "./dist/review-comments.js" + "./review-comments": "./dist/review-comments.js", + "./triage-view": "./dist/triage-view.js" }, "types": "./dist/index.d.ts", "scripts": { From cf52577fb9806c957f5040ee9b674d665deb6343 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:06:28 -0400 Subject: [PATCH 0393/1132] Test deterministic finding triage view --- tests/lifecycle-triage-view.test.mjs | 94 ++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/lifecycle-triage-view.test.mjs diff --git a/tests/lifecycle-triage-view.test.mjs b/tests/lifecycle-triage-view.test.mjs new file mode 100644 index 00000000..cf4f4975 --- /dev/null +++ b/tests/lifecycle-triage-view.test.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildReport } from "@synsec/report"; +import { + emptyLifecycleStore, + reconcileLifecycle, + setFindingOwner, + setFindingState, +} from "@synsec/lifecycle"; +import { + addFindingReviewComment, + emptyFindingReviewCommentStore, +} from "@synsec/lifecycle/review-comments"; +import { buildFindingTriageView } from "@synsec/lifecycle/triage-view"; + +function report() { + return buildReport({ + target: { path: "/repo" }, + scans: [{ + scanner: "fixture", + startedAt: "2026-08-22T19:00:00.000Z", + completedAt: "2026-08-22T19:00:01.000Z", + target: { path: "/repo" }, + diagnostics: ["scanner detail that must not enter triage view"], + findings: [{ + id: "A", + title: "Finding A", + description: "scanner evidence", + category: "sast", + severity: "high", + confidence: 1, + scanner: { name: "fixture", ruleId: "A" }, + location: { path: "src/a.ts", startLine: 10 }, + metadata: { sourceExcerpt: "sensitive source" }, + }, { + id: "B", + title: "Finding B", + category: "dependency", + severity: "medium", + confidence: 1, + scanner: { name: "fixture", ruleId: "B" }, + }], + }], + scope: { mode: "repository" }, + }); +} + +test("triage view composes current state ownership and comments without scanner/source evidence", () => { + const current = report(); + const [a, b] = current.findings.map((finding) => finding.fingerprint); + let lifecycle = reconcileLifecycle(current, emptyLifecycleStore(), "2026-08-22T19:01:00.000Z"); + lifecycle = setFindingState(lifecycle, a, "confirmed", { + note: "Needs remediation", + updatedAt: "2026-08-22T19:02:00.000Z", + }); + lifecycle = setFindingOwner(lifecycle, a, "appsec", "2026-08-22T19:03:00.000Z"); + + let comments = emptyFindingReviewCommentStore(); + comments = addFindingReviewComment(comments, a, "Reviewed with maintainers.", { + author: "appsec", + createdAt: "2026-08-22T19:04:00.000Z", + }); + comments = addFindingReviewComment(comments, "not-current", "Historical only.", { + createdAt: "2026-08-22T19:05:00.000Z", + }); + + const view = buildFindingTriageView(current, lifecycle, comments); + assert.equal(view.interpretation, "triage-metadata-not-scanner-evidence"); + assert.deepEqual(view.summary, { current: 2, assigned: 1, unassigned: 1, commented: 1 }); + assert.equal(view.items.length, 2); + + const itemA = view.items.find((item) => item.fingerprint === a); + const itemB = view.items.find((item) => item.fingerprint === b); + assert.equal(itemA.state, "confirmed"); + assert.equal(itemA.owner, "appsec"); + assert.equal(itemA.note, "Needs remediation"); + assert.equal(itemA.comments[0].body, "Reviewed with maintainers."); + assert.equal(itemB.state, "new"); + assert.equal(itemB.owner, undefined); + + const serialized = JSON.stringify(view); + assert.equal(serialized.includes("src/a.ts"), false); + assert.equal(serialized.includes("sensitive source"), false); + assert.equal(serialized.includes("scanner detail"), false); + assert.equal(serialized.includes("Historical only"), false); +}); + +test("triage view omits findings without lifecycle state rather than manufacturing review state", () => { + const current = report(); + const view = buildFindingTriageView(current, emptyLifecycleStore(), emptyFindingReviewCommentStore()); + assert.deepEqual(view.items, []); + assert.deepEqual(view.summary, { current: 0, assigned: 0, unassigned: 0, commented: 0 }); +}); From 2bf4dd5b09b76228fb7aa3222ab48f16c9e5cd62 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:07:03 -0400 Subject: [PATCH 0394/1132] Add GitHub App worker permission diagnostics --- packages/github/src/app-permissions.ts | 92 ++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 packages/github/src/app-permissions.ts diff --git a/packages/github/src/app-permissions.ts b/packages/github/src/app-permissions.ts new file mode 100644 index 00000000..a08a73da --- /dev/null +++ b/packages/github/src/app-permissions.ts @@ -0,0 +1,92 @@ +import type { + GitHubInstallationPermissionLevel, + GitHubInstallationPermissions, +} from "./app.js"; + +export interface GitHubAppPermissionRequirement { + permission: string; + level: GitHubInstallationPermissionLevel; + purpose: "repository-acquisition" | "check-publication" | "sarif-publication"; +} + +export interface GitHubAppPermissionDiagnostic extends GitHubAppPermissionRequirement { + actual?: GitHubInstallationPermissionLevel; + status: "satisfied" | "missing" | "insufficient" | "unknown"; + message: string; +} + +export interface GitHubAppPermissionDiagnosticResult { + ok: boolean; + metadataAvailable: boolean; + required: GitHubAppPermissionRequirement[]; + diagnostics: GitHubAppPermissionDiagnostic[]; +} + +function satisfies(actual: GitHubInstallationPermissionLevel | undefined, required: GitHubInstallationPermissionLevel): boolean { + if (required === "read") return actual === "read" || actual === "write"; + return actual === "write"; +} + +/** Return the minimum token permissions used by SynSec's current hosted worker operations. */ +export function requiredGitHubAppWorkerPermissions(options: { publishSarif?: boolean } = {}): GitHubAppPermissionRequirement[] { + return [ + { permission: "contents", level: "read", purpose: "repository-acquisition" }, + { permission: "checks", level: "write", purpose: "check-publication" }, + ...(options.publishSarif + ? [{ permission: "security_events", level: "write", purpose: "sarif-publication" } as const] + : []), + ]; +} + +/** + * Explain whether GitHub-reported installation-token permissions satisfy SynSec worker needs. + * + * Missing permission metadata is reported as unknown and `ok=false`; SynSec never interprets an + * unavailable permission map as authorization to continue. This diagnostic describes existing + * permissions only and does not request, broaden, or mutate installation access. + */ +export function diagnoseGitHubAppWorkerPermissions( + permissions: GitHubInstallationPermissions | undefined, + options: { publishSarif?: boolean } = {}, +): GitHubAppPermissionDiagnosticResult { + const required = requiredGitHubAppWorkerPermissions(options); + const metadataAvailable = permissions !== undefined; + const diagnostics = required.map((requirement): GitHubAppPermissionDiagnostic => { + const actual = permissions?.[requirement.permission]; + if (!metadataAvailable) { + return { + ...requirement, + status: "unknown", + message: `GitHub did not provide permission metadata for ${requirement.permission}:${requirement.level}.`, + }; + } + if (actual === undefined) { + return { + ...requirement, + status: "missing", + message: `Missing GitHub App permission ${requirement.permission}:${requirement.level} for ${requirement.purpose}.`, + }; + } + if (!satisfies(actual, requirement.level)) { + return { + ...requirement, + actual, + status: "insufficient", + message: `GitHub App permission ${requirement.permission}:${actual} is insufficient; ${requirement.level} is required for ${requirement.purpose}.`, + }; + } + return { + ...requirement, + actual, + status: "satisfied", + message: `GitHub App permission ${requirement.permission}:${actual} satisfies ${requirement.purpose}.`, + }; + }); + + return { + ok: diagnostics.every((diagnostic) => diagnostic.status === "satisfied"), + metadataAvailable, + required, + diagnostics, + }; +} From 9bd72421ded41931801e154ab552d2497a72a514 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:07:12 -0400 Subject: [PATCH 0395/1132] Export GitHub App permission diagnostics --- packages/github/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/github/package.json b/packages/github/package.json index 3370109e..f1d6f82c 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -13,6 +13,7 @@ "./base-scan": "./dist/base-scan.js", "./app": "./dist/app.js", "./app-token-provider": "./dist/app-token-provider.js", + "./app-permissions": "./dist/app-permissions.js", "./app-intake": "./dist/app-intake.js", "./app-dispatch": "./dist/app-dispatch.js", "./app-handler": "./dist/app-handler.js", From 61fcbd455a87aaa6e0c79e65977942958543309a Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:07:20 -0400 Subject: [PATCH 0396/1132] Test GitHub App permission diagnostics --- tests/github-app-permissions.test.mjs | 51 +++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/github-app-permissions.test.mjs diff --git a/tests/github-app-permissions.test.mjs b/tests/github-app-permissions.test.mjs new file mode 100644 index 00000000..d94cbd04 --- /dev/null +++ b/tests/github-app-permissions.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + diagnoseGitHubAppWorkerPermissions, + requiredGitHubAppWorkerPermissions, +} from "@synsec/github/app-permissions"; + +test("worker permission diagnostics report the minimum non-SARIF requirements", () => { + assert.deepEqual(requiredGitHubAppWorkerPermissions(), [ + { permission: "contents", level: "read", purpose: "repository-acquisition" }, + { permission: "checks", level: "write", purpose: "check-publication" }, + ]); + + const result = diagnoseGitHubAppWorkerPermissions({ contents: "read", checks: "write" }); + assert.equal(result.ok, true); + assert.equal(result.metadataAvailable, true); + assert.equal(result.diagnostics.every((item) => item.status === "satisfied"), true); +}); + +test("worker permission diagnostics add security_events only when SARIF publication is enabled", () => { + const required = requiredGitHubAppWorkerPermissions({ publishSarif: true }); + assert.deepEqual(required.map((item) => `${item.permission}:${item.level}`), [ + "contents:read", + "checks:write", + "security_events:write", + ]); + + const result = diagnoseGitHubAppWorkerPermissions({ + contents: "write", + checks: "write", + security_events: "write", + }, { publishSarif: true }); + assert.equal(result.ok, true); +}); + +test("worker permission diagnostics distinguish missing and insufficient permissions", () => { + const result = diagnoseGitHubAppWorkerPermissions({ contents: "read", checks: "read" }, { publishSarif: true }); + assert.equal(result.ok, false); + assert.equal(result.diagnostics.find((item) => item.permission === "contents").status, "satisfied"); + assert.equal(result.diagnostics.find((item) => item.permission === "checks").status, "insufficient"); + assert.equal(result.diagnostics.find((item) => item.permission === "security_events").status, "missing"); + assert.match(result.diagnostics.find((item) => item.permission === "checks").message, /checks:read is insufficient/); +}); + +test("missing GitHub permission metadata fails closed as unknown", () => { + const result = diagnoseGitHubAppWorkerPermissions(undefined, { publishSarif: true }); + assert.equal(result.ok, false); + assert.equal(result.metadataAvailable, false); + assert.equal(result.diagnostics.every((item) => item.status === "unknown"), true); +}); From 2797c15070cd292b67d506e1f7faf0074a55cc7b Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:07:46 -0400 Subject: [PATCH 0397/1132] Add sanitized local triage dashboard renderer --- packages/lifecycle/src/triage-html.ts | 69 +++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 packages/lifecycle/src/triage-html.ts diff --git a/packages/lifecycle/src/triage-html.ts b/packages/lifecycle/src/triage-html.ts new file mode 100644 index 00000000..5dc58afe --- /dev/null +++ b/packages/lifecycle/src/triage-html.ts @@ -0,0 +1,69 @@ +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { FindingTriageView } from "./triage-view.js"; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function stateLabel(value: string): string { + return value.replaceAll("-", " "); +} + +/** + * Render a self-contained local finding-triage dashboard from the sanitized triage-view model. + * The renderer has no access to repository source, scanner diagnostics, finding metadata, or tokens. + */ +export function renderFindingTriageHtml(view: FindingTriageView): string { + const rows = view.items.map((item) => { + const comments = item.comments.length === 0 + ? "No review comments" + : `
    ${item.comments.map((comment) => `
  1. ${escapeHtml(comment.body)}
    ${escapeHtml(comment.author ?? "unattributed")} · ${escapeHtml(comment.createdAt)}
  2. `).join("")}
`; + return `
+
${escapeHtml(item.title)}${escapeHtml(item.severity)}
+
+
State
${escapeHtml(stateLabel(item.state))}
+
Owner
${escapeHtml(item.owner ?? "unassigned")}
+
Updated
${escapeHtml(item.updatedAt)}
+
+ ${item.note ? `

${escapeHtml(item.note)}

` : ""} +
Review comments (${item.comments.length})${comments}
+
${escapeHtml(item.fingerprint)}
+
`; + }).join("\n"); + + return ` + + + + + +SynSec finding triage + + + +

SynSec finding triage

+

Human triage metadata only · report ${escapeHtml(view.reportId)}

+
+ ${view.summary.current} current + ${view.summary.assigned} assigned + ${view.summary.unassigned} unassigned + ${view.summary.commented} commented +
+${rows || "

No current lifecycle findings.

"} + +\n`; +} + +export async function writeFindingTriageHtml(path: string, view: FindingTriageView): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, renderFindingTriageHtml(view), { encoding: "utf8", mode: 0o600 }); + await chmod(path, 0o600).catch(() => undefined); +} From 5cffef90c08fb10e802a1011a6bcaa5a20b14f3f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:07:54 -0400 Subject: [PATCH 0398/1132] Export local triage dashboard renderer --- packages/lifecycle/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/lifecycle/package.json b/packages/lifecycle/package.json index 92c7ce44..53b2703d 100644 --- a/packages/lifecycle/package.json +++ b/packages/lifecycle/package.json @@ -6,7 +6,8 @@ "exports": { ".": "./dist/index.js", "./review-comments": "./dist/review-comments.js", - "./triage-view": "./dist/triage-view.js" + "./triage-view": "./dist/triage-view.js", + "./triage-html": "./dist/triage-html.js" }, "types": "./dist/index.d.ts", "scripts": { From d084fe07803eb047855d6b551adaa7395a7daffa Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:08:05 -0400 Subject: [PATCH 0399/1132] Test sanitized local triage dashboard --- tests/lifecycle-triage-html.test.mjs | 69 ++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/lifecycle-triage-html.test.mjs diff --git a/tests/lifecycle-triage-html.test.mjs b/tests/lifecycle-triage-html.test.mjs new file mode 100644 index 00000000..2236d666 --- /dev/null +++ b/tests/lifecycle-triage-html.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { renderFindingTriageHtml, writeFindingTriageHtml } from "@synsec/lifecycle/triage-html"; + +function view() { + return { + schemaVersion: 1, + reportId: "report-", + items: [{ + fingerprint: "fp<&>", + title: "Unsafe ", + severity: "high", + state: "confirmed", + updatedAt: "2026-08-22T19:10:00.000Z", + owner: "appsec ", + note: "Needs & follow-up", + comments: [{ + id: "comment-1", + fingerprint: "fp<&>", + body: "Do not render ", + author: "reviewer & owner", + createdAt: "2026-08-22T19:11:00.000Z", + }], + }], + summary: { current: 1, assigned: 1, unassigned: 0, commented: 1 }, + interpretation: "triage-metadata-not-scanner-evidence", + }; +} + +test("triage HTML escapes every scanner/human-controlled display field", () => { + const html = renderFindingTriageHtml(view()); + assert.match(html, /Unsafe <script>alert\(1\)<\/script>/); + assert.match(html, /report-<unsafe>/); + assert.match(html, /appsec <team>/); + assert.match(html, /Needs <review> & follow-up/); + assert.match(html, /<img src=x onerror=alert\(1\)>/); + assert.match(html, /reviewer & owner/); + assert.equal(html.includes(""), false); + assert.equal(html.includes(""), false); + assert.match(html, //); +}); + +test("triage HTML writer uses restrictive permissions where supported", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-triage-html-")); + const path = join(root, "triage", "index.html"); + try { + await writeFindingTriageHtml(path, view()); + const html = await readFile(path, "utf8"); + assert.match(html, /SynSec finding triage/); + if (process.platform !== "win32") assert.equal((await stat(path)).mode & 0o777, 0o600); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("empty triage view renders an explicit no-current-findings state", () => { + const html = renderFindingTriageHtml({ + schemaVersion: 1, + reportId: "empty", + items: [], + summary: { current: 0, assigned: 0, unassigned: 0, commented: 0 }, + interpretation: "triage-metadata-not-scanner-evidence", + }); + assert.match(html, /No current lifecycle findings/); +}); From e763307b7c48317414f79b21f9b31d4985115123 Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:08:40 -0400 Subject: [PATCH 0400/1132] Add self-contained SBOM dependency dashboard --- packages/report/src/sbom-html.ts | 124 +++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 packages/report/src/sbom-html.ts diff --git a/packages/report/src/sbom-html.ts b/packages/report/src/sbom-html.ts new file mode 100644 index 00000000..c9b9ec81 --- /dev/null +++ b/packages/report/src/sbom-html.ts @@ -0,0 +1,124 @@ +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { SbomPackage } from "@synsec/core"; +import type { SynSecReport } from "./index.js"; + +export interface SbomViewPackage { + name: string; + version?: string; + type?: string; + purl?: string; + licenses: string[]; + locationCount: number; +} + +export interface SbomView { + schemaVersion: 1; + reportId: string; + packageCount: number; + uniquePackageCount: number; + packages: SbomViewPackage[]; + licenses: string[]; + producers: string[]; + /** Dependency inventory only; this view does not imply vulnerability or runtime reachability. */ + interpretation: "sbom-inventory-not-vulnerability-or-reachability"; +} + +const MAX_VIEW_PACKAGES = 100_000; + +function packageKey(pkg: SbomPackage): string { + return (pkg.purl?.trim() || `${pkg.type ?? ""}|${pkg.name}|${pkg.version ?? ""}`).toLowerCase(); +} + +export function buildSbomView(report: SynSecReport): SbomView { + const artifacts = (report.artifacts ?? []).filter((artifact) => artifact.type === "sbom"); + const byPackage = new Map(); + let packageCount = 0; + + for (const artifact of artifacts) { + packageCount += artifact.packages.length; + for (const pkg of artifact.packages) { + if (byPackage.size >= MAX_VIEW_PACKAGES && !byPackage.has(packageKey(pkg))) { + throw new Error(`SBOM view exceeds the ${MAX_VIEW_PACKAGES}-package limit.`); + } + const key = packageKey(pkg); + const existing = byPackage.get(key); + const licenses = [...new Set([...(existing?.licenses ?? []), ...(pkg.licenses ?? [])].map((value) => value.trim()).filter(Boolean))].sort(); + const candidate: SbomViewPackage = { + name: pkg.name, + ...(pkg.version ? { version: pkg.version } : {}), + ...(pkg.type ? { type: pkg.type } : {}), + ...(pkg.purl ? { purl: pkg.purl } : {}), + licenses, + locationCount: Math.max(existing?.locationCount ?? 0, pkg.locations?.length ?? 0), + }; + byPackage.set(key, candidate); + } + } + + const packages = [...byPackage.values()].sort((a, b) => + a.name.localeCompare(b.name) || (a.version ?? "").localeCompare(b.version ?? "") || (a.purl ?? "").localeCompare(b.purl ?? "")); + const licenses = [...new Set(packages.flatMap((pkg) => pkg.licenses))].sort(); + const producers = [...new Set(artifacts.map((artifact) => artifact.producer.trim()).filter(Boolean))].sort(); + + return { + schemaVersion: 1, + reportId: report.reportId, + packageCount, + uniquePackageCount: packages.length, + packages, + licenses, + producers, + interpretation: "sbom-inventory-not-vulnerability-or-reachability", + }; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function renderSbomHtml(view: SbomView): string { + const rows = view.packages.map((pkg) => ` +${escapeHtml(pkg.name)} +${escapeHtml(pkg.version ?? "—")} +${escapeHtml(pkg.type ?? "—")} +${pkg.purl ? `${escapeHtml(pkg.purl)}` : "—"} +${pkg.licenses.length ? escapeHtml(pkg.licenses.join(", ")) : "—"} +${pkg.locationCount} +`).join("\n"); + + return ` + + + + + +SynSec dependency inventory + + + +

SynSec dependency inventory

+

SBOM inventory only · report ${escapeHtml(view.reportId)}

+
+ ${view.uniquePackageCount} unique packages + ${view.packageCount} artifact package records + ${view.licenses.length} observed licenses + ${view.producers.length} SBOM producers +
+${rows ? `
${rows}
PackageVersionTypePURLLicensesLocations
` : "

No SBOM packages are present in this report.

"} + +\n`; +} + +export async function writeSbomHtml(path: string, view: SbomView): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, renderSbomHtml(view), { encoding: "utf8", mode: 0o600 }); + await chmod(path, 0o600).catch(() => undefined); +} From 3c2f59e24b01dce65be56cd138165c11c712d56f Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:08:48 -0400 Subject: [PATCH 0401/1132] Export SBOM dependency dashboard --- packages/report/package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/report/package.json b/packages/report/package.json index 3d9bfb5e..01dcf1ff 100644 --- a/packages/report/package.json +++ b/packages/report/package.json @@ -23,6 +23,10 @@ "./history-html": { "types": "./dist/history-html.d.ts", "import": "./dist/history-html.js" + }, + "./sbom-html": { + "types": "./dist/sbom-html.d.ts", + "import": "./dist/sbom-html.js" } }, "types": "./dist/index.d.ts", From ae95bc47de9f55996b7618b6166322137d3ece8e Mon Sep 17 00:00:00 2001 From: Mahmud Chowdhury Date: Sat, 22 Aug 2026 15:09:03 -0400 Subject: [PATCH 0402/1132] Test self-contained SBOM dependency dashboard --- tests/report-sbom-html.test.mjs | 110 ++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/report-sbom-html.test.mjs diff --git a/tests/report-sbom-html.test.mjs b/tests/report-sbom-html.test.mjs new file mode 100644 index 00000000..cf89d35c --- /dev/null +++ b/tests/report-sbom-html.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { buildReport } from "@synsec/report"; +import { buildSbomView, renderSbomHtml, writeSbomHtml } from "@synsec/report/sbom-html"; + +function report() { + return buildReport({ + target: { path: "/repo", commitSha: "0123456789abcdef0123456789abcdef01234567" }, + scans: [{ + scanner: "syft", + startedAt: "2026-08-22T19:00:00.000Z", + completedAt: "2026-08-22T19:00:01.000Z", + target: { path: "/repo" }, + findings: [], + diagnostics: ["must not enter the dependency dashboard"], + artifacts: [{ + type: "sbom", + format: "syft-json", + producer: "Syft ", + generatedAt: "2026-08-22T19:00:01.000Z", + packageCount: 2, + packages: [{ + name: "pkg