@activemind/scd 1.4.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/LICENSE.md +35 -0
- package/README.md +417 -0
- package/bin/scd.js +140 -0
- package/lib/audit-report.js +93 -0
- package/lib/audit-sync.js +172 -0
- package/lib/audit.js +356 -0
- package/lib/cli-helpers.js +108 -0
- package/lib/commands/accept.js +28 -0
- package/lib/commands/audit.js +17 -0
- package/lib/commands/configure.js +200 -0
- package/lib/commands/doctor.js +14 -0
- package/lib/commands/exceptions.js +19 -0
- package/lib/commands/export-findings.js +46 -0
- package/lib/commands/findings.js +306 -0
- package/lib/commands/ignore.js +28 -0
- package/lib/commands/init.js +16 -0
- package/lib/commands/insights.js +24 -0
- package/lib/commands/install.js +15 -0
- package/lib/commands/list.js +109 -0
- package/lib/commands/remove.js +16 -0
- package/lib/commands/repo.js +862 -0
- package/lib/commands/report.js +234 -0
- package/lib/commands/resolve.js +25 -0
- package/lib/commands/rules.js +185 -0
- package/lib/commands/scan.js +519 -0
- package/lib/commands/scope.js +341 -0
- package/lib/commands/sync.js +40 -0
- package/lib/commands/uninstall.js +15 -0
- package/lib/commands/version.js +33 -0
- package/lib/comment-map.js +388 -0
- package/lib/config.js +325 -0
- package/lib/context-modifiers.js +211 -0
- package/lib/deep-analyzer.js +225 -0
- package/lib/doctor.js +236 -0
- package/lib/exception-manager.js +675 -0
- package/lib/export-findings.js +376 -0
- package/lib/file-context.js +380 -0
- package/lib/file-filter.js +204 -0
- package/lib/file-manifest.js +145 -0
- package/lib/git-utils.js +102 -0
- package/lib/global-config.js +239 -0
- package/lib/hooks-manager.js +130 -0
- package/lib/init-repo.js +147 -0
- package/lib/insights-analyzer.js +416 -0
- package/lib/insights-output.js +160 -0
- package/lib/installer.js +128 -0
- package/lib/output-constants.js +32 -0
- package/lib/output-terminal.js +407 -0
- package/lib/push-queue.js +322 -0
- package/lib/remove-repo.js +108 -0
- package/lib/repo-context.js +187 -0
- package/lib/report-html.js +1154 -0
- package/lib/report-index.js +157 -0
- package/lib/report-json.js +136 -0
- package/lib/report-markdown.js +250 -0
- package/lib/resolve-manager.js +148 -0
- package/lib/rule-registry.js +205 -0
- package/lib/scan-cache.js +171 -0
- package/lib/scan-context.js +312 -0
- package/lib/scan-schema.js +67 -0
- package/lib/scanner-full.js +681 -0
- package/lib/scanner-manual.js +348 -0
- package/lib/scanner-secrets.js +83 -0
- package/lib/scope.js +331 -0
- package/lib/store-verify.js +395 -0
- package/lib/store.js +310 -0
- package/lib/taint-register.js +196 -0
- package/lib/version-check.js +46 -0
- package/package.json +37 -0
- package/rules/rule-loader.js +324 -0
- package/rules/rules-aspx-cs.json +399 -0
- package/rules/rules-aspx.json +222 -0
- package/rules/rules-infra-leakage.json +434 -0
- package/rules/rules-js.json +664 -0
- package/rules/rules-php.json +521 -0
- package/rules/rules-python.json +466 -0
- package/rules/rules-secrets.json +99 -0
- package/rules/rules-sensitive-files.json +475 -0
- package/rules/rules-ts.json +76 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* lib/file-manifest.js
|
|
5
|
+
*
|
|
6
|
+
* Pre-scan file classification — runs before any rules are applied.
|
|
7
|
+
*
|
|
8
|
+
* Classifies every file in the scan queue into a scan context:
|
|
9
|
+
*
|
|
10
|
+
* source — production code, scanned with full rule set
|
|
11
|
+
* test — test/fixture files, scanned with test rule set (stub for now)
|
|
12
|
+
* excluded — vendor/generated files, not scanned, documented in output
|
|
13
|
+
*
|
|
14
|
+
* Classification is based on buildFileContext() from lib/file-context.js,
|
|
15
|
+
* which uses two-layer detection: path/filename signals + content confirmation.
|
|
16
|
+
* A file is only placed in the test context when classification is definitive
|
|
17
|
+
* (content confirms the path/filename signal, or content alone is sufficient).
|
|
18
|
+
* Tentative-only classifications fall back to source — conservative by design.
|
|
19
|
+
*
|
|
20
|
+
* This is the correct architectural layer for this decision. It replaces the
|
|
21
|
+
* previous approach of post-scan severity modifiers that compensated for
|
|
22
|
+
* findings in test files after rules had already been applied.
|
|
23
|
+
*
|
|
24
|
+
* Contexts are designed to be extensible — new contexts (e.g. 'config',
|
|
25
|
+
* 'migration', 'schema') can be added without changing the core scan loop.
|
|
26
|
+
* Individual contexts can also be force-included into the source loop via
|
|
27
|
+
* config in the future (e.g. config.scan.force_source_context: ['test']).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const { buildFileContext, FILE_TYPES } = require('./file-context');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* File types that are excluded from scanning entirely.
|
|
34
|
+
* Vendor and generated files produce no actionable findings.
|
|
35
|
+
*/
|
|
36
|
+
const EXCLUDED_FILE_TYPES = new Set([
|
|
37
|
+
FILE_TYPES.VENDOR,
|
|
38
|
+
FILE_TYPES.GENERATED,
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* File types that are routed to the test scan context.
|
|
43
|
+
* These are scanned with a separate, focused rule set (currently empty —
|
|
44
|
+
* test rules will be defined in a future iteration).
|
|
45
|
+
*/
|
|
46
|
+
const TEST_FILE_TYPES = new Set([
|
|
47
|
+
FILE_TYPES.TEST,
|
|
48
|
+
FILE_TYPES.FIXTURE,
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build a file manifest by classifying all files before scanning begins.
|
|
53
|
+
*
|
|
54
|
+
* @param {Array<{filePath: string, content: string, ...}>} files
|
|
55
|
+
* The full list of files prepared by the file collector, in their
|
|
56
|
+
* original order. Each entry must have at minimum filePath and content.
|
|
57
|
+
*
|
|
58
|
+
* @returns {{
|
|
59
|
+
* source: Array, — files to scan with source rules (full rule set)
|
|
60
|
+
* test: Array, — files to scan with test rules (currently empty set)
|
|
61
|
+
* excluded: Array<{filePath, reason, fileType}>,
|
|
62
|
+
* summary: {total, source, test, excluded},
|
|
63
|
+
* contexts: Map<string, string> — filePath → context name, for scan-cache
|
|
64
|
+
* }}
|
|
65
|
+
*/
|
|
66
|
+
function buildFileManifest(files) {
|
|
67
|
+
const source = [];
|
|
68
|
+
const test = [];
|
|
69
|
+
const excluded = [];
|
|
70
|
+
const contexts = new Map(); // filePath → 'source' | 'test' | 'excluded'
|
|
71
|
+
|
|
72
|
+
// fileContexts maps filePath → fileContext for all non-excluded files.
|
|
73
|
+
// Used by the secrets scanner which doesn't have file content available —
|
|
74
|
+
// it must reuse the manifest classification rather than call buildFileContext()
|
|
75
|
+
// without content (which would misclassify tentative test files as test
|
|
76
|
+
// instead of falling back to source).
|
|
77
|
+
const fileContexts = new Map();
|
|
78
|
+
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
const { filePath, content } = file;
|
|
81
|
+
|
|
82
|
+
// buildFileContext() runs two-layer detection:
|
|
83
|
+
// Layer 1: path/filename signals (tentative for test/fixture)
|
|
84
|
+
// Layer 2: content confirmation (first 50 lines)
|
|
85
|
+
// If a tentative signal is not confirmed by content, fileType falls back
|
|
86
|
+
// to SOURCE. We therefore trust fileType directly — the manifest is the
|
|
87
|
+
// single authoritative classification for each file.
|
|
88
|
+
const ctx = buildFileContext(filePath, content);
|
|
89
|
+
|
|
90
|
+
if (EXCLUDED_FILE_TYPES.has(ctx.fileType)) {
|
|
91
|
+
excluded.push({
|
|
92
|
+
filePath,
|
|
93
|
+
fileType: ctx.fileType,
|
|
94
|
+
reason: ctx.fileType === FILE_TYPES.VENDOR ? 'vendor' :
|
|
95
|
+
ctx.fileType === FILE_TYPES.GENERATED ? 'generated' : ctx.fileType,
|
|
96
|
+
signals: ctx.signals,
|
|
97
|
+
});
|
|
98
|
+
contexts.set(filePath, 'excluded');
|
|
99
|
+
// excluded files get no fileContext entry — they are not scanned
|
|
100
|
+
|
|
101
|
+
} else if (TEST_FILE_TYPES.has(ctx.fileType)) {
|
|
102
|
+
test.push({ ...file, fileContext: ctx });
|
|
103
|
+
contexts.set(filePath, 'test');
|
|
104
|
+
fileContexts.set(filePath, ctx);
|
|
105
|
+
|
|
106
|
+
} else {
|
|
107
|
+
// SOURCE, CONFIG, DOCS, or any unrecognised type → source context.
|
|
108
|
+
// Conservative: when in doubt, scan with full rule set.
|
|
109
|
+
source.push({ ...file, fileContext: ctx });
|
|
110
|
+
contexts.set(filePath, 'source');
|
|
111
|
+
fileContexts.set(filePath, ctx);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const summary = {
|
|
116
|
+
total: files.length,
|
|
117
|
+
source: source.length,
|
|
118
|
+
test: test.length,
|
|
119
|
+
excluded: excluded.length,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return { source, test, excluded, summary, contexts, fileContexts };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Format a one-line manifest summary for terminal output.
|
|
127
|
+
* Shown before scanning begins so the user sees file breakdown upfront.
|
|
128
|
+
*
|
|
129
|
+
* Example:
|
|
130
|
+
* "312 source · 47 test (separate context) · 12 excluded (vendor/generated)"
|
|
131
|
+
*/
|
|
132
|
+
function formatManifestSummary(summary) {
|
|
133
|
+
const parts = [`${summary.source} source`];
|
|
134
|
+
|
|
135
|
+
if (summary.test > 0) {
|
|
136
|
+
parts.push(`${summary.test} test (separate context)`);
|
|
137
|
+
}
|
|
138
|
+
if (summary.excluded > 0) {
|
|
139
|
+
parts.push(`${summary.excluded} excluded (vendor/generated)`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return parts.join(' · ');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { buildFileManifest, formatManifestSummary };
|
package/lib/git-utils.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-utils.js
|
|
3
|
+
* Helpers to extract which files are relevant for each hook type.
|
|
4
|
+
*
|
|
5
|
+
* pre-commit → files staged for this commit (git diff --cached)
|
|
6
|
+
* pre-push → files changed vs remote (what will actually be pushed)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { execSync } = require('child_process');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Returns a list of { filePath, content } objects for the relevant hook.
|
|
15
|
+
*/
|
|
16
|
+
async function getChangedFiles(hookType = 'pre-push') {
|
|
17
|
+
try {
|
|
18
|
+
let output = '';
|
|
19
|
+
|
|
20
|
+
if (hookType === 'pre-commit') {
|
|
21
|
+
// Files staged for the upcoming commit
|
|
22
|
+
output = execSync('git diff --cached --name-only --diff-filter=ACM', {
|
|
23
|
+
encoding: 'utf8'
|
|
24
|
+
});
|
|
25
|
+
} else {
|
|
26
|
+
// pre-push: files changed in commits not yet on remote
|
|
27
|
+
// Falls back to last commit if no remote configured (common in new repos)
|
|
28
|
+
try {
|
|
29
|
+
// Try against remote tracking branch first
|
|
30
|
+
output = execSync('git diff --name-only --diff-filter=ACM @{u} HEAD', {
|
|
31
|
+
encoding: 'utf8'
|
|
32
|
+
});
|
|
33
|
+
} catch {
|
|
34
|
+
try {
|
|
35
|
+
// Has more than one commit – diff against previous
|
|
36
|
+
output = execSync('git diff --name-only --diff-filter=ACM HEAD~1 HEAD', {
|
|
37
|
+
encoding: 'utf8'
|
|
38
|
+
});
|
|
39
|
+
} catch {
|
|
40
|
+
// Only one commit – list all tracked files
|
|
41
|
+
output = execSync('git diff --name-only --diff-filter=ACM --cached HEAD', {
|
|
42
|
+
encoding: 'utf8', shell: true
|
|
43
|
+
});
|
|
44
|
+
if (!output.trim()) {
|
|
45
|
+
// Absolute fallback: all files in repo
|
|
46
|
+
output = execSync('git ls-files', { encoding: 'utf8' });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const filePaths = output
|
|
53
|
+
.trim()
|
|
54
|
+
.split('\n')
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.filter(f => shouldScanFile(f));
|
|
57
|
+
|
|
58
|
+
const files = [];
|
|
59
|
+
for (const filePath of filePaths) {
|
|
60
|
+
try {
|
|
61
|
+
if (hookType === 'pre-commit') {
|
|
62
|
+
// Read staged content (not working tree) via git show
|
|
63
|
+
const content = execSync(`git show :${filePath}`, { encoding: 'utf8' });
|
|
64
|
+
files.push({ filePath, content });
|
|
65
|
+
} else {
|
|
66
|
+
if (fs.existsSync(filePath)) {
|
|
67
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
68
|
+
files.push({ filePath, content });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// Binary file or unreadable – skip
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return files;
|
|
77
|
+
} catch {
|
|
78
|
+
// Not in a git repo or no commits yet
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Files we skip scanning (binary, dependencies, generated)
|
|
85
|
+
*/
|
|
86
|
+
function shouldScanFile(filePath) {
|
|
87
|
+
const skip = [
|
|
88
|
+
'node_modules/', '.git/', 'dist/', 'build/', '.next/',
|
|
89
|
+
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
|
90
|
+
];
|
|
91
|
+
const skipExt = [
|
|
92
|
+
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico',
|
|
93
|
+
'.woff', '.woff2', '.ttf', '.eot', '.pdf',
|
|
94
|
+
'.zip', '.tar', '.gz', '.map'
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
if (skip.some(s => filePath.includes(s))) return false;
|
|
98
|
+
if (skipExt.some(e => filePath.endsWith(e))) return false;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { getChangedFiles };
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* global-config.js
|
|
3
|
+
* Manages the global (user-level) Secure Code by Design configuration.
|
|
4
|
+
*
|
|
5
|
+
* Location: ~/.scd/config (never inside a repo)
|
|
6
|
+
* Format: KEY=VALUE (simple, no external deps)
|
|
7
|
+
*
|
|
8
|
+
* Stored settings:
|
|
9
|
+
* (extensible for future global settings)
|
|
10
|
+
*
|
|
11
|
+
* Security notes:
|
|
12
|
+
* - File is created with mode 0600 (owner read/write only)
|
|
13
|
+
* - API key is never printed in full – always masked
|
|
14
|
+
* - scd configure --show reveals only first 12 + last 4 chars
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
|
|
23
|
+
const GLOBAL_DIR = path.join(os.homedir(), '.scd');
|
|
24
|
+
const GLOBAL_CONFIG = path.join(GLOBAL_DIR, 'config');
|
|
25
|
+
const FILE_MODE = 0o600;
|
|
26
|
+
|
|
27
|
+
// ── Read/write helpers ─────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function ensureDir() {
|
|
30
|
+
if (!fs.existsSync(GLOBAL_DIR)) {
|
|
31
|
+
fs.mkdirSync(GLOBAL_DIR, { recursive: true, mode: 0o700 });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readRaw() {
|
|
36
|
+
if (!fs.existsSync(GLOBAL_CONFIG)) return {};
|
|
37
|
+
try {
|
|
38
|
+
const lines = fs.readFileSync(GLOBAL_CONFIG, 'utf8').split('\n');
|
|
39
|
+
const result = {};
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const trimmed = line.trim();
|
|
42
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
43
|
+
const eqIdx = trimmed.indexOf('=');
|
|
44
|
+
if (eqIdx === -1) continue;
|
|
45
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
46
|
+
const val = trimmed.slice(eqIdx + 1).trim();
|
|
47
|
+
result[key] = val;
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
} catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeRaw(data) {
|
|
56
|
+
ensureDir();
|
|
57
|
+
const lines = [
|
|
58
|
+
'# Secure Code by Design – global configuration',
|
|
59
|
+
'# Managed by: scd configure',
|
|
60
|
+
'# NEVER share this file – it contains API keys',
|
|
61
|
+
'',
|
|
62
|
+
...Object.entries(data).map(([k, v]) => `${k}=${v}`),
|
|
63
|
+
'',
|
|
64
|
+
];
|
|
65
|
+
fs.writeFileSync(GLOBAL_CONFIG, lines.join('\n'), { mode: FILE_MODE, encoding: 'utf8' });
|
|
66
|
+
// Enforce permissions even if file already existed
|
|
67
|
+
try { fs.chmodSync(GLOBAL_CONFIG, FILE_MODE); } catch { /* ignore on Windows */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get a value from global config.
|
|
74
|
+
* Returns undefined if not set.
|
|
75
|
+
*/
|
|
76
|
+
function get(key) {
|
|
77
|
+
return readRaw()[key];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Set a key in global config.
|
|
82
|
+
*/
|
|
83
|
+
function set(key, value) {
|
|
84
|
+
const data = readRaw();
|
|
85
|
+
data[key] = value;
|
|
86
|
+
writeRaw(data);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Remove a key from global config.
|
|
91
|
+
* Returns true if the key existed and was removed.
|
|
92
|
+
*/
|
|
93
|
+
function remove(key) {
|
|
94
|
+
const data = readRaw();
|
|
95
|
+
if (!(key in data)) return false;
|
|
96
|
+
delete data[key];
|
|
97
|
+
writeRaw(data);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get the configured central server URL.
|
|
103
|
+
* Returns null if not set.
|
|
104
|
+
*/
|
|
105
|
+
function getCentralUrl() {
|
|
106
|
+
return get('CENTRAL_URL') || null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Set the central server URL.
|
|
111
|
+
*/
|
|
112
|
+
function setCentralUrl(url) {
|
|
113
|
+
// Normalize localhost → 127.0.0.1 to avoid IPv6 resolution issues.
|
|
114
|
+
// On macOS with Node 18, localhost resolves to ::1 but Express listens
|
|
115
|
+
// on IPv4 by default — resulting in ECONNREFUSED. Using 127.0.0.1 is
|
|
116
|
+
// always correct and avoids the ambiguity.
|
|
117
|
+
const normalized = url.replace(/\/\//g, '//').replace('//localhost', '//127.0.0.1').replace(/\/$/, '');
|
|
118
|
+
set('CENTRAL_URL', normalized);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Remove the central server URL (disables push queue).
|
|
123
|
+
*/
|
|
124
|
+
function removeCentralUrl() {
|
|
125
|
+
return remove('CENTRAL_URL');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get the configured central server API token.
|
|
130
|
+
* Returns null if not set.
|
|
131
|
+
*/
|
|
132
|
+
function getCentralToken() {
|
|
133
|
+
return get('CENTRAL_TOKEN') || null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Set the central server API token.
|
|
138
|
+
*/
|
|
139
|
+
function setCentralToken(token) {
|
|
140
|
+
set('CENTRAL_TOKEN', token.trim());
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Remove the central server API token.
|
|
145
|
+
*/
|
|
146
|
+
function removeCentralToken() {
|
|
147
|
+
return remove('CENTRAL_TOKEN');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Timeout configuration ─────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
const TIMEOUT_DEFAULTS = {
|
|
153
|
+
SERVER_TIMEOUT_MS: 30000, // 30 seconds — regular API calls
|
|
154
|
+
DEEP_TIMEOUT_MS: 1200000, // 20 minutes — Ollama can be slow on CPU hardware
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Parse human-readable timeout values: '30s', '5m', '1200000' (ms as string).
|
|
159
|
+
*/
|
|
160
|
+
function parseTimeoutArg(value) {
|
|
161
|
+
if (typeof value === 'number') return value;
|
|
162
|
+
const str = String(value).trim().toLowerCase();
|
|
163
|
+
if (str.endsWith('m')) {
|
|
164
|
+
const n = parseInt(str, 10);
|
|
165
|
+
if (isNaN(n)) throw new Error(`Invalid timeout value: ${value}`);
|
|
166
|
+
return n * 60 * 1000;
|
|
167
|
+
}
|
|
168
|
+
if (str.endsWith('s')) {
|
|
169
|
+
const n = parseInt(str, 10);
|
|
170
|
+
if (isNaN(n)) throw new Error(`Invalid timeout value: ${value}`);
|
|
171
|
+
return n * 1000;
|
|
172
|
+
}
|
|
173
|
+
const n = parseInt(str, 10);
|
|
174
|
+
if (isNaN(n)) throw new Error(`Invalid timeout value: ${value}`);
|
|
175
|
+
return n;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function getServerTimeout() {
|
|
179
|
+
const val = get('SERVER_TIMEOUT_MS');
|
|
180
|
+
const n = val ? parseInt(val, 10) : NaN;
|
|
181
|
+
return isNaN(n) ? TIMEOUT_DEFAULTS.SERVER_TIMEOUT_MS : n;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function setServerTimeout(ms) {
|
|
185
|
+
set('SERVER_TIMEOUT_MS', String(ms));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function getDeepTimeout() {
|
|
189
|
+
const val = get('DEEP_TIMEOUT_MS');
|
|
190
|
+
const n = val ? parseInt(val, 10) : NaN;
|
|
191
|
+
return isNaN(n) ? TIMEOUT_DEFAULTS.DEEP_TIMEOUT_MS : n;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function setDeepTimeout(ms) {
|
|
195
|
+
set('DEEP_TIMEOUT_MS', String(ms));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Scan trace ────────────────────────────────────────────────────────────
|
|
199
|
+
// When enabled, every finding (active and suppressed) carries a _trace object
|
|
200
|
+
// in all scan-JSON files. Never shown in terminal output or reports.
|
|
201
|
+
// Set manually: SCAN_TRACE=true in ~/.scd/config
|
|
202
|
+
// Not exposed via scd configure — internal debug tool only.
|
|
203
|
+
|
|
204
|
+
function getScanTrace() {
|
|
205
|
+
return get('SCAN_TRACE') === 'true';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function setScanTrace(enabled) {
|
|
209
|
+
set('SCAN_TRACE', enabled ? 'true' : 'false');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Server version cache ──────────────────────────────────────────────────
|
|
213
|
+
// Cached from health endpoint and batch responses.
|
|
214
|
+
// Lets CLI warn about version mismatch without an extra network call.
|
|
215
|
+
|
|
216
|
+
function getServerVersion() {
|
|
217
|
+
return get('SERVER_VERSION') || null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function getMinCliVersion() {
|
|
221
|
+
return get('MIN_CLI_VERSION') || null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function setServerVersionInfo(serverVersion, minCliVersion) {
|
|
225
|
+
if (serverVersion) set('SERVER_VERSION', serverVersion);
|
|
226
|
+
if (minCliVersion) set('MIN_CLI_VERSION', minCliVersion);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = {
|
|
230
|
+
get, set, remove,
|
|
231
|
+
getCentralUrl, setCentralUrl, removeCentralUrl,
|
|
232
|
+
getCentralToken, setCentralToken, removeCentralToken,
|
|
233
|
+
getServerTimeout, setServerTimeout,
|
|
234
|
+
getDeepTimeout, setDeepTimeout,
|
|
235
|
+
getScanTrace, setScanTrace,
|
|
236
|
+
getServerVersion, getMinCliVersion, setServerVersionInfo,
|
|
237
|
+
parseTimeoutArg,
|
|
238
|
+
GLOBAL_CONFIG, GLOBAL_DIR,
|
|
239
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hooks-manager.js
|
|
3
|
+
* Per-repo git hook management.
|
|
4
|
+
*
|
|
5
|
+
* scd init sets core.hooksPath globally (all repos protected by default).
|
|
6
|
+
* This module allows a specific repo to override that by setting
|
|
7
|
+
* core.hooksPath locally — either disabling hooks entirely or pointing
|
|
8
|
+
* explicitly back to ~/.scd/hooks.
|
|
9
|
+
*
|
|
10
|
+
* Status model:
|
|
11
|
+
* enabled — hooks active (local or global points to a real dir)
|
|
12
|
+
* disabled — explicitly disabled via scd repo hooks --disable
|
|
13
|
+
* (local override set to /dev/null or NUL)
|
|
14
|
+
* global-broken — no local override, but global points to /dev/null
|
|
15
|
+
* (user mistake or old demo setup — not a scd operation)
|
|
16
|
+
* not-installed — no hooksPath configured anywhere
|
|
17
|
+
* unknown — could not read git config (not a git repo etc.)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const { execSync } = require('child_process');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
const HOOKS_DIR = path.join(os.homedir(), '.scd', 'hooks');
|
|
27
|
+
const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null';
|
|
28
|
+
|
|
29
|
+
function gitConfig(args, cwd) {
|
|
30
|
+
try {
|
|
31
|
+
const val = execSync('git config ' + args, {
|
|
32
|
+
encoding: 'utf8', cwd,
|
|
33
|
+
stdio: ['pipe', 'pipe', 'pipe'], // suppress stderr (e.g. 'fatal: not a git repo')
|
|
34
|
+
}).trim();
|
|
35
|
+
return val || null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns true if the given path is inside a git repository.
|
|
43
|
+
*/
|
|
44
|
+
function isGitRepo(dirPath) {
|
|
45
|
+
try {
|
|
46
|
+
execSync('git rev-parse --git-dir', {
|
|
47
|
+
encoding: 'utf8', cwd: dirPath,
|
|
48
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
49
|
+
});
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isNullDevice(p) {
|
|
57
|
+
return p === 'NUL' || p === '/dev/null';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Returns current hook status for the given repo root.
|
|
62
|
+
* @returns {{ status: string, hooksPath: string|null, source: 'local'|'global'|null }}
|
|
63
|
+
*/
|
|
64
|
+
function getHookStatus(repoRoot) {
|
|
65
|
+
try {
|
|
66
|
+
// 0. Check path exists and is a git repo
|
|
67
|
+
if (!require('fs').existsSync(repoRoot)) {
|
|
68
|
+
return { status: 'missing', hooksPath: null, source: null };
|
|
69
|
+
}
|
|
70
|
+
if (!isGitRepo(repoRoot)) {
|
|
71
|
+
return { status: 'not-a-git-repo', hooksPath: null, source: null };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 1. Local .git/config override (repo-specific, set by scd repo hooks)
|
|
75
|
+
const localPath = gitConfig('--local core.hooksPath', repoRoot);
|
|
76
|
+
if (localPath !== null) {
|
|
77
|
+
return {
|
|
78
|
+
status: isNullDevice(localPath) ? 'disabled' : 'enabled',
|
|
79
|
+
hooksPath: localPath,
|
|
80
|
+
source: 'local',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2. Global ~/.gitconfig (applies to all repos without local override)
|
|
85
|
+
const globalPath = gitConfig('--global core.hooksPath', repoRoot);
|
|
86
|
+
if (globalPath !== null) {
|
|
87
|
+
if (isNullDevice(globalPath)) {
|
|
88
|
+
// Global is broken — not a scd-managed per-repo disable
|
|
89
|
+
return { status: 'global-broken', hooksPath: globalPath, source: 'global' };
|
|
90
|
+
}
|
|
91
|
+
return { status: 'enabled', hooksPath: globalPath, source: 'global' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { status: 'not-installed', hooksPath: null, source: null };
|
|
95
|
+
|
|
96
|
+
} catch {
|
|
97
|
+
return { status: 'unknown', hooksPath: null, source: null };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Disable hooks for this repo by setting local core.hooksPath to /dev/null.
|
|
103
|
+
* This overrides the global setting without affecting other repos.
|
|
104
|
+
*/
|
|
105
|
+
function disableHooks(repoRoot) {
|
|
106
|
+
execSync(`git config --local core.hooksPath "${NULL_DEVICE}"`, {
|
|
107
|
+
encoding: 'utf8', cwd: repoRoot,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Re-enable hooks for this repo.
|
|
113
|
+
*
|
|
114
|
+
* Strategy: set local core.hooksPath explicitly to ~/.scd/hooks rather
|
|
115
|
+
* than just unsetting the local key. This is safer because:
|
|
116
|
+
* - If global is /dev/null (like after a demo), unset would still leave
|
|
117
|
+
* hooks disabled — not what the user wants.
|
|
118
|
+
* - Explicit local set ensures hooks are active regardless of global state.
|
|
119
|
+
*
|
|
120
|
+
* To fully clean up (no local override at all), the user can run:
|
|
121
|
+
* git config --local --unset core.hooksPath
|
|
122
|
+
* But for scd's purposes, explicit re-enable is the right default.
|
|
123
|
+
*/
|
|
124
|
+
function enableHooks(repoRoot) {
|
|
125
|
+
execSync(`git config --local core.hooksPath "${HOOKS_DIR}"`, {
|
|
126
|
+
encoding: 'utf8', cwd: repoRoot,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { getHookStatus, disableHooks, enableHooks, HOOKS_DIR, isNullDevice, isGitRepo };
|