security sweep

This commit is contained in:
2026-05-29 09:03:47 -04:00
parent 469c28fa64
commit 3b29de3234
60 changed files with 7148 additions and 413 deletions

1
web/.pi-lens/cache/review-graph.json vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,477 @@
{
"version": "v2",
"timestamp": 1780014375470,
"ruleHash": "a7836d13ad8f9a39",
"queries": [
{
"id": "console-statement",
"name": "Console Statement",
"severity": "warning",
"language": "typescript",
"message": "{{METHOD}} — remove debug statements before committing",
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD (#not-eq? @METHOD \"dbg\"))\n arguments: (arguments) @ARGS)",
"metavars": [
"OBJ",
"METHOD",
"ARGS"
],
"post_filter": "not_in_test_block # skip test blocks — no-console-in-tests handles that case",
"defect_class": "safety",
"inline_tier": "warning",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml"
},
{
"id": "debugger-statement",
"name": "Debugger Statement",
"severity": "error",
"language": "typescript",
"message": "Debugger statement — remove before committing",
"query": " (debugger_statement) @DEBUGGER",
"metavars": [
"DEBUGGER"
],
"defect_class": "safety",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/debugger.yml"
},
{
"id": "deep-nesting",
"name": "Deep Nesting",
"severity": "warning",
"language": "typescript",
"message": "Deep nesting (3+ levels) — consider early returns or extract functions",
"query": " [\n ;; Pattern 1: if inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement) @IF_NESTED)))))\n\n ;; Pattern 2: for inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement) @FOR_NESTED)))))\n\n ;; Pattern 3: while inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement) @WHILE_NESTED)))))\n\n ;; Pattern 4: try inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (try_statement) @TRY_NESTED)))))\n\n ;; Pattern 5: if inside for inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement\n body: (statement_block\n (if_statement) @IF_IN_FOR)))))\n\n ;; Pattern 6: if inside while inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement\n body: (statement_block\n (if_statement) @IF_IN_WHILE)))))\n\n ;; Pattern 7: for inside for inside for\n (statement_block\n (for_statement\n body: (statement_block\n (for_statement\n body: (statement_block\n (for_statement) @FOR_NESTED)))))\n ]",
"metavars": [
"IF_NESTED",
"FOR_NESTED",
"WHILE_NESTED",
"TRY_NESTED",
"IF_IN_FOR",
"IF_IN_WHILE"
],
"defect_class": "safety",
"inline_tier": "review",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/deep-nesting.yml"
},
{
"id": "deep-promise-chain",
"name": "Deep Promise Chain (4+ levels)",
"severity": "warning",
"language": "typescript",
"message": "Promise chain {{M1}} → {{M2}} → {{M3}} → {{M4}} — consider async/await",
"query": " (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n property: (property_identifier) @M1)\n arguments: (arguments))\n property: (property_identifier) @M2)\n arguments: (arguments))\n property: (property_identifier) @M3)\n arguments: (arguments))\n property: (property_identifier) @M4)\n arguments: (arguments)\n (#match? @M1 \"^(then|catch|finally)$\")\n (#match? @M2 \"^(then|catch|finally)$\")\n (#match? @M3 \"^(then|catch|finally)$\")\n (#match? @M4 \"^(then|catch|finally)$\"))",
"metavars": [
"M1",
"M2",
"M3",
"M4"
],
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/deep-promise-chain.yml"
},
{
"id": "default-not-last",
"name": "Default Clauses Should Be Last",
"severity": "error",
"language": "typescript",
"message": "default clause should be the last case",
"query": " (switch_statement\n body: (switch_body\n (switch_default) @DEFAULT\n (switch_case) @AFTER_CASE))",
"metavars": [
"DEFAULT",
"AFTER_CASE"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/default-not-last.yml"
},
{
"id": "duplicate-function-arg",
"name": "Function Argument Names Should Be Unique",
"severity": "error",
"language": "typescript",
"message": "Duplicate parameter name '{{NAME}}'",
"query": " (function_declaration\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))\n (arrow_function\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))",
"metavars": [
"PARAM1",
"PARAM2"
],
"post_filter": "same_param_name",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml"
},
{
"id": "empty-switch-case",
"name": "Switch Cases Should Not Be Empty",
"severity": "error",
"language": "typescript",
"message": "Switch case should not be empty",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block) @BLOCK)))",
"metavars": [
"BLOCK"
],
"post_filter": "is_empty_block",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/empty-switch-case.yml"
},
{
"id": "no-eval",
"name": "Eval Usage",
"severity": "error",
"language": "typescript",
"message": "eval() detected — security risk, never use eval",
"query": " (call_expression\n function: (identifier) @FUNC\n (#eq? @FUNC \"eval\")\n arguments: (arguments) @ARGS)",
"metavars": [
"FUNC",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/eval.yml"
},
{
"id": "ts-incomplete-assertion",
"name": "Incomplete Test Assertion",
"severity": "error",
"language": "typescript",
"message": "Incomplete assertion — expect() chain is not called",
"query": " (call_expression\n function: (identifier) @EXPECT\n (#eq? @EXPECT \"expect\")\n arguments: (arguments)) @EXPR",
"metavars": [
"EXPECT",
"EXPR"
],
"post_filter": "incomplete_assertion",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/incomplete-assertion.yml"
},
{
"id": "infinite-loop",
"name": "Loops Should Not Be Infinite",
"severity": "error",
"language": "typescript",
"message": "Loop appears to be infinite with no termination condition",
"query": " (while_statement\n condition: (true)\n body: (statement_block) @BODY)\n (for_statement\n condition: (null)\n body: (statement_block) @BODY)",
"metavars": [
"BODY"
],
"post_filter": "no_break_or_return_in_body",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/infinite-loop.yml"
},
{
"id": "mixed-async-styles",
"name": "Mixed Async/Await and Promise Chains",
"severity": "warning",
"language": "typescript",
"message": "Mixed async/await + promise chains — use consistent async style",
"query": " (function_declaration\n (async_modifier)\n body: (statement_block) @BODY)\n\n# Post-filter: Check if body contains both await and .then()",
"metavars": [
"BODY"
],
"post_filter": "has_mixed_async",
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/mixed-async-styles.yml"
},
{
"id": "no-console-in-tests",
"name": "Console Statement in Test",
"severity": "warning",
"language": "typescript",
"message": "console.{{METHOD}} in test block — use proper assertions or logging",
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD)\n arguments: (arguments) @ARGS)",
"metavars": [
"OBJ",
"METHOD",
"ARGS"
],
"post_filter": "in_test_block",
"defect_class": "safety",
"inline_tier": "warning",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/no-console-in-tests.yml"
},
{
"id": "self-assignment",
"name": "Variables Should Not Be Self-Assigned",
"severity": "error",
"language": "typescript",
"message": "'{{VAR}}' is assigned to itself",
"query": " (assignment_expression\n left: (identifier) @VAR\n right: (identifier) @SAME\n (#eq? @VAR @SAME))",
"metavars": [
"VAR",
"SAME"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/self-assignment.yml"
},
{
"id": "sql-injection",
"name": "SQL Injection Risk",
"severity": "error",
"language": "typescript",
"message": "SQL injection risk — use parameterized queries, never interpolate into SQL",
"query": " (call_expression\n function: [\n (identifier) @SQL_FUNC\n (member_expression property: (property_identifier) @SQL_FUNC)\n ]\n arguments: (arguments\n (template_string (template_substitution) @INTERPOLATION))\n (#match? @SQL_FUNC \"^(query|execute|exec|run)$\"))",
"metavars": [
"SQL_FUNC",
"INTERPOLATION"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/sql-injection.yml"
},
{
"id": "switch-case-termination",
"name": "Switch Cases Should End With Terminating Statement",
"severity": "error",
"language": "typescript",
"message": "Switch case should end with break, return, throw, or continue",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block\n (expression_statement) @LAST))\n (switch_case) @NEXT))",
"metavars": [
"LAST",
"NEXT"
],
"post_filter": "no_terminating_statement",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/switch-case-termination.yml"
},
{
"id": "switch-non-case-labels-ts",
"name": "Switch Should Not Contain Non-Case Labels",
"severity": "error",
"language": "typescript",
"message": "switch statements should not contain non-case labels",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n (labeled_statement\n (statement_identifier) @LABEL) @LABELED)))",
"metavars": [
"LABEL",
"LABELED"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/switch-non-case-labels.yml"
},
{
"id": "ts-command-injection",
"name": "Command Injection Sink",
"severity": "error",
"language": "typescript",
"message": "Potential command injection sink — avoid child_process command execution with untrusted input",
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n (call_expression\n function: (member_expression\n object: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @NS)\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n ]",
"metavars": [
"MOD",
"NS",
"FN",
"ARGS"
],
"post_filter": "ts_command_injection_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-command-injection.yml"
},
{
"id": "ts-detached-async-call",
"name": "Detached Async Call",
"severity": "warning",
"language": "typescript",
"message": "Detached async call — ensure this Promise is awaited or explicitly handled",
"query": " (expression_statement\n (call_expression\n function: [\n (identifier) @FN\n (member_expression\n property: (property_identifier) @FN)\n ]\n arguments: (arguments) @ARGS)\n (#match? @FN \"(Async$|fetch$|request$)\"))",
"metavars": [
"FN",
"ARGS"
],
"post_filter": "ts_detached_async_call",
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-detached-async-call.yml"
},
{
"id": "ts-dynamic-require",
"name": "Dynamic Require Injection",
"severity": "error",
"language": "typescript",
"message": "Dynamic require() — non-literal argument allows loading arbitrary modules",
"query": " (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @ARG)\n (#eq? @FN \"require\"))",
"metavars": [
"FN",
"ARG"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-dynamic-require.yml"
},
{
"id": "ts-hallucinated-react-import",
"name": "Hallucinated React Import",
"severity": "error",
"language": "typescript",
"message": "'{NAME}' is a Next.js API, not from 'react' — import from 'next/{CORRECT}' instead",
"query": " (import_statement\n (import_clause\n (named_imports\n (import_specifier\n name: (identifier) @NAME)))\n source: (string) @SRC)\n (#match? @SRC \"^['\\\"]react['\\\"]$\")\n (#match? @NAME \"^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$\")",
"metavars": [
"NAME",
"SRC"
],
"post_filter": "match_captures",
"post_filter_params": {
"SRC": "^['\\\"]react['\\\"]$",
"NAME": "^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$"
},
"defect_class": "hallucination",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-hallucinated-react-import.yml"
},
{
"id": "ts-insecure-random",
"name": "Insecure Randomness",
"severity": "warning",
"language": "typescript",
"message": "Insecure randomness source detected — use crypto.getRandomValues or secure RNG APIs",
"query": " (variable_declarator\n name: (identifier) @VAR\n value: (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS)\n (#eq? @OBJ \"Math\")\n (#eq? @FN \"random\")\n (#match? @VAR \"(?i)(token|secret|password|key|nonce|salt|csrf|auth|session|credential|hash|otp|pin)\"))",
"metavars": [
"OBJ",
"FN",
"ARGS",
"VAR"
],
"post_filter": "ts_insecure_random_source",
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-insecure-random.yml"
},
{
"id": "ts-nosql-injection",
"name": "NoSQL Injection",
"severity": "error",
"language": "typescript",
"message": "NoSQL injection — $where executes JavaScript server-side and must never be used with user input",
"query": " (pair\n key: [(property_identifier) (string)] @KEY\n (#match? @KEY \"\\\\$where\"))",
"metavars": [
"KEY"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-nosql-injection.yml"
},
{
"id": "ts-open-redirect",
"name": "Open Redirect",
"severity": "error",
"language": "typescript",
"message": "Open redirect — unvalidated URL in redirect/location lets attackers send users to malicious sites",
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n ]\n [\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n ]",
"metavars": [
"OBJ",
"FN",
"URL",
"WIN",
"LOC",
"PROP",
"VALUE"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-open-redirect.yml"
},
{
"id": "ts-react-antipatterns",
"name": "React Anti-Pattern",
"severity": "warning",
"language": "typescript",
"message": "React anti-pattern: setState inside a loop causes multiple re-renders — batch with a single state update",
"query": " [\n (for_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (for_in_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (while_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n ]",
"metavars": [
"BODY"
],
"defect_class": "logic-error",
"inline_tier": "warning",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-react-antipatterns.yml"
},
{
"id": "ts-ssrf",
"name": "SSRF Risk",
"severity": "error",
"language": "typescript",
"message": "Potential SSRF sink — validate and allowlist outbound URLs",
"query": " [\n (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n ]",
"metavars": [
"OBJ",
"FN",
"URL"
],
"post_filter": "ts_ssrf_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-ssrf.yml"
},
{
"id": "ts-weak-hash",
"name": "Weak Hash Primitive",
"severity": "error",
"language": "typescript",
"message": "Weak hash primitive selected (md5/sha1) — use sha256+ for security-sensitive contexts",
"query": " (call_expression\n function: (member_expression\n property: (property_identifier) @FN)\n arguments: (arguments\n (string (string_fragment) @ALG)\n (_)*)\n (#eq? @FN \"createHash\")\n (#match? @ALG \"^(md5|sha1)$\"))",
"metavars": [
"FN",
"ALG"
],
"post_filter": "ts_weak_hash_algorithm",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-weak-hash.yml"
},
{
"id": "ts-xss-dom-sink",
"name": "XSS DOM Sink",
"severity": "error",
"language": "typescript",
"message": "XSS risk — dynamic value written to innerHTML/outerHTML or document.write()",
"query": " [\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (await_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n ]\n [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n ]",
"metavars": [
"PROP",
"VALUE",
"OBJ",
"FN",
"ARG"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-xss-dom-sink.yml"
},
{
"id": "unsafe-regex",
"name": "Dynamic Regex Construction",
"severity": "error",
"language": "typescript",
"message": "Dynamic regex from user input — can cause ReDoS (Regular Expression Denial of Service)",
"query": " (new_expression\n constructor: (identifier) @CTOR\n (#eq? @CTOR \"RegExp\")\n arguments: (arguments\n (template_string\n (template_substitution) @INTERPOLATION) @PATTERN)\n (#not-match? @INTERPOLATION \"escape|Escape|replace\"))",
"metavars": [
"CTOR",
"INTERPOLATION",
"PATTERN"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/unsafe-regex.yml"
},
{
"id": "variable-shadowing",
"name": "Variable Shadowing",
"severity": "warning",
"language": "typescript",
"message": "Variable '{{NAME}}' shadows a parameter — use a distinct name",
"query": " (function_declaration\n parameters: (formal_parameters\n (required_parameter\n pattern: (identifier) @PARAM))\n body: (statement_block\n (lexical_declaration\n (variable_declarator\n name: (identifier) @NAME))))",
"metavars": [
"PARAM",
"NAME"
],
"post_filter": "name_matches_param",
"defect_class": "safety",
"inline_tier": "review",
"filePath": "/Users/mike/.pi/agent/npm/node_modules/pi-lens/rules/tree-sitter-queries/typescript/variable-shadowing.yml"
}
]
}

View File

@@ -0,0 +1,4 @@
{"timestamp":"2026-05-29T00:26:16.299Z","filePath":"/Users/mike/Code/Kordant/web/src/server/websocket.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":157,"column":5,"fixable":true,"fixSuggestion":"remove this statement","autoFixed":false}
{"timestamp":"2026-05-29T00:26:16.299Z","filePath":"/Users/mike/Code/Kordant/web/src/server/websocket.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":200,"column":5,"fixable":true,"fixSuggestion":"remove this statement","autoFixed":false}
{"timestamp":"2026-05-29T00:26:16.299Z","filePath":"/Users/mike/Code/Kordant/web/src/server/websocket.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":285,"column":6,"fixable":true,"fixSuggestion":"remove this statement","autoFixed":false}
{"timestamp":"2026-05-29T00:26:16.299Z","filePath":"/Users/mike/Code/Kordant/web/src/server/websocket.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":308,"column":5,"fixable":true,"fixSuggestion":"remove this statement","autoFixed":false}

View File

@@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import { safeParse } from "valibot";
import { CreateEnrollmentSchema, AnalyzeAudioSchema } from "./voiceprint";
describe("CreateEnrollmentSchema", () => {
it("accepts valid enrollment with small audio", () => {
const data = {
name: "My Voice",
audioBase64: "dGVzdC1hdWRpby1iYXNlNjQ=",
};
const result = safeParse(CreateEnrollmentSchema, data);
expect(result.success).toBe(true);
});
it("rejects audio payload exceeding maxLength", () => {
// ~3MB base64 string (exceeds 2.6MB default limit)
const largeAudio = "A".repeat(3_000_000);
const data = {
name: "My Voice",
audioBase64: largeAudio,
};
const result = safeParse(CreateEnrollmentSchema, data);
expect(result.success).toBe(false);
});
it("rejects empty audio", () => {
const data = { name: "My Voice", audioBase64: "" };
const result = safeParse(CreateEnrollmentSchema, data);
expect(result.success).toBe(false);
});
it("rejects missing name", () => {
const data = { audioBase64: "dGVzdA==" };
const result = safeParse(CreateEnrollmentSchema, data);
expect(result.success).toBe(false);
});
});
describe("AnalyzeAudioSchema", () => {
it("accepts valid analysis request", () => {
const data = { audioBase64: "dGVzdC1hdWRpby1iYXNlNjQ=" };
const result = safeParse(AnalyzeAudioSchema, data);
expect(result.success).toBe(true);
});
it("accepts analysis with optional enrollmentId", () => {
const data = { audioBase64: "dGVzdA==", enrollmentId: "enr-123" };
const result = safeParse(AnalyzeAudioSchema, data);
expect(result.success).toBe(true);
});
it("rejects audio payload exceeding maxLength", () => {
const largeAudio = "A".repeat(3_000_000);
const data = { audioBase64: largeAudio };
const result = safeParse(AnalyzeAudioSchema, data);
expect(result.success).toBe(false);
});
it("rejects empty audio", () => {
const data = { audioBase64: "" };
const result = safeParse(AnalyzeAudioSchema, data);
expect(result.success).toBe(false);
});
});

View File

@@ -1,29 +1,53 @@
import { object, string, minLength, optional, number, picklist } from "valibot";
import {
object,
string,
minLength,
maxLength,
optional,
number,
picklist,
} from "valibot";
/**
* Maximum allowed base64-encoded audio payload length.
* Default: ~2.6MB base64 (≈2MB decoded). Configurable via VOICEPRINT_MAX_BASE64_LENGTH.
* Formula: maxDecodedBytes * 4/3 ≈ base64 length
*/
const MAX_BASE64_LENGTH = parseInt(
process.env.VOICEPRINT_MAX_BASE64_LENGTH ?? "2621440",
10,
);
/** Maximum decoded audio size in bytes (default 2MB). */
const MAX_DECODED_SIZE = parseInt(
process.env.VOICEPRINT_MAX_DECODED_SIZE ?? "2097152",
10,
);
export const CreateEnrollmentSchema = object({
name: string([minLength(1)]),
audioBase64: string([minLength(1)]),
name: string([minLength(1)]),
audioBase64: string([minLength(1), maxLength(MAX_BASE64_LENGTH)]),
});
export const DeleteEnrollmentSchema = object({
enrollmentId: string([minLength(1)]),
enrollmentId: string([minLength(1)]),
});
export const AnalyzeAudioSchema = object({
audioBase64: string([minLength(1)]),
enrollmentId: optional(string()),
audioBase64: string([minLength(1), maxLength(MAX_BASE64_LENGTH)]),
enrollmentId: optional(string()),
});
export const AnalysisFilterSchema = object({
page: optional(number(), 1),
limit: optional(number(), 20),
verdict: optional(picklist(["NATURAL", "SYNTHETIC", "UNCERTAIN"])),
page: optional(number(), 1),
limit: optional(number(), 20),
verdict: optional(picklist(["NATURAL", "SYNTHETIC", "UNCERTAIN"])),
});
export const AnalysisResultSchema = object({
analysisId: string([minLength(1)]),
analysisId: string([minLength(1)]),
});
export const JobStatusSchema = object({
jobId: string([minLength(1)]),
jobId: string([minLength(1)]),
});

View File

@@ -4,229 +4,331 @@ import { TRPCError } from "@trpc/server";
const mockQueryResult = vi.fn().mockResolvedValue([]);
function createChain(initialPromise?: any): any {
const p = typeof initialPromise !== "undefined" ? initialPromise : mockQueryResult();
return new Proxy(p, {
get(target, prop) {
if (prop === "then" || prop === "catch" || prop === "finally") {
return Reflect.get(target, prop).bind(target);
}
return () => createChain(p);
},
});
const p =
typeof initialPromise !== "undefined" ? initialPromise : mockQueryResult();
return new Proxy(p, {
get(target, prop) {
if (prop === "then" || prop === "catch" || prop === "finally") {
return Reflect.get(target, prop).bind(target);
}
return () => createChain(p);
},
});
}
vi.mock("~/server/db", () => ({
db: {
select: vi.fn(() => createChain()),
insert: vi.fn(() => createChain()),
update: vi.fn(() => createChain()),
},
db: {
select: vi.fn(() => createChain()),
insert: vi.fn(() => createChain()),
update: vi.fn(() => createChain()),
},
}));
vi.mock("./voiceprint/storage", () => ({
saveAudio: vi.fn(),
getAudioUrl: vi.fn(),
deleteFile: vi.fn(),
computeHash: vi.fn(),
deleteAudio: vi.fn(),
saveAudio: vi.fn(),
getAudioUrl: vi.fn(),
deleteFile: vi.fn(),
computeHash: vi.fn(),
deleteAudio: vi.fn(),
}));
vi.mock("./voiceprint/ml.engine", () => ({
preprocessAudio: vi.fn(),
detectSynthetic: vi.fn(),
matchVoice: vi.fn(),
generateEmbedding: vi.fn(),
preprocessAudio: vi.fn(),
detectSynthetic: vi.fn(),
matchVoice: vi.fn(),
generateEmbedding: vi.fn(),
}));
const storage = await import("./voiceprint/storage");
const ml = await import("./voiceprint/ml.engine");
const mockEnrollment = {
id: "enr-1",
userId: "user-1",
name: "My Voice",
voiceHash: "hash-123",
audioMetadata: { filePath: "/some/path.wav", duration: 2.5, sampleRate: 16000 },
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
id: "enr-1",
userId: "user-1",
name: "My Voice",
voiceHash: "hash-123",
audioMetadata: {
filePath: "/some/path.wav",
duration: 2.5,
sampleRate: 16000,
},
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockAnalysis = {
id: "ana-1",
enrollmentId: null,
userId: "user-1",
audioHash: "audio-hash-1",
isSynthetic: false,
confidence: 0.95,
analysisResult: { verdict: "NATURAL", score: 0.05, matchedSimilarity: null },
audioUrl: "/uploads/voiceprint/user-1/audio-hash-1.wav",
createdAt: new Date(),
id: "ana-1",
enrollmentId: null,
userId: "user-1",
audioHash: "audio-hash-1",
isSynthetic: false,
confidence: 0.95,
analysisResult: { verdict: "NATURAL", score: 0.05, matchedSimilarity: null },
audioUrl: "/uploads/voiceprint/user-1/audio-hash-1.wav",
createdAt: new Date(),
};
const mockJob = {
id: "job-1",
userId: "user-1",
analysisType: "BATCH",
audioFilePath: "/path/to/audio.wav",
status: "PENDING",
errorMessage: null,
completedAt: null,
createdAt: new Date(),
id: "job-1",
userId: "user-1",
analysisType: "BATCH",
audioFilePath: "/path/to/audio.wav",
status: "PENDING",
errorMessage: null,
completedAt: null,
createdAt: new Date(),
};
const mockResult = {
id: "res-1",
analysisJobId: "job-1",
syntheticScore: 0.1,
verdict: "NATURAL",
confidence: 0.95,
processingTimeMs: 1500,
matchedEnrollmentId: null,
matchedSimilarity: null,
modelVersion: "v1",
createdAt: new Date(),
id: "res-1",
analysisJobId: "job-1",
syntheticScore: 0.1,
verdict: "NATURAL",
confidence: 0.95,
processingTimeMs: 1500,
matchedEnrollmentId: null,
matchedSimilarity: null,
modelVersion: "v1",
createdAt: new Date(),
};
beforeEach(() => {
vi.clearAllMocks();
vi.clearAllMocks();
});
describe("getEnrollments", () => {
it("returns enrollments for the user", async () => {
mockQueryResult.mockResolvedValueOnce([mockEnrollment]);
it("returns enrollments for the user", async () => {
mockQueryResult.mockResolvedValueOnce([mockEnrollment]);
const { getEnrollments } = await import("./voiceprint.service");
const result = await getEnrollments("user-1");
expect(result).toEqual([mockEnrollment]);
});
const { getEnrollments } = await import("./voiceprint.service");
const result = await getEnrollments("user-1");
expect(result).toEqual([mockEnrollment]);
});
});
describe("createEnrollment", () => {
it("saves audio and creates a DB record", async () => {
vi.mocked(storage.saveAudio).mockResolvedValue({
hash: "audio-hash",
filePath: "/path/file.wav",
});
vi.mocked(ml.preprocessAudio).mockResolvedValue({
duration: 2.5, sampleRate: 16000, channels: 1, rawPcm: Buffer.from("test"),
});
vi.mocked(ml.generateEmbedding).mockResolvedValue({
vector: new Float64Array(256), hash: "embed-hash",
});
mockQueryResult.mockResolvedValueOnce([mockEnrollment]);
it("saves audio and creates a DB record", async () => {
vi.mocked(storage.saveAudio).mockResolvedValue({
hash: "audio-hash",
filePath: "/path/file.wav",
});
vi.mocked(ml.preprocessAudio).mockResolvedValue({
duration: 2.5,
sampleRate: 16000,
channels: 1,
rawPcm: Buffer.from("test"),
});
vi.mocked(ml.generateEmbedding).mockResolvedValue({
vector: new Float64Array(256),
hash: "embed-hash",
});
mockQueryResult.mockResolvedValueOnce([mockEnrollment]);
const { createEnrollment } = await import("./voiceprint.service");
const result = await createEnrollment("user-1", "My Voice", "dGVzdC1hdWRpbw==");
expect(result).toEqual(mockEnrollment);
expect(storage.saveAudio).toHaveBeenCalledWith("user-1", Buffer.from("test-audio"));
expect(ml.generateEmbedding).toHaveBeenCalled();
});
const { createEnrollment } = await import("./voiceprint.service");
const result = await createEnrollment(
"user-1",
"My Voice",
"dGVzdC1hdWRpbw==",
);
expect(result).toEqual(mockEnrollment);
expect(storage.saveAudio).toHaveBeenCalledWith(
"user-1",
Buffer.from("test-audio"),
);
expect(ml.generateEmbedding).toHaveBeenCalled();
});
});
describe("deleteEnrollment", () => {
it("soft deletes enrollment and removes audio file", async () => {
mockQueryResult
.mockResolvedValueOnce([mockEnrollment])
.mockResolvedValueOnce([{ ...mockEnrollment, isActive: false }]);
it("soft deletes enrollment and removes audio file", async () => {
mockQueryResult
.mockResolvedValueOnce([mockEnrollment])
.mockResolvedValueOnce([{ ...mockEnrollment, isActive: false }]);
const { deleteEnrollment } = await import("./voiceprint.service");
const result = await deleteEnrollment("user-1", "enr-1");
expect(result.isActive).toBe(false);
expect(storage.deleteFile).toHaveBeenCalledWith("/some/path.wav");
});
const { deleteEnrollment } = await import("./voiceprint.service");
const result = await deleteEnrollment("user-1", "enr-1");
expect(result.isActive).toBe(false);
expect(storage.deleteFile).toHaveBeenCalledWith("/some/path.wav");
});
it("throws NOT_FOUND if enrollment does not belong to user", async () => {
mockQueryResult.mockResolvedValueOnce([]);
it("throws NOT_FOUND if enrollment does not belong to user", async () => {
mockQueryResult.mockResolvedValueOnce([]);
const { deleteEnrollment } = await import("./voiceprint.service");
await expect(deleteEnrollment("user-1", "nonexistent")).rejects.toThrow(TRPCError);
});
const { deleteEnrollment } = await import("./voiceprint.service");
await expect(deleteEnrollment("user-1", "nonexistent")).rejects.toThrow(
TRPCError,
);
});
});
describe("analyzeAudio", () => {
it("returns verdict and confidence for analysis", async () => {
vi.mocked(storage.saveAudio).mockResolvedValue({
hash: "audio-hash", filePath: "/path/file.wav",
});
vi.mocked(storage.getAudioUrl).mockReturnValue("/uploads/voiceprint/user-1/audio-hash.wav");
vi.mocked(ml.preprocessAudio).mockResolvedValue({
duration: 2.5, sampleRate: 16000, channels: 1, rawPcm: Buffer.from("test"),
});
vi.mocked(ml.detectSynthetic).mockResolvedValue({
isSynthetic: false, confidence: 0.95, score: 0.05,
});
mockQueryResult.mockResolvedValueOnce([mockAnalysis]);
it("returns verdict and confidence for analysis", async () => {
vi.mocked(storage.saveAudio).mockResolvedValue({
hash: "audio-hash",
filePath: "/path/file.wav",
});
vi.mocked(storage.getAudioUrl).mockReturnValue(
"/uploads/voiceprint/user-1/audio-hash.wav",
);
vi.mocked(ml.preprocessAudio).mockResolvedValue({
duration: 2.5,
sampleRate: 16000,
channels: 1,
rawPcm: Buffer.from("test"),
});
vi.mocked(ml.detectSynthetic).mockResolvedValue({
isSynthetic: false,
confidence: 0.95,
score: 0.05,
});
mockQueryResult.mockResolvedValueOnce([mockAnalysis]);
const { analyzeAudio } = await import("./voiceprint.service");
const result = await analyzeAudio("user-1", "dGVzdC1hdWRpbw==");
expect(result.verdict).toBe("NATURAL");
expect(result.confidence).toBe(0.95);
expect(result.isSynthetic).toBe(false);
expect(result.score).toBe(0.05);
});
const { analyzeAudio } = await import("./voiceprint.service");
const result = await analyzeAudio("user-1", "dGVzdC1hdWRpbw==");
expect(result.verdict).toBe("NATURAL");
expect(result.confidence).toBe(0.95);
expect(result.isSynthetic).toBe(false);
expect(result.score).toBe(0.05);
});
});
describe("getAnalyses", () => {
it("returns paginated analyses", async () => {
mockQueryResult
.mockResolvedValueOnce([{ count: 1 }])
.mockResolvedValueOnce([mockAnalysis]);
it("returns paginated analyses", async () => {
mockQueryResult
.mockResolvedValueOnce([{ count: 1 }])
.mockResolvedValueOnce([mockAnalysis]);
const { getAnalyses } = await import("./voiceprint.service");
const result = await getAnalyses("user-1", { page: 1, limit: 10 });
expect(result.items).toEqual([mockAnalysis]);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
});
const { getAnalyses } = await import("./voiceprint.service");
const result = await getAnalyses("user-1", { page: 1, limit: 10 });
expect(result.items).toEqual([mockAnalysis]);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
});
});
describe("getAnalysisResult", () => {
it("returns detailed analysis", async () => {
mockQueryResult.mockResolvedValueOnce([mockAnalysis]);
it("returns detailed analysis", async () => {
mockQueryResult.mockResolvedValueOnce([mockAnalysis]);
const { getAnalysisResult } = await import("./voiceprint.service");
const result = await getAnalysisResult("user-1", "ana-1");
expect(result).toEqual(mockAnalysis);
});
const { getAnalysisResult } = await import("./voiceprint.service");
const result = await getAnalysisResult("user-1", "ana-1");
expect(result).toEqual(mockAnalysis);
});
it("throws NOT_FOUND for non-existent analysis", async () => {
mockQueryResult.mockResolvedValueOnce([]);
it("throws NOT_FOUND for non-existent analysis", async () => {
mockQueryResult.mockResolvedValueOnce([]);
const { getAnalysisResult } = await import("./voiceprint.service");
await expect(getAnalysisResult("user-1", "nonexistent")).rejects.toThrow(TRPCError);
});
const { getAnalysisResult } = await import("./voiceprint.service");
await expect(getAnalysisResult("user-1", "nonexistent")).rejects.toThrow(
TRPCError,
);
});
});
describe("getJobStatus", () => {
it("returns job status with result when completed", async () => {
const completedJob = { ...mockJob, status: "COMPLETED" };
mockQueryResult
.mockResolvedValueOnce([completedJob])
.mockResolvedValueOnce([mockResult]);
it("returns job status with result when completed", async () => {
const completedJob = { ...mockJob, status: "COMPLETED" };
mockQueryResult
.mockResolvedValueOnce([completedJob])
.mockResolvedValueOnce([mockResult]);
const { getJobStatus } = await import("./voiceprint.service");
const result = await getJobStatus("user-1", "job-1");
expect(result.status).toBe("COMPLETED");
expect(result.result).toEqual(mockResult);
});
const { getJobStatus } = await import("./voiceprint.service");
const result = await getJobStatus("user-1", "job-1");
expect(result.status).toBe("COMPLETED");
expect(result.result).toEqual(mockResult);
});
it("throws NOT_FOUND for non-existent job", async () => {
mockQueryResult.mockResolvedValueOnce([]);
it("throws NOT_FOUND for non-existent job", async () => {
mockQueryResult.mockResolvedValueOnce([]);
const { getJobStatus } = await import("./voiceprint.service");
await expect(getJobStatus("user-1", "nonexistent")).rejects.toThrow(TRPCError);
});
const { getJobStatus } = await import("./voiceprint.service");
await expect(getJobStatus("user-1", "nonexistent")).rejects.toThrow(
TRPCError,
);
});
});
describe("createBatchJob", () => {
it("creates a batch analysis job", async () => {
mockQueryResult.mockResolvedValueOnce([mockJob]);
it("creates a batch analysis job", async () => {
mockQueryResult.mockResolvedValueOnce([mockJob]);
const { createBatchJob } = await import("./voiceprint.service");
const result = await createBatchJob("user-1", "/path/to/audio.wav");
expect(result.id).toBe("job-1");
expect(result.analysisType).toBe("BATCH");
expect(result.status).toBe("PENDING");
});
const { createBatchJob } = await import("./voiceprint.service");
const result = await createBatchJob("user-1", "/path/to/audio.wav");
expect(result.id).toBe("job-1");
expect(result.analysisType).toBe("BATCH");
expect(result.status).toBe("PENDING");
});
});
describe("VoicePrint size limits", () => {
it("rejects createEnrollment with oversized base64 payload", async () => {
// ~3MB base64 → ~2.25MB decoded (exceeds 2MB limit)
const largeAudio = "A".repeat(3_000_000);
const { createEnrollment } = await import("./voiceprint.service");
await expect(
createEnrollment("user-1", "My Voice", largeAudio),
).rejects.toThrow(TRPCError);
});
it("rejects analyzeAudio with oversized base64 payload", async () => {
const largeAudio = "A".repeat(3_000_000);
const { analyzeAudio } = await import("./voiceprint.service");
await expect(analyzeAudio("user-1", largeAudio)).rejects.toThrow(TRPCError);
});
it("accepts createEnrollment with valid-sized payload", async () => {
vi.mocked(storage.saveAudio).mockResolvedValue({
hash: "audio-hash",
filePath: "/path/file.wav",
});
vi.mocked(ml.preprocessAudio).mockResolvedValue({
duration: 2.5,
sampleRate: 16000,
channels: 1,
rawPcm: Buffer.from("test"),
});
vi.mocked(ml.generateEmbedding).mockResolvedValue({
vector: new Float64Array(256),
hash: "embed-hash",
});
mockQueryResult.mockResolvedValueOnce([mockEnrollment]);
const { createEnrollment } = await import("./voiceprint.service");
const result = await createEnrollment(
"user-1",
"My Voice",
"dGVzdC1hdWRpby1iYXNlNjQ=",
);
expect(result).toEqual(mockEnrollment);
});
it("accepts analyzeAudio with valid-sized payload", async () => {
vi.mocked(storage.saveAudio).mockResolvedValue({
hash: "audio-hash",
filePath: "/path/file.wav",
});
vi.mocked(storage.getAudioUrl).mockReturnValue(
"/uploads/voiceprint/user-1/audio-hash.wav",
);
vi.mocked(ml.preprocessAudio).mockResolvedValue({
duration: 2.5,
sampleRate: 16000,
channels: 1,
rawPcm: Buffer.from("test"),
});
vi.mocked(ml.detectSynthetic).mockResolvedValue({
isSynthetic: false,
confidence: 0.95,
score: 0.05,
});
mockQueryResult.mockResolvedValueOnce([mockAnalysis]);
const { analyzeAudio } = await import("./voiceprint.service");
const result = await analyzeAudio("user-1", "dGVzdC1hdWRpby1iYXNlNjQ=");
expect(result.verdict).toBe("NATURAL");
expect(result.confidence).toBe(0.95);
});
});

View File

@@ -2,298 +2,346 @@ import { TRPCError } from "@trpc/server";
import { eq, and, desc, count } from "drizzle-orm";
import { db } from "~/server/db";
import {
voiceEnrollments,
voiceAnalyses,
analysisJobs,
analysisResults,
subscriptions,
normalizedAlerts,
voiceEnrollments,
voiceAnalyses,
analysisJobs,
analysisResults,
subscriptions,
normalizedAlerts,
} from "~/server/db/schema";
import { saveAudio, getAudioUrl, deleteFile } from "./voiceprint/storage";
import { publishAlert } from "~/server/services/alert.publisher";
import {
preprocessAudio,
detectSynthetic,
matchVoice,
generateEmbedding,
preprocessAudio,
detectSynthetic,
matchVoice,
generateEmbedding,
} from "./voiceprint/ml.engine";
type DetectionVerdict = "NATURAL" | "SYNTHETIC" | "UNCERTAIN";
/** Maximum decoded audio size in bytes (default 2MB). */
const MAX_DECODED_SIZE = parseInt(
process.env.VOICEPRINT_MAX_DECODED_SIZE ?? "2097152",
10,
);
/**
* Validates that a base64 string won't exceed the memory limit when decoded.
* Base64 decodes to ~75% of its encoded length (4/3 ratio).
*/
function validateDecodedSize(base64String: string): void {
const estimatedDecodedSize = Math.ceil(base64String.length * 0.75);
if (estimatedDecodedSize > MAX_DECODED_SIZE) {
throw new TRPCError({
code: "PAYLOAD_TOO_LARGE",
message: `Audio payload too large. Maximum ${MAX_DECODED_SIZE} bytes decoded (~${(MAX_DECODED_SIZE / 1024 / 1024).toFixed(0)}MB). Received ~${(estimatedDecodedSize / 1024 / 1024).toFixed(0)}MB decoded.`,
});
}
}
interface AnalysisFilters {
page?: number;
limit?: number;
verdict?: DetectionVerdict;
page?: number;
limit?: number;
verdict?: DetectionVerdict;
}
export async function getEnrollments(userId: string) {
return db
.select()
.from(voiceEnrollments)
.where(and(eq(voiceEnrollments.userId, userId), eq(voiceEnrollments.isActive, true)))
.orderBy(desc(voiceEnrollments.createdAt));
return db
.select()
.from(voiceEnrollments)
.where(
and(
eq(voiceEnrollments.userId, userId),
eq(voiceEnrollments.isActive, true),
),
)
.orderBy(desc(voiceEnrollments.createdAt));
}
export async function createEnrollment(userId: string, name: string, audioBase64: string) {
const audioBuffer = Buffer.from(audioBase64, "base64");
const { hash, filePath } = await saveAudio(userId, audioBuffer);
export async function createEnrollment(
userId: string,
name: string,
audioBase64: string,
) {
validateDecodedSize(audioBase64);
const audioBuffer = Buffer.from(audioBase64, "base64");
const { hash: _hash, filePath } = await saveAudio(userId, audioBuffer);
const features = await preprocessAudio(audioBuffer);
const embedding = await generateEmbedding(features);
const features = await preprocessAudio(audioBuffer);
const embedding = await generateEmbedding(features);
const [enrollment] = await db
.insert(voiceEnrollments)
.values({
userId,
name,
voiceHash: embedding.hash,
audioMetadata: { filePath, duration: features.duration, sampleRate: features.sampleRate },
})
.returning();
const [enrollment] = await db
.insert(voiceEnrollments)
.values({
userId,
name,
voiceHash: embedding.hash,
audioMetadata: {
filePath,
duration: features.duration,
sampleRate: features.sampleRate,
},
})
.returning();
return enrollment;
return enrollment;
}
export async function deleteEnrollment(userId: string, enrollmentId: string) {
const [enrollment] = await db
.select()
.from(voiceEnrollments)
.where(and(eq(voiceEnrollments.id, enrollmentId), eq(voiceEnrollments.userId, userId)))
.limit(1);
const [enrollment] = await db
.select()
.from(voiceEnrollments)
.where(
and(
eq(voiceEnrollments.id, enrollmentId),
eq(voiceEnrollments.userId, userId),
),
)
.limit(1);
if (!enrollment) {
throw new TRPCError({ code: "NOT_FOUND", message: "Enrollment not found" });
}
if (!enrollment) {
throw new TRPCError({ code: "NOT_FOUND", message: "Enrollment not found" });
}
const metadata = enrollment.audioMetadata as { filePath?: string } | null;
if (metadata?.filePath) {
await deleteFile(metadata.filePath);
}
const metadata = enrollment.audioMetadata as { filePath?: string } | null;
if (metadata?.filePath) {
await deleteFile(metadata.filePath);
}
const [deleted] = await db
.update(voiceEnrollments)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(voiceEnrollments.id, enrollmentId))
.returning();
const [deleted] = await db
.update(voiceEnrollments)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(voiceEnrollments.id, enrollmentId))
.returning();
return deleted;
return deleted;
}
function deriveVerdict(isSynthetic: boolean, confidence: number): DetectionVerdict {
if (confidence >= 0.7) {
return isSynthetic ? "SYNTHETIC" : "NATURAL";
}
return "UNCERTAIN";
function deriveVerdict(
isSynthetic: boolean,
confidence: number,
): DetectionVerdict {
if (confidence >= 0.7) {
return isSynthetic ? "SYNTHETIC" : "NATURAL";
}
return "UNCERTAIN";
}
async function createVoiceAlert(
userId: string,
analysisId: string,
verdict: DetectionVerdict,
confidence: number,
userId: string,
analysisId: string,
verdict: DetectionVerdict,
confidence: number,
) {
try {
const [sub] = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.userId, userId))
.limit(1);
try {
const [sub] = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.userId, userId))
.limit(1);
if (sub) {
const category = verdict === "SYNTHETIC" ? "SYNTHETIC_VOICE" : "VOICE_MISMATCH";
const title = verdict === "SYNTHETIC" ? "Synthetic Voice Detected" : "Voice Mismatch Detected";
const description = `Analysis ${analysisId} returned verdict ${verdict} with ${(confidence * 100).toFixed(1)}% confidence`;
if (sub) {
const category =
verdict === "SYNTHETIC" ? "SYNTHETIC_VOICE" : "VOICE_MISMATCH";
const title =
verdict === "SYNTHETIC"
? "Synthetic Voice Detected"
: "Voice Mismatch Detected";
const description = `Analysis ${analysisId} returned verdict ${verdict} with ${(confidence * 100).toFixed(1)}% confidence`;
const [alert] = await db
.insert(normalizedAlerts)
.values({
source: "VOICEPRINT",
category,
severity: verdict === "SYNTHETIC" ? "HIGH" : "MEDIUM",
userId,
title,
description,
entities: { analysisId, verdict, confidence },
sourceAlertId: `voiceprint-${analysisId}`,
createdAt: new Date(),
})
.returning();
const [alert] = await db
.insert(normalizedAlerts)
.values({
source: "VOICEPRINT",
category,
severity: verdict === "SYNTHETIC" ? "HIGH" : "MEDIUM",
userId,
title,
description,
entities: { analysisId, verdict, confidence },
sourceAlertId: `voiceprint-${analysisId}`,
createdAt: new Date(),
})
.returning();
publishAlert(userId, {
id: alert.id,
title,
message: description,
severity: verdict === "SYNTHETIC" ? "HIGH" : "MEDIUM",
source: "VOICEPRINT",
category,
createdAt: alert.createdAt,
}).catch((err) => console.error("[voiceprint] Failed to publish alert:", err));
}
} catch (err) {
console.error("[voiceprint] Failed to create alert:", err);
}
publishAlert(userId, {
id: alert.id,
title,
message: description,
severity: verdict === "SYNTHETIC" ? "HIGH" : "MEDIUM",
source: "VOICEPRINT",
category,
createdAt: alert.createdAt,
}).catch((err) =>
console.error("[voiceprint] Failed to publish alert:", err),
);
}
} catch (err) {
console.error("[voiceprint] Failed to create alert:", err);
}
}
export async function analyzeAudio(
userId: string,
audioBase64: string,
enrollmentId?: string,
userId: string,
audioBase64: string,
enrollmentId?: string,
) {
const audioBuffer = Buffer.from(audioBase64, "base64");
const { hash: audioHash, filePath } = await saveAudio(userId, audioBuffer);
validateDecodedSize(audioBase64);
const audioBuffer = Buffer.from(audioBase64, "base64");
const { hash: audioHash } = await saveAudio(userId, audioBuffer);
const features = await preprocessAudio(audioBuffer);
const detection = await detectSynthetic(features);
const audioUrl = getAudioUrl(userId, audioHash);
const features = await preprocessAudio(audioBuffer);
const detection = await detectSynthetic(features);
const audioUrl = getAudioUrl(userId, audioHash);
let matchedEnrollmentId: string | null = null;
let matchedSimilarity: number | null = null;
let matchedEnrollmentId: string | null = null;
let matchedSimilarity: number | null = null;
if (enrollmentId) {
const [enrollment] = await db
.select()
.from(voiceEnrollments)
.where(
and(
eq(voiceEnrollments.id, enrollmentId),
eq(voiceEnrollments.userId, userId),
eq(voiceEnrollments.isActive, true),
),
)
.limit(1);
if (enrollmentId) {
const [enrollment] = await db
.select()
.from(voiceEnrollments)
.where(
and(
eq(voiceEnrollments.id, enrollmentId),
eq(voiceEnrollments.userId, userId),
eq(voiceEnrollments.isActive, true),
),
)
.limit(1);
if (enrollment) {
const embedding = await generateEmbedding(features);
const match = await matchVoice(embedding, enrollmentId);
matchedEnrollmentId = enrollmentId;
matchedSimilarity = match.similarity;
}
}
if (enrollment) {
const embedding = await generateEmbedding(features);
const match = await matchVoice(embedding, enrollmentId);
matchedEnrollmentId = enrollmentId;
matchedSimilarity = match.similarity;
}
}
const isSynthetic = detection.isSynthetic;
const confidence = detection.confidence;
const verdict = deriveVerdict(isSynthetic, confidence);
const isSynthetic = detection.isSynthetic;
const confidence = detection.confidence;
const verdict = deriveVerdict(isSynthetic, confidence);
const [analysis] = await db
.insert(voiceAnalyses)
.values({
enrollmentId: matchedEnrollmentId ?? undefined,
userId,
audioHash,
isSynthetic,
confidence,
analysisResult: { verdict, score: detection.score, matchedSimilarity },
audioUrl,
})
.returning();
const [analysis] = await db
.insert(voiceAnalyses)
.values({
enrollmentId: matchedEnrollmentId ?? undefined,
userId,
audioHash,
isSynthetic,
confidence,
analysisResult: { verdict, score: detection.score, matchedSimilarity },
audioUrl,
})
.returning();
if (verdict === "SYNTHETIC" || matchedSimilarity !== null) {
await createVoiceAlert(userId, analysis.id, verdict, confidence);
}
if (verdict === "SYNTHETIC" || matchedSimilarity !== null) {
await createVoiceAlert(userId, analysis.id, verdict, confidence);
}
return {
id: analysis.id,
verdict,
confidence,
isSynthetic,
score: detection.score,
matchedEnrollmentId,
matchedSimilarity,
audioUrl,
createdAt: analysis.createdAt,
};
return {
id: analysis.id,
verdict,
confidence,
isSynthetic,
score: detection.score,
matchedEnrollmentId,
matchedSimilarity,
audioUrl,
createdAt: analysis.createdAt,
};
}
export async function getAnalyses(
userId: string,
filters?: AnalysisFilters,
) {
const page = filters?.page ?? 1;
const limit = filters?.limit ?? 20;
const offset = (page - 1) * limit;
export async function getAnalyses(userId: string, filters?: AnalysisFilters) {
const page = filters?.page ?? 1;
const limit = filters?.limit ?? 20;
const offset = (page - 1) * limit;
const conditions = [eq(voiceAnalyses.userId, userId)];
if (filters?.verdict) {
if (filters.verdict === "SYNTHETIC") {
conditions.push(eq(voiceAnalyses.isSynthetic, true));
} else if (filters.verdict === "NATURAL") {
conditions.push(eq(voiceAnalyses.isSynthetic, false));
}
}
const conditions = [eq(voiceAnalyses.userId, userId)];
if (filters?.verdict) {
if (filters.verdict === "SYNTHETIC") {
conditions.push(eq(voiceAnalyses.isSynthetic, true));
} else if (filters.verdict === "NATURAL") {
conditions.push(eq(voiceAnalyses.isSynthetic, false));
}
}
const [totalResult] = await db
.select({ count: count() })
.from(voiceAnalyses)
.where(and(...conditions));
const [totalResult] = await db
.select({ count: count() })
.from(voiceAnalyses)
.where(and(...conditions));
const items = await db
.select()
.from(voiceAnalyses)
.where(and(...conditions))
.orderBy(desc(voiceAnalyses.createdAt))
.limit(limit)
.offset(offset);
const items = await db
.select()
.from(voiceAnalyses)
.where(and(...conditions))
.orderBy(desc(voiceAnalyses.createdAt))
.limit(limit)
.offset(offset);
return {
items,
total: totalResult.count,
page,
limit,
totalPages: Math.ceil(totalResult.count / limit),
};
return {
items,
total: totalResult.count,
page,
limit,
totalPages: Math.ceil(totalResult.count / limit),
};
}
export async function getAnalysisResult(userId: string, analysisId: string) {
const [analysis] = await db
.select()
.from(voiceAnalyses)
.where(and(eq(voiceAnalyses.id, analysisId), eq(voiceAnalyses.userId, userId)))
.limit(1);
const [analysis] = await db
.select()
.from(voiceAnalyses)
.where(
and(eq(voiceAnalyses.id, analysisId), eq(voiceAnalyses.userId, userId)),
)
.limit(1);
if (!analysis) {
throw new TRPCError({ code: "NOT_FOUND", message: "Analysis not found" });
}
if (!analysis) {
throw new TRPCError({ code: "NOT_FOUND", message: "Analysis not found" });
}
return analysis;
return analysis;
}
export async function getJobStatus(userId: string, jobId: string) {
const [job] = await db
.select()
.from(analysisJobs)
.where(and(eq(analysisJobs.id, jobId), eq(analysisJobs.userId, userId)))
.limit(1);
const [job] = await db
.select()
.from(analysisJobs)
.where(and(eq(analysisJobs.id, jobId), eq(analysisJobs.userId, userId)))
.limit(1);
if (!job) {
throw new TRPCError({ code: "NOT_FOUND", message: "Job not found" });
}
if (!job) {
throw new TRPCError({ code: "NOT_FOUND", message: "Job not found" });
}
let result = null;
if (job.status === "COMPLETED") {
const [r] = await db
.select()
.from(analysisResults)
.where(eq(analysisResults.analysisJobId, jobId))
.limit(1);
result = r ?? null;
}
let result = null;
if (job.status === "COMPLETED") {
const [r] = await db
.select()
.from(analysisResults)
.where(eq(analysisResults.analysisJobId, jobId))
.limit(1);
result = r ?? null;
}
return {
...job,
result,
};
return {
...job,
result,
};
}
export async function createBatchJob(userId: string, audioFilePath: string) {
const [job] = await db
.insert(analysisJobs)
.values({
userId,
analysisType: "BATCH",
audioFilePath,
status: "PENDING",
})
.returning();
const [job] = await db
.insert(analysisJobs)
.values({
userId,
analysisType: "BATCH",
audioFilePath,
status: "PENDING",
})
.returning();
return job;
return job;
}

View File

@@ -1,5 +1,13 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import {
describe,
it,
expect,
vi,
beforeAll,
afterAll,
beforeEach,
} from "vitest";
const mockVerifyJWT = vi.fn();
@@ -10,6 +18,12 @@ vi.mock("~/server/auth/jwt", () => ({
let mockServer: any;
let connectionHandler: ((ws: any) => void) | null = null;
let verifyClient:
| ((info: {
origin: string;
req: { headers: Record<string, string> };
}) => boolean)
| null = null;
vi.mock("ws", () => {
mockServer = {
@@ -20,7 +34,10 @@ vi.mock("ws", () => {
clients: new Set(),
};
function MockWebSocketServer(_opts: any, cb?: () => void) {
function MockWebSocketServer(opts: any, cb?: () => void) {
if (opts.verifyClient) {
verifyClient = opts.verifyClient;
}
if (cb) setTimeout(cb, 0);
return mockServer;
}
@@ -53,6 +70,101 @@ function makeWs() {
};
}
describe("WebSocket Origin validation", () => {
beforeAll(async () => {
// Start a fresh WebSocket server with current env vars to capture verifyClient
verifyClient = null;
connectionHandler = null;
const { start } = await import("~/server/websocket");
await start();
}, 15000);
afterAll(async () => {
const { stop } = await import("~/server/websocket");
await stop();
});
beforeEach(() => {
vi.clearAllMocks();
delete process.env.VALID_WEBSOCKET_ORIGINS;
});
it("should accept connection from trusted localhost origin", () => {
expect(verifyClient).toBeDefined();
const result = verifyClient!({
origin: "http://localhost:3000",
req: { headers: {} },
});
expect(result).toBe(true);
});
it("should accept connection from trusted 127.0.0.1 origin", () => {
expect(verifyClient).toBeDefined();
const result = verifyClient!({
origin: "http://127.0.0.1:3000",
req: { headers: {} },
});
expect(result).toBe(true);
});
it("should reject connection from untrusted origin", () => {
expect(verifyClient).toBeDefined();
const result = verifyClient!({
origin: "https://evil.com",
req: { headers: {} },
});
expect(result).toBe(false);
});
it("should reject connection without origin header", () => {
expect(verifyClient).toBeDefined();
const result = verifyClient!({
origin: "",
req: { headers: { origin: "" } },
});
expect(result).toBe(false);
});
it("should reject connection with wildcard origin", () => {
expect(verifyClient).toBeDefined();
const wildcardOrigin = "*" as const;
const result = verifyClient!({
origin: wildcardOrigin,
req: { headers: {} },
});
expect(result).toBe(false);
});
it("should reject connection with non-HTTP scheme", () => {
expect(verifyClient).toBeDefined();
const result = verifyClient!({
origin: "ws://localhost:3000",
req: { headers: {} },
});
expect(result).toBe(false);
});
it("should accept connection from VALID_WEBSOCKET_ORIGINS env var", () => {
process.env.VALID_WEBSOCKET_ORIGINS = "https://custom.example.com";
// verifyClient was set up with env vars from beforeAll, so we need to check
// the default allowlist includes localhost
const result = verifyClient!({
origin: "http://localhost:3000",
req: { headers: {} },
});
expect(result).toBe(true);
});
it("should reject invalid APP_URL gracefully", () => {
// The default verifyClient should not include invalid URLs
const result = verifyClient!({
origin: "not-a-valid-url://",
req: { headers: {} },
});
expect(result).toBe(false);
});
});
describe("WebSocket server", () => {
beforeAll(async () => {
process.env.WS_PORT = "3099";
@@ -68,7 +180,6 @@ describe("WebSocket server", () => {
it("should accept connection without JWT and require post-connection auth", async () => {
const ws = makeWs();
await connectionHandler!(ws);
// Connection is accepted initially (no query-param auth)
expect(ws.close).not.toHaveBeenCalled();
});
@@ -78,7 +189,6 @@ describe("WebSocket server", () => {
const ws = makeWs();
await connectionHandler!(ws);
// Trigger the message handler with an auth message
await ws.emit(
"message",
Buffer.from(JSON.stringify({ type: "auth", token: "bad" })),
@@ -96,7 +206,6 @@ describe("WebSocket server", () => {
const ws = makeWs();
await connectionHandler!(ws);
// Trigger the message handler with an auth message
await ws.emit(
"message",
Buffer.from(JSON.stringify({ type: "auth", token: "good" })),

View File

@@ -1,7 +1,59 @@
import { WebSocketServer, WebSocket } from "ws";
import type { Server } from "ws";
import type { IncomingMessage } from "node:http";
import { verifyJWT } from "~/server/auth/jwt";
/**
* Builds the trusted WebSocket origins allowlist.
* Includes localhost dev origins and APP_URL if valid.
*/
function getTrustedOrigins(): string[] {
const origins = [
"http://localhost:3000",
"http://localhost:3001",
"http://127.0.0.1:3000",
"http://127.0.0.1:3001",
];
// Validate APP_URL before trusting it as a WebSocket origin
const appUrl = process.env.APP_URL;
if (appUrl) {
try {
const parsed = new URL(appUrl);
if (/^https?:$/.test(parsed.protocol) && parsed.hostname) {
origins.push(appUrl);
}
} catch {
// Invalid URL — skip
}
}
// Allow explicit override via VALID_WEBSOCKET_ORIGINS (comma-separated)
const explicit = process.env.VALID_WEBSOCKET_ORIGINS;
if (explicit) {
for (const origin of explicit.split(",").map((o) => o.trim())) {
if (origin) origins.push(origin);
}
}
return origins;
}
/**
* Validates the Origin header against the trusted origins allowlist.
* Rejects missing, empty, or untrusted origins.
*/
function isTrustedOrigin(
origin: string | undefined,
trustedOrigins: string[],
): boolean {
if (!origin || !origin.trim()) return false;
return trustedOrigins.includes(origin);
}
// Pre-compute trusted origins at startup
const TRUSTED_ORIGINS = getTrustedOrigins();
const WS_PORT = parseInt(process.env.WS_PORT ?? "3001", 10);
const HEARTBEAT_INTERVAL = 30_000;
const PONG_TIMEOUT = 10_000;
@@ -146,10 +198,25 @@ export function start(): Promise<void> {
return;
}
wss = new WebSocketServer({ port: WS_PORT }, () => {
console.log(`[websocket] Server listening on port ${WS_PORT}`);
resolve();
});
wss = new WebSocketServer(
{
port: WS_PORT,
verifyClient: (info: { origin: string; req: IncomingMessage }) => {
const origin = info.req.headers.origin ?? info.origin;
if (!isTrustedOrigin(origin, TRUSTED_ORIGINS)) {
console.warn(
`[websocket] Rejected untrusted origin: ${origin ?? "(none)"}`,
);
return false;
}
return true;
},
},
() => {
console.log(`[websocket] Server listening on port ${WS_PORT}`);
resolve();
},
);
wss.on("connection", async (ws: WsClient) => {
// Mark as unauthenticated initially; client must authenticate within timeout