Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ _Avoid_: hidden gold, judge assertion
- A **Research Observation** may indicate several **Knowledge Concepts** through proposed, cited Investigation Assertions without becoming a **Finding**.
- A **Research Observation** may preserve several external identifiers and versioned score assessments; each remains attributable to the Observation's citations and time.
- A **Research Observation** may cite a message from another project thread when that discussion materially supports or contextualizes it.
- A **Research Observation** becomes eligible for promotion to a **Finding** only after cited validation demonstrates a reproducible protected security effect under recorded authorization; rejected leads and coverage records remain distinct outcomes.
- An **Investigation Entity** references a canonical project record when one exists instead of copying that record into the **Investigation Graph**.
- An **Investigation Assertion** may be supported, contradicted, derived, revised, rejected, or left unresolved without changing the canonical record it discusses.
- An **Investigation Citation** identifies why an **Investigation Assertion** exists; an **Artifact** remains the durable evidence object.
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ The explicit Security Knowledge Graph remains in SQLite for versioned, reusable

The project Investigation Graph is an assertion layer over existing records, not another owner of Targets, Artifacts, Findings, Research Observations, Tasks, Attack Paths, Tool Runs, messages, memory, or reusable security knowledge. A Research Observation preserves measured or directly seen behavior, structured inputs and outputs, measurements, external identifiers, versioned scores, actor, time, and precise citations before interpretation. The user-facing Research Map projects canonical records, cited threads and messages, external sources, reusable-concept references, current Investigation Assertions, and Research Priorities through one coherent relational snapshot. The write model resolves canonical records through project-local Investigation Entities and stores append-only Assertions, coordinate-only role-bearing Citations, and rule-versioned Derivations with ordered inputs. Evidence state (`observed`, `derived`, `proposed`, `contradicted`, or `rejected`) stays separate from assertion lifecycle (`current`, `withdrawn`, or `superseded`). Revision is an optimistic, transactional replacement that retains the predecessor and its citations. SQLite and PostgreSQL relational queries define correctness. See [ADR 0001](./adr/0001-investigation-graph-as-assertion-layer.md).

Impact validation is a deterministic promotion boundary over those records. An anomaly remains a Research Observation until cited Artifacts and Investigation Assertions demonstrate a protected read/write, cross-account effect, privilege change, secret exposure, integrity loss, deletion, availability loss, or another concrete security effect under recorded authorization and a reproducible Target Recipe/configuration. The gate preserves rejected leads, coverage records, and inconclusive observations as separate outcomes; only `finding-ready` decisions may feed the Evidence Interface's Finding creation path.

A Research Priority is an unresolved, citation-backed current assertion ranked for follow-up. Its deterministic score weights objective relevance (25%), evidence gap (20%), expected information gain (20%), target importance (15%), inverse predicate cost (8%), inverse predicate risk (7%), and authorization readiness (5%). Authorization readiness comes from the durable target ledger. Deliberately turning a Research Priority into a Task uses the existing Task workflow and a unique assertion-task receipt; it never schedules work, creates an approval, runs a tool, or promotes a Finding.

The former generic security-graph repository is retired. Historical database tables may remain so existing local data is not destructively dropped, but no product path writes them and they are not authoritative. The only graph ownership boundaries are the global Security Knowledge Graph and each project's Investigation Graph.
Expand Down
311 changes: 311 additions & 0 deletions src/server/graph/impact-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
export const SECURITY_EFFECT_TYPES = [
"protected-read",
"protected-write",
"cross-account-effect",
"privilege-change",
"secret-exposure",
"integrity-loss",
"deletion",
"availability-loss",
"other-demonstrated-effect",
] as const;

export type SecurityEffectType = (typeof SECURITY_EFFECT_TYPES)[number];

export type ValidatedSecurityEffect = {
type: SecurityEffectType;
summary: string;
protectedOperation: string;
beforeState: string;
afterState: string;
evidenceArtifactIds: string[];
assertionIds: string[];
sourcePrincipalId?: string;
affectedPrincipalId?: string;
};

