foretop-ebb 0.1.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.
- ebb/__init__.py +1 -0
- ebb/__main__.py +4 -0
- ebb/brand.py +5 -0
- ebb/build_findings.py +118 -0
- ebb/canonicalize.py +60 -0
- ebb/cli.py +176 -0
- ebb/detect/__init__.py +0 -0
- ebb/detect/base.py +29 -0
- ebb/detect/dockerfile.py +8 -0
- ebb/detect/json_format.py +8 -0
- ebb/detect/notebook.py +25 -0
- ebb/detect/patterns.py +51 -0
- ebb/detect/python.py +8 -0
- ebb/detect/registry.py +25 -0
- ebb/detect/scan_text.py +46 -0
- ebb/detect/terraform.py +8 -0
- ebb/detect/toml.py +8 -0
- ebb/detect/typescript.py +8 -0
- ebb/detect/yaml.py +8 -0
- ebb/finding.py +34 -0
- ebb/owner.py +52 -0
- ebb/registries/retirements/anthropic.yaml +101 -0
- ebb/registries/retirements/bedrock.yaml +81 -0
- ebb/registries/retirements/cohere.yaml +33 -0
- ebb/registries/retirements/google.yaml +408 -0
- ebb/registries/retirements/openai.yaml +107 -0
- ebb/registry/__init__.py +0 -0
- ebb/registry/loader.py +74 -0
- ebb/registry/models.py +47 -0
- ebb/registry/resolve.py +36 -0
- ebb/render/__init__.py +0 -0
- ebb/render/annotations.py +50 -0
- ebb/render/json_renderer.py +7 -0
- ebb/render/markdown.py +41 -0
- ebb/render/sarif.py +11 -0
- ebb/render/terminal.py +31 -0
- ebb/triage.py +166 -0
- ebb/triage_role.py +415 -0
- ebb/verdict.py +48 -0
- ebb/walk.py +38 -0
- foretop_ebb-0.1.0.dist-info/METADATA +175 -0
- foretop_ebb-0.1.0.dist-info/RECORD +61 -0
- foretop_ebb-0.1.0.dist-info/WHEEL +4 -0
- foretop_ebb-0.1.0.dist-info/entry_points.txt +4 -0
- foretop_ebb-0.1.0.dist-info/licenses/LICENSE +202 -0
- keel/__init__.py +1 -0
- keel/collect/__init__.py +0 -0
- keel/collect/walk.py +95 -0
- keel/finding.py +105 -0
- keel/gate.py +152 -0
- keel/git_ref.py +45 -0
- keel/owner.py +58 -0
- keel/render/__init__.py +0 -0
- keel/render/annotations.py +62 -0
- keel/render/markdown.py +40 -0
- keel/render/sarif.py +85 -0
- keel/render/terminal.py +38 -0
- keel/render/triage_json.py +15 -0
- keel/report.py +98 -0
- keel/run.py +67 -0
- keel/triage.py +167 -0
ebb/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
ebb/__main__.py
ADDED
ebb/brand.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
BRAND = "foretop"
|
|
2
|
+
"""The insulation rule (NEXT_STEPS.md): until the trademark search clears, the brand name lives
|
|
3
|
+
in exactly this one constant — package prefix, CLI namespace, user-facing strings — and nowhere
|
|
4
|
+
else. A rename is then a find-replace, a re-publish, and a GitHub org rename, not a week of
|
|
5
|
+
hunting down hardcoded strings."""
|
ebb/build_findings.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import uuid
|
|
3
|
+
from datetime import UTC, date, datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ebb import __version__ as ENGINE_VERSION # noqa: N812
|
|
7
|
+
from ebb.canonicalize import canonicalize
|
|
8
|
+
from ebb.detect.base import RawMatch
|
|
9
|
+
from ebb.finding import Evidence, Finding, FindingState, ProductCode, Verdict
|
|
10
|
+
from ebb.owner import find_owner
|
|
11
|
+
from ebb.registry.loader import Registry
|
|
12
|
+
from ebb.registry.models import RegistryEntry
|
|
13
|
+
from ebb.registry.resolve import Resolution, Resolved, resolve
|
|
14
|
+
from ebb.verdict import compute_confidence, compute_severity, compute_verdict
|
|
15
|
+
from ebb.walk import scan_repo
|
|
16
|
+
|
|
17
|
+
RULE_ID = "ebb.retirement"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compute_identity(canonical_id: str, relative_path: str, symbol: str) -> str:
|
|
21
|
+
"""Canonical model id + file path + normalized surrounding symbol — deliberately excludes
|
|
22
|
+
the line number, per SUITE_ARCHITECTURE.md §3.1: identity must survive cosmetic reformatting
|
|
23
|
+
and break on real change. A hash rather than a readable string because identity is compared,
|
|
24
|
+
never displayed — title/detail_md carry the human-facing description."""
|
|
25
|
+
raw = f"ebb|{canonical_id}|{relative_path}|{symbol}"
|
|
26
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _title_for(canonical_id: str, verdict: Verdict, entry: RegistryEntry | None) -> str:
|
|
30
|
+
if verdict is Verdict.BREAK:
|
|
31
|
+
assert entry is not None and entry.shutdown_at is not None
|
|
32
|
+
return f"{canonical_id} retired {entry.shutdown_at.isoformat()}"
|
|
33
|
+
if verdict is Verdict.DRIFT:
|
|
34
|
+
assert entry is not None and entry.shutdown_at is not None
|
|
35
|
+
return f"{canonical_id} retires {entry.shutdown_at.isoformat()}"
|
|
36
|
+
if verdict is Verdict.CLEAR:
|
|
37
|
+
return f"{canonical_id} is actively supported"
|
|
38
|
+
return f"{canonical_id} is not in the tracked registry"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _detail_md_for(canonical_id: str, entry: RegistryEntry | None) -> str:
|
|
42
|
+
if entry is None:
|
|
43
|
+
return (
|
|
44
|
+
f"`{canonical_id}` was detected but is not a verified entry in the retirement "
|
|
45
|
+
"registry. This means unknown, not confirmed safe."
|
|
46
|
+
)
|
|
47
|
+
parts = [f"Provider: **{entry.provider.value}**", f"Source: {entry.source_url}"]
|
|
48
|
+
if entry.announced_at:
|
|
49
|
+
parts.append(f"Announced: {entry.announced_at.isoformat()}")
|
|
50
|
+
if entry.shutdown_at:
|
|
51
|
+
parts.append(f"Shutdown: {entry.shutdown_at.isoformat()}")
|
|
52
|
+
if entry.replacement_id:
|
|
53
|
+
parts.append(f"Replacement: `{entry.replacement_id}`")
|
|
54
|
+
return "\n".join(f"- {p}" for p in parts)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_finding(
|
|
58
|
+
match: RawMatch,
|
|
59
|
+
*,
|
|
60
|
+
repo_root: Path,
|
|
61
|
+
registry: Registry,
|
|
62
|
+
run_id: str,
|
|
63
|
+
today: date | None = None,
|
|
64
|
+
) -> Finding:
|
|
65
|
+
today = today or datetime.now(UTC).date()
|
|
66
|
+
canonical_id = canonicalize(match.matched_text)
|
|
67
|
+
resolution: Resolution = resolve(canonical_id, registry)
|
|
68
|
+
entry = resolution.entry if isinstance(resolution, Resolved) else None
|
|
69
|
+
|
|
70
|
+
verdict = compute_verdict(resolution, today)
|
|
71
|
+
severity = compute_severity(verdict, resolution, today)
|
|
72
|
+
confidence = compute_confidence(resolution)
|
|
73
|
+
|
|
74
|
+
relative_path = str(match.path.relative_to(repo_root))
|
|
75
|
+
owner = find_owner(match.path, match.line, repo_root)
|
|
76
|
+
content_hash = hashlib.sha256(match.path.read_bytes()).hexdigest()
|
|
77
|
+
|
|
78
|
+
evidence = Evidence(
|
|
79
|
+
source_uri=relative_path,
|
|
80
|
+
locator=str(match.line),
|
|
81
|
+
excerpt=match.matched_text,
|
|
82
|
+
retrieved_at=datetime.now(UTC),
|
|
83
|
+
content_hash=content_hash,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return Finding(
|
|
87
|
+
identity=compute_identity(canonical_id, relative_path, match.symbol),
|
|
88
|
+
product=ProductCode.EBB,
|
|
89
|
+
rule_id=RULE_ID,
|
|
90
|
+
subject=canonical_id,
|
|
91
|
+
severity=severity,
|
|
92
|
+
confidence=confidence,
|
|
93
|
+
title=_title_for(canonical_id, verdict, entry),
|
|
94
|
+
detail_md=_detail_md_for(canonical_id, entry),
|
|
95
|
+
evidence=[evidence],
|
|
96
|
+
owner=owner,
|
|
97
|
+
deadline=entry.shutdown_at if entry else None,
|
|
98
|
+
verdict=verdict,
|
|
99
|
+
# No baseline mechanism exists for ebb's stateless local CLI yet — every finding from a
|
|
100
|
+
# single `ebb scan` invocation is NEW. See FindingState's own docstring.
|
|
101
|
+
state=FindingState.NEW,
|
|
102
|
+
first_seen_run=run_id,
|
|
103
|
+
rule_version=registry.version,
|
|
104
|
+
engine_version=ENGINE_VERSION,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_findings(
|
|
109
|
+
repo_root: Path, registry: Registry, *, today: date | None = None
|
|
110
|
+
) -> list[Finding]:
|
|
111
|
+
"""The full pipeline, wired together for the first time: walk -> detect -> canonicalize ->
|
|
112
|
+
resolve -> owner attribution -> verdict/severity/confidence -> Finding. One run_id shared
|
|
113
|
+
across every finding from this invocation."""
|
|
114
|
+
run_id = f"run-{uuid.uuid4()}"
|
|
115
|
+
return [
|
|
116
|
+
build_finding(match, repo_root=repo_root, registry=registry, run_id=run_id, today=today)
|
|
117
|
+
for match in scan_repo(repo_root)
|
|
118
|
+
]
|
ebb/canonicalize.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
# Collapses infrastructure-wrapper syntax down to the bare model id a provider's own API would
|
|
4
|
+
# use directly, so the same underlying model is recognized as the same canonical id regardless
|
|
5
|
+
# of which cloud layer referenced it. Every pattern here is verified against live vendor docs
|
|
6
|
+
# (CLAUDE_CODE_PLAN.md's "never write a contract from memory" applies to this too):
|
|
7
|
+
#
|
|
8
|
+
# - AWS Bedrock cross-Region inference profile IDs are exactly "{region}.{vendor}.{model-id}",
|
|
9
|
+
# e.g. `us.anthropic.claude-3-haiku-20240307-v1:0` — this literal example is drawn straight
|
|
10
|
+
# from AWS's own inference-profiles-support docs, not invented.
|
|
11
|
+
# - Bedrock (profile or plain) model IDs carry a trailing "-v{N}:{M}" version suffix.
|
|
12
|
+
# - GCP Vertex AI publisher-model resource names are "publishers/{publisher}/models/{model}",
|
|
13
|
+
# optionally prefixed "projects/{project}/locations/{location}/" — confirmed against Google's
|
|
14
|
+
# own REST reference for the publisherModels resource.
|
|
15
|
+
# - Vertex AI model *versions* use a trailing "@{N}" suffix — this is GCP's general resource
|
|
16
|
+
# versioning convention (used across several GCP APIs), not confirmed against a Claude-
|
|
17
|
+
# specific example, so treat it as a well-hedged structural rule rather than a cited fact.
|
|
18
|
+
#
|
|
19
|
+
# Deliberately NOT handled here: stripping "-latest" / "-preview" style suffixes. Some of those
|
|
20
|
+
# are curated as their own whole registry entries (e.g. `chatgpt-4o-latest`, which OpenAI
|
|
21
|
+
# deprecated as an alias in its own right — see src/ebb/registries/retirements/openai.yaml).
|
|
22
|
+
# Stripping the suffix here would silently break that entry's resolution. A floating alias
|
|
23
|
+
# that ISN'T itself curated stays exactly as unresolvable after canonicalization as it was
|
|
24
|
+
# before — see resolve()'s tests (Session 4) for why that's correct, not a gap.
|
|
25
|
+
_BEDROCK_REGION_PREFIX = re.compile(r"^(us|eu|apac|us-gov)\.")
|
|
26
|
+
_BEDROCK_VENDOR_PREFIX = re.compile(
|
|
27
|
+
r"^(anthropic|amazon|meta|mistral|cohere|ai21|openai|deepseek|writer)\."
|
|
28
|
+
)
|
|
29
|
+
_BEDROCK_VERSION_SUFFIX = re.compile(r"(-v\d+)?:\d+$")
|
|
30
|
+
_VERTEX_VERSION_SUFFIX = re.compile(r"@\d+$")
|
|
31
|
+
_VERTEX_RESOURCE_PATH = re.compile(
|
|
32
|
+
r"^(?:projects/[^/]+/locations/[^/]+/)?publishers/[^/]+/models/(?P<model_id>.+)$"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def canonicalize(raw_text: str) -> str:
|
|
37
|
+
"""Pure and isolated, per CLAUDE_CODE_PLAN.md Session 5 — no registry lookup, no I/O, no
|
|
38
|
+
dependency on anything but its own input. Idempotent (canonicalize(canonicalize(x)) ==
|
|
39
|
+
canonicalize(x) for all x) and round-trips (any of this module's recognized wrapper forms
|
|
40
|
+
of a given model canonicalizes to the same result as the model's own bare id) — both
|
|
41
|
+
verified by the property tests in tests/unit/test_canonicalize.py.
|
|
42
|
+
"""
|
|
43
|
+
text = raw_text.strip()
|
|
44
|
+
|
|
45
|
+
vertex_match = _VERTEX_RESOURCE_PATH.match(text)
|
|
46
|
+
if vertex_match:
|
|
47
|
+
text = vertex_match.group("model_id")
|
|
48
|
+
|
|
49
|
+
text = _VERTEX_VERSION_SUFFIX.sub("", text)
|
|
50
|
+
text = _BEDROCK_VERSION_SUFFIX.sub("", text)
|
|
51
|
+
|
|
52
|
+
region_match = _BEDROCK_REGION_PREFIX.match(text)
|
|
53
|
+
if region_match:
|
|
54
|
+
text = text[region_match.end() :]
|
|
55
|
+
|
|
56
|
+
vendor_match = _BEDROCK_VENDOR_PREFIX.match(text)
|
|
57
|
+
if vendor_match:
|
|
58
|
+
text = text[vendor_match.end() :]
|
|
59
|
+
|
|
60
|
+
return text
|
ebb/cli.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from datetime import UTC, datetime
|
|
3
|
+
from importlib import resources
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from keel.gate import GateFetchError, gate_identities, maybe_fetch_gate, summarize
|
|
8
|
+
from keel.render.triage_json import render_triage_json
|
|
9
|
+
from keel.report import ReportError, maybe_report
|
|
10
|
+
from keel.triage import TriageResult
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from ebb.brand import BRAND
|
|
14
|
+
from ebb.build_findings import build_findings
|
|
15
|
+
from ebb.finding import Finding, Severity
|
|
16
|
+
from ebb.registry.loader import RegistryLoadError, load_registry
|
|
17
|
+
from ebb.render.annotations import render_annotations
|
|
18
|
+
from ebb.render.json_renderer import render_json
|
|
19
|
+
from ebb.render.markdown import render_markdown
|
|
20
|
+
from ebb.render.sarif import render_sarif
|
|
21
|
+
from ebb.render.terminal import render_terminal
|
|
22
|
+
from ebb.triage import build_triage
|
|
23
|
+
|
|
24
|
+
_FORMATS = ("table", "markdown", "json", "sarif", "annotations", "triage-json")
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
name="ebb", help=f"{BRAND} ebb — find model identifiers before a provider retires them."
|
|
28
|
+
)
|
|
29
|
+
console = Console()
|
|
30
|
+
error_console = Console(stderr=True)
|
|
31
|
+
|
|
32
|
+
_SEVERITY_ORDER = [
|
|
33
|
+
Severity.INFO,
|
|
34
|
+
Severity.LOW,
|
|
35
|
+
Severity.MEDIUM,
|
|
36
|
+
Severity.HIGH,
|
|
37
|
+
Severity.CRITICAL,
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.callback()
|
|
42
|
+
def _callback() -> None:
|
|
43
|
+
"""Keeps `scan` addressable as `ebb scan PATH` — without a callback, Typer collapses an
|
|
44
|
+
app with a single command into a bare `ebb PATH` invocation instead."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _default_registry_dir() -> Path:
|
|
48
|
+
# Packaged as data inside the `ebb` distribution (src/ebb/registries/retirements/) so
|
|
49
|
+
# `uvx foretop-ebb scan .` works on a cold machine with no monorepo checkout in sight
|
|
50
|
+
# (specs/ebb.md §9's definition of done) — importlib.resources resolves relative to
|
|
51
|
+
# wherever the installed package actually lives, whether that's an editable workspace
|
|
52
|
+
# checkout or a normal site-packages install. --registry-dir still overrides this for
|
|
53
|
+
# anyone pinning to their own fork of the data (specs/ebb.md §5: the registry becomes a
|
|
54
|
+
# versioned dependency of its own eventually — this is the seam that day reuses).
|
|
55
|
+
return Path(str(resources.files("ebb") / "registries" / "retirements"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _render(findings: list[Finding], fmt: str, *, triage: TriageResult | None = None) -> str:
|
|
59
|
+
if fmt == "table":
|
|
60
|
+
return render_terminal(findings, no_color=not sys.stdout.isatty())
|
|
61
|
+
if fmt == "markdown":
|
|
62
|
+
return render_markdown(findings)
|
|
63
|
+
if fmt == "json":
|
|
64
|
+
return render_json(findings)
|
|
65
|
+
if fmt == "sarif":
|
|
66
|
+
import json
|
|
67
|
+
|
|
68
|
+
return json.dumps(render_sarif(findings), indent=2, sort_keys=True)
|
|
69
|
+
if fmt == "annotations":
|
|
70
|
+
return render_annotations(findings)
|
|
71
|
+
if fmt == "triage-json":
|
|
72
|
+
assert triage is not None # guaranteed by the fmt == "triage-json" caller-side guard
|
|
73
|
+
return render_triage_json(triage)
|
|
74
|
+
raise ValueError(f"unknown format: {fmt!r}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command()
|
|
78
|
+
def scan(
|
|
79
|
+
path: Path = typer.Argument(Path("."), help="Repository path to scan."), # noqa: B008
|
|
80
|
+
fmt: str = typer.Option(
|
|
81
|
+
"table",
|
|
82
|
+
"--format",
|
|
83
|
+
help="Output format: table, markdown, json, sarif, annotations, or triage-json.",
|
|
84
|
+
),
|
|
85
|
+
registry_dir: Path | None = typer.Option( # noqa: B008
|
|
86
|
+
None,
|
|
87
|
+
"--registry-dir",
|
|
88
|
+
help="Directory of registry YAML files. Defaults to this repo's registries/retirements/.",
|
|
89
|
+
),
|
|
90
|
+
fail_on: str | None = typer.Option(
|
|
91
|
+
None,
|
|
92
|
+
"--fail-on",
|
|
93
|
+
help="Exit 1 if any finding's severity is at or above this level "
|
|
94
|
+
"(info, low, medium, high, critical). Unset: always exit 0.",
|
|
95
|
+
),
|
|
96
|
+
report: bool = typer.Option(
|
|
97
|
+
False,
|
|
98
|
+
"--report",
|
|
99
|
+
help="Send this run's findings to the hub (FORETOP_TOKEN required). Off by default. "
|
|
100
|
+
"Always prints the exact payload before sending — findings are metadata only, "
|
|
101
|
+
"never your source.",
|
|
102
|
+
),
|
|
103
|
+
gate: bool = typer.Option(
|
|
104
|
+
False,
|
|
105
|
+
"--gate",
|
|
106
|
+
help="Fetch the org's baseline/suppressions from the hub (FORETOP_TOKEN required) and "
|
|
107
|
+
"exclude matching findings from --fail-on's decision. Off by default; a no-op unless "
|
|
108
|
+
"--fail-on is also given.",
|
|
109
|
+
),
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Scan a repository for model references and report their retirement status.
|
|
112
|
+
|
|
113
|
+
Exit codes are an API (specs/ebb.md §6): 0 clean or below --fail-on's threshold, 1 a
|
|
114
|
+
finding met or exceeded --fail-on, 2 an internal error (bad --format, registry failed to
|
|
115
|
+
load). These must never be conflated.
|
|
116
|
+
"""
|
|
117
|
+
if fmt not in _FORMATS:
|
|
118
|
+
error_console.print(f"Unknown --format {fmt!r}. Choose one of: {', '.join(_FORMATS)}.")
|
|
119
|
+
raise typer.Exit(code=2)
|
|
120
|
+
|
|
121
|
+
threshold: Severity | None = None
|
|
122
|
+
if fail_on is not None:
|
|
123
|
+
try:
|
|
124
|
+
threshold = Severity(fail_on)
|
|
125
|
+
except ValueError:
|
|
126
|
+
error_console.print(
|
|
127
|
+
f"Unknown --fail-on {fail_on!r}. Choose one of: "
|
|
128
|
+
f"{', '.join(s.value for s in _SEVERITY_ORDER)}."
|
|
129
|
+
)
|
|
130
|
+
raise typer.Exit(code=2) from None
|
|
131
|
+
|
|
132
|
+
registry_paths = sorted((registry_dir or _default_registry_dir()).glob("*.yaml"))
|
|
133
|
+
try:
|
|
134
|
+
registry = load_registry(registry_paths)
|
|
135
|
+
except RegistryLoadError as exc:
|
|
136
|
+
error_console.print(f"Registry failed to load: {exc}")
|
|
137
|
+
raise typer.Exit(code=2) from exc
|
|
138
|
+
|
|
139
|
+
findings = build_findings(path.resolve(), registry)
|
|
140
|
+
# Computed lazily, shared between --format triage-json and --report: build_triage is not
|
|
141
|
+
# free (classify_usage_role re-reads and re-parses every matched source file), so a plain
|
|
142
|
+
# scan with neither flag must never pay for it, and giving both flags at once must not pay
|
|
143
|
+
# for it twice.
|
|
144
|
+
triage = (
|
|
145
|
+
build_triage(findings, registry=registry, repo_root=path.resolve())
|
|
146
|
+
if fmt == "triage-json" or report
|
|
147
|
+
else None
|
|
148
|
+
)
|
|
149
|
+
print(_render(findings, fmt, triage=triage))
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
maybe_report(enabled=report, product="ebb", path=path, findings=findings, triage=triage)
|
|
153
|
+
except ReportError as exc:
|
|
154
|
+
error_console.print(str(exc))
|
|
155
|
+
raise typer.Exit(code=2) from exc
|
|
156
|
+
|
|
157
|
+
if threshold is not None:
|
|
158
|
+
threshold_rank = _SEVERITY_ORDER.index(threshold)
|
|
159
|
+
candidates = {
|
|
160
|
+
f.identity for f in findings if _SEVERITY_ORDER.index(f.severity) >= threshold_rank
|
|
161
|
+
}
|
|
162
|
+
# --gate is a no-op both when unset and when there's nothing --fail-on would flag
|
|
163
|
+
# anyway — no network call in either case, matching --report's own offline-by-default
|
|
164
|
+
# posture (MARKETING_BRIEF.md: "runs offline, no account").
|
|
165
|
+
if gate and candidates:
|
|
166
|
+
try:
|
|
167
|
+
gate_response = maybe_fetch_gate(enabled=True)
|
|
168
|
+
except GateFetchError as exc:
|
|
169
|
+
error_console.print(str(exc))
|
|
170
|
+
raise typer.Exit(code=2) from exc
|
|
171
|
+
assert gate_response is not None # enabled=True always returns a GateResponse
|
|
172
|
+
result = gate_identities(candidates, gate_response, today=datetime.now(UTC).date())
|
|
173
|
+
print(summarize(result))
|
|
174
|
+
candidates -= result.excluded
|
|
175
|
+
if candidates:
|
|
176
|
+
raise typer.Exit(code=1)
|
ebb/detect/__init__.py
ADDED
|
File without changes
|
ebb/detect/base.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class RawMatch:
|
|
8
|
+
"""One model-identifier-shaped string found in a file.
|
|
9
|
+
|
|
10
|
+
Line numbers exist for human-readable output only — per SUITE_ARCHITECTURE.md §3.1, line
|
|
11
|
+
numbers must never enter finding identity. `symbol` is the "normalized surrounding symbol"
|
|
12
|
+
identity actually uses instead: the identifier immediately governing the matched text (an
|
|
13
|
+
assignment target, a JSON/YAML/TOML key, a Dockerfile ENV/ARG name), captured at detection
|
|
14
|
+
time since that's when the full line text is already in hand. See
|
|
15
|
+
ebb.detect.scan_text.nearest_symbol for exactly what "normalized" means here.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
path: Path
|
|
19
|
+
line: int
|
|
20
|
+
matched_text: str
|
|
21
|
+
symbol: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Detector(Protocol):
|
|
25
|
+
"""A pure function `(path, content) -> list[RawMatch]`. No I/O, no side effects — the
|
|
26
|
+
walker reads file content once and hands it to every detector registered for that file's
|
|
27
|
+
kind. Adding a language means adding one detector and registering it in registry.py."""
|
|
28
|
+
|
|
29
|
+
def __call__(self, path: Path, content: str) -> list[RawMatch]: ...
|
ebb/detect/dockerfile.py
ADDED
ebb/detect/notebook.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from ebb.detect.base import RawMatch
|
|
5
|
+
from ebb.detect.scan_text import scan_lines
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def detect(path: Path, content: str) -> list[RawMatch]:
|
|
9
|
+
"""Scans every cell's source (code and markdown alike — a model id in a doc cell is still
|
|
10
|
+
worth surfacing; ranking it belongs to later sessions, not detection). A notebook that
|
|
11
|
+
fails to parse as JSON yields no matches rather than crashing the walk — one malformed
|
|
12
|
+
.ipynb must never take down a scan of the other 499 files in the repo."""
|
|
13
|
+
try:
|
|
14
|
+
notebook = json.loads(content)
|
|
15
|
+
except json.JSONDecodeError:
|
|
16
|
+
return []
|
|
17
|
+
|
|
18
|
+
matches: list[RawMatch] = []
|
|
19
|
+
for cell in notebook.get("cells", []):
|
|
20
|
+
source = cell.get("source", "")
|
|
21
|
+
cell_text = source if isinstance(source, str) else "".join(source)
|
|
22
|
+
# Line numbers are cell-relative, not file-relative — a notebook has no single
|
|
23
|
+
# meaningful line axis across cells. Informational only, as everywhere in Slice 1.
|
|
24
|
+
matches.extend(scan_lines(path, cell_text))
|
|
25
|
+
return matches
|
ebb/detect/patterns.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
# Structural patterns, not an enumerated list: DEC-06 (specs/ebb.md §1) commits to three
|
|
4
|
+
# providers "deeply correct" at launch — OpenAI, Anthropic, Google. Each pattern matches the
|
|
5
|
+
# provider's naming *shape* (including older `claude-3-opus-20240229`-style and newer
|
|
6
|
+
# `claude-opus-4-8`-style Anthropic ids — both conventions exist in the wild) so a model
|
|
7
|
+
# released after this file was written still matches, without needing a code change. This is
|
|
8
|
+
# deliberately looser than the versioned registry (Session 4), which pins exact known-valid ids
|
|
9
|
+
# with source_url/verified_at; Slice 1 only needs "shaped like a model identifier".
|
|
10
|
+
# OpenAI dates ship two conventions: a full ISO date (`-2024-05-13`) and a bare 4-digit code,
|
|
11
|
+
# optionally trailed by `-preview` (`-0125`, `-1106-preview`) — both need to be optional and
|
|
12
|
+
# tried in this order so the longer ISO form isn't shadowed by the shorter one matching a
|
|
13
|
+
# prefix of it.
|
|
14
|
+
_OPENAI_DATE_SUFFIX = r"(?:-\d{4}-\d{2}-\d{2}|-\d{4}(?:-preview)?)"
|
|
15
|
+
# `4\.\d+` (gpt-4.1, gpt-4.1-mini) must come before bare `4` in the alternation: alternatives
|
|
16
|
+
# are tried in order and the first one that lets the overall match succeed wins, so with `4`
|
|
17
|
+
# first, "gpt-4.1" would match only "gpt-4" and silently drop the ".1" that makes it a
|
|
18
|
+
# different, real model. Found via the Session 7 golden corpus, not guessed.
|
|
19
|
+
_OPENAI = (
|
|
20
|
+
r"\bgpt-(?:4o|4\.\d+|4|3\.5|5(?:\.\d+)?)(?:-turbo|-mini|-nano)?" + _OPENAI_DATE_SUFFIX + r"?\b"
|
|
21
|
+
)
|
|
22
|
+
_OPENAI_O_SERIES = r"\bo[134](?:-preview|-mini|-pro)?" + _OPENAI_DATE_SUFFIX + r"?\b"
|
|
23
|
+
_OPENAI_EMBEDDING = r"\btext-embedding-(?:ada-002|3-small|3-large)\b"
|
|
24
|
+
_OPENAI_OTHER = r"\b(?:dall-e-[23]|whisper-1)\b"
|
|
25
|
+
|
|
26
|
+
_ANTHROPIC_DATED = (
|
|
27
|
+
r"\bclaude-\d(?:[.-]\d)?(?:-(?:opus|sonnet|haiku|instant))?(?:-\d{8})?(?:-latest)?\b"
|
|
28
|
+
)
|
|
29
|
+
# Tier-before-version: covers both the old `claude-instant-1.2` (dotted) and the current
|
|
30
|
+
# `claude-opus-4-8` / `claude-haiku-4-5-20251001` (hyphenated) conventions.
|
|
31
|
+
_ANTHROPIC_NAMED = r"\bclaude-(?:opus|sonnet|haiku|fable|instant)-\d+(?:[.-]\d+)?(?:-\d{8})?\b"
|
|
32
|
+
|
|
33
|
+
_GOOGLE_GEMINI = (
|
|
34
|
+
r"\bgemini-\d(?:\.\d)?(?:-pro|-flash|-ultra)?(?:-vision)?(?:-\d{3})?(?:-preview|-exp)?\b"
|
|
35
|
+
)
|
|
36
|
+
_GOOGLE_BISON = r"\b(?:text|chat)-bison(?:-00[12])?\b"
|
|
37
|
+
|
|
38
|
+
MODEL_ID_PATTERN = re.compile(
|
|
39
|
+
"|".join(
|
|
40
|
+
[
|
|
41
|
+
_OPENAI,
|
|
42
|
+
_OPENAI_O_SERIES,
|
|
43
|
+
_OPENAI_EMBEDDING,
|
|
44
|
+
_OPENAI_OTHER,
|
|
45
|
+
_ANTHROPIC_DATED,
|
|
46
|
+
_ANTHROPIC_NAMED,
|
|
47
|
+
_GOOGLE_GEMINI,
|
|
48
|
+
_GOOGLE_BISON,
|
|
49
|
+
]
|
|
50
|
+
)
|
|
51
|
+
)
|
ebb/detect/python.py
ADDED
ebb/detect/registry.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from ebb.detect import dockerfile, json_format, notebook, python, terraform, toml, typescript, yaml
|
|
4
|
+
from ebb.detect.base import Detector
|
|
5
|
+
|
|
6
|
+
_BY_EXTENSION: dict[str, Detector] = {
|
|
7
|
+
".py": python.detect,
|
|
8
|
+
".ts": typescript.detect,
|
|
9
|
+
".tsx": typescript.detect,
|
|
10
|
+
".yml": yaml.detect,
|
|
11
|
+
".yaml": yaml.detect,
|
|
12
|
+
".toml": toml.detect,
|
|
13
|
+
".json": json_format.detect,
|
|
14
|
+
".tf": terraform.detect,
|
|
15
|
+
".ipynb": notebook.detect,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_detector(path: Path) -> Detector | None:
|
|
20
|
+
"""Adding a language: add one file under detect/, one entry here (or one more filename
|
|
21
|
+
rule below for extension-less formats), and one fixture under tests/fixtures/. Nothing
|
|
22
|
+
else in the walker or CLI needs to change."""
|
|
23
|
+
if path.name == "Dockerfile" or path.name.startswith("Dockerfile."):
|
|
24
|
+
return dockerfile.detect
|
|
25
|
+
return _BY_EXTENSION.get(path.suffix)
|
ebb/detect/scan_text.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from ebb.detect.base import RawMatch
|
|
5
|
+
from ebb.detect.patterns import MODEL_ID_PATTERN
|
|
6
|
+
|
|
7
|
+
# The identifier immediately before the nearest '=' or ':' preceding a match on its own line —
|
|
8
|
+
# covers Python/TypeScript assignments (MODEL = "..."), JSON/YAML/TOML keys ("model": "...",
|
|
9
|
+
# model: ...), and Dockerfile ENV/ARG forms (ENV MODEL=...). Language-agnostic on purpose: a
|
|
10
|
+
# real per-language symbol table (AST-scoped to the enclosing function/class) would be far more
|
|
11
|
+
# precise, but is a much bigger undertaking than this session's scope — this heuristic is
|
|
12
|
+
# "good enough to keep identity stable across reformatting," which is the actual requirement
|
|
13
|
+
# (SUITE_ARCHITECTURE.md §3.1), not "semantically perfect."
|
|
14
|
+
_ASSIGNMENT_PATTERN = re.compile(r"([A-Za-z_][A-Za-z0-9_.\-]*)[\"']?\s*[:=]")
|
|
15
|
+
_MAX_FALLBACK_SYMBOL_LENGTH = 40
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def nearest_symbol(line: str, match_start: int) -> str:
|
|
19
|
+
"""Never empty: falls back to a bounded prefix of the line itself (e.g. a bare string in a
|
|
20
|
+
list has no assignment to name it), and finally to "unknown" for a blank line — every match
|
|
21
|
+
gets a stable, non-empty symbol so identity never depends on an absent one."""
|
|
22
|
+
prefix = line[:match_start]
|
|
23
|
+
candidates = list(_ASSIGNMENT_PATTERN.finditer(prefix))
|
|
24
|
+
if candidates:
|
|
25
|
+
return candidates[-1].group(1)
|
|
26
|
+
stripped = line.strip()
|
|
27
|
+
return stripped[:_MAX_FALLBACK_SYMBOL_LENGTH] if stripped else "unknown"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def scan_lines(path: Path, content: str) -> list[RawMatch]:
|
|
31
|
+
"""Line-oriented regex scan shared by every format where a model id appears as plain text
|
|
32
|
+
inside otherwise-structured content (source code, YAML/TOML/JSON values, Terraform,
|
|
33
|
+
Dockerfiles). One RawMatch per occurrence — no dedup here, that's identity's job later
|
|
34
|
+
(SUITE_ARCHITECTURE.md §3.1), not the raw-match layer's."""
|
|
35
|
+
matches: list[RawMatch] = []
|
|
36
|
+
for lineno, line in enumerate(content.splitlines(), start=1):
|
|
37
|
+
for m in MODEL_ID_PATTERN.finditer(line):
|
|
38
|
+
matches.append(
|
|
39
|
+
RawMatch(
|
|
40
|
+
path=path,
|
|
41
|
+
line=lineno,
|
|
42
|
+
matched_text=m.group(0),
|
|
43
|
+
symbol=nearest_symbol(line, m.start()),
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return matches
|
ebb/detect/terraform.py
ADDED
ebb/detect/toml.py
ADDED
ebb/detect/typescript.py
ADDED
ebb/detect/yaml.py
ADDED
ebb/finding.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Re-export shim: the Finding schema moved to keel.finding in Session 26.
|
|
2
|
+
|
|
3
|
+
SUITE_ARCHITECTURE.md §3 always called this "the keystone: everything flows from this" and
|
|
4
|
+
listed all five products in ProductCode — but the module lived here, in ebb, and four of five
|
|
5
|
+
products never emitted a Finding at all. Session 26 moved it to packages/keel so the other
|
|
6
|
+
four can adopt it one at a time (telltale first), because every paid tier on the pricing page
|
|
7
|
+
is a query across products and that needs one shape in one table.
|
|
8
|
+
|
|
9
|
+
This shim keeps `from ebb.finding import ...` working unchanged for ebb's own eight importing
|
|
10
|
+
modules — same approach apps/telltale/src/telltale/walk.py (Session 10) and
|
|
11
|
+
apps/undertow-crawler/src/undertow_crawler/fetch.py (Session 20) already use for their own
|
|
12
|
+
extractions. ebb's behaviour did not change; its full test suite passing untouched is the
|
|
13
|
+
proof.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from keel.finding import (
|
|
17
|
+
Confidence,
|
|
18
|
+
Evidence,
|
|
19
|
+
Finding,
|
|
20
|
+
FindingState,
|
|
21
|
+
ProductCode,
|
|
22
|
+
Severity,
|
|
23
|
+
Verdict,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Confidence",
|
|
28
|
+
"Evidence",
|
|
29
|
+
"Finding",
|
|
30
|
+
"FindingState",
|
|
31
|
+
"ProductCode",
|
|
32
|
+
"Severity",
|
|
33
|
+
"Verdict",
|
|
34
|
+
]
|