1use crate::models::{DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage};
23use crate::parsers::utils::{npm_purl, parse_sri};
24use log::warn;
25use serde_json::Value;
26use std::collections::HashMap;
27use std::fs;
28use std::path::Path;
29
30use super::PackageParser;
31
32const FIELD_LOCKFILE_VERSION: &str = "lockfileVersion";
34const FIELD_NAME: &str = "name";
35const FIELD_VERSION: &str = "version";
36const FIELD_DEPENDENCIES: &str = "dependencies";
37const FIELD_PACKAGES: &str = "packages";
38const FIELD_RESOLVED: &str = "resolved";
39const FIELD_INTEGRITY: &str = "integrity";
40const FIELD_DEV: &str = "dev";
41const FIELD_OPTIONAL: &str = "optional";
42const FIELD_DEV_OPTIONAL: &str = "devOptional";
43const FIELD_LINK: &str = "link";
44
45pub struct NpmLockParser;
50
51impl PackageParser for NpmLockParser {
52 const PACKAGE_TYPE: PackageType = PackageType::Npm;
53
54 fn is_match(path: &Path) -> bool {
55 path.file_name()
56 .and_then(|name| name.to_str())
57 .map(|name| {
58 name == "package-lock.json"
59 || name == ".package-lock.json"
60 || name == "npm-shrinkwrap.json"
61 || name == ".npm-shrinkwrap.json"
62 })
63 .unwrap_or(false)
64 }
65
66 fn extract_packages(path: &Path) -> Vec<PackageData> {
67 let content = match fs::read_to_string(path) {
68 Ok(content) => content,
69 Err(e) => {
70 warn!("Failed to read package-lock.json at {:?}: {}", path, e);
71 return vec![default_package_data()];
72 }
73 };
74
75 let json: Value = match serde_json::from_str(&content) {
76 Ok(json) => json,
77 Err(e) => {
78 warn!("Failed to parse package-lock.json at {:?}: {}", path, e);
79 return vec![default_package_data()];
80 }
81 };
82
83 let lockfile_version = json
84 .get(FIELD_LOCKFILE_VERSION)
85 .and_then(|v| v.as_i64())
86 .unwrap_or(1);
87
88 let root_name = json
89 .get(FIELD_NAME)
90 .and_then(|v| v.as_str())
91 .unwrap_or("")
92 .to_string();
93
94 let root_version = json
95 .get(FIELD_VERSION)
96 .and_then(|v| v.as_str())
97 .unwrap_or("")
98 .to_string();
99
100 vec![if lockfile_version == 1 {
101 parse_lockfile_v1(&json, root_name, root_version, lockfile_version)
102 } else {
103 parse_lockfile_v2_plus(&json, root_name, root_version, lockfile_version)
104 }]
105 }
106}
107
108fn default_package_data() -> PackageData {
110 PackageData {
111 package_type: Some(NpmLockParser::PACKAGE_TYPE),
112 datasource_id: Some(DatasourceId::NpmPackageLockJson),
113 ..Default::default()
114 }
115}
116
117fn parse_lockfile_v2_plus(
119 json: &Value,
120 root_name: String,
121 root_version: String,
122 lockfile_version: i64,
123) -> PackageData {
124 let packages = match json.get(FIELD_PACKAGES).and_then(|v| v.as_object()) {
125 Some(packages) => packages,
126 None => {
127 warn!("No 'packages' field found in lockfile v2+");
128 return default_package_data();
129 }
130 };
131
132 let (root_name, root_version) = extract_root_package_identity(json, root_name, root_version);
133 let (namespace, name) = extract_namespace_and_name(&root_name);
134 let purl = create_purl(&namespace, &name, Some(root_version.as_str()));
135
136 let mut root_deps = std::collections::HashSet::new();
138
139 if let Some(root_deps_obj) = json.get(FIELD_DEPENDENCIES).and_then(|v| v.as_object()) {
141 for key in root_deps_obj.keys() {
142 root_deps.insert(key.clone());
143 }
144 }
145 if let Some(root_dev_deps_obj) = json.get("devDependencies").and_then(|v| v.as_object()) {
146 for key in root_dev_deps_obj.keys() {
147 root_deps.insert(key.clone());
148 }
149 }
150 if let Some(root_package) = packages.get("").and_then(|value| value.as_object()) {
151 collect_root_dependency_names(root_package.get(FIELD_DEPENDENCIES), &mut root_deps);
152 collect_root_dependency_names(root_package.get("devDependencies"), &mut root_deps);
153 collect_root_dependency_names(root_package.get("optionalDependencies"), &mut root_deps);
154 }
155
156 let mut dependencies = Vec::new();
157
158 for (key, value) in packages {
159 if key.is_empty() {
161 continue;
162 }
163
164 let package_name = extract_package_name_from_path(key);
166 if package_name.is_empty() {
167 continue;
168 }
169
170 let version = value
171 .get(FIELD_VERSION)
172 .and_then(|v| v.as_str())
173 .map(str::to_string);
174
175 let is_dev = value
176 .get(FIELD_DEV)
177 .and_then(|v| v.as_bool())
178 .unwrap_or(false);
179 let is_optional = value
180 .get(FIELD_OPTIONAL)
181 .and_then(|v| v.as_bool())
182 .unwrap_or(false);
183 let is_dev_optional = value
184 .get(FIELD_DEV_OPTIONAL)
185 .and_then(|v| v.as_bool())
186 .unwrap_or(false);
187
188 let resolved = value.get(FIELD_RESOLVED).and_then(|v| v.as_str());
189 let integrity = value.get(FIELD_INTEGRITY).and_then(|v| v.as_str());
190 let from = value.get("from").and_then(|v| v.as_str());
191 let in_bundle = value
192 .get("inBundle")
193 .and_then(|v| v.as_bool())
194 .unwrap_or(false);
195 let is_link = value
196 .get(FIELD_LINK)
197 .and_then(|v| v.as_bool())
198 .unwrap_or(false);
199 let is_direct = root_deps.contains(&package_name) && is_direct_dependency_path(key);
200
201 let dependency = match version {
202 Some(version) => build_npm_dependency(
203 &package_name,
204 version,
205 is_dev,
206 is_dev_optional,
207 is_optional,
208 resolved,
209 integrity,
210 is_direct,
211 from,
212 in_bundle,
213 Vec::new(),
214 ),
215 None if is_link => build_link_dependency(
216 &package_name,
217 is_dev,
218 is_dev_optional,
219 is_optional,
220 resolved,
221 is_direct,
222 ),
223 None => continue,
224 };
225
226 dependencies.push(dependency);
227 }
228
229 let extra_data = Some(HashMap::from([(
230 "lockfileVersion".to_string(),
231 Value::from(lockfile_version),
232 )]));
233
234 PackageData {
235 package_type: Some(NpmLockParser::PACKAGE_TYPE),
236 namespace: Some(namespace),
237 name: Some(name),
238 version: Some(root_version),
239 qualifiers: None,
240 subpath: None,
241 primary_language: None,
242 description: None,
243 release_date: None,
244 parties: Vec::new(),
245 keywords: Vec::new(),
246 homepage_url: None,
247 download_url: None,
248 size: None,
249 sha1: None,
250 md5: None,
251 sha256: None,
252 sha512: None,
253 bug_tracking_url: None,
254 code_view_url: None,
255 vcs_url: None,
256 copyright: None,
257 holder: None,
258 declared_license_expression: None,
259 declared_license_expression_spdx: None,
260 license_detections: Vec::new(),
261 other_license_expression: None,
262 other_license_expression_spdx: None,
263 other_license_detections: Vec::new(),
264 extracted_license_statement: None,
265 notice_text: None,
266 source_packages: Vec::new(),
267 file_references: Vec::new(),
268 is_private: false,
269 is_virtual: false,
270 extra_data,
271 dependencies,
272 repository_homepage_url: None,
273 repository_download_url: None,
274 api_data_url: None,
275 datasource_id: Some(DatasourceId::NpmPackageLockJson),
276 purl,
277 }
278}
279
280fn parse_lockfile_v1(
282 json: &Value,
283 root_name: String,
284 root_version: String,
285 _lockfile_version: i64,
286) -> PackageData {
287 let dependencies_obj = match json.get(FIELD_DEPENDENCIES).and_then(|v| v.as_object()) {
288 Some(deps) => deps,
289 None => {
290 warn!("No 'dependencies' field found in lockfile v1");
291 return default_package_data();
292 }
293 };
294
295 let (namespace, name) = extract_namespace_and_name(&root_name);
296 let purl = create_purl(&namespace, &name, Some(root_version.as_str()));
297
298 let dependencies = parse_dependencies_v1(dependencies_obj);
299
300 PackageData {
301 package_type: Some(NpmLockParser::PACKAGE_TYPE),
302 namespace: Some(namespace),
303 name: Some(name),
304 version: Some(root_version),
305 qualifiers: None,
306 subpath: None,
307 primary_language: None,
308 description: None,
309 release_date: None,
310 parties: Vec::new(),
311 keywords: Vec::new(),
312 homepage_url: None,
313 download_url: None,
314 size: None,
315 sha1: None,
316 md5: None,
317 sha256: None,
318 sha512: None,
319 bug_tracking_url: None,
320 code_view_url: None,
321 vcs_url: None,
322 copyright: None,
323 holder: None,
324 declared_license_expression: None,
325 declared_license_expression_spdx: None,
326 license_detections: Vec::new(),
327 other_license_expression: None,
328 other_license_expression_spdx: None,
329 other_license_detections: Vec::new(),
330 extracted_license_statement: None,
331 notice_text: None,
332 source_packages: Vec::new(),
333 file_references: Vec::new(),
334 is_private: false,
335 is_virtual: false,
336 extra_data: None,
337 dependencies,
338 repository_homepage_url: None,
339 repository_download_url: None,
340 api_data_url: None,
341 datasource_id: Some(DatasourceId::NpmPackageLockJson),
342 purl,
343 }
344}
345
346fn parse_dependencies_v1(dependencies_obj: &serde_json::Map<String, Value>) -> Vec<Dependency> {
351 parse_dependencies_v1_with_depth(dependencies_obj, 0)
352}
353
354fn parse_dependencies_v1_with_depth(
356 dependencies_obj: &serde_json::Map<String, Value>,
357 depth: usize,
358) -> Vec<Dependency> {
359 let mut dependencies = Vec::new();
360
361 for (package_name, dep_data) in dependencies_obj {
362 let version = match dep_data.get(FIELD_VERSION).and_then(|v| v.as_str()) {
363 Some(v) => v.to_string(),
364 None => continue,
365 };
366
367 let is_dev = dep_data
368 .get(FIELD_DEV)
369 .and_then(|v| v.as_bool())
370 .unwrap_or(false);
371 let is_optional = dep_data
372 .get(FIELD_OPTIONAL)
373 .and_then(|v| v.as_bool())
374 .unwrap_or(false);
375
376 let resolved = dep_data.get(FIELD_RESOLVED).and_then(|v| v.as_str());
377 let integrity = dep_data.get(FIELD_INTEGRITY).and_then(|v| v.as_str());
378 let from = dep_data.get("from").and_then(|v| v.as_str());
379 let in_bundle = dep_data
380 .get("inBundle")
381 .and_then(|v| v.as_bool())
382 .unwrap_or(false);
383
384 let nested_deps = dep_data
385 .get(FIELD_DEPENDENCIES)
386 .and_then(|v| v.as_object())
387 .map(|nested| parse_dependencies_v1_with_depth(nested, depth + 1))
388 .unwrap_or_default();
389
390 let is_direct = depth == 0;
391
392 let dependency = build_npm_dependency(
393 package_name,
394 version,
395 is_dev,
396 false, is_optional,
398 resolved,
399 integrity,
400 is_direct,
401 from,
402 in_bundle,
403 nested_deps,
404 );
405
406 dependencies.push(dependency);
407 }
408
409 dependencies
410}
411
412fn extract_namespace_and_name(package_name: &str) -> (String, String) {
415 if package_name.starts_with('@') {
416 let parts: Vec<&str> = package_name.splitn(2, '/').collect();
418 if parts.len() == 2 {
419 (parts[0].to_string(), parts[1].to_string())
420 } else {
421 (String::new(), package_name.to_string())
423 }
424 } else {
425 (String::new(), package_name.to_string())
427 }
428}
429
430fn extract_package_name_from_path(path: &str) -> String {
432 if let Some(pos) = path.rfind("node_modules/") {
434 let after_node_modules = &path[pos + "node_modules/".len()..];
435
436 if after_node_modules.starts_with('@') {
438 if let Some(slash_pos) = after_node_modules.find('/') {
440 let scope_and_package = &after_node_modules[..=slash_pos];
441 let remaining = &after_node_modules[slash_pos + 1..];
443 if let Some(next_slash) = remaining.find('/') {
444 return format!("{}{}", scope_and_package, &remaining[..next_slash]);
446 } else {
447 return after_node_modules.to_string();
449 }
450 }
451 } else {
452 if let Some(slash_pos) = after_node_modules.find('/') {
454 return after_node_modules[..slash_pos].to_string();
455 } else {
456 return after_node_modules.to_string();
457 }
458 }
459 }
460
461 path.to_string()
462}
463
464fn create_purl(namespace: &str, name: &str, version: Option<&str>) -> Option<String> {
465 let full_name = if namespace.is_empty() {
466 name.to_string()
467 } else {
468 format!("{}/{}", namespace, name)
469 };
470 npm_purl(&full_name, version.filter(|value| !value.is_empty()))
471}
472
473fn extract_root_package_identity(
474 json: &Value,
475 root_name: String,
476 root_version: String,
477) -> (String, String) {
478 let root_package = json
479 .get(FIELD_PACKAGES)
480 .and_then(|value| value.as_object())
481 .and_then(|packages| packages.get(""))
482 .and_then(|value| value.as_object());
483
484 let name = non_empty_string(&root_name).or_else(|| {
485 root_package
486 .and_then(|package| package.get(FIELD_NAME))
487 .and_then(|value| value.as_str())
488 .map(str::to_string)
489 .filter(|value| !value.trim().is_empty())
490 });
491 let version = non_empty_string(&root_version).or_else(|| {
492 root_package
493 .and_then(|package| package.get(FIELD_VERSION))
494 .and_then(|value| value.as_str())
495 .map(str::to_string)
496 .filter(|value| !value.trim().is_empty())
497 });
498
499 (name.unwrap_or_default(), version.unwrap_or_default())
500}
501
502fn non_empty_string(value: &str) -> Option<String> {
503 let trimmed = value.trim();
504 if trimmed.is_empty() {
505 None
506 } else {
507 Some(trimmed.to_string())
508 }
509}
510
511fn collect_root_dependency_names(
512 value: Option<&Value>,
513 root_deps: &mut std::collections::HashSet<String>,
514) {
515 if let Some(entries) = value.and_then(|value| value.as_object()) {
516 for key in entries.keys() {
517 root_deps.insert(key.clone());
518 }
519 }
520}
521
522fn is_direct_dependency_path(package_path: &str) -> bool {
523 let node_modules_count = package_path.matches("node_modules/").count();
524
525 match node_modules_count {
526 0 => true,
527 1 => package_path.starts_with("node_modules/") || package_path.starts_with(".pnpm/"),
528 _ => false,
529 }
530}
531
532fn parse_integrity_field(integrity: Option<&str>) -> (Option<String>, Option<String>) {
535 let integrity = match integrity {
536 Some(i) => i,
537 None => return (None, None),
538 };
539
540 match parse_sri(integrity) {
541 Some((algo, hex_digest)) => match algo.as_str() {
542 "sha1" => (Some(hex_digest), None),
543 "sha512" => (None, Some(hex_digest)),
544 _ => (None, None),
545 },
546 None => (None, None),
547 }
548}
549
550fn parse_resolved_url(url: &str) -> Option<String> {
553 if let Some(hash_pos) = url.rfind('#') {
555 let hash = &url[hash_pos + 1..];
556 if hash.len() == 40 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
558 return Some(hash.to_string());
559 }
560 }
561 None
562}
563
564fn determine_scope(
567 is_dev: bool,
568 is_dev_optional: bool,
569 is_optional: bool,
570) -> (&'static str, bool, bool) {
571 if is_dev || is_dev_optional {
572 ("devDependencies", false, true)
573 } else if is_optional {
574 ("dependencies", true, true)
575 } else {
576 ("dependencies", true, false)
577 }
578}
579
580fn parse_npm_alias_spec(version_spec: &str) -> Option<(String, String, String)> {
581 let aliased_spec = version_spec.strip_prefix("npm:")?;
582 let (aliased_name, constraint) = aliased_spec.rsplit_once('@')?;
583 let (namespace, name) = extract_namespace_and_name(aliased_name);
584
585 if name.is_empty() || constraint.trim().is_empty() {
586 None
587 } else {
588 Some((namespace, name, constraint.to_string()))
589 }
590}
591
592fn is_exact_version(version: &str) -> bool {
593 let version = version.trim();
594
595 if version.is_empty() {
596 return false;
597 }
598
599 if version.starts_with('~')
600 || version.starts_with('^')
601 || version.starts_with('>')
602 || version.starts_with('<')
603 || version.starts_with('=')
604 || version.starts_with('*')
605 || version.contains("||")
606 || version.contains(" - ")
607 {
608 return false;
609 }
610
611 !is_non_version_dependency(version)
612}
613
614fn is_non_version_dependency(version: &str) -> bool {
615 let version = version.trim();
616
617 version.starts_with("http://")
618 || version.starts_with("https://")
619 || version.starts_with("git://")
620 || version.starts_with("git+ssh://")
621 || version.starts_with("git+http://")
622 || version.starts_with("git+https://")
623 || version.starts_with("git+file://")
624 || version.starts_with("git@")
625 || version.starts_with("file:")
626 || version.starts_with("link:")
627 || version.starts_with("github:")
628 || version.starts_with("gitlab:")
629 || version.starts_with("bitbucket:")
630 || version.starts_with("gist:")
631}
632
633fn non_version_download_url(version: &str, resolved: Option<&str>) -> Option<String> {
634 resolved
635 .map(str::to_string)
636 .or_else(|| match version.trim() {
637 version if version.starts_with("http://") || version.starts_with("https://") => {
638 Some(version.to_string())
639 }
640 _ => None,
641 })
642}
643
644#[allow(clippy::too_many_arguments)]
645fn build_npm_dependency(
646 package_name: &str,
647 version: String,
648 is_dev: bool,
649 is_dev_optional: bool,
650 is_optional: bool,
651 resolved: Option<&str>,
652 integrity: Option<&str>,
653 is_direct: bool,
654 from: Option<&str>,
655 in_bundle: bool,
656 nested_deps: Vec<Dependency>,
657) -> Dependency {
658 let (dep_namespace, dep_name) = extract_namespace_and_name(package_name);
659 let (scope, is_runtime, is_optional_flag) =
660 determine_scope(is_dev, is_dev_optional, is_optional);
661
662 let alias_spec = parse_npm_alias_spec(&version);
663 let (purl_namespace, purl_name, resolved_version, is_pinned, dep_purl, download_url) =
664 if let Some((alias_namespace, alias_name, alias_constraint)) = alias_spec.clone() {
665 let is_pinned = is_exact_version(&alias_constraint);
666 let dep_purl = create_purl(
667 &alias_namespace,
668 &alias_name,
669 is_pinned.then_some(alias_constraint.as_str()),
670 );
671 let download_url = non_version_download_url(&alias_constraint, resolved);
672
673 (
674 alias_namespace,
675 alias_name,
676 alias_constraint,
677 is_pinned,
678 dep_purl,
679 download_url,
680 )
681 } else {
682 let is_pinned = is_exact_version(&version);
683 let dep_purl = create_purl(
684 &dep_namespace,
685 &dep_name,
686 is_pinned.then_some(version.as_str()),
687 );
688 let download_url = non_version_download_url(&version, resolved);
689
690 (
691 dep_namespace.clone(),
692 dep_name.clone(),
693 version.clone(),
694 is_pinned,
695 dep_purl,
696 download_url,
697 )
698 };
699
700 let (sha1_from_integrity, sha512_from_integrity) = parse_integrity_field(integrity);
701 let sha1_from_url = resolved.and_then(parse_resolved_url);
702 let sha1 = sha1_from_integrity.or(sha1_from_url);
703
704 let mut dep_extra_data = HashMap::new();
705 if let Some(from) = from {
706 dep_extra_data.insert("from".to_string(), Value::String(from.to_string()));
707 }
708 if in_bundle {
709 dep_extra_data.insert("inBundle".to_string(), Value::Bool(true));
710 }
711
712 let resolved_package = ResolvedPackage {
713 package_type: NpmLockParser::PACKAGE_TYPE,
714 namespace: purl_namespace,
715 name: purl_name,
716 version: resolved_version,
717 primary_language: Some("JavaScript".to_string()),
718 download_url,
719 sha1,
720 sha256: None,
721 sha512: sha512_from_integrity,
722 md5: None,
723 is_virtual: true,
724 extra_data: None,
725 dependencies: nested_deps,
726 repository_homepage_url: None,
727 repository_download_url: None,
728 api_data_url: None,
729 datasource_id: Some(DatasourceId::NpmPackageLockJson),
730 purl: None,
731 };
732
733 Dependency {
734 purl: dep_purl,
735 extracted_requirement: Some(version),
736 scope: Some(scope.to_string()),
737 is_runtime: Some(is_runtime),
738 is_optional: Some(is_optional_flag),
739 is_pinned: Some(is_pinned),
740 is_direct: Some(is_direct),
741 resolved_package: Some(Box::new(resolved_package)),
742 extra_data: (!dep_extra_data.is_empty()).then_some(dep_extra_data),
743 }
744}
745
746fn build_link_dependency(
747 package_name: &str,
748 is_dev: bool,
749 is_dev_optional: bool,
750 is_optional: bool,
751 resolved: Option<&str>,
752 is_direct: bool,
753) -> Dependency {
754 let (dep_namespace, dep_name) = extract_namespace_and_name(package_name);
755 let (scope, is_runtime, is_optional_flag) =
756 determine_scope(is_dev, is_dev_optional, is_optional);
757 let mut extra_data = HashMap::from([("link".to_string(), Value::Bool(true))]);
758
759 if let Some(resolved) = resolved {
760 extra_data.insert("resolved".to_string(), Value::String(resolved.to_string()));
761 }
762
763 Dependency {
764 purl: create_purl(&dep_namespace, &dep_name, None),
765 extracted_requirement: resolved.map(str::to_string),
766 scope: Some(scope.to_string()),
767 is_runtime: Some(is_runtime),
768 is_optional: Some(is_optional_flag),
769 is_pinned: Some(false),
770 is_direct: Some(is_direct),
771 resolved_package: None,
772 extra_data: Some(extra_data),
773 }
774}
775
776crate::register_parser!(
777 "npm package-lock.json lockfile",
778 &[
779 "**/package-lock.json",
780 "**/.package-lock.json",
781 "**/npm-shrinkwrap.json"
782 ],
783 "npm",
784 "JavaScript",
785 Some("https://docs.npmjs.com/cli/v8/configuring-npm/package-lock-json"),
786);