dw-kit 1.3.0 → 1.3.5

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,198 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { parsePackageLockfile, findLockfile } from './sc-scanner.mjs';
5
+
6
+ const SECURITY_DIR = '.dw/security';
7
+ const SNAPSHOT_FILE = 'advisory-snapshot.json';
8
+ const SCHEMA_VERSION = '1.0';
9
+ const OSV_BATCH_ENDPOINT = 'https://api.osv.dev/v1/querybatch';
10
+ const STALE_DAYS_DEFAULT = 7;
11
+ const FETCH_TIMEOUT_MS = 15000;
12
+
13
+ export function snapshotPath(rootDir = process.cwd()) {
14
+ return join(rootDir, SECURITY_DIR, SNAPSHOT_FILE);
15
+ }
16
+
17
+ export function loadSnapshot(rootDir = process.cwd()) {
18
+ const p = snapshotPath(rootDir);
19
+ if (!existsSync(p)) return null;
20
+ try {
21
+ const raw = readFileSync(p, 'utf-8');
22
+ return JSON.parse(raw);
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export function snapshotAgeDays(snapshot) {
29
+ if (!snapshot || !snapshot.fetched_at) return Infinity;
30
+ const fetched = new Date(snapshot.fetched_at).getTime();
31
+ if (!fetched) return Infinity;
32
+ return (Date.now() - fetched) / (1000 * 60 * 60 * 24);
33
+ }
34
+
35
+ export function isStale(snapshot, maxDays = STALE_DAYS_DEFAULT) {
36
+ return snapshotAgeDays(snapshot) > maxDays;
37
+ }
38
+
39
+ export function isSchemaCompatible(snapshot) {
40
+ if (!snapshot || !snapshot.schema_version) return false;
41
+ return snapshot.schema_version === SCHEMA_VERSION;
42
+ }
43
+
44
+ function ensureSecurityDir(rootDir) {
45
+ const dir = join(rootDir, SECURITY_DIR);
46
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
47
+ return dir;
48
+ }
49
+
50
+ export function saveSnapshot(snapshot, rootDir = process.cwd()) {
51
+ ensureSecurityDir(rootDir);
52
+ const target = snapshotPath(rootDir);
53
+
54
+ const body = JSON.stringify(snapshot, null, 2) + '\n';
55
+ const sha = createHash('sha256').update(body).digest('hex');
56
+ snapshot.snapshot_sha = `sha256:${sha.slice(0, 16)}`;
57
+
58
+ const finalBody = JSON.stringify(snapshot, null, 2) + '\n';
59
+ writeFileSync(target, finalBody, 'utf-8');
60
+ return target;
61
+ }
62
+
63
+ export async function fetchOsvBatch(queries, { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
64
+ const controller = new AbortController();
65
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
66
+ try {
67
+ const res = await fetch(OSV_BATCH_ENDPOINT, {
68
+ method: 'POST',
69
+ headers: { 'content-type': 'application/json' },
70
+ body: JSON.stringify({ queries }),
71
+ signal: controller.signal,
72
+ });
73
+ if (!res.ok) throw new Error(`OSV batch HTTP ${res.status}`);
74
+ return await res.json();
75
+ } finally {
76
+ clearTimeout(timer);
77
+ }
78
+ }
79
+
80
+ export async function fetchOsvByName(packageName, ecosystem = 'npm', { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
81
+ const controller = new AbortController();
82
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
83
+ try {
84
+ const res = await fetch('https://api.osv.dev/v1/query', {
85
+ method: 'POST',
86
+ headers: { 'content-type': 'application/json' },
87
+ body: JSON.stringify({ package: { name: packageName, ecosystem } }),
88
+ signal: controller.signal,
89
+ });
90
+ if (!res.ok) throw new Error(`OSV name-query HTTP ${res.status}`);
91
+ return await res.json();
92
+ } finally {
93
+ clearTimeout(timer);
94
+ }
95
+ }
96
+
97
+ export async function fetchOsvDetail(vulnId, { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
98
+ const controller = new AbortController();
99
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
100
+ try {
101
+ const res = await fetch(`https://api.osv.dev/v1/vulns/${vulnId}`, { signal: controller.signal });
102
+ if (!res.ok) throw new Error(`OSV detail HTTP ${res.status}`);
103
+ return await res.json();
104
+ } finally {
105
+ clearTimeout(timer);
106
+ }
107
+ }
108
+
109
+ export async function syncSnapshotForProject(rootDir = process.cwd(), { ecosystem = 'npm' } = {}) {
110
+ const lockPath = findLockfile(rootDir);
111
+ if (!lockPath) {
112
+ const err = new Error('No lockfile found (expected package-lock.json)');
113
+ err.code = 'NO_LOCKFILE';
114
+ throw err;
115
+ }
116
+
117
+ const packages = parsePackageLockfile(lockPath);
118
+ const queries = [];
119
+ const queryIndex = [];
120
+ for (const [name, version] of packages) {
121
+ queries.push({ package: { name, ecosystem }, version });
122
+ queryIndex.push({ name, version });
123
+ }
124
+
125
+ if (queries.length === 0) {
126
+ const empty = buildEmptySnapshot(ecosystem);
127
+ saveSnapshot(empty, rootDir);
128
+ return { snapshot: empty, advisoryCount: 0, packageCount: 0 };
129
+ }
130
+
131
+ const batchResults = await fetchOsvBatch(queries);
132
+ const vulnIds = new Set();
133
+ if (batchResults && Array.isArray(batchResults.results)) {
134
+ for (const r of batchResults.results) {
135
+ if (r && Array.isArray(r.vulns)) {
136
+ for (const v of r.vulns) if (v.id) vulnIds.add(v.id);
137
+ }
138
+ }
139
+ }
140
+
141
+ const advisories = [];
142
+ for (const id of vulnIds) {
143
+ try {
144
+ const detail = await fetchOsvDetail(id);
145
+ if (detail) advisories.push(detail);
146
+ } catch {
147
+ // skip — do not fail entire sync on single detail failure
148
+ }
149
+ }
150
+
151
+ const snapshot = {
152
+ schema_version: SCHEMA_VERSION,
153
+ fetched_at: new Date().toISOString(),
154
+ source: 'osv.dev',
155
+ ecosystem,
156
+ package_count: queryIndex.length,
157
+ advisory_count: advisories.length,
158
+ advisories,
159
+ };
160
+
161
+ saveSnapshot(snapshot, rootDir);
162
+ return { snapshot, advisoryCount: advisories.length, packageCount: queryIndex.length };
163
+ }
164
+
165
+ function buildEmptySnapshot(ecosystem) {
166
+ return {
167
+ schema_version: SCHEMA_VERSION,
168
+ fetched_at: new Date().toISOString(),
169
+ source: 'osv.dev',
170
+ ecosystem,
171
+ package_count: 0,
172
+ advisory_count: 0,
173
+ advisories: [],
174
+ };
175
+ }
176
+
177
+ export function snapshotInfo(rootDir = process.cwd()) {
178
+ const p = snapshotPath(rootDir);
179
+ if (!existsSync(p)) {
180
+ return { exists: false };
181
+ }
182
+ const stat = statSync(p);
183
+ const snap = loadSnapshot(rootDir);
184
+ return {
185
+ exists: true,
186
+ path: p,
187
+ mtimeMs: stat.mtimeMs,
188
+ fetched_at: snap?.fetched_at || null,
189
+ source: snap?.source || null,
190
+ ecosystem: snap?.ecosystem || null,
191
+ schema_version: snap?.schema_version || null,
192
+ advisory_count: snap?.advisory_count ?? (Array.isArray(snap?.advisories) ? snap.advisories.length : 0),
193
+ package_count: snap?.package_count ?? 0,
194
+ age_days: snap ? snapshotAgeDays(snap) : Infinity,
195
+ stale: snap ? isStale(snap) : true,
196
+ schema_compatible: isSchemaCompatible(snap),
197
+ };
198
+ }
@@ -62,11 +62,17 @@ export function summarize(events) {
62
62
  const bySkill = {};
63
63
  const byHook = {};
64
64
  const byTask = {};
65
+ const bySupplyChain = { scan_run: 0, block: 0, allow: 0, sync: 0 };
65
66
 
66
67
  for (const e of events) {
67
68
  if (e.event === 'skill') bySkill[e.name] = (bySkill[e.name] || 0) + 1;
68
69
  else if (e.event === 'hook') byHook[e.name] = (byHook[e.name] || 0) + 1;
69
70
  else if (e.event === 'task') byTask[e.action || 'unknown'] = (byTask[e.action || 'unknown'] || 0) + 1;
71
+ else if (e.event === 'sc_guard') {
72
+ const action = e.action || e.name || 'scan_run';
73
+ if (bySupplyChain[action] === undefined) bySupplyChain[action] = 0;
74
+ bySupplyChain[action]++;
75
+ }
70
76
  }
71
77
 
72
78
  return {
@@ -74,6 +80,7 @@ export function summarize(events) {
74
80
  bySkill,
75
81
  byHook,
76
82
  byTask,
83
+ bySupplyChain,
77
84
  dateRange:
78
85
  events.length > 0 ? { from: events[0].ts, to: events[events.length - 1].ts } : null,
79
86
  };