secanix 0.1.4 → 0.1.6

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
@@ -2,13 +2,24 @@
2
2
 
3
3
  Security scanner buat app hasil vibe-coding (Next.js + Supabase/Firebase).
4
4
 
5
- Checks it runs:
6
- - Leaked secrets (API keys, tokens, credentials committed to the repo)
7
- - Exposed Supabase service role keys
8
- - Missing auth on Next.js API routes
9
- - Disabled Supabase Row Level Security (RLS)
10
- - CORS wildcard origins
11
- - Vulnerable dependencies (known CVEs)
5
+ ## Checks
6
+
7
+ | Check | ruleId | Severity | Triggers on | Fix |
8
+ |---|---|---|---|---|
9
+ | Leaked secrets (gitleaks) | varies per gitleaks rule | Critical | Hardcoded API key/token/credential in a git-tracked or about-to-be-tracked file | Rotate the secret now, strip it from code & git history (not just a new commit), move it to an env var / secret manager |
10
+ | Exposed Supabase service role key | `supabase-service-role-key-public-env` | Critical | `NEXT_PUBLIC_*` env var holding a Supabase service role key — gets inlined into the client bundle at build time | Move it to a server-only env var, rotate the key in the Supabase dashboard if it's ever been deployed |
11
+ | Missing auth on Next.js API route | `nextjs-api-route-missing-auth` | High | A Pages/App Router API handler with no session/token check before it runs any logic | Add an auth check (`getServerSession`/`getToken`/etc.) at the top of the handler |
12
+ | Supabase RLS disabled | `supabase-rls-disabled` | Critical | A table where RLS was explicitly turned off | Re-enable RLS (`ALTER TABLE ... ENABLE ROW LEVEL SECURITY`) and add matching policies |
13
+ | Supabase RLS missing | `supabase-rls-missing` | High | A table created with no RLS enable statement at all | Add `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` plus a policy for the table |
14
+ | CORS wildcard origin | `cors-wildcard-origin` | Medium | `Access-Control-Allow-Origin: *` (or equivalent) on an API response | Replace `*` with an explicit origin allowlist, or validate the origin dynamically server-side |
15
+ | Open Firebase security rules | `firebase-rules-open` | Critical | Firestore/Realtime Database/Storage rules using `if true` / `.read`/`.write: true` | Replace the allow-all rule with one that checks `request.auth != null` etc. — Firebase's default is deny-all, don't override it to allow-all |
16
+ | Exposed Firebase Admin key (env) | `firebase-admin-key-public-env` | Critical | `NEXT_PUBLIC_*` env var holding a Firebase Admin SDK key — gets inlined into the client bundle at build time | Move it to a server-only env var, rotate the key in Firebase Console if it's ever been deployed |
17
+ | Committed Firebase service account key | `firebase-service-account-key-committed` | Critical | A literal Admin SDK service-account JSON key file checked into the repo | Delete the file from the repo & git history, rotate the key in Firebase Console, store the new one in a secret manager / env var |
18
+ | Vulnerable dependency (osv-scanner) | varies (GHSA id) | Derived from CVSS score (≥9 critical, ≥7 high, ≥4 medium, else low) | A dependency with a known CVE | Update to the patched version named in the advisory |
19
+
20
+ Two checks (leaked secrets, vulnerable dependencies) delegate to gitleaks/osv-scanner, so
21
+ their `ruleId` varies per finding instead of being fixed — everything else in this table is
22
+ custom, Next.js/Supabase/Firebase-specific pattern matching.
12
23
 
13
24
  ## CLI Usage
14
25
 
