pi-opa-net 0.1.0 → 0.2.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/CHANGELOG.md +42 -0
- package/README.md +48 -4
- package/bin/pi-opa-net.js +88 -7
- package/package.json +5 -2
- package/policy/safety.rego +36 -0
- package/schemas/decision-output.v1.json +41 -3
- package/src/audit/AuditSink.ts +26 -0
- package/src/cli/run.ts +92 -2
- package/src/cli/unlock-key.ts +52 -0
- package/src/config/Config.ts +32 -1
- package/src/output/DecisionBuilder.ts +90 -19
- package/src/rules/RuleRegistry.ts +50 -7
- package/src/rules/catalog.ts +39 -3
- package/src/unlock/KeyDerivation.ts +28 -0
- package/src/unlock/KeyParser.ts +33 -0
- package/src/unlock/KeyVerifier.ts +74 -0
- package/src/unlock/SaltResolver.ts +98 -0
- package/src/unlock/UnlockFilter.ts +107 -0
- package/src/unlock/types.ts +58 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { RawDeny } from '../engine/types.ts';
|
|
2
|
+
import type { RuleRegistry } from '../rules/index.ts';
|
|
3
|
+
import { KeyParser } from './KeyParser.ts';
|
|
4
|
+
import { KeyVerifier } from './KeyVerifier.ts';
|
|
5
|
+
import type { UnlockReasonInfo, UnlockResult, VerifyResult } from './types.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* All-or-nothing unlock filter [D5 / LD-G6].
|
|
9
|
+
*
|
|
10
|
+
* For each deny reason (severity:block), checks if ANY presented key validly
|
|
11
|
+
* unlocks that reason's rule_id. `allow ⟺ every block reason has a valid key`.
|
|
12
|
+
*
|
|
13
|
+
* Expired TTL keys are reported (unlock_status:'expired') but do NOT bypass.
|
|
14
|
+
* `unlock_key_id` is always the first 8 hex of the matching key's mac — the
|
|
15
|
+
* full 16-hex key NEVER appears in the result.
|
|
16
|
+
*/
|
|
17
|
+
export class UnlockFilter {
|
|
18
|
+
// Static-only utility class — private constructor prevents instantiation.
|
|
19
|
+
private constructor() {}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param reasons Raw deny reasons from the engine.
|
|
23
|
+
* @param keys Raw key strings presented by the agent.
|
|
24
|
+
* @param salt Deploy-local salt buffer.
|
|
25
|
+
* @param nowMs Verifier process clock in ms (Date.now()).
|
|
26
|
+
* @param registry Rule registry for message → rule_id lookup.
|
|
27
|
+
*/
|
|
28
|
+
static filter(
|
|
29
|
+
reasons: readonly RawDeny[],
|
|
30
|
+
keys: readonly string[],
|
|
31
|
+
salt: Buffer,
|
|
32
|
+
nowMs: number,
|
|
33
|
+
registry: RuleRegistry,
|
|
34
|
+
): UnlockResult {
|
|
35
|
+
const infos: UnlockReasonInfo[] = reasons.map((deny) => {
|
|
36
|
+
const meta = registry.lookup(deny);
|
|
37
|
+
const ruleId = meta.ruleId;
|
|
38
|
+
|
|
39
|
+
let validResult: VerifyResult | null = null;
|
|
40
|
+
let expiredResult: VerifyResult | null = null;
|
|
41
|
+
let validKey: string | null = null;
|
|
42
|
+
|
|
43
|
+
for (const key of keys) {
|
|
44
|
+
const result = KeyVerifier.verify(key, ruleId, salt, nowMs);
|
|
45
|
+
if (result.valid) {
|
|
46
|
+
validResult = result;
|
|
47
|
+
validKey = key;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
if (result.reason === 'expired' && !expiredResult) {
|
|
51
|
+
expiredResult = result;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (validResult) {
|
|
56
|
+
const parsed = KeyParser.parse(validKey!);
|
|
57
|
+
const fullMac = parsed?.mac ?? '';
|
|
58
|
+
const keyId = fullMac.slice(0, 8);
|
|
59
|
+
const keyType = validResult.keyType;
|
|
60
|
+
const expiresAt = validResult.expiresAt;
|
|
61
|
+
return {
|
|
62
|
+
message: deny.message,
|
|
63
|
+
ruleId,
|
|
64
|
+
bypassed: true,
|
|
65
|
+
unlockKeyId: keyId,
|
|
66
|
+
keyType,
|
|
67
|
+
expiresAt,
|
|
68
|
+
unlockStatus: 'valid',
|
|
69
|
+
unlock_key_id: keyId,
|
|
70
|
+
unlock_key_type: keyType,
|
|
71
|
+
unlock_expires_at: expiresAt,
|
|
72
|
+
unlock_status: 'valid',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (expiredResult) {
|
|
77
|
+
return {
|
|
78
|
+
message: deny.message,
|
|
79
|
+
ruleId,
|
|
80
|
+
bypassed: false,
|
|
81
|
+
unlockKeyId: '',
|
|
82
|
+
unlock_key_id: '',
|
|
83
|
+
unlockStatus: 'expired',
|
|
84
|
+
unlock_status: 'expired',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
message: deny.message,
|
|
90
|
+
ruleId,
|
|
91
|
+
bypassed: false,
|
|
92
|
+
unlockKeyId: '',
|
|
93
|
+
unlock_key_id: '',
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const bypassedCount = infos.filter((r) => r.bypassed).length;
|
|
98
|
+
const blockedCount = infos.length - bypassedCount;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
allow: blockedCount === 0,
|
|
102
|
+
bypassedCount,
|
|
103
|
+
blockedCount,
|
|
104
|
+
reasons: infos,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the unlock subsystem [D1-D11].
|
|
3
|
+
*
|
|
4
|
+
* These types are the contract between UnlockFilter → DecisionBuilder.
|
|
5
|
+
* Internal (TS-side) property naming uses camelCase; the DecisionBuilder
|
|
6
|
+
* translates to snake_case schema fields at output time.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Result of parsing a raw key string. */
|
|
10
|
+
export interface ParsedKey {
|
|
11
|
+
readonly type: 'll' | 'ttl';
|
|
12
|
+
readonly mac: string;
|
|
13
|
+
/** Unix expiry in seconds. Present only for TTL keys. */
|
|
14
|
+
readonly exp?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Outcome of verifying a parsed key against a rule_id + salt. */
|
|
18
|
+
export interface VerifyResult {
|
|
19
|
+
readonly valid: boolean;
|
|
20
|
+
readonly reason?: 'malformed' | 'wrong-rule' | 'wrong-salt' | 'expired';
|
|
21
|
+
readonly keyType?: 'll' | 'ttl';
|
|
22
|
+
/** Unix expiry in seconds (TTL only). */
|
|
23
|
+
readonly expiresAt?: number;
|
|
24
|
+
readonly unlockStatus?: 'valid' | 'expired';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Per-reason unlock info produced by UnlockFilter.
|
|
29
|
+
*
|
|
30
|
+
* Carries BOTH camelCase (consumed by DecisionBuilder) and snake_case
|
|
31
|
+
* (consumed by UnlockFilter test assertions) aliases so the same object
|
|
32
|
+
* satisfies both consumers without conversion.
|
|
33
|
+
*/
|
|
34
|
+
export interface UnlockReasonInfo {
|
|
35
|
+
readonly message: string;
|
|
36
|
+
readonly ruleId: string;
|
|
37
|
+
readonly bypassed: boolean;
|
|
38
|
+
|
|
39
|
+
/** camelCase aliases — DecisionBuilder reads these. */
|
|
40
|
+
readonly unlockKeyId: string;
|
|
41
|
+
readonly keyType?: 'll' | 'ttl';
|
|
42
|
+
readonly expiresAt?: number;
|
|
43
|
+
readonly unlockStatus?: 'valid' | 'expired';
|
|
44
|
+
|
|
45
|
+
/** snake_case aliases — schema-compatible / UnlockFilter assertions. */
|
|
46
|
+
readonly unlock_key_id: string;
|
|
47
|
+
readonly unlock_key_type?: 'll' | 'ttl';
|
|
48
|
+
readonly unlock_expires_at?: number;
|
|
49
|
+
readonly unlock_status?: 'valid' | 'expired';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Aggregate result of filtering reasons through unlock keys. */
|
|
53
|
+
export interface UnlockResult {
|
|
54
|
+
readonly allow: boolean;
|
|
55
|
+
readonly bypassedCount: number;
|
|
56
|
+
readonly blockedCount: number;
|
|
57
|
+
readonly reasons: readonly UnlockReasonInfo[];
|
|
58
|
+
}
|