secanix 0.1.2 → 0.1.4

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
@@ -1,42 +1,65 @@
1
- # Secanix
2
-
3
- Security scanner buat app hasil vibe-coding (Next.js + Supabase/Firebase).
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)
12
-
13
- ## CLI Usage
14
-
15
- ```
16
- npx -p secanix@latest secanix
17
- ```
18
- Runs all checks against the current directory and prints a human-readable report.
19
-
20
- ```
21
- npx -p secanix@latest secanix --json
22
- ```
23
- Same scan, machine-readable JSON output — useful for piping into other tooling.
24
-
25
- ## GitHub Action
26
-
27
- Add to `.github/workflows/security-scan.yml` in your repo:
28
-
29
- ```yaml
30
- name: Security Scan
31
- on: pull_request
32
- permissions:
33
- pull-requests: write
34
- jobs:
35
- scan:
36
- runs-on: ubuntu-latest
37
- steps:
38
- - uses: actions/checkout@v4
39
- - uses: cutryandifonna/secanix@v1
40
- ```
41
-
42
- The action fails CI when a critical finding is present (leaked secret, exposed Supabase service role key, or RLS disabled). It posts and updates a single PR comment listing all findings by severity. This requires the consuming workflow to grant `permissions: pull-requests: write` itself, as shown above — without it, comment posting fails with a 403. GitHub-hosted `ubuntu-latest` runners (uses `sudo` for tool installs — self-hosted runners need equivalent permissions).
1
+ # Secanix
2
+
3
+ Security scanner buat app hasil vibe-coding (Next.js + Supabase/Firebase).
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)
12
+
13
+ ## CLI Usage
14
+
15
+ ```
16
+ npx -p secanix@latest secanix
17
+ ```
18
+ Runs all checks against the current directory and prints a human-readable report.
19
+
20
+ ```
21
+ npx -p secanix@latest secanix --json
22
+ ```
23
+ Same scan, machine-readable JSON output — useful for piping into other tooling.
24
+
25
+ ## Suppressing false positives
26
+
27
+ Some findings are correct in general but not in your case — e.g. an API route
28
+ protected by `middleware.ts` instead of an in-handler check, which
29
+ `api-auth-missing` can't see. Add a `.secanix.json` at your project root:
30
+
31
+ ```json
32
+ {
33
+ "ignore": [
34
+ {
35
+ "file": "app/api/admin/route.ts",
36
+ "ruleId": "nextjs-api-route-missing-auth",
37
+ "reason": "protected by middleware.ts, matcher /api/admin/*"
38
+ }
39
+ ]
40
+ }
41
+ ```
42
+
43
+ Both `file` (relative path) and `ruleId` must match exactly. Suppressed
44
+ findings aren't silently dropped — they're printed separately (with your
45
+ `reason`) so they stay visible for review, not just filtered out of the JSON
46
+ output.
47
+
48
+ ## GitHub Action
49
+
50
+ Add to `.github/workflows/security-scan.yml` in your repo:
51
+
52
+ ```yaml
53
+ name: Security Scan
54
+ on: pull_request
55
+ permissions:
56
+ pull-requests: write
57
+ jobs:
58
+ scan:
59
+ runs-on: ubuntu-latest
60
+ steps:
61
+ - uses: actions/checkout@v4
62
+ - uses: cutryandifonna/secanix@v1
63
+ ```
64
+
65
+ The action fails CI when a critical finding is present (leaked secret, exposed Supabase service role key, or RLS disabled). It posts and updates a single PR comment listing all findings by severity. This requires the consuming workflow to grant `permissions: pull-requests: write` itself, as shown above — without it, comment posting fails with a 403. GitHub-hosted `ubuntu-latest` runners (uses `sudo` for tool installs — self-hosted runners need equivalent permissions).
@@ -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 {
@@ -69,11 +85,13 @@ export async function findMissingApiAuth(targetDir) {
69
85
  RULE_PATH,
70
86
  "--json",
71
87
  "--quiet",
72
- "--no-git-ignore",
73
88
  targetDir,
74
89
  ]);
