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,312 @@
1
+ /**
2
+ * `penthera hook`: a git pre-commit hook that runs the checks that matter most
3
+ * before code leaves the laptop, on staged content only.
4
+ *
5
+ * - secrets in staged files (lib/whitebox/secrets.js, AI provider keys included)
6
+ * - `penthera agents` rules on staged agent config (.mcp.json, CLAUDE.md, skills/, ...)
7
+ * - a staged package-lock.json: the capability diff against HEAD's lockfile,
8
+ * plus the license policy when penthera.policy.json has one
9
+ *
10
+ * Everything is read from the git index (what will be committed), never the
11
+ * working tree, so a half-staged file is judged by its staged half. Offline,
12
+ * no telemetry. Findings use the standard shape, so ids match a full scan and
13
+ * penthera.suppress.json works here too.
14
+ */
15
+ import { execFileSync, spawnSync } from "node:child_process";
16
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join, posix, resolve } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { findSecretsInText, isSecretScanTarget, maskMatch } from "../whitebox/secrets.js";
21
+ import { loadPolicy } from "../install/policy.js";
22
+ import { loadRepoSuppressions } from "../memory/repo-suppress.js";
23
+ import { findingId } from "../memory/views.js";
24
+ import { fingerprintFinding } from "../audit-loop.js";
25
+ import { globToRegExp, isAtLeast, SEVERITIES } from "./policy.js";
26
+
27
+ export const HOOK_MARKER = "# penthera-hook";
28
+ export const BACKUP_NAME = "pre-commit.pre-penthera";
29
+ const MAX_BLOB_BYTES = 2 * 1024 * 1024;
30
+
31
+ // ── git plumbing ──────────────────────────────────────────────────────────
32
+
33
+ function git(cwd, args, opts = {}) {
34
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024, ...opts });
35
+ }
36
+
37
+ export function repoRoot(cwd) {
38
+ try {
39
+ return git(cwd, ["rev-parse", "--show-toplevel"]).trim();
40
+ } catch {
41
+ throw new Error(`Not inside a git repository: ${cwd}`);
42
+ }
43
+ }
44
+
45
+ /** Paths added, copied, modified or renamed in the index (what this commit adds). */
46
+ export function stagedFiles(root) {
47
+ return git(root, ["diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR", "--no-renames"]).split("\0").filter(Boolean);
48
+ }
49
+
50
+ /** Read index blobs in one `git cat-file --batch` call. Returns Map(path → Buffer); large files are skipped. */
51
+ export function readStaged(root, paths) {
52
+ const out = new Map();
53
+ const wanted = paths.filter((p) => !p.includes("\n"));
54
+ if (!wanted.length) return out;
55
+ const r = spawnSync("git", ["cat-file", "--batch"], { cwd: root, input: wanted.map((p) => `:${p}\n`).join(""), maxBuffer: 1024 * 1024 * 1024 });
56
+ if (r.status !== 0) throw new Error(`git cat-file failed: ${String(r.stderr).trim()}`);
57
+ const buf = r.stdout;
58
+ let pos = 0;
59
+ for (const p of wanted) {
60
+ const nl = buf.indexOf(10, pos);
61
+ if (nl === -1) break;
62
+ const header = buf.subarray(pos, nl).toString();
63
+ pos = nl + 1;
64
+ const m = /^\S+ (\w+) (\d+)$/.exec(header);
65
+ if (!m) continue; // "<name> missing"
66
+ const size = Number(m[2]);
67
+ if (m[1] === "blob" && size <= MAX_BLOB_BYTES) out.set(p, buf.subarray(pos, pos + size));
68
+ pos += size + 1;
69
+ }
70
+ return out;
71
+ }
72
+
73
+ // ── Checks ────────────────────────────────────────────────────────────────
74
+
75
+ function lineOf(text, index) {
76
+ let n = 1;
77
+ for (let i = 0; i < index; i++) if (text.charCodeAt(i) === 10) n++;
78
+ return n;
79
+ }
80
+
81
+ function checkSecrets(files, blobs) {
82
+ const findings = [];
83
+ for (const path of files) {
84
+ if (!isSecretScanTarget(path)) continue;
85
+ const buf = blobs.get(path);
86
+ if (!buf || buf.subarray(0, 8192).includes(0)) continue;
87
+ const text = buf.toString("utf-8");
88
+ for (const hit of findSecretsInText(text)) {
89
+ // Same title/url/category/source as the repo scan, so finding ids match.
90
+ findings.push({
91
+ severity: hit.severity,
92
+ title: `${hit.name} in source`,
93
+ description: `Possible secret in \`${path}\`: \`${maskMatch(hit.match)}\``,
94
+ url: path,
95
+ line: lineOf(text, text.indexOf(hit.match)),
96
+ evidence: maskMatch(hit.match),
97
+ category: "secrets",
98
+ source: "secret-scan",
99
+ });
100
+ }
101
+ }
102
+ return findings;
103
+ }
104
+
105
+ const AGENT_FILES = new Set(["AGENTS.md", "CLAUDE.md", "CLAUDE.local.md", "GEMINI.md", ".cursorrules", ".windsurfrules", ".mcp.json", ".github/copilot-instructions.md", ".vscode/mcp.json"]);
106
+ const AGENT_DIRS = [".claude/", ".cursor/", ".agents/", ".codex/", ".clinerules/", ".claude-plugin/", ".github/instructions/", "skills/", "plugins/"];
107
+
108
+ /** Is this repo path agent configuration that `penthera agents` reads? */
109
+ export function isAgentConfig(path) {
110
+ return AGENT_FILES.has(path) || AGENT_DIRS.some((d) => path.startsWith(d));
111
+ }
112
+
113
+ /**
114
+ * Agent rules on the staged view of the agent config. The whole agent config
115
+ * in the index is checked out to a temp folder (a skill is judged as a folder,
116
+ * not file by file); only findings that touch a staged path are kept.
117
+ */
118
+ async function checkAgents(root, staged) {
119
+ const touched = staged.filter(isAgentConfig);
120
+ if (!touched.length) return { findings: [], files: 0 };
121
+ const all = git(root, ["ls-files", "-z", "--cached"]).split("\0").filter((p) => p && isAgentConfig(p));
122
+ const tmp = realpathSync(mkdtempSync(join(tmpdir(), "penthera-hook-")));
123
+ try {
124
+ const r = spawnSync("git", ["checkout-index", "-z", "--stdin", `--prefix=${tmp}/`], { cwd: root, input: all.join("\0") });
125
+ if (r.status !== 0) throw new Error(`git checkout-index failed: ${String(r.stderr).trim()}`);
126
+ const { scanAgents } = await import("../agents/index.js");
127
+ const result = await scanAgents(tmp, { lockPath: join(tmp, ".penthera-hook-no-lock") });
128
+ const relates = (url) => touched.some((s) => url === s || s.startsWith(`${url}/`) || url.startsWith(`${s}/`));
129
+ const findings = result.findings
130
+ .filter((f) => !String(f.templateId).startsWith("agents/lock-") && relates(f.url))
131
+ .map((f) => {
132
+ const m = /\(line (\d+)\)/.exec(f.description || "");
133
+ return m ? { ...f, line: Number(m[1]) } : f;
134
+ });
135
+ return { findings, files: touched.length };
136
+ } finally {
137
+ rmSync(tmp, { recursive: true, force: true });
138
+ }
139
+ }
140
+
141
+ const EMPTY_LOCK = { lockfileVersion: 3, packages: {} };
142
+
143
+ /** Capability diff (and license policy) for each staged lockfile, against HEAD. */
144
+ async function checkLockfiles(root, staged, blobs, { licensePolicy }) {
145
+ const locks = staged.filter((p) => ["package-lock.json", "npm-shrinkwrap.json"].includes(posix.basename(p)));
146
+ if (!locks.length) return { findings: [], files: 0 };
147
+ const { scanDeps } = await import("../deps/index.js");
148
+ const findings = [];
149
+ for (const path of locks) {
150
+ const buf = blobs.get(path);
151
+ if (!buf) continue;
152
+ let lock;
153
+ try { lock = JSON.parse(buf.toString("utf-8")); } catch (e) {
154
+ findings.push({ severity: "high", title: `${path} is not valid JSON`, description: e.message, url: path, category: "dependency-risk", source: "deps-scan" });
155
+ continue;
156
+ }
157
+ let baseLock = EMPTY_LOCK;
158
+ try { baseLock = JSON.parse(git(root, ["show", `HEAD:${path}`])); } catch { /* new lockfile or first commit */ }
159
+ const dir = posix.dirname(path);
160
+ const result = await scanDeps(join(root, dir), { lock, baseLock, baseLabel: "HEAD", changedOnly: true, licensePolicy });
161
+ const prefix = dir === "." ? "" : `${dir}/`;
162
+ for (const f of result.findings) findings.push({ ...f, url: f.url ? `${prefix}${f.url}` : path });
163
+ }
164
+ return { findings, files: locks.length };
165
+ }
166
+
167
+ // ── Run ───────────────────────────────────────────────────────────────────
168
+
169
+ function suppressedBy(entries, f) {
170
+ if (!entries.length) return null;
171
+ const id = findingId(f);
172
+ const fp = fingerprintFinding(f);
173
+ return entries.find((e) => e.id === id || e.fingerprint === fp) || null;
174
+ }
175
+
176
+ const bySeverity = (a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity) || String(a.url).localeCompare(String(b.url)) || (a.line || 0) - (b.line || 0);
177
+
178
+ /**
179
+ * Check what is staged. Never writes to the repo.
180
+ * @param {string} cwd anywhere inside the repo
181
+ * @param {{ policyPath?: string, now?: string }} opts
182
+ * @returns {Promise<{ root, staged, checked, blocking, below, suppressed, blockOn, policyPath, warnings, duration }>}
183
+ */
184
+ export async function runHook(cwd, opts = {}) {
185
+ const started = Date.now();
186
+ const root = repoRoot(cwd);
187
+ const { policy, path: policyPath } = loadPolicy(root, opts.policyPath || null);
188
+ const { blockOn, checks, ignore } = policy.hook;
189
+ const ignored = ignore.map(globToRegExp);
190
+ const staged = stagedFiles(root).filter((p) => !ignored.some((re) => re.test(p)));
191
+
192
+ const wantBlobs = staged.filter((p) => (checks.secrets && isSecretScanTarget(p)) || /(^|\/)(package-lock|npm-shrinkwrap)\.json$/.test(p));
193
+ const blobs = readStaged(root, wantBlobs);
194
+
195
+ const secrets = checks.secrets ? checkSecrets(staged, blobs) : [];
196
+ const agents = checks.agents ? await checkAgents(root, staged) : { findings: [], files: 0 };
197
+ const deps = checks.deps ? await checkLockfiles(root, staged, blobs, { licensePolicy: checks.licenses ? policy.licenses : null }) : { findings: [], files: 0 };
198
+
199
+ const sup = loadRepoSuppressions(root, { now: opts.now });
200
+ const all = [...secrets, ...agents.findings, ...deps.findings].map((f) => ({ ...f, id: findingId(f) }));
201
+ const suppressed = [];
202
+ const kept = [];
203
+ for (const f of all) {
204
+ const s = suppressedBy(sup.entries, f);
205
+ if (s) suppressed.push({ ...f, suppression: { reason: s.reason } });
206
+ else kept.push(f);
207
+ }
208
+ kept.sort(bySeverity);
209
+ return {
210
+ root,
211
+ staged: staged.length,
212
+ checked: { secrets: checks.secrets ? staged.filter(isSecretScanTarget).length : 0, agents: agents.files, lockfiles: deps.files },
213
+ blockOn,
214
+ policyPath,
215
+ blocking: kept.filter((f) => isAtLeast(f.severity, blockOn)),
216
+ below: kept.filter((f) => !isAtLeast(f.severity, blockOn)),
217
+ suppressed,
218
+ warnings: sup.warnings,
219
+ duration: Date.now() - started,
220
+ };
221
+ }
222
+
223
+ /** One short "how to fix" line per finding. */
224
+ export function fixHint(f) {
225
+ if (f.category === "secrets") return "Load it from an env var or secret store and unstage the file; rotate it if it was ever pushed or shared.";
226
+ if (f.category === "license") return `Replace ${f.package}, or add it to "allowPackages" in the licenses section of penthera.policy.json.`;
227
+ if (f.category === "dependency-risk") return `Review it before committing the lockfile: \`penthera deps --base HEAD -v\` shows the evidence.`;
228
+ const first = String(f.description || "").replace(/\s*\[[^\]]*\]$/, "").replace(/\s*\(line \d+\)/, "").split(/(?<=\.)\s/)[0];
229
+ return first || "See `penthera agents` for details.";
230
+ }
231
+
232
+ // ── Install / uninstall ───────────────────────────────────────────────────
233
+
234
+ const BIN = fileURLToPath(new URL("../../bin/penthera.js", import.meta.url));
235
+ const sq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;
236
+
237
+ export function hookScript({ node = process.execPath, bin = BIN } = {}) {
238
+ return `#!/bin/sh
239
+ ${HOOK_MARKER}: pre-commit hook installed by \`penthera hook install\`.
240
+ # Remove it with \`penthera hook uninstall\`, which restores any hook it replaced.
241
+ # Skip it for one commit with \`git commit --no-verify\`.
242
+ hook_dir=$(dirname "$0")
243
+ if [ -x "$hook_dir/${BACKUP_NAME}" ]; then
244
+ "$hook_dir/${BACKUP_NAME}" "$@" || exit $?
245
+ fi
246
+ if [ -f ${sq(bin)} ] && [ -x ${sq(node)} ]; then
247
+ exec ${sq(node)} ${sq(bin)} hook run
248
+ elif command -v penthera >/dev/null 2>&1; then
249
+ exec penthera hook run
250
+ fi
251
+ echo "penthera: not found, pre-commit check skipped (npm install -g penthera, then penthera hook install)" >&2
252
+ exit 0
253
+ `;
254
+ }
255
+
256
+ /** Where git looks for hooks (honours core.hooksPath and worktrees). */
257
+ export function hooksDir(root) {
258
+ return resolve(root, git(root, ["rev-parse", "--git-path", "hooks"]).trim());
259
+ }
260
+
261
+ function hooksPathSetting(root) {
262
+ try { return git(root, ["config", "--get", "core.hooksPath"]).trim() || null; } catch { return null; }
263
+ }
264
+
265
+ const isOurs = (path) => existsSync(path) && readFileSync(path, "utf-8").includes(HOOK_MARKER);
266
+
267
+ /**
268
+ * Write .git/hooks/pre-commit. An existing hook that is not ours is kept as
269
+ * pre-commit.pre-penthera and runs first.
270
+ * @returns {{ path, chained: string|null, updated: boolean }}
271
+ */
272
+ export function installHook(cwd, { force = false, script = hookScript() } = {}) {
273
+ const root = repoRoot(cwd);
274
+ const custom = hooksPathSetting(root);
275
+ if (custom && !force) {
276
+ const err = new Error(`core.hooksPath is set to "${custom}" (husky, lefthook or similar manage hooks here). Add \`penthera hook run\` to that tool's pre-commit config instead, or rerun with --force to write ${join(custom, "pre-commit")}.`);
277
+ err.code = "HOOKS_PATH";
278
+ throw err;
279
+ }
280
+ const dir = hooksDir(root);
281
+ const path = join(dir, "pre-commit");
282
+ const backup = join(dir, BACKUP_NAME);
283
+ let updated = false;
284
+ if (existsSync(path)) {
285
+ if (isOurs(path)) updated = true;
286
+ else if (existsSync(backup)) throw new Error(`Both ${path} and ${backup} exist. Merge them by hand, then run \`penthera hook install\` again.`);
287
+ else renameSync(path, backup);
288
+ }
289
+ mkdirSync(dir, { recursive: true });
290
+ writeFileSync(path, script);
291
+ chmodSync(path, 0o755);
292
+ return { path, chained: existsSync(backup) ? backup : null, updated };
293
+ }
294
+
295
+ /**
296
+ * Remove our hook and put back the one it replaced.
297
+ * @returns {{ path, removed: boolean, restored: string|null }}
298
+ */
299
+ export function uninstallHook(cwd) {
300
+ const root = repoRoot(cwd);
301
+ const dir = hooksDir(root);
302
+ const path = join(dir, "pre-commit");
303
+ const backup = join(dir, BACKUP_NAME);
304
+ if (!existsSync(path)) return { path, removed: false, restored: null };
305
+ if (!isOurs(path)) throw new Error(`${path} was not installed by penthera; left untouched.`);
306
+ unlinkSync(path);
307
+ if (existsSync(backup)) {
308
+ renameSync(backup, path);
309
+ return { path, removed: true, restored: path };
310
+ }
311
+ return { path, removed: true, restored: null };
312
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The `hook` section of penthera.policy.json: what `penthera hook run` checks
3
+ * and what blocks a commit.
4
+ *
5
+ * "hook": {
6
+ * "blockOn": "high", // lowest severity that blocks: critical | high | medium | low
7
+ * "checks": { "secrets": true, "agents": true, "deps": true, "licenses": true },
8
+ * "ignore": ["tests/fixtures/**"] // staged paths the hook skips (globs)
9
+ * }
10
+ */
11
+ export const SEVERITIES = ["critical", "high", "medium", "low", "info"];
12
+ const CHECKS = ["secrets", "agents", "deps", "licenses"];
13
+
14
+ export const DEFAULT_HOOK_POLICY = Object.freeze({
15
+ blockOn: "high",
16
+ checks: Object.freeze(Object.fromEntries(CHECKS.map((c) => [c, true]))),
17
+ ignore: Object.freeze([]),
18
+ });
19
+
20
+ export function normalizeHookPolicy(raw, label = "hook") {
21
+ if (raw == null) return { ...DEFAULT_HOOK_POLICY, checks: { ...DEFAULT_HOOK_POLICY.checks }, ignore: [] };
22
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${label}: expected an object`);
23
+ const extra = Object.keys(raw).filter((k) => !["blockOn", "checks", "ignore"].includes(k));
24
+ if (extra.length) throw new Error(`${label}: unknown field(s) ${extra.join(", ")} (expected blockOn, checks, ignore)`);
25
+ const blockOn = raw.blockOn ?? "high";
26
+ if (!SEVERITIES.slice(0, 4).includes(blockOn)) throw new Error(`${label}: "blockOn" must be one of critical, high, medium, low`);
27
+ const checks = { ...DEFAULT_HOOK_POLICY.checks };
28
+ if (raw.checks != null) {
29
+ if (typeof raw.checks !== "object" || Array.isArray(raw.checks)) throw new Error(`${label}: "checks" must be an object`);
30
+ for (const [k, v] of Object.entries(raw.checks)) {
31
+ if (!CHECKS.includes(k)) throw new Error(`${label}: unknown check "${k}" (known: ${CHECKS.join(", ")})`);
32
+ if (typeof v !== "boolean") throw new Error(`${label}: checks.${k} must be true or false`);
33
+ checks[k] = v;
34
+ }
35
+ }
36
+ const ignore = raw.ignore ?? [];
37
+ if (!Array.isArray(ignore) || ignore.some((x) => typeof x !== "string" || !x.trim())) throw new Error(`${label}: "ignore" must be an array of path globs`);
38
+ return { blockOn, checks, ignore: ignore.map((x) => x.trim()) };
39
+ }
40
+
41
+ /** Git-style path glob: `**` crosses folders, `*` and `?` stay inside one; a bare name matches at any depth. */
42
+ export function globToRegExp(glob) {
43
+ let g = glob.replace(/^\.\//, "");
44
+ if (!g.includes("/")) g = `**/${g}`;
45
+ if (g.endsWith("/")) g += "**";
46
+ let re = "";
47
+ for (let i = 0; i < g.length; i++) {
48
+ const ch = g[i];
49
+ if (ch === "*" && g[i + 1] === "*") {
50
+ i++;
51
+ if (g[i + 1] === "/") { i++; re += "(?:.*/)?"; } else re += ".*";
52
+ } else if (ch === "*") re += "[^/]*";
53
+ else if (ch === "?") re += "[^/]";
54
+ else re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
55
+ }
56
+ return new RegExp(`^${re}$`);
57
+ }
58
+
59
+ export function isAtLeast(severity, threshold) {
60
+ const s = SEVERITIES.indexOf(severity);
61
+ return s !== -1 && s <= SEVERITIES.indexOf(threshold);
62
+ }
@@ -14,10 +14,16 @@
14
14
  * optionally pinned to one version with `@x.y.z`; an allowed package passes
15
15
  * both lists. A field left out keeps its default; a field given replaces it.
16
16
  * Everything here is pure: no files are read except the policy itself.
17
+ *
18
+ * The same file has two optional sections for other commands: `licenses`
19
+ * (penthera deps, lib/deps/license.js) and `hook` (penthera hook run,
20
+ * lib/hook/policy.js). Both come back as null / defaults when absent.
17
21
  */
18
22
  import { existsSync, readFileSync } from "node:fs";
19
23
  import { join } from "node:path";
20
24
  import { CAPABILITY_ORDER, CAPABILITIES } from "../deps/capabilities.js";
25
+ import { normalizeLicensePolicy } from "../deps/license.js";
26
+ import { normalizeHookPolicy } from "../hook/policy.js";
21
27
 
22
28
  export const POLICY_FILE = "penthera.policy.json";
23
29
 
@@ -41,9 +47,9 @@ export const DEFAULT_POLICY = Object.freeze({
41
47
  */
42
48
  export function normalizePolicy(raw, label = POLICY_FILE) {
43
49
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${label}: expected a JSON object`);
44
- const known = ["allow", "deny", "requireApproval", "$schema", "description"];
50
+ const known = ["allow", "deny", "requireApproval", "licenses", "hook", "$schema", "description"];
45
51
  const extra = Object.keys(raw).filter((k) => !known.includes(k));
46
- if (extra.length) throw new Error(`${label}: unknown field(s) ${extra.join(", ")} (expected allow, deny, requireApproval)`);
52
+ if (extra.length) throw new Error(`${label}: unknown field(s) ${extra.join(", ")} (expected allow, deny, requireApproval, licenses, hook)`);
47
53
  const out = {};
48
54
  for (const field of ["allow", "deny", "requireApproval"]) {
49
55
  const v = raw[field] ?? DEFAULT_POLICY[field];
@@ -56,6 +62,8 @@ export function normalizePolicy(raw, label = POLICY_FILE) {
56
62
  const bad = out[field].filter((id) => !RULE_IDS.includes(id));
57
63
  if (bad.length) throw new Error(`${label}: unknown capability in "${field}": ${bad.join(", ")}. Known: ${RULE_IDS.join(", ")}`);
58
64
  }
65
+ out.licenses = raw.licenses == null ? null : normalizeLicensePolicy(raw.licenses, `${label}: licenses`);
66
+ out.hook = normalizeHookPolicy(raw.hook, `${label}: hook`);
59
67
  return out;
60
68
  }
61
69
 
@@ -38,6 +38,15 @@
38
38
  "content": "Final report: detailed description, root cause, applied and ongoing mitigations, and any cross-border impact."
39
39
  }
40
40
  ],
41
+ "cra": {
42
+ "law": "Cyber Resilience Act, Regulation (EU) 2024/2847, Article 14 (reporting applies from 11 September 2026)",
43
+ "csirtCoordinator": "NCSC-NL (Nationaal Cyber Security Centrum)",
44
+ "csirtContact": "https://www.ncsc.nl/contact",
45
+ "srp": "https://portal.cra-srp.enisa.europa.eu/",
46
+ "timezone": "Europe/Amsterdam",
47
+ "source": "https://www.enisa.europa.eu/topics/product-security/vulnerability-services/eu-incident-response-and-cyber-crisis-management/single-reporting-platform-srp/list-of-csirts-designated-as-coordinators",
48
+ "note": "NCSC-NL is the Dutch CSIRT designated as coordinator on ENISA's list (checked 2026-10-19). It applies if your main establishment is in the Netherlands, i.e. where cybersecurity decisions about your products are predominantly taken (Art. 14(7))."
49
+ },
41
50
  "penalties": "Up to €10M or 2% of global annual turnover. Management can be held personally accountable.",
42
51
  "pointers": [
43
52
  "Determine whether you are an essential or important entity, and your sector's competent authority.",