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.
- package/LICENSE +21 -0
- package/package.json +50 -0
- package/src/activityFetcher.js +66 -0
- package/src/agent.js +506 -0
- package/src/blastRadius.js +134 -0
- package/src/configLoader.js +61 -0
- package/src/crossProjectFanOut.js +180 -0
- package/src/dashboardGenerator.js +642 -0
- package/src/executiveSummary.js +76 -0
- package/src/fleetAggregator.js +155 -0
- package/src/fleetDashboardGenerator.js +199 -0
- package/src/fleetSnapshot.js +103 -0
- package/src/freshnessChecker.js +306 -0
- package/src/freshnessPolicy.js +73 -0
- package/src/gitlabAuth.js +38 -0
- package/src/httpRetry.js +48 -0
- package/src/impactReport.js +92 -0
- package/src/mrReviewer.js +245 -0
- package/src/orbitClient.js +214 -0
- package/src/prGenerator.js +228 -0
- package/src/remoteFixer.js +129 -0
- package/src/riskCalculator.js +143 -0
- package/src/scanners/cvss.js +78 -0
- package/src/scanners/dependencyTreeBuilder.js +227 -0
- package/src/scanners/ecosystemFixers.js +371 -0
- package/src/scanners/manifestParser.js +99 -0
- package/src/scanners/osvScanner.js +228 -0
- package/src/scanners/supplyChainTrustSignals.js +472 -0
- package/src/strategyGenerator.js +384 -0
- package/src/upgradeImpactSimulator.js +241 -0
|
@@ -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
|
+
};
|