Skip to content

Commit 619362a

Browse files
Krishcalinclaude
andcommitted
Background jobs, and three edges deliberately not drawn
The first edges in the graph that do not start at an account. They were worth adding only once `finding_neighbourhood` existed to read them — `path_actors` walks account-sourced edges alone, so adding these earlier would have been more edges nothing reads, which is the mistake this whole arc began by fixing. edges 75 -> 78 runs_as / schedules / runs_program 5 / 3 / 3 `check_job_privileged_step_user` had already written down what it wanted, and could not say it: "the job->user edge is what makes 'this armed job runs as DDIC' derivable". Third module in a row where the relationship was designed in a comment and unexpressible in the contract. MOST OF THIS CHANGE IS ABOUT THE EDGE NOT DRAWN. JOBCMD-JOB-005 reports a low-privileged scheduler getting code executed under a powerful step user. The obvious edge is scheduler -> step user, and it would be wrong: it reads as "this account can act as that one", which is a conclusion a reader draws from two facts rather than a relationship the export states. So it is `user -schedules-> job` and `job -runs_as-> user`, and a test fails if a user -> user edge is ever declared here. It is the same transitive claim `why_not_the_transitive_edge` already refuses, wearing a different costume. JOBCMD-JOB-003's own comment sketches "job -> program -> user", which reads as though RSBDCOS0 runs as the step user. It does not: the job runs the program AND the job runs as the user. Two edges from the job, and a test asserts nothing is declared FROM the program. `runs_as` points from the job, so it is deliberately NOT an actor edge. A job borrows an identity; the user does not reach for the job, and answering "who is standing on this path" with somebody a job impersonates would be a different claim. `path_actors` leaves it alone; `finding_neighbourhood` surfaces it. A COUNTER EARNING ITS PLACE. Adding the JOBCMD-JOB- rule pushed `declined_ambiguous` from 1 to 2, because JOB-003 then matched a rule without having declared its pairs — visible immediately in the scan output rather than silently absent. Converting it brought the count back to 1. 5,502 pass with a database. 4 new tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 9e3feff commit 619362a

3 files changed

Lines changed: 135 additions & 0 deletions

File tree

