safeey-cli 0.2.0 → 0.2.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/package.json +3 -3
- package/src/engine/gitignore.js +128 -0
- package/src/scan.js +58 -1
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safeey-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Scan your source code for security vulnerabilities — a Safeey product by Noohra Innovate Ltd.",
|
|
5
5
|
"bin": {
|
|
6
|
-
"safeey": "
|
|
6
|
+
"safeey": "bin/safeey.js"
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
"bin",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"repository": {
|
|
31
31
|
"type": "git",
|
|
32
|
-
"url": "https://github.com/davolu/safeey-cli.git"
|
|
32
|
+
"url": "git+https://github.com/davolu/safeey-cli.git"
|
|
33
33
|
},
|
|
34
34
|
"homepage": "https://safeey.io",
|
|
35
35
|
"bugs": {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// src/engine/gitignore.js
|
|
2
|
+
// Answers one question: "would git ignore this path?" We use it to decide
|
|
3
|
+
// whether secrets in a .env file matter. A secret in a *properly ignored* .env
|
|
4
|
+
// is that file doing its job. A secret in a .env that ISN'T ignored is a leak
|
|
5
|
+
// waiting to be committed — that's the finding worth raising.
|
|
6
|
+
//
|
|
7
|
+
// Primary strategy: shell out to `git check-ignore`, which implements the full
|
|
8
|
+
// gitignore spec (nesting, negation, anchoring, core.excludesFile) correctly so
|
|
9
|
+
// we don't have to. Fallback: a focused matcher for trees that aren't git repos
|
|
10
|
+
// (where "no .gitignore at all" correctly resolves to "not ignored" -> flag it).
|
|
11
|
+
|
|
12
|
+
const { execFileSync } = require("child_process");
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
|
|
16
|
+
const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "out", "vendor", "coverage"]);
|
|
17
|
+
|
|
18
|
+
function isGitRepo(root) {
|
|
19
|
+
try {
|
|
20
|
+
const out = execFileSync("git", ["-C", root, "rev-parse", "--is-inside-work-tree"], {
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
23
|
+
});
|
|
24
|
+
return out.trim() === "true";
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Returns true (ignored), false (not ignored), or null (couldn't determine).
|
|
31
|
+
function gitCheckIgnore(root, relPath) {
|
|
32
|
+
try {
|
|
33
|
+
execFileSync("git", ["-C", root, "check-ignore", "-q", "--", relPath], { stdio: "ignore" });
|
|
34
|
+
return true; // exit 0 => path is ignored
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (e && e.status === 1) return false; // exit 1 => not ignored
|
|
37
|
+
return null; // 128 / git missing => unknown
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---- Non-git fallback ------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
// Convert a single gitignore pattern to { re, negate, dirOnly } relative to the
|
|
44
|
+
// directory the .gitignore lives in. Intentionally a pragmatic subset of the
|
|
45
|
+
// spec — enough to resolve the env-file cases the tool cares about.
|
|
46
|
+
function patternToMatcher(raw) {
|
|
47
|
+
let pat = raw.replace(/\r$/, "");
|
|
48
|
+
if (!pat || pat.startsWith("#")) return null;
|
|
49
|
+
// Trim unescaped trailing whitespace.
|
|
50
|
+
pat = pat.replace(/((?:[^\\ ]|\\ )*?)\s+$/, "$1");
|
|
51
|
+
if (!pat) return null;
|
|
52
|
+
|
|
53
|
+
let negate = false;
|
|
54
|
+
if (pat.startsWith("!")) { negate = true; pat = pat.slice(1); }
|
|
55
|
+
|
|
56
|
+
let dirOnly = false;
|
|
57
|
+
if (pat.endsWith("/")) { dirOnly = true; pat = pat.slice(0, -1); }
|
|
58
|
+
|
|
59
|
+
const anchored = pat.startsWith("/") || pat.includes("/");
|
|
60
|
+
if (pat.startsWith("/")) pat = pat.slice(1);
|
|
61
|
+
|
|
62
|
+
// Escape regex metachars, then re-enable glob semantics.
|
|
63
|
+
let re = pat.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
64
|
+
re = re.replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*").replace(/\?/g, "[^/]");
|
|
65
|
+
|
|
66
|
+
const body = anchored ? `^${re}(?:/.*)?$` : `(?:^|/)${re}(?:/.*)?$`;
|
|
67
|
+
return { re: new RegExp(body), negate, dirOnly };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readGitignores(root) {
|
|
71
|
+
const found = []; // { dir, matchers: [...] }
|
|
72
|
+
(function walk(dir) {
|
|
73
|
+
let entries = [];
|
|
74
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
75
|
+
const gi = path.join(dir, ".gitignore");
|
|
76
|
+
if (fs.existsSync(gi)) {
|
|
77
|
+
try {
|
|
78
|
+
const matchers = fs.readFileSync(gi, "utf8").split(/\n/).map(patternToMatcher).filter(Boolean);
|
|
79
|
+
found.push({ dir, matchers });
|
|
80
|
+
} catch { /* unreadable — skip */ }
|
|
81
|
+
}
|
|
82
|
+
for (const e of entries) {
|
|
83
|
+
if (e.isDirectory() && !IGNORE_DIRS.has(e.name)) walk(path.join(dir, e.name));
|
|
84
|
+
}
|
|
85
|
+
})(root);
|
|
86
|
+
return found;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildFallbackMatcher(root) {
|
|
90
|
+
const gitignores = readGitignores(root);
|
|
91
|
+
return function isIgnored(absPath) {
|
|
92
|
+
let decision = false;
|
|
93
|
+
for (const { dir, matchers } of gitignores) {
|
|
94
|
+
const rel = path.relative(dir, absPath);
|
|
95
|
+
if (!rel || rel.startsWith("..")) continue; // file not under this .gitignore
|
|
96
|
+
for (const m of matchers) {
|
|
97
|
+
if (m.re.test(rel)) decision = !m.negate;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return decision;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- Public API ------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function makeIgnoreChecker(root) {
|
|
107
|
+
const git = isGitRepo(root);
|
|
108
|
+
const fallback = git ? null : buildFallbackMatcher(root);
|
|
109
|
+
const cache = new Map();
|
|
110
|
+
return {
|
|
111
|
+
mode: git ? "git" : "fallback",
|
|
112
|
+
// true | false | null(unknown, git-mode only)
|
|
113
|
+
isIgnored(absPath) {
|
|
114
|
+
if (cache.has(absPath)) return cache.get(absPath);
|
|
115
|
+
let res;
|
|
116
|
+
if (git) {
|
|
117
|
+
const rel = path.relative(root, absPath) || path.basename(absPath);
|
|
118
|
+
res = gitCheckIgnore(root, rel);
|
|
119
|
+
} else {
|
|
120
|
+
res = fallback(absPath);
|
|
121
|
+
}
|
|
122
|
+
cache.set(absPath, res);
|
|
123
|
+
return res;
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { makeIgnoreChecker };
|
package/src/scan.js
CHANGED
|
@@ -5,6 +5,44 @@ const { analyzeJs } = require("./engine/js");
|
|
|
5
5
|
const { analyzeSecrets } = require("./engine/secrets");
|
|
6
6
|
const { analyzeDeps } = require("./engine/deps");
|
|
7
7
|
const { analyzePatterns } = require("./engine/patterns");
|
|
8
|
+
const { makeIgnoreChecker } = require("./engine/gitignore");
|
|
9
|
+
|
|
10
|
+
// Any .env-style file. `.env.example` / `.env.sample` / `.env.template` / .dist
|
|
11
|
+
// are meant to be committed and hold placeholders, so they're scanned normally
|
|
12
|
+
// (a *real* secret in one of those IS a finding). The bare/real env files are
|
|
13
|
+
// the ones whose secrets we suppress-if-ignored, flag-if-not.
|
|
14
|
+
const ENV_RE = /^\.env(\.|$)/;
|
|
15
|
+
const ENV_EXAMPLE_RE = /(example|sample|template|dist|defaults)/i;
|
|
16
|
+
|
|
17
|
+
function classifyEnvFile(base) {
|
|
18
|
+
if (!ENV_RE.test(base)) return null;
|
|
19
|
+
return ENV_EXAMPLE_RE.test(base) ? "example" : "real";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Does the file actually contain KEY=value assignments with a value? Keeps us
|
|
23
|
+
// from flagging an empty/placeholder .env.
|
|
24
|
+
function hasEnvAssignment(code) {
|
|
25
|
+
return /^\s*[A-Za-z_][A-Za-z0-9_]*\s*=\s*\S/m.test(code);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function envNotIgnoredFinding(rel, hasSecret, unknown) {
|
|
29
|
+
return {
|
|
30
|
+
ruleId: "config/env-not-ignored",
|
|
31
|
+
title: "Environment file not covered by .gitignore",
|
|
32
|
+
severity: hasSecret ? "high" : "medium",
|
|
33
|
+
confidence: "medium",
|
|
34
|
+
category: "secrets",
|
|
35
|
+
cwe: "CWE-538",
|
|
36
|
+
owasp: "A05:2021 Security Misconfiguration",
|
|
37
|
+
file: rel,
|
|
38
|
+
line: 0,
|
|
39
|
+
column: 0,
|
|
40
|
+
message: unknown
|
|
41
|
+
? `${rel} holds environment values and no .gitignore was found to protect it. If this directory is (or becomes) a git repo, the file will be committed and any credentials in it leaked.`
|
|
42
|
+
: `${rel} holds environment values but isn't matched by any .gitignore rule, so it can be committed to version control and its credentials leaked.`,
|
|
43
|
+
fix: "Add a matching rule to .gitignore (e.g. `.env` or `.env*`, keeping an explicit `!.env.example`). If the file was already committed, rotate the credentials and purge it from git history (e.g. git filter-repo).",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
8
46
|
|
|
9
47
|
const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "out", "vendor", "coverage", ".turbo", ".cache"]);
|
|
10
48
|
const JS_EXT = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
|
|
@@ -44,19 +82,38 @@ function scan(target, opts = {}) {
|
|
|
44
82
|
const findings = [];
|
|
45
83
|
let scanned = 0;
|
|
46
84
|
|
|
85
|
+
// Used to decide whether a .env's secrets are safely ignored or exposed.
|
|
86
|
+
const ignore = makeIgnoreChecker(root);
|
|
87
|
+
|
|
47
88
|
for (const file of files) {
|
|
48
89
|
const ext = path.extname(file).toLowerCase();
|
|
49
90
|
const base = path.basename(file);
|
|
50
91
|
let size = 0;
|
|
51
92
|
try { size = fs.statSync(file).size; } catch { continue; }
|
|
52
93
|
if (size > MAX_FILE_BYTES) continue;
|
|
53
|
-
|
|
94
|
+
const envClass = classifyEnvFile(base);
|
|
95
|
+
if (!TEXT_EXT.has(ext) && base !== "package.json" && !envClass) continue;
|
|
54
96
|
|
|
55
97
|
let code = "";
|
|
56
98
|
try { code = fs.readFileSync(file, "utf8"); } catch { continue; }
|
|
57
99
|
scanned++;
|
|
58
100
|
const rel = path.relative(root, file) || base;
|
|
59
101
|
|
|
102
|
+
// A real .env file is *meant* to hold secrets. The question isn't "are
|
|
103
|
+
// there secrets in it" but "is it protected from being committed". So we
|
|
104
|
+
// suppress its secret findings when it's properly ignored, and raise a
|
|
105
|
+
// single clear finding when it isn't.
|
|
106
|
+
if (envClass === "real") {
|
|
107
|
+
const secretFindings = opts.secrets !== false ? analyzeSecrets(code, rel) : [];
|
|
108
|
+
const ignored = ignore.isIgnored(file); // true | false | null(unknown)
|
|
109
|
+
if (ignored !== true && hasEnvAssignment(code)) {
|
|
110
|
+
findings.push(envNotIgnoredFinding(rel, secretFindings.length > 0, ignored === null));
|
|
111
|
+
for (const f of secretFindings) findings.push(f); // exposure is real — show what's inside
|
|
112
|
+
}
|
|
113
|
+
// ignored === true -> secrets stay local and protected; report nothing.
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
60
117
|
if (JS_EXT.has(ext)) for (const f of analyzeJs(code, rel)) findings.push(f);
|
|
61
118
|
if (base === "package.json") for (const f of analyzeDeps(code, rel)) findings.push(f);
|
|
62
119
|
if (opts.secrets !== false) for (const f of analyzeSecrets(code, rel)) findings.push(f);
|