75
- return parseSemgrepReport(stdout).map((finding) => ({
90
+ const findings = parseSemgrepReport(stdout).map((finding) => ({
76
91
  ...finding,
77
92
  file: relative(targetDir, resolve(targetDir, finding.file)).split(sep).join("/"),
78
93
  }));
94
+ if (findings.length === 0 || !(await hasMiddlewareFile(targetDir)))
95
+ return findings;
96
+ return findings.map((finding) => ({ ...finding, description: finding.description + MIDDLEWARE_CAVEAT }));
79
97
  }
@@ -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,7 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { copyFile, mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const CONFIG_PATH = join(__dirname, "..", "rules", "gitleaks-config.toml");
5
8
  export class GitleaksNotFoundError extends Error {
6
9
  constructor() {
7
10
  super("gitleaks gak ketemu di PATH. Install: https://github.com/gitleaks/gitleaks#installing");
@@ -58,16 +61,53 @@ export function parseGitleaksReport(json) {
58
61
  }
59
62
  return findings;
60
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
+ }
61
92
  export async function runSecretScan(targetDir) {
62
93
  const tempDir = await mkdtemp(join(tmpdir(), "vibe-secret-scan-"));
63
94
  const reportPath = join(tempDir, "gitleaks-report.json");
64
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
+ }
65
103
  await runGitleaksProcess([
66
104
  "detect",
67
105
  "--source",
68
- targetDir,
106
+ scanDir,
69
107
  "--no-git",
70
108
  "--no-banner",
109
+ "--config",
110
+ CONFIG_PATH,
71
111
  "--report-format",
72
112
  "json",
73
113
  "--report-path",
@@ -76,7 +116,18 @@ export async function runSecretScan(targetDir) {
76
116
  "0",
77
117
  ]);
78
118
  const content = await readFile(reportPath, "utf8").catch(() => "");
79
- 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
+ });
80
131
  }
81
132
  finally {
82
133
  await rm(tempDir, { recursive: true, force: true });
package/dist/cli.js CHANGED
@@ -1,5 +1,7 @@
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";
@@ -7,7 +9,40 @@ import { findDependencyVulnerabilities, OsvScannerNotFoundError } from "./checks
7
9
  import { findExposedServiceRoleKeys } from "./checks/exposedServiceRoleKey.js";
8
10
  import { findRlsDisabledTables } from "./checks/rlsDisabled.js";
9
11
  import { GitleaksNotFoundError, runSecretScan } from "./checks/secretScan.js";
10
- import { buildReport, formatReport } from "./report.js";
12
+ import { applyIgnoreRules, buildReport, formatReport } from "./report.js";
13
+ const IGNORE_FILE = ".secanix.json";
14
+ // Missing file = no suppression (default, zero-friction). Malformed file
15
+ // warns and falls back to no suppression rather than crashing the scan.
16
+ export async function loadIgnoreRules(targetDir) {
17
+ let content;
18
+ try {
19
+ content = await readFile(join(targetDir, IGNORE_FILE), "utf8");
20
+ }
21
+ catch {
22
+ return [];
23
+ }
24
+ try {
25
+ const parsed = JSON.parse(content);
26
+ const ignore = parsed.ignore;
27
+ if (!Array.isArray(ignore))
28
+ return [];
29
+ const rules = [];
30
+ for (const entry of ignore) {
31
+ if (typeof entry === "object" &&
32
+ entry !== null &&
33
+ typeof entry.file === "string" &&
34
+ typeof entry.ruleId === "string") {
35
+ const { file, ruleId, reason } = entry;
36
+ rules.push({ file, ruleId, reason: typeof reason === "string" ? reason : undefined });
37
+ }
38
+ }
39
+ return rules;
40
+ }
41
+ catch (err) {
42
+ console.error(`${IGNORE_FILE} invalid, diabaikan: ${err.message}`);
43
+ return [];
44
+ }
45
+ }
11
46
  const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
12
47
  export async function run(targetDir = process.cwd(), options = {}) {
13
48
  const { json = false } = options;
@@ -54,7 +89,15 @@ export async function run(targetDir = process.cwd(), options = {}) {
54
89
  }
55
90
  throw err;
56
91
  }
57
- const reported = buildReport(checkFindings);
92
+ const ignoreRules = await loadIgnoreRules(targetDir);
93
+ const { findings: reported, suppressed } = applyIgnoreRules(buildReport(checkFindings), ignoreRules);
94
+ if (suppressed.length > 0) {
95
+ console.error(`${suppressed.length} temuan diabaikan via ${IGNORE_FILE}:`);
96
+ for (const finding of suppressed) {
97
+ const reasonSuffix = finding.reason ? ` (${finding.reason})` : "";
98
+ console.error(` ${finding.file}:${finding.line} — ${finding.ruleId}${reasonSuffix}`);
99
+ }
100
+ }
58
101
  if (json) {
59
102
  console.log(JSON.stringify(reported));
60
103
  }
package/dist/report.js CHANGED
@@ -60,6 +60,22 @@ export function classify(finding, checkId) {
60
60
  }
61
61
  return { ...finding, severity: "medium", fixSuggestion: DEFAULT_FIX };
62
62
  }
63
+ // file+ruleId must both match — narrow on purpose so an ignore entry never
64
+ // silently swallows an unrelated finding that happens to share one field.
65
+ export function applyIgnoreRules(reported, ignoreRules) {
66
+ const findings = [];
67
+ const suppressed = [];
68
+ for (const finding of reported) {
69
+ const rule = ignoreRules.find((r) => r.file === finding.file && r.ruleId === finding.ruleId);
70
+ if (rule) {
71
+ suppressed.push({ ...finding, reason: rule.reason });
72
+ }
73
+ else {
74
+ findings.push(finding);
75
+ }
76
+ }
77
+ return { findings, suppressed };
78
+ }
63
79
  const SEVERITY_ORDER = ["critical", "high", "medium", "low"];
64
80
  const SEVERITY_RANK = new Map(SEVERITY_ORDER.map((severity, index) => [severity, index]));
65
81
  export function buildReport(checkFindings) {
@@ -0,0 +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,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.2",
3
+ "version": "0.1.4",
4
4
  "private": false,
5
5
  "description": "Security scanner buat app hasil vibe-coding (Next.js + Supabase/Firebase).",
6
6
  "license": "MIT",