ikanban 0.4.9 → 0.5.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 +57 -9
- package/bin/ikanban.js +49 -6
- package/hooks/deploy-check.js +221 -0
- package/hooks/ikanban-safe.js +26 -3
- package/hooks/plan-gate.js +206 -0
- package/hooks/trace.js +365 -0
- package/lib/ikanban-safe.js +26 -3
- package/lib/plancheck.js +221 -0
- package/lib/roadmap.js +73 -26
- package/lib/scanstore.js +403 -0
- package/lib/statuswords.js +13 -0
- package/package.json +3 -3
- package/scanners/ikanban-aiscan.py +121 -30
- package/scanners/ikanban-cloudscan.py +110 -41
- package/scanners/ikanban-controlscan.py +783 -0
- package/scanners/ikanban-dastscan.py +108 -40
- package/scanners/ikanban-import.py +597 -0
- package/scanners/ikanban-infrascan.py +116 -51
- package/scanners/ikanban-secscan.py +116 -104
package/README.md
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
# ikanban
|
|
2
2
|
|
|
3
3
|
The iKanban command line. It scans your own repo on your machine and, when you ask, files the
|
|
4
|
-
|
|
4
|
+
findings into [iKanban](https://i-kanban.com). Full reports and raw tool output stay on your
|
|
5
|
+
machine, encrypted (see "Where scans are stored").
|
|
5
6
|
|
|
6
7
|
```bash
|
|
7
8
|
npx ikanban secscan # dependencies, committed secrets, risky file permissions
|
|
8
9
|
npx ikanban aiscan --help # red-team scan of your AI app (OWASP LLM Top 10)
|
|
9
|
-
npx ikanban secscan --publish # also file the
|
|
10
|
+
npx ikanban secscan --publish # also file the findings into iKanban
|
|
10
11
|
npx ikanban infrascan --image app:1 # IaC misconfiguration and container image scan (Trivy)
|
|
11
12
|
npx ikanban dastscan --url URL # passive web scan of a registered URL (OWASP ZAP in Docker)
|
|
12
13
|
npx ikanban dastscan --url URL --openapi openapi.json # API scan of a registered URL (ZAP, active)
|
|
13
14
|
npx ikanban cloudscan --aws-profile P --region R --account ID # read-only AWS account audit (Prowler)
|
|
15
|
+
npx ikanban controlscan --path . # repository controls: CI audits, lockfiles, secret scanning, CODEOWNERS…
|
|
16
|
+
npx ikanban import codeql.sarif --publish --team TEAM_ID # another scanner's SARIF file as findings
|
|
17
|
+
npx ikanban scans list # the stored scans of this repository
|
|
18
|
+
npx ikanban scans show <id> # print a stored report (decrypted on this machine only)
|
|
14
19
|
npx ikanban secscan --task IKA-123 # also show the run in that task's Pipelines
|
|
15
20
|
npx ikanban roadmap sync plan.json # make the Roadmap plan match the plan file
|
|
16
21
|
npx ikanban hooks install # sync plan files on every agent write and git push
|
|
@@ -73,17 +78,57 @@ peer conflict), `secscan` writes a lockfile for a copy of `package.json` in its
|
|
|
73
78
|
`.npmrc`) and audits that copy. Nothing is written into the repository. If npm still cannot
|
|
74
79
|
resolve the tree, the folder is SKIPPED with the fix: commit a `package-lock.json`.
|
|
75
80
|
|
|
76
|
-
##
|
|
81
|
+
## Where scans are stored
|
|
77
82
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
Each scanner writes its report and raw tool output into a private temporary folder. When it
|
|
84
|
+
ends, `ikanban` encrypts every file (AES-256-GCM) into
|
|
85
|
+
`~/.ikanban/scans/<repo>-<8 hex>/<scanner>-<UTC time>/` (Windows: `%USERPROFILE%\.ikanban\scans`)
|
|
86
|
+
and deletes the temporary folder. Nothing is written into the repository. `manifest.json` next to
|
|
87
|
+
the encrypted files lists only file names, sizes, the time, the scanner and the repository path.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
ikanban scans list [--repo PATH | --all] # stored scans (default: this folder's repository)
|
|
91
|
+
ikanban scans show <id> [file] [--out FILE] # decrypt to the terminal (default report.md)
|
|
92
|
+
ikanban scans migrate [--path PATH] # seal old .iKanban/scans folders, remove the plain copies
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The key is 32 random bytes, made on first use, and kept by the operating system:
|
|
96
|
+
|
|
97
|
+
- macOS: the login keychain (service `ikanban-scan-store`).
|
|
98
|
+
- Windows: DPAPI for the current user; the protected key is `%USERPROFILE%\.ikanban\scan-store.key.dpapi`.
|
|
99
|
+
- Linux: the Secret Service through `secret-tool` (service `ikanban-scan-store`).
|
|
100
|
+
- Anywhere else, or when the keychain fails, or with `IKANBAN_SCAN_STORE=file` (CI runners): an
|
|
101
|
+
owner-only file `~/.ikanban/scan-store.key`, and `ikanban` prints one line saying so.
|
|
102
|
+
|
|
103
|
+
The key is never printed or put on a command line. A changed file or another machine's key gives
|
|
104
|
+
a clear error, never garbled output. With `--work-dir PATH` the run folder in PATH is sealed and
|
|
105
|
+
removed too. Under `ikanban`, a scanner prints its progress, the counts and
|
|
106
|
+
`Full report: ikanban scans show <id>`; add `--print-report` to print the report itself. Running a
|
|
107
|
+
`.py` scanner directly with Python keeps the old behaviour: the report prints, and the files stay
|
|
108
|
+
in `.iKanban/scans/`.
|
|
109
|
+
|
|
110
|
+
## Repository controls
|
|
111
|
+
|
|
112
|
+
`controlscan` checks eight controls of the repository and gives each pass, warn, fail or skipped
|
|
113
|
+
with the reason: a CI dependency audit (or Dependabot / Renovate), committed lockfiles, CI secret
|
|
114
|
+
scanning, CI static analysis (SAST), no `.env` or key file in git, default-branch protection,
|
|
115
|
+
CODEOWNERS and SECURITY.md. Branch protection, and GitHub secret scanning when CI has no secret
|
|
116
|
+
scanner, are read with your own `gh` sign-in; without `gh` that check is skipped, never passed.
|
|
117
|
+
`--publish` files one result per control; a failed control files one Medium finding, a warning
|
|
118
|
+
one Low finding.
|
|
119
|
+
|
|
120
|
+
## Import another scanner's results
|
|
121
|
+
|
|
122
|
+
`ikanban import FILE.sarif` reads a SARIF 2.1.0 file (CodeQL, Semgrep, Trivy, Snyk…). A result
|
|
123
|
+
whose rule carries a CWE becomes a code finding under the OWASP Top 10 2021 category that lists
|
|
124
|
+
that CWE. A result with no CWE, or a CWE no category lists, stays in the local report as
|
|
125
|
+
"not filed: no OWASP category". The SARIF file is never uploaded.
|
|
81
126
|
|
|
82
127
|
## Pipelines
|
|
83
128
|
|
|
84
129
|
Every scanner takes `--task IKA-123` (or `IKANBAN_TASK`) and `--task-team` (default `--team`). The
|
|
85
130
|
run then shows in that task's Pipelines view: running, then passed, failed or skipped with a
|
|
86
|
-
one-line summary
|
|
131
|
+
one-line summary. The report itself is never sent there. While the scan runs, the stage log gets one
|
|
87
132
|
line as each step starts and ends. A plan without Pipelines never fails the scan.
|
|
88
133
|
|
|
89
134
|
## What you need
|
|
@@ -100,8 +145,11 @@ iKanban checks your plan when you publish; a plan that does not include it gets
|
|
|
100
145
|
|
|
101
146
|
## What it sends
|
|
102
147
|
|
|
103
|
-
Nothing, unless you pass `--publish`. With `--publish`,
|
|
104
|
-
|
|
148
|
+
Nothing, unless you pass `--publish`. With `--publish`, only findings go to iKanban: title,
|
|
149
|
+
category, severity, file and line, and the fix. Every text field is scrubbed on your machine first
|
|
150
|
+
(secrets, keys and tokens cut, your home folder written as `~`), and again on the server. AI scan
|
|
151
|
+
evidence (the attack, the answer, the grader's reason) is cut to 2,000 characters. Full reports,
|
|
152
|
+
raw tool output and SARIF files stay on your machine.
|
|
105
153
|
|
|
106
154
|
## Licence
|
|
107
155
|
|
package/bin/ikanban.js
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// Scans run free on your machine; only --publish talks to iKanban, and the
|
|
5
5
|
// server checks your plan there (a 402 means the plan, not the scan).
|
|
6
6
|
const path = require("node:path");
|
|
7
|
-
const { runCommand } = require("../lib/ikanban-safe.js");
|
|
8
7
|
const { version } = require("../package.json");
|
|
8
|
+
// Loaded when a scan needs it: `ikanban plan check` must run from a plain checkout, where the
|
|
9
|
+
// copied lib/ikanban-safe.js is missing (the plan gate tells agents to run it there).
|
|
10
|
+
const runCommand = (...a) => require("../lib/ikanban-safe.js").runCommand(...a);
|
|
9
11
|
|
|
10
12
|
const COMMANDS = {
|
|
11
13
|
aiscan: { file: "ikanban-aiscan.py", about: "Red-team scan of your AI app (OWASP LLM Top 10)" },
|
|
@@ -13,10 +15,12 @@ const COMMANDS = {
|
|
|
13
15
|
infrascan: { file: "ikanban-infrascan.py", about: "Container image and IaC scan (needs Trivy)" },
|
|
14
16
|
dastscan: { file: "ikanban-dastscan.py", about: "Web or API scan of a registered URL (needs Docker)" },
|
|
15
17
|
cloudscan: { file: "ikanban-cloudscan.py", about: "Read-only AWS account audit (needs AWS CLI, Docker)" },
|
|
18
|
+
controlscan: { file: "ikanban-controlscan.py", about: "Repository security controls: CI audits, lockfiles, secret scanning, CODEOWNERS…" },
|
|
19
|
+
import: { file: "ikanban-import.py", about: "Turn another scanner's SARIF file into findings: import FILE.sarif" },
|
|
16
20
|
};
|
|
17
21
|
|
|
18
22
|
function help() {
|
|
19
|
-
const lines = Object.entries(COMMANDS).map(([n, c]) => ` ${n.padEnd(
|
|
23
|
+
const lines = Object.entries(COMMANDS).map(([n, c]) => ` ${n.padEnd(12)}${c.about}`);
|
|
20
24
|
console.log(`ikanban ${version}
|
|
21
25
|
|
|
22
26
|
Usage: ikanban <command> [options]
|
|
@@ -25,12 +29,14 @@ Commands:
|
|
|
25
29
|
${lines.join("\n")}
|
|
26
30
|
roadmap Keep a Roadmap plan in step with its plan file: roadmap sync [plan.json] [--check]
|
|
27
31
|
hooks Install the hooks that run that sync on every agent write and git push
|
|
32
|
+
plan Check a plan file against the template: plan check FILE [--done]
|
|
28
33
|
|
|
29
34
|
pipeline Report a task's stage and stream its log to iKanban (any CI)
|
|
30
35
|
|
|
31
36
|
Run "ikanban <command> --help" for its options. Add --publish to file the
|
|
32
|
-
|
|
33
|
-
--task IKA-123 to show the run in that task's Pipelines.
|
|
37
|
+
findings into iKanban (needs an API token; the server checks your plan), and
|
|
38
|
+
--task IKA-123 to show the run in that task's Pipelines. Full reports and raw tool
|
|
39
|
+
output stay on this machine, encrypted in ~/.ikanban/scans ("ikanban scans").
|
|
34
40
|
Needs Python 3.9 or newer.`);
|
|
35
41
|
}
|
|
36
42
|
|
|
@@ -62,6 +68,14 @@ if (cmd === "roadmap" || cmd === "hooks") {
|
|
|
62
68
|
});
|
|
63
69
|
return;
|
|
64
70
|
}
|
|
71
|
+
// Plan 7 P1: one shared definition of "a plan that passes", for every gate.
|
|
72
|
+
if (cmd === "plan") {
|
|
73
|
+
Promise.resolve(require("../lib/plancheck.js").run(args)).then((code) => process.exit(code), (e) => {
|
|
74
|
+
console.error(`ikanban plan: ${e.message}`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
65
79
|
// Plan 21 F10: report a task's pipeline stages and stream their logs, from any CI.
|
|
66
80
|
if (cmd === "pipeline") {
|
|
67
81
|
require("../lib/pipeline.js").run(args).then((code) => process.exit(code), (e) => {
|
|
@@ -70,6 +84,10 @@ if (cmd === "pipeline") {
|
|
|
70
84
|
});
|
|
71
85
|
return;
|
|
72
86
|
}
|
|
87
|
+
// Plan 5 X3: stored scans, decrypted on this machine only.
|
|
88
|
+
if (cmd === "scans") {
|
|
89
|
+
process.exit(require("../lib/scanstore.js").run(args));
|
|
90
|
+
}
|
|
73
91
|
const command = COMMANDS[cmd];
|
|
74
92
|
if (!command) {
|
|
75
93
|
console.error(`ikanban: unknown command "${cmd}". Run "ikanban --help".`);
|
|
@@ -84,5 +102,30 @@ const [py, pre] = python;
|
|
|
84
102
|
const script = path.join(__dirname, "..", "scanners", command.file);
|
|
85
103
|
// Windows consoles default to cp1252, which lacks characters the reports print.
|
|
86
104
|
const env = { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" };
|
|
87
|
-
|
|
88
|
-
|
|
105
|
+
// Plan 5 X3: the scanner writes into a private temp folder (IKANBAN_SCAN_DIR); afterwards every
|
|
106
|
+
// file is sealed into ~/.ikanban/scans and the folder is removed. The exit code is the scanner's.
|
|
107
|
+
const optionValue = (name) => {
|
|
108
|
+
const i = args.indexOf(name);
|
|
109
|
+
if (i >= 0) return args[i + 1];
|
|
110
|
+
const eq = args.find((a) => a.startsWith(`${name}=`));
|
|
111
|
+
return eq ? eq.slice(name.length + 1) : undefined;
|
|
112
|
+
};
|
|
113
|
+
const repoPath = path.resolve(optionValue("--path") || ".");
|
|
114
|
+
const workDir = optionValue("--work-dir"); // an explicit --work-dir run folder is sealed too
|
|
115
|
+
const started = Date.now() - 2000;
|
|
116
|
+
const extraDirs = () => {
|
|
117
|
+
if (!workDir) return [];
|
|
118
|
+
const fs = require("node:fs");
|
|
119
|
+
const dir = path.resolve(workDir);
|
|
120
|
+
if (!fs.existsSync(dir)) return [];
|
|
121
|
+
const runDir = new RegExp(`^${cmd}-\\d{8}-\\d{6}$`);
|
|
122
|
+
return fs.readdirSync(dir).filter((n) => runDir.test(n)).map((n) => path.join(dir, n))
|
|
123
|
+
.filter((p) => fs.statSync(p).isDirectory() && fs.statSync(p).mtimeMs >= started);
|
|
124
|
+
};
|
|
125
|
+
const store = require("../lib/scanstore.js").createStore();
|
|
126
|
+
process.exit(store.runSealed({
|
|
127
|
+
scanner: cmd,
|
|
128
|
+
repoPath,
|
|
129
|
+
extraDirs,
|
|
130
|
+
run: (scanEnv) => runCommand(py, [...pre, script, ...args], { stdio: "inherit", env: { ...env, ...scanEnv } }).status,
|
|
131
|
+
}));
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// iKanban: after a deploy, check what the site serves before anyone reads it (plan 35 V1-T4).
|
|
3
|
+
// An agent hook runs this after each shell command (Claude Code PostToolUse, Gemini CLI
|
|
4
|
+
// AfterTool, Copilot CLI postToolUse; `npx ikanban hooks install` wires them). When the command
|
|
5
|
+
// contains one of the deploy commands in .iKanban/settings-local.json "deploy_check":
|
|
6
|
+
// 1. Bundle guard: it reads the site's build marker (build_url) and says PASS when the marker
|
|
7
|
+
// names the commit just deployed, else STALE: no reading of the site until it does.
|
|
8
|
+
// 2. Intent check: it asks the agent to open site_url and show that the change does what the
|
|
9
|
+
// task asked, before it reports the deploy as done.
|
|
10
|
+
// It runs no git command (it reads .git directly) and never blocks the agent: it always exits 0;
|
|
11
|
+
// problems go to stderr. V13-T10: with the kit's api_url, api_key and team in settings-local.json,
|
|
12
|
+
// it reports each result (pass, stale, intent) to iKanban for Observification; the key goes only
|
|
13
|
+
// to iKanban's API (ikanbanFetch). The task is IKANBAN_TASK or the key in the branch name.
|
|
14
|
+
// V16-T14: with those settings, iKanban decides (Agent settings → Pipelines → Deploy check): Off there
|
|
15
|
+
// is off here, and its commands and addresses replace the local "deploy_check" block. The local block
|
|
16
|
+
// is used only without the kit's API settings, or when iKanban does not answer and no copy is saved.
|
|
17
|
+
const fs = require("node:fs");
|
|
18
|
+
const os = require("node:os");
|
|
19
|
+
const path = require("node:path");
|
|
20
|
+
const { ikanbanFetch, teamId } = require("./ikanban-safe.js");
|
|
21
|
+
|
|
22
|
+
const TIMEOUT_MS = 8000;
|
|
23
|
+
// The app's settings are read once in 5 minutes: the hook runs after every shell command.
|
|
24
|
+
const SETTINGS_TTL_MS = 5 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
function repoRoot(cwd) {
|
|
27
|
+
for (let dir = fs.realpathSync(cwd); ; dir = path.dirname(dir)) {
|
|
28
|
+
if (fs.existsSync(path.join(dir, ".git"))) return dir;
|
|
29
|
+
if (path.dirname(dir) === dir) return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function gitDir(root) {
|
|
34
|
+
const dot = path.join(root, ".git");
|
|
35
|
+
if (fs.statSync(dot).isDirectory()) return dot;
|
|
36
|
+
const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dot, "utf8"));
|
|
37
|
+
return m ? path.resolve(root, m[1].trim()) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The commit HEAD names, read from .git (a worktree's commondir holds the shared refs). */
|
|
41
|
+
function headSha(root) {
|
|
42
|
+
const dir = gitDir(root);
|
|
43
|
+
if (!dir) return null;
|
|
44
|
+
const head = fs.readFileSync(path.join(dir, "HEAD"), "utf8").trim();
|
|
45
|
+
if (/^[0-9a-f]{40}$/.test(head)) return head;
|
|
46
|
+
const ref = (/^ref:\s*(.+)$/.exec(head) || [])[1];
|
|
47
|
+
if (!ref) return null;
|
|
48
|
+
const common = fs.existsSync(path.join(dir, "commondir"))
|
|
49
|
+
? path.resolve(dir, fs.readFileSync(path.join(dir, "commondir"), "utf8").trim())
|
|
50
|
+
: dir;
|
|
51
|
+
for (const d of [dir, common]) {
|
|
52
|
+
const f = path.join(d, ref);
|
|
53
|
+
if (fs.existsSync(f)) return fs.readFileSync(f, "utf8").trim();
|
|
54
|
+
}
|
|
55
|
+
const packed = path.join(common, "packed-refs");
|
|
56
|
+
if (!fs.existsSync(packed)) return null;
|
|
57
|
+
const line = fs.readFileSync(packed, "utf8").split("\n").find((l) => l.endsWith(` ${ref}`));
|
|
58
|
+
return line ? line.split(" ")[0] : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function kitSettings(root) {
|
|
62
|
+
for (const kit of [".iKanban", ".i-kanban"]) {
|
|
63
|
+
const f = path.join(root, kit, "settings-local.json");
|
|
64
|
+
if (!fs.existsSync(f)) continue;
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(fs.readFileSync(f, "utf8")) || {};
|
|
67
|
+
} catch {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function taskKey(root) {
|
|
75
|
+
const own = (process.env.IKANBAN_TASK || "").trim();
|
|
76
|
+
if (/^[A-Za-z][A-Za-z0-9]{0,9}-\d+$/.test(own)) return own.toUpperCase();
|
|
77
|
+
let head = "";
|
|
78
|
+
try { head = fs.readFileSync(path.join(gitDir(root), "HEAD"), "utf8"); } catch { return undefined; }
|
|
79
|
+
const branch = (/^ref:\s*refs\/heads\/(.+)$/m.exec(head) || [])[1] || "";
|
|
80
|
+
const m = /(?:^|[/_-])([A-Za-z][A-Za-z0-9]{0,9}-\d+)(?=$|[/_-])/.exec(branch.trim());
|
|
81
|
+
return m ? m[1].toUpperCase() : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** V13-T10: one result to iKanban; best effort, never in the agent's way. */
|
|
85
|
+
async function report(kit, body) {
|
|
86
|
+
if (!kit.api_url || !kit.api_key || !kit.team) return;
|
|
87
|
+
try {
|
|
88
|
+
const team = await teamId({ base: kit.api_url, token: kit.api_key, team: kit.team });
|
|
89
|
+
const res = await ikanbanFetch(`/api/teams/${encodeURIComponent(team)}/deploy-checks`, {
|
|
90
|
+
base: kit.api_url, token: kit.api_key, method: "POST", body, timeoutMs: 4000,
|
|
91
|
+
});
|
|
92
|
+
if (!res.ok) throw new Error(`iKanban answered ${res.status}`);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
process.stderr.write(`iKanban deploy check: not reported (${e.message})\n`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** V16-T14: the team's deploy check from iKanban; null when it does not answer. */
|
|
99
|
+
async function appSettings(kit) {
|
|
100
|
+
const file = path.join(os.homedir(), ".ikanban", "deploy-check", `${String(kit.team).replace(/[^A-Za-z0-9-]/g, "_")}.json`);
|
|
101
|
+
let saved = null;
|
|
102
|
+
try {
|
|
103
|
+
saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
104
|
+
} catch {
|
|
105
|
+
saved = null;
|
|
106
|
+
}
|
|
107
|
+
const mine = saved && saved.api_url === kit.api_url && saved.settings && typeof saved.settings.enabled === "boolean";
|
|
108
|
+
if (mine && Date.now() - saved.at < SETTINGS_TTL_MS) return saved.settings;
|
|
109
|
+
try {
|
|
110
|
+
const team = await teamId({ base: kit.api_url, token: kit.api_key, team: kit.team });
|
|
111
|
+
const res = await ikanbanFetch(`/api/teams/${encodeURIComponent(team)}/deploy-check-settings`, { base: kit.api_url, token: kit.api_key, timeoutMs: 3000 });
|
|
112
|
+
if (!res.ok) throw new Error(`iKanban answered ${res.status}`);
|
|
113
|
+
const d = ((await res.json()) || {}).data || {};
|
|
114
|
+
if (typeof d.enabled !== "boolean") throw new Error("no deploy check settings in the answer");
|
|
115
|
+
const settings = { enabled: d.enabled, commands: Array.isArray(d.commands) ? d.commands : [], build_url: d.build_url || undefined, site_url: d.site_url || undefined };
|
|
116
|
+
try {
|
|
117
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
118
|
+
fs.writeFileSync(file, JSON.stringify({ at: Date.now(), api_url: kit.api_url, settings }));
|
|
119
|
+
} catch {
|
|
120
|
+
// the saved copy only spares a request
|
|
121
|
+
}
|
|
122
|
+
return settings;
|
|
123
|
+
} catch (e) {
|
|
124
|
+
process.stderr.write(`iKanban deploy check: settings not read from iKanban (${e.message})\n`);
|
|
125
|
+
return mine ? saved.settings : null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The deploy check in force: iKanban's when the kit can ask it, else the local block. */
|
|
130
|
+
async function settingsFor(kit) {
|
|
131
|
+
if (kit.api_url && kit.api_key && kit.team) {
|
|
132
|
+
const app = await appSettings(kit);
|
|
133
|
+
if (app) return app.enabled ? app : null;
|
|
134
|
+
}
|
|
135
|
+
return kit.deploy_check || null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The shell command from any of the three agents' hook input. */
|
|
139
|
+
function commandOf(event) {
|
|
140
|
+
let args = event.tool_input || event.toolArgs || event.tool_args || {};
|
|
141
|
+
if (typeof args === "string") {
|
|
142
|
+
try { args = JSON.parse(args); } catch { return ""; }
|
|
143
|
+
}
|
|
144
|
+
return args && typeof args.command === "string" ? args.command : "";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** C1: build_url comes from a settings file, so only https, or http to this laptop. */
|
|
148
|
+
function allowedUrl(raw) {
|
|
149
|
+
try {
|
|
150
|
+
const u = new URL(raw);
|
|
151
|
+
return u.protocol === "https:" || (u.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(u.hostname));
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Security review: the site URL reaches the agent as text, so only an origin and a short plain path:
|
|
158
|
+
* no sign-in, query or fragment (the same rule as check_deploy_settings on the server). */
|
|
159
|
+
function cleanUrl(raw) {
|
|
160
|
+
if (typeof raw !== "string" || raw.length > 200 || /[\s\u0000-\u001f\u007f]/.test(raw) || !allowedUrl(raw)) return null;
|
|
161
|
+
const u = new URL(raw);
|
|
162
|
+
if (u.username || u.password || u.search || u.hash || u.hostname.length > 100 || !/^[A-Za-z0-9/._~-]{1,100}$/.test(u.pathname)) return null;
|
|
163
|
+
return u.origin + u.pathname;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function bundleGuard(cfg, sha) {
|
|
167
|
+
const short = sha ? sha.slice(0, 7) : "";
|
|
168
|
+
if (!cfg.build_url) return "Bundle guard: no build_url in deploy_check, so the served build is unknown; read the site's build marker yourself before any check.";
|
|
169
|
+
if (!allowedUrl(cfg.build_url)) return `Bundle guard: not run: build_url must be https (or http on localhost): ${cfg.build_url}`;
|
|
170
|
+
if (!short) return "Bundle guard: could not read this repository's HEAD commit.";
|
|
171
|
+
let body;
|
|
172
|
+
try {
|
|
173
|
+
const res = await fetch(cfg.build_url, { redirect: "error", signal: AbortSignal.timeout(TIMEOUT_MS), headers: { "cache-control": "no-cache" } });
|
|
174
|
+
body = (await res.text()).slice(0, 4000);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
return `Bundle guard: could not read ${cfg.build_url} (${e.message}). Read it again before any check of the site.`;
|
|
177
|
+
}
|
|
178
|
+
if (body.includes(short)) return `Bundle guard: PASS: ${cfg.build_url} serves commit ${sha.slice(0, 8)}, the one just deployed.`;
|
|
179
|
+
return `Bundle guard: STALE: ${cfg.build_url} does not name commit ${sha.slice(0, 8)} yet (it says: ${body.replace(/\s+/g, " ").slice(0, 120)}). Do not read or check the site until it does: wait for the deploy, then read the marker again.`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function main(input) {
|
|
183
|
+
let event = {};
|
|
184
|
+
try { event = JSON.parse(input || "{}"); } catch { return {}; }
|
|
185
|
+
if (!event || typeof event !== "object") return {};
|
|
186
|
+
const cwd = typeof event.cwd === "string" && fs.existsSync(event.cwd) ? event.cwd : process.cwd();
|
|
187
|
+
const root = repoRoot(cwd);
|
|
188
|
+
const kit = root ? kitSettings(root) : {};
|
|
189
|
+
const command = commandOf(event);
|
|
190
|
+
if (!command) return {};
|
|
191
|
+
const cfg = await settingsFor(kit);
|
|
192
|
+
if (!cfg || !Array.isArray(cfg.commands)) return {};
|
|
193
|
+
if (!cfg.commands.some((c) => typeof c === "string" && c.trim() && command.includes(c.trim()))) return {};
|
|
194
|
+
// Gemini CLI also calls AfterTool for a failed command; a failed deploy has nothing to check.
|
|
195
|
+
if (event.tool_response && event.tool_response.error) return {};
|
|
196
|
+
const sha = headSha(root);
|
|
197
|
+
const guard = await bundleGuard(cfg, sha);
|
|
198
|
+
const result = guard.startsWith("Bundle guard: PASS") ? "pass" : guard.startsWith("Bundle guard: STALE") ? "stale" : null;
|
|
199
|
+
const siteUrl = cleanUrl(cfg.site_url);
|
|
200
|
+
const common = { site: siteUrl || undefined, commit: sha || undefined, task: taskKey(root) };
|
|
201
|
+
if (result) await report(kit, { result, ...common });
|
|
202
|
+
await report(kit, { result: "intent", ...common });
|
|
203
|
+
const site = siteUrl ? ` Open "${siteUrl}"` : " Open the deployed site";
|
|
204
|
+
const text = `${guard}\nIntent check:${site} in the browser once the bundle guard passes, and show that the change does what the task asked (the screen, the number, the flow), on the page a user would open. Report what you saw; "deployed" alone is not a check.`;
|
|
205
|
+
// Copilot CLI reads a top-level additionalContext; Claude Code and Gemini CLI read hookSpecificOutput.
|
|
206
|
+
if (event.toolName !== undefined || event.toolArgs !== undefined) return { additionalContext: text };
|
|
207
|
+
return { hookSpecificOutput: { hookEventName: event.hook_event_name || "PostToolUse", additionalContext: text } };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let input = "";
|
|
211
|
+
process.stdin.on("data", (c) => { input += c; });
|
|
212
|
+
process.stdin.on("end", async () => {
|
|
213
|
+
let out = {};
|
|
214
|
+
try {
|
|
215
|
+
out = await main(input);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
process.stderr.write(`iKanban deploy check: ${e.message}\n`);
|
|
218
|
+
}
|
|
219
|
+
process.stdout.write(`${JSON.stringify(out)}\n`);
|
|
220
|
+
process.exit(0);
|
|
221
|
+
});
|
package/hooks/ikanban-safe.js
CHANGED
|
@@ -64,16 +64,20 @@ function apiOrigin(base) {
|
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
66
|
* fetch() to iKanban's API. opts: base (the signed-in api_url), token, method, body (sent as
|
|
67
|
-
* JSON), timeoutMs. Refuses another host, an absolute or protocol-relative path, and redirects.
|
|
67
|
+
* JSON), timeoutMs, headers (only X-Trace-Token is passed on). Refuses another host, an absolute or protocol-relative path, and redirects.
|
|
68
68
|
*/
|
|
69
|
-
async function ikanbanFetch(apiPath, { base, token, method = "GET", body, timeoutMs = 60000 } = {}) {
|
|
69
|
+
async function ikanbanFetch(apiPath, { base, token, method = "GET", body, timeoutMs = 60000, headers: extra = {} } = {}) {
|
|
70
70
|
const origin = apiOrigin(base);
|
|
71
71
|
if (typeof apiPath !== "string" || !apiPath.startsWith("/") || apiPath.startsWith("//")) {
|
|
72
72
|
throw new Error(`"${apiPath}" is not a path on the iKanban API; the key was not sent`);
|
|
73
73
|
}
|
|
74
74
|
const url = new URL(apiPath, origin);
|
|
75
75
|
if (url.origin !== origin) throw new Error(`"${apiPath}" leaves ${origin}; the key was not sent`);
|
|
76
|
+
// Plan 35: X-Trace-Token (a trace's write token) is the one extra header; nothing may replace the key's.
|
|
76
77
|
const headers = { Authorization: `Bearer ${token}` };
|
|
78
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
79
|
+
if (k.toLowerCase() === "x-trace-token" && typeof v === "string" && /^[A-Za-z0-9]{16,128}$/.test(v)) headers["X-Trace-Token"] = v;
|
|
80
|
+
}
|
|
77
81
|
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
78
82
|
let res;
|
|
79
83
|
try {
|
|
@@ -93,4 +97,23 @@ async function ikanbanFetch(apiPath, { base, token, method = "GET", body, timeou
|
|
|
93
97
|
return res;
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
|
|
100
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
101
|
+
|
|
102
|
+
/** The team's id for API paths. Set up writes the team's key ("IKA") into settings-local.json,
|
|
103
|
+
* and the API's routes take the id, so a key is looked up among the caller's teams. */
|
|
104
|
+
async function teamId({ base, token, team }) {
|
|
105
|
+
if (UUID.test(String(team || ""))) return team;
|
|
106
|
+
const res = await ikanbanFetch("/api/teams", { base, token, timeoutMs: 5000 });
|
|
107
|
+
if (!res.ok) throw new Error(`iKanban answered ${res.status} for the team list`);
|
|
108
|
+
const json = await res.json();
|
|
109
|
+
const list = Array.isArray(json.data) ? json.data : (json.data && json.data.teams) || [];
|
|
110
|
+
// Security review: the key first; a name only when no key matches; never a guess between two.
|
|
111
|
+
const want = String(team || "").toLowerCase();
|
|
112
|
+
const byKey = list.filter((t) => String(t.identifier || "").toLowerCase() === want);
|
|
113
|
+
const hits = byKey.length ? byKey : list.filter((t) => String(t.name || "").toLowerCase() === want);
|
|
114
|
+
if (hits.length > 1) throw new Error(`more than one team is called "${team}"; put the team's id in settings-local.json`);
|
|
115
|
+
if (!hits.length || !UUID.test(String(hits[0].id))) throw new Error(`no team "${team}" for this key`);
|
|
116
|
+
return hits[0].id;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { runCommand, startCommand, ikanbanFetch, apiOrigin, teamId };
|