@readystack/cra-24-72-14-reporting-lint 1.0.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/LICENSE.txt ADDED
@@ -0,0 +1,26 @@
1
+ CRA 24/72/14 Reporting Lint — Licence
2
+
3
+ Free scope
4
+ Checking the file currently open in the editor is free for any use, personal or
5
+ commercial, for as long as you keep the extension installed. No registration, no
6
+ telemetry, no network call.
7
+
8
+ Full scope
9
+ The workspace sweep and the generated readiness report require a valid licence key.
10
+ One key covers one person, or one seat on a team. A key may be moved between machines
11
+ belonging to the same person or seat.
12
+
13
+ Refund
14
+ Seven days, in full, for any reason.
15
+
16
+ Restrictions
17
+ You may not redistribute, resell, sublicense or publish the extension package or the
18
+ rule table as your own product, and you may not remove or bypass the licence check.
19
+
20
+ No warranty
21
+ This software is provided "as is", without warranty of any kind, express or implied.
22
+ It is a document linter. It is not legal advice, not a conformity assessment, and not
23
+ a substitute for counsel. In no event shall the author be liable for any claim,
24
+ damages or other liability arising from the use of this software.
25
+
26
+ Copyright (c) 2026 ReadyStack. All rights reserved.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # CRA 24/72/14 Reporting Lint (Article 14)
2
+
3
+ Reads your SECURITY.md against the EU Cyber Resilience Act reporting clock that started on 11 September 2026 — 24 hours, 72 hours, 14 days, to ENISA and your coordinating CSIRT.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ npx @readystack/cra-24-72-14-reporting-lint file.md
9
+ ```
10
+
11
+ Node 18+. The same 18 rules as the VS Code extension, from a terminal or CI.
12
+
13
+ ## Free
14
+
15
+ - Checks the Markdown file you have open against all 18 rules, offline, with the article and the replacement line for every finding — no watermark, no counter, nothing withheld.
16
+ - `--rules` lists every rule
17
+
18
+ ## With a licence ($29 once)
19
+
20
+ - Sweeps every Markdown file in the workspace in one pass, works out which checks are answered nowhere in the repository rather than merely missing from one file, and writes one dated CRA-24-72-14-READINESS.md to hand to an auditor.
21
+
22
+ ```
23
+ @readystack/cra-24-72-14-reporting-lint --dir ./templates --report html --out report.html
24
+ ```
25
+
26
+ One hour of EU product-compliance consulting runs $150-250, and a first documentation review is rarely one hour.
27
+
28
+ ## Use from an AI agent (MCP)
29
+
30
+ Claude Code · Cursor · Windsurf · any MCP client - add to your MCP config:
31
+
32
+ ```json
33
+ { "mcpServers": { "cra-24-72-14-reporting-lint": { "command": "npx", "args": ["-y", "@readystack/cra-24-72-14-reporting-lint", "--mcp"] } } }
34
+ ```
35
+
36
+ Tools: `check_text` and `check_file` (free) · `check_dir` (licence; the full sweep is free for 7 days). The agent gets every finding with the line number.
37
+
38
+ ## Use in CI
39
+
40
+ ```yaml
41
+ - name: CRA 24/72/14 Reporting Lint (Article 14)
42
+ run: npx -y @readystack/cra-24-72-14-reporting-lint --dir . --ci
43
+ ```
44
+
45
+ (container: `docker run --rm -v "$PWD:/work" getreadystack/cra-24-72-14-reporting-lint --dir /work --ci`)
46
+
47
+ Try the full run free for 7 days — no key needed. Then one licence, 7-day refund, no questions. Set `READYSTACK_LICENSE=<key>` or run `--license <key>` once.
48
+
49
+ [Get a licence](https://buy.polar.sh/polar_cl_zmN08yKAf6Qz5WMiSQO9V9MxIRQN87VQEfurn03M3KT)
50
+
51
+
52
+ <!-- cra 24 72 14 reporting lint -->
package/cli.js ADDED
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ // cli.js — the same rules as the VS Code extension, run from a terminal or a container. Generated by adapters.py.
3
+ 'use strict';
4
+ const fs = require('fs'), path = require('path');
5
+ const RULES = require('./rules.json');
6
+ const S = require('./strings.json');
7
+ const lic = require('./license.js');
8
+ const ENGINE = require('./engine.js'); // s144 — scaffold products: the same engine the VS Code extension runs
9
+ const RULE_LIST = Array.isArray(ENGINE.RULES) ? ENGINE.RULES : (Array.isArray(RULES) ? RULES : []);
10
+ const RULE_N = ENGINE.RULE_COUNT || RULE_LIST.length;
11
+ function scan(text, file) {
12
+ const r = ENGINE.engine.check(String(text), { today: new Date().toISOString().slice(0, 10), path: file || '' });
13
+ return (r && r.findings || []).map((f) => ({ line: parseInt(f.line, 10) || 1, msg: String(f.msg || f.message || f.check || ''), fix: f.fix || null, sev: (String(f.sev || 'error').toLowerCase().startsWith('err') ? 'error' : 'warn') }));
14
+ }
15
+ function isText(p) { try { const b = fs.readFileSync(p); if (b.length > 2 * 1024 * 1024) return false; const s = b.subarray(0, 4096); for (const x of s) if (x === 0) return false; return true; } catch (e) { return false; } }
16
+ function walk(dir, exts, out) {
17
+ let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return out; }
18
+ for (const e of ents) {
19
+ if (e.name === 'node_modules' || e.name === '.git' || e.name.startsWith('.')) continue;
20
+ const p = path.join(dir, e.name);
21
+ if (e.isDirectory()) walk(p, exts, out);
22
+ else if ((!exts.length || exts.includes(path.extname(e.name).toLowerCase())) && isText(p)) out.push(p);
23
+ }
24
+ return out;
25
+ }
26
+ function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
27
+ function csvq(s) { return '"' + String(s == null ? '' : s).replace(/"/g, '""') + '"'; }
28
+ function render(rows, fmt) {
29
+ if (fmt === 'json') return JSON.stringify({ tool: S.name, rules: RULE_N, files: rows }, null, 1);
30
+ if (fmt === 'csv') { const o = ['file,line,severity,message,fix']; for (const r of rows) for (const h of r.hits) o.push([csvq(r.file), h.line, h.sev, csvq(h.msg), csvq(h.fix)].join(',')); return o.join('\n') + '\n'; }
31
+ if (fmt === 'html') {
32
+ const n = rows.reduce((a, r) => a + r.hits.length, 0);
33
+ let o = '<!doctype html><meta charset="utf-8"><title>' + esc(S.name) + ' report</title><style>body{font:14px system-ui;margin:24px}table{border-collapse:collapse}td,th{border:1px solid #ddd;padding:4px 8px;font-size:13px}code{font-family:ui-monospace,monospace}</style>';
34
+ o += '<h1>' + esc(S.name) + '</h1><p>' + rows.length + ' files · ' + n + ' findings · ' + RULE_N + ' rules</p><table><tr><th>file</th><th>line</th><th>sev</th><th>message</th><th>fix</th></tr>';
35
+ for (const r of rows) for (const h of r.hits) o += '<tr><td><code>' + esc(r.file) + '</code></td><td>' + h.line + '</td><td>' + esc(h.sev) + '</td><td>' + esc(h.msg) + '</td><td>' + esc(h.fix || '') + '</td></tr>';
36
+ return o + '</table>';
37
+ }
38
+ let o = ''; for (const r of rows) { o += r.file + '\n'; for (const h of r.hits) o += ' ' + String(h.line).padStart(5) + ' ' + h.sev.padEnd(5) + ' ' + h.msg + (h.fix ? '\n -> ' + h.fix : '') + '\n'; if (!r.hits.length) o += ' (no findings)\n'; }
39
+ return o;
40
+ }
41
+ function trialState() {
42
+ if (process.env.READYSTACK_NO_TRIAL) return { active: false };
43
+ try {
44
+ const p = path.join(path.dirname(lic.storePath()), S.bin + '.trial.json');
45
+ let t = null; try { t = JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) {}
46
+ if (!t || !t.until) { t = { until: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString().slice(0, 10) }; fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(t)); }
47
+ return { active: new Date(t.until + 'T23:59:59Z').getTime() > Date.now(), until: t.until };
48
+ } catch (e) { return { active: false }; }
49
+ }
50
+ function mcpServe() {
51
+ // s144 — MCP server over stdio (newline-delimited JSON-RPC · no dependencies). Free: check_text · check_file. Licence (7-day trial): check_dir.
52
+ const rl = require('readline').createInterface({ input: process.stdin });
53
+ const send = (o) => process.stdout.write(JSON.stringify(o) + '\n');
54
+ const tools = [
55
+ { name: 'check_text', description: S.name + ' - run all ' + RULE_N + ' checks on a text (free)', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'file contents' }, path: { type: 'string', description: 'optional file name for context' } }, required: ['text'] } },
56
+ { name: 'check_file', description: S.name + ' - run all checks on one file by path (free)', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } },
57
+ { name: 'check_dir', description: S.name + ' - sweep a folder and return every finding (licence; the full run is free for 7 days)', inputSchema: { type: 'object', properties: { dir: { type: 'string' }, ext: { type: 'string', description: 'optional extension filter, e.g. .html' } }, required: ['dir'] } }
58
+ ];
59
+ const result = (id, rows) => send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: render(rows, 'text') }], structuredContent: { tool: S.name, rules: RULE_N, files: rows } } });
60
+ const fail = (id, text) => send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }], isError: true } });
61
+ rl.on('line', async (line) => {
62
+ let m; try { m = JSON.parse(line); } catch (e) { return; }
63
+ const id = m.id, method = m.method;
64
+ if (method === 'initialize') return send({ jsonrpc: '2.0', id, result: { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: '@readystack/' + S.bin, version: '1.0.0' } } });
65
+ if (method === 'notifications/initialized' || method === 'ping') { if (id !== undefined) send({ jsonrpc: '2.0', id, result: {} }); return; }
66
+ if (method === 'tools/list') return send({ jsonrpc: '2.0', id, result: { tools } });
67
+ if (method === 'tools/call') {
68
+ const name = (m.params || {}).name, args = (m.params || {}).arguments || {};
69
+ try {
70
+ if (name === 'check_text') return result(id, [{ file: args.path || '(text)', hits: scan(String(args.text || ''), args.path || '') }]);
71
+ if (name === 'check_file') return result(id, [{ file: args.path, hits: scan(fs.readFileSync(args.path, 'utf8'), args.path) }]);
72
+ if (name === 'check_dir') {
73
+ const r = await lic.ensure();
74
+ if (!r.ok && !trialState().active) return fail(id, S.need_key + ' Get a licence ($' + S.price + ', once, 7-day refund): ' + lic.BUY_URL);
75
+ const files = walk(args.dir, args.ext ? [args.ext] : (S.exts || []), []);
76
+ return result(id, files.map((f) => { let t = ''; try { t = fs.readFileSync(f, 'utf8'); } catch (e) { return { file: f, hits: [], error: String(e.message) }; } return { file: f, hits: scan(t, f) }; }));
77
+ }
78
+ return send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'unknown tool ' + name } });
79
+ } catch (e) { return fail(id, String(e && e.message || e)); }
80
+ }
81
+ if (id !== undefined) send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'method not found: ' + method } });
82
+ });
83
+ }
84
+ function help() {
85
+ return [S.name + ' - ' + S.subtitle, '', 'Usage: ' + S.bin + ' <file> [more files] check the files you name (free, every rule)',
86
+ ' ' + S.bin + ' --dir <folder> [--ext .html] scan a whole folder (licence)',
87
+ ' ' + S.bin + ' ... --report csv|json|html [--out file] export a report (licence)',
88
+ ' ' + S.bin + ' ... --ci exit 1 when an error-level finding exists (licence)',
89
+ ' ' + S.bin + ' --license <key> store your licence key (or set READYSTACK_LICENSE)',
90
+ ' ' + S.bin + ' --rules list the ' + RULE_N + ' rules',
91
+ ' ' + S.bin + ' --mcp run as an MCP server (stdio) for Claude Code / Cursor / Windsurf - free checks, folder sweep needs a licence', '',
92
+ 'Free: ' + S.free, 'Licence ($' + S.price + ', once, 7-day refund): ' + S.paid, 'Get a licence: ' + lic.BUY_URL, ''].join('\n');
93
+ }
94
+ (async function main() {
95
+ try { const _feed = await lic.pullFeed(); if (_feed && Array.isArray(_feed.rules)) { if (Array.isArray(ENGINE.RULES)) for (const r of _feed.rules) ENGINE.RULES.push(r); } } catch (e) {} // ★s134 구독 피드 병합 (키 있는 손님만)
96
+ const a = process.argv.slice(2);
97
+ const get = (k) => { const i = a.indexOf(k); return i >= 0 ? a[i + 1] : null; };
98
+ if (a.includes('--mcp')) { mcpServe(); return; }
99
+ if (!a.length || a.includes('--help') || a.includes('-h')) { process.stdout.write(help()); return; }
100
+ if (a.includes('--rules')) { process.stdout.write((RULE_LIST.length ? RULE_LIST.map((r, i) => String(i + 1).padStart(3) + ' [' + (r.sev || 'warn') + '] ' + (r.message || r.msg || r.id || '')) : Array.from({ length: RULE_N }, (_, i) => String(i + 1).padStart(3) + ' [engine] check ' + (i + 1))).join('\n') + '\n'); return; }
101
+ if (a.includes('--license')) { const r = await lic.ensure(get('--license')); process.stdout.write(r.ok ? 'Licence stored: ' + lic.storePath() + '\n' : 'Licence not accepted (' + r.why + '). Get one: ' + lic.BUY_URL + '\n'); process.exit(r.ok ? 0 : 2); }
102
+ const dir = get('--dir'), fmt = get('--report'), out = get('--out'), ci = a.includes('--ci');
103
+ const exts = a.includes('--ext') ? [get('--ext')] : (S.exts || []);
104
+ const paid = !!(dir || fmt || ci);
105
+ if (paid) {
106
+ const r = await lic.ensure();
107
+ if (!r.ok) {
108
+ const t = trialState(); // s144 reverse trial: the full run is free for 7 days from the first paid use, then the key
109
+ if (t.active) process.stderr.write('Trial: the full run is free until ' + t.until + ' — after that $' + S.price + ' once (7-day refund). Get a licence: ' + lic.BUY_URL + '\n');
110
+ else { process.stderr.write(S.need_key + '\n set READYSTACK_LICENSE=<key> or ' + S.bin + ' --license <key>\n Get a licence ($' + S.price + ', once): ' + lic.BUY_URL + '\n'); process.exit(2); }
111
+ }
112
+ }
113
+ const files = dir ? walk(dir, exts, []) : a.filter((x, i) => !x.startsWith('--') && !['--dir', '--report', '--out', '--ext', '--license'].includes(a[i - 1]));
114
+ if (!files.length) { process.stderr.write('No files. ' + S.bin + ' --help\n'); process.exit(2); }
115
+ const rows = files.map((f) => { let t = ''; try { t = fs.readFileSync(f, 'utf8'); } catch (e) { return { file: f, hits: [], error: String(e.message) }; } return { file: f, hits: scan(t) }; });
116
+ const text = render(rows, fmt || 'text');
117
+ if (out) fs.writeFileSync(out, text); else process.stdout.write(text.endsWith('\n') ? text : text + '\n');
118
+ const errors = rows.reduce((n, r) => n + r.hits.filter((h) => h.sev === 'error').length, 0);
119
+ if (ci && errors) process.exit(1);
120
+ })().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(3); });
package/engine.js ADDED
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+ // CRA 24/72/14 Reporting Lint — the engine. The same bytes run in VS Code and in the free web page.
3
+ var RULES = (typeof module !== 'undefined' && module.exports && typeof require === 'function')
4
+ ? require('./rules.json')
5
+ : (typeof CRA_RULES !== 'undefined' ? CRA_RULES : []);
6
+
7
+ var TODAY_DEFAULT = '2026-09-11'; // the day Article 14 reporting started applying
8
+ var SUPPORT_MIN_YEARS = 5; // Art. 13(8) default expectation
9
+
10
+ function lineOf(text, index) {
11
+ var n = 1;
12
+ for (var i = 0; i < index && i < text.length; i++) if (text.charCodeAt(i) === 10) n++;
13
+ return n;
14
+ }
15
+
16
+ function ymd(s) {
17
+ var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s || ''));
18
+ if (!m) return null;
19
+ var y = +m[1], mo = +m[2], d = +m[3];
20
+ if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
21
+ return y * 10000 + mo * 100 + d;
22
+ }
23
+
24
+ function plusYears(n, years) {
25
+ return n + years * 10000;
26
+ }
27
+
28
+ function supportDates(text) {
29
+ var out = [];
30
+ var re = /(?:support period|end of support|end-of-support|supported until|security updates)[^\n]{0,90}?(\d{4}-\d{2}-\d{2})/gi;
31
+ var m;
32
+ while ((m = re.exec(text)) !== null) {
33
+ var v = ymd(m[1]);
34
+ if (v) out.push({ raw: m[1], val: v, line: lineOf(text, m.index) });
35
+ if (re.lastIndex === m.index) re.lastIndex++;
36
+ }
37
+ return out;
38
+ }
39
+
40
+ function finding(rule, line, extra) {
41
+ return {
42
+ check: rule.id,
43
+ sev: rule.sev,
44
+ line: line,
45
+ msg: extra ? rule.msg + ' ' + extra : rule.msg,
46
+ fix: rule.fix,
47
+ cite: rule.cite
48
+ };
49
+ }
50
+
51
+ function check(text, opts) {
52
+ opts = opts || {};
53
+ var src = String(text == null ? '' : text);
54
+ var today = ymd(opts.today) || ymd(TODAY_DEFAULT);
55
+ var lines = src.split(/\r?\n/);
56
+ var findings = [];
57
+ var dates = supportDates(src);
58
+
59
+ for (var r = 0; r < RULES.length; r++) {
60
+ var rule = RULES[r];
61
+ if (rule.kind === 'date') {
62
+ for (var d = 0; d < dates.length; d++) {
63
+ var dt = dates[d];
64
+ if (rule.mode === 'past' && dt.val < today) {
65
+ findings.push(finding(rule, dt.line, 'Declared end ' + dt.raw + ', today ' + (opts.today || TODAY_DEFAULT) + '.'));
66
+ } else if (rule.mode === 'short' && dt.val >= today && dt.val < plusYears(today, SUPPORT_MIN_YEARS)) {
67
+ findings.push(finding(rule, dt.line, 'Declared end ' + dt.raw + ', five years from today is ' + String(plusYears(today, SUPPORT_MIN_YEARS)).replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3') + '.'));
68
+ }
69
+ }
70
+ continue;
71
+ }
72
+ if (!rule.re) continue;
73
+ if (rule.kind === 'wrong') {
74
+ var re = new RegExp(rule.re, 'i');
75
+ for (var i = 0; i < lines.length; i++) if (re.test(lines[i])) findings.push(finding(rule, i + 1));
76
+ } else {
77
+ if (!(new RegExp(rule.re, 'i')).test(src)) findings.push(finding(rule, 0));
78
+ }
79
+ }
80
+
81
+ findings.sort(function (a, b) { return (a.line - b.line) || (a.check < b.check ? -1 : a.check > b.check ? 1 : 0); });
82
+ return {
83
+ findings: findings,
84
+ counted: RULES.length,
85
+ errors: findings.filter(function (f) { return f.sev === 'error'; }).length,
86
+ warnings: findings.filter(function (f) { return f.sev === 'warn'; }).length,
87
+ today: opts.today || TODAY_DEFAULT
88
+ };
89
+ }
90
+
91
+ var CRAENGINE = { engine: { check: check }, RULES: RULES, RULE_COUNT: RULES.length, VERSION: '1.0.0' };
92
+ if (typeof module !== 'undefined' && module.exports) module.exports = CRAENGINE;
93
+ else window.CRAENGINE = CRAENGINE;
package/license.js ADDED
@@ -0,0 +1,64 @@
1
+ // license.js — Polar licence check for the CLI / container. Generated by adapters.py; do not edit by hand.
2
+ 'use strict';
3
+ const https = require('https'), fs = require('fs'), path = require('path'), os = require('os');
4
+ const ORG_ID = 'a5cdf664-d8e7-4f87-8895-056717aaba17';
5
+ const BENEFIT_ID = 'd898f960-e6b2-457c-825f-53372843985c'; // s142 — this product's Polar benefit (s140 law: ask with benefit_id, or one key opens every product)
6
+ const BUY_URL = 'https://buy.polar.sh/polar_cl_zmN08yKAf6Qz5WMiSQO9V9MxIRQN87VQEfurn03M3KT';
7
+ const SLUG = 'cra-24-72-14-reporting-lint';
8
+ const GRACE_MS = 30 * 24 * 3600 * 1000; // after a successful check, 30 days work offline
9
+ const RECHECK_MS = 7 * 24 * 3600 * 1000; // re-ask Polar every 7 days (refunds / cancellations)
10
+ function storePath() { return path.join(process.env.READYSTACK_HOME || path.join(os.homedir(), '.config', 'readystack'), SLUG + '.json'); }
11
+ function load() { try { return JSON.parse(fs.readFileSync(storePath(), 'utf8')); } catch (e) { return {}; } }
12
+ function save(o) { try { fs.mkdirSync(path.dirname(storePath()), { recursive: true }); fs.writeFileSync(storePath(), JSON.stringify(o)); } catch (e) { /* read-only home: still works for this run */ } }
13
+ function validate(key) {
14
+ return new Promise(function (resolve) {
15
+ if (!ORG_ID) return resolve({ ok: false, offline: false });
16
+ const body = JSON.stringify(/^[0-9a-f-]{36}$/.test(BENEFIT_ID) ? { key: key, organization_id: ORG_ID, benefit_id: BENEFIT_ID } : { key: key, organization_id: ORG_ID });
17
+ const req = https.request({ hostname: 'api.polar.sh', path: '/v1/customer-portal/license-keys/validate', method: 'POST', timeout: 8000,
18
+ headers: { 'content-type': 'application/json', 'polar-version': '2026-04', 'content-length': Buffer.byteLength(body) } }, function (res) {
19
+ let buf = ''; res.on('data', function (d) { buf += d; });
20
+ res.on('end', function () {
21
+ if (res.statusCode !== 200) return resolve({ ok: false, offline: false });
22
+ try { const j = JSON.parse(buf); resolve({ ok: j && (j.status === 'granted' || j.valid === true || !!j.id), offline: false }); }
23
+ catch (e) { resolve({ ok: false, offline: false }); }
24
+ });
25
+ });
26
+ req.on('timeout', function () { req.destroy(); resolve({ ok: false, offline: true }); });
27
+ req.on('error', function () { resolve({ ok: false, offline: true }); });
28
+ req.write(body); req.end();
29
+ });
30
+ }
31
+ async function ensure(explicitKey) {
32
+ const st = load();
33
+ const key = explicitKey || process.env.READYSTACK_LICENSE || st.key;
34
+ if (!key) return { ok: false, why: 'no_key' };
35
+ const age = Date.now() - (st.okAt || 0);
36
+ if (!explicitKey && st.key === key && age < RECHECK_MS) return { ok: true, cached: true };
37
+ const r = await validate(String(key).trim());
38
+ if (r.ok) { save({ key: String(key).trim(), okAt: Date.now() }); return { ok: true }; }
39
+ if (r.offline && st.key === key && age < GRACE_MS) return { ok: true, offline: true };
40
+ return { ok: false, why: r.offline ? 'offline' : 'invalid' };
41
+ }
42
+ // ★s134 — 구독 규칙 피드(층3 "바뀌면 업데이트"): 키 있는 손님만 · 7일마다 · 오프라인은 캐시. 워커 GET /api/rules/<slug>?key=
43
+ const FEED_URL = 'https://getreadystack.com/api/rules/';
44
+ function pullFeed() {
45
+ const st = load(); const key = process.env.READYSTACK_LICENSE || st.key; const cached = st.feed || null;
46
+ if (!key) return Promise.resolve(cached);
47
+ if (cached && (Date.now() - (st.feedAt || 0)) < RECHECK_MS) return Promise.resolve(cached);
48
+ return new Promise(function (resolve) {
49
+ let req;
50
+ try {
51
+ req = https.get(FEED_URL + encodeURIComponent(SLUG) + '?key=' + encodeURIComponent(key), { timeout: 8000, headers: { 'user-agent': 'readystack-cli' } }, function (res) {
52
+ let buf = ''; res.on('data', function (d) { buf += d; });
53
+ res.on('end', function () {
54
+ if (res.statusCode !== 200) return resolve(cached);
55
+ try { const j = JSON.parse(buf); if (!j || !Array.isArray(j.rules)) return resolve(cached); save(Object.assign(load(), { feed: j, feedAt: Date.now() })); resolve(j); }
56
+ catch (e) { resolve(cached); }
57
+ });
58
+ });
59
+ } catch (e) { return resolve(cached); }
60
+ req.on('timeout', function () { req.destroy(); resolve(cached); });
61
+ req.on('error', function () { resolve(cached); });
62
+ });
63
+ }
64
+ module.exports = { ensure, BUY_URL, storePath, pullFeed };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@readystack/cra-24-72-14-reporting-lint",
3
+ "version": "1.0.0",
4
+ "description": "Reads your SECURITY.md against the EU Cyber Resilience Act reporting clock that started on 11 September 2026 — 24 hours, 72 hours, 14 days, to ENISA and your coordinating CSIRT.",
5
+ "license": "SEE LICENSE IN LICENSE.txt",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "keywords": [
10
+ "cyber resilience act",
11
+ "cra",
12
+ "article 14",
13
+ "vulnerability reporting",
14
+ "enisa",
15
+ "csirt",
16
+ "security.md",
17
+ "compliance"
18
+ ],
19
+ "homepage": "https://getreadystack.com",
20
+ "funding": "https://buy.polar.sh/polar_cl_zmN08yKAf6Qz5WMiSQO9V9MxIRQN87VQEfurn03M3KT",
21
+ "bin": {
22
+ "cra-24-72-14-reporting-lint": "cli.js"
23
+ },
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "files": [
28
+ "cli.js",
29
+ "license.js",
30
+ "rules.json",
31
+ "strings.json",
32
+ "README.md",
33
+ "LICENSE.txt",
34
+ "engine.js"
35
+ ]
36
+ }
package/rules.json ADDED
@@ -0,0 +1,166 @@
1
+ [
2
+ {
3
+ "id": "art14_no_24h_early_warning",
4
+ "kind": "missing",
5
+ "sev": "error",
6
+ "re": "(24[ -]?hours?|twenty-four hours)[^\\n]{0,90}(early warning|ENISA|CSIRT)|early warning[^\\n]{0,90}(24[ -]?hours?|twenty-four hours)",
7
+ "msg": "No 24-hour early warning step. The clock starts when you become aware, not when you finish triage.",
8
+ "fix": "Step 1 of the runbook: early warning to the coordinating CSIRT and ENISA within 24 hours of awareness.",
9
+ "cite": "CRA Art. 14(1)(a), Art. 14(4)(a)"
10
+ },
11
+ {
12
+ "id": "art14_no_72h_notification",
13
+ "kind": "missing",
14
+ "sev": "error",
15
+ "re": "(72[ -]?hours?|seventy-two hours)[^\\n]{0,90}(notification|notify|vulnerability|incident)|(notification|notify)[^\\n]{0,90}(72[ -]?hours?|seventy-two hours)",
16
+ "msg": "No 72-hour notification step. The early warning does not discharge the obligation on its own.",
17
+ "fix": "Step 2: vulnerability or incident notification within 72 hours, with severity, impact and any corrective measures taken.",
18
+ "cite": "CRA Art. 14(1)(b), Art. 14(4)(b)"
19
+ },
20
+ {
21
+ "id": "art14_no_14day_final_report",
22
+ "kind": "missing",
23
+ "sev": "error",
24
+ "re": "(14[ -]?days?|fourteen days)[^\\n]{0,110}(final report|corrective|remediat)|final report[^\\n]{0,110}(14[ -]?days?|fourteen days)",
25
+ "msg": "No final report step. The 14-day clock runs from the moment a corrective measure is available, not from disclosure.",
26
+ "fix": "Step 3: final report no later than 14 days after a corrective or mitigating measure is available.",
27
+ "cite": "CRA Art. 14(2)(c)"
28
+ },
29
+ {
30
+ "id": "art14_no_enisa",
31
+ "kind": "missing",
32
+ "sev": "error",
33
+ "re": "ENISA",
34
+ "msg": "ENISA is never named. Every Article 14 report goes to ENISA as well as to a CSIRT.",
35
+ "fix": "Name ENISA as a simultaneous recipient of the early warning, the notification and the final report.",
36
+ "cite": "CRA Art. 14(1)"
37
+ },
38
+ {
39
+ "id": "art14_no_csirt",
40
+ "kind": "missing",
41
+ "sev": "error",
42
+ "re": "CSIRT",
43
+ "msg": "No CSIRT named. Reports go to the CSIRT designated as coordinator for your main establishment in the Union.",
44
+ "fix": "Name the Member State and the coordinating CSIRT you report to, so the on-call engineer does not have to find it at 3am.",
45
+ "cite": "CRA Art. 14(1), Art. 14(7)"
46
+ },
47
+ {
48
+ "id": "art16_no_single_reporting_platform",
49
+ "kind": "missing",
50
+ "sev": "warn",
51
+ "re": "single reporting platform",
52
+ "msg": "The single reporting platform is not mentioned, so the runbook does not say where the report is actually filed.",
53
+ "fix": "State that reports are submitted through the single reporting platform and record the account that can file them.",
54
+ "cite": "CRA Art. 16"
55
+ },
56
+ {
57
+ "id": "art14_no_actively_exploited_trigger",
58
+ "kind": "missing",
59
+ "sev": "error",
60
+ "re": "actively exploited",
61
+ "msg": "The trigger is not defined. The duty attaches to an actively exploited vulnerability, not to every vulnerability you receive.",
62
+ "fix": "Define 'actively exploited' in the runbook and say who decides that a report has crossed the line.",
63
+ "cite": "CRA Art. 14(1), Art. 3(42)"
64
+ },
65
+ {
66
+ "id": "art14_no_severe_incident_trigger",
67
+ "kind": "missing",
68
+ "sev": "error",
69
+ "re": "severe incident",
70
+ "msg": "The second trigger is missing. A severe incident affecting the security of the product is reportable on the same clock.",
71
+ "fix": "Add the severe-incident trigger alongside the actively-exploited-vulnerability trigger.",
72
+ "cite": "CRA Art. 14(3), Art. 14(4)"
73
+ },
74
+ {
75
+ "id": "annex1_no_cvd_policy",
76
+ "kind": "missing",
77
+ "sev": "warn",
78
+ "re": "coordinated vulnerability disclosure|disclosure policy|CVD policy",
79
+ "msg": "No coordinated vulnerability disclosure policy is referenced, so reporters have no stated route in.",
80
+ "fix": "Link the coordinated vulnerability disclosure policy and say what a reporter can expect from you.",
81
+ "cite": "CRA Annex I Part II(5)"
82
+ },
83
+ {
84
+ "id": "annex1_no_sbom",
85
+ "kind": "missing",
86
+ "sev": "warn",
87
+ "re": "SBOM|software bill of materials|CycloneDX|SPDX",
88
+ "msg": "No SBOM is referenced. Without one you cannot answer 'which shipped versions contain the component' inside 72 hours.",
89
+ "fix": "Reference the SBOM location and format so the notification can list affected versions.",
90
+ "cite": "CRA Annex I Part II(1)"
91
+ },
92
+ {
93
+ "id": "annex2_no_contact_point",
94
+ "kind": "missing",
95
+ "sev": "error",
96
+ "re": "[\\w.+-]+@[\\w-]+\\.[a-z]{2,}|mailto:|https?://[^\\s)]+/(security|report|advisor)",
97
+ "msg": "No single point of contact. There is no address a finder or an authority can reach you at.",
98
+ "fix": "Publish one monitored address or form as the single point of contact for security reports.",
99
+ "cite": "CRA Annex II(4)"
100
+ },
101
+ {
102
+ "id": "art13_no_support_period_date",
103
+ "kind": "missing",
104
+ "sev": "error",
105
+ "re": "(support period|end of support|end-of-support|supported until|security updates)[^\\n]{0,90}\\d{4}-\\d{2}-\\d{2}",
106
+ "msg": "No support period end date in ISO form. A prose promise of 'ongoing support' is not a declared support period.",
107
+ "fix": "Write the support period end date as YYYY-MM-DD next to the words 'support period' or 'end of support'.",
108
+ "cite": "CRA Art. 13(8)"
109
+ },
110
+ {
111
+ "id": "wrong_start_date_2027",
112
+ "kind": "wrong",
113
+ "sev": "error",
114
+ "re": "(report|notif|article 14|obligation)[^\\n]{0,100}(december 2027|11 december 2027|2027-12-11|dec\\.? 2027|december 11,? 2027)",
115
+ "msg": "Wrong start date. The reporting obligation already applies; December 2027 is when the remaining obligations follow.",
116
+ "fix": "Correct the date to 11 September 2026 for reporting, and keep 11 December 2027 only for the other obligations.",
117
+ "cite": "CRA Art. 71(2)"
118
+ },
119
+ {
120
+ "id": "wrong_recipient",
121
+ "kind": "wrong",
122
+ "sev": "error",
123
+ "re": "(report|notify|notification|disclose)[^\\n]{0,80}(european commission|europol|local police|national police|data protection authority|supervisory authority)",
124
+ "msg": "Wrong recipient on the Article 14 path. This report does not go there.",
125
+ "fix": "Route the report to the coordinating CSIRT and ENISA. Keep any other authority in a separate, clearly labelled path.",
126
+ "cite": "CRA Art. 14(1)"
127
+ },
128
+ {
129
+ "id": "gdpr_clock_confusion",
130
+ "kind": "wrong",
131
+ "sev": "error",
132
+ "re": "(gdpr|general data protection)[^\\n]{0,90}(72|article 33)|article 33[^\\n]{0,70}72",
133
+ "msg": "Two different 72-hour clocks are being treated as one. The personal-data breach duty is a separate obligation with a separate recipient.",
134
+ "fix": "Split the runbook into two paths and say plainly that one incident can start both clocks at once.",
135
+ "cite": "CRA Art. 14 vs GDPR Art. 33"
136
+ },
137
+ {
138
+ "id": "response_window_in_days",
139
+ "kind": "wrong",
140
+ "sev": "warn",
141
+ "re": "(respond|reply|acknowledge|triage)[^\\n]{0,60}within\\s+\\d+\\s*(business\\s+)?(day|week|month)",
142
+ "msg": "A response window measured in days sits next to an obligation measured in hours. A reporter reading this will assume the slower number.",
143
+ "fix": "Keep your disclosure-timeline promise, but state the 24/72/14 clock separately so neither is mistaken for the other.",
144
+ "cite": "CRA Art. 14(1)"
145
+ },
146
+ {
147
+ "id": "support_period_lapsed",
148
+ "kind": "date",
149
+ "mode": "past",
150
+ "sev": "error",
151
+ "re": "",
152
+ "msg": "The declared support period has already ended, while the product is still published.",
153
+ "fix": "Either extend the declared support period or state the withdrawal date for the affected versions.",
154
+ "cite": "CRA Art. 13(8)"
155
+ },
156
+ {
157
+ "id": "support_period_under_five_years",
158
+ "kind": "date",
159
+ "mode": "short",
160
+ "sev": "warn",
161
+ "re": "",
162
+ "msg": "The declared support period is shorter than five years from today.",
163
+ "fix": "Five years is the default expectation unless the product's expected lifetime is genuinely shorter. If it is, say so and say why.",
164
+ "cite": "CRA Art. 13(8)"
165
+ }
166
+ ]
package/strings.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "CRA 24/72/14 Reporting Lint (Article 14)",
3
+ "subtitle": "Reads your SECURITY.md against the EU Cyber Resilience Act reporting clock that started on 11 September 2026 — 24 hours, 72 hours, 14 days, to ENISA and your coordinating CSIRT.",
4
+ "bin": "cra-24-72-14-reporting-lint",
5
+ "price": 29,
6
+ "free": "Checks the Markdown file you have open against all 18 rules, offline, with the article and the replacement line for every finding — no watermark, no counter, nothing withheld.",
7
+ "paid": "Sweeps every Markdown file in the workspace in one pass, works out which checks are answered nowhere in the repository rather than merely missing from one file, and writes one dated CRA-24-72-14-READINESS.md to hand to an auditor.",
8
+ "need_key": "This option needs a licence (CRA 24/72/14 Reporting Lint (Article 14)).",
9
+ "exts": [
10
+ ".md"
11
+ ]
12
+ }