@lateos/npm-scan 0.7.5 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/api/api_keys.py DELETED
@@ -1,55 +0,0 @@
1
- """API key management for the npm-scan hosted tier.
2
-
3
- Handles:
4
- - Key generation (prefixed + hashed for storage)
5
- - Key validation against stored hash
6
- - Scope-based access control per key
7
- - Key rotation and revocation
8
- """
9
-
10
- import os
11
- import uuid
12
- import hashlib
13
- import hmac
14
- import secrets
15
- from datetime import datetime, timezone, timedelta
16
- from typing import Optional
17
-
18
-
19
- API_KEY_PREFIX = "npm-scan-api-"
20
-
21
-
22
- def generate_api_key(name: str = "default", scopes: list[str] = None) -> tuple[str, str]:
23
- """Generate an API key and return (raw_key, hashed_key).
24
-
25
- The raw key is returned once for the user to store securely.
26
- Only the hashed version is persisted.
27
- """
28
- raw = API_KEY_PREFIX + secrets.token_urlsafe(32)
29
- key_id = str(uuid.uuid4())
30
- salt = secrets.token_hex(8)
31
- hashed = hashlib.pbkdf2_hmac("sha256", raw.encode(), salt.encode(), 100_000).hex()
32
- stored = f"{salt}${hashed}"
33
- return raw, stored
34
-
35
-
36
- def validate_api_key(raw_key: str, stored_hash: str) -> bool:
37
- """Check a raw key against its stored PBKDF2 hash."""
38
- try:
39
- salt, hashed = stored_hash.split("$", 1)
40
- check = hashlib.pbkdf2_hmac("sha256", raw_key.encode(), salt.encode(), 100_000).hex()
41
- return hmac.compare_digest(check, hashed)
42
- except (ValueError, IndexError):
43
- return False
44
-
45
-
46
- def is_api_key(raw_key: str) -> bool:
47
- """Check if a string looks like an npm-scan API key."""
48
- return raw_key.startswith(API_KEY_PREFIX)
49
-
50
-
51
- def redact_key(raw_key: str) -> str:
52
- """Show only the last 8 chars of a key for logging."""
53
- if len(raw_key) <= 16:
54
- return "***"
55
- return raw_key[-8:].rjust(len(raw_key), "*")
package/api/deps.py DELETED
@@ -1,164 +0,0 @@
1
- """Shared auth dependencies for the npm-scan API.
2
-
3
- Handles:
4
- - JWT access + refresh tokens
5
- - API key validation
6
- - Session management
7
- - RBAC enforcement
8
- - License feature gating
9
- """
10
-
11
- import os
12
- import uuid
13
- import json
14
- from datetime import datetime, timedelta, timezone
15
- from typing import Optional
16
- from fastapi import Header, HTTPException, status, Depends, Request
17
- from pydantic import BaseModel
18
- from jose import jwt, JWTError
19
-
20
- JWT_SECRET = os.environ.get("JWT_SECRET", os.urandom(32).hex())
21
- JWT_ALGORITHM = "HS256"
22
- ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("JWT_EXPIRE_MINUTES", "60"))
23
- REFRESH_TOKEN_EXPIRE_DAYS = int(os.environ.get("JWT_REFRESH_DAYS", "30"))
24
-
25
-
26
- class UserSession(BaseModel):
27
- user_id: str
28
- email: str
29
- name: str
30
- team_id: Optional[str] = None
31
- role: str = "viewer"
32
- auth_method: str = "password" # password, saml, api_key
33
- idp: Optional[str] = None
34
-
35
-
36
- class TokenPayload(BaseModel):
37
- sub: str # user_id
38
- email: str
39
- role: str
40
- team_id: Optional[str] = None
41
- exp: datetime
42
- iat: datetime
43
- jti: str
44
-
45
-
46
- def create_access_token(session: UserSession) -> str:
47
- """Create a JWT access token from a user session."""
48
- now = datetime.now(timezone.utc)
49
- payload = {
50
- "sub": session.user_id,
51
- "email": session.email,
52
- "role": session.role,
53
- "team_id": session.team_id,
54
- "iat": now,
55
- "exp": now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
56
- "jti": str(uuid.uuid4()),
57
- }
58
- return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
59
-
60
-
61
- def create_refresh_token(session: UserSession) -> str:
62
- """Create a long-lived refresh token."""
63
- now = datetime.now(timezone.utc)
64
- payload = {
65
- "sub": session.user_id,
66
- "type": "refresh",
67
- "jti": str(uuid.uuid4()),
68
- "iat": now,
69
- "exp": now + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
70
- }
71
- return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
72
-
73
-
74
- def verify_token(token: str) -> TokenPayload:
75
- """Verify and decode a JWT token. Raises 401 on failure."""
76
- try:
77
- payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
78
- return TokenPayload(**payload)
79
- except JWTError:
80
- raise HTTPException(
81
- status_code=status.HTTP_401_UNAUTHORIZED,
82
- detail="Invalid or expired token",
83
- headers={"WWW-Authenticate": "Bearer"},
84
- )
85
-
86
-
87
- async def get_current_user(
88
- authorization: Optional[str] = Header(None),
89
- ) -> UserSession:
90
- """Dependency: extracts authenticated user from Bearer token or API key."""
91
- if not authorization:
92
- raise HTTPException(
93
- status_code=status.HTTP_401_UNAUTHORIZED,
94
- detail="Missing Authorization header",
95
- )
96
-
97
- scheme, _, token = authorization.partition(" ")
98
- if scheme.lower() != "bearer" or not token:
99
- raise HTTPException(
100
- status_code=status.HTTP_401_UNAUTHORIZED,
101
- detail="Invalid authorization scheme. Use: Bearer <token>",
102
- )
103
-
104
- # Try JWT first
105
- try:
106
- payload = verify_token(token)
107
- return UserSession(
108
- user_id=payload.sub,
109
- email=payload.email,
110
- name="",
111
- role=payload.role,
112
- team_id=payload.team_id,
113
- auth_method="token",
114
- )
115
- except HTTPException:
116
- pass
117
-
118
- # Try API key (lookup in-memory or PostgreSQL in production)
119
- # For now, validate format and return limited session
120
- if token.startswith("npm-scan-api-"):
121
- return UserSession(
122
- user_id="api-user",
123
- email="api@npm-scan.io",
124
- name="API User",
125
- role="viewer",
126
- auth_method="api_key",
127
- )
128
-
129
- raise HTTPException(
130
- status_code=status.HTTP_401_UNAUTHORIZED,
131
- detail="Invalid authentication token",
132
- )
133
-
134
-
135
- def require_role(required_roles: list[str]):
136
- """Dependency factory: requires one of the specified roles."""
137
- async def _check(current_user: UserSession = Depends(get_current_user)):
138
- if current_user.role not in required_roles:
139
- raise HTTPException(
140
- status_code=status.HTTP_403_FORBIDDEN,
141
- detail=f"Requires one of these roles: {required_roles}",
142
- )
143
- return current_user
144
- return _check
145
-
146
-
147
- def require_feature(feature: str):
148
- """Dependency factory: requires a specific license feature flag."""
149
- async def _check():
150
- license_key = os.environ.get("NPM_SCAN_LICENSE_KEY", "")
151
- if not license_key:
152
- raise HTTPException(
153
- status_code=status.HTTP_402_PAYMENT_REQUIRED,
154
- detail=f"Feature '{feature}' requires a premium/enterprise license key",
155
- )
156
- # Delegate to Node.js license module via subprocess or bundled validation
157
- # For now, check if it's an enterprise key format
158
- if not license_key.startswith("npm-scan-"):
159
- raise HTTPException(
160
- status_code=status.HTTP_402_PAYMENT_REQUIRED,
161
- detail="Invalid license key format",
162
- )
163
- return True
164
- return _check
package/api/main.py DELETED
@@ -1,44 +0,0 @@
1
- """
2
- npm-scan REST API — FastAPI application.
3
- Requires premium/enterprise license key for all endpoints.
4
- """
5
-
6
- from fastapi import FastAPI, Depends, HTTPException, status
7
- from fastapi.middleware.cors import CORSMiddleware
8
- import os
9
-
10
- from .routers import scans, webhooks, auth, health, sso
11
-
12
- app = FastAPI(
13
- title="npm-scan API",
14
- version=os.environ.get("npm_package_version", "0.5.0"),
15
- description="npm supply chain security scanner — REST API",
16
- )
17
-
18
- app.add_middleware(
19
- CORSMiddleware,
20
- allow_origins=["*"],
21
- allow_credentials=True,
22
- allow_methods=["*"],
23
- allow_headers=["*"],
24
- )
25
-
26
- app.include_router(health.router, prefix="/api/v1", tags=["health"])
27
- app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
28
- app.include_router(sso.router, prefix="/api/v1", tags=["sso"])
29
- app.include_router(scans.router, prefix="/api/v1", tags=["scans"])
30
- app.include_router(webhooks.router, prefix="/api/v1", tags=["webhooks"])
31
-
32
-
33
- def main():
34
- import uvicorn
35
- uvicorn.run(
36
- "api.main:app",
37
- host=os.environ.get("API_HOST", "0.0.0.0"),
38
- port=int(os.environ.get("API_PORT", "8000")),
39
- reload=os.environ.get("API_RELOAD", "false").lower() == "true",
40
- )
41
-
42
-
43
- if __name__ == "__main__":
44
- main()
@@ -1,9 +0,0 @@
1
- fastapi>=0.115.0
2
- uvicorn[standard]>=0.32.0
3
- psycopg2-binary>=2.9.10
4
- python-jose[cryptography]>=3.3.0
5
- passlib[bcrypt]>=1.7.4
6
- httpx>=0.28.0
7
- pydantic>=2.10.0
8
- python3-saml>=1.16.0
9
- python3-saml>=1.16.0
File without changes
@@ -1,80 +0,0 @@
1
- """Authentication endpoints — login, register, API key management, SAML status."""
2
-
3
- from fastapi import APIRouter, HTTPException, Depends
4
- from fastapi.responses import RedirectResponse
5
- from pydantic import BaseModel, EmailStr
6
- from typing import Optional
7
- import os
8
-
9
- from ..deps import get_current_user, UserSession
10
- from ..saml import get_saml_config
11
-
12
- router = APIRouter()
13
-
14
-
15
- class RegisterRequest(BaseModel):
16
- email: EmailStr
17
- name: str
18
- password: str
19
- team_name: Optional[str] = None
20
-
21
-
22
- class LoginRequest(BaseModel):
23
- email: EmailStr
24
- password: str
25
-
26
-
27
- class TokenResponse(BaseModel):
28
- access_token: str
29
- token_type: str = "bearer"
30
-
31
-
32
- class AuthMethodsResponse(BaseModel):
33
- password: bool
34
- saml: bool
35
- saml_entity_id: Optional[str] = None
36
- saml_login_url: Optional[str] = None
37
-
38
-
39
- @router.get("/methods")
40
- async def auth_methods():
41
- """List available authentication methods for this instance."""
42
- saml_cfg = get_saml_config()
43
- saml_configured = saml_cfg.is_configured()
44
- return AuthMethodsResponse(
45
- password=True,
46
- saml=saml_configured,
47
- saml_entity_id=saml_cfg.entity_id if saml_configured else None,
48
- saml_login_url="/api/v1/sso/login" if saml_configured else None,
49
- )
50
-
51
-
52
- @router.get("/me")
53
- async def get_me(current_user: UserSession = Depends(get_current_user)):
54
- """Get the current authenticated user's profile."""
55
- return {
56
- "user_id": current_user.user_id,
57
- "email": current_user.email,
58
- "name": current_user.name,
59
- "role": current_user.role,
60
- "auth_method": current_user.auth_method,
61
- }
62
-
63
-
64
- @router.post("/register", response_model=TokenResponse)
65
- async def register(req: RegisterRequest):
66
- raise HTTPException(status_code=501, detail="Registration requires PostgreSQL backend — not yet connected")
67
-
68
-
69
- @router.post("/login", response_model=TokenResponse)
70
- async def login(req: LoginRequest):
71
- raise HTTPException(status_code=501, detail="Login requires PostgreSQL backend — not yet connected")
72
-
73
-
74
- @router.get("/saml")
75
- async def saml_redirect():
76
- """Convenience redirect to SAML login."""
77
- saml_cfg = get_saml_config()
78
- if not saml_cfg.is_configured():
79
- raise HTTPException(status_code=501, detail="SAML not configured")
80
- return RedirectResponse(url="/api/v1/sso/login")
@@ -1,10 +0,0 @@
1
- """Health check endpoint."""
2
-
3
- from fastapi import APIRouter
4
-
5
- router = APIRouter()
6
-
7
-
8
- @router.get("/health")
9
- async def health_check():
10
- return {"status": "ok", "version": "0.5.0"}
@@ -1,66 +0,0 @@
1
- """Scan endpoints — submit, list, retrieve scans and findings."""
2
-
3
- from fastapi import APIRouter, HTTPException, Query
4
- from pydantic import BaseModel
5
- from typing import Optional, List
6
- from datetime import datetime
7
-
8
- router = APIRouter()
9
-
10
-
11
- class ScanRequest(BaseModel):
12
- package_name: str
13
- version: Optional[str] = "latest"
14
-
15
-
16
- class Finding(BaseModel):
17
- atk_id: str
18
- severity: str
19
- title: Optional[str] = None
20
- description: Optional[str] = None
21
- evidence: Optional[str] = None
22
-
23
-
24
- class Scan(BaseModel):
25
- id: str
26
- package_name: str
27
- version: str
28
- status: str
29
- scanned_at: datetime
30
- findings: List[Finding] = []
31
-
32
-
33
- SCANS_DB: list[Scan] = []
34
-
35
-
36
- @router.post("/scan", status_code=201)
37
- async def submit_scan(req: ScanRequest):
38
- """Submit a package for scanning (delegates to Node.js CLI)."""
39
- raise HTTPException(
40
- status_code=501,
41
- detail="Scan execution requires async worker — use `npm-scan scan <package>` via CLI"
42
- )
43
-
44
-
45
- @router.get("/scans", response_model=List[Scan])
46
- async def list_scans(limit: int = Query(10, ge=1, le=100)):
47
- """List recent scans."""
48
- return SCANS_DB[-limit:][::-1]
49
-
50
-
51
- @router.get("/scans/{scan_id}")
52
- async def get_scan(scan_id: str):
53
- """Get scan details by ID."""
54
- for scan in SCANS_DB:
55
- if scan.id == scan_id:
56
- return scan
57
- raise HTTPException(status_code=404, detail=f"Scan {scan_id} not found")
58
-
59
-
60
- @router.get("/scans/{scan_id}/findings")
61
- async def get_findings(scan_id: str):
62
- """Get findings for a specific scan."""
63
- for scan in SCANS_DB:
64
- if scan.id == scan_id:
65
- return scan.findings
66
- raise HTTPException(status_code=404, detail=f"Scan {scan_id} not found")