whitehack-flashlight / bridge.mjs
Yu-and-Ai's picture
Add scanner bridge
929ec73 verified
Raw
History Blame Contribute Delete
6.26 kB
import { TextDecoder } from 'node:util'
import { isAbsolute, join } from 'node:path'
import { pathToFileURL } from 'node:url'
const DOCUMENT_TYPE = 'whitehack-flashlight/v0.1'
const SCANNER_NAME = 'whitehack'
const SCANNER_VERSION = '0.9.0'
const SCANNER_CHECK_COUNT = 47
const MAX_UTF8_BYTES = 65_536
const MAX_LINES = 2_000
const MAX_FINDINGS = 500
const MAX_REQUEST_BYTES = 512 * 1024
const LANGUAGES = Object.freeze({
javascript: Object.freeze({ core: 'js', rules: 43 }),
python: Object.freeze({ core: 'py', rules: 21 }),
solidity: Object.freeze({ core: 'sol', rules: 10 }),
})
const CONFIDENCES = Object.freeze(['high', 'medium-high', 'medium', 'heuristic'])
const LIMITS = Object.freeze({
max_utf8_bytes: MAX_UTF8_BYTES,
max_lines: MAX_LINES,
max_findings: MAX_FINDINGS,
timeout_seconds: 3.0,
})
const INTERPRETATION = Object.freeze({
finding: 'review_prompt_not_vulnerability_verdict',
empty: 'not_proof_of_safety',
})
const PRIVACY = Object.freeze({
source_returned: false,
snippets_returned: false,
application_persistence: 'not_written_by_this_app',
hosting_platform_retention: 'unknown',
})
const UTF8 = new TextDecoder('utf-8', { fatal: true })
function scannerIdentity() {
return {
name: SCANNER_NAME,
version: SCANNER_VERSION,
check_count: SCANNER_CHECK_COUNT,
}
}
function zeroSummary() {
return {
finding_count: 0,
by_confidence: {
high: 0,
'medium-high': 0,
medium: 0,
heuristic: 0,
},
}
}
function errorResponse(code) {
return {
document_type: DOCUMENT_TYPE,
status: 'error',
complete: false,
scanner: scannerIdentity(),
limits: { ...LIMITS },
scope: null,
summary: zeroSummary(),
findings: [],
interpretation: { ...INTERPRETATION },
privacy: { ...PRIVACY },
error: { code },
}
}
function emit(document) {
process.stdout.write(JSON.stringify(document))
}
async function readRequest() {
const chunks = []
let observed = 0
for await (const chunk of process.stdin) {
observed += chunk.byteLength
if (observed > MAX_REQUEST_BYTES) throw new Error('request_too_large')
chunks.push(chunk)
}
return UTF8.decode(Buffer.concat(chunks))
}
function parseRequest(raw) {
const request = JSON.parse(raw)
if (
request === null
|| typeof request !== 'object'
|| Array.isArray(request)
|| Object.keys(request).sort().join(',') !== 'language,source'
) {
throw new Error('invalid_request')
}
if (typeof request.source !== 'string') throw new Error('invalid_input')
if (!Object.hasOwn(LANGUAGES, request.language)) throw new Error('unsupported_language')
const byteCount = Buffer.byteLength(request.source, 'utf8')
if (byteCount > MAX_UTF8_BYTES) throw new Error('input_byte_limit_exceeded')
const lineCount = request.source.split('\n').length
if (lineCount > MAX_LINES) throw new Error('input_line_limit_exceeded')
if (request.source.trim().length === 0) throw new Error('input_empty')
return { ...request, byteCount, lineCount }
}
function publicFindings(findings) {
return findings
.map((finding) => ({
line: finding.line,
check: finding.check,
title: finding.title,
confidence: finding.confidence,
doctrine: finding.doctrine,
principle: finding.principle,
}))
.sort((left, right) => (
left.line - right.line
|| (left.check < right.check ? -1 : left.check > right.check ? 1 : 0)
))
}
function summarize(findings) {
const byConfidence = Object.fromEntries(CONFIDENCES.map((confidence) => [confidence, 0]))
for (const finding of findings) byConfidence[finding.confidence] += 1
return {
finding_count: findings.length,
by_confidence: byConfidence,
}
}
async function main() {
const packageRoot = process.argv[2]
if (
process.argv.length !== 3
|| typeof packageRoot !== 'string'
|| !isAbsolute(packageRoot)
) {
emit(errorResponse('scanner_identity_mismatch'))
return
}
const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10)
if (!Number.isSafeInteger(nodeMajor) || nodeMajor < 18) {
emit(errorResponse('scanner_runtime_unsupported'))
return
}
let core
try {
core = await import(pathToFileURL(join(packageRoot, 'src', 'core.js')).href)
} catch {
emit(errorResponse('scanner_unavailable'))
return
}
const { CHECK_MANIFEST, ScanTextError, scanText } = core
if (
!Array.isArray(CHECK_MANIFEST)
|| CHECK_MANIFEST.length !== SCANNER_CHECK_COUNT
|| typeof scanText !== 'function'
) {
emit(errorResponse('scanner_identity_mismatch'))
return
}
let request
try {
request = parseRequest(await readRequest())
} catch (error) {
const allowed = new Set([
'invalid_request',
'invalid_input',
'unsupported_language',
'input_byte_limit_exceeded',
'input_line_limit_exceeded',
'input_empty',
'request_too_large',
])
emit(errorResponse(allowed.has(error?.message) ? error.message : 'invalid_request'))
return
}
const language = LANGUAGES[request.language]
const rulesConsidered = CHECK_MANIFEST.filter((check) => (
check.languages.length === 0 || check.languages.includes(language.core)
)).length
if (rulesConsidered !== language.rules) {
emit(errorResponse('scanner_identity_mismatch'))
return
}
let findings
try {
findings = publicFindings(scanText(request.source, {
file: '[pasted-input]',
lang: language.core,
maxLines: MAX_LINES,
maxFindings: MAX_FINDINGS,
}))
} catch (error) {
if (error instanceof ScanTextError) {
emit(errorResponse(error.code))
return
}
emit(errorResponse('scanner_failed'))
return
}
emit({
document_type: DOCUMENT_TYPE,
status: 'complete',
complete: true,
scanner: scannerIdentity(),
limits: { ...LIMITS },
scope: {
kind: 'caller-provided-text',
language: request.language,
utf8_bytes: request.byteCount,
lines: request.lineCount,
rules_considered: rulesConsidered,
},
summary: summarize(findings),
findings,
interpretation: { ...INTERPRETATION },
privacy: { ...PRIVACY },
error: null,
})
}
await main()