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()