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 CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "safeey-cli",
3
- "version": "0.2.0",
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": "./bin/safeey.js"
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
- if (!TEXT_EXT.has(ext) && base !== "package.json" && base !== ".env") continue;
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);