penthera 1.1.0

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE +21 -0
  3. package/README.md +561 -0
  4. package/SECURITY.md +31 -0
  5. package/bin/penthera.js +3 -0
  6. package/bin/scan.js +11 -0
  7. package/docs/index.md +60 -0
  8. package/docs/owasp-wstg-coverage.md +79 -0
  9. package/lib/adaptive-probes.js +334 -0
  10. package/lib/attack-surface.js +149 -0
  11. package/lib/audit-loop.js +170 -0
  12. package/lib/blackbox/adaptive-scan.js +137 -0
  13. package/lib/blackbox/client-auth.js +60 -0
  14. package/lib/blackbox/email-dns.js +135 -0
  15. package/lib/blackbox/headers.js +91 -0
  16. package/lib/blackbox/idor.js +113 -0
  17. package/lib/blackbox/jwt.js +73 -0
  18. package/lib/blackbox/oauth.js +50 -0
  19. package/lib/blackbox/openapi.js +227 -0
  20. package/lib/compliance/incident.js +169 -0
  21. package/lib/compliance/index.js +163 -0
  22. package/lib/compliance/policy-pack.js +293 -0
  23. package/lib/compliance/questionnaire.js +113 -0
  24. package/lib/compliance/readiness.js +229 -0
  25. package/lib/compliance/self-assessment.js +66 -0
  26. package/lib/crawler.js +257 -0
  27. package/lib/fuzzer.js +233 -0
  28. package/lib/injections.js +676 -0
  29. package/lib/jurisdictions/nl.json +48 -0
  30. package/lib/knowledge-graph.js +232 -0
  31. package/lib/machine.js +814 -0
  32. package/lib/nuclei-loader.js +283 -0
  33. package/lib/owasp-wstg.js +204 -0
  34. package/lib/params.js +235 -0
  35. package/lib/plugins.js +70 -0
  36. package/lib/provenance.js +82 -0
  37. package/lib/recon.js +193 -0
  38. package/lib/remediation/index.js +219 -0
  39. package/lib/retirejs.js +239 -0
  40. package/lib/supply-chain.js +131 -0
  41. package/lib/templates.js +364 -0
  42. package/lib/tls.js +145 -0
  43. package/lib/whitebox/frameworks.js +139 -0
  44. package/lib/whitebox/sbom.js +144 -0
  45. package/lib/whitebox/secrets.js +111 -0
  46. package/package.json +76 -0
  47. package/pentest.config.example.js +82 -0
  48. package/scripts/package-skill.sh +16 -0
  49. package/skills/penthera/SKILL.md +272 -0
  50. package/skills/penthera/references/authorization.md +36 -0
  51. package/skills/penthera/references/output-and-ci.md +109 -0
  52. package/skills/penthera/references/profiles-and-flags.md +74 -0
  53. package/skills/penthera/references/remediation.md +267 -0
  54. package/skills/penthera/references/reporting.md +113 -0
  55. package/skills/penthera/references/secure-defaults.md +41 -0
  56. package/skills/penthera/references/troubleshooting.md +100 -0
  57. package/skills/penthera/scripts/preflight.sh +66 -0
  58. package/skills/penthera/scripts/validate-report.mjs +53 -0
  59. package/skills/penthera/scripts/validate-report.sh +8 -0
  60. package/src/cli/ansi.js +70 -0
  61. package/src/cli/baseline.js +37 -0
  62. package/src/cli/merge-results.js +71 -0
  63. package/src/cli/onboarding.js +216 -0
  64. package/src/cli/profiles.js +53 -0
  65. package/src/cli/prompt.js +64 -0
  66. package/src/cli/run-scan.js +152 -0
  67. package/src/cli.js +536 -0
  68. package/src/report/markdown.js +182 -0
  69. package/src/reporter.js +512 -0
  70. package/src/scan-repo.js +205 -0
  71. package/src/scan-url.js +563 -0
  72. package/src/utils/auth.js +13 -0
  73. package/src/utils/http.js +38 -0
  74. package/src/utils/url.js +54 -0
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Software Bill of Materials (CycloneDX) generator.
3
+ *
4
+ * Parses a repo's dependency manifests offline (no network, no API keys) and
5
+ * emits a CycloneDX 1.5 SBOM, the artifact enterprises increasingly demand of
6
+ * their suppliers, and direct evidence for NIS2 supply-chain security (d).
7
+ *
8
+ * Supports npm (package-lock.json, then package.json) and Python
9
+ * (requirements.txt). Resolved lockfile versions are preferred; where only a
10
+ * range is known it is recorded as-is and marked.
11
+ */
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ function cleanRange(range) {
16
+ // Strip a leading range operator for a best-effort version; keep the rest.
17
+ return String(range).replace(/^[\^~>=<\s]+/, "").trim() || String(range);
18
+ }
19
+
20
+ function purl(ecosystem, name, version) {
21
+ return `pkg:${ecosystem}/${encodeURIComponent(name)}@${encodeURIComponent(version)}`;
22
+ }
23
+
24
+ function parseNpmLock(lock) {
25
+ const comps = [];
26
+ if (lock.packages) { // lockfile v2/v3
27
+ for (const [k, v] of Object.entries(lock.packages)) {
28
+ if (k === "" || !v || !v.version) continue; // "" is the root project
29
+ const name = k.split("node_modules/").pop();
30
+ if (!name) continue;
31
+ comps.push({ name, version: v.version, scope: v.dev ? "optional" : "required", resolved: true });
32
+ }
33
+ } else if (lock.dependencies) { // lockfile v1
34
+ const walk = (deps) => {
35
+ for (const [name, v] of Object.entries(deps)) {
36
+ if (v?.version) comps.push({ name, version: v.version, scope: v.dev ? "optional" : "required", resolved: true });
37
+ if (v?.dependencies) walk(v.dependencies);
38
+ }
39
+ };
40
+ walk(lock.dependencies);
41
+ }
42
+ return comps;
43
+ }
44
+
45
+ function parsePackageJson(pkg) {
46
+ const comps = [];
47
+ for (const [name, range] of Object.entries(pkg.dependencies || {})) {
48
+ comps.push({ name, version: cleanRange(range), scope: "required", resolved: false });
49
+ }
50
+ for (const [name, range] of Object.entries(pkg.devDependencies || {})) {
51
+ comps.push({ name, version: cleanRange(range), scope: "optional", resolved: false });
52
+ }
53
+ return comps;
54
+ }
55
+
56
+ function parseRequirements(text) {
57
+ const comps = [];
58
+ for (const raw of text.split(/\r?\n/)) {
59
+ const line = raw.trim();
60
+ if (!line || line.startsWith("#") || line.startsWith("-")) continue;
61
+ const m = line.match(/^([A-Za-z0-9._-]+)\s*(?:==|>=|~=|<=|!=)?\s*([\w.*+!-]+)?/);
62
+ if (!m) continue;
63
+ comps.push({ name: m[1], version: m[2] || "unknown", scope: "required", resolved: !!m[2] && line.includes("=="), ecosystem: "pypi" });
64
+ }
65
+ return comps;
66
+ }
67
+
68
+ function dedupe(comps) {
69
+ const seen = new Set();
70
+ return comps.filter((c) => {
71
+ const key = `${c.ecosystem || "npm"}:${c.name}@${c.version}`;
72
+ if (seen.has(key)) return false;
73
+ seen.add(key);
74
+ return true;
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Build a CycloneDX SBOM for a repo. Returns null if no manifest is found.
80
+ * @param {string} repoPath
81
+ * @param {object} opts - { serialNumber, timestamp } (injected for reproducibility/testing)
82
+ */
83
+ export function buildSbom(repoPath, opts = {}) {
84
+ let components = [];
85
+ let rootName = "application";
86
+ let rootVersion = "0.0.0";
87
+ const sources = [];
88
+
89
+ const pkgJsonPath = join(repoPath, "package.json");
90
+ const lockPath = join(repoPath, "package-lock.json");
91
+ const reqPath = join(repoPath, "requirements.txt");
92
+
93
+ if (existsSync(pkgJsonPath)) {
94
+ try {
95
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
96
+ rootName = pkg.name || rootName;
97
+ rootVersion = pkg.version || rootVersion;
98
+ } catch { /* ignore malformed */ }
99
+ }
100
+
101
+ if (existsSync(lockPath)) {
102
+ try {
103
+ const npm = parseNpmLock(JSON.parse(readFileSync(lockPath, "utf-8"))).map((c) => ({ ...c, ecosystem: "npm" }));
104
+ if (npm.length) { components.push(...npm); sources.push("package-lock.json"); }
105
+ } catch { /* ignore */ }
106
+ }
107
+ if (components.length === 0 && existsSync(pkgJsonPath)) {
108
+ try {
109
+ const npm = parsePackageJson(JSON.parse(readFileSync(pkgJsonPath, "utf-8"))).map((c) => ({ ...c, ecosystem: "npm" }));
110
+ if (npm.length) { components.push(...npm); sources.push("package.json"); }
111
+ } catch { /* ignore */ }
112
+ }
113
+ if (existsSync(reqPath)) {
114
+ try {
115
+ const py = parseRequirements(readFileSync(reqPath, "utf-8"));
116
+ if (py.length) { components.push(...py); sources.push("requirements.txt"); }
117
+ } catch { /* ignore */ }
118
+ }
119
+
120
+ if (sources.length === 0) return null;
121
+
122
+ components = dedupe(components);
123
+
124
+ return {
125
+ bomFormat: "CycloneDX",
126
+ specVersion: "1.5",
127
+ serialNumber: opts.serialNumber || undefined,
128
+ version: 1,
129
+ metadata: {
130
+ timestamp: opts.timestamp || undefined,
131
+ tools: [{ vendor: "Penthera", name: "penthera", components: [] }],
132
+ component: { type: "application", name: rootName, version: rootVersion },
133
+ properties: [{ name: "penthera:sources", value: sources.join(", ") }],
134
+ },
135
+ components: components.map((c) => ({
136
+ type: "library",
137
+ name: c.name,
138
+ version: c.version,
139
+ scope: c.scope,
140
+ purl: purl(c.ecosystem || "npm", c.name, c.version),
141
+ properties: c.resolved ? undefined : [{ name: "penthera:version-note", value: "declared range, not a resolved version" }],
142
+ })),
143
+ };
144
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Scan repository source for hardcoded secrets.
3
+ */
4
+ import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
5
+ import { join, relative } from "node:path";
6
+
7
+ const SKIP_DIRS = new Set([
8
+ "node_modules", ".git", "dist", "build", ".next", "coverage",
9
+ "reports", ".security-results", "vendor",
10
+ ]);
11
+
12
+ const SKIP_FILES = /\.(png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot|zip|tar|gz|pdf|lock)$/i;
13
+
14
+ // Values that clearly aren't real secrets: placeholders, redactions, and the
15
+ // dummy/probe credentials that security tooling (Penthera included) hardcodes
16
+ // to test login endpoints, e.g. `password: "invalid-password-12345"`. Matched
17
+ // against the assigned value only, with word boundaries so a real secret that
18
+ // merely contains one of these substrings isn't suppressed.
19
+ const TEST_VALUE = /\b(?:placeholder|example|changeme|dummy|sample|redacted|invalid|fake|test|your)\b|xxx|\*{3,}/i;
20
+
21
+ const SECRET_PATTERNS = [
22
+ { name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/, severity: "critical" },
23
+ { name: "GitHub token", pattern: /ghp_[a-zA-Z0-9]{36,}/, severity: "critical" },
24
+ { name: "OpenAI API key", pattern: /sk-[a-zA-Z0-9]{20,}/, severity: "critical" },
25
+ { name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{20,}/, severity: "critical" },
26
+ {
27
+ name: "Generic API key assignment",
28
+ pattern: /(?:api[_-]?key|apikey|secret[_-]?key)\s*[:=]\s*['"][^'"\s]{8,}['"]/i,
29
+ severity: "high",
30
+ skipIf: TEST_VALUE,
31
+ },
32
+ {
33
+ name: "Hardcoded password",
34
+ pattern: /(?:password|passwd|pwd)\s*[:=]\s*['"][^'"\s]{4,}['"]/i,
35
+ severity: "high",
36
+ skipIf: TEST_VALUE,
37
+ },
38
+ { name: "Private key block", pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/, severity: "critical" },
39
+ { name: "GCP/Firebase service account", pattern: /"private_key"\s*:\s*"-----BEGIN/, severity: "critical" },
40
+ {
41
+ name: "JWT secret assignment",
42
+ pattern: /(?:jwt[_-]?secret|session[_-]?secret)\s*[:=]\s*['"][^'"\s]{8,}['"]/i,
43
+ severity: "high",
44
+ skipIf: TEST_VALUE,
45
+ },
46
+ ];
47
+
48
+ const SCAN_EXTENSIONS = new Set([
49
+ ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".py", ".rb", ".go", ".java",
50
+ ".env", ".yaml", ".yml", ".json", ".toml", ".cfg", ".ini", ".sh",
51
+ ]);
52
+
53
+ function walkFiles(dir, files = []) {
54
+ if (!existsSync(dir)) return files;
55
+ for (const entry of readdirSync(dir)) {
56
+ if (SKIP_DIRS.has(entry)) continue;
57
+ const full = join(dir, entry);
58
+ if (statSync(full).isDirectory()) {
59
+ walkFiles(full, files);
60
+ } else if (!SKIP_FILES.test(entry)) {
61
+ const ext = entry.includes(".") ? entry.slice(entry.lastIndexOf(".")) : "";
62
+ if (SCAN_EXTENSIONS.has(ext) || entry.startsWith(".env")) files.push(full);
63
+ }
64
+ }
65
+ return files;
66
+ }
67
+
68
+ function maskMatch(match) {
69
+ return match.length <= 8 ? "***" : match.slice(0, 4) + "…" + match.slice(-4);
70
+ }
71
+
72
+ export function scanSecrets(repoPath) {
73
+ const findings = [];
74
+ const seen = new Set();
75
+
76
+ for (const filePath of walkFiles(repoPath)) {
77
+ let content;
78
+ try {
79
+ content = readFileSync(filePath, "utf-8");
80
+ } catch {
81
+ continue;
82
+ }
83
+
84
+ const rel = relative(repoPath, filePath);
85
+
86
+ for (const rule of SECRET_PATTERNS) {
87
+ const match = content.match(rule.pattern);
88
+ if (!match) continue;
89
+ // For assignment-style rules, test the placeholder/dummy filter against
90
+ // the assigned value only (not the whole `key = "value"` match), so a
91
+ // variable named e.g. `testToken` can't mask a real secret.
92
+ const value = (match[0].match(/['"]([^'"]+)['"]/) || [, match[0]])[1];
93
+ if (rule.skipIf && rule.skipIf.test(value)) continue;
94
+
95
+ const key = `${rel}::${rule.name}`;
96
+ if (seen.has(key)) continue;
97
+ seen.add(key);
98
+
99
+ findings.push({
100
+ severity: rule.severity,
101
+ title: `${rule.name} in source`,
102
+ description: `Possible secret in \`${rel}\`: \`${maskMatch(match[0])}\``,
103
+ url: rel,
104
+ category: "secrets",
105
+ source: "secret-scan",
106
+ });
107
+ }
108
+ }
109
+
110
+ return findings;
111
+ }
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "penthera",
3
+ "version": "1.1.0",
4
+ "type": "module",
5
+ "description": "Lightweight security scanner for web apps — scan URLs or repos from the CLI",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/danoszz/penthera.git"
9
+ },
10
+ "homepage": "https://github.com/danoszz/penthera#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/danoszz/penthera/issues"
13
+ },
14
+ "bin": {
15
+ "penthera": "./bin/penthera.js",
16
+ "penthera-scan": "./bin/scan.js"
17
+ },
18
+ "files": [
19
+ "bin",
20
+ "src",
21
+ "lib",
22
+ "skills",
23
+ "docs",
24
+ "scripts/package-skill.sh",
25
+ "pentest.config.example.js",
26
+ "CHANGELOG.md",
27
+ "LICENSE",
28
+ "README.md",
29
+ "SECURITY.md"
30
+ ],
31
+ "exports": {
32
+ "./plugins": "./lib/plugins.js",
33
+ "./adaptive": "./lib/blackbox/adaptive-scan.js",
34
+ "./wstg": "./lib/owasp-wstg.js"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "scripts": {
40
+ "test": "vitest run --reporter=verbose",
41
+ "scan": "node bin/penthera.js",
42
+ "scan:full": "node bin/scan.js",
43
+ "pentest": "vitest run --reporter=verbose",
44
+ "pentest:watch": "vitest --reporter=verbose",
45
+ "pentest:static": "vitest run tests/pentest-static.test.js --reporter=verbose",
46
+ "pentest:live": "vitest run tests/pentest-live.test.js --reporter=verbose",
47
+ "pentest:adaptive": "vitest run tests/pentest-adaptive.test.js --reporter=verbose",
48
+ "pentest:recon": "vitest run tests/pentest-recon.test.js --reporter=verbose",
49
+ "pentest:mock": "vitest run tests/pentest-mock.test.js --reporter=verbose",
50
+ "pentest:wstg": "vitest run tests/owasp-wstg.test.js --reporter=verbose",
51
+ "validate:report": "node skills/penthera/scripts/validate-report.mjs",
52
+ "package:skill": "bash scripts/package-skill.sh",
53
+ "prepack": "npm test",
54
+ "mock-server": "node tests/fixtures/mock-server.js"
55
+ },
56
+ "keywords": [
57
+ "security",
58
+ "scanner",
59
+ "pentest",
60
+ "vulnerability",
61
+ "recon",
62
+ "cli",
63
+ "agent-skills",
64
+ "cursor",
65
+ "claude-code",
66
+ "owasp",
67
+ "sarif"
68
+ ],
69
+ "license": "MIT",
70
+ "devDependencies": {
71
+ "vitest": "^3.1.1"
72
+ },
73
+ "dependencies": {
74
+ "yaml": "^2.8.3"
75
+ }
76
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Penthera — Live test configuration (example)
3
+ *
4
+ * Copy to pentest.config.js and customize for your app:
5
+ * cp pentest.config.example.js pentest.config.js
6
+ *
7
+ * Or override the target URL without editing this file:
8
+ * PENTEST_BASE_URL=https://staging.myapp.com npm run pentest:live
9
+ */
10
+
11
+ // ── Target ────────────────────────────────────────────────────────────────
12
+ export const BASE_URL = process.env.PENTEST_BASE_URL || "http://localhost:3000";
13
+
14
+ // ── Allowed CORS Origins ──────────────────────────────────────────────────
15
+ export const ALLOWED_ORIGINS = [
16
+ "http://localhost:3000",
17
+ "http://localhost:5173",
18
+ "https://staging.myapp.com",
19
+ ];
20
+
21
+ // ── Route Definitions ─────────────────────────────────────────────────────
22
+ // Group routes by auth level. Penthera verifies each group behaves correctly.
23
+
24
+ export const PUBLIC_ROUTES = [
25
+ { method: "GET", path: "/api/health" },
26
+ // { method: "POST", path: "/api/contact", body: { email: "test@example.com" } },
27
+ ];
28
+
29
+ export const USER_ROUTES = [
30
+ // Should return 401 without a valid Bearer token
31
+ // { method: "GET", path: "/api/user/profile" },
32
+ // { method: "POST", path: "/api/projects/create", body: { name: "test" } },
33
+ ];
34
+
35
+ export const ADMIN_ROUTES = [
36
+ // Should return 401/403 for non-admin tokens
37
+ // { method: "GET", path: "/api/admin/users" },
38
+ ];
39
+
40
+ export const CRON_ROUTES = [
41
+ // Should reject requests without the correct CRON_SECRET header
42
+ // { method: "GET", path: "/api/cron/cleanup" },
43
+ ];
44
+
45
+ // ── Input Validation Endpoints ────────────────────────────────────────────
46
+ export const INPUT_VALIDATION_ROUTES = [
47
+ // {
48
+ // method: "POST",
49
+ // path: "/api/leads/capture",
50
+ // fields: {
51
+ // email: "sectest@example.com",
52
+ // name: '<script>alert("xss")</script>',
53
+ // },
54
+ // emailField: "email",
55
+ // },
56
+ ];
57
+
58
+ // ── Stripe / Payment Routes ───────────────────────────────────────────────
59
+ export const PAYMENT_ROUTES = [
60
+ // { method: "POST", path: "/api/stripe/checkout", body: {} },
61
+ ];
62
+
63
+ // ── Rate Limited Routes ───────────────────────────────────────────────────
64
+ export const RATE_LIMITED_ROUTES = [
65
+ // { method: "POST", path: "/api/contact", concurrency: 20, body: { message: "test" } },
66
+ ];
67
+
68
+ // ── Filesystem Discovery ──────────────────────────────────────────────────
69
+ // Point to your Next.js app/api directory for adaptive white-box tests.
70
+ export const API_ROOT_PATH = null;
71
+ // export const API_ROOT_PATH = "/Users/you/project/app/api";
72
+
73
+ // ── Authenticated scanning (optional) ─────────────────────────────────────
74
+ // Used by CLI: --auth-cookie / --auth-bearer or env vars PENTHERA_COOKIE / PENTHERA_BEARER
75
+ export const SESSION_COOKIE = process.env.PENTHERA_COOKIE || null;
76
+ export const BEARER_TOKEN = process.env.PENTHERA_BEARER || null;
77
+
78
+ // Route to test JWT alg:none bypass (must require auth)
79
+ export const AUTH_TEST_PATH = "/api/user/profile";
80
+
81
+ // ── Health Check ────────────────────────────────────────────────────────────
82
+ export const HEALTH_ENDPOINT = "/api/health";
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bash
2
+ # Package the Agent Skill for Claude.ai upload or GitHub Releases.
3
+ set -euo pipefail
4
+
5
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
6
+ OUT="$ROOT/dist"
7
+ ZIP="$OUT/penthera-skill.zip"
8
+
9
+ mkdir -p "$OUT"
10
+ rm -f "$ZIP"
11
+
12
+ cd "$ROOT/skills"
13
+ zip -r "$ZIP" penthera/ -x "*.DS_Store"
14
+
15
+ echo "Created $ZIP"
16
+ ls -lh "$ZIP"