secure-husky-setup 1.0.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/Readme.md ADDED
@@ -0,0 +1,49 @@
1
+
2
+ ---
3
+
4
+ ```markdown
5
+ # Secure Husky Setup
6
+
7
+ Automatically installs and configures:
8
+
9
+ - Husky (Git hooks)
10
+ - Gitleaks (Secret scanning)
11
+ - Pre-commit protection
12
+
13
+ ---
14
+
15
+ ## Install from GitHub
16
+
17
+ Inside your project directory:
18
+
19
+ ```bash
20
+ npm install --save-dev git+https://github.com/HUSAINTRIVEDI52/npm-package-husky-gitleaks.git
21
+ ```
22
+
23
+
24
+ ---
25
+
26
+ ## Initialize
27
+
28
+ After installing, run:
29
+
30
+ ```bash
31
+ npx secure-husky-setup init
32
+ ```
33
+
34
+ This will:
35
+
36
+ - Install Husky locally
37
+ - Download Gitleaks locally
38
+ - Configure the pre-commit hook
39
+
40
+ ---
41
+
42
+ ## Done
43
+
44
+ Now every `git commit` will automatically scan for secrets.
45
+
46
+ If secrets are detected, the commit will be blocked.
47
+
48
+ ---
49
+
package/bin/index.js ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ const { installHusky } = require('../lib/husky');
3
+ const { installGitleaks } = require('../lib/gitleaks');
4
+ const { installSonarScanner, setupSonarProperties } = require('../lib/sonarqube');
5
+ const { setupPreCommitHook } = require('../lib/hooks');
6
+ // Added by Arjun — import CI setup functions from the new lib/ci.js module
7
+ const { setupPrePushHook, setupCIScript, setupCIWorkflow, validateProject, ensurePackageLock } = require('../lib/ci');
8
+ const { isGitRepo } = require('../lib/git');
9
+ const { logInfo, logError, logSuccess } = require('../lib/logger');
10
+
11
+ const command = process.argv[2];
12
+
13
+ (async () => {
14
+ if (command !== 'init') {
15
+ console.log("Usage: secure-husky-setup init");
16
+ process.exit(0);
17
+ }
18
+
19
+ try {
20
+ logInfo("Initializing secure git hooks...");
21
+
22
+ if (!await isGitRepo()) {
23
+ throw new Error("Not inside a git repository.");
24
+ }
25
+
26
+ // ── Existing steps — pre-commit hooks (no changes made here) ─────────────
27
+ await installHusky();
28
+ await installGitleaks();
29
+ await installSonarScanner();
30
+ await setupSonarProperties();
31
+ await setupPreCommitHook();
32
+
33
+ logSuccess("Secure Husky + Gitleaks + SonarQube setup completed.");
34
+ logInfo("Next step: edit sonar-project.properties and set sonar.host.url and sonar.token.");
35
+
36
+ // Added by Arjun — pre-push hook + GitHub Actions CI workflow setup ───────
37
+ // Runs Newman API tests and smoke tests automatically on every git push
38
+ logInfo("Setting up Newman & Smoke Test CI workflow...");
39
+
40
+ // Added by Arjun — ensure package-lock.json exists (required by npm ci in workflow)
41
+ await ensurePackageLock();
42
+
43
+ // Added by Arjun — validate package.json has "start" and "test" scripts
44
+ await validateProject();
45
+
46
+ // Added by Arjun — write standalone scripts/run-ci-checks.sh (all test logic lives here)
47
+ await setupCIScript();
48
+
49
+ // Added by Arjun — copy ci-tests.yml into .github/workflows/
50
+ await setupCIWorkflow();
51
+
52
+ // Added by Arjun — create .husky/pre-push hook (thin wrapper that calls run-ci-checks.sh)
53
+ await setupPrePushHook();
54
+
55
+ logSuccess("Newman + Smoke Test pre-push hook and GitHub Actions workflow setup completed.");
56
+ // ── End of Arjun's additions ──────────────────────────────────────────────
57
+
58
+ } catch (err) {
59
+ logError(err.message);
60
+ process.exit(1);
61
+ }
62
+ })();
package/lib/ci.js ADDED
@@ -0,0 +1,323 @@
1
+ 'use strict';
2
+
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // Added by Arjun
5
+ // File: lib/ci.js
6
+ // Purpose: Sets up Newman API tests + Smoke Tests as a pre-push git hook
7
+ // and copies the GitHub Actions CI workflow into the project.
8
+ //
9
+ // FLEXIBILITY NOTE:
10
+ // Test logic lives in scripts/run-ci-checks.sh (standalone script).
11
+ // The pre-push hook simply calls that script.
12
+ // To move tests to pre-commit in future, just add one line to pre-commit hook:
13
+ // ./scripts/run-ci-checks.sh
14
+ // No logic needs to be rewritten.
15
+ //
16
+ // New files introduced:
17
+ // - lib/ci.js (this file)
18
+ // - templates/ci-tests.yml (GitHub Actions workflow template)
19
+ // Changes made to existing files:
20
+ // - bin/index.js (4 new lines added — see comments there)
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+
23
+ const fs = require('fs-extra');
24
+ const path = require('path');
25
+ const { execSync } = require('child_process');
26
+ const { logInfo, logSuccess, logError } = require('./logger');
27
+
28
+ // Added by Arjun — path to the CI workflow template bundled with this package
29
+ const TEMPLATE_PATH = path.resolve(__dirname, '../templates/ci-tests.yml');
30
+
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+ // Added by Arjun — writes scripts/run-ci-checks.sh into the project
33
+ // This is the STANDALONE script containing all test logic.
34
+ // It can be called from pre-push, pre-commit, or any other hook.
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+ exports.setupCIScript = async () => {
37
+ const scriptsDir = path.join(process.cwd(), 'scripts');
38
+ const scriptPath = path.join(scriptsDir, 'run-ci-checks.sh');
39
+
40
+ await fs.ensureDir(scriptsDir);
41
+
42
+ if (await fs.pathExists(scriptPath)) {
43
+ logInfo("run-ci-checks.sh already exists — overwriting with latest version.");
44
+ } else {
45
+ logInfo("Creating scripts/run-ci-checks.sh...");
46
+ }
47
+
48
+ await fs.writeFile(scriptPath, buildCIScript());
49
+ await fs.chmod(scriptPath, 0o755);
50
+ logSuccess("scripts/run-ci-checks.sh created.");
51
+ logInfo("To move tests to pre-commit in future: add './scripts/run-ci-checks.sh' to .husky/pre-commit.");
52
+ };
53
+
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ // Added by Arjun — sets up .husky/pre-push hook
56
+ // Simply calls run-ci-checks.sh — no logic lives here.
57
+ // ─────────────────────────────────────────────────────────────────────────────
58
+ exports.setupPrePushHook = async () => {
59
+ const huskyDir = path.join(process.cwd(), '.husky');
60
+ const hookPath = path.join(huskyDir, 'pre-push');
61
+
62
+ if (!await fs.pathExists(huskyDir)) {
63
+ logInfo("Husky directory not found. Skipping pre-push hook setup.");
64
+ return;
65
+ }
66
+
67
+ if (await fs.pathExists(hookPath)) {
68
+ logInfo("Pre-push hook already configured. Overwriting with latest setup...");
69
+ } else {
70
+ logInfo("Creating new pre-push hook...");
71
+ }
72
+
73
+ await fs.writeFile(hookPath, buildPrePushHook());
74
+ await fs.chmod(hookPath, 0o755);
75
+ logSuccess("Pre-push hook created — calls scripts/run-ci-checks.sh.");
76
+ };
77
+
78
+ // ─────────────────────────────────────────────────────────────────────────────
79
+ // Added by Arjun — copies ci-tests.yml into .github/workflows/
80
+ // ─────────────────────────────────────────────────────────────────────────────
81
+ exports.setupCIWorkflow = async () => {
82
+ const targetDir = path.join(process.cwd(), '.github', 'workflows');
83
+ const targetFile = path.join(targetDir, 'ci-tests.yml');
84
+
85
+ if (!await fs.pathExists(TEMPLATE_PATH)) {
86
+ logError("CI template not found. Please reinstall the package.");
87
+ return;
88
+ }
89
+
90
+ await fs.ensureDir(targetDir);
91
+
92
+ if (await fs.pathExists(targetFile)) {
93
+ logInfo("ci-tests.yml already exists — overwriting with latest version.");
94
+ } else {
95
+ logInfo("Creating .github/workflows/ci-tests.yml...");
96
+ }
97
+
98
+ await fs.copy(TEMPLATE_PATH, targetFile);
99
+ logSuccess("GitHub Actions workflow copied to .github/workflows/ci-tests.yml");
100
+ };
101
+
102
+ // ─────────────────────────────────────────────────────────────────────────────
103
+ // Added by Arjun — validates package.json has required scripts
104
+ // ─────────────────────────────────────────────────────────────────────────────
105
+ exports.validateProject = async () => {
106
+ const pkgPath = path.join(process.cwd(), 'package.json');
107
+
108
+ if (!await fs.pathExists(pkgPath)) {
109
+ logError("No package.json found. Skipping validation.");
110
+ return;
111
+ }
112
+
113
+ const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8'));
114
+ const scripts = pkg.scripts || {};
115
+
116
+ if (!scripts.start) {
117
+ logError('No "start" script in package.json — CI server boot will fail.');
118
+ logInfo('Add: "start": "node index.js"');
119
+ }
120
+
121
+ if (!scripts.test) {
122
+ logError('No "test" script in package.json — smoke tests will fail.');
123
+ logInfo('Add: "test": "jest" (or your test runner)');
124
+ }
125
+
126
+ if (scripts.start && scripts.test) {
127
+ logSuccess('package.json has required "start" and "test" scripts.');
128
+ }
129
+ };
130
+
131
+ // ─────────────────────────────────────────────────────────────────────────────
132
+ // Added by Arjun — ensures package-lock.json exists (required by npm ci)
133
+ // ─────────────────────────────────────────────────────────────────────────────
134
+ exports.ensurePackageLock = async () => {
135
+ const lockPath = path.join(process.cwd(), 'package-lock.json');
136
+ const yarnPath = path.join(process.cwd(), 'yarn.lock');
137
+
138
+ if (await fs.pathExists(lockPath) || await fs.pathExists(yarnPath)) {
139
+ logSuccess("Lock file found (package-lock.json / yarn.lock).");
140
+ return;
141
+ }
142
+
143
+ logInfo("No package-lock.json found — running npm install to generate it...");
144
+ try {
145
+ execSync('npm install', { stdio: 'inherit', cwd: process.cwd() });
146
+ logSuccess("package-lock.json generated. Remember to commit it.");
147
+ } catch {
148
+ logError("Failed to generate package-lock.json. Run npm install manually.");
149
+ }
150
+ };
151
+
152
+ // ─────────────────────────────────────────────────────────────────────────────
153
+ // Pre-push hook — thin wrapper, just calls the standalone script
154
+ // ─────────────────────────────────────────────────────────────────────────────
155
+ function buildPrePushHook() {
156
+ return `#!/bin/sh
157
+
158
+ # ---------------------------------------------------------------
159
+ # Pre-push hook — Newman + Smoke Tests
160
+ # Delegates all logic to scripts/run-ci-checks.sh
161
+ #
162
+ # To move tests to pre-commit in future:
163
+ # Remove this file and add this line to .husky/pre-commit:
164
+ # ./scripts/run-ci-checks.sh
165
+ # ---------------------------------------------------------------
166
+
167
+ ./scripts/run-ci-checks.sh
168
+ `;
169
+ }
170
+
171
+ // ─────────────────────────────────────────────────────────────────────────────
172
+ // Standalone CI checks script — ALL test logic lives here
173
+ // Can be called from pre-push, pre-commit, CI, or manually:
174
+ // sh scripts/run-ci-checks.sh
175
+ // ─────────────────────────────────────────────────────────────────────────────
176
+ function buildCIScript() {
177
+ return `#!/bin/sh
178
+
179
+ # ---------------------------------------------------------------
180
+ # run-ci-checks.sh — Smoke Tests + Newman API Tests
181
+ #
182
+ # Called by .husky/pre-push by default.
183
+ # To move to pre-commit: add './scripts/run-ci-checks.sh' to .husky/pre-commit
184
+ # To run manually: sh scripts/run-ci-checks.sh
185
+ # ---------------------------------------------------------------
186
+
187
+ # ---------------------------------------------------------------
188
+ # Git diff check — only run if actual files changed
189
+ # ---------------------------------------------------------------
190
+ LOCAL=$(git rev-parse @)
191
+ REMOTE=$(git rev-parse @{u} 2>/dev/null)
192
+
193
+ if [ "$REMOTE" != "" ] && [ "$LOCAL" = "$REMOTE" ]; then
194
+ echo "[CI Checks] No changes to push. Skipping."
195
+ exit 0
196
+ fi
197
+
198
+ if [ "$REMOTE" != "" ]; then
199
+ CHANGED=$(git diff --name-only "$REMOTE" "$LOCAL" 2>/dev/null)
200
+ else
201
+ CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null)
202
+ fi
203
+
204
+ if [ -z "$CHANGED" ]; then
205
+ echo "[CI Checks] No changed files detected. Skipping."
206
+ exit 0
207
+ fi
208
+
209
+ echo ""
210
+ echo "[CI Checks] Changed files detected:"
211
+ echo "$CHANGED" | sed 's/^/ -> /'
212
+
213
+ echo ""
214
+ echo "[CI Checks] Starting checks..."
215
+
216
+ # ---------------------------------------------------------------
217
+ # Step 1: Smoke Tests
218
+ # ---------------------------------------------------------------
219
+ echo ""
220
+ echo "[Smoke Tests] Starting server..."
221
+
222
+ npm start &
223
+ SERVER_PID=\$!
224
+
225
+ for i in \$(seq 1 30); do
226
+ if curl -sf http://localhost:\${PORT:-3000}/health > /dev/null 2>&1 || \\
227
+ curl -sf http://localhost:\${PORT:-3000} > /dev/null 2>&1; then
228
+ echo "[Smoke Tests] Server is up."
229
+ break
230
+ fi
231
+ echo "[Smoke Tests] Waiting for server... (\$i/30)"
232
+ sleep 1
233
+ done
234
+
235
+ echo "[Smoke Tests] Running npm test..."
236
+ npm test
237
+ SMOKE_EXIT=\$?
238
+
239
+ kill \$SERVER_PID 2>/dev/null
240
+
241
+ if [ \$SMOKE_EXIT -ne 0 ]; then
242
+ echo "[Smoke Tests] Failed. Push blocked."
243
+ exit 1
244
+ fi
245
+
246
+ echo "[Smoke Tests] Passed. ✔"
247
+
248
+ # ---------------------------------------------------------------
249
+ # Step 2: Newman
250
+ # ---------------------------------------------------------------
251
+ echo ""
252
+ echo "[Newman] Looking for Postman collections..."
253
+
254
+ COLLECTIONS=\$(find . \\
255
+ -not -path '*/node_modules/*' \\
256
+ -not -path '*/.git/*' \\
257
+ -not -path '*/scripts/*' \\
258
+ \\( -name "*.postman_collection.json" -o -name "collection.json" \\) \\
259
+ 2>/dev/null)
260
+
261
+ if [ -z "\$COLLECTIONS" ]; then
262
+ echo "[Newman] No Postman collection found. Skipping."
263
+ exit 0
264
+ fi
265
+
266
+ if ! command -v newman > /dev/null 2>&1; then
267
+ echo "[Newman] Installing newman globally..."
268
+ npm install -g newman newman-reporter-htmlextra
269
+ fi
270
+
271
+ npm start &
272
+ SERVER_PID=\$!
273
+
274
+ for i in \$(seq 1 30); do
275
+ if curl -sf http://localhost:\${PORT:-3000}/health > /dev/null 2>&1 || \\
276
+ curl -sf http://localhost:\${PORT:-3000} > /dev/null 2>&1; then
277
+ echo "[Newman] Server is up."
278
+ break
279
+ fi
280
+ sleep 1
281
+ done
282
+
283
+ mkdir -p newman-reports
284
+
285
+ ENV_FILE=\$(find . \\
286
+ -not -path '*/node_modules/*' \\
287
+ -not -path '*/.git/*' \\
288
+ -name "*.postman_environment.json" \\
289
+ 2>/dev/null | head -1)
290
+
291
+ NEWMAN_EXIT=0
292
+ for COLLECTION in \$COLLECTIONS; do
293
+ REPORT_NAME=\$(basename "\$COLLECTION" .json)
294
+ echo "[Newman] Running: \$COLLECTION"
295
+
296
+ ENV_FLAG=""
297
+ if [ -n "\$ENV_FILE" ]; then
298
+ ENV_FLAG="--environment \$ENV_FILE"
299
+ fi
300
+
301
+ newman run "\$COLLECTION" \\
302
+ \$ENV_FLAG \\
303
+ --env-var "baseUrl=http://localhost:\${PORT:-3000}" \\
304
+ --reporters cli,htmlextra \\
305
+ --reporter-htmlextra-export "newman-reports/\${REPORT_NAME}-report.html" \\
306
+ --bail
307
+
308
+ if [ \$? -ne 0 ]; then
309
+ NEWMAN_EXIT=1
310
+ fi
311
+ done
312
+
313
+ kill \$SERVER_PID 2>/dev/null
314
+
315
+ if [ \$NEWMAN_EXIT -ne 0 ]; then
316
+ echo "[Newman] One or more collections failed. Push blocked."
317
+ exit 1
318
+ fi
319
+
320
+ echo "[Newman] All collections passed. ✔"
321
+ exit 0
322
+ `;
323
+ }
package/lib/git.js ADDED
@@ -0,0 +1,7 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ exports.isGitRepo = async () => {
5
+ const gitPath = path.join(process.cwd(), '.git');
6
+ return fs.existsSync(gitPath);
7
+ };
@@ -0,0 +1,111 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const execa = require('execa');
4
+ const https = require('https');
5
+ const { logInfo, logSuccess } = require('./logger');
6
+
7
+ const VERSION = "8.18.0";
8
+
9
+ // Added by Arjun — detect the correct gitleaks binary for the current OS and architecture
10
+ function getPlatformAsset() {
11
+ const platform = process.platform;
12
+ const arch = process.arch;
13
+
14
+ const archMap = {
15
+ x64: 'x64',
16
+ arm64: 'arm64',
17
+ arm: 'armv7',
18
+ };
19
+
20
+ const gitleaksArch = archMap[arch] || 'x64';
21
+
22
+ if (platform === 'darwin') {
23
+ return { filename: `gitleaks_${VERSION}_darwin_${gitleaksArch}.tar.gz`, extract: 'tar' };
24
+ }
25
+
26
+ if (platform === 'win32') {
27
+ return { filename: `gitleaks_${VERSION}_windows_${gitleaksArch}.zip`, extract: 'zip' };
28
+ }
29
+
30
+ return { filename: `gitleaks_${VERSION}_linux_${gitleaksArch}.tar.gz`, extract: 'tar' };
31
+ }
32
+
33
+ // Added by Arjun — automatically add entries to .gitignore if missing
34
+ async function ensureGitignoreEntries(entries) {
35
+ const gitignorePath = path.join(process.cwd(), '.gitignore');
36
+
37
+ let content = '';
38
+ if (await fs.pathExists(gitignorePath)) {
39
+ content = await fs.readFile(gitignorePath, 'utf-8');
40
+ }
41
+
42
+ const added = [];
43
+ for (const entry of entries) {
44
+ if (!content.includes(entry)) {
45
+ content += `\n${entry}`;
46
+ added.push(entry);
47
+ }
48
+ }
49
+
50
+ if (added.length > 0) {
51
+ await fs.writeFile(gitignorePath, content);
52
+ logInfo(`.gitignore updated — added: ${added.join(', ')}`);
53
+ }
54
+ }
55
+
56
+ exports.installGitleaks = async () => {
57
+ const toolsDir = path.join(process.cwd(), '.tools');
58
+ const gitleaksDir = path.join(toolsDir, 'gitleaks');
59
+ const binaryPath = path.join(gitleaksDir, 'gitleaks');
60
+
61
+ if (await fs.pathExists(binaryPath)) {
62
+ logInfo("Gitleaks already installed locally.");
63
+ return;
64
+ }
65
+
66
+ logInfo("Installing Gitleaks locally...");
67
+ await fs.ensureDir(gitleaksDir);
68
+
69
+ // Added by Arjun — use platform-aware asset instead of hardcoded linux_x64
70
+ const { filename, extract } = getPlatformAsset();
71
+ const url = `https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/${filename}`;
72
+ const destPath = path.join(gitleaksDir, filename);
73
+
74
+ logInfo(`Downloading ${filename}...`);
75
+ await downloadFile(url, destPath);
76
+
77
+ // Added by Arjun — handle both tar.gz (mac/linux) and zip (windows)
78
+ if (extract === 'tar') {
79
+ await execa('tar', ['-xzf', destPath, '-C', gitleaksDir]);
80
+ } else {
81
+ await execa('unzip', ['-o', destPath, '-d', gitleaksDir]);
82
+ }
83
+
84
+ await fs.remove(destPath);
85
+ await fs.chmod(binaryPath, 0o755);
86
+
87
+ // Added by Arjun — automatically add .tools/ and node_modules/ to .gitignore
88
+ // .tools/ → prevents gitleaks binary from being staged and scanned
89
+ // node_modules/ → prevents dependencies from being committed to git
90
+ await ensureGitignoreEntries(['.tools/', 'node_modules/']);
91
+
92
+ logSuccess("Gitleaks installed locally.");
93
+ };
94
+
95
+ function downloadFile(url, dest) {
96
+ return new Promise((resolve, reject) => {
97
+ const request = https.get(url, { headers: { 'User-Agent': 'node' } }, (response) => {
98
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
99
+ return downloadFile(response.headers.location, dest).then(resolve).catch(reject);
100
+ }
101
+ if (response.statusCode !== 200) {
102
+ return reject(new Error(`Download failed with status code ${response.statusCode}`));
103
+ }
104
+ const file = fs.createWriteStream(dest);
105
+ response.pipe(file);
106
+ file.on('finish', () => file.close(resolve));
107
+ file.on('error', reject);
108
+ });
109
+ request.on('error', reject);
110
+ });
111
+ }
package/lib/hooks.js ADDED
@@ -0,0 +1,112 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const { logInfo, logSuccess } = require('./logger');
4
+
5
+ exports.setupPreCommitHook = async () => {
6
+ const huskyDir = path.join(process.cwd(), '.husky');
7
+ const hookPath = path.join(huskyDir, 'pre-commit');
8
+
9
+ if (!await fs.pathExists(huskyDir)) {
10
+ logInfo("Husky directory not found. Skipping hook setup.");
11
+ return;
12
+ }
13
+
14
+ const hookContent = buildHookScript();
15
+
16
+ if (await fs.pathExists(hookPath)) {
17
+ logInfo("Pre-commit hook already configured. Overwriting with latest setup...");
18
+ } else {
19
+ logInfo("Creating new pre-commit hook...");
20
+ }
21
+
22
+ await fs.writeFile(hookPath, hookContent);
23
+ await fs.chmod(hookPath, 0o755);
24
+
25
+ const gitleaksIgnorePath = path.join(process.cwd(), '.gitleaksignore');
26
+ await fs.writeFile(gitleaksIgnorePath, '.tools/\nsonar-project.properties\n');
27
+ logInfo(".gitleaksignore created — excluding .tools/ and sonar-project.properties.");
28
+
29
+ logSuccess("Pre-commit hook created with Gitleaks + SonarQube (git diff only).");
30
+ };
31
+
32
+ function buildHookScript() {
33
+ return `#!/bin/sh
34
+
35
+ STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
36
+
37
+ if [ -z "$STAGED_FILES" ]; then
38
+ echo "No changed files detected. Skipping checks."
39
+ exit 0
40
+ fi
41
+
42
+ echo "[Git Diff] Changed files in this commit:"
43
+ echo "$STAGED_FILES" | while IFS= read -r FILE; do
44
+ echo " -> $FILE"
45
+ done
46
+
47
+ echo ""
48
+ echo "[Gitleaks] Scanning changed files for secrets..."
49
+
50
+ GITLEAKS_BIN="./.tools/gitleaks/gitleaks"
51
+
52
+ if [ ! -f "$GITLEAKS_BIN" ]; then
53
+ echo "[Gitleaks] Binary not found. Skipping."
54
+ else
55
+ GITLEAKS_TMPDIR=$(mktemp -d)
56
+
57
+ echo "$STAGED_FILES" | while IFS= read -r FILE; do
58
+ case "$FILE" in
59
+ sonar-project.properties) ;;
60
+ .tools/*) ;;
61
+ *)
62
+ if [ -f "$FILE" ]; then
63
+ DEST="$GITLEAKS_TMPDIR/$FILE"
64
+ mkdir -p "$(dirname "$DEST")"
65
+ cp "$FILE" "$DEST"
66
+ fi
67
+ ;;
68
+ esac
69
+ done
70
+
71
+ $GITLEAKS_BIN detect --source "$GITLEAKS_TMPDIR" --no-git --verbose
72
+ GITLEAKS_EXIT=$?
73
+ rm -rf "$GITLEAKS_TMPDIR"
74
+
75
+ if [ $GITLEAKS_EXIT -ne 0 ]; then
76
+ echo "[Gitleaks] Secrets detected! Commit blocked."
77
+ exit 1
78
+ fi
79
+
80
+ echo "[Gitleaks] No secrets found."
81
+ fi
82
+
83
+ echo ""
84
+ echo "[SonarQube] Scanning changed files..."
85
+
86
+ SONAR_BIN="./node_modules/.bin/sonar-scanner"
87
+
88
+ if [ ! -f "$SONAR_BIN" ]; then
89
+ echo "[SonarQube] sonar-scanner not found. Skipping."
90
+ else
91
+ if [ ! -f "sonar-project.properties" ]; then
92
+ echo "[SonarQube] sonar-project.properties not found. Skipping."
93
+ else
94
+ SONAR_INCLUSIONS=$(echo "$STAGED_FILES" | tr '\n' ',' | sed 's/,$//')
95
+ echo "[SonarQube] Scanning: $SONAR_INCLUSIONS"
96
+
97
+ $SONAR_BIN -Dsonar.inclusions="$SONAR_INCLUSIONS" -Dsonar.qualitygate.wait=true
98
+ SONAR_EXIT=$?
99
+
100
+ if [ $SONAR_EXIT -ne 0 ]; then
101
+ echo "[SonarQube] Quality Gate FAILED. Commit blocked."
102
+ echo "[SonarQube] Fix the issues at: $(grep 'sonar.host.url' sonar-project.properties | cut -d'=' -f2)/dashboard?id=$(grep 'sonar.projectKey' sonar-project.properties | cut -d'=' -f2)"
103
+ exit 1
104
+ fi
105
+
106
+ echo "[SonarQube] Quality Gate PASSED. ✔"
107
+ fi
108
+ fi
109
+
110
+ exit 0
111
+ `;
112
+ }
package/lib/husky.js ADDED
@@ -0,0 +1,27 @@
1
+ const { fileExists, readJSON, writeJSON } = require('./utils');
2
+ const { installDevDependency } = require('./packageManager');
3
+ const execa = require('execa');
4
+ const path = require('path');
5
+ const fs = require('fs-extra');
6
+ const { logInfo, logSuccess } = require('./logger');
7
+
8
+ exports.installHusky = async () => {
9
+ const pkgPath = path.join(process.cwd(), 'package.json');
10
+ const pkg = await readJSON(pkgPath);
11
+
12
+ if (!pkg.devDependencies || !pkg.devDependencies.husky) {
13
+ logInfo("Installing Husky...");
14
+ await installDevDependency('husky');
15
+ }
16
+
17
+ logInfo("Initializing Husky...");
18
+ await execa('npx', ['husky', 'install'], { stdio: 'inherit' });
19
+
20
+ if (!pkg.scripts) pkg.scripts = {};
21
+
22
+ if (!pkg.scripts.prepare) {
23
+ pkg.scripts.prepare = "husky install";
24
+ await writeJSON(pkgPath, pkg);
25
+ logSuccess("Added prepare script.");
26
+ }
27
+ };
package/lib/logger.js ADDED
@@ -0,0 +1,5 @@
1
+ const chalk = require('chalk');
2
+
3
+ exports.logInfo = (msg) => console.log(chalk.blue(`ℹ ${msg}`));
4
+ exports.logSuccess = (msg) => console.log(chalk.green(`✔ ${msg}`));
5
+ exports.logError = (msg) => console.log(chalk.red(`✖ ${msg}`));
@@ -0,0 +1,5 @@
1
+ const execa = require('execa');
2
+
3
+ exports.installDevDependency = async (pkg) => {
4
+ await execa('npm', ['install', pkg, '--save-dev'], { stdio: 'inherit' });
5
+ };
@@ -0,0 +1,102 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const http = require('http');
4
+ const { logInfo, logSuccess } = require('./logger');
5
+ const { installDevDependency } = require('./packageManager');
6
+
7
+ const SONAR_PROPS_FILE = 'sonar-project.properties';
8
+
9
+ // ---------------------------------------------------------------
10
+ // SonarQube server configuration
11
+ // Currently pointing to local Docker instance for testing
12
+ // Switch to GCP later by changing SONAR_HOST_URL and SONAR_TOKEN
13
+ // ---------------------------------------------------------------
14
+ const SONAR_HOST_URL = 'http://192.168.1.72:9000';
15
+ const SONAR_TOKEN = 'sqa_57476a8e9fe67dcdddfbe5a146a681c9373a3ab8';
16
+ // const SONAR_ORG = 'arjunlatiwala'; // only needed for SonarCloud
17
+
18
+ // Auto-create project on SonarQube via API so developer never needs to do it manually
19
+ async function ensureProjectExists(projectKey, projectName) {
20
+ return new Promise((resolve) => {
21
+ const auth = Buffer.from(`${SONAR_TOKEN}:`).toString('base64');
22
+ const postData = `name=${encodeURIComponent(projectName)}&project=${encodeURIComponent(projectKey)}`;
23
+
24
+ const options = {
25
+ hostname: '192.168.1.72',
26
+ port: 9000,
27
+ path: '/api/projects/create',
28
+ method: 'POST',
29
+ headers: {
30
+ 'Authorization': `Basic ${auth}`,
31
+ 'Content-Type': 'application/x-www-form-urlencoded',
32
+ 'Content-Length': Buffer.byteLength(postData)
33
+ }
34
+ };
35
+
36
+ const req = http.request(options, (res) => {
37
+ let data = '';
38
+ res.on('data', chunk => data += chunk);
39
+ res.on('end', () => {
40
+ if (res.statusCode === 200 || res.statusCode === 201) {
41
+ logSuccess(`SonarQube project "${projectKey}" created automatically.`);
42
+ } else if (res.statusCode === 400 && data.includes('already exists')) {
43
+ logInfo(`SonarQube project "${projectKey}" already exists.`);
44
+ } else {
45
+ logInfo(`SonarQube project setup: ${res.statusCode} — continuing anyway.`);
46
+ }
47
+ resolve();
48
+ });
49
+ });
50
+
51
+ req.on('error', (err) => {
52
+ logInfo(`SonarQube server unreachable — skipping project creation. (${err.message})`);
53
+ resolve();
54
+ });
55
+
56
+ req.write(postData);
57
+ req.end();
58
+ });
59
+ }
60
+
61
+ exports.installSonarScanner = async () => {
62
+ logInfo('Installing sonarqube-scanner as a dev dependency...');
63
+ await installDevDependency('sonarqube-scanner');
64
+ logSuccess('sonarqube-scanner installed.');
65
+ };
66
+
67
+ exports.setupSonarProperties = async () => {
68
+ const propsPath = path.join(process.cwd(), SONAR_PROPS_FILE);
69
+
70
+ const pkgPath = path.join(process.cwd(), 'package.json');
71
+ let projectKey = 'my-project';
72
+ let projectName = 'My Project';
73
+
74
+ if (await fs.pathExists(pkgPath)) {
75
+ const pkg = await fs.readJSON(pkgPath);
76
+ if (pkg.name) {
77
+ projectKey = pkg.name.replace(/[^a-zA-Z0-9_\-.:]/g, '_');
78
+ projectName = pkg.name;
79
+ }
80
+ }
81
+
82
+ // Auto-create project so developer never needs to do it manually
83
+ logInfo(`Setting up SonarQube project "${projectKey}"...`);
84
+ await ensureProjectExists(projectKey, projectName);
85
+
86
+ const content = `# ---------------------------------------------------------------
87
+ # SonarQube configuration — auto-generated by secure-husky-setup
88
+ # Do not edit manually
89
+ # ---------------------------------------------------------------
90
+ sonar.host.url=${SONAR_HOST_URL}
91
+ sonar.login=${SONAR_TOKEN}
92
+ sonar.projectKey=${projectKey}
93
+ sonar.projectName=${projectName}
94
+ sonar.projectVersion=1.0
95
+ sonar.sources=.
96
+ sonar.sourceEncoding=UTF-8
97
+ sonar.exclusions=node_modules/**,dist/**,build/**,coverage/**,.husky/**,.tools/**
98
+ `;
99
+
100
+ await fs.writeFile(propsPath, content);
101
+ logSuccess(`${SONAR_PROPS_FILE} configured automatically.`);
102
+ };
package/lib/utils.js ADDED
@@ -0,0 +1,13 @@
1
+ const fs = require('fs-extra');
2
+
3
+ exports.fileExists = async (path) => {
4
+ return await fs.pathExists(path);
5
+ };
6
+
7
+ exports.readJSON = async (path) => {
8
+ return await fs.readJSON(path);
9
+ };
10
+
11
+ exports.writeJSON = async (path, data) => {
12
+ return await fs.writeJSON(path, data, { spaces: 2 });
13
+ };
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "secure-husky-setup",
3
+ "version": "1.0.0",
4
+ "description": "Automatic Husky + Gitleaks setup for any JS project",
5
+ "main": "bin/index.js",
6
+ "bin": {
7
+ "secure-husky-setup": "./bin/index.js"
8
+ },
9
+ "keywords": [
10
+ "husky",
11
+ "gitleaks",
12
+ "git-hooks",
13
+ "security"
14
+ ],
15
+ "author": "Your Name",
16
+ "license": "MIT",
17
+ "files": ["bin", "lib", "templates"],
18
+ "dependencies": {
19
+ "chalk": "^4.1.2",
20
+ "execa": "^5.1.1",
21
+ "fs-extra": "^11.3.3",
22
+ "sonarqube-scanner": "^4.0.0"
23
+ }
24
+ }
@@ -0,0 +1,163 @@
1
+ name: CI - Newman & Smoke Tests
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - '**'
7
+
8
+ jobs:
9
+ # ─────────────────────────────────────────
10
+ # JOB 1: Node.js Smoke Tests
11
+ # ─────────────────────────────────────────
12
+ smoke-tests:
13
+ name: 🔥 Smoke Tests
14
+ runs-on: ubuntu-latest
15
+
16
+ steps:
17
+ - name: Checkout code
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Set up Node.js
21
+ uses: actions/setup-node@v4
22
+ with:
23
+ node-version: '20'
24
+ cache: 'npm'
25
+
26
+ - name: Install dependencies
27
+ run: npm ci
28
+
29
+ - name: Build project (if applicable)
30
+ run: |
31
+ if [ "$(node -e "const p=require('./package.json'); console.log(!!p.scripts?.build)")" = "true" ]; then
32
+ npm run build
33
+ else
34
+ echo "No build script found, skipping."
35
+ fi
36
+
37
+ - name: Start server in background
38
+ run: |
39
+ npm start &
40
+ echo "SERVER_PID=$!" >> $GITHUB_ENV
41
+ for i in $(seq 1 30); do
42
+ if curl -sf http://localhost:${PORT:-3000}/health > /dev/null 2>&1 || \
43
+ curl -sf http://localhost:${PORT:-3000} > /dev/null 2>&1; then
44
+ echo "✅ Server is up!"
45
+ break
46
+ fi
47
+ echo "Waiting for server... ($i/30)"
48
+ sleep 1
49
+ done
50
+ env:
51
+ NODE_ENV: test
52
+ PORT: ${{ vars.PORT || 3000 }}
53
+
54
+ - name: Run smoke tests
55
+ run: npm test
56
+ env:
57
+ NODE_ENV: test
58
+ PORT: ${{ vars.PORT || 3000 }}
59
+
60
+ - name: Stop server
61
+ if: always()
62
+ run: |
63
+ if [ -n "$SERVER_PID" ]; then
64
+ kill $SERVER_PID || true
65
+ fi
66
+
67
+ # ─────────────────────────────────────────
68
+ # JOB 2: Newman (Postman Collection) Tests
69
+ # ─────────────────────────────────────────
70
+ newman-tests:
71
+ name: 📬 Newman API Tests
72
+ runs-on: ubuntu-latest
73
+ needs: smoke-tests
74
+
75
+ steps:
76
+ - name: Checkout code
77
+ uses: actions/checkout@v4
78
+
79
+ - name: Set up Node.js
80
+ uses: actions/setup-node@v4
81
+ with:
82
+ node-version: '20'
83
+ cache: 'npm'
84
+
85
+ - name: Install dependencies
86
+ run: npm ci
87
+
88
+ - name: Install Newman & reporters
89
+ run: npm install -g newman newman-reporter-htmlextra
90
+
91
+ - name: Start server in background
92
+ run: |
93
+ npm start &
94
+ echo "SERVER_PID=$!" >> $GITHUB_ENV
95
+ for i in $(seq 1 30); do
96
+ if curl -sf http://localhost:${PORT:-3000}/health > /dev/null 2>&1 || \
97
+ curl -sf http://localhost:${PORT:-3000} > /dev/null 2>&1; then
98
+ echo "✅ Server is up!"
99
+ break
100
+ fi
101
+ echo "Waiting for server... ($i/30)"
102
+ sleep 1
103
+ done
104
+ env:
105
+ NODE_ENV: test
106
+ PORT: ${{ vars.PORT || 3000 }}
107
+
108
+ - name: Find and run Postman collections
109
+ run: |
110
+ mkdir -p newman-reports
111
+
112
+ COLLECTIONS=$(find . \
113
+ -not -path '*/node_modules/*' \
114
+ -not -path '*/.git/*' \
115
+ \( -name "*.postman_collection.json" -o -name "collection.json" \) \
116
+ 2>/dev/null)
117
+
118
+ if [ -z "$COLLECTIONS" ]; then
119
+ echo "⚠️ No Postman collection files found!"
120
+ exit 1
121
+ fi
122
+
123
+ ENV_FILE=$(find . \
124
+ -not -path '*/node_modules/*' \
125
+ -not -path '*/.git/*' \
126
+ -name "*.postman_environment.json" \
127
+ 2>/dev/null | head -1)
128
+
129
+ for COLLECTION in $COLLECTIONS; do
130
+ REPORT_NAME=$(basename "$COLLECTION" .json)
131
+ echo "▶️ Running collection: $COLLECTION"
132
+
133
+ ENV_FLAG=""
134
+ if [ -n "$ENV_FILE" ]; then
135
+ ENV_FLAG="--environment $ENV_FILE"
136
+ fi
137
+
138
+ newman run "$COLLECTION" \
139
+ $ENV_FLAG \
140
+ --env-var "baseUrl=http://localhost:${PORT:-3000}" \
141
+ --reporters cli,htmlextra \
142
+ --reporter-htmlextra-export "newman-reports/${REPORT_NAME}-report.html" \
143
+ --reporter-htmlextra-title "${REPORT_NAME} Test Report" \
144
+ --bail
145
+ done
146
+ env:
147
+ NODE_ENV: test
148
+ PORT: ${{ vars.PORT || 3000 }}
149
+
150
+ - name: Upload Newman HTML reports
151
+ if: always()
152
+ uses: actions/upload-artifact@v4
153
+ with:
154
+ name: newman-test-reports
155
+ path: newman-reports/
156
+ retention-days: 14
157
+
158
+ - name: Stop server
159
+ if: always()
160
+ run: |
161
+ if [ -n "$SERVER_PID" ]; then
162
+ kill $SERVER_PID || true
163
+ fi