@ktpartners/dgs-platform 2.9.0 → 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 +197 -0
- package/README.md +34 -2
- package/agents/dgs-executor.md +124 -3
- package/agents/dgs-idea-researcher.md +447 -0
- package/agents/dgs-plan-checker.md +61 -3
- package/agents/dgs-planner.md +51 -8
- package/bin/install.js +44 -0
- package/commands/dgs/abandon-quick.md +28 -0
- package/commands/dgs/add-tests.md +2 -2
- package/commands/dgs/audit-milestone.md +4 -3
- package/commands/dgs/capture-principle.md +11 -11
- package/commands/dgs/cleanup.md +2 -2
- package/commands/dgs/complete-milestone.md +11 -11
- package/commands/dgs/complete-quick.md +28 -0
- package/commands/dgs/create-milestone-job.md +2 -2
- package/commands/dgs/debug.md +3 -3
- package/commands/dgs/develop-idea.md +1 -1
- package/commands/dgs/diff-report.md +124 -0
- package/commands/dgs/fast.md +3 -1
- package/commands/dgs/health.md +1 -1
- package/commands/dgs/map-codebase.md +6 -6
- package/commands/dgs/new-milestone.md +5 -5
- package/commands/dgs/new-project.md +8 -21
- package/commands/dgs/package-scan.md +43 -0
- package/commands/dgs/plan-milestone-gaps.md +1 -1
- package/commands/dgs/progress.md +3 -3
- package/commands/dgs/quick-abandon.md +8 -0
- package/commands/dgs/quick-complete.md +8 -0
- package/commands/dgs/quick.md +10 -3
- package/commands/dgs/research-idea.md +3 -2
- package/commands/dgs/research-phase.md +3 -3
- package/commands/dgs/switch-project.md +14 -1
- package/commands/dgs/write-spec.md +3 -3
- package/deliver-great-systems/bin/dgs-tools.cjs +401 -32
- 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 +626 -46
- package/deliver-great-systems/bin/lib/commands.test.cjs +451 -0
- package/deliver-great-systems/bin/lib/commit-verify.test.cjs +236 -0
- package/deliver-great-systems/bin/lib/config.cjs +80 -6
- package/deliver-great-systems/bin/lib/config.test.cjs +309 -0
- package/deliver-great-systems/bin/lib/context.cjs +120 -0
- package/deliver-great-systems/bin/lib/core.cjs +35 -14
- package/deliver-great-systems/bin/lib/core.test.cjs +79 -1
- package/deliver-great-systems/bin/lib/execution.cjs +49 -17
- 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/flat-migration.test.cjs +396 -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/ideas.cjs +206 -91
- package/deliver-great-systems/bin/lib/ideas.test.cjs +244 -1
- package/deliver-great-systems/bin/lib/init.cjs +357 -61
- package/deliver-great-systems/bin/lib/init.test.cjs +625 -8
- package/deliver-great-systems/bin/lib/jobs.cjs +131 -25
- package/deliver-great-systems/bin/lib/jobs.test.cjs +193 -74
- package/deliver-great-systems/bin/lib/migration.cjs +409 -1
- package/deliver-great-systems/bin/lib/migration.test.cjs +158 -1
- package/deliver-great-systems/bin/lib/milestone.cjs +154 -31
- 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 +146 -3
- package/deliver-great-systems/bin/lib/phase.test.cjs +420 -0
- package/deliver-great-systems/bin/lib/plan-number-validity.test.cjs +48 -0
- package/deliver-great-systems/bin/lib/projects.cjs +65 -10
- package/deliver-great-systems/bin/lib/projects.test.cjs +198 -2
- package/deliver-great-systems/bin/lib/quick.cjs +739 -0
- package/deliver-great-systems/bin/lib/quick.test.cjs +730 -0
- package/deliver-great-systems/bin/lib/repos.cjs +37 -13
- package/deliver-great-systems/bin/lib/review.cjs +1821 -0
- package/deliver-great-systems/bin/lib/roadmap.cjs +34 -13
- package/deliver-great-systems/bin/lib/specs.cjs +3 -81
- package/deliver-great-systems/bin/lib/state-transition-gate.test.cjs +160 -0
- package/deliver-great-systems/bin/lib/state.cjs +147 -55
- 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/sync.cjs +75 -0
- package/deliver-great-systems/bin/lib/verify.cjs +198 -7
- 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 +790 -0
- package/deliver-great-systems/bin/lib/worktrees.test.cjs +963 -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 +27 -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/abandon-quick.md +89 -0
- package/deliver-great-systems/workflows/add-idea.md +3 -3
- package/deliver-great-systems/workflows/add-phase.md +5 -0
- package/deliver-great-systems/workflows/add-tests.md +14 -0
- package/deliver-great-systems/workflows/add-todo.md +1 -0
- package/deliver-great-systems/workflows/approve-spec.md +25 -4
- package/deliver-great-systems/workflows/audit-milestone.md +66 -10
- package/deliver-great-systems/workflows/audit-phase.md +15 -5
- package/deliver-great-systems/workflows/cancel-job.md +2 -2
- package/deliver-great-systems/workflows/check-todos.md +2 -3
- package/deliver-great-systems/workflows/codereview.md +103 -9
- package/deliver-great-systems/workflows/complete-milestone.md +218 -24
- package/deliver-great-systems/workflows/complete-quick.md +106 -0
- package/deliver-great-systems/workflows/consolidate-ideas.md +1 -1
- package/deliver-great-systems/workflows/create-milestone-job.md +4 -4
- package/deliver-great-systems/workflows/develop-idea.md +11 -11
- package/deliver-great-systems/workflows/diagnose-issues.md +14 -0
- package/deliver-great-systems/workflows/discuss-idea.md +1 -1
- package/deliver-great-systems/workflows/discuss-phase.md +3 -2
- package/deliver-great-systems/workflows/execute-phase.md +209 -33
- package/deliver-great-systems/workflows/execute-plan.md +22 -22
- package/deliver-great-systems/workflows/help.md +53 -20
- package/deliver-great-systems/workflows/import-spec.md +65 -7
- package/deliver-great-systems/workflows/init-product.md +45 -167
- package/deliver-great-systems/workflows/new-milestone.md +140 -33
- 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/progress-all.md +133 -0
- package/deliver-great-systems/workflows/quick-abandon.md +89 -0
- package/deliver-great-systems/workflows/quick-complete.md +106 -0
- package/deliver-great-systems/workflows/quick.md +328 -26
- package/deliver-great-systems/workflows/refine-spec.md +1 -1
- package/deliver-great-systems/workflows/research-idea.md +77 -139
- package/deliver-great-systems/workflows/resume-project.md +2 -2
- package/deliver-great-systems/workflows/run-job.md +29 -43
- package/deliver-great-systems/workflows/settings.md +13 -77
- package/deliver-great-systems/workflows/validate-phase.md +39 -1
- package/deliver-great-systems/workflows/verify-work.md +14 -0
- package/deliver-great-systems/workflows/write-spec.md +11 -13
- package/hooks/dist/dgs-enforce-discipline.js +196 -0
- package/package.json +1 -1
- package/scripts/build-hooks.js +1 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package-scan.cjs -- Phase 150 orchestrator for /dgs:package-scan
|
|
3
|
+
*
|
|
4
|
+
* Composes the Phase 149 leaf modules (package-ecosystems, package-runner,
|
|
5
|
+
* package-adapters) into a scan pipeline: iterates REPOS.md + product root,
|
|
6
|
+
* resolves each target via resolveCodeContext so active worktrees are scanned,
|
|
7
|
+
* applies the Snyk->OSV->native tool cascade (honouring testing.packages.tool
|
|
8
|
+
* pins with install-hint failures), invokes the runner, normalises adapter
|
|
9
|
+
* output, merges findings with pkg-NNN ids, and returns a structured result.
|
|
10
|
+
*
|
|
11
|
+
* Does NOT: write reports (Phase 151), wire the /dgs:package-scan command
|
|
12
|
+
* (Phase 151), normalise severities (Phase 152), extract licences (Phase 153),
|
|
13
|
+
* dedupe cross-repo (Phase 153).
|
|
14
|
+
*
|
|
15
|
+
* Exports: cmdPackageScan, runScan, collectScanTargets, selectTool, resolveSnykAuth.
|
|
16
|
+
*
|
|
17
|
+
* Covers roadmap requirements: PKG-01..PKG-05, PKG-15..PKG-18, PKG-40.
|
|
18
|
+
*
|
|
19
|
+
* Finding id ordering: target-order -> ecosystem-group-order -> workspace-loop-order -> adapter-emitted-order.
|
|
20
|
+
* Ids assigned AFTER all targets scanned, as pkg-NNN (zero-padded 3-digit).
|
|
21
|
+
*/
|
|
22
|
+
'use strict';
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const { spawnSync } = require('child_process');
|
|
26
|
+
|
|
27
|
+
const { getPlanningRoot } = require('./paths.cjs');
|
|
28
|
+
const { output, error, loadConfig } = require('./core.cjs');
|
|
29
|
+
const { parseReposMd } = require('./repos.cjs');
|
|
30
|
+
const { resolveCodeContext } = require('./context.cjs');
|
|
31
|
+
const { getLocalConfigPath } = require('./config.cjs');
|
|
32
|
+
|
|
33
|
+
const { writePackageScanReport, _collapseSeverity } = require('./package-scan-report.cjs');
|
|
34
|
+
const { provenanceLookup } = require('./package-scan-provenance.cjs');
|
|
35
|
+
const { detectEcosystems } = require('./package-ecosystems.cjs');
|
|
36
|
+
const {
|
|
37
|
+
runTool,
|
|
38
|
+
ECOSYSTEM_OVERRIDES,
|
|
39
|
+
DEFAULT_TIMEOUT_MS,
|
|
40
|
+
} = require('./package-runner.cjs');
|
|
41
|
+
const {
|
|
42
|
+
adapterSnyk, adapterOsv, adapterNpmAudit,
|
|
43
|
+
adapterPipAudit, adapterGovulncheck, adapterBundlerAudit,
|
|
44
|
+
} = require('./package-adapters.cjs');
|
|
45
|
+
|
|
46
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/** Map ecosystem -> native-tool key (null = no native tool in P0 cascade). */
|
|
49
|
+
const NATIVE_TOOL_FOR_ECOSYSTEM = Object.freeze({
|
|
50
|
+
node: 'npm-audit',
|
|
51
|
+
python: 'pip-audit',
|
|
52
|
+
go: 'govulncheck',
|
|
53
|
+
ruby: 'bundler-audit',
|
|
54
|
+
java: null, // PKG-03: Maven/Gradle have no native in P0; Snyk/OSV only
|
|
55
|
+
yarn: 'npm-audit', // ECOSYSTEM_OVERRIDES forces yarn -> osv when available
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** Map toolKey -> adapter function. */
|
|
59
|
+
const ADAPTER_FOR_TOOL = Object.freeze({
|
|
60
|
+
'snyk': adapterSnyk,
|
|
61
|
+
'osv-scanner': adapterOsv,
|
|
62
|
+
'npm-audit': adapterNpmAudit,
|
|
63
|
+
'pip-audit': adapterPipAudit,
|
|
64
|
+
'govulncheck': adapterGovulncheck,
|
|
65
|
+
'bundler-audit': adapterBundlerAudit,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Phase 153: severity_threshold is overridable via --threshold; include_dev_dependencies via
|
|
69
|
+
// --include-dev-deps / --no-include-dev-deps; both fall back to the DEFAULTS below when no
|
|
70
|
+
// CLI flag and no config key is present.
|
|
71
|
+
/** Defaults for testing.packages.* keys (read via loadConfig(cwd)). */
|
|
72
|
+
const DEFAULTS = Object.freeze({
|
|
73
|
+
tool: 'auto',
|
|
74
|
+
severity_threshold: 'low',
|
|
75
|
+
include_dev_dependencies: true,
|
|
76
|
+
timeout_seconds: 300,
|
|
77
|
+
// UAT Bug 2: Snyk org UUID (nullable). When truthy + tool===snyk,
|
|
78
|
+
// _buildArgv appends --org=<ID> so multi-org accounts resolve to the
|
|
79
|
+
// correct organisation without SNYK_CFG_ORG env plumbing.
|
|
80
|
+
snyk_org: null,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Phase 153 PKG-27: severity rank table for threshold filtering.
|
|
84
|
+
const SEVERITY_RANK = Object.freeze({
|
|
85
|
+
critical: 4,
|
|
86
|
+
high: 3,
|
|
87
|
+
medium: 2,
|
|
88
|
+
low: 1,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
function _severityRank(tier) {
|
|
92
|
+
if (tier === null || tier === undefined) return 0;
|
|
93
|
+
const key = String(tier).toLowerCase();
|
|
94
|
+
return SEVERITY_RANK[key] || 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function _filterByThreshold(findings, threshold) {
|
|
98
|
+
if (!Array.isArray(findings) || findings.length === 0) return [];
|
|
99
|
+
if (threshold === null || threshold === undefined) return findings.slice();
|
|
100
|
+
const min = _severityRank(threshold);
|
|
101
|
+
if (min === 0) return findings.slice(); // unknown threshold = no filter (be conservative)
|
|
102
|
+
return findings.filter(f => _severityRank(_collapseSeverity(f.severity)) >= min);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Phase 153 PKG-32: detect a `.snyk` policy file in the given directory.
|
|
106
|
+
function _detectSnykPolicy(dir) {
|
|
107
|
+
try {
|
|
108
|
+
return !!dir && fs.existsSync(path.join(dir, '.snyk'));
|
|
109
|
+
} catch { return false; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _parseArgs(args) {
|
|
113
|
+
const out = { threshold: null, only_repo: null, include_dev_dependencies: null, json: false, raw: false };
|
|
114
|
+
const valid = ['critical', 'high', 'medium', 'low'];
|
|
115
|
+
const arr = Array.isArray(args) ? args : [];
|
|
116
|
+
for (let i = 0; i < arr.length; i++) {
|
|
117
|
+
const a = arr[i];
|
|
118
|
+
if (a === '--threshold') {
|
|
119
|
+
const v = arr[i + 1];
|
|
120
|
+
if (v === undefined || (typeof v === 'string' && v.startsWith('--'))) {
|
|
121
|
+
throw new Error('Missing value for --threshold. Valid: ' + valid.join(', '));
|
|
122
|
+
}
|
|
123
|
+
if (!valid.includes(String(v).toLowerCase())) {
|
|
124
|
+
throw new Error("Invalid threshold '" + v + "'. Valid: critical, high, medium, low.");
|
|
125
|
+
}
|
|
126
|
+
out.threshold = String(v).toLowerCase();
|
|
127
|
+
i++;
|
|
128
|
+
} else if (a === '--repo') {
|
|
129
|
+
const v = arr[i + 1];
|
|
130
|
+
if (v === undefined || (typeof v === 'string' && v.startsWith('--'))) {
|
|
131
|
+
throw new Error('Missing value for --repo.');
|
|
132
|
+
}
|
|
133
|
+
out.only_repo = v;
|
|
134
|
+
i++;
|
|
135
|
+
} else if (a === '--include-dev-deps') {
|
|
136
|
+
out.include_dev_dependencies = true;
|
|
137
|
+
} else if (a === '--no-include-dev-deps') {
|
|
138
|
+
out.include_dev_dependencies = false;
|
|
139
|
+
} else if (a === '--json') {
|
|
140
|
+
out.json = true;
|
|
141
|
+
} else if (a === '--raw') {
|
|
142
|
+
out.raw = true;
|
|
143
|
+
}
|
|
144
|
+
// Unknown flags ignored for forward-compat.
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Probe whether a binary is available on PATH. Per-invocation (no caching).
|
|
153
|
+
*/
|
|
154
|
+
function _checkToolOnPath(binName) {
|
|
155
|
+
const cmd = process.platform === 'win32' ? 'where' : 'command';
|
|
156
|
+
const argv = process.platform === 'win32' ? [binName] : ['-v', binName];
|
|
157
|
+
const result = spawnSync(cmd, argv, { encoding: 'utf-8', timeout: 2000, shell: process.platform !== 'win32' });
|
|
158
|
+
const available = result && result.status === 0 && typeof result.stdout === 'string' && result.stdout.trim().length > 0;
|
|
159
|
+
return { available, path: available ? result.stdout.trim().split(/\r?\n/)[0] : null };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Read testing.packages.* from config.json + config.local.json without going through
|
|
164
|
+
* loadConfig (which flattens to a canonical shape that omits the testing section).
|
|
165
|
+
* Local overrides shared for overlapping keys; snyk_token only comes from config.local.json.
|
|
166
|
+
*/
|
|
167
|
+
function _readPackagesConfig(cwd) {
|
|
168
|
+
const root = getPlanningRoot(cwd);
|
|
169
|
+
const sharedPath = path.join(root, 'config.json');
|
|
170
|
+
const localPath = getLocalConfigPath(cwd);
|
|
171
|
+
let shared = {};
|
|
172
|
+
let local = {};
|
|
173
|
+
try {
|
|
174
|
+
if (fs.existsSync(sharedPath)) shared = JSON.parse(fs.readFileSync(sharedPath, 'utf-8'));
|
|
175
|
+
} catch { /* ignore */ }
|
|
176
|
+
try {
|
|
177
|
+
if (fs.existsSync(localPath)) local = JSON.parse(fs.readFileSync(localPath, 'utf-8'));
|
|
178
|
+
} catch { /* ignore */ }
|
|
179
|
+
const sharedPkg = (shared.testing && shared.testing.packages) || {};
|
|
180
|
+
const localPkg = (local.testing && local.testing.packages) || {};
|
|
181
|
+
return { ...sharedPkg, ...localPkg };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── Public: resolveSnykAuth ──────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Detects Snyk auth presence without invoking `snyk auth` (spec success criterion 3).
|
|
188
|
+
* Precedence: config.local.json -> SNYK_TOKEN env -> `snyk config get api` ->
|
|
189
|
+
* `snyk whoami` (configstore-OAuth via `snyk auth`) -> none.
|
|
190
|
+
*
|
|
191
|
+
* UAT Bug 1 (PACKAGE-SCAN-2026-04-20-0928.md): the fourth probe (`snyk whoami`
|
|
192
|
+
* exit 0) catches users who authenticated via the browser-OAuth flow (credential
|
|
193
|
+
* stored in ~/.config/configstore/snyk.json) — `snyk config get api` returns
|
|
194
|
+
* empty for these users. When whoami succeeds, we do NOT return a token (the
|
|
195
|
+
* configstore credential is not revealed); runScan's env block at line 555
|
|
196
|
+
* correctly handles this by setting env={}, which lets the snyk CLI read its
|
|
197
|
+
* own configstore at invocation time.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} cwd
|
|
200
|
+
* @param {{_spawnSync?: function}} [deps] - test seam; defaults to the imported spawnSync.
|
|
201
|
+
* @returns {{
|
|
202
|
+
* available: boolean,
|
|
203
|
+
* source: 'config.local.json'|'env'|'snyk-cli-config'|'snyk-cli-whoami'|'none',
|
|
204
|
+
* auth_source: 'dgs_local'|'env_token'|'api_config'|'oauth_configstore'|null,
|
|
205
|
+
* token?: string
|
|
206
|
+
* }}
|
|
207
|
+
*/
|
|
208
|
+
function resolveSnykAuth(cwd, deps = {}) {
|
|
209
|
+
const _spawn = deps._spawnSync || spawnSync;
|
|
210
|
+
|
|
211
|
+
// 1. config.local.json (highest precedence)
|
|
212
|
+
try {
|
|
213
|
+
const pkg = _readPackagesConfig(cwd);
|
|
214
|
+
const token = pkg.snyk_token;
|
|
215
|
+
if (typeof token === 'string' && token.length > 0) {
|
|
216
|
+
return { available: true, source: 'config.local.json', auth_source: 'dgs_local', token };
|
|
217
|
+
}
|
|
218
|
+
} catch { /* fall through */ }
|
|
219
|
+
|
|
220
|
+
// 2. SNYK_TOKEN env var
|
|
221
|
+
const envToken = process.env.SNYK_TOKEN;
|
|
222
|
+
if (typeof envToken === 'string' && envToken.length > 0) {
|
|
223
|
+
return { available: true, source: 'env', auth_source: 'env_token', token: envToken };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 3. `snyk config get api` -- NEVER call `snyk auth` (pitfall 6)
|
|
227
|
+
try {
|
|
228
|
+
const probe = _spawn('snyk', ['config', 'get', 'api'], { encoding: 'utf-8', timeout: 5000 });
|
|
229
|
+
if (probe && probe.status === 0 && typeof probe.stdout === 'string') {
|
|
230
|
+
const stdout = probe.stdout.trim();
|
|
231
|
+
if (stdout.length > 0) {
|
|
232
|
+
return { available: true, source: 'snyk-cli-config', auth_source: 'api_config', token: stdout };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} catch { /* fall through */ }
|
|
236
|
+
|
|
237
|
+
// 4. `snyk whoami` -- configstore-OAuth (set by `snyk auth` browser flow).
|
|
238
|
+
// Do NOT invoke `snyk auth` itself (pitfall 6 — opens a browser).
|
|
239
|
+
// whoami does not yield a token, so we omit the token field; runScan detects
|
|
240
|
+
// `!snyk.token` and omits SNYK_TOKEN from the spawn env, which lets the snyk
|
|
241
|
+
// CLI consult its own configstore on the next invocation.
|
|
242
|
+
try {
|
|
243
|
+
const whoami = _spawn('snyk', ['whoami'], { encoding: 'utf-8', timeout: 5000 });
|
|
244
|
+
if (whoami && whoami.status === 0) {
|
|
245
|
+
return { available: true, source: 'snyk-cli-whoami', auth_source: 'oauth_configstore' };
|
|
246
|
+
}
|
|
247
|
+
} catch { /* fall through */ }
|
|
248
|
+
|
|
249
|
+
return { available: false, source: 'none', auth_source: null };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─── Public: selectTool ───────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Given { configTool, entryEcosystem, snykAuth, toolsOnPath }, return tier decision.
|
|
256
|
+
* Pure function — no side effects, no I/O. toolsOnPath is a function (binName) -> { available }.
|
|
257
|
+
*
|
|
258
|
+
* ECOSYSTEM_OVERRIDES (e.g., yarn -> osv) takes precedence over configTool.
|
|
259
|
+
*/
|
|
260
|
+
function selectTool(ctx) {
|
|
261
|
+
const { configTool, entryEcosystem, snykAuth, toolsOnPath } = ctx;
|
|
262
|
+
const isOnPath = (bin) => {
|
|
263
|
+
const r = toolsOnPath(bin);
|
|
264
|
+
return r && r.available;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Ecosystem override first
|
|
268
|
+
const override = ECOSYSTEM_OVERRIDES[entryEcosystem];
|
|
269
|
+
if (override && override.force_tool) {
|
|
270
|
+
const forced = override.force_tool;
|
|
271
|
+
if (forced === 'osv') {
|
|
272
|
+
if (isOnPath('osv-scanner')) {
|
|
273
|
+
return { tier: 'osv', toolKey: 'osv-scanner', reason: 'forced_by_ecosystem_override' };
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
tier: 'unavailable',
|
|
277
|
+
reason: 'forced_tool_unavailable',
|
|
278
|
+
details: { ecosystem: entryEcosystem, tool_forced: forced }
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (forced === 'snyk') {
|
|
282
|
+
if (snykAuth && snykAuth.available && isOnPath('snyk')) {
|
|
283
|
+
return { tier: 'snyk', toolKey: 'snyk', reason: 'forced_by_ecosystem_override' };
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
tier: 'unavailable',
|
|
287
|
+
reason: 'forced_tool_unavailable',
|
|
288
|
+
details: { ecosystem: entryEcosystem, tool_forced: forced }
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Pins
|
|
294
|
+
if (configTool === 'snyk') {
|
|
295
|
+
if (snykAuth && snykAuth.available && isOnPath('snyk')) {
|
|
296
|
+
return { tier: 'snyk', toolKey: 'snyk', reason: 'pinned' };
|
|
297
|
+
}
|
|
298
|
+
return { tier: 'unavailable', reason: 'tool_unavailable_on_pin', tool: 'snyk' };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (configTool === 'osv') {
|
|
302
|
+
if (isOnPath('osv-scanner')) {
|
|
303
|
+
return { tier: 'osv', toolKey: 'osv-scanner', reason: 'pinned' };
|
|
304
|
+
}
|
|
305
|
+
return { tier: 'unavailable', reason: 'tool_unavailable_on_pin', tool: 'osv-scanner' };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (configTool === 'native') {
|
|
309
|
+
const toolKey = NATIVE_TOOL_FOR_ECOSYSTEM[entryEcosystem] ?? null;
|
|
310
|
+
return { tier: 'native', toolKey, reason: 'pinned' };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Auto cascade
|
|
314
|
+
if (snykAuth && snykAuth.available && isOnPath('snyk')) {
|
|
315
|
+
return { tier: 'snyk', toolKey: 'snyk', reason: 'auto' };
|
|
316
|
+
}
|
|
317
|
+
if (isOnPath('osv-scanner')) {
|
|
318
|
+
return { tier: 'osv', toolKey: 'osv-scanner', reason: 'auto' };
|
|
319
|
+
}
|
|
320
|
+
const toolKey = NATIVE_TOOL_FOR_ECOSYSTEM[entryEcosystem] ?? null;
|
|
321
|
+
return { tier: 'native', toolKey, reason: 'auto' };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ─── Public: collectScanTargets ───────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Enumerate scan targets: every repo in REPOS.md (worktree-aware) plus the product root.
|
|
328
|
+
*
|
|
329
|
+
* Phase 153 PKG-28: when `onlyRepo` is provided, restrict targets to the matching repo
|
|
330
|
+
* and exclude the product root. Returns shape: `{ targets, error }`. Backward-compat:
|
|
331
|
+
* legacy callsites that expected `Array` should switch to `.targets`.
|
|
332
|
+
*
|
|
333
|
+
* @param {string} cwd
|
|
334
|
+
* @param {string|null} [onlyRepo]
|
|
335
|
+
* @returns {{ targets: Array<{ name: string, dir: string, has_snyk_policy?: boolean }>, error: object|null }}
|
|
336
|
+
*/
|
|
337
|
+
function collectScanTargets(cwd, onlyRepo) {
|
|
338
|
+
const planningRoot = getPlanningRoot(cwd);
|
|
339
|
+
const parsed = parseReposMd(cwd);
|
|
340
|
+
const repos = (parsed && Array.isArray(parsed.repos)) ? parsed.repos : [];
|
|
341
|
+
|
|
342
|
+
const allTargets = repos.map(r => {
|
|
343
|
+
let dir;
|
|
344
|
+
try {
|
|
345
|
+
const ctx = resolveCodeContext(cwd, r.name);
|
|
346
|
+
dir = ctx && ctx.directory ? ctx.directory : path.resolve(planningRoot, r.path || ('../' + r.name));
|
|
347
|
+
} catch {
|
|
348
|
+
dir = path.resolve(planningRoot, r.path || ('../' + r.name));
|
|
349
|
+
}
|
|
350
|
+
return { name: r.name, dir, has_snyk_policy: _detectSnykPolicy(dir) };
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
if (onlyRepo !== null && onlyRepo !== undefined && onlyRepo !== '') {
|
|
354
|
+
const found = allTargets.find(t => t.name === onlyRepo);
|
|
355
|
+
if (!found) {
|
|
356
|
+
const validRepos = allTargets.length > 0
|
|
357
|
+
? allTargets.map(t => t.name).join(', ')
|
|
358
|
+
: '(none registered)';
|
|
359
|
+
return {
|
|
360
|
+
targets: [],
|
|
361
|
+
error: {
|
|
362
|
+
kind: 'unknown_repo',
|
|
363
|
+
message: "Unknown repo: '" + onlyRepo + "'. Valid repos: " + validRepos + ". Use /dgs:package-scan (no flag) to scan all.",
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
return { targets: [found], error: null };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// PKG-16: always append product root when scanning all; detector handles "no manifests" gracefully
|
|
371
|
+
allTargets.push({ name: '_product_root', dir: planningRoot, has_snyk_policy: _detectSnykPolicy(planningRoot) });
|
|
372
|
+
return { targets: allTargets, error: null };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ─── argv builder ─────────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
function _buildArgv(tier, toolKey, entry, pkgCfg, opts = {}) {
|
|
378
|
+
const includeDev = pkgCfg.include_dev_dependencies !== false;
|
|
379
|
+
const isMonorepoRoot = !!opts.monorepoRoot; // Snyk needs --all-projects at root
|
|
380
|
+
switch (toolKey) {
|
|
381
|
+
case 'snyk': {
|
|
382
|
+
const argv = ['snyk', 'test', '--json'];
|
|
383
|
+
if (!includeDev) argv.push('--production');
|
|
384
|
+
if (isMonorepoRoot) argv.push('--all-projects');
|
|
385
|
+
// UAT Bug 2: thread --org=<ID> for multi-org Snyk accounts.
|
|
386
|
+
if (pkgCfg.snyk_org) argv.push('--org=' + pkgCfg.snyk_org);
|
|
387
|
+
return argv;
|
|
388
|
+
}
|
|
389
|
+
case 'osv-scanner':
|
|
390
|
+
return ['osv-scanner', '--json', '-r', '.'];
|
|
391
|
+
case 'npm-audit': {
|
|
392
|
+
const argv = ['npm', 'audit', '--json'];
|
|
393
|
+
if (!includeDev) argv.push('--omit=dev');
|
|
394
|
+
return argv;
|
|
395
|
+
}
|
|
396
|
+
case 'pip-audit':
|
|
397
|
+
return ['pip-audit', '--format=json'];
|
|
398
|
+
case 'govulncheck':
|
|
399
|
+
return ['govulncheck', '-json', './...'];
|
|
400
|
+
case 'bundler-audit':
|
|
401
|
+
return ['bundle', 'audit', 'check', '--format', 'json'];
|
|
402
|
+
default:
|
|
403
|
+
return [];
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ─── adapter dispatch ─────────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
function _invokeAdapter(toolKey, runResult, ctx) {
|
|
410
|
+
const adapter = ADAPTER_FOR_TOOL[toolKey];
|
|
411
|
+
if (!adapter) return [];
|
|
412
|
+
// govulncheck consumes stdout (NDJSON) string; others consume parsed JSON.
|
|
413
|
+
if (toolKey === 'govulncheck') {
|
|
414
|
+
return adapter(runResult.stdout, ctx) || [];
|
|
415
|
+
}
|
|
416
|
+
const payload = runResult.parsed !== undefined ? runResult.parsed : runResult.stdout;
|
|
417
|
+
try {
|
|
418
|
+
return adapter(payload, ctx) || [];
|
|
419
|
+
} catch {
|
|
420
|
+
return [];
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ─── main runScan ─────────────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Pure orchestrator. Composes detector + runner + adapters + config + repos + context.
|
|
428
|
+
* Never throws on per-repo failure; only catastrophic setup errors escape.
|
|
429
|
+
*
|
|
430
|
+
* @param {string} cwd
|
|
431
|
+
* @param {object} [opts] -- future flags (threshold, repo, etc.); {} acceptable in Phase 150
|
|
432
|
+
* @param {object} [deps] -- TEST SEAM; inject { detector, runner, adapters, snykAuth, checkToolOnPath }
|
|
433
|
+
* @returns {{ exit_code: 0|2, tool_per_target, repo_results, findings, skipped, diagnostics }}
|
|
434
|
+
*/
|
|
435
|
+
function runScan(cwd, opts = {}, deps) {
|
|
436
|
+
deps = deps || {};
|
|
437
|
+
const detector = deps.detector || detectEcosystems;
|
|
438
|
+
const runner = deps.runner || runTool;
|
|
439
|
+
const snykAuthFn = deps.snykAuth || resolveSnykAuth;
|
|
440
|
+
const checkToolOnPath = deps.checkToolOnPath || _checkToolOnPath;
|
|
441
|
+
// Phase 153 PKG-33: provenance lookup test seam.
|
|
442
|
+
const provLookupFn = deps.provenanceLookup || provenanceLookup;
|
|
443
|
+
|
|
444
|
+
let pkgCfgRaw;
|
|
445
|
+
try {
|
|
446
|
+
pkgCfgRaw = _readPackagesConfig(cwd);
|
|
447
|
+
} catch {
|
|
448
|
+
pkgCfgRaw = {};
|
|
449
|
+
}
|
|
450
|
+
const pkgCfg = { ...DEFAULTS, ...pkgCfgRaw };
|
|
451
|
+
delete pkgCfg.snyk_token; // never exposed in effective config
|
|
452
|
+
|
|
453
|
+
const snyk = snykAuthFn(cwd);
|
|
454
|
+
const toolsOnPath = (bin) => checkToolOnPath(bin);
|
|
455
|
+
|
|
456
|
+
// Pin-fail-fast pre-check
|
|
457
|
+
if (pkgCfg.tool === 'snyk' && (!snyk.available || !toolsOnPath('snyk').available)) {
|
|
458
|
+
return {
|
|
459
|
+
exit_code: 2,
|
|
460
|
+
diagnostics: [{
|
|
461
|
+
kind: 'tool_unavailable_on_pin',
|
|
462
|
+
tool: 'snyk',
|
|
463
|
+
hint: 'Install Snyk CLI (https://docs.snyk.io/snyk-cli/install-the-snyk-cli) and run `snyk auth` or set SNYK_TOKEN via `dgs-tools config-local-set testing.packages.snyk_token`',
|
|
464
|
+
}],
|
|
465
|
+
findings: [],
|
|
466
|
+
repo_results: [],
|
|
467
|
+
tool_per_target: {},
|
|
468
|
+
skipped: [],
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
if (pkgCfg.tool === 'osv' && !toolsOnPath('osv-scanner').available) {
|
|
472
|
+
return {
|
|
473
|
+
exit_code: 2,
|
|
474
|
+
diagnostics: [{
|
|
475
|
+
kind: 'tool_unavailable_on_pin',
|
|
476
|
+
tool: 'osv-scanner',
|
|
477
|
+
hint: 'Install OSV-Scanner (https://github.com/google/osv-scanner)',
|
|
478
|
+
}],
|
|
479
|
+
findings: [],
|
|
480
|
+
repo_results: [],
|
|
481
|
+
tool_per_target: {},
|
|
482
|
+
skipped: [],
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Phase 153 PKG-28: support opts.only_repo to restrict scan targets.
|
|
487
|
+
const collected = collectScanTargets(cwd, opts.only_repo || null);
|
|
488
|
+
if (collected.error) {
|
|
489
|
+
return {
|
|
490
|
+
exit_code: 2,
|
|
491
|
+
tool_per_target: {},
|
|
492
|
+
repo_results: [],
|
|
493
|
+
findings: [],
|
|
494
|
+
skipped: [],
|
|
495
|
+
diagnostics: [collected.error],
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
const targets = collected.targets;
|
|
499
|
+
// Phase 153 PKG-27: resolve effective threshold (CLI > config > DEFAULT).
|
|
500
|
+
const effectiveThreshold = opts.threshold || pkgCfg.severity_threshold;
|
|
501
|
+
// Phase 153 PKG-28: resolve include_dev_dependencies (CLI > config > DEFAULT).
|
|
502
|
+
if (opts.include_dev_dependencies !== null && opts.include_dev_dependencies !== undefined) {
|
|
503
|
+
pkgCfg.include_dev_dependencies = opts.include_dev_dependencies;
|
|
504
|
+
}
|
|
505
|
+
const repo_results = [];
|
|
506
|
+
const tool_per_target = {};
|
|
507
|
+
const diagnostics = [];
|
|
508
|
+
const skipped = [];
|
|
509
|
+
// Phase 153 PKG-30/34: licence roster accumulator (Snyk only).
|
|
510
|
+
const rosterAccumulator = [];
|
|
511
|
+
|
|
512
|
+
for (const target of targets) {
|
|
513
|
+
let entries = [];
|
|
514
|
+
try {
|
|
515
|
+
entries = detector(target.dir) || [];
|
|
516
|
+
} catch {
|
|
517
|
+
entries = [];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (entries.length === 0) {
|
|
521
|
+
repo_results.push({
|
|
522
|
+
repo: target.name,
|
|
523
|
+
ecosystem: null,
|
|
524
|
+
manifest_path: null,
|
|
525
|
+
tool_used: null,
|
|
526
|
+
tool_used_source: null,
|
|
527
|
+
outcome: 'no_manifests',
|
|
528
|
+
findings: [],
|
|
529
|
+
durationMs: 0,
|
|
530
|
+
});
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Group entries by ecosystem
|
|
535
|
+
const byEco = new Map();
|
|
536
|
+
for (const e of entries) {
|
|
537
|
+
if (!byEco.has(e.ecosystem)) byEco.set(e.ecosystem, []);
|
|
538
|
+
byEco.get(e.ecosystem).push(e);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
for (const [ecosystem, group] of byEco) {
|
|
542
|
+
const selection = selectTool({
|
|
543
|
+
configTool: pkgCfg.tool,
|
|
544
|
+
entryEcosystem: ecosystem,
|
|
545
|
+
snykAuth: snyk,
|
|
546
|
+
toolsOnPath,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// Record tool_per_target (first ecosystem encountered wins for summary view)
|
|
550
|
+
if (tool_per_target[target.name] === undefined && selection.toolKey) {
|
|
551
|
+
tool_per_target[target.name] = selection.toolKey;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (selection.tier === 'unavailable') {
|
|
555
|
+
repo_results.push({
|
|
556
|
+
repo: target.name,
|
|
557
|
+
ecosystem,
|
|
558
|
+
manifest_path: null,
|
|
559
|
+
tool_used: null,
|
|
560
|
+
tool_used_source: null,
|
|
561
|
+
outcome: 'skipped',
|
|
562
|
+
findings: [],
|
|
563
|
+
durationMs: 0,
|
|
564
|
+
diagnostic: {
|
|
565
|
+
kind: selection.reason,
|
|
566
|
+
message: selection.reason === 'forced_tool_unavailable'
|
|
567
|
+
? `ECOSYSTEM_OVERRIDES forced ${selection.details.tool_forced} for ${ecosystem} but the tool is not on PATH`
|
|
568
|
+
: `Pinned tool ${selection.tool} is not available`,
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
skipped.push({ repo: target.name, ecosystem, reason: selection.reason });
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (selection.toolKey === null) {
|
|
576
|
+
// java native path -> no_native_tool_for_ecosystem
|
|
577
|
+
repo_results.push({
|
|
578
|
+
repo: target.name,
|
|
579
|
+
ecosystem,
|
|
580
|
+
manifest_path: null,
|
|
581
|
+
tool_used: null,
|
|
582
|
+
tool_used_source: null,
|
|
583
|
+
outcome: 'no_native_tool_for_ecosystem',
|
|
584
|
+
findings: [],
|
|
585
|
+
durationMs: 0,
|
|
586
|
+
});
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const timeoutMs = (pkgCfg.timeout_seconds || DEFAULTS.timeout_seconds) * 1000;
|
|
591
|
+
const env = (selection.tier === 'snyk' && snyk.token) ? { SNYK_TOKEN: snyk.token } : {};
|
|
592
|
+
|
|
593
|
+
if (selection.tier === 'snyk' || selection.tier === 'osv') {
|
|
594
|
+
// Single invocation at target root; adapter extracts per-finding manifest_path
|
|
595
|
+
const argv = _buildArgv(selection.tier, selection.toolKey, null, pkgCfg, {
|
|
596
|
+
monorepoRoot: group.length > 1 || (group[0] && group[0].manifest_path),
|
|
597
|
+
});
|
|
598
|
+
let runResult;
|
|
599
|
+
try {
|
|
600
|
+
runResult = runner(target.dir, selection.toolKey, argv, { timeoutMs, env, expectJson: true });
|
|
601
|
+
} catch (err) {
|
|
602
|
+
runResult = { outcome: 'tool_failure', exitCode: -1, stdout: '', stderr: err.message || '', duration: 0, timedOut: false };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Phase 153 PKG-32: per-invocation suppression counter; updated by adapterSnyk via ctx.recordSuppressions.
|
|
606
|
+
let suppressionsCount = 0;
|
|
607
|
+
let findings = [];
|
|
608
|
+
if (runResult.outcome === 'ok') {
|
|
609
|
+
findings = _invokeAdapter(selection.toolKey, runResult, {
|
|
610
|
+
repo: target.name,
|
|
611
|
+
ecosystem,
|
|
612
|
+
cwd: target.dir,
|
|
613
|
+
// manifest_path intentionally undefined for single-invoke: adapter extracts from tool JSON
|
|
614
|
+
recordSuppressions: (n) => { suppressionsCount = n; },
|
|
615
|
+
// Phase 153 PKG-30/34: receive licence roster entries (Snyk only).
|
|
616
|
+
recordLicenceRoster: (entries) => {
|
|
617
|
+
if (!Array.isArray(entries)) return;
|
|
618
|
+
for (const e of entries) rosterAccumulator.push({ repo: target.name, ...e });
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
repo_results.push({
|
|
624
|
+
repo: target.name,
|
|
625
|
+
ecosystem,
|
|
626
|
+
manifest_path: null,
|
|
627
|
+
tool_used: selection.toolKey,
|
|
628
|
+
tool_used_source: selection.tier === 'snyk' ? snyk.source : null,
|
|
629
|
+
outcome: runResult.outcome,
|
|
630
|
+
findings,
|
|
631
|
+
durationMs: runResult.duration || 0,
|
|
632
|
+
has_snyk_policy: target.has_snyk_policy === true,
|
|
633
|
+
snyk_suppressions_count: suppressionsCount,
|
|
634
|
+
});
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// selection.tier === 'native'
|
|
639
|
+
// Python monorepo: multiple python entries -> tool_failure per entry (shared venv)
|
|
640
|
+
if (ecosystem === 'python' && group.length > 1 && selection.toolKey === 'pip-audit') {
|
|
641
|
+
for (const entry of group) {
|
|
642
|
+
repo_results.push({
|
|
643
|
+
repo: target.name,
|
|
644
|
+
ecosystem,
|
|
645
|
+
manifest_path: entry.manifest_path,
|
|
646
|
+
tool_used: selection.toolKey,
|
|
647
|
+
tool_used_source: null,
|
|
648
|
+
outcome: 'tool_failure',
|
|
649
|
+
findings: [],
|
|
650
|
+
durationMs: 0,
|
|
651
|
+
diagnostic: {
|
|
652
|
+
kind: 'python_monorepo_shared_venv',
|
|
653
|
+
message: 'Python monorepo native-tool invocation requires per-workspace venv; configure Snyk or install osv-scanner',
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Native loop: one runner call per workspace entry
|
|
661
|
+
for (const entry of group) {
|
|
662
|
+
const entryDir = entry.manifest_path
|
|
663
|
+
? path.join(target.dir, path.dirname(entry.manifest_path))
|
|
664
|
+
: target.dir;
|
|
665
|
+
const argv = _buildArgv(selection.tier, selection.toolKey, entry, pkgCfg);
|
|
666
|
+
let runResult;
|
|
667
|
+
try {
|
|
668
|
+
runResult = runner(entryDir, selection.toolKey, argv, { timeoutMs, env, expectJson: true });
|
|
669
|
+
} catch (err) {
|
|
670
|
+
runResult = { outcome: 'tool_failure', exitCode: -1, stdout: '', stderr: err.message || '', duration: 0, timedOut: false };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
let findings = [];
|
|
674
|
+
if (runResult.outcome === 'ok') {
|
|
675
|
+
findings = _invokeAdapter(selection.toolKey, runResult, {
|
|
676
|
+
repo: target.name,
|
|
677
|
+
ecosystem,
|
|
678
|
+
cwd: entryDir,
|
|
679
|
+
manifest_path: entry.manifest_path,
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
repo_results.push({
|
|
684
|
+
repo: target.name,
|
|
685
|
+
ecosystem,
|
|
686
|
+
manifest_path: entry.manifest_path,
|
|
687
|
+
tool_used: selection.toolKey,
|
|
688
|
+
tool_used_source: null,
|
|
689
|
+
outcome: runResult.outcome,
|
|
690
|
+
findings,
|
|
691
|
+
durationMs: runResult.duration || 0,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Phase 153 PKG-32: ensure every repo_results row carries has_snyk_policy + snyk_suppressions_count.
|
|
698
|
+
// Snyk path already sets both; other paths default has_snyk_policy from target.has_snyk_policy and count to 0.
|
|
699
|
+
const targetByName = new Map();
|
|
700
|
+
for (const t of targets) targetByName.set(t.name, t);
|
|
701
|
+
for (const row of repo_results) {
|
|
702
|
+
if (row.has_snyk_policy === undefined) {
|
|
703
|
+
const t = targetByName.get(row.repo);
|
|
704
|
+
row.has_snyk_policy = t ? (t.has_snyk_policy === true) : false;
|
|
705
|
+
}
|
|
706
|
+
if (row.snyk_suppressions_count === undefined) {
|
|
707
|
+
row.snyk_suppressions_count = 0;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Id assignment invariant (PKG-22 / test-gate spec §5a):
|
|
712
|
+
// - Orchestrator assigns pkg-NNN to every adapter finding in this loop.
|
|
713
|
+
// - Report writer may emit N extra canonical findings per Snyk entry for
|
|
714
|
+
// licence violations (PKG-23). Those carry id = '{pkg-NNN}-lic' —
|
|
715
|
+
// derived at emit time by _canonicalFindingsForAdapter, never
|
|
716
|
+
// re-assigned here. Adapters never assign ids of any kind.
|
|
717
|
+
//
|
|
718
|
+
// Assign pkg-NNN ids AFTER scanning all targets (preserves target/ecosystem/workspace order)
|
|
719
|
+
const findings = [];
|
|
720
|
+
let counter = 1;
|
|
721
|
+
for (const rr of repo_results) {
|
|
722
|
+
for (const f of rr.findings) {
|
|
723
|
+
const id = 'pkg-' + String(counter).padStart(3, '0');
|
|
724
|
+
f.id = id;
|
|
725
|
+
findings.push(f);
|
|
726
|
+
counter += 1;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Phase 153 PKG-27: apply threshold filter to top-level findings AND per-repo
|
|
731
|
+
// sub-arrays so the Summary table's per-row severity counts match.
|
|
732
|
+
const filteredFindings = _filterByThreshold(findings, effectiveThreshold);
|
|
733
|
+
for (const row of repo_results) {
|
|
734
|
+
if (Array.isArray(row.findings)) {
|
|
735
|
+
row.findings = _filterByThreshold(row.findings, effectiveThreshold);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Phase 153 PKG-33: attach plan provenance to each remaining finding.
|
|
740
|
+
// Lookup is memoised per (repoDir, manifestPath, packageName) within this scan.
|
|
741
|
+
const provCache = new Map();
|
|
742
|
+
for (const f of filteredFindings) {
|
|
743
|
+
const repoEntry = targets.find(t => t.name === f.repo);
|
|
744
|
+
const repoDir = repoEntry ? repoEntry.dir : null;
|
|
745
|
+
const manifestPath = f.manifest_path || null;
|
|
746
|
+
const packageName = f.package_name || null;
|
|
747
|
+
const cacheKey = `${repoDir}\u0000${manifestPath}\u0000${packageName}`;
|
|
748
|
+
let prov;
|
|
749
|
+
if (provCache.has(cacheKey)) {
|
|
750
|
+
prov = provCache.get(cacheKey);
|
|
751
|
+
} else {
|
|
752
|
+
prov = provLookupFn({ repoDir, manifestPath, packageName });
|
|
753
|
+
provCache.set(cacheKey, prov);
|
|
754
|
+
}
|
|
755
|
+
f.introduced_in_commit = prov ? prov.commit : null;
|
|
756
|
+
f.introduced_in_plan = prov ? prov.plan : null;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
exit_code: 0,
|
|
761
|
+
tool_per_target,
|
|
762
|
+
repo_results,
|
|
763
|
+
findings: filteredFindings,
|
|
764
|
+
skipped,
|
|
765
|
+
diagnostics,
|
|
766
|
+
threshold_applied: effectiveThreshold,
|
|
767
|
+
// Phase 153 PKG-30/34: licence roster (always an array; empty when no Snyk roster emitted).
|
|
768
|
+
licence_roster: rosterAccumulator,
|
|
769
|
+
// UAT Bug 2: surface resolved snyk_org (config.local.json > config.json > null).
|
|
770
|
+
snyk_org: pkgCfg.snyk_org || null,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ─── Public: cmdPackageScan (CLI entry) ───────────────────────────────────────
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Invoke `dgs-tools commit <msg> --files <path>` in a subprocess, or call the
|
|
778
|
+
* test-seam override if provided. Returns {status, stdout, stderr}.
|
|
779
|
+
*/
|
|
780
|
+
function _invokeCommit(cwd, message, filePath, overrideFn) {
|
|
781
|
+
if (typeof overrideFn === 'function') {
|
|
782
|
+
return overrideFn(message, filePath);
|
|
783
|
+
}
|
|
784
|
+
const dgsToolsPath = path.join(__dirname, '..', 'dgs-tools.cjs');
|
|
785
|
+
const result = spawnSync('node', [dgsToolsPath, 'commit', message, '--files', filePath], {
|
|
786
|
+
cwd,
|
|
787
|
+
encoding: 'utf-8',
|
|
788
|
+
});
|
|
789
|
+
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* CLI entry. Composes runScan -> writePackageScanReport -> atomic commit.
|
|
794
|
+
*
|
|
795
|
+
* Flow:
|
|
796
|
+
* 1. runScan(cwd, {}) -- catastrophic errors short-circuit with exit 2.
|
|
797
|
+
* 2. If raw, emit JSON result via output().
|
|
798
|
+
* 3. If scan exit_code !== 0 (e.g., tool_unavailable_on_pin): NO report
|
|
799
|
+
* written, NO commit attempted; exit with scan's exit_code.
|
|
800
|
+
* 4. If scan exit_code === 0: writePackageScanReport(cwd, result, opts).
|
|
801
|
+
* If write throws, emit diagnostic to stderr and exit 2.
|
|
802
|
+
* 5. _invokeCommit() stages + commits the report file. Commit failure is
|
|
803
|
+
* NON-FATAL (report remains on disk; stderr notes the failure).
|
|
804
|
+
* 6. Emit `Wrote {path} ({n} findings, tool: {tool}).` to stdout (non-raw).
|
|
805
|
+
* 7. exit(scan's exit_code) — always 0 at this point (we short-circuited above).
|
|
806
|
+
*/
|
|
807
|
+
function cmdPackageScan(cwd, args, raw, opts) {
|
|
808
|
+
opts = opts || {};
|
|
809
|
+
// Test seam: opts._runScan lets tests inject a stubbed runScan (with
|
|
810
|
+
// detector/runner/snykAuth/checkToolOnPath stubs already pre-bound) without
|
|
811
|
+
// having to monkey-patch the module export.
|
|
812
|
+
const runScanFn = opts._runScan || runScan;
|
|
813
|
+
|
|
814
|
+
// Phase 153 PKG-27/28: parse CLI argv for --threshold, --repo,
|
|
815
|
+
// --include-dev-deps/--no-include-dev-deps, --json.
|
|
816
|
+
let parsed;
|
|
817
|
+
try {
|
|
818
|
+
parsed = _parseArgs(Array.isArray(args) ? args : []);
|
|
819
|
+
} catch (err) {
|
|
820
|
+
process.stderr.write((err && err.message ? err.message : String(err)) + '\n');
|
|
821
|
+
process.exit(2);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const effectiveRaw = raw || parsed.raw || parsed.json;
|
|
825
|
+
const scanOpts = {
|
|
826
|
+
threshold: parsed.threshold,
|
|
827
|
+
only_repo: parsed.only_repo,
|
|
828
|
+
include_dev_dependencies: parsed.include_dev_dependencies,
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
let result;
|
|
832
|
+
try {
|
|
833
|
+
result = runScanFn(cwd, scanOpts);
|
|
834
|
+
} catch (err) {
|
|
835
|
+
result = {
|
|
836
|
+
exit_code: 2,
|
|
837
|
+
diagnostics: [{ kind: 'setup_error', message: err && err.message ? err.message : String(err) }],
|
|
838
|
+
findings: [],
|
|
839
|
+
repo_results: [],
|
|
840
|
+
tool_per_target: {},
|
|
841
|
+
skipped: [],
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (effectiveRaw) {
|
|
846
|
+
// Emit compact JSON to stdout without calling output() (which calls
|
|
847
|
+
// process.exit), so downstream composition (write report + commit) can
|
|
848
|
+
// still execute. Single-line form so callers can split on newlines.
|
|
849
|
+
process.stdout.write(JSON.stringify(result));
|
|
850
|
+
process.stdout.write('\n');
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Scan-level failure short-circuits before writing or committing.
|
|
854
|
+
if (result.exit_code !== 0) {
|
|
855
|
+
if (!effectiveRaw) {
|
|
856
|
+
const diag = (result.diagnostics && result.diagnostics[0]) || {};
|
|
857
|
+
const kind = diag.kind || 'unknown';
|
|
858
|
+
const hint = diag.hint ? ' — ' + diag.hint : (diag.message ? ' — ' + diag.message : '');
|
|
859
|
+
process.stderr.write(`Scan failed: ${kind}${hint}\n`);
|
|
860
|
+
}
|
|
861
|
+
process.exit(result.exit_code);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Happy path: write the report.
|
|
865
|
+
let reportInfo;
|
|
866
|
+
try {
|
|
867
|
+
reportInfo = writePackageScanReport(cwd, result, opts);
|
|
868
|
+
} catch (err) {
|
|
869
|
+
if (!effectiveRaw) {
|
|
870
|
+
process.stderr.write(`Report generation failed: ${err && err.message ? err.message : String(err)}\n`);
|
|
871
|
+
}
|
|
872
|
+
process.exit(2);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const nFindings = reportInfo.n_findings;
|
|
876
|
+
const toolString = reportInfo.tool_string;
|
|
877
|
+
const reportPath = reportInfo.path;
|
|
878
|
+
const commitMsg = `scan(packages): ${toolString} report — ${nFindings} finding${nFindings === 1 ? '' : 's'}`;
|
|
879
|
+
|
|
880
|
+
const commitResult = _invokeCommit(cwd, commitMsg, reportPath, opts.commit);
|
|
881
|
+
if (commitResult && commitResult.status !== 0) {
|
|
882
|
+
if (!effectiveRaw) {
|
|
883
|
+
const stderrMsg = (commitResult.stderr || '').trim() || 'unknown error';
|
|
884
|
+
process.stderr.write(`Commit failed (report still on disk at ${reportPath}): ${stderrMsg}\n`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (!raw) {
|
|
889
|
+
const countPhrase = nFindings === 0
|
|
890
|
+
? 'clean scan'
|
|
891
|
+
: `${nFindings} finding${nFindings === 1 ? '' : 's'}`;
|
|
892
|
+
process.stdout.write(`Wrote ${reportPath} (${countPhrase}, tool: ${toolString}).\n`);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
process.exit(result.exit_code);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
module.exports = {
|
|
899
|
+
cmdPackageScan,
|
|
900
|
+
runScan,
|
|
901
|
+
collectScanTargets,
|
|
902
|
+
selectTool,
|
|
903
|
+
resolveSnykAuth,
|
|
904
|
+
// Internal exports for test seam only:
|
|
905
|
+
_checkToolOnPath,
|
|
906
|
+
_invokeCommit,
|
|
907
|
+
NATIVE_TOOL_FOR_ECOSYSTEM,
|
|
908
|
+
ADAPTER_FOR_TOOL,
|
|
909
|
+
DEFAULTS,
|
|
910
|
+
// Phase 153 PKG-27/28 — CLI flag plumbing exported for unit tests.
|
|
911
|
+
_parseArgs,
|
|
912
|
+
_severityRank,
|
|
913
|
+
_filterByThreshold,
|
|
914
|
+
SEVERITY_RANK,
|
|
915
|
+
// Phase 153 PKG-32 — .snyk policy detection exported for unit tests.
|
|
916
|
+
_detectSnykPolicy,
|
|
917
|
+
// Re-export for test convenience.
|
|
918
|
+
_collapseSeverity,
|
|
919
|
+
};
|