export type ImpactValidationInput = {
conclusion:
| "validated-security-effect"
| "rejected-lead"
| "coverage-only"
| "inconclusive";
observationIds: string[];
targetIds: string[];
evidenceArtifactIds: string[];
assertionIds: string[];
authorizationIds: string[];
effects: ValidatedSecurityEffect[];
reproduction: {
attempts: number;
successes: number;
independentRuns: number;
targetRecipeDigest?: string;
configurationId?: string;
};
controls: Array<{
kind: "baseline" | "negative-control" | "cross-account" | "fixed-revision";
summary: string;
evidenceArtifactIds: string[];
}>;
rejectionReason?: string;
coverageScope?: string;
remainingGaps?: string[];
};

export type ImpactValidationDecision = {
disposition:
| "finding-ready"
| "rejected-lead"
| "coverage-record"
| "research-observation";
promotionAllowed: boolean;
securityEffectDemonstrated: boolean;
reasons: string[];
missingRequirements: string[];
findingMetadata?: {
validationProtocol: "impact-validation-v1";
observationIds: string[];
assertionIds: string[];
authorizationIds: string[];
securityEffectTypes: SecurityEffectType[];
reproduction: ImpactValidationInput["reproduction"];
controlKinds: ImpactValidationInput["controls"][number]["kind"][];
};
};

/**
* Determines whether cited observations have crossed the Finding boundary.
* This function never creates a Finding or grants authorization by itself.
*/
export function decideImpactValidation(
input: ImpactValidationInput,
): ImpactValidationDecision {
assertUniqueNonEmpty(input.observationIds, "observationIds");
assertUniqueNonEmpty(input.targetIds, "targetIds");
assertUnique(input.evidenceArtifactIds, "evidenceArtifactIds");
assertUnique(input.assertionIds, "assertionIds");
assertUnique(input.authorizationIds, "authorizationIds");
assertReproduction(input.reproduction);
validateEffects(input.effects);
validateControls(input.controls);

if (input.conclusion === "rejected-lead") {
assertNonEmpty(input.rejectionReason, "rejectionReason");
if (input.effects.length > 0) {
throw new Error(
"A rejected lead cannot retain validated security effects.",
);
}
return {
disposition: "rejected-lead",
promotionAllowed: false,
securityEffectDemonstrated: false,
reasons: [input.rejectionReason as string],
missingRequirements: [],
};
}

if (input.conclusion === "coverage-only") {
assertNonEmpty(input.coverageScope, "coverageScope");
if (input.effects.length > 0) {
throw new Error(
"A coverage record cannot retain validated security effects.",
);
}
return {
disposition: "coverage-record",
promotionAllowed: false,
securityEffectDemonstrated: false,
reasons: [input.coverageScope as string],
missingRequirements: [],
};
}

if (input.conclusion === "inconclusive") {
if (input.effects.length > 0) {
throw new Error(
"An inconclusive validation cannot retain validated security effects.",
);
}
return {
disposition: "research-observation",
promotionAllowed: false,
securityEffectDemonstrated: false,
reasons: input.remainingGaps?.length
? [...input.remainingGaps]
: ["Impact remains unestablished."],
missingRequirements: input.remainingGaps?.length
? [...input.remainingGaps]
: ["a reproducible protected security effect"],
};
}

const missingRequirements: string[] = [];
if (input.effects.length === 0)
missingRequirements.push("at least one demonstrated security effect");
if (input.evidenceArtifactIds.length === 0) {
missingRequirements.push("durable evidence artifacts");
}
if (input.assertionIds.length === 0) {
missingRequirements.push("citation-backed Investigation Assertions");
}
if (input.authorizationIds.length === 0) {
missingRequirements.push("target authorization references");
}
if (input.reproduction.successes < 1) {
missingRequirements.push("at least one successful reproduction");
}
if (input.reproduction.independentRuns < 1) {
missingRequirements.push("an independently reset run");
}
if (!input.reproduction.targetRecipeDigest) {
missingRequirements.push("the reproducing Target Recipe digest");
}
if (!input.reproduction.configurationId) {
missingRequirements.push("the reproducing configuration identity");
}
if (input.controls.length === 0) {
missingRequirements.push(
"a baseline, negative, cross-account, or fixed-revision control",
);
}
for (const effect of input.effects) {
for (const artifactId of effect.evidenceArtifactIds) {
if (!input.evidenceArtifactIds.includes(artifactId)) {
missingRequirements.push(
`effect evidence ${artifactId} in the validation artifact set`,
);
}
}
for (const assertionId of effect.assertionIds) {
if (!input.assertionIds.includes(assertionId)) {
missingRequirements.push(
`effect assertion ${assertionId} in the validation assertion set`,
);
}
}
}

const uniqueMissing = [...new Set(missingRequirements)];
if (uniqueMissing.length > 0) {
return {
disposition: "research-observation",
promotionAllowed: false,
securityEffectDemonstrated: false,
reasons: [
"The claimed effect has not crossed the evidence and reproduction gate.",
],
missingRequirements: uniqueMissing,
};
}

return {
disposition: "finding-ready",
promotionAllowed: true,
securityEffectDemonstrated: true,
reasons: input.effects.map((effect) => effect.summary),
missingRequirements: [],
findingMetadata: {
validationProtocol: "impact-validation-v1",
observationIds: [...input.observationIds],
assertionIds: [...input.assertionIds],
authorizationIds: [...input.authorizationIds],
securityEffectTypes: [
...new Set(input.effects.map((effect) => effect.type)),
],
reproduction: { ...input.reproduction },
controlKinds: [...new Set(input.controls.map((control) => control.kind))],
},
};
}

