forked from staafl/javascript-check-dependencies-action
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.js
More file actions
211 lines (181 loc) · 5.85 KB
/
index.js
File metadata and controls
211 lines (181 loc) · 5.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
const core = require("@actions/core");
const glob = require("@actions/glob");
const fs = require("fs");
const semver = require("semver");
// badRules structure after loading:
// {
// "@acme/bad": ["1.0.*", "^1.1.2"],
// "evil-package": ["*"]
// }
async function run() {
try {
const rulesUrl = core.getInput("rules_url", { required: true });
const badRules = await loadBadDependencyRules(rulesUrl);
if (!badRules || Object.keys(badRules).length === 0) {
core.warning("No bad dependency rules loaded – nothing to check.");
return;
}
const globber = await glob.create("**/package-lock.json");
const files = [];
for await (const file of globber.globGenerator()) {
files.push(file);
}
if (files.length === 0) {
core.info("No package-lock.json files found. Nothing to check.");
return;
}
core.info(`Found ${files.length} package-lock.json file(s). Scanning...`);
const allFindings = [];
for (const file of files) {
const content = fs.readFileSync(file, "utf8");
let json;
try {
json = JSON.parse(content);
} catch (err) {
core.warning(`Skipping ${file}: invalid JSON (${err.message})`);
continue;
}
const findings = scanPackageLock(json, file, badRules);
allFindings.push(...findings);
}
if (allFindings.length > 0) {
core.startGroup("Compromised dependencies found");
for (const finding of allFindings) {
core.error(
`File: ${finding.file}\n` +
`Location: ${finding.location}\n` +
`Package: ${finding.name}\n` +
`Version: ${finding.version}\n` +
`Matched ranges: ${finding.matchedRanges.join(", ")}\n`
);
}
core.endGroup();
core.setFailed(
`Detected ${allFindings.length} occurrence(s) of dependencies matching the bad rules from ${rulesUrl}.`
);
} else {
core.info(`No compromised dependencies found using rules from ${rulesUrl}.`);
}
} catch (error) {
core.setFailed(error.message);
}
}
/**
* Load bad dependency rules from a URL.
* Expected format:
* [
* ["@acme/bad", "1.0.*", "^1.1.2"],
* ["evil-package": "*"]
* ]
*/
async function loadBadDependencyRules(url) {
core.info(`Loading bad dependency rules from ${url}...`);
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch bad dependency rules: ${res.status} ${res.statusText}`);
}
let data;
try {
data = await res.json();
} catch (e) {
throw new Error(`Bad dependency rules at ${url} are not valid JSON: ${e.message}`);
}
if (!data || !Array.isArray(data)) {
throw new Error("Bad dependency rules: JSON must be an array with each entry of the format [package-name,range1,range2...].");
}
const normalized = {};
for (const pkgAndRanges of data) {
// ["@acme/bad", "1.0.*", "^1.1.2"]
if (Array.isArray(pkgAndRanges)) {
const name = pkgAndRanges[0];
if (!normalized[name]) {
normalized[name] = [];
}
[].push.apply(normalized[name], pkgAndRanges.slice(1).map(String));
} else {
core.warning(
`Ignoring rules for "${pkg}" – array of strings, got ${typeof pkgAndRanges}`
);
}
}
core.info(
`Loaded rules for ${Object.keys(normalized).length} package(s): ${Object.keys(normalized).slice(0, 3).join(",")}...`
);
return normalized;
}
/**
* Check if a given (packageName, version) is bad according to rules.
*
* rules = {
* "@acme/bad": ["1.0.*", "^1.1.2"],
* "evil-package": ["*"]
* }
*/
function matchBadRules(packageName, version, rules) {
const pkgRules = rules[packageName];
if (!pkgRules || pkgRules.length === 0) return null;
if (typeof version !== "string" || !version) return null;
// Coerce version, since package-lock versions should be concrete but
// this makes us a bit more robust.
const coerced = semver.coerce(version);
if (!coerced) {
// If coercion fails but there's a "*" rule, treat it as a match.
const hasWildcard = pkgRules.some((r) => r.trim() === "*");
return hasWildcard ? ["*"] : null;
}
const matchedRanges = pkgRules.filter((range) => {
const trimmed = range.trim();
if (trimmed === "*") {
return true;
}
return semver.satisfies(coerced, trimmed, { includePrerelease: true });
});
return matchedRanges.length > 0 ? matchedRanges : null;
}
/**
* Scan a parsed package-lock.json for bad packages.
* Works for v1 and v2/v3 formats by walking the whole object.
*/
function scanPackageLock(json, file, rules) {
const findings = [];
function visit(node, pathSoFar) {
if (!node || typeof node !== "object") return;
// If this object itself describes a package: look for name + version
if (node.name && node.version) {
const matchedRanges = matchBadRules(node.name, node.version, rules);
if (matchedRanges) {
findings.push({
file,
location: pathSoFar || "<root>",
name: node.name,
version: node.version,
matchedRanges,
});
}
}
// Handle dependencies-style maps:
// "dependencies": { "pkg": { "version": "1.0.0", ... } }
for (const [key, value] of Object.entries(node)) {
if (value && typeof value === "object") {
// If key is a package name in rules and value has a version field,
// this is likely a dependency entry.
if (rules[key] && value.version) {
const matchedRanges = matchBadRules(key, value.version, rules);
if (matchedRanges) {
findings.push({
file,
location: pathSoFar ? `${pathSoFar}.${key}` : key,
name: key,
version: value.version,
matchedRanges,
});
}
}
visit(value, pathSoFar ? `${pathSoFar}.${key}` : key);
}
}
}
visit(json, "");
return findings;
}
run();