Skip to content

Commit 04a55d9

Browse files
committed
security: close P2 hardening gates and defaults
1 parent 5c660c4 commit 04a55d9

11 files changed

Lines changed: 289 additions & 3 deletions

File tree

.github/workflows/security-pipeline.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ jobs:
3131
- name: Run dependency audit (SCA)
3232
run: docker exec core-php-1 composer audit --no-interaction
3333

34+
- name: Enforce hardening gate (P2)
35+
run: docker exec -e PSFS_SECURITY_STRICT=1 core-php-1 php scripts/security/hardening_gate.php
36+
3437
- name: Enforce quality gate
3538
run: docker exec core-php-1 php scripts/security/quality_gate.php
3639

@@ -41,6 +44,13 @@ jobs:
4144
name: security-quality-gate
4245
path: security/reports/quality-gate.json
4346

47+
- name: Upload hardening gate report
48+
if: always()
49+
uses: actions/upload-artifact@v4
50+
with:
51+
name: security-hardening-gate
52+
path: security/reports/hardening-gate.json
53+
4454
security-static-analysis:
4555
runs-on: ubuntu-latest
4656

.gitleaks.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[allowlist]
2+
description = "PSFS baseline allowlist for non-secret deterministic artifacts"
3+
paths = [
4+
'''^vendor/''',
5+
'''^\.idea/''',
6+
'''^security/reports/'''
7+
]
8+
regexes = [
9+
'''FLASH_MESSAGE_TOKEN\s*=\s*['\"][a-f0-9]{40}['\"]''',
10+
'''USER_ID_TOKEN\s*=\s*['\"][a-f0-9]{40}['\"]''',
11+
'''MANAGER_ID_TOKEN\s*=\s*['\"][a-f0-9]{40}['\"]''',
12+
'''ADMIN_ID_TOKEN\s*=\s*['\"][a-f0-9]{40}['\"]''',
13+
'''SESSION_TOKEN\s*=\s*['\"][a-f0-9]{40}['\"]'''
14+
]
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
function out(string $message): void
6+
{
7+
fwrite(STDOUT, $message . PHP_EOL);
8+
}
9+
10+
function fail(string $message, int $code = 1): never
11+
{
12+
fwrite(STDERR, $message . PHP_EOL);
13+
exit($code);
14+
}
15+
16+
function loadJson(string $path): array
17+
{
18+
$raw = @file_get_contents($path);
19+
if ($raw === false) {
20+
fail("Cannot read JSON file: {$path}");
21+
}
22+
$decoded = json_decode($raw, true);
23+
if (!is_array($decoded)) {
24+
fail("Invalid JSON structure: {$path}");
25+
}
26+
return $decoded;
27+
}
28+
29+
function shouldApplyRule(array $rule): bool
30+
{
31+
if (!isset($rule['when']) || !is_array($rule['when'])) {
32+
return true;
33+
}
34+
35+
$when = $rule['when'];
36+
$env = (string)($when['env'] ?? '');
37+
if ($env === '') {
38+
return true;
39+
}
40+
41+
$expected = (string)($when['equals'] ?? '1');
42+
$current = (string)getenv($env);
43+
44+
return $current === $expected;
45+
}
46+
47+
function evaluateRule(array $rule, array $config): ?array
48+
{
49+
$id = (string)($rule['id'] ?? 'UNKNOWN');
50+
$key = (string)($rule['key'] ?? '');
51+
$type = (string)($rule['type'] ?? '');
52+
$severity = strtolower((string)($rule['severity'] ?? 'low'));
53+
54+
if ($key === '' || $type === '') {
55+
return [
56+
'id' => $id,
57+
'severity' => 'high',
58+
'message' => 'Invalid policy rule definition',
59+
];
60+
}
61+
62+
$value = array_key_exists($key, $config) ? $config[$key] : ($rule['default'] ?? null);
63+
64+
if ($type === 'exact') {
65+
$expected = $rule['expected'] ?? null;
66+
if ($value !== $expected) {
67+
return [
68+
'id' => $id,
69+
'severity' => $severity,
70+
'message' => "Expected {$key}=" . json_encode($expected) . ", got " . json_encode($value),
71+
];
72+
}
73+
return null;
74+
}
75+
76+
if ($type === 'non_empty_string') {
77+
if (!is_string($value) || trim($value) === '') {
78+
return [
79+
'id' => $id,
80+
'severity' => $severity,
81+
'message' => "Expected non-empty string for {$key}",
82+
];
83+
}
84+
return null;
85+
}
86+
87+
if ($type === 'forbid_exact') {
88+
$forbidden = $rule['forbidden'] ?? null;
89+
if ($value === $forbidden) {
90+
return [
91+
'id' => $id,
92+
'severity' => $severity,
93+
'message' => "Forbidden value {$key}=" . json_encode($forbidden),
94+
];
95+
}
96+
return null;
97+
}
98+
99+
return [
100+
'id' => $id,
101+
'severity' => 'high',
102+
'message' => "Unsupported policy rule type: {$type}",
103+
];
104+
}
105+
106+
$policyPath = getenv('PSFS_SECURITY_HARDENING_POLICY') ?: 'security/contracts/hardening-policy.json';
107+
$configPath = getenv('PSFS_SECURITY_CONFIG_FILE') ?: 'config/config.json';
108+
$reportPath = getenv('PSFS_SECURITY_HARDENING_REPORT') ?: 'security/reports/hardening-gate.json';
109+
110+
$policy = loadJson($policyPath);
111+
$config = loadJson($configPath);
112+
$rules = $policy['rules'] ?? [];
113+
if (!is_array($rules)) {
114+
fail('Invalid hardening policy rules');
115+
}
116+
117+
$violations = [];
118+
foreach ($rules as $rule) {
119+
if (!is_array($rule) || !shouldApplyRule($rule)) {
120+
continue;
121+
}
122+
123+
$violation = evaluateRule($rule, $config);
124+
if (is_array($violation)) {
125+
$violations[] = $violation;
126+
}
127+
}
128+
129+
$blocking = array_values(array_filter($violations, static fn(array $violation): bool => in_array(
130+
strtolower((string)($violation['severity'] ?? 'low')),
131+
['high', 'critical'],
132+
true
133+
)));
134+
135+
$status = empty($blocking) ? 'pass' : 'block';
136+
137+
$report = [
138+
'version' => '1.0',
139+
'generated_at' => gmdate('c'),
140+
'status' => $status,
141+
'summary' => [
142+
'rules_evaluated' => count($rules),
143+
'violations' => count($violations),
144+
'blocking' => count($blocking),
145+
],
146+
'violations' => $violations,
147+
];
148+
149+
if (!is_dir(dirname($reportPath))) {
150+
mkdir(dirname($reportPath), 0775, true);
151+
}
152+
file_put_contents($reportPath, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
153+
out("[HARDENING_GATE] status={$status} report={$reportPath}");
154+
155+
exit($status === 'pass' ? 0 : 1);

security/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Security Contracts (CI)
2+
3+
Contenido minimo trackeado para gates CI/CD.
4+
5+
- `contracts/control-matrix.yaml`: controles `must_pass` y mapeo OWASP.
6+
- `contracts/findings.json`: estado de findings para bloqueo por severidad.
7+
- `reports/`: artefactos generados en CI (`quality-gate.json`, SBOM, SARIF, etc).
8+
9+
No incluye threat model detallado ni prompts internos de agentes.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
version: "1.0"
2+
generated_at: "2026-04-14T00:30:00Z"
3+
controls:
4+
- control_id: "CTRL-AUTH-001"
5+
componente: "AuthApi"
6+
descripcion: "Precedencia token: header valido > cookie valida > query legacy solo compat"
7+
tipo: "preventivo"
8+
owasp_mapping: ["A01", "A07"]
9+
severity: "critical"
10+
validation:
11+
test: "AuthApiTest::testResolveApiTokenPrecedenceHeaderCookieAndQuery"
12+
gate: "must_pass"
13+
14+
- control_id: "CTRL-AUTH-002"
15+
componente: "AuthApi"
16+
descripcion: "Header token malformado debe degradar a cookie valida"
17+
tipo: "preventivo"
18+
owasp_mapping: ["A01", "A07"]
19+
severity: "critical"
20+
validation:
21+
test: "AuthApiTest::testResolveApiTokenRejectsMalformedHeaderTokenAndFallsBackToCookie"
22+
gate: "must_pass"
23+
24+
- control_id: "CTRL-COOKIE-002"
25+
componente: "ResponseCookieHelper"
26+
descripcion: "Cookies con HttpOnly=true, Path=/, SameSite validado, Secure segun contexto"
27+
tipo: "preventivo"
28+
owasp_mapping: ["A02", "A05", "A07"]
29+
severity: "high"
30+
validation:
31+
test: "RequestResponseSecurityContractTest::testCookiePayloadMatrixAndSecurityInterplay"
32+
gate: "must_pass"
33+
34+
- control_id: "CTRL-CORS-003"
35+
componente: "RequestHelper"
36+
descripcion: "Origen CORS normalizado y allowlist estricta; deny por defecto"
37+
tipo: "preventivo"
38+
owasp_mapping: ["A05", "A10"]
39+
severity: "high"
40+
validation:
41+
test: "RequestResponseSecurityContractTest::testCheckCorsSetsHeadersForAllowedOriginOnGet"
42+
gate: "must_pass"

security/contracts/findings.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"version": "1.0",
3+
"generated_at": "2026-04-14T00:30:00Z",
4+
"findings": [
5+
{
6+
"id": "F-AUTH-2026-001",
7+
"severidad": "high",
8+
"status": "resolved",
9+
"componente": "AuthApi",
10+
"descripcion": "Fallback token malformado corregido",
11+
"evidencia": "AuthApi::resolveApiToken revisa cookie configurada y cookie default"
12+
},
13+
{
14+
"id": "F-TEST-2026-002",
15+
"severidad": "low",
16+
"status": "resolved",
17+
"componente": "Security tests",
18+
"descripcion": "Risky por debug handler residual en notFound test",
19+
"evidencia": "Test ajustado para ejecutar sin debug/profiling"
20+
}
21+
]
22+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"version": "1.0",
3+
"rules": [
4+
{
5+
"id": "HARD-AUTH-001",
6+
"description": "api.query_token.compat must be false",
7+
"type": "exact",
8+
"key": "api.query_token.compat",
9+
"default": false,
10+
"expected": false,
11+
"severity": "high"
12+
},
13+
{
14+
"id": "HARD-AUTH-002",
15+
"description": "api.token.cookie must be defined",
16+
"type": "non_empty_string",
17+
"key": "api.token.cookie",
18+
"default": "X-API-SEC-TOKEN",
19+
"severity": "medium"
20+
},
21+
{
22+
"id": "HARD-CORS-003",
23+
"description": "cors.enabled wildcard '*' is forbidden in strict mode",
24+
"type": "forbid_exact",
25+
"key": "cors.enabled",
26+
"forbidden": "*",
27+
"severity": "high",
28+
"when": {
29+
"env": "PSFS_SECURITY_STRICT",
30+
"equals": "1"
31+
}
32+
}
33+
]
34+
}

security/reports/.gitkeep

Whitespace-only changes.

src/base/config/Config.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ class Config
3535
'debug' => true,
3636
'front.version' => 'v1',
3737
'version' => 'v1',
38+
'api.query_token.compat' => false,
39+
'api.token.cookie' => 'X-API-SEC-TOKEN',
3840
'metadata.attributes.enabled' => true,
3941
'migrations.engine' => 'phinx',
4042
'migrations.legacy_fallback_enabled' => true,

src/base/types/AuthApi.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ private function resolveApiToken(): string
8484
return '';
8585
}
8686

87-
$legacyCompat = (bool)Config::getParam('api.query_token.compat', true);
87+
$legacyCompat = (bool)Config::getParam('api.query_token.compat', false);
8888
if (!$legacyCompat) {
8989
Logger::log(
9090
'[AuthApi] Legacy API token in query string has been rejected by policy',

0 commit comments

Comments
 (0)