function validateEffects(effects: ValidatedSecurityEffect[]): void {
for (const [index, effect] of effects.entries()) {
assertNonEmpty(effect.summary, `effects.${index}.summary`);
assertNonEmpty(
effect.protectedOperation,
`effects.${index}.protectedOperation`,
);
assertNonEmpty(effect.beforeState, `effects.${index}.beforeState`);
assertNonEmpty(effect.afterState, `effects.${index}.afterState`);
assertUniqueNonEmpty(
effect.evidenceArtifactIds,
`effects.${index}.evidenceArtifactIds`,
);
assertUniqueNonEmpty(effect.assertionIds, `effects.${index}.assertionIds`);
if (effect.beforeState === effect.afterState) {
throw new Error(
`effects.${index} must demonstrate an observable state change.`,
);
}
if (effect.type === "cross-account-effect") {
assertNonEmpty(
effect.sourcePrincipalId,
`effects.${index}.sourcePrincipalId`,
);
assertNonEmpty(
effect.affectedPrincipalId,
`effects.${index}.affectedPrincipalId`,
);
if (effect.sourcePrincipalId === effect.affectedPrincipalId) {
throw new Error(
`effects.${index} cross-account principals must be distinct.`,
);
}
}
}
}

function validateControls(controls: ImpactValidationInput["controls"]): void {
for (const [index, control] of controls.entries()) {
assertNonEmpty(control.summary, `controls.${index}.summary`);
assertUniqueNonEmpty(
control.evidenceArtifactIds,
`controls.${index}.evidenceArtifactIds`,
);
}
}

function assertReproduction(
reproduction: ImpactValidationInput["reproduction"],
): void {
for (const [name, value] of Object.entries({
attempts: reproduction.attempts,
successes: reproduction.successes,
independentRuns: reproduction.independentRuns,
})) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`reproduction.${name} must be a non-negative integer.`);
}
}
if (reproduction.successes > reproduction.attempts) {
throw new Error("reproduction.successes cannot exceed attempts.");
}
if (reproduction.independentRuns > reproduction.attempts) {
throw new Error("reproduction.independentRuns cannot exceed attempts.");
}
if (
reproduction.targetRecipeDigest &&
!/^sha256:[0-9a-f]{64}$/.test(reproduction.targetRecipeDigest)
) {
throw new Error(
"reproduction.targetRecipeDigest must be a lowercase SHA-256 digest.",
);
}
}

function assertUniqueNonEmpty(values: string[], name: string): void {
if (values.length === 0) throw new Error(`${name} must not be empty.`);
assertUnique(values, name);
for (const value of values) assertNonEmpty(value, name);
}

function assertUnique(values: string[], name: string): void {
if (new Set(values).size !== values.length)
throw new Error(`${name} must not contain duplicates.`);
}

function assertNonEmpty(value: string | undefined, name: string): void {
if (!value?.trim()) throw new Error(`${name} must be non-empty.`);
}
8 changes: 8 additions & 0 deletions src/server/graph/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
export {
decideImpactValidation,
type ImpactValidationDecision,
type ImpactValidationInput,
SECURITY_EFFECT_TYPES,
type SecurityEffectType,
type ValidatedSecurityEffect,
} from "./impact-validation";
export {
ASSERTION_EPISTEMIC_STATUSES,
ASSERTION_LIFECYCLE_STATUSES,
Expand Down
Loading
Loading