secanix 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -9,6 +9,8 @@ Checks it runs:
9
9
  - Disabled Supabase Row Level Security (RLS)
10
10
  - CORS wildcard origins
11
11
  - Vulnerable dependencies (known CVEs)
12
+ - Open Firebase security rules (Firestore/Realtime Database/Storage)
13
+ - Exposed Firebase Admin SDK keys (env leak or a committed service account JSON file)
12
14
 
13
15
  ## CLI Usage
14
16
 
@@ -22,6 +24,29 @@ npx -p secanix@latest secanix --json
22
24
  ```
23
25
  Same scan, machine-readable JSON output — useful for piping into other tooling.
24
26
 
27
+ ## Suppressing false positives
28
+
29
+ Some findings are correct in general but not in your case — e.g. an API route
30
+ protected by `middleware.ts` instead of an in-handler check, which
31
+ `api-auth-missing` can't see. Add a `.secanix.json` at your project root:
32
+
33
+ ```json
34
+ {
35
+ "ignore": [
36
+ {
37
+ "file": "app/api/admin/route.ts",
38
+ "ruleId": "nextjs-api-route-missing-auth",
39
+ "reason": "protected by middleware.ts, matcher /api/admin/*"
40
+ }
41
+ ]
42
+ }
43
+ ```
44
+
45
+ Both `file` (relative path) and `ruleId` must match exactly. Suppressed
46
+ findings aren't silently dropped — they're printed separately (with your
47
+ `reason`) so they stay visible for review, not just filtered out of the JSON
48
+ output.
49
+
25
50
  ## GitHub Action
26
51
 
27
52
  Add to `.github/workflows/security-scan.yml` in your repo:
@@ -1,6 +1,22 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { access } from "node:fs/promises";
2
3
  import { dirname, join, relative, resolve, sep } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
5
+ const MIDDLEWARE_CANDIDATES = [
6
+ "middleware.ts",
7
+ "middleware.js",
8
+ join("src", "middleware.ts"),
9
+ join("src", "middleware.js"),
10
+ ];
11
+ // A route can be protected upstream by middleware.ts instead of an in-handler
12
+ // check — this rule only ever sees the handler file, so it can't tell.
13
+ // Rather than guess at matcher-pattern coverage (getting that wrong would
14
+ // silently hide a genuinely unprotected route), we stay honest about the gap.
15
+ const MIDDLEWARE_CAVEAT = ' Kalo route ini diproteksi lewat middleware.ts, ini bisa jadi false positive — cek matcher-nya, atau suppress via .secanix.json kalo emang udah aman.';
16
+ async function hasMiddlewareFile(targetDir) {
17
+ const results = await Promise.all(MIDDLEWARE_CANDIDATES.map((candidate) => access(join(targetDir, candidate)).then(() => true, () => false)));
18
+ return results.some(Boolean);
19
+ }
4
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
5
21
  const RULE_PATH = join(__dirname, "..", "rules", "nextjs-api-auth-missing.yaml");
6
22
  export class SemgrepNotFoundError extends Error {
@@ -71,8 +87,11 @@ export async function findMissingApiAuth(targetDir) {
71
87
  "--quiet",
72
88
  targetDir,
73
89
  ]);
74
- return parseSemgrepReport(stdout).map((finding) => ({
90
+ const findings = parseSemgrepReport(stdout).map((finding) => ({
75
91
  ...finding,
76
92
  file: relative(targetDir, resolve(targetDir, finding.file)).split(sep).join("/"),
77
93
  }));
94
+ if (findings.length === 0 || !(await hasMiddlewareFile(targetDir)))
95
+ return findings;
96
+ return findings.map((finding) => ({ ...finding, description: finding.description + MIDDLEWARE_CAVEAT }));
78
97
  }
@@ -0,0 +1,59 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename, extname, relative, sep } from "node:path";
3
+ import { collectFiles, SOURCE_EXTENSIONS } from "./fsWalk.js";
4
+ // NEXT_PUBLIC_* env vars get inlined into the client bundle at build time —
5
+ // a Firebase Admin SDK key under that prefix is a guaranteed leak.
6
+ const PUBLIC_ADMIN_KEY_PATTERN = /NEXT_PUBLIC_[A-Z0-9_]*(?:FIREBASE_PRIVATE_KEY|FIREBASE_ADMIN|SERVICE_ACCOUNT)[A-Z0-9_]*/i;
7
+ // The literal downloaded Admin SDK key file always carries this exact field.
8
+ // gitleaks' generic PEM detector expects real newlines, not the \n-escaped
9
+ // key JSON.stringify produces, so a committed key file slips past secret-scan.
10
+ const SERVICE_ACCOUNT_JSON_PATTERN = /"type"\s*:\s*"service_account"/;
11
+ function isScannableFile(filePath) {
12
+ const name = basename(filePath);
13
+ if (name.startsWith(".env"))
14
+ return true;
15
+ const ext = extname(filePath);
16
+ return SOURCE_EXTENSIONS.has(ext) || ext === ".json";
17
+ }
18
+ export function scanTextForPublicFirebaseAdminKey(content) {
19
+ const matches = [];
20
+ content.split(/\r?\n/).forEach((line, index) => {
21
+ const match = PUBLIC_ADMIN_KEY_PATTERN.exec(line);
22
+ if (match)
23
+ matches.push({ line: index + 1, variableName: match[0] });
24
+ });
25
+ return matches;
26
+ }
27
+ export function scanTextForCommittedServiceAccountKey(content) {
28
+ const matches = [];
29
+ content.split(/\r?\n/).forEach((line, index) => {
30
+ if (SERVICE_ACCOUNT_JSON_PATTERN.test(line))
31
+ matches.push({ line: index + 1 });
32
+ });
33
+ return matches;
34
+ }
35
+ export async function findExposedFirebaseAdminKeys(targetDir) {
36
+ const files = await collectFiles(targetDir, isScannableFile);
37
+ const findings = [];
38
+ for (const file of files) {
39
+ const content = await readFile(file, "utf8");
40
+ const relFile = relative(targetDir, file).split(sep).join("/");
41
+ for (const match of scanTextForPublicFirebaseAdminKey(content)) {
42
+ findings.push({
43
+ file: relFile,
44
+ line: match.line,
45
+ ruleId: "firebase-admin-key-public-env",
46
+ description: `Env var publik "${match.variableName}" kelihatan nyimpen Firebase Admin SDK key — Next.js bakal inline ini ke client bundle.`,
47
+ });
48
+ }
49
+ for (const match of scanTextForCommittedServiceAccountKey(content)) {
50
+ findings.push({
51
+ file: relFile,
52
+ line: match.line,
53
+ ruleId: "firebase-service-account-key-committed",
54
+ description: `File ini kelihatan kayak service account key JSON asli dari Firebase Admin SDK — private key literal ke-commit ke repo.`,
55
+ });
56
+ }
57
+ }
58
+ return findings;
59
+ }
@@ -0,0 +1,46 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename, relative, sep } from "node:path";
3
+ import { collectFiles } from "./fsWalk.js";
4
+ const OPEN_SECURITY_RULE_PATTERN = /allow\s+[\w,\s]+:\s*if\s+true\s*;/gi;
5
+ const OPEN_DATABASE_JSON_PATTERN = /"\.(?:read|write)"\s*:\s*(?:true|"true")/gi;
6
+ function isFirebaseRulesFile(filePath) {
7
+ const name = basename(filePath);
8
+ return name === "firestore.rules" || name === "storage.rules" || name === "database.rules.json";
9
+ }
10
+ function findMatchesAcrossLines(content, globalPattern) {
11
+ const matches = [];
12
+ const pattern = new RegExp(globalPattern.source, globalPattern.flags);
13
+ let match;
14
+ while ((match = pattern.exec(content)) !== null) {
15
+ const line = content.slice(0, match.index).split(/\r?\n/).length;
16
+ matches.push({ line });
17
+ }
18
+ return matches;
19
+ }
20
+ export function findOpenRulesInSecurityRulesText(content) {
21
+ return findMatchesAcrossLines(content, OPEN_SECURITY_RULE_PATTERN);
22
+ }
23
+ export function findOpenRulesInDatabaseJson(content) {
24
+ return findMatchesAcrossLines(content, OPEN_DATABASE_JSON_PATTERN);
25
+ }
26
+ export async function findOpenFirebaseRules(targetDir) {
27
+ const files = await collectFiles(targetDir, isFirebaseRulesFile);
28
+ const findings = [];
29
+ for (const file of files) {
30
+ const name = basename(file);
31
+ const content = await readFile(file, "utf8");
32
+ const relFile = relative(targetDir, file).split(sep).join("/");
33
+ const matches = name === "database.rules.json"
34
+ ? findOpenRulesInDatabaseJson(content)
35
+ : findOpenRulesInSecurityRulesText(content);
36
+ for (const match of matches) {
37
+ findings.push({
38
+ file: relFile,
39
+ line: match.line,
40
+ ruleId: "firebase-rules-open",
41
+ description: `Rule di "${name}" ngasih akses baca/tulis tanpa syarat (if true / .read atau .write: true) — data bisa diakses siapa aja tanpa auth.`,
42
+ });
43
+ }
44
+ }
45
+ return findings;
46
+ }
@@ -59,7 +59,7 @@ export async function findRlsDisabledTables(targetDir) {
59
59
  file: table.file,
60
60
  line: table.line,
61
61
  ruleId: "supabase-rls-missing",
62
- description: `Tabel "${table.name}" gak ketemu "ENABLE ROW LEVEL SECURITY" di migration manapun — kemungkinan RLS mati.`,
62
+ description: `Tabel "${table.name}" gak ketemu "ENABLE ROW LEVEL SECURITY" di migration manapun — kemungkinan RLS mati. Ini cuma liat file migration, bukan state Supabase Dashboard asli — kalo RLS-nya emang udah dinyalain lewat Dashboard, suppress lewat .secanix.json.`,
63
63
  });
64
64
  }
65
65
  }
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { dirname, join } from "node:path";
3
- import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { copyFile, mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { fileURLToPath } from "node:url";
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -61,14 +61,49 @@ export function parseGitleaksReport(json) {
61
61
  }
62
62
  return findings;
63
63
  }
64
+ // Returns tracked + untracked-but-not-ignored relative paths, or null when
65
+ // targetDir isn't a git repo (no .gitignore semantics to respect there).
66
+ function listGitRespectedFiles(targetDir) {
67
+ return new Promise((resolve) => {
68
+ const child = spawn("git", ["-C", targetDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { stdio: ["ignore", "pipe", "ignore"] });
69
+ let stdout = "";
70
+ child.stdout.on("data", (chunk) => {
71
+ stdout += chunk.toString();
72
+ });
73
+ child.on("error", () => resolve(null));
74
+ child.on("close", (code) => {
75
+ if (code !== 0) {
76
+ resolve(null);
77
+ return;
78
+ }
79
+ resolve(stdout.split("\0").filter((f) => f.length > 0));
80
+ });
81
+ });
82
+ }
83
+ // Mirrors the given relative paths into mirrorRoot so gitleaks (run with
84
+ // --no-git) only ever sees files .gitignore would let through.
85
+ async function mirrorFiles(sourceDir, files, mirrorRoot) {
86
+ for (const rel of files) {
87
+ const dest = join(mirrorRoot, rel);
88
+ await mkdir(dirname(dest), { recursive: true });
89
+ await copyFile(join(sourceDir, rel), dest).catch(() => { });
90
+ }
91
+ }
64
92
  export async function runSecretScan(targetDir) {
65
93
  const tempDir = await mkdtemp(join(tmpdir(), "vibe-secret-scan-"));
66
94
  const reportPath = join(tempDir, "gitleaks-report.json");
67
95
  try {
96
+ const respectedFiles = await listGitRespectedFiles(targetDir);
97
+ let scanDir = targetDir;
98
+ if (respectedFiles !== null) {
99
+ scanDir = join(tempDir, "mirror");
100
+ await mkdir(scanDir, { recursive: true });
101
+ await mirrorFiles(targetDir, respectedFiles, scanDir);
102
+ }
68
103
  await runGitleaksProcess([
69
104
  "detect",
70
105
  "--source",
71
- targetDir,
106
+ scanDir,
72
107
  "--no-git",
73
108
  "--no-banner",
74
109
  "--config",
@@ -81,7 +116,18 @@ export async function runSecretScan(targetDir) {
81
116
  "0",
82
117
  ]);
83
118
  const content = await readFile(reportPath, "utf8").catch(() => "");
84
- return parseGitleaksReport(content);
119
+ const findings = parseGitleaksReport(content);
120
+ if (respectedFiles === null)
121
+ return findings;
122
+ // Mirror dir is deleted below; rewrite paths to point at the real project.
123
+ const mirrorPrefix = scanDir.replace(/\\/g, "/");
124
+ return findings.map((finding) => {
125
+ const fileFwd = finding.file.replace(/\\/g, "/");
126
+ if (!fileFwd.startsWith(mirrorPrefix))
127
+ return finding;
128
+ const rel = fileFwd.slice(mirrorPrefix.length).replace(/^\/+/, "");
129
+ return { ...finding, file: join(targetDir, rel) };
130
+ });
85
131
  }
86
132
  finally {
87
133
  await rm(tempDir, { recursive: true, force: true });
package/dist/cli.js CHANGED
@@ -1,13 +1,50 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync, realpathSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { join } from "node:path";
3
5
  import { pathToFileURL } from "node:url";
4
6
  import { findMissingApiAuth, SemgrepNotFoundError } from "./checks/apiAuthMissing.js";
5
7
  import { findCorsWildcard } from "./checks/corsWildcard.js";
6
8
  import { findDependencyVulnerabilities, OsvScannerNotFoundError } from "./checks/dependencyVulnerabilities.js";
9
+ import { findExposedFirebaseAdminKeys } from "./checks/exposedFirebaseAdminKey.js";
7
10
  import { findExposedServiceRoleKeys } from "./checks/exposedServiceRoleKey.js";
11
+ import { findOpenFirebaseRules } from "./checks/firebaseRulesOpen.js";
8
12
  import { findRlsDisabledTables } from "./checks/rlsDisabled.js";
9
13
  import { GitleaksNotFoundError, runSecretScan } from "./checks/secretScan.js";
10
- import { buildReport, formatReport } from "./report.js";
14
+ import { applyIgnoreRules, buildReport, formatReport } from "./report.js";
15
+ const IGNORE_FILE = ".secanix.json";
16
+ // Missing file = no suppression (default, zero-friction). Malformed file
17
+ // warns and falls back to no suppression rather than crashing the scan.
18
+ export async function loadIgnoreRules(targetDir) {
19
+ let content;
20
+ try {
21
+ content = await readFile(join(targetDir, IGNORE_FILE), "utf8");
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ try {
27
+ const parsed = JSON.parse(content);
28
+ const ignore = parsed.ignore;
29
+ if (!Array.isArray(ignore))
30
+ return [];
31
+ const rules = [];
32
+ for (const entry of ignore) {
33
+ if (typeof entry === "object" &&
34
+ entry !== null &&
35
+ typeof entry.file === "string" &&
36
+ typeof entry.ruleId === "string") {
37
+ const { file, ruleId, reason } = entry;
38
+ rules.push({ file, ruleId, reason: typeof reason === "string" ? reason : undefined });
39
+ }
40
+ }
41
+ return rules;
42
+ }
43
+ catch (err) {
44
+ console.error(`${IGNORE_FILE} invalid, diabaikan: ${err.message}`);
45
+ return [];
46
+ }
47
+ }
11
48
  const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
12
49
  export async function run(targetDir = process.cwd(), options = {}) {
13
50
  const { json = false } = options;
@@ -41,6 +78,11 @@ export async function run(targetDir = process.cwd(), options = {}) {
41
78
  }
42
79
  checkFindings.push({ checkId: "rls-disabled", findings: await findRlsDisabledTables(targetDir) });
43
80
  checkFindings.push({ checkId: "cors-wildcard", findings: await findCorsWildcard(targetDir) });
81
+ checkFindings.push({ checkId: "firebase-rules-open", findings: await findOpenFirebaseRules(targetDir) });
82
+ checkFindings.push({
83
+ checkId: "firebase-admin-key-exposed",
84
+ findings: await findExposedFirebaseAdminKeys(targetDir),
85
+ });
44
86
  try {
45
87
  checkFindings.push({
46
88
  checkId: "dependency-cve",
@@ -54,7 +96,15 @@ export async function run(targetDir = process.cwd(), options = {}) {
54
96
  }
55
97
  throw err;
56
98
  }
57
- const reported = buildReport(checkFindings);
99
+ const ignoreRules = await loadIgnoreRules(targetDir);
100
+ const { findings: reported, suppressed } = applyIgnoreRules(buildReport(checkFindings), ignoreRules);
101
+ if (suppressed.length > 0) {
102
+ console.error(`${suppressed.length} temuan diabaikan via ${IGNORE_FILE}:`);
103
+ for (const finding of suppressed) {
104
+ const reasonSuffix = finding.reason ? ` (${finding.reason})` : "";
105
+ console.error(` ${finding.file}:${finding.line} — ${finding.ruleId}${reasonSuffix}`);
106
+ }
107
+ }
58
108
  if (json) {
59
109
  console.log(JSON.stringify(reported));
60
110
  }
package/dist/report.js CHANGED
@@ -20,6 +20,18 @@ const RULE_INFO = {
20
20
  severity: "medium",
21
21
  fix: "Ganti '*' jadi daftar origin eksplisit yang emang butuh akses, atau validasi origin secara dinamis di server.",
22
22
  },
23
+ "firebase-rules-open": {
24
+ severity: "critical",
25
+ fix: "Ganti 'if true' / '.read'/'.write': true dengan rule yang validasi auth (request.auth != null, dst) — rule default Firebase itu deny-all, jangan di-override jadi allow-all.",
26
+ },
27
+ "firebase-admin-key-public-env": {
28
+ severity: "critical",
29
+ fix: "Jangan expose Firebase Admin key lewat NEXT_PUBLIC_* — pindahin ke env var server-only, lalu rotate key ini di Firebase Console (Project Settings > Service Accounts) kalau udah sempet ke-deploy.",
30
+ },
31
+ "firebase-service-account-key-committed": {
32
+ severity: "critical",
33
+ fix: "Hapus file JSON key ini dari repo & git history, rotate key di Firebase Console, generate key baru & simpan di secret manager / env var server-only.",
34
+ },
23
35
  };
24
36
  // gitleaks (secret-scan) and osv-scanner (dependency-cve) each emit a
25
37
  // ruleId that varies per finding (rule name / GHSA id), so they're
@@ -60,6 +72,22 @@ export function classify(finding, checkId) {
60
72
  }
61
73
  return { ...finding, severity: "medium", fixSuggestion: DEFAULT_FIX };
62
74
  }
75
+ // file+ruleId must both match — narrow on purpose so an ignore entry never
76
+ // silently swallows an unrelated finding that happens to share one field.
77
+ export function applyIgnoreRules(reported, ignoreRules) {
78
+ const findings = [];
79
+ const suppressed = [];
80
+ for (const finding of reported) {
81
+ const rule = ignoreRules.find((r) => r.file === finding.file && r.ruleId === finding.ruleId);
82
+ if (rule) {
83
+ suppressed.push({ ...finding, reason: rule.reason });
84
+ }
85
+ else {
86
+ findings.push(finding);
87
+ }
88
+ }
89
+ return { findings, suppressed };
90
+ }
63
91
  const SEVERITY_ORDER = ["critical", "high", "medium", "low"];
64
92
  const SEVERITY_RANK = new Map(SEVERITY_ORDER.map((severity, index) => [severity, index]));
65
93
  export function buildReport(checkFindings) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "secanix",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "description": "Security scanner buat app hasil vibe-coding (Next.js + Supabase/Firebase).",
6
6
  "license": "MIT",