skillctl-secure 0.28.0__py3-none-any.whl

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.
Files changed (122) hide show
  1. skillctl/__init__.py +1 -0
  2. skillctl/__main__.py +5 -0
  3. skillctl/authcontrols.py +165 -0
  4. skillctl/benchmark.py +150 -0
  5. skillctl/browsercontrols.py +200 -0
  6. skillctl/bulk_cli.py +66 -0
  7. skillctl/bulk_plan.py +96 -0
  8. skillctl/bulk_update.py +173 -0
  9. skillctl/bulk_update_tui.py +196 -0
  10. skillctl/cacheutil.py +21 -0
  11. skillctl/candidates.py +244 -0
  12. skillctl/catalog.py +48 -0
  13. skillctl/catalog_discovery.py +67 -0
  14. skillctl/catalog_index.py +195 -0
  15. skillctl/catalog_schema.py +58 -0
  16. skillctl/catalog_search_cli.py +100 -0
  17. skillctl/catalog_search_tui.py +188 -0
  18. skillctl/cicdflow.py +89 -0
  19. skillctl/cli.py +958 -0
  20. skillctl/cli_contract.py +71 -0
  21. skillctl/compatibility.py +283 -0
  22. skillctl/context_cost.py +90 -0
  23. skillctl/context_tui.py +84 -0
  24. skillctl/copilot_export.py +188 -0
  25. skillctl/coverage.py +11 -0
  26. skillctl/dedupe.py +14 -0
  27. skillctl/doctor.py +113 -0
  28. skillctl/entrypoint.py +207 -0
  29. skillctl/environments.py +193 -0
  30. skillctl/estimate.py +136 -0
  31. skillctl/evidence.py +133 -0
  32. skillctl/findings.py +37 -0
  33. skillctl/flow.py +126 -0
  34. skillctl/governor.py +50 -0
  35. skillctl/hardening.py +195 -0
  36. skillctl/history_tui.py +149 -0
  37. skillctl/iac.py +95 -0
  38. skillctl/install_intent.py +148 -0
  39. skillctl/install_review.py +58 -0
  40. skillctl/intent_diff.py +97 -0
  41. skillctl/journal.py +157 -0
  42. skillctl/journal_hooks.py +37 -0
  43. skillctl/journal_operations.py +148 -0
  44. skillctl/lockfile.py +59 -0
  45. skillctl/manager.py +1533 -0
  46. skillctl/models.py +56 -0
  47. skillctl/navigation_tui.py +92 -0
  48. skillctl/operation_lock.py +111 -0
  49. skillctl/packet.py +46 -0
  50. skillctl/paths.py +35 -0
  51. skillctl/permissions.py +20 -0
  52. skillctl/policy.py +96 -0
  53. skillctl/policy_editor.py +146 -0
  54. skillctl/precision.py +197 -0
  55. skillctl/profiles.py +89 -0
  56. skillctl/publishers.py +25 -0
  57. skillctl/ranking.py +87 -0
  58. skillctl/recommendation.py +153 -0
  59. skillctl/recommendation_tui.py +351 -0
  60. skillctl/reconcile_cli.py +181 -0
  61. skillctl/reconcile_recovery.py +267 -0
  62. skillctl/reconcile_transaction.py +274 -0
  63. skillctl/reconcile_tui.py +335 -0
  64. skillctl/reconciliation.py +253 -0
  65. skillctl/remote.py +163 -0
  66. skillctl/resources/benchmarks/vulnlab/answer-key.yaml +122 -0
  67. skillctl/resources/benchmarks/vulnlab/target/.env +2 -0
  68. skillctl/resources/benchmarks/vulnlab/target/.github/workflows/build.yml +19 -0
  69. skillctl/resources/benchmarks/vulnlab/target/README.md +6 -0
  70. skillctl/resources/benchmarks/vulnlab/target/app.py +35 -0
  71. skillctl/resources/benchmarks/vulnlab/target/openapi.yaml +6 -0
  72. skillctl/resources/benchmarks/vulnlab/target/requirements.txt +2 -0
  73. skillctl/resources/benchmarks/vulnlab/target/scripts/build.sh +4 -0
  74. skillctl/resources/benchmarks/vulnlab/target/templates/payment.html +4 -0
  75. skillctl/resources/benchmarks/vulnlab/target/trivy-results.json +16 -0
  76. skillctl/resources/custom/.gitkeep +0 -0
  77. skillctl/resources/custom/api-security-review/SKILL.md +149 -0
  78. skillctl/resources/custom/api-security-review/references/inventory-template.md +8 -0
  79. skillctl/resources/custom/api-security-review/references/output-template.md +33 -0
  80. skillctl/resources/custom/biso-security-review/SKILL.md +135 -0
  81. skillctl/resources/custom/biso-security-review/references/disposition.md +15 -0
  82. skillctl/resources/custom/biso-security-review/references/orchestration.md +46 -0
  83. skillctl/resources/custom/biso-security-review/references/output-template.md +55 -0
  84. skillctl/resources/custom/biso-security-review/references/review-checklist.md +36 -0
  85. skillctl/resources/custom/biso-security-review/references/risk-rating.md +16 -0
  86. skillctl/resources/custom/cicd-security-review/SKILL.md +133 -0
  87. skillctl/resources/custom/cicd-security-review/references/output-template.md +27 -0
  88. skillctl/resources/custom/pci-architect/SKILL.md +109 -0
  89. skillctl/resources/custom/pci-architect/references/control-matrix.md +12 -0
  90. skillctl/resources/custom/pci-architect/references/output-template.md +44 -0
  91. skillctl/resources/custom/threat-model/SKILL.md +69 -0
  92. skillctl/resources/custom/threat-model/references/output-template.md +44 -0
  93. skillctl/resources/custom/threat-model/references/risk-rating.md +25 -0
  94. skillctl/resources/custom/vulnerability-manager/SKILL.md +132 -0
  95. skillctl/resources/custom/vulnerability-manager/references/escalation.md +18 -0
  96. skillctl/resources/custom/vulnerability-manager/references/output-template.md +21 -0
  97. skillctl/resources/custom/vulnerability-manager/references/prioritization.md +36 -0
  98. skillctl/resources/finding-schema.json +131 -0
  99. skillctl/resources/skills.yaml +1907 -0
  100. skillctl/review.py +495 -0
  101. skillctl/review_candidates.py +43 -0
  102. skillctl/risk_acknowledgment.py +33 -0
  103. skillctl/runtime_binding.py +141 -0
  104. skillctl/safety_plan.py +156 -0
  105. skillctl/section_navigation.py +37 -0
  106. skillctl/stagecache.py +92 -0
  107. skillctl/stateio.py +55 -0
  108. skillctl/transactions.py +131 -0
  109. skillctl/trust.py +72 -0
  110. skillctl/tui.py +304 -0
  111. skillctl/tui_interaction.py +125 -0
  112. skillctl/tui_original.py +1646 -0
  113. skillctl/tui_progressive.py +102 -0
  114. skillctl/tui_refresh.py +286 -0
  115. skillctl/update_intent_review.py +74 -0
  116. skillctl/update_intent_tui.py +57 -0
  117. skillctl/vulnnormalize.py +114 -0
  118. skillctl_secure-0.28.0.dist-info/METADATA +292 -0
  119. skillctl_secure-0.28.0.dist-info/RECORD +122 -0
  120. skillctl_secure-0.28.0.dist-info/WHEEL +4 -0
  121. skillctl_secure-0.28.0.dist-info/entry_points.txt +2 -0
  122. skillctl_secure-0.28.0.dist-info/licenses/LICENSE +202 -0
skillctl/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.28.0"
skillctl/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from skillctl.entrypoint import run
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(run())
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+ import ast, json, re
3
+ from pathlib import Path
4
+
5
+ PY_AUTH_DECORATORS = (
6
+ "login_required", "permission_required", "user_passes_test",
7
+ "jwt_required", "roles_required", "roles_accepted",
8
+ "admin_required", "auth_required", "requires_auth"
9
+ )
10
+ PY_AUTH_MIXINS = (
11
+ "LoginRequiredMixin", "PermissionRequiredMixin", "UserPassesTestMixin"
12
+ )
13
+ FASTAPI_MARKERS = (
14
+ "Depends(", "Security(", "OAuth2PasswordBearer", "HTTPBearer",
15
+ "APIKeyHeader", "APIKeyCookie", "APIKeyQuery"
16
+ )
17
+ CLAIM_WORDS = ("role", "roles", "scope", "scopes", "tenant", "tenant_id", "sub", "subject", "permissions")
18
+
19
+ EXPRESS_AUTH_NAMES = re.compile(
20
+ r'(?i)\b(auth|authenticate|authorize|authorization|requireauth|requireadmin|'
21
+ r'checkrole|checkscope|permission|rbac|acl|jwt|verifytoken|verifyjwt)\b'
22
+ )
23
+
24
+ def _name(node):
25
+ if isinstance(node, ast.Name):
26
+ return node.id
27
+ if isinstance(node, ast.Attribute):
28
+ parts=[]
29
+ cur=node
30
+ while isinstance(cur, ast.Attribute):
31
+ parts.append(cur.attr); cur=cur.value
32
+ if isinstance(cur, ast.Name): parts.append(cur.id)
33
+ return ".".join(reversed(parts))
34
+ if isinstance(node, ast.Call):
35
+ return _name(node.func)
36
+ return ""
37
+
38
+ def _segment(lines,node):
39
+ try:
40
+ s=max(1,getattr(node,"lineno",1));e=min(len(lines),getattr(node,"end_lineno",s))
41
+ return "\n".join(lines[s-1:e])
42
+ except Exception:
43
+ return ""
44
+
45
+ def analyze_python(path:Path, rel:str)->dict:
46
+ try:
47
+ text=path.read_text(errors="replace")
48
+ tree=ast.parse(text)
49
+ except Exception:
50
+ return {"path":rel,"controls":[]}
51
+ lines=text.splitlines()
52
+ controls=[]
53
+
54
+ classes={}
55
+ for cls in [n for n in tree.body if isinstance(n,ast.ClassDef)]:
56
+ bases=[_name(x) for x in cls.bases]
57
+ matches=[b for b in bases if any(m in b for m in PY_AUTH_MIXINS)]
58
+ if matches:
59
+ classes[cls.name]=matches
60
+ controls.append({
61
+ "kind":"django-auth-mixin","path":rel,"line":getattr(cls,"lineno",0),
62
+ "class":cls.name,"controls":matches,"confidence":"high"
63
+ })
64
+
65
+ for fn in [n for n in ast.walk(tree) if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef))]:
66
+ decorators=[_name(d) for d in fn.decorator_list]
67
+ auth_decs=[d for d in decorators if any(marker in d for marker in PY_AUTH_DECORATORS)]
68
+ if auth_decs:
69
+ controls.append({
70
+ "kind":"python-auth-decorator","path":rel,"line":getattr(fn,"lineno",0),
71
+ "function":fn.name,"controls":auth_decs,"confidence":"high"
72
+ })
73
+
74
+ fn_src=_segment(lines,fn)
75
+ if any(marker in fn_src for marker in FASTAPI_MARKERS):
76
+ controls.append({
77
+ "kind":"fastapi-security-dependency","path":rel,"line":getattr(fn,"lineno",0),
78
+ "function":fn.name,"controls":[m for m in FASTAPI_MARKERS if m in fn_src],"confidence":"medium"
79
+ })
80
+
81
+ # Claims / principal based checks.
82
+ lower=fn_src.lower()
83
+ claim_hits=[]
84
+ for word in CLAIM_WORDS:
85
+ if re.search(rf'(?i)(current_user|g\.user|request\.user|claims?|payload|token|principal).{{0,80}}\b{re.escape(word)}\b', fn_src, re.S):
86
+ claim_hits.append(word)
87
+ if claim_hits:
88
+ controls.append({
89
+ "kind":"claims-authorization-check","path":rel,"line":getattr(fn,"lineno",0),
90
+ "function":fn.name,"claims":sorted(set(claim_hits)),"confidence":"medium"
91
+ })
92
+
93
+ # Explicit object/tenant comparisons.
94
+ object_guard_patterns = [
95
+ r'\b(?:current_user|request\.user|g\.user|principal)\.id\s*[=!]=',
96
+ r'\btenant_id\s*[=!]=',
97
+ r'\bowner_id\s*[=!]=',
98
+ r'\baccount_id\s*[=!]=',
99
+ r'\buser_id\s*[=!]=\s*(?:current_user|request\.user|g\.user|principal)\.id',
100
+ r'\b(?:authorize|can_access|has_permission|check_permission|is_allowed)\s*\(',
101
+ ]
102
+ hits=[p for p in object_guard_patterns if re.search(p,lower)]
103
+ if hits:
104
+ controls.append({
105
+ "kind":"object-authorization-guard","path":rel,"line":getattr(fn,"lineno",0),
106
+ "function":fn.name,"patterns":hits,"confidence":"high"
107
+ })
108
+
109
+ return {"path":rel,"controls":controls}
110
+
111
+ def analyze_express(path:Path, rel:str)->dict:
112
+ try:text=path.read_text(errors="replace")
113
+ except Exception:return {"path":rel,"controls":[]}
114
+ controls=[]
115
+ route_rx=re.compile(
116
+ r'(?is)\b(?:app|router)\.(get|post|put|patch|delete|use)\s*\((.*?)\)\s*;?'
117
+ )
118
+ for m in route_rx.finditer(text):
119
+ body=m.group(2)
120
+ # The first argument is usually path; middleware follows.
121
+ if EXPRESS_AUTH_NAMES.search(body):
122
+ line=text[:m.start()].count("\n")+1
123
+ controls.append({
124
+ "kind":"express-auth-middleware","path":rel,"line":line,
125
+ "method":m.group(1).upper(),
126
+ "evidence":body[:500],
127
+ "confidence":"medium"
128
+ })
129
+
130
+ # JWT claim/role checks anywhere in file.
131
+ for rx,kind in [
132
+ (re.compile(r'(?i)\b(req\.user|req\.auth|jwt|claims?|payload).{0,100}\b(role|roles|scope|permissions|tenant)\b'),"express-claims-check"),
133
+ (re.compile(r'(?i)\b(jwt\.verify|jsonwebtoken\.verify|verifyToken|verifyJwt)\b'),"express-jwt-verification"),
134
+ ]:
135
+ for m in rx.finditer(text):
136
+ controls.append({
137
+ "kind":kind,"path":rel,"line":text[:m.start()].count("\n")+1,
138
+ "evidence":m.group(0)[:300],"confidence":"medium"
139
+ })
140
+ return {"path":rel,"controls":controls}
141
+
142
+ def analyze_target(target:Path, canonical_files:list[str]|None=None)->dict:
143
+ target=Path(target).resolve()
144
+ files=canonical_files or [str(p.relative_to(target)) for p in target.rglob("*") if p.is_file()]
145
+ result={"schema_version":1,"controls":[]}
146
+ for rel in files:
147
+ p=target/rel
148
+ if not p.is_file(): continue
149
+ low=rel.lower()
150
+ if low.endswith(".py"):
151
+ result["controls"].extend(analyze_python(p,rel)["controls"])
152
+ elif low.endswith((".js",".jsx",".ts",".tsx")):
153
+ result["controls"].extend(analyze_express(p,rel)["controls"])
154
+ return result
155
+
156
+ def controls_for_function(auth_data:dict,path:str,function:str)->list[dict]:
157
+ return [
158
+ c for c in auth_data.get("controls",[])
159
+ if c.get("path")==path and (not c.get("function") or c.get("function")==function)
160
+ ]
161
+
162
+ def write_auth_controls(target:Path,evidence:dict,path:Path)->dict:
163
+ data=analyze_target(target,evidence.get("canonical_files"))
164
+ Path(path).write_text(json.dumps(data,indent=2)+"\n")
165
+ return data
skillctl/benchmark.py ADDED
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+ import json, re
3
+ from pathlib import Path
4
+ from dataclasses import dataclass, asdict
5
+ import yaml
6
+
7
+ SEV = {"info":0, "low":1, "medium":2, "high":3, "critical":4}
8
+
9
+ @dataclass
10
+ class FindingScore:
11
+ id: str
12
+ detected: bool
13
+ evidence: bool
14
+ severity_score: float
15
+ confidence_match: bool
16
+ matched_finding: str = ""
17
+ notes: str = ""
18
+
19
+ def _norm(s): return str(s or "").strip().lower()
20
+
21
+ def _load_structured(workspace: Path):
22
+ p=workspace/"70-biso"/"findings.json"
23
+ if not p.exists(): return []
24
+ try:
25
+ data=json.loads(p.read_text())
26
+ return data.get("findings",[]) if data.get("schema_version")==1 else []
27
+ except Exception:
28
+ return []
29
+
30
+ def _reports(workspace):
31
+ files=[p for p in workspace.rglob("*.md") if p.is_file()]
32
+ text="\n".join(p.read_text(errors="replace") for p in files)
33
+ return text,files
34
+
35
+ def _terms(f):
36
+ blob=" ".join([
37
+ str(f.get("title","")),
38
+ " ".join(f.get("concepts",[]) or []),
39
+ " ".join(f.get("attack_path",[]) or []),
40
+ str(f.get("business_impact","")),
41
+ str(f.get("required_action","")),
42
+ ]).lower()
43
+ return blob
44
+
45
+ def _match(expected, actual):
46
+ blob=_terms(actual)
47
+ concepts=[x.lower() for x in expected.get("concepts_any",[])]
48
+ req=[x.lower() for x in expected.get("required_terms",[])]
49
+ evidence_blob=" ".join(
50
+ f"{e.get('path','')} {e.get('lines','')} {e.get('note','')}"
51
+ for e in actual.get("evidence",[]) or []
52
+ ).lower()
53
+ req_hits=sum(1 for x in req if x in blob or x in evidence_blob)
54
+ concept_hit=any(x in blob for x in concepts) if concepts else True
55
+ return req_hits + (2 if concept_hit else 0)
56
+
57
+ def _severity_score(expected, actual):
58
+ e=SEV.get(_norm(expected),-1); a=SEV.get(_norm(actual),-1)
59
+ if e<0 or a<0:return 0.0
60
+ if a==e:return 1.0
61
+ if a>e:return 0.9 # conservative overrating: nearly full credit, flagged for review
62
+ if a==e-1:return 0.5
63
+ return 0.0
64
+
65
+ def evaluate(workspace:Path, answer_key:Path):
66
+ key=yaml.safe_load(answer_key.read_text())
67
+ structured=_load_structured(workspace)
68
+ prose,files=_reports(workspace)
69
+ scores=[]; used=set()
70
+
71
+ for exp in key["expected_findings"]:
72
+ best=None;bestscore=0;besti=-1
73
+ for i,f in enumerate(structured):
74
+ if i in used:continue
75
+ s=_match(exp,f)
76
+ if s>bestscore:bestscore=s;best=f;besti=i
77
+ detected=best is not None and bestscore>=3
78
+ if detected:used.add(besti)
79
+
80
+ # prose fallback only for detection if structured output absent/missed
81
+ if not detected:
82
+ low=prose.lower()
83
+ req=all(x.lower() in low for x in exp.get("required_terms",[]))
84
+ concept=any(x.lower() in low for x in exp.get("concepts_any",[]))
85
+ detected=req and concept
86
+
87
+ evidence=False; sevscore=0.0; conf=False; matched=""
88
+ if best is not None and bestscore>=3:
89
+ evpaths=[_norm(e.get("path")) for e in best.get("evidence",[]) or []]
90
+ evidence=any(any(_norm(want) in p for p in evpaths) for want in exp.get("evidence_any",[]))
91
+ sevscore=_severity_score(exp.get("severity"),best.get("severity"))
92
+ conf=_norm(best.get("confidence"))==_norm(exp.get("confidence"))
93
+ matched=str(best.get("id",""))
94
+ scores.append(FindingScore(exp["id"],detected,evidence,sevscore,conf,matched))
95
+
96
+ low=prose.lower()
97
+ forbidden=[]
98
+ for nf in key.get("expected_non_findings",[]):
99
+ for claim in nf.get("forbidden_claims",[]):
100
+ if claim.lower() in low:forbidden.append({"id":nf["id"],"claim":claim})
101
+
102
+ final=workspace/"70-biso"/"security-review.md"
103
+ disposition=False
104
+ if final.exists():
105
+ ft=final.read_text(errors="replace").upper()
106
+ disposition=any(x.upper() in ft for x in key["security_properties"]["expected_final_disposition_any"])
107
+
108
+ n=len(scores)
109
+ detected=sum(x.detected for x in scores)
110
+ evidence=sum(x.evidence for x in scores)
111
+ sev=sum(x.severity_score for x in scores)
112
+ conf=sum(x.confidence_match for x in scores)
113
+ summary={
114
+ "expected_findings":n,
115
+ "detected":detected,
116
+ "recall":round(detected/n,3),
117
+ "evidence_accuracy":round(evidence/n,3),
118
+ "severity_score":round(sev/n,3),
119
+ "confidence_match_rate":round(conf/n,3),
120
+ "forbidden_claims":len(forbidden),
121
+ "disposition_acceptable":disposition,
122
+ "structured_findings":len(structured),
123
+ "unmatched_structured_findings":max(0,len(structured)-len(used))
124
+ }
125
+ return {"benchmark":key["benchmark"],"summary":summary,"findings":[asdict(x) for x in scores],"forbidden_hits":forbidden}
126
+
127
+ def render_markdown(result):
128
+ s=result["summary"]
129
+ rows="\n".join(
130
+ f"| {f['id']} | {'✓' if f['detected'] else '✗'} | {'✓' if f['evidence'] else '✗'} | "
131
+ f"{f['severity_score']:.1f} | {'✓' if f['confidence_match'] else '✗'} | {f['matched_finding'] or '-'} |"
132
+ for f in result["findings"]
133
+ )
134
+ return f"""# Benchmark Results — Evaluator 2.0
135
+
136
+ - Expected findings: {s['expected_findings']}
137
+ - Detected: {s['detected']}
138
+ - Recall: {s['recall']:.1%}
139
+ - Evidence accuracy: {s['evidence_accuracy']:.1%}
140
+ - Severity score: {s['severity_score']:.1%}
141
+ - Confidence match rate: {s['confidence_match_rate']:.1%}
142
+ - Structured findings emitted: {s['structured_findings']}
143
+ - Unmatched structured findings: {s['unmatched_structured_findings']}
144
+ - Unsupported forbidden claims: {s['forbidden_claims']}
145
+ - Final disposition acceptable: {s['disposition_acceptable']}
146
+
147
+ | Expected | Detected | Evidence | Severity credit | Confidence | Matched finding |
148
+ | --- | --- | --- | ---: | --- | --- |
149
+ {rows}
150
+ """
@@ -0,0 +1,200 @@
1
+ from __future__ import annotations
2
+ import json, re
3
+ from pathlib import Path
4
+
5
+ SECURITY_HEADERS = {
6
+ "content-security-policy": "csp",
7
+ "content-security-policy-report-only": "csp-report-only",
8
+ "strict-transport-security": "hsts",
9
+ "x-content-type-options": "x-content-type-options",
10
+ "referrer-policy": "referrer-policy",
11
+ "permissions-policy": "permissions-policy",
12
+ "cross-origin-opener-policy": "coop",
13
+ "cross-origin-resource-policy": "corp",
14
+ "cross-origin-embedder-policy": "coep",
15
+ "x-frame-options": "x-frame-options",
16
+ }
17
+
18
+ def _read(path: Path) -> str:
19
+ try:
20
+ return path.read_text(errors="replace")
21
+ except Exception:
22
+ return ""
23
+
24
+ def analyze_html(path: Path, rel: str) -> dict:
25
+ text = _read(path)
26
+ controls = []
27
+ scripts = []
28
+ iframes = []
29
+
30
+ for m in re.finditer(r'(?is)<meta[^>]+http-equiv=["\']([^"\']+)["\'][^>]+content=["\']([^"\']*)["\']', text):
31
+ name = m.group(1).strip().lower()
32
+ if name in SECURITY_HEADERS:
33
+ controls.append({
34
+ "kind": SECURITY_HEADERS[name],
35
+ "path": rel,
36
+ "line": text[:m.start()].count("\n") + 1,
37
+ "value": m.group(2)[:500],
38
+ "confidence": "high"
39
+ })
40
+
41
+ for m in re.finditer(r'(?is)<script\b([^>]*)>', text):
42
+ attrs = m.group(1)
43
+ srcm = re.search(r'(?i)\bsrc=["\']([^"\']+)["\']', attrs)
44
+ if not srcm:
45
+ continue
46
+ src = srcm.group(1)
47
+ sri = re.search(r'(?i)\bintegrity=["\']([^"\']+)["\']', attrs)
48
+ cross = re.search(r'(?i)\bcrossorigin(?:=["\']([^"\']*)["\'])?', attrs)
49
+ scripts.append({
50
+ "path": rel,
51
+ "line": text[:m.start()].count("\n") + 1,
52
+ "src": src,
53
+ "external": src.startswith(("http://", "https://", "//")),
54
+ "integrity_present": bool(sri),
55
+ "crossorigin_present": bool(cross),
56
+ })
57
+
58
+ for m in re.finditer(r'(?is)<iframe\b([^>]*)>', text):
59
+ attrs = m.group(1)
60
+ srcm = re.search(r'(?i)\bsrc=["\']([^"\']+)["\']', attrs)
61
+ sandbox = re.search(r'(?i)\bsandbox(?:=["\']([^"\']*)["\'])?', attrs)
62
+ allow = re.search(r'(?i)\ballow=["\']([^"\']+)["\']', attrs)
63
+ iframes.append({
64
+ "path": rel,
65
+ "line": text[:m.start()].count("\n") + 1,
66
+ "src": srcm.group(1) if srcm else "",
67
+ "sandbox_present": bool(sandbox),
68
+ "sandbox_value": (sandbox.group(1) or "") if sandbox else "",
69
+ "allow": allow.group(1) if allow else "",
70
+ })
71
+
72
+ return {"controls": controls, "scripts": scripts, "iframes": iframes}
73
+
74
+ def analyze_code(path: Path, rel: str) -> dict:
75
+ text = _read(path)
76
+ low = text.lower()
77
+ controls = []
78
+ cookies = []
79
+ cors = []
80
+ framework = []
81
+
82
+ # Generic response-header assignments.
83
+ for header, kind in SECURITY_HEADERS.items():
84
+ for m in re.finditer(re.escape(header), low):
85
+ controls.append({
86
+ "kind": kind,
87
+ "path": rel,
88
+ "line": text[:m.start()].count("\n") + 1,
89
+ "value": "",
90
+ "confidence": "medium"
91
+ })
92
+
93
+ # CORS frameworks/config.
94
+ patterns = [
95
+ (r'(?i)\bCORS\s*\(', "flask-cors"),
96
+ (r'(?i)\bcors\s*\(', "express-cors"),
97
+ (r'(?i)\bAccess-Control-Allow-Origin\b', "cors-header"),
98
+ ]
99
+ for pat, kind in patterns:
100
+ for m in re.finditer(pat, text):
101
+ line = text[:m.start()].count("\n") + 1
102
+ snippet = text[m.start():m.start()+350]
103
+ wildcard = "*" in snippet
104
+ credentials = bool(re.search(r'(?i)(supports_credentials|credentials)\s*[:=]\s*(true|True)', snippet))
105
+ cors.append({
106
+ "kind": kind,
107
+ "path": rel,
108
+ "line": line,
109
+ "wildcard_observed": wildcard,
110
+ "credentials_observed": credentials,
111
+ "evidence": snippet[:300]
112
+ })
113
+
114
+ # Security middleware/frameworks.
115
+ framework_patterns = [
116
+ (r'(?i)\bTalisman\s*\(', "flask-talisman"),
117
+ (r'(?i)\bhelmet\s*\(', "express-helmet"),
118
+ (r'(?i)\bsecure_headers\b', "secure-headers"),
119
+ ]
120
+ for pat, kind in framework_patterns:
121
+ for m in re.finditer(pat, text):
122
+ framework.append({
123
+ "kind": kind,
124
+ "path": rel,
125
+ "line": text[:m.start()].count("\n") + 1,
126
+ "confidence": "medium"
127
+ })
128
+
129
+ # Cookie security settings.
130
+ cookie_markers = [
131
+ ("secure", r'(?i)\bsecure\s*[:=]\s*(true|True)'),
132
+ ("httponly", r'(?i)\bhttponly\s*[:=]\s*(true|True)'),
133
+ ("samesite", r'(?i)\bsamesite\s*[:=]\s*["\']?(strict|lax|none)'),
134
+ ]
135
+ if re.search(r'(?i)(set_cookie|res\.cookie|session_cookie)', text):
136
+ flags = {}
137
+ for name, pat in cookie_markers:
138
+ m = re.search(pat, text)
139
+ flags[name] = m.group(1).lower() if m and name == "samesite" else bool(m)
140
+ cookies.append({
141
+ "path": rel,
142
+ "line": 1,
143
+ "secure": flags.get("secure", False),
144
+ "httponly": flags.get("httponly", False),
145
+ "samesite": flags.get("samesite", False),
146
+ "confidence": "medium"
147
+ })
148
+
149
+ return {"controls": controls, "cookies": cookies, "cors": cors, "framework": framework}
150
+
151
+ def analyze_target(target: Path, canonical_files: list[str] | None = None) -> dict:
152
+ target = Path(target).resolve()
153
+ files = canonical_files or [str(p.relative_to(target)) for p in target.rglob("*") if p.is_file()]
154
+ result = {
155
+ "schema_version": 1,
156
+ "headers": [],
157
+ "cookies": [],
158
+ "cors": [],
159
+ "framework_controls": [],
160
+ "scripts": [],
161
+ "iframes": [],
162
+ "summary": {}
163
+ }
164
+
165
+ for rel in files:
166
+ p = target / rel
167
+ if not p.is_file():
168
+ continue
169
+ low = rel.lower()
170
+ if low.endswith((".html", ".htm")):
171
+ h = analyze_html(p, rel)
172
+ result["headers"].extend(h["controls"])
173
+ result["scripts"].extend(h["scripts"])
174
+ result["iframes"].extend(h["iframes"])
175
+ elif low.endswith((".py", ".js", ".jsx", ".ts", ".tsx", ".conf", ".nginx", ".yaml", ".yml")):
176
+ c = analyze_code(p, rel)
177
+ result["headers"].extend(c["controls"])
178
+ result["cookies"].extend(c["cookies"])
179
+ result["cors"].extend(c["cors"])
180
+ result["framework_controls"].extend(c["framework"])
181
+
182
+ kinds = {x["kind"] for x in result["headers"]}
183
+ result["summary"] = {
184
+ "csp_observed": "csp" in kinds,
185
+ "csp_report_only_observed": "csp-report-only" in kinds,
186
+ "hsts_observed": "hsts" in kinds,
187
+ "x_frame_options_observed": "x-frame-options" in kinds,
188
+ "external_script_count": sum(1 for x in result["scripts"] if x.get("external")),
189
+ "external_scripts_without_sri": sum(1 for x in result["scripts"] if x.get("external") and not x.get("integrity_present")),
190
+ "iframe_count": len(result["iframes"]),
191
+ "unsandboxed_iframe_count": sum(1 for x in result["iframes"] if not x.get("sandbox_present")),
192
+ "cors_entry_count": len(result["cors"]),
193
+ "cookie_entry_count": len(result["cookies"]),
194
+ }
195
+ return result
196
+
197
+ def write_browser_controls(target: Path, evidence: dict, path: Path) -> dict:
198
+ data = analyze_target(target, evidence.get("canonical_files"))
199
+ path.write_text(json.dumps(data, indent=2) + "\n")
200
+ return data
skillctl/bulk_cli.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List
4
+
5
+ import typer
6
+ from rich.table import Table
7
+
8
+ from .bulk_plan import build_bulk_install_plan
9
+ from .cli import app, console, manager
10
+ from .cli_contract import deterministic_json
11
+
12
+
13
+ @app.command("bulk-plan")
14
+ def bulk_plan_cmd(
15
+ ctx: typer.Context,
16
+ skills: List[str] = typer.Argument(..., help="One or more catalog skills to plan together."),
17
+ json_output: bool = typer.Option(False, "--json", help="Emit the complete machine-readable aggregate plan."),
18
+ ):
19
+ """Preview a fail-closed multi-skill install plan without mutating the toolbox."""
20
+ m = manager(ctx.obj["manifest"])
21
+ plan = build_bulk_install_plan(m, skills)
22
+
23
+ if json_output:
24
+ typer.echo(deterministic_json(plan), nl=False)
25
+ return
26
+
27
+ console.print(
28
+ f"[bold]Bulk install plan[/bold] environment={plan['environment']} "
29
+ f"gate=[bold]{plan['gate']}[/bold] requested={len(plan['requested'])} "
30
+ f"mutations={plan['mutation_count']}"
31
+ )
32
+ table = Table("Step", "Skill", "Action", "Gate", "Trust", "Audit", "Required by", "Destination")
33
+ for index, step in enumerate(plan["steps"], 1):
34
+ audit = step["audit"]
35
+ audit_label = (
36
+ "ERROR" if audit.get("error") else
37
+ f"HIGH:{audit.get('high', 0)}" if audit.get("high") else
38
+ "checked" if audit.get("checked") else "unknown"
39
+ )
40
+ table.add_row(
41
+ str(index),
42
+ str(step["skill"]),
43
+ str(step["action"]),
44
+ str(step["gate"]),
45
+ str(step["trust_state"]),
46
+ audit_label,
47
+ ", ".join(step.get("required_by", [])),
48
+ str(step["destination"]),
49
+ )
50
+ console.print(table)
51
+
52
+ if plan["gate_reasons"]:
53
+ console.print("\n[bold]Gate reasons[/bold]")
54
+ for reason in plan["gate_reasons"]:
55
+ console.print(f"• {reason}")
56
+
57
+ console.print("\n[bold]Observed aggregate intent[/bold]")
58
+ for statement in plan.get("intent", {}).get("statements", []):
59
+ console.print(f"• {statement}")
60
+
61
+ console.print(
62
+ "[dim]Read-only aggregate plan: shared dependencies are deduplicated; no install, state, lockfile, runtime-binding, or catalog mutation was performed.[/dim]"
63
+ )
64
+
65
+
66
+ __all__ = ["bulk_plan_cmd"]