penthera 1.4.0 → 1.6.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.
- package/CHANGELOG.md +227 -0
- package/README.md +148 -575
- package/bin/scan.js +19 -0
- package/docs/commands/README.md +23 -0
- package/docs/commands/action.md +21 -0
- package/docs/commands/agents.md +38 -0
- package/docs/commands/deps.md +115 -0
- package/docs/commands/fix.md +53 -0
- package/docs/commands/hook.md +69 -0
- package/docs/commands/install.md +53 -0
- package/docs/commands/scan.md +100 -0
- package/docs/commands/skill.md +58 -0
- package/docs/compare/README.md +25 -0
- package/docs/compare/github.md +60 -0
- package/docs/compare/semgrep.md +54 -0
- package/docs/compare/snyk.md +62 -0
- package/docs/compare/socket.md +78 -0
- package/docs/compare/zap.md +59 -0
- package/docs/compliance-cra.md +141 -0
- package/docs/compliance-nis2.md +3 -1
- package/docs/dashboard.md +244 -0
- package/docs/evidence.md +8 -4
- package/docs/examples/github-actions-deps.yml +4 -4
- package/docs/examples/github-actions-scan.yml +4 -4
- package/docs/index.md +11 -2
- package/docs/internal-audit.md +83 -0
- package/docs/marketing.md +151 -0
- package/docs/plan.md +101 -0
- package/docs/privacy.md +38 -0
- package/docs/references.bib +10 -0
- package/docs/references.md +1 -0
- package/docs/research/socket-positioning-notes.md +183 -0
- package/docs/use-cases/README.md +14 -0
- package/docs/use-cases/agencies.md +71 -0
- package/docs/use-cases/ai-coding-teams.md +81 -0
- package/docs/use-cases/eu-sme-compliance.md +68 -0
- package/docs/use-cases/open-source-maintainers.md +75 -0
- package/docs/use-cases/solo-founder.md +65 -0
- package/lib/blackbox/email-dns.js +5 -0
- package/lib/compliance/cra.js +525 -0
- package/lib/compliance/questionnaire.js +4 -2
- package/lib/deps/capabilities.js +49 -12
- package/lib/deps/index.js +314 -30
- package/lib/deps/license.js +329 -0
- package/lib/deps/lockfile.js +29 -3
- package/lib/deps/popular-pypi.json +1 -0
- package/lib/deps/python/capabilities.js +447 -0
- package/lib/deps/python/index.js +234 -0
- package/lib/deps/python/lockfiles.js +359 -0
- package/lib/deps/python/source.js +396 -0
- package/lib/deps/python/toml.js +219 -0
- package/lib/deps/report.js +54 -6
- package/lib/deps/source.js +149 -24
- package/lib/deps/spdx.json +1 -0
- package/lib/deps/typosquat.js +24 -13
- package/lib/fix/apply.js +83 -19
- package/lib/hook/index.js +345 -0
- package/lib/hook/policy.js +62 -0
- package/lib/install/index.js +59 -18
- package/lib/install/policy.js +27 -7
- package/lib/jurisdictions/nl.json +9 -0
- package/lib/memory/repo-suppress.js +11 -3
- package/lib/references.json +77 -0
- package/lib/remediation/index.js +9 -0
- package/lib/retirejs.js +69 -17
- package/lib/rules/regex-guard.js +368 -0
- package/lib/templates.js +13 -6
- package/lib/tls.js +7 -0
- package/lib/whitebox/aibom.js +384 -0
- package/lib/whitebox/frameworks.js +8 -5
- package/lib/whitebox/sbom.js +45 -10
- package/lib/whitebox/secrets.js +31 -8
- package/package.json +8 -3
- package/skills/penthera/SKILL.md +55 -8
- package/src/cli/deps.js +81 -6
- package/src/cli/evidence.js +14 -4
- package/src/cli/hook.js +131 -0
- package/src/cli/install.js +14 -5
- package/src/cli/report-outputs.js +25 -2
- package/src/cli/ui.js +89 -0
- package/src/cli.js +81 -11
- package/src/report/audience.js +10 -10
- package/src/report/evidence.js +33 -14
- package/src/report/footer.js +11 -0
- package/src/report/markdown.js +6 -3
- package/src/report/md-escape.js +16 -0
- package/src/reporter.js +1 -0
- package/src/scan-url.js +5 -1
- package/src/ui/api/data.js +309 -0
- package/src/ui/api/demo.js +464 -0
- package/src/ui/api/routes.js +134 -0
- package/src/ui/api/runs.js +243 -0
- package/src/ui/api/summarize.js +451 -0
- package/src/ui/security.js +153 -0
- package/src/ui/server.js +186 -0
- package/src/ui/static/app.css +723 -0
- package/src/ui/static/favicon.svg +1 -0
- package/src/ui/static/index.html +57 -0
- package/src/ui/static/js/api.js +29 -0
- package/src/ui/static/js/app.js +138 -0
- package/src/ui/static/js/charts.js +207 -0
- package/src/ui/static/js/copy.js +79 -0
- package/src/ui/static/js/dom.js +120 -0
- package/src/ui/static/js/format.js +42 -0
- package/src/ui/static/js/theme.js +10 -0
- package/src/ui/static/js/typer.js +99 -0
- package/src/ui/static/js/views/ai.js +68 -0
- package/src/ui/static/js/views/deps.js +96 -0
- package/src/ui/static/js/views/evidence.js +115 -0
- package/src/ui/static/js/views/findings.js +165 -0
- package/src/ui/static/js/views/history.js +92 -0
- package/src/ui/static/js/views/overview.js +184 -0
- package/src/ui/static/js/views/run.js +349 -0
- package/src/ui/static/js/views/settings.js +67 -0
package/lib/tls.js
CHANGED
|
@@ -26,6 +26,13 @@ const WEAK_PROTOCOLS = new Set(["TLSv1", "TLSv1.1", "SSLv3"]);
|
|
|
26
26
|
*/
|
|
27
27
|
export function checkTls(hostname, port = 443, timeout = 10_000) {
|
|
28
28
|
return new Promise((resolve) => {
|
|
29
|
+
// Validation is off on purpose, and only on this socket: the audit has to
|
|
30
|
+
// complete the handshake with a target whose certificate is expired,
|
|
31
|
+
// self-signed or for the wrong name, so it can report exactly that. The
|
|
32
|
+
// verdict comes from socket.authorized / authorizationError below. The
|
|
33
|
+
// socket only reads the handshake (cert, protocol, cipher) and is closed
|
|
34
|
+
// straight away; no request or credential is ever sent over it. Every
|
|
35
|
+
// other connection Penthera makes keeps Node's default validation.
|
|
29
36
|
const socket = tls.connect(
|
|
30
37
|
{ host: hostname, port, servername: hostname, rejectUnauthorized: false },
|
|
31
38
|
() => {
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Bill of Materials (CycloneDX 1.6 ML-BOM) generator.
|
|
3
|
+
*
|
|
4
|
+
* Inventories the AI a repo uses, offline and read-only:
|
|
5
|
+
* - AI SDK dependencies (openai, @anthropic-ai/sdk, @google/genai, ai,
|
|
6
|
+
* langchain, llamaindex, ollama, transformers, ...) as `library` components;
|
|
7
|
+
* - model identifiers found as string literals in code and config
|
|
8
|
+
* (claude-*, gpt-*, gemini-*, llama*, Hugging Face repo ids, ...) as
|
|
9
|
+
* `machine-learning-model` components with a `modelCard` stub and
|
|
10
|
+
* file:line evidence;
|
|
11
|
+
* - MCP servers (from lib/agents/discover.js) as `services`;
|
|
12
|
+
* - Agent Skills and prompt files as `data` components with SHA-256 hashes.
|
|
13
|
+
*
|
|
14
|
+
* Spec: CycloneDX 1.6, which added `machine-learning-model` and `data`
|
|
15
|
+
* component types and `modelCard` (ML-BOM), and models services with
|
|
16
|
+
* endpoints and trust boundaries.
|
|
17
|
+
* https://cyclonedx.org/docs/1.6/json/
|
|
18
|
+
* https://cyclonedx.org/capabilities/mlbom/
|
|
19
|
+
* https://github.com/CycloneDX/specification/blob/master/schema/bom-1.6.schema.json
|
|
20
|
+
* The schema is vendored in tests/fixtures/cyclonedx/ and every AI-BOM is
|
|
21
|
+
* validated against it in tests/aibom.test.js.
|
|
22
|
+
*
|
|
23
|
+
* A model string in code says a model is referenced, not that it is used in
|
|
24
|
+
* production; the evidence locations let a reviewer check.
|
|
25
|
+
*/
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
import { readFileSync } from "node:fs";
|
|
28
|
+
import { relative, sep, basename, extname } from "node:path";
|
|
29
|
+
import { collectDependencies, purl } from "./sbom.js";
|
|
30
|
+
import { discoverAgentConfig, walk, readText } from "../agents/discover.js";
|
|
31
|
+
|
|
32
|
+
// ── AI SDKs ────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
// [name or prefix*, provider]
|
|
35
|
+
const NPM_AI = [
|
|
36
|
+
["openai", "OpenAI"], ["@openai/agents", "OpenAI"], ["@azure/openai", "Microsoft"],
|
|
37
|
+
["@anthropic-ai/sdk", "Anthropic"], ["@anthropic-ai/claude-agent-sdk", "Anthropic"],
|
|
38
|
+
["@anthropic-ai/bedrock-sdk", "Anthropic"], ["@anthropic-ai/vertex-sdk", "Anthropic"],
|
|
39
|
+
["@google/genai", "Google"], ["@google/generative-ai", "Google"], ["@google-cloud/vertexai", "Google"],
|
|
40
|
+
["ai", "Vercel AI SDK"], ["@ai-sdk/*", "Vercel AI SDK"],
|
|
41
|
+
["langchain", "LangChain"], ["@langchain/*", "LangChain"],
|
|
42
|
+
["llamaindex", "LlamaIndex"], ["@llamaindex/*", "LlamaIndex"],
|
|
43
|
+
["ollama", "Ollama"],
|
|
44
|
+
["@huggingface/transformers", "Hugging Face"], ["@xenova/transformers", "Hugging Face"], ["@huggingface/inference", "Hugging Face"],
|
|
45
|
+
["@mistralai/mistralai", "Mistral AI"], ["cohere-ai", "Cohere"], ["groq-sdk", "Groq"],
|
|
46
|
+
["replicate", "Replicate"], ["together-ai", "Together AI"],
|
|
47
|
+
["@aws-sdk/client-bedrock-runtime", "AWS Bedrock"], ["@modelcontextprotocol/sdk", "Model Context Protocol"],
|
|
48
|
+
];
|
|
49
|
+
const PYPI_AI = [
|
|
50
|
+
["openai", "OpenAI"], ["openai-agents", "OpenAI"], ["anthropic", "Anthropic"], ["claude-agent-sdk", "Anthropic"],
|
|
51
|
+
["google-genai", "Google"], ["google-generativeai", "Google"],
|
|
52
|
+
["langchain", "LangChain"], ["langchain-*", "LangChain"], ["langgraph", "LangChain"],
|
|
53
|
+
["llama-index", "LlamaIndex"], ["llama-index-*", "LlamaIndex"], ["ollama", "Ollama"],
|
|
54
|
+
["transformers", "Hugging Face"], ["sentence-transformers", "Hugging Face"], ["huggingface-hub", "Hugging Face"],
|
|
55
|
+
["torch", "PyTorch"], ["tensorflow", "TensorFlow"], ["keras", "Keras"],
|
|
56
|
+
["mistralai", "Mistral AI"], ["cohere", "Cohere"], ["groq", "Groq"], ["litellm", "LiteLLM"], ["vllm", "vLLM"],
|
|
57
|
+
["replicate", "Replicate"], ["mcp", "Model Context Protocol"], ["crewai", "CrewAI"],
|
|
58
|
+
["autogen-agentchat", "AutoGen"], ["pyautogen", "AutoGen"], ["dspy", "DSPy"], ["dspy-ai", "DSPy"],
|
|
59
|
+
["instructor", "Instructor"], ["haystack-ai", "Haystack"],
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
function matchSdk(table, name) {
|
|
63
|
+
for (const [pat, provider] of table) {
|
|
64
|
+
if (pat.endsWith("*") ? name.startsWith(pat.slice(0, -1)) : name === pat) return provider;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const normPy = (n) => n.toLowerCase().replace(/[_.]+/g, "-");
|
|
69
|
+
|
|
70
|
+
/** pyproject.toml dependency names (PEP 621 list and Poetry table), best effort. */
|
|
71
|
+
function pyprojectDeps(text) {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const m of text.matchAll(/^\s*["']([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*([<>=!~^][^"';]*)?["']\s*,?/gm)) {
|
|
74
|
+
out.push({ name: m[1], version: (m[2] || "").replace(/^[=<>!~^\s]+/, "").split(",")[0].trim() || "unknown" });
|
|
75
|
+
}
|
|
76
|
+
const poetry = text.match(/\[tool\.poetry\.dependencies\]([\s\S]*?)(?:\n\[|$)/);
|
|
77
|
+
if (poetry) {
|
|
78
|
+
for (const m of poetry[1].matchAll(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*=\s*["']([^"']*)["']/gm)) {
|
|
79
|
+
if (m[1] !== "python") out.push({ name: m[1], version: m[2].replace(/^[=<>!~^\s]+/, "") || "unknown" });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Model identifiers ─────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
// Each pattern must match a whole string literal. distribution: hosted API vs open weights.
|
|
88
|
+
const MODEL_PATTERNS = [
|
|
89
|
+
{ provider: "Anthropic", distribution: "hosted-api", re: /^(?:(?:us|eu|apac|global)\.)?(?:anthropic\.)?claude-(?:opus|sonnet|haiku|instant|fable|\d)[a-z0-9.\-@:]*$/i },
|
|
90
|
+
{ provider: "OpenAI", distribution: "open-weights", re: /^(?:openai\/)?gpt-oss-[a-z0-9.\-]+$/i },
|
|
91
|
+
{ provider: "OpenAI", distribution: "hosted-api", re: /^(?:gpt-(?:\d|4o)[a-z0-9.\-]*|o[134](?:-mini|-pro|-preview)?(?:-\d{4}-\d{2}-\d{2})?|chatgpt-4o-latest|text-embedding-(?:3-small|3-large|ada-002)|dall-e-[23]|whisper-1|tts-1(?:-hd)?|codex-mini[a-z0-9.\-]*)$/i },
|
|
92
|
+
{ provider: "Google", distribution: "hosted-api", re: /^(?:models\/)?gemini-(?:\d|pro|ultra|flash|exp)[a-z0-9.\-]*$/i },
|
|
93
|
+
{ provider: "Google", distribution: "open-weights", re: /^gemma-?\d[a-z0-9.\-:]*$/i },
|
|
94
|
+
{ provider: "Meta", distribution: "open-weights", re: /^(?:meta\.)?llama-?\d[a-z0-9.\-:]*$/i },
|
|
95
|
+
{ provider: "Mistral AI", distribution: null, re: /^(?:mistral-(?:large|medium|small|tiny|embed|saba|nemo)[a-z0-9.\-]*|open-mi[sx]tral-[a-z0-9.\-]+|mixtral[a-z0-9.\-:]*|codestral[a-z0-9.\-:]*|ministral-[a-z0-9.\-]+|pixtral-[a-z0-9.\-]+)$/i },
|
|
96
|
+
{ provider: "DeepSeek", distribution: null, re: /^deepseek-(?:chat|coder|reasoner|r1|v\d)[a-z0-9.\-:]*$/i },
|
|
97
|
+
{ provider: "Alibaba (Qwen)", distribution: "open-weights", re: /^qwen\d[a-z0-9.\-:]*$/i },
|
|
98
|
+
{ provider: "Microsoft", distribution: "open-weights", re: /^phi-?\d[a-z0-9.\-:]*$/i },
|
|
99
|
+
{ provider: "Cohere", distribution: "hosted-api", re: /^(?:command-(?:r|a|light|nightly)[a-z0-9.\-]*|embed-(?:english|multilingual)-[a-z0-9.\-]+|rerank-[a-z0-9.\-]+)$/i },
|
|
100
|
+
{ provider: "xAI", distribution: "hosted-api", re: /^grok-\d[a-z0-9.\-]*$/i },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const PROVIDER_DOCS = {
|
|
104
|
+
Anthropic: "https://docs.anthropic.com/en/docs/about-claude/models",
|
|
105
|
+
OpenAI: "https://platform.openai.com/docs/models",
|
|
106
|
+
Google: "https://ai.google.dev/gemini-api/docs/models",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Hugging Face orgs common enough that `org/name` is almost surely a model id.
|
|
110
|
+
const HF_ORGS = new Set([
|
|
111
|
+
"meta-llama", "mistralai", "google", "microsoft", "qwen", "deepseek-ai", "sentence-transformers", "baai",
|
|
112
|
+
"openai", "openai-community", "tiiuae", "bigscience", "eleutherai", "stabilityai", "facebook", "facebookai",
|
|
113
|
+
"intfloat", "nomic-ai", "huggingfaceh4", "huggingfacetb", "xenova", "onnx-community", "thenlper", "jinaai",
|
|
114
|
+
"mixedbread-ai", "cross-encoder", "google-bert", "distilbert", "allenai", "salesforce", "nvidia", "ibm-granite",
|
|
115
|
+
"cohereforai", "unsloth", "thebloke", "lmsys", "nousresearch", "black-forest-labs", "runwayml", "compvis",
|
|
116
|
+
"snowflake", "alibaba-nlp", "answerdotai", "cardiffnlp", "deepset", "bigcode", "codellama", "hf-internal-testing",
|
|
117
|
+
]);
|
|
118
|
+
const HF_ID = /^([A-Za-z0-9][\w.-]{0,95})\/([A-Za-z0-9][\w.-]{0,95})$/;
|
|
119
|
+
const HF_CONTEXT = /from_pretrained|pipeline\s*\(|SentenceTransformer|hf_hub_download|snapshot_download|HfInference|InferenceClient|huggingface|AutoModel|AutoTokenizer|model_id|model_name|repo_id/i;
|
|
120
|
+
const FILEISH = /\.(?:m?[jt]sx?|cjs|json|ya?ml|toml|py|md|txt|html?|css|png|jpe?g|svg|lock|sh|go|rs)$/i;
|
|
121
|
+
|
|
122
|
+
function classifyModel(literal, line) {
|
|
123
|
+
for (const p of MODEL_PATTERNS) {
|
|
124
|
+
if (p.re.test(literal)) return { name: literal, provider: p.provider, distribution: p.distribution };
|
|
125
|
+
}
|
|
126
|
+
const hf = literal.match(HF_ID);
|
|
127
|
+
if (hf && !FILEISH.test(literal) && /[a-z]/i.test(hf[2])) {
|
|
128
|
+
if (HF_ORGS.has(hf[1].toLowerCase()) || HF_CONTEXT.test(line)) {
|
|
129
|
+
return { name: literal, provider: hf[1], distribution: "open-weights", huggingFace: true };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const CODE_EXT = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx", ".py", ".go", ".rb", ".java", ".kt", ".rs", ".php", ".cs", ".swift", ".json", ".jsonc", ".yaml", ".yml", ".toml"]);
|
|
136
|
+
const SKIP_FILES = /(?:^|[/\\])(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|poetry\.lock|[^/\\]*\.cdx\.json|[^/\\]*\.min\.js|[^/\\]*\.map)$/i;
|
|
137
|
+
const MAX_SCAN_BYTES = 1024 * 1024;
|
|
138
|
+
const MAX_OCCURRENCES = 20;
|
|
139
|
+
const LITERAL = /(["'`])((?:(?!\1)[^\\\n]|\\.){2,200})\1/g;
|
|
140
|
+
const CONFIG_VALUE = /^\s*["']?[\w.-]*model[\w.-]*["']?\s*[:=]\s*([A-Za-z0-9][\w.:/@-]*)\s*(?:#.*)?$/i;
|
|
141
|
+
|
|
142
|
+
function findModels(root, files) {
|
|
143
|
+
const models = new Map();
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
const rel = relative(root, file).split(sep).join("/");
|
|
146
|
+
if (!CODE_EXT.has(extname(file).toLowerCase()) || SKIP_FILES.test(rel)) continue;
|
|
147
|
+
const text = readText(file);
|
|
148
|
+
if (typeof text !== "string" || text.length > MAX_SCAN_BYTES) continue;
|
|
149
|
+
const lines = text.split(/\r?\n/);
|
|
150
|
+
const isConfig = /\.(?:ya?ml|toml)$/i.test(file);
|
|
151
|
+
for (let i = 0; i < lines.length; i++) {
|
|
152
|
+
const line = lines[i];
|
|
153
|
+
const literals = [];
|
|
154
|
+
for (const m of line.matchAll(LITERAL)) if (!m[2].includes("${")) literals.push(m[2]);
|
|
155
|
+
if (isConfig) { const m = line.match(CONFIG_VALUE); if (m) literals.push(m[1]); }
|
|
156
|
+
for (const lit of literals) {
|
|
157
|
+
const hit = classifyModel(lit.trim(), line);
|
|
158
|
+
if (!hit) continue;
|
|
159
|
+
const key = hit.name;
|
|
160
|
+
if (!models.has(key)) models.set(key, { ...hit, occurrences: [] });
|
|
161
|
+
const occ = models.get(key).occurrences;
|
|
162
|
+
if (occ.length < MAX_OCCURRENCES && !occ.some((o) => o.location === rel && o.line === i + 1)) {
|
|
163
|
+
occ.push({ location: rel, line: i + 1 });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return [...models.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Prompt files ──────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
const PROMPT_FILE = /\.(?:prompt|prompty)$|\.prompt\.(?:md|ya?ml|json|txt)$/i;
|
|
174
|
+
const PROMPT_DIR_EXT = /\.(?:md|txt|ya?ml|json|j2|jinja2?|hbs|mustache|tmpl|tpl|prompt)$/i;
|
|
175
|
+
|
|
176
|
+
function isPromptFile(rel) {
|
|
177
|
+
if (PROMPT_FILE.test(rel)) return true;
|
|
178
|
+
const parts = rel.split("/");
|
|
179
|
+
return parts.slice(0, -1).some((p) => p.toLowerCase() === "prompts") && PROMPT_DIR_EXT.test(rel);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function sha256File(path) {
|
|
183
|
+
try { return createHash("sha256").update(readFileSync(path)).digest("hex"); } catch { return null; }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── MCP servers ───────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
const RUNNERS = new Set(["npx", "bunx", "uvx", "pipx", "dlx"]);
|
|
189
|
+
|
|
190
|
+
function mcpPackage(def) {
|
|
191
|
+
const cmd = basename(String(def.command || ""));
|
|
192
|
+
const args = Array.isArray(def.args) ? def.args.map(String) : [];
|
|
193
|
+
let rest = args;
|
|
194
|
+
if (cmd === "pnpm" || cmd === "yarn") rest = args[0] === "dlx" ? args.slice(1) : [];
|
|
195
|
+
else if (!RUNNERS.has(cmd)) return null;
|
|
196
|
+
const pkg = rest.find((a) => !a.startsWith("-"));
|
|
197
|
+
return pkg && /^(?:@[\w.-]+\/)?[\w.-]+(?:@[\w.^~<>=-]+)?$/.test(pkg) ? pkg : null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function mcpService(server) {
|
|
201
|
+
const def = server.def || {};
|
|
202
|
+
const url = typeof def.url === "string" ? def.url : typeof def.serverUrl === "string" ? def.serverUrl : null;
|
|
203
|
+
const transport = def.type || def.transport || (url ? "http" : "stdio");
|
|
204
|
+
const props = [
|
|
205
|
+
{ name: "penthera:ai-kind", value: "mcp-server" },
|
|
206
|
+
{ name: "penthera:mcp-transport", value: String(transport) },
|
|
207
|
+
{ name: "penthera:config-file", value: String(server.file || "") },
|
|
208
|
+
];
|
|
209
|
+
if (def.command) props.push({ name: "penthera:mcp-command", value: basename(String(def.command)) });
|
|
210
|
+
const pkg = mcpPackage(def);
|
|
211
|
+
if (pkg) props.push({ name: "penthera:mcp-package", value: pkg });
|
|
212
|
+
const envKeys = def.env && typeof def.env === "object" ? Object.keys(def.env) : [];
|
|
213
|
+
if (envKeys.length) props.push({ name: "penthera:mcp-env-keys", value: envKeys.join(", ") }); // names only, never values
|
|
214
|
+
const svc = {
|
|
215
|
+
"bom-ref": `mcp:${server.name}`,
|
|
216
|
+
name: server.name,
|
|
217
|
+
description: `MCP server (${transport})`,
|
|
218
|
+
"x-trust-boundary": !!url,
|
|
219
|
+
properties: props,
|
|
220
|
+
};
|
|
221
|
+
if (url && /^https?:\/\//i.test(url)) svc.endpoints = [url];
|
|
222
|
+
return svc;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Build ─────────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Build a CycloneDX 1.6 AI-BOM for a repo. Always returns a BOM (possibly with
|
|
229
|
+
* no AI components, which is itself evidence).
|
|
230
|
+
* @param {string} repoPath
|
|
231
|
+
* @param {object} opts - { serialNumber, timestamp, toolVersion }
|
|
232
|
+
*/
|
|
233
|
+
export function buildAiBom(repoPath, opts = {}) {
|
|
234
|
+
const { components: deps, sources, rootName, rootVersion } = collectDependencies(repoPath);
|
|
235
|
+
const inv = discoverAgentConfig(repoPath);
|
|
236
|
+
const root = inv.root;
|
|
237
|
+
const { files } = walk(root);
|
|
238
|
+
const usedRefs = new Set();
|
|
239
|
+
const uniqueRef = (ref) => { let r = ref; let n = 2; while (usedRefs.has(r)) r = `${ref}#${n++}`; usedRefs.add(r); return r; };
|
|
240
|
+
usedRefs.add("root");
|
|
241
|
+
|
|
242
|
+
// AI SDK libraries
|
|
243
|
+
const sdkSeen = new Set();
|
|
244
|
+
const sdks = [];
|
|
245
|
+
for (const d of deps) {
|
|
246
|
+
const eco = d.ecosystem || "npm";
|
|
247
|
+
const provider = eco === "pypi" ? matchSdk(PYPI_AI, normPy(d.name)) : matchSdk(NPM_AI, d.name);
|
|
248
|
+
if (!provider) continue;
|
|
249
|
+
sdks.push({ ...d, ecosystem: eco, provider });
|
|
250
|
+
}
|
|
251
|
+
const pyprojectText = readText(`${root}/pyproject.toml`);
|
|
252
|
+
if (typeof pyprojectText === "string") {
|
|
253
|
+
for (const d of pyprojectDeps(pyprojectText)) {
|
|
254
|
+
const provider = matchSdk(PYPI_AI, normPy(d.name));
|
|
255
|
+
if (provider && !sdks.some((s) => s.ecosystem === "pypi" && normPy(s.name) === normPy(d.name))) {
|
|
256
|
+
sdks.push({ name: d.name, version: d.version, ecosystem: "pypi", provider, resolved: false });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (!sources.includes("pyproject.toml")) sources.push("pyproject.toml");
|
|
260
|
+
}
|
|
261
|
+
const sdkComponents = [];
|
|
262
|
+
for (const s of sdks) {
|
|
263
|
+
const p = purl(s.ecosystem, s.name, s.version);
|
|
264
|
+
if (sdkSeen.has(p)) continue;
|
|
265
|
+
sdkSeen.add(p);
|
|
266
|
+
const props = [
|
|
267
|
+
{ name: "penthera:ai-kind", value: "sdk" },
|
|
268
|
+
{ name: "penthera:ai-provider", value: s.provider },
|
|
269
|
+
];
|
|
270
|
+
if (!s.resolved) props.push({ name: "penthera:version-note", value: "declared range, not a resolved version" });
|
|
271
|
+
sdkComponents.push({
|
|
272
|
+
"bom-ref": uniqueRef(p),
|
|
273
|
+
type: "library",
|
|
274
|
+
name: s.name,
|
|
275
|
+
version: s.version,
|
|
276
|
+
...(s.scope ? { scope: s.scope } : {}),
|
|
277
|
+
purl: p,
|
|
278
|
+
tags: ["ai", "ai-sdk"],
|
|
279
|
+
properties: props,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Models
|
|
284
|
+
const modelComponents = findModels(root, files).map((m) => {
|
|
285
|
+
const props = [{ name: "penthera:ai-kind", value: "model-reference" }];
|
|
286
|
+
if (m.distribution) props.push({ name: "penthera:model-distribution", value: m.distribution });
|
|
287
|
+
const ext = [];
|
|
288
|
+
if (m.huggingFace) ext.push({ type: "model-card", url: `https://huggingface.co/${m.name}` });
|
|
289
|
+
else if (PROVIDER_DOCS[m.provider]) ext.push({ type: "documentation", url: PROVIDER_DOCS[m.provider] });
|
|
290
|
+
return {
|
|
291
|
+
"bom-ref": uniqueRef(`model:${m.name}`),
|
|
292
|
+
type: "machine-learning-model",
|
|
293
|
+
supplier: { name: m.provider },
|
|
294
|
+
name: m.name,
|
|
295
|
+
description: `${m.provider} model referenced in code`,
|
|
296
|
+
tags: ["ai", "model"],
|
|
297
|
+
modelCard: {
|
|
298
|
+
properties: [{ name: "penthera:model-card-status", value: "stub: complete task, datasets and considerations from the provider's model card" }],
|
|
299
|
+
},
|
|
300
|
+
...(ext.length ? { externalReferences: ext } : {}),
|
|
301
|
+
evidence: { occurrences: m.occurrences },
|
|
302
|
+
properties: props,
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Skills
|
|
307
|
+
const skillComponents = inv.skills.map((s) => {
|
|
308
|
+
const rel = relative(root, s.skillFile).split(sep).join("/");
|
|
309
|
+
const hash = sha256File(s.skillFile);
|
|
310
|
+
const desc = typeof s.frontmatter?.description === "string" ? s.frontmatter.description.slice(0, 500) : undefined;
|
|
311
|
+
return {
|
|
312
|
+
"bom-ref": uniqueRef(`skill:${s.name}`),
|
|
313
|
+
type: "data",
|
|
314
|
+
name: s.name,
|
|
315
|
+
...(desc ? { description: desc } : {}),
|
|
316
|
+
tags: ["ai", "agent-skill"],
|
|
317
|
+
...(hash ? { hashes: [{ alg: "SHA-256", content: hash }] } : {}),
|
|
318
|
+
data: [{ type: "configuration", name: rel, description: "Agent Skill instructions (SKILL.md)" }],
|
|
319
|
+
evidence: { occurrences: [{ location: rel }] },
|
|
320
|
+
properties: [{ name: "penthera:ai-kind", value: "skill" }],
|
|
321
|
+
};
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Prompt files
|
|
325
|
+
const promptComponents = files
|
|
326
|
+
.map((f) => ({ f, rel: relative(root, f).split(sep).join("/") }))
|
|
327
|
+
.filter(({ rel }) => isPromptFile(rel))
|
|
328
|
+
.map(({ f, rel }) => {
|
|
329
|
+
const hash = sha256File(f);
|
|
330
|
+
return {
|
|
331
|
+
"bom-ref": uniqueRef(`prompt:${rel}`),
|
|
332
|
+
type: "data",
|
|
333
|
+
name: rel,
|
|
334
|
+
tags: ["ai", "prompt"],
|
|
335
|
+
...(hash ? { hashes: [{ alg: "SHA-256", content: hash }] } : {}),
|
|
336
|
+
data: [{ type: "configuration", name: rel, description: "Prompt template" }],
|
|
337
|
+
evidence: { occurrences: [{ location: rel }] },
|
|
338
|
+
properties: [{ name: "penthera:ai-kind", value: "prompt" }],
|
|
339
|
+
};
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// MCP servers
|
|
343
|
+
const services = inv.mcpServers.map((s) => {
|
|
344
|
+
const svc = mcpService(s);
|
|
345
|
+
svc["bom-ref"] = uniqueRef(svc["bom-ref"]);
|
|
346
|
+
return svc;
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const components = [...sdkComponents, ...modelComponents, ...skillComponents, ...promptComponents];
|
|
350
|
+
const refs = [...components, ...services].map((c) => c["bom-ref"]);
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
bomFormat: "CycloneDX",
|
|
354
|
+
specVersion: "1.6",
|
|
355
|
+
...(opts.serialNumber ? { serialNumber: opts.serialNumber } : {}),
|
|
356
|
+
version: 1,
|
|
357
|
+
metadata: {
|
|
358
|
+
...(opts.timestamp ? { timestamp: opts.timestamp } : {}),
|
|
359
|
+
tools: { components: [{ type: "application", name: "penthera", ...(opts.toolVersion ? { version: opts.toolVersion } : {}) }] },
|
|
360
|
+
component: { "bom-ref": "root", type: "application", name: rootName, version: rootVersion },
|
|
361
|
+
properties: [
|
|
362
|
+
{ name: "penthera:bom-kind", value: "ai-bom" },
|
|
363
|
+
{ name: "penthera:sources", value: [...sources, "source files", "agent config"].join(", ") },
|
|
364
|
+
{ name: "penthera:summary", value: `${sdkComponents.length} AI SDKs, ${modelComponents.length} models, ${services.length} MCP servers, ${skillComponents.length} skills, ${promptComponents.length} prompt files` },
|
|
365
|
+
],
|
|
366
|
+
},
|
|
367
|
+
components,
|
|
368
|
+
...(services.length ? { services } : {}),
|
|
369
|
+
dependencies: [{ ref: "root", dependsOn: refs }],
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Counts for CLI output. */
|
|
374
|
+
export function aiBomSummary(bom) {
|
|
375
|
+
const kind = (c) => c.properties?.find((p) => p.name === "penthera:ai-kind")?.value;
|
|
376
|
+
const comps = bom?.components || [];
|
|
377
|
+
return {
|
|
378
|
+
sdks: comps.filter((c) => kind(c) === "sdk").length,
|
|
379
|
+
models: comps.filter((c) => c.type === "machine-learning-model").length,
|
|
380
|
+
mcpServers: (bom?.services || []).length,
|
|
381
|
+
skills: comps.filter((c) => kind(c) === "skill").length,
|
|
382
|
+
prompts: comps.filter((c) => kind(c) === "prompt").length,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Discover API routes in Express, Hono, and Fastify projects.
|
|
3
3
|
*/
|
|
4
|
-
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
4
|
+
import { readdirSync, readFileSync, statSync, lstatSync, existsSync } from "node:fs";
|
|
5
5
|
import { join, relative } from "node:path";
|
|
6
6
|
|
|
7
7
|
const SKIP = new Set(["node_modules", ".git", "dist", "build", ".next", "coverage", "vendor"]);
|
|
@@ -18,13 +18,16 @@ const ROUTE_RES = [
|
|
|
18
18
|
/method:\s*['"](GET|POST|PUT|PATCH|DELETE)['"]\s*,\s*url:\s*['"`]([^'"`]+)['"`]/gi,
|
|
19
19
|
];
|
|
20
20
|
|
|
21
|
-
function walkCodeFiles(dir, files = []) {
|
|
22
|
-
|
|
21
|
+
function walkCodeFiles(dir, files = [], root = dir) {
|
|
22
|
+
// Nested repos and worktrees are separate projects; symlinks are not followed.
|
|
23
|
+
if (!existsSync(dir) || (dir !== root && existsSync(join(dir, ".git")))) return files;
|
|
23
24
|
for (const entry of readdirSync(dir)) {
|
|
24
25
|
if (SKIP.has(entry)) continue;
|
|
25
26
|
const full = join(dir, entry);
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
const st = lstatSync(full);
|
|
28
|
+
if (st.isSymbolicLink()) continue;
|
|
29
|
+
if (st.isDirectory()) {
|
|
30
|
+
walkCodeFiles(full, files, root);
|
|
28
31
|
} else if (CODE_EXT.test(entry)) {
|
|
29
32
|
files.push(full);
|
|
30
33
|
}
|
package/lib/whitebox/sbom.js
CHANGED
|
@@ -8,16 +8,20 @@
|
|
|
8
8
|
* Supports npm (package-lock.json, then package.json) and Python
|
|
9
9
|
* (requirements.txt). Resolved lockfile versions are preferred; where only a
|
|
10
10
|
* range is known it is recorded as-is and marked.
|
|
11
|
+
*
|
|
12
|
+
* `buildAiBom` (lib/whitebox/aibom.js) extends this into an AI-BOM: AI SDKs,
|
|
13
|
+
* model identifiers, MCP servers, skills and prompt files.
|
|
11
14
|
*/
|
|
12
15
|
import { existsSync, readFileSync } from "node:fs";
|
|
13
16
|
import { join } from "node:path";
|
|
17
|
+
import { classifyLicense, licenseField } from "../deps/license.js";
|
|
14
18
|
|
|
15
19
|
function cleanRange(range) {
|
|
16
20
|
// Strip a leading range operator for a best-effort version; keep the rest.
|
|
17
21
|
return String(range).replace(/^[\^~>=<\s]+/, "").trim() || String(range);
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
function purl(ecosystem, name, version) {
|
|
24
|
+
export function purl(ecosystem, name, version) {
|
|
21
25
|
return `pkg:${ecosystem}/${encodeURIComponent(name)}@${encodeURIComponent(version)}`;
|
|
22
26
|
}
|
|
23
27
|
|
|
@@ -28,12 +32,12 @@ function parseNpmLock(lock) {
|
|
|
28
32
|
if (k === "" || !v || !v.version) continue; // "" is the root project
|
|
29
33
|
const name = k.split("node_modules/").pop();
|
|
30
34
|
if (!name) continue;
|
|
31
|
-
comps.push({ name, version: v.version, scope: v.dev ? "optional" : "required", resolved: true });
|
|
35
|
+
comps.push({ name, version: v.version, scope: v.dev ? "optional" : "required", resolved: true, license: v.license || null, path: k });
|
|
32
36
|
}
|
|
33
37
|
} else if (lock.dependencies) { // lockfile v1
|
|
34
38
|
const walk = (deps) => {
|
|
35
39
|
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 });
|
|
40
|
+
if (v?.version) comps.push({ name, version: v.version, scope: v.dev ? "optional" : "required", resolved: true, license: null, path: `node_modules/${name}` });
|
|
37
41
|
if (v?.dependencies) walk(v.dependencies);
|
|
38
42
|
}
|
|
39
43
|
};
|
|
@@ -65,6 +69,24 @@ function parseRequirements(text) {
|
|
|
65
69
|
return comps;
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
/** CycloneDX `licenses`: one SPDX id, an SPDX expression, or a free-text name. */
|
|
73
|
+
function cdxLicenses(raw) {
|
|
74
|
+
if (!raw) return undefined;
|
|
75
|
+
const c = classifyLicense(raw);
|
|
76
|
+
if (c.kind === "spdx" && c.tree.type === "license" && !c.tree.plus && !c.tree.exception && !c.tree.ref) return [{ license: { id: c.tree.id } }];
|
|
77
|
+
if (c.kind === "spdx") return [{ expression: c.expression }];
|
|
78
|
+
return [{ license: { name: raw } }];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** An installed package's license, for lockfiles that do not record it (v1). */
|
|
82
|
+
function installedLicense(repoPath, path) {
|
|
83
|
+
try {
|
|
84
|
+
return licenseField(JSON.parse(readFileSync(join(repoPath, path, "package.json"), "utf-8")));
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
function dedupe(comps) {
|
|
69
91
|
const seen = new Set();
|
|
70
92
|
return comps.filter((c) => {
|
|
@@ -76,11 +98,11 @@ function dedupe(comps) {
|
|
|
76
98
|
}
|
|
77
99
|
|
|
78
100
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
101
|
+
* Collect a repo's declared dependencies from its manifests. Returns
|
|
102
|
+
* { components, sources, rootName, rootVersion } (sources is empty when no
|
|
103
|
+
* manifest was found). Shared by the SBOM and the AI-BOM.
|
|
82
104
|
*/
|
|
83
|
-
export function
|
|
105
|
+
export function collectDependencies(repoPath) {
|
|
84
106
|
let components = [];
|
|
85
107
|
let rootName = "application";
|
|
86
108
|
let rootVersion = "0.0.0";
|
|
@@ -100,7 +122,8 @@ export function buildSbom(repoPath, opts = {}) {
|
|
|
100
122
|
|
|
101
123
|
if (existsSync(lockPath)) {
|
|
102
124
|
try {
|
|
103
|
-
const npm = parseNpmLock(JSON.parse(readFileSync(lockPath, "utf-8")))
|
|
125
|
+
const npm = parseNpmLock(JSON.parse(readFileSync(lockPath, "utf-8")))
|
|
126
|
+
.map((c) => ({ ...c, ecosystem: "npm", license: c.license || installedLicense(repoPath, c.path) }));
|
|
104
127
|
if (npm.length) { components.push(...npm); sources.push("package-lock.json"); }
|
|
105
128
|
} catch { /* ignore */ }
|
|
106
129
|
}
|
|
@@ -117,9 +140,17 @@ export function buildSbom(repoPath, opts = {}) {
|
|
|
117
140
|
} catch { /* ignore */ }
|
|
118
141
|
}
|
|
119
142
|
|
|
120
|
-
|
|
143
|
+
return { components: dedupe(components), sources, rootName, rootVersion };
|
|
144
|
+
}
|
|
121
145
|
|
|
122
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Build a CycloneDX SBOM for a repo. Returns null if no manifest is found.
|
|
148
|
+
* @param {string} repoPath
|
|
149
|
+
* @param {object} opts - { serialNumber, timestamp } (injected for reproducibility/testing)
|
|
150
|
+
*/
|
|
151
|
+
export function buildSbom(repoPath, opts = {}) {
|
|
152
|
+
const { components, sources, rootName, rootVersion } = collectDependencies(repoPath);
|
|
153
|
+
if (sources.length === 0) return null;
|
|
123
154
|
|
|
124
155
|
return {
|
|
125
156
|
bomFormat: "CycloneDX",
|
|
@@ -137,8 +168,12 @@ export function buildSbom(repoPath, opts = {}) {
|
|
|
137
168
|
name: c.name,
|
|
138
169
|
version: c.version,
|
|
139
170
|
scope: c.scope,
|
|
171
|
+
licenses: cdxLicenses(c.license),
|
|
140
172
|
purl: purl(c.ecosystem || "npm", c.name, c.version),
|
|
141
173
|
properties: c.resolved ? undefined : [{ name: "penthera:version-note", value: "declared range, not a resolved version" }],
|
|
142
174
|
})),
|
|
143
175
|
};
|
|
144
176
|
}
|
|
177
|
+
|
|
178
|
+
// AI-BOM (CycloneDX 1.6 ML-BOM) lives next door; re-exported for one import path.
|
|
179
|
+
export { buildAiBom } from "./aibom.js";
|
package/lib/whitebox/secrets.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Scan repository source for hardcoded secrets.
|
|
3
3
|
*/
|
|
4
|
-
import { readdirSync, readFileSync,
|
|
4
|
+
import { readdirSync, readFileSync, lstatSync, existsSync } from "node:fs";
|
|
5
5
|
import { join, relative } from "node:path";
|
|
6
6
|
|
|
7
7
|
const SKIP_DIRS = new Set([
|
|
@@ -15,8 +15,10 @@ const SKIP_FILES = /\.(png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot|zip|tar|gz|p
|
|
|
15
15
|
// dummy/probe credentials that security tooling (Penthera included) hardcodes
|
|
16
16
|
// to test login endpoints, e.g. `password: "invalid-password-12345"`. Matched
|
|
17
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
|
-
|
|
18
|
+
// merely contains one of these substrings isn't suppressed. A value that is
|
|
19
|
+
// only a template placeholder (`"${apiKey}"`, `"{{ api_key }}"`) holds no
|
|
20
|
+
// secret either: it is filled in at runtime.
|
|
21
|
+
const TEST_VALUE = /\b(?:placeholder|example|changeme|dummy|sample|redacted|invalid|fake|test|your)\b|xxx|\*{3,}|^\$\{[^}]+\}$|^\{\{[^}]+\}\}$/i;
|
|
20
22
|
|
|
21
23
|
const SECRET_PATTERNS = [
|
|
22
24
|
{ name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/, severity: "critical" },
|
|
@@ -67,13 +69,34 @@ const SCAN_EXTENSIONS = new Set([
|
|
|
67
69
|
".ipynb", // notebooks are a classic place for a pasted provider key
|
|
68
70
|
]);
|
|
69
71
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
/** Would the repo scan read this file? `relPath` uses forward slashes (git paths). */
|
|
73
|
+
export function isSecretScanTarget(relPath) {
|
|
74
|
+
const parts = relPath.split("/");
|
|
75
|
+
if (parts.slice(0, -1).some((d) => SKIP_DIRS.has(d))) return false;
|
|
76
|
+
const entry = parts[parts.length - 1];
|
|
77
|
+
if (SKIP_FILES.test(entry)) return false;
|
|
78
|
+
const ext = entry.includes(".") ? entry.slice(entry.lastIndexOf(".")) : "";
|
|
79
|
+
return SCAN_EXTENSIONS.has(ext) || entry.startsWith(".env");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A subfolder with its own `.git` (a nested repo, submodule or worktree, such
|
|
83
|
+
// as the ones coding agents create under .claude/worktrees) is a separate
|
|
84
|
+
// project: scanning it would report its secrets as this repo's. Scan it on its
|
|
85
|
+
// own instead. Symlinked folders are not followed, so a link cannot pull files
|
|
86
|
+
// from outside the repo into the scan or loop forever.
|
|
87
|
+
export function isNestedRepo(dir, root) {
|
|
88
|
+
return dir !== root && existsSync(join(dir, ".git"));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function walkFiles(dir, files = [], root = dir) {
|
|
92
|
+
if (!existsSync(dir) || isNestedRepo(dir, root)) return files;
|
|
72
93
|
for (const entry of readdirSync(dir)) {
|
|
73
94
|
if (SKIP_DIRS.has(entry)) continue;
|
|
74
95
|
const full = join(dir, entry);
|
|
75
|
-
|
|
76
|
-
|
|
96
|
+
const st = lstatSync(full);
|
|
97
|
+
if (st.isSymbolicLink()) continue;
|
|
98
|
+
if (st.isDirectory()) {
|
|
99
|
+
walkFiles(full, files, root);
|
|
77
100
|
} else if (!SKIP_FILES.test(entry)) {
|
|
78
101
|
const ext = entry.includes(".") ? entry.slice(entry.lastIndexOf(".")) : "";
|
|
79
102
|
if (SCAN_EXTENSIONS.has(ext) || entry.startsWith(".env")) files.push(full);
|
|
@@ -82,7 +105,7 @@ function walkFiles(dir, files = []) {
|
|
|
82
105
|
return files;
|
|
83
106
|
}
|
|
84
107
|
|
|
85
|
-
function maskMatch(match) {
|
|
108
|
+
export function maskMatch(match) {
|
|
86
109
|
return match.length <= 8 ? "***" : match.slice(0, 4) + "…" + match.slice(-4);
|
|
87
110
|
}
|
|
88
111
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "penthera",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Open-source security for the apps and AI systems you ship. Check it before you deploy, vet what you build with (dependencies, MCP servers, skills), keep watching after you ship. Offline, deterministic, free.",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/danoszz/penthera.git"
|
|
@@ -49,10 +49,13 @@
|
|
|
49
49
|
"validate:report": "node skills/penthera/scripts/validate-report.mjs",
|
|
50
50
|
"package:skill": "bash scripts/package-skill.sh",
|
|
51
51
|
"refresh:popular": "node scripts/refresh-popular-packages.mjs",
|
|
52
|
+
"refresh:popular-pypi": "node scripts/refresh-popular-pypi.mjs",
|
|
52
53
|
"prepack": "npm test",
|
|
53
54
|
"mock-server": "node tests/fixtures/mock-server.js",
|
|
54
55
|
"bench": "node bench/run.js",
|
|
55
|
-
"
|
|
56
|
+
"docs:numbers": "node scripts/docs-numbers.mjs",
|
|
57
|
+
"refresh:retire-db": "node scripts/refresh-retire-db.mjs",
|
|
58
|
+
"audit:internal": "node scripts/audit/run.mjs"
|
|
56
59
|
},
|
|
57
60
|
"keywords": [
|
|
58
61
|
"security",
|
|
@@ -78,6 +81,8 @@
|
|
|
78
81
|
],
|
|
79
82
|
"license": "MIT",
|
|
80
83
|
"devDependencies": {
|
|
84
|
+
"ajv": "^8.20.0",
|
|
85
|
+
"fast-check": "^4.10.2",
|
|
81
86
|
"vitest": "^5.0.5"
|
|
82
87
|
},
|
|
83
88
|
"dependencies": {
|