@westbayberry/dg 1.0.10 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +88 -17
  2. package/dist/index.mjs +194 -158
  3. package/package.json +7 -3
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @westbayberry/dg
2
2
 
3
- Supply chain security scanner for npm and Python dependencies. Detects malicious packages, typosquatting, dependency confusion, and 20+ attack patterns before they reach production.
3
+ Supply chain security scanner for npm and Python dependencies. Scans lockfile changes against 26+ detectors to catch malicious packages, typosquatting, dependency confusion, credential theft, and obfuscated code before they reach production.
4
4
 
5
5
  ## Install
6
6
 
@@ -8,6 +8,8 @@ Supply chain security scanner for npm and Python dependencies. Detects malicious
8
8
  npm install -g @westbayberry/dg
9
9
  ```
10
10
 
11
+ Requires Node.js 18+.
12
+
11
13
  ## Quick Start
12
14
 
13
15
  ```bash
@@ -15,48 +17,93 @@ dg login
15
17
  dg scan
16
18
  ```
17
19
 
18
- The CLI auto-discovers projects in your directory tree npm lockfiles (package-lock.json, yarn.lock, pnpm-lock.yaml) and Python dependency files (requirements.txt, Pipfile.lock, poetry.lock). If multiple projects are found, you pick which ones to scan.
20
+ The CLI walks your directory tree and finds npm lockfiles (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`) and Python dependency files (`requirements.txt`, `Pipfile.lock`, `poetry.lock`). If multiple projects are found, an interactive selector lets you pick which ones to scan.
21
+
22
+ Only changed packages are scanned by default — `dg` diffs your lockfile against the git merge-base with `main` to find what's new or updated.
19
23
 
20
24
  ## Commands
21
25
 
22
26
  ```
23
- dg scan [options] Scan dependencies (auto-discovers npm + Python projects)
24
- dg npm install <pkg> Scan packages before installing
27
+ dg scan [options] Scan dependencies for supply chain threats
28
+ dg npm install <pkg> Scan packages before installing them
25
29
  dg login Authenticate with your WestBayBerry account
26
30
  dg logout Remove saved credentials
27
- dg hook install Install git pre-commit hook to scan lockfile changes
31
+ dg hook install Install git pre-commit hook
28
32
  dg hook uninstall Remove the pre-commit hook
29
33
  dg update Check for and install the latest version
30
34
  dg wrap Show instructions to alias npm to dg
31
35
  ```
32
36
 
33
- ### Scan Options
37
+ ## What It Detects
38
+
39
+ Each package is analyzed by 26+ detectors covering:
40
+
41
+ | Category | Examples |
42
+ |----------|----------|
43
+ | **Code execution** | `child_process` spawning, `eval`/`Function` calls, shell command injection |
44
+ | **Network exfiltration** | HTTP/WebSocket/DNS/gRPC calls, URL obfuscation, data exfil patterns |
45
+ | **Credential theft** | Reading SSH keys, browser tokens, cloud credentials, `.npmrc`/`.pypirc` |
46
+ | **Install scripts** | Suspicious `preinstall`/`postinstall` hooks, download-and-execute chains |
47
+ | **Obfuscation** | Hex/unicode encoding, string reconstruction, phantom eval, minified payloads |
48
+ | **Supply chain** | Typosquatting, dependency confusion, version squatting, borrowed repo URLs |
49
+ | **Persistence** | Writing to shell configs, cron jobs, systemd units, SSH `authorized_keys` |
50
+ | **Behavioral** | Time-gated payloads, purpose mismatch, runtime evasion, binary addons |
51
+ | **Reputation** | Missing/fake GitHub repos, ghost packages, low download counts |
52
+
53
+ Findings include severity (1–5), confidence (0–1), and code evidence with file paths and line numbers.
54
+
55
+ ## Scan Options
34
56
 
35
57
  | Flag | Default | Description |
36
58
  |------|---------|-------------|
37
59
  | `--mode <mode>` | `warn` | `block` / `warn` / `off` |
