capscan 0.1.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/dist/index.js +727 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
- package/src/benchmark.ts +114 -0
- package/src/commands/check.ts +134 -0
- package/src/commands/compare.ts +95 -0
- package/src/commands/diff.ts +61 -0
- package/src/commands/init.ts +132 -0
- package/src/commands/scan.ts +44 -0
- package/src/commands/snapshot.ts +28 -0
- package/src/commands/why.ts +112 -0
- package/src/determinism-test.ts +76 -0
- package/src/generate-expected.ts +30 -0
- package/src/golden-test.ts +79 -0
- package/src/index.ts +27 -0
- package/src/performance-budget.ts +89 -0
- package/src/reporters/json.ts +12 -0
- package/src/reporters/markdown.ts +55 -0
- package/src/reporters/terminal.ts +128 -0
- package/tsconfig.json +9 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { readFile, writeFile, access } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const NPMRC_CONTENT = `
|
|
6
|
+
# CapScan pre-install hook
|
|
7
|
+
# Checks dependency capabilities before installing
|
|
8
|
+
preinstall = npx capscan check --quiet
|
|
9
|
+
`.trim();
|
|
10
|
+
|
|
11
|
+
const GITIGNORE_LINE = 'node_modules/';
|
|
12
|
+
|
|
13
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
14
|
+
try {
|
|
15
|
+
await access(path);
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function readOrCreateNpmrc(projectPath: string): Promise<string> {
|
|
23
|
+
const npmrcPath = join(projectPath, '.npmrc');
|
|
24
|
+
|
|
25
|
+
if (await fileExists(npmrcPath)) {
|
|
26
|
+
return await readFile(npmrcPath, 'utf-8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readOrCreateGitignore(projectPath: string): Promise<string> {
|
|
33
|
+
const gitignorePath = join(projectPath, '.gitignore');
|
|
34
|
+
|
|
35
|
+
if (await fileExists(gitignorePath)) {
|
|
36
|
+
return await readFile(gitignorePath, 'utf-8');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hasCapscanHook(content: string): boolean {
|
|
43
|
+
return content.includes('capscan check');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function addCapscanHook(content: string): string {
|
|
47
|
+
if (hasCapscanHook(content)) {
|
|
48
|
+
return content;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const lines = content.split('\n');
|
|
52
|
+
const lastEmptyIndex = lines.length - 1;
|
|
53
|
+
|
|
54
|
+
// Find a good place to insert
|
|
55
|
+
let insertIndex = lastEmptyIndex;
|
|
56
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
57
|
+
if (lines[i].trim() === '') {
|
|
58
|
+
insertIndex = i;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// If no empty line found, append at end
|
|
64
|
+
if (insertIndex === lastEmptyIndex && lines[lastEmptyIndex]?.trim() !== '') {
|
|
65
|
+
lines.push('');
|
|
66
|
+
insertIndex = lines.length;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
lines.splice(insertIndex, 0, ...NPMRC_CONTENT.split('\n'));
|
|
70
|
+
|
|
71
|
+
return lines.join('\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function addNodeModulesToGitignore(content: string): string {
|
|
75
|
+
if (content.includes('node_modules/')) {
|
|
76
|
+
return content;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return content.trim() + '\n\n' + GITIGNORE_LINE + '\n';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const initCommand = defineCommand({
|
|
83
|
+
meta: {
|
|
84
|
+
name: 'init',
|
|
85
|
+
description: 'Initialize CapScan hooks in your project',
|
|
86
|
+
},
|
|
87
|
+
args: {
|
|
88
|
+
path: { type: 'positional', description: 'Path to project directory', default: '.' },
|
|
89
|
+
force: {
|
|
90
|
+
type: 'boolean',
|
|
91
|
+
description: 'Overwrite existing configuration',
|
|
92
|
+
default: false,
|
|
93
|
+
alias: 'f',
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
async run({ args }) {
|
|
97
|
+
const projectPath = args.path;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
console.log('\n \x1b[36mCapScan\x1b[0m \x1b[2mInit\x1b[0m\n');
|
|
101
|
+
|
|
102
|
+
// Update .npmrc
|
|
103
|
+
const npmrcContent = await readOrCreateNpmrc(projectPath);
|
|
104
|
+
if (hasCapscanHook(npmrcContent) && !args.force) {
|
|
105
|
+
console.log(' \x1b[33m●\x1b[0m .npmrc already configured');
|
|
106
|
+
} else {
|
|
107
|
+
const newNpmrc = addCapscanHook(npmrcContent);
|
|
108
|
+
const npmrcPath = join(projectPath, '.npmrc');
|
|
109
|
+
await writeFile(npmrcPath, newNpmrc, 'utf-8');
|
|
110
|
+
console.log(' \x1b[32m✓\x1b[0m .npmrc updated');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Update .gitignore
|
|
114
|
+
const gitignoreContent = await readOrCreateGitignore(projectPath);
|
|
115
|
+
if (gitignoreContent.includes('node_modules/')) {
|
|
116
|
+
console.log(' \x1b[33m●\x1b[0m .gitignore already has node_modules/');
|
|
117
|
+
} else {
|
|
118
|
+
const newGitignore = addNodeModulesToGitignore(gitignoreContent);
|
|
119
|
+
const gitignorePath = join(projectPath, '.gitignore');
|
|
120
|
+
await writeFile(gitignorePath, newGitignore, 'utf-8');
|
|
121
|
+
console.log(' \x1b[32m✓\x1b[0m .gitignore updated');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log('\n \x1b[2mCapScan will now check capabilities before each install.\x1b[0m');
|
|
125
|
+
console.log(' \x1b[2mTo disable: remove the "preinstall" line from .npmrc\x1b[0m\n');
|
|
126
|
+
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error('\x1b[31m✗\x1b[0m Init failed:', error);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { scan } from '@capscan/engine';
|
|
3
|
+
import { reportTerminal } from '../reporters/terminal.js';
|
|
4
|
+
import { reportJson } from '../reporters/json.js';
|
|
5
|
+
import { reportMarkdown } from '../reporters/markdown.js';
|
|
6
|
+
import ora from 'ora';
|
|
7
|
+
|
|
8
|
+
export const scanCommand = defineCommand({
|
|
9
|
+
meta: { name: 'scan', description: 'Scan a project for dependency capabilities' },
|
|
10
|
+
args: {
|
|
11
|
+
path: { type: 'positional', description: 'Path to project directory', default: '.' },
|
|
12
|
+
format: { type: 'string', description: 'Output format (json, terminal, markdown)', default: 'terminal', alias: 'f' },
|
|
13
|
+
output: { type: 'string', description: 'Output file path', alias: 'o' },
|
|
14
|
+
},
|
|
15
|
+
async run({ args }) {
|
|
16
|
+
const spinner = ora('Scanning project...').start();
|
|
17
|
+
try {
|
|
18
|
+
const result = await scan({ path: args.path });
|
|
19
|
+
spinner.succeed(`Found ${result.summary.packagesWithCapabilities} packages with capabilities`);
|
|
20
|
+
|
|
21
|
+
if (args.format === 'json') {
|
|
22
|
+
const out = await reportJson(result, args.output);
|
|
23
|
+
if (args.output) console.log(`Report saved to ${args.output}`);
|
|
24
|
+
else console.log(out);
|
|
25
|
+
} else if (args.format === 'markdown') {
|
|
26
|
+
const md = reportMarkdown(result);
|
|
27
|
+
if (args.output) {
|
|
28
|
+
await writeFile(args.output, md, 'utf-8');
|
|
29
|
+
console.log(`Report saved to ${args.output}`);
|
|
30
|
+
} else {
|
|
31
|
+
console.log(md);
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
console.log(reportTerminal(result));
|
|
35
|
+
}
|
|
36
|
+
} catch (error) {
|
|
37
|
+
spinner.fail('Scan failed');
|
|
38
|
+
console.error(error);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
import { writeFile } from 'node:fs/promises';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { scan, createSnapshot } from '@capscan/engine';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
|
|
6
|
+
export const snapshotCommand = defineCommand({
|
|
7
|
+
meta: { name: 'snapshot', description: 'Create a capability snapshot for diffing' },
|
|
8
|
+
args: {
|
|
9
|
+
path: { type: 'positional', description: 'Path to project directory', default: '.' },
|
|
10
|
+
output: { type: 'string', description: 'Snapshot file path', default: '.capscan/snapshot.json' },
|
|
11
|
+
},
|
|
12
|
+
async run({ args }) {
|
|
13
|
+
const spinner = ora('Creating snapshot...').start();
|
|
14
|
+
try {
|
|
15
|
+
const result = await scan({ path: args.path });
|
|
16
|
+
const snapshotPath = join(args.path, args.output);
|
|
17
|
+
const snapshot = await createSnapshot(result, snapshotPath);
|
|
18
|
+
|
|
19
|
+
const pkgCount = Object.keys(snapshot.packages).length;
|
|
20
|
+
spinner.succeed(`Snapshot saved to ${args.output}`);
|
|
21
|
+
console.log(` ${pkgCount} packages with capabilities recorded.`);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
spinner.fail('Snapshot failed');
|
|
24
|
+
console.error(error);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { scan } from '@capscan/engine';
|
|
3
|
+
import type { CapabilityCategory, Permission, ScanResult, PackageResult, CapabilityFinding } from '@capscan/engine';
|
|
4
|
+
|
|
5
|
+
function dim(s: string): string { return `\x1b[2m${s}\x1b[0m`; }
|
|
6
|
+
function bold(s: string): string { return `\x1b[1m${s}\x1b[0m`; }
|
|
7
|
+
|
|
8
|
+
const CATEGORY_LABELS: Record<CapabilityCategory, string> = {
|
|
9
|
+
filesystem: 'Filesystem', network: 'Network', process: 'Process',
|
|
10
|
+
environment: 'Environment', crypto: 'Crypto', dynamic_code: 'Dynamic Code',
|
|
11
|
+
native: 'Native', installation: 'Installation', obfuscation: 'Obfuscation',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const CATEGORY_ICONS: Record<CapabilityCategory, string> = {
|
|
15
|
+
filesystem: '📁', network: '🌐', process: '⚙️', environment: '🔑',
|
|
16
|
+
crypto: '🔐', dynamic_code: '📦', native: '🧩', installation: '🔧', obfuscation: '🎭',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function isCategory(input: string): input is CapabilityCategory {
|
|
20
|
+
return Object.keys(CATEGORY_LABELS).includes(input);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function extractPermissions(finding: CapabilityFinding): Permission[] {
|
|
24
|
+
return [finding.capability.permission];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function printFindings(label: string, findings: Array<{ pkg: PackageResult; finding: CapabilityFinding }>) {
|
|
28
|
+
const lines: string[] = [];
|
|
29
|
+
lines.push('');
|
|
30
|
+
lines.push(` ${bold(label)}`);
|
|
31
|
+
lines.push(` ${dim('─'.repeat(50))}`);
|
|
32
|
+
lines.push('');
|
|
33
|
+
|
|
34
|
+
// Group by package
|
|
35
|
+
const byPkg = new Map<string, Array<{ finding: CapabilityFinding }>>();
|
|
36
|
+
for (const { pkg, finding } of findings) {
|
|
37
|
+
const arr = byPkg.get(pkg.name) || [];
|
|
38
|
+
arr.push({ finding });
|
|
39
|
+
byPkg.set(pkg.name, arr);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let totalEvidence = 0;
|
|
43
|
+
for (const [pkgName, items] of byPkg) {
|
|
44
|
+
const pkgVersion = items[0] ? findings.find(f => f.pkg.name === pkgName)?.pkg.version : '';
|
|
45
|
+
lines.push(` ${bold(pkgName)}@${dim(pkgVersion || '')}`);
|
|
46
|
+
for (const { finding } of items) {
|
|
47
|
+
for (const e of finding.evidence) {
|
|
48
|
+
totalEvidence++;
|
|
49
|
+
const loc = e.line > 0 ? `${e.file}:${e.line}` : e.file;
|
|
50
|
+
lines.push(` └── ${dim(loc)} ${bold(e.symbol)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
lines.push('');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
lines.push(` ${dim(`${totalEvidence} evidence points across ${byPkg.size} packages`)}`);
|
|
57
|
+
lines.push('');
|
|
58
|
+
console.log(lines.join('\n'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const whyCommand = defineCommand({
|
|
62
|
+
meta: { name: 'why', description: 'Explain why a capability, permission, or package is detected' },
|
|
63
|
+
args: {
|
|
64
|
+
target: { type: 'positional', description: 'Category, permission, or package name', required: true },
|
|
65
|
+
extra: { type: 'positional', description: 'Second filter (permission or package)', required: false },
|
|
66
|
+
path: { type: 'string', description: 'Path to project directory', default: '.', alias: 'p' },
|
|
67
|
+
},
|
|
68
|
+
async run({ args }) {
|
|
69
|
+
const result = await scan({ path: args.path });
|
|
70
|
+
const target = args.target.toLowerCase();
|
|
71
|
+
const extra = args.extra?.toLowerCase();
|
|
72
|
+
|
|
73
|
+
// Find matching findings
|
|
74
|
+
const matches: Array<{ pkg: PackageResult; finding: CapabilityFinding }> = [];
|
|
75
|
+
|
|
76
|
+
for (const pkg of result.packages) {
|
|
77
|
+
for (const finding of pkg.capabilities) {
|
|
78
|
+
const matchesTarget =
|
|
79
|
+
isCategory(target) && finding.capability.category === target ||
|
|
80
|
+
finding.capability.permission === target ||
|
|
81
|
+
pkg.name.toLowerCase() === target ||
|
|
82
|
+
pkg.name.toLowerCase().includes(target);
|
|
83
|
+
|
|
84
|
+
const matchesExtra = !extra ||
|
|
85
|
+
finding.capability.permission === extra ||
|
|
86
|
+
pkg.name.toLowerCase() === extra ||
|
|
87
|
+
pkg.name.toLowerCase().includes(extra);
|
|
88
|
+
|
|
89
|
+
if (matchesTarget && matchesExtra) {
|
|
90
|
+
matches.push({ pkg, finding });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (matches.length === 0) {
|
|
96
|
+
console.log(`\n No findings for "${args.target}${extra ? ' ' + extra : ''}".\n`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Determine display label
|
|
101
|
+
let label: string;
|
|
102
|
+
if (isCategory(target)) {
|
|
103
|
+
label = `${CATEGORY_ICONS[target]} ${CATEGORY_LABELS[target]}`;
|
|
104
|
+
} else if (matches[0]?.finding.capability.permission === target) {
|
|
105
|
+
label = target;
|
|
106
|
+
} else {
|
|
107
|
+
label = `Package: ${target}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
printFindings(label, matches);
|
|
111
|
+
},
|
|
112
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { scan } from '@capscan/engine';
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const FIXTURES_DIR = resolve(import.meta.dirname, '../../../fixtures');
|
|
6
|
+
const RUNS = 10;
|
|
7
|
+
|
|
8
|
+
async function determinismTest() {
|
|
9
|
+
const fixtures = readdirSync(FIXTURES_DIR).filter(f => {
|
|
10
|
+
try {
|
|
11
|
+
readFileSync(join(FIXTURES_DIR, f, 'package.json'), 'utf-8');
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
console.log(`\n CapScan Determinism Test`);
|
|
19
|
+
console.log(` ${'─'.repeat(50)}`);
|
|
20
|
+
console.log(` Running each fixture ${RUNS} times and comparing outputs\n`);
|
|
21
|
+
|
|
22
|
+
let allPassed = true;
|
|
23
|
+
|
|
24
|
+
for (const fixture of fixtures) {
|
|
25
|
+
const fixtureDir = join(FIXTURES_DIR, fixture);
|
|
26
|
+
const results: unknown[] = [];
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < RUNS; i++) {
|
|
29
|
+
const result = await scan({ path: fixtureDir });
|
|
30
|
+
// Exclude timestamp from comparison (it changes every run)
|
|
31
|
+
(result as { meta: { timestamp: string } }).meta.timestamp = '<stable>';
|
|
32
|
+
results.push(result);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const baseline = JSON.stringify(results[0]);
|
|
36
|
+
let mismatch = -1;
|
|
37
|
+
for (let i = 1; i < results.length; i++) {
|
|
38
|
+
if (JSON.stringify(results[i]) !== baseline) {
|
|
39
|
+
mismatch = i;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (mismatch === -1) {
|
|
45
|
+
console.log(` \x1b[32m✓\x1b[0m ${fixture} — ${RUNS}/${RUNS} identical`);
|
|
46
|
+
} else {
|
|
47
|
+
console.log(` \x1b[31m✗\x1b[0m ${fixture} — mismatch at run ${mismatch}`);
|
|
48
|
+
|
|
49
|
+
// Show diff
|
|
50
|
+
const baselineObj = JSON.parse(baseline);
|
|
51
|
+
const mismatchObj = JSON.parse(results[mismatch] as string);
|
|
52
|
+
|
|
53
|
+
// Compare timestamps
|
|
54
|
+
if (baselineObj.meta.timestamp !== mismatchObj.meta.timestamp) {
|
|
55
|
+
console.log(` Timestamp differs: ${baselineObj.meta.timestamp} vs ${mismatchObj.meta.timestamp}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Compare package counts
|
|
59
|
+
if (baselineObj.summary.totalPackages !== mismatchObj.summary.totalPackages) {
|
|
60
|
+
console.log(` Package count differs: ${baselineObj.summary.totalPackages} vs ${mismatchObj.summary.totalPackages}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Compare capabilities
|
|
64
|
+
if (JSON.stringify(baselineObj.summary.capabilities) !== JSON.stringify(mismatchObj.summary.capabilities)) {
|
|
65
|
+
console.log(` Capabilities differ`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
allPassed = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(`\n ${allPassed ? '\x1b[32mAll tests passed\x1b[0m' : '\x1b[31mSome tests failed\x1b[0m'}\n`);
|
|
73
|
+
process.exit(allPassed ? 0 : 1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
determinismTest();
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { scan } from '@capscan/engine';
|
|
2
|
+
import { writeFileSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const FIXTURES_DIR = resolve(import.meta.dirname, '../../../fixtures');
|
|
6
|
+
|
|
7
|
+
async function generateExpected() {
|
|
8
|
+
const fixtures = readdirSync(FIXTURES_DIR).filter(f => {
|
|
9
|
+
try {
|
|
10
|
+
readFileSync(join(FIXTURES_DIR, f, 'package.json'), 'utf-8');
|
|
11
|
+
return true;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
console.log('Generating expected.json files...\n');
|
|
18
|
+
|
|
19
|
+
for (const fixture of fixtures) {
|
|
20
|
+
const fixtureDir = join(FIXTURES_DIR, fixture);
|
|
21
|
+
const result = await scan({ path: fixtureDir });
|
|
22
|
+
const outputPath = join(fixtureDir, 'expected.json');
|
|
23
|
+
writeFileSync(outputPath, JSON.stringify(result, null, 2), 'utf-8');
|
|
24
|
+
console.log(` ✓ ${fixture}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log('\nDone.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
generateExpected();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { scan } from '@capscan/engine';
|
|
2
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const FIXTURES_DIR = resolve(import.meta.dirname, '../../../fixtures');
|
|
6
|
+
|
|
7
|
+
interface ScanResult {
|
|
8
|
+
version: number;
|
|
9
|
+
meta: { tool: string; engine: string; version: string; timestamp: string; packageManager: string; scanPath: string };
|
|
10
|
+
summary: { totalPackages: number; packagesWithCapabilities: number; capabilities: Record<string, Record<string, number>> };
|
|
11
|
+
packages: Array<{ name: string; version: string; path: string; capabilities: unknown[] }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizeForComparison(result: ScanResult): unknown {
|
|
15
|
+
return {
|
|
16
|
+
summary: result.summary,
|
|
17
|
+
packages: result.packages
|
|
18
|
+
.filter(p => p.capabilities.length > 0)
|
|
19
|
+
.map(p => ({
|
|
20
|
+
name: p.name,
|
|
21
|
+
version: p.version,
|
|
22
|
+
capabilityCount: p.capabilities.length,
|
|
23
|
+
permissions: [...new Set((p.capabilities as Array<{ capability: { permission: string } }>).map(c => c.capability.permission))].sort(),
|
|
24
|
+
}))
|
|
25
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function runGoldenTests() {
|
|
30
|
+
const fixtures = readdirSync(FIXTURES_DIR).filter(f => {
|
|
31
|
+
try {
|
|
32
|
+
const stat = readFileSync(join(FIXTURES_DIR, f, 'package.json'), 'utf-8');
|
|
33
|
+
return stat.includes('"dependencies"');
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
console.log(`\n CapScan Golden Tests`);
|
|
40
|
+
console.log(` ${'─'.repeat(50)}\n`);
|
|
41
|
+
|
|
42
|
+
let passed = 0;
|
|
43
|
+
let failed = 0;
|
|
44
|
+
|
|
45
|
+
for (const fixture of fixtures) {
|
|
46
|
+
const fixtureDir = join(FIXTURES_DIR, fixture);
|
|
47
|
+
const expectedPath = join(fixtureDir, 'expected.json');
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const result = await scan({ path: fixtureDir });
|
|
51
|
+
const expected = JSON.parse(readFileSync(expectedPath, 'utf-8'));
|
|
52
|
+
|
|
53
|
+
const actualNorm = normalizeForComparison(result as ScanResult);
|
|
54
|
+
const expectedNorm = normalizeForComparison(expected);
|
|
55
|
+
|
|
56
|
+
const actualJson = JSON.stringify(actualNorm, null, 2);
|
|
57
|
+
const expectedJson = JSON.stringify(expectedNorm, null, 2);
|
|
58
|
+
|
|
59
|
+
if (actualJson === expectedJson) {
|
|
60
|
+
console.log(` \x1b[32m✓\x1b[0m ${fixture}`);
|
|
61
|
+
passed++;
|
|
62
|
+
} else {
|
|
63
|
+
console.log(` \x1b[31m✗\x1b[0m ${fixture}`);
|
|
64
|
+
console.log(` Expected: ${(expectedNorm as { packages: unknown[] }).packages.length} packages`);
|
|
65
|
+
console.log(` Actual: ${(actualNorm as { packages: unknown[] }).packages.length} packages`);
|
|
66
|
+
failed++;
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.log(` \x1b[31m✗\x1b[0m ${fixture} (error)`);
|
|
70
|
+
console.log(` ${error}`);
|
|
71
|
+
failed++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(`\n ${passed} passed, ${failed} failed\n`);
|
|
76
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
runGoldenTests();
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineCommand, runMain } from 'citty';
|
|
2
|
+
import { scanCommand } from './commands/scan.js';
|
|
3
|
+
import { diffCommand } from './commands/diff.js';
|
|
4
|
+
import { snapshotCommand } from './commands/snapshot.js';
|
|
5
|
+
import { whyCommand } from './commands/why.js';
|
|
6
|
+
import { compareCommand } from './commands/compare.js';
|
|
7
|
+
import { checkCommand } from './commands/check.js';
|
|
8
|
+
import { initCommand } from './commands/init.js';
|
|
9
|
+
|
|
10
|
+
const main = defineCommand({
|
|
11
|
+
meta: {
|
|
12
|
+
name: 'capscan',
|
|
13
|
+
version: '0.1.0',
|
|
14
|
+
description: 'Deterministic capability analysis engine for software dependencies',
|
|
15
|
+
},
|
|
16
|
+
subCommands: {
|
|
17
|
+
scan: scanCommand,
|
|
18
|
+
why: whyCommand,
|
|
19
|
+
diff: diffCommand,
|
|
20
|
+
snapshot: snapshotCommand,
|
|
21
|
+
compare: compareCommand,
|
|
22
|
+
check: checkCommand,
|
|
23
|
+
init: initCommand,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
runMain(main);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { scan } from '@capscan/engine';
|
|
2
|
+
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const FIXTURES_DIR = resolve(import.meta.dirname, '../../../fixtures');
|
|
6
|
+
const BUDGET_FILE = resolve(import.meta.dirname, '../../../fixtures/.performance-budget.json');
|
|
7
|
+
const MAX_REGRESSION_PCT = 15;
|
|
8
|
+
|
|
9
|
+
interface Budget {
|
|
10
|
+
[fixture: string]: {
|
|
11
|
+
maxMs: number;
|
|
12
|
+
lastRunMs: number;
|
|
13
|
+
timestamp: string;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function loadBudget(): Budget {
|
|
18
|
+
if (existsSync(BUDGET_FILE)) {
|
|
19
|
+
return JSON.parse(readFileSync(BUDGET_FILE, 'utf-8'));
|
|
20
|
+
}
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function saveBudget(budget: Budget) {
|
|
25
|
+
writeFileSync(BUDGET_FILE, JSON.stringify(budget, null, 2), 'utf-8');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function performanceBudget() {
|
|
29
|
+
const fixtures = readdirSync(FIXTURES_DIR).filter(f => {
|
|
30
|
+
try {
|
|
31
|
+
readFileSync(join(FIXTURES_DIR, f, 'package.json'), 'utf-8');
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
console.log(`\n CapScan Performance Budget`);
|
|
39
|
+
console.log(` ${'─'.repeat(50)}`);
|
|
40
|
+
console.log(` Max regression: ${MAX_REGRESSION_PCT}%\n`);
|
|
41
|
+
|
|
42
|
+
const budget = loadBudget();
|
|
43
|
+
let failed = false;
|
|
44
|
+
const newBudget: Budget = {};
|
|
45
|
+
|
|
46
|
+
for (const fixture of fixtures) {
|
|
47
|
+
const fixtureDir = join(FIXTURES_DIR, fixture);
|
|
48
|
+
|
|
49
|
+
// Warm up (3 runs)
|
|
50
|
+
for (let i = 0; i < 3; i++) {
|
|
51
|
+
await scan({ path: fixtureDir });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Benchmark (5 runs, take median)
|
|
55
|
+
const times: number[] = [];
|
|
56
|
+
for (let i = 0; i < 5; i++) {
|
|
57
|
+
const start = performance.now();
|
|
58
|
+
await scan({ path: fixtureDir });
|
|
59
|
+
times.push(performance.now() - start);
|
|
60
|
+
}
|
|
61
|
+
times.sort((a, b) => a - b);
|
|
62
|
+
const medianTime = times[2]; // Middle of 5
|
|
63
|
+
|
|
64
|
+
const prev = budget[fixture];
|
|
65
|
+
const maxMs = prev ? prev.maxMs * (1 + MAX_REGRESSION_PCT / 100) : medianTime * 2;
|
|
66
|
+
|
|
67
|
+
newBudget[fixture] = {
|
|
68
|
+
maxMs: Math.max(prev?.maxMs || 0, medianTime),
|
|
69
|
+
lastRunMs: medianTime,
|
|
70
|
+
timestamp: new Date().toISOString(),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
if (prev && medianTime > prev.maxMs * (1 + MAX_REGRESSION_PCT / 100)) {
|
|
74
|
+
const regressionPct = ((medianTime - prev.maxMs) / prev.maxMs * 100).toFixed(1);
|
|
75
|
+
console.log(` \x1b[31m✗\x1b[0m ${fixture} — ${medianTime.toFixed(0)}ms (was ${prev.maxMs.toFixed(0)}ms, +${regressionPct}% regression)`);
|
|
76
|
+
failed = true;
|
|
77
|
+
} else {
|
|
78
|
+
console.log(` \x1b[32m✓\x1b[0m ${fixture} — ${medianTime.toFixed(0)}ms`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
saveBudget(newBudget);
|
|
83
|
+
console.log(`\n ${failed ? '\x1b[31mPerformance regression detected\x1b[0m' : '\x1b[32mAll within budget\x1b[0m'}`);
|
|
84
|
+
console.log(` Budget saved to fixtures/.performance-budget.json\n`);
|
|
85
|
+
|
|
86
|
+
process.exit(failed ? 1 : 0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
performanceBudget();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import type { ScanResult } from '@capscan/engine';
|
|
4
|
+
|
|
5
|
+
export async function reportJson(result: ScanResult, outputPath?: string): Promise<string> {
|
|
6
|
+
const json = JSON.stringify(result, null, 2);
|
|
7
|
+
if (outputPath) {
|
|
8
|
+
await writeFile(resolve(outputPath), json, 'utf-8');
|
|
9
|
+
return outputPath;
|
|
10
|
+
}
|
|
11
|
+
return json;
|
|
12
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ScanResult, CapabilityCategory } from '@capscan/engine';
|
|
2
|
+
|
|
3
|
+
const CAPABILITY_LABELS: Record<CapabilityCategory, string> = {
|
|
4
|
+
filesystem: 'Filesystem', network: 'Network', process: 'Process',
|
|
5
|
+
environment: 'Environment', crypto: 'Crypto', dynamic_code: 'Dynamic Code',
|
|
6
|
+
native: 'Native', installation: 'Installation', obfuscation: 'Obfuscation',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function generateCapabilityTable(summary: ScanResult['summary']): string {
|
|
10
|
+
const lines: string[] = [];
|
|
11
|
+
lines.push('| Category | Permission | Count |');
|
|
12
|
+
lines.push('|----------|------------|-------|');
|
|
13
|
+
for (const [cat, perms] of Object.entries(summary.capabilities)) {
|
|
14
|
+
for (const [perm, count] of Object.entries(perms)) {
|
|
15
|
+
if (count > 0) lines.push(`| ${CAPABILITY_LABELS[cat as CapabilityCategory]} | \`${perm}\` | ${count} |`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return lines.join('\n');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function reportMarkdown(result: ScanResult): string {
|
|
22
|
+
const lines: string[] = [];
|
|
23
|
+
lines.push('# CapScan Report');
|
|
24
|
+
lines.push('');
|
|
25
|
+
lines.push(`**Generated**: ${result.meta.timestamp}`);
|
|
26
|
+
lines.push(`**Engine**: ${result.meta.engine}`);
|
|
27
|
+
lines.push(`**Package Manager**: ${result.meta.packageManager}`);
|
|
28
|
+
lines.push(`**Total Dependencies**: ${result.summary.totalPackages}`);
|
|
29
|
+
lines.push(`**Packages with Capabilities**: ${result.summary.packagesWithCapabilities}`);
|
|
30
|
+
lines.push('');
|
|
31
|
+
lines.push('## Summary');
|
|
32
|
+
lines.push('');
|
|
33
|
+
lines.push(generateCapabilityTable(result.summary));
|
|
34
|
+
lines.push('');
|
|
35
|
+
|
|
36
|
+
const packagesWithCaps = result.packages.filter(p => p.capabilities.length > 0);
|
|
37
|
+
if (packagesWithCaps.length > 0) {
|
|
38
|
+
lines.push('## Packages');
|
|
39
|
+
lines.push('');
|
|
40
|
+
for (const pkg of packagesWithCaps) {
|
|
41
|
+
lines.push(`### ${pkg.name}@${pkg.version}`);
|
|
42
|
+
lines.push('');
|
|
43
|
+
lines.push('| Permission | Symbol | File | Line | Confidence |');
|
|
44
|
+
lines.push('|------------|--------|------|------|------------|');
|
|
45
|
+
for (const f of pkg.capabilities) {
|
|
46
|
+
for (const e of f.evidence) {
|
|
47
|
+
lines.push(`| \`${f.capability.permission}\` | \`${e.symbol}\` | \`${e.file}\` | ${e.line} | ${e.confidence} |`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
lines.push('');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return lines.join('\n');
|
|
55
|
+
}
|