javi-forge 1.25.1 → 1.27.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/ci-local/hooks/commit-msg +7 -0
- package/ci-local/hooks/pre-commit +8 -0
- package/ci-local/hooks/pre-push +8 -0
- package/dist/cli/dispatch/ci.js +1 -1
- package/dist/cli/dispatch/skills-cmd.js +8 -0
- package/dist/commands/ci.js +19 -5
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.js +121 -0
- package/dist/commands/hooks/sections/deps.d.ts +28 -0
- package/dist/commands/hooks/sections/deps.js +126 -0
- package/dist/commands/hooks/sections/permissions.d.ts +33 -0
- package/dist/commands/hooks/sections/permissions.js +101 -0
- package/dist/commands/hooks/sections/secrets.d.ts +58 -0
- package/dist/commands/hooks/sections/secrets.js +182 -0
- package/dist/commands/hooks.d.ts +4 -4
- package/dist/commands/hooks.js +13 -4
- package/dist/commands/init/steps/ghagga.d.ts +3 -4
- package/dist/commands/init/steps/ghagga.js +5 -15
- package/dist/commands/init/steps/security.d.ts +10 -20
- package/dist/commands/init/steps/security.js +58 -78
- package/dist/commands/init.js +1 -2
- package/dist/commands/skills/analysis.js +31 -2
- package/dist/commands/skills/benchmark.js +10 -0
- package/dist/commands/skills/constants.d.ts +5 -0
- package/dist/commands/skills/constants.js +5 -0
- package/dist/commands/skills/parsing.d.ts +18 -3
- package/dist/commands/skills/parsing.js +29 -3
- package/dist/commands/skills/scoring.d.ts +7 -6
- package/dist/commands/skills/scoring.js +31 -1
- package/dist/constants.d.ts +6 -1
- package/dist/constants.js +12 -7
- package/dist/lib/context.d.ts +22 -0
- package/dist/lib/context.js +120 -79
- package/dist/lib/safe-read.d.ts +62 -0
- package/dist/lib/safe-read.js +221 -0
- package/dist/lib/security-analysis.d.ts +19 -2
- package/dist/lib/security-analysis.js +65 -13
- package/dist/lib/skill-scanner.d.ts +22 -1
- package/dist/lib/skill-scanner.js +76 -4
- package/dist/types/index.d.ts +18 -0
- package/dist/ui/Skills.js +12 -7
- package/package.json +1 -1
- package/templates/github/ghagga-review.yml +0 -30
- package/templates/security-hooks/commit-msg-signing +0 -29
- package/templates/security-hooks/pre-commit-permissions +0 -74
- package/templates/security-hooks/pre-commit-secrets +0 -74
- package/templates/security-hooks/pre-push-branch-protection +0 -62
- package/templates/security-hooks/pre-push-deps +0 -83
- package/templates/security-hooks/pre-push-signing +0 -67
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
set -e
|
|
3
3
|
# =============================================================================
|
|
4
|
+
# LEGACY — retained for reference only (hook-consolidation).
|
|
5
|
+
# The canonical hook mechanism is now `.git/hooks/` written by `installCIHooks`
|
|
6
|
+
# (javi-forge ci init / init). The shipped commit-msg shim lives in
|
|
7
|
+
# assets/hooks/commit-msg; `javi-forge init` no longer copies this directory or
|
|
8
|
+
# sets `core.hooksPath=ci-local/hooks`. This body is kept for historical
|
|
9
|
+
# reference and is NOT the source of truth. See docs/commands.md → hooks.
|
|
10
|
+
# =============================================================================
|
|
4
11
|
# COMMIT-MSG: Block accidental AI-attribution in commit messages
|
|
5
12
|
# =============================================================================
|
|
6
13
|
# Best-effort, NOT an adversarial-grade security control.
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# =============================================================================
|
|
3
|
+
# LEGACY — retained for reference only (hook-consolidation).
|
|
4
|
+
# The canonical hook mechanism is now `.git/hooks/` written by `installCIHooks`
|
|
5
|
+
# (javi-forge ci init / init), whose static shims exec the dispatcher
|
|
6
|
+
# `javi-forge hooks run <name>`. `javi-forge init` no longer copies this
|
|
7
|
+
# directory or sets `core.hooksPath=ci-local/hooks`; a repo still on that legacy
|
|
8
|
+
# path is auto-migrated on the next install. This body is kept for historical
|
|
9
|
+
# reference and is NOT the source of truth. See docs/commands.md → hooks.
|
|
10
|
+
# =============================================================================
|
|
3
11
|
# PRE-COMMIT: Quick CI check via javi-forge
|
|
4
12
|
# =============================================================================
|
|
5
13
|
# Requires: npm install -g javi-forge
|
package/ci-local/hooks/pre-push
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# =============================================================================
|
|
3
|
+
# LEGACY — retained for reference only (hook-consolidation).
|
|
4
|
+
# The canonical hook mechanism is now `.git/hooks/` written by `installCIHooks`
|
|
5
|
+
# (javi-forge ci init / init), whose static shims exec the dispatcher
|
|
6
|
+
# `javi-forge hooks run <name>`. `javi-forge init` no longer copies this
|
|
7
|
+
# directory or sets `core.hooksPath=ci-local/hooks`; a repo still on that legacy
|
|
8
|
+
# path is auto-migrated on the next install. This body is kept for historical
|
|
9
|
+
# reference and is NOT the source of truth. See docs/commands.md → hooks.
|
|
10
|
+
# =============================================================================
|
|
3
11
|
# PRE-PUSH: Full CI simulation via javi-forge
|
|
4
12
|
# =============================================================================
|
|
5
13
|
# Requires: npm install -g javi-forge
|
package/dist/cli/dispatch/ci.js
CHANGED
|
@@ -83,7 +83,7 @@ export async function handleCi(cli, ctx) {
|
|
|
83
83
|
}
|
|
84
84
|
if (installed.length > 0) {
|
|
85
85
|
console.log(`✓ Installed git hooks: ${installed.join(", ")}`);
|
|
86
|
-
console.log(" Hooks call javi-forge
|
|
86
|
+
console.log(" Hooks call javi-forge hooks run (with npx fallback)");
|
|
87
87
|
}
|
|
88
88
|
// Upgrades are reported DISTINCTLY from fresh installs: replacing an
|
|
89
89
|
// older javi-forge hook is not the same event as writing a new one.
|
|
@@ -57,6 +57,10 @@ export async function handleSkillsCmd(cli, ctx) {
|
|
|
57
57
|
console.error(`\u2717 Skill not found: ${skillPath}`);
|
|
58
58
|
process.exit(1);
|
|
59
59
|
}
|
|
60
|
+
if (result.unread) {
|
|
61
|
+
console.error(`\u2717 Skill could not be scored \u2014 ${result.unread}: ${skillPath}`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
60
64
|
console.log(`\nSkill: ${result.skillName}`);
|
|
61
65
|
console.log(` Completeness: ${result.completeness}/100`);
|
|
62
66
|
console.log(` Clarity: ${result.clarity}/100`);
|
|
@@ -77,6 +81,10 @@ export async function handleSkillsCmd(cli, ctx) {
|
|
|
77
81
|
console.error(`\u2717 Skill not found: ${skillPath}`);
|
|
78
82
|
process.exit(1);
|
|
79
83
|
}
|
|
84
|
+
if (result.unread) {
|
|
85
|
+
console.error(`\u2717 Skill could not be benchmarked \u2014 ${result.unread}: ${skillPath}`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
80
88
|
console.log(`\nBenchmark: ${result.skillName}`);
|
|
81
89
|
for (const check of result.checks) {
|
|
82
90
|
const icon = check.passed ? "\u2713" : "\u2717";
|
package/dist/commands/ci.js
CHANGED
|
@@ -269,11 +269,21 @@ export async function resolveCIRunners(projectDir, options = {}) {
|
|
|
269
269
|
const configPath = config ?? (await findCIConfig(projectDir));
|
|
270
270
|
if (configPath) {
|
|
271
271
|
const ciConfig = await loadCIConfig(configPath);
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
272
|
+
const hasRunners = ciConfig.runners.length > 0;
|
|
273
|
+
const hasGates = (ciConfig.gates ?? []).length > 0;
|
|
274
|
+
// A config that declares runners OR gates is a genuine "user configured CI"
|
|
275
|
+
// signal: resolve it as the `config` source (a gates-only v2 config keeps
|
|
276
|
+
// its zero-runner, gates-run contract — see describeRunners). A hooks-only
|
|
277
|
+
// (or otherwise empty) v2 config declares NEITHER runners NOR gates, so it
|
|
278
|
+
// must NOT suppress zero-config auto-detection: fall through so `javi-forge
|
|
279
|
+
// ci` still runs a real lint/compile/test runner instead of a no-op.
|
|
280
|
+
if (hasRunners || hasGates) {
|
|
281
|
+
const runners = [];
|
|
282
|
+
for (const runnerConfig of ciConfig.runners) {
|
|
283
|
+
runners.push(await resolveConfiguredRunner(projectDir, runnerConfig));
|
|
284
|
+
}
|
|
285
|
+
return freezeRunners("config", runners, ciConfig.gates ?? []);
|
|
275
286
|
}
|
|
276
|
-
return freezeRunners("config", runners, ciConfig.gates ?? []);
|
|
277
287
|
}
|
|
278
288
|
// Zero-config default: single auto-detected runner (unchanged behavior).
|
|
279
289
|
const info = await detectCIStack(projectDir);
|
|
@@ -477,7 +487,11 @@ export async function runCI(options, onStep, onGateOutcome) {
|
|
|
477
487
|
try {
|
|
478
488
|
const ctxResult = await refreshContextDir(projectDir);
|
|
479
489
|
if (ctxResult) {
|
|
480
|
-
|
|
490
|
+
// An unreadable manifest is reported, not silently treated as no deps.
|
|
491
|
+
const detail = ctxResult.warnings.length > 0
|
|
492
|
+
? `INDEX.md + summary.md updated (manifest warnings: ${ctxResult.warnings.join("; ")})`
|
|
493
|
+
: "INDEX.md + summary.md updated";
|
|
494
|
+
report(onStep, stepContext, "Refresh .context/ directory", "done", detail);
|
|
481
495
|
}
|
|
482
496
|
else {
|
|
483
497
|
report(onStep, stepContext, "Refresh .context/ directory", "skipped", "no .context/ or no manifest");
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import type { DoctorResult } from "../types/index.js";
|
|
2
2
|
export type CheckStatus = "ok" | "fail" | "skip";
|
|
3
|
+
interface RemoteInfo {
|
|
4
|
+
host: "github" | "gitlab" | "other";
|
|
5
|
+
owner: string;
|
|
6
|
+
repo: string;
|
|
7
|
+
}
|
|
8
|
+
/** Parse an origin remote URL (ssh or https) into host/owner/repo. */
|
|
9
|
+
export declare function parseRemote(url: string): RemoteInfo | null;
|
|
3
10
|
/**
|
|
4
11
|
* Run comprehensive health checks for the project and framework.
|
|
5
12
|
*/
|
|
6
13
|
export declare function runDoctor(projectDir?: string): Promise<DoctorResult>;
|
|
14
|
+
export {};
|
|
7
15
|
//# sourceMappingURL=doctor.d.ts.map
|
package/dist/commands/doctor.js
CHANGED
|
@@ -34,6 +34,110 @@ async function countDir(dir) {
|
|
|
34
34
|
const entries = await fs.readdir(dir);
|
|
35
35
|
return entries.filter((e) => !e.startsWith(".")).length;
|
|
36
36
|
}
|
|
37
|
+
/** Read a single git config value, "" when unset or on any error. */
|
|
38
|
+
async function gitConfigValue(cwd, key) {
|
|
39
|
+
try {
|
|
40
|
+
const { stdout } = await execFileAsync("git", ["config", "--get", key], {
|
|
41
|
+
cwd,
|
|
42
|
+
});
|
|
43
|
+
return stdout.trim();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Parse an origin remote URL (ssh or https) into host/owner/repo. */
|
|
50
|
+
export function parseRemote(url) {
|
|
51
|
+
const trimmed = url.trim();
|
|
52
|
+
if (!trimmed)
|
|
53
|
+
return null;
|
|
54
|
+
// git@host:owner/repo(.git) | ssh://git@host/owner/repo | https://host/owner/repo(.git)
|
|
55
|
+
const m = trimmed.match(/(?:git@|https?:\/\/|ssh:\/\/(?:git@)?)([^/:]+)[/:]([^/]+)\/(.+?)(?:\.git)?$/);
|
|
56
|
+
if (!m)
|
|
57
|
+
return null;
|
|
58
|
+
const [, hostRaw, owner, repo] = m;
|
|
59
|
+
const host = hostRaw.includes("github")
|
|
60
|
+
? "github"
|
|
61
|
+
: hostRaw.includes("gitlab")
|
|
62
|
+
? "gitlab"
|
|
63
|
+
: "other";
|
|
64
|
+
return { host, owner, repo };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* L4+L6 advisory (merged — signing is one check): report `ok` when commit
|
|
68
|
+
* signing is fully configured (`commit.gpgsign=true` AND `user.signingkey`
|
|
69
|
+
* set), otherwise `skip` with the enable snippet. Doctor is read-only — this is
|
|
70
|
+
* a recommendation, never a gate (a hook enforcing it was trivially bypassed
|
|
71
|
+
* with `--no-verify`).
|
|
72
|
+
*/
|
|
73
|
+
async function commitSigningCheck(cwd) {
|
|
74
|
+
const gpgSign = await gitConfigValue(cwd, "commit.gpgsign");
|
|
75
|
+
const signingKey = await gitConfigValue(cwd, "user.signingkey");
|
|
76
|
+
if (gpgSign === "true" && signingKey) {
|
|
77
|
+
return {
|
|
78
|
+
label: "Commit signing",
|
|
79
|
+
status: "ok",
|
|
80
|
+
detail: "signing enabled (commit.gpgsign=true)",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
label: "Commit signing",
|
|
85
|
+
status: "skip",
|
|
86
|
+
detail: "not configured — enable with: git config commit.gpgsign true && git config user.signingkey <KEY>",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* L5 advisory: server-side branch protection is the real control (the old local
|
|
91
|
+
* push-blocking hook was CI-exempt and `--no-verify`-bypassable). When `gh` is
|
|
92
|
+
* on PATH and origin is GitHub, probe the branch protection API; a missing
|
|
93
|
+
* probe or protection is a non-alarming `skip` (advisory, not a failure).
|
|
94
|
+
* GitLab or no `gh` → `skip` with a note to verify in the forge UI.
|
|
95
|
+
*/
|
|
96
|
+
async function branchProtectionCheck(cwd) {
|
|
97
|
+
const remote = parseRemote(await gitConfigValue(cwd, "remote.origin.url"));
|
|
98
|
+
const label = "Branch protection";
|
|
99
|
+
if (!remote || remote.host === "other") {
|
|
100
|
+
return {
|
|
101
|
+
label,
|
|
102
|
+
status: "skip",
|
|
103
|
+
detail: "no GitHub/GitLab origin — verify branch protection in the forge UI",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (remote.host === "gitlab" || !(await which("gh"))) {
|
|
107
|
+
return {
|
|
108
|
+
label,
|
|
109
|
+
status: "skip",
|
|
110
|
+
detail: "verify branch protection in the forge UI",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// gh + GitHub: resolve the default branch, then probe protection.
|
|
114
|
+
let branch = "main";
|
|
115
|
+
try {
|
|
116
|
+
const { stdout } = await execFileAsync("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd });
|
|
117
|
+
branch = stdout.trim().replace(/^origin\//, "") || "main";
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
/* fall back to main */
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
await execFileAsync("gh", [
|
|
124
|
+
"api",
|
|
125
|
+
`repos/${remote.owner}/${remote.repo}/branches/${branch}/protection`,
|
|
126
|
+
]);
|
|
127
|
+
return {
|
|
128
|
+
label,
|
|
129
|
+
status: "ok",
|
|
130
|
+
detail: `server-side protection enabled on ${branch}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return {
|
|
135
|
+
label,
|
|
136
|
+
status: "skip",
|
|
137
|
+
detail: `no server-side branch protection detected on ${branch}`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
37
141
|
/**
|
|
38
142
|
* Run comprehensive health checks for the project and framework.
|
|
39
143
|
*/
|
|
@@ -76,6 +180,14 @@ export async function runDoctor(projectDir) {
|
|
|
76
180
|
}
|
|
77
181
|
}
|
|
78
182
|
sections.push({ title: "System Tools", checks: toolChecks });
|
|
183
|
+
// ── 1b. Security (advisories, read-only) ────────────────────────────────────
|
|
184
|
+
// L4/L6 (commit signing) and L5 (branch protection) are advisories, NOT hooks
|
|
185
|
+
// (hook-consolidation D9): doctor reports, nothing blocks.
|
|
186
|
+
const securityChecks = [
|
|
187
|
+
await commitSigningCheck(cwd),
|
|
188
|
+
await branchProtectionCheck(cwd),
|
|
189
|
+
];
|
|
190
|
+
sections.push({ title: "Security", checks: securityChecks });
|
|
79
191
|
// ── 2. Framework Structure ─────────────────────────────────────────────────
|
|
80
192
|
const structureChecks = [];
|
|
81
193
|
const expectedDirs = [
|
|
@@ -207,6 +319,15 @@ export async function runDoctor(projectDir) {
|
|
|
207
319
|
status: "ok",
|
|
208
320
|
detail: "INDEX.md + summary.md updated",
|
|
209
321
|
});
|
|
322
|
+
// A manifest that could not be read is not "no dependencies" — surface
|
|
323
|
+
// each warning so an unreadable/oversized/invalid manifest is visible.
|
|
324
|
+
for (const warning of result.warnings) {
|
|
325
|
+
contextChecks.push({
|
|
326
|
+
label: "dependency manifest",
|
|
327
|
+
status: "fail",
|
|
328
|
+
detail: warning,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
210
331
|
}
|
|
211
332
|
else {
|
|
212
333
|
contextChecks.push({
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L2 dependency-audit section (hook-consolidation S4).
|
|
3
|
+
*
|
|
4
|
+
* Port of `templates/security-hooks/pre-push-deps`. Same manifest→tool ladder,
|
|
5
|
+
* run via `execFileAsync` (argv, no shell). A tool that is not installed is an
|
|
6
|
+
* advisory skip (ok:true) exactly like the bash "not installed. Skipping"
|
|
7
|
+
* branch — a missing auditor must never block a push. A genuine audit finding
|
|
8
|
+
* (the auditor ran and exited non-zero) is a blocking failure (ok:false).
|
|
9
|
+
*/
|
|
10
|
+
import type { HookSection } from "../../hooks.js";
|
|
11
|
+
export interface DepsSectionDeps {
|
|
12
|
+
execFile: (cmd: string, args: string[], opts?: {
|
|
13
|
+
cwd?: string;
|
|
14
|
+
}) => Promise<{
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
}>;
|
|
18
|
+
which: (bin: string) => Promise<boolean>;
|
|
19
|
+
fileExists: (p: string) => Promise<boolean>;
|
|
20
|
+
log: (msg: string) => void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The `deps` section factory. Detects the project's manifest and runs the
|
|
24
|
+
* matching auditor. No manifest → advisory skip. A thrown error is caught and
|
|
25
|
+
* mapped to a blocking failure (never an unhandled rejection).
|
|
26
|
+
*/
|
|
27
|
+
export declare function depsSection(overrides?: Partial<DepsSectionDeps>): HookSection;
|
|
28
|
+
//# sourceMappingURL=deps.d.ts.map
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L2 dependency-audit section (hook-consolidation S4).
|
|
3
|
+
*
|
|
4
|
+
* Port of `templates/security-hooks/pre-push-deps`. Same manifest→tool ladder,
|
|
5
|
+
* run via `execFileAsync` (argv, no shell). A tool that is not installed is an
|
|
6
|
+
* advisory skip (ok:true) exactly like the bash "not installed. Skipping"
|
|
7
|
+
* branch — a missing auditor must never block a push. A genuine audit finding
|
|
8
|
+
* (the auditor ran and exited non-zero) is a blocking failure (ok:false).
|
|
9
|
+
*/
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import fs from "fs-extra";
|
|
12
|
+
import { execFileAsync } from "../../../lib/exec.js";
|
|
13
|
+
async function whichReal(bin) {
|
|
14
|
+
try {
|
|
15
|
+
await execFileAsync("which", [bin]);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function defaultDeps() {
|
|
23
|
+
return {
|
|
24
|
+
execFile: async (cmd, args, opts) => {
|
|
25
|
+
const { stdout, stderr } = await execFileAsync(cmd, args, opts);
|
|
26
|
+
return { stdout: String(stdout), stderr: String(stderr) };
|
|
27
|
+
},
|
|
28
|
+
which: whichReal,
|
|
29
|
+
fileExists: (p) => fs.pathExists(p),
|
|
30
|
+
log: (m) => console.log(m),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** ENOENT ⇒ the auditor binary is absent (advisory skip, never blocking). */
|
|
34
|
+
function isMissingBinary(e) {
|
|
35
|
+
return e?.code === "ENOENT";
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Run an auditor. A clean exit is ok:true; a non-zero exit (the tool ran and
|
|
39
|
+
* found something) is a blocking ok:false; a missing binary (ENOENT) is an
|
|
40
|
+
* advisory skip ok:true (parity with the bash "not installed" branch).
|
|
41
|
+
*/
|
|
42
|
+
async function runAudit(deps, projectDir, cmd, args) {
|
|
43
|
+
deps.log(` ▶ ${cmd} ${args.join(" ")}`);
|
|
44
|
+
try {
|
|
45
|
+
await deps.execFile(cmd, args, { cwd: projectDir });
|
|
46
|
+
return { ok: true };
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
if (isMissingBinary(e)) {
|
|
50
|
+
deps.log(` ⓘ ${cmd} not installed. Skipping (does not block).`);
|
|
51
|
+
return { ok: true };
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
detail: `high/critical vulnerabilities found (${cmd} ${args.join(" ")})`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The `deps` section factory. Detects the project's manifest and runs the
|
|
61
|
+
* matching auditor. No manifest → advisory skip. A thrown error is caught and
|
|
62
|
+
* mapped to a blocking failure (never an unhandled rejection).
|
|
63
|
+
*/
|
|
64
|
+
export function depsSection(overrides = {}) {
|
|
65
|
+
const deps = { ...defaultDeps(), ...overrides };
|
|
66
|
+
const at = (projectDir, name) => path.join(projectDir, name);
|
|
67
|
+
return {
|
|
68
|
+
id: "deps",
|
|
69
|
+
blocking: true,
|
|
70
|
+
async run({ projectDir }) {
|
|
71
|
+
try {
|
|
72
|
+
if (await deps.fileExists(at(projectDir, "package.json"))) {
|
|
73
|
+
if (await deps.fileExists(at(projectDir, "pnpm-lock.yaml"))) {
|
|
74
|
+
return runAudit(deps, projectDir, "pnpm", [
|
|
75
|
+
"audit",
|
|
76
|
+
"--audit-level=high",
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
if (await deps.fileExists(at(projectDir, "yarn.lock"))) {
|
|
80
|
+
return runAudit(deps, projectDir, "yarn", [
|
|
81
|
+
"npm",
|
|
82
|
+
"audit",
|
|
83
|
+
"--severity",
|
|
84
|
+
"high",
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
87
|
+
return runAudit(deps, projectDir, "npm", [
|
|
88
|
+
"audit",
|
|
89
|
+
"--audit-level=high",
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
92
|
+
if ((await deps.fileExists(at(projectDir, "requirements.txt"))) ||
|
|
93
|
+
(await deps.fileExists(at(projectDir, "pyproject.toml")))) {
|
|
94
|
+
if (!(await deps.which("pip-audit"))) {
|
|
95
|
+
deps.log(" ⓘ pip-audit not installed. Skipping (does not block).");
|
|
96
|
+
return { ok: true };
|
|
97
|
+
}
|
|
98
|
+
return runAudit(deps, projectDir, "pip-audit", []);
|
|
99
|
+
}
|
|
100
|
+
if (await deps.fileExists(at(projectDir, "Cargo.toml"))) {
|
|
101
|
+
if (!(await deps.which("cargo-audit"))) {
|
|
102
|
+
deps.log(" ⓘ cargo-audit not installed. Skipping (does not block).");
|
|
103
|
+
return { ok: true };
|
|
104
|
+
}
|
|
105
|
+
return runAudit(deps, projectDir, "cargo-audit", []);
|
|
106
|
+
}
|
|
107
|
+
if (await deps.fileExists(at(projectDir, "go.mod"))) {
|
|
108
|
+
if (!(await deps.which("govulncheck"))) {
|
|
109
|
+
deps.log(" ⓘ govulncheck not installed. Skipping (does not block).");
|
|
110
|
+
return { ok: true };
|
|
111
|
+
}
|
|
112
|
+
return runAudit(deps, projectDir, "govulncheck", ["./..."]);
|
|
113
|
+
}
|
|
114
|
+
deps.log(" ⓘ no supported dependency manifest found. Skipping.");
|
|
115
|
+
return { ok: true };
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=deps.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L3 permission-boundary section (hook-consolidation S4).
|
|
3
|
+
*
|
|
4
|
+
* Port of `templates/security-hooks/pre-commit-permissions`. Builds the staged
|
|
5
|
+
* list NUL-safe (`-z` → split on "\0") and does the mode checks with `fs.stat`
|
|
6
|
+
* in TS instead of shelling out to `stat`/`grep`. Two issue classes block the
|
|
7
|
+
* commit: a world-writable file (`mode & 0o002`) and an executable
|
|
8
|
+
* (`mode & 0o111`) that is NOT an allowed script/hook/shebang file.
|
|
9
|
+
*/
|
|
10
|
+
import type { HookSection } from "../../hooks.js";
|
|
11
|
+
export interface PermissionsSectionDeps {
|
|
12
|
+
execFile: (cmd: string, args: string[], opts?: {
|
|
13
|
+
cwd?: string;
|
|
14
|
+
}) => Promise<{
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
}>;
|
|
18
|
+
/** stat a path; returns null when the file does not exist / is not a regular file. */
|
|
19
|
+
statFile: (p: string) => Promise<{
|
|
20
|
+
mode: number;
|
|
21
|
+
isFile: boolean;
|
|
22
|
+
} | null>;
|
|
23
|
+
/** first line of a file (for the shebang allowance); "" on any read error. */
|
|
24
|
+
firstLine: (p: string) => Promise<string>;
|
|
25
|
+
log: (msg: string) => void;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The `permissions` section factory. Injectable seams default to the real
|
|
29
|
+
* git/fs implementation. A thrown error is caught and mapped to a blocking
|
|
30
|
+
* failure (never an unhandled rejection).
|
|
31
|
+
*/
|
|
32
|
+
export declare function permissionsSection(overrides?: Partial<PermissionsSectionDeps>): HookSection;
|
|
33
|
+
//# sourceMappingURL=permissions.d.ts.map
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L3 permission-boundary section (hook-consolidation S4).
|
|
3
|
+
*
|
|
4
|
+
* Port of `templates/security-hooks/pre-commit-permissions`. Builds the staged
|
|
5
|
+
* list NUL-safe (`-z` → split on "\0") and does the mode checks with `fs.stat`
|
|
6
|
+
* in TS instead of shelling out to `stat`/`grep`. Two issue classes block the
|
|
7
|
+
* commit: a world-writable file (`mode & 0o002`) and an executable
|
|
8
|
+
* (`mode & 0o111`) that is NOT an allowed script/hook/shebang file.
|
|
9
|
+
*/
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import fs from "fs-extra";
|
|
12
|
+
import { execFileAsync } from "../../../lib/exec.js";
|
|
13
|
+
/** Extensions that legitimately carry the executable bit. */
|
|
14
|
+
const SCRIPT_EXT = /\.(sh|bash|zsh|py|rb|pl)$/;
|
|
15
|
+
async function statFileReal(p) {
|
|
16
|
+
try {
|
|
17
|
+
const st = await fs.stat(p);
|
|
18
|
+
return { mode: st.mode, isFile: st.isFile() };
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function firstLineReal(p) {
|
|
25
|
+
try {
|
|
26
|
+
const content = await fs.readFile(p, "utf-8");
|
|
27
|
+
return content.split("\n", 1)[0] ?? "";
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function defaultDeps() {
|
|
34
|
+
return {
|
|
35
|
+
execFile: async (cmd, args, opts) => {
|
|
36
|
+
const { stdout, stderr } = await execFileAsync(cmd, args, opts);
|
|
37
|
+
return { stdout: String(stdout), stderr: String(stderr) };
|
|
38
|
+
},
|
|
39
|
+
statFile: statFileReal,
|
|
40
|
+
firstLine: firstLineReal,
|
|
41
|
+
log: (m) => console.log(m),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
async function stagedFiles(deps, projectDir) {
|
|
45
|
+
const { stdout } = await deps.execFile("git", ["diff", "--cached", "--name-only", "--diff-filter=ACM", "-z"], { cwd: projectDir });
|
|
46
|
+
return stdout.split("\0").filter((f) => f.length > 0);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The `permissions` section factory. Injectable seams default to the real
|
|
50
|
+
* git/fs implementation. A thrown error is caught and mapped to a blocking
|
|
51
|
+
* failure (never an unhandled rejection).
|
|
52
|
+
*/
|
|
53
|
+
export function permissionsSection(overrides = {}) {
|
|
54
|
+
const deps = { ...defaultDeps(), ...overrides };
|
|
55
|
+
return {
|
|
56
|
+
id: "permissions",
|
|
57
|
+
blocking: true,
|
|
58
|
+
async run({ projectDir }) {
|
|
59
|
+
try {
|
|
60
|
+
const files = await stagedFiles(deps, projectDir);
|
|
61
|
+
if (files.length === 0)
|
|
62
|
+
return { ok: true };
|
|
63
|
+
const issues = [];
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
const full = path.join(projectDir, file);
|
|
66
|
+
const st = await deps.statFile(full);
|
|
67
|
+
if (!st?.isFile)
|
|
68
|
+
continue;
|
|
69
|
+
if ((st.mode & 0o002) !== 0) {
|
|
70
|
+
issues.push(`world-writable: ${file}`);
|
|
71
|
+
}
|
|
72
|
+
if ((st.mode & 0o111) !== 0) {
|
|
73
|
+
const allowedByExt = SCRIPT_EXT.test(file);
|
|
74
|
+
const allowedByHooksDir = file.includes("/hooks/");
|
|
75
|
+
const allowedByShebang = allowedByExt || allowedByHooksDir
|
|
76
|
+
? true
|
|
77
|
+
: (await deps.firstLine(full)).startsWith("#!");
|
|
78
|
+
if (!allowedByExt && !allowedByHooksDir && !allowedByShebang) {
|
|
79
|
+
issues.push(`unexpected executable: ${file}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (issues.length === 0)
|
|
84
|
+
return { ok: true };
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
detail: `${issues.length} permission issue(s): ${issues
|
|
88
|
+
.slice(0, 10)
|
|
89
|
+
.join("; ")}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=permissions.js.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1 secret-scan section (hook-consolidation S4).
|
|
3
|
+
*
|
|
4
|
+
* Port of `templates/security-hooks/pre-commit-secrets` into TypeScript. The
|
|
5
|
+
* bash body built the staged-file list with `git diff --cached --name-only`
|
|
6
|
+
* and then piped it through `xargs git diff` (pre-commit-secrets:52) — a
|
|
7
|
+
* whitespace split that silently dropped any path containing a space and
|
|
8
|
+
* swallowed the git error with `|| true` (K-005). This port builds the staged
|
|
9
|
+
* list NUL-safe (`-z` → split on `"\0"`) and passes each filename as a SEPARATE
|
|
10
|
+
* argv element to `git diff` (no shell, no xargs), so a path like
|
|
11
|
+
* `app secrets.env` is scanned as ONE path and can never be split.
|
|
12
|
+
*
|
|
13
|
+
* A matched pattern in an ADDED diff line blocks the commit (ok:false).
|
|
14
|
+
*/
|
|
15
|
+
import type { HookSection } from "../../hooks.js";
|
|
16
|
+
/** ARG_MAX guard: cap the number of file paths per `git diff` argv batch. */
|
|
17
|
+
export declare const DEFAULT_CHUNK_SIZE = 512;
|
|
18
|
+
/**
|
|
19
|
+
* maxBuffer for the content `git diff` read. Node's default is 1 MiB; a staged
|
|
20
|
+
* diff larger than that rejects and (caught) blocks every large legitimate
|
|
21
|
+
* commit with a cryptic buffer error. Raise the ceiling to a generous but
|
|
22
|
+
* BOUNDED 64 MiB so ordinary large commits scan, while a genuinely pathological
|
|
23
|
+
* diff still fails closed instead of exhausting memory.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DIFF_MAX_BUFFER: number;
|
|
26
|
+
interface Finding {
|
|
27
|
+
file: string;
|
|
28
|
+
line: number;
|
|
29
|
+
pattern: string;
|
|
30
|
+
}
|
|
31
|
+
export interface SecretsSectionDeps {
|
|
32
|
+
/** argv-only exec (no shell). Defaults to the real `git` via execFileAsync. */
|
|
33
|
+
execFile: (cmd: string, args: string[], opts?: {
|
|
34
|
+
cwd?: string;
|
|
35
|
+
maxBuffer?: number;
|
|
36
|
+
}) => Promise<{
|
|
37
|
+
stdout: string;
|
|
38
|
+
stderr: string;
|
|
39
|
+
}>;
|
|
40
|
+
log: (msg: string) => void;
|
|
41
|
+
/** File-count per `git diff` batch (ARG_MAX guard). Defaults to 512. */
|
|
42
|
+
chunkSize: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Walk a unified diff and report every ADDED line (`+`, never `+++`) that
|
|
46
|
+
* matches a secret pattern, tracking the new-file path and line number so a
|
|
47
|
+
* finding reads `path:line pattern`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function scanDiff(diff: string): Finding[];
|
|
50
|
+
/**
|
|
51
|
+
* The `secrets` section factory. Injectable seams default to the real
|
|
52
|
+
* `git`-via-execFileAsync implementation; tests pass mocks. A thrown git error
|
|
53
|
+
* becomes a blocking `{ ok: false }` (never an unhandled rejection), matching
|
|
54
|
+
* the ciSection/tddSection hardening.
|
|
55
|
+
*/
|
|
56
|
+
export declare function secretsSection(overrides?: Partial<SecretsSectionDeps>): HookSection;
|
|
57
|
+
export {};
|
|
58
|
+
//# sourceMappingURL=secrets.d.ts.map
|