@ktpartners/dgs-platform 3.0.4 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +115 -0
- package/README.md +8 -1
- package/agents/dgs-executor.md +124 -3
- package/agents/dgs-idea-researcher.md +447 -0
- package/agents/dgs-plan-checker.md +32 -0
- package/agents/dgs-planner.md +41 -8
- package/bin/install.js +44 -0
- package/commands/dgs/audit-milestone.md +2 -1
- package/commands/dgs/diff-report.md +124 -0
- package/commands/dgs/new-project.md +8 -21
- package/commands/dgs/package-scan.md +43 -0
- package/commands/dgs/research-idea.md +1 -0
- package/commands/dgs/switch-project.md +13 -0
- package/deliver-great-systems/bin/dgs-tools.cjs +120 -5
- package/deliver-great-systems/bin/lib/audit-tolerance.cjs +77 -0
- package/deliver-great-systems/bin/lib/audit-tolerance.test.cjs +101 -0
- package/deliver-great-systems/bin/lib/commands.cjs +311 -16
- package/deliver-great-systems/bin/lib/commands.test.cjs +115 -0
- package/deliver-great-systems/bin/lib/commit-verify.test.cjs +236 -0
- package/deliver-great-systems/bin/lib/config.cjs +41 -0
- package/deliver-great-systems/bin/lib/config.test.cjs +309 -0
- package/deliver-great-systems/bin/lib/core.cjs +7 -3
- package/deliver-great-systems/bin/lib/core.test.cjs +79 -1
- package/deliver-great-systems/bin/lib/fast-routing.cjs +199 -0
- package/deliver-great-systems/bin/lib/fast-routing.test.cjs +108 -0
- package/deliver-great-systems/bin/lib/final-commit-precondition.test.cjs +87 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/bundler-audit-gemfile.json +21 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-expected.md +186 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-runresult.json +235 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/govulncheck-import.json +3 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/npm-audit-v10.json +37 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-clean.json +3 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-vulns.json +77 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/pip-audit-requirements.json +28 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-lodash.json +30 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-workspaces.json +55 -0
- package/deliver-great-systems/bin/lib/frontmatter.cjs +1 -1
- package/deliver-great-systems/bin/lib/governance.cjs +211 -0
- package/deliver-great-systems/bin/lib/governance.test.cjs +339 -0
- package/deliver-great-systems/bin/lib/health-untracked-phase.test.cjs +269 -0
- package/deliver-great-systems/bin/lib/init.cjs +56 -27
- package/deliver-great-systems/bin/lib/init.test.cjs +212 -5
- package/deliver-great-systems/bin/lib/jobs.cjs +7 -4
- package/deliver-great-systems/bin/lib/milestone.cjs +101 -3
- package/deliver-great-systems/bin/lib/milestone.test.cjs +203 -0
- package/deliver-great-systems/bin/lib/package-adapters.cjs +530 -0
- package/deliver-great-systems/bin/lib/package-adapters.test.cjs +618 -0
- package/deliver-great-systems/bin/lib/package-ecosystems.cjs +350 -0
- package/deliver-great-systems/bin/lib/package-ecosystems.test.cjs +348 -0
- package/deliver-great-systems/bin/lib/package-runner.cjs +199 -0
- package/deliver-great-systems/bin/lib/package-runner.test.cjs +198 -0
- package/deliver-great-systems/bin/lib/package-scan-provenance.cjs +56 -0
- package/deliver-great-systems/bin/lib/package-scan-provenance.test.cjs +103 -0
- package/deliver-great-systems/bin/lib/package-scan-report.cjs +1140 -0
- package/deliver-great-systems/bin/lib/package-scan-report.test.cjs +1963 -0
- package/deliver-great-systems/bin/lib/package-scan-skill.cjs +96 -0
- package/deliver-great-systems/bin/lib/package-scan-skill.test.cjs +136 -0
- package/deliver-great-systems/bin/lib/package-scan.cjs +919 -0
- package/deliver-great-systems/bin/lib/package-scan.test.cjs +2147 -0
- package/deliver-great-systems/bin/lib/phase.cjs +18 -1
- package/deliver-great-systems/bin/lib/plan-number-validity.test.cjs +48 -0
- package/deliver-great-systems/bin/lib/projects.cjs +38 -3
- package/deliver-great-systems/bin/lib/projects.test.cjs +112 -2
- package/deliver-great-systems/bin/lib/quick.cjs +178 -23
- package/deliver-great-systems/bin/lib/quick.test.cjs +138 -4
- package/deliver-great-systems/bin/lib/repos.cjs +12 -12
- package/deliver-great-systems/bin/lib/review.cjs +1821 -0
- package/deliver-great-systems/bin/lib/state.cjs +7 -3
- package/deliver-great-systems/bin/lib/summary-frontmatter.cjs +54 -0
- package/deliver-great-systems/bin/lib/summary-frontmatter.test.cjs +78 -0
- package/deliver-great-systems/bin/lib/sweep-scope.test.cjs +263 -0
- package/deliver-great-systems/bin/lib/verify.cjs +118 -6
- package/deliver-great-systems/bin/lib/verify.test.cjs +82 -0
- package/deliver-great-systems/bin/lib/wave-0-template-rename.test.cjs +40 -0
- package/deliver-great-systems/bin/lib/worktrees.cjs +27 -1
- package/deliver-great-systems/bin/lib/worktrees.test.cjs +76 -0
- package/deliver-great-systems/references/agent-step-reliability.md +60 -0
- package/deliver-great-systems/references/conflict-resolution.md +4 -0
- package/deliver-great-systems/references/context-tiers.md +4 -0
- package/deliver-great-systems/references/package-scan-config.md +151 -0
- package/deliver-great-systems/references/questioning.md +0 -30
- package/deliver-great-systems/references/spec-review-loop.md +1 -2
- package/deliver-great-systems/references/workflow-conventions.md +29 -0
- package/deliver-great-systems/skills/dgs-tests/package-scan.md +44 -0
- package/deliver-great-systems/templates/REVIEW.md +35 -0
- package/deliver-great-systems/templates/VALIDATION.md +1 -1
- package/deliver-great-systems/templates/claude-md.md +11 -0
- package/deliver-great-systems/templates/package-scan-report.md +108 -0
- package/deliver-great-systems/templates/project.md +6 -170
- package/deliver-great-systems/templates/summary.md +3 -1
- package/deliver-great-systems/workflows/add-phase.md +5 -0
- package/deliver-great-systems/workflows/audit-milestone.md +66 -10
- package/deliver-great-systems/workflows/cancel-job.md +1 -1
- package/deliver-great-systems/workflows/codereview.md +103 -9
- package/deliver-great-systems/workflows/complete-milestone.md +26 -7
- package/deliver-great-systems/workflows/complete-quick.md +40 -2
- package/deliver-great-systems/workflows/discuss-phase.md +3 -2
- package/deliver-great-systems/workflows/execute-phase.md +89 -2
- package/deliver-great-systems/workflows/execute-plan.md +10 -1
- package/deliver-great-systems/workflows/help.md +51 -18
- package/deliver-great-systems/workflows/import-spec.md +65 -7
- package/deliver-great-systems/workflows/init-product.md +46 -152
- package/deliver-great-systems/workflows/new-milestone.md +115 -14
- package/deliver-great-systems/workflows/new-project.md +60 -331
- package/deliver-great-systems/workflows/package-scan.md +59 -0
- package/deliver-great-systems/workflows/plan-phase.md +79 -1
- package/deliver-great-systems/workflows/quick-complete.md +40 -2
- package/deliver-great-systems/workflows/quick.md +183 -10
- package/deliver-great-systems/workflows/research-idea.md +80 -142
- package/deliver-great-systems/workflows/run-job.md +21 -35
- package/deliver-great-systems/workflows/settings.md +13 -77
- package/deliver-great-systems/workflows/write-spec.md +9 -11
- package/hooks/dist/dgs-enforce-discipline.js +196 -0
- package/package.json +1 -1
- package/scripts/build-hooks.js +1 -0
|
@@ -0,0 +1,1140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package-scan-report.cjs -- Phase 151 report writer for /dgs:package-scan
|
|
3
|
+
*
|
|
4
|
+
* Consumes Phase 150's frozen `runScan` result shape and emits a markdown
|
|
5
|
+
* report with YAML frontmatter (programmatic consumption) + a human-readable
|
|
6
|
+
* body. Chooses the report's on-disk location via a three-tier cascade:
|
|
7
|
+
* active phase -> active milestone -> project root with timestamp.
|
|
8
|
+
*
|
|
9
|
+
* Exports:
|
|
10
|
+
* writePackageScanReport(cwd, runResult, opts) -- composes emit + render + resolve + write
|
|
11
|
+
* _emitYamlFrontmatter(payload) -- hand-rolled YAML emitter (round-trip-validated)
|
|
12
|
+
* _parseEmittedYaml(text) -- minimal parser (subset the emitter produces)
|
|
13
|
+
* _renderBody(runResult) -- pure markdown emitter
|
|
14
|
+
* _resolveReportPath(cwd, opts) -- cascade resolver
|
|
15
|
+
*
|
|
16
|
+
* Covers roadmap requirements: PKG-06, PKG-07, PKG-20, PKG-21.
|
|
17
|
+
*
|
|
18
|
+
* Pure module: no module-level state, no side effects except (a) the file
|
|
19
|
+
* write inside writePackageScanReport and (b) mkdirSync for the milestones/
|
|
20
|
+
* or project-root parent dir during path resolution (acceptable per research
|
|
21
|
+
* hard rule 7).
|
|
22
|
+
*/
|
|
23
|
+
'use strict';
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const { getPlanningRoot } = require('./paths.cjs');
|
|
27
|
+
const { getLocalConfigPath } = require('./config.cjs');
|
|
28
|
+
|
|
29
|
+
// ─── Canonical frontmatter constants ─────────────────────────────────────────
|
|
30
|
+
// Every findings[] entry emitted by this module carries these two constant
|
|
31
|
+
// fields so downstream consumers (Phase 152 skill plugin, Phase 153 gap
|
|
32
|
+
// planner) can detect + route on them without re-parsing severity strings.
|
|
33
|
+
//
|
|
34
|
+
// test_source: "package-scan"
|
|
35
|
+
// gap_type: "dependency-security"
|
|
36
|
+
const CANONICAL_TEST_SOURCE = 'package-scan';
|
|
37
|
+
const CANONICAL_GAP_TYPE = 'dependency-security';
|
|
38
|
+
|
|
39
|
+
// ─── Severity / licence normalisation (PKG-22 / PKG-23) ──────────────────────
|
|
40
|
+
// SEVERITY_MAP covers every raw severity string the six adapters emit. Keys
|
|
41
|
+
// are lowercased at lookup time; the map's own keys are pre-lowercased. The
|
|
42
|
+
// map resolves adapter dialects to exactly one canonical tier:
|
|
43
|
+
// critical|high|medium|low. Unknown or null inputs collapse to medium (see
|
|
44
|
+
// _collapseSeverity below) — a conservative default that prefers false
|
|
45
|
+
// positives over silently dropping a finding.
|
|
46
|
+
//
|
|
47
|
+
// Tool dialects covered (via lowercased lookup):
|
|
48
|
+
// - Snyk: critical | high | medium | low (canonical tiers, pass-through)
|
|
49
|
+
// - Bundler-Audit: High | Medium | Low (lowercases to canonical)
|
|
50
|
+
// - OSV: CRITICAL | HIGH | MODERATE | MEDIUM | LOW (database_specific.severity)
|
|
51
|
+
// - npm audit: critical | high | moderate | low | info
|
|
52
|
+
// - pip-audit: null (never emits severity)
|
|
53
|
+
// - govulncheck: null (never emits severity)
|
|
54
|
+
const SEVERITY_MAP = Object.freeze({
|
|
55
|
+
critical: 'critical',
|
|
56
|
+
high: 'high',
|
|
57
|
+
medium: 'medium',
|
|
58
|
+
low: 'low',
|
|
59
|
+
moderate: 'medium', // npm-audit + OSV MODERATE (lowered)
|
|
60
|
+
info: 'low', // npm-audit info-level
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// LICENCE_SEVERITY_MAP maps SPDX identifiers (lowercased) to the canonical
|
|
64
|
+
// severity tier. Used by _mapLicenceToSeverity for licence-finding synthesis
|
|
65
|
+
// when the Snyk adapter surfaces a `licence` field. Permissive licences (MIT,
|
|
66
|
+
// Apache-2.0, BSD-*, ISC, etc.) are intentionally absent from the map — the
|
|
67
|
+
// helper returns null for them and no licence finding is synthesised.
|
|
68
|
+
const LICENCE_SEVERITY_MAP = Object.freeze({
|
|
69
|
+
'gpl-3.0': 'high',
|
|
70
|
+
'gpl-3.0-only': 'high',
|
|
71
|
+
'gpl-3.0-or-later': 'high',
|
|
72
|
+
'agpl-3.0': 'high',
|
|
73
|
+
'agpl-3.0-only': 'high',
|
|
74
|
+
'agpl-3.0-or-later': 'high',
|
|
75
|
+
'lgpl-2.1': 'medium',
|
|
76
|
+
'lgpl-2.1-only': 'medium',
|
|
77
|
+
'lgpl-2.1-or-later': 'medium',
|
|
78
|
+
'lgpl-3.0': 'medium',
|
|
79
|
+
'lgpl-3.0-only': 'medium',
|
|
80
|
+
'lgpl-3.0-or-later': 'medium',
|
|
81
|
+
'mpl-1.1': 'medium',
|
|
82
|
+
'mpl-2.0': 'medium',
|
|
83
|
+
// Phase 153 PKG-34: SSPL classified as restrictive (high).
|
|
84
|
+
'sspl-1.0': 'high',
|
|
85
|
+
'sspl-1.0-only': 'high',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ─── YAML emitter ────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
const TOP_LEVEL_ORDER = Object.freeze([
|
|
91
|
+
// UAT Bug 2: snyk_org emitted between 'tool' and 'repos_scanned' so the
|
|
92
|
+
// resolved Snyk org UUID is auditable in every report frontmatter.
|
|
93
|
+
'type', 'date', 'tool', 'snyk_org', 'repos_scanned',
|
|
94
|
+
'critical', 'high', 'medium', 'low',
|
|
95
|
+
'duration', 'findings',
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
const FINDING_FIELD_ORDER = Object.freeze([
|
|
99
|
+
'id', 'test_source', 'gap_type', 'severity', 'resource_id',
|
|
100
|
+
'repo', 'manifest_path', 'title', 'description', 'remediation',
|
|
101
|
+
'reference', 'cve', 'cvss', 'dependency_chain', 'chain_available',
|
|
102
|
+
'direct_or_transitive', 'tool',
|
|
103
|
+
// Phase 153 PKG-33: optional plan provenance fields.
|
|
104
|
+
'introduced_in_commit', 'introduced_in_plan',
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
const STRING_FINDING_FIELDS = new Set([
|
|
108
|
+
'id', 'test_source', 'gap_type', 'severity', 'resource_id',
|
|
109
|
+
'repo', 'manifest_path', 'title', 'description', 'remediation',
|
|
110
|
+
'reference', 'cve', 'direct_or_transitive', 'tool',
|
|
111
|
+
// Phase 153 PKG-33: provenance fields are nullable strings.
|
|
112
|
+
'introduced_in_commit', 'introduced_in_plan',
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Emit a YAML string for a finding-array member. All non-null keys emit in
|
|
117
|
+
* FINDING_FIELD_ORDER. String scalars are double-quoted and escaped; newlines
|
|
118
|
+
* switch to block-scalar `|-` form. Null emits literal `null`; numbers/booleans
|
|
119
|
+
* unquoted. `dependency_chain` emits as either `null` or a nested block
|
|
120
|
+
* sequence. Throws on any disallowed value (function, circular, non-finding
|
|
121
|
+
* nested object).
|
|
122
|
+
*/
|
|
123
|
+
function _emitFindingEntry(entry, visited) {
|
|
124
|
+
if (!entry || typeof entry !== 'object') {
|
|
125
|
+
throw new Error('Finding must be a non-null object');
|
|
126
|
+
}
|
|
127
|
+
if (visited.has(entry)) throw new Error('Circular reference in finding');
|
|
128
|
+
visited.add(entry);
|
|
129
|
+
|
|
130
|
+
// First field uses the ` - ` prefix; remaining indent is 4 spaces.
|
|
131
|
+
const lines = [];
|
|
132
|
+
let first = true;
|
|
133
|
+
for (const key of FINDING_FIELD_ORDER) {
|
|
134
|
+
if (!(key in entry)) continue;
|
|
135
|
+
const value = entry[key];
|
|
136
|
+
const indent = first ? ' - ' : ' ';
|
|
137
|
+
if (key === 'dependency_chain') {
|
|
138
|
+
if (value === null || value === undefined) {
|
|
139
|
+
lines.push(indent + 'dependency_chain: null');
|
|
140
|
+
} else if (Array.isArray(value)) {
|
|
141
|
+
if (value.length === 0) {
|
|
142
|
+
lines.push(indent + 'dependency_chain: []');
|
|
143
|
+
} else {
|
|
144
|
+
lines.push(indent + 'dependency_chain:');
|
|
145
|
+
for (const link of value) {
|
|
146
|
+
if (typeof link !== 'string') {
|
|
147
|
+
throw new Error('dependency_chain elements must be strings');
|
|
148
|
+
}
|
|
149
|
+
lines.push(' - ' + _emitStringScalarInline(link));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
throw new Error('dependency_chain must be null or an array');
|
|
154
|
+
}
|
|
155
|
+
first = false;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (STRING_FINDING_FIELDS.has(key)) {
|
|
159
|
+
if (value === null || value === undefined) {
|
|
160
|
+
lines.push(indent + key + ': null');
|
|
161
|
+
} else if (typeof value === 'string') {
|
|
162
|
+
const rendered = _emitStringScalar(value, first ? 4 : 4);
|
|
163
|
+
if (rendered.block) {
|
|
164
|
+
lines.push(indent + key + ': |-');
|
|
165
|
+
for (const bl of rendered.lines) {
|
|
166
|
+
lines.push(' ' + bl);
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
lines.push(indent + key + ': ' + rendered.inline);
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
throw new Error('Field ' + key + ' must be a string or null, got ' + typeof value);
|
|
173
|
+
}
|
|
174
|
+
first = false;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// Number / boolean fields (cvss, chain_available).
|
|
178
|
+
if (key === 'cvss') {
|
|
179
|
+
if (value === null || value === undefined) {
|
|
180
|
+
lines.push(indent + 'cvss: null');
|
|
181
|
+
} else if (typeof value === 'number' && Number.isFinite(value)) {
|
|
182
|
+
lines.push(indent + 'cvss: ' + String(value));
|
|
183
|
+
} else {
|
|
184
|
+
throw new Error('cvss must be number or null, got ' + typeof value);
|
|
185
|
+
}
|
|
186
|
+
} else if (key === 'chain_available') {
|
|
187
|
+
if (typeof value !== 'boolean') {
|
|
188
|
+
throw new Error('chain_available must be boolean, got ' + typeof value);
|
|
189
|
+
}
|
|
190
|
+
lines.push(indent + 'chain_available: ' + String(value));
|
|
191
|
+
}
|
|
192
|
+
first = false;
|
|
193
|
+
}
|
|
194
|
+
visited.delete(entry);
|
|
195
|
+
return lines.join('\n');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Emit a string scalar inline (double-quoted). For use inside block-sequence
|
|
200
|
+
* entries where the value is known to be a single-line string.
|
|
201
|
+
*/
|
|
202
|
+
function _emitStringScalarInline(value) {
|
|
203
|
+
const rendered = _emitStringScalar(value, 4);
|
|
204
|
+
if (rendered.block) {
|
|
205
|
+
// Extremely unusual for a chain element; block form not supported here.
|
|
206
|
+
throw new Error('Block-scalar form unsupported in inline string context');
|
|
207
|
+
}
|
|
208
|
+
return rendered.inline;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Emit a string scalar. Returns either `{block: false, inline: '"..."'}` for
|
|
213
|
+
* single-line strings (double-quoted, with `"` and `\` escaped) or
|
|
214
|
+
* `{block: true, lines: ['line1', 'line2', ...]}` for multi-line strings
|
|
215
|
+
* (caller writes `|-` header + 2-space further indent).
|
|
216
|
+
*/
|
|
217
|
+
function _emitStringScalar(value) {
|
|
218
|
+
const s = String(value);
|
|
219
|
+
if (s.indexOf('\n') === -1) {
|
|
220
|
+
let escaped = '';
|
|
221
|
+
for (const ch of s) {
|
|
222
|
+
if (ch === '\\') escaped += '\\\\';
|
|
223
|
+
else if (ch === '"') escaped += '\\"';
|
|
224
|
+
else escaped += ch;
|
|
225
|
+
}
|
|
226
|
+
return { block: false, inline: '"' + escaped + '"' };
|
|
227
|
+
}
|
|
228
|
+
const rawLines = s.split('\n');
|
|
229
|
+
return { block: true, lines: rawLines };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Emit YAML frontmatter for the full payload (top-level fields + findings[]).
|
|
234
|
+
* Performs round-trip validation after emitting and throws if the parsed
|
|
235
|
+
* output does not deep-equal the input payload.
|
|
236
|
+
*/
|
|
237
|
+
function _emitYamlFrontmatter(payload) {
|
|
238
|
+
if (!payload || typeof payload !== 'object') {
|
|
239
|
+
throw new Error('Frontmatter payload must be a non-null object');
|
|
240
|
+
}
|
|
241
|
+
const lines = ['---'];
|
|
242
|
+
for (const key of TOP_LEVEL_ORDER) {
|
|
243
|
+
if (!(key in payload)) continue;
|
|
244
|
+
const value = payload[key];
|
|
245
|
+
if (key === 'findings') {
|
|
246
|
+
if (!Array.isArray(value)) {
|
|
247
|
+
throw new Error('findings must be an array');
|
|
248
|
+
}
|
|
249
|
+
if (value.length === 0) {
|
|
250
|
+
lines.push('findings: []');
|
|
251
|
+
} else {
|
|
252
|
+
lines.push('findings:');
|
|
253
|
+
const visited = new WeakSet();
|
|
254
|
+
for (const entry of value) {
|
|
255
|
+
lines.push(_emitFindingEntry(entry, visited));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (value === null || value === undefined) {
|
|
261
|
+
lines.push(key + ': null');
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (typeof value === 'string') {
|
|
265
|
+
const rendered = _emitStringScalar(value);
|
|
266
|
+
if (rendered.block) {
|
|
267
|
+
lines.push(key + ': |-');
|
|
268
|
+
for (const bl of rendered.lines) {
|
|
269
|
+
lines.push(' ' + bl);
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
lines.push(key + ': ' + rendered.inline);
|
|
273
|
+
}
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
277
|
+
lines.push(key + ': ' + String(value));
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (typeof value === 'boolean') {
|
|
281
|
+
lines.push(key + ': ' + String(value));
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (typeof value === 'function') {
|
|
285
|
+
throw new Error('Function values are not allowed in frontmatter payload (field: ' + key + ')');
|
|
286
|
+
}
|
|
287
|
+
if (typeof value === 'object') {
|
|
288
|
+
throw new Error('Unsupported nested object for field: ' + key);
|
|
289
|
+
}
|
|
290
|
+
throw new Error('Unsupported value type ' + typeof value + ' for field: ' + key);
|
|
291
|
+
}
|
|
292
|
+
lines.push('---');
|
|
293
|
+
const emitted = lines.join('\n') + '\n';
|
|
294
|
+
|
|
295
|
+
// Round-trip validation
|
|
296
|
+
let parsed;
|
|
297
|
+
try {
|
|
298
|
+
parsed = _parseEmittedYaml(emitted);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
throw new Error('YAML emitter round-trip failed: parse error — ' + (err && err.message ? err.message : String(err)));
|
|
301
|
+
}
|
|
302
|
+
if (!_deepEqual(parsed, payload)) {
|
|
303
|
+
throw new Error('YAML emitter round-trip failed: parsed output does not match input payload');
|
|
304
|
+
}
|
|
305
|
+
return emitted;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ─── YAML parser (minimal, matches emitter subset) ───────────────────────────
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Parse the YAML subset produced by _emitYamlFrontmatter. Handles:
|
|
312
|
+
* - top-level `key: <scalar>` with scalars being double-quoted strings,
|
|
313
|
+
* `null`, integers, floats, booleans, or `[]` (empty sequence).
|
|
314
|
+
* - block-scalar `|-` with 2-space-indented content lines.
|
|
315
|
+
* - `findings:` header followed by block-sequence entries (` - key: value`
|
|
316
|
+
* then ` key2: value2` or ` key2: |-\n line1\n line2`).
|
|
317
|
+
*/
|
|
318
|
+
function _parseEmittedYaml(text) {
|
|
319
|
+
if (typeof text !== 'string') throw new Error('text must be a string');
|
|
320
|
+
// Strip leading / trailing --- fences.
|
|
321
|
+
let body = text;
|
|
322
|
+
if (body.startsWith('---\n')) body = body.slice(4);
|
|
323
|
+
const closeIdx = body.indexOf('\n---');
|
|
324
|
+
if (closeIdx !== -1) body = body.slice(0, closeIdx);
|
|
325
|
+
const rawLines = body.split('\n');
|
|
326
|
+
// Remove trailing empty line if present.
|
|
327
|
+
if (rawLines.length > 0 && rawLines[rawLines.length - 1] === '') rawLines.pop();
|
|
328
|
+
|
|
329
|
+
const out = {};
|
|
330
|
+
let i = 0;
|
|
331
|
+
while (i < rawLines.length) {
|
|
332
|
+
const line = rawLines[i];
|
|
333
|
+
// Top-level lines: no leading whitespace.
|
|
334
|
+
if (/^[A-Za-z_]/.test(line)) {
|
|
335
|
+
const colonIdx = line.indexOf(':');
|
|
336
|
+
if (colonIdx === -1) throw new Error('Malformed line: ' + line);
|
|
337
|
+
const key = line.slice(0, colonIdx);
|
|
338
|
+
const rest = line.slice(colonIdx + 1);
|
|
339
|
+
if (key === 'findings') {
|
|
340
|
+
// Sequence
|
|
341
|
+
if (rest.trim() === '[]') {
|
|
342
|
+
out.findings = [];
|
|
343
|
+
i += 1;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (rest.trim() !== '') {
|
|
347
|
+
throw new Error('findings: expected [] or block sequence, got ' + rest);
|
|
348
|
+
}
|
|
349
|
+
// Parse block sequence entries
|
|
350
|
+
const entries = [];
|
|
351
|
+
i += 1;
|
|
352
|
+
while (i < rawLines.length && rawLines[i].startsWith(' -')) {
|
|
353
|
+
const entry = {};
|
|
354
|
+
// First line of entry
|
|
355
|
+
const firstLine = rawLines[i];
|
|
356
|
+
const firstRest = firstLine.slice(4); // strip " - "
|
|
357
|
+
const fcolon = firstRest.indexOf(':');
|
|
358
|
+
if (fcolon === -1) throw new Error('Malformed entry first line: ' + firstLine);
|
|
359
|
+
const fkey = firstRest.slice(0, fcolon);
|
|
360
|
+
const fvalRaw = firstRest.slice(fcolon + 1);
|
|
361
|
+
const firstResult = _parseScalarOrBlock(fvalRaw, rawLines, i + 1, ' ');
|
|
362
|
+
entry[fkey] = firstResult.value;
|
|
363
|
+
i = firstResult.nextIndex;
|
|
364
|
+
// Remaining lines for this entry start with " " (4 spaces) and are
|
|
365
|
+
// not the next sequence entry.
|
|
366
|
+
while (i < rawLines.length && rawLines[i].startsWith(' ') && !rawLines[i].startsWith(' - ')) {
|
|
367
|
+
const l = rawLines[i];
|
|
368
|
+
const inner = l.slice(4);
|
|
369
|
+
if (inner.startsWith('- ')) {
|
|
370
|
+
// Part of a nested sequence handled by _parseScalarOrBlock earlier.
|
|
371
|
+
i += 1;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const ccolon = inner.indexOf(':');
|
|
375
|
+
if (ccolon === -1) {
|
|
376
|
+
i += 1;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const ckey = inner.slice(0, ccolon);
|
|
380
|
+
const cvalRaw = inner.slice(ccolon + 1);
|
|
381
|
+
const parsed = _parseScalarOrBlock(cvalRaw, rawLines, i + 1, ' ');
|
|
382
|
+
entry[ckey] = parsed.value;
|
|
383
|
+
i = parsed.nextIndex;
|
|
384
|
+
}
|
|
385
|
+
entries.push(entry);
|
|
386
|
+
}
|
|
387
|
+
out.findings = entries;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
// Regular top-level key
|
|
391
|
+
const parsed = _parseScalarOrBlock(rest, rawLines, i + 1, ' ');
|
|
392
|
+
out[key] = parsed.value;
|
|
393
|
+
i = parsed.nextIndex;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
// Unexpected line structure; skip.
|
|
397
|
+
i += 1;
|
|
398
|
+
}
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Parse a scalar from `rest` (the slice after `key:`). If rest starts with
|
|
404
|
+
* ` |-`, collect indented continuation lines at `blockIndent` as a multi-line
|
|
405
|
+
* string. Returns `{value, nextIndex}` where nextIndex is the index of the
|
|
406
|
+
* first line AFTER this value's lines.
|
|
407
|
+
*/
|
|
408
|
+
function _parseScalarOrBlock(rest, rawLines, continueIdx, blockIndent) {
|
|
409
|
+
const trimmed = rest.trim();
|
|
410
|
+
if (trimmed === '|-') {
|
|
411
|
+
const bodyLines = [];
|
|
412
|
+
let i = continueIdx;
|
|
413
|
+
while (i < rawLines.length && rawLines[i].startsWith(blockIndent)) {
|
|
414
|
+
bodyLines.push(rawLines[i].slice(blockIndent.length));
|
|
415
|
+
i += 1;
|
|
416
|
+
}
|
|
417
|
+
return { value: bodyLines.join('\n'), nextIndex: i };
|
|
418
|
+
}
|
|
419
|
+
if (trimmed === '') {
|
|
420
|
+
// Possibly a nested sequence (e.g., dependency_chain:)
|
|
421
|
+
const seqIndent = blockIndent;
|
|
422
|
+
const seq = [];
|
|
423
|
+
let i = continueIdx;
|
|
424
|
+
while (i < rawLines.length && rawLines[i].startsWith(seqIndent + '- ')) {
|
|
425
|
+
const itemRaw = rawLines[i].slice(seqIndent.length + 2);
|
|
426
|
+
seq.push(_parseInlineScalar(itemRaw));
|
|
427
|
+
i += 1;
|
|
428
|
+
}
|
|
429
|
+
return { value: seq, nextIndex: i };
|
|
430
|
+
}
|
|
431
|
+
if (trimmed === '[]') {
|
|
432
|
+
return { value: [], nextIndex: continueIdx };
|
|
433
|
+
}
|
|
434
|
+
return { value: _parseInlineScalar(trimmed), nextIndex: continueIdx };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function _parseInlineScalar(s) {
|
|
438
|
+
if (s === 'null') return null;
|
|
439
|
+
if (s === 'true') return true;
|
|
440
|
+
if (s === 'false') return false;
|
|
441
|
+
if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') {
|
|
442
|
+
// unescape
|
|
443
|
+
const inner = s.slice(1, -1);
|
|
444
|
+
let out = '';
|
|
445
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
446
|
+
const ch = inner[i];
|
|
447
|
+
if (ch === '\\' && i + 1 < inner.length) {
|
|
448
|
+
const next = inner[i + 1];
|
|
449
|
+
if (next === '"') { out += '"'; i += 1; continue; }
|
|
450
|
+
if (next === '\\') { out += '\\'; i += 1; continue; }
|
|
451
|
+
out += ch;
|
|
452
|
+
} else {
|
|
453
|
+
out += ch;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
// Number?
|
|
459
|
+
if (/^-?\d+$/.test(s)) return parseInt(s, 10);
|
|
460
|
+
if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s);
|
|
461
|
+
// Fallthrough: treat as bare string (should not happen for emitter output).
|
|
462
|
+
return s;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ─── Deep equality (used for round-trip validation) ──────────────────────────
|
|
466
|
+
|
|
467
|
+
function _deepEqual(a, b) {
|
|
468
|
+
if (a === b) return true;
|
|
469
|
+
if (a === null || b === null) return a === b;
|
|
470
|
+
if (typeof a !== typeof b) return false;
|
|
471
|
+
if (typeof a !== 'object') return a === b;
|
|
472
|
+
if (Array.isArray(a)) {
|
|
473
|
+
if (!Array.isArray(b)) return false;
|
|
474
|
+
if (a.length !== b.length) return false;
|
|
475
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
476
|
+
if (!_deepEqual(a[i], b[i])) return false;
|
|
477
|
+
}
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
if (Array.isArray(b)) return false;
|
|
481
|
+
const aKeys = Object.keys(a);
|
|
482
|
+
const bKeys = Object.keys(b);
|
|
483
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
484
|
+
for (const k of aKeys) {
|
|
485
|
+
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
|
|
486
|
+
if (!_deepEqual(a[k], b[k])) return false;
|
|
487
|
+
}
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ─── Body renderer ───────────────────────────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
function _statusLabel(outcome) {
|
|
494
|
+
switch (outcome) {
|
|
495
|
+
case 'ok': return 'ok';
|
|
496
|
+
case 'no_manifests': return 'skipped (no manifests)';
|
|
497
|
+
case 'tool_failure': return 'tool failure';
|
|
498
|
+
case 'timeout': return 'timeout';
|
|
499
|
+
case 'no_native_tool_for_ecosystem': return 'no native tool';
|
|
500
|
+
case 'skipped': return 'skipped';
|
|
501
|
+
default: return outcome || 'unknown';
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function _collapseSeverity(raw) {
|
|
506
|
+
if (raw === null || raw === undefined) return 'medium';
|
|
507
|
+
const key = String(raw).toLowerCase();
|
|
508
|
+
return Object.prototype.hasOwnProperty.call(SEVERITY_MAP, key) ? SEVERITY_MAP[key] : 'medium';
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Map a raw SPDX licence identifier to a canonical severity tier. Returns null
|
|
513
|
+
* for permissive licences (MIT, Apache-2.0, BSD-*, ISC, etc.) or when the
|
|
514
|
+
* licence field is null/undefined. Used by _canonicalFindingsForAdapter to
|
|
515
|
+
* decide whether a Snyk finding should emit an extra licence-violation
|
|
516
|
+
* finding (PKG-23).
|
|
517
|
+
*/
|
|
518
|
+
function _mapLicenceToSeverity(licence) {
|
|
519
|
+
if (licence === null || licence === undefined) return null;
|
|
520
|
+
const key = String(licence).toLowerCase();
|
|
521
|
+
return Object.prototype.hasOwnProperty.call(LICENCE_SEVERITY_MAP, key)
|
|
522
|
+
? LICENCE_SEVERITY_MAP[key]
|
|
523
|
+
: null;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function _renderBody(runResult) {
|
|
527
|
+
const lines = ['# Package Scan Report', ''];
|
|
528
|
+
const rr = Array.isArray(runResult.repo_results) ? runResult.repo_results : [];
|
|
529
|
+
const findings = Array.isArray(runResult.findings) ? runResult.findings : [];
|
|
530
|
+
const diagnostics = Array.isArray(runResult.diagnostics) ? runResult.diagnostics : [];
|
|
531
|
+
|
|
532
|
+
// Empty repo_results case.
|
|
533
|
+
if (rr.length === 0) {
|
|
534
|
+
lines.push('## No targets scanned', '');
|
|
535
|
+
if (diagnostics.length > 0) {
|
|
536
|
+
for (const d of diagnostics) {
|
|
537
|
+
const msg = d.message || d.hint || d.kind || 'unknown diagnostic';
|
|
538
|
+
lines.push(`- **${d.kind || 'diagnostic'}:** ${msg}`);
|
|
539
|
+
}
|
|
540
|
+
} else {
|
|
541
|
+
lines.push('- No repos registered and product root has no manifests.');
|
|
542
|
+
}
|
|
543
|
+
lines.push('');
|
|
544
|
+
return lines.join('\n');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Summary table — Phase 153 PKG-32 adds the ".snyk policy" column.
|
|
548
|
+
lines.push('## Summary', '');
|
|
549
|
+
lines.push('| Repo | Ecosystem | Tool | .snyk policy | Critical | High | Medium | Low | Status |');
|
|
550
|
+
lines.push('|------|-----------|------|--------------|----------|------|--------|-----|--------|');
|
|
551
|
+
// Group findings by (repo, ecosystem) so we know per-row severity counts.
|
|
552
|
+
for (const row of rr) {
|
|
553
|
+
const repo = row.repo;
|
|
554
|
+
const eco = row.ecosystem || '—';
|
|
555
|
+
const tool = row.tool_used || '—';
|
|
556
|
+
const snykPolicy = row.has_snyk_policy === true ? 'yes'
|
|
557
|
+
: row.has_snyk_policy === false ? 'no' : '—';
|
|
558
|
+
const isOk = row.outcome === 'ok';
|
|
559
|
+
let critical = '—', high = '—', medium = '—', low = '—';
|
|
560
|
+
if (isOk) {
|
|
561
|
+
let c = 0, h = 0, m = 0, l = 0;
|
|
562
|
+
const rowFindings = Array.isArray(row.findings) ? row.findings : [];
|
|
563
|
+
for (const f of rowFindings) {
|
|
564
|
+
const s = _collapseSeverity(f.severity);
|
|
565
|
+
if (s === 'critical') c += 1;
|
|
566
|
+
else if (s === 'high') h += 1;
|
|
567
|
+
else if (s === 'medium') m += 1;
|
|
568
|
+
else if (s === 'low') l += 1;
|
|
569
|
+
}
|
|
570
|
+
critical = String(c); high = String(h); medium = String(m); low = String(l);
|
|
571
|
+
}
|
|
572
|
+
lines.push(`| ${repo} | ${eco} | ${tool} | ${snykPolicy} | ${critical} | ${high} | ${medium} | ${low} | ${_statusLabel(row.outcome)} |`);
|
|
573
|
+
}
|
|
574
|
+
lines.push('');
|
|
575
|
+
|
|
576
|
+
// Phase 153 PKG-32: per-repo suppression notes (when .snyk policy applied AND count > 0).
|
|
577
|
+
for (const row of rr) {
|
|
578
|
+
if (row.has_snyk_policy === true
|
|
579
|
+
&& typeof row.snyk_suppressions_count === 'number'
|
|
580
|
+
&& row.snyk_suppressions_count > 0) {
|
|
581
|
+
const n = row.snyk_suppressions_count;
|
|
582
|
+
const word = n === 1 ? 'vulnerability' : 'vulnerabilities';
|
|
583
|
+
lines.push(`> ${row.repo}: .snyk policy applied: ${n} ${word} suppressed.`);
|
|
584
|
+
lines.push('');
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Phase 153 PKG-30/34: Licence Compliance section (always rendered).
|
|
589
|
+
const aggToolForLicence = _aggregateToolString(runResult);
|
|
590
|
+
const licenceRoster = Array.isArray(runResult.licence_roster) ? runResult.licence_roster : [];
|
|
591
|
+
if (findings.length > 0 || licenceRoster.length > 0 || aggToolForLicence !== 'none') {
|
|
592
|
+
lines.push(_renderLicenceComplianceSection({ tool_agg: aggToolForLicence, licence_roster: licenceRoster }));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Phase 153 PKG-35: cross-repo dedup (body-only; YAML findings stay one-per-repo).
|
|
596
|
+
const groups = _groupFindingsByPackageVersion(findings);
|
|
597
|
+
const crossRepoSection = _renderCrossRepoSummary(groups);
|
|
598
|
+
if (crossRepoSection) lines.push(crossRepoSection);
|
|
599
|
+
// Phase 153 PKG-36: dependency version overlap.
|
|
600
|
+
const overlaps = _detectVersionOverlaps(findings, runResult.licence_roster);
|
|
601
|
+
const overlapSection = _renderVersionOverlap(overlaps);
|
|
602
|
+
if (overlapSection) lines.push(overlapSection);
|
|
603
|
+
|
|
604
|
+
// Severity sections (only when findings exist)
|
|
605
|
+
if (findings.length === 0) {
|
|
606
|
+
lines.push('## Findings', '');
|
|
607
|
+
lines.push(`No vulnerabilities found across ${rr.length} scanned repos.`);
|
|
608
|
+
lines.push('');
|
|
609
|
+
} else {
|
|
610
|
+
const severityOrder = ['critical', 'high', 'medium', 'low'];
|
|
611
|
+
const byTier = { critical: [], high: [], medium: [], low: [] };
|
|
612
|
+
for (const f of findings) {
|
|
613
|
+
const s = _collapseSeverity(f.severity);
|
|
614
|
+
byTier[s].push(f);
|
|
615
|
+
}
|
|
616
|
+
const titleCase = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low' };
|
|
617
|
+
for (const tier of severityOrder) {
|
|
618
|
+
const tierFindings = byTier[tier];
|
|
619
|
+
if (tierFindings.length === 0) continue;
|
|
620
|
+
lines.push(`## ${titleCase[tier]}`, '');
|
|
621
|
+
for (const f of tierFindings) {
|
|
622
|
+
const title = (f.vulnerability && f.vulnerability.title) || 'Untitled';
|
|
623
|
+
const pkg = f.package_name || 'unknown';
|
|
624
|
+
const ver = f.installed_version || '';
|
|
625
|
+
const headerSuffix = ver ? `${pkg}@${ver}` : pkg;
|
|
626
|
+
lines.push(`### ${f.repo}: ${headerSuffix} — ${title}`);
|
|
627
|
+
const cve = (f.vulnerability && f.vulnerability.cve) || null;
|
|
628
|
+
const cvss = (typeof f.cvss_score === 'number') ? f.cvss_score : null;
|
|
629
|
+
const tool = f.tool || 'unknown';
|
|
630
|
+
const manifest = f.manifest_path ? '`' + f.manifest_path + '`' : 'repo root';
|
|
631
|
+
const direct = f.direct_or_transitive || 'unknown';
|
|
632
|
+
let chainLine;
|
|
633
|
+
if (f.chain_available && Array.isArray(f.dependency_chain) && f.dependency_chain.length > 0) {
|
|
634
|
+
chainLine = f.dependency_chain.map(_chainEntryToString).join(' → ');
|
|
635
|
+
} else {
|
|
636
|
+
chainLine = 'unavailable (chain_available: false — recommend Snyk for full chain analysis)';
|
|
637
|
+
}
|
|
638
|
+
const fix = f.remediation || 'no upgrade path available — manual review required';
|
|
639
|
+
const ref = (f.vulnerability && f.vulnerability.reference_url) || 'unavailable';
|
|
640
|
+
lines.push(`- **CVE:** ${cve || 'unavailable'}`);
|
|
641
|
+
lines.push(`- **CVSS:** ${cvss !== null ? String(cvss) : 'unavailable'}`);
|
|
642
|
+
lines.push(`- **Tool:** ${tool}`);
|
|
643
|
+
lines.push(`- **Manifest:** ${manifest}`);
|
|
644
|
+
lines.push(`- **Direct/Transitive:** ${direct}`);
|
|
645
|
+
lines.push(`- **Dependency chain:** ${chainLine}`);
|
|
646
|
+
lines.push(`- **Fix:** ${fix}`);
|
|
647
|
+
lines.push(`- **Reference:** ${ref}`);
|
|
648
|
+
// Phase 153 PKG-33: provenance line.
|
|
649
|
+
let introLine;
|
|
650
|
+
if (f.introduced_in_commit && f.introduced_in_plan) {
|
|
651
|
+
introLine = `- **Introduced in:** commit ${f.introduced_in_commit} (plan ${f.introduced_in_plan})`;
|
|
652
|
+
} else if (f.introduced_in_commit) {
|
|
653
|
+
introLine = `- **Introduced in:** commit ${f.introduced_in_commit}`;
|
|
654
|
+
} else {
|
|
655
|
+
introLine = `- **Introduced in:** unknown`;
|
|
656
|
+
}
|
|
657
|
+
lines.push(introLine);
|
|
658
|
+
const descr = f.vulnerability && f.vulnerability.description;
|
|
659
|
+
if (descr !== null && descr !== undefined && String(descr).length > 0) {
|
|
660
|
+
lines.push('');
|
|
661
|
+
for (const dl of String(descr).split('\n')) {
|
|
662
|
+
lines.push('> ' + dl);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
lines.push('');
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Diagnostics appendix (only when findings exist + diagnostics populated)
|
|
671
|
+
if (findings.length > 0 && diagnostics.length > 0) {
|
|
672
|
+
lines.push('## Diagnostics', '');
|
|
673
|
+
for (const d of diagnostics) {
|
|
674
|
+
const msg = d.message || d.hint || d.kind || 'unknown';
|
|
675
|
+
lines.push(`- **${d.kind || 'diagnostic'}:** ${msg}`);
|
|
676
|
+
}
|
|
677
|
+
lines.push('');
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Pitfall 17 one-line warning (when aggregated tool is snyk).
|
|
681
|
+
const aggTool = _aggregateToolString(runResult);
|
|
682
|
+
if (aggTool === 'snyk') {
|
|
683
|
+
lines.push('> Note: scan output may include package names and tool URLs. Review before pushing.');
|
|
684
|
+
lines.push('');
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return lines.join('\n');
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ─── Phase 153 Plan 04: Cross-repo dedup + version overlap (PKG-35, PKG-36) ─
|
|
691
|
+
|
|
692
|
+
function _severityRankLocal(tier) {
|
|
693
|
+
const RANK = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
694
|
+
if (tier === null || tier === undefined) return 0;
|
|
695
|
+
return RANK[String(tier).toLowerCase()] || 0;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function _groupFindingsByPackageVersion(findings) {
|
|
699
|
+
if (!Array.isArray(findings)) return [];
|
|
700
|
+
const map = new Map();
|
|
701
|
+
for (const f of findings) {
|
|
702
|
+
const pkg = f.package_name || 'unknown';
|
|
703
|
+
const ver = f.installed_version || '';
|
|
704
|
+
const vuln = f.vulnerability || {};
|
|
705
|
+
const cveKey = vuln.cve || null;
|
|
706
|
+
const titleKey = vuln.title || '';
|
|
707
|
+
const key = `${pkg}\u0000${ver}\u0000${cveKey || titleKey}`;
|
|
708
|
+
if (!map.has(key)) {
|
|
709
|
+
map.set(key, {
|
|
710
|
+
package_name: pkg,
|
|
711
|
+
installed_version: ver,
|
|
712
|
+
cve: cveKey,
|
|
713
|
+
title: vuln.title || '',
|
|
714
|
+
severity: _collapseSeverity(f.severity),
|
|
715
|
+
repos: [],
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
const group = map.get(key);
|
|
719
|
+
if (!group.repos.includes(f.repo)) group.repos.push(f.repo);
|
|
720
|
+
const prevRank = _severityRankLocal(group.severity);
|
|
721
|
+
const curRank = _severityRankLocal(_collapseSeverity(f.severity));
|
|
722
|
+
if (curRank > prevRank) group.severity = _collapseSeverity(f.severity);
|
|
723
|
+
}
|
|
724
|
+
const out = Array.from(map.values());
|
|
725
|
+
out.sort((a, b) => {
|
|
726
|
+
const sa = _severityRankLocal(a.severity);
|
|
727
|
+
const sb = _severityRankLocal(b.severity);
|
|
728
|
+
if (sa !== sb) return sb - sa;
|
|
729
|
+
if (a.repos.length !== b.repos.length) return b.repos.length - a.repos.length;
|
|
730
|
+
return String(a.package_name).localeCompare(String(b.package_name));
|
|
731
|
+
});
|
|
732
|
+
return out;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function _detectVersionOverlaps(findings, licenceRoster) {
|
|
736
|
+
const fArr = Array.isArray(findings) ? findings : [];
|
|
737
|
+
const rArr = Array.isArray(licenceRoster) ? licenceRoster : [];
|
|
738
|
+
const byPkg = new Map(); // package_name -> Map(version -> Set(repo))
|
|
739
|
+
const addEntry = (pkg, ver, repo) => {
|
|
740
|
+
if (!pkg || ver === undefined || ver === null || ver === '') return;
|
|
741
|
+
if (!byPkg.has(pkg)) byPkg.set(pkg, new Map());
|
|
742
|
+
const versionMap = byPkg.get(pkg);
|
|
743
|
+
if (!versionMap.has(ver)) versionMap.set(ver, new Set());
|
|
744
|
+
if (repo) versionMap.get(ver).add(repo);
|
|
745
|
+
};
|
|
746
|
+
for (const f of fArr) addEntry(f.package_name, f.installed_version, f.repo);
|
|
747
|
+
for (const e of rArr) addEntry(e.package_name, e.installed_version, e.repo);
|
|
748
|
+
const out = [];
|
|
749
|
+
for (const [pkg, versionMap] of byPkg.entries()) {
|
|
750
|
+
if (versionMap.size < 2) continue;
|
|
751
|
+
const versions = Array.from(versionMap.keys()).sort();
|
|
752
|
+
const repos = {};
|
|
753
|
+
for (const v of versions) repos[v] = Array.from(versionMap.get(v)).sort();
|
|
754
|
+
out.push({ package_name: pkg, versions, repos });
|
|
755
|
+
}
|
|
756
|
+
out.sort((a, b) => String(a.package_name).localeCompare(String(b.package_name)));
|
|
757
|
+
return out;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function _renderCrossRepoSummary(groups) {
|
|
761
|
+
const multi = Array.isArray(groups) ? groups.filter(g => g.repos && g.repos.length >= 2) : [];
|
|
762
|
+
if (multi.length === 0) return '';
|
|
763
|
+
const lines = ['## Cross-Repo Summary', ''];
|
|
764
|
+
lines.push('| Package | Version | CVE | Severity | Repos |');
|
|
765
|
+
lines.push('|---------|---------|-----|----------|-------|');
|
|
766
|
+
for (const g of multi) {
|
|
767
|
+
const cve = g.cve || '—';
|
|
768
|
+
const repos = g.repos.slice().sort().join(', ');
|
|
769
|
+
lines.push(`| ${g.package_name} | ${g.installed_version || '—'} | ${cve} | ${g.severity} | ${repos} |`);
|
|
770
|
+
}
|
|
771
|
+
lines.push('');
|
|
772
|
+
return lines.join('\n');
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function _renderVersionOverlap(overlaps) {
|
|
776
|
+
const arr = Array.isArray(overlaps) ? overlaps : [];
|
|
777
|
+
if (arr.length === 0) return '';
|
|
778
|
+
const lines = ['## Dependency Overlap', ''];
|
|
779
|
+
lines.push('| Package | Versions | Repos |');
|
|
780
|
+
lines.push('|---------|----------|-------|');
|
|
781
|
+
for (const o of arr) {
|
|
782
|
+
const versions = o.versions.join(', ');
|
|
783
|
+
const reposParts = [];
|
|
784
|
+
for (const v of o.versions) {
|
|
785
|
+
const repos = (o.repos && o.repos[v]) ? o.repos[v].join(', ') : '';
|
|
786
|
+
reposParts.push(`${v}: ${repos}`);
|
|
787
|
+
}
|
|
788
|
+
lines.push(`| ${o.package_name} | ${versions} | ${reposParts.join(' / ')} |`);
|
|
789
|
+
}
|
|
790
|
+
lines.push('');
|
|
791
|
+
return lines.join('\n');
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// ─── Phase 153 Plan 03: Licence Compliance section (PKG-30, PKG-34) ─────────
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* PKG-30, PKG-34: render the dedicated Licence Compliance section.
|
|
798
|
+
* @param {{ tool_agg: string|null, licence_roster: Array|null }} args
|
|
799
|
+
* @returns {string} Markdown fragment ending with a blank line.
|
|
800
|
+
*/
|
|
801
|
+
function _renderLicenceComplianceSection(args) {
|
|
802
|
+
const lines = ['## Licence Compliance', ''];
|
|
803
|
+
const tool = args && args.tool_agg;
|
|
804
|
+
const roster = (args && Array.isArray(args.licence_roster)) ? args.licence_roster : [];
|
|
805
|
+
if (tool !== 'snyk') {
|
|
806
|
+
// Non-snyk (or no tool aggregated) — suggest Snyk for licence coverage.
|
|
807
|
+
lines.push('> Licence scan incomplete -- use Snyk for full coverage.');
|
|
808
|
+
lines.push('');
|
|
809
|
+
return lines.join('\n');
|
|
810
|
+
}
|
|
811
|
+
if (roster.length === 0) {
|
|
812
|
+
// UAT Bug 3: Snyk IS the tool but roster is empty. This is NOT
|
|
813
|
+
// "incomplete coverage" — the scan ran and surfaced no restrictive
|
|
814
|
+
// licences. Point the user at Snyk org-level licence policy configuration
|
|
815
|
+
// for expanded scope rather than repeating the non-snyk "install Snyk"
|
|
816
|
+
// prompt, which was misleading during live UAT.
|
|
817
|
+
lines.push('> Snyk licence scan completed; no restrictive licences flagged.');
|
|
818
|
+
lines.push('> To expand licence coverage, configure org-level licence policies at https://app.snyk.io/org/licenses.');
|
|
819
|
+
lines.push('');
|
|
820
|
+
return lines.join('\n');
|
|
821
|
+
}
|
|
822
|
+
lines.push('| Package | Version | Repo | Licence | Flag |');
|
|
823
|
+
lines.push('|---------|---------|------|---------|------|');
|
|
824
|
+
// Deterministic ordering: restrictive first, copyleft next, permissive last; secondary by package name.
|
|
825
|
+
const sorted = roster.slice().sort((a, b) => {
|
|
826
|
+
const fa = _licenceRosterFlag(a.licence);
|
|
827
|
+
const fb = _licenceRosterFlag(b.licence);
|
|
828
|
+
const rank = (flag) => flag === 'RESTRICTIVE' ? 2 : (flag === 'copyleft' ? 1 : 0);
|
|
829
|
+
if (rank(fb) !== rank(fa)) return rank(fb) - rank(fa);
|
|
830
|
+
return String(a.package_name).localeCompare(String(b.package_name));
|
|
831
|
+
});
|
|
832
|
+
for (const e of sorted) {
|
|
833
|
+
const flag = _licenceRosterFlag(e.licence);
|
|
834
|
+
const licence = e.licence === null || e.licence === undefined ? 'unknown' : String(e.licence);
|
|
835
|
+
lines.push(`| ${e.package_name} | ${e.installed_version} | ${e.repo || '—'} | ${licence} | ${flag} |`);
|
|
836
|
+
}
|
|
837
|
+
lines.push('');
|
|
838
|
+
return lines.join('\n');
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function _licenceRosterFlag(licence) {
|
|
842
|
+
if (licence === null || licence === undefined) return '';
|
|
843
|
+
const key = String(licence).toLowerCase();
|
|
844
|
+
if (/^gpl[-]?/.test(key) || /^agpl[-]?/.test(key) || /^sspl[-]?/.test(key)) return 'RESTRICTIVE';
|
|
845
|
+
if (/^lgpl[-]?/.test(key) || /^mpl[-]?/.test(key)) return 'copyleft';
|
|
846
|
+
return '';
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// ─── Path resolution ─────────────────────────────────────────────────────────
|
|
850
|
+
|
|
851
|
+
function _pad2(n) { return String(n).padStart(2, '0'); }
|
|
852
|
+
|
|
853
|
+
function _formatTimestamp(dateObj) {
|
|
854
|
+
const y = dateObj.getUTCFullYear();
|
|
855
|
+
const mo = _pad2(dateObj.getUTCMonth() + 1);
|
|
856
|
+
const d = _pad2(dateObj.getUTCDate());
|
|
857
|
+
const hh = _pad2(dateObj.getUTCHours());
|
|
858
|
+
const mm = _pad2(dateObj.getUTCMinutes());
|
|
859
|
+
return `${y}-${mo}-${d}-${hh}${mm}`;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function _resolveReportPath(cwd, opts) {
|
|
863
|
+
opts = opts || {};
|
|
864
|
+
const planningRoot = getPlanningRoot(cwd);
|
|
865
|
+
let activeContext = null;
|
|
866
|
+
try {
|
|
867
|
+
const localPath = getLocalConfigPath(cwd);
|
|
868
|
+
if (fs.existsSync(localPath)) {
|
|
869
|
+
const local = JSON.parse(fs.readFileSync(localPath, 'utf-8'));
|
|
870
|
+
activeContext = (local.execution && local.execution.active_context) || null;
|
|
871
|
+
}
|
|
872
|
+
} catch {
|
|
873
|
+
activeContext = null;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (typeof activeContext === 'string' && activeContext.length > 0) {
|
|
877
|
+
// Tier 1: active phase. Look for a projects/*/phases/ directory whose
|
|
878
|
+
// basename matches activeContext (or whose number prefix matches).
|
|
879
|
+
const projectsDir = path.join(planningRoot, 'projects');
|
|
880
|
+
if (fs.existsSync(projectsDir) && fs.statSync(projectsDir).isDirectory()) {
|
|
881
|
+
const projects = fs.readdirSync(projectsDir, { withFileTypes: true });
|
|
882
|
+
for (const pEnt of projects) {
|
|
883
|
+
if (!pEnt.isDirectory()) continue;
|
|
884
|
+
const phasesDir = path.join(projectsDir, pEnt.name, 'phases');
|
|
885
|
+
if (!fs.existsSync(phasesDir)) continue;
|
|
886
|
+
const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
887
|
+
for (const phEnt of phaseEntries) {
|
|
888
|
+
if (!phEnt.isDirectory()) continue;
|
|
889
|
+
if (phEnt.name === activeContext) {
|
|
890
|
+
const m = phEnt.name.match(/^(\d+)/);
|
|
891
|
+
const prefix = m ? m[1] : phEnt.name;
|
|
892
|
+
return path.join(phasesDir, phEnt.name, prefix + '-PACKAGE-SCAN.md');
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
// Tier 2: milestone slug (e.g., `v23.1`, `v2.0`, etc.).
|
|
898
|
+
if (/^v\d+(\.\d+)?$/.test(activeContext)) {
|
|
899
|
+
const milestonesDir = path.join(planningRoot, 'milestones');
|
|
900
|
+
fs.mkdirSync(milestonesDir, { recursive: true });
|
|
901
|
+
return path.join(milestonesDir, activeContext + '-PACKAGE-SCAN.md');
|
|
902
|
+
}
|
|
903
|
+
// Tier 2b: activeContext is a worktree slug whose entry carries a milestone_version.
|
|
904
|
+
// Enables `package-scan` to land reports under `milestones/<version>-PACKAGE-SCAN.md`
|
|
905
|
+
// when the active context is a milestone worktree (e.g. `package-dependency-scanning`).
|
|
906
|
+
try {
|
|
907
|
+
const localPath2 = getLocalConfigPath(cwd);
|
|
908
|
+
if (fs.existsSync(localPath2)) {
|
|
909
|
+
const local2 = JSON.parse(fs.readFileSync(localPath2, 'utf-8'));
|
|
910
|
+
const currentProject = local2.current_project;
|
|
911
|
+
const wt = currentProject
|
|
912
|
+
&& local2.projects
|
|
913
|
+
&& local2.projects[currentProject]
|
|
914
|
+
&& local2.projects[currentProject].worktrees
|
|
915
|
+
&& local2.projects[currentProject].worktrees[activeContext];
|
|
916
|
+
if (wt
|
|
917
|
+
&& wt.type === 'milestone'
|
|
918
|
+
&& typeof wt.milestone_version === 'string'
|
|
919
|
+
&& /^v\d+(\.\d+)?$/.test(wt.milestone_version)) {
|
|
920
|
+
const milestonesDir = path.join(planningRoot, 'milestones');
|
|
921
|
+
fs.mkdirSync(milestonesDir, { recursive: true });
|
|
922
|
+
return path.join(milestonesDir, wt.milestone_version + '-PACKAGE-SCAN.md');
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
} catch { /* fall through to tier 3 */ }
|
|
926
|
+
// Tier 2 fallback: active_context did not match a phase dir, but is not a
|
|
927
|
+
// milestone-slug pattern either. Fall through to tier 3.
|
|
928
|
+
}
|
|
929
|
+
// Tier 3: project-root timestamped.
|
|
930
|
+
const now = (opts.now ? opts.now() : new Date());
|
|
931
|
+
const stamp = _formatTimestamp(now instanceof Date ? now : new Date(now));
|
|
932
|
+
return path.join(planningRoot, `PACKAGE-SCAN-${stamp}.md`);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ─── Composition helpers ─────────────────────────────────────────────────────
|
|
936
|
+
|
|
937
|
+
function _aggregateToolString(runResult) {
|
|
938
|
+
const tools = new Set();
|
|
939
|
+
let hasNull = false;
|
|
940
|
+
const rr = Array.isArray(runResult.repo_results) ? runResult.repo_results : [];
|
|
941
|
+
for (const row of rr) {
|
|
942
|
+
if (row.tool_used === null || row.tool_used === undefined) {
|
|
943
|
+
hasNull = true;
|
|
944
|
+
} else {
|
|
945
|
+
tools.add(row.tool_used);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (tools.size === 0) return 'none';
|
|
949
|
+
if (tools.size === 1) return Array.from(tools)[0];
|
|
950
|
+
return 'mixed';
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function _countSeverity(findings, tier) {
|
|
954
|
+
let n = 0;
|
|
955
|
+
for (const f of findings) {
|
|
956
|
+
if (_collapseSeverity(f.severity) === tier) n += 1;
|
|
957
|
+
}
|
|
958
|
+
return n;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Normalise a dependency_chain entry to a string. Adapter output may use either
|
|
963
|
+
* plain strings (e.g., from govulncheck) or `{name, version}` objects (Snyk
|
|
964
|
+
* _parseSnykFromEntry). Non-string entries render as `name@version` or just
|
|
965
|
+
* `name` if version is empty.
|
|
966
|
+
*/
|
|
967
|
+
function _chainEntryToString(entry) {
|
|
968
|
+
if (typeof entry === 'string') return entry;
|
|
969
|
+
if (entry && typeof entry === 'object') {
|
|
970
|
+
const name = entry.name || entry.package || '';
|
|
971
|
+
const ver = entry.version || '';
|
|
972
|
+
return ver ? `${name}@${ver}` : name;
|
|
973
|
+
}
|
|
974
|
+
return String(entry);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function _normaliseChain(chain) {
|
|
978
|
+
if (chain === null || chain === undefined) return null;
|
|
979
|
+
if (!Array.isArray(chain)) return null;
|
|
980
|
+
return chain.map(_chainEntryToString);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function _mapAdapterFindingToCanonical(f) {
|
|
984
|
+
const pkg = f.package_name || '';
|
|
985
|
+
const ver = f.installed_version || '';
|
|
986
|
+
const resource_id = ver ? `${pkg}@${ver}` : pkg;
|
|
987
|
+
const vuln = f.vulnerability || {};
|
|
988
|
+
const severity = _collapseSeverity(f.severity);
|
|
989
|
+
return {
|
|
990
|
+
id: f.id,
|
|
991
|
+
test_source: CANONICAL_TEST_SOURCE,
|
|
992
|
+
gap_type: CANONICAL_GAP_TYPE,
|
|
993
|
+
severity,
|
|
994
|
+
resource_id,
|
|
995
|
+
repo: f.repo,
|
|
996
|
+
manifest_path: f.manifest_path === undefined ? null : f.manifest_path,
|
|
997
|
+
title: vuln.title || '',
|
|
998
|
+
description: vuln.description === undefined ? null : vuln.description,
|
|
999
|
+
remediation: f.remediation === undefined ? null : f.remediation,
|
|
1000
|
+
reference: vuln.reference_url === undefined ? null : vuln.reference_url,
|
|
1001
|
+
cve: vuln.cve === undefined ? null : vuln.cve,
|
|
1002
|
+
cvss: typeof f.cvss_score === 'number' ? f.cvss_score : null,
|
|
1003
|
+
dependency_chain: _normaliseChain(f.dependency_chain === undefined ? null : f.dependency_chain),
|
|
1004
|
+
chain_available: !!f.chain_available,
|
|
1005
|
+
direct_or_transitive: f.direct_or_transitive === undefined ? null : f.direct_or_transitive,
|
|
1006
|
+
tool: f.tool,
|
|
1007
|
+
// Phase 153 PKG-33: plan provenance (optional; null when unknown).
|
|
1008
|
+
introduced_in_commit: f.introduced_in_commit === undefined ? null : f.introduced_in_commit,
|
|
1009
|
+
introduced_in_plan: f.introduced_in_plan === undefined ? null : f.introduced_in_plan,
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Map one adapter finding to 1 or 2 canonical findings.
|
|
1015
|
+
*
|
|
1016
|
+
* - Always emits the security finding (unchanged shape from _mapAdapterFindingToCanonical).
|
|
1017
|
+
* - When f.tool === 'snyk' AND f.licence maps to a non-null canonical tier,
|
|
1018
|
+
* appends a licence finding with gap_type: 'dependency-licence'. The
|
|
1019
|
+
* licence finding's id is `${security.id}-lic` (derived, collision-free)
|
|
1020
|
+
* when the security finding has a non-null id, else null.
|
|
1021
|
+
*
|
|
1022
|
+
* Licence synthesis is Snyk-only on purpose: PKG-30 pins licence detection to
|
|
1023
|
+
* Snyk's pass-through licence field — other tools don't reliably emit licence
|
|
1024
|
+
* data. The future test-gate gap planner correlates licence + security
|
|
1025
|
+
* findings on `resource_id` (same package@version for both halves).
|
|
1026
|
+
*/
|
|
1027
|
+
function _canonicalFindingsForAdapter(f) {
|
|
1028
|
+
const security = _mapAdapterFindingToCanonical(f);
|
|
1029
|
+
if (f.tool !== 'snyk') return [security];
|
|
1030
|
+
const licence = f.licence === undefined ? null : f.licence;
|
|
1031
|
+
const licenceSeverity = _mapLicenceToSeverity(licence);
|
|
1032
|
+
if (licenceSeverity === null) return [security];
|
|
1033
|
+
const pkg = f.package_name || 'unknown';
|
|
1034
|
+
const ver = f.installed_version || 'unknown';
|
|
1035
|
+
const licenceId = security.id === null || security.id === undefined
|
|
1036
|
+
? null
|
|
1037
|
+
: `${security.id}-lic`;
|
|
1038
|
+
const licenceFinding = {
|
|
1039
|
+
id: licenceId,
|
|
1040
|
+
test_source: CANONICAL_TEST_SOURCE,
|
|
1041
|
+
gap_type: 'dependency-licence',
|
|
1042
|
+
severity: licenceSeverity,
|
|
1043
|
+
resource_id: security.resource_id,
|
|
1044
|
+
repo: f.repo,
|
|
1045
|
+
manifest_path: f.manifest_path === undefined ? null : f.manifest_path,
|
|
1046
|
+
title: `Restrictive licence: ${licence}`,
|
|
1047
|
+
description: `Package ${pkg}@${ver} is licensed under ${licence}. Using this dependency may impose copyleft obligations on your project.`,
|
|
1048
|
+
remediation: 'Review licence compatibility or replace with a permissive-licensed alternative.',
|
|
1049
|
+
reference: null,
|
|
1050
|
+
cve: null,
|
|
1051
|
+
cvss: null,
|
|
1052
|
+
dependency_chain: null,
|
|
1053
|
+
chain_available: false,
|
|
1054
|
+
direct_or_transitive: f.direct_or_transitive === undefined ? null : f.direct_or_transitive,
|
|
1055
|
+
tool: 'snyk',
|
|
1056
|
+
// Phase 153 PKG-33: licence finding inherits provenance from the security finding (same package, same manifest).
|
|
1057
|
+
introduced_in_commit: security.introduced_in_commit,
|
|
1058
|
+
introduced_in_plan: security.introduced_in_plan,
|
|
1059
|
+
};
|
|
1060
|
+
return [security, licenceFinding];
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// ─── Public: writePackageScanReport ──────────────────────────────────────────
|
|
1064
|
+
|
|
1065
|
+
function writePackageScanReport(cwd, runResult, opts) {
|
|
1066
|
+
opts = opts || {};
|
|
1067
|
+
if (!runResult || typeof runResult !== 'object') {
|
|
1068
|
+
throw new Error('runResult must be a non-null object');
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const now = opts.now ? opts.now() : new Date();
|
|
1072
|
+
const nowDate = now instanceof Date ? now : new Date(now);
|
|
1073
|
+
|
|
1074
|
+
const rr = Array.isArray(runResult.repo_results) ? runResult.repo_results : [];
|
|
1075
|
+
const findings = Array.isArray(runResult.findings) ? runResult.findings : [];
|
|
1076
|
+
|
|
1077
|
+
const toolString = _aggregateToolString(runResult);
|
|
1078
|
+
const repos_scanned = rr.filter(r => r.outcome !== 'no_manifests').length;
|
|
1079
|
+
const duration = Math.round(rr.reduce((s, r) => s + (r.durationMs || 0), 0) / 1000);
|
|
1080
|
+
|
|
1081
|
+
const payload = {
|
|
1082
|
+
type: 'package-scan',
|
|
1083
|
+
date: nowDate.toISOString().slice(0, 10),
|
|
1084
|
+
tool: toolString,
|
|
1085
|
+
// UAT Bug 2: forward snyk_org from runScan result. Null when unset.
|
|
1086
|
+
snyk_org: (runResult.snyk_org === undefined ? null : runResult.snyk_org),
|
|
1087
|
+
repos_scanned,
|
|
1088
|
+
critical: _countSeverity(findings, 'critical'),
|
|
1089
|
+
high: _countSeverity(findings, 'high'),
|
|
1090
|
+
medium: _countSeverity(findings, 'medium'),
|
|
1091
|
+
low: _countSeverity(findings, 'low'),
|
|
1092
|
+
duration,
|
|
1093
|
+
findings: findings.flatMap(_canonicalFindingsForAdapter),
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
let frontmatter;
|
|
1097
|
+
try {
|
|
1098
|
+
frontmatter = _emitYamlFrontmatter(payload);
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
throw new Error('YAML emitter: ' + (err && err.message ? err.message : String(err)));
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const body = _renderBody(runResult);
|
|
1104
|
+
const reportPath = opts.overridePath || _resolveReportPath(cwd, { now: opts.now });
|
|
1105
|
+
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
|
1106
|
+
const content = frontmatter + '\n' + body + '\n';
|
|
1107
|
+
fs.writeFileSync(reportPath, content);
|
|
1108
|
+
|
|
1109
|
+
return {
|
|
1110
|
+
path: reportPath,
|
|
1111
|
+
tool_string: toolString,
|
|
1112
|
+
n_findings: findings.length,
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
module.exports = {
|
|
1117
|
+
writePackageScanReport,
|
|
1118
|
+
_emitYamlFrontmatter,
|
|
1119
|
+
_parseEmittedYaml,
|
|
1120
|
+
_renderBody,
|
|
1121
|
+
_resolveReportPath,
|
|
1122
|
+
// Internal helpers exported for symmetry with tests.
|
|
1123
|
+
_aggregateToolString,
|
|
1124
|
+
_countSeverity,
|
|
1125
|
+
_collapseSeverity,
|
|
1126
|
+
_mapAdapterFindingToCanonical,
|
|
1127
|
+
_canonicalFindingsForAdapter,
|
|
1128
|
+
_mapLicenceToSeverity,
|
|
1129
|
+
// Phase 152: canonical normaliser tables exported for test-gate consumers.
|
|
1130
|
+
SEVERITY_MAP,
|
|
1131
|
+
LICENCE_SEVERITY_MAP,
|
|
1132
|
+
// Phase 153 PKG-30/34: Licence Compliance section renderer.
|
|
1133
|
+
_renderLicenceComplianceSection,
|
|
1134
|
+
_licenceRosterFlag,
|
|
1135
|
+
// Phase 153 PKG-35/36: cross-repo dedup + version overlap.
|
|
1136
|
+
_groupFindingsByPackageVersion,
|
|
1137
|
+
_detectVersionOverlaps,
|
|
1138
|
+
_renderCrossRepoSummary,
|
|
1139
|
+
_renderVersionOverlap,
|
|
1140
|
+
};
|