@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,2147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package-scan.test.cjs -- Unit tests for the Phase 150 orchestrator
|
|
3
|
+
*
|
|
4
|
+
* Covers PKG-01, PKG-02, PKG-03, PKG-04, PKG-05, PKG-15, PKG-16, PKG-17,
|
|
5
|
+
* PKG-18, PKG-19 (read path), PKG-40. Plus integration smoke (Plan 03).
|
|
6
|
+
*
|
|
7
|
+
* All fabrications use `node -e` runner stubs (same pattern as Phase 149)
|
|
8
|
+
* so no real Snyk / OSV / npm-audit / pip-audit / govulncheck / bundler-audit
|
|
9
|
+
* binaries are required. For cascade and selectTool tests, checkToolOnPath and
|
|
10
|
+
* snykAuth are injected to make selection deterministic regardless of host.
|
|
11
|
+
*/
|
|
12
|
+
'use strict';
|
|
13
|
+
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
14
|
+
const assert = require('node:assert');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const { execSync } = require('child_process');
|
|
19
|
+
|
|
20
|
+
const packageScan = require('./package-scan.cjs');
|
|
21
|
+
const {
|
|
22
|
+
cmdPackageScan,
|
|
23
|
+
runScan,
|
|
24
|
+
collectScanTargets,
|
|
25
|
+
selectTool,
|
|
26
|
+
resolveSnykAuth,
|
|
27
|
+
NATIVE_TOOL_FOR_ECOSYSTEM,
|
|
28
|
+
DEFAULTS,
|
|
29
|
+
} = packageScan;
|
|
30
|
+
|
|
31
|
+
const { resetPaths } = require('./paths.cjs');
|
|
32
|
+
|
|
33
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function setupPlanningRoot(opts = {}) {
|
|
36
|
+
const d = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'dgs-pscan-')));
|
|
37
|
+
execSync('git init -q', { cwd: d });
|
|
38
|
+
// config.json
|
|
39
|
+
const config = opts.config || {};
|
|
40
|
+
fs.writeFileSync(path.join(d, 'config.json'), JSON.stringify(config, null, 2));
|
|
41
|
+
// REPOS.md
|
|
42
|
+
if (opts.repos) {
|
|
43
|
+
let md = '# Repos\n\n';
|
|
44
|
+
md += '| Name | Path | GitHub URL | Description |\n';
|
|
45
|
+
md += '|------|------|------------|-------------|\n';
|
|
46
|
+
for (const r of opts.repos) {
|
|
47
|
+
md += `| ${r.name} | ${r.path} | | |\n`;
|
|
48
|
+
}
|
|
49
|
+
fs.writeFileSync(path.join(d, 'REPOS.md'), md);
|
|
50
|
+
}
|
|
51
|
+
if (opts.local) {
|
|
52
|
+
fs.writeFileSync(path.join(d, 'config.local.json'), JSON.stringify(opts.local, null, 2));
|
|
53
|
+
}
|
|
54
|
+
resetPaths();
|
|
55
|
+
return d;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cleanup(d) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
|
|
59
|
+
|
|
60
|
+
function setupWorktreeContext(tmpDir, { repoName, slug, worktreeDir, project = 'testproj' }) {
|
|
61
|
+
const localPath = path.join(tmpDir, 'config.local.json');
|
|
62
|
+
let local = {};
|
|
63
|
+
try { local = JSON.parse(fs.readFileSync(localPath, 'utf-8')); } catch {}
|
|
64
|
+
local.current_project = project;
|
|
65
|
+
local.execution = { ...(local.execution || {}), active_context: slug };
|
|
66
|
+
local.projects = local.projects || {};
|
|
67
|
+
local.projects[project] = local.projects[project] || {};
|
|
68
|
+
local.projects[project].worktrees = local.projects[project].worktrees || {};
|
|
69
|
+
local.projects[project].worktrees[slug] = local.projects[project].worktrees[slug] || { repos: {} };
|
|
70
|
+
local.projects[project].worktrees[slug].repos[repoName] = worktreeDir;
|
|
71
|
+
fs.writeFileSync(localPath, JSON.stringify(local, null, 2));
|
|
72
|
+
resetPaths();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Build a stub runner that records calls and returns a canned outcome per toolKey. */
|
|
76
|
+
function spyRunner(canned = {}) {
|
|
77
|
+
const calls = [];
|
|
78
|
+
const fn = (cwd, toolKey, argv, opts) => {
|
|
79
|
+
calls.push({ cwd, toolKey, argv, opts });
|
|
80
|
+
const entry = canned[toolKey];
|
|
81
|
+
if (typeof entry === 'function') return entry({ cwd, toolKey, argv, opts });
|
|
82
|
+
return entry || {
|
|
83
|
+
exitCode: 0, stdout: '{}', stderr: '', timedOut: false,
|
|
84
|
+
duration: 1, outcome: 'ok', parsed: {},
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
fn.calls = calls;
|
|
88
|
+
return fn;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function makeStubDeps(overrides = {}) {
|
|
92
|
+
return {
|
|
93
|
+
detector: overrides.detector || (() => []),
|
|
94
|
+
runner: overrides.runner || spyRunner(),
|
|
95
|
+
snykAuth: overrides.snykAuth || (() => ({ available: false, source: 'none' })),
|
|
96
|
+
checkToolOnPath: overrides.checkToolOnPath || ((bin) => ({ available: false })),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── selectTool (pure) ────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
describe('selectTool pure function', () => {
|
|
103
|
+
const availSet = (set) => (bin) => ({ available: set.has(bin) });
|
|
104
|
+
const authed = { available: true, source: 'env', token: 't' };
|
|
105
|
+
const unauthed = { available: false, source: 'none' };
|
|
106
|
+
|
|
107
|
+
test('configTool=snyk + auth + snyk on PATH -> snyk', () => {
|
|
108
|
+
const r = selectTool({ configTool: 'snyk', entryEcosystem: 'node', snykAuth: authed, toolsOnPath: availSet(new Set(['snyk'])) });
|
|
109
|
+
assert.strictEqual(r.tier, 'snyk');
|
|
110
|
+
assert.strictEqual(r.toolKey, 'snyk');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('configTool=snyk + no auth -> unavailable/pin', () => {
|
|
114
|
+
const r = selectTool({ configTool: 'snyk', entryEcosystem: 'node', snykAuth: unauthed, toolsOnPath: availSet(new Set(['snyk'])) });
|
|
115
|
+
assert.strictEqual(r.tier, 'unavailable');
|
|
116
|
+
assert.match(String(r.reason), /pin/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('configTool=snyk + snyk not on PATH -> unavailable (pin fails fast)', () => {
|
|
120
|
+
const r = selectTool({ configTool: 'snyk', entryEcosystem: 'node', snykAuth: authed, toolsOnPath: availSet(new Set()) });
|
|
121
|
+
assert.strictEqual(r.tier, 'unavailable');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('configTool=osv + osv-scanner on PATH -> osv', () => {
|
|
125
|
+
const r = selectTool({ configTool: 'osv', entryEcosystem: 'node', snykAuth: unauthed, toolsOnPath: availSet(new Set(['osv-scanner'])) });
|
|
126
|
+
assert.strictEqual(r.tier, 'osv');
|
|
127
|
+
assert.strictEqual(r.toolKey, 'osv-scanner');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('configTool=osv + osv-scanner NOT on PATH -> unavailable', () => {
|
|
131
|
+
const r = selectTool({ configTool: 'osv', entryEcosystem: 'node', snykAuth: unauthed, toolsOnPath: availSet(new Set()) });
|
|
132
|
+
assert.strictEqual(r.tier, 'unavailable');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('configTool=native + node -> npm-audit', () => {
|
|
136
|
+
const r = selectTool({ configTool: 'native', entryEcosystem: 'node', snykAuth: unauthed, toolsOnPath: availSet(new Set()) });
|
|
137
|
+
assert.strictEqual(r.tier, 'native');
|
|
138
|
+
assert.strictEqual(r.toolKey, 'npm-audit');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('configTool=native + python -> pip-audit', () => {
|
|
142
|
+
const r = selectTool({ configTool: 'native', entryEcosystem: 'python', snykAuth: unauthed, toolsOnPath: availSet(new Set()) });
|
|
143
|
+
assert.strictEqual(r.toolKey, 'pip-audit');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('configTool=native + go -> govulncheck', () => {
|
|
147
|
+
const r = selectTool({ configTool: 'native', entryEcosystem: 'go', snykAuth: unauthed, toolsOnPath: availSet(new Set()) });
|
|
148
|
+
assert.strictEqual(r.toolKey, 'govulncheck');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('configTool=native + ruby -> bundler-audit', () => {
|
|
152
|
+
const r = selectTool({ configTool: 'native', entryEcosystem: 'ruby', snykAuth: unauthed, toolsOnPath: availSet(new Set()) });
|
|
153
|
+
assert.strictEqual(r.toolKey, 'bundler-audit');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('configTool=native + java -> toolKey null', () => {
|
|
157
|
+
const r = selectTool({ configTool: 'native', entryEcosystem: 'java', snykAuth: unauthed, toolsOnPath: availSet(new Set()) });
|
|
158
|
+
assert.strictEqual(r.tier, 'native');
|
|
159
|
+
assert.strictEqual(r.toolKey, null);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('configTool=auto + snyk resolvable -> snyk', () => {
|
|
163
|
+
const r = selectTool({ configTool: 'auto', entryEcosystem: 'node', snykAuth: authed, toolsOnPath: availSet(new Set(['snyk'])) });
|
|
164
|
+
assert.strictEqual(r.tier, 'snyk');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('configTool=auto + no snyk + osv on PATH -> osv', () => {
|
|
168
|
+
const r = selectTool({ configTool: 'auto', entryEcosystem: 'node', snykAuth: unauthed, toolsOnPath: availSet(new Set(['osv-scanner'])) });
|
|
169
|
+
assert.strictEqual(r.tier, 'osv');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('configTool=auto + no snyk + no osv -> native for entry ecosystem', () => {
|
|
173
|
+
const r = selectTool({ configTool: 'auto', entryEcosystem: 'python', snykAuth: unauthed, toolsOnPath: availSet(new Set()) });
|
|
174
|
+
assert.strictEqual(r.tier, 'native');
|
|
175
|
+
assert.strictEqual(r.toolKey, 'pip-audit');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('ECOSYSTEM_OVERRIDES["yarn"] forces osv when available (overrides configTool)', () => {
|
|
179
|
+
const r = selectTool({ configTool: 'snyk', entryEcosystem: 'yarn', snykAuth: authed, toolsOnPath: availSet(new Set(['osv-scanner', 'snyk'])) });
|
|
180
|
+
assert.strictEqual(r.tier, 'osv');
|
|
181
|
+
assert.match(r.reason, /override/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('ECOSYSTEM_OVERRIDES["yarn"] forced_tool_unavailable when OSV absent', () => {
|
|
185
|
+
const r = selectTool({ configTool: 'auto', entryEcosystem: 'yarn', snykAuth: authed, toolsOnPath: availSet(new Set(['snyk'])) });
|
|
186
|
+
assert.strictEqual(r.tier, 'unavailable');
|
|
187
|
+
assert.strictEqual(r.reason, 'forced_tool_unavailable');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('native-tier toolKey mapping is a frozen export', () => {
|
|
191
|
+
assert.ok(Object.isFrozen(NATIVE_TOOL_FOR_ECOSYSTEM));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('DEFAULTS.tool === "auto"', () => {
|
|
195
|
+
assert.strictEqual(DEFAULTS.tool, 'auto');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('DEFAULTS.timeout_seconds === 300', () => {
|
|
199
|
+
assert.strictEqual(DEFAULTS.timeout_seconds, 300);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('DEFAULTS.severity_threshold === "low"', () => {
|
|
203
|
+
assert.strictEqual(DEFAULTS.severity_threshold, 'low');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ─── resolveSnykAuth ──────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
describe('resolveSnykAuth (PKG-03 auth precedence, success criterion 3)', () => {
|
|
210
|
+
let tmpDir;
|
|
211
|
+
const origEnv = process.env.SNYK_TOKEN;
|
|
212
|
+
beforeEach(() => { delete process.env.SNYK_TOKEN; });
|
|
213
|
+
afterEach(() => {
|
|
214
|
+
if (origEnv !== undefined) process.env.SNYK_TOKEN = origEnv;
|
|
215
|
+
else delete process.env.SNYK_TOKEN;
|
|
216
|
+
cleanup(tmpDir);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('config.local.json token returns source=config.local.json', () => {
|
|
220
|
+
tmpDir = setupPlanningRoot({
|
|
221
|
+
local: { testing: { packages: { snyk_token: 'local-tok' } } },
|
|
222
|
+
});
|
|
223
|
+
const r = resolveSnykAuth(tmpDir);
|
|
224
|
+
assert.strictEqual(r.available, true);
|
|
225
|
+
assert.strictEqual(r.source, 'config.local.json');
|
|
226
|
+
assert.strictEqual(r.token, 'local-tok');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('SNYK_TOKEN env only -> source=env', () => {
|
|
230
|
+
tmpDir = setupPlanningRoot();
|
|
231
|
+
process.env.SNYK_TOKEN = 'env-tok';
|
|
232
|
+
const r = resolveSnykAuth(tmpDir);
|
|
233
|
+
assert.strictEqual(r.source, 'env');
|
|
234
|
+
assert.strictEqual(r.token, 'env-tok');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('config.local.json wins over env (precedence)', () => {
|
|
238
|
+
tmpDir = setupPlanningRoot({
|
|
239
|
+
local: { testing: { packages: { snyk_token: 'local-tok' } } },
|
|
240
|
+
});
|
|
241
|
+
process.env.SNYK_TOKEN = 'env-tok';
|
|
242
|
+
const r = resolveSnykAuth(tmpDir);
|
|
243
|
+
assert.strictEqual(r.source, 'config.local.json');
|
|
244
|
+
assert.strictEqual(r.token, 'local-tok');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('none of three sources -> available=false source=none', () => {
|
|
248
|
+
tmpDir = setupPlanningRoot();
|
|
249
|
+
// Assume snyk CLI is not installed OR returns empty on the test host.
|
|
250
|
+
const r = resolveSnykAuth(tmpDir);
|
|
251
|
+
// If snyk happens to be installed with config, this test would false-positive;
|
|
252
|
+
// we only assert the shape contract.
|
|
253
|
+
assert.ok(typeof r.available === 'boolean');
|
|
254
|
+
assert.ok(['config.local.json', 'env', 'snyk-cli-config', 'snyk-cli-whoami', 'none'].includes(r.source));
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ─── UAT Bug 1: fourth auth source (snyk whoami / configstore OAuth) ───────
|
|
258
|
+
test('snyk whoami exit 0 (no api config) -> source=snyk-cli-whoami, auth_source=oauth_configstore', () => {
|
|
259
|
+
tmpDir = setupPlanningRoot();
|
|
260
|
+
const fakeSpawn = (cmd, args) => {
|
|
261
|
+
if (args[0] === 'config') return { status: 1, stdout: '', stderr: '' };
|
|
262
|
+
if (args[0] === 'whoami') return { status: 0, stdout: 'user@example.com\n', stderr: '' };
|
|
263
|
+
return { status: 1, stdout: '', stderr: '' };
|
|
264
|
+
};
|
|
265
|
+
const r = resolveSnykAuth(tmpDir, { _spawnSync: fakeSpawn });
|
|
266
|
+
assert.strictEqual(r.available, true);
|
|
267
|
+
assert.strictEqual(r.source, 'snyk-cli-whoami');
|
|
268
|
+
assert.strictEqual(r.auth_source, 'oauth_configstore');
|
|
269
|
+
assert.strictEqual(r.token, undefined);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('auth_source field populated for all four successful sources', () => {
|
|
273
|
+
// 1. dgs_local (config.local.json)
|
|
274
|
+
tmpDir = setupPlanningRoot({
|
|
275
|
+
local: { testing: { packages: { snyk_token: 'tok' } } },
|
|
276
|
+
});
|
|
277
|
+
assert.strictEqual(resolveSnykAuth(tmpDir).auth_source, 'dgs_local');
|
|
278
|
+
cleanup(tmpDir);
|
|
279
|
+
|
|
280
|
+
// 2. env_token (SNYK_TOKEN env)
|
|
281
|
+
tmpDir = setupPlanningRoot();
|
|
282
|
+
process.env.SNYK_TOKEN = 'env-tok';
|
|
283
|
+
assert.strictEqual(resolveSnykAuth(tmpDir).auth_source, 'env_token');
|
|
284
|
+
delete process.env.SNYK_TOKEN;
|
|
285
|
+
cleanup(tmpDir);
|
|
286
|
+
|
|
287
|
+
// 3. api_config (snyk config get api)
|
|
288
|
+
tmpDir = setupPlanningRoot();
|
|
289
|
+
const fakeApi = (cmd, args) => args[0] === 'config' ? { status: 0, stdout: 'api-tok\n' } : { status: 1, stdout: '' };
|
|
290
|
+
assert.strictEqual(resolveSnykAuth(tmpDir, { _spawnSync: fakeApi }).auth_source, 'api_config');
|
|
291
|
+
cleanup(tmpDir);
|
|
292
|
+
|
|
293
|
+
// 4. oauth_configstore (snyk whoami)
|
|
294
|
+
tmpDir = setupPlanningRoot();
|
|
295
|
+
const fakeWho = (cmd, args) => args[0] === 'whoami' ? { status: 0, stdout: 'u@x\n' } : { status: 1, stdout: '' };
|
|
296
|
+
assert.strictEqual(resolveSnykAuth(tmpDir, { _spawnSync: fakeWho }).auth_source, 'oauth_configstore');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('all four sources fail -> available=false, auth_source=null', () => {
|
|
300
|
+
tmpDir = setupPlanningRoot();
|
|
301
|
+
const fakeFail = () => ({ status: 1, stdout: '', stderr: '' });
|
|
302
|
+
const r = resolveSnykAuth(tmpDir, { _spawnSync: fakeFail });
|
|
303
|
+
assert.strictEqual(r.available, false);
|
|
304
|
+
assert.strictEqual(r.source, 'none');
|
|
305
|
+
assert.strictEqual(r.auth_source, null);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('token NEVER leaks via JSON.stringify of runScan result', () => {
|
|
309
|
+
tmpDir = setupPlanningRoot({
|
|
310
|
+
local: { testing: { packages: { snyk_token: 's3cret-leak-test-zzz' } } },
|
|
311
|
+
});
|
|
312
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
313
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' || bin === 'npm' }),
|
|
314
|
+
snykAuth: () => ({ available: true, source: 'config.local.json', token: 's3cret-leak-test-zzz' }),
|
|
315
|
+
}));
|
|
316
|
+
assert.doesNotMatch(JSON.stringify(result), /s3cret-leak-test-zzz/);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ─── collectScanTargets ───────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
describe('collectScanTargets (PKG-15, PKG-16)', () => {
|
|
323
|
+
let tmpDir;
|
|
324
|
+
afterEach(() => cleanup(tmpDir));
|
|
325
|
+
|
|
326
|
+
test('no REPOS.md returns [{ _product_root }]', () => {
|
|
327
|
+
tmpDir = setupPlanningRoot();
|
|
328
|
+
const { targets } = collectScanTargets(tmpDir);
|
|
329
|
+
assert.strictEqual(targets.length, 1);
|
|
330
|
+
assert.strictEqual(targets[0].name, '_product_root');
|
|
331
|
+
assert.strictEqual(targets[0].dir, tmpDir);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('REPOS.md with 2 repos returns 3 targets with _product_root last', () => {
|
|
335
|
+
tmpDir = setupPlanningRoot({ repos: [
|
|
336
|
+
{ name: 'api', path: '../api' },
|
|
337
|
+
{ name: 'web', path: '../web' },
|
|
338
|
+
]});
|
|
339
|
+
const { targets } = collectScanTargets(tmpDir);
|
|
340
|
+
assert.strictEqual(targets.length, 3);
|
|
341
|
+
assert.strictEqual(targets[0].name, 'api');
|
|
342
|
+
assert.strictEqual(targets[1].name, 'web');
|
|
343
|
+
assert.strictEqual(targets[2].name, '_product_root');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('_product_root.dir is planning root', () => {
|
|
347
|
+
tmpDir = setupPlanningRoot({ repos: [{ name: 'api', path: '../api' }] });
|
|
348
|
+
const { targets } = collectScanTargets(tmpDir);
|
|
349
|
+
const pr = targets.find(t => t.name === '_product_root');
|
|
350
|
+
assert.strictEqual(pr.dir, tmpDir);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('with worktree context, repo dir comes from worktree (not main path)', () => {
|
|
354
|
+
tmpDir = setupPlanningRoot({ repos: [{ name: 'api', path: '../api' }] });
|
|
355
|
+
const worktreeDir = path.join(tmpDir, 'worktree-api');
|
|
356
|
+
fs.mkdirSync(worktreeDir, { recursive: true });
|
|
357
|
+
setupWorktreeContext(tmpDir, { repoName: 'api', slug: 'v1.0', worktreeDir });
|
|
358
|
+
const { targets } = collectScanTargets(tmpDir);
|
|
359
|
+
const api = targets.find(t => t.name === 'api');
|
|
360
|
+
assert.strictEqual(api.dir, worktreeDir);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ─── runScan — clean scans ───────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
describe('runScan - clean scans and skips', () => {
|
|
367
|
+
let tmpDir;
|
|
368
|
+
afterEach(() => cleanup(tmpDir));
|
|
369
|
+
|
|
370
|
+
test('empty REPOS.md + no manifests -> exit_code 0, no_manifests outcome', () => {
|
|
371
|
+
tmpDir = setupPlanningRoot();
|
|
372
|
+
const result = runScan(tmpDir, {}, makeStubDeps({ detector: () => [] }));
|
|
373
|
+
assert.strictEqual(result.exit_code, 0);
|
|
374
|
+
assert.strictEqual(result.findings.length, 0);
|
|
375
|
+
assert.strictEqual(result.repo_results.length, 1);
|
|
376
|
+
assert.strictEqual(result.repo_results[0].outcome, 'no_manifests');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test('one repo with no manifest -> outcome no_manifests, no tool invocation', () => {
|
|
380
|
+
tmpDir = setupPlanningRoot({ repos: [{ name: 'api', path: '../api' }] });
|
|
381
|
+
const runner = spyRunner();
|
|
382
|
+
const result = runScan(tmpDir, {}, makeStubDeps({ detector: () => [], runner }));
|
|
383
|
+
assert.strictEqual(runner.calls.length, 0);
|
|
384
|
+
assert.strictEqual(result.exit_code, 0);
|
|
385
|
+
const apiResult = result.repo_results.find(r => r.repo === 'api');
|
|
386
|
+
assert.strictEqual(apiResult.outcome, 'no_manifests');
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('skip entries do not consume pkg-NNN ids', () => {
|
|
390
|
+
tmpDir = setupPlanningRoot();
|
|
391
|
+
const result = runScan(tmpDir, {}, makeStubDeps({ detector: () => [] }));
|
|
392
|
+
assert.strictEqual(result.findings.length, 0);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// ─── runScan — cascade and pins ───────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
describe('runScan - cascade and pins (PKG-03, PKG-17, PKG-40)', () => {
|
|
399
|
+
let tmpDir;
|
|
400
|
+
afterEach(() => cleanup(tmpDir));
|
|
401
|
+
|
|
402
|
+
test('auto + snyk resolvable -> tool_used: snyk on every target', () => {
|
|
403
|
+
tmpDir = setupPlanningRoot({ config: { testing: { packages: { tool: 'auto' } } } });
|
|
404
|
+
const runner = spyRunner({
|
|
405
|
+
'snyk': { exitCode: 0, stdout: '{}', stderr: '', timedOut: false, duration: 1, outcome: 'ok', parsed: { vulnerabilities: [] } },
|
|
406
|
+
});
|
|
407
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
408
|
+
detector: (dir) => dir === tmpDir ? [{ ecosystem: 'node', manifest_path: null }] : [],
|
|
409
|
+
runner,
|
|
410
|
+
snykAuth: () => ({ available: true, source: 'env', token: 'TK' }),
|
|
411
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
412
|
+
}));
|
|
413
|
+
const productEntry = result.repo_results.find(r => r.repo === '_product_root');
|
|
414
|
+
assert.strictEqual(productEntry.tool_used, 'snyk');
|
|
415
|
+
assert.strictEqual(runner.calls[0].opts.env.SNYK_TOKEN, 'TK');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('auto + no snyk + osv on path -> tool_used: osv-scanner', () => {
|
|
419
|
+
tmpDir = setupPlanningRoot();
|
|
420
|
+
const runner = spyRunner({
|
|
421
|
+
'osv-scanner': { exitCode: 0, stdout: '{}', stderr: '', timedOut: false, duration: 1, outcome: 'ok', parsed: { results: [] } },
|
|
422
|
+
});
|
|
423
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
424
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
425
|
+
runner,
|
|
426
|
+
snykAuth: () => ({ available: false, source: 'none' }),
|
|
427
|
+
checkToolOnPath: (bin) => ({ available: bin === 'osv-scanner' }),
|
|
428
|
+
}));
|
|
429
|
+
const entry = result.repo_results.find(r => r.tool_used);
|
|
430
|
+
assert.strictEqual(entry.tool_used, 'osv-scanner');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('auto + no snyk + no osv -> native tool for each ecosystem', () => {
|
|
434
|
+
tmpDir = setupPlanningRoot();
|
|
435
|
+
const runner = spyRunner({
|
|
436
|
+
'npm-audit': { exitCode: 0, stdout: '{}', outcome: 'ok', parsed: {}, duration: 1 },
|
|
437
|
+
});
|
|
438
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
439
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
440
|
+
runner,
|
|
441
|
+
snykAuth: () => ({ available: false, source: 'none' }),
|
|
442
|
+
checkToolOnPath: () => ({ available: false }),
|
|
443
|
+
}));
|
|
444
|
+
const entry = result.repo_results.find(r => r.tool_used);
|
|
445
|
+
assert.strictEqual(entry.tool_used, 'npm-audit');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test('pin snyk + auth absent -> exit_code 2, tool_unavailable_on_pin diagnostic, NO runner calls', () => {
|
|
449
|
+
tmpDir = setupPlanningRoot({ config: { testing: { packages: { tool: 'snyk' } } } });
|
|
450
|
+
const runner = spyRunner();
|
|
451
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
452
|
+
runner,
|
|
453
|
+
snykAuth: () => ({ available: false, source: 'none' }),
|
|
454
|
+
checkToolOnPath: () => ({ available: false }),
|
|
455
|
+
}));
|
|
456
|
+
assert.strictEqual(result.exit_code, 2);
|
|
457
|
+
assert.strictEqual(runner.calls.length, 0);
|
|
458
|
+
assert.strictEqual(result.diagnostics[0].kind, 'tool_unavailable_on_pin');
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test('pin osv + osv not on path -> exit_code 2, install hint', () => {
|
|
462
|
+
tmpDir = setupPlanningRoot({ config: { testing: { packages: { tool: 'osv' } } } });
|
|
463
|
+
const runner = spyRunner();
|
|
464
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
465
|
+
runner,
|
|
466
|
+
checkToolOnPath: () => ({ available: false }),
|
|
467
|
+
}));
|
|
468
|
+
assert.strictEqual(result.exit_code, 2);
|
|
469
|
+
assert.match(result.diagnostics[0].hint, /OSV-Scanner/);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test('findings present -> exit_code 0 (PKG-40)', () => {
|
|
473
|
+
tmpDir = setupPlanningRoot();
|
|
474
|
+
const runner = spyRunner({
|
|
475
|
+
'npm-audit': {
|
|
476
|
+
exitCode: 1, stdout: '{}', outcome: 'ok', duration: 1,
|
|
477
|
+
parsed: { vulnerabilities: { 'lodash': { severity: 'high', via: [{ url: 'u', title: 't' }] } } },
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
481
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
482
|
+
runner,
|
|
483
|
+
checkToolOnPath: () => ({ available: false }),
|
|
484
|
+
}));
|
|
485
|
+
assert.strictEqual(result.exit_code, 0);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('tool_failure in one target + ok in another -> exit_code 0', () => {
|
|
489
|
+
tmpDir = setupPlanningRoot({ repos: [{ name: 'api', path: '../api' }] });
|
|
490
|
+
const runner = spyRunner({
|
|
491
|
+
'npm-audit': ({ cwd }) => {
|
|
492
|
+
if (cwd.endsWith('api')) return { exitCode: 2, stderr: 'boom', outcome: 'tool_failure', duration: 1 };
|
|
493
|
+
return { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 1, parsed: { vulnerabilities: {} } };
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
497
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
498
|
+
runner,
|
|
499
|
+
checkToolOnPath: () => ({ available: false }),
|
|
500
|
+
}));
|
|
501
|
+
assert.strictEqual(result.exit_code, 0);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('process.env not mutated; SNYK_TOKEN not set after', () => {
|
|
505
|
+
tmpDir = setupPlanningRoot();
|
|
506
|
+
process.env._DGS_CANARY_150 = 'alive';
|
|
507
|
+
const origSnyk = process.env.SNYK_TOKEN;
|
|
508
|
+
delete process.env.SNYK_TOKEN;
|
|
509
|
+
runScan(tmpDir, {}, makeStubDeps({
|
|
510
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
511
|
+
runner: spyRunner({ 'snyk': { exitCode: 0, stdout: '{}', outcome: 'ok', parsed: {}, duration: 1 } }),
|
|
512
|
+
snykAuth: () => ({ available: true, source: 'env', token: 'TOK' }),
|
|
513
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
514
|
+
}));
|
|
515
|
+
assert.strictEqual(process.env._DGS_CANARY_150, 'alive');
|
|
516
|
+
assert.strictEqual(process.env.SNYK_TOKEN, undefined);
|
|
517
|
+
delete process.env._DGS_CANARY_150;
|
|
518
|
+
if (origSnyk !== undefined) process.env.SNYK_TOKEN = origSnyk;
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('ECOSYSTEM_OVERRIDES yarn -> osv when available, even when configTool auto and snyk resolvable', () => {
|
|
522
|
+
tmpDir = setupPlanningRoot();
|
|
523
|
+
const runner = spyRunner({
|
|
524
|
+
'osv-scanner': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 1, parsed: { results: [] } },
|
|
525
|
+
});
|
|
526
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
527
|
+
detector: () => [{ ecosystem: 'yarn', manifest_path: null }],
|
|
528
|
+
runner,
|
|
529
|
+
snykAuth: () => ({ available: true, source: 'env', token: 'T' }),
|
|
530
|
+
checkToolOnPath: (bin) => ({ available: bin === 'osv-scanner' || bin === 'snyk' }),
|
|
531
|
+
}));
|
|
532
|
+
const entry = result.repo_results.find(r => r.tool_used);
|
|
533
|
+
assert.strictEqual(entry && entry.tool_used, 'osv-scanner');
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('ECOSYSTEM_OVERRIDES yarn + OSV unavailable -> forced_tool_unavailable diagnostic', () => {
|
|
537
|
+
tmpDir = setupPlanningRoot();
|
|
538
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
539
|
+
detector: () => [{ ecosystem: 'yarn', manifest_path: null }],
|
|
540
|
+
runner: spyRunner(),
|
|
541
|
+
checkToolOnPath: () => ({ available: false }),
|
|
542
|
+
}));
|
|
543
|
+
const entry = result.repo_results.find(r => r.outcome === 'skipped' && r.diagnostic);
|
|
544
|
+
assert.ok(entry);
|
|
545
|
+
assert.strictEqual(entry.diagnostic.kind, 'forced_tool_unavailable');
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// ─── runScan — snyk_org argv threading (UAT Bug 2) ───────────────────────────
|
|
550
|
+
|
|
551
|
+
describe('snyk_org argv threading (UAT Bug 2)', () => {
|
|
552
|
+
let tmpDir;
|
|
553
|
+
afterEach(() => cleanup(tmpDir));
|
|
554
|
+
|
|
555
|
+
test('config.json snyk_org -> argv contains --org=<ID>', () => {
|
|
556
|
+
tmpDir = setupPlanningRoot({
|
|
557
|
+
config: { testing: { packages: { snyk_org: 'ORG-UUID-A' } } },
|
|
558
|
+
});
|
|
559
|
+
const runner = spyRunner({
|
|
560
|
+
snyk: { exitCode: 0, stdout: '{}', stderr: '', timedOut: false, duration: 1, outcome: 'ok', parsed: { vulnerabilities: [] } },
|
|
561
|
+
});
|
|
562
|
+
runScan(tmpDir, {}, makeStubDeps({
|
|
563
|
+
detector: () => [{ ecosystem: 'node', manifest_path: 'package.json' }],
|
|
564
|
+
runner,
|
|
565
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
566
|
+
snykAuth: () => ({ available: true, source: 'env', auth_source: 'env_token', token: 'T' }),
|
|
567
|
+
}));
|
|
568
|
+
assert.ok(runner.calls.length >= 1, `expected at least one runner call; got ${runner.calls.length}`);
|
|
569
|
+
assert.ok(runner.calls[0].argv.includes('--org=ORG-UUID-A'),
|
|
570
|
+
`argv missing --org: ${JSON.stringify(runner.calls[0].argv)}`);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test('config.local.json snyk_org wins over config.json', () => {
|
|
574
|
+
tmpDir = setupPlanningRoot({
|
|
575
|
+
config: { testing: { packages: { snyk_org: 'ORG-SHARED' } } },
|
|
576
|
+
local: { testing: { packages: { snyk_org: 'ORG-LOCAL' } } },
|
|
577
|
+
});
|
|
578
|
+
const runner = spyRunner({
|
|
579
|
+
snyk: { exitCode: 0, stdout: '{}', stderr: '', timedOut: false, duration: 1, outcome: 'ok', parsed: { vulnerabilities: [] } },
|
|
580
|
+
});
|
|
581
|
+
runScan(tmpDir, {}, makeStubDeps({
|
|
582
|
+
detector: () => [{ ecosystem: 'node', manifest_path: 'package.json' }],
|
|
583
|
+
runner,
|
|
584
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
585
|
+
snykAuth: () => ({ available: true, source: 'env', auth_source: 'env_token', token: 'T' }),
|
|
586
|
+
}));
|
|
587
|
+
assert.ok(runner.calls[0].argv.includes('--org=ORG-LOCAL'));
|
|
588
|
+
assert.ok(!runner.calls[0].argv.includes('--org=ORG-SHARED'));
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test('null/unset snyk_org -> no --org flag in argv', () => {
|
|
592
|
+
tmpDir = setupPlanningRoot();
|
|
593
|
+
const runner = spyRunner({
|
|
594
|
+
snyk: { exitCode: 0, stdout: '{}', stderr: '', timedOut: false, duration: 1, outcome: 'ok', parsed: { vulnerabilities: [] } },
|
|
595
|
+
});
|
|
596
|
+
runScan(tmpDir, {}, makeStubDeps({
|
|
597
|
+
detector: () => [{ ecosystem: 'node', manifest_path: 'package.json' }],
|
|
598
|
+
runner,
|
|
599
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
600
|
+
snykAuth: () => ({ available: true, source: 'env', auth_source: 'env_token', token: 'T' }),
|
|
601
|
+
}));
|
|
602
|
+
for (const a of runner.calls[0].argv) {
|
|
603
|
+
assert.ok(!String(a).startsWith('--org'), `unexpected --org in argv: ${a}`);
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test('runScan result surfaces snyk_org field', () => {
|
|
608
|
+
tmpDir = setupPlanningRoot({
|
|
609
|
+
config: { testing: { packages: { snyk_org: 'ORG-RES' } } },
|
|
610
|
+
});
|
|
611
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
612
|
+
detector: () => [],
|
|
613
|
+
checkToolOnPath: () => ({ available: false }),
|
|
614
|
+
snykAuth: () => ({ available: false, source: 'none', auth_source: null }),
|
|
615
|
+
}));
|
|
616
|
+
assert.strictEqual(result.snyk_org, 'ORG-RES');
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test('runScan result with no snyk_org -> snyk_org: null', () => {
|
|
620
|
+
tmpDir = setupPlanningRoot();
|
|
621
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
622
|
+
detector: () => [],
|
|
623
|
+
checkToolOnPath: () => ({ available: false }),
|
|
624
|
+
snykAuth: () => ({ available: false, source: 'none', auth_source: null }),
|
|
625
|
+
}));
|
|
626
|
+
assert.strictEqual(result.snyk_org, null);
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// ─── runScan — worktree targeting ─────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
describe('runScan - worktree targeting (spec success criterion 4, PKG-04)', () => {
|
|
633
|
+
let tmpDir;
|
|
634
|
+
afterEach(() => cleanup(tmpDir));
|
|
635
|
+
|
|
636
|
+
test('active_context maps repoA to worktree -> runner cwd = worktree', () => {
|
|
637
|
+
tmpDir = setupPlanningRoot({ repos: [{ name: 'api', path: '../api' }] });
|
|
638
|
+
const worktreeDir = path.join(tmpDir, 'wt-api');
|
|
639
|
+
fs.mkdirSync(worktreeDir, { recursive: true });
|
|
640
|
+
setupWorktreeContext(tmpDir, { repoName: 'api', slug: 'v1.0', worktreeDir });
|
|
641
|
+
const runner = spyRunner({
|
|
642
|
+
'npm-audit': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 1, parsed: { vulnerabilities: {} } },
|
|
643
|
+
});
|
|
644
|
+
runScan(tmpDir, {}, makeStubDeps({
|
|
645
|
+
detector: (dir) => dir === worktreeDir ? [{ ecosystem: 'node', manifest_path: null }] : [],
|
|
646
|
+
runner,
|
|
647
|
+
checkToolOnPath: () => ({ available: false }),
|
|
648
|
+
}));
|
|
649
|
+
const call = runner.calls[0];
|
|
650
|
+
assert.strictEqual(call.cwd, worktreeDir);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test('no active_context -> runner uses main checkout path', () => {
|
|
654
|
+
tmpDir = setupPlanningRoot({ repos: [{ name: 'api', path: '../api' }] });
|
|
655
|
+
const runner = spyRunner({
|
|
656
|
+
'npm-audit': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 1, parsed: {} },
|
|
657
|
+
});
|
|
658
|
+
runScan(tmpDir, {}, makeStubDeps({
|
|
659
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
660
|
+
runner,
|
|
661
|
+
checkToolOnPath: () => ({ available: false }),
|
|
662
|
+
}));
|
|
663
|
+
// main path = <planningRootParent>/api
|
|
664
|
+
const expected = path.resolve(tmpDir, '../api');
|
|
665
|
+
assert.strictEqual(runner.calls[0].cwd, expected);
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
// ─── runScan — monorepo handling ──────────────────────────────────────────────
|
|
670
|
+
|
|
671
|
+
describe('runScan - monorepo handling (PKG-03 v1.1)', () => {
|
|
672
|
+
let tmpDir;
|
|
673
|
+
afterEach(() => cleanup(tmpDir));
|
|
674
|
+
|
|
675
|
+
test('native tier + 3 node workspace entries -> 3 runner calls', () => {
|
|
676
|
+
tmpDir = setupPlanningRoot();
|
|
677
|
+
const runner = spyRunner({
|
|
678
|
+
'npm-audit': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 1, parsed: {} },
|
|
679
|
+
});
|
|
680
|
+
runScan(tmpDir, {}, makeStubDeps({
|
|
681
|
+
detector: () => [
|
|
682
|
+
{ ecosystem: 'node', manifest_path: 'packages/a/package.json' },
|
|
683
|
+
{ ecosystem: 'node', manifest_path: 'packages/b/package.json' },
|
|
684
|
+
{ ecosystem: 'node', manifest_path: 'packages/c/package.json' },
|
|
685
|
+
],
|
|
686
|
+
runner,
|
|
687
|
+
checkToolOnPath: () => ({ available: false }),
|
|
688
|
+
}));
|
|
689
|
+
assert.strictEqual(runner.calls.length, 3);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test('native tier + 3 workspace entries -> adapter ctx.manifest_path matches each', () => {
|
|
693
|
+
tmpDir = setupPlanningRoot();
|
|
694
|
+
const runner = spyRunner({
|
|
695
|
+
'npm-audit': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 1, parsed: {} },
|
|
696
|
+
});
|
|
697
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
698
|
+
detector: () => [
|
|
699
|
+
{ ecosystem: 'node', manifest_path: 'a/package.json' },
|
|
700
|
+
{ ecosystem: 'node', manifest_path: 'b/package.json' },
|
|
701
|
+
],
|
|
702
|
+
runner,
|
|
703
|
+
checkToolOnPath: () => ({ available: false }),
|
|
704
|
+
}));
|
|
705
|
+
const manifests = result.repo_results.filter(r => r.tool_used === 'npm-audit').map(r => r.manifest_path);
|
|
706
|
+
assert.deepStrictEqual(manifests.sort(), ['a/package.json', 'b/package.json']);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test('snyk tier + 3 workspace entries -> runner called ONCE with --all-projects', () => {
|
|
710
|
+
tmpDir = setupPlanningRoot();
|
|
711
|
+
const runner = spyRunner({
|
|
712
|
+
'snyk': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 1, parsed: { vulnerabilities: [] } },
|
|
713
|
+
});
|
|
714
|
+
runScan(tmpDir, {}, makeStubDeps({
|
|
715
|
+
detector: () => [
|
|
716
|
+
{ ecosystem: 'node', manifest_path: 'a/package.json' },
|
|
717
|
+
{ ecosystem: 'node', manifest_path: 'b/package.json' },
|
|
718
|
+
{ ecosystem: 'node', manifest_path: 'c/package.json' },
|
|
719
|
+
],
|
|
720
|
+
runner,
|
|
721
|
+
snykAuth: () => ({ available: true, source: 'env', token: 'T' }),
|
|
722
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
723
|
+
}));
|
|
724
|
+
assert.strictEqual(runner.calls.length, 1);
|
|
725
|
+
assert.ok(runner.calls[0].argv.includes('--all-projects'));
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test('osv tier + 3 workspace entries -> runner called ONCE with -r .', () => {
|
|
729
|
+
tmpDir = setupPlanningRoot();
|
|
730
|
+
const runner = spyRunner({
|
|
731
|
+
'osv-scanner': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 1, parsed: { results: [] } },
|
|
732
|
+
});
|
|
733
|
+
runScan(tmpDir, {}, makeStubDeps({
|
|
734
|
+
detector: () => [
|
|
735
|
+
{ ecosystem: 'node', manifest_path: 'a/package.json' },
|
|
736
|
+
{ ecosystem: 'node', manifest_path: 'b/package.json' },
|
|
737
|
+
{ ecosystem: 'node', manifest_path: 'c/package.json' },
|
|
738
|
+
],
|
|
739
|
+
runner,
|
|
740
|
+
checkToolOnPath: (bin) => ({ available: bin === 'osv-scanner' }),
|
|
741
|
+
}));
|
|
742
|
+
assert.strictEqual(runner.calls.length, 1);
|
|
743
|
+
assert.ok(runner.calls[0].argv.includes('-r'));
|
|
744
|
+
assert.ok(runner.calls[0].argv.includes('.'));
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
test('python monorepo + native tier -> 2 tool_failure, 0 pip-audit calls', () => {
|
|
748
|
+
tmpDir = setupPlanningRoot();
|
|
749
|
+
const runner = spyRunner();
|
|
750
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
751
|
+
detector: () => [
|
|
752
|
+
{ ecosystem: 'python', manifest_path: 'svc-a/requirements.txt' },
|
|
753
|
+
{ ecosystem: 'python', manifest_path: 'svc-b/requirements.txt' },
|
|
754
|
+
],
|
|
755
|
+
runner,
|
|
756
|
+
checkToolOnPath: () => ({ available: false }),
|
|
757
|
+
}));
|
|
758
|
+
const pipCalls = runner.calls.filter(c => c.toolKey === 'pip-audit').length;
|
|
759
|
+
assert.strictEqual(pipCalls, 0);
|
|
760
|
+
const failures = result.repo_results.filter(r => r.outcome === 'tool_failure' && r.diagnostic && r.diagnostic.kind === 'python_monorepo_shared_venv');
|
|
761
|
+
assert.strictEqual(failures.length, 2);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test('mixed target (node + python at root) + native -> 2 runner calls (npm-audit + pip-audit)', () => {
|
|
765
|
+
tmpDir = setupPlanningRoot();
|
|
766
|
+
const runner = spyRunner({
|
|
767
|
+
'npm-audit': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 1, parsed: {} },
|
|
768
|
+
'pip-audit': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 1, parsed: [] },
|
|
769
|
+
});
|
|
770
|
+
runScan(tmpDir, {}, makeStubDeps({
|
|
771
|
+
detector: () => [
|
|
772
|
+
{ ecosystem: 'node', manifest_path: null },
|
|
773
|
+
{ ecosystem: 'python', manifest_path: null },
|
|
774
|
+
],
|
|
775
|
+
runner,
|
|
776
|
+
checkToolOnPath: () => ({ available: false }),
|
|
777
|
+
}));
|
|
778
|
+
assert.strictEqual(runner.calls.length, 2);
|
|
779
|
+
const tools = runner.calls.map(c => c.toolKey).sort();
|
|
780
|
+
assert.deepStrictEqual(tools, ['npm-audit', 'pip-audit']);
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// ─── runScan — finding id assignment ──────────────────────────────────────────
|
|
785
|
+
|
|
786
|
+
describe('runScan - finding id assignment + aggregation (PKG-05, PKG-18)', () => {
|
|
787
|
+
let tmpDir;
|
|
788
|
+
afterEach(() => cleanup(tmpDir));
|
|
789
|
+
|
|
790
|
+
function fakeNpmWithVulns() {
|
|
791
|
+
return {
|
|
792
|
+
exitCode: 1, stdout: '{}', outcome: 'ok', duration: 1,
|
|
793
|
+
parsed: {
|
|
794
|
+
vulnerabilities: {
|
|
795
|
+
'pkg-a': { name: 'pkg-a', severity: 'high', via: [{ url: 'u', title: 't1', source: 1 }] },
|
|
796
|
+
'pkg-b': { name: 'pkg-b', severity: 'low', via: [{ url: 'u', title: 't2', source: 2 }] },
|
|
797
|
+
},
|
|
798
|
+
},
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
test('every finding has id pkg-NNN', () => {
|
|
803
|
+
tmpDir = setupPlanningRoot();
|
|
804
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
805
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
806
|
+
runner: spyRunner({ 'npm-audit': fakeNpmWithVulns() }),
|
|
807
|
+
checkToolOnPath: () => ({ available: false }),
|
|
808
|
+
}));
|
|
809
|
+
for (const f of result.findings) {
|
|
810
|
+
assert.match(f.id, /^pkg-\d{3}$/);
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test('ids are monotonic starting at pkg-001', () => {
|
|
815
|
+
tmpDir = setupPlanningRoot();
|
|
816
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
817
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
818
|
+
runner: spyRunner({ 'npm-audit': fakeNpmWithVulns() }),
|
|
819
|
+
checkToolOnPath: () => ({ available: false }),
|
|
820
|
+
}));
|
|
821
|
+
if (result.findings.length > 0) {
|
|
822
|
+
assert.strictEqual(result.findings[0].id, 'pkg-001');
|
|
823
|
+
for (let i = 1; i < result.findings.length; i++) {
|
|
824
|
+
const prev = parseInt(result.findings[i - 1].id.slice(4), 10);
|
|
825
|
+
const cur = parseInt(result.findings[i].id.slice(4), 10);
|
|
826
|
+
assert.strictEqual(cur, prev + 1);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
test('repo_results.findings and aggregate findings[] have same ids', () => {
|
|
832
|
+
tmpDir = setupPlanningRoot();
|
|
833
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
834
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
835
|
+
runner: spyRunner({ 'npm-audit': fakeNpmWithVulns() }),
|
|
836
|
+
checkToolOnPath: () => ({ available: false }),
|
|
837
|
+
}));
|
|
838
|
+
const perRepoIds = [];
|
|
839
|
+
for (const rr of result.repo_results) for (const f of rr.findings) perRepoIds.push(f.id);
|
|
840
|
+
const aggIds = result.findings.map(f => f.id);
|
|
841
|
+
assert.deepStrictEqual(perRepoIds.sort(), aggIds.sort());
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
test('zero findings -> empty findings[] and no ids burned', () => {
|
|
845
|
+
tmpDir = setupPlanningRoot();
|
|
846
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
847
|
+
detector: () => [],
|
|
848
|
+
runner: spyRunner(),
|
|
849
|
+
checkToolOnPath: () => ({ available: false }),
|
|
850
|
+
}));
|
|
851
|
+
assert.strictEqual(result.findings.length, 0);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
test('findings assigned after all targets scanned (target order preserved)', () => {
|
|
855
|
+
tmpDir = setupPlanningRoot({ repos: [{ name: 'api', path: '../api' }] });
|
|
856
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
857
|
+
detector: (dir) => {
|
|
858
|
+
// Fake detector: api gets findings first, product_root gets findings second
|
|
859
|
+
return [{ ecosystem: 'node', manifest_path: null }];
|
|
860
|
+
},
|
|
861
|
+
runner: spyRunner({ 'npm-audit': fakeNpmWithVulns() }),
|
|
862
|
+
checkToolOnPath: () => ({ available: false }),
|
|
863
|
+
}));
|
|
864
|
+
// api findings should come before _product_root findings in aggregate
|
|
865
|
+
const apiEntry = result.repo_results.find(r => r.repo === 'api');
|
|
866
|
+
if (apiEntry && apiEntry.findings.length > 0) {
|
|
867
|
+
const firstApiId = apiEntry.findings[0].id;
|
|
868
|
+
assert.strictEqual(firstApiId, 'pkg-001');
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
// ─── runScan — config read path ───────────────────────────────────────────────
|
|
874
|
+
|
|
875
|
+
describe('runScan - config read path (PKG-19 integration)', () => {
|
|
876
|
+
let tmpDir;
|
|
877
|
+
afterEach(() => cleanup(tmpDir));
|
|
878
|
+
|
|
879
|
+
test('defaults applied when no testing.packages section', () => {
|
|
880
|
+
tmpDir = setupPlanningRoot();
|
|
881
|
+
const runner = spyRunner({
|
|
882
|
+
'npm-audit': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 1, parsed: {} },
|
|
883
|
+
});
|
|
884
|
+
runScan(tmpDir, {}, makeStubDeps({
|
|
885
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
886
|
+
runner,
|
|
887
|
+
checkToolOnPath: () => ({ available: false }),
|
|
888
|
+
}));
|
|
889
|
+
// default timeout_seconds = 300 -> 300000 ms
|
|
890
|
+
assert.strictEqual(runner.calls[0].opts.timeoutMs, 300000);
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
test('timeout_seconds passed as opts.timeoutMs * 1000', () => {
|
|
894
|
+
tmpDir = setupPlanningRoot({ config: { testing: { packages: { timeout_seconds: 60 } } } });
|
|
895
|
+
const runner = spyRunner({
|
|
896
|
+
'npm-audit': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 1, parsed: {} },
|
|
897
|
+
});
|
|
898
|
+
runScan(tmpDir, {}, makeStubDeps({
|
|
899
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
900
|
+
runner,
|
|
901
|
+
checkToolOnPath: () => ({ available: false }),
|
|
902
|
+
}));
|
|
903
|
+
assert.strictEqual(runner.calls[0].opts.timeoutMs, 60000);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
test('include_dev_dependencies=false threads into npm-audit argv', () => {
|
|
907
|
+
tmpDir = setupPlanningRoot({ config: { testing: { packages: { include_dev_dependencies: false } } } });
|
|
908
|
+
const runner = spyRunner({
|
|
909
|
+
'npm-audit': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 1, parsed: {} },
|
|
910
|
+
});
|
|
911
|
+
runScan(tmpDir, {}, makeStubDeps({
|
|
912
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
913
|
+
runner,
|
|
914
|
+
checkToolOnPath: () => ({ available: false }),
|
|
915
|
+
}));
|
|
916
|
+
assert.ok(runner.calls[0].argv.includes('--omit=dev'));
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
// ─── cmdPackageScan CLI wrapper ───────────────────────────────────────────────
|
|
921
|
+
//
|
|
922
|
+
// The CLI calls process.exit(), which the node test runner would terminate on.
|
|
923
|
+
// We run these tests as subprocesses to isolate exit() + stdout hijacking from
|
|
924
|
+
// the test runner's own stdout-TAP channel.
|
|
925
|
+
|
|
926
|
+
describe('cmdPackageScan - CLI entry', () => {
|
|
927
|
+
let tmpDir;
|
|
928
|
+
afterEach(() => cleanup(tmpDir));
|
|
929
|
+
|
|
930
|
+
function runCli(tmpDir, raw) {
|
|
931
|
+
const rawFlag = raw ? 'true' : 'false';
|
|
932
|
+
const modPath = path.resolve(__dirname, 'package-scan.cjs');
|
|
933
|
+
const program = `
|
|
934
|
+
const { cmdPackageScan } = require(${JSON.stringify(modPath)});
|
|
935
|
+
cmdPackageScan(${JSON.stringify(tmpDir)}, {}, ${rawFlag});
|
|
936
|
+
`;
|
|
937
|
+
const r = require('child_process').spawnSync(process.execPath, ['-e', program], {
|
|
938
|
+
cwd: tmpDir,
|
|
939
|
+
encoding: 'utf-8',
|
|
940
|
+
});
|
|
941
|
+
return { exitCode: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
test('calls runScan and exits with 0 on success', () => {
|
|
945
|
+
tmpDir = setupPlanningRoot();
|
|
946
|
+
const r = runCli(tmpDir, false);
|
|
947
|
+
assert.strictEqual(r.exitCode, 0);
|
|
948
|
+
// Phase 151 replaced the "N findings across M repos" placeholder with the
|
|
949
|
+
// report-aware `Wrote {path} ({clean scan|N finding(s)}, tool: {tool}).` line.
|
|
950
|
+
assert.match(r.stdout, /Wrote .*PACKAGE-SCAN/);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
test('with raw=true emits JSON', () => {
|
|
954
|
+
tmpDir = setupPlanningRoot();
|
|
955
|
+
const r = runCli(tmpDir, true);
|
|
956
|
+
assert.strictEqual(r.exitCode, 0);
|
|
957
|
+
// In raw mode, Phase 151's cmdPackageScan emits compact single-line JSON
|
|
958
|
+
// to stdout (no process.exit inside output()), followed by the report-write
|
|
959
|
+
// side effect — no extra stdout lines in raw mode. Find the first newline
|
|
960
|
+
// to isolate the JSON payload.
|
|
961
|
+
const newlineIdx = r.stdout.indexOf('\n');
|
|
962
|
+
assert.ok(newlineIdx > 0, 'expected newline separator in raw stdout');
|
|
963
|
+
const jsonPart = r.stdout.slice(0, newlineIdx);
|
|
964
|
+
const j = JSON.parse(jsonPart);
|
|
965
|
+
assert.strictEqual(j.exit_code, 0);
|
|
966
|
+
assert.ok(Array.isArray(j.findings));
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
test('summary line mentions report path + tool when raw=false', () => {
|
|
970
|
+
tmpDir = setupPlanningRoot();
|
|
971
|
+
const r = runCli(tmpDir, false);
|
|
972
|
+
// Either "clean scan" (zero findings) or "N finding(s)" phrase, plus the tool.
|
|
973
|
+
assert.match(r.stdout, /Wrote .*PACKAGE-SCAN.*\((clean scan|\d+ findings?), tool: \S+\)/);
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
test('exit_code surfaces as process exit code', () => {
|
|
977
|
+
// Force a pin-fail-fast via tool:snyk config with no auth
|
|
978
|
+
tmpDir = setupPlanningRoot({ config: { testing: { packages: { tool: 'snyk' } } } });
|
|
979
|
+
const r = runCli(tmpDir, true);
|
|
980
|
+
// On a clean test host without Snyk installed, should exit 2
|
|
981
|
+
assert.strictEqual([0, 2].includes(r.exitCode), true);
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
// ─── Integration smoke (Plan 03) ──────────────────────────────────────────────
|
|
986
|
+
|
|
987
|
+
describe('integration smoke 150', () => {
|
|
988
|
+
let tmpDir;
|
|
989
|
+
afterEach(() => cleanup(tmpDir));
|
|
990
|
+
|
|
991
|
+
test('scenario 1: four-ecosystem end-to-end, pkg-NNN ids assigned', () => {
|
|
992
|
+
tmpDir = setupPlanningRoot({
|
|
993
|
+
repos: [
|
|
994
|
+
{ name: 'api', path: '../api' },
|
|
995
|
+
{ name: 'worker', path: '../worker' },
|
|
996
|
+
{ name: 'cli', path: '../cli' },
|
|
997
|
+
{ name: 'admin', path: '../admin' },
|
|
998
|
+
],
|
|
999
|
+
});
|
|
1000
|
+
// Create manifest dirs
|
|
1001
|
+
for (const r of ['api', 'worker', 'cli', 'admin']) {
|
|
1002
|
+
const rd = path.resolve(tmpDir, '..', r);
|
|
1003
|
+
fs.mkdirSync(rd, { recursive: true });
|
|
1004
|
+
}
|
|
1005
|
+
fs.writeFileSync(path.resolve(tmpDir, '..', 'api', 'package.json'), '{}');
|
|
1006
|
+
fs.writeFileSync(path.resolve(tmpDir, '..', 'worker', 'requirements.txt'), '');
|
|
1007
|
+
fs.writeFileSync(path.resolve(tmpDir, '..', 'cli', 'go.mod'), 'module x');
|
|
1008
|
+
fs.writeFileSync(path.resolve(tmpDir, '..', 'admin', 'Gemfile.lock'), '');
|
|
1009
|
+
|
|
1010
|
+
// Canned outputs (JSON structures that adapters can handle; empty findings OK)
|
|
1011
|
+
const runner = spyRunner({
|
|
1012
|
+
'npm-audit': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 1, parsed: { vulnerabilities: {} } },
|
|
1013
|
+
'pip-audit': { exitCode: 0, stdout: '[]', outcome: 'ok', duration: 1, parsed: [] },
|
|
1014
|
+
'govulncheck': { exitCode: 0, stdout: '', outcome: 'ok', duration: 1, parsed: null },
|
|
1015
|
+
'bundler-audit': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 1, parsed: { results: [] } },
|
|
1016
|
+
});
|
|
1017
|
+
const result = runScan(tmpDir, {}, {
|
|
1018
|
+
detector: require('./package-ecosystems.cjs').detectEcosystems,
|
|
1019
|
+
runner,
|
|
1020
|
+
snykAuth: () => ({ available: false, source: 'none' }),
|
|
1021
|
+
checkToolOnPath: () => ({ available: false }),
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
assert.strictEqual(result.exit_code, 0);
|
|
1025
|
+
assert.strictEqual(result.repo_results.length >= 4, true);
|
|
1026
|
+
assert.strictEqual(result.tool_per_target['api'], 'npm-audit');
|
|
1027
|
+
assert.strictEqual(result.tool_per_target['worker'], 'pip-audit');
|
|
1028
|
+
assert.strictEqual(result.tool_per_target['cli'], 'govulncheck');
|
|
1029
|
+
assert.strictEqual(result.tool_per_target['admin'], 'bundler-audit');
|
|
1030
|
+
|
|
1031
|
+
// cleanup sibling repo dirs (best effort)
|
|
1032
|
+
for (const r of ['api', 'worker', 'cli', 'admin']) {
|
|
1033
|
+
try { fs.rmSync(path.resolve(tmpDir, '..', r), { recursive: true, force: true }); } catch {}
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
test('scenario 2: config round-trip — snyk_token routed to config-local-set', () => {
|
|
1038
|
+
tmpDir = setupPlanningRoot();
|
|
1039
|
+
const { cmdConfigSet, cmdConfigLocalSet } = require('./config.cjs');
|
|
1040
|
+
|
|
1041
|
+
// Capture all exit/stdout/stderr for the duration of this test — the
|
|
1042
|
+
// origStdoutWrite and origStderrWrite MUST be restored in the final finally
|
|
1043
|
+
// block, otherwise the node test runner TAP channel gets swallowed.
|
|
1044
|
+
let exitCode = null;
|
|
1045
|
+
let stdout = '';
|
|
1046
|
+
let stderr = '';
|
|
1047
|
+
const origExit = process.exit;
|
|
1048
|
+
const origStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
1049
|
+
const origStderrWrite = process.stderr.write.bind(process.stderr);
|
|
1050
|
+
process.exit = (c) => {
|
|
1051
|
+
exitCode = c;
|
|
1052
|
+
if (c !== 0) { const e = new Error('__EXIT__'); e.__s = true; throw e; }
|
|
1053
|
+
};
|
|
1054
|
+
process.stdout.write = (d) => { stdout += String(d); return true; };
|
|
1055
|
+
process.stderr.write = (d) => { stderr += String(d); return true; };
|
|
1056
|
+
let rejectLocalOnly, writeLocal, authResult;
|
|
1057
|
+
try {
|
|
1058
|
+
// 1. cmdConfigSet(snyk_token) must reject with local-only
|
|
1059
|
+
try { cmdConfigSet(tmpDir, 'testing.packages.snyk_token', 'abc', true); }
|
|
1060
|
+
catch (e) { if (!e.__s) throw e; }
|
|
1061
|
+
rejectLocalOnly = { exitCode, stderr };
|
|
1062
|
+
|
|
1063
|
+
// 2. cmdConfigLocalSet accepts it
|
|
1064
|
+
exitCode = null;
|
|
1065
|
+
stdout = '';
|
|
1066
|
+
try { cmdConfigLocalSet(tmpDir, 'testing.packages.snyk_token', 'abc', true); }
|
|
1067
|
+
catch (e) { if (!e.__s) throw e; }
|
|
1068
|
+
|
|
1069
|
+
// Read config.local.json back from disk
|
|
1070
|
+
writeLocal = JSON.parse(fs.readFileSync(path.join(tmpDir, 'config.local.json'), 'utf-8'));
|
|
1071
|
+
|
|
1072
|
+
// 3. resolveSnykAuth picks it up
|
|
1073
|
+
resetPaths();
|
|
1074
|
+
authResult = resolveSnykAuth(tmpDir);
|
|
1075
|
+
} finally {
|
|
1076
|
+
process.exit = origExit;
|
|
1077
|
+
process.stdout.write = origStdoutWrite;
|
|
1078
|
+
process.stderr.write = origStderrWrite;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
assert.strictEqual(rejectLocalOnly.exitCode, 1);
|
|
1082
|
+
assert.match(rejectLocalOnly.stderr, /local-only/);
|
|
1083
|
+
assert.strictEqual(writeLocal.testing.packages.snyk_token, 'abc');
|
|
1084
|
+
assert.strictEqual(authResult.available, true);
|
|
1085
|
+
assert.strictEqual(authResult.source, 'config.local.json');
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
test('scenario 3: worktree awareness — runner cwd = worktree directory', () => {
|
|
1089
|
+
tmpDir = setupPlanningRoot({ repos: [{ name: 'api', path: '../api' }] });
|
|
1090
|
+
const worktreeDir = path.join(tmpDir, 'wt-api');
|
|
1091
|
+
fs.mkdirSync(worktreeDir, { recursive: true });
|
|
1092
|
+
fs.writeFileSync(path.join(worktreeDir, 'package.json'), '{}');
|
|
1093
|
+
setupWorktreeContext(tmpDir, { repoName: 'api', slug: 'v1.0', worktreeDir });
|
|
1094
|
+
const runner = spyRunner({
|
|
1095
|
+
'npm-audit': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 1, parsed: {} },
|
|
1096
|
+
});
|
|
1097
|
+
runScan(tmpDir, {}, makeStubDeps({
|
|
1098
|
+
detector: (dir) => dir === worktreeDir ? [{ ecosystem: 'node', manifest_path: null }] : [],
|
|
1099
|
+
runner,
|
|
1100
|
+
snykAuth: () => ({ available: false, source: 'none' }),
|
|
1101
|
+
checkToolOnPath: () => ({ available: false }),
|
|
1102
|
+
}));
|
|
1103
|
+
assert.strictEqual(runner.calls[0].cwd, worktreeDir);
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
test('scenario 4: token redaction and process.env non-mutation', () => {
|
|
1107
|
+
tmpDir = setupPlanningRoot({
|
|
1108
|
+
local: { testing: { packages: { snyk_token: 's3cret-deadbeef-token' } } },
|
|
1109
|
+
});
|
|
1110
|
+
process.env._DGS_CANARY_150 = 'alive';
|
|
1111
|
+
const origSnyk = process.env.SNYK_TOKEN;
|
|
1112
|
+
delete process.env.SNYK_TOKEN;
|
|
1113
|
+
const runner = spyRunner({
|
|
1114
|
+
'snyk': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 1, parsed: { vulnerabilities: [] } },
|
|
1115
|
+
});
|
|
1116
|
+
const result = runScan(tmpDir, {}, makeStubDeps({
|
|
1117
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
1118
|
+
runner,
|
|
1119
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
1120
|
+
}));
|
|
1121
|
+
|
|
1122
|
+
assert.strictEqual(process.env._DGS_CANARY_150, 'alive');
|
|
1123
|
+
assert.strictEqual(process.env.SNYK_TOKEN, undefined);
|
|
1124
|
+
assert.doesNotMatch(JSON.stringify(result), /s3cret-deadbeef-token/);
|
|
1125
|
+
// If any runner call happened for snyk, env was passed correctly
|
|
1126
|
+
const snykCall = runner.calls.find(c => c.toolKey === 'snyk');
|
|
1127
|
+
if (snykCall) assert.strictEqual(snykCall.opts.env.SNYK_TOKEN, 's3cret-deadbeef-token');
|
|
1128
|
+
|
|
1129
|
+
delete process.env._DGS_CANARY_150;
|
|
1130
|
+
if (origSnyk !== undefined) process.env.SNYK_TOKEN = origSnyk;
|
|
1131
|
+
});
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
// ─── Phase 151 composition — cmdPackageScan writes report + commits ──────────
|
|
1135
|
+
|
|
1136
|
+
describe('cmdPackageScan — Phase 151 composition', () => {
|
|
1137
|
+
/**
|
|
1138
|
+
* Run the CLI entry under a controlled harness that:
|
|
1139
|
+
* - captures stdout / stderr without polluting the test runner
|
|
1140
|
+
* - intercepts process.exit so the test runner keeps running
|
|
1141
|
+
* - restores all three in `finally` (critical for TAP channel integrity)
|
|
1142
|
+
*/
|
|
1143
|
+
function runCmdCaptured(fn) {
|
|
1144
|
+
let exitCode = null;
|
|
1145
|
+
let stdout = '';
|
|
1146
|
+
let stderr = '';
|
|
1147
|
+
const origExit = process.exit;
|
|
1148
|
+
const origStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
1149
|
+
const origStderrWrite = process.stderr.write.bind(process.stderr);
|
|
1150
|
+
process.exit = (c) => {
|
|
1151
|
+
exitCode = c;
|
|
1152
|
+
const e = new Error('__EXIT__');
|
|
1153
|
+
e.__s = true;
|
|
1154
|
+
throw e;
|
|
1155
|
+
};
|
|
1156
|
+
process.stdout.write = (d) => { stdout += String(d); return true; };
|
|
1157
|
+
process.stderr.write = (d) => { stderr += String(d); return true; };
|
|
1158
|
+
try {
|
|
1159
|
+
try { fn(); } catch (e) { if (!e.__s) throw e; }
|
|
1160
|
+
} finally {
|
|
1161
|
+
process.exit = origExit;
|
|
1162
|
+
process.stdout.write = origStdoutWrite;
|
|
1163
|
+
process.stderr.write = origStderrWrite;
|
|
1164
|
+
}
|
|
1165
|
+
return { exitCode, stdout, stderr };
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
let tmpDir;
|
|
1169
|
+
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
|
1170
|
+
|
|
1171
|
+
test('end-to-end happy path: writes report, invokes commit, prints provenance', () => {
|
|
1172
|
+
tmpDir = setupPlanningRoot({
|
|
1173
|
+
repos: [{ name: 'api', path: '../api' }],
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
// Stub a canned snyk finding that flows through the full composition.
|
|
1177
|
+
const snykJson = {
|
|
1178
|
+
vulnerabilities: [{
|
|
1179
|
+
id: 'SNYK-JS-LODASH-567746',
|
|
1180
|
+
title: 'Prototype Pollution in lodash',
|
|
1181
|
+
packageName: 'lodash',
|
|
1182
|
+
version: '4.17.20',
|
|
1183
|
+
severity: 'critical',
|
|
1184
|
+
cvssScore: 7.4,
|
|
1185
|
+
identifiers: { CVE: ['CVE-2020-8203'] },
|
|
1186
|
+
url: 'https://nvd.nist.gov/vuln/detail/CVE-2020-8203',
|
|
1187
|
+
from: ['your-app@1.0.0', 'auth-lib@1.0.0', 'lodash@4.17.20'],
|
|
1188
|
+
fixedIn: ['4.17.21'],
|
|
1189
|
+
}],
|
|
1190
|
+
};
|
|
1191
|
+
const runner = spyRunner({
|
|
1192
|
+
'snyk': { exitCode: 0, stdout: JSON.stringify(snykJson), outcome: 'ok', duration: 1200, parsed: snykJson },
|
|
1193
|
+
});
|
|
1194
|
+
const commitCalls = [];
|
|
1195
|
+
const commitStub = (msg, file) => {
|
|
1196
|
+
commitCalls.push({ msg, file });
|
|
1197
|
+
return { status: 0, stdout: '', stderr: '' };
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
// Use the opts._runScan test seam: pre-bind stub deps so cmdPackageScan
|
|
1201
|
+
// picks up a fully stubbed scan without touching the real tool cascade.
|
|
1202
|
+
const stubRunScan = (cwd_, opts_) => runScan(cwd_, opts_, {
|
|
1203
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
1204
|
+
runner,
|
|
1205
|
+
snykAuth: () => ({ available: true, source: 'env', token: 't' }),
|
|
1206
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
const captured = runCmdCaptured(() => {
|
|
1210
|
+
cmdPackageScan(tmpDir, [], false, { commit: commitStub, _runScan: stubRunScan });
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
assert.strictEqual(captured.exitCode, 0);
|
|
1214
|
+
assert.match(captured.stdout, /Wrote /);
|
|
1215
|
+
assert.match(captured.stdout, /tool: snyk/);
|
|
1216
|
+
assert.strictEqual(commitCalls.length, 1);
|
|
1217
|
+
assert.match(commitCalls[0].file, /PACKAGE-SCAN/);
|
|
1218
|
+
assert.ok(fs.existsSync(commitCalls[0].file));
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
test('end-to-end clean-scan: zero findings, "clean scan" phrase, commit still invoked', () => {
|
|
1222
|
+
tmpDir = setupPlanningRoot({
|
|
1223
|
+
repos: [{ name: 'api', path: '../api' }],
|
|
1224
|
+
});
|
|
1225
|
+
const runner = spyRunner({
|
|
1226
|
+
'osv-scanner': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 500, parsed: { results: [] } },
|
|
1227
|
+
});
|
|
1228
|
+
const commitCalls = [];
|
|
1229
|
+
const commitStub = (msg, file) => { commitCalls.push({ msg, file }); return { status: 0, stdout: '', stderr: '' }; };
|
|
1230
|
+
|
|
1231
|
+
const stubRunScan = (cwd_, opts_) => runScan(cwd_, opts_, {
|
|
1232
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
1233
|
+
runner,
|
|
1234
|
+
snykAuth: () => ({ available: false, source: 'none' }),
|
|
1235
|
+
checkToolOnPath: (bin) => ({ available: bin === 'osv-scanner' }),
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
const captured = runCmdCaptured(() => {
|
|
1239
|
+
cmdPackageScan(tmpDir, [], false, { commit: commitStub, _runScan: stubRunScan });
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
assert.strictEqual(captured.exitCode, 0);
|
|
1243
|
+
assert.match(captured.stdout, /clean scan/);
|
|
1244
|
+
assert.strictEqual(commitCalls.length, 1);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
test('pin-unavailable short-circuit: no report, no commit, stderr contains install hint', () => {
|
|
1248
|
+
tmpDir = setupPlanningRoot({
|
|
1249
|
+
config: { testing: { packages: { tool: 'snyk' } } },
|
|
1250
|
+
repos: [{ name: 'api', path: '../api' }],
|
|
1251
|
+
});
|
|
1252
|
+
const commitCalls = [];
|
|
1253
|
+
const commitStub = (msg, file) => { commitCalls.push({ msg, file }); return { status: 0, stdout: '', stderr: '' }; };
|
|
1254
|
+
|
|
1255
|
+
const stubRunScan = (cwd_, opts_) => runScan(cwd_, opts_, {
|
|
1256
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
1257
|
+
runner: spyRunner(),
|
|
1258
|
+
snykAuth: () => ({ available: false, source: 'none' }),
|
|
1259
|
+
checkToolOnPath: () => ({ available: false }),
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
const captured = runCmdCaptured(() => {
|
|
1263
|
+
cmdPackageScan(tmpDir, [], false, { commit: commitStub, _runScan: stubRunScan });
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
assert.notStrictEqual(captured.exitCode, 0);
|
|
1267
|
+
assert.match(captured.stderr, /Scan failed:/);
|
|
1268
|
+
assert.match(captured.stderr, /tool_unavailable_on_pin/);
|
|
1269
|
+
assert.strictEqual(commitCalls.length, 0);
|
|
1270
|
+
// No file written at the project root.
|
|
1271
|
+
const entries = fs.readdirSync(tmpDir);
|
|
1272
|
+
const wrote = entries.some(e => /PACKAGE-SCAN/.test(e));
|
|
1273
|
+
assert.strictEqual(wrote, false);
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
test('commit-failure resilience: report stays on disk; CLI exits 0', () => {
|
|
1277
|
+
tmpDir = setupPlanningRoot({
|
|
1278
|
+
repos: [{ name: 'api', path: '../api' }],
|
|
1279
|
+
});
|
|
1280
|
+
const runner = spyRunner({
|
|
1281
|
+
'osv-scanner': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 500, parsed: { results: [] } },
|
|
1282
|
+
});
|
|
1283
|
+
const commitStub = (msg, file) => ({ status: 1, stdout: '', stderr: 'fake commit failure' });
|
|
1284
|
+
|
|
1285
|
+
const stubRunScan = (cwd_, opts_) => runScan(cwd_, opts_, {
|
|
1286
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
1287
|
+
runner,
|
|
1288
|
+
snykAuth: () => ({ available: false, source: 'none' }),
|
|
1289
|
+
checkToolOnPath: (bin) => ({ available: bin === 'osv-scanner' }),
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
let reportedPath;
|
|
1293
|
+
const captured = runCmdCaptured(() => {
|
|
1294
|
+
cmdPackageScan(tmpDir, [], false, {
|
|
1295
|
+
_runScan: stubRunScan,
|
|
1296
|
+
commit: (m, f) => {
|
|
1297
|
+
reportedPath = f;
|
|
1298
|
+
return commitStub(m, f);
|
|
1299
|
+
},
|
|
1300
|
+
});
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
assert.strictEqual(captured.exitCode, 0);
|
|
1304
|
+
assert.match(captured.stderr, /Commit failed/);
|
|
1305
|
+
assert.ok(reportedPath && fs.existsSync(reportedPath), 'report must exist on disk after commit failure');
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
test('raw mode: JSON emitted, commit still invoked', () => {
|
|
1309
|
+
tmpDir = setupPlanningRoot({
|
|
1310
|
+
repos: [{ name: 'api', path: '../api' }],
|
|
1311
|
+
});
|
|
1312
|
+
const runner = spyRunner({
|
|
1313
|
+
'osv-scanner': { exitCode: 0, stdout: '{}', outcome: 'ok', duration: 500, parsed: { results: [] } },
|
|
1314
|
+
});
|
|
1315
|
+
const commitCalls = [];
|
|
1316
|
+
const commitStub = (msg, file) => { commitCalls.push({ msg, file }); return { status: 0, stdout: '', stderr: '' }; };
|
|
1317
|
+
|
|
1318
|
+
const stubRunScan = (cwd_, opts_) => runScan(cwd_, opts_, {
|
|
1319
|
+
detector: () => [{ ecosystem: 'node', manifest_path: null }],
|
|
1320
|
+
runner,
|
|
1321
|
+
snykAuth: () => ({ available: false, source: 'none' }),
|
|
1322
|
+
checkToolOnPath: (bin) => ({ available: bin === 'osv-scanner' }),
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
const captured = runCmdCaptured(() => {
|
|
1326
|
+
cmdPackageScan(tmpDir, [], true, { commit: commitStub, _runScan: stubRunScan });
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
assert.strictEqual(captured.exitCode, 0);
|
|
1330
|
+
// raw=true emits JSON via output()
|
|
1331
|
+
assert.match(captured.stdout, /"exit_code"|"findings"/);
|
|
1332
|
+
// Non-raw provenance line should NOT appear.
|
|
1333
|
+
assert.doesNotMatch(captured.stdout, /Wrote /);
|
|
1334
|
+
assert.strictEqual(commitCalls.length, 1);
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
test('dispatch: node dgs-tools.cjs package-scan invokes cmdPackageScan', () => {
|
|
1338
|
+
tmpDir = setupPlanningRoot({
|
|
1339
|
+
config: { mode: 'v2' },
|
|
1340
|
+
repos: [],
|
|
1341
|
+
});
|
|
1342
|
+
// Ensure REPOS.md is empty (no repos) and product root has no manifests.
|
|
1343
|
+
fs.writeFileSync(path.join(tmpDir, 'REPOS.md'), '# Repos\n');
|
|
1344
|
+
const dgsToolsPath = path.resolve(__dirname, '..', 'dgs-tools.cjs');
|
|
1345
|
+
const { spawnSync } = require('child_process');
|
|
1346
|
+
const result = spawnSync('node', [dgsToolsPath, 'package-scan', '--raw'], {
|
|
1347
|
+
cwd: tmpDir,
|
|
1348
|
+
encoding: 'utf-8',
|
|
1349
|
+
timeout: 20000,
|
|
1350
|
+
});
|
|
1351
|
+
assert.notStrictEqual(result.status, 127, 'dgs-tools should not fail with command-not-found');
|
|
1352
|
+
const combined = (result.stderr || '') + (result.stdout || '');
|
|
1353
|
+
assert.doesNotMatch(combined, /Unknown command/);
|
|
1354
|
+
// The dispatch itself proves the case is wired; the scan behaviour is
|
|
1355
|
+
// covered by the earlier tests. Any of these tokens indicates a reached
|
|
1356
|
+
// code path inside cmdPackageScan or downstream:
|
|
1357
|
+
assert.match(combined, /(exit_code|Wrote|Scan failed|tool_per_target|package-scan)/);
|
|
1358
|
+
});
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
// ─── Phase 152 Plan 03: gate-parity (PKG-26) ─────────────────────────────────
|
|
1362
|
+
//
|
|
1363
|
+
// Byte-level standalone-vs-gate parity test. Proves writePackageScanReport is
|
|
1364
|
+
// deterministic given identical inputs and that the emitter's output bytes
|
|
1365
|
+
// match a pinned golden fixture. Any silent drift in canonical shape or
|
|
1366
|
+
// ordering trips the first assertion loudly.
|
|
1367
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1368
|
+
describe('gate-parity (PKG-26)', () => {
|
|
1369
|
+
const FIXTURE_DIR = path.resolve(__dirname, 'fixtures', 'package-scan');
|
|
1370
|
+
const RUNRESULT_PATH = path.join(FIXTURE_DIR, 'gate-parity-runresult.json');
|
|
1371
|
+
const EXPECTED_PATH = path.join(FIXTURE_DIR, 'gate-parity-expected.md');
|
|
1372
|
+
const FIXED_DATE = new Date('2026-04-18T00:00:00.000Z');
|
|
1373
|
+
|
|
1374
|
+
let tmpDir;
|
|
1375
|
+
afterEach(() => { if (tmpDir) cleanup(tmpDir); });
|
|
1376
|
+
|
|
1377
|
+
const {
|
|
1378
|
+
writePackageScanReport,
|
|
1379
|
+
_parseEmittedYaml,
|
|
1380
|
+
} = require('./package-scan-report.cjs');
|
|
1381
|
+
|
|
1382
|
+
test('byte-identical golden parity: writePackageScanReport bytes === gate-parity-expected.md', () => {
|
|
1383
|
+
tmpDir = setupPlanningRoot({});
|
|
1384
|
+
const runResult = JSON.parse(fs.readFileSync(RUNRESULT_PATH, 'utf8'));
|
|
1385
|
+
const outPath = path.join(tmpDir, 'standalone.md');
|
|
1386
|
+
writePackageScanReport(tmpDir, runResult, {
|
|
1387
|
+
overridePath: outPath,
|
|
1388
|
+
now: () => FIXED_DATE,
|
|
1389
|
+
});
|
|
1390
|
+
const actual = fs.readFileSync(outPath);
|
|
1391
|
+
const expected = fs.readFileSync(EXPECTED_PATH);
|
|
1392
|
+
if (Buffer.compare(actual, expected) !== 0) {
|
|
1393
|
+
console.error('Gate-parity bytes differ. Regenerate with the command in 152-03-PLAN.md Task 2 ONLY after an intentional emitter change.');
|
|
1394
|
+
console.error('First 500 bytes of actual:', actual.slice(0, 500).toString());
|
|
1395
|
+
console.error('First 500 bytes of expected:', expected.slice(0, 500).toString());
|
|
1396
|
+
}
|
|
1397
|
+
assert.strictEqual(Buffer.compare(actual, expected), 0, 'bytes must match golden fixture');
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
test('standalone-vs-gate parity: JSON round-trip of runResult produces identical bytes', () => {
|
|
1401
|
+
const tmpA = setupPlanningRoot({});
|
|
1402
|
+
const tmpB = setupPlanningRoot({});
|
|
1403
|
+
try {
|
|
1404
|
+
const runResult = JSON.parse(fs.readFileSync(RUNRESULT_PATH, 'utf8'));
|
|
1405
|
+
|
|
1406
|
+
// Standalone invocation: writePackageScanReport called directly.
|
|
1407
|
+
const outA = path.join(tmpA, 'standalone.md');
|
|
1408
|
+
writePackageScanReport(tmpA, runResult, {
|
|
1409
|
+
overridePath: outA,
|
|
1410
|
+
now: () => FIXED_DATE,
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
// Simulated gate invocation: the future test-gate orchestrator would
|
|
1414
|
+
// serialise the runResult across a subagent boundary and pass the
|
|
1415
|
+
// deserialised copy to the writer. JSON round-trip simulates that.
|
|
1416
|
+
function simulateGateInvocation(cwd, rr, opts) {
|
|
1417
|
+
const copy = JSON.parse(JSON.stringify(rr));
|
|
1418
|
+
return writePackageScanReport(cwd, copy, opts);
|
|
1419
|
+
}
|
|
1420
|
+
const outB = path.join(tmpB, 'gate.md');
|
|
1421
|
+
simulateGateInvocation(tmpB, runResult, {
|
|
1422
|
+
overridePath: outB,
|
|
1423
|
+
now: () => FIXED_DATE,
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
const standaloneBytes = fs.readFileSync(outA);
|
|
1427
|
+
const gateBytes = fs.readFileSync(outB);
|
|
1428
|
+
assert.strictEqual(
|
|
1429
|
+
Buffer.compare(standaloneBytes, gateBytes),
|
|
1430
|
+
0,
|
|
1431
|
+
'standalone bytes must equal gate (JSON-round-tripped) bytes',
|
|
1432
|
+
);
|
|
1433
|
+
} finally {
|
|
1434
|
+
cleanup(tmpA);
|
|
1435
|
+
cleanup(tmpB);
|
|
1436
|
+
}
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
test('YAML findings block extracts + round-trips via _parseEmittedYaml', () => {
|
|
1440
|
+
const content = fs.readFileSync(EXPECTED_PATH, 'utf8');
|
|
1441
|
+
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
1442
|
+
assert.ok(m, 'frontmatter fence present');
|
|
1443
|
+
const parsed = _parseEmittedYaml('---\n' + m[1] + '\n---');
|
|
1444
|
+
assert.ok(Array.isArray(parsed.findings), 'findings is an array');
|
|
1445
|
+
assert.strictEqual(parsed.findings.length, 5, 'exactly 5 canonical findings');
|
|
1446
|
+
|
|
1447
|
+
const ids = parsed.findings.map(f => f.id);
|
|
1448
|
+
assert.deepStrictEqual(
|
|
1449
|
+
ids,
|
|
1450
|
+
['pkg-001', 'pkg-002', 'pkg-002-lic', 'pkg-003', 'pkg-004'],
|
|
1451
|
+
'findings ordered: security (pkg-001), security (pkg-002), licence (pkg-002-lic), security (pkg-003), security (pkg-004)',
|
|
1452
|
+
);
|
|
1453
|
+
|
|
1454
|
+
// Licence finding assertions (PKG-23 split).
|
|
1455
|
+
const lic = parsed.findings[2];
|
|
1456
|
+
assert.strictEqual(lic.gap_type, 'dependency-licence');
|
|
1457
|
+
assert.strictEqual(lic.severity, 'high');
|
|
1458
|
+
assert.strictEqual(lic.resource_id, 'gpl-licensed-dep@2.0.0');
|
|
1459
|
+
|
|
1460
|
+
// moderate → medium.
|
|
1461
|
+
assert.strictEqual(parsed.findings[1].severity, 'medium');
|
|
1462
|
+
|
|
1463
|
+
// null → medium (pip-audit).
|
|
1464
|
+
assert.strictEqual(parsed.findings[3].severity, 'medium');
|
|
1465
|
+
|
|
1466
|
+
// critical stays critical (npm-audit pass-through).
|
|
1467
|
+
assert.strictEqual(parsed.findings[4].severity, 'critical');
|
|
1468
|
+
});
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
// ─── Phase 152 Plan 01 Task 3: pkg-NNN id assignment — licence split ─────────
|
|
1472
|
+
describe('pkg-NNN id assignment — licence split (PKG-22)', () => {
|
|
1473
|
+
let tmpDir;
|
|
1474
|
+
afterEach(() => { if (tmpDir) cleanup(tmpDir); });
|
|
1475
|
+
|
|
1476
|
+
test('orchestrator assigns one pkg-NNN per adapter finding; licence split happens at report-writer', () => {
|
|
1477
|
+
tmpDir = setupPlanningRoot({
|
|
1478
|
+
repos: [{ name: 'api', path: '../api' }],
|
|
1479
|
+
});
|
|
1480
|
+
// Canned Snyk output: one vulnerability with licence GPL-3.0.
|
|
1481
|
+
const snykJson = {
|
|
1482
|
+
vulnerabilities: [{
|
|
1483
|
+
id: 'SNYK-JS-GPL-001',
|
|
1484
|
+
title: 'Prototype Pollution in gpl-licensed-dep',
|
|
1485
|
+
packageName: 'gpl-licensed-dep',
|
|
1486
|
+
version: '2.0.0',
|
|
1487
|
+
severity: 'high',
|
|
1488
|
+
cvssScore: 7.2,
|
|
1489
|
+
identifiers: { CVE: [] },
|
|
1490
|
+
url: 'https://example.com/advisory',
|
|
1491
|
+
from: ['your-app@1.0.0', 'gpl-licensed-dep@2.0.0'],
|
|
1492
|
+
fixedIn: [],
|
|
1493
|
+
license: 'GPL-3.0',
|
|
1494
|
+
}],
|
|
1495
|
+
};
|
|
1496
|
+
const runner = spyRunner({
|
|
1497
|
+
'snyk': { exitCode: 0, stdout: JSON.stringify(snykJson), outcome: 'ok', duration: 800, parsed: snykJson },
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
// Detector returns ecosystem ONLY for the `api` target; product_root has no manifests.
|
|
1501
|
+
const detector = (dir) => {
|
|
1502
|
+
if (/api$/.test(dir)) return [{ ecosystem: 'node', manifest_path: null }];
|
|
1503
|
+
return [];
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
const result = runScan(tmpDir, {}, {
|
|
1507
|
+
detector,
|
|
1508
|
+
runner,
|
|
1509
|
+
snykAuth: () => ({ available: true, source: 'env', token: 't' }),
|
|
1510
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
// Orchestrator-level: exactly ONE finding (licence split is report-writer-level).
|
|
1514
|
+
assert.strictEqual(result.findings.length, 1, 'orchestrator produces 1 finding per adapter entry');
|
|
1515
|
+
assert.strictEqual(result.findings[0].id, 'pkg-001');
|
|
1516
|
+
assert.strictEqual(result.findings[0].licence, 'GPL-3.0');
|
|
1517
|
+
|
|
1518
|
+
// Now emit via writePackageScanReport and assert the YAML findings block
|
|
1519
|
+
// contains TWO entries with ids pkg-001 and pkg-001-lic.
|
|
1520
|
+
const { writePackageScanReport, _parseEmittedYaml } = require('./package-scan-report.cjs');
|
|
1521
|
+
const outPath = path.join(tmpDir, 'OUT.md');
|
|
1522
|
+
writePackageScanReport(tmpDir, result, { overridePath: outPath, now: () => new Date('2026-04-17T00:00:00.000Z') });
|
|
1523
|
+
const content = fs.readFileSync(outPath, 'utf-8');
|
|
1524
|
+
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
1525
|
+
assert.ok(m, 'frontmatter present');
|
|
1526
|
+
const parsed = _parseEmittedYaml('---\n' + m[1] + '\n---');
|
|
1527
|
+
assert.strictEqual(parsed.findings.length, 2, 'report writer splits GPL finding into security + licence');
|
|
1528
|
+
assert.strictEqual(parsed.findings[0].id, 'pkg-001');
|
|
1529
|
+
assert.strictEqual(parsed.findings[0].gap_type, 'dependency-security');
|
|
1530
|
+
assert.strictEqual(parsed.findings[1].id, 'pkg-001-lic');
|
|
1531
|
+
assert.strictEqual(parsed.findings[1].gap_type, 'dependency-licence');
|
|
1532
|
+
assert.strictEqual(parsed.findings[1].severity, 'high');
|
|
1533
|
+
// Both halves share the same resource_id (correlation key).
|
|
1534
|
+
assert.strictEqual(parsed.findings[0].resource_id, 'gpl-licensed-dep@2.0.0');
|
|
1535
|
+
assert.strictEqual(parsed.findings[1].resource_id, 'gpl-licensed-dep@2.0.0');
|
|
1536
|
+
});
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
// ─── Phase 153 Plan 01: CLI flags (PKG-27, PKG-28) ────────────────────────────
|
|
1540
|
+
|
|
1541
|
+
describe('Phase 153: CLI flags (PKG-27, PKG-28)', () => {
|
|
1542
|
+
const {
|
|
1543
|
+
_parseArgs,
|
|
1544
|
+
_severityRank,
|
|
1545
|
+
_filterByThreshold,
|
|
1546
|
+
} = require('./package-scan.cjs');
|
|
1547
|
+
|
|
1548
|
+
describe('_parseArgs(args)', () => {
|
|
1549
|
+
test('empty args => all defaults', () => {
|
|
1550
|
+
const r = _parseArgs([]);
|
|
1551
|
+
assert.deepStrictEqual(r, {
|
|
1552
|
+
threshold: null,
|
|
1553
|
+
only_repo: null,
|
|
1554
|
+
include_dev_dependencies: null,
|
|
1555
|
+
json: false,
|
|
1556
|
+
raw: false,
|
|
1557
|
+
});
|
|
1558
|
+
});
|
|
1559
|
+
test('--threshold high => threshold:high', () => {
|
|
1560
|
+
assert.strictEqual(_parseArgs(['--threshold', 'high']).threshold, 'high');
|
|
1561
|
+
});
|
|
1562
|
+
test('--threshold critical => threshold:critical', () => {
|
|
1563
|
+
assert.strictEqual(_parseArgs(['--threshold', 'critical']).threshold, 'critical');
|
|
1564
|
+
});
|
|
1565
|
+
test('--threshold low => threshold:low', () => {
|
|
1566
|
+
assert.strictEqual(_parseArgs(['--threshold', 'low']).threshold, 'low');
|
|
1567
|
+
});
|
|
1568
|
+
test('--threshold bogus => throws', () => {
|
|
1569
|
+
assert.throws(() => _parseArgs(['--threshold', 'bogus']),
|
|
1570
|
+
/Invalid threshold.*critical.*high.*medium.*low/);
|
|
1571
|
+
});
|
|
1572
|
+
test('--repo api => only_repo:api', () => {
|
|
1573
|
+
assert.strictEqual(_parseArgs(['--repo', 'api']).only_repo, 'api');
|
|
1574
|
+
});
|
|
1575
|
+
test('--repo api --threshold high => both set', () => {
|
|
1576
|
+
const r = _parseArgs(['--repo', 'api', '--threshold', 'high']);
|
|
1577
|
+
assert.strictEqual(r.only_repo, 'api');
|
|
1578
|
+
assert.strictEqual(r.threshold, 'high');
|
|
1579
|
+
});
|
|
1580
|
+
test('--include-dev-deps => include_dev_dependencies:true', () => {
|
|
1581
|
+
assert.strictEqual(_parseArgs(['--include-dev-deps']).include_dev_dependencies, true);
|
|
1582
|
+
});
|
|
1583
|
+
test('--no-include-dev-deps => include_dev_dependencies:false', () => {
|
|
1584
|
+
assert.strictEqual(_parseArgs(['--no-include-dev-deps']).include_dev_dependencies, false);
|
|
1585
|
+
});
|
|
1586
|
+
test('--json => json:true', () => {
|
|
1587
|
+
assert.strictEqual(_parseArgs(['--json']).json, true);
|
|
1588
|
+
});
|
|
1589
|
+
test('--raw => raw:true (independent flag)', () => {
|
|
1590
|
+
assert.strictEqual(_parseArgs(['--raw']).raw, true);
|
|
1591
|
+
});
|
|
1592
|
+
test('--threshold without value => throws', () => {
|
|
1593
|
+
assert.throws(() => _parseArgs(['--threshold']), /Missing value for --threshold/);
|
|
1594
|
+
});
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
describe('_severityRank(tier)', () => {
|
|
1598
|
+
test('canonical tiers map to descending integers', () => {
|
|
1599
|
+
assert.strictEqual(_severityRank('critical'), 4);
|
|
1600
|
+
assert.strictEqual(_severityRank('high'), 3);
|
|
1601
|
+
assert.strictEqual(_severityRank('medium'), 2);
|
|
1602
|
+
assert.strictEqual(_severityRank('low'), 1);
|
|
1603
|
+
});
|
|
1604
|
+
test('unknown => 0', () => {
|
|
1605
|
+
assert.strictEqual(_severityRank('unknown'), 0);
|
|
1606
|
+
});
|
|
1607
|
+
test('null => 0', () => {
|
|
1608
|
+
assert.strictEqual(_severityRank(null), 0);
|
|
1609
|
+
});
|
|
1610
|
+
test('CRITICAL (case-insensitive) => 4', () => {
|
|
1611
|
+
assert.strictEqual(_severityRank('CRITICAL'), 4);
|
|
1612
|
+
});
|
|
1613
|
+
test('strict ordering', () => {
|
|
1614
|
+
assert.ok(_severityRank('critical') > _severityRank('high'));
|
|
1615
|
+
assert.ok(_severityRank('high') > _severityRank('medium'));
|
|
1616
|
+
assert.ok(_severityRank('medium') > _severityRank('low'));
|
|
1617
|
+
assert.ok(_severityRank('low') > _severityRank('unknown'));
|
|
1618
|
+
});
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1621
|
+
describe('_filterByThreshold(findings, threshold)', () => {
|
|
1622
|
+
const sevs = ['critical', 'high', 'medium', 'low'];
|
|
1623
|
+
const mkF = (s) => ({ severity: s });
|
|
1624
|
+
test('empty findings => empty array', () => {
|
|
1625
|
+
assert.deepStrictEqual(_filterByThreshold([], 'high'), []);
|
|
1626
|
+
});
|
|
1627
|
+
test('threshold low => returns all', () => {
|
|
1628
|
+
const f = sevs.map(mkF);
|
|
1629
|
+
assert.strictEqual(_filterByThreshold(f, 'low').length, 4);
|
|
1630
|
+
});
|
|
1631
|
+
test('threshold medium => drops low', () => {
|
|
1632
|
+
const f = sevs.map(mkF);
|
|
1633
|
+
const out = _filterByThreshold(f, 'medium');
|
|
1634
|
+
assert.strictEqual(out.length, 3);
|
|
1635
|
+
assert.ok(out.every(x => x.severity !== 'low'));
|
|
1636
|
+
});
|
|
1637
|
+
test('threshold high => keeps only critical+high', () => {
|
|
1638
|
+
const f = sevs.map(mkF);
|
|
1639
|
+
const out = _filterByThreshold(f, 'high');
|
|
1640
|
+
assert.strictEqual(out.length, 2);
|
|
1641
|
+
});
|
|
1642
|
+
test('monotonicity over mixed input of 20', () => {
|
|
1643
|
+
const arr = [];
|
|
1644
|
+
for (let i = 0; i < 5; i++) for (const s of sevs) arr.push(mkF(s));
|
|
1645
|
+
const lenLow = _filterByThreshold(arr, 'low').length;
|
|
1646
|
+
const lenMed = _filterByThreshold(arr, 'medium').length;
|
|
1647
|
+
const lenHigh = _filterByThreshold(arr, 'high').length;
|
|
1648
|
+
const lenCrit = _filterByThreshold(arr, 'critical').length;
|
|
1649
|
+
assert.ok(lenLow >= lenMed);
|
|
1650
|
+
assert.ok(lenMed >= lenHigh);
|
|
1651
|
+
assert.ok(lenHigh >= lenCrit);
|
|
1652
|
+
});
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
describe('runScan with opts.threshold', () => {
|
|
1656
|
+
let tmpDir;
|
|
1657
|
+
afterEach(() => cleanup(tmpDir));
|
|
1658
|
+
|
|
1659
|
+
test('threshold high filters to critical+high only', () => {
|
|
1660
|
+
tmpDir = setupPlanningRoot();
|
|
1661
|
+
const detector = (dir) => {
|
|
1662
|
+
if (dir === tmpDir) return [{ ecosystem: 'node', manifest_path: null }];
|
|
1663
|
+
return [];
|
|
1664
|
+
};
|
|
1665
|
+
const snykPayload = {
|
|
1666
|
+
vulnerabilities: [
|
|
1667
|
+
{ packageName: 'a', version: '1.0', severity: 'critical', title: 'a', identifiers: { CVE: [] }, from: ['x@1', 'a@1'] },
|
|
1668
|
+
{ packageName: 'b', version: '1.0', severity: 'high', title: 'b', identifiers: { CVE: [] }, from: ['x@1', 'b@1'] },
|
|
1669
|
+
{ packageName: 'c', version: '1.0', severity: 'medium', title: 'c', identifiers: { CVE: [] }, from: ['x@1', 'c@1'] },
|
|
1670
|
+
{ packageName: 'd', version: '1.0', severity: 'low', title: 'd', identifiers: { CVE: [] }, from: ['x@1', 'd@1'] },
|
|
1671
|
+
],
|
|
1672
|
+
};
|
|
1673
|
+
const runner = spyRunner({
|
|
1674
|
+
'snyk': { exitCode: 0, stdout: JSON.stringify(snykPayload), outcome: 'ok', duration: 1, parsed: snykPayload },
|
|
1675
|
+
});
|
|
1676
|
+
const result = runScan(tmpDir, { threshold: 'high' }, {
|
|
1677
|
+
detector, runner,
|
|
1678
|
+
snykAuth: () => ({ available: true, source: 'env', token: 't' }),
|
|
1679
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
1680
|
+
});
|
|
1681
|
+
assert.strictEqual(result.findings.length, 2);
|
|
1682
|
+
assert.ok(result.findings.every(f => ['critical', 'high'].includes(f.severity)));
|
|
1683
|
+
});
|
|
1684
|
+
|
|
1685
|
+
test('opts:{} keeps all 4 findings (default threshold)', () => {
|
|
1686
|
+
tmpDir = setupPlanningRoot();
|
|
1687
|
+
const detector = (dir) => dir === tmpDir ? [{ ecosystem: 'node', manifest_path: null }] : [];
|
|
1688
|
+
const snykPayload = {
|
|
1689
|
+
vulnerabilities: [
|
|
1690
|
+
{ packageName: 'a', version: '1.0', severity: 'critical', title: 'a', identifiers: { CVE: [] }, from: ['x@1', 'a@1'] },
|
|
1691
|
+
{ packageName: 'b', version: '1.0', severity: 'high', title: 'b', identifiers: { CVE: [] }, from: ['x@1', 'b@1'] },
|
|
1692
|
+
{ packageName: 'c', version: '1.0', severity: 'medium', title: 'c', identifiers: { CVE: [] }, from: ['x@1', 'c@1'] },
|
|
1693
|
+
{ packageName: 'd', version: '1.0', severity: 'low', title: 'd', identifiers: { CVE: [] }, from: ['x@1', 'd@1'] },
|
|
1694
|
+
],
|
|
1695
|
+
};
|
|
1696
|
+
const runner = spyRunner({
|
|
1697
|
+
'snyk': { exitCode: 0, stdout: JSON.stringify(snykPayload), outcome: 'ok', duration: 1, parsed: snykPayload },
|
|
1698
|
+
});
|
|
1699
|
+
const result = runScan(tmpDir, {}, {
|
|
1700
|
+
detector, runner,
|
|
1701
|
+
snykAuth: () => ({ available: true, source: 'env', token: 't' }),
|
|
1702
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
1703
|
+
});
|
|
1704
|
+
assert.strictEqual(result.findings.length, 4);
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
test('config severity_threshold=medium honoured when no opts.threshold', () => {
|
|
1708
|
+
tmpDir = setupPlanningRoot({ config: { testing: { packages: { severity_threshold: 'medium' } } } });
|
|
1709
|
+
const detector = (dir) => dir === tmpDir ? [{ ecosystem: 'node', manifest_path: null }] : [];
|
|
1710
|
+
const snykPayload = {
|
|
1711
|
+
vulnerabilities: [
|
|
1712
|
+
{ packageName: 'a', version: '1.0', severity: 'critical', title: 'a', identifiers: { CVE: [] }, from: ['x@1', 'a@1'] },
|
|
1713
|
+
{ packageName: 'b', version: '1.0', severity: 'high', title: 'b', identifiers: { CVE: [] }, from: ['x@1', 'b@1'] },
|
|
1714
|
+
{ packageName: 'c', version: '1.0', severity: 'medium', title: 'c', identifiers: { CVE: [] }, from: ['x@1', 'c@1'] },
|
|
1715
|
+
{ packageName: 'd', version: '1.0', severity: 'low', title: 'd', identifiers: { CVE: [] }, from: ['x@1', 'd@1'] },
|
|
1716
|
+
],
|
|
1717
|
+
};
|
|
1718
|
+
const runner = spyRunner({
|
|
1719
|
+
'snyk': { exitCode: 0, stdout: JSON.stringify(snykPayload), outcome: 'ok', duration: 1, parsed: snykPayload },
|
|
1720
|
+
});
|
|
1721
|
+
const result = runScan(tmpDir, {}, {
|
|
1722
|
+
detector, runner,
|
|
1723
|
+
snykAuth: () => ({ available: true, source: 'env', token: 't' }),
|
|
1724
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
1725
|
+
});
|
|
1726
|
+
assert.strictEqual(result.findings.length, 3);
|
|
1727
|
+
});
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
describe('runScan with opts.only_repo', () => {
|
|
1731
|
+
let tmpDir;
|
|
1732
|
+
afterEach(() => cleanup(tmpDir));
|
|
1733
|
+
|
|
1734
|
+
test('only_repo:api scans api only (excludes product root)', () => {
|
|
1735
|
+
tmpDir = setupPlanningRoot({ repos: [
|
|
1736
|
+
{ name: 'api', path: '../api' },
|
|
1737
|
+
{ name: 'web', path: '../web' },
|
|
1738
|
+
]});
|
|
1739
|
+
const detector = (_dir) => [];
|
|
1740
|
+
const result = runScan(tmpDir, { only_repo: 'api' }, makeStubDeps({ detector }));
|
|
1741
|
+
assert.strictEqual(result.exit_code, 0);
|
|
1742
|
+
// Only one repo target scanned (api), product root excluded
|
|
1743
|
+
assert.strictEqual(result.repo_results.length, 1);
|
|
1744
|
+
assert.strictEqual(result.repo_results[0].repo, 'api');
|
|
1745
|
+
});
|
|
1746
|
+
|
|
1747
|
+
test('only_repo:nonexistent => exit_code 2 with diagnostic', () => {
|
|
1748
|
+
tmpDir = setupPlanningRoot({ repos: [
|
|
1749
|
+
{ name: 'api', path: '../api' },
|
|
1750
|
+
{ name: 'web', path: '../web' },
|
|
1751
|
+
]});
|
|
1752
|
+
const result = runScan(tmpDir, { only_repo: 'nonexistent' }, makeStubDeps());
|
|
1753
|
+
assert.strictEqual(result.exit_code, 2);
|
|
1754
|
+
assert.ok(Array.isArray(result.diagnostics));
|
|
1755
|
+
assert.ok(result.diagnostics.length >= 1);
|
|
1756
|
+
assert.strictEqual(result.diagnostics[0].kind, 'unknown_repo');
|
|
1757
|
+
assert.match(result.diagnostics[0].message, /Unknown repo: 'nonexistent'/);
|
|
1758
|
+
assert.match(result.diagnostics[0].message, /Valid repos: api, web/);
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
test('only_repo:null => all repos + product root scanned', () => {
|
|
1762
|
+
tmpDir = setupPlanningRoot({ repos: [{ name: 'api', path: '../api' }] });
|
|
1763
|
+
const result = runScan(tmpDir, { only_repo: null }, makeStubDeps({ detector: () => [] }));
|
|
1764
|
+
assert.strictEqual(result.exit_code, 0);
|
|
1765
|
+
assert.strictEqual(result.repo_results.length, 2); // api + product root
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
test('empty REPOS.md + only_repo:api => exit_code 2 (none registered)', () => {
|
|
1769
|
+
tmpDir = setupPlanningRoot();
|
|
1770
|
+
const result = runScan(tmpDir, { only_repo: 'api' }, makeStubDeps());
|
|
1771
|
+
assert.strictEqual(result.exit_code, 2);
|
|
1772
|
+
assert.match(result.diagnostics[0].message, /Unknown repo/);
|
|
1773
|
+
assert.match(result.diagnostics[0].message, /(none registered|Valid repos:)/);
|
|
1774
|
+
});
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
describe('cmdPackageScan argv routing', () => {
|
|
1778
|
+
let tmpDir;
|
|
1779
|
+
afterEach(() => cleanup(tmpDir));
|
|
1780
|
+
|
|
1781
|
+
test('--threshold high passed through to runScan', () => {
|
|
1782
|
+
tmpDir = setupPlanningRoot();
|
|
1783
|
+
let capturedOpts;
|
|
1784
|
+
const runScanSpy = (cwd, opts) => {
|
|
1785
|
+
capturedOpts = opts;
|
|
1786
|
+
return { exit_code: 0, findings: [], repo_results: [], tool_per_target: {}, skipped: [], diagnostics: [] };
|
|
1787
|
+
};
|
|
1788
|
+
// Stub process.exit so it does not kill the test process.
|
|
1789
|
+
const origExit = process.exit;
|
|
1790
|
+
process.exit = () => {};
|
|
1791
|
+
try {
|
|
1792
|
+
cmdPackageScan(tmpDir, ['--threshold', 'high'], false, {
|
|
1793
|
+
_runScan: runScanSpy,
|
|
1794
|
+
commit: () => ({ status: 0, stdout: '', stderr: '' }),
|
|
1795
|
+
});
|
|
1796
|
+
} finally {
|
|
1797
|
+
process.exit = origExit;
|
|
1798
|
+
}
|
|
1799
|
+
assert.strictEqual(capturedOpts.threshold, 'high');
|
|
1800
|
+
});
|
|
1801
|
+
|
|
1802
|
+
test('--repo api passed through to runScan', () => {
|
|
1803
|
+
tmpDir = setupPlanningRoot();
|
|
1804
|
+
let capturedOpts;
|
|
1805
|
+
const runScanSpy = (cwd, opts) => {
|
|
1806
|
+
capturedOpts = opts;
|
|
1807
|
+
return { exit_code: 0, findings: [], repo_results: [], tool_per_target: {}, skipped: [], diagnostics: [] };
|
|
1808
|
+
};
|
|
1809
|
+
const origExit = process.exit;
|
|
1810
|
+
process.exit = () => {};
|
|
1811
|
+
try {
|
|
1812
|
+
cmdPackageScan(tmpDir, ['--repo', 'api'], false, {
|
|
1813
|
+
_runScan: runScanSpy,
|
|
1814
|
+
commit: () => ({ status: 0, stdout: '', stderr: '' }),
|
|
1815
|
+
});
|
|
1816
|
+
} finally {
|
|
1817
|
+
process.exit = origExit;
|
|
1818
|
+
}
|
|
1819
|
+
assert.strictEqual(capturedOpts.only_repo, 'api');
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
test('--no-include-dev-deps passed through', () => {
|
|
1823
|
+
tmpDir = setupPlanningRoot();
|
|
1824
|
+
let capturedOpts;
|
|
1825
|
+
const runScanSpy = (cwd, opts) => {
|
|
1826
|
+
capturedOpts = opts;
|
|
1827
|
+
return { exit_code: 0, findings: [], repo_results: [], tool_per_target: {}, skipped: [], diagnostics: [] };
|
|
1828
|
+
};
|
|
1829
|
+
const origExit = process.exit;
|
|
1830
|
+
process.exit = () => {};
|
|
1831
|
+
try {
|
|
1832
|
+
cmdPackageScan(tmpDir, ['--no-include-dev-deps'], false, {
|
|
1833
|
+
_runScan: runScanSpy,
|
|
1834
|
+
commit: () => ({ status: 0, stdout: '', stderr: '' }),
|
|
1835
|
+
});
|
|
1836
|
+
} finally {
|
|
1837
|
+
process.exit = origExit;
|
|
1838
|
+
}
|
|
1839
|
+
assert.strictEqual(capturedOpts.include_dev_dependencies, false);
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
test('--json emits compact JSON to stdout (raw-equivalent)', () => {
|
|
1843
|
+
tmpDir = setupPlanningRoot();
|
|
1844
|
+
const runScanSpy = (_cwd, _opts) => ({
|
|
1845
|
+
exit_code: 0, findings: [], repo_results: [], tool_per_target: {}, skipped: [], diagnostics: [],
|
|
1846
|
+
});
|
|
1847
|
+
const origExit = process.exit;
|
|
1848
|
+
const origWrite = process.stdout.write;
|
|
1849
|
+
let captured = '';
|
|
1850
|
+
process.exit = () => {};
|
|
1851
|
+
process.stdout.write = (chunk) => { captured += chunk; return true; };
|
|
1852
|
+
try {
|
|
1853
|
+
cmdPackageScan(tmpDir, ['--json'], false, {
|
|
1854
|
+
_runScan: runScanSpy,
|
|
1855
|
+
commit: () => ({ status: 0, stdout: '', stderr: '' }),
|
|
1856
|
+
});
|
|
1857
|
+
} finally {
|
|
1858
|
+
process.exit = origExit;
|
|
1859
|
+
process.stdout.write = origWrite;
|
|
1860
|
+
}
|
|
1861
|
+
// First line of captured output should be JSON
|
|
1862
|
+
const firstLine = captured.split('\n')[0];
|
|
1863
|
+
const parsed = JSON.parse(firstLine);
|
|
1864
|
+
assert.strictEqual(parsed.exit_code, 0);
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
test('--threshold bogus => exit 2 + stderr diagnostic', () => {
|
|
1868
|
+
tmpDir = setupPlanningRoot();
|
|
1869
|
+
const origExit = process.exit;
|
|
1870
|
+
const origStderrWrite = process.stderr.write;
|
|
1871
|
+
let exitCode = null;
|
|
1872
|
+
let stderr = '';
|
|
1873
|
+
process.exit = (code) => { exitCode = code; };
|
|
1874
|
+
process.stderr.write = (s) => { stderr += s; return true; };
|
|
1875
|
+
try {
|
|
1876
|
+
cmdPackageScan(tmpDir, ['--threshold', 'bogus'], false, {
|
|
1877
|
+
_runScan: () => ({ exit_code: 0, findings: [], repo_results: [], tool_per_target: {}, skipped: [], diagnostics: [] }),
|
|
1878
|
+
commit: () => ({ status: 0, stdout: '', stderr: '' }),
|
|
1879
|
+
});
|
|
1880
|
+
} finally {
|
|
1881
|
+
process.exit = origExit;
|
|
1882
|
+
process.stderr.write = origStderrWrite;
|
|
1883
|
+
}
|
|
1884
|
+
assert.strictEqual(exitCode, 2);
|
|
1885
|
+
assert.match(stderr, /Invalid threshold/);
|
|
1886
|
+
});
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
describe('dispatcher smoke', () => {
|
|
1890
|
+
let tmpDir;
|
|
1891
|
+
afterEach(() => cleanup(tmpDir));
|
|
1892
|
+
|
|
1893
|
+
test('--threshold critical --json from dispatcher emits valid JSON or exits 2', () => {
|
|
1894
|
+
tmpDir = setupPlanningRoot();
|
|
1895
|
+
const dgsToolsPath = path.join(__dirname, '..', 'dgs-tools.cjs');
|
|
1896
|
+
const { spawnSync } = require('child_process');
|
|
1897
|
+
const r = spawnSync('node', [dgsToolsPath, 'package-scan', '--threshold', 'critical', '--json'], {
|
|
1898
|
+
cwd: tmpDir, encoding: 'utf-8', timeout: 30000,
|
|
1899
|
+
});
|
|
1900
|
+
// Accept exit 0 (clean scan; valid JSON in stdout) OR exit 2 (some setup error)
|
|
1901
|
+
assert.ok(r.status === 0 || r.status === 2, `unexpected exit: ${r.status}`);
|
|
1902
|
+
if (r.status === 0) {
|
|
1903
|
+
const firstLine = (r.stdout || '').split('\n')[0];
|
|
1904
|
+
if (firstLine.length > 0 && firstLine.trim().startsWith('{')) {
|
|
1905
|
+
const parsed = JSON.parse(firstLine);
|
|
1906
|
+
assert.ok(typeof parsed === 'object');
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
});
|
|
1910
|
+
|
|
1911
|
+
test('--threshold bogus from dispatcher exits 2 with Invalid threshold stderr', () => {
|
|
1912
|
+
tmpDir = setupPlanningRoot();
|
|
1913
|
+
const dgsToolsPath = path.join(__dirname, '..', 'dgs-tools.cjs');
|
|
1914
|
+
const { spawnSync } = require('child_process');
|
|
1915
|
+
const r = spawnSync('node', [dgsToolsPath, 'package-scan', '--threshold', 'bogus'], {
|
|
1916
|
+
cwd: tmpDir, encoding: 'utf-8', timeout: 30000,
|
|
1917
|
+
});
|
|
1918
|
+
assert.strictEqual(r.status, 2);
|
|
1919
|
+
assert.match(r.stderr || '', /Invalid threshold/);
|
|
1920
|
+
});
|
|
1921
|
+
|
|
1922
|
+
test('--repo ghost from dispatcher exits 2', () => {
|
|
1923
|
+
tmpDir = setupPlanningRoot();
|
|
1924
|
+
const dgsToolsPath = path.join(__dirname, '..', 'dgs-tools.cjs');
|
|
1925
|
+
const { spawnSync } = require('child_process');
|
|
1926
|
+
const r = spawnSync('node', [dgsToolsPath, 'package-scan', '--repo', 'ghost'], {
|
|
1927
|
+
cwd: tmpDir, encoding: 'utf-8', timeout: 30000,
|
|
1928
|
+
});
|
|
1929
|
+
assert.strictEqual(r.status, 2);
|
|
1930
|
+
const stderr = r.stderr || '';
|
|
1931
|
+
assert.ok(/Unknown repo/.test(stderr) || /none registered/.test(stderr) || /Scan failed/.test(stderr), `stderr: ${stderr}`);
|
|
1932
|
+
});
|
|
1933
|
+
});
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
// ─── Phase 153 Plan 02: .snyk policy detection (PKG-32) ──────────────────────
|
|
1937
|
+
|
|
1938
|
+
describe('Phase 153: .snyk policy detection (PKG-32)', () => {
|
|
1939
|
+
const { _detectSnykPolicy } = require('./package-scan.cjs');
|
|
1940
|
+
let tmpDir;
|
|
1941
|
+
afterEach(() => cleanup(tmpDir));
|
|
1942
|
+
|
|
1943
|
+
test('_detectSnykPolicy(dir with .snyk file) => true', () => {
|
|
1944
|
+
tmpDir = setupPlanningRoot();
|
|
1945
|
+
fs.writeFileSync(path.join(tmpDir, '.snyk'), 'version: v1.0.0\n');
|
|
1946
|
+
assert.strictEqual(_detectSnykPolicy(tmpDir), true);
|
|
1947
|
+
});
|
|
1948
|
+
|
|
1949
|
+
test('_detectSnykPolicy(dir without .snyk) => false', () => {
|
|
1950
|
+
tmpDir = setupPlanningRoot();
|
|
1951
|
+
assert.strictEqual(_detectSnykPolicy(tmpDir), false);
|
|
1952
|
+
});
|
|
1953
|
+
|
|
1954
|
+
test('collectScanTargets stamps has_snyk_policy on every target', () => {
|
|
1955
|
+
tmpDir = setupPlanningRoot({ repos: [{ name: 'api', path: '../api' }] });
|
|
1956
|
+
fs.writeFileSync(path.join(tmpDir, '.snyk'), 'version: v1.0.0\n');
|
|
1957
|
+
const { targets } = collectScanTargets(tmpDir);
|
|
1958
|
+
for (const t of targets) {
|
|
1959
|
+
assert.strictEqual(typeof t.has_snyk_policy, 'boolean');
|
|
1960
|
+
}
|
|
1961
|
+
const productRoot = targets.find(t => t.name === '_product_root');
|
|
1962
|
+
assert.strictEqual(productRoot.has_snyk_policy, true);
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1965
|
+
test('runScan: ctx.recordSuppressions(2) -> repo_results[0].snyk_suppressions_count === 2', () => {
|
|
1966
|
+
tmpDir = setupPlanningRoot();
|
|
1967
|
+
fs.writeFileSync(path.join(tmpDir, '.snyk'), 'version: v1.0.0\n');
|
|
1968
|
+
const detector = (dir) => dir === tmpDir ? [{ ecosystem: 'node', manifest_path: null }] : [];
|
|
1969
|
+
// Stub adapter via ADAPTER_FOR_TOOL override is hard; we use a stubbed runner
|
|
1970
|
+
// and rely on the actual adapterSnyk to call recordSuppressions when ctx provides it.
|
|
1971
|
+
const snykPayload = {
|
|
1972
|
+
vulnerabilities: [
|
|
1973
|
+
{ packageName: 'a', version: '1.0', severity: 'high', title: 'a', identifiers: { CVE: [] }, from: ['x@1', 'a@1'] },
|
|
1974
|
+
{ packageName: 'b', version: '1.0', severity: 'high', title: 'b', identifiers: { CVE: [] }, from: ['x@1', 'b@1'] },
|
|
1975
|
+
{ packageName: 'c', version: '1.0', severity: 'high', title: 'c', identifiers: { CVE: [] }, from: ['x@1', 'c@1'] },
|
|
1976
|
+
],
|
|
1977
|
+
filtered: { ignore: [{ id: 'S-1' }, { id: 'S-2' }] },
|
|
1978
|
+
};
|
|
1979
|
+
const runner = spyRunner({
|
|
1980
|
+
'snyk': { exitCode: 0, stdout: JSON.stringify(snykPayload), outcome: 'ok', duration: 1, parsed: snykPayload },
|
|
1981
|
+
});
|
|
1982
|
+
const result = runScan(tmpDir, {}, {
|
|
1983
|
+
detector, runner,
|
|
1984
|
+
snykAuth: () => ({ available: true, source: 'env', token: 't' }),
|
|
1985
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
1986
|
+
});
|
|
1987
|
+
assert.strictEqual(result.findings.length, 3);
|
|
1988
|
+
const productRow = result.repo_results.find(r => r.repo === '_product_root');
|
|
1989
|
+
assert.ok(productRow);
|
|
1990
|
+
assert.strictEqual(productRow.has_snyk_policy, true);
|
|
1991
|
+
assert.strictEqual(productRow.snyk_suppressions_count, 2);
|
|
1992
|
+
});
|
|
1993
|
+
|
|
1994
|
+
test('runScan: adapter never calls recordSuppressions => snyk_suppressions_count === 0', () => {
|
|
1995
|
+
tmpDir = setupPlanningRoot();
|
|
1996
|
+
const detector = (_dir) => [];
|
|
1997
|
+
const result = runScan(tmpDir, {}, makeStubDeps({ detector }));
|
|
1998
|
+
const productRow = result.repo_results.find(r => r.repo === '_product_root');
|
|
1999
|
+
assert.ok(productRow);
|
|
2000
|
+
// no manifests => no adapter invocation, but field is set safely
|
|
2001
|
+
assert.strictEqual(productRow.snyk_suppressions_count, 0);
|
|
2002
|
+
});
|
|
2003
|
+
});
|
|
2004
|
+
|
|
2005
|
+
// ─── Phase 153 Plan 05: plan provenance (PKG-33) ─────────────────────────────
|
|
2006
|
+
|
|
2007
|
+
describe('Phase 153: plan provenance (PKG-33)', () => {
|
|
2008
|
+
let tmpDir;
|
|
2009
|
+
afterEach(() => cleanup(tmpDir));
|
|
2010
|
+
|
|
2011
|
+
test('runScan stamps introduced_in_commit + introduced_in_plan from deps.provenanceLookup', () => {
|
|
2012
|
+
tmpDir = setupPlanningRoot();
|
|
2013
|
+
const detector = (dir) => dir === tmpDir ? [{ ecosystem: 'node', manifest_path: null }] : [];
|
|
2014
|
+
const snykPayload = {
|
|
2015
|
+
vulnerabilities: [
|
|
2016
|
+
{ packageName: 'lodash', version: '1.0', severity: 'high', title: 'a', identifiers: { CVE: [] }, from: ['x@1', 'lodash@1'] },
|
|
2017
|
+
],
|
|
2018
|
+
};
|
|
2019
|
+
const runner = spyRunner({
|
|
2020
|
+
'snyk': { exitCode: 0, stdout: JSON.stringify(snykPayload), outcome: 'ok', duration: 1, parsed: snykPayload },
|
|
2021
|
+
});
|
|
2022
|
+
const result = runScan(tmpDir, {}, {
|
|
2023
|
+
detector, runner,
|
|
2024
|
+
snykAuth: () => ({ available: true, source: 'env', token: 't' }),
|
|
2025
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
2026
|
+
provenanceLookup: () => ({ commit: 'abc1234', plan: '149-01' }),
|
|
2027
|
+
});
|
|
2028
|
+
assert.strictEqual(result.findings.length, 1);
|
|
2029
|
+
assert.strictEqual(result.findings[0].introduced_in_commit, 'abc1234');
|
|
2030
|
+
assert.strictEqual(result.findings[0].introduced_in_plan, '149-01');
|
|
2031
|
+
});
|
|
2032
|
+
|
|
2033
|
+
test('lookup returning nulls => fields are null', () => {
|
|
2034
|
+
tmpDir = setupPlanningRoot();
|
|
2035
|
+
const detector = (dir) => dir === tmpDir ? [{ ecosystem: 'node', manifest_path: null }] : [];
|
|
2036
|
+
const snykPayload = {
|
|
2037
|
+
vulnerabilities: [
|
|
2038
|
+
{ packageName: 'lodash', version: '1.0', severity: 'high', title: 'a', identifiers: { CVE: [] }, from: ['x@1', 'lodash@1'] },
|
|
2039
|
+
],
|
|
2040
|
+
};
|
|
2041
|
+
const runner = spyRunner({
|
|
2042
|
+
'snyk': { exitCode: 0, stdout: JSON.stringify(snykPayload), outcome: 'ok', duration: 1, parsed: snykPayload },
|
|
2043
|
+
});
|
|
2044
|
+
const result = runScan(tmpDir, {}, {
|
|
2045
|
+
detector, runner,
|
|
2046
|
+
snykAuth: () => ({ available: true, source: 'env', token: 't' }),
|
|
2047
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
2048
|
+
provenanceLookup: () => ({ commit: null, plan: null }),
|
|
2049
|
+
});
|
|
2050
|
+
assert.strictEqual(result.findings[0].introduced_in_commit, null);
|
|
2051
|
+
assert.strictEqual(result.findings[0].introduced_in_plan, null);
|
|
2052
|
+
});
|
|
2053
|
+
|
|
2054
|
+
test('memoisation: same (repoDir, manifestPath, packageName) => lookup called once for same package', () => {
|
|
2055
|
+
tmpDir = setupPlanningRoot();
|
|
2056
|
+
const detector = (dir) => dir === tmpDir ? [{ ecosystem: 'node', manifest_path: null }] : [];
|
|
2057
|
+
// Two findings of the SAME package — should hit cache.
|
|
2058
|
+
const snykPayload = {
|
|
2059
|
+
vulnerabilities: [
|
|
2060
|
+
{ packageName: 'lodash', version: '1.0', severity: 'high', title: 'a', identifiers: { CVE: [] }, from: ['x@1', 'lodash@1'] },
|
|
2061
|
+
{ packageName: 'lodash', version: '1.0', severity: 'high', title: 'b', identifiers: { CVE: [] }, from: ['x@1', 'lodash@1'] },
|
|
2062
|
+
{ packageName: 'axios', version: '1.0', severity: 'high', title: 'c', identifiers: { CVE: [] }, from: ['x@1', 'axios@1'] },
|
|
2063
|
+
],
|
|
2064
|
+
};
|
|
2065
|
+
const runner = spyRunner({
|
|
2066
|
+
'snyk': { exitCode: 0, stdout: JSON.stringify(snykPayload), outcome: 'ok', duration: 1, parsed: snykPayload },
|
|
2067
|
+
});
|
|
2068
|
+
let lookupCalls = 0;
|
|
2069
|
+
runScan(tmpDir, {}, {
|
|
2070
|
+
detector, runner,
|
|
2071
|
+
snykAuth: () => ({ available: true, source: 'env', token: 't' }),
|
|
2072
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
2073
|
+
provenanceLookup: () => { lookupCalls += 1; return { commit: 'abc', plan: null }; },
|
|
2074
|
+
});
|
|
2075
|
+
// Two unique package names → two lookup calls.
|
|
2076
|
+
assert.strictEqual(lookupCalls, 2);
|
|
2077
|
+
});
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2080
|
+
// ─── Phase 153 Plan 03: licence_roster on runResult (PKG-30, PKG-34) ─────────
|
|
2081
|
+
|
|
2082
|
+
describe('Phase 153: licence_roster on runResult (PKG-30, PKG-34)', () => {
|
|
2083
|
+
let tmpDir;
|
|
2084
|
+
afterEach(() => cleanup(tmpDir));
|
|
2085
|
+
|
|
2086
|
+
test('snyk adapter calling ctx.recordLicenceRoster => result.licence_roster has entries with repo stamped', () => {
|
|
2087
|
+
tmpDir = setupPlanningRoot();
|
|
2088
|
+
const detector = (dir) => dir === tmpDir ? [{ ecosystem: 'node', manifest_path: null }] : [];
|
|
2089
|
+
const snykPayload = {
|
|
2090
|
+
vulnerabilities: [],
|
|
2091
|
+
dependencyPackages: { 'lodash@4.17.21': { name: 'lodash', version: '4.17.21', license: 'MIT' } },
|
|
2092
|
+
};
|
|
2093
|
+
const runner = spyRunner({
|
|
2094
|
+
'snyk': { exitCode: 0, stdout: JSON.stringify(snykPayload), outcome: 'ok', duration: 1, parsed: snykPayload },
|
|
2095
|
+
});
|
|
2096
|
+
const result = runScan(tmpDir, {}, {
|
|
2097
|
+
detector, runner,
|
|
2098
|
+
snykAuth: () => ({ available: true, source: 'env', token: 't' }),
|
|
2099
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
2100
|
+
});
|
|
2101
|
+
assert.ok(Array.isArray(result.licence_roster));
|
|
2102
|
+
assert.strictEqual(result.licence_roster.length, 1);
|
|
2103
|
+
assert.strictEqual(result.licence_roster[0].package_name, 'lodash');
|
|
2104
|
+
assert.strictEqual(result.licence_roster[0].repo, '_product_root');
|
|
2105
|
+
});
|
|
2106
|
+
|
|
2107
|
+
test('multi-target scan concatenates rosters with per-entry repo stamping', () => {
|
|
2108
|
+
tmpDir = setupPlanningRoot({ repos: [
|
|
2109
|
+
{ name: 'api', path: '../api' },
|
|
2110
|
+
{ name: 'web', path: '../web' },
|
|
2111
|
+
]});
|
|
2112
|
+
const apiPath = path.resolve(path.dirname(tmpDir), 'api');
|
|
2113
|
+
const webPath = path.resolve(path.dirname(tmpDir), 'web');
|
|
2114
|
+
const detector = (dir) => {
|
|
2115
|
+
if (dir === apiPath || dir === webPath) return [{ ecosystem: 'node', manifest_path: null }];
|
|
2116
|
+
return [];
|
|
2117
|
+
};
|
|
2118
|
+
// Different roster per target — runner returns the same payload regardless,
|
|
2119
|
+
// but we use a per-call snyk that examines cwd to differentiate.
|
|
2120
|
+
const apiPayload = { vulnerabilities: [], dependencyPackages: { 'a@1.0': { name: 'a', version: '1.0', license: 'MIT' } } };
|
|
2121
|
+
const webPayload = { vulnerabilities: [], dependencyPackages: { 'b@1.0': { name: 'b', version: '1.0', license: 'MIT' } } };
|
|
2122
|
+
const runner = (cwd, _toolKey, _argv, _opts) => {
|
|
2123
|
+
const payload = cwd === apiPath ? apiPayload : webPayload;
|
|
2124
|
+
return { exitCode: 0, stdout: JSON.stringify(payload), outcome: 'ok', duration: 1, parsed: payload };
|
|
2125
|
+
};
|
|
2126
|
+
const result = runScan(tmpDir, {}, {
|
|
2127
|
+
detector, runner,
|
|
2128
|
+
snykAuth: () => ({ available: true, source: 'env', token: 't' }),
|
|
2129
|
+
checkToolOnPath: (bin) => ({ available: bin === 'snyk' }),
|
|
2130
|
+
});
|
|
2131
|
+
assert.ok(Array.isArray(result.licence_roster));
|
|
2132
|
+
// 2 unique entries, one per repo.
|
|
2133
|
+
const repos = result.licence_roster.map(e => e.repo).sort();
|
|
2134
|
+
assert.deepStrictEqual(repos, ['api', 'web']);
|
|
2135
|
+
});
|
|
2136
|
+
|
|
2137
|
+
test('non-snyk scan => result.licence_roster is empty array (not undefined)', () => {
|
|
2138
|
+
tmpDir = setupPlanningRoot();
|
|
2139
|
+
const detector = (_dir) => [];
|
|
2140
|
+
const result = runScan(tmpDir, {}, makeStubDeps({ detector }));
|
|
2141
|
+
assert.ok(Array.isArray(result.licence_roster));
|
|
2142
|
+
assert.strictEqual(result.licence_roster.length, 0);
|
|
2143
|
+
});
|
|
2144
|
+
});
|
|
2145
|
+
|
|
2146
|
+
|
|
2147
|
+
|