@@ -0,0 +1,61 @@
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
+ import { envFileTrackingContext, listTrackedFiles } from "./gitUtils.js";
5
+ // NEXT_PUBLIC_* env vars get inlined into the client bundle at build time —
6
+ // a Firebase Admin SDK key under that prefix is a guaranteed leak.
7
+ const PUBLIC_ADMIN_KEY_PATTERN = /NEXT_PUBLIC_[A-Z0-9_]*(?:FIREBASE_PRIVATE_KEY|FIREBASE_ADMIN|SERVICE_ACCOUNT)[A-Z0-9_]*/i;
8
+ // The literal downloaded Admin SDK key file always carries this exact field.
9
+ // gitleaks' generic PEM detector expects real newlines, not the \n-escaped
10
+ // key JSON.stringify produces, so a committed key file slips past secret-scan.
11
+ const SERVICE_ACCOUNT_JSON_PATTERN = /"type"\s*:\s*"service_account"/;
12
+ function isScannableFile(filePath) {
13
+ const name = basename(filePath);
14
+ if (name.startsWith(".env"))
15
+ return true;
16
+ const ext = extname(filePath);
17
+ return SOURCE_EXTENSIONS.has(ext) || ext === ".json";
18
+ }
19
+ export function scanTextForPublicFirebaseAdminKey(content) {
20
+ const matches = [];
21
+ content.split(/\r?\n/).forEach((line, index) => {
22
+ const match = PUBLIC_ADMIN_KEY_PATTERN.exec(line);
23
+ if (match)
24
+ matches.push({ line: index + 1, variableName: match[0] });
25
+ });
26
+ return matches;
27
+ }
28
+ export function scanTextForCommittedServiceAccountKey(content) {
29
+ const matches = [];
30
+ content.split(/\r?\n/).forEach((line, index) => {
31
+ if (SERVICE_ACCOUNT_JSON_PATTERN.test(line))
32
+ matches.push({ line: index + 1 });
33
+ });
34
+ return matches;
35
+ }
36
+ export async function findExposedFirebaseAdminKeys(targetDir) {
37
+ const files = await collectFiles(targetDir, isScannableFile);
38
+ const tracked = await listTrackedFiles(targetDir);
39
+ const findings = [];
40
+ for (const file of files) {
41
+ const content = await readFile(file, "utf8");
42
+ const relFile = relative(targetDir, file).split(sep).join("/");
43
+ for (const match of scanTextForPublicFirebaseAdminKey(content)) {
44
+ findings.push({
45
+ file: relFile,
46
+ line: match.line,
47
+ ruleId: "firebase-admin-key-public-env",
48
+ description: `Env var publik "${match.variableName}" kelihatan nyimpen Firebase Admin SDK key — Next.js bakal inline ini ke client bundle.${envFileTrackingContext(relFile, tracked)}`,
49
+ });
50
+ }
51
+ for (const match of scanTextForCommittedServiceAccountKey(content)) {
52
+ findings.push({
53
+ file: relFile,
54
+ line: match.line,
55
+ ruleId: "firebase-service-account-key-committed",
56
+ description: `File ini kelihatan kayak service account key JSON asli dari Firebase Admin SDK — private key literal ke-commit ke repo.`,
57
+ });
58
+ }
59
+ }
60
+ return findings;
61
+ }
@@ -1,6 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { basename, extname, relative, sep } from "node:path";
3
3
  import { collectFiles, SOURCE_EXTENSIONS } from "./fsWalk.js";
4
+ import { envFileTrackingContext, listTrackedFiles } from "./gitUtils.js";
4
5
  // NEXT_PUBLIC_* env vars get inlined into the client bundle at build time —
5
6
  // a Supabase service role key under that prefix is a guaranteed leak.
6
7
  const EXPOSED_KEY_PATTERN = /NEXT_PUBLIC_[A-Z0-9_]*(?:SERVICE_ROLE|SERVICE_KEY)[A-Z0-9_]*/i;
@@ -23,15 +24,17 @@ function isScannableFile(filePath) {
23
24
  }
