@lateos/npm-scan 0.4.1 → 0.6.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/README.md +12 -5
- package/api/README.md +80 -0
- package/api/__init__.py +0 -0
- package/api/api_keys.py +55 -0
- package/api/deps.py +164 -0
- package/api/main.py +44 -0
- package/api/requirements.txt +9 -0
- package/api/routers/__init__.py +0 -0
- package/api/routers/auth.py +80 -0
- package/api/routers/health.py +10 -0
- package/api/routers/scans.py +66 -0
- package/api/routers/sso.py +385 -0
- package/api/routers/webhooks.py +78 -0
- package/api/saml-config.yaml +58 -0
- package/api/saml.py +184 -0
- package/backend/db/pg-schema.sql +155 -0
- package/backend/license.js +76 -4
- package/cli/cli.js +26 -8
- package/deploy/helm/npm-scan/Chart.yaml +16 -0
- package/deploy/helm/npm-scan/templates/_helpers.tpl +9 -0
- package/deploy/helm/npm-scan/templates/api.yaml +66 -0
- package/deploy/helm/npm-scan/templates/ingress.yaml +28 -0
- package/deploy/helm/npm-scan/templates/postgresql.yaml +67 -0
- package/deploy/helm/npm-scan/templates/secrets.yaml +19 -0
- package/deploy/helm/npm-scan/templates/worker.yaml +32 -0
- package/deploy/helm/npm-scan/values.yaml +73 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,7 +21,12 @@ npx @lateos/npm-scan scan lodash
|
|
|
21
21
|
- **SBOM Output** — CycloneDX 1.5 and SPDX 2.3 with findings mapped as vulnerabilities
|
|
22
22
|
- **NIST 800-161 Compliance** — HTML report includes control traceability matrix (SR-2.1 → SR-11.4)
|
|
23
23
|
- **EU CRA Compliance** — report maps findings to Cyber Resilience Act articles and Annex I requirements
|
|
24
|
-
- **SIEM Export** — CEF format for Splunk and other SIEM ingestion
|
|
24
|
+
- **SIEM Export** — CEF format for Splunk and other SIEM ingestion (premium)
|
|
25
|
+
- **EU CRA Compliance** — report maps findings to Cyber Resilience Act articles (premium)
|
|
26
|
+
- **License Key Gating** — premium features locked behind signed license keys
|
|
27
|
+
- **REST API** — FastAPI-based API with webhooks, auth, scan management (premium)
|
|
28
|
+
- **SAML SSO** — enterprise single sign-on via Okta, Azure AD, OneLogin, Keycloak (enterprise)
|
|
29
|
+
- **Kubernetes / Helm** — Helm chart for deploying the full pipeline on K8s (premium)
|
|
25
30
|
- **SQLite Storage** — local scan history, zero external dependencies
|
|
26
31
|
- **CLI** — `scan`, `scan-lockfile`, `report --sbom --html --nist --cra --siem`
|
|
27
32
|
- **Dynamic Sandbox** — gVisor-based isolation (premium, documented in `docs/sandbox-threat-model.md`)
|
|
@@ -42,19 +47,21 @@ npm-scan report -i <id> --sbom spdx Generate SPDX SBOM
|
|
|
42
47
|
npm-scan report -i <id> --html Generate HTML report (with NIST table)
|
|
43
48
|
npm-scan report -i <id> --nist Print NIST 800-161 compliance table
|
|
44
49
|
npm-scan report -i <id> --cra Print EU CRA compliance table
|
|
45
|
-
npm-scan report -i <id> --siem cef Generate SIEM CEF output
|
|
50
|
+
npm-scan report -i <id> --siem cef Generate SIEM CEF output (premium)
|
|
46
51
|
npm-scan report --html Generate HTML report for all scans
|
|
47
52
|
npm-scan report --nist Print NIST compliance for all scans
|
|
48
|
-
npm-scan report --cra Print EU CRA compliance for all scans
|
|
49
|
-
npm-scan report --siem cef Generate SIEM for all scans
|
|
53
|
+
npm-scan report --cra Print EU CRA compliance for all scans (premium)
|
|
54
|
+
npm-scan report --siem cef Generate SIEM for all scans (premium)
|
|
50
55
|
```
|
|
51
56
|
|
|
52
57
|
## Architecture
|
|
53
58
|
|
|
54
59
|
```
|
|
55
60
|
cli/ Commander.js CLI entrypoint
|
|
56
|
-
backend/ Detectors, fetch, SQLite db, SBOM, report
|
|
61
|
+
backend/ Detectors, fetch, SQLite db, SBOM, report, license, SIEM, CRA
|
|
62
|
+
api/ FastAPI REST API + webhooks (premium)
|
|
57
63
|
docker/ Multi-arch Docker images + compose
|
|
64
|
+
deploy/ Kubernetes Helm chart (premium)
|
|
58
65
|
docs/ Project plan, attack taxonomy (ATK), sandbox threat model
|
|
59
66
|
tests/ Corpus: 5 clean + 33 malicious packages
|
|
60
67
|
```
|
package/api/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# npm-scan REST API
|
|
2
|
+
|
|
3
|
+
FastAPI-based REST API for hosted/team tier. Requires premium or enterprise license.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -r api/requirements.txt
|
|
9
|
+
python -m api.main
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Endpoints
|
|
13
|
+
|
|
14
|
+
| Method | Path | Description |
|
|
15
|
+
|--------|------|-------------|
|
|
16
|
+
| POST | /api/v1/scan | Submit a package for scanning |
|
|
17
|
+
| GET | /api/v1/scans | List recent scans |
|
|
18
|
+
| GET | /api/v1/scans/{id} | Get scan details |
|
|
19
|
+
| GET | /api/v1/scans/{id}/findings | Get findings for a scan |
|
|
20
|
+
| GET | /api/v1/scans/{id}/report | Generate report |
|
|
21
|
+
| POST | /api/v1/webhooks | Register a webhook |
|
|
22
|
+
| GET | /api/v1/webhooks | List webhooks |
|
|
23
|
+
| DELETE | /api/v1/webhooks/{id} | Delete a webhook |
|
|
24
|
+
| POST | /api/v1/auth/login | Login |
|
|
25
|
+
| POST | /api/v1/auth/register | Register |
|
|
26
|
+
| GET | /api/v1/auth/methods | List available auth methods (password, SAML) |
|
|
27
|
+
| GET | /api/v1/auth/me | Current user profile |
|
|
28
|
+
| GET | /api/v1/auth/saml | Convenience redirect to SAML login |
|
|
29
|
+
| GET | /api/v1/sso/metadata | SP metadata XML for IdP registration |
|
|
30
|
+
| GET | /api/v1/sso/login | SP-initiated SAML SSO redirect |
|
|
31
|
+
| POST | /api/v1/sso/acs | Assertion Consumer Service (SAML callback) |
|
|
32
|
+
| POST | /api/v1/sso/slo | Single Logout |
|
|
33
|
+
| GET | /api/v1/sso/session | Current SAML session status |
|
|
34
|
+
| GET | /api/v1/sso/config | SAML configuration status |
|
|
35
|
+
| GET | /api/v1/health | Health check |
|
|
36
|
+
|
|
37
|
+
## Authentication
|
|
38
|
+
|
|
39
|
+
All endpoints except `/api/v1/health`, `/api/v1/auth/methods`, and `/api/v1/auth/saml` require an API key or session token.
|
|
40
|
+
|
|
41
|
+
### Methods
|
|
42
|
+
|
|
43
|
+
1. **JWT Token** — obtained via `POST /api/v1/auth/login` or SAML ACS callback
|
|
44
|
+
2. **API Key** — long-lived key with scoped permissions
|
|
45
|
+
3. **SAML SSO** — enterprise IdP integration (Okta, Azure AD, OneLogin, Keycloak)
|
|
46
|
+
|
|
47
|
+
Pass as header: `Authorization: Bearer <token_or_api_key>`
|
|
48
|
+
|
|
49
|
+
## SAML / SSO Configuration
|
|
50
|
+
|
|
51
|
+
SAML SSO requires an enterprise license and is configured via environment variables or `saml-config.yaml`.
|
|
52
|
+
|
|
53
|
+
### Environment Variables
|
|
54
|
+
|
|
55
|
+
| Variable | Description |
|
|
56
|
+
|----------|-------------|
|
|
57
|
+
| `SAML_IDP_ENTITY_ID` | IdP entity ID (from IdP metadata) |
|
|
58
|
+
| `SAML_IDP_SSO_URL` | IdP SSO endpoint URL |
|
|
59
|
+
| `SAML_IDP_X509_CERT` | IdP X.509 certificate for assertion verification |
|
|
60
|
+
| `SAML_SP_PRIVATE_KEY` | SP private key for signing authn requests |
|
|
61
|
+
| `SAML_SP_X509_CERT` | SP X.509 certificate (shared with IdP) |
|
|
62
|
+
| `SAML_IDP_METADATA_URL` | Auto-discover IdP config from metadata URL |
|
|
63
|
+
|
|
64
|
+
See `saml-config.yaml` for a full configuration template.
|
|
65
|
+
|
|
66
|
+
### Supported IdPs
|
|
67
|
+
|
|
68
|
+
- Okta
|
|
69
|
+
- Azure Active Directory / Entra ID
|
|
70
|
+
- OneLogin
|
|
71
|
+
- Keycloak
|
|
72
|
+
- Any SAML 2.0 compliant IdP
|
|
73
|
+
|
|
74
|
+
### Flow
|
|
75
|
+
|
|
76
|
+
1. User visits `GET /api/v1/auth/saml` or `GET /api/v1/sso/login`
|
|
77
|
+
2. Server redirects to IdP with signed SAML AuthnRequest
|
|
78
|
+
3. User authenticates at IdP
|
|
79
|
+
4. IdP POSTs SAML Response to `POST /api/v1/sso/acs`
|
|
80
|
+
5. Server validates assertion, provisions user, returns JWT tokens
|
package/api/__init__.py
ADDED
|
File without changes
|
package/api/api_keys.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
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()
|
|
File without changes
|
|
@@ -0,0 +1,80 @@
|
|
|
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")
|
|
@@ -0,0 +1,66 @@
|
|
|
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")
|