data/graph_edges.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,27 @@
2020
"edge_type": "holds_profile",
2121
"why": "USR-002 reports users holding SAP_ALL, SAP_NEW or S_A.SYSTEM, and the assignment IS the finding. A profile is not a role — in SAP a role generates a profile and the two are separate objects — so this is its own edge type rather than a second spelling of holds_role. The check declares the pairing in `relations`, so which user holds which profile is evidence here rather than inference."
2222
},
23+
{
24+
"check_prefix": "JOBCMD-JOB-",
25+
"from_type": "job",
26+
"to_type": "user",
27+
"edge_type": "runs_as",
28+
"why": "An armed background job executes its steps with the step user's authorisations (TBTCP-AUTHCKNAM). `check_job_privileged_step_user` already said what this is for: 'the job->user edge is what makes \"this armed job runs as DDIC\" derivable'. The direction is the job's, not the user's — the job borrows the identity, the user does not reach for the job — so this is NOT an actor edge and `path_actors` does not walk it."
29+
},
30+
{
31+
"check_prefix": "JOBCMD-JOB-003",
32+
"from_type": "job",
33+
"to_type": "program",
34+
"edge_type": "runs_program",
35+
"why": "An armed job step names the ABAP program it executes (TBTCP-PROGNAME), and RSBDCOS0 in that position is the OS bridge that bypasses the SM69 allowlist entirely. Emitted as a second edge FROM THE JOB rather than as the chain the check's own comment sketches: 'job -> program -> user' reads as though the program runs as the step user, and nothing in the export says that — the job runs the program and the job runs as the user."
36+
},
37+
{
38+
"check_prefix": "JOBCMD-JOB-005",
39+
"from_type": "user",
40+
"to_type": "job",
41+
"edge_type": "schedules",
42+
"why": "The scheduler named in SDLUNAME arranged for the job to run. Kept separate from runs_as deliberately: together the two edges say a lower-privileged scheduler gets code executed under a stronger identity, which is exactly what the check reports — but as two facts a reader joins, never as a scheduler->step-user edge. That edge would read as 'this account can act as that one', a conclusion rather than something the export states, and the same transitive claim why_not_the_transitive_edge already refuses."
43+
},
2344
{
2445
"check_prefix": "S4AUTHZ-001",
2546
"from_type": "user",

modules/basis_job_command.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ def check_command_any_os(self):
383383
# --------------------------------------------------------------- JOB checks
384384
def check_job_privileged_step_user(self):
385385
crit, high = [], []
386+
crit_rels, high_rels = [], []
386387
crit_objs, high_objs = [], []
387388
for label, step, _h in self._armed_steps():
388389
user = self._get(step, "AUTHCKNAM", "STEP_USER", "BTCUSER", "AUTH_USER")
@@ -398,12 +399,18 @@ def check_job_privileged_step_user(self):
398399
crit_objs.append({"type": "job", "name": label})
399400
if user:
400401
crit_objs.append({"type": "user", "name": user})
402+
if label and user:
403+
crit_rels.append({"from": {"type": "job", "name": label},
404+
"to": {"type": "user", "name": user}})
401405
elif cls == "standard":
402406
high.append(f"{label} — step user {user}")
403407
if label:
404408
high_objs.append({"type": "job", "name": label})
405409
if user:
406410
high_objs.append({"type": "user", "name": user})
411+
if label and user:
412+
high_rels.append({"from": {"type": "job", "name": label},
413+
"to": {"type": "user", "name": user}})
407414
if crit:
408415
self.finding(
409416
check_id="JOBCMD-JOB-001",
@@ -422,6 +429,7 @@ def check_job_privileged_step_user(self):
422429
# Aggregate: re-planning one job onto a technical user must not retire
423430
# this finding and re-raise it with a zeroed age while others remain.
424431
affected_objects=crit_objs,
432+
relations=crit_rels,
425433
scope="aggregate",
426434
remediation=(
427435
"Reassign these jobs to a dedicated, least-privilege background (type B / "
@@ -447,6 +455,7 @@ def check_job_privileged_step_user(self):
447455
affected_items=high,
448456
# Aggregate, for the same reason as JOBCMD-JOB-001.
449457
affected_objects=high_objs,
458+
relations=high_rels,
450459
scope="aggregate",
451460
remediation=(
452461
"Move these jobs to a purpose-built least-privilege background user; lock the "
@@ -519,6 +528,7 @@ def check_job_external_step(self):
519528
def check_job_os_report(self):
520529
offenders = []
521530
objects = []
531+
relations = []
522532
for label, step, _h in self._armed_steps():
523533
prog = self._get(step, "PROGNAME", "REPORT", "ABAP_PROGRAM").upper()
524534
user = self._get(step, "AUTHCKNAM", "STEP_USER", "BTCUSER")
@@ -541,6 +551,18 @@ def check_job_os_report(self):
541551
objects.append({"type": "program", "name": prog})
542552
if user:
543553
objects.append({"type": "user", "name": user})
554+
# TWO EDGES FROM THE JOB, not the chain the comment above
555+
# sketches. "job -> program -> user" reads as though the program
556+
# runs as the user; it does not — the JOB runs the program AND
557+
# runs as the user, and the program is one of its steps. Drawing
558+
# it as a chain would put a relationship between RSBDCOS0 and the
559+
# step user that nothing in the export states.
560+
if label and prog:
561+
relations.append({"from": {"type": "job", "name": label},
562+
"to": {"type": "program", "name": prog}})
563+
if label and user:
564+
relations.append({"from": {"type": "job", "name": label},
565+
"to": {"type": "user", "name": user}})
544566
if offenders:
545567
self.finding(
546568
check_id="JOBCMD-JOB-003",
@@ -558,6 +580,7 @@ def check_job_os_report(self):
558580
# Aggregate: unscheduling one RSBDCOS0 job while another remains must not
559581
# reset this finding's clock.
560582
affected_objects=objects,
583+
relations=relations,
561584
scope="aggregate",
562585
remediation=(
563586
"Remove RSBDCOS0 from scheduled jobs and restrict authorization to run it; "
@@ -572,6 +595,7 @@ def check_job_os_report(self):
572595
def check_job_stale_step_user(self):
573596
offenders = []
574597
objects = []
598+
relations = []
575599
for label, step, _h in self._armed_steps():
576600
user = self._get(step, "AUTHCKNAM", "STEP_USER", "BTCUSER").upper()
577601
if not user:
@@ -599,6 +623,9 @@ def check_job_stale_step_user(self):
599623
objects.append({"type": "job", "name": label})
600624
if user:
601625
objects.append({"type": "user", "name": user})
626+
if label and user:
627+
relations.append({"from": {"type": "job", "name": label},
628+
"to": {"type": "user", "name": user}})
602629
if offenders:
603630
self.finding(
604631
check_id="JOBCMD-JOB-004",
@@ -615,6 +642,7 @@ def check_job_stale_step_user(self):
615642
affected_items=offenders,
616643
# Aggregate: a job-hygiene backlog. Fixing one job step user shortens it.
617644
affected_objects=objects,
645+
relations=relations,
618646
scope="aggregate",
619647
remediation=(
620648
"Re-plan affected jobs onto a valid, unlocked, least-privilege system (type B) "
@@ -627,6 +655,7 @@ def check_job_stale_step_user(self):
627655
def check_job_identity_borrow(self):
628656
offenders = []
629657
objects = []
658+
relations = []
630659
for label, step, header in self._armed_steps():
631660
user = self._get(step, "AUTHCKNAM", "STEP_USER", "BTCUSER").upper()
632661
sched = self._get(step, "SDLUNAME", "SCHEDULER", "CREATOR").upper() or \
@@ -640,6 +669,20 @@ def check_job_identity_borrow(self):
640669
objects.append({"type": "job", "name": label})
641670
objects.append({"type": "user", "name": user})
642671
objects.append({"type": "user", "name": sched})
672+
# THE BORROWING, AS TWO STEPS RATHER THAN ONE. The scheduler
673+
# schedules the job; the job runs as the step user. Declaring a
674+
# scheduler -> step user edge instead would be the transitive
675+
# claim `data/graph_edges.json` refuses elsewhere — it reads as
676+
# "this account can act as that one", which is the CONCLUSION a
677+
# reader should draw for themselves from two facts, not a
678+
# relationship the export states.
679+
if label:
680+
relations.append(
681+
{"from": {"type": "user", "name": sched},
682+
"to": {"type": "job", "name": label}})
683+
relations.append(
684+
{"from": {"type": "job", "name": label},
685+
"to": {"type": "user", "name": user}})
643686
if offenders:
644687
self.finding(
645688
check_id="JOBCMD-JOB-005",
@@ -656,6 +699,7 @@ def check_job_identity_borrow(self):
656699
# Aggregate: the set of borrowing relationships. Correcting one job's step
657700
# user must not orphan the finding's history for the rest.
658701
affected_objects=objects,
702+
relations=relations,
659703
scope="aggregate",
660704
remediation=(
661705
"Ensure the step user reflects the least privilege actually required and that "

tests/test_declared_relations.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,76 @@ def test_a_role_collection_is_not_a_pfcg_role():
330330
assert rules[("user", "role")] == "holds_role"
331331

332332

333+
# --------------------------------------------------------------------------- #
334+
# Background jobs: the first edges that do NOT start at an account #
335+
# --------------------------------------------------------------------------- #
336+
337+
def jobs(steps, headers=None, **rest):
338+
from modules.basis_job_command import BasisJobCommandAuditor
339+
data = {"background_job_steps": steps,
340+
"background_jobs": headers or [], **rest}
341+
return {f["check_id"]: f
342+
for f in BasisJobCommandAuditor(data, {}, {}).run_all_checks()}
343+
344+
345+
def test_a_job_declares_the_identity_it_runs_as():
346+
"""`check_job_privileged_step_user` already said what this is for: 'the
347+
job->user edge is what makes "this armed job runs as DDIC" derivable'."""
348+
got = jobs([{"JOBNAME": "JOB_FI_CLOSE", "JOBCOUNT": "1",
349+
"AUTHCKNAM": "DDIC", "PROGNAME": "ZFI_CLOSE"},
350+
{"JOBNAME": "JOB_MASS_UPD", "JOBCOUNT": "2",
351+
"AUTHCKNAM": "SAP*", "PROGNAME": "ZMASS"}])
352+
fired = [f for cid, f in got.items()
353+
if cid.startswith("JOBCMD-JOB-001") and f.get("relations")]
354+
assert fired, "no job finding declared what it runs as"
355+
pairs = {(r["from"]["name"], r["to"]["name"])
356+
for f in fired for r in f["relations"]}
357+
assert ("JOB_FI_CLOSE", "DDIC") in pairs, pairs
358+
359+
360+
def test_runs_as_points_from_the_job_and_is_not_an_actor_edge():
361+
"""The job borrows the identity; the user does not reach for the job. So
362+
`path_actors` must not walk it — answering "who is standing here" with
363+
somebody a job impersonates would be a different claim entirely."""
364+
from server import graph
365+
rules = {r["edge_type"]: r for r in load_rules()}
366+
assert rules["runs_as"]["from_type"] == "job"
367+
assert "runs_as" not in graph._ACTOR_EDGES
368+
369+
370+
def test_identity_borrowing_is_two_edges_and_never_one():
371+
"""A low-privileged scheduler getting code run as a powerful step user is
372+
the finding. Declaring scheduler -> step user directly would read as "this
373+
account can act as that one" — a conclusion the reader should draw from two
374+
facts, not a relationship the export states."""
375+
got = jobs([{"JOBNAME": "JOB_X", "JOBCOUNT": "1",
376+
"AUTHCKNAM": "DDIC", "SDLUNAME": "JSMITH"}])
377+
assert "JOBCMD-JOB-005" in got, got.keys()
378+
rels = got["JOBCMD-JOB-005"]["relations"]
379+
kinds = {(r["from"]["type"], r["to"]["type"]) for r in rels}
380+
assert ("user", "job") in kinds and ("job", "user") in kinds
381+
# ...and never the shortcut.
382+
assert not [r for r in rels
383+
if r["from"]["type"] == "user" and r["to"]["type"] == "user"], (
384+
"a scheduler -> step user edge was declared: that is the transitive "
385+
"claim the rules file refuses")
386+
387+
388+
def test_the_os_bridge_is_two_edges_from_the_job():
389+
"""The check's comment sketches "job -> program -> user", which reads as
390+
though the program runs as the step user. It does not: the job runs the
391+
program AND runs as the user."""
392+
got = jobs([{"JOBNAME": "JOB_OS", "JOBCOUNT": "1",
393+
"PROGNAME": "RSBDCOS0", "AUTHCKNAM": "DDIC"}])
394+
assert "JOBCMD-JOB-003" in got, got.keys()
395+
rels = got["JOBCMD-JOB-003"]["relations"]
396+
assert {(r["from"]["type"], r["to"]["type"]) for r in rels} == {
397+
("job", "program"), ("job", "user")}
398+
assert not [r for r in rels if r["from"]["type"] == "program"], (
399+
"an edge was declared FROM the program, which nothing in the export "
400+
"supports")
401+
402+
333403
def test_every_rule_still_explains_itself():
334404
"""`why` is what stops the rules file becoming a pile of type pairs nobody
335405
can audit."""

0 commit comments

Comments
 (0)