slopguard-cli 0.1.1__tar.gz → 0.2.0__tar.gz
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.
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/PKG-INFO +1 -1
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/pyproject.toml +1 -1
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/__init__.py +1 -1
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/cli.py +183 -2
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/data/__init__.py +26 -2
- slopguard_cli-0.2.0/slopguard/saas/__init__.py +26 -0
- slopguard_cli-0.2.0/slopguard/saas/client.py +114 -0
- slopguard_cli-0.2.0/slopguard/saas/credentials.py +118 -0
- slopguard_cli-0.2.0/slopguard/update.py +94 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/test_cli.py +2 -2
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/test_misc.py +16 -4
- slopguard_cli-0.2.0/tests/test_saas_auth.py +190 -0
- slopguard_cli-0.2.0/tests/test_saas_client.py +186 -0
- slopguard_cli-0.2.0/tests/test_update.py +141 -0
- slopguard_cli-0.1.1/slopguard/update.py +0 -15
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/.gitignore +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/.ruff.toml +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/Makefile +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/README.md +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/scripts/generate_seed_data.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/__main__.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/config.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/data/hallucinations_seed.json +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/data/popular_packages.json +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/models.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/parsers/__init__.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/parsers/base.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/parsers/npm.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/parsers/python.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/registry/__init__.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/registry/base.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/registry/npm.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/registry/pypi.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/report/__init__.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/report/json.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/report/terminal.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/scoring/__init__.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/scoring/engine.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/scoring/signals.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/__init__.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/conftest.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/fixtures/.slopguard.yaml +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/fixtures/package.json +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/fixtures/pyproject.toml +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/fixtures/requirements.txt +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/test_parsers_npm.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/test_parsers_python.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/test_registry_npm.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/test_registry_pypi.py +0 -0
- {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/test_scoring_engine.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slopguard-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Defend developers and AI coding agents against slopsquatting (hallucinated package names).
|
|
5
5
|
Project-URL: Homepage, https://github.com/hariomunknownslab/slopguard
|
|
6
6
|
Project-URL: Repository, https://github.com/hariomunknownslab/slopguard
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "slopguard-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Defend developers and AI coding agents against slopsquatting (hallucinated package names)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -270,6 +270,13 @@ def scan_cmd(
|
|
|
270
270
|
int | None, typer.Option("--concurrency", help="Maximum concurrent registry probes.")
|
|
271
271
|
] = None,
|
|
272
272
|
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show debug logs.")] = False,
|
|
273
|
+
upload: Annotated[
|
|
274
|
+
bool,
|
|
275
|
+
typer.Option(
|
|
276
|
+
"--upload",
|
|
277
|
+
help="Upload the scan to the SlopGuard SaaS after scanning. Requires `slopguard login`.",
|
|
278
|
+
),
|
|
279
|
+
] = False,
|
|
273
280
|
) -> None:
|
|
274
281
|
"""Scan a project for slopsquatted / hallucinated dependencies."""
|
|
275
282
|
if verbose:
|
|
@@ -304,18 +311,192 @@ def scan_cmd(
|
|
|
304
311
|
else:
|
|
305
312
|
render_terminal_report(report, duration_seconds=duration)
|
|
306
313
|
|
|
314
|
+
if upload:
|
|
315
|
+
_upload_report(report, duration_ms=int(duration * 1000))
|
|
316
|
+
|
|
307
317
|
raise typer.Exit(code=report.exit_code)
|
|
308
318
|
|
|
309
319
|
|
|
320
|
+
def _upload_report(report: ScanReport, *, duration_ms: int) -> None:
|
|
321
|
+
"""Persist a completed ScanReport to the SaaS via POST /v1/scans."""
|
|
322
|
+
from slopguard.saas import load_credentials
|
|
323
|
+
from slopguard.saas.client import ApiError, list_projects, post_scan
|
|
324
|
+
|
|
325
|
+
creds = load_credentials()
|
|
326
|
+
if creds is None:
|
|
327
|
+
raise _error_exit("upload requires `slopguard login` first.")
|
|
328
|
+
if creds.organization_id is None:
|
|
329
|
+
raise _error_exit(
|
|
330
|
+
"credentials missing organization_id; run `slopguard logout` then `slopguard login` again."
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
projects = list_projects(creds.api_url, creds.token, creds.organization_id)
|
|
335
|
+
except ApiError as exc:
|
|
336
|
+
raise _error_exit(f"could not list projects: {exc.detail} (status {exc.status})") from exc
|
|
337
|
+
if not projects:
|
|
338
|
+
raise _error_exit("no project on this organization; visit the dashboard and create one.")
|
|
339
|
+
project_id = projects[0]["id"]
|
|
340
|
+
|
|
341
|
+
payload: dict[str, object] = {
|
|
342
|
+
"project_id": project_id,
|
|
343
|
+
"report": report.model_dump(mode="json"),
|
|
344
|
+
"source": _detect_runner(),
|
|
345
|
+
"duration_ms": duration_ms,
|
|
346
|
+
}
|
|
347
|
+
try:
|
|
348
|
+
created = post_scan(creds.api_url, creds.token, payload)
|
|
349
|
+
except ApiError as exc:
|
|
350
|
+
raise _error_exit(f"upload failed: {exc.detail} (status {exc.status})") from exc
|
|
351
|
+
|
|
352
|
+
Console().print(
|
|
353
|
+
f"[green]Uploaded[/green] to {creds.api_url}/app/{creds.org_slug}/scans/{created['id']}"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _detect_runner() -> str:
|
|
358
|
+
import os
|
|
359
|
+
|
|
360
|
+
if os.environ.get("GITHUB_ACTIONS") == "true":
|
|
361
|
+
return "ci"
|
|
362
|
+
if os.environ.get("CI") == "true":
|
|
363
|
+
return "ci"
|
|
364
|
+
return "cli"
|
|
365
|
+
|
|
366
|
+
|
|
310
367
|
@app.command("version")
|
|
311
368
|
def version_cmd() -> None:
|
|
312
369
|
"""Print the SlopGuard version and exit 0."""
|
|
313
370
|
print(__version__)
|
|
314
371
|
|
|
315
372
|
|
|
316
|
-
@app.command("update"
|
|
373
|
+
@app.command("update")
|
|
317
374
|
def update_cmd() -> None:
|
|
318
|
-
"""
|
|
375
|
+
"""Refresh the local hallucination DB from the SlopGuard SaaS feed.
|
|
376
|
+
|
|
377
|
+
No auth required. Downloads the public registry from
|
|
378
|
+
``$SLOPGUARD_API_URL/v1/hallucinations`` (default: the SlopGuard SaaS)
|
|
379
|
+
and caches it at ``~/.cache/slopguard/hallucinations_db.json`` for the
|
|
380
|
+
next scan to read.
|
|
381
|
+
"""
|
|
319
382
|
from slopguard.update import run
|
|
320
383
|
|
|
321
384
|
raise typer.Exit(code=run())
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# --- SaaS auth subcommands -------------------------------------------------
|
|
388
|
+
#
|
|
389
|
+
# Free, unauthenticated scanning never touches these — the imports below are
|
|
390
|
+
# deferred so the CLI keeps starting fast and offline.
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _resolve_api_url(flag_value: str | None) -> str:
|
|
394
|
+
"""API URL resolution order: --api-url > $SLOPGUARD_API_URL > default."""
|
|
395
|
+
import os
|
|
396
|
+
|
|
397
|
+
from slopguard.saas.client import DEFAULT_API_URL
|
|
398
|
+
|
|
399
|
+
return flag_value or os.environ.get("SLOPGUARD_API_URL") or DEFAULT_API_URL
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@app.command("login")
|
|
403
|
+
def login_cmd(
|
|
404
|
+
token: Annotated[
|
|
405
|
+
str | None,
|
|
406
|
+
typer.Option(
|
|
407
|
+
"--token",
|
|
408
|
+
help="API token. If omitted, you'll be prompted (paste hidden).",
|
|
409
|
+
),
|
|
410
|
+
] = None,
|
|
411
|
+
api_url: Annotated[
|
|
412
|
+
str | None,
|
|
413
|
+
typer.Option(
|
|
414
|
+
"--api-url",
|
|
415
|
+
help="SlopGuard API base URL. Defaults to $SLOPGUARD_API_URL or production.",
|
|
416
|
+
),
|
|
417
|
+
] = None,
|
|
418
|
+
) -> None:
|
|
419
|
+
"""Authenticate the CLI with a SlopGuard API token.
|
|
420
|
+
|
|
421
|
+
Mint a token in the dashboard at *<api>/app/<org>/tokens* and paste it
|
|
422
|
+
here. The token is stored at ``~/.config/slopguard/credentials.toml``
|
|
423
|
+
with mode 0600.
|
|
424
|
+
"""
|
|
425
|
+
from slopguard.saas import Credentials, save_credentials
|
|
426
|
+
from slopguard.saas.client import ApiError, get_me
|
|
427
|
+
|
|
428
|
+
api = _resolve_api_url(api_url)
|
|
429
|
+
plaintext = token or typer.prompt("API token (input hidden)", hide_input=True)
|
|
430
|
+
if not plaintext.startswith(("sg_live_", "sg_test_")):
|
|
431
|
+
raise _error_exit(
|
|
432
|
+
"token must start with sg_live_ or sg_test_; mint one at "
|
|
433
|
+
f"{api.rstrip('/')}/app/<org-slug>/tokens"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
me = get_me(api, plaintext)
|
|
438
|
+
except ApiError as exc:
|
|
439
|
+
raise _error_exit(
|
|
440
|
+
f"could not verify token against {api}: {exc.detail} (status {exc.status})"
|
|
441
|
+
) from exc
|
|
442
|
+
|
|
443
|
+
save_credentials(
|
|
444
|
+
Credentials(
|
|
445
|
+
api_url=api,
|
|
446
|
+
token=plaintext,
|
|
447
|
+
org_slug=me.active_org.slug,
|
|
448
|
+
organization_id=me.active_org.id,
|
|
449
|
+
user_email=me.email,
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
console = Console()
|
|
454
|
+
console.print(
|
|
455
|
+
f"[green]Authenticated.[/green] Org: [bold]{me.active_org.name}[/bold] "
|
|
456
|
+
f"([cyan]{me.active_org.slug}[/cyan], plan: {me.active_org.plan})"
|
|
457
|
+
)
|
|
458
|
+
console.print("Credentials saved to ~/.config/slopguard/credentials.toml (mode 600).")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@app.command("whoami")
|
|
462
|
+
def whoami_cmd() -> None:
|
|
463
|
+
"""Print the active org context for the saved credentials."""
|
|
464
|
+
from slopguard.saas import load_credentials
|
|
465
|
+
from slopguard.saas.client import ApiError, get_me
|
|
466
|
+
|
|
467
|
+
creds = load_credentials()
|
|
468
|
+
if creds is None:
|
|
469
|
+
raise _error_exit(
|
|
470
|
+
"not authenticated. Run `slopguard login` first.",
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
me = get_me(creds.api_url, creds.token)
|
|
475
|
+
except ApiError as exc:
|
|
476
|
+
if exc.status in (401, 403):
|
|
477
|
+
raise _error_exit(
|
|
478
|
+
"saved token rejected. Run `slopguard logout` then `slopguard login` again."
|
|
479
|
+
) from exc
|
|
480
|
+
raise _error_exit(f"API call failed: {exc.detail} (status {exc.status})") from exc
|
|
481
|
+
|
|
482
|
+
console = Console()
|
|
483
|
+
console.print(f"[bold]Email:[/bold] {me.email or '—'}")
|
|
484
|
+
console.print(
|
|
485
|
+
f"[bold]Active org:[/bold] {me.active_org.name} ([cyan]{me.active_org.slug}[/cyan])"
|
|
486
|
+
)
|
|
487
|
+
console.print(f"[bold]Role:[/bold] {me.active_org.role}")
|
|
488
|
+
console.print(f"[bold]Plan:[/bold] {me.active_org.plan}")
|
|
489
|
+
console.print(f"[bold]API:[/bold] {creds.api_url}")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@app.command("logout")
|
|
493
|
+
def logout_cmd() -> None:
|
|
494
|
+
"""Forget the saved credentials."""
|
|
495
|
+
from slopguard.saas import delete_credentials
|
|
496
|
+
|
|
497
|
+
removed = delete_credentials()
|
|
498
|
+
console = Console()
|
|
499
|
+
if removed:
|
|
500
|
+
console.print("[green]Logged out.[/green] Credentials file removed.")
|
|
501
|
+
else:
|
|
502
|
+
console.print("[yellow]Not logged in.[/yellow] No credentials file to remove.")
|
|
@@ -5,14 +5,38 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
from functools import lru_cache
|
|
7
7
|
from importlib.resources import files
|
|
8
|
+
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
from slopguard.models import Ecosystem, HallucinationDB, HallucinationEntry, PopularPackages
|
|
10
11
|
|
|
12
|
+
# Override path written by ``slopguard update``. When present, it takes
|
|
13
|
+
# precedence over the bundled seed so users get the freshest list
|
|
14
|
+
# without reinstalling.
|
|
15
|
+
CACHE_OVERRIDE = Path.home() / ".cache" / "slopguard" / "hallucinations_db.json"
|
|
16
|
+
|
|
11
17
|
|
|
12
18
|
@lru_cache(maxsize=1)
|
|
13
19
|
def load_hallucination_db() -> HallucinationDB:
|
|
14
|
-
"""Load and validate the
|
|
15
|
-
|
|
20
|
+
"""Load and validate the hallucination database.
|
|
21
|
+
|
|
22
|
+
Prefers the user cache (refreshed by ``slopguard update``) over the
|
|
23
|
+
embedded seed. Falls back transparently if the cache is missing or
|
|
24
|
+
malformed.
|
|
25
|
+
"""
|
|
26
|
+
raw: str
|
|
27
|
+
if CACHE_OVERRIDE.is_file():
|
|
28
|
+
try:
|
|
29
|
+
raw = CACHE_OVERRIDE.read_text(encoding="utf-8")
|
|
30
|
+
except OSError:
|
|
31
|
+
raw = (
|
|
32
|
+
files("slopguard.data")
|
|
33
|
+
.joinpath("hallucinations_seed.json")
|
|
34
|
+
.read_text(encoding="utf-8")
|
|
35
|
+
)
|
|
36
|
+
else:
|
|
37
|
+
raw = (
|
|
38
|
+
files("slopguard.data").joinpath("hallucinations_seed.json").read_text(encoding="utf-8")
|
|
39
|
+
)
|
|
16
40
|
payload = json.loads(raw)
|
|
17
41
|
# Strip non-schema keys (operator notes, etc.) before validating.
|
|
18
42
|
keep = {"schema_version", "updated", "entries"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""SaaS integration: credentials store + HTTP client for the SlopGuard API.
|
|
2
|
+
|
|
3
|
+
Free, unauthenticated CLI invocations never touch this module. It is only
|
|
4
|
+
imported when the user runs ``slopguard login``, ``slopguard whoami``,
|
|
5
|
+
``slopguard logout``, or ``slopguard scan --upload``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from slopguard.saas.credentials import (
|
|
11
|
+
DEFAULT_CREDENTIALS_PATH,
|
|
12
|
+
Credentials,
|
|
13
|
+
CredentialsError,
|
|
14
|
+
delete_credentials,
|
|
15
|
+
load_credentials,
|
|
16
|
+
save_credentials,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DEFAULT_CREDENTIALS_PATH",
|
|
21
|
+
"Credentials",
|
|
22
|
+
"CredentialsError",
|
|
23
|
+
"delete_credentials",
|
|
24
|
+
"load_credentials",
|
|
25
|
+
"save_credentials",
|
|
26
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Tiny HTTP client for the SlopGuard API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
DEFAULT_API_URL = "https://api.slopguard.io"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ApiError(Exception):
|
|
14
|
+
def __init__(self, status: int, detail: str) -> None:
|
|
15
|
+
self.status = status
|
|
16
|
+
self.detail = detail
|
|
17
|
+
super().__init__(f"API {status}: {detail}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class MeOrg:
|
|
22
|
+
id: str
|
|
23
|
+
name: str
|
|
24
|
+
slug: str
|
|
25
|
+
plan: str
|
|
26
|
+
role: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class MeResponse:
|
|
31
|
+
user_id: str | None
|
|
32
|
+
email: str | None
|
|
33
|
+
principal_kind: str
|
|
34
|
+
active_org: MeOrg
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_me(api_url: str, token: str, *, timeout: float = 10.0) -> MeResponse:
|
|
38
|
+
"""Synchronous /v1/me call. Used by login/whoami/--upload."""
|
|
39
|
+
try:
|
|
40
|
+
response = httpx.get(
|
|
41
|
+
f"{api_url.rstrip('/')}/v1/me",
|
|
42
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
43
|
+
timeout=timeout,
|
|
44
|
+
)
|
|
45
|
+
except httpx.RequestError as exc:
|
|
46
|
+
raise ApiError(0, f"network error: {exc}") from exc
|
|
47
|
+
|
|
48
|
+
if response.status_code != 200:
|
|
49
|
+
detail = _extract_detail(response)
|
|
50
|
+
raise ApiError(response.status_code, detail)
|
|
51
|
+
payload = response.json()
|
|
52
|
+
active = payload["active_organization"]
|
|
53
|
+
return MeResponse(
|
|
54
|
+
user_id=payload.get("user_id"),
|
|
55
|
+
email=payload.get("email"),
|
|
56
|
+
principal_kind=payload["principal_kind"],
|
|
57
|
+
active_org=MeOrg(
|
|
58
|
+
id=active["id"],
|
|
59
|
+
name=active["name"],
|
|
60
|
+
slug=active["slug"],
|
|
61
|
+
plan=active["plan"],
|
|
62
|
+
role=active["role"],
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def post_scan(
|
|
68
|
+
api_url: str, token: str, payload: dict[str, Any], *, timeout: float = 30.0
|
|
69
|
+
) -> dict[str, Any]:
|
|
70
|
+
"""POST /v1/scans. Returns the parsed ScanSummary response."""
|
|
71
|
+
try:
|
|
72
|
+
response = httpx.post(
|
|
73
|
+
f"{api_url.rstrip('/')}/v1/scans",
|
|
74
|
+
headers={
|
|
75
|
+
"Authorization": f"Bearer {token}",
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
},
|
|
78
|
+
json=payload,
|
|
79
|
+
timeout=timeout,
|
|
80
|
+
)
|
|
81
|
+
except httpx.RequestError as exc:
|
|
82
|
+
raise ApiError(0, f"network error: {exc}") from exc
|
|
83
|
+
if response.status_code not in (200, 201):
|
|
84
|
+
raise ApiError(response.status_code, _extract_detail(response))
|
|
85
|
+
return response.json() # type: ignore[no-any-return]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def list_projects(
|
|
89
|
+
api_url: str, token: str, organization_id: str, *, timeout: float = 10.0
|
|
90
|
+
) -> list[dict[str, Any]]:
|
|
91
|
+
try:
|
|
92
|
+
response = httpx.get(
|
|
93
|
+
f"{api_url.rstrip('/')}/v1/projects",
|
|
94
|
+
params={"organization_id": organization_id},
|
|
95
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
96
|
+
timeout=timeout,
|
|
97
|
+
)
|
|
98
|
+
except httpx.RequestError as exc:
|
|
99
|
+
raise ApiError(0, f"network error: {exc}") from exc
|
|
100
|
+
if response.status_code != 200:
|
|
101
|
+
raise ApiError(response.status_code, _extract_detail(response))
|
|
102
|
+
return response.json() # type: ignore[no-any-return]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _extract_detail(response: httpx.Response) -> str:
|
|
106
|
+
try:
|
|
107
|
+
body = response.json()
|
|
108
|
+
except ValueError:
|
|
109
|
+
body = None
|
|
110
|
+
if isinstance(body, dict):
|
|
111
|
+
detail = body.get("detail")
|
|
112
|
+
if isinstance(detail, str):
|
|
113
|
+
return detail
|
|
114
|
+
return response.text[:200] or response.reason_phrase
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Read/write the CLI credentials file.
|
|
2
|
+
|
|
3
|
+
Stored at ``~/.config/slopguard/credentials.toml`` with mode 0600 so other
|
|
4
|
+
users on the machine cannot read it (spec §6.6).
|
|
5
|
+
|
|
6
|
+
Layout::
|
|
7
|
+
|
|
8
|
+
[active]
|
|
9
|
+
api_url = "https://api.slopguard.io"
|
|
10
|
+
token = "sg_live_..."
|
|
11
|
+
org_slug = "acme-security"
|
|
12
|
+
organization_id = "..."
|
|
13
|
+
user_email = "harry@unknownslab.com"
|
|
14
|
+
|
|
15
|
+
A future ``slopguard switch`` could let multiple profiles live side-by-
|
|
16
|
+
side; for now only ``[active]`` is read or written.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import tomllib
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
DEFAULT_CREDENTIALS_PATH = Path.home() / ".config" / "slopguard" / "credentials.toml"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CredentialsError(Exception):
|
|
30
|
+
"""Raised when credentials cannot be read or written."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Credentials:
|
|
35
|
+
api_url: str
|
|
36
|
+
token: str
|
|
37
|
+
org_slug: str | None = None
|
|
38
|
+
organization_id: str | None = None
|
|
39
|
+
user_email: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_credentials(path: Path | None = None) -> Credentials | None:
|
|
43
|
+
"""Return the active credentials, or None if the file doesn't exist."""
|
|
44
|
+
if path is None:
|
|
45
|
+
path = DEFAULT_CREDENTIALS_PATH
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
with path.open("rb") as fh:
|
|
50
|
+
data = tomllib.load(fh)
|
|
51
|
+
except tomllib.TOMLDecodeError as exc:
|
|
52
|
+
raise CredentialsError(f"invalid credentials file {path}: {exc}") from exc
|
|
53
|
+
|
|
54
|
+
active = data.get("active")
|
|
55
|
+
if not isinstance(active, dict):
|
|
56
|
+
return None
|
|
57
|
+
token = active.get("token")
|
|
58
|
+
api_url = active.get("api_url")
|
|
59
|
+
if not isinstance(token, str) or not isinstance(api_url, str):
|
|
60
|
+
return None
|
|
61
|
+
return Credentials(
|
|
62
|
+
api_url=api_url,
|
|
63
|
+
token=token,
|
|
64
|
+
org_slug=active.get("org_slug") if isinstance(active.get("org_slug"), str) else None,
|
|
65
|
+
organization_id=(
|
|
66
|
+
active.get("organization_id")
|
|
67
|
+
if isinstance(active.get("organization_id"), str)
|
|
68
|
+
else None
|
|
69
|
+
),
|
|
70
|
+
user_email=(
|
|
71
|
+
active.get("user_email") if isinstance(active.get("user_email"), str) else None
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def save_credentials(creds: Credentials, path: Path | None = None) -> None:
|
|
77
|
+
"""Write ``creds`` to the file at ``path``. Creates parent dirs and chmod 0600."""
|
|
78
|
+
if path is None:
|
|
79
|
+
path = DEFAULT_CREDENTIALS_PATH
|
|
80
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
body = _render(creds)
|
|
82
|
+
# Write to a temp sibling then atomic-rename so a crash mid-write can't
|
|
83
|
+
# leave a half-written credentials file.
|
|
84
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
85
|
+
tmp.write_text(body, encoding="utf-8")
|
|
86
|
+
os.chmod(tmp, 0o600)
|
|
87
|
+
tmp.replace(path)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def delete_credentials(path: Path | None = None) -> bool:
|
|
91
|
+
"""Remove the credentials file. Returns True if a file was deleted."""
|
|
92
|
+
if path is None:
|
|
93
|
+
path = DEFAULT_CREDENTIALS_PATH
|
|
94
|
+
if not path.exists():
|
|
95
|
+
return False
|
|
96
|
+
path.unlink()
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _render(creds: Credentials) -> str:
|
|
101
|
+
lines = [
|
|
102
|
+
"# slopguard CLI credentials — do not share. chmod 600.",
|
|
103
|
+
"",
|
|
104
|
+
"[active]",
|
|
105
|
+
f'api_url = "{_escape(creds.api_url)}"',
|
|
106
|
+
f'token = "{_escape(creds.token)}"',
|
|
107
|
+
]
|
|
108
|
+
if creds.org_slug:
|
|
109
|
+
lines.append(f'org_slug = "{_escape(creds.org_slug)}"')
|
|
110
|
+
if creds.organization_id:
|
|
111
|
+
lines.append(f'organization_id = "{_escape(creds.organization_id)}"')
|
|
112
|
+
if creds.user_email:
|
|
113
|
+
lines.append(f'user_email = "{_escape(creds.user_email)}"')
|
|
114
|
+
return "\n".join(lines) + "\n"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _escape(value: str) -> str:
|
|
118
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""``slopguard update`` — refresh the local hallucination DB.
|
|
2
|
+
|
|
3
|
+
Fetches the curated DB from the SlopGuard GitHub Pages site (no API,
|
|
4
|
+
no account, no auth) and writes a seed-shaped file to
|
|
5
|
+
``~/.cache/slopguard/hallucinations_db.json``. The next ``slopguard
|
|
6
|
+
scan`` prefers this cached file over the bundled seed (see
|
|
7
|
+
``slopguard.data.load_hallucination_db``).
|
|
8
|
+
|
|
9
|
+
Source of truth:
|
|
10
|
+
https://hariomunknownslab.github.io/slopguard/db.json
|
|
11
|
+
|
|
12
|
+
Updated daily by the probe-cron GitHub Action; each entry goes through
|
|
13
|
+
PR review before publication (see probe-data/README.md in the repo).
|
|
14
|
+
|
|
15
|
+
Override the URL with ``SLOPGUARD_DB_URL`` to fetch from a fork or
|
|
16
|
+
mirror. The legacy ``SLOPGUARD_API_URL`` env var still works for the
|
|
17
|
+
0.1.x SaaS endpoint shape — used as a fallback for backward
|
|
18
|
+
compatibility.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
DEFAULT_DB_URL = "https://hariomunknownslab.github.io/slopguard/db.json"
|
|
32
|
+
CACHE_PATH = Path.home() / ".cache" / "slopguard" / "hallucinations_db.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _db_url() -> str:
|
|
36
|
+
"""Where to fetch the published DB.
|
|
37
|
+
|
|
38
|
+
Precedence: ``SLOPGUARD_DB_URL`` > legacy
|
|
39
|
+
``SLOPGUARD_API_URL/v1/hallucinations`` > default GitHub Pages.
|
|
40
|
+
"""
|
|
41
|
+
if explicit := os.environ.get("SLOPGUARD_DB_URL"):
|
|
42
|
+
return explicit
|
|
43
|
+
if legacy := os.environ.get("SLOPGUARD_API_URL"):
|
|
44
|
+
return legacy.rstrip("/") + "/v1/hallucinations"
|
|
45
|
+
return DEFAULT_DB_URL
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _convert(wire: dict[str, Any]) -> dict[str, Any]:
|
|
49
|
+
"""Map the API wire payload to the local seed schema."""
|
|
50
|
+
entries: list[dict[str, Any]] = []
|
|
51
|
+
for row in wire.get("entries", []):
|
|
52
|
+
entries.append(
|
|
53
|
+
{
|
|
54
|
+
"name": row["package_name"],
|
|
55
|
+
"ecosystem": row["ecosystem"],
|
|
56
|
+
"first_seen": row["first_observed_at"][:10],
|
|
57
|
+
"recurrence_rate": row["recurrence_rate"],
|
|
58
|
+
"models_observed": [],
|
|
59
|
+
"notes": (
|
|
60
|
+
f"Promoted from SlopGuard probe — {row['total_observations']}x "
|
|
61
|
+
f"observations, published {row['published_at'][:10]}"
|
|
62
|
+
),
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
return {
|
|
66
|
+
"schema_version": 1,
|
|
67
|
+
"updated": wire.get("generated_at", datetime.now(tz=UTC).isoformat())[:10],
|
|
68
|
+
"entries": entries,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run() -> int:
|
|
73
|
+
url = _db_url()
|
|
74
|
+
try:
|
|
75
|
+
resp = httpx.get(url, timeout=15.0)
|
|
76
|
+
resp.raise_for_status()
|
|
77
|
+
except httpx.HTTPError as exc:
|
|
78
|
+
print(f"slopguard update: failed to reach {url}: {exc}")
|
|
79
|
+
return 1
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
payload = _convert(resp.json())
|
|
83
|
+
except (ValueError, KeyError) as exc:
|
|
84
|
+
print(f"slopguard update: invalid response payload ({exc})")
|
|
85
|
+
return 1
|
|
86
|
+
|
|
87
|
+
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
tmp = CACHE_PATH.with_suffix(".json.tmp")
|
|
89
|
+
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
90
|
+
tmp.replace(CACHE_PATH)
|
|
91
|
+
|
|
92
|
+
count = len(payload["entries"])
|
|
93
|
+
print(f"slopguard update: cached {count} entries -> {CACHE_PATH}")
|
|
94
|
+
return 0
|
|
@@ -107,7 +107,7 @@ def runner() -> CliRunner:
|
|
|
107
107
|
def test_version_subcommand(runner: CliRunner) -> None:
|
|
108
108
|
result = runner.invoke(app, ["version"])
|
|
109
109
|
assert result.exit_code == 0
|
|
110
|
-
assert result.stdout.strip() == "0.
|
|
110
|
+
assert result.stdout.strip() == "0.2.0"
|
|
111
111
|
|
|
112
112
|
|
|
113
113
|
def test_scan_fixtures_no_network_terminal(runner: CliRunner, fixtures_dir: Path) -> None:
|
|
@@ -183,4 +183,4 @@ def test_module_main_runs() -> None:
|
|
|
183
183
|
check=False,
|
|
184
184
|
)
|
|
185
185
|
assert result.returncode == 0
|
|
186
|
-
assert result.stdout.strip() == "0.
|
|
186
|
+
assert result.stdout.strip() == "0.2.0"
|
|
@@ -32,10 +32,22 @@ def test_main_entry_invokes_typer(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
32
32
|
assert exc_info.value.code == 0
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
@respx.mock
|
|
36
|
+
def test_update_writes_cache_when_api_reachable(
|
|
37
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Smoke test: with a mocked API the update path returns 0 and writes a cache."""
|
|
40
|
+
from slopguard import update
|
|
41
|
+
|
|
42
|
+
monkeypatch.setattr(update, "CACHE_PATH", tmp_path / "h.json")
|
|
43
|
+
monkeypatch.setenv("SLOPGUARD_API_URL", "http://api.test")
|
|
44
|
+
respx.get("http://api.test/v1/hallucinations").mock(
|
|
45
|
+
return_value=httpx.Response(
|
|
46
|
+
200,
|
|
47
|
+
json={"generated_at": "2026-05-23T22:00:00Z", "count": 0, "entries": []},
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
assert update.run() == 0
|
|
39
51
|
|
|
40
52
|
|
|
41
53
|
def test_npm_parser_lockfile_v1(tmp_path: Path) -> None:
|