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