security sweep
This commit is contained in:
1
web/.pi-lens/cache/review-graph.json
vendored
Normal file
1
web/.pi-lens/cache/review-graph.json
vendored
Normal file
File diff suppressed because one or more lines are too long
477
web/.pi-lens/cache/typescript-rules-v2.json
vendored
Normal file
477
web/.pi-lens/cache/typescript-rules-v2.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
4
web/.pi-lens/worklog.jsonl
Normal file
4
web/.pi-lens/worklog.jsonl
Normal 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}
|
||||
64
web/src/server/api/schemas/voiceprint.test.ts
Normal file
64
web/src/server/api/schemas/voiceprint.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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)]),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" })),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user