24
25
  export async function findExposedServiceRoleKeys(targetDir) {
25
26
  const files = await collectFiles(targetDir, isScannableFile);
27
+ const tracked = await listTrackedFiles(targetDir);
26
28
  const findings = [];
27
29
  for (const file of files) {
28
30
  const content = await readFile(file, "utf8");
31
+ const relFile = relative(targetDir, file).split(sep).join("/");
29
32
  for (const match of scanTextForExposedServiceRoleKey(content)) {
30
33
  findings.push({
31
- file: relative(targetDir, file).split(sep).join("/"),
34
+ file: relFile,
32
35
  line: match.line,
33
36
  ruleId: "supabase-service-role-key-public-env",
34
- description: `Env var publik "${match.variableName}" kelihatan nyimpen Supabase service role key — Next.js bakal inline ini ke client bundle.`,
37
+ description: `Env var publik "${match.variableName}" kelihatan nyimpen Supabase service role key — Next.js bakal inline ini ke client bundle.${envFileTrackingContext(relFile, tracked)}`,
35
38
  });
36
39
  }
37
40
  }
@@ -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
+ }
@@ -0,0 +1,37 @@
1
+ import { spawn } from "node:child_process";
2
+ import { basename } from "node:path";
3
+ // Returns the set of forward-slash relative paths staged/committed in
4
+ // targetDir's git index, or null when targetDir isn't a git repo (no
5
+ // tracking info to report).
6
+ export function listTrackedFiles(targetDir) {
7
+ return new Promise((resolve) => {
8
+ const child = spawn("git", ["-C", targetDir, "ls-files", "-z", "--cached"], {
9
+ stdio: ["ignore", "pipe", "ignore"],
10
+ });
11
+ let stdout = "";
12
+ child.stdout.on("data", (chunk) => {
13
+ stdout += chunk.toString();
14
+ });
15
+ child.on("error", () => resolve(null));
16
+ child.on("close", (code) => {
17
+ if (code !== 0) {
18
+ resolve(null);
19
+ return;
20
+ }
21
+ resolve(new Set(stdout.split("\0").filter((f) => f.length > 0)));
22
+ });
23
+ });
24
+ }
25
+ const TRACKED_CONTEXT = " File ini ke-track di git — kemungkinan udah ke-commit ke repo.";
26
+ const UNTRACKED_CONTEXT = " File ini gitignored atau belum ke-commit — tapi kalau var ini pernah dipasang di production (Vercel dst) dengan prefix NEXT_PUBLIC_, tetap ke-inline ke client bundle.";
27
+ // Two checks (exposedServiceRoleKey, exposedFirebaseAdminKey) scan raw
28
+ // filesystem .env files without git-awareness, so a gitignored/local-only
29
+ // var reads the same as a committed one. Appending this note keeps their
30
+ // CRITICAL severity honest about which case it actually is.
31
+ export function envFileTrackingContext(relFile, tracked) {
32
+ if (!basename(relFile).startsWith(".env"))
33
+ return "";
34
+ if (tracked === null)
35
+ return "";
36
+ return tracked.has(relFile) ? TRACKED_CONTEXT : UNTRACKED_CONTEXT;
37
+ }
package/dist/cli.js CHANGED
@@ -6,9 +6,12 @@ import { pathToFileURL } from "node:url";
6
6
  import { findMissingApiAuth, SemgrepNotFoundError } from "./checks/apiAuthMissing.js";
7
7
  import { findCorsWildcard } from "./checks/corsWildcard.js";
8
8
  import { findDependencyVulnerabilities, OsvScannerNotFoundError } from "./checks/dependencyVulnerabilities.js";
9
+ import { findExposedFirebaseAdminKeys } from "./checks/exposedFirebaseAdminKey.js";
9
10
  import { findExposedServiceRoleKeys } from "./checks/exposedServiceRoleKey.js";
11
+ import { findOpenFirebaseRules } from "./checks/firebaseRulesOpen.js";
10
12
  import { findRlsDisabledTables } from "./checks/rlsDisabled.js";