38
- | `--block-threshold <n>` | `70` | Score threshold for blocking |
39
- | `--warn-threshold <n>` | `60` | Score threshold for warnings |
60
+ | `--block-threshold <n>` | `70` | Score threshold for blocking (0–100) |
61
+ | `--warn-threshold <n>` | `60` | Score threshold for warnings (0–100) |
40
62
  | `--max-packages <n>` | `200` | Max packages per scan |
41
63
  | `--allowlist <pkgs>` | | Comma-separated packages to skip |
42
- | `--json` | | Output JSON for CI parsing |
64
+ | `--json` | | Output raw JSON (for CI parsing) |
43
65
  | `--scan-all` | | Scan all packages, not just changed |
44
66
  | `--base-lockfile <path>` | | Explicit base lockfile for diff |
45
67
  | `--workspace <dir>` | | Scan a specific workspace subdirectory |
46
- | `--debug` | | Show diagnostic output |
68
+ | `--debug` | | Show discovery, batching, and timing info |
47
69
 
48
- ### Exit Codes
70
+ ## Exit Codes
49
71
 
50
72
  | Code | Meaning | CI Action |
51
73
  |------|---------|-----------|
52
74
  | `0` | Pass | Continue |
53
75
  | `1` | Warning | Advisory — review recommended |
54
76
  | `2` | Block | Fail the pipeline |
55
- | `3` | Error | Internal error |
77
+ | `3` | Error | Internal error (auth, network, etc.) |
56
78
 
57
- ## CI Setup
79
+ ## Configuration
80
+
81
+ Settings can come from CLI flags, environment variables, or a `.dgrc.json` config file (searched in the current directory, then `~/`). CLI flags take highest precedence.
82
+
83
+ ### `.dgrc.json`
84
+
85
+ ```json
86
+ {
87
+ "apiKey": "dg_...",
88
+ "mode": "block",
89
+ "blockThreshold": 70,
90
+ "warnThreshold": 60,
91
+ "maxPackages": 200,
92
+ "allowlist": ["known-safe-pkg"]
93
+ }
94
+ ```
95
+
96
+ ### Environment Variables
97
+
98
+ | Variable | Description |
99
+ |----------|-------------|
100
+ | `DG_API_URL` | API base URL |
101
+ | `DG_MODE` | `block` / `warn` / `off` |
102
+ | `DG_ALLOWLIST` | Comma-separated allowlist |
103
+ | `DG_DEBUG` | Set to `1` for diagnostic output |
104
+ | `DG_WORKSPACE` | Workspace subdirectory |
58
105
 
59
- Authenticate first, then add the scan to your pipeline:
106
+ ## CI Setup
60
107
 
61
108
  ### GitHub Actions
62
109
 
@@ -74,6 +121,18 @@ npx @westbayberry/dg login
74
121
  npx @westbayberry/dg scan --mode block --json
75
122
  ```
76
123
 
124
+ The `--json` flag outputs machine-readable results. Exit code `2` signals a blocked scan — wire it into your pipeline to fail the build.
125
+
126
+ ### Monorepo / Workspace
127
+
128
+ Scan a specific workspace:
129
+
130
+ ```bash
131
+ dg scan --workspace packages/api
132
+ ```
133
+
134
+ Or let `dg` discover all projects and pick interactively.
135
+
77
136
  ## Git Hook
78
137
 
79
138
  Block commits that introduce risky dependencies:
@@ -82,22 +141,34 @@ Block commits that introduce risky dependencies:
82
141
  dg hook install
83
142
  ```
84
143
 
85
- This installs a pre-commit hook that runs `dg scan --mode block` whenever a lockfile change is staged. Use `dg hook uninstall` to remove it.
144
+ This adds a pre-commit hook that runs `dg scan --mode block` whenever a lockfile is staged. If any package scores above the block threshold, the commit is rejected. Remove it with `dg hook uninstall`.
86
145
 
87
146
  ## npm Wrapper
88
147
 
89
- Scan packages before installing:
148
+ Scan packages before they're installed:
90
149
 
91
150
  ```bash
92
151
  dg npm install express lodash
93
152
  ```
94
153
 
95
- Use `--dg-force` to bypass a block. Or alias npm globally:
154
+ Packages are resolved and scanned through the API. If a package is blocked, you'll get a confirmation prompt — press `y` to install anyway, or use `--dg-force` to skip the prompt.
155
+
156
+ To make this the default for all `npm install` commands:
96
157
 
97
158
  ```bash
98
159
  echo 'alias npm="dg npm"' >> ~/.zshrc
99
160
  ```
100
161
 
162
+ ## Python Support
163
+
164
+ Python projects are detected alongside npm. The scanner reads:
165
+
166
+ - `requirements.txt` — `name==version` pins
167
+ - `Pipfile.lock` — default and develop sections
168
+ - `poetry.lock` — `[[package]]` entries
169
+
170
+ Python packages are analyzed through the same detection engine against the PyPI registry.
171
+
101
172
  ## Links
102
173
 
103
174
  - [Dashboard](https://westbayberry.com/dashboard)
package/dist/index.mjs CHANGED
@@ -3296,7 +3296,7 @@ var require_react_development = __commonJS({
3296
3296
  }
3297
3297
  return dispatcher.useContext(Context);
3298
3298
  }
3299
- function useState5(initialState) {
3299
+ function useState6(initialState) {
3300
3300
  var dispatcher = resolveDispatcher();
3301
3301
  return dispatcher.useState(initialState);
3302
3302
  }
@@ -3308,7 +3308,7 @@ var require_react_development = __commonJS({
3308
3308
  var dispatcher = resolveDispatcher();
3309
3309
  return dispatcher.useRef(initialValue);
3310
3310
  }
3311
- function useEffect12(create2, deps) {
3311
+ function useEffect13(create2, deps) {
3312
3312
  var dispatcher = resolveDispatcher();
3313
3313
  return dispatcher.useEffect(create2, deps);
3314
3314
  }
@@ -4091,7 +4091,7 @@ var require_react_development = __commonJS({
4091
4091
  exports.useContext = useContext7;
4092
4092
  exports.useDebugValue = useDebugValue;
4093
4093
  exports.useDeferredValue = useDeferredValue;
4094
- exports.useEffect = useEffect12;
4094
+ exports.useEffect = useEffect13;
4095
4095
  exports.useId = useId;
4096
4096
  exports.useImperativeHandle = useImperativeHandle;
4097
4097
  exports.useInsertionEffect = useInsertionEffect;
@@ -4099,7 +4099,7 @@ var require_react_development = __commonJS({
4099
4099
  exports.useMemo = useMemo4;
4100
4100
  exports.useReducer = useReducer5;
4101
4101
  exports.useRef = useRef6;
4102
- exports.useState = useState5;
4102
+ exports.useState = useState6;
4103
4103
  exports.useSyncExternalStore = useSyncExternalStore;
4104
4104
  exports.useTransition = useTransition;
4105
4105
  exports.version = ReactVersion;
@@ -39587,6 +39587,144 @@ var init_lockfile = __esm({
39587
39587
  }
39588
39588
  });
39589
39589
 
39590
+ // src/discover.ts
39591
+ var discover_exports = {};
39592
+ __export(discover_exports, {
39593
+ discoverProjects: () => discoverProjects
39594
+ });
39595
+ import { existsSync as existsSync6, readFileSync as readFileSync7, readdirSync, statSync } from "node:fs";
39596
+ import { join as join6, relative, basename } from "node:path";
39597
+ function discoverProjects(root) {
39598
+ const projects = [];
39599
+ walk(root, root, 0, projects);
39600
+ return projects.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
39601
+ }
39602
+ function walk(dir, root, depth, out) {
39603
+ if (depth > MAX_DEPTH) return;
39604
+ for (const lockfile of NPM_LOCKFILES) {
39605
+ const lockPath = join6(dir, lockfile);
39606
+ if (existsSync6(lockPath)) {
39607
+ const count = countNpmPackages(lockPath);
39608
+ if (count > 0) {
39609
+ out.push({
39610
+ path: dir,
39611
+ relativePath: relative(root, dir) || ".",
39612
+ ecosystem: "npm",
39613
+ depFile: lockfile,
39614
+ packageCount: count
39615
+ });
39616
+ }
39617
+ break;
39618
+ }
39619
+ }
39620
+ for (const depFile of PYTHON_DEPFILES) {
39621
+ const depPath = join6(dir, depFile);
39622
+ if (existsSync6(depPath)) {
39623
+ const count = countPythonPackages(depPath, depFile);
39624
+ if (count > 0) {
39625
+ out.push({
39626
+ path: dir,
39627
+ relativePath: relative(root, dir) || ".",
39628
+ ecosystem: "pypi",
39629
+ depFile,
39630
+ packageCount: count
39631
+ });
39632
+ }
39633
+ break;
39634
+ }
39635
+ }
39636
+ let entries;
39637
+ try {
39638
+ entries = readdirSync(dir);
39639
+ } catch {
39640
+ return;
39641
+ }
39642
+ for (const entry of entries) {
39643
+ if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue;
39644
+ const full = join6(dir, entry);
39645
+ try {
39646
+ if (statSync(full).isDirectory()) {
39647
+ walk(full, root, depth + 1, out);
39648
+ }
39649
+ } catch {
39650
+ }
39651
+ }
39652
+ }
39653
+ function countNpmPackages(lockPath) {
39654
+ try {
39655
+ const name = basename(lockPath);
39656
+ const content = readFileSync7(lockPath, "utf-8");
39657
+ if (name === "yarn.lock") {
39658
+ return (content.match(/^\S.*:$/gm) || []).length;
39659
+ }
39660
+ if (name === "pnpm-lock.yaml") {
39661
+ return (content.match(/^\s{2}'?[/@]/gm) || []).length || estimateLines(content, 200);
39662
+ }
39663
+ const parsed = JSON.parse(content);
39664
+ if (parsed.packages) {
39665
+ return Object.keys(parsed.packages).filter((k) => k !== "").length;
39666
+ }
39667
+ if (parsed.dependencies) {
39668
+ return Object.keys(parsed.dependencies).length;
39669
+ }
39670
+ return 0;
39671
+ } catch {
39672
+ return 0;
39673
+ }
39674
+ }
39675
+ function countPythonPackages(depPath, depFile) {
39676
+ try {
39677
+ const content = readFileSync7(depPath, "utf-8");
39678
+ if (depFile === "Pipfile.lock") {
39679
+ const parsed = JSON.parse(content);
39680
+ const defaultCount = Object.keys(parsed.default || {}).length;
39681
+ const devCount = Object.keys(parsed.develop || {}).length;
39682
+ return defaultCount + devCount;
39683
+ }
39684
+ if (depFile === "poetry.lock") {
39685
+ return (content.match(/^\[\[package\]\]/gm) || []).length;
39686
+ }
39687
+ return content.split("\n").filter(
39688
+ (line) => line.trim() && !line.trim().startsWith("#") && !line.trim().startsWith("-")
39689
+ ).length;
39690
+ } catch {
39691
+ return 0;
39692
+ }
39693
+ }
39694
+ function estimateLines(content, fallback) {
39695
+ const lines = content.split("\n").length;
39696
+ return lines > 20 ? Math.floor(lines / 4) : fallback;
39697
+ }
39698
+ var SKIP_DIRS, NPM_LOCKFILES, PYTHON_DEPFILES, MAX_DEPTH;
39699
+ var init_discover = __esm({
39700
+ "src/discover.ts"() {
39701
+ "use strict";
39702
+ SKIP_DIRS = /* @__PURE__ */ new Set([
39703
+ "node_modules",
39704
+ ".venv",
39705
+ "venv",
39706
+ "__pycache__",
39707
+ ".git",
39708
+ ".tox",
39709
+ ".eggs",
39710
+ "dist",
39711
+ "build",
39712
+ ".next",
39713
+ ".nuxt",
39714
+ "coverage",
39715
+ ".cache",
39716
+ ".pytest_cache",
39717
+ ".mypy_cache",
39718
+ "validation-results",
39719
+ "test-fixtures",
39720
+ "fixtures"
39721
+ ]);
39722
+ NPM_LOCKFILES = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "npm-shrinkwrap.json"];
39723
+ PYTHON_DEPFILES = ["requirements.txt", "Pipfile.lock", "poetry.lock"];
39724
+ MAX_DEPTH = 8;
39725
+ }
39726
+ });
39727
+
39590
39728
  // src/static-output.ts
39591
39729
  var static_output_exports = {};
39592
39730
  __export(static_output_exports, {
@@ -39750,7 +39888,24 @@ async function runStatic(config) {
39750
39888
  const discovery = discoverChanges(process.cwd(), config);
39751
39889
  dbg(`discovery method: ${discovery.method}`);
39752
39890
  if (discovery.packages.length === 0) {
39753
- process.stderr.write(import_chalk4.default.dim(" No package changes detected.\n"));
39891
+ const { discoverProjects: discoverProjects2 } = await Promise.resolve().then(() => (init_discover(), discover_exports));
39892
+ const subProjects = discoverProjects2(process.cwd());
39893
+ if (subProjects.length > 0) {
39894
+ process.stderr.write(import_chalk4.default.yellow(" No packages found in current directory, but found projects in subdirectories:\n\n"));
39895
+ for (const proj of subProjects) {
39896
+ const pkgLabel = proj.packageCount === 1 ? "package" : "packages";
39897
+ process.stderr.write(
39898
+ import_chalk4.default.white(` ${proj.ecosystem} `) + import_chalk4.default.cyan(proj.relativePath) + import_chalk4.default.dim(` (${proj.packageCount} ${pkgLabel})
39899
+ `)
39900
+ );
39901
+ }
39902
+ process.stderr.write(import_chalk4.default.dim("\n Use --workspace to scan a specific project:\n"));
39903
+ process.stderr.write(import_chalk4.default.dim(` dg scan --workspace ${subProjects[0].relativePath}${config.scanAll ? " --scan-all" : ""}
39904
+
39905
+ `));
39906
+ } else {
39907
+ process.stderr.write(import_chalk4.default.dim(" No package changes detected.\n"));
39908
+ }
39754
39909
  process.exit(0);
39755
39910
  }
39756
39911
  const packages = discovery.packages.filter(
@@ -40037,14 +40192,14 @@ __export(hook_exports, {
40037
40192
  });
40038
40193
  import { execFileSync as execFileSync2 } from "node:child_process";
40039
40194
  import {
40040
- existsSync as existsSync6,
40041
- readFileSync as readFileSync7,
40195
+ existsSync as existsSync7,
40196
+ readFileSync as readFileSync8,
40042
40197
  writeFileSync as writeFileSync3,
40043
40198
  mkdirSync,
40044
40199
  chmodSync as chmodSync2,
40045
40200
  unlinkSync as unlinkSync2
40046
40201
  } from "node:fs";
40047
- import { join as join6 } from "node:path";
40202
+ import { join as join7 } from "node:path";
40048
40203
  function findGitDir() {
40049
40204
  try {
40050
40205
  return execFileSync2("git", ["rev-parse", "--git-dir"], {
@@ -40057,11 +40212,11 @@ function findGitDir() {
40057
40212
  }
40058
40213
  function installHook() {
40059
40214
  const gitDir = findGitDir();
40060
- const hooksDir = join6(gitDir, "hooks");
40061
- const hookPath = join6(hooksDir, "pre-commit");
40215
+ const hooksDir = join7(gitDir, "hooks");
40216
+ const hookPath = join7(hooksDir, "pre-commit");
40062
40217
  mkdirSync(hooksDir, { recursive: true });
40063
- if (existsSync6(hookPath)) {
40064
- const existing = readFileSync7(hookPath, "utf-8");
40218
+ if (existsSync7(hookPath)) {
40219
+ const existing = readFileSync8(hookPath, "utf-8");
40065
40220
  if (existing.includes(HOOK_MARKER)) {
40066
40221
  process.stderr.write(" Hook already installed.\n");
40067
40222
  return;
@@ -40081,12 +40236,12 @@ function installHook() {
40081
40236
  }
40082
40237
  function uninstallHook() {
40083
40238
  const gitDir = findGitDir();
40084
- const hookPath = join6(gitDir, "hooks", "pre-commit");
40085
- if (!existsSync6(hookPath)) {
40239
+ const hookPath = join7(gitDir, "hooks", "pre-commit");
40240
+ if (!existsSync7(hookPath)) {
40086
40241
  process.stderr.write(" No hook to remove.\n");
40087
40242
  return;
40088
40243
  }
40089
- const content = readFileSync7(hookPath, "utf-8");
40244
+ const content = readFileSync8(hookPath, "utf-8");
40090
40245
  if (!content.includes(HOOK_MARKER)) {
40091
40246
  process.stderr.write(
40092
40247
  " No Dependency Guardian hook found in pre-commit.\n"
@@ -40807,140 +40962,6 @@ var init_NpmWrapperApp = __esm({
40807
40962
  }
40808
40963
  });
40809
40964
 
40810
- // src/discover.ts
40811
- import { existsSync as existsSync7, readFileSync as readFileSync8, readdirSync, statSync } from "node:fs";
40812
- import { join as join7, relative, basename } from "node:path";
40813
- function discoverProjects(root) {
40814
- const projects = [];
40815
- walk(root, root, 0, projects);
40816
- return projects.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
40817
- }
40818
- function walk(dir, root, depth, out) {
40819
- if (depth > MAX_DEPTH) return;
40820
- for (const lockfile of NPM_LOCKFILES) {
40821
- const lockPath = join7(dir, lockfile);
40822
- if (existsSync7(lockPath)) {
40823
- const count = countNpmPackages(lockPath);
40824
- if (count > 0) {
40825
- out.push({
40826
- path: dir,
40827
- relativePath: relative(root, dir) || ".",
40828
- ecosystem: "npm",
40829
- depFile: lockfile,
40830
- packageCount: count
40831
- });
40832
- }
40833
- break;
40834
- }
40835
- }
40836
- for (const depFile of PYTHON_DEPFILES) {
40837
- const depPath = join7(dir, depFile);
40838
- if (existsSync7(depPath)) {
40839
- const count = countPythonPackages(depPath, depFile);
40840
- if (count > 0) {
40841
- out.push({
40842
- path: dir,
40843
- relativePath: relative(root, dir) || ".",
40844
- ecosystem: "pypi",
40845
- depFile,
40846
- packageCount: count
40847
- });
40848
- }
40849
- break;
40850
- }
40851
- }
40852
- let entries;
40853
- try {
40854
- entries = readdirSync(dir);
40855
- } catch {
40856
- return;
40857
- }
40858
- for (const entry of entries) {
40859
- if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue;
40860
- const full = join7(dir, entry);
40861
- try {
40862
- if (statSync(full).isDirectory()) {
40863
- walk(full, root, depth + 1, out);
40864
- }
40865
- } catch {
40866
- }
40867
- }
40868
- }
40869
- function countNpmPackages(lockPath) {
40870
- try {
40871
- const name = basename(lockPath);
40872
- const content = readFileSync8(lockPath, "utf-8");
40873
- if (name === "yarn.lock") {
40874
- return (content.match(/^\S.*:$/gm) || []).length;
40875
- }
40876
- if (name === "pnpm-lock.yaml") {
40877
- return (content.match(/^\s{2}'?[/@]/gm) || []).length || estimateLines(content, 200);
40878
- }
40879
- const parsed = JSON.parse(content);
40880
- if (parsed.packages) {
40881
- return Object.keys(parsed.packages).filter((k) => k !== "").length;
40882
- }
40883
- if (parsed.dependencies) {
40884
- return Object.keys(parsed.dependencies).length;
40885
- }
40886
- return 0;
40887
- } catch {
40888
- return 0;
40889
- }
40890
- }
40891
- function countPythonPackages(depPath, depFile) {
40892
- try {
40893
- const content = readFileSync8(depPath, "utf-8");
40894
- if (depFile === "Pipfile.lock") {
40895
- const parsed = JSON.parse(content);
40896
- const defaultCount = Object.keys(parsed.default || {}).length;
40897
- const devCount = Object.keys(parsed.develop || {}).length;
40898
- return defaultCount + devCount;
40899
- }
40900
- if (depFile === "poetry.lock") {
40901
- return (content.match(/^\[\[package\]\]/gm) || []).length;
40902
- }
40903
- return content.split("\n").filter(
40904
- (line) => line.trim() && !line.trim().startsWith("#") && !line.trim().startsWith("-")
40905
- ).length;
40906
- } catch {
40907
- return 0;
40908
- }
40909
- }
40910
- function estimateLines(content, fallback) {
40911
- const lines = content.split("\n").length;
40912
- return lines > 20 ? Math.floor(lines / 4) : fallback;
40913
- }
40914
- var SKIP_DIRS, NPM_LOCKFILES, PYTHON_DEPFILES, MAX_DEPTH;
40915
- var init_discover = __esm({
40916
- "src/discover.ts"() {
40917
- "use strict";
40918
- SKIP_DIRS = /* @__PURE__ */ new Set([
40919
- "node_modules",
40920
- ".venv",
40921
- "venv",
40922
- "__pycache__",
40923
- ".git",
40924
- ".tox",
40925
- ".eggs",
40926
- "dist",
40927
- "build",
40928
- ".next",
40929
- ".nuxt",
40930
- "coverage",
40931
- ".cache",
40932
- ".pytest_cache",
40933
- ".mypy_cache",
40934
- "validation-results",
40935
- "test-fixtures",
40936
- "fixtures"
40937
- ]);
40938
- NPM_LOCKFILES = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "npm-shrinkwrap.json"];
40939
- PYTHON_DEPFILES = ["requirements.txt", "Pipfile.lock", "poetry.lock"];
40940
- MAX_DEPTH = 8;
40941
- }
40942
- });
40943
-
40944
40965
  // src/ui/hooks/useScan.ts
40945
40966
  function reducer3(_state, action) {
40946
40967
  switch (action.type) {
@@ -40963,14 +40984,20 @@ function reducer3(_state, action) {
40963
40984
  function useScan(config) {
40964
40985
  const [state, dispatch] = (0, import_react27.useReducer)(reducer3, { phase: "discovering" });
40965
40986
  const started = (0, import_react27.useRef)(false);
40966
- const discoveredProjects = (0, import_react27.useRef)(null);
40987
+ const [multiProjects, setMultiProjects] = (0, import_react27.useState)(null);
40967
40988
  (0, import_react27.useEffect)(() => {
40968
40989
  if (started.current) return;
40969
40990
  started.current = true;
40991
+ const projects = discoverProjects(process.cwd());
40992
+ if (projects.length > 1) setMultiProjects(projects);
40970
40993
  try {
40971
40994
  const discovery = discoverChanges(process.cwd(), config);
40972
40995
  const packages = discovery.packages.filter((p) => !config.allowlist.includes(p.name));
40973
40996
  if (packages.length === 0) {
40997
+ if (discovery.packages.length === 0 && projects.length > 0) {
40998
+ dispatch({ type: "PROJECTS_FOUND", projects });
40999
+ return;
41000
+ }
40974
41001
  const message = discovery.packages.length === 0 ? "No package changes detected." : "All changed packages are allowlisted.";
40975
41002
  dispatch({ type: "DISCOVERY_EMPTY", message });
40976
41003
  return;
@@ -40978,8 +41005,6 @@ function useScan(config) {
40978
41005
  dispatch({ type: "DISCOVERY_COMPLETE", packages, skippedCount: discovery.skipped.length });
40979
41006
  runNpmScan(packages, discovery.skipped.length, config, dispatch);
40980
41007
  } catch {
40981
- const projects = discoverProjects(process.cwd());
40982
- discoveredProjects.current = projects.length > 1 ? projects : null;
40983
41008
  if (projects.length === 0) {
40984
41009
  dispatch({ type: "DISCOVERY_EMPTY", message: "No dependency files found." });
40985
41010
  return;
@@ -40997,13 +41022,13 @@ function useScan(config) {
40997
41022
  scanProjects(projects, config, dispatch);
40998
41023
  }, [config]);
40999
41024
  const restartSelection = (0, import_react27.useCallback)(() => {
41000
- if (!discoveredProjects.current) return;
41001
- dispatch({ type: "RESTART_SELECTION", projects: discoveredProjects.current });
41002
- }, []);
41025
+ if (!multiProjects) return;
41026
+ dispatch({ type: "RESTART_SELECTION", projects: multiProjects });
41027
+ }, [multiProjects]);
41003
41028
  return {
41004
41029
  state,
41005
41030
  scanSelectedProjects,
41006
- restartSelection: discoveredProjects.current ? restartSelection : null
41031
+ restartSelection: multiProjects ? restartSelection : null
41007
41032
  };
41008
41033
  }
41009
41034
  async function runNpmScan(packages, skippedCount, config, dispatch) {
@@ -41189,7 +41214,7 @@ function groupPackages3(packages) {
41189
41214
  group.push(pkg);
41190
41215
  map.set(fingerprint, group);
41191
41216
  }
41192
- return [...map.values()].map((pkgs) => ({ packages: pkgs, key: pkgs[0].name })).sort((a, b) => b.packages[0].score - a.packages[0].score);
41217
+ return [...map.entries()].map(([fingerprint, pkgs]) => ({ packages: pkgs, key: fingerprint })).sort((a, b) => b.packages[0].score - a.packages[0].score);
41193
41218
  }
41194
41219
  function actionBadge4(score, config) {
41195
41220
  if (score >= config.blockThreshold)
@@ -41394,6 +41419,11 @@ var init_InteractiveResultsView = __esm({
41394
41419
  if (expIdx === cursor && expLvl === "detail") return;
41395
41420
  const newVp = adjustViewport(cursor, cursor, "detail", vpStart);
41396
41421
  dispatchView({ type: "EXPAND", expandedIndex: cursor, expandLevel: "detail", viewport: newVp });
41422
+ } else if (input === "c") {
41423
+ if (expIdx !== null && expLvl !== null) {
41424
+ const newVp = adjustViewport(cursor, null, null, vpStart);
41425
+ dispatchView({ type: "EXPAND", expandedIndex: null, expandLevel: null, viewport: newVp });
41426
+ }
41397
41427
  } else if (input === "b" && onBack) {
41398
41428
  onBack();
41399
41429
  } else if (input === "q") {
@@ -41519,7 +41549,10 @@ var init_InteractiveResultsView = __esm({
41519
41549
  " toggle",
41520
41550
  " ",
41521
41551
  import_chalk10.default.cyan("e"),
41522
- " detail",
41552
+ " expand",
41553
+ " ",
41554
+ import_chalk10.default.cyan("c"),
41555
+ " collapse",
41523
41556
  " ",
41524
41557
  onBack && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(import_jsx_runtime10.Fragment, { children: [
41525
41558
  import_chalk10.default.cyan("b"),
@@ -41665,6 +41698,9 @@ var init_ProjectSelector = __esm({
41665
41698
  import_jsx_runtime11 = __toESM(require_jsx_runtime());
41666
41699
  ProjectSelector = ({ projects, onConfirm, onCancel }) => {
41667
41700
  const [cursor, setCursor] = (0, import_react30.useState)(0);
41701
+ (0, import_react30.useEffect)(() => {
41702
+ process.stdout.write("\x1B[2J\x1B[H");
41703
+ }, []);
41668
41704
  const [selected, setSelected] = (0, import_react30.useState)(() => new Set(projects.map((_, i) => i)));
41669
41705
  use_input_default((input, key) => {
41670
41706
  if (key.upArrow) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@westbayberry/dg",
3
- "version": "1.0.10",
4
- "description": "Supply chain security scanner scan npm dependencies in any CI or terminal",
3
+ "version": "1.0.14",
4
+ "description": "Supply chain security scanner for npm and Python dependencies detects malicious packages, typosquatting, dependency confusion, and 26+ attack patterns",
5
5
  "bin": {
6
6
  "dependency-guardian": "dist/index.mjs",
7
7
  "dg": "dist/index.mjs"
@@ -15,10 +15,14 @@
15
15
  "keywords": [
16
16
  "security",
17
17
  "npm",
18
+ "pypi",
18
19
  "supply-chain",
19
20
  "scanner",
20
21
  "cli",
21
- "dependencies"
22
+ "dependencies",
23
+ "malware",
24
+ "typosquatting",
25
+ "dependency-confusion"
22
26
  ],
23
27
  "publishConfig": {
24
28
  "access": "public"