dep-drift-sec 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/dist/adapters/fs-loader.js +17 -0
- package/dist/adapters/npm-registry.js +30 -0
- package/dist/cli/index.js +131 -0
- package/dist/core/drift.js +52 -0
- package/dist/core/graph.js +73 -0
- package/dist/core/security.js +78 -0
- package/dist/core/types.js +44 -0
- package/dist/report/console-report.js +69 -0
- package/dist/report/json-report.js +6 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# dep-drift-sec
|
|
2
|
+
|
|
3
|
+
**Production-grade security and stability guardrails for your Node.js dependencies.**
|
|
4
|
+
|
|
5
|
+
`dep-drift-sec` is an open-source CLI tool designed to prevent production breakage and identify supply-chain risks. It bridges the gap between basic vulnerability scanners and manual audits by focusing on **dependency drift**, **transitive relationships**, and **maintenance heuristics**.
|
|
6
|
+
|
|
7
|
+
> [!NOTE]
|
|
8
|
+
> The CLI is open-source and works entirely offline/locally without any mandatory backend service.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Key Features
|
|
13
|
+
|
|
14
|
+
- **Drift Protection**: Detects flexible version ranges (`^`, `~`) in `package.json` and transitive version conflicts that cause "works on my machine" bugs.
|
|
15
|
+
- **Transitive Visibility**: Explicitly surfaces the full dependency chain for every risky package found.
|
|
16
|
+
- **Maintenance Heuristics**:
|
|
17
|
+
- **Unmaintained**: Identifies packages not updated in the last 18 months.
|
|
18
|
+
- **Deprecated**: Alerts on packages officially marked as deprecated by maintainers.
|
|
19
|
+
- **Single-Maintainer**: Highlights potential single-point-of-failure risks in your supply chain.
|
|
20
|
+
- **CI/CD Ready**: Machine-readable JSON output and standardized exit codes for easy pipeline integration.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Getting Started
|
|
25
|
+
|
|
26
|
+
### Installation (Development)
|
|
27
|
+
```bash
|
|
28
|
+
npm install
|
|
29
|
+
npm run build
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Usage
|
|
33
|
+
|
|
34
|
+
#### Using `npx` (Recommended for CI/Production)
|
|
35
|
+
```bash
|
|
36
|
+
# Run a check on the current directory
|
|
37
|
+
npx dep-drift-sec check
|
|
38
|
+
|
|
39
|
+
# Run with JSON output and SaaS-ready flag
|
|
40
|
+
npx dep-drift-sec check --json --upload
|
|
41
|
+
|
|
42
|
+
# Check a specific project path
|
|
43
|
+
npx dep-drift-sec check --path ./my-project
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
#### Using Local Scripts
|
|
47
|
+
```bash
|
|
48
|
+
# Quick scan (requires local build)
|
|
49
|
+
npm run scan
|
|
50
|
+
|
|
51
|
+
# Direct execution
|
|
52
|
+
node dist/cli/index.js check
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Scan Results Contract (v1.0)
|
|
58
|
+
|
|
59
|
+
When run with `--json`, the CLI produces a versioned, stable JSON structure.
|
|
60
|
+
|
|
61
|
+
### Example Output
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"meta": {
|
|
65
|
+
"schemaVersion": "1.0",
|
|
66
|
+
"scanId": "550e8400-e29b-41d4-a716-446655440000",
|
|
67
|
+
"projectName": "my-project",
|
|
68
|
+
"projectId": "a3f5b2c...",
|
|
69
|
+
"generatedAt": "2026-01-17T20:55:00Z"
|
|
70
|
+
},
|
|
71
|
+
"summary": {
|
|
72
|
+
"driftCount": 0,
|
|
73
|
+
"securityCount": 1,
|
|
74
|
+
"riskLevel": "medium",
|
|
75
|
+
"riskReason": "1 dependency has security or drift issues, increasing breakage and security risk.",
|
|
76
|
+
"recommendedAction": "warn",
|
|
77
|
+
"recommendedExitCode": 2
|
|
78
|
+
},
|
|
79
|
+
"drift": [],
|
|
80
|
+
"security": [
|
|
81
|
+
{
|
|
82
|
+
"dependencyName": "example-pkg",
|
|
83
|
+
"transitive": true,
|
|
84
|
+
"introducedBy": ["direct-parent"],
|
|
85
|
+
"description": "An example package",
|
|
86
|
+
"issues": [
|
|
87
|
+
{
|
|
88
|
+
"type": "unmaintained",
|
|
89
|
+
"reason": "Last update was 70 months ago.",
|
|
90
|
+
"riskLevel": "medium",
|
|
91
|
+
"details": { "lastUpdate": "2020-03-04" }
|
|
92
|
+
}
|
|
93
|
+
],
|
|
94
|
+
"overallRisk": "medium"
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## CI/CD Integration (Exit Codes)
|
|
103
|
+
|
|
104
|
+
| Code | Meaning | Outcome |
|
|
105
|
+
| :--- | :--- | :--- |
|
|
106
|
+
| `0` | **OK** | No drift or security issues found. |
|
|
107
|
+
| `1` | **Drift Detected** | Version ranges found in `package.json`. |
|
|
108
|
+
| `2` | **Security Issue** | Heuristics triggered (unmaintained, etc). |
|
|
109
|
+
| `3` | **Mixed Issues** | Both drift and security issues present. |
|
|
110
|
+
| `4` | **Internal Error** | Missing files or runtime failure. |
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT - See [LICENSE](./LICENSE) for details.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
export async function loadProjectData(projectPath) {
|
|
4
|
+
const packageJsonPath = path.join(projectPath, 'package.json');
|
|
5
|
+
const packageLockPath = path.join(projectPath, 'package-lock.json');
|
|
6
|
+
try {
|
|
7
|
+
const packageJsonContent = await fs.readFile(packageJsonPath, 'utf8');
|
|
8
|
+
const packageLockContent = await fs.readFile(packageLockPath, 'utf8');
|
|
9
|
+
return {
|
|
10
|
+
packageJson: JSON.parse(packageJsonContent),
|
|
11
|
+
packageLock: JSON.parse(packageLockContent),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
throw new Error(`Failed to load project data from ${projectPath}: ${error.message}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
const REGISTRY_URL = 'https://registry.npmjs.org';
|
|
3
|
+
export async function fetchNpmMetadata(packageName) {
|
|
4
|
+
try {
|
|
5
|
+
const response = await axios.get(`${REGISTRY_URL}/${packageName}`, {
|
|
6
|
+
timeout: 5000,
|
|
7
|
+
});
|
|
8
|
+
return response.data;
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
// If not found or error, return null (might be private or deleted)
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function fetchAllMetadata(packageNames) {
|
|
16
|
+
const metadataMap = {};
|
|
17
|
+
// For MVP, we fetch sequentially or in small chunks to avoid registry rate limits
|
|
18
|
+
// but since we might have many transitive deps, we'll do them in parallel with a limit if needed.
|
|
19
|
+
// Here we'll just do a simple Promise.all for now.
|
|
20
|
+
const results = await Promise.all(packageNames.map(async (name) => {
|
|
21
|
+
const meta = await fetchNpmMetadata(name);
|
|
22
|
+
return { name, meta };
|
|
23
|
+
}));
|
|
24
|
+
for (const { name, meta } of results) {
|
|
25
|
+
if (meta) {
|
|
26
|
+
metadataMap[name] = meta;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return metadataMap;
|
|
30
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import { loadProjectData } from '../adapters/fs-loader.js';
|
|
6
|
+
import { fetchAllMetadata } from '../adapters/npm-registry.js';
|
|
7
|
+
import { detectDrift } from '../core/drift.js';
|
|
8
|
+
import { analyzeSecurity } from '../core/security.js';
|
|
9
|
+
import { formatConsoleReport } from '../report/console-report.js';
|
|
10
|
+
import { formatJsonReport } from '../report/json-report.js';
|
|
11
|
+
import { buildDependencyGraph } from '../core/graph.js';
|
|
12
|
+
const program = new Command();
|
|
13
|
+
program
|
|
14
|
+
.name('dep-drift-sec')
|
|
15
|
+
.description('Detect dependency drift and basic security risks')
|
|
16
|
+
.version('1.0.0');
|
|
17
|
+
program
|
|
18
|
+
.command('check')
|
|
19
|
+
.description('Run drift and security checks on the current project')
|
|
20
|
+
.option('--json', 'Output report in JSON format')
|
|
21
|
+
.option('--path <path>', 'Path to the Node project', '.')
|
|
22
|
+
.option('--upload', 'Upload results to SaaS (reserved)')
|
|
23
|
+
.action(async (options) => {
|
|
24
|
+
try {
|
|
25
|
+
if (options.upload) {
|
|
26
|
+
console.warn('Warning: Upload is not enabled in the open-source CLI.');
|
|
27
|
+
}
|
|
28
|
+
const projectPath = path.resolve(process.cwd(), options.path);
|
|
29
|
+
const project = await loadProjectData(projectPath);
|
|
30
|
+
// Generate stable projectId from lockfile content
|
|
31
|
+
const lockfileContent = JSON.stringify(project.packageLock);
|
|
32
|
+
const projectId = crypto.createHash('sha256').update(lockfileContent).digest('hex');
|
|
33
|
+
const scanId = crypto.randomUUID();
|
|
34
|
+
const generatedAt = new Date().toISOString();
|
|
35
|
+
// 1. Extract unique deps for metadata fetching
|
|
36
|
+
const lockPackages = project.packageLock.packages || {};
|
|
37
|
+
const uniqueDeps = new Set();
|
|
38
|
+
for (const p of Object.keys(lockPackages)) {
|
|
39
|
+
if (p === '' || !p.startsWith('node_modules/'))
|
|
40
|
+
continue;
|
|
41
|
+
uniqueDeps.add(p.split('node_modules/').pop());
|
|
42
|
+
}
|
|
43
|
+
// 2. Fetch metadata
|
|
44
|
+
const metadataMap = await fetchAllMetadata(Array.from(uniqueDeps));
|
|
45
|
+
// 3. Build normalized graph
|
|
46
|
+
const graph = buildDependencyGraph(project, metadataMap, 'local');
|
|
47
|
+
// 4. Analyze
|
|
48
|
+
const driftGroups = detectDrift(graph);
|
|
49
|
+
const securityGroups = analyzeSecurity(graph);
|
|
50
|
+
// 5. Calculate Summary Totals
|
|
51
|
+
const totalDriftIssues = driftGroups.reduce((acc, g) => acc + g.issues.length, 0);
|
|
52
|
+
const uniqueSecurityDeps = securityGroups.length;
|
|
53
|
+
// 6. Determine overall risk factors
|
|
54
|
+
let overallRisk = 'low';
|
|
55
|
+
let riskReason = 'No significant risks detected. Your dependencies appear healthy.';
|
|
56
|
+
let recommendedAction = 'allow';
|
|
57
|
+
const affectedDeps = new Set([
|
|
58
|
+
...securityGroups.map(g => g.dependencyName),
|
|
59
|
+
...driftGroups.map(g => g.dependencyName)
|
|
60
|
+
]);
|
|
61
|
+
const affectedCount = affectedDeps.size;
|
|
62
|
+
const transitiveCount = [
|
|
63
|
+
...securityGroups.filter(g => g.transitive),
|
|
64
|
+
...driftGroups.filter(g => g.transitive && !securityGroups.some(sg => sg.dependencyName === g.dependencyName))
|
|
65
|
+
].length;
|
|
66
|
+
if (affectedCount > 0) {
|
|
67
|
+
const transText = transitiveCount > 0 ? ` (${transitiveCount} transitive)` : "";
|
|
68
|
+
riskReason = `${affectedCount} dependenc${affectedCount > 1 ? 'ies have' : 'y has'} security or drift issues${transText}, increasing breakage and security risk.`;
|
|
69
|
+
}
|
|
70
|
+
if (securityGroups.some(g => g.overallRisk === 'high')) {
|
|
71
|
+
overallRisk = 'high';
|
|
72
|
+
recommendedAction = 'block';
|
|
73
|
+
}
|
|
74
|
+
else if (securityGroups.some(g => g.overallRisk === 'medium') || driftGroups.length > 0) {
|
|
75
|
+
overallRisk = 'medium';
|
|
76
|
+
recommendedAction = 'warn';
|
|
77
|
+
}
|
|
78
|
+
else if (securityGroups.some(g => g.overallRisk === 'low')) {
|
|
79
|
+
overallRisk = 'low';
|
|
80
|
+
recommendedAction = 'allow';
|
|
81
|
+
}
|
|
82
|
+
// 7. Determine Exit Code (Preserved logic)
|
|
83
|
+
let exitCode = 0;
|
|
84
|
+
if (totalDriftIssues > 0 && securityGroups.length > 0)
|
|
85
|
+
exitCode = 3;
|
|
86
|
+
else if (totalDriftIssues > 0)
|
|
87
|
+
exitCode = 1;
|
|
88
|
+
else if (securityGroups.length > 0)
|
|
89
|
+
exitCode = 2;
|
|
90
|
+
const report = {
|
|
91
|
+
meta: {
|
|
92
|
+
schemaVersion: "1.0",
|
|
93
|
+
scanId,
|
|
94
|
+
projectName: project.packageJson.name || 'unknown',
|
|
95
|
+
projectId,
|
|
96
|
+
generatedAt,
|
|
97
|
+
},
|
|
98
|
+
summary: {
|
|
99
|
+
driftCount: totalDriftIssues,
|
|
100
|
+
securityCount: uniqueSecurityDeps,
|
|
101
|
+
riskLevel: overallRisk,
|
|
102
|
+
riskReason,
|
|
103
|
+
recommendedAction,
|
|
104
|
+
recommendedExitCode: exitCode,
|
|
105
|
+
},
|
|
106
|
+
drift: driftGroups,
|
|
107
|
+
security: securityGroups,
|
|
108
|
+
};
|
|
109
|
+
if (options.json) {
|
|
110
|
+
process.stdout.write(formatJsonReport(report) + '\n');
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
process.stdout.write(formatConsoleReport(report) + '\n');
|
|
114
|
+
}
|
|
115
|
+
process.exitCode = exitCode;
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (!options.json) {
|
|
119
|
+
console.error(`Error: ${error.message}`);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// In JSON mode, we should still output valid JSON if possible,
|
|
123
|
+
// but for a fatal initialization error, we'll just exit with code 4.
|
|
124
|
+
// The requirements say "JSON output is always valid when --json is passed",
|
|
125
|
+
// so let's try to wrap the error.
|
|
126
|
+
process.stdout.write(JSON.stringify({ error: error.message, exitCode: 4 }) + '\n');
|
|
127
|
+
}
|
|
128
|
+
process.exitCode = 4;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export function detectDrift(graph) {
|
|
2
|
+
const groups = [];
|
|
3
|
+
const groupMap = new Map();
|
|
4
|
+
const getGroup = (name) => {
|
|
5
|
+
if (!groupMap.has(name)) {
|
|
6
|
+
const node = graph.dependencies.find(d => d.name === name);
|
|
7
|
+
const group = {
|
|
8
|
+
dependencyName: name,
|
|
9
|
+
transitive: node?.transitive ?? false,
|
|
10
|
+
introducedBy: node?.introducedBy ?? [],
|
|
11
|
+
issues: []
|
|
12
|
+
};
|
|
13
|
+
groupMap.set(name, group);
|
|
14
|
+
groups.push(group);
|
|
15
|
+
}
|
|
16
|
+
return groupMap.get(name);
|
|
17
|
+
};
|
|
18
|
+
for (const node of graph.dependencies) {
|
|
19
|
+
// 1. Check for ^ or ~ on direct dependencies
|
|
20
|
+
if (!node.transitive) {
|
|
21
|
+
const range = node.version;
|
|
22
|
+
if (range.startsWith('^') || range.startsWith('~')) {
|
|
23
|
+
getGroup(node.name).issues.push({
|
|
24
|
+
dependencyName: node.name,
|
|
25
|
+
type: 'range-usage',
|
|
26
|
+
expected: range,
|
|
27
|
+
actual: range,
|
|
28
|
+
reason: `The dependency "${node.name}" uses a "${range[0]}" symbol (e.g., ^ or ~). This means npm might automatically install a newer version. To ensure everyone uses the exact same version, it is better to use a fixed version (e.g., "1.2.3" instead of "${range}").`
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// 2. Detect multiple versions of the same dependency (Transitive Drift)
|
|
34
|
+
const versionMap = {};
|
|
35
|
+
for (const node of graph.dependencies) {
|
|
36
|
+
if (!versionMap[node.name])
|
|
37
|
+
versionMap[node.name] = new Set();
|
|
38
|
+
versionMap[node.name].add(node.resolvedVersion);
|
|
39
|
+
}
|
|
40
|
+
for (const [name, versions] of Object.entries(versionMap)) {
|
|
41
|
+
if (versions.size > 1) {
|
|
42
|
+
getGroup(name).issues.push({
|
|
43
|
+
dependencyName: name,
|
|
44
|
+
type: 'transitive-drift',
|
|
45
|
+
expected: Array.from(versions)[0],
|
|
46
|
+
actual: Array.from(versions).join(', '),
|
|
47
|
+
reason: `Multiple versions of "${name}" are present in your project (${Array.from(versions).join(', ')}). This often happens when different libraries require incompatible versions of the same dependency. This can increase project size and cause unpredictable bugs.`
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return groups.filter(g => g.issues.length > 0);
|
|
52
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export function buildDependencyGraph(project, metadataMap, environment = 'local') {
|
|
2
|
+
const { packageJson, packageLock } = project;
|
|
3
|
+
const dependencies = [];
|
|
4
|
+
const lockPackages = packageLock.packages || {};
|
|
5
|
+
const directDeps = {
|
|
6
|
+
...(packageJson.dependencies || {}),
|
|
7
|
+
...(packageJson.devDependencies || {})
|
|
8
|
+
};
|
|
9
|
+
// Build a map of dependency name -> list of parents that require it
|
|
10
|
+
// We use both the path (for nested npm v3 style) AND the dependencies field (for flat npm v7+ style)
|
|
11
|
+
const parentMap = {};
|
|
12
|
+
for (const [path, pkg] of Object.entries(lockPackages)) {
|
|
13
|
+
// 1. From Path Nesting (Fallback for minimized lockfiles or npm < 7)
|
|
14
|
+
const pathParts = path.split('node_modules/');
|
|
15
|
+
if (pathParts.length > 2) {
|
|
16
|
+
const depName = pathParts[pathParts.length - 1];
|
|
17
|
+
const parentName = pathParts[pathParts.length - 2].replace(/\/$/, '');
|
|
18
|
+
if (!parentMap[depName])
|
|
19
|
+
parentMap[depName] = new Set();
|
|
20
|
+
parentMap[depName].add(parentName);
|
|
21
|
+
}
|
|
22
|
+
// 2. From dependencies field (Standard npm behavior)
|
|
23
|
+
const currentPkgName = path === '' ? (packageJson.name || 'root') : path.split('node_modules/').pop();
|
|
24
|
+
const pkgDeps = {
|
|
25
|
+
...(pkg.dependencies || {}),
|
|
26
|
+
...(pkg.devDependencies || {}),
|
|
27
|
+
...(pkg.optionalDependencies || {})
|
|
28
|
+
};
|
|
29
|
+
for (const depName of Object.keys(pkgDeps)) {
|
|
30
|
+
if (!parentMap[depName])
|
|
31
|
+
parentMap[depName] = new Set();
|
|
32
|
+
parentMap[depName].add(currentPkgName);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Process nodes
|
|
36
|
+
const processedNodes = new Map();
|
|
37
|
+
for (const [path, pkg] of Object.entries(lockPackages)) {
|
|
38
|
+
if (path === '')
|
|
39
|
+
continue; // Skip root
|
|
40
|
+
if (path.startsWith('node_modules/')) {
|
|
41
|
+
const name = path.split('node_modules/').pop();
|
|
42
|
+
// If we have multiple paths for the same package (transitive drift),
|
|
43
|
+
// we'll handle them as separate nodes if their versions differ,
|
|
44
|
+
// but usually buildDependencyGraph has been flat-ish so far.
|
|
45
|
+
// Let's keep it simple: one node per unique package name from the lockfile's perspective.
|
|
46
|
+
const versionRange = directDeps[name] || pkg.version;
|
|
47
|
+
const isTransitive = !directDeps[name];
|
|
48
|
+
const meta = metadataMap[name];
|
|
49
|
+
// Direct parents are those who have this dependency in their path segment
|
|
50
|
+
// but we filter out 'root' for the introducedBy array as requested previously
|
|
51
|
+
// actually, if it's transitive, we want the non-root parents.
|
|
52
|
+
const parents = Array.from(parentMap[name] || []).filter(p => p !== (packageJson.name || 'root'));
|
|
53
|
+
dependencies.push({
|
|
54
|
+
name,
|
|
55
|
+
version: typeof versionRange === 'string' ? versionRange : pkg.version,
|
|
56
|
+
resolvedVersion: pkg.version,
|
|
57
|
+
transitive: isTransitive,
|
|
58
|
+
introducedBy: parents,
|
|
59
|
+
metadata: {
|
|
60
|
+
lastPublish: meta?.time?.[meta['dist-tags']?.latest],
|
|
61
|
+
maintainers: meta?.maintainers?.length,
|
|
62
|
+
deprecated: meta?.deprecated,
|
|
63
|
+
description: meta?.description,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
root: packageJson.name || 'root',
|
|
70
|
+
environment,
|
|
71
|
+
dependencies,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export function analyzeSecurity(graph) {
|
|
2
|
+
const groups = [];
|
|
3
|
+
const now = new Date();
|
|
4
|
+
const eighteenMonthsAgo = new Date();
|
|
5
|
+
eighteenMonthsAgo.setMonth(now.getMonth() - 18);
|
|
6
|
+
// We check each unique dependency name in the graph
|
|
7
|
+
const dependencyMap = new Map();
|
|
8
|
+
for (const node of graph.dependencies) {
|
|
9
|
+
if (dependencyMap.has(node.name))
|
|
10
|
+
continue;
|
|
11
|
+
const { metadata } = node;
|
|
12
|
+
const issues = [];
|
|
13
|
+
// 1. Deprecated
|
|
14
|
+
if (metadata.deprecated) {
|
|
15
|
+
issues.push({
|
|
16
|
+
type: 'deprecated',
|
|
17
|
+
reason: `The author of "${node.name}" has marked this library as obsolete (deprecated). Message: "${metadata.deprecated}". It is highly recommended to find a modern alternative as it will likely no longer receive updates.`,
|
|
18
|
+
riskLevel: 'high',
|
|
19
|
+
details: {
|
|
20
|
+
message: metadata.deprecated,
|
|
21
|
+
latestVersion: node.resolvedVersion,
|
|
22
|
+
description: metadata.description
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
// 2. Unmaintained
|
|
27
|
+
if (metadata.lastPublish) {
|
|
28
|
+
const lastPublishDate = new Date(metadata.lastPublish);
|
|
29
|
+
if (lastPublishDate < eighteenMonthsAgo) {
|
|
30
|
+
const monthsSince = Math.round((now.getTime() - lastPublishDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44));
|
|
31
|
+
issues.push({
|
|
32
|
+
type: 'unmaintained',
|
|
33
|
+
reason: `The last update for "${node.name}" was on ${lastPublishDate.toLocaleDateString()} (more than 18 months ago). A library that is no longer updated may contain unpatched security vulnerabilities or become incompatible with newer Node.js versions.`,
|
|
34
|
+
riskLevel: 'medium',
|
|
35
|
+
details: {
|
|
36
|
+
lastUpdate: lastPublishDate.toISOString().split('T')[0],
|
|
37
|
+
monthsSinceLastUpdate: monthsSince,
|
|
38
|
+
version: node.resolvedVersion,
|
|
39
|
+
description: metadata.description
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// 3. Single maintainer
|
|
45
|
+
if (metadata.maintainers === 1) {
|
|
46
|
+
issues.push({
|
|
47
|
+
type: 'single-maintainer',
|
|
48
|
+
reason: `"${node.name}" is managed by only one person. This is risky because if this person stops maintaining it or if their account is compromised, there is no one else to fix issues quickly.`,
|
|
49
|
+
riskLevel: 'low',
|
|
50
|
+
details: {
|
|
51
|
+
maintainerCount: 1,
|
|
52
|
+
description: metadata.description
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (issues.length > 0) {
|
|
57
|
+
// Calculate overallRisk
|
|
58
|
+
const riskOrder = { 'low': 0, 'medium': 1, 'high': 2 };
|
|
59
|
+
let overallRisk = 'low';
|
|
60
|
+
for (const issue of issues) {
|
|
61
|
+
if (riskOrder[issue.riskLevel] > riskOrder[overallRisk]) {
|
|
62
|
+
overallRisk = issue.riskLevel;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const group = {
|
|
66
|
+
dependencyName: node.name,
|
|
67
|
+
transitive: node.transitive,
|
|
68
|
+
introducedBy: node.introducedBy || [],
|
|
69
|
+
description: metadata.description,
|
|
70
|
+
issues,
|
|
71
|
+
overallRisk
|
|
72
|
+
};
|
|
73
|
+
dependencyMap.set(node.name, group);
|
|
74
|
+
groups.push(group);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return groups;
|
|
78
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const RiskLevelSchema = z.enum(['low', 'medium', 'high']);
|
|
3
|
+
export const RecommendedActionSchema = z.enum(['allow', 'warn', 'block']);
|
|
4
|
+
export const DependencyNodeSchema = z.object({
|
|
5
|
+
name: z.string(),
|
|
6
|
+
version: z.string(), // Range or fixed version from package.json
|
|
7
|
+
resolvedVersion: z.string(), // Actual version from lockfile
|
|
8
|
+
transitive: z.boolean(),
|
|
9
|
+
introducedBy: z.array(z.string()).optional(),
|
|
10
|
+
metadata: z.object({
|
|
11
|
+
lastPublish: z.string().optional(), // Date string
|
|
12
|
+
maintainers: z.number().optional(),
|
|
13
|
+
downloads: z.number().optional(),
|
|
14
|
+
deprecated: z.string().optional(),
|
|
15
|
+
description: z.string().optional(),
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
18
|
+
export const DependencyGraphSchema = z.object({
|
|
19
|
+
root: z.string(),
|
|
20
|
+
environment: z.enum(['local', 'ci', 'prod']),
|
|
21
|
+
dependencies: z.array(DependencyNodeSchema),
|
|
22
|
+
});
|
|
23
|
+
export const ScanMetadataSchema = z.object({
|
|
24
|
+
schemaVersion: z.literal("1.0"),
|
|
25
|
+
scanId: z.string().uuid(),
|
|
26
|
+
projectName: z.string(),
|
|
27
|
+
projectId: z.string(),
|
|
28
|
+
generatedAt: z.string().datetime(),
|
|
29
|
+
});
|
|
30
|
+
export const AnalysisReportSchema = z.object({
|
|
31
|
+
summary: z.object({
|
|
32
|
+
driftCount: z.number(),
|
|
33
|
+
securityCount: z.number(),
|
|
34
|
+
riskLevel: RiskLevelSchema,
|
|
35
|
+
riskReason: z.string().optional(),
|
|
36
|
+
recommendedAction: RecommendedActionSchema.optional(),
|
|
37
|
+
recommendedExitCode: z.number(),
|
|
38
|
+
}),
|
|
39
|
+
drift: z.array(z.any()),
|
|
40
|
+
security: z.array(z.any()),
|
|
41
|
+
});
|
|
42
|
+
export const ScanResultSchema = AnalysisReportSchema.extend({
|
|
43
|
+
meta: ScanMetadataSchema,
|
|
44
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export function formatConsoleReport(report) {
|
|
2
|
+
const { summary, drift, security } = report;
|
|
3
|
+
let output = `\n=== dep-drift-sec Analysis ===\n`;
|
|
4
|
+
output += `Risk Level: ${summary.riskLevel.toUpperCase()}\n`;
|
|
5
|
+
output += `Action: ${(summary.recommendedAction || 'allow').toUpperCase()}\n`;
|
|
6
|
+
output += `Reason: ${summary.riskReason || 'N/A'}\n\n`;
|
|
7
|
+
output += `Drift Issues: ${summary.driftCount}\n`;
|
|
8
|
+
output += `Security Issues: ${summary.securityCount}\n\n`;
|
|
9
|
+
if (drift.length > 0) {
|
|
10
|
+
output += `--- Dependency Drift ---\n`;
|
|
11
|
+
drift.forEach((group) => {
|
|
12
|
+
const label = group.transitive ? '[TRANSITIVE]' : '[DIRECT]';
|
|
13
|
+
const introducedBy = group.transitive && group.introducedBy.length > 0
|
|
14
|
+
? ` (via ${group.introducedBy.join(', ')})`
|
|
15
|
+
: '';
|
|
16
|
+
output += `${label} ${group.dependencyName}${introducedBy}\n`;
|
|
17
|
+
output += ` Impact: This dependency has version fluctuations which can lead to "works on my machine" bugs.\n`;
|
|
18
|
+
group.issues.forEach(issue => {
|
|
19
|
+
output += ` - [${issue.type.toUpperCase()}] ${issue.reason}\n`;
|
|
20
|
+
output += ` Expected: ${issue.expected}, Actual: ${issue.actual}\n`;
|
|
21
|
+
});
|
|
22
|
+
output += `\n`;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
if (security.length > 0) {
|
|
26
|
+
output += `--- Security Heuristics ---\n`;
|
|
27
|
+
security.forEach((group) => {
|
|
28
|
+
const label = group.transitive ? '[TRANSITIVE]' : '[DIRECT]';
|
|
29
|
+
const introducedBy = group.transitive && group.introducedBy.length > 0
|
|
30
|
+
? ` (via ${group.introducedBy.join(', ')})`
|
|
31
|
+
: '';
|
|
32
|
+
output += `[${group.overallRisk.toUpperCase()}] ${label} ${group.dependencyName}${introducedBy}\n`;
|
|
33
|
+
// Per-dependency explanation
|
|
34
|
+
if (group.overallRisk === 'high') {
|
|
35
|
+
output += ` Impact: This package is deprecated or critical; it should be replaced immediately to avoid security breaches.\n`;
|
|
36
|
+
}
|
|
37
|
+
else if (group.overallRisk === 'medium') {
|
|
38
|
+
output += ` Impact: This package is unmaintained; it may have hidden vulnerabilities or compatibility issues.\n`;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
output += ` Impact: This package has minor supply chain risks (e.g., single maintainer).\n`;
|
|
42
|
+
}
|
|
43
|
+
if (group.description) {
|
|
44
|
+
output += ` Description: ${group.description}\n`;
|
|
45
|
+
}
|
|
46
|
+
group.issues.forEach(issue => {
|
|
47
|
+
output += ` - [${issue.type.toUpperCase()}] ${issue.reason}\n`;
|
|
48
|
+
if (issue.details) {
|
|
49
|
+
const detailLines = [];
|
|
50
|
+
for (const [key, value] of Object.entries(issue.details)) {
|
|
51
|
+
if (key === 'description')
|
|
52
|
+
continue;
|
|
53
|
+
if (value !== undefined)
|
|
54
|
+
detailLines.push(`${key}: ${value}`);
|
|
55
|
+
}
|
|
56
|
+
if (detailLines.length > 0) {
|
|
57
|
+
output += ` Details: ${detailLines.join(', ')}\n`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
output += `\n`;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (summary.driftCount === 0 && summary.securityCount === 0) {
|
|
65
|
+
output += `No issues detected. Your dependencies are healthy!\n`;
|
|
66
|
+
}
|
|
67
|
+
output += `\nRecommended Exit Code: ${summary.recommendedExitCode}\n`;
|
|
68
|
+
return output;
|
|
69
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dep-drift-sec",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "index.js",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "vitest",
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"scan": "node dist/cli/index.js check"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [],
|
|
11
|
+
"author": "",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/simonelakra/dep-drift-sec.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/simonelakra/dep-drift-sec/issues"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/simonelakra/dep-drift-sec#readme",
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"description": "Production-ready CLI to detect dependency drift and security risks.",
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^25.0.9",
|
|
30
|
+
"tsx": "^4.21.0",
|
|
31
|
+
"typescript": "^5.9.3",
|
|
32
|
+
"vitest": "^4.0.17"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"axios": "^1.13.2",
|
|
36
|
+
"commander": "^14.0.2",
|
|
37
|
+
"zod": "^4.3.5"
|
|
38
|
+
},
|
|
39
|
+
"bin": {
|
|
40
|
+
"dep-drift-sec": "dist/cli/index.js"
|
|
41
|
+
},
|
|
42
|
+
"type": "module"
|
|
43
|
+
}
|