11
13
  import { GitleaksNotFoundError, runSecretScan } from "./checks/secretScan.js";
14
+ import { checkLicense } from "./licenseCheck.js";
12
15
  import { applyIgnoreRules, buildReport, formatReport } from "./report.js";
13
16
  const IGNORE_FILE = ".secanix.json";
14
17
  // Missing file = no suppression (default, zero-friction). Malformed file
@@ -76,6 +79,11 @@ export async function run(targetDir = process.cwd(), options = {}) {
76
79
  }
77
80
  checkFindings.push({ checkId: "rls-disabled", findings: await findRlsDisabledTables(targetDir) });
78
81
  checkFindings.push({ checkId: "cors-wildcard", findings: await findCorsWildcard(targetDir) });
82
+ checkFindings.push({ checkId: "firebase-rules-open", findings: await findOpenFirebaseRules(targetDir) });
83
+ checkFindings.push({
84
+ checkId: "firebase-admin-key-exposed",
85
+ findings: await findExposedFirebaseAdminKeys(targetDir),
86
+ });
79
87
  try {
80
88
  checkFindings.push({
81
89
  checkId: "dependency-cve",
@@ -108,10 +116,28 @@ export async function run(targetDir = process.cwd(), options = {}) {
108
116
  }
109
117
  return 0;
110
118
  }
119
+ export async function runLicenseCheck(key) {
120
+ const result = await checkLicense(key);
121
+ if (result.status === "valid") {
122
+ console.log("secanix Pro license valid.");
123
+ return 0;
124
+ }
125
+ if (result.status === "invalid") {
126
+ console.error(`secanix Pro license invalid: ${result.message}`);
127
+ return 1;
128
+ }
129
+ console.error(`secanix Pro license check inconclusive: ${result.message}`);
130
+ return 2;
131
+ }
111
132
  const isMain = process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
112
133
  if (isMain) {
113
134
  const args = process.argv.slice(2);
114
- const json = args.includes("--json");
115
- const targetDir = args.find((arg) => arg !== "--json" && !arg.startsWith("-"));
116
- process.exit(await run(targetDir, { json }));
135
+ if (args[0] === "license-check") {
136
+ process.exitCode = await runLicenseCheck(process.env.SECANIX_LICENSE_KEY);
137
+ }
138
+ else {
139
+ const json = args.includes("--json");
140
+ const targetDir = args.find((arg) => arg !== "--json" && !arg.startsWith("-"));
141
+ process.exitCode = await run(targetDir, { json });
142
+ }
117
143
  }
@@ -0,0 +1,37 @@
1
+ const VALIDATE_URL = "https://api.lemonsqueezy.com/v1/licenses/validate";
2
+ const REQUEST_TIMEOUT_MS = 10_000;
3
+ export async function checkLicense(key) {
4
+ if (!key) {
5
+ return { status: "invalid", message: "SECANIX_LICENSE_KEY is missing or empty." };
6
+ }
7
+ let response;
8
+ try {
9
+ response = await fetch(VALIDATE_URL, {
10
+ method: "POST",
11
+ headers: { Accept: "application/json" },
12
+ body: new URLSearchParams({ license_key: key }),
13
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
14
+ });
15
+ }
16
+ catch (err) {
17
+ return { status: "error", message: `Could not reach LemonSqueezy: ${err.message}` };
18
+ }
19
+ let body;
20
+ try {
21
+ body = await response.json();
22
+ }
23
+ catch {
24
+ return { status: "error", message: "LemonSqueezy returned a non-JSON response." };
25
+ }
26
+ if (typeof body !== "object" || body === null || !("valid" in body)) {
27
+ return { status: "error", message: "Unexpected response shape from LemonSqueezy." };
28
+ }
29
+ const { valid, error } = body;
30
+ if (valid === true) {
31
+ return { status: "valid" };
32
+ }
33
+ return {
34
+ status: "invalid",
35
+ message: typeof error === "string" ? error : "License key is invalid or expired.",
36
+ };
37
+ }
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
@@ -1,13 +1,13 @@
1
- [extend]
2
- useDefault = true
3
-
4
- [allowlist]
5
- paths = [
6
- '''(^|/)node_modules(/|$)''',
7
- '''(^|/)\.git(/|$)''',
8
- '''(^|/)\.next(/|$)''',
9
- '''(^|/)dist(/|$)''',
10
- '''(^|/)build(/|$)''',
11
- '''(^|/)out(/|$)''',
12
- '''(^|/)coverage(/|$)''',
13
- ]
1
+ [extend]
2
+ useDefault = true
3
+
4
+ [allowlist]
5
+ paths = [
6
+ '''(^|/)node_modules(/|$)''',
7
+ '''(^|/)\.git(/|$)''',
8
+ '''(^|/)\.next(/|$)''',
9
+ '''(^|/)dist(/|$)''',
10
+ '''(^|/)build(/|$)''',
11
+ '''(^|/)out(/|$)''',
12
+ '''(^|/)coverage(/|$)''',
13
+ ]
@@ -1,24 +1,24 @@
1
- rules:
2
- - id: nextjs-api-route-missing-auth
3
- languages: [typescript, javascript]
4
- severity: WARNING
5
- message: >-
6
- API route Next.js ini kelihatan gak ada auth check (session/token) sebelum jalanin logic.
7
- paths:
8
- include:
9
- - "pages/api/**"
10
- - "src/pages/api/**"
11
- - "app/**/route.ts"
12
- - "app/**/route.js"
13
- - "src/app/**/route.ts"
14
- - "src/app/**/route.js"
15
- patterns:
16
- - pattern-either:
17
- - pattern: export default function $HANDLER(...) { ... }
18
- - pattern: export default async function $HANDLER(...) { ... }
19
- - pattern: export async function GET(...) { ... }
20
- - pattern: export async function POST(...) { ... }
21
- - pattern: export async function PUT(...) { ... }
22
- - pattern: export async function PATCH(...) { ... }
23
- - pattern: export async function DELETE(...) { ... }
24
- - pattern-not-regex: (?i)(getServerSession|getToken|currentUser|verifyAuth|requireAuth|withAuth|auth\(\)|\.auth\.getUser|\.auth\.getSession|jwt\.verify|isAuthenticated|checkAuth)
1
+ rules:
2
+ - id: nextjs-api-route-missing-auth
3
+ languages: [typescript, javascript]
4
+ severity: WARNING
5
+ message: >-
6
+ API route Next.js ini kelihatan gak ada auth check (session/token) sebelum jalanin logic.
7
+ paths:
8
+ include:
9
+ - "pages/api/**"
10
+ - "src/pages/api/**"
11
+ - "app/**/route.ts"
12
+ - "app/**/route.js"
13
+ - "src/app/**/route.ts"
14
+ - "src/app/**/route.js"
15
+ patterns:
16
+ - pattern-either:
17
+ - pattern: export default function $HANDLER(...) { ... }
18
+ - pattern: export default async function $HANDLER(...) { ... }
19
+ - pattern: export async function GET(...) { ... }
20
+ - pattern: export async function POST(...) { ... }
21
+ - pattern: export async function PUT(...) { ... }
22
+ - pattern: export async function PATCH(...) { ... }
23
+ - pattern: export async function DELETE(...) { ... }
24
+ - pattern-not-regex: (?i)(getServerSession|getToken|currentUser|verifyAuth|requireAuth|withAuth|auth\(\)|\.auth\.getUser|\.auth\.getSession|jwt\.verify|isAuthenticated|checkAuth)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "secanix",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "description": "Security scanner buat app hasil vibe-coding (Next.js + Supabase/Firebase).",
6
6
  "license": "MIT",