ikanban 0.4.9 → 0.5.1
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 +59 -10
- 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/roadmap-sync.js +5 -2
- package/hooks/trace.js +384 -0
- package/lib/ikanban-safe.js +26 -3
- package/lib/plancheck.js +231 -0
- package/lib/roadmap.js +135 -28
- 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
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ikanban-controlscan — checks the security controls of YOUR OWN repository (plan 5 X4).
|
|
4
|
+
|
|
5
|
+
python3 ikanban-controlscan.py --path . # check, print the result
|
|
6
|
+
python3 ikanban-controlscan.py --publish --team TEAM_ID # also file it into iKanban
|
|
7
|
+
|
|
8
|
+
The 8 repository checks, each pass / warn / fail / skipped with the reason:
|
|
9
|
+
|
|
10
|
+
Supply chain & dependency scanning
|
|
11
|
+
ci-dependency-audit a CI workflow runs a dependency audit, or Dependabot or Renovate is configured
|
|
12
|
+
lockfiles-committed every package manifest has its lockfile in git
|
|
13
|
+
Code & secrets scanning
|
|
14
|
+
ci-secret-scanning a CI workflow runs gitleaks, trufflehog or detect-secrets (or GitHub secret
|
|
15
|
+
scanning with push protection is on, read through your own gh sign-in)
|
|
16
|
+
ci-sast a CI workflow runs CodeQL, Semgrep or another SAST tool
|
|
17
|
+
env-files-ignored no .env or private key file is tracked in git; .gitignore covers .env
|
|
18
|
+
Repository & access
|
|
19
|
+
branch-protection the default branch blocks force-push and deletion and needs a pull request
|
|
20
|
+
(GitHub API through your own `gh`; skipped without gh or its sign-in)
|
|
21
|
+
Security process
|
|
22
|
+
codeowners a CODEOWNERS file names owners
|
|
23
|
+
security-policy SECURITY.md says how to report a vulnerability
|
|
24
|
+
|
|
25
|
+
No network, except the GitHub API through the user's own `gh` (branch protection, secret scanning).
|
|
26
|
+
--publish sends one POST /api/teams/{team}/security/scan-findings per control (kind ctl-<key>):
|
|
27
|
+
a failed check files one Medium finding, a warning one Low finding, a pass none; a skipped check
|
|
28
|
+
is sent as an incomplete run, so nothing closes. Standard library only.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import hashlib
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import shutil
|
|
39
|
+
import subprocess
|
|
40
|
+
import sys
|
|
41
|
+
import urllib.error
|
|
42
|
+
import urllib.parse
|
|
43
|
+
import urllib.request
|
|
44
|
+
from datetime import datetime, timezone
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
CONTROLSCAN_VERSION = "1.0.0"
|
|
48
|
+
SEVERITIES = ["critical", "high", "medium", "low", "info"]
|
|
49
|
+
SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
|
|
50
|
+
SUPPLY, CODE, ACCESS, PROCESS = ("Supply chain & dependency scanning", "Code & secrets scanning",
|
|
51
|
+
"Repository & access", "Security process")
|
|
52
|
+
|
|
53
|
+
# The control list of plan 5 X4 (the server computes findings-lifecycle and ai-red-team-fresh).
|
|
54
|
+
CONTROLS = [
|
|
55
|
+
{"key": "ci-dependency-audit", "group": SUPPLY, "title": "CI audits dependencies",
|
|
56
|
+
"fix": "Add a dependency audit step to CI (npm audit, cargo audit, pip-audit, osv-scanner, ikanban secscan) "
|
|
57
|
+
"or configure Dependabot (.github/dependabot.yml) or Renovate."},
|
|
58
|
+
{"key": "lockfiles-committed", "group": SUPPLY, "title": "Lockfiles are committed",
|
|
59
|
+
"fix": "Commit the lockfile next to each manifest (package-lock.json, pnpm-lock.yaml, yarn.lock, Cargo.lock, "
|
|
60
|
+
"poetry.lock or uv.lock, Gemfile.lock, go.sum, composer.lock)."},
|
|
61
|
+
{"key": "ci-secret-scanning", "group": CODE, "title": "CI scans for secrets",
|
|
62
|
+
"fix": "Run gitleaks, trufflehog or detect-secrets in CI, or turn on GitHub secret scanning with push protection."},
|
|
63
|
+
{"key": "ci-sast", "group": CODE, "title": "CI runs static analysis (SAST)",
|
|
64
|
+
"fix": "Run CodeQL (github/codeql-action), Semgrep or another SAST tool in CI."},
|
|
65
|
+
{"key": "env-files-ignored", "group": CODE, "title": ".env and key files stay out of git",
|
|
66
|
+
"fix": "Remove the file from git (git rm --cached FILE), rotate what it held, and add .env and key "
|
|
67
|
+
"files to .gitignore."},
|
|
68
|
+
{"key": "branch-protection", "group": ACCESS, "title": "Default branch is protected",
|
|
69
|
+
"fix": "Protect the default branch: block force-push and deletion and require a pull request "
|
|
70
|
+
"(GitHub: Settings, Rules or Branches)."},
|
|
71
|
+
{"key": "codeowners", "group": ACCESS, "title": "CODEOWNERS names owners",
|
|
72
|
+
"fix": "Add a CODEOWNERS file (root, .github/ or docs/) with at least one line like `* @org/team`."},
|
|
73
|
+
{"key": "security-policy", "group": PROCESS, "title": "SECURITY.md says how to report a vulnerability",
|
|
74
|
+
"fix": "Add SECURITY.md with a contact: an email address or a private reporting link."},
|
|
75
|
+
]
|
|
76
|
+
CONTROL = {c["key"]: c for c in CONTROLS}
|
|
77
|
+
|
|
78
|
+
CI_FILES = (".gitlab-ci.yml", "azure-pipelines.yml", "bitbucket-pipelines.yml", ".circleci/config.yml",
|
|
79
|
+
"Jenkinsfile", ".travis.yml", ".drone.yml", "buildspec.yml", "cloudbuild.yaml")
|
|
80
|
+
AUDIT_TOOLS = re.compile(r"(?i)\b(?:npm|pnpm|yarn|bun)\s+(?:npm\s+)?audit\b|\bcargo[- ](?:audit|deny)\b|\bpip-audit\b|"
|
|
81
|
+
r"\bsafety\s+(?:check|scan)\b|\bosv-scanner\b|\btrivy\s+(?:fs|repo)\b|\bsnyk\s+test\b|"
|
|
82
|
+
r"dependency-check|dependency-review-action|\bgovulncheck\b|\bbundle(?:r|-)?\s*audit\b|"
|
|
83
|
+
r"\bikanban\s+secscan\b|\bgrype\b")
|
|
84
|
+
SECRET_TOOLS = re.compile(r"(?i)\bgitleaks\b|\btrufflehog\b|\bdetect-secrets\b|\bggshield\b|\bsecretlint\b|"
|
|
85
|
+
r"\bikanban\s+secscan\b")
|
|
86
|
+
SAST_TOOLS = re.compile(r"(?i)codeql|\bsemgrep\b|\bsonar(?:cloud|qube|-scanner)?\b|\bsnyk\s+code\b|\bbandit\b|"
|
|
87
|
+
r"\bgosec\b|\bbrakeman\b|\bnjsscan\b|\bhorusec\b|checkmarx|\bbearer\b|\bopengrep\b")
|
|
88
|
+
LOCKS = {
|
|
89
|
+
"package.json": ("package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "bun.lock"),
|
|
90
|
+
"Cargo.toml": ("Cargo.lock",),
|
|
91
|
+
"pyproject.toml": ("poetry.lock", "uv.lock", "pdm.lock", "Pipfile.lock", "requirements.txt"),
|
|
92
|
+
"Pipfile": ("Pipfile.lock",),
|
|
93
|
+
"Gemfile": ("Gemfile.lock",),
|
|
94
|
+
"go.mod": ("go.sum",),
|
|
95
|
+
"composer.json": ("composer.lock",),
|
|
96
|
+
}
|
|
97
|
+
SECRET_FILE = re.compile(r"(?i)(?:^|/)(?:\.env(?:\.[^/]*)?|id_rsa|id_dsa|id_ecdsa|id_ed25519|[^/]*\.(?:pem|key|p12|pfx|jks))$")
|
|
98
|
+
SECRET_FILE_OK = re.compile(r"(?i)\.(?:example|sample|template|dist|defaults?)$|\.pub$")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def result(key: str, status: str, reason: str) -> dict:
|
|
102
|
+
c = CONTROL[key]
|
|
103
|
+
return {"key": key, "group": c["group"], "title": c["title"], "status": status, "reason": reason}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def git(root: Path, *args, timeout=20):
|
|
107
|
+
"""(exit code, stdout); never raises."""
|
|
108
|
+
try:
|
|
109
|
+
p = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, timeout=timeout)
|
|
110
|
+
return p.returncode, p.stdout
|
|
111
|
+
except (OSError, subprocess.SubprocessError):
|
|
112
|
+
return 127, ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Repo:
|
|
116
|
+
"""What every check reads, gathered once: tracked files and the CI configuration text."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, root: Path):
|
|
119
|
+
self.root = root
|
|
120
|
+
code, top = git(root, "rev-parse", "--show-toplevel")
|
|
121
|
+
self.is_git = code == 0 and bool(top.strip())
|
|
122
|
+
code, out = git(root, "ls-files", "-z") if self.is_git else (1, "")
|
|
123
|
+
self.tracked = sorted(f for f in out.split("\0") if f) if code == 0 else []
|
|
124
|
+
self.ci = {}
|
|
125
|
+
wf = root / ".github" / "workflows"
|
|
126
|
+
names = sorted(p for p in wf.glob("*.y*ml")) if wf.is_dir() else []
|
|
127
|
+
names += [root / n for n in CI_FILES if (root / n).is_file()]
|
|
128
|
+
for p in names:
|
|
129
|
+
try:
|
|
130
|
+
self.ci[p.relative_to(root).as_posix()] = p.read_text(encoding="utf-8", errors="replace")[:1_000_000]
|
|
131
|
+
except OSError:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
def ci_match(self, rx):
|
|
135
|
+
return sorted(name for name, text in self.ci.items() if rx.search(text))
|
|
136
|
+
|
|
137
|
+
def first_file(self, *rels):
|
|
138
|
+
for rel in rels:
|
|
139
|
+
p = self.root / rel
|
|
140
|
+
if p.is_file():
|
|
141
|
+
return p
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ── the GitHub API, only through the user's own gh ──────────────────────────────
|
|
146
|
+
def gh_ready() -> bool:
|
|
147
|
+
"""gh is installed and signed in."""
|
|
148
|
+
if not shutil.which("gh"):
|
|
149
|
+
return False
|
|
150
|
+
try:
|
|
151
|
+
return subprocess.run(["gh", "auth", "status"], capture_output=True, text=True, timeout=20).returncode == 0
|
|
152
|
+
except (OSError, subprocess.SubprocessError):
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def gh_json(path: str):
|
|
157
|
+
"""(ok, data | error text) for `gh api <path>`; the path is built from checked parts only."""
|
|
158
|
+
try:
|
|
159
|
+
p = subprocess.run(["gh", "api", "-H", "Accept: application/vnd.github+json", path],
|
|
160
|
+
capture_output=True, text=True, timeout=30)
|
|
161
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
162
|
+
return False, str(e)
|
|
163
|
+
if p.returncode != 0:
|
|
164
|
+
return False, (p.stderr or p.stdout).strip()[:200]
|
|
165
|
+
try:
|
|
166
|
+
return True, json.loads(p.stdout or "null")
|
|
167
|
+
except ValueError:
|
|
168
|
+
return False, "gh api did not answer JSON"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
GITHUB_SLUG = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def github_slug(root: Path) -> str | None:
|
|
175
|
+
_, url = git(root, "remote", "get-url", "origin")
|
|
176
|
+
url = url.strip()
|
|
177
|
+
if not re.match(r"^(?:https://(?:[^@/]+@)?github\.com/|git@github\.com:|ssh://git@github\.com/)", url):
|
|
178
|
+
return None
|
|
179
|
+
slug = normalize_remote(url)
|
|
180
|
+
return slug if GITHUB_SLUG.match(slug) and ".." not in slug else None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ── the checks ──────────────────────────────────────────────────────────────────
|
|
184
|
+
def check_dependency_audit(r: Repo) -> dict:
|
|
185
|
+
hits = r.ci_match(AUDIT_TOOLS)
|
|
186
|
+
if hits:
|
|
187
|
+
return result("ci-dependency-audit", "pass", f"dependency audit in {', '.join(hits)}")
|
|
188
|
+
bot = r.first_file(".github/dependabot.yml", ".github/dependabot.yaml", "renovate.json", "renovate.json5",
|
|
189
|
+
".github/renovate.json", ".github/renovate.json5", ".renovaterc", ".renovaterc.json",
|
|
190
|
+
".gitlab/renovate.json")
|
|
191
|
+
if bot:
|
|
192
|
+
return result("ci-dependency-audit", "pass", f"{bot.relative_to(r.root).as_posix()} is configured")
|
|
193
|
+
where = f"{len(r.ci)} CI file(s) read" if r.ci else "no CI workflow found"
|
|
194
|
+
return result("ci-dependency-audit", "fail", f"no dependency audit step in CI ({where}) and no Dependabot "
|
|
195
|
+
"or Renovate config")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def check_lockfiles(r: Repo) -> dict:
|
|
199
|
+
if not r.is_git:
|
|
200
|
+
return result("lockfiles-committed", "skipped", "not a git repository: cannot tell what is committed")
|
|
201
|
+
tracked = set(r.tracked)
|
|
202
|
+
manifests = [f for f in r.tracked if f.rsplit("/", 1)[-1] in LOCKS
|
|
203
|
+
and "node_modules/" not in f and "vendor/" not in f]
|
|
204
|
+
if not manifests:
|
|
205
|
+
return result("lockfiles-committed", "skipped", "no package manifest is tracked in git")
|
|
206
|
+
missing = []
|
|
207
|
+
for m in manifests:
|
|
208
|
+
folder, name = (m.rsplit("/", 1) if "/" in m else ("", m))
|
|
209
|
+
parts = folder.split("/") if folder else []
|
|
210
|
+
dirs = ["/".join(parts[:i]) for i in range(len(parts), -1, -1)] # the folder, then each parent
|
|
211
|
+
if not any(f"{d}/{lock}".lstrip("/") in tracked for d in dirs for lock in LOCKS[name]):
|
|
212
|
+
missing.append(m)
|
|
213
|
+
if missing:
|
|
214
|
+
more = f" and {len(missing) - 10} more" if len(missing) > 10 else ""
|
|
215
|
+
return result("lockfiles-committed", "fail", f"no committed lockfile for {', '.join(missing[:10])}{more}")
|
|
216
|
+
return result("lockfiles-committed", "pass", f"{len(manifests)} manifest(s), each with a committed lockfile")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def github_secret_scanning(slug: str):
|
|
220
|
+
"""(status, reason) from the repository's security_and_analysis, or None when it cannot be read."""
|
|
221
|
+
ok, data = gh_json(f"repos/{slug}")
|
|
222
|
+
sa = (data or {}).get("security_and_analysis") if ok and isinstance(data, dict) else None
|
|
223
|
+
if not sa:
|
|
224
|
+
return None
|
|
225
|
+
on = (sa.get("secret_scanning") or {}).get("status") == "enabled"
|
|
226
|
+
push = (sa.get("secret_scanning_push_protection") or {}).get("status") == "enabled"
|
|
227
|
+
if on and push:
|
|
228
|
+
return "pass", "GitHub secret scanning with push protection is on"
|
|
229
|
+
if on:
|
|
230
|
+
return "warn", "GitHub secret scanning is on, but push protection is off"
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def check_secret_scanning(r: Repo) -> dict:
|
|
235
|
+
hits = r.ci_match(SECRET_TOOLS)
|
|
236
|
+
if hits:
|
|
237
|
+
return result("ci-secret-scanning", "pass", f"secret scanning in {', '.join(hits)}")
|
|
238
|
+
slug = github_slug(r.root) if r.is_git else None
|
|
239
|
+
if slug and gh_ready():
|
|
240
|
+
got = github_secret_scanning(slug)
|
|
241
|
+
if got:
|
|
242
|
+
return result("ci-secret-scanning", *got)
|
|
243
|
+
note = "" if slug and gh_ready() else "; GitHub secret scanning not read (needs a GitHub origin and gh signed in)"
|
|
244
|
+
return result("ci-secret-scanning", "fail", f"no CI step runs gitleaks, trufflehog or detect-secrets{note}")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def check_sast(r: Repo) -> dict:
|
|
248
|
+
hits = r.ci_match(SAST_TOOLS)
|
|
249
|
+
if hits:
|
|
250
|
+
return result("ci-sast", "pass", f"static analysis in {', '.join(hits)}")
|
|
251
|
+
where = f"{len(r.ci)} CI file(s) read" if r.ci else "no CI workflow found"
|
|
252
|
+
return result("ci-sast", "fail", f"no CodeQL, Semgrep or other SAST step in CI ({where})")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def check_env_files(r: Repo) -> dict:
|
|
256
|
+
if not r.is_git:
|
|
257
|
+
return result("env-files-ignored", "skipped", "not a git repository: cannot tell what is committed")
|
|
258
|
+
bad = [f for f in r.tracked if SECRET_FILE.search(f) and not SECRET_FILE_OK.search(f)]
|
|
259
|
+
if bad:
|
|
260
|
+
more = f" and {len(bad) - 10} more" if len(bad) > 10 else ""
|
|
261
|
+
return result("env-files-ignored", "fail", f"tracked in git: {', '.join(bad[:10])}{more}")
|
|
262
|
+
code, _ = git(r.root, "check-ignore", "-q", "--no-index", ".env")
|
|
263
|
+
if code != 0:
|
|
264
|
+
return result("env-files-ignored", "warn", "no .env or key file is tracked, but .gitignore does not cover .env")
|
|
265
|
+
return result("env-files-ignored", "pass", "no .env or key file is tracked; .gitignore covers .env")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def check_branch_protection(r: Repo) -> dict:
|
|
269
|
+
if not r.is_git:
|
|
270
|
+
return result("branch-protection", "skipped", "not a git repository")
|
|
271
|
+
slug = github_slug(r.root)
|
|
272
|
+
if not slug:
|
|
273
|
+
return result("branch-protection", "skipped", "origin is not a GitHub repository; check the host's "
|
|
274
|
+
"branch rules by hand")
|
|
275
|
+
if not gh_ready():
|
|
276
|
+
return result("branch-protection", "skipped", "needs the GitHub CLI (gh) installed and signed in "
|
|
277
|
+
"(gh auth login)")
|
|
278
|
+
ok, repo = gh_json(f"repos/{slug}")
|
|
279
|
+
branch = repo.get("default_branch") if ok and isinstance(repo, dict) else None
|
|
280
|
+
if not branch or not re.match(r"^[A-Za-z0-9._/-]+$", branch) or ".." in branch:
|
|
281
|
+
return result("branch-protection", "skipped", f"GitHub API: could not read {slug} ({repo if not ok else 'no default branch'})")
|
|
282
|
+
b = urllib.parse.quote(branch, safe="")
|
|
283
|
+
ok_rules, rules = gh_json(f"repos/{slug}/rules/branches/{b}")
|
|
284
|
+
types = {x.get("type") for x in rules if isinstance(x, dict)} if ok_rules and isinstance(rules, list) else set()
|
|
285
|
+
ok_prot, prot = gh_json(f"repos/{slug}/branches/{b}/protection")
|
|
286
|
+
prot = prot if ok_prot and isinstance(prot, dict) else {}
|
|
287
|
+
if not ok_rules and not ok_prot and "404" not in f"{rules}{prot}":
|
|
288
|
+
return result("branch-protection", "skipped", f"GitHub API: could not read the rules of {branch}")
|
|
289
|
+
no_force = "non_fast_forward" in types or (prot and not (prot.get("allow_force_pushes") or {}).get("enabled", True))
|
|
290
|
+
no_delete = "deletion" in types or (prot and not (prot.get("allow_deletions") or {}).get("enabled", True))
|
|
291
|
+
needs_pr = "pull_request" in types or bool(prot.get("required_pull_request_reviews"))
|
|
292
|
+
missing = [label for label, ok in (("force-push is not blocked", no_force), ("deletion is not blocked", no_delete),
|
|
293
|
+
("no pull request is required", needs_pr)) if not ok]
|
|
294
|
+
if missing:
|
|
295
|
+
return result("branch-protection", "fail", f"{branch}: {'; '.join(missing)}")
|
|
296
|
+
return result("branch-protection", "pass", f"{branch} blocks force-push and deletion and needs a pull request")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
OWNER = re.compile(r"(?:^|\s)(?:@[\w.-]+(?:/[\w.-]+)?|[\w.+-]+@[\w-]+\.[\w.-]+)")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def check_codeowners(r: Repo) -> dict:
|
|
303
|
+
f = r.first_file(".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS", ".gitlab/CODEOWNERS")
|
|
304
|
+
if not f:
|
|
305
|
+
return result("codeowners", "fail", "no CODEOWNERS file in the root, .github/, docs/ or .gitlab/")
|
|
306
|
+
rel = f.relative_to(r.root).as_posix()
|
|
307
|
+
lines = [ln for ln in f.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
308
|
+
if ln.strip() and not ln.lstrip().startswith("#")]
|
|
309
|
+
if not any(OWNER.search(ln) for ln in lines):
|
|
310
|
+
return result("codeowners", "warn", f"{rel} names no owner (@user, @org/team or an email)")
|
|
311
|
+
return result("codeowners", "pass", f"{rel} names owners on {len(lines)} line(s)")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
CONTACT = re.compile(r"(?i)[\w.+-]+@[\w-]+\.[\w.-]+|https?://\S+|security/advisories|private vulnerability report")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def check_security_policy(r: Repo) -> dict:
|
|
318
|
+
found = None
|
|
319
|
+
for folder in ("", ".github", "docs"):
|
|
320
|
+
d = r.root / folder if folder else r.root
|
|
321
|
+
if d.is_dir():
|
|
322
|
+
found = next((p for p in sorted(d.iterdir()) if p.is_file() and p.name.lower() == "security.md"), None)
|
|
323
|
+
if found:
|
|
324
|
+
break
|
|
325
|
+
if not found:
|
|
326
|
+
return result("security-policy", "fail", "no SECURITY.md in the root, .github/ or docs/")
|
|
327
|
+
rel = found.relative_to(r.root).as_posix()
|
|
328
|
+
if not CONTACT.search(found.read_text(encoding="utf-8", errors="replace")):
|
|
329
|
+
return result("security-policy", "warn", f"{rel} gives no contact (an email or a reporting link)")
|
|
330
|
+
return result("security-policy", "pass", f"{rel} says how to report a vulnerability")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
CHECKS = [check_dependency_audit, check_lockfiles, check_secret_scanning, check_sast, check_env_files,
|
|
334
|
+
check_branch_protection, check_codeowners, check_security_policy]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def run_checks(root: Path) -> list:
|
|
338
|
+
r = Repo(root)
|
|
339
|
+
out = []
|
|
340
|
+
for check in CHECKS:
|
|
341
|
+
try:
|
|
342
|
+
out.append(check(r))
|
|
343
|
+
except Exception as e: # noqa: BLE001 — one broken check never hides the others
|
|
344
|
+
key = CONTROLS[len(out)]["key"]
|
|
345
|
+
out.append(result(key, "skipped", f"the check stopped: {type(e).__name__}"))
|
|
346
|
+
return out
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ── report and publish ──────────────────────────────────────────────────────────
|
|
350
|
+
MARK = {"pass": "✅ pass", "warn": "⚠️ warn", "fail": "❌ fail", "skipped": "⏭ skipped"}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def build_report(name: str, results: list) -> tuple:
|
|
354
|
+
counts = {s: sum(1 for x in results if x["status"] == s) for s in ("pass", "warn", "fail", "skipped")}
|
|
355
|
+
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
356
|
+
L = [f"# Repository controls — {name}", "", f"_{stamp} · `ikanban-controlscan`_", "",
|
|
357
|
+
"| Control | Group | Result | Why |", "|---|---|---|---|"]
|
|
358
|
+
L += [f"| {x['key']} | {x['group']} | {MARK[x['status']]} | {x['reason']} |" for x in results]
|
|
359
|
+
L += ["", f"**{counts['pass']} pass, {counts['warn']} warn, {counts['fail']} fail, {counts['skipped']} skipped.**", ""]
|
|
360
|
+
for x in results:
|
|
361
|
+
if x["status"] in ("fail", "warn"):
|
|
362
|
+
L.append(f"- **{x['key']}**: {CONTROL[x['key']]['fix']}")
|
|
363
|
+
L += ["", "A skipped check is a gap, not a pass: its control stays unchecked in iKanban."]
|
|
364
|
+
return "\n".join(L), counts
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def finding_key(kind: str, scope: str) -> str:
|
|
368
|
+
return "controlscan-" + hashlib.sha256(f"{kind}|{scope}".encode()).hexdigest()[:16]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def publish_bodies(results: list, scope: str) -> list:
|
|
372
|
+
"""[(kind, scan-findings body)] — one per control; every text field through scrub_text."""
|
|
373
|
+
out = []
|
|
374
|
+
for x in results:
|
|
375
|
+
kind = f"ctl-{x['key']}"
|
|
376
|
+
findings = []
|
|
377
|
+
if x["status"] in ("fail", "warn"):
|
|
378
|
+
findings.append(scrub_finding({
|
|
379
|
+
"finding_key": finding_key(kind, scope),
|
|
380
|
+
"owasp_category": x["group"],
|
|
381
|
+
"severity": "Medium" if x["status"] == "fail" else "Low",
|
|
382
|
+
"title": f"{CONTROL[x['key']]['title']}: {x['status']}"[:300],
|
|
383
|
+
"location_file": None,
|
|
384
|
+
"location_lines": None,
|
|
385
|
+
"remediation": f"{x['reason']}. {CONTROL[x['key']]['fix']}"[:2000],
|
|
386
|
+
}))
|
|
387
|
+
out.append((kind, {"findings": findings, "scope": scope, "complete": x["status"] != "skipped", "kind": kind}))
|
|
388
|
+
return out
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def publish(results: list, scope: str, args) -> int:
|
|
392
|
+
for kind, body in publish_bodies(results, scope):
|
|
393
|
+
ok, data = api("POST", f"/teams/{args.team}/security/scan-findings", args.base_url, args.token, body)
|
|
394
|
+
if not ok:
|
|
395
|
+
print(f"filing {kind} FAILED: {data or 'this iKanban server does not take repository controls yet'}",
|
|
396
|
+
file=sys.stderr)
|
|
397
|
+
return 2
|
|
398
|
+
print(f"filed {len(results)} repository controls for {scope}", file=sys.stderr)
|
|
399
|
+
return 0
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ── copied from ikanban-secscan.py: scope, key-host pinning, API, work folder, Pipelines ──────
|
|
403
|
+
def normalize_remote(url: str) -> str:
|
|
404
|
+
"""owner/repo from any remote style (scp, https, ssh, a host alias), lower case;
|
|
405
|
+
never the user or a token in the URL."""
|
|
406
|
+
path = re.sub(r"^[a-z+]+://[^/]*/?", "", url.strip()) # scheme, user, host[:port]
|
|
407
|
+
if path == url.strip():
|
|
408
|
+
path = path.split(":", 1)[-1] # scp style user@host:path
|
|
409
|
+
path = path.strip("/")
|
|
410
|
+
path = path[:-4] if path.endswith(".git") else path
|
|
411
|
+
return "/".join(path.split("/")[-2:]).lower() if path else ""
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def scan_scope(root: Path) -> str:
|
|
415
|
+
"""Plan 11 H3: which repository a scan covers, the same on every laptop and agent
|
|
416
|
+
run (an agent's run folder is named by its run id): origin's owner/repo, else the
|
|
417
|
+
repository's folder name; plus the subfolder when --path is one. A rescan closes
|
|
418
|
+
only findings of the same scope."""
|
|
419
|
+
root = root.resolve()
|
|
420
|
+
|
|
421
|
+
def git(*a):
|
|
422
|
+
try:
|
|
423
|
+
return subprocess.run(["git", "-C", str(root), *a], capture_output=True,
|
|
424
|
+
text=True, timeout=10).stdout.strip()
|
|
425
|
+
except (OSError, subprocess.SubprocessError):
|
|
426
|
+
return ""
|
|
427
|
+
top = git("rev-parse", "--show-toplevel")
|
|
428
|
+
base = Path(top).resolve() if top else root
|
|
429
|
+
base_name = normalize_remote(git("remote", "get-url", "origin")) or base.name.lower()
|
|
430
|
+
sub = root.relative_to(base).as_posix() if base in root.parents else ""
|
|
431
|
+
return f"{base_name}/{sub}" if sub else base_name
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# Coding standard T3: the iKanban key goes only to iKanban's API (or this machine), and never
|
|
435
|
+
# on a redirect. A --base-url, IKANBAN_BASE_URL or config file cannot send it anywhere else.
|
|
436
|
+
IKANBAN_APIS = ("https://api.i-kanban.com", "https://dev-api.i-kanban.com")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def ikanban_api(base_url):
|
|
440
|
+
"""The origin of base_url if it is iKanban's API; otherwise stop, naming it (never the key)."""
|
|
441
|
+
u = urllib.parse.urlsplit(base_url or "")
|
|
442
|
+
origin = f"{u.scheme}://{u.netloc}"
|
|
443
|
+
if u.username is None and u.path in ("", "/") and not u.query and (
|
|
444
|
+
origin in IKANBAN_APIS or (u.scheme == "http" and u.hostname in ("127.0.0.1", "localhost", "::1"))):
|
|
445
|
+
return origin
|
|
446
|
+
raise SystemExit(f"error: {base_url or '(empty)'} is not an iKanban API "
|
|
447
|
+
f"({', '.join(IKANBAN_APIS)} or this machine); the key was not sent")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
451
|
+
def redirect_request(self, *args, **kwargs):
|
|
452
|
+
return None # a 3xx comes back as an HTTPError, and the key goes nowhere else
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def api(method: str, path: str, base_url: str, token: str, payload=None):
|
|
459
|
+
"""(ok, data | error text | None for 404)."""
|
|
460
|
+
req = urllib.request.Request(
|
|
461
|
+
f"{ikanban_api(base_url)}/api{path}",
|
|
462
|
+
data=json.dumps(payload).encode() if payload is not None else None,
|
|
463
|
+
headers={"Authorization": f"Bearer {token}",
|
|
464
|
+
"Content-Type": "application/json"},
|
|
465
|
+
method=method,
|
|
466
|
+
)
|
|
467
|
+
try:
|
|
468
|
+
with _OPENER.open(req, timeout=30) as r:
|
|
469
|
+
body = json.loads(r.read().decode() or "{}")
|
|
470
|
+
return True, body.get("data", body)
|
|
471
|
+
except urllib.error.HTTPError as e:
|
|
472
|
+
detail = e.read().decode()[:300]
|
|
473
|
+
if e.code in (404, 405) and not detail.strip():
|
|
474
|
+
return False, None # an unknown route: GET answers 404, POST 405, both empty
|
|
475
|
+
return False, f"HTTP {e.code}: {detail}"
|
|
476
|
+
except Exception as e: # noqa: BLE001 — surface any transport failure verbatim
|
|
477
|
+
return False, str(e)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
|
|
481
|
+
# <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
|
|
482
|
+
# holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
|
|
483
|
+
def add_work_dir_arg(ap):
|
|
484
|
+
ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
|
|
485
|
+
help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
|
|
486
|
+
"<repository>/.iKanban/scans)")
|
|
487
|
+
ap.add_argument("--print-report", action="store_true",
|
|
488
|
+
help="Print the full report on stdout (under the ikanban CLI stdout otherwise shows the "
|
|
489
|
+
"counts and `ikanban scans show <id>`)")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
|
|
493
|
+
# Plan 5 X3: the ikanban CLI hands over a private temp folder (IKANBAN_SCAN_DIR) and the scan id
|
|
494
|
+
# (IKANBAN_SCAN_ID). That folder is this run's folder; the CLI seals it into ~/.ikanban/scans.
|
|
495
|
+
cli_dir = os.environ.get("IKANBAN_SCAN_DIR", "")
|
|
496
|
+
if override and cli_dir and os.environ.get("IKANBAN_SCAN_ID") and (
|
|
497
|
+
Path(override).expanduser().resolve() == Path(cli_dir).expanduser().resolve()):
|
|
498
|
+
run_dir = Path(cli_dir).expanduser().resolve()
|
|
499
|
+
run_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
500
|
+
os.chmod(run_dir, 0o700)
|
|
501
|
+
return run_dir
|
|
502
|
+
scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
|
|
503
|
+
scans.mkdir(parents=True, exist_ok=True)
|
|
504
|
+
ignore = scans / ".gitignore"
|
|
505
|
+
if not ignore.exists():
|
|
506
|
+
ignore.write_text("*\n")
|
|
507
|
+
run_dir = scans / f"{scanner}-{datetime.now(timezone.utc):%Y%m%d-%H%M%S}"
|
|
508
|
+
run_dir.mkdir(mode=0o700, exist_ok=True)
|
|
509
|
+
os.chmod(run_dir, 0o700) # mkdir's mode is masked by umask; raw tool output stays private
|
|
510
|
+
return run_dir
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def write_private(path: Path, text: str) -> None:
|
|
514
|
+
"""Write a file (0600) without following a symlink planted at its name: write a new
|
|
515
|
+
temp file next to it (O_EXCL | O_NOFOLLOW), then rename it over the name."""
|
|
516
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
517
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
518
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
519
|
+
f.write(text)
|
|
520
|
+
os.replace(tmp, path)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def slug(text: str) -> str:
|
|
524
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# ── scrub_text: identical in every scanner (test_scrub_text.py compares the copies) ──────────
|
|
528
|
+
# Plan 5 X2-T1: every text field of every finding passes through scrub_text before it leaves this
|
|
529
|
+
# machine. A secret keeps its first 4 characters; a home folder becomes ~. The server scrubs again.
|
|
530
|
+
_SCRUB_CUT = (
|
|
531
|
+
re.compile(r"-{5}BEGIN [A-Z ]*PRIVATE KEY-{5}.*?(?:-{5}END [A-Z ]*PRIVATE KEY-{5}|\Z)", re.S),
|
|
532
|
+
re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
|
|
533
|
+
re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b"),
|
|
534
|
+
re.compile(r"\bgithub_pat_[A-Za-z0-9_]{22,}\b"),
|
|
535
|
+
re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}\b"),
|
|
536
|
+
re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}\b"),
|
|
537
|
+
re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]{20,}\b"),
|
|
538
|
+
re.compile(r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{24,}\b"),
|
|
539
|
+
re.compile(r"\bwhsec_[A-Za-z0-9]{16,}\b"),
|
|
540
|
+
re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),
|
|
541
|
+
re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
|
|
542
|
+
re.compile(r"\bvk_[A-Za-z0-9_-]{8,}"),
|
|
543
|
+
)
|
|
544
|
+
_SCRUB_ASSIGNED = re.compile(
|
|
545
|
+
r"(?i)(\b(?:aws_secret_access_key|aws_session_token|client_secret|secret_key|private_key|api[_-]?key|"
|
|
546
|
+
r"access[_-]?token|auth[_-]?token|token|password|passwd|pwd|secret)\b[\"']?\s*[:=]\s*[\"']?)([^\s\"',;&]+)")
|
|
547
|
+
_SCRUB_BEARER = re.compile(r"(?i)(\b(?:bearer|basic)\s+)([A-Za-z0-9._~+/=-]{12,})")
|
|
548
|
+
_SCRUB_URL_CREDS = re.compile(r"(\b[a-z][a-z0-9+.-]*://[^\s/:@]+:)([^\s@/]+)(@)")
|
|
549
|
+
_SCRUB_HOME = re.compile(r"(?<![\w.-])(?:[A-Za-z]:\\[Uu]sers\\|/Users/|/home/)[^\\/\s\"'<>]+")
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def scrub_text(text) -> str:
|
|
553
|
+
"""Secrets, iKanban keys, tokens, AWS and GitHub keys, private key blocks and home folders
|
|
554
|
+
cut from one text. None becomes ""."""
|
|
555
|
+
if text is None:
|
|
556
|
+
return ""
|
|
557
|
+
text = str(text)
|
|
558
|
+
for rx in _SCRUB_CUT:
|
|
559
|
+
text = rx.sub(lambda m: m.group(0)[:4] + "…[hidden]", text)
|
|
560
|
+
text = _SCRUB_ASSIGNED.sub(
|
|
561
|
+
lambda m: m.group(1) + "[hidden]" if len(m.group(2)) >= 8 or any(c.isdigit() for c in m.group(2))
|
|
562
|
+
else m.group(0), text)
|
|
563
|
+
text = _SCRUB_BEARER.sub(lambda m: m.group(1) + "[hidden]", text)
|
|
564
|
+
text = _SCRUB_URL_CREDS.sub(lambda m: m.group(1) + "[hidden]" + m.group(3), text)
|
|
565
|
+
home = str(Path.home())
|
|
566
|
+
if home and home not in ("/", "\\") and home in text:
|
|
567
|
+
text = text.replace(home, "~")
|
|
568
|
+
return _SCRUB_HOME.sub("~", text)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def scrub_finding(value):
|
|
572
|
+
"""scrub_text on every string in a finding, through nested dicts and lists; other values stay."""
|
|
573
|
+
if isinstance(value, str):
|
|
574
|
+
return scrub_text(value)
|
|
575
|
+
if isinstance(value, dict):
|
|
576
|
+
return {k: scrub_finding(v) for k, v in value.items()}
|
|
577
|
+
if isinstance(value, list):
|
|
578
|
+
return [scrub_finding(v) for v in value]
|
|
579
|
+
return value
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
# ── end scrub_text ──
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
# ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
|
|
586
|
+
# Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
|
|
587
|
+
# "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
|
|
588
|
+
# then that line as the log (plan 5: never the report). A plan without Pipelines (402) or any error never fails the scan.
|
|
589
|
+
_UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
|
|
590
|
+
# Every iKanban scanner reports into one named lane, not the server's "legacy" lane.
|
|
591
|
+
PIPE_RUN = "security-scan"
|
|
592
|
+
PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per log line
|
|
593
|
+
LAST_RUN = {"report": "", "detail": "", "skipped": False}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def add_pipeline_args(ap):
|
|
597
|
+
ap.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""),
|
|
598
|
+
help="Also show this run in the task's Pipelines (a key like IKA-123, or its id; "
|
|
599
|
+
"or IKANBAN_TASK)")
|
|
600
|
+
ap.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""),
|
|
601
|
+
help="The task's team: id or identifier like IKA (or IKANBAN_TASK_TEAM; default --team)")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def summary_detail(counts: dict, total: int, skipped: list) -> str:
|
|
605
|
+
if skipped and not total and all(s for s in skipped):
|
|
606
|
+
return ("SKIPPED: " + "; ".join(skipped))[:300]
|
|
607
|
+
worst = ", ".join(f"{counts[s]} {s}" for s in ("critical", "high", "medium", "low") if counts.get(s))
|
|
608
|
+
return f"{total} findings" + (f": {worst}" if worst else "")
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def mask_report(text: str) -> str:
|
|
612
|
+
"""scrub_text before a line leaves for Pipelines, plus a scanner's own rules (scrub_report)."""
|
|
613
|
+
text = scrub_text(text)
|
|
614
|
+
scrub = globals().get("scrub_report")
|
|
615
|
+
return scrub(text)[0] if scrub else text
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def show_report(md: str, summary: str, print_report: bool = False) -> None:
|
|
619
|
+
"""Plan 5 X3: under the ikanban CLI (IKANBAN_SCAN_ID set) stdout gets the counts and where the
|
|
620
|
+
full report is stored; the body only with --print-report. A plain python run prints the body."""
|
|
621
|
+
scan_id = os.environ.get("IKANBAN_SCAN_ID", "")
|
|
622
|
+
if scan_id and not print_report:
|
|
623
|
+
print(summary)
|
|
624
|
+
print(f"Full report: ikanban scans show {scan_id}")
|
|
625
|
+
else:
|
|
626
|
+
print(md)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def scan_files_note(work: Path) -> str:
|
|
630
|
+
"""Where this run's files are: the encrypted store under the CLI, else the run folder."""
|
|
631
|
+
scan_id = os.environ.get("IKANBAN_SCAN_ID", "")
|
|
632
|
+
return f"scan files: sealed into ~/.ikanban/scans as {scan_id}" if scan_id else f"scan files: {work}"
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
# The running Pipeline (set by run_main with --task). Owner: Pipelines showed nothing while a
|
|
637
|
+
# scan ran, so each step also sends one line as it starts and ends; the one-line summary follows at the end.
|
|
638
|
+
# Progress lines are fixed status text: never raw tool output or an error body (it can carry
|
|
639
|
+
# secrets or text an attacker wrote). Those stay on local stderr and in the masked report.
|
|
640
|
+
PIPE = None
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def progress(text: str) -> None:
|
|
644
|
+
if os.environ.get("IKANBAN_SCAN_ID") and "--json" not in sys.argv:
|
|
645
|
+
print(text, flush=True) # Plan 5: under the CLI stdout shows progress, not the report
|
|
646
|
+
if PIPE:
|
|
647
|
+
PIPE.log(text)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
class Pipeline:
|
|
652
|
+
def __init__(self, stage, task, team, base_url, token):
|
|
653
|
+
self.stage, self.task, self.team, self.base, self.token = stage, task, team, base_url, token
|
|
654
|
+
self.on = bool(task and team and token)
|
|
655
|
+
self.tid = None
|
|
656
|
+
|
|
657
|
+
def _off(self, why):
|
|
658
|
+
self.on = False
|
|
659
|
+
note = "the plan does not include Pipelines" if "402" in str(why) else why
|
|
660
|
+
print(f"ikanban: Pipelines not updated ({note}); the scan goes on", file=sys.stderr)
|
|
661
|
+
|
|
662
|
+
def _call(self, method, path, body=None):
|
|
663
|
+
try:
|
|
664
|
+
return api(method, path, self.base, self.token, body)
|
|
665
|
+
except SystemExit as e: # ikanban_api refuses a base URL that is not iKanban's
|
|
666
|
+
return False, str(e)
|
|
667
|
+
|
|
668
|
+
def _post(self, path, body):
|
|
669
|
+
if not self.on:
|
|
670
|
+
return
|
|
671
|
+
if self.tid is None:
|
|
672
|
+
if _UUID.match(self.team):
|
|
673
|
+
self.tid = self.team
|
|
674
|
+
else:
|
|
675
|
+
ok, teams = self._call("GET", "/teams")
|
|
676
|
+
if not ok:
|
|
677
|
+
return self._off(teams)
|
|
678
|
+
found = [t.get("id") for t in teams or [] if (t.get("identifier") or "").lower() == self.team.lower()]
|
|
679
|
+
if not found:
|
|
680
|
+
return self._off(f"team {self.team} not found among your teams")
|
|
681
|
+
self.tid = found[0]
|
|
682
|
+
ok, data = self._call("POST", f"/teams/{self.tid}/{path}", body)
|
|
683
|
+
if not ok:
|
|
684
|
+
self._off(data or "this iKanban server has no Pipelines")
|
|
685
|
+
|
|
686
|
+
def start(self):
|
|
687
|
+
self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
|
|
688
|
+
"run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"})
|
|
689
|
+
|
|
690
|
+
def log(self, line):
|
|
691
|
+
"""One progress line, sent as it happens (masked like the report). A failure only turns Pipelines off."""
|
|
692
|
+
self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage,
|
|
693
|
+
"text": mask_report(line.rstrip("\n"))[:PIPE_CHUNK - 1] + "\n"})
|
|
694
|
+
|
|
695
|
+
def finish(self, code):
|
|
696
|
+
status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
|
|
697
|
+
detail = LAST_RUN["detail"] or ("the scan stopped with an error" if code == 2 else "")
|
|
698
|
+
if code == 1 and not LAST_RUN["skipped"]:
|
|
699
|
+
detail = (detail + " (at or above --fail-on)").strip()
|
|
700
|
+
self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
|
|
701
|
+
"run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
|
|
702
|
+
"detail": detail[:300] or None})
|
|
703
|
+
# Plan 5 X1-T2: the summary only; the report stays on the machine that ran the scan.
|
|
704
|
+
summary = f"{self.stage} {status}" + (f": {detail[:300]}" if detail else "")
|
|
705
|
+
self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage,
|
|
706
|
+
"text": mask_report(summary) + "\n"})
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def run_main(stage, main_fn, argv=None):
|
|
710
|
+
"""main() as it is, wrapped in Pipelines events when --task is given."""
|
|
711
|
+
raw = sys.argv[1:] if argv is None else argv
|
|
712
|
+
if any(a in ("-h", "--help", "--version", "--list-apps") for a in raw):
|
|
713
|
+
return main_fn(argv)
|
|
714
|
+
pre = argparse.ArgumentParser(add_help=False)
|
|
715
|
+
pre.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""))
|
|
716
|
+
pre.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""))
|
|
717
|
+
pre.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""))
|
|
718
|
+
pre.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""))
|
|
719
|
+
pre.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
|
|
720
|
+
known, _ = pre.parse_known_args(raw)
|
|
721
|
+
pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
|
|
722
|
+
pl.start()
|
|
723
|
+
global PIPE
|
|
724
|
+
PIPE = pl if pl.on else None
|
|
725
|
+
try:
|
|
726
|
+
code = main_fn(argv)
|
|
727
|
+
except SystemExit as e:
|
|
728
|
+
if not isinstance(e.code, int):
|
|
729
|
+
print(e.code, file=sys.stderr)
|
|
730
|
+
code = e.code if isinstance(e.code, int) else 2
|
|
731
|
+
pl.finish(code)
|
|
732
|
+
return code
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def main(argv=None):
|
|
736
|
+
ap = argparse.ArgumentParser(description="Repository security controls check that reports into iKanban.")
|
|
737
|
+
ap.add_argument("--version", action="version", version=f"ikanban controlscan {CONTROLSCAN_VERSION}")
|
|
738
|
+
ap.add_argument("--path", default=".", help="Repository to check (default: cwd)")
|
|
739
|
+
ap.add_argument("--publish", action="store_true",
|
|
740
|
+
help="File the control results into iKanban (one per control; the report stays here)")
|
|
741
|
+
ap.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""),
|
|
742
|
+
help="Team UUID to publish into (or IKANBAN_TEAM_ID)")
|
|
743
|
+
ap.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
|
|
744
|
+
ap.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""),
|
|
745
|
+
help="Personal API key (or VIBE_API_TOKEN)")
|
|
746
|
+
ap.add_argument("--out", default=None, help="Also write the report here")
|
|
747
|
+
ap.add_argument("--json", action="store_true", help="Machine-readable result")
|
|
748
|
+
add_pipeline_args(ap)
|
|
749
|
+
add_work_dir_arg(ap)
|
|
750
|
+
args = ap.parse_args(argv)
|
|
751
|
+
|
|
752
|
+
root = Path(args.path).expanduser().resolve()
|
|
753
|
+
if not root.is_dir():
|
|
754
|
+
print(f"error: {root} is not a directory", file=sys.stderr)
|
|
755
|
+
return 2
|
|
756
|
+
if args.publish and (not args.token or not args.team):
|
|
757
|
+
print("error: --publish needs --token (or VIBE_API_TOKEN) and --team (or IKANBAN_TEAM_ID)", file=sys.stderr)
|
|
758
|
+
return 2
|
|
759
|
+
work = scan_work_dir("controlscan", root, args.work_dir)
|
|
760
|
+
progress(f"controlscan {CONTROLSCAN_VERSION} · {root.name}")
|
|
761
|
+
results = run_checks(root)
|
|
762
|
+
for x in results:
|
|
763
|
+
progress(f"{x['key']} … {x['status']}")
|
|
764
|
+
md, counts = build_report(root.name, results)
|
|
765
|
+
detail = f"{counts['pass']} pass, {counts['warn']} warn, {counts['fail']} fail, {counts['skipped']} skipped"
|
|
766
|
+
LAST_RUN.update(report=md, detail=detail, skipped=counts["skipped"] == len(results))
|
|
767
|
+
write_private(work / "report.md", md)
|
|
768
|
+
write_private(work / "controls.json", json.dumps(results, indent=2))
|
|
769
|
+
if args.out:
|
|
770
|
+
Path(args.out).write_text(md, encoding="utf-8")
|
|
771
|
+
if args.json:
|
|
772
|
+
print(json.dumps({"path": str(root), "controls": results, "counts": counts}, indent=2))
|
|
773
|
+
else:
|
|
774
|
+
show_report(md, detail, args.print_report)
|
|
775
|
+
sys.stdout.flush()
|
|
776
|
+
print(scan_files_note(work), file=sys.stderr)
|
|
777
|
+
if args.publish:
|
|
778
|
+
return publish(results, scan_scope(root), args)
|
|
779
|
+
return 0
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
if __name__ == "__main__":
|
|
783
|
+
sys.exit(run_main("controlscan", main))
|