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 +65 -42
- package/dist/checks/apiAuthMissing.js +20 -2
- package/dist/checks/rlsDisabled.js +1 -1
- package/dist/checks/secretScan.js +55 -4
- package/dist/cli.js +45 -2
- package/dist/report.js +16 -0
- package/dist/rules/gitleaks-config.toml +13 -0
- package/dist/rules/nextjs-api-auth-missing.yaml +24 -24
- package/package.json +1 -1
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
|
-
##
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|
|
@@ -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)
|