dependencyiq 2.0.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.
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Blast-radius analysis: "if this package is vulnerable, how exposed is
3
+ * this project really?" Backed by GitLab Orbit when it's reachable and
4
+ * enabled on the group; otherwise falls back to a clearly-flagged
5
+ * heuristic so risk scores never silently pretend to be more confident
6
+ * than they are.
7
+ */
8
+
9
+ const orbitClient = require('./orbitClient');
10
+
11
+ // Built-in heuristic defaults, used when AGENTS.md doesn't specify
12
+ // public_api_paths / test_paths. A project with a non-standard layout
13
+ // can override these via AGENTS.md (see configLoader.js) so the
14
+ // classification matches its actual structure rather than these guesses.
15
+ const PUBLIC_PATH_HINTS = ['/api/', '/routes/', '/controllers/', '/endpoints/', 'handler'];
16
+ const TEST_PATH_HINTS = ['/test/', '/tests/', '/spec/', '.test.', '.spec.', '__tests__'];
17
+
18
+ /**
19
+ * Turn an AGENTS.md glob like "src/api/**" or "**\/*.test.js" into a
20
+ * loose substring matcher. Not a full glob engine — these hints only
21
+ * need to answer "does this path look public-API / test" well enough to
22
+ * weight exposure, and over-engineering a globber would add risk for no
23
+ * real gain. Strips all "*" wildcards and surrounding slashes, leaving
24
+ * the literal segment to substring-match: "src/api/**" -> "src/api",
25
+ * "**\/*.test.js" -> ".test.js".
26
+ */
27
+ function globToHint(glob) {
28
+ return glob.replace(/\*/g, '').replace(/^\/+|\/+$/g, '');
29
+ }
30
+
31
+ function hintsFor(configured, defaults) {
32
+ if (Array.isArray(configured) && configured.length > 0) {
33
+ return configured.map(globToHint).filter(Boolean);
34
+ }
35
+ return defaults;
36
+ }
37
+
38
+ let orbitReachable = null; // cached per-process: null = unknown, true/false once checked
39
+
40
+ async function isOrbitReachable() {
41
+ if (orbitReachable !== null) return orbitReachable;
42
+ if (!process.env.GITLAB_TOKEN) {
43
+ orbitReachable = false;
44
+ return false;
45
+ }
46
+ try {
47
+ const status = await orbitClient.getStatus();
48
+ orbitReachable = status?.status === 'healthy';
49
+ } catch {
50
+ orbitReachable = false;
51
+ }
52
+ return orbitReachable;
53
+ }
54
+
55
+ /**
56
+ * Classify one file's path as public-api / test / internal — used to
57
+ * group the per-file evidence list (dashboardGenerator.js's decision
58
+ * trail) instead of just reporting an aggregate "is *something* in the
59
+ * public API" boolean. Honours AGENTS.md's public_api_paths/test_paths
60
+ * when provided; otherwise uses the built-in heuristic defaults.
61
+ */
62
+ function categorizeFile(filePath, hints = {}) {
63
+ const lower = filePath?.toLowerCase() || '';
64
+ const testHints = hintsFor(hints.test_paths, TEST_PATH_HINTS);
65
+ const publicHints = hintsFor(hints.public_api_paths, PUBLIC_PATH_HINTS);
66
+ if (testHints.some(hint => lower.includes(hint.toLowerCase()))) return 'test';
67
+ if (publicHints.some(hint => lower.includes(hint.toLowerCase()))) return 'public-api';
68
+ return 'internal';
69
+ }
70
+
71
+ function classifyFiles(files, hints = {}) {
72
+ const categorized = files.map(f => categorizeFile(f.path, hints));
73
+ return {
74
+ isInPublicAPI: categorized.includes('public-api'),
75
+ isInTests: categorized.includes('test'),
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Analyze exposure for a single vulnerable package.
81
+ * @param {string} projectId - GitLab project ID
82
+ * @param {string} packageName - package/import name
83
+ * @param {Object} hints - { public_api_paths, test_paths } from AGENTS.md
84
+ * (configLoader); empty/omitted uses the built-in heuristic defaults.
85
+ * @returns {Promise<Object>} exposure analysis with a `source` field
86
+ * ('orbit' | 'unavailable') so callers know how much to trust it.
87
+ */
88
+ async function analyzeExposure(projectId, packageName, hints = {}) {
89
+ if (projectId && await isOrbitReachable()) {
90
+ try {
91
+ const files = await orbitClient.findPackageImporters(projectId, packageName);
92
+ const { isInPublicAPI, isInTests } = classifyFiles(files, hints);
93
+ const categorizedFiles = files.map(f => ({ ...f, category: categorizeFile(f.path, hints) }));
94
+ return {
95
+ source: 'orbit',
96
+ affectedFiles: categorizedFiles,
97
+ affectedFilesCount: files.length,
98
+ isInPublicAPI,
99
+ isInTests,
100
+ languages: [...new Set(files.map(f => f.language).filter(Boolean))],
101
+ // Orbit actually checked (this isn't the unavailable-fallback path)
102
+ // and found zero importers anywhere in the project: the dependency
103
+ // is most likely dead weight, so the right fix is removing it, not
104
+ // patching a CVE nothing uses. OSV-Scanner can't know this (it only
105
+ // reads manifests) and GitLab's Security Analyst Agent can't either
106
+ // (it only acts on already-detected vulnerabilities, never raw
107
+ // import graphs).
108
+ recommendation: files.length === 0 ? 'remove' : 'upgrade',
109
+ };
110
+ } catch (error) {
111
+ console.warn(` āš ļø Orbit query failed for ${packageName}: ${error.message}`);
112
+ }
113
+ }
114
+
115
+ return {
116
+ source: 'unavailable',
117
+ affectedFiles: [],
118
+ affectedFilesCount: 0,
119
+ isInPublicAPI: false,
120
+ isInTests: false,
121
+ languages: [],
122
+ // Deliberately NOT 'remove' — zero files here means "we don't know",
123
+ // not "Orbit confirmed zero importers". Only a real Orbit answer can
124
+ // justify recommending deletion.
125
+ recommendation: 'upgrade',
126
+ warning: 'GitLab Orbit is not reachable/enabled for this project — exposure defaulted to unknown rather than guessed. Enable Orbit on the group to get real blast-radius scoring.',
127
+ };
128
+ }
129
+
130
+ module.exports = {
131
+ analyzeExposure,
132
+ isOrbitReachable,
133
+ categorizeFile,
134
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * AGENTS.md config loader.
3
+ *
4
+ * AGENTS.md has documented a YAML config block (risk_thresholds,
5
+ * excluded_packages, freshness_policy, ...) since early in this project,
6
+ * but until now nothing actually read it — it was documentation, not
7
+ * config. This parses the first ```yaml fenced block in AGENTS.md and
8
+ * merges it over sane defaults, so the file finally does what its
9
+ * comments always claimed.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const yaml = require('js-yaml');
15
+
16
+ const DEFAULTS = {
17
+ risk_thresholds: { urgent: 80, high: 50, medium: 20, low: 0 },
18
+ excluded_packages: [],
19
+ freshness_policy: {
20
+ max_minor_versions_behind: null, // null = not enforced
21
+ max_days_behind: null,
22
+ stability_window_days: 14,
23
+ },
24
+ // Glob-ish path hints used to classify where a vulnerable package is
25
+ // imported (public-API vs internal vs test). Empty here so the
26
+ // classifier uses its built-in heuristic defaults; a project can
27
+ // override these in AGENTS.md to match its own layout.
28
+ public_api_paths: [],
29
+ test_paths: [],
30
+ };
31
+
32
+ /**
33
+ * @param {string} repoPath - directory containing AGENTS.md
34
+ * @returns {Object} merged config (defaults + whatever AGENTS.md overrides)
35
+ */
36
+ function loadAgentsConfig(repoPath) {
37
+ const filePath = path.join(repoPath, 'AGENTS.md');
38
+ if (!fs.existsSync(filePath)) return { ...DEFAULTS, source: 'defaults (no AGENTS.md found)' };
39
+
40
+ const text = fs.readFileSync(filePath, 'utf-8');
41
+ const match = text.match(/```ya?ml\r?\n([\s\S]*?)```/);
42
+ if (!match) return { ...DEFAULTS, source: 'defaults (AGENTS.md found, no yaml block)' };
43
+
44
+ let parsed;
45
+ try {
46
+ parsed = yaml.load(match[1]);
47
+ } catch (error) {
48
+ return { ...DEFAULTS, source: `defaults (AGENTS.md yaml block failed to parse: ${error.message})` };
49
+ }
50
+
51
+ return {
52
+ risk_thresholds: { ...DEFAULTS.risk_thresholds, ...(parsed?.risk_thresholds || {}) },
53
+ excluded_packages: parsed?.excluded_packages || DEFAULTS.excluded_packages,
54
+ freshness_policy: { ...DEFAULTS.freshness_policy, ...(parsed?.freshness_policy || {}) },
55
+ public_api_paths: parsed?.public_api_paths || DEFAULTS.public_api_paths,
56
+ test_paths: parsed?.test_paths || DEFAULTS.test_paths,
57
+ source: 'AGENTS.md',
58
+ };
59
+ }
60
+
61
+ module.exports = { loadAgentsConfig, DEFAULTS };
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Emergency Supply Chain Response: org-wide fan-out for a compromised
3
+ * package (the Log4j/xz-utils/event-stream scenario).
4
+ *
5
+ * Most blast-radius tools in this space are scoped to one repo/MR. This
6
+ * is scoped to a GitLab group: it asks Orbit Remote's SDLC-wide graph
7
+ * "which projects in this group import this package at all," then for
8
+ * each one classifies whether it's a direct dependency (safe to
9
+ * auto-fix) or only reached transitively (imported in code, but not a
10
+ * direct manifest entry — flagged for manual review, since editing a
11
+ * transitive entry wouldn't actually pin anything).
12
+ *
13
+ * Honesty note: this only covers projects where Orbit's import graph
14
+ * shows actual usage. Projects that merely *declare* the package in a
15
+ * manifest without ever importing it in code aren't surfaced here — that
16
+ * would require scanning every group project's lockfile/manifest
17
+ * directly (Orbit's schema doesn't expose a dependency-manifest node as
18
+ * of this writing), which is out of scope for this pass.
19
+ */
20
+
21
+ const orbitClient = require('./orbitClient');
22
+ const { applyFixToContent } = require('./scanners/ecosystemFixers');
23
+ const { findRemoteManifest, applyRemoteFix } = require('./remoteFixer');
24
+ const { openMergeRequestForBranch, createTrackingIssue } = require('./prGenerator');
25
+
26
+ /**
27
+ * Classify one affected project's exposure tier by attempting a dry-run
28
+ * fix against its manifest (without committing anything).
29
+ * @returns {Promise<Object>} { tier: 'direct'|'transitive'|'unknown', manifestPath, dryRunResult }
30
+ */
31
+ async function classifyProjectExposure(project, vulnerability) {
32
+ const manifest = await findRemoteManifest(project.projectId, vulnerability.ecosystem, vulnerability.manifestFile);
33
+ if (!manifest.found) {
34
+ return { tier: 'unknown', reason: manifest.reason };
35
+ }
36
+
37
+ const dryRun = applyFixToContent(
38
+ vulnerability.ecosystem,
39
+ manifest.content,
40
+ vulnerability.package,
41
+ vulnerability.fixedVersion,
42
+ vulnerability.recommendation
43
+ );
44
+
45
+ return {
46
+ tier: dryRun.touched ? 'direct' : 'transitive',
47
+ manifestPath: manifest.path,
48
+ reason: dryRun.touched ? null : dryRun.warning,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Run the full emergency response for a compromised package across a group.
54
+ * @param {string} groupId - GitLab top-level group ID
55
+ * @param {Object} vulnerability - normalized vulnerability (package, ecosystem, fixedVersion, id, recommendation, ...)
56
+ * @param {Object} options - { dryRun: boolean, fixDirect: boolean }
57
+ * @returns {Promise<Object>} rollup report
58
+ */
59
+ async function runEmergencyResponse(groupId, vulnerability, options = {}) {
60
+ const { dryRun = true, fixDirect = false } = options;
61
+
62
+ console.log(`\n🚨 EMERGENCY SUPPLY CHAIN RESPONSE: ${vulnerability.package} (${vulnerability.id})`);
63
+ console.log(` Querying GitLab Orbit for every project in group ${groupId} that imports it...`);
64
+
65
+ const affectedProjects = await orbitClient.findProjectsImportingPackage(groupId, vulnerability.package);
66
+ console.log(` Found ${affectedProjects.length} project(s) with confirmed usage.`);
67
+
68
+ const results = [];
69
+ for (const project of affectedProjects) {
70
+ const exposure = await classifyProjectExposure(project, vulnerability);
71
+ const entry = {
72
+ projectId: project.projectId,
73
+ projectPath: project.projectPath,
74
+ filesImporting: project.files.length,
75
+ tier: exposure.tier,
76
+ reason: exposure.reason,
77
+ issue: null,
78
+ mergeRequest: null,
79
+ };
80
+
81
+ const description = `# Supply Chain Alert: ${vulnerability.package}
82
+
83
+ \`${vulnerability.package}\` (${vulnerability.ecosystem}) — **${vulnerability.vulnerability || 'compromised/vulnerable package'}** (${vulnerability.id}) — was found imported in **${project.files.length} file(s)** in this project by a GitLab Orbit group-wide scan.
84
+
85
+ - **Exposure tier**: ${exposure.tier === 'direct' ? 'Direct dependency — can be auto-fixed' : exposure.tier === 'transitive' ? 'Imported in code, but not a direct manifest entry (transitive) — needs manual root-cause fix' : `Could not classify: ${exposure.reason}`}
86
+ - **Affected files**: ${project.files.slice(0, 5).map(f => f.path).join(', ')}${project.files.length > 5 ? `, +${project.files.length - 5} more` : ''}
87
+
88
+ This issue was opened automatically as part of a group-wide emergency response. ${fixDirect && exposure.tier === 'direct' ? 'A merge request with the fix is linked below.' : 'No automated fix was applied' + (exposure.tier === 'transitive' ? ' — this is a transitive dependency, editing this manifest directly would not pin anything.' : '.')}
89
+ `;
90
+
91
+ if (!dryRun) {
92
+ try {
93
+ const issue = await createTrackingIssue(
94
+ project.projectId,
95
+ `Supply chain alert: ${vulnerability.package} (${vulnerability.id})`,
96
+ description
97
+ );
98
+ entry.issue = issue;
99
+ } catch (error) {
100
+ entry.issueError = error.message;
101
+ }
102
+
103
+ if (fixDirect && exposure.tier === 'direct') {
104
+ try {
105
+ const branchName = `security/emergency-${vulnerability.package.replace(/[^a-zA-Z0-9]+/g, '-')}-${Date.now()}`;
106
+ const fixResult = await applyRemoteFix(project.projectId, vulnerability, branchName);
107
+ if (fixResult.applied) {
108
+ entry.mergeRequest = await openMergeRequestForBranch(project.projectId, vulnerability, fixResult, description);
109
+ } else {
110
+ entry.fixWarning = fixResult.warning;
111
+ }
112
+ } catch (error) {
113
+ entry.fixError = error.message;
114
+ }
115
+ }
116
+ }
117
+
118
+ results.push(entry);
119
+ }
120
+
121
+ return buildRollup(vulnerability, results, { dryRun, fixDirect });
122
+ }
123
+
124
+ function buildRollup(vulnerability, results, options) {
125
+ const direct = results.filter(r => r.tier === 'direct');
126
+ const transitive = results.filter(r => r.tier === 'transitive');
127
+ const unknown = results.filter(r => r.tier === 'unknown');
128
+ const fixed = results.filter(r => r.mergeRequest?.success);
129
+
130
+ return {
131
+ package: vulnerability.package,
132
+ vulnerabilityId: vulnerability.id,
133
+ dryRun: options.dryRun,
134
+ totalAffectedProjects: results.length,
135
+ byTier: { direct: direct.length, transitive: transitive.length, unknown: unknown.length },
136
+ remediationProgress: results.length > 0 ? Math.round((fixed.length / direct.length || 0) * 100) : 0,
137
+ results,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Render the rollup as a markdown dashboard summary — the honest
143
+ * substitute for a live UI dashboard, postable as one tracking issue.
144
+ */
145
+ function formatRollup(rollup) {
146
+ const lines = [
147
+ `# Emergency Supply Chain Response: ${rollup.package} (${rollup.vulnerabilityId})`,
148
+ '',
149
+ rollup.dryRun ? '**DRY RUN — no issues or merge requests were created.**' : '',
150
+ '',
151
+ `**Repositories impacted**: ${rollup.totalAffectedProjects}`,
152
+ '',
153
+ `- šŸ”“ Direct dependency (auto-fixable): ${rollup.byTier.direct}`,
154
+ `- 🟔 Transitive only (needs manual root-cause fix): ${rollup.byTier.transitive}`,
155
+ `- ⚪ Could not classify: ${rollup.byTier.unknown}`,
156
+ '',
157
+ `**Remediation progress**: ${rollup.remediationProgress}% of direct-dependency projects have an MR open`,
158
+ '',
159
+ '## Per-project detail',
160
+ '',
161
+ ];
162
+
163
+ for (const r of rollup.results) {
164
+ lines.push(`### ${r.projectPath || r.projectId}`);
165
+ lines.push(`- Tier: ${r.tier}${r.reason ? ` (${r.reason})` : ''}`);
166
+ lines.push(`- Files importing: ${r.filesImporting}`);
167
+ if (r.issue) lines.push(`- Issue: ${r.issue.url}`);
168
+ if (r.mergeRequest?.success) lines.push(`- Merge request: ${r.mergeRequest.url}`);
169
+ if (r.fixWarning) lines.push(`- Fix not applied: ${r.fixWarning}`);
170
+ lines.push('');
171
+ }
172
+
173
+ return lines.join('\n');
174
+ }
175
+
176
+ module.exports = {
177
+ runEmergencyResponse,
178
+ classifyProjectExposure,
179
+ formatRollup,
180
+ };