penthera 1.4.0 → 1.5.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.
@@ -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
+ }
@@ -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
- * 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)
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 buildSbom(repoPath, opts = {}) {
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"))).map((c) => ({ ...c, ecosystem: "npm" }));
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
- if (sources.length === 0) return null;
143
+ return { components: dedupe(components), sources, rootName, rootVersion };
144
+ }
121
145
 
122
- components = dedupe(components);
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";
@@ -67,6 +67,16 @@ const SCAN_EXTENSIONS = new Set([
67
67
  ".ipynb", // notebooks are a classic place for a pasted provider key
68
68
  ]);
69
69
 
70
+ /** Would the repo scan read this file? `relPath` uses forward slashes (git paths). */
71
+ export function isSecretScanTarget(relPath) {
72
+ const parts = relPath.split("/");
73
+ if (parts.slice(0, -1).some((d) => SKIP_DIRS.has(d))) return false;
74
+ const entry = parts[parts.length - 1];
75
+ if (SKIP_FILES.test(entry)) return false;
76
+ const ext = entry.includes(".") ? entry.slice(entry.lastIndexOf(".")) : "";
77
+ return SCAN_EXTENSIONS.has(ext) || entry.startsWith(".env");
78
+ }
79
+
70
80
  function walkFiles(dir, files = []) {
71
81
  if (!existsSync(dir)) return files;
72
82
  for (const entry of readdirSync(dir)) {
@@ -82,7 +92,7 @@ function walkFiles(dir, files = []) {
82
92
  return files;
83
93
  }
84
94
 
85
- function maskMatch(match) {
95
+ export function maskMatch(match) {
86
96
  return match.length <= 8 ? "***" : match.slice(0, 4) + "…" + match.slice(-4);
87
97
  }
88
98
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "penthera",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
- "description": "The routine work of a security team, on your own machine: audits your app, dependencies, and AI tooling (skills, MCP, hooks), fixes and verifies, and writes the evidence. Offline, deterministic, free.",
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"
@@ -78,6 +78,7 @@
78
78
  ],
79
79
  "license": "MIT",
80
80
  "devDependencies": {
81
+ "ajv": "^8.17.1",
81
82
  "vitest": "^5.0.5"
82
83
  },
83
84
  "dependencies": {
@@ -1,11 +1,14 @@
1
1
  ---
2
2
  name: penthera
3
3
  description: >
4
- The routine work of a security team, run locally with the Penthera CLI. Scans the
4
+ Open-source security for the apps and AI systems the user ships, run locally with
5
+ the Penthera CLI. Scans the
5
6
  user's own live URL and repo (TLS, headers, auth, OpenAPI, secrets incl. AI keys,
6
7
  opt-in injection); checks npm dependencies offline ("is this dependency safe?",
7
- "what changed in my deps?", "install X safely"); audits AI tooling ("audit my MCP
8
- servers / skills / hooks", "check this skill before I install it"); fixes findings
8
+ "what changed in my deps?", "install X safely", "any AGPL in our deps?"); blocks
9
+ secrets and risky agent config before they are committed ("add a pre-commit hook");
10
+ audits AI tooling ("audit my MCP servers / skills / hooks", "check this skill
11
+ before I install it"); fixes findings
9
12
  on a local branch and re-scans to verify ("scan and fix my app"); and writes
10
13
  reports and evidence ("report for my auditor / manager", NIS2, SARIF, baseline
11
14
  diff). Use when the user asks to scan, audit, harden, fix, or review the security of
@@ -16,7 +19,7 @@ compatibility: Node.js 22+, network access to target, Penthera CLI (npm link or
16
19
  allowed-tools: "Bash(node:*) Bash(npm:*) Bash(penthera:*) Read Write Edit"
17
20
  metadata:
18
21
  author: danoszz
19
- version: 1.4.0
22
+ version: 1.5.0
20
23
  category: security
21
24
  ---
22
25
 
@@ -68,6 +71,8 @@ All examples below use `penthera`; substitute `node bin/penthera.js` when needed
68
71
  | Scan repo only (secrets, routes) | `penthera --repo . -o reports/repo-scan.json` |
69
72
  | Is this dependency safe? / what changed in my deps? | `penthera deps` or `penthera deps --base origin/main` (see **Dependency risk scan**) |
70
73
  | Install X safely / is it safe to add this package? | `penthera install <pkg> --dry-run`, then `penthera install <pkg>` (see **Install firewall**) |
74
+ | Are our dependency licenses OK? / any AGPL? | `penthera deps --license-policy permissive` (see **License policy**) |
75
+ | Add a pre-commit hook / stop me committing secrets | `penthera hook install` (see **Pre-commit hook**) |
71
76
  | URL + source combined | `penthera <url> --repo . -o reports/scan.json --sarif reports/scan.sarif` |
72
77
  | Scan and fix my app | **Workflow 4**: `penthera agent --loop` (scan, fix, verify, commit locally), then hand-fix what is left from [references/remediation.md](references/remediation.md) |
73
78
  | Compare to previous scan | `penthera <url> -o reports/scan.json --baseline reports/previous.json` |
@@ -296,6 +301,30 @@ penthera install <pkg> --yes # install only if nothing breaches the
296
301
  2. Exit 1 means blocked: the project is untouched. Never add a package to `allow` in `penthera.policy.json`, or approve install scripts, without the user saying so after seeing the evidence.
297
302
  3. If the user approves, run `penthera install <pkg>` in their terminal (the prompt is theirs to answer), or `--yes` after they have added an `allow` entry.
298
303
 
304
+ ## License policy
305
+
306
+ **Triggers:** "are our licenses OK?", "do we ship anything GPL / AGPL?", "license check before release", "add a license policy"
307
+
308
+ ```bash
309
+ penthera deps --license-policy permissive --json # presets: permissive, weak-copyleft-ok, no-agpl
310
+ ```
311
+
312
+ 1. Findings have category `license`: a breach or `UNLICENSED` is high; a missing, custom or non-SPDX license is medium.
313
+ 2. Without the flag, the `licenses` section of `penthera.policy.json` applies if present. Never add packages to `allowPackages` without the user's say-so; that is a legal decision, not a technical one.
314
+
315
+ ## Pre-commit hook
316
+
317
+ **Triggers:** "add a pre-commit hook", "stop me committing secrets / API keys", "check before I commit", "block bad MCP config in commits"
318
+
319
+ ```bash
320
+ penthera hook install # .git/hooks/pre-commit; an existing hook is kept and runs first
321
+ penthera hook run # check what is staged now (exit 1 = blocked)
322
+ ```
323
+
324
+ 1. It checks staged content only: secrets and AI provider keys, `penthera agents` rules on staged agent config, and the capability diff plus license policy when `package-lock.json` is staged. Blocks on high and critical by default (`hook.blockOn` in `penthera.policy.json`).
325
+ 2. If `core.hooksPath` is set (husky, lefthook), do not pass `--force`; add `npx --no-install penthera hook run` to that tool's pre-commit config instead.
326
+ 3. Never suggest `git commit --no-verify` to get past a finding unless the user asks how to skip it; suggest fixing it or a `penthera.suppress.json` entry with a reason.
327
+
299
328
  ## Examples
300
329
 
301
330
  **Example 1: Localhost quick check**