safeey-cli 0.2.1 → 0.6.1
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/bin/safeey.js +174 -20
- package/package.json +2 -2
- package/src/auth.js +232 -0
- package/src/baseline.js +42 -0
- package/src/config.js +1 -0
- package/src/engine/githistory.js +84 -0
- package/src/engine/infra.js +46 -0
- package/src/engine/osv.js +173 -0
- package/src/engine/patterns.js +2 -1
- package/src/engine/secrets.js +37 -1
- package/src/engine/suppress.js +50 -0
- package/src/engine/taint.js +182 -0
- package/src/fix.js +116 -0
- package/src/report-sarif.js +67 -0
- package/src/rules/framework.js +102 -0
- package/src/rules/infra.js +120 -0
- package/src/rules/js-rules.js +114 -0
- package/src/scan.js +88 -29
- package/src/spinner.js +97 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// src/engine/infra.js — run infra rules against a file classified by kind.
|
|
2
|
+
const { normalizeAll } = require("../rules/schema");
|
|
3
|
+
const { INFRA_RULES, classifyInfraFile } = require("../rules/infra");
|
|
4
|
+
|
|
5
|
+
// Normalize (fills category/owasp/etc.); keep the infra-specific fields around.
|
|
6
|
+
const RULES = normalizeAll(
|
|
7
|
+
INFRA_RULES.map((r) => ({ ...r, languages: ["*"] })),
|
|
8
|
+
"pattern"
|
|
9
|
+
).map((n, i) => ({ ...n, kinds: INFRA_RULES[i].kinds, whole: INFRA_RULES[i].whole, absentRe: INFRA_RULES[i].absentRe }));
|
|
10
|
+
|
|
11
|
+
function analyzeInfra(code, file, base) {
|
|
12
|
+
const kind = classifyInfraFile(file, base, code);
|
|
13
|
+
if (!kind) return [];
|
|
14
|
+
const applicable = RULES.filter((r) => r.kinds.includes(kind));
|
|
15
|
+
if (applicable.length === 0) return [];
|
|
16
|
+
|
|
17
|
+
const findings = [];
|
|
18
|
+
const lines = code.split(/\r?\n/);
|
|
19
|
+
|
|
20
|
+
for (const rule of applicable) {
|
|
21
|
+
if (rule.whole) {
|
|
22
|
+
// Whole-file rule: fires when re matches somewhere AND absentRe is absent.
|
|
23
|
+
if (rule.re.test(code) && rule.absentRe && !rule.absentRe.test(code)) {
|
|
24
|
+
findings.push(mk(rule, file, 1, 1));
|
|
25
|
+
}
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
for (let i = 0; i < lines.length; i++) {
|
|
29
|
+
const line = lines[i];
|
|
30
|
+
if (line.length > 2000) continue;
|
|
31
|
+
const m = rule.re.exec(line);
|
|
32
|
+
if (m) findings.push(mk(rule, file, i + 1, (m.index ?? 0) + 1));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return findings;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mk(rule, file, line, column) {
|
|
39
|
+
return {
|
|
40
|
+
ruleId: rule.id, title: rule.title, severity: rule.severity, confidence: rule.confidence,
|
|
41
|
+
category: rule.category, cwe: rule.cwe, owasp: rule.owasp,
|
|
42
|
+
file, line, column, message: rule.message, fix: rule.fix,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { analyzeInfra };
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// src/engine/osv.js — check installed dependencies against known CVEs via OSV.dev.
|
|
2
|
+
//
|
|
3
|
+
// This is the only part of Safeey that makes a network request, so it is
|
|
4
|
+
// OPT-IN (`--osv`). It sends *only* package names and versions (never your
|
|
5
|
+
// source) to OSV.dev, Google's public vulnerability database, and maps any
|
|
6
|
+
// advisories back to findings. Reads the lockfile so it covers transitive
|
|
7
|
+
// dependencies, not just what's in package.json.
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
|
|
12
|
+
const OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch";
|
|
13
|
+
const OSV_VULN_URL = "https://api.osv.dev/v1/vulns/";
|
|
14
|
+
|
|
15
|
+
// --- lockfile parsing -------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
// Returns [{ name, version }] from the best lockfile we can find. npm v2/v3
|
|
18
|
+
// lockfiles ("packages" map) preferred; falls back to legacy "dependencies".
|
|
19
|
+
function readNpmLock(dir) {
|
|
20
|
+
const lockPath = path.join(dir, "package-lock.json");
|
|
21
|
+
if (!fs.existsSync(lockPath)) return null;
|
|
22
|
+
let data;
|
|
23
|
+
try { data = JSON.parse(fs.readFileSync(lockPath, "utf8")); } catch { return null; }
|
|
24
|
+
const out = new Map();
|
|
25
|
+
if (data.packages) {
|
|
26
|
+
for (const [key, info] of Object.entries(data.packages)) {
|
|
27
|
+
if (!key || !info || !info.version) continue; // "" is the root project
|
|
28
|
+
const name = key.startsWith("node_modules/") ? key.slice(key.lastIndexOf("node_modules/") + 13) : info.name;
|
|
29
|
+
if (name) out.set(`${name}@${info.version}`, { name, version: info.version });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (out.size === 0 && data.dependencies) {
|
|
33
|
+
const walk = (deps) => {
|
|
34
|
+
for (const [name, info] of Object.entries(deps)) {
|
|
35
|
+
if (info && info.version) out.set(`${name}@${info.version}`, { name, version: info.version });
|
|
36
|
+
if (info && info.dependencies) walk(info.dependencies);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
walk(data.dependencies);
|
|
40
|
+
}
|
|
41
|
+
return [...out.values()];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Fallback: pin-only reads from package.json (no transitive coverage).
|
|
45
|
+
function readManifest(dir) {
|
|
46
|
+
const p = path.join(dir, "package.json");
|
|
47
|
+
if (!fs.existsSync(p)) return null;
|
|
48
|
+
let data;
|
|
49
|
+
try { data = JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const field of ["dependencies", "devDependencies"]) {
|
|
52
|
+
for (const [name, range] of Object.entries(data[field] || {})) {
|
|
53
|
+
const version = String(range).replace(/^[\^~>=<\s]+/, "");
|
|
54
|
+
if (/^\d/.test(version)) out.push({ name, version });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function collectPackages(root) {
|
|
61
|
+
return readNpmLock(root) || readManifest(root) || [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- OSV API ----------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
async function postJson(url, body) {
|
|
67
|
+
const res = await fetch(url, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { "content-type": "application/json" },
|
|
70
|
+
body: JSON.stringify(body),
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok) throw new Error(`OSV ${res.status}`);
|
|
73
|
+
return res.json();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Map an OSV advisory + the package it hit into a Safeey finding.
|
|
77
|
+
function advisoryToFinding(pkg, vuln, rel) {
|
|
78
|
+
const sev = severityOf(vuln);
|
|
79
|
+
const id = vuln.id || (vuln.aliases && vuln.aliases[0]) || "advisory";
|
|
80
|
+
const summary = vuln.summary || (vuln.details ? vuln.details.slice(0, 140) : "known vulnerability");
|
|
81
|
+
const fixed = fixedVersion(vuln);
|
|
82
|
+
return {
|
|
83
|
+
ruleId: `osv/${id}`,
|
|
84
|
+
title: `Vulnerable dependency: ${pkg.name}@${pkg.version}`,
|
|
85
|
+
severity: sev,
|
|
86
|
+
confidence: "high",
|
|
87
|
+
category: "dependencies",
|
|
88
|
+
cwe: (vuln.database_specific && vuln.database_specific.cwe_ids && vuln.database_specific.cwe_ids[0]) || null,
|
|
89
|
+
owasp: "A06:2021 Vulnerable and Outdated Components",
|
|
90
|
+
file: rel,
|
|
91
|
+
line: 0,
|
|
92
|
+
column: 0,
|
|
93
|
+
message: `${pkg.name}@${pkg.version} is affected by ${id}: ${summary}`,
|
|
94
|
+
fix: fixText(pkg, vuln),
|
|
95
|
+
// Consumed by the auto-fixer (src/fix.js):
|
|
96
|
+
package: { name: pkg.name, version: pkg.version },
|
|
97
|
+
fixedVersion: fixed,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function fixText(pkg, vuln) {
|
|
102
|
+
const fixed = fixedVersion(vuln);
|
|
103
|
+
if (fixed) return `Upgrade ${pkg.name} to ${fixed} or later.`;
|
|
104
|
+
return `Upgrade ${pkg.name} to a patched version (see https://osv.dev/vulnerability/${vuln.id}).`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function fixedVersion(vuln) {
|
|
108
|
+
for (const aff of vuln.affected || []) {
|
|
109
|
+
for (const range of aff.ranges || []) {
|
|
110
|
+
for (const ev of range.events || []) {
|
|
111
|
+
if (ev.fixed) return ev.fixed;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// OSV severity is inconsistent across sources; derive a coarse level.
|
|
119
|
+
function severityOf(vuln) {
|
|
120
|
+
const s = (vuln.database_specific && vuln.database_specific.severity) || "";
|
|
121
|
+
const t = String(s).toUpperCase();
|
|
122
|
+
if (t.includes("CRITICAL")) return "critical";
|
|
123
|
+
if (t.includes("HIGH")) return "high";
|
|
124
|
+
if (t.includes("MODERATE") || t.includes("MEDIUM")) return "medium";
|
|
125
|
+
if (t.includes("LOW")) return "low";
|
|
126
|
+
// CVSS vector present? crude parse of the base score.
|
|
127
|
+
const cvss = (vuln.severity || []).find((x) => /CVSS/.test(x.type));
|
|
128
|
+
if (cvss && cvss.score) {
|
|
129
|
+
const m = /\/AV:/.test(cvss.score) ? null : parseFloat(cvss.score);
|
|
130
|
+
if (m != null && !Number.isNaN(m)) return m >= 9 ? "critical" : m >= 7 ? "high" : m >= 4 ? "medium" : "low";
|
|
131
|
+
}
|
|
132
|
+
return "high"; // default: don't under-report an advisory
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Public entry. Returns { findings, checked, error }.
|
|
136
|
+
async function analyzeDependenciesOSV(root, rel = "package-lock.json") {
|
|
137
|
+
const pkgs = collectPackages(root);
|
|
138
|
+
if (pkgs.length === 0) return { findings: [], checked: 0, error: null };
|
|
139
|
+
if (typeof fetch !== "function") {
|
|
140
|
+
return { findings: [], checked: 0, error: "global fetch unavailable (needs Node 18+)" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
// Batch query: returns per-package lists of vuln IDs only.
|
|
145
|
+
const queries = pkgs.map((p) => ({ version: p.version, package: { name: p.name, ecosystem: "npm" } }));
|
|
146
|
+
const batch = await postJson(OSV_BATCH_URL, { queries });
|
|
147
|
+
const results = (batch && batch.results) || [];
|
|
148
|
+
|
|
149
|
+
// Collect unique vuln IDs, fetch details once each, then map back.
|
|
150
|
+
const idToPkgs = new Map();
|
|
151
|
+
results.forEach((r, i) => {
|
|
152
|
+
for (const v of (r && r.vulns) || []) {
|
|
153
|
+
if (!idToPkgs.has(v.id)) idToPkgs.set(v.id, []);
|
|
154
|
+
idToPkgs.get(v.id).push(pkgs[i]);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const findings = [];
|
|
159
|
+
for (const [id, affectedPkgs] of idToPkgs) {
|
|
160
|
+
let detail;
|
|
161
|
+
try {
|
|
162
|
+
const res = await fetch(OSV_VULN_URL + id);
|
|
163
|
+
detail = res.ok ? await res.json() : { id };
|
|
164
|
+
} catch { detail = { id }; }
|
|
165
|
+
for (const pkg of affectedPkgs) findings.push(advisoryToFinding(pkg, detail, rel));
|
|
166
|
+
}
|
|
167
|
+
return { findings, checked: pkgs.length, error: null };
|
|
168
|
+
} catch (e) {
|
|
169
|
+
return { findings: [], checked: pkgs.length, error: e.message };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { analyzeDependenciesOSV, collectPackages, advisoryToFinding, severityOf };
|
package/src/engine/patterns.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
const { normalizeAll } = require("../rules/schema");
|
|
6
6
|
const { PATTERN_RULES } = require("../rules/patterns");
|
|
7
|
+
const { FRAMEWORK_RULES } = require("../rules/framework");
|
|
7
8
|
|
|
8
|
-
const RULES = normalizeAll(PATTERN_RULES, "pattern");
|
|
9
|
+
const RULES = normalizeAll([...PATTERN_RULES, ...FRAMEWORK_RULES], "pattern");
|
|
9
10
|
|
|
10
11
|
// Map file extension -> language tag used in rule.languages.
|
|
11
12
|
const EXT_LANG = {
|
package/src/engine/secrets.js
CHANGED
|
@@ -49,6 +49,37 @@ const GENERIC_SECRET = {
|
|
|
49
49
|
// Lines we shouldn't flag for the generic rule: placeholders and env references.
|
|
50
50
|
const IGNORE = /(process\.env|import\.meta\.env|os\.environ|getenv|example|placeholder|dummy|your[_-]?(key|token|secret)|xxxx+|<[^>]+>|\$\{|\{\{)/i;
|
|
51
51
|
|
|
52
|
+
// Shannon entropy in bits/char — high values mark random-looking strings
|
|
53
|
+
// (keys/tokens) rather than natural text or identifiers.
|
|
54
|
+
function shannonEntropy(s) {
|
|
55
|
+
const freq = {};
|
|
56
|
+
for (const ch of s) freq[ch] = (freq[ch] || 0) + 1;
|
|
57
|
+
let e = 0;
|
|
58
|
+
for (const ch in freq) { const p = freq[ch] / s.length; e -= p * Math.log2(p); }
|
|
59
|
+
return e;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const ENTROPY_RULE = { id: "secret/high-entropy-string", title: "High-entropy string (possible secret)", severity: "medium", confidence: "low" };
|
|
63
|
+
const KEYLIKE = /^[A-Za-z0-9+/_\-]{20,100}={0,2}$/;
|
|
64
|
+
const WORDY = /^[A-Za-z][a-z]+([A-Z][a-z]+)*$/; // camelCase words — not a key
|
|
65
|
+
|
|
66
|
+
// Flag quoted string literals that look like a high-entropy secret we didn't
|
|
67
|
+
// already match by provider/name. Conservative and low-confidence by design.
|
|
68
|
+
function entropyFindings(line, file, lineNo) {
|
|
69
|
+
const out = [];
|
|
70
|
+
const re = /['"]([^'"\n]{20,100})['"]/g;
|
|
71
|
+
let m;
|
|
72
|
+
while ((m = re.exec(line))) {
|
|
73
|
+
const val = m[1];
|
|
74
|
+
if (!KEYLIKE.test(val) || WORDY.test(val)) continue;
|
|
75
|
+
const threshold = /^[0-9a-f]+$/i.test(val) ? 3.2 : 4.0; // hex has lower per-char entropy
|
|
76
|
+
if (shannonEntropy(val) >= threshold) {
|
|
77
|
+
out.push(makeFinding({ ...ENTROPY_RULE }, file, lineNo, { 0: val, index: m.index + 1 }));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
52
83
|
function mask(s) {
|
|
53
84
|
const t = s.replace(/['"]/g, "");
|
|
54
85
|
if (t.length <= 10) return t.slice(0, 3) + "****";
|
|
@@ -90,7 +121,12 @@ function analyzeSecrets(code, file) {
|
|
|
90
121
|
|
|
91
122
|
if (!matchedProvider) {
|
|
92
123
|
const m = GENERIC_SECRET.re.exec(line);
|
|
93
|
-
if (m && !IGNORE.test(line))
|
|
124
|
+
if (m && !IGNORE.test(line)) {
|
|
125
|
+
findings.push(makeFinding(GENERIC_SECRET, file, i + 1, m));
|
|
126
|
+
} else if (!IGNORE.test(line)) {
|
|
127
|
+
// Last resort: catch key-shaped high-entropy strings with no tell-tale name.
|
|
128
|
+
for (const f of entropyFindings(line, file, i + 1)) findings.push(f);
|
|
129
|
+
}
|
|
94
130
|
}
|
|
95
131
|
}
|
|
96
132
|
return findings;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/engine/suppress.js — let users silence findings they've reviewed.
|
|
2
|
+
// Without this, aggressive rules just get the whole tool switched off. Two
|
|
3
|
+
// mechanisms: inline comments in code, and a .safeeyignore file for whole paths.
|
|
4
|
+
|
|
5
|
+
// Inline:
|
|
6
|
+
// foo(bar) // safeey-ignore js/command-injection
|
|
7
|
+
// // safeey-ignore-next-line js/sql-injection
|
|
8
|
+
// risky()
|
|
9
|
+
// A bare `safeey-ignore` with no rule id suppresses all findings on that line.
|
|
10
|
+
function fileSuppressions(code) {
|
|
11
|
+
const map = new Map(); // 1-based line -> Set(ruleId) | Set("*")
|
|
12
|
+
const lines = code.split(/\r?\n/);
|
|
13
|
+
lines.forEach((line, i) => {
|
|
14
|
+
const m = /safeey-ignore(-next-line)?\b[ \t]*([^\n]*)$/.exec(line);
|
|
15
|
+
if (!m) return;
|
|
16
|
+
const target = m[1] ? i + 2 : i + 1; // next-line -> the following line
|
|
17
|
+
const ids = (m[2] || "").trim().split(/[\s,]+/).filter(Boolean);
|
|
18
|
+
const set = map.get(target) || new Set();
|
|
19
|
+
if (ids.length === 0) set.add("*");
|
|
20
|
+
else ids.forEach((id) => set.add(id));
|
|
21
|
+
map.set(target, set);
|
|
22
|
+
});
|
|
23
|
+
return map;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isSuppressed(map, line, ruleId) {
|
|
27
|
+
const set = map.get(line);
|
|
28
|
+
if (!set) return false;
|
|
29
|
+
return set.has("*") || set.has(ruleId);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// .safeeyignore: one glob-ish path pattern per line (# comments allowed).
|
|
33
|
+
// Supports `*` and `**`; matched against the path relative to the scan root.
|
|
34
|
+
function loadPathIgnores(readFile, rootJoin) {
|
|
35
|
+
const raw = readFile(rootJoin(".safeeyignore"));
|
|
36
|
+
if (!raw) return () => false;
|
|
37
|
+
const res = raw.split(/\r?\n/)
|
|
38
|
+
.map((l) => l.trim())
|
|
39
|
+
.filter((l) => l && !l.startsWith("#"))
|
|
40
|
+
.map(globToRe);
|
|
41
|
+
return (relPath) => res.some((re) => re.test(relPath.replace(/\\/g, "/")));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function globToRe(glob) {
|
|
45
|
+
let g = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
46
|
+
g = g.replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*").replace(/\?/g, "[^/]");
|
|
47
|
+
return new RegExp(`(^|/)${g}($|/)`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { fileSuppressions, isSuppressed, loadPathIgnores };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// src/engine/taint.js — lightweight taint tracking for JS/TS.
|
|
2
|
+
//
|
|
3
|
+
// The pattern/AST rules flag a dangerous *sink* regardless of whether its input
|
|
4
|
+
// is attacker-controlled. Taint tracking asks the better question: does
|
|
5
|
+
// untrusted input actually *reach* this sink? Following a value from source
|
|
6
|
+
// (a request field) to sink (exec, a query, a redirect) both catches real
|
|
7
|
+
// vulnerabilities the flat rules miss (input arrives via a variable) and avoids
|
|
8
|
+
// false positives (exec of a constant is fine).
|
|
9
|
+
//
|
|
10
|
+
// Scope: intraprocedural and best-effort. We resolve variables through Babel's
|
|
11
|
+
// scope bindings, propagate taint across assignments, string-building and common
|
|
12
|
+
// pass-through calls, but we do NOT do interprocedural (cross-function) flow.
|
|
13
|
+
// Findings are therefore high-confidence when they fire, but absence of a
|
|
14
|
+
// finding is not proof of safety. Kept deliberately conservative to stay quiet.
|
|
15
|
+
|
|
16
|
+
const parser = require("@babel/parser");
|
|
17
|
+
const traverseModule = require("@babel/traverse");
|
|
18
|
+
const traverse = traverseModule.default || traverseModule;
|
|
19
|
+
const { memberName } = require("../rules/helpers");
|
|
20
|
+
|
|
21
|
+
const PARSE_OPTS = {
|
|
22
|
+
sourceType: "unmodule",
|
|
23
|
+
errorRecovery: true,
|
|
24
|
+
allowReturnOutsideFunction: true,
|
|
25
|
+
plugins: ["jsx", "typescript", "classProperties", "decorators-legacy", "optionalChaining", "nullishCoalescingOperator"],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Roots that represent untrusted external input.
|
|
29
|
+
const SOURCE_ROOTS = /^(req|request|ctx)$/;
|
|
30
|
+
// Full member paths that are sources on their own.
|
|
31
|
+
const SOURCE_MEMBERS = /^(req|request)\.(query|params|body|headers|cookies|url|originalUrl|hostname|ip)\b/;
|
|
32
|
+
const DIRECT_SOURCES = /^(process\.argv|location\.(search|hash|href|pathname)|document\.(URL|cookie|referrer)|window\.name)$/;
|
|
33
|
+
|
|
34
|
+
// Pass-through calls: the result stays tainted if the receiver/args are tainted.
|
|
35
|
+
const PASSTHROUGH = /(^|\.)(trim|toString|toLowerCase|toUpperCase|replace|replaceAll|slice|substring|substr|concat|padStart|padEnd|normalize|split|join|at|charAt|repeat)$/;
|
|
36
|
+
const PASSTHROUGH_FUNCS = /^(String|decodeURIComponent|decodeURI|encodeURIComponent|unescape|escape|Buffer\.from|JSON\.parse)$/;
|
|
37
|
+
|
|
38
|
+
// Sinks: callee (member) name -> { type, cwe, severity, requiresStringBuild }
|
|
39
|
+
const CALL_SINKS = [
|
|
40
|
+
{ re: /(^|\.)(exec|execSync|spawn|spawnSync|execFile|execFileSync)$/, type: "command-injection", cwe: "CWE-78", severity: "critical", title: "User input reaches a shell/command execution sink" },
|
|
41
|
+
{ re: /^(eval)$/, type: "code-injection", cwe: "CWE-95", severity: "critical", title: "User input reaches eval()" },
|
|
42
|
+
{ re: /(^|\.)(query|execute|raw)$/, type: "sql-injection", cwe: "CWE-89", severity: "high", requiresStringBuild: true, title: "User input is string-built into a SQL query" },
|
|
43
|
+
{ re: /(^|\.)redirect$/, type: "open-redirect", cwe: "CWE-601", severity: "medium", title: "User input reaches an HTTP redirect" },
|
|
44
|
+
{ re: /(^|\.)(readFile|readFileSync|writeFile|writeFileSync|createReadStream|createWriteStream|unlink|unlinkSync|readdir|readdirSync)$/, type: "path-traversal", cwe: "CWE-22", severity: "high", title: "User input reaches a filesystem path" },
|
|
45
|
+
{ re: /(^|\.)(send|write|end)$/, type: "reflected-xss", cwe: "CWE-79", severity: "medium", title: "User input reaches an HTTP response body" },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
function rootName(node) {
|
|
49
|
+
let n = node;
|
|
50
|
+
while (n && n.type === "MemberExpression") n = n.object;
|
|
51
|
+
return n && n.type === "Identifier" ? n.name : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isStringBuilt(node) {
|
|
55
|
+
if (!node) return false;
|
|
56
|
+
if (node.type === "TemplateLiteral") return node.expressions.length > 0;
|
|
57
|
+
if (node.type === "BinaryExpression" && node.operator === "+") return true;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Is `node` tainted within the given scope? `seen` guards against cycles.
|
|
62
|
+
function isTainted(node, scope, seen) {
|
|
63
|
+
if (!node || typeof node !== "object") return false;
|
|
64
|
+
|
|
65
|
+
switch (node.type) {
|
|
66
|
+
case "MemberExpression": {
|
|
67
|
+
const full = memberName(node);
|
|
68
|
+
if (full && (SOURCE_MEMBERS.test(full) || DIRECT_SOURCES.test(full))) return true;
|
|
69
|
+
const root = rootName(node);
|
|
70
|
+
if (root && SOURCE_ROOTS.test(root)) return true;
|
|
71
|
+
// tainted if the object chain or a computed property is tainted
|
|
72
|
+
if (isTainted(node.object, scope, seen)) return true;
|
|
73
|
+
if (node.computed && isTainted(node.property, scope, seen)) return true;
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
case "Identifier": {
|
|
77
|
+
const full = node.name;
|
|
78
|
+
if (DIRECT_SOURCES.test(full)) return true;
|
|
79
|
+
if (SOURCE_ROOTS.test(full)) return true;
|
|
80
|
+
if (!scope) return false;
|
|
81
|
+
const binding = scope.getBinding(node.name);
|
|
82
|
+
if (!binding || seen.has(binding)) return false;
|
|
83
|
+
seen.add(binding);
|
|
84
|
+
const p = binding.path;
|
|
85
|
+
if (p && p.node) {
|
|
86
|
+
if (p.node.type === "VariableDeclarator" && p.node.init) return isTainted(p.node.init, p.scope || scope, seen);
|
|
87
|
+
// function param named like a source (e.g. the (req,res) handler param)
|
|
88
|
+
if ((p.node.type === "Identifier") && SOURCE_ROOTS.test(p.node.name)) return true;
|
|
89
|
+
}
|
|
90
|
+
// reassignments
|
|
91
|
+
for (const ref of binding.constantViolations || []) {
|
|
92
|
+
if (ref.node && ref.node.type === "AssignmentExpression" && isTainted(ref.node.right, ref.scope || scope, seen)) return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
case "TemplateLiteral":
|
|
97
|
+
return node.expressions.some((e) => isTainted(e, scope, seen));
|
|
98
|
+
case "BinaryExpression":
|
|
99
|
+
if (node.operator === "+") return isTainted(node.left, scope, seen) || isTainted(node.right, scope, seen);
|
|
100
|
+
return false;
|
|
101
|
+
case "LogicalExpression":
|
|
102
|
+
return isTainted(node.left, scope, seen) || isTainted(node.right, scope, seen);
|
|
103
|
+
case "ConditionalExpression":
|
|
104
|
+
return isTainted(node.consequent, scope, seen) || isTainted(node.alternate, scope, seen);
|
|
105
|
+
case "CallExpression": {
|
|
106
|
+
const name = memberName(node.callee) || "";
|
|
107
|
+
if (PASSTHROUGH_FUNCS.test(name) || PASSTHROUGH.test(name)) {
|
|
108
|
+
if (node.callee.type === "MemberExpression" && isTainted(node.callee.object, scope, seen)) return true;
|
|
109
|
+
return node.arguments.some((a) => isTainted(a, scope, seen));
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
default:
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function analyzeTaint(code, file) {
|
|
119
|
+
let ast;
|
|
120
|
+
try {
|
|
121
|
+
ast = parser.parse(code, { ...PARSE_OPTS, sourceType: "module" });
|
|
122
|
+
} catch {
|
|
123
|
+
try { ast = parser.parse(code, { ...PARSE_OPTS, sourceType: "script" }); } catch { return []; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const findings = [];
|
|
127
|
+
const pushed = new Set();
|
|
128
|
+
|
|
129
|
+
function record(node, sink, argNode) {
|
|
130
|
+
const line = node.loc ? node.loc.start.line : 0;
|
|
131
|
+
const col = node.loc ? node.loc.start.column + 1 : 0;
|
|
132
|
+
const key = `${sink.type}|${line}|${col}`;
|
|
133
|
+
if (pushed.has(key)) return;
|
|
134
|
+
pushed.add(key);
|
|
135
|
+
findings.push({
|
|
136
|
+
ruleId: `taint/${sink.type}`,
|
|
137
|
+
title: sink.title,
|
|
138
|
+
severity: sink.severity,
|
|
139
|
+
confidence: "high",
|
|
140
|
+
category: sink.type === "reflected-xss" ? "xss" : sink.type === "open-redirect" ? "auth" : sink.type === "path-traversal" ? "path" : "injection",
|
|
141
|
+
cwe: sink.cwe,
|
|
142
|
+
owasp: null,
|
|
143
|
+
file,
|
|
144
|
+
line,
|
|
145
|
+
column: col,
|
|
146
|
+
message: `${sink.title.replace(/^User input/, "Untrusted input")} — the value flows from a request/external source to this call without sanitisation.`,
|
|
147
|
+
fix: "Validate or encode the value for its context (parameterised queries, an allow-list for paths/redirects, argument arrays instead of shell strings) before it reaches this sink.",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
traverse(ast, {
|
|
152
|
+
CallExpression(path) {
|
|
153
|
+
const name = memberName(path.node.callee);
|
|
154
|
+
if (!name) return;
|
|
155
|
+
for (const sink of CALL_SINKS) {
|
|
156
|
+
if (!sink.re.test(name)) continue;
|
|
157
|
+
const arg = path.node.arguments[0];
|
|
158
|
+
if (!arg) continue;
|
|
159
|
+
if (sink.requiresStringBuild && !isStringBuilt(arg)) continue;
|
|
160
|
+
if (isTainted(arg, path.scope, new Set())) { record(path.node, sink, arg); break; }
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
NewExpression(path) {
|
|
164
|
+
if (memberName(path.node.callee) === "Function") {
|
|
165
|
+
const arg = path.node.arguments[path.node.arguments.length - 1];
|
|
166
|
+
if (arg && isTainted(arg, path.scope, new Set())) {
|
|
167
|
+
record(path.node, { type: "code-injection", cwe: "CWE-95", severity: "critical", title: "User input reaches the Function constructor" }, arg);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
AssignmentExpression(path) {
|
|
172
|
+
const left = memberName(path.node.left);
|
|
173
|
+
if (left && /(^|\.)(innerHTML|outerHTML)$/.test(left) && isTainted(path.node.right, path.scope, new Set())) {
|
|
174
|
+
record(path.node, { type: "reflected-xss", cwe: "CWE-79", severity: "high", title: "User input is assigned to innerHTML" }, path.node.right);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return findings;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { analyzeTaint };
|
package/src/fix.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// src/fix.js — the paid auto-fix feature.
|
|
2
|
+
//
|
|
3
|
+
// Auto-fixing security issues has to be conservative: a "fix" that rewrites app
|
|
4
|
+
// logic can silently change behaviour or introduce new bugs. So we only auto-
|
|
5
|
+
// apply *mechanical, low-risk* fixes and always show a preview + confirm first.
|
|
6
|
+
// Everything else stays advice-only (the finding's own `fix` text), surfaced as
|
|
7
|
+
// "manual" so the user knows Safeey isn't going to touch their logic.
|
|
8
|
+
//
|
|
9
|
+
// Currently automated:
|
|
10
|
+
// - config/env-not-ignored : add the env file to .gitignore
|
|
11
|
+
// - osv/* : bump the vulnerable dependency to its patched
|
|
12
|
+
// version in package.json (user runs install)
|
|
13
|
+
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
|
|
17
|
+
// ruleId (or prefix) -> which fixer handles it.
|
|
18
|
+
function fixerKind(ruleId) {
|
|
19
|
+
if (ruleId === "config/env-not-ignored") return "gitignore-env";
|
|
20
|
+
if (ruleId.startsWith("osv/")) return "dep-bump";
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Split findings into what we can auto-apply vs. what needs a human.
|
|
25
|
+
function planFixes(findings) {
|
|
26
|
+
const fixable = [];
|
|
27
|
+
const manual = [];
|
|
28
|
+
for (const f of findings) {
|
|
29
|
+
const kind = fixerKind(f.ruleId);
|
|
30
|
+
// dep-bump is only actionable if OSV told us a patched version.
|
|
31
|
+
if (kind === "dep-bump" && !f.fixedVersion) { manual.push(f); continue; }
|
|
32
|
+
if (kind) fixable.push({ ...f, _kind: kind });
|
|
33
|
+
else manual.push(f);
|
|
34
|
+
}
|
|
35
|
+
return { fixable, manual };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Compute the changes without writing. Returns [{ file, summary, apply() }].
|
|
39
|
+
function planEdits(fixable, root) {
|
|
40
|
+
const edits = [];
|
|
41
|
+
|
|
42
|
+
// --- gitignore the env file (one edit even if several env findings) -------
|
|
43
|
+
const envFindings = fixable.filter((f) => f._kind === "gitignore-env");
|
|
44
|
+
if (envFindings.length) {
|
|
45
|
+
const giPath = path.join(root, ".gitignore");
|
|
46
|
+
let current = "";
|
|
47
|
+
try { current = fs.readFileSync(giPath, "utf8"); } catch { /* new file */ }
|
|
48
|
+
const lines = current.split(/\r?\n/);
|
|
49
|
+
const need = [];
|
|
50
|
+
const has = (pat) => lines.some((l) => l.trim() === pat);
|
|
51
|
+
if (!has(".env") && !has(".env*")) need.push(".env");
|
|
52
|
+
if (!has("!.env.example")) need.push("!.env.example");
|
|
53
|
+
if (need.length) {
|
|
54
|
+
const addition = (current && !current.endsWith("\n") ? "\n" : "") +
|
|
55
|
+
(current ? "" : "") + "\n# added by safeey fix\n" + need.join("\n") + "\n";
|
|
56
|
+
edits.push({
|
|
57
|
+
file: ".gitignore",
|
|
58
|
+
summary: `add ${need.join(", ")} to .gitignore`,
|
|
59
|
+
apply: () => fs.writeFileSync(giPath, current + addition),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- bump vulnerable dependencies in package.json -------------------------
|
|
65
|
+
const deps = fixable.filter((f) => f._kind === "dep-bump" && f.package && f.fixedVersion);
|
|
66
|
+
if (deps.length) {
|
|
67
|
+
const pkgPath = path.join(root, "package.json");
|
|
68
|
+
let pkg = null;
|
|
69
|
+
try { pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); } catch { pkg = null; }
|
|
70
|
+
if (pkg) {
|
|
71
|
+
// Keep the highest required fixed version per package.
|
|
72
|
+
const targets = new Map();
|
|
73
|
+
for (const f of deps) {
|
|
74
|
+
const cur = targets.get(f.package.name);
|
|
75
|
+
if (!cur || cmpVersions(f.fixedVersion, cur) > 0) targets.set(f.package.name, f.fixedVersion);
|
|
76
|
+
}
|
|
77
|
+
const changes = [];
|
|
78
|
+
for (const [name, version] of targets) {
|
|
79
|
+
for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
|
|
80
|
+
if (pkg[field] && pkg[field][name]) {
|
|
81
|
+
const prefix = /^[~]/.test(pkg[field][name]) ? "~" : "^";
|
|
82
|
+
const next = `${prefix}${version}`;
|
|
83
|
+
if (pkg[field][name] !== next) { pkg[field][name] = next; changes.push(`${name} -> ${next}`); }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (changes.length) {
|
|
88
|
+
edits.push({
|
|
89
|
+
file: "package.json",
|
|
90
|
+
summary: `bump ${changes.join(", ")}`,
|
|
91
|
+
apply: () => fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"),
|
|
92
|
+
note: "run your package manager's install afterward to update the lockfile",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return edits;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function applyEdits(edits) {
|
|
102
|
+
const done = [];
|
|
103
|
+
for (const e of edits) { e.apply(); done.push(e.summary); }
|
|
104
|
+
return done;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function cmpVersions(a, b) {
|
|
108
|
+
const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
|
|
109
|
+
const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
|
|
110
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
111
|
+
if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
|
|
112
|
+
}
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { planFixes, planEdits, applyEdits, fixerKind };
|