@westbayberry/dg 1.0.13 → 1.0.16

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 +405 -278
  3. package/package.json +11 -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
@@ -114,12 +114,6 @@ function parseConfig(argv) {
114
114
  const noConfig = values["no-config"];
115
115
  const dgrc = noConfig ? {} : loadDgrc();
116
116
  const apiKey = dgrc.apiKey ?? "";
117
- if (!apiKey) {
118
- process.stderr.write(
119
- "Error: Not logged in. Run `dg login` to authenticate.\n"
120
- );
121
- process.exit(1);
122
- }
123
117
  const modeRaw = values.mode ?? process.env.DG_MODE ?? dgrc.mode ?? "warn";
124
118
  if (!["block", "warn", "off"].includes(modeRaw)) {
125
119
  process.stderr.write(
@@ -131,7 +125,10 @@ function parseConfig(argv) {
131
125
  const allowlistRaw = values.allowlist ?? process.env.DG_ALLOWLIST ?? "";
132
126
  const blockThreshold = Number(values["block-threshold"] ?? dgrc.blockThreshold ?? "70");
133
127
  const warnThreshold = Number(values["warn-threshold"] ?? dgrc.warnThreshold ?? "60");
134
- const maxPackages = Number(values["max-packages"] ?? dgrc.maxPackages ?? "200");
128
+ let maxPackages = Number(values["max-packages"] ?? dgrc.maxPackages ?? "200");
129
+ if (!apiKey) {
130
+ maxPackages = Math.min(maxPackages, 50);
131
+ }
135
132
  const debug = values.debug || process.env.DG_DEBUG === "1";
136
133
  if (isNaN(blockThreshold) || blockThreshold < 0 || blockThreshold > 100) {
137
134
  process.stderr.write("Error: --block-threshold must be a number between 0 and 100\n");
@@ -3296,7 +3293,7 @@ var require_react_development = __commonJS({
3296
3293
  }
3297
3294
  return dispatcher.useContext(Context);
3298
3295
  }
3299
- function useState6(initialState) {
3296
+ function useState7(initialState) {
3300
3297
  var dispatcher = resolveDispatcher();
3301
3298
  return dispatcher.useState(initialState);
3302
3299
  }
@@ -3304,7 +3301,7 @@ var require_react_development = __commonJS({
3304
3301
  var dispatcher = resolveDispatcher();
3305
3302
  return dispatcher.useReducer(reducer4, initialArg, init);
3306
3303
  }
3307
- function useRef6(initialValue) {
3304
+ function useRef7(initialValue) {
3308
3305
  var dispatcher = resolveDispatcher();
3309
3306
  return dispatcher.useRef(initialValue);
3310
3307
  }
@@ -4098,8 +4095,8 @@ var require_react_development = __commonJS({
4098
4095
  exports.useLayoutEffect = useLayoutEffect2;
4099
4096
  exports.useMemo = useMemo4;
4100
4097
  exports.useReducer = useReducer5;
4101
- exports.useRef = useRef6;
4102
- exports.useState = useState6;
4098
+ exports.useRef = useRef7;
4099
+ exports.useState = useState7;
4103
4100
  exports.useSyncExternalStore = useSyncExternalStore;
4104
4101
  exports.useTransition = useTransition;
4105
4102
  exports.version = ReactVersion;
@@ -38917,15 +38914,35 @@ async function callAnalyzeAPI(packages, config, onProgress) {
38917
38914
  for (let i = 0; i < packages.length; i += BATCH_SIZE) {
38918
38915
  batches.push(packages.slice(i, i + BATCH_SIZE));
38919
38916
  }
38920
- const results = [];
38917
+ const MAX_CONCURRENT_BATCHES = 2;
38918
+ const results = new Array(batches.length);
38921
38919
  let completed = 0;
38922
- for (const batch of batches) {
38923
- const result = await callBatchWithRetry(batch, config);
38924
- completed += batch.length;
38920
+ let nextIdx = 0;
38921
+ async function runBatch(idx) {
38922
+ const result = await callBatchWithRetry(batches[idx], config);
38923
+ results[idx] = result;
38924
+ completed += batches[idx].length;
38925
38925
  if (onProgress) {
38926
- onProgress(completed, packages.length, batch.map((p) => p.name));
38926
+ onProgress(completed, packages.length, batches[idx].map((p) => p.name));
38927
+ }
38928
+ }
38929
+ const inFlight = /* @__PURE__ */ new Set();
38930
+ while (nextIdx < batches.length && inFlight.size < MAX_CONCURRENT_BATCHES) {
38931
+ const idx = nextIdx++;
38932
+ const p = runBatch(idx).then(() => {
38933
+ inFlight.delete(p);
38934
+ });
38935
+ inFlight.add(p);
38936
+ }
38937
+ while (inFlight.size > 0) {
38938
+ await Promise.race(inFlight);
38939
+ while (nextIdx < batches.length && inFlight.size < MAX_CONCURRENT_BATCHES) {
38940
+ const idx = nextIdx++;
38941
+ const p = runBatch(idx).then(() => {
38942
+ inFlight.delete(p);
38943
+ });
38944
+ inFlight.add(p);
38927
38945
  }
38928
- results.push(result);
38929
38946
  }
38930
38947
  return mergeResponses(results, config);
38931
38948
  }
@@ -38984,13 +39001,16 @@ async function callAnalyzeBatch(packages, config) {
38984
39001
  const timeoutId = setTimeout(() => controller.abort(), 12e4);
38985
39002
  let response;
38986
39003
  try {
39004
+ const headers = {
39005
+ "Content-Type": "application/json",
39006
+ "User-Agent": "dependency-guardian-cli/1.0.0"
39007
+ };
39008
+ if (config.apiKey) {
39009
+ headers["Authorization"] = `Bearer ${config.apiKey}`;
39010
+ }
38987
39011
  response = await fetch(url, {
38988
39012
  method: "POST",
38989
- headers: {
38990
- "Content-Type": "application/json",
38991
- Authorization: `Bearer ${config.apiKey}`,
38992
- "User-Agent": "dependency-guardian-cli/1.0.0"
38993
- },
39013
+ headers,
38994
39014
  body: JSON.stringify(payload),
38995
39015
  signal: controller.signal
38996
39016
  });
@@ -39012,6 +39032,14 @@ async function callAnalyzeBatch(packages, config) {
39012
39032
  );
39013
39033
  }
39014
39034
  if (response.status === 429) {
39035
+ const body = await response.json().catch(() => ({}));
39036
+ if (body.anonymous) {
39037
+ throw new APIError(
39038
+ "Anonymous scan limit reached. Run `dg login` for 200 free scans/month.",
39039
+ 429,
39040
+ ""
39041
+ );
39042
+ }
39015
39043
  throw new APIError(
39016
39044
  "Rate limit exceeded. Upgrade your plan at https://westbayberry.com/pricing",
39017
39045
  429,
@@ -39064,13 +39092,16 @@ async function callPyPIBatch(packages, config) {
39064
39092
  const timeoutId = setTimeout(() => controller.abort(), 12e4);
39065
39093
  let response;
39066
39094
  try {
39095
+ const headers = {
39096
+ "Content-Type": "application/json",
39097
+ "User-Agent": "dependency-guardian-cli/1.0.0"
39098
+ };
39099
+ if (config.apiKey) {
39100
+ headers["Authorization"] = `Bearer ${config.apiKey}`;
39101
+ }
39067
39102
  response = await fetch(url, {
39068
39103
  method: "POST",
39069
- headers: {
39070
- "Content-Type": "application/json",
39071
- Authorization: `Bearer ${config.apiKey}`,
39072
- "User-Agent": "dependency-guardian-cli/1.0.0"
39073
- },
39104
+ headers,
39074
39105
  body: JSON.stringify(payload),
39075
39106
  signal: controller.signal
39076
39107
  });
@@ -39092,6 +39123,14 @@ async function callPyPIBatch(packages, config) {
39092
39123
  );
39093
39124
  }
39094
39125
  if (response.status === 429) {
39126
+ const body = await response.json().catch(() => ({}));
39127
+ if (body.anonymous) {
39128
+ throw new APIError(
39129
+ "Anonymous scan limit reached. Run `dg login` for 200 free scans/month.",
39130
+ 429,
39131
+ ""
39132
+ );
39133
+ }
39095
39134
  throw new APIError(
39096
39135
  "Rate limit exceeded. Upgrade your plan at https://westbayberry.com/pricing",
39097
39136
  429,
@@ -39116,7 +39155,7 @@ var init_api = __esm({
39116
39155
  this.name = "APIError";
39117
39156
  }
39118
39157
  };
39119
- BATCH_SIZE = 15;
39158
+ BATCH_SIZE = 200;
39120
39159
  MAX_RETRIES = 2;
39121
39160
  RETRY_DELAY_MS = 5e3;
39122
39161
  }
@@ -39587,6 +39626,144 @@ var init_lockfile = __esm({
39587
39626
  }
39588
39627
  });
39589
39628
 
39629
+ // src/discover.ts
39630
+ var discover_exports = {};
39631
+ __export(discover_exports, {
39632
+ discoverProjects: () => discoverProjects
39633
+ });
39634
+ import { existsSync as existsSync6, readFileSync as readFileSync7, readdirSync, statSync } from "node:fs";
39635
+ import { join as join6, relative, basename } from "node:path";
39636
+ function discoverProjects(root) {
39637
+ const projects = [];
39638
+ walk(root, root, 0, projects);
39639
+ return projects.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
39640
+ }
39641
+ function walk(dir, root, depth, out) {
39642
+ if (depth > MAX_DEPTH) return;
39643
+ for (const lockfile of NPM_LOCKFILES) {
39644
+ const lockPath = join6(dir, lockfile);
39645
+ if (existsSync6(lockPath)) {
39646
+ const count = countNpmPackages(lockPath);
39647
+ if (count > 0) {
39648
+ out.push({
39649
+ path: dir,
39650
+ relativePath: relative(root, dir) || ".",
39651
+ ecosystem: "npm",
39652
+ depFile: lockfile,
39653
+ packageCount: count
39654
+ });
39655
+ }
39656
+ break;
39657
+ }
39658
+ }
39659
+ for (const depFile of PYTHON_DEPFILES) {
39660
+ const depPath = join6(dir, depFile);
39661
+ if (existsSync6(depPath)) {
39662
+ const count = countPythonPackages(depPath, depFile);
39663
+ if (count > 0) {
39664
+ out.push({
39665
+ path: dir,
39666
+ relativePath: relative(root, dir) || ".",
39667
+ ecosystem: "pypi",
39668
+ depFile,
39669
+ packageCount: count
39670
+ });
39671
+ }
39672
+ break;
39673
+ }
39674
+ }
39675
+ let entries;
39676
+ try {
39677
+ entries = readdirSync(dir);
39678
+ } catch {
39679
+ return;
39680
+ }
39681
+ for (const entry of entries) {
39682
+ if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue;
39683
+ const full = join6(dir, entry);
39684
+ try {
39685
+ if (statSync(full).isDirectory()) {
39686
+ walk(full, root, depth + 1, out);
39687
+ }
39688
+ } catch {
39689
+ }
39690
+ }
39691
+ }
39692
+ function countNpmPackages(lockPath) {
39693
+ try {
39694
+ const name = basename(lockPath);
39695
+ const content = readFileSync7(lockPath, "utf-8");
39696
+ if (name === "yarn.lock") {
39697
+ return (content.match(/^\S.*:$/gm) || []).length;
39698
+ }
39699
+ if (name === "pnpm-lock.yaml") {
39700
+ return (content.match(/^\s{2}'?[/@]/gm) || []).length || estimateLines(content, 200);
39701
+ }
39702
+ const parsed = JSON.parse(content);
39703
+ if (parsed.packages) {
39704
+ return Object.keys(parsed.packages).filter((k) => k !== "").length;
39705
+ }
39706
+ if (parsed.dependencies) {
39707
+ return Object.keys(parsed.dependencies).length;
39708
+ }
39709
+ return 0;
39710
+ } catch {
39711
+ return 0;
39712
+ }
39713
+ }
39714
+ function countPythonPackages(depPath, depFile) {
39715
+ try {
39716
+ const content = readFileSync7(depPath, "utf-8");
39717
+ if (depFile === "Pipfile.lock") {
39718
+ const parsed = JSON.parse(content);
39719
+ const defaultCount = Object.keys(parsed.default || {}).length;
39720
+ const devCount = Object.keys(parsed.develop || {}).length;
39721
+ return defaultCount + devCount;
39722
+ }
39723
+ if (depFile === "poetry.lock") {
39724
+ return (content.match(/^\[\[package\]\]/gm) || []).length;
39725
+ }
39726
+ return content.split("\n").filter(
39727
+ (line) => line.trim() && !line.trim().startsWith("#") && !line.trim().startsWith("-")
39728
+ ).length;
39729
+ } catch {
39730
+ return 0;
39731
+ }
39732
+ }
39733
+ function estimateLines(content, fallback) {
39734
+ const lines = content.split("\n").length;
39735
+ return lines > 20 ? Math.floor(lines / 4) : fallback;
39736
+ }
39737
+ var SKIP_DIRS, NPM_LOCKFILES, PYTHON_DEPFILES, MAX_DEPTH;
39738
+ var init_discover = __esm({
39739
+ "src/discover.ts"() {
39740
+ "use strict";
39741
+ SKIP_DIRS = /* @__PURE__ */ new Set([
39742
+ "node_modules",
39743
+ ".venv",
39744
+ "venv",
39745
+ "__pycache__",
39746
+ ".git",
39747
+ ".tox",
39748
+ ".eggs",
39749
+ "dist",
39750
+ "build",
39751
+ ".next",
39752
+ ".nuxt",
39753
+ "coverage",
39754
+ ".cache",
39755
+ ".pytest_cache",
39756
+ ".mypy_cache",
39757
+ "validation-results",
39758
+ "test-fixtures",
39759
+ "fixtures"
39760
+ ]);
39761
+ NPM_LOCKFILES = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "npm-shrinkwrap.json"];
39762
+ PYTHON_DEPFILES = ["requirements.txt", "Pipfile.lock", "poetry.lock"];
39763
+ MAX_DEPTH = 8;
39764
+ }
39765
+ });
39766
+
39590
39767
  // src/static-output.ts
39591
39768
  var static_output_exports = {};
39592
39769
  __export(static_output_exports, {
@@ -39617,7 +39794,7 @@ function truncate(s, max) {
39617
39794
  function groupPackages(packages) {
39618
39795
  const map = /* @__PURE__ */ new Map();
39619
39796
  for (const pkg of packages) {
39620
- const fingerprint = pkg.findings.length === 0 ? `__clean_${pkg.score}` : pkg.findings.map((f) => `${f.id}:${f.severity}`).sort().join("|") + `|score:${pkg.score}`;
39797
+ const fingerprint = pkg.findings.length === 0 ? `__clean_${pkg.score}` : pkg.findings.map((f) => `${f.category}:${f.severity}`).sort().join("|") + `|score:${pkg.score}`;
39621
39798
  const group = map.get(fingerprint) ?? [];
39622
39799
  group.push(pkg);
39623
39800
  map.set(fingerprint, group);
@@ -39699,7 +39876,7 @@ function renderResultStatic(result, config) {
39699
39876
  const sevLabel = SEVERITY_LABELS[finding.severity] ?? "INFO";
39700
39877
  const colorFn = severityColor(finding.severity);
39701
39878
  lines.push(
39702
- ` ${colorFn(pad(sevLabel, 10))}${finding.id} \u2014 ${finding.title}`
39879
+ ` ${colorFn(pad(sevLabel, 10))}${finding.category} \u2014 ${finding.title}`
39703
39880
  );
39704
39881
  const evidenceLimit = 3;
39705
39882
  for (let i = 0; i < Math.min(finding.evidence.length, evidenceLimit); i++) {
@@ -39750,7 +39927,24 @@ async function runStatic(config) {
39750
39927
  const discovery = discoverChanges(process.cwd(), config);
39751
39928
  dbg(`discovery method: ${discovery.method}`);
39752
39929
  if (discovery.packages.length === 0) {
39753
- process.stderr.write(import_chalk4.default.dim(" No package changes detected.\n"));
39930
+ const { discoverProjects: discoverProjects2 } = await Promise.resolve().then(() => (init_discover(), discover_exports));
39931
+ const subProjects = discoverProjects2(process.cwd());
39932
+ if (subProjects.length > 0) {
39933
+ process.stderr.write(import_chalk4.default.yellow(" No packages found in current directory, but found projects in subdirectories:\n\n"));
39934
+ for (const proj of subProjects) {
39935
+ const pkgLabel = proj.packageCount === 1 ? "package" : "packages";
39936
+ process.stderr.write(
39937
+ import_chalk4.default.white(` ${proj.ecosystem} `) + import_chalk4.default.cyan(proj.relativePath) + import_chalk4.default.dim(` (${proj.packageCount} ${pkgLabel})
39938
+ `)
39939
+ );
39940
+ }
39941
+ process.stderr.write(import_chalk4.default.dim("\n Use --workspace to scan a specific project:\n"));
39942
+ process.stderr.write(import_chalk4.default.dim(` dg scan --workspace ${subProjects[0].relativePath}${config.scanAll ? " --scan-all" : ""}
39943
+
39944
+ `));
39945
+ } else {
39946
+ process.stderr.write(import_chalk4.default.dim(" No package changes detected.\n"));
39947
+ }
39754
39948
  process.exit(0);
39755
39949
  }
39756
39950
  const packages = discovery.packages.filter(
@@ -39789,6 +39983,11 @@ async function runStatic(config) {
39789
39983
  );
39790
39984
  const output = renderResultStatic(result, config);
39791
39985
  process.stdout.write(output + "\n");
39986
+ if (!config.apiKey && !config.json) {
39987
+ process.stderr.write(
39988
+ "\n" + import_chalk4.default.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n") + import_chalk4.default.cyan(" Want this on every PR? ") + "Run " + import_chalk4.default.white("dg login") + import_chalk4.default.dim(" \u2192 free tier, 200 scans/month\n") + import_chalk4.default.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n")
39989
+ );
39990
+ }
39792
39991
  if (result.action === "block" && config.mode === "block") {
39793
39992
  process.exit(2);
39794
39993
  } else if (result.action === "block" || result.action === "warn") {
@@ -39907,6 +40106,11 @@ async function scanAndInstallStatic(resolved, parsed, config) {
39907
40106
  }
39908
40107
  const output = renderResultStatic(result, config);
39909
40108
  process.stdout.write(output + "\n");
40109
+ if (!config.apiKey) {
40110
+ process.stderr.write(
40111
+ "\n" + import_chalk4.default.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n") + import_chalk4.default.cyan(" Want this on every PR? ") + "Run " + import_chalk4.default.white("dg login") + import_chalk4.default.dim(" \u2192 free tier, 200 scans/month\n") + import_chalk4.default.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n")
40112
+ );
40113
+ }
39910
40114
  if (result.action === "warn") {
39911
40115
  process.stderr.write(
39912
40116
  import_chalk4.default.yellow(" Warnings detected. Proceeding with install.\n\n")
@@ -40037,14 +40241,14 @@ __export(hook_exports, {
40037
40241
  });
40038
40242
  import { execFileSync as execFileSync2 } from "node:child_process";
40039
40243
  import {
40040
- existsSync as existsSync6,
40041
- readFileSync as readFileSync7,
40244
+ existsSync as existsSync7,
40245
+ readFileSync as readFileSync8,
40042
40246
  writeFileSync as writeFileSync3,
40043
40247
  mkdirSync,
40044
40248
  chmodSync as chmodSync2,
40045
40249
  unlinkSync as unlinkSync2
40046
40250
  } from "node:fs";
40047
- import { join as join6 } from "node:path";
40251
+ import { join as join7 } from "node:path";
40048
40252
  function findGitDir() {
40049
40253
  try {
40050
40254
  return execFileSync2("git", ["rev-parse", "--git-dir"], {
@@ -40057,11 +40261,11 @@ function findGitDir() {
40057
40261
  }
40058
40262
  function installHook() {
40059
40263
  const gitDir = findGitDir();
40060
- const hooksDir = join6(gitDir, "hooks");
40061
- const hookPath = join6(hooksDir, "pre-commit");
40264
+ const hooksDir = join7(gitDir, "hooks");
40265
+ const hookPath = join7(hooksDir, "pre-commit");
40062
40266
  mkdirSync(hooksDir, { recursive: true });
40063
- if (existsSync6(hookPath)) {
40064
- const existing = readFileSync7(hookPath, "utf-8");
40267
+ if (existsSync7(hookPath)) {
40268
+ const existing = readFileSync8(hookPath, "utf-8");
40065
40269
  if (existing.includes(HOOK_MARKER)) {
40066
40270
  process.stderr.write(" Hook already installed.\n");
40067
40271
  return;
@@ -40081,12 +40285,12 @@ function installHook() {
40081
40285
  }
40082
40286
  function uninstallHook() {
40083
40287
  const gitDir = findGitDir();
40084
- const hookPath = join6(gitDir, "hooks", "pre-commit");
40085
- if (!existsSync6(hookPath)) {
40288
+ const hookPath = join7(gitDir, "hooks", "pre-commit");
40289
+ if (!existsSync7(hookPath)) {
40086
40290
  process.stderr.write(" No hook to remove.\n");
40087
40291
  return;
40088
40292
  }
40089
- const content = readFileSync7(hookPath, "utf-8");
40293
+ const content = readFileSync8(hookPath, "utf-8");
40090
40294
  if (!content.includes(HOOK_MARKER)) {
40091
40295
  process.stderr.write(
40092
40296
  " No Dependency Guardian hook found in pre-commit.\n"
@@ -40447,7 +40651,7 @@ var init_DurationLine = __esm({
40447
40651
  function groupPackages2(packages) {
40448
40652
  const map = /* @__PURE__ */ new Map();
40449
40653
  for (const pkg of packages) {
40450
- const fingerprint = pkg.findings.length === 0 ? `__clean_${pkg.score}` : pkg.findings.map((f) => `${f.id}:${f.severity}`).sort().join("|") + `|score:${pkg.score}`;
40654
+ const fingerprint = pkg.findings.length === 0 ? `__clean_${pkg.score}` : pkg.findings.map((f) => `${f.category}:${f.severity}`).sort().join("|") + `|score:${pkg.score}`;
40451
40655
  const group = map.get(fingerprint) ?? [];
40452
40656
  group.push(pkg);
40453
40657
  map.set(fingerprint, group);
@@ -40567,7 +40771,7 @@ var init_ResultsView = __esm({
40567
40771
  /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(Text, { children: [
40568
40772
  " ",
40569
40773
  colorFn(pad2(sevLabel, 10)),
40570
- finding.id,
40774
+ finding.category,
40571
40775
  " ",
40572
40776
  "\u2014",
40573
40777
  " ",
@@ -40582,7 +40786,7 @@ var init_ResultsView = __esm({
40582
40786
  import_chalk6.default.dim(`... and ${overflow} more`)
40583
40787
  ] }),
40584
40788
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(Text, { children: " " })
40585
- ] }, `${finding.id}-${idx}`);
40789
+ ] }, `${finding.category}-${idx}`);
40586
40790
  }),
40587
40791
  safeVersion && /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(Text, { children: [
40588
40792
  " ",
@@ -40807,140 +41011,6 @@ var init_NpmWrapperApp = __esm({
40807
41011
  }
40808
41012
  });
40809
41013
 
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
41014
  // src/ui/hooks/useScan.ts
40945
41015
  function reducer3(_state, action) {
40946
41016
  switch (action.type) {
@@ -40969,6 +41039,10 @@ function useScan(config) {
40969
41039
  started.current = true;
40970
41040
  const projects = discoverProjects(process.cwd());
40971
41041
  if (projects.length > 1) setMultiProjects(projects);
41042
+ if (projects.length > 1) {
41043
+ dispatch({ type: "PROJECTS_FOUND", projects });
41044
+ return;
41045
+ }
40972
41046
  try {
40973
41047
  const discovery = discoverChanges(process.cwd(), config);
40974
41048
  const packages = discovery.packages.filter((p) => !config.allowlist.includes(p.name));
@@ -40984,12 +41058,8 @@ function useScan(config) {
40984
41058
  dispatch({ type: "DISCOVERY_EMPTY", message: "No dependency files found." });
40985
41059
  return;
40986
41060
  }
40987
- if (projects.length === 1) {
40988
- dispatch({ type: "DISCOVERY_COMPLETE", packages: [], skippedCount: 0 });
40989
- scanProjects(projects, config, dispatch);
40990
- return;
40991
- }
40992
- dispatch({ type: "PROJECTS_FOUND", projects });
41061
+ dispatch({ type: "DISCOVERY_COMPLETE", packages: [], skippedCount: 0 });
41062
+ scanProjects(projects, config, dispatch);
40993
41063
  }
40994
41064
  }, [config]);
40995
41065
  const scanSelectedProjects = (0, import_react27.useCallback)((projects) => {
@@ -41180,11 +41250,45 @@ var init_useExpandAnimation = __esm({
41180
41250
  }
41181
41251
  });
41182
41252
 
41253
+ // src/ui/hooks/useTerminalSize.ts
41254
+ function useTerminalSize() {
41255
+ const { stdout } = use_stdout_default();
41256
+ const [size, setSize] = (0, import_react29.useState)({
41257
+ rows: stdout?.rows ?? process.stdout.rows ?? 24,
41258
+ cols: stdout?.columns ?? process.stdout.columns ?? 80
41259
+ });
41260
+ (0, import_react29.useEffect)(() => {
41261
+ const handle = () => {
41262
+ const rows = process.stdout.rows ?? 24;
41263
+ const cols = process.stdout.columns ?? 80;
41264
+ if (process.stdout.isTTY) {
41265
+ process.stdout.write("\x1B[2J\x1B[H");
41266
+ }
41267
+ setSize({ rows, cols });
41268
+ };
41269
+ process.stdout.setMaxListeners(process.stdout.getMaxListeners() + 1);
41270
+ process.stdout.on("resize", handle);
41271
+ return () => {
41272
+ process.stdout.off("resize", handle);
41273
+ process.stdout.setMaxListeners(Math.max(0, process.stdout.getMaxListeners() - 1));
41274
+ };
41275
+ }, []);
41276
+ return size;
41277
+ }
41278
+ var import_react29;
41279
+ var init_useTerminalSize = __esm({
41280
+ async "src/ui/hooks/useTerminalSize.ts"() {
41281
+ "use strict";
41282
+ import_react29 = __toESM(require_react());
41283
+ await init_build2();
41284
+ }
41285
+ });
41286
+
41183
41287
  // src/ui/components/InteractiveResultsView.tsx
41184
41288
  function groupPackages3(packages) {
41185
41289
  const map = /* @__PURE__ */ new Map();
41186
41290
  for (const pkg of packages) {
41187
- const fingerprint = pkg.findings.length === 0 ? `__clean_${pkg.score}` : pkg.findings.map((f) => `${f.id}:${f.severity}`).sort().join("|") + `|score:${pkg.score}`;
41291
+ const fingerprint = pkg.findings.length === 0 ? `__clean_${pkg.score}` : pkg.findings.map((f) => `${f.category}:${f.severity}`).sort().join("|") + `|score:${pkg.score}`;
41188
41292
  const group = map.get(fingerprint) ?? [];
41189
41293
  group.push(pkg);
41190
41294
  map.set(fingerprint, group);
@@ -41250,15 +41354,16 @@ function viewReducer(_state, action) {
41250
41354
  return { cursor: action.cursor, expandedIndex: action.expandedIndex, expandLevel: action.expandLevel, viewport: action.viewport };
41251
41355
  }
41252
41356
  }
41253
- var import_react29, import_chalk10, import_jsx_runtime10, SEVERITY_LABELS3, SEVERITY_COLORS, EVIDENCE_LIMIT2, FIXED_CHROME, InteractiveResultsView, T, FindingsSummary, FindingsDetail;
41357
+ var import_react30, import_chalk10, import_jsx_runtime10, SEVERITY_LABELS3, SEVERITY_COLORS, EVIDENCE_LIMIT2, FIXED_CHROME, InteractiveResultsView, T, FindingsSummary, FindingsDetail;
41254
41358
  var init_InteractiveResultsView = __esm({
41255
41359
  async "src/ui/components/InteractiveResultsView.tsx"() {
41256
41360
  "use strict";
41257
- import_react29 = __toESM(require_react());
41361
+ import_react30 = __toESM(require_react());
41258
41362
  await init_build2();
41259
41363
  import_chalk10 = __toESM(require_source());
41260
41364
  await init_ScoreHeader();
41261
41365
  init_useExpandAnimation();
41366
+ await init_useTerminalSize();
41262
41367
  import_jsx_runtime10 = __toESM(require_jsx_runtime());
41263
41368
  SEVERITY_LABELS3 = {
41264
41369
  5: "CRIT",
@@ -41283,40 +41388,32 @@ var init_InteractiveResultsView = __esm({
41283
41388
  onExit,
41284
41389
  onBack
41285
41390
  }) => {
41286
- (0, import_react29.useEffect)(() => {
41287
- if (!process.stdout.isTTY) return;
41288
- process.stdout.write("\x1B[?1049h");
41289
- return () => {
41290
- process.stdout.write("\x1B[?1049l");
41291
- };
41292
- }, []);
41293
- const flagged = (0, import_react29.useMemo)(
41391
+ const flagged = (0, import_react30.useMemo)(
41294
41392
  () => result.packages.filter((p) => p.score > 0),
41295
41393
  [result.packages]
41296
41394
  );
41297
- const clean = (0, import_react29.useMemo)(
41395
+ const clean = (0, import_react30.useMemo)(
41298
41396
  () => result.packages.filter((p) => p.score === 0),
41299
41397
  [result.packages]
41300
41398
  );
41301
41399
  const total = result.packages.length;
41302
- const groups = (0, import_react29.useMemo)(() => groupPackages3(flagged), [flagged]);
41303
- const [view, dispatchView] = (0, import_react29.useReducer)(viewReducer, {
41400
+ const groups = (0, import_react30.useMemo)(() => groupPackages3(flagged), [flagged]);
41401
+ const [view, dispatchView] = (0, import_react30.useReducer)(viewReducer, {
41304
41402
  cursor: 0,
41305
41403
  expandLevel: null,
41306
41404
  expandedIndex: null,
41307
41405
  viewport: 0
41308
41406
  });
41309
- const viewRef = (0, import_react29.useRef)(view);
41407
+ const viewRef = (0, import_react30.useRef)(view);
41310
41408
  viewRef.current = view;
41311
- const { stdout } = use_stdout_default();
41312
- const termCols = stdout?.columns ?? process.stdout.columns ?? 80;
41313
- const termRows = stdout?.rows ?? process.stdout.rows ?? 24;
41314
- const availableRows = Math.max(5, termRows - FIXED_CHROME);
41409
+ const { rows: termRows, cols: termCols } = useTerminalSize();
41410
+ const chromeHeight = FIXED_CHROME + (config.apiKey ? 0 : 2);
41411
+ const availableRows = Math.max(5, termRows - chromeHeight);
41315
41412
  const innerWidth = Math.max(40, termCols - 6);
41316
41413
  const getLevel = (idx) => {
41317
41414
  return view.expandedIndex === idx ? view.expandLevel : null;
41318
41415
  };
41319
- const expandTargetHeight = (0, import_react29.useMemo)(() => {
41416
+ const expandTargetHeight = (0, import_react30.useMemo)(() => {
41320
41417
  if (view.expandedIndex === null || view.expandLevel === null) return 0;
41321
41418
  const group = groups[view.expandedIndex];
41322
41419
  if (!group) return 0;
@@ -41332,7 +41429,7 @@ var init_InteractiveResultsView = __esm({
41332
41429
  if (idx === view.expandedIndex) return 1 + animVisibleLines;
41333
41430
  return groupRowHeight(group, level, result.safeVersions);
41334
41431
  };
41335
- const visibleEnd = (0, import_react29.useMemo)(() => {
41432
+ const visibleEnd = (0, import_react30.useMemo)(() => {
41336
41433
  let consumed = 0;
41337
41434
  let end = view.viewport;
41338
41435
  while (end < groups.length) {
@@ -41364,6 +41461,13 @@ var init_InteractiveResultsView = __esm({
41364
41461
  }
41365
41462
  return newStart;
41366
41463
  };
41464
+ (0, import_react30.useEffect)(() => {
41465
+ if (groups.length === 0) return;
41466
+ const { cursor, expandedIndex, expandLevel, viewport } = viewRef.current;
41467
+ const clamped = Math.min(viewport, Math.max(0, groups.length - 1));
41468
+ const newVp = adjustViewport(cursor, expandedIndex, expandLevel, clamped);
41469
+ dispatchView({ type: "MOVE", cursor, viewport: newVp });
41470
+ }, [availableRows]);
41367
41471
  use_input_default((input, key) => {
41368
41472
  if (groups.length === 0) {
41369
41473
  if (input === "q" || key.return) onExit();
@@ -41391,13 +41495,15 @@ var init_InteractiveResultsView = __esm({
41391
41495
  const newVp = adjustViewport(cursor, newExpIdx, newExpLvl, vpStart);
41392
41496
  dispatchView({ type: "EXPAND", expandedIndex: newExpIdx, expandLevel: newExpLvl, viewport: newVp });
41393
41497
  } else if (input === "e") {
41394
- if (expIdx === cursor && expLvl === "detail") return;
41395
- const newVp = adjustViewport(cursor, cursor, "detail", vpStart);
41396
- dispatchView({ type: "EXPAND", expandedIndex: cursor, expandLevel: "detail", viewport: newVp });
41397
- } else if (input === "c") {
41398
- if (expIdx !== null && expLvl !== null) {
41498
+ if (expIdx === cursor && expLvl === "detail") {
41499
+ const newVp = adjustViewport(cursor, cursor, "summary", vpStart);
41500
+ dispatchView({ type: "EXPAND", expandedIndex: cursor, expandLevel: "summary", viewport: newVp });
41501
+ } else if (expIdx === cursor && expLvl === "summary") {
41399
41502
  const newVp = adjustViewport(cursor, null, null, vpStart);
41400
41503
  dispatchView({ type: "EXPAND", expandedIndex: null, expandLevel: null, viewport: newVp });
41504
+ } else {
41505
+ const newVp = adjustViewport(cursor, cursor, "detail", vpStart);
41506
+ dispatchView({ type: "EXPAND", expandedIndex: cursor, expandLevel: "detail", viewport: newVp });
41401
41507
  }
41402
41508
  } else if (input === "b" && onBack) {
41403
41509
  onBack();
@@ -41514,6 +41620,12 @@ var init_InteractiveResultsView = __esm({
41514
41620
  ]
41515
41621
  }
41516
41622
  ),
41623
+ !config.apiKey && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(Box_default, { paddingLeft: 1, paddingRight: 1, width: "100%", children: /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Text, { children: [
41624
+ import_chalk10.default.cyan("Want this on every PR?"),
41625
+ " Run ",
41626
+ import_chalk10.default.white("dg login"),
41627
+ import_chalk10.default.dim(" \u2192 free tier, 200 scans/month")
41628
+ ] }) }),
41517
41629
  /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Text, { dimColor: true, children: [
41518
41630
  " ",
41519
41631
  groups.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(import_jsx_runtime10.Fragment, { children: [
@@ -41524,10 +41636,7 @@ var init_InteractiveResultsView = __esm({
41524
41636
  " toggle",
41525
41637
  " ",
41526
41638
  import_chalk10.default.cyan("e"),
41527
- " expand",
41528
- " ",
41529
- import_chalk10.default.cyan("c"),
41530
- " collapse",
41639
+ " expand/collapse",
41531
41640
  " ",
41532
41641
  onBack && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(import_jsx_runtime10.Fragment, { children: [
41533
41642
  import_chalk10.default.cyan("b"),
@@ -41569,8 +41678,8 @@ var init_InteractiveResultsView = __esm({
41569
41678
  " ",
41570
41679
  sevColor(pad3(sevLabel, 4)),
41571
41680
  " ",
41572
- import_chalk10.default.dim(f.id)
41573
- ] }, `${f.id}-${idx}`)
41681
+ import_chalk10.default.dim(f.category)
41682
+ ] }, `${f.category}-${idx}`)
41574
41683
  );
41575
41684
  }
41576
41685
  if (hasAffects) {
@@ -41608,15 +41717,15 @@ var init_InteractiveResultsView = __esm({
41608
41717
  " ",
41609
41718
  sevColor(pad3(sevLabel, 4)),
41610
41719
  " ",
41611
- import_chalk10.default.bold(finding.id)
41612
- ] }, `${finding.id}-${idx}-badge`)
41720
+ import_chalk10.default.bold(finding.category)
41721
+ ] }, `${finding.category}-${idx}-badge`)
41613
41722
  );
41614
41723
  allLines.push(
41615
41724
  /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Text, { dimColor: true, children: [
41616
41725
  continuation,
41617
41726
  " ",
41618
41727
  finding.title
41619
- ] }, `${finding.id}-${idx}-title`)
41728
+ ] }, `${finding.category}-${idx}-title`)
41620
41729
  );
41621
41730
  for (let i = 0; i < evidenceSlice.length; i++) {
41622
41731
  allLines.push(
@@ -41626,7 +41735,7 @@ var init_InteractiveResultsView = __esm({
41626
41735
  import_chalk10.default.dim("\u203A"),
41627
41736
  " ",
41628
41737
  truncate3(evidenceSlice[i], evidenceWidth)
41629
- ] }, `${finding.id}-${idx}-ev-${i}`)
41738
+ ] }, `${finding.category}-${idx}-ev-${i}`)
41630
41739
  );
41631
41740
  }
41632
41741
  if (overflow > 0) {
@@ -41635,7 +41744,7 @@ var init_InteractiveResultsView = __esm({
41635
41744
  continuation,
41636
41745
  " ",
41637
41746
  import_chalk10.default.dim(`+${overflow} more`)
41638
- ] }, `${finding.id}-${idx}-overflow`)
41747
+ ] }, `${finding.category}-${idx}-overflow`)
41639
41748
  );
41640
41749
  }
41641
41750
  }
@@ -41664,19 +41773,16 @@ var init_InteractiveResultsView = __esm({
41664
41773
  });
41665
41774
 
41666
41775
  // src/ui/components/ProjectSelector.tsx
41667
- var import_react30, import_jsx_runtime11, ProjectSelector;
41776
+ var import_react31, import_jsx_runtime11, ProjectSelector;
41668
41777
  var init_ProjectSelector = __esm({
41669
41778
  async "src/ui/components/ProjectSelector.tsx"() {
41670
41779
  "use strict";
41671
- import_react30 = __toESM(require_react());
41780
+ import_react31 = __toESM(require_react());
41672
41781
  await init_build2();
41673
41782
  import_jsx_runtime11 = __toESM(require_jsx_runtime());
41674
41783
  ProjectSelector = ({ projects, onConfirm, onCancel }) => {
41675
- const [cursor, setCursor] = (0, import_react30.useState)(0);
41676
- (0, import_react30.useEffect)(() => {
41677
- process.stdout.write("\x1B[2J\x1B[H");
41678
- }, []);
41679
- const [selected, setSelected] = (0, import_react30.useState)(() => new Set(projects.map((_, i) => i)));
41784
+ const [cursor, setCursor] = (0, import_react31.useState)(0);
41785
+ const [selected, setSelected] = (0, import_react31.useState)(() => new Set(projects.map((_, i) => i)));
41680
41786
  use_input_default((input, key) => {
41681
41787
  if (key.upArrow) {
41682
41788
  setCursor((c) => Math.max(0, c - 1));
@@ -41730,11 +41836,11 @@ var App_exports = {};
41730
41836
  __export(App_exports, {
41731
41837
  App: () => App2
41732
41838
  });
41733
- var import_react31, import_jsx_runtime12, App2;
41839
+ var import_react32, import_jsx_runtime12, App2;
41734
41840
  var init_App2 = __esm({
41735
41841
  async "src/ui/App.tsx"() {
41736
41842
  "use strict";
41737
- import_react31 = __toESM(require_react());
41843
+ import_react32 = __toESM(require_react());
41738
41844
  await init_build2();
41739
41845
  init_useScan();
41740
41846
  await init_Spinner();
@@ -41742,11 +41848,29 @@ var init_App2 = __esm({
41742
41848
  await init_InteractiveResultsView();
41743
41849
  await init_ErrorView();
41744
41850
  await init_ProjectSelector();
41851
+ await init_useTerminalSize();
41745
41852
  import_jsx_runtime12 = __toESM(require_jsx_runtime());
41746
41853
  App2 = ({ config }) => {
41747
41854
  const { state, scanSelectedProjects, restartSelection } = useScan(config);
41748
41855
  const { exit } = use_app_default();
41749
- const handleResultsExit = (0, import_react31.useCallback)(() => {
41856
+ const { rows: termRows } = useTerminalSize();
41857
+ const prevPhaseRef = (0, import_react32.useRef)(state.phase);
41858
+ (0, import_react32.useEffect)(() => {
41859
+ if (!process.stdout.isTTY) return;
41860
+ process.stdout.write("\x1B[?1049h");
41861
+ process.stdout.write("\x1B[2J\x1B[H");
41862
+ return () => {
41863
+ process.stdout.write("\x1B[?1049l");
41864
+ process.stdout.write("\x1B[?25h");
41865
+ };
41866
+ }, []);
41867
+ (0, import_react32.useEffect)(() => {
41868
+ if (prevPhaseRef.current !== state.phase && process.stdout.isTTY) {
41869
+ process.stdout.write("\x1B[2J\x1B[H");
41870
+ }
41871
+ prevPhaseRef.current = state.phase;
41872
+ }, [state.phase]);
41873
+ const handleResultsExit = (0, import_react32.useCallback)(() => {
41750
41874
  if (state.phase === "results") {
41751
41875
  const { result } = state;
41752
41876
  if (result.action === "block" && config.mode === "block") {
@@ -41759,7 +41883,7 @@ var init_App2 = __esm({
41759
41883
  }
41760
41884
  exit();
41761
41885
  }, [state, config, exit]);
41762
- (0, import_react31.useEffect)(() => {
41886
+ (0, import_react32.useEffect)(() => {
41763
41887
  if (state.phase === "empty") {
41764
41888
  process.exitCode = 0;
41765
41889
  const timer = setTimeout(() => exit(), 0);
@@ -41771,55 +41895,58 @@ var init_App2 = __esm({
41771
41895
  return () => clearTimeout(timer);
41772
41896
  }
41773
41897
  }, [state, config, exit]);
41774
- switch (state.phase) {
41775
- case "discovering":
41776
- return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(Spinner2, { label: "Searching for dependencies..." });
41777
- case "selecting":
41778
- return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
41779
- ProjectSelector,
41780
- {
41781
- projects: state.projects,
41782
- onConfirm: scanSelectedProjects,
41783
- onCancel: () => {
41784
- process.exitCode = 0;
41785
- exit();
41898
+ const content = (() => {
41899
+ switch (state.phase) {
41900
+ case "discovering":
41901
+ return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(Spinner2, { label: "Searching for dependencies..." });
41902
+ case "selecting":
41903
+ return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
41904
+ ProjectSelector,
41905
+ {
41906
+ projects: state.projects,
41907
+ onConfirm: scanSelectedProjects,
41908
+ onCancel: () => {
41909
+ process.exitCode = 0;
41910
+ exit();
41911
+ }
41786
41912
  }
41787
- }
41788
- );
41789
- case "scanning":
41790
- return /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(Box_default, { flexDirection: "column", children: [
41791
- /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
41792
- ProgressBar,
41913
+ );
41914
+ case "scanning":
41915
+ return /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(Box_default, { flexDirection: "column", children: [
41916
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
41917
+ ProgressBar,
41918
+ {
41919
+ value: state.done,
41920
+ total: state.total,
41921
+ label: state.currentBatch.length > 0 ? state.currentBatch[state.currentBatch.length - 1] : void 0
41922
+ }
41923
+ ),
41924
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(Text, { dimColor: true, children: [
41925
+ "Scanning ",
41926
+ state.done,
41927
+ "/",
41928
+ state.total,
41929
+ " packages..."
41930
+ ] })
41931
+ ] });
41932
+ case "results":
41933
+ return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
41934
+ InteractiveResultsView,
41793
41935
  {
41794
- value: state.done,
41795
- total: state.total,
41796
- label: state.currentBatch.length > 0 ? state.currentBatch[state.currentBatch.length - 1] : void 0
41936
+ result: state.result,
41937
+ config,
41938
+ durationMs: state.durationMs,
41939
+ onExit: handleResultsExit,
41940
+ onBack: restartSelection ?? void 0
41797
41941
  }
41798
- ),
41799
- /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(Text, { dimColor: true, children: [
41800
- "Scanning ",
41801
- state.done,
41802
- "/",
41803
- state.total,
41804
- " packages..."
41805
- ] })
41806
- ] });
41807
- case "results":
41808
- return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
41809
- InteractiveResultsView,
41810
- {
41811
- result: state.result,
41812
- config,
41813
- durationMs: state.durationMs,
41814
- onExit: handleResultsExit,
41815
- onBack: restartSelection ?? void 0
41816
- }
41817
- );
41818
- case "empty":
41819
- return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(Text, { dimColor: true, children: state.message });
41820
- case "error":
41821
- return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(ErrorView, { error: state.error });
41822
- }
41942
+ );
41943
+ case "empty":
41944
+ return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(Text, { dimColor: true, children: state.message });
41945
+ case "error":
41946
+ return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(ErrorView, { error: state.error });
41947
+ }
41948
+ })();
41949
+ return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(Box_default, { flexDirection: "column", height: termRows, children: content });
41823
41950
  };
41824
41951
  }
41825
41952
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@westbayberry/dg",
3
- "version": "1.0.13",
4
- "description": "Supply chain security scanner scan npm dependencies in any CI or terminal",
3
+ "version": "1.0.16",
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"
@@ -12,13 +12,21 @@
12
12
  "license": "UNLICENSED",
13
13
  "author": "WestBayBerry",
14
14
  "homepage": "https://westbayberry.com",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/WestBayBerry/dependency-guardian-action.git"
18
+ },
15
19
  "keywords": [
16
20
  "security",
17
21
  "npm",
22
+ "pypi",
18
23
  "supply-chain",
19
24
  "scanner",
20
25
  "cli",
21
- "dependencies"
26
+ "dependencies",
27
+ "malware",
28
+ "typosquatting",
29
+ "dependency-confusion"
22
30
  ],
23
31
  "publishConfig": {
24
32
  "access": "public"