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.
- skillctl/__init__.py +1 -0
- skillctl/__main__.py +5 -0
- skillctl/authcontrols.py +165 -0
- skillctl/benchmark.py +150 -0
- skillctl/browsercontrols.py +200 -0
- skillctl/bulk_cli.py +66 -0
- skillctl/bulk_plan.py +96 -0
- skillctl/bulk_update.py +173 -0
- skillctl/bulk_update_tui.py +196 -0
- skillctl/cacheutil.py +21 -0
- skillctl/candidates.py +244 -0
- skillctl/catalog.py +48 -0
- skillctl/catalog_discovery.py +67 -0
- skillctl/catalog_index.py +195 -0
- skillctl/catalog_schema.py +58 -0
- skillctl/catalog_search_cli.py +100 -0
- skillctl/catalog_search_tui.py +188 -0
- skillctl/cicdflow.py +89 -0
- skillctl/cli.py +958 -0
- skillctl/cli_contract.py +71 -0
- skillctl/compatibility.py +283 -0
- skillctl/context_cost.py +90 -0
- skillctl/context_tui.py +84 -0
- skillctl/copilot_export.py +188 -0
- skillctl/coverage.py +11 -0
- skillctl/dedupe.py +14 -0
- skillctl/doctor.py +113 -0
- skillctl/entrypoint.py +207 -0
- skillctl/environments.py +193 -0
- skillctl/estimate.py +136 -0
- skillctl/evidence.py +133 -0
- skillctl/findings.py +37 -0
- skillctl/flow.py +126 -0
- skillctl/governor.py +50 -0
- skillctl/hardening.py +195 -0
- skillctl/history_tui.py +149 -0
- skillctl/iac.py +95 -0
- skillctl/install_intent.py +148 -0
- skillctl/install_review.py +58 -0
- skillctl/intent_diff.py +97 -0
- skillctl/journal.py +157 -0
- skillctl/journal_hooks.py +37 -0
- skillctl/journal_operations.py +148 -0
- skillctl/lockfile.py +59 -0
- skillctl/manager.py +1533 -0
- skillctl/models.py +56 -0
- skillctl/navigation_tui.py +92 -0
- skillctl/operation_lock.py +111 -0
- skillctl/packet.py +46 -0
- skillctl/paths.py +35 -0
- skillctl/permissions.py +20 -0
- skillctl/policy.py +96 -0
- skillctl/policy_editor.py +146 -0
- skillctl/precision.py +197 -0
- skillctl/profiles.py +89 -0
- skillctl/publishers.py +25 -0
- skillctl/ranking.py +87 -0
- skillctl/recommendation.py +153 -0
- skillctl/recommendation_tui.py +351 -0
- skillctl/reconcile_cli.py +181 -0
- skillctl/reconcile_recovery.py +267 -0
- skillctl/reconcile_transaction.py +274 -0
- skillctl/reconcile_tui.py +335 -0
- skillctl/reconciliation.py +253 -0
- skillctl/remote.py +163 -0
- skillctl/resources/benchmarks/vulnlab/answer-key.yaml +122 -0
- skillctl/resources/benchmarks/vulnlab/target/.env +2 -0
- skillctl/resources/benchmarks/vulnlab/target/.github/workflows/build.yml +19 -0
- skillctl/resources/benchmarks/vulnlab/target/README.md +6 -0
- skillctl/resources/benchmarks/vulnlab/target/app.py +35 -0
- skillctl/resources/benchmarks/vulnlab/target/openapi.yaml +6 -0
- skillctl/resources/benchmarks/vulnlab/target/requirements.txt +2 -0
- skillctl/resources/benchmarks/vulnlab/target/scripts/build.sh +4 -0
- skillctl/resources/benchmarks/vulnlab/target/templates/payment.html +4 -0
- skillctl/resources/benchmarks/vulnlab/target/trivy-results.json +16 -0
- skillctl/resources/custom/.gitkeep +0 -0
- skillctl/resources/custom/api-security-review/SKILL.md +149 -0
- skillctl/resources/custom/api-security-review/references/inventory-template.md +8 -0
- skillctl/resources/custom/api-security-review/references/output-template.md +33 -0
- skillctl/resources/custom/biso-security-review/SKILL.md +135 -0
- skillctl/resources/custom/biso-security-review/references/disposition.md +15 -0
- skillctl/resources/custom/biso-security-review/references/orchestration.md +46 -0
- skillctl/resources/custom/biso-security-review/references/output-template.md +55 -0
- skillctl/resources/custom/biso-security-review/references/review-checklist.md +36 -0
- skillctl/resources/custom/biso-security-review/references/risk-rating.md +16 -0
- skillctl/resources/custom/cicd-security-review/SKILL.md +133 -0
- skillctl/resources/custom/cicd-security-review/references/output-template.md +27 -0
- skillctl/resources/custom/pci-architect/SKILL.md +109 -0
- skillctl/resources/custom/pci-architect/references/control-matrix.md +12 -0
- skillctl/resources/custom/pci-architect/references/output-template.md +44 -0
- skillctl/resources/custom/threat-model/SKILL.md +69 -0
- skillctl/resources/custom/threat-model/references/output-template.md +44 -0
- skillctl/resources/custom/threat-model/references/risk-rating.md +25 -0
- skillctl/resources/custom/vulnerability-manager/SKILL.md +132 -0
- skillctl/resources/custom/vulnerability-manager/references/escalation.md +18 -0
- skillctl/resources/custom/vulnerability-manager/references/output-template.md +21 -0
- skillctl/resources/custom/vulnerability-manager/references/prioritization.md +36 -0
- skillctl/resources/finding-schema.json +131 -0
- skillctl/resources/skills.yaml +1907 -0
- skillctl/review.py +495 -0
- skillctl/review_candidates.py +43 -0
- skillctl/risk_acknowledgment.py +33 -0
- skillctl/runtime_binding.py +141 -0
- skillctl/safety_plan.py +156 -0
- skillctl/section_navigation.py +37 -0
- skillctl/stagecache.py +92 -0
- skillctl/stateio.py +55 -0
- skillctl/transactions.py +131 -0
- skillctl/trust.py +72 -0
- skillctl/tui.py +304 -0
- skillctl/tui_interaction.py +125 -0
- skillctl/tui_original.py +1646 -0
- skillctl/tui_progressive.py +102 -0
- skillctl/tui_refresh.py +286 -0
- skillctl/update_intent_review.py +74 -0
- skillctl/update_intent_tui.py +57 -0
- skillctl/vulnnormalize.py +114 -0
- skillctl_secure-0.28.0.dist-info/METADATA +292 -0
- skillctl_secure-0.28.0.dist-info/RECORD +122 -0
- skillctl_secure-0.28.0.dist-info/WHEEL +4 -0
- skillctl_secure-0.28.0.dist-info/entry_points.txt +2 -0
- 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
skillctl/authcontrols.py
ADDED
|
@@ -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"]
|