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 install_name = extract_package_name_from_path(key);
168 if install_name.is_empty() {
169 continue;
170 }
171
172 let package_name = value
173 .get(FIELD_NAME)
174 .and_then(|v| v.as_str())
175 .map(str::trim)
176 .filter(|name| !name.is_empty())
177 .map(str::to_string)
178 .unwrap_or_else(|| install_name.clone());
179
180 let version = value
181 .get(FIELD_VERSION)
182 .and_then(|v| v.as_str())
183 .map(str::to_string);
184
185 let is_dev = value
186 .get(FIELD_DEV)
187 .and_then(|v| v.as_bool())
188 .unwrap_or(false);
189 let is_optional = value
190 .get(FIELD_OPTIONAL)
191 .and_then(|v| v.as_bool())
192 .unwrap_or(false);
193 let is_dev_optional = value
194 .get(FIELD_DEV_OPTIONAL)
195 .and_then(|v| v.as_bool())
196 .unwrap_or(false);
197
198 let resolved = value.get(FIELD_RESOLVED).and_then(|v| v.as_str());
199 let integrity = value.get(FIELD_INTEGRITY).and_then(|v| v.as_str());
200 let from = value.get("from").and_then(|v| v.as_str());
201 let in_bundle = value
202 .get("inBundle")
203 .and_then(|v| v.as_bool())
204 .unwrap_or(false);
205 let is_link = value
206 .get(FIELD_LINK)
207 .and_then(|v| v.as_bool())
208 .unwrap_or(false);
209 let is_direct = root_deps.contains(&install_name) && is_direct_dependency_path(key);
210
211 let dependency = match version {
212 Some(version) => build_npm_dependency(
213 &package_name,
214 version,
215 is_dev,
216 is_dev_optional,
217 is_optional,
218 resolved,
219 integrity,
220 is_direct,
221 from,
222 in_bundle,
223 Vec::new(),
224 ),
225 None if is_link => build_link_dependency(
226 &package_name,
227 is_dev,
228 is_dev_optional,
229 is_optional,
230 resolved,
231 is_direct,
232 ),
233 None => continue,
234 };
235
236 dependencies.push(dependency);
237 }
238
239 let extra_data = Some(HashMap::from([(
240 "lockfileVersion".to_string(),
241 Value::from(lockfile_version),
242 )]));
243
244 PackageData {
245 package_type: Some(NpmLockParser::PACKAGE_TYPE),
246 namespace: namespace.clone(),
247 name,
248 version,
249 qualifiers: None,
250 subpath: None,
251 primary_language: None,
252 description: None,
253 release_date: None,
254 parties: Vec::new(),
255 keywords: Vec::new(),
256 homepage_url: None,
257 download_url: None,
258 size: None,
259 sha1: None,
260 md5: None,
261 sha256: None,
262 sha512: None,
263 bug_tracking_url: None,
264 code_view_url: None,
265 vcs_url: None,
266 copyright: None,
267 holder: None,
268 declared_license_expression: None,
269 declared_license_expression_spdx: None,
270 license_detections: Vec::new(),
271 other_license_expression: None,
272 other_license_expression_spdx: None,
273 other_license_detections: Vec::new(),
274 extracted_license_statement: None,
275 notice_text: None,
276 source_packages: Vec::new(),
277 file_references: Vec::new(),
278 is_private: false,
279 is_virtual: false,
280 extra_data,
281 dependencies,
282 repository_homepage_url: None,
283 repository_download_url: None,
284 api_data_url: None,
285 datasource_id: Some(DatasourceId::NpmPackageLockJson),
286 purl,
287 }
288}
289
290fn parse_lockfile_v1(
292 json: &Value,
293 root_name: String,
294 root_version: String,
295 _lockfile_version: i64,
296) -> PackageData {
297 let dependencies_obj = match json.get(FIELD_DEPENDENCIES).and_then(|v| v.as_object()) {
298 Some(deps) => deps,
299 None => {
300 warn!("No 'dependencies' field found in lockfile v1");
301 return default_package_data();
302 }
303 };
304
305 let (namespace, name, version, purl) =
306 normalize_root_package_metadata(&root_name, &root_version);
307
308 let dependencies = parse_dependencies_v1(dependencies_obj);
309
310 PackageData {
311 package_type: Some(NpmLockParser::PACKAGE_TYPE),
312 namespace: namespace.clone(),
313 name,
314 version,
315 qualifiers: None,
316 subpath: None,
317 primary_language: None,
318 description: None,
319 release_date: None,
320 parties: Vec::new(),
321 keywords: Vec::new(),
322 homepage_url: None,
323 download_url: None,
324 size: None,
325 sha1: None,
326 md5: None,
327 sha256: None,
328 sha512: None,
329 bug_tracking_url: None,
330 code_view_url: None,
331 vcs_url: None,
332 copyright: None,
333 holder: None,
334 declared_license_expression: None,
335 declared_license_expression_spdx: None,
336 license_detections: Vec::new(),
337 other_license_expression: None,
338 other_license_expression_spdx: None,
339 other_license_detections: Vec::new(),
340 extracted_license_statement: None,
341 notice_text: None,
342 source_packages: Vec::new(),
343 file_references: Vec::new(),
344 is_private: false,
345 is_virtual: false,
346 extra_data: None,
347 dependencies,
348 repository_homepage_url: None,
349 repository_download_url: None,
350 api_data_url: None,
351 datasource_id: Some(DatasourceId::NpmPackageLockJson),
352 purl,
353 }
354}
355
356fn parse_dependencies_v1(dependencies_obj: &serde_json::Map<String, Value>) -> Vec<Dependency> {
361 parse_dependencies_v1_with_depth(dependencies_obj, 0)
362}
363
364fn parse_dependencies_v1_with_depth(
366 dependencies_obj: &serde_json::Map<String, Value>,
367 depth: usize,
368) -> Vec<Dependency> {
369 let mut dependencies = Vec::new();
370
371 for (package_name, dep_data) in dependencies_obj {
372 let version = match dep_data.get(FIELD_VERSION).and_then(|v| v.as_str()) {
373 Some(v) => v.to_string(),
374 None => continue,
375 };
376
377 let is_dev = dep_data
378 .get(FIELD_DEV)
379 .and_then(|v| v.as_bool())
380 .unwrap_or(false);
381 let is_optional = dep_data
382 .get(FIELD_OPTIONAL)
383 .and_then(|v| v.as_bool())
384 .unwrap_or(false);
385
386 let resolved = dep_data.get(FIELD_RESOLVED).and_then(|v| v.as_str());
387 let integrity = dep_data.get(FIELD_INTEGRITY).and_then(|v| v.as_str());
388 let from = dep_data.get("from").and_then(|v| v.as_str());
389 let in_bundle = dep_data
390 .get("inBundle")
391 .and_then(|v| v.as_bool())
392 .unwrap_or(false);
393
394 let nested_deps = dep_data
395 .get(FIELD_DEPENDENCIES)
396 .and_then(|v| v.as_object())
397 .map(|nested| parse_dependencies_v1_with_depth(nested, depth + 1))
398 .unwrap_or_default();
399
400 let is_direct = depth == 0;
401
402 let dependency = build_npm_dependency(
403 package_name,
404 version,
405 is_dev,
406 false, is_optional,
408 resolved,
409 integrity,
410 is_direct,
411 from,
412 in_bundle,
413 nested_deps,
414 );
415
416 dependencies.push(dependency);
417 }
418
419 dependencies
420}
421
422fn extract_namespace_and_name(package_name: &str) -> (String, String) {
425 if package_name.starts_with('@') {
426 let parts: Vec<&str> = package_name.splitn(2, '/').collect();
428 if parts.len() == 2 {
429 (parts[0].to_string(), parts[1].to_string())
430 } else {
431 (String::new(), package_name.to_string())
433 }
434 } else {
435 (String::new(), package_name.to_string())
437 }
438}
439
440fn extract_package_name_from_path(path: &str) -> String {
442 if let Some(pos) = path.rfind("node_modules/") {
444 let after_node_modules = &path[pos + "node_modules/".len()..];
445
446 if after_node_modules.starts_with('@') {
448 if let Some(slash_pos) = after_node_modules.find('/') {
450 let scope_and_package = &after_node_modules[..=slash_pos];
451 let remaining = &after_node_modules[slash_pos + 1..];
453 if let Some(next_slash) = remaining.find('/') {
454 return format!("{}{}", scope_and_package, &remaining[..next_slash]);
456 } else {
457 return after_node_modules.to_string();
459 }
460 }
461 } else {
462 if let Some(slash_pos) = after_node_modules.find('/') {
464 return after_node_modules[..slash_pos].to_string();
465 } else {
466 return after_node_modules.to_string();
467 }
468 }
469 }
470
471 path.to_string()
472}
473
474fn create_purl(namespace: &str, name: &str, version: Option<&str>) -> Option<String> {
475 let full_name = if namespace.is_empty() {
476 name.to_string()
477 } else {
478 format!("{}/{}", namespace, name)
479 };
480 npm_purl(&full_name, version.filter(|value| !value.is_empty()))
481}
482
483fn normalize_root_package_metadata(
484 root_name: &str,
485 root_version: &str,
486) -> (
487 Option<String>,
488 Option<String>,
489 Option<String>,
490 Option<String>,
491) {
492 let (namespace, name) = extract_namespace_and_name(root_name);
493 let normalized_name = non_empty_string(&name);
494 let normalized_namespace = normalized_name.as_ref().map(|_| namespace);
495 let normalized_version = normalized_name
496 .as_ref()
497 .and_then(|_| non_empty_string(root_version));
498 let purl = normalized_name.as_deref().and_then(|name| {
499 create_purl(
500 normalized_namespace.as_deref().unwrap_or(""),
501 name,
502 normalized_version.as_deref(),
503 )
504 });
505
506 (
507 normalized_namespace,
508 normalized_name,
509 normalized_version,
510 purl,
511 )
512}
513
514fn extract_root_package_identity(
515 json: &Value,
516 root_name: String,
517 root_version: String,
518) -> (String, String) {
519 let root_package = json
520 .get(FIELD_PACKAGES)
521 .and_then(|value| value.as_object())
522 .and_then(|packages| packages.get(""))
523 .and_then(|value| value.as_object());
524
525 let name = non_empty_string(&root_name).or_else(|| {
526 root_package
527 .and_then(|package| package.get(FIELD_NAME))
528 .and_then(|value| value.as_str())
529 .map(str::to_string)
530 .filter(|value| !value.trim().is_empty())
531 });
532 let version = non_empty_string(&root_version).or_else(|| {
533 root_package
534 .and_then(|package| package.get(FIELD_VERSION))
535 .and_then(|value| value.as_str())
536 .map(str::to_string)
537 .filter(|value| !value.trim().is_empty())
538 });
539
540 (name.unwrap_or_default(), version.unwrap_or_default())
541}
542
543fn non_empty_string(value: &str) -> Option<String> {
544 let trimmed = value.trim();
545 if trimmed.is_empty() {
546 None
547 } else {
548 Some(trimmed.to_string())
549 }
550}
551
552fn collect_root_dependency_names(
553 value: Option<&Value>,
554 root_deps: &mut std::collections::HashSet<String>,
555) {
556 if let Some(entries) = value.and_then(|value| value.as_object()) {
557 for key in entries.keys() {
558 root_deps.insert(key.clone());
559 }
560 }
561}
562
563fn is_direct_dependency_path(package_path: &str) -> bool {
564 let node_modules_count = package_path.matches("node_modules/").count();
565
566 match node_modules_count {
567 0 => true,
568 1 => package_path.starts_with("node_modules/") || package_path.starts_with(".pnpm/"),
569 _ => false,
570 }
571}
572
573fn parse_integrity_field(integrity: Option<&str>) -> (Option<String>, Option<String>) {
576 let integrity = match integrity {
577 Some(i) => i,
578 None => return (None, None),
579 };
580
581 match parse_sri(integrity) {
582 Some((algo, hex_digest)) => match algo.as_str() {
583 "sha1" => (Some(hex_digest), None),
584 "sha512" => (None, Some(hex_digest)),
585 _ => (None, None),
586 },
587 None => (None, None),
588 }
589}
590
591fn parse_resolved_url(url: &str) -> Option<String> {
594 if let Some(hash_pos) = url.rfind('#') {
596 let hash = &url[hash_pos + 1..];
597 if hash.len() == 40 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
599 return Some(hash.to_string());
600 }
601 }
602 None
603}
604
605fn determine_scope(
608 is_dev: bool,
609 is_dev_optional: bool,
610 is_optional: bool,
611) -> (&'static str, bool, bool) {
612 if is_dev || is_dev_optional {
613 ("devDependencies", false, true)
614 } else if is_optional {
615 ("dependencies", true, true)
616 } else {
617 ("dependencies", true, false)
618 }
619}
620
621fn parse_npm_alias_spec(version_spec: &str) -> Option<(String, String, String)> {
622 let aliased_spec = version_spec.strip_prefix("npm:")?;
623 let (aliased_name, constraint) = aliased_spec.rsplit_once('@')?;
624 let (namespace, name) = extract_namespace_and_name(aliased_name);
625
626 if name.is_empty() || constraint.trim().is_empty() {
627 None
628 } else {
629 Some((namespace, name, constraint.to_string()))
630 }
631}
632
633fn is_exact_version(version: &str) -> bool {
634 let version = version.trim();
635
636 if version.is_empty() {
637 return false;
638 }
639
640 if version.starts_with('~')
641 || version.starts_with('^')
642 || version.starts_with('>')
643 || version.starts_with('<')
644 || version.starts_with('=')
645 || version.starts_with('*')
646 || version.contains("||")
647 || version.contains(" - ")
648 {
649 return false;
650 }
651
652 !is_non_version_dependency(version)
653}
654
655fn is_non_version_dependency(version: &str) -> bool {
656 let version = version.trim();
657
658 version.starts_with("http://")
659 || version.starts_with("https://")
660 || version.starts_with("git://")
661 || version.starts_with("git+ssh://")
662 || version.starts_with("git+http://")
663 || version.starts_with("git+https://")
664 || version.starts_with("git+file://")
665 || version.starts_with("git@")
666 || version.starts_with("file:")
667 || version.starts_with("link:")
668 || version.starts_with("github:")
669 || version.starts_with("gitlab:")
670 || version.starts_with("bitbucket:")
671 || version.starts_with("gist:")
672}
673
674fn non_version_download_url(version: &str, resolved: Option<&str>) -> Option<String> {
675 resolved
676 .map(str::to_string)
677 .or_else(|| match version.trim() {
678 version if version.starts_with("http://") || version.starts_with("https://") => {
679 Some(version.to_string())
680 }
681 _ => None,
682 })
683}
684
685#[allow(clippy::too_many_arguments)]
686fn build_npm_dependency(
687 package_name: &str,
688 version: String,
689 is_dev: bool,
690 is_dev_optional: bool,
691 is_optional: bool,
692 resolved: Option<&str>,
693 integrity: Option<&str>,
694 is_direct: bool,
695 from: Option<&str>,
696 in_bundle: bool,
697 nested_deps: Vec<Dependency>,
698) -> Dependency {
699 let (dep_namespace, dep_name) = extract_namespace_and_name(package_name);
700 let (scope, is_runtime, is_optional_flag) =
701 determine_scope(is_dev, is_dev_optional, is_optional);
702
703 let alias_spec = parse_npm_alias_spec(&version);
704 let (purl_namespace, purl_name, resolved_version, is_pinned, dep_purl, download_url) =
705 if let Some((alias_namespace, alias_name, alias_constraint)) = alias_spec.clone() {
706 let is_pinned = is_exact_version(&alias_constraint);
707 let dep_purl = create_purl(
708 &alias_namespace,
709 &alias_name,
710 is_pinned.then_some(alias_constraint.as_str()),
711 );
712 let download_url = non_version_download_url(&alias_constraint, resolved);
713
714 (
715 alias_namespace,
716 alias_name,
717 alias_constraint,
718 is_pinned,
719 dep_purl,
720 download_url,
721 )
722 } else {
723 let is_pinned = is_exact_version(&version);
724 let dep_purl = create_purl(
725 &dep_namespace,
726 &dep_name,
727 is_pinned.then_some(version.as_str()),
728 );
729 let download_url = non_version_download_url(&version, resolved);
730
731 (
732 dep_namespace.clone(),
733 dep_name.clone(),
734 version.clone(),
735 is_pinned,
736 dep_purl,
737 download_url,
738 )
739 };
740
741 let (sha1_from_integrity, sha512_from_integrity) = parse_integrity_field(integrity);
742 let sha1_from_url = resolved.and_then(parse_resolved_url);
743 let sha1 = sha1_from_integrity.or(sha1_from_url);
744
745 let mut dep_extra_data = HashMap::new();
746 if let Some(from) = from {
747 dep_extra_data.insert("from".to_string(), Value::String(from.to_string()));
748 }
749 if in_bundle {
750 dep_extra_data.insert("inBundle".to_string(), Value::Bool(true));
751 }
752
753 let resolved_package = ResolvedPackage {
754 primary_language: Some("JavaScript".to_string()),
755 download_url,
756 sha1: sha1.and_then(|h| Sha1Digest::from_hex(&h).ok()),
757 sha256: None,
758 sha512: sha512_from_integrity.and_then(|h| Sha512Digest::from_hex(&h).ok()),
759 md5: None,
760 is_virtual: true,
761 extra_data: None,
762 dependencies: nested_deps,
763 repository_homepage_url: None,
764 repository_download_url: None,
765 api_data_url: None,
766 datasource_id: Some(DatasourceId::NpmPackageLockJson),
767 purl: None,
768 ..ResolvedPackage::new(
769 NpmLockParser::PACKAGE_TYPE,
770 purl_namespace,
771 purl_name,
772 resolved_version,
773 )
774 };
775
776 Dependency {
777 purl: dep_purl,
778 extracted_requirement: Some(version),
779 scope: Some(scope.to_string()),
780 is_runtime: Some(is_runtime),
781 is_optional: Some(is_optional_flag),
782 is_pinned: Some(is_pinned),
783 is_direct: Some(is_direct),
784 resolved_package: Some(Box::new(resolved_package)),
785 extra_data: (!dep_extra_data.is_empty()).then_some(dep_extra_data),
786 }
787}
788
789fn build_link_dependency(
790 package_name: &str,
791 is_dev: bool,
792 is_dev_optional: bool,
793 is_optional: bool,
794 resolved: Option<&str>,
795 is_direct: bool,
796) -> Dependency {
797 let (dep_namespace, dep_name) = extract_namespace_and_name(package_name);
798 let (scope, is_runtime, is_optional_flag) =
799 determine_scope(is_dev, is_dev_optional, is_optional);
800 let mut extra_data = HashMap::from([("link".to_string(), Value::Bool(true))]);
801
802 if let Some(resolved) = resolved {
803 extra_data.insert("resolved".to_string(), Value::String(resolved.to_string()));
804 }
805
806 Dependency {
807 purl: create_purl(&dep_namespace, &dep_name, None),
808 extracted_requirement: resolved.map(str::to_string),
809 scope: Some(scope.to_string()),
810 is_runtime: Some(is_runtime),
811 is_optional: Some(is_optional_flag),
812 is_pinned: Some(false),
813 is_direct: Some(is_direct),
814 resolved_package: None,
815 extra_data: Some(extra_data),
816 }
817}
818
819crate::register_parser!(
820 "npm package-lock.json lockfile",
821 &[
822 "**/package-lock.json",
823 "**/.package-lock.json",
824 "**/npm-shrinkwrap.json"
825 ],
826 "npm",
827 "JavaScript",
828 Some("https://docs.npmjs.com/cli/v8/configuring-npm/package-lock-json"),
829);