@@ -6,6 +6,7 @@ use crate::analyzer::runtime::{RuntimeDetector, PackageManager};
66use crate :: analyzer:: tool_management:: ToolDetector ;
77use crate :: analyzer:: vulnerability:: { VulnerableDependency , VulnerabilityError , VulnerabilityInfo , VulnerabilitySeverity } ;
88use super :: MutableLanguageVulnerabilityChecker ;
9+ use serde_json:: Value as JsonValue ;
910
1011pub struct JavaScriptVulnerabilityChecker {
1112 tool_detector : ToolDetector ,
@@ -132,39 +133,67 @@ impl JavaScriptVulnerabilityChecker {
132133 warn ! ( "yarn not found, skipping yarn audit" ) ;
133134 return Ok ( None ) ;
134135 }
135-
136+
136137 info ! ( "Executing yarn audit in {}" , project_path. display( ) ) ;
137-
138- // Execute yarn audit --json
139- let output = Command :: new ( "yarn" )
140- . args ( & [ "audit" , "--json" ] )
141- . current_dir ( project_path)
142- . output ( )
143- . map_err ( |e| VulnerabilityError :: CommandError (
144- format ! ( "Failed to run yarn audit: {}" , e)
145- ) ) ?;
146-
147- // yarn audit behavior: returns 0 even when vulnerabilities are found
148- // Non-zero exit code indicates an actual error
149- if !output. status . success ( ) && output. stdout . is_empty ( ) {
150- return Err ( VulnerabilityError :: CommandError (
151- format ! ( "yarn audit failed with exit code {}: {}" ,
152- output. status. code( ) . unwrap_or( -1 ) ,
153- String :: from_utf8_lossy( & output. stderr) )
154- ) ) ;
155- }
156-
157- if output. stdout . is_empty ( ) {
158- return Ok ( None ) ;
138+
139+ // Strategy:
140+ // 1) Try Yarn Berry command: yarn npm audit --json (Yarn v2+)
141+ // 2) Fallback to classic: yarn audit --json (Yarn v1)
142+ // 3) Handle both single-JSON and line-delimited JSON formats
143+ let candidates: Vec < Vec < & str > > = vec ! [
144+ vec![ "npm" , "audit" , "--json" ] ,
145+ vec![ "audit" , "--json" ] ,
146+ ] ;
147+
148+ for args in candidates {
149+ let output = match Command :: new ( "yarn" ) . args ( & args) . current_dir ( project_path) . output ( ) {
150+ Ok ( o) => o,
151+ Err ( e) => {
152+ warn ! ( "Failed to run 'yarn {}': {}" , args. join( " " ) , e) ;
153+ continue ;
154+ }
155+ } ;
156+
157+ // Non-zero with empty stdout is a hard failure; otherwise attempt to parse what we got
158+ if !output. status . success ( ) && output. stdout . is_empty ( ) {
159+ warn ! (
160+ "yarn {} failed (code {:?}): {}" ,
161+ args. join( " " ) ,
162+ output. status. code( ) ,
163+ String :: from_utf8_lossy( & output. stderr)
164+ ) ;
165+ continue ;
166+ }
167+
168+ if output. stdout . is_empty ( ) {
169+ // Nothing to parse
170+ continue ;
171+ }
172+
173+ // Try to parse as a single JSON blob first (be tolerant of banners/noise)
174+ if let Some ( audit_data) = try_parse_json_tolerant ( & output. stdout ) {
175+ // If it looks like NPM's shape (common for `yarn npm audit`), reuse NPM parser
176+ if audit_data. get ( "vulnerabilities" ) . is_some ( ) {
177+ if let Ok ( res) = self . parse_npm_audit_output ( & audit_data, dependencies) {
178+ if res. is_some ( ) { return Ok ( res) ; }
179+ }
180+ }
181+
182+ // Otherwise try Yarn object shape
183+ if let Ok ( res) = self . parse_yarn_audit_output ( & audit_data, dependencies) {
184+ if res. is_some ( ) { return Ok ( res) ; }
185+ }
186+ } else {
187+ // If not a single JSON, try line-delimited JSON format (Yarn v1 classic)
188+ if let Ok ( res) = self . parse_yarn_streaming_audit_lines ( & output. stdout , dependencies) {
189+ if res. is_some ( ) { return Ok ( res) ; }
190+ }
191+ }
159192 }
160-
161- // Parse yarn audit output
162- let audit_data: serde_json:: Value = serde_json:: from_slice ( & output. stdout )
163- . map_err ( |e| VulnerabilityError :: ParseError (
164- format ! ( "Failed to parse yarn audit output: {}" , e)
165- ) ) ?;
166-
167- self . parse_yarn_audit_output ( & audit_data, dependencies)
193+
194+ // If we got here, we couldn't parse Yarn output; don't fail the whole scan
195+ warn ! ( "Unable to parse yarn audit output; skipping Yarn results" ) ;
196+ Ok ( None )
168197 }
169198
170199 fn execute_pnpm_audit (
@@ -389,51 +418,16 @@ impl JavaScriptVulnerabilityChecker {
389418 let mut vulnerable_deps: Vec < VulnerableDependency > = Vec :: new ( ) ;
390419
391420 // Yarn audit JSON structure parsing
392- // Yarn returns audit data in a different format than npm
421+ // Shape 1: Single object with { data: { advisories: { id: {...} } } } (rare)
393422 if let Some ( data) = audit_data. get ( "data" ) . and_then ( |d| d. as_object ( ) ) {
394423 if let Some ( advisories) = data. get ( "advisories" ) . and_then ( |a| a. as_object ( ) ) {
395424 for ( advisory_id, advisory) in advisories {
396425 if let Some ( advisory_obj) = advisory. as_object ( ) {
397- let package_name = advisory_obj. get ( "module_name" ) . and_then ( |n| n. as_str ( ) )
398- . unwrap_or ( "" ) . to_string ( ) ;
399-
400- // Find matching dependency
426+ let package_name = advisory_obj. get ( "module_name" ) . and_then ( |n| n. as_str ( ) ) . unwrap_or ( "" ) . to_string ( ) ;
401427 if let Some ( dep) = dependencies. iter ( ) . find ( |d| d. name == package_name) {
402- let id = advisory_id. clone ( ) ;
403- let title = advisory_obj. get ( "title" ) . and_then ( |t| t. as_str ( ) )
404- . unwrap_or ( "Unknown vulnerability" ) . to_string ( ) ;
405- let description = advisory_obj. get ( "overview" ) . and_then ( |o| o. as_str ( ) )
406- . unwrap_or ( "" ) . to_string ( ) ;
407- let severity = self . parse_severity ( advisory_obj. get ( "severity" ) . and_then ( |s| s. as_str ( ) ) ) ;
408- let vulnerable_versions = advisory_obj. get ( "vulnerable_versions" ) . and_then ( |v| v. as_str ( ) )
409- . unwrap_or ( "*" ) . to_string ( ) ;
410-
411- let cve = advisory_obj. get ( "cves" ) . and_then ( |c| c. as_array ( ) )
412- . and_then ( |arr| arr. first ( ) )
413- . and_then ( |v| v. as_str ( ) )
414- . map ( |s| s. to_string ( ) ) ;
415-
416- let url = advisory_obj. get ( "url" ) . and_then ( |u| u. as_str ( ) )
417- . map ( |s| s. to_string ( ) ) ;
418-
419- let vuln_info = VulnerabilityInfo {
420- id,
421- vuln_type : "security" . to_string ( ) , // Security vulnerability
422- severity,
423- title,
424- description,
425- cve,
426- ghsa : url. clone ( ) . filter ( |u| u. contains ( "GHSA" ) ) . map ( |u| {
427- u. split ( '/' ) . last ( ) . unwrap_or ( & u) . to_string ( )
428- } ) ,
429- affected_versions : vulnerable_versions,
430- patched_versions : advisory_obj. get ( "patched_versions" ) . and_then ( |p| p. as_str ( ) ) . map ( |s| s. to_string ( ) ) ,
431- published_date : None ,
432- references : url. map ( |u| vec ! [ u] ) . unwrap_or_default ( ) ,
433- } ;
434-
435- // Check if we already have this dependency
436- if let Some ( existing) = vulnerable_deps. iter_mut ( ) . find ( |vuln_dep| vuln_dep. name == package_name) {
428+ let ( vuln_info, pkg_name) = self . extract_yarn_advisory ( advisory_id, advisory_obj) ;
429+ // Use dep.name to keep version/source consistent
430+ if let Some ( existing) = vulnerable_deps. iter_mut ( ) . find ( |v| v. name == pkg_name) {
437431 existing. vulnerabilities . push ( vuln_info) ;
438432 } else {
439433 vulnerable_deps. push ( VulnerableDependency {
@@ -455,14 +449,127 @@ impl JavaScriptVulnerabilityChecker {
455449 Ok ( Some ( vulnerable_deps) )
456450 }
457451 }
452+
453+ // Parse Yarn classic line-delimited JSON output
454+ fn parse_yarn_streaming_audit_lines (
455+ & self ,
456+ stdout : & [ u8 ] ,
457+ dependencies : & [ DependencyInfo ] ,
458+ ) -> Result < Option < Vec < VulnerableDependency > > , VulnerabilityError > {
459+ let mut vulnerable_deps: Vec < VulnerableDependency > = Vec :: new ( ) ;
460+ let text = String :: from_utf8_lossy ( stdout) ;
461+ for line in text. lines ( ) {
462+ let line = line. trim ( ) ;
463+ if line. is_empty ( ) { continue ; }
464+ if let Ok ( json) = serde_json:: from_str :: < serde_json:: Value > ( line) {
465+ if json. get ( "type" ) . and_then ( |t| t. as_str ( ) ) == Some ( "auditAdvisory" ) {
466+ if let Some ( advisory_obj) = json
467+ . get ( "data" )
468+ . and_then ( |d| d. get ( "advisory" ) )
469+ . and_then ( |a| a. as_object ( ) )
470+ {
471+ let package_name = advisory_obj
472+ . get ( "module_name" )
473+ . and_then ( |n| n. as_str ( ) )
474+ . unwrap_or ( "" )
475+ . to_string ( ) ;
476+ if let Some ( dep) = dependencies. iter ( ) . find ( |d| d. name == package_name) {
477+ let ( vuln_info, pkg_name) = self . extract_yarn_advisory (
478+ advisory_obj
479+ . get ( "id" )
480+ . and_then ( |v| v. as_i64 ( ) )
481+ . map ( |v| v. to_string ( ) )
482+ . unwrap_or_else ( || "unknown" . to_string ( ) )
483+ . as_str ( ) ,
484+ advisory_obj,
485+ ) ;
486+
487+ if let Some ( existing) = vulnerable_deps. iter_mut ( ) . find ( |v| v. name == pkg_name) {
488+ existing. vulnerabilities . push ( vuln_info) ;
489+ } else {
490+ vulnerable_deps. push ( VulnerableDependency {
491+ name : dep. name . clone ( ) ,
492+ version : dep. version . clone ( ) ,
493+ language : Language :: JavaScript ,
494+ vulnerabilities : vec ! [ vuln_info] ,
495+ } ) ;
496+ }
497+ }
498+ }
499+ }
500+ }
501+ }
502+
503+ if vulnerable_deps. is_empty ( ) { Ok ( None ) } else { Ok ( Some ( vulnerable_deps) ) }
504+ }
505+
506+ fn extract_yarn_advisory < ' a > (
507+ & self ,
508+ advisory_id : impl Into < String > ,
509+ advisory_obj : & serde_json:: Map < String , serde_json:: Value > ,
510+ ) -> ( VulnerabilityInfo , String ) {
511+ let package_name = advisory_obj
512+ . get ( "module_name" )
513+ . and_then ( |n| n. as_str ( ) )
514+ . unwrap_or ( "" )
515+ . to_string ( ) ;
516+ let id = advisory_id. into ( ) ;
517+ let title = advisory_obj. get ( "title" ) . and_then ( |t| t. as_str ( ) ) . unwrap_or ( "Unknown vulnerability" ) . to_string ( ) ;
518+ let description = advisory_obj. get ( "overview" ) . and_then ( |o| o. as_str ( ) ) . unwrap_or ( "" ) . to_string ( ) ;
519+ let severity = self . parse_severity ( advisory_obj. get ( "severity" ) . and_then ( |s| s. as_str ( ) ) ) ;
520+ let vulnerable_versions = advisory_obj. get ( "vulnerable_versions" ) . and_then ( |v| v. as_str ( ) ) . unwrap_or ( "*" ) . to_string ( ) ;
521+ let cve = advisory_obj
522+ . get ( "cves" )
523+ . and_then ( |c| c. as_array ( ) )
524+ . and_then ( |arr| arr. first ( ) )
525+ . and_then ( |v| v. as_str ( ) )
526+ . map ( |s| s. to_string ( ) ) ;
527+ let url = advisory_obj. get ( "url" ) . and_then ( |u| u. as_str ( ) ) . map ( |s| s. to_string ( ) ) ;
528+
529+ let vuln_info = VulnerabilityInfo {
530+ id,
531+ vuln_type : "security" . to_string ( ) ,
532+ severity,
533+ title,
534+ description,
535+ cve,
536+ ghsa : url. clone ( ) . filter ( |u| u. contains ( "GHSA" ) ) . map ( |u| u. split ( '/' ) . last ( ) . unwrap_or ( & u) . to_string ( ) ) ,
537+ affected_versions : vulnerable_versions,
538+ patched_versions : advisory_obj. get ( "patched_versions" ) . and_then ( |p| p. as_str ( ) ) . map ( |s| s. to_string ( ) ) ,
539+ published_date : None ,
540+ references : url. map ( |u| vec ! [ u] ) . unwrap_or_default ( ) ,
541+ } ;
542+
543+ ( vuln_info, package_name)
544+ }
458545
459546 fn parse_pnpm_audit_output (
460547 & self ,
461548 audit_data : & serde_json:: Value ,
462549 dependencies : & [ DependencyInfo ] ,
463550 ) -> Result < Option < Vec < VulnerableDependency > > , VulnerabilityError > {
464- // PNPM audit output is similar to NPM
465- self . parse_npm_audit_output ( audit_data, dependencies)
551+ // PNPM audit output can resemble NPM or provide an advisories map similar to Yarn classic
552+ if audit_data. get ( "vulnerabilities" ) . is_some ( ) {
553+ return self . parse_npm_audit_output ( audit_data, dependencies) ;
554+ }
555+
556+ if let Some ( advisories) = audit_data. get ( "advisories" ) . cloned ( ) {
557+ // Wrap into Yarn-like shape and reuse Yarn parser
558+ let yarn_like = serde_json:: json!( {
559+ "data" : { "advisories" : advisories }
560+ } ) ;
561+ return self . parse_yarn_audit_output ( & yarn_like, dependencies) ;
562+ }
563+
564+ // Some pnpm versions produce per-advisory arrays; attempt best-effort mapping if present
565+ if let Some ( findings) = audit_data. get ( "audit" ) . or_else ( || audit_data. get ( "metadata" ) ) . or_else ( || audit_data. get ( "data" ) ) {
566+ // Try npm parser as a reasonable default
567+ if let Ok ( res) = self . parse_npm_audit_output ( audit_data, dependencies) {
568+ if res. is_some ( ) { return Ok ( res) ; }
569+ }
570+ }
571+
572+ Ok ( None )
466573 }
467574
468575 fn parse_severity ( & self , severity : Option < & str > ) -> VulnerabilitySeverity {
@@ -486,22 +593,68 @@ impl MutableLanguageVulnerabilityChecker for JavaScriptVulnerabilityChecker {
486593 info ! ( "Checking JavaScript/TypeScript dependencies" ) ;
487594
488595 let runtime_detector = RuntimeDetector :: new ( project_path. to_path_buf ( ) ) ;
489- let _detection_result = runtime_detector. detect_js_runtime_and_package_manager ( ) ;
490-
596+ let detection_result = runtime_detector. detect_js_runtime_and_package_manager ( ) ;
597+
491598 info ! ( "Runtime detection: {}" , runtime_detector. get_detection_summary( ) ) ;
492-
493- // Get all available package managers
494- let available_managers = runtime_detector. detect_all_package_managers ( ) ;
495-
496- // Execute audit commands for each available manager
599+
600+ // Build execution order: primary detected manager first, then any lockfile-based managers
601+ let mut managers = Vec :: new ( ) ;
602+ if detection_result. package_manager != crate :: analyzer:: runtime:: PackageManager :: Unknown {
603+ managers. push ( detection_result. package_manager . clone ( ) ) ;
604+ }
605+ for m in runtime_detector. detect_all_package_managers ( ) {
606+ if !managers. contains ( & m) {
607+ managers. push ( m) ;
608+ }
609+ }
610+
611+ // Always consider running Bun audit for JS projects if available,
612+ // as Bun often surfaces advisories even when other managers don't.
613+ if !managers. contains ( & crate :: analyzer:: runtime:: PackageManager :: Bun )
614+ && runtime_detector. is_js_project ( )
615+ {
616+ managers. push ( crate :: analyzer:: runtime:: PackageManager :: Bun ) ;
617+ }
618+
619+ // If still empty but it's a JS project, default to npm as a last resort
620+ if managers. is_empty ( ) && runtime_detector. is_js_project ( ) {
621+ managers. push ( crate :: analyzer:: runtime:: PackageManager :: Npm ) ;
622+ }
623+
624+ // Execute audit commands for each selected manager
497625 let mut all_vulnerabilities = Vec :: new ( ) ;
498-
499- for manager in available_managers {
626+
627+ for manager in managers {
500628 if let Some ( vulns) = self . execute_audit_for_manager ( & manager, project_path, dependencies) ? {
501629 all_vulnerabilities. extend ( vulns) ;
502630 }
503631 }
504632
505633 Ok ( all_vulnerabilities)
506634 }
507- }
635+ }
636+
637+ // Best-effort tolerant JSON extractor: handles banners/noise by
638+ // 1) parsing whole buffer, 2) slicing between first '{' and last '}',
639+ // 3) scanning lines for a valid JSON object.
640+ fn try_parse_json_tolerant ( buf : & [ u8 ] ) -> Option < JsonValue > {
641+ if let Ok ( val) = serde_json:: from_slice :: < JsonValue > ( buf) {
642+ return Some ( val) ;
643+ }
644+ let text = String :: from_utf8_lossy ( buf) ;
645+ if let ( Some ( start) , Some ( end) ) = ( text. find ( '{' ) , text. rfind ( '}' ) ) {
646+ if start < end {
647+ if let Ok ( val) = serde_json:: from_str :: < JsonValue > ( & text[ start..=end] ) {
648+ return Some ( val) ;
649+ }
650+ }
651+ }
652+ for line in text. lines ( ) {
653+ let line = line. trim ( ) ;
654+ if !line. starts_with ( '{' ) || !line. ends_with ( '}' ) { continue ; }
655+ if let Ok ( val) = serde_json:: from_str :: < JsonValue > ( line) {
656+ return Some ( val) ;
657+ }
658+ }
659+ None
660+ }